Merge pull request #893 from fabro-sh/host-attach-with-managed-record
Some checks failed
Rust / Rustdoc (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Sandbox providers (Docker) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Typecheck (push) Has been cancelled
TypeScript / Test (push) Has been cancelled
TypeScript / Build (push) Has been cancelled

Attach to a run's host sandbox with the worker's managed record
This commit is contained in:
Bryan Helmkamp 2026-09-22 08:59:02 -04:00 • committed by GitHub
commit d2469cab33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 314 additions and 102 deletions

52
Cargo.lock generated
View file

@ -5069,7 +5069,7 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pebble-agent"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/pebble?rev=6d03b3ba58c501e0b4ee8341bfb69d288dda9408#6d03b3ba58c501e0b4ee8341bfb69d288dda9408"
source = "git+https://github.com/lithoscomputer/pebble?rev=eb08b70337a650cde8a94e920a83c56537657f8f#eb08b70337a650cde8a94e920a83c56537657f8f"
dependencies = [
"async-trait",
"futures-util",
@ -5086,7 +5086,7 @@ dependencies = [
[[package]]
name = "pebble-cli-core"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/pebble?rev=6d03b3ba58c501e0b4ee8341bfb69d288dda9408#6d03b3ba58c501e0b4ee8341bfb69d288dda9408"
source = "git+https://github.com/lithoscomputer/pebble?rev=eb08b70337a650cde8a94e920a83c56537657f8f#eb08b70337a650cde8a94e920a83c56537657f8f"
dependencies = [
"anyhow",
"async-trait",
@ -5115,7 +5115,7 @@ dependencies = [
[[package]]
name = "pebble-coding-agent"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/pebble?rev=6d03b3ba58c501e0b4ee8341bfb69d288dda9408#6d03b3ba58c501e0b4ee8341bfb69d288dda9408"
source = "git+https://github.com/lithoscomputer/pebble?rev=eb08b70337a650cde8a94e920a83c56537657f8f#eb08b70337a650cde8a94e920a83c56537657f8f"
dependencies = [
"async-trait",
"futures-util",
@ -5157,7 +5157,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petri-attractor-steps"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
source = "git+https://github.com/lithoscomputer/petri.git?rev=23f78a2bf4d7e8fb747fa5e918603f2d75178e91#23f78a2bf4d7e8fb747fa5e918603f2d75178e91"
dependencies = [
"async-trait",
"globset",
@ -5188,7 +5188,7 @@ dependencies = [
[[package]]
name = "petri-driver"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
source = "git+https://github.com/lithoscomputer/petri.git?rev=23f78a2bf4d7e8fb747fa5e918603f2d75178e91#23f78a2bf4d7e8fb747fa5e918603f2d75178e91"
dependencies = [
"async-trait",
"getrandom 0.3.4",
@ -5208,7 +5208,7 @@ dependencies = [
[[package]]
name = "petri-engine"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
source = "git+https://github.com/lithoscomputer/petri.git?rev=23f78a2bf4d7e8fb747fa5e918603f2d75178e91#23f78a2bf4d7e8fb747fa5e918603f2d75178e91"
dependencies = [
"petri-ir",
"serde",
@ -5220,7 +5220,7 @@ dependencies = [
[[package]]
name = "petri-execution"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
source = "git+https://github.com/lithoscomputer/petri.git?rev=23f78a2bf4d7e8fb747fa5e918603f2d75178e91#23f78a2bf4d7e8fb747fa5e918603f2d75178e91"
dependencies = [
"async-trait",
"petri-driver",
@ -5244,7 +5244,7 @@ dependencies = [
[[package]]
name = "petri-executor"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
source = "git+https://github.com/lithoscomputer/petri.git?rev=23f78a2bf4d7e8fb747fa5e918603f2d75178e91#23f78a2bf4d7e8fb747fa5e918603f2d75178e91"
dependencies = [
"async-trait",
"libc",
@ -5259,7 +5259,7 @@ dependencies = [
[[package]]
name = "petri-executor-sandbox"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
source = "git+https://github.com/lithoscomputer/petri.git?rev=23f78a2bf4d7e8fb747fa5e918603f2d75178e91#23f78a2bf4d7e8fb747fa5e918603f2d75178e91"
dependencies = [
"async-trait",
"petri-executor",
@ -5281,7 +5281,7 @@ dependencies = [
[[package]]
name = "petri-frontend"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
source = "git+https://github.com/lithoscomputer/petri.git?rev=23f78a2bf4d7e8fb747fa5e918603f2d75178e91#23f78a2bf4d7e8fb747fa5e918603f2d75178e91"
dependencies = [
"marked-yaml",
"petri-ir",
@ -5295,7 +5295,7 @@ dependencies = [
[[package]]
name = "petri-frontend-attractor"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
source = "git+https://github.com/lithoscomputer/petri.git?rev=23f78a2bf4d7e8fb747fa5e918603f2d75178e91#23f78a2bf4d7e8fb747fa5e918603f2d75178e91"
dependencies = [
"minijinja",
"petri-frontend",
@ -5312,7 +5312,7 @@ dependencies = [
[[package]]
name = "petri-frontend-fabro"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
source = "git+https://github.com/lithoscomputer/petri.git?rev=23f78a2bf4d7e8fb747fa5e918603f2d75178e91#23f78a2bf4d7e8fb747fa5e918603f2d75178e91"
dependencies = [
"petri-frontend",
"petri-frontend-attractor",
@ -5328,7 +5328,7 @@ dependencies = [
[[package]]
name = "petri-frontend-native"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
source = "git+https://github.com/lithoscomputer/petri.git?rev=23f78a2bf4d7e8fb747fa5e918603f2d75178e91#23f78a2bf4d7e8fb747fa5e918603f2d75178e91"
dependencies = [
"petri-frontend",
"petri-ir",
@ -5339,7 +5339,7 @@ dependencies = [
[[package]]
name = "petri-ir"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
source = "git+https://github.com/lithoscomputer/petri.git?rev=23f78a2bf4d7e8fb747fa5e918603f2d75178e91#23f78a2bf4d7e8fb747fa5e918603f2d75178e91"
dependencies = [
"regex",
"serde",
@ -5352,7 +5352,7 @@ dependencies = [
[[package]]
name = "petri-runtime"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
source = "git+https://github.com/lithoscomputer/petri.git?rev=23f78a2bf4d7e8fb747fa5e918603f2d75178e91#23f78a2bf4d7e8fb747fa5e918603f2d75178e91"
dependencies = [
"async-trait",
"petri-driver",
@ -5373,7 +5373,7 @@ dependencies = [
[[package]]
name = "petri-steps"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
source = "git+https://github.com/lithoscomputer/petri.git?rev=23f78a2bf4d7e8fb747fa5e918603f2d75178e91#23f78a2bf4d7e8fb747fa5e918603f2d75178e91"
dependencies = [
"async-trait",
"petri-executor",
@ -5389,7 +5389,7 @@ dependencies = [
[[package]]
name = "petri-store"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
source = "git+https://github.com/lithoscomputer/petri.git?rev=23f78a2bf4d7e8fb747fa5e918603f2d75178e91#23f78a2bf4d7e8fb747fa5e918603f2d75178e91"
dependencies = [
"async-trait",
"getrandom 0.3.4",
@ -5404,7 +5404,7 @@ dependencies = [
[[package]]
name = "petri-testkit"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
source = "git+https://github.com/lithoscomputer/petri.git?rev=23f78a2bf4d7e8fb747fa5e918603f2d75178e91#23f78a2bf4d7e8fb747fa5e918603f2d75178e91"
dependencies = [
"async-trait",
"petri-driver",
@ -6283,7 +6283,7 @@ dependencies = [
[[package]]
name = "sandbox-driver"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=07600aa5c6695ec4c999da93c05d2cb78fe11b0c#07600aa5c6695ec4c999da93c05d2cb78fe11b0c"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=583a16463320966e8f5c88bedd1d759e93fbd05f#583a16463320966e8f5c88bedd1d759e93fbd05f"
dependencies = [
"async-trait",
"globset",
@ -6300,7 +6300,7 @@ dependencies = [
[[package]]
name = "sandbox-driver-daytona"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=07600aa5c6695ec4c999da93c05d2cb78fe11b0c#07600aa5c6695ec4c999da93c05d2cb78fe11b0c"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=583a16463320966e8f5c88bedd1d759e93fbd05f#583a16463320966e8f5c88bedd1d759e93fbd05f"
dependencies = [
"anyhow",
"async-trait",
@ -6327,7 +6327,7 @@ dependencies = [
[[package]]
name = "sandbox-driver-daytona-config"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=07600aa5c6695ec4c999da93c05d2cb78fe11b0c#07600aa5c6695ec4c999da93c05d2cb78fe11b0c"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=583a16463320966e8f5c88bedd1d759e93fbd05f#583a16463320966e8f5c88bedd1d759e93fbd05f"
dependencies = [
"sandbox-driver-docker-config",
"serde",
@ -6337,7 +6337,7 @@ dependencies = [
[[package]]
name = "sandbox-driver-docker"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=07600aa5c6695ec4c999da93c05d2cb78fe11b0c#07600aa5c6695ec4c999da93c05d2cb78fe11b0c"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=583a16463320966e8f5c88bedd1d759e93fbd05f#583a16463320966e8f5c88bedd1d759e93fbd05f"
dependencies = [
"anyhow",
"async-trait",
@ -6358,7 +6358,7 @@ dependencies = [
[[package]]
name = "sandbox-driver-docker-config"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=07600aa5c6695ec4c999da93c05d2cb78fe11b0c#07600aa5c6695ec4c999da93c05d2cb78fe11b0c"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=583a16463320966e8f5c88bedd1d759e93fbd05f#583a16463320966e8f5c88bedd1d759e93fbd05f"
dependencies = [
"serde",
"serde_json",
@ -6367,7 +6367,7 @@ dependencies = [
[[package]]
name = "sandbox-driver-host"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=07600aa5c6695ec4c999da93c05d2cb78fe11b0c#07600aa5c6695ec4c999da93c05d2cb78fe11b0c"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=583a16463320966e8f5c88bedd1d759e93fbd05f#583a16463320966e8f5c88bedd1d759e93fbd05f"
dependencies = [
"anyhow",
"async-trait",
@ -6385,7 +6385,7 @@ dependencies = [
[[package]]
name = "sandbox-driver-protocol"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=07600aa5c6695ec4c999da93c05d2cb78fe11b0c#07600aa5c6695ec4c999da93c05d2cb78fe11b0c"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=583a16463320966e8f5c88bedd1d759e93fbd05f#583a16463320966e8f5c88bedd1d759e93fbd05f"
dependencies = [
"async-trait",
"base64",
@ -6402,7 +6402,7 @@ dependencies = [
[[package]]
name = "sandbox-driver-testing"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=07600aa5c6695ec4c999da93c05d2cb78fe11b0c#07600aa5c6695ec4c999da93c05d2cb78fe11b0c"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=583a16463320966e8f5c88bedd1d759e93fbd05f#583a16463320966e8f5c88bedd1d759e93fbd05f"
dependencies = [
"async-trait",
"sandbox-driver",

View file

@ -92,17 +92,19 @@ futures-util = "0.3"
# torn frame and report what it dropped as `ExecStreamingResult::output_loss`
# instead of failing the command; #23 made an idle Host sentinel reap its
# own process group once its owning provider pid is gone, so a provider
# that is killed rather than stopped leaves no sentinel behind). The CI
# plugin job installs the driver executables at the same rev, read from
# this file.
sandbox-driver = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "07600aa5c6695ec4c999da93c05d2cb78fe11b0c" }
sandbox-driver-protocol = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "07600aa5c6695ec4c999da93c05d2cb78fe11b0c" }
sandbox-driver-host = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "07600aa5c6695ec4c999da93c05d2cb78fe11b0c" }
sandbox-driver-docker = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "07600aa5c6695ec4c999da93c05d2cb78fe11b0c" }
sandbox-driver-docker-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "07600aa5c6695ec4c999da93c05d2cb78fe11b0c" }
sandbox-driver-daytona = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "07600aa5c6695ec4c999da93c05d2cb78fe11b0c" }
sandbox-driver-daytona-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "07600aa5c6695ec4c999da93c05d2cb78fe11b0c" }
sandbox-driver-testing = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "07600aa5c6695ec4c999da93c05d2cb78fe11b0c" }
# that is killed rather than stopped leaves no sentinel behind; #61 let a
# second process attach read-only to a managed host workspace by path, so
# the server's `petri.run` ownership check runs on the host as on Docker).
# The CI plugin job installs the driver executables at the same rev, read
# from this file.
sandbox-driver = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "583a16463320966e8f5c88bedd1d759e93fbd05f" }
sandbox-driver-protocol = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "583a16463320966e8f5c88bedd1d759e93fbd05f" }
sandbox-driver-host = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "583a16463320966e8f5c88bedd1d759e93fbd05f" }
sandbox-driver-docker = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "583a16463320966e8f5c88bedd1d759e93fbd05f" }
sandbox-driver-docker-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "583a16463320966e8f5c88bedd1d759e93fbd05f" }
sandbox-driver-daytona = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "583a16463320966e8f5c88bedd1d759e93fbd05f" }
sandbox-driver-daytona-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "583a16463320966e8f5c88bedd1d759e93fbd05f" }
sandbox-driver-testing = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "583a16463320966e8f5c88bedd1d759e93fbd05f" }
# pebble: the coding agent loop fabro runs its agent stages, Ask Fabro
# sessions, hook evaluators, and `fabro exec` on. Pinned by rev to pebble
# `main`. The `Environment` over a sandbox-driver handle is pebble's
@ -111,22 +113,22 @@ sandbox-driver-testing = { git = "https://github.com/lithoscomputer/sandbox-driv
# the driver; a move of either pin moves the other. Pebble pins the same
# lithos-llm rev as fabro, and its lockfile policy is that every shared crate
# resolves to the version lithos-llm locks.
pebble-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "6d03b3ba58c501e0b4ee8341bfb69d288dda9408" }
pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "6d03b3ba58c501e0b4ee8341bfb69d288dda9408", features = ["mcp", "search-providers", "sandbox-driver"] }
pebble-cli-core = { git = "https://github.com/lithoscomputer/pebble", rev = "6d03b3ba58c501e0b4ee8341bfb69d288dda9408" }
pebble-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "eb08b70337a650cde8a94e920a83c56537657f8f" }
pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "eb08b70337a650cde8a94e920a83c56537657f8f", features = ["mcp", "search-providers", "sandbox-driver"] }
pebble-cli-core = { git = "https://github.com/lithoscomputer/pebble", rev = "eb08b70337a650cde8a94e920a83c56537657f8f" }
# petri: the workflow engine Fabro runs its workflows on. Pinned by rev, the
# same way pebble and sandbox-driver are. Petri pins the same pebble,
# lithos-llm and sandbox-driver revisions as this file, so the workspace links
# one copy of each. Only `fabro-petri` and `fabro-dot` (the DOT parser alone)
# may depend on these packages; the keys carry the `petri_` prefix so the crate
# names say where they come from.
petri_runtime = { git = "https://github.com/lithoscomputer/petri.git", rev = "9ee3f851cbbe124ebee46a4a5129987509aa8001", package = "petri-runtime" }
petri_execution = { git = "https://github.com/lithoscomputer/petri.git", rev = "9ee3f851cbbe124ebee46a4a5129987509aa8001", package = "petri-execution" }
petri_store = { git = "https://github.com/lithoscomputer/petri.git", rev = "9ee3f851cbbe124ebee46a4a5129987509aa8001", package = "petri-store" }
petri_attractor_steps = { git = "https://github.com/lithoscomputer/petri.git", rev = "9ee3f851cbbe124ebee46a4a5129987509aa8001", package = "petri-attractor-steps" }
petri_frontend_attractor = { git = "https://github.com/lithoscomputer/petri.git", rev = "9ee3f851cbbe124ebee46a4a5129987509aa8001", package = "petri-frontend-attractor" }
petri_frontend_fabro = { git = "https://github.com/lithoscomputer/petri.git", rev = "9ee3f851cbbe124ebee46a4a5129987509aa8001", package = "petri-frontend-fabro" }
petri_testkit = { git = "https://github.com/lithoscomputer/petri.git", rev = "9ee3f851cbbe124ebee46a4a5129987509aa8001", package = "petri-testkit" }
petri_runtime = { git = "https://github.com/lithoscomputer/petri.git", rev = "23f78a2bf4d7e8fb747fa5e918603f2d75178e91", package = "petri-runtime" }
petri_execution = { git = "https://github.com/lithoscomputer/petri.git", rev = "23f78a2bf4d7e8fb747fa5e918603f2d75178e91", package = "petri-execution" }
petri_store = { git = "https://github.com/lithoscomputer/petri.git", rev = "23f78a2bf4d7e8fb747fa5e918603f2d75178e91", package = "petri-store" }
petri_attractor_steps = { git = "https://github.com/lithoscomputer/petri.git", rev = "23f78a2bf4d7e8fb747fa5e918603f2d75178e91", package = "petri-attractor-steps" }
petri_frontend_attractor = { git = "https://github.com/lithoscomputer/petri.git", rev = "23f78a2bf4d7e8fb747fa5e918603f2d75178e91", package = "petri-frontend-attractor" }
petri_frontend_fabro = { git = "https://github.com/lithoscomputer/petri.git", rev = "23f78a2bf4d7e8fb747fa5e918603f2d75178e91", package = "petri-frontend-fabro" }
petri_testkit = { git = "https://github.com/lithoscomputer/petri.git", rev = "23f78a2bf4d7e8fb747fa5e918603f2d75178e91", package = "petri-testkit" }
sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] }
fork = "0.2"
exec = "0.3"

View file

@ -11,9 +11,12 @@
//! Ownership is keyed on `petri.run`: a persisted id is acted on only when
//! the sandbox behind it still carries the run's label, so an id that has
//! come to name someone else's sandbox on a shared daemon is refused. A host
//! sandbox is a directory: it carries no labels, and its id is derived from
//! its path, so a reconnect from this process designates the directory
//! again whatever registry the creating process kept.
//! sandbox is a managed directory the run's worker recorded, with the same
//! labels, in the host registry inside the run's Petri directory; the
//! server reads that registry without writing it and attaches to the
//! directory by path, so the run's ownership is checked on the host as it
//! is on Docker. Only a directory no registry records is designated again
//! without a record.
//!
//! Credentials arrive explicitly. Nothing here reads the process
//! environment for a secret: the Daytona key comes from the vault through
@ -23,11 +26,12 @@
//! daemon.
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::Context as _;
use fabro_config::Storage;
use fabro_static::EnvVars;
use fabro_types::settings::server::{
SandboxPluginSettings, ServerSandboxProviderSettings, ServerSandboxProvidersSettings,
@ -132,15 +136,34 @@ impl std::fmt::Debug for DaytonaCredentials {
}
/// What the server needs to reach every provider a run record can name:
/// its provider settings (which kinds are enabled, which run as plugins)
/// and the Daytona credentials from the vault.
/// its provider settings (which kinds are enabled, which run as plugins),
/// the Daytona credentials from the vault, and the storage root under
/// which each run's worker kept its host registry.
#[derive(Clone, Debug, Default)]
pub(crate) struct ProviderAccess {
pub(crate) providers: ServerSandboxProvidersSettings,
pub(crate) daytona: Option<DaytonaCredentials>,
pub(crate) providers: ServerSandboxProvidersSettings,
pub(crate) daytona: Option<DaytonaCredentials>,
/// The server's storage root. `None` where no run's host sandbox is
/// reached (the doctor's probes, the startup inventory): a host
/// record is then designated by path without its registry record.
pub(crate) storage_root: Option<PathBuf>,
}
impl ProviderAccess {
/// The host registry the run's worker kept: Petri's `host-registry`
/// inside the run's Petri directory, which is `petri` under the run's
/// scratch directory (the run directory `petri_runs` hands the worker).
fn host_registry(&self, run_id: RunId) -> Option<PathBuf> {
let root = self.storage_root.as_ref()?;
Some(
Storage::new(root)
.run_scratch(&run_id)
.root()
.join("petri")
.join("host-registry"),
)
}
/// The settings entry for `kind`. A bundled kind without an entry is
/// enabled with defaults; any other kind must be configured.
fn settings_for(&self, kind: &SandboxProviderKind) -> Option<ServerSandboxProviderSettings> {
@ -184,11 +207,12 @@ pub(crate) enum ConnectError {
/// Connects the provider behind `kind`, unscoped: every sandbox on the
/// backend is visible to it. Callers that act on a persisted id narrow it
/// with [`scope_to_run`].
/// to the run, as [`attach_run_sandbox`] does.
///
/// Bundled kinds link the driver's provider crates in process. `local` is
/// the driver's Host provider with a fresh registry: a run's directory is
/// reached by the id its path derives, whatever registry the worker kept.
/// the driver's Host provider with a fresh registry, enough for health;
/// a run's host sandbox is reached through the run's own registry by
/// [`attach_run_sandbox`].
/// `docker` connects to the daemon the process environment names, the
/// same variables Petri hands its Docker plugin, without requiring the
/// daemon to answer: `health` reports an unreachable daemon so preflight
@ -278,36 +302,23 @@ fn is_petri_sandbox(labels: &BTreeMap<String, String>) -> bool {
labels.contains_key(PETRI_RUN_LABEL)
}
/// `provider` narrowed to the sandboxes of `run_id`: an attach to an id
/// whose sandbox does not carry the run's `petri.run` label is refused.
/// The `local` kind is returned unscoped: a host directory carries no
/// labels, and nothing else shares the host's directories with Fabro.
/// Deletion does not come through here: a run's sandboxes are deleted
/// through Petri's lease ledger (`fabro_petri::prune`).
fn scope_to_run(
kind: &SandboxProviderKind,
provider: Arc<dyn SandboxProvider>,
run_id: RunId,
) -> Arc<dyn SandboxProvider> {
if kind.bundled() == Some(BundledProvider::Local) {
return provider;
}
Arc::new(OwnedProvider::new(provider, run_ownership(run_id)))
}
/// Attaches to a run's sandbox from its record, through the record's
/// provider scoped to the run. The handle is whatever state the sandbox is
/// in; [`activate`] brings it to `Running`.
/// provider scoped to the run: an attach whose sandbox does not carry the
/// run's `petri.run` label is refused. The handle is whatever state the
/// sandbox is in; [`activate`] brings it to `Running`. Deletion does not
/// come through here: a run's sandboxes are deleted through Petri's lease
/// ledger (`fabro_petri::prune`).
///
/// A host sandbox is the directory it designates. An id the Host provider
/// minted for a long path lives only in the registry of the process that
/// created it (the run's worker), so a reconnect from this process
/// designates the directory again: the same workspace, whatever the id.
/// A host sandbox goes through [`attach_host_run_sandbox`]; every other
/// kind through its connected provider and [`attach_run_sandbox_on`].
pub(crate) async fn attach_run_sandbox(
access: &ProviderAccess,
record: &RunSandboxInstance,
run_id: RunId,
) -> anyhow::Result<Arc<dyn Sandbox>> {
if record.provider.bundled() == Some(BundledProvider::Local) {
return attach_host_run_sandbox(access, record, run_id).await;
}
let provider = connect_provider(&record.provider, access)
.await
.with_context(|| format!("Failed to connect to the {} provider", record.provider))?;
@ -315,7 +326,8 @@ pub(crate) async fn attach_run_sandbox(
}
/// [`attach_run_sandbox`] on an already connected, unscoped `provider` for
/// the record's kind: the run scope is applied here.
/// the record's kind, which attaches by the record's id: the run scope is
/// applied here.
async fn attach_run_sandbox_on(
provider: Arc<dyn SandboxProvider>,
record: &RunSandboxInstance,
@ -323,22 +335,75 @@ async fn attach_run_sandbox_on(
) -> anyhow::Result<Arc<dyn Sandbox>> {
let kind = &record.provider;
let sandbox_id = &record.runtime.id;
let provider = scope_to_run(kind, provider, run_id);
let provider = OwnedProvider::new(provider, run_ownership(run_id));
let id =
SandboxId::try_new(sandbox_id).with_context(|| format!("Invalid {kind} sandbox id"))?;
match provider.attach(&id, None).await {
Ok(handle) => Ok(handle),
Err(DriverError::NotFound { .. }) if kind.bundled() == Some(BundledProvider::Local) => {
let working_directory = &record.runtime.working_directory;
let spec = SandboxSpec::new(SandboxSource::HostDirectory)
.working_directory(working_directory.clone());
provider.create(&spec, None).await.with_context(|| {
format!("Failed to reconnect {kind} sandbox '{sandbox_id}' at {working_directory}")
})
provider
.attach(&id, None)
.await
.with_context(|| format!("Failed to reconnect {kind} sandbox '{sandbox_id}'"))
}
/// Attaches to a run's host sandbox: the managed directory the record
/// names, with the record the run's worker kept for it.
///
/// The worker's host registry is one process's at a time, so the server
/// observes it (`HostProvider::observe_registry`) rather than opening it:
/// the registry is read, never written, and the directory is resolved to
/// its record by path. That record carries Petri's labels, so the run's
/// `petri.run` ownership is checked here exactly as it is on Docker, and
/// the handle refuses every lifecycle change: starting, stopping, and
/// deleting the sandbox stay the worker's. A stopped sandbox's retained
/// workspace is usable through the handle.
///
/// A directory the registry does not know is designated again without a
/// record, as before this check existed: the run predates the driver that
/// records it, this process has no storage root to find the registry under
/// (a probe, a test), or the run's Petri directory is gone while the
/// directory is not. Such a handle carries no labels and so no ownership;
/// it serves the read paths only.
async fn attach_host_run_sandbox(
access: &ProviderAccess,
record: &RunSandboxInstance,
run_id: RunId,
) -> anyhow::Result<Arc<dyn Sandbox>> {
let kind = &record.provider;
// The kind's policy (configured, enabled) is checked as for any other
// provider; the fresh provider that connects is not what a run's
// directory is reached through.
connect_provider(kind, access)
.await
.with_context(|| format!("Failed to connect to the {kind} provider"))?;
let sandbox_id = &record.runtime.id;
let working_directory = &record.runtime.working_directory;
if let Some(registry) = access.host_registry(run_id) {
let observer: Arc<HostProvider> = Arc::new(HostProvider::observe_registry(registry));
match observer
.attach_directory(Path::new(working_directory), None)
.await
{
Ok(handle) => {
return OwnedProvider::new(observer, run_ownership(run_id))
.check(handle)
.await
.with_context(|| format!("Failed to reconnect {kind} sandbox '{sandbox_id}'"));
}
Err(DriverError::NotFound { .. }) => {}
Err(error) => {
return Err(anyhow::Error::new(error).context(format!(
"Failed to reconnect {kind} sandbox '{sandbox_id}' at {working_directory}"
)));
}
}
Err(error) => Err(anyhow::Error::new(error)
.context(format!("Failed to reconnect {kind} sandbox '{sandbox_id}'"))),
}
let spec =
SandboxSpec::new(SandboxSource::HostDirectory).working_directory(working_directory.clone());
HostProvider::new()
.create(&spec, None)
.await
.with_context(|| {
format!("Failed to reconnect {kind} sandbox '{sandbox_id}' at {working_directory}")
})
}
/// Brings a sandbox back into use, idempotently: a running sandbox is left
@ -352,13 +417,18 @@ pub(crate) async fn activate(sandbox: &dyn Sandbox) -> sandbox_driver::Result<()
}
/// Attaches to a run's sandbox and brings it to `Running`, for every
/// access-time caller.
/// access-time caller. A host sandbox is returned as attached: its handle
/// works in the directory in any recorded state, and starting it is the
/// worker's alone.
pub(crate) async fn attach_running_run_sandbox(
access: &ProviderAccess,
record: &RunSandboxInstance,
run_id: RunId,
) -> anyhow::Result<Arc<dyn Sandbox>> {
let sandbox = attach_run_sandbox(access, record, run_id).await?;
if record.provider.bundled() == Some(BundledProvider::Local) {
return Ok(sandbox);
}
activate(sandbox.as_ref())
.await
.with_context(|| format!("Failed to start {} sandbox", record.provider))?;
@ -462,8 +532,9 @@ pub(crate) async fn check_daytona_api_key(
probe_timeout: Duration,
) -> anyhow::Result<DaytonaKeyCheck> {
let access = ProviderAccess {
providers: ServerSandboxProvidersSettings::default(),
daytona: Some(credentials.clone()),
providers: ServerSandboxProvidersSettings::default(),
daytona: Some(credentials.clone()),
storage_root: None,
};
let probe = async {
let health = provider_health(&SandboxProviderKind::DAYTONA, &access).await?;
@ -839,6 +910,7 @@ mod tests {
ProviderAccess {
providers,
daytona: None,
storage_root: None,
}
}
@ -962,6 +1034,7 @@ mod tests {
let access = ProviderAccess {
providers,
daytona: None,
storage_root: None,
};
let error = connect_provider(&SandboxProviderKind::DOCKER, &access)
.await
@ -978,6 +1051,138 @@ mod tests {
assert!(matches!(error, ConnectError::Unconfigured { kind: k } if k == kind("e2b")));
}
/// The worker's registry knows the run's directory: the server attaches
/// to it with the worker's record, checks the run's label on it, and
/// works in it in whatever state the worker left it.
#[tokio::test]
async fn a_host_record_attaches_with_the_workers_managed_record() {
let storage = tempfile::tempdir().unwrap();
let access = ProviderAccess {
storage_root: Some(storage.path().to_path_buf()),
..ProviderAccess::default()
};
let run_id = RunId::new();
let registry = access.host_registry(run_id).expect("a storage root");
// The worker's provider over the run's registry, creating the scope's
// managed workspace with Petri's labels.
let worker = HostProvider::with_registry(&registry)
.await
.expect("the worker opens the run's registry");
let workspace = registry
.parent()
.expect("the run's Petri directory")
.join("scopes")
.join("ws")
.join("work");
let mut spec = SandboxSpec::new(SandboxSource::HostDirectory)
.working_directory(workspace.display().to_string());
spec.workspace_ownership = Some(sandbox_driver::WorkspaceOwnership::Managed);
spec.labels
.insert(PETRI_RUN_LABEL.to_owned(), run_id.to_string());
let created = worker
.create(&spec, None)
.await
.expect("the worker creates the managed workspace");
created
.fs()
.write("note", b"kept")
.await
.expect("the worker writes");
let mut record = record(SandboxProviderKind::LOCAL, created.id().as_str());
record.runtime.working_directory = created.working_directory().to_owned();
let attached = attach_run_sandbox(&access, &record, run_id)
.await
.expect("the run's directory attaches with its record");
assert_eq!(
attached.id(),
created.id(),
"the worker's id, not a derived one"
);
let status = attached.describe().await.expect("describe");
assert_eq!(
status.labels.get(PETRI_RUN_LABEL).map(String::as_str),
Some(run_id.to_string().as_str())
);
assert_eq!(
status.workspace_ownership,
Some(sandbox_driver::WorkspaceOwnership::Managed)
);
// A directory the registry records for another run is refused, as
// on Docker: the record's label, not the directory, is the proof.
let other_run = RunId::new();
let mut foreign_spec = SandboxSpec::new(SandboxSource::HostDirectory).working_directory(
registry
.parent()
.expect("the run's Petri directory")
.join("scopes")
.join("foreign")
.join("work")
.display()
.to_string(),
);
foreign_spec.workspace_ownership = Some(sandbox_driver::WorkspaceOwnership::Managed);
foreign_spec
.labels
.insert(PETRI_RUN_LABEL.to_owned(), other_run.to_string());
let foreign = worker
.create(&foreign_spec, None)
.await
.expect("another run's workspace in the same registry");
let mut foreign_record = record.clone();
foreign_record.runtime.working_directory = foreign.working_directory().to_owned();
let error = attach_run_sandbox(&access, &foreign_record, run_id)
.await
.err()
.expect("the directory carries another run's label");
assert!(
error.chain().any(|cause| matches!(
cause.downcast_ref::<DriverError>(),
Some(DriverError::NotOwned { .. })
)),
"{error:#}"
);
// The worker stops the sandbox at release; the retained workspace is
// still read through the server's handle, without starting it.
created.stop().await.expect("the worker stops");
let running = attach_running_run_sandbox(&access, &record, run_id)
.await
.expect("a stopped host sandbox's workspace is usable");
assert_eq!(
running.describe().await.expect("describe").state,
SandboxState::Stopped,
"the worker's state shows through"
);
assert_eq!(running.fs().read("note").await.expect("read"), b"kept");
let refused = running
.delete()
.await
.expect_err("deletion is the worker's");
assert!(matches!(refused, DriverError::ReadOnly { .. }), "{refused}");
// A directory the registry does not know is designated as before.
let other = tempfile::tempdir().unwrap();
let mut unknown = record.clone();
unknown.runtime.working_directory =
other.path().canonicalize().unwrap().display().to_string();
let designated = attach_run_sandbox(&access, &unknown, run_id)
.await
.expect("an unrecorded directory is designated");
let status = designated.describe().await.expect("describe");
assert_eq!(
status.workspace_ownership,
Some(sandbox_driver::WorkspaceOwnership::Designated)
);
assert!(status.labels.is_empty());
created.delete().await.expect("the worker deletes");
foreign.delete().await.expect("the worker deletes");
}
/// Without a storage root there is no registry to consult: the directory
/// is designated again, whatever id the record carries.
#[tokio::test]
async fn a_host_record_reconnects_by_designating_its_directory() {
let directory = tempfile::tempdir().unwrap();
@ -1001,7 +1206,7 @@ mod tests {
.expect("a host sandbox runs");
assert!(directory.path().is_dir());
// The path-derived id attaches directly.
// The path-derived id designates the same directory.
let derived = HostProvider::directory_id(directory.path())
.await
.expect("an id for the directory");

View file

@ -1550,11 +1550,12 @@ impl AppState {
/// when no key is stored).
pub(crate) async fn provider_access(&self) -> Result<ProviderAccess, SecretStoreError> {
Ok(ProviderAccess {
providers: self.server_settings().server.sandbox.providers.clone(),
daytona: self
providers: self.server_settings().server.sandbox.providers.clone(),
daytona: self
.vault_secret(EnvVars::DAYTONA_API_KEY)
.await?
.map(|api_key| self.daytona_credentials(api_key)),
storage_root: Some(self.server_storage_dir()),
})
}
@ -2400,11 +2401,14 @@ fn build_sandbox_inventory(
) -> SandboxInventory {
let provider_settings = &server_settings.server.sandbox.providers;
let access = ProviderAccess {
providers: provider_settings.clone(),
daytona: daytona_api_key.map(|api_key| {
providers: provider_settings.clone(),
daytona: daytona_api_key.map(|api_key| {
DaytonaCredentials::from_api_key(api_key, |name| env_lookup(name))
.with_http_client(http_client)
}),
// The inventory lists and looks up by id; no run's host directory is
// reached through it.
storage_root: None,
};
let mut inventory = SandboxInventory::empty();

View file

@ -129,6 +129,7 @@ mod tests {
inventory.with_lazy(kind, ProviderAccess {
providers,
daytona: None,
storage_root: None,
})
}