diff --git a/run.json b/run.json index 59a70c9c8..372677157 100644 --- a/run.json +++ b/run.json @@ -505,7 +505,7 @@ "kind": "running" }, "status_updated_at": "2026-05-27T16:27:37.247434Z", - "last_event_at": "2026-05-27T17:17:04.948309Z", + "last_event_at": "2026-05-27T17:26:27.491252Z", "pending_control": null, "checkpoints": [ { @@ -1354,9 +1354,9 @@ } }, { - "seq": 0, + "seq": 1034, "checkpoint": { - "timestamp": "2026-05-27T17:26:22.861568Z", + "timestamp": "2026-05-27T17:26:27.490712Z", "current_node": "verify", "completed_nodes": [ "start", @@ -1370,39 +1370,39 @@ ], "node_retries": {}, "context_values": { - "last_stage": "simplify_gpt", - "internal.retry_count.simplify_opus": 0, - "internal.retry_count.verify": 0, - "internal.work_dir": "/home/daytona/workspace/fabro", - "last_response": "Review complete. I found no actionable reuse/quality/efficiency cleanup issues to fix in the current branch diff.\n\nValidation run:\n\n- `cargo nextest run -p fabro-cli -- variable` ✅\n- `cargo nextest ", - "thread.simplify_opus.current_node": "simplify_gpt", - "response.simplify_gpt": "Review complete. I found no actionable reuse/quality/efficiency cleanup issues to fix in the current branch diff.\n\nValidation run:\n\n- `cargo nextest run -p fabro-cli -- variable` ✅\n- `cargo nextest run -p fabro-cli -- fabro` ✅\n- `cargo dev docs check` ✅\n- `cargo +nightly-2026-04-14 fmt --check --all` ✅\n- `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings` ✅\n\nNo files were modified during this cleanup pass.", - "response.implement": "Implemented CLI variable management end-to-end.\n\nSummary:\n- Added `fabro-client` wrappers for variables API:\n - `list_variables`\n - `get_variable`\n - `create_variable`\n - `update_variable`\n - `delete_variable`\n- Added `fabro variable` CLI namespace:\n - `fabro variable list` / `ls`\n - `fabro variable get NAME`\n - `fabro variable set NAME [VALUE]`\n - `fabro variable rm NAME`\n- Implemented behavior:\n - Plain `list` table includes `NAME`, `VALUE`, `UPDATED`\n - Plain `get` prints raw value\n - JSON output returns full variable objects\n - `set` is an upsert and supports `--description`\n - `set` allows explicit empty values and empty `--value-stdin`\n - `rm` JSON returns `{ \"name\": NAME }`\n- Added CLI integration test coverage and `TestContext::variable()`.\n- Updated root help snapshots and generated CLI reference docs.\n- Updated workflow variables docs to explain server-managed `{{ vars.NAME }}` and distinguish variables from secrets.\n\nTDD note:\n- Added variable CLI tests first and confirmed red state with `cargo nextest run -p fabro-cli -- variable::help` failing because `variable` was not yet implemented.\n\nValidation run:\n- `cargo nextest run -p fabro-cli -- variable` ✅\n- `cargo nextest run -p fabro-cli -- fabro` ✅\n- `cargo dev docs refresh` ✅\n- `cargo dev docs check` ✅\n- `cargo +nightly-2026-04-14 fmt --check --all` ✅\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` ✅\n- `cargo check -q --workspace` ✅\n- `git diff --check` ✅\n\nNote: `cargo insta pending-snapshots` was unavailable because `cargo-insta` is not installed in the environment; checked manually and found no pending `*.snap.new` / `*.snap.pending` files.", - "thread.simplify_gpt.current_node": "verify", "internal.retry_count.preflight_compile": 0, - "internal.fidelity": "compact", - "outcome": "succeeded", - "thread.toolchain.current_node": "preflight_compile", - "graph.rankdir": "LR", - "thread.preflight_compile.current_node": "preflight_lint", - "internal.retry_count.toolchain": 0, - "internal.thread_id": "simplify_gpt", - "internal.retry_count.simplify_gpt": 0, - "thread.implement.current_node": "simplify_opus", - "response.simplify_opus": "All tests pass. Let me summarize.\n\n## Summary\n\nReviewed the `fabro variable` CLI change with three parallel agents (reuse, quality, efficiency).\n\n### Findings reviewed\n- **Code reuse:** flagged exact-duplicate `format_age` between `secret/list.rs` and `variable/list.rs`; also noted broader patterns (table boilerplate, color_choice) pre-existing in 8+ places.\n- **Code quality:** flagged unused `Client::update_variable`, the duplicated `format_age`, divergent JSON envelope keys (`{name}` vs `{key}`), and intentional empty-value-acceptance for stdin.\n- **Efficiency:** no meaningful issues — each subcommand makes a single API call.\n\n### Fixed\n1. Extracted `format_age` to `lib/crates/fabro-cli/src/shared/utilities.rs` and updated both `secret/list.rs` and `variable/list.rs` to import via `crate::shared::format_age`. Removes 20 lines of byte-identical duplication.\n\n### Deliberately skipped (intentional per plan)\n- **Empty value handling for `variable set`:** the plan (R3) explicitly requires empty values be allowed for variables, unlike secrets.\n- **`set` as upsert via `POST`:** the plan explicitly chose this to match `fabro secret set`'s mental model.\n- **`update_variable` Rust client wrapper:** the plan listed all five wrappers (list/get/create/update/delete) for API surface parity. Used by the web client; kept the Rust wrapper for completeness.\n- **JSON envelope `{\"name\": ...}` for `variable rm`:** specified by the plan and matches the API field (the secret `{\"key\": ...}` is the outlier).\n- **Broader cli_table/color_choice duplication:** pre-existing across 8+ commands; out of scope.\n- **Project-wide `use func` import style nit:** pre-existing in secret too; not introduced by this change.\n\n### Verification\n- `cargo check -p fabro-cli`: clean\n- `cargo +nightly-2026-04-14 fmt --check -p fabro-cli`: clean\n- `cargo +nightly-2026-04-14 clippy -p fabro-cli --all-targets -- -D warnings`: clean\n- `cargo nextest run -p fabro-cli -E 'test(/variable/)'`: 20/20 passed\n- `cargo nextest run -p fabro-cli -E 'test(/secret/)'`: 30/30 passed (no regression from `format_age` extraction)", "internal.node_visit_count": 1, - "command.output": "blob://sha256/fc9defa961f2471f59d082245293de47c0a407bb3e95e7fb5ae5b3695a839fcb", - "thread.preflight_lint.current_node": "implement", - "current_node": "verify", - "failure_signature": "", - "internal.run_id": "01KSN4661TG7HFT3ATDKNGMGC0", - "internal.retry_count.implement": 0, - "graph.goal": "---\ntitle: Add CLI Variable Management\ntype: feat\nstatus: active\ndate: 2026-05-27\n---\n\n# Add CLI Variable Management\n\n## Overview\n\nExpose the recently added variables API through the CLI with a singular `fabro variable`\nnamespace. Variables are non-sensitive run-configuration values, so the CLI should expose\nvalues in `list` and `get`, while continuing to direct credentials and tokens to\n`fabro secret`.\n\n## Requirements Trace\n\n- R1. Provide variables management in the CLI, similar to secrets management.\n- R2. Support the full readable-variable CRUD surface: list, get, set/upsert, and remove.\n- R3. Preserve existing server/API behavior: variable names are env-style, values may be\n empty, and `set` preserves an existing description when `--description` is omitted.\n- R4. Keep generated CLI docs and help snapshots in sync with the new public command.\n\n## Context & Research\n\n- `lib/crates/fabro-cli/src/commands/secret/` is the command pattern to follow for\n namespace dispatch, JSON output, tabular list output, stdin value input, and status\n messages.\n- `lib/crates/fabro-server/src/server/handler/variables.rs` already provides\n `GET /variables`, `POST /variables`, `GET /variables/{name}`,\n `PUT /variables/{name}`, and `DELETE /variables/{name}`.\n- `lib/crates/fabro-types/src/variable.rs` defines the canonical API/request types and\n validates env-style names.\n- `lib/crates/fabro-api/tests/variable_round_trip.rs` already proves OpenAPI generated\n types reuse the canonical variable types.\n- `docs/public/workflows/variables.mdx` currently explains workflow template variables\n but does not yet document how server-managed `{{ vars.NAME }}` values are configured.\n\n## Key Technical Decisions\n\n- Use `fabro variable`, not `fabro variables`, to match existing singular CLI namespaces\n such as `fabro secret`, `fabro model`, and `fabro repo`.\n- Add `get` because variables are intentionally readable; secrets remain write-only.\n- Make `set` an upsert using the API's create/upsert endpoint, matching the mental model\n of `fabro secret set`.\n- Reuse `--value-stdin` from secrets but allow empty stdin values for variables after\n trimming trailing newlines.\n- Plain `list` should include a `VALUE` column. Do not add truncation or redaction in\n this first pass; exact retrieval is available through JSON output and `get`.\n\n## Implementation Units\n\n- [ ] **Unit 1: Add fabro-client variable wrappers**\n\n**Goal:** Give CLI code stable methods over the generated OpenAPI client.\n\n**Requirements:** R2, R3\n\n**Dependencies:** Existing variables API and generated `fabro-api` client.\n\n**Files:**\n- Modify: `lib/crates/fabro-client/src/client.rs`\n\n**Approach:**\n- Add wrappers for `list_variables`, `get_variable`, `create_variable`,\n `update_variable`, and `delete_variable`.\n- Return `Vec` from `list_variables` by unwrapping the API response's\n `data`, matching `list_secrets`.\n- Use the generated path-parameter operations for `get`, `update`, and `delete`.\n\n**Patterns to follow:**\n- `list_secrets`, `create_secret`, and `delete_secret_by_name` in the same file.\n\n**Test scenarios:**\n- Happy path: CLI integration tests in later units exercise each wrapper through the\n shared server client path.\n- Error path: missing and invalid variable operations propagate the server's API errors.\n\n**Verification:**\n- The CLI can compile against these wrapper methods without importing generated client\n builders directly.\n\n- [ ] **Unit 2: Add CLI args, dispatch, and command module**\n\n**Goal:** Register the new top-level namespace and route subcommands to implementation\nmodules.\n\n**Requirements:** R1, R2, R4\n\n**Dependencies:** Unit 1\n\n**Files:**\n- Modify: `lib/crates/fabro-cli/src/args.rs`\n- Modify: `lib/crates/fabro-cli/src/main.rs`\n- Modify: `lib/crates/fabro-cli/src/commands/mod.rs`\n- Create: `lib/crates/fabro-cli/src/commands/variable/mod.rs`\n\n**Approach:**\n- Add `Commands::Variable(VariableNamespace)` with description\n `Manage server-owned variables`.\n- Add `VariableNamespace` with `ServerTargetArgs`, matching `SecretNamespace`.\n- Add `VariableCommand::{List, Get, Rm, Set}`; give `list` the `ls` alias.\n- Add command-name mapping for analytics/logging: `variable list`, `variable get`,\n `variable rm`, and `variable set`.\n- Dispatch through `commands::variable::dispatch`, deriving the target context with\n `base_ctx.with_target(&ns.target)`.\n\n**Patterns to follow:**\n- `SecretNamespace`, `SecretCommand`, and `commands::secret::dispatch`.\n\n**Test scenarios:**\n- Happy path: `fabro --help` lists `variable`.\n- Happy path: `fabro variable --help` shows `list`, `get`, `rm`, and `set`.\n- Happy path: command-name mapping covers all subcommands.\n\n**Verification:**\n- The new namespace is reachable through clap and main dispatch without affecting\n existing commands.\n\n- [ ] **Unit 3: Implement variable list/get/set/rm behavior**\n\n**Goal:** Provide the full user-facing variables management workflow.\n\n**Requirements:** R1, R2, R3\n\n**Dependencies:** Units 1 and 2\n\n**Files:**\n- Create: `lib/crates/fabro-cli/src/commands/variable/list.rs`\n- Create: `lib/crates/fabro-cli/src/commands/variable/get.rs`\n- Create: `lib/crates/fabro-cli/src/commands/variable/set.rs`\n- Create: `lib/crates/fabro-cli/src/commands/variable/rm.rs`\n\n**Approach:**\n- `list`: fetch all variables, print JSON array when JSON output is active, otherwise\n print a table with `NAME`, `VALUE`, and `UPDATED`.\n- `get`: fetch one variable, print the full variable object for JSON output, otherwise\n print only the raw value to stdout.\n- `set`: accept ` [VALUE]`, `--value-stdin`, and `--description`; call the upsert\n API wrapper and print the stored variable for JSON output or `Set NAME` otherwise.\n- `rm`: call the delete API wrapper and print `{ \"name\": NAME }` for JSON output or\n `Removed NAME` otherwise.\n- For `set`, allow empty explicit values and empty stdin values. Only error when no value\n is provided and stdin is not being used.\n\n**Patterns to follow:**\n- `commands/secret/list.rs` for table style and age formatting.\n- `commands/secret/set.rs` for argument precedence and stdin handling, adjusted so empty\n values are valid.\n- `commands/secret/rm.rs` for delete output shape.\n\n**Test scenarios:**\n- Happy path: `set DEPLOY_ENV staging --description \"Deployment target\"` then `list`\n shows `DEPLOY_ENV`, `staging`, and an updated age.\n- Happy path: `get DEPLOY_ENV` prints exactly `staging\\n` in plain output.\n- Happy path: `set DEPLOY_ENV production` updates the value and preserves the existing\n description through API behavior.\n- Happy path: `set EMPTY \"\"` stores an empty value.\n- Happy path: `printf '\\n' | fabro variable set EMPTY --value-stdin` stores an empty\n value instead of failing.\n- Error path: `get MISSING` and `rm MISSING` fail with `variable not found: MISSING`.\n- Error path: `set 1BAD value` fails with the server invalid-name error.\n\n**Verification:**\n- The command works against the default test server and does not write directly to\n local `variables.json`.\n\n- [ ] **Unit 4: Add test harness support and CLI integration tests**\n\n**Goal:** Lock the public CLI surface and expected behavior with integration coverage.\n\n**Requirements:** R1, R2, R3, R4\n\n**Dependencies:** Units 1-3\n\n**Files:**\n- Modify: `lib/crates/fabro-test/src/lib.rs`\n- Modify: `lib/crates/fabro-cli/tests/it/cmd/mod.rs`\n- Modify: `lib/crates/fabro-cli/tests/it/cmd/fabro.rs`\n- Modify: `lib/crates/fabro-cli/tests/it/cmd/json_global.rs`\n- Create: `lib/crates/fabro-cli/tests/it/cmd/variable.rs`\n- Create: `lib/crates/fabro-cli/tests/it/cmd/variable_list.rs`\n- Create: `lib/crates/fabro-cli/tests/it/cmd/variable_get.rs`\n- Create: `lib/crates/fabro-cli/tests/it/cmd/variable_set.rs`\n- Create: `lib/crates/fabro-cli/tests/it/cmd/variable_rm.rs`\n\n**Approach:**\n- Add `TestContext::variable()` helper mirroring `TestContext::secret()`.\n- Add help snapshots for the namespace and each subcommand.\n- Add lifecycle tests for set/list/get/update/rm, `ls` alias, empty value support, JSON\n output, missing variable errors, and invalid-name errors.\n- Update root help and curated landing snapshots only if the final clap/landing output\n changes.\n\n**Patterns to follow:**\n- `secret.rs`, `secret_list.rs`, `secret_set.rs`, and `secret_rm.rs`.\n\n**Test scenarios:**\n- Happy path: JSON `list` returns an array of full variable objects including `value`.\n- Happy path: JSON `get` and `set` return full variable objects.\n- Happy path: global JSON config makes `variable list` emit JSON, matching the\n `secret list` config test.\n- Error path: missing variables and invalid names produce nonzero exits and readable\n errors.\n\n**Verification:**\n- `cargo nextest run -p fabro-cli -- variable`\n- `cargo nextest run -p fabro-cli -- fabro`\n\n- [ ] **Unit 5: Update generated and conceptual docs**\n\n**Goal:** Keep public documentation aligned with the new command and clarify how variables\nrelate to secrets.\n\n**Requirements:** R1, R4\n\n**Dependencies:** Units 2-4\n\n**Files:**\n- Modify: `docs/public/reference/cli.mdx`\n- Modify: `docs/public/workflows/variables.mdx`\n\n**Approach:**\n- Regenerate the CLI reference with `cargo dev docs refresh`.\n- Add a short section to `docs/public/workflows/variables.mdx` explaining that\n server-managed run config variables can be set with `fabro variable set NAME VALUE`\n and referenced as `{{ vars.NAME }}` in run config interpolation.\n- State that variables are non-sensitive and readable; tokens, keys, and credentials\n should use `fabro secret set`.\n\n**Patterns to follow:**\n- Existing generated docs workflow in `lib/crates/fabro-dev/src/commands/docs.rs`.\n- Existing CLI references to `fabro secret set` in administration docs.\n\n**Test scenarios:**\n- Happy path: generated CLI docs include `fabro variable` and its subcommands.\n- Documentation check: `cargo dev docs check` succeeds after regeneration.\n\n**Verification:**\n- The docs describe the CLI surface without implying variables are secret storage.\n\n## System-Wide Impact\n\n- **API surface parity:** No server or OpenAPI changes are planned; the CLI consumes the\n existing variables API.\n- **Error propagation:** Invalid names, missing variables, and write failures should flow\n through the existing `fabro-client` API error classification.\n- **State lifecycle risks:** CLI commands must use the server API rather than editing\n `variables.json` locally, so behavior remains correct for remote and socket-backed\n servers.\n- **Security boundary:** Values are intentionally visible for variables. Documentation\n must clearly distinguish variables from secrets to avoid accidental credential storage.\n- **Unchanged invariants:** `fabro secret` remains write-only and unchanged.\n\n## Risks & Dependencies\n\n| Risk | Mitigation |\n| --- | --- |\n| Users put credentials in variables because the command looks like secrets | Document variables as non-sensitive and keep secret guidance explicit. |\n| Empty values accidentally fail because secret handling rejects empties | Test explicit empty strings and newline-only stdin for `variable set`. |\n| CLI docs drift after adding clap args | Regenerate with `cargo dev docs refresh` and verify with `cargo dev docs check`. |\n| Plain `list` becomes awkward for long values | Accept for v1; `get` and JSON output provide exact machine-readable retrieval. |\n\n## Verification Plan\n\n- `cargo nextest run -p fabro-cli -- variable`\n- `cargo nextest run -p fabro-cli -- fabro`\n- `cargo dev docs check`\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`\n\n## Assumptions\n\n- The chosen CLI surface is full CRUD with readable values.\n- The namespace is singular: `fabro variable`.\n- No TypeScript client regeneration is required for this CLI-only change.\n- No server API, OpenAPI schema, or storage migration changes are required.", - "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", - "internal.retry_count.start": 0, - "failure_class": "", + "internal.retry_count.toolchain": 0, + "response.implement": "Implemented CLI variable management end-to-end.\n\nSummary:\n- Added `fabro-client` wrappers for variables API:\n - `list_variables`\n - `get_variable`\n - `create_variable`\n - `update_variable`\n - `delete_variable`\n- Added `fabro variable` CLI namespace:\n - `fabro variable list` / `ls`\n - `fabro variable get NAME`\n - `fabro variable set NAME [VALUE]`\n - `fabro variable rm NAME`\n- Implemented behavior:\n - Plain `list` table includes `NAME`, `VALUE`, `UPDATED`\n - Plain `get` prints raw value\n - JSON output returns full variable objects\n - `set` is an upsert and supports `--description`\n - `set` allows explicit empty values and empty `--value-stdin`\n - `rm` JSON returns `{ \"name\": NAME }`\n- Added CLI integration test coverage and `TestContext::variable()`.\n- Updated root help snapshots and generated CLI reference docs.\n- Updated workflow variables docs to explain server-managed `{{ vars.NAME }}` and distinguish variables from secrets.\n\nTDD note:\n- Added variable CLI tests first and confirmed red state with `cargo nextest run -p fabro-cli -- variable::help` failing because `variable` was not yet implemented.\n\nValidation run:\n- `cargo nextest run -p fabro-cli -- variable` ✅\n- `cargo nextest run -p fabro-cli -- fabro` ✅\n- `cargo dev docs refresh` ✅\n- `cargo dev docs check` ✅\n- `cargo +nightly-2026-04-14 fmt --check --all` ✅\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` ✅\n- `cargo check -q --workspace` ✅\n- `git diff --check` ✅\n\nNote: `cargo insta pending-snapshots` was unavailable because `cargo-insta` is not installed in the environment; checked manually and found no pending `*.snap.new` / `*.snap.pending` files.", "internal.retry_count.preflight_lint": 0, - "thread.start.current_node": "toolchain" + "thread.simplify_opus.current_node": "simplify_gpt", + "thread.implement.current_node": "simplify_opus", + "internal.retry_count.implement": 0, + "internal.retry_count.simplify_opus": 0, + "internal.run_id": "01KSN4661TG7HFT3ATDKNGMGC0", + "internal.thread_id": "simplify_gpt", + "failure_signature": "", + "internal.retry_count.start": 0, + "last_response": "Review complete. I found no actionable reuse/quality/efficiency cleanup issues to fix in the current branch diff.\n\nValidation run:\n\n- `cargo nextest run -p fabro-cli -- variable` ✅\n- `cargo nextest ", + "thread.start.current_node": "toolchain", + "response.simplify_opus": "All tests pass. Let me summarize.\n\n## Summary\n\nReviewed the `fabro variable` CLI change with three parallel agents (reuse, quality, efficiency).\n\n### Findings reviewed\n- **Code reuse:** flagged exact-duplicate `format_age` between `secret/list.rs` and `variable/list.rs`; also noted broader patterns (table boilerplate, color_choice) pre-existing in 8+ places.\n- **Code quality:** flagged unused `Client::update_variable`, the duplicated `format_age`, divergent JSON envelope keys (`{name}` vs `{key}`), and intentional empty-value-acceptance for stdin.\n- **Efficiency:** no meaningful issues — each subcommand makes a single API call.\n\n### Fixed\n1. Extracted `format_age` to `lib/crates/fabro-cli/src/shared/utilities.rs` and updated both `secret/list.rs` and `variable/list.rs` to import via `crate::shared::format_age`. Removes 20 lines of byte-identical duplication.\n\n### Deliberately skipped (intentional per plan)\n- **Empty value handling for `variable set`:** the plan (R3) explicitly requires empty values be allowed for variables, unlike secrets.\n- **`set` as upsert via `POST`:** the plan explicitly chose this to match `fabro secret set`'s mental model.\n- **`update_variable` Rust client wrapper:** the plan listed all five wrappers (list/get/create/update/delete) for API surface parity. Used by the web client; kept the Rust wrapper for completeness.\n- **JSON envelope `{\"name\": ...}` for `variable rm`:** specified by the plan and matches the API field (the secret `{\"key\": ...}` is the outlier).\n- **Broader cli_table/color_choice duplication:** pre-existing across 8+ commands; out of scope.\n- **Project-wide `use func` import style nit:** pre-existing in secret too; not introduced by this change.\n\n### Verification\n- `cargo check -p fabro-cli`: clean\n- `cargo +nightly-2026-04-14 fmt --check -p fabro-cli`: clean\n- `cargo +nightly-2026-04-14 clippy -p fabro-cli --all-targets -- -D warnings`: clean\n- `cargo nextest run -p fabro-cli -E 'test(/variable/)'`: 20/20 passed\n- `cargo nextest run -p fabro-cli -E 'test(/secret/)'`: 30/30 passed (no regression from `format_age` extraction)", + "command.output": "blob://sha256/fc9defa961f2471f59d082245293de47c0a407bb3e95e7fb5ae5b3695a839fcb", + "graph.goal": "---\ntitle: Add CLI Variable Management\ntype: feat\nstatus: active\ndate: 2026-05-27\n---\n\n# Add CLI Variable Management\n\n## Overview\n\nExpose the recently added variables API through the CLI with a singular `fabro variable`\nnamespace. Variables are non-sensitive run-configuration values, so the CLI should expose\nvalues in `list` and `get`, while continuing to direct credentials and tokens to\n`fabro secret`.\n\n## Requirements Trace\n\n- R1. Provide variables management in the CLI, similar to secrets management.\n- R2. Support the full readable-variable CRUD surface: list, get, set/upsert, and remove.\n- R3. Preserve existing server/API behavior: variable names are env-style, values may be\n empty, and `set` preserves an existing description when `--description` is omitted.\n- R4. Keep generated CLI docs and help snapshots in sync with the new public command.\n\n## Context & Research\n\n- `lib/crates/fabro-cli/src/commands/secret/` is the command pattern to follow for\n namespace dispatch, JSON output, tabular list output, stdin value input, and status\n messages.\n- `lib/crates/fabro-server/src/server/handler/variables.rs` already provides\n `GET /variables`, `POST /variables`, `GET /variables/{name}`,\n `PUT /variables/{name}`, and `DELETE /variables/{name}`.\n- `lib/crates/fabro-types/src/variable.rs` defines the canonical API/request types and\n validates env-style names.\n- `lib/crates/fabro-api/tests/variable_round_trip.rs` already proves OpenAPI generated\n types reuse the canonical variable types.\n- `docs/public/workflows/variables.mdx` currently explains workflow template variables\n but does not yet document how server-managed `{{ vars.NAME }}` values are configured.\n\n## Key Technical Decisions\n\n- Use `fabro variable`, not `fabro variables`, to match existing singular CLI namespaces\n such as `fabro secret`, `fabro model`, and `fabro repo`.\n- Add `get` because variables are intentionally readable; secrets remain write-only.\n- Make `set` an upsert using the API's create/upsert endpoint, matching the mental model\n of `fabro secret set`.\n- Reuse `--value-stdin` from secrets but allow empty stdin values for variables after\n trimming trailing newlines.\n- Plain `list` should include a `VALUE` column. Do not add truncation or redaction in\n this first pass; exact retrieval is available through JSON output and `get`.\n\n## Implementation Units\n\n- [ ] **Unit 1: Add fabro-client variable wrappers**\n\n**Goal:** Give CLI code stable methods over the generated OpenAPI client.\n\n**Requirements:** R2, R3\n\n**Dependencies:** Existing variables API and generated `fabro-api` client.\n\n**Files:**\n- Modify: `lib/crates/fabro-client/src/client.rs`\n\n**Approach:**\n- Add wrappers for `list_variables`, `get_variable`, `create_variable`,\n `update_variable`, and `delete_variable`.\n- Return `Vec` from `list_variables` by unwrapping the API response's\n `data`, matching `list_secrets`.\n- Use the generated path-parameter operations for `get`, `update`, and `delete`.\n\n**Patterns to follow:**\n- `list_secrets`, `create_secret`, and `delete_secret_by_name` in the same file.\n\n**Test scenarios:**\n- Happy path: CLI integration tests in later units exercise each wrapper through the\n shared server client path.\n- Error path: missing and invalid variable operations propagate the server's API errors.\n\n**Verification:**\n- The CLI can compile against these wrapper methods without importing generated client\n builders directly.\n\n- [ ] **Unit 2: Add CLI args, dispatch, and command module**\n\n**Goal:** Register the new top-level namespace and route subcommands to implementation\nmodules.\n\n**Requirements:** R1, R2, R4\n\n**Dependencies:** Unit 1\n\n**Files:**\n- Modify: `lib/crates/fabro-cli/src/args.rs`\n- Modify: `lib/crates/fabro-cli/src/main.rs`\n- Modify: `lib/crates/fabro-cli/src/commands/mod.rs`\n- Create: `lib/crates/fabro-cli/src/commands/variable/mod.rs`\n\n**Approach:**\n- Add `Commands::Variable(VariableNamespace)` with description\n `Manage server-owned variables`.\n- Add `VariableNamespace` with `ServerTargetArgs`, matching `SecretNamespace`.\n- Add `VariableCommand::{List, Get, Rm, Set}`; give `list` the `ls` alias.\n- Add command-name mapping for analytics/logging: `variable list`, `variable get`,\n `variable rm`, and `variable set`.\n- Dispatch through `commands::variable::dispatch`, deriving the target context with\n `base_ctx.with_target(&ns.target)`.\n\n**Patterns to follow:**\n- `SecretNamespace`, `SecretCommand`, and `commands::secret::dispatch`.\n\n**Test scenarios:**\n- Happy path: `fabro --help` lists `variable`.\n- Happy path: `fabro variable --help` shows `list`, `get`, `rm`, and `set`.\n- Happy path: command-name mapping covers all subcommands.\n\n**Verification:**\n- The new namespace is reachable through clap and main dispatch without affecting\n existing commands.\n\n- [ ] **Unit 3: Implement variable list/get/set/rm behavior**\n\n**Goal:** Provide the full user-facing variables management workflow.\n\n**Requirements:** R1, R2, R3\n\n**Dependencies:** Units 1 and 2\n\n**Files:**\n- Create: `lib/crates/fabro-cli/src/commands/variable/list.rs`\n- Create: `lib/crates/fabro-cli/src/commands/variable/get.rs`\n- Create: `lib/crates/fabro-cli/src/commands/variable/set.rs`\n- Create: `lib/crates/fabro-cli/src/commands/variable/rm.rs`\n\n**Approach:**\n- `list`: fetch all variables, print JSON array when JSON output is active, otherwise\n print a table with `NAME`, `VALUE`, and `UPDATED`.\n- `get`: fetch one variable, print the full variable object for JSON output, otherwise\n print only the raw value to stdout.\n- `set`: accept ` [VALUE]`, `--value-stdin`, and `--description`; call the upsert\n API wrapper and print the stored variable for JSON output or `Set NAME` otherwise.\n- `rm`: call the delete API wrapper and print `{ \"name\": NAME }` for JSON output or\n `Removed NAME` otherwise.\n- For `set`, allow empty explicit values and empty stdin values. Only error when no value\n is provided and stdin is not being used.\n\n**Patterns to follow:**\n- `commands/secret/list.rs` for table style and age formatting.\n- `commands/secret/set.rs` for argument precedence and stdin handling, adjusted so empty\n values are valid.\n- `commands/secret/rm.rs` for delete output shape.\n\n**Test scenarios:**\n- Happy path: `set DEPLOY_ENV staging --description \"Deployment target\"` then `list`\n shows `DEPLOY_ENV`, `staging`, and an updated age.\n- Happy path: `get DEPLOY_ENV` prints exactly `staging\\n` in plain output.\n- Happy path: `set DEPLOY_ENV production` updates the value and preserves the existing\n description through API behavior.\n- Happy path: `set EMPTY \"\"` stores an empty value.\n- Happy path: `printf '\\n' | fabro variable set EMPTY --value-stdin` stores an empty\n value instead of failing.\n- Error path: `get MISSING` and `rm MISSING` fail with `variable not found: MISSING`.\n- Error path: `set 1BAD value` fails with the server invalid-name error.\n\n**Verification:**\n- The command works against the default test server and does not write directly to\n local `variables.json`.\n\n- [ ] **Unit 4: Add test harness support and CLI integration tests**\n\n**Goal:** Lock the public CLI surface and expected behavior with integration coverage.\n\n**Requirements:** R1, R2, R3, R4\n\n**Dependencies:** Units 1-3\n\n**Files:**\n- Modify: `lib/crates/fabro-test/src/lib.rs`\n- Modify: `lib/crates/fabro-cli/tests/it/cmd/mod.rs`\n- Modify: `lib/crates/fabro-cli/tests/it/cmd/fabro.rs`\n- Modify: `lib/crates/fabro-cli/tests/it/cmd/json_global.rs`\n- Create: `lib/crates/fabro-cli/tests/it/cmd/variable.rs`\n- Create: `lib/crates/fabro-cli/tests/it/cmd/variable_list.rs`\n- Create: `lib/crates/fabro-cli/tests/it/cmd/variable_get.rs`\n- Create: `lib/crates/fabro-cli/tests/it/cmd/variable_set.rs`\n- Create: `lib/crates/fabro-cli/tests/it/cmd/variable_rm.rs`\n\n**Approach:**\n- Add `TestContext::variable()` helper mirroring `TestContext::secret()`.\n- Add help snapshots for the namespace and each subcommand.\n- Add lifecycle tests for set/list/get/update/rm, `ls` alias, empty value support, JSON\n output, missing variable errors, and invalid-name errors.\n- Update root help and curated landing snapshots only if the final clap/landing output\n changes.\n\n**Patterns to follow:**\n- `secret.rs`, `secret_list.rs`, `secret_set.rs`, and `secret_rm.rs`.\n\n**Test scenarios:**\n- Happy path: JSON `list` returns an array of full variable objects including `value`.\n- Happy path: JSON `get` and `set` return full variable objects.\n- Happy path: global JSON config makes `variable list` emit JSON, matching the\n `secret list` config test.\n- Error path: missing variables and invalid names produce nonzero exits and readable\n errors.\n\n**Verification:**\n- `cargo nextest run -p fabro-cli -- variable`\n- `cargo nextest run -p fabro-cli -- fabro`\n\n- [ ] **Unit 5: Update generated and conceptual docs**\n\n**Goal:** Keep public documentation aligned with the new command and clarify how variables\nrelate to secrets.\n\n**Requirements:** R1, R4\n\n**Dependencies:** Units 2-4\n\n**Files:**\n- Modify: `docs/public/reference/cli.mdx`\n- Modify: `docs/public/workflows/variables.mdx`\n\n**Approach:**\n- Regenerate the CLI reference with `cargo dev docs refresh`.\n- Add a short section to `docs/public/workflows/variables.mdx` explaining that\n server-managed run config variables can be set with `fabro variable set NAME VALUE`\n and referenced as `{{ vars.NAME }}` in run config interpolation.\n- State that variables are non-sensitive and readable; tokens, keys, and credentials\n should use `fabro secret set`.\n\n**Patterns to follow:**\n- Existing generated docs workflow in `lib/crates/fabro-dev/src/commands/docs.rs`.\n- Existing CLI references to `fabro secret set` in administration docs.\n\n**Test scenarios:**\n- Happy path: generated CLI docs include `fabro variable` and its subcommands.\n- Documentation check: `cargo dev docs check` succeeds after regeneration.\n\n**Verification:**\n- The docs describe the CLI surface without implying variables are secret storage.\n\n## System-Wide Impact\n\n- **API surface parity:** No server or OpenAPI changes are planned; the CLI consumes the\n existing variables API.\n- **Error propagation:** Invalid names, missing variables, and write failures should flow\n through the existing `fabro-client` API error classification.\n- **State lifecycle risks:** CLI commands must use the server API rather than editing\n `variables.json` locally, so behavior remains correct for remote and socket-backed\n servers.\n- **Security boundary:** Values are intentionally visible for variables. Documentation\n must clearly distinguish variables from secrets to avoid accidental credential storage.\n- **Unchanged invariants:** `fabro secret` remains write-only and unchanged.\n\n## Risks & Dependencies\n\n| Risk | Mitigation |\n| --- | --- |\n| Users put credentials in variables because the command looks like secrets | Document variables as non-sensitive and keep secret guidance explicit. |\n| Empty values accidentally fail because secret handling rejects empties | Test explicit empty strings and newline-only stdin for `variable set`. |\n| CLI docs drift after adding clap args | Regenerate with `cargo dev docs refresh` and verify with `cargo dev docs check`. |\n| Plain `list` becomes awkward for long values | Accept for v1; `get` and JSON output provide exact machine-readable retrieval. |\n\n## Verification Plan\n\n- `cargo nextest run -p fabro-cli -- variable`\n- `cargo nextest run -p fabro-cli -- fabro`\n- `cargo dev docs check`\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`\n\n## Assumptions\n\n- The chosen CLI surface is full CRUD with readable values.\n- The namespace is singular: `fabro variable`.\n- No TypeScript client regeneration is required for this CLI-only change.\n- No server API, OpenAPI schema, or storage migration changes are required.", + "last_stage": "simplify_gpt", + "failure_class": "", + "outcome": "succeeded", + "current_node": "verify", + "thread.preflight_lint.current_node": "implement", + "internal.retry_count.simplify_gpt": 0, + "internal.work_dir": "/home/daytona/workspace/fabro", + "internal.retry_count.verify": 0, + "response.simplify_gpt": "Review complete. I found no actionable reuse/quality/efficiency cleanup issues to fix in the current branch diff.\n\nValidation run:\n\n- `cargo nextest run -p fabro-cli -- variable` ✅\n- `cargo nextest run -p fabro-cli -- fabro` ✅\n- `cargo dev docs check` ✅\n- `cargo +nightly-2026-04-14 fmt --check --all` ✅\n- `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings` ✅\n\nNo files were modified during this cleanup pass.", + "graph.rankdir": "LR", + "thread.toolchain.current_node": "preflight_compile", + "thread.preflight_compile.current_node": "preflight_lint", + "thread.simplify_gpt.current_node": "verify", + "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", + "internal.fidelity": "compact" }, "node_outcomes": { "toolchain": { @@ -1419,60 +1419,6 @@ "active_time_ms": 1445 } }, - "start": { - "status": "succeeded", - "usage": null - }, - "preflight_lint": { - "status": "succeeded", - "context_updates": { - "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" - }, - "notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", - "usage": null, - "timing": { - "wall_time_ms": 0, - "inference_time_ms": 0, - "tool_time_ms": 141353, - "active_time_ms": 141353 - } - }, - "implement": { - "status": "succeeded", - "context_updates": { - "response.implement": "Implemented CLI variable management end-to-end.\n\nSummary:\n- Added `fabro-client` wrappers for variables API:\n - `list_variables`\n - `get_variable`\n - `create_variable`\n - `update_variable`\n - `delete_variable`\n- Added `fabro variable` CLI namespace:\n - `fabro variable list` / `ls`\n - `fabro variable get NAME`\n - `fabro variable set NAME [VALUE]`\n - `fabro variable rm NAME`\n- Implemented behavior:\n - Plain `list` table includes `NAME`, `VALUE`, `UPDATED`\n - Plain `get` prints raw value\n - JSON output returns full variable objects\n - `set` is an upsert and supports `--description`\n - `set` allows explicit empty values and empty `--value-stdin`\n - `rm` JSON returns `{ \"name\": NAME }`\n- Added CLI integration test coverage and `TestContext::variable()`.\n- Updated root help snapshots and generated CLI reference docs.\n- Updated workflow variables docs to explain server-managed `{{ vars.NAME }}` and distinguish variables from secrets.\n\nTDD note:\n- Added variable CLI tests first and confirmed red state with `cargo nextest run -p fabro-cli -- variable::help` failing because `variable` was not yet implemented.\n\nValidation run:\n- `cargo nextest run -p fabro-cli -- variable` ✅\n- `cargo nextest run -p fabro-cli -- fabro` ✅\n- `cargo dev docs refresh` ✅\n- `cargo dev docs check` ✅\n- `cargo +nightly-2026-04-14 fmt --check --all` ✅\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` ✅\n- `cargo check -q --workspace` ✅\n- `git diff --check` ✅\n\nNote: `cargo insta pending-snapshots` was unavailable because `cargo-insta` is not installed in the environment; checked manually and found no pending `*.snap.new` / `*.snap.pending` files.", - "last_stage": "implement", - "last_response": "Implemented CLI variable management end-to-end.\n\nSummary:\n- Added `fabro-client` wrappers for variables API:\n - `list_variables`\n - `get_variable`\n - `create_variable`\n - `update_variable`\n - `de" - }, - "notes": "Stage completed: implement", - "usage": { - "input": { - "usage": { - "model": { - "provider": "openai", - "model_id": "gpt-5.5" - }, - "tokens": { - "input_tokens": 2883761, - "output_tokens": 16358, - "reasoning_tokens": 8084, - "cache_read_tokens": 6571520, - "cache_write_tokens": 0 - } - }, - "facts": { - "algorithm": "openai" - } - }, - "total_usd_micros": 18437825 - }, - "timing": { - "wall_time_ms": 0, - "inference_time_ms": 1484989, - "tool_time_ms": 408080, - "active_time_ms": 1893069 - } - }, "simplify_opus": { "status": "succeeded", "context_updates": { @@ -1516,6 +1462,74 @@ "active_time_ms": 527841 } }, + "start": { + "status": "succeeded", + "usage": null + }, + "implement": { + "status": "succeeded", + "context_updates": { + "response.implement": "Implemented CLI variable management end-to-end.\n\nSummary:\n- Added `fabro-client` wrappers for variables API:\n - `list_variables`\n - `get_variable`\n - `create_variable`\n - `update_variable`\n - `delete_variable`\n- Added `fabro variable` CLI namespace:\n - `fabro variable list` / `ls`\n - `fabro variable get NAME`\n - `fabro variable set NAME [VALUE]`\n - `fabro variable rm NAME`\n- Implemented behavior:\n - Plain `list` table includes `NAME`, `VALUE`, `UPDATED`\n - Plain `get` prints raw value\n - JSON output returns full variable objects\n - `set` is an upsert and supports `--description`\n - `set` allows explicit empty values and empty `--value-stdin`\n - `rm` JSON returns `{ \"name\": NAME }`\n- Added CLI integration test coverage and `TestContext::variable()`.\n- Updated root help snapshots and generated CLI reference docs.\n- Updated workflow variables docs to explain server-managed `{{ vars.NAME }}` and distinguish variables from secrets.\n\nTDD note:\n- Added variable CLI tests first and confirmed red state with `cargo nextest run -p fabro-cli -- variable::help` failing because `variable` was not yet implemented.\n\nValidation run:\n- `cargo nextest run -p fabro-cli -- variable` ✅\n- `cargo nextest run -p fabro-cli -- fabro` ✅\n- `cargo dev docs refresh` ✅\n- `cargo dev docs check` ✅\n- `cargo +nightly-2026-04-14 fmt --check --all` ✅\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` ✅\n- `cargo check -q --workspace` ✅\n- `git diff --check` ✅\n\nNote: `cargo insta pending-snapshots` was unavailable because `cargo-insta` is not installed in the environment; checked manually and found no pending `*.snap.new` / `*.snap.pending` files.", + "last_stage": "implement", + "last_response": "Implemented CLI variable management end-to-end.\n\nSummary:\n- Added `fabro-client` wrappers for variables API:\n - `list_variables`\n - `get_variable`\n - `create_variable`\n - `update_variable`\n - `de" + }, + "notes": "Stage completed: implement", + "usage": { + "input": { + "usage": { + "model": { + "provider": "openai", + "model_id": "gpt-5.5" + }, + "tokens": { + "input_tokens": 2883761, + "output_tokens": 16358, + "reasoning_tokens": 8084, + "cache_read_tokens": 6571520, + "cache_write_tokens": 0 + } + }, + "facts": { + "algorithm": "openai" + } + }, + "total_usd_micros": 18437825 + }, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 1484989, + "tool_time_ms": 408080, + "active_time_ms": 1893069 + } + }, + "preflight_compile": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo check -q --workspace 2>&1", + "usage": null, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 128996, + "active_time_ms": 128996 + } + }, + "preflight_lint": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", + "usage": null, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 141353, + "active_time_ms": 141353 + } + }, "verify": { "status": "succeeded", "context_updates": { @@ -1565,38 +1579,146 @@ "tool_time_ms": 100472, "active_time_ms": 241263 } - }, - "preflight_compile": { - "status": "succeeded", - "context_updates": { - "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" - }, - "notes": "Script completed: cargo check -q --workspace 2>&1", - "usage": null, - "timing": { - "wall_time_ms": 0, - "inference_time_ms": 0, - "tool_time_ms": 128996, - "active_time_ms": 128996 - } } }, "next_node_id": "exit", + "git_commit_sha": "4d0ffd4042987a0bd0469bfc7271adb3c7ec6cfb", "node_visits": { - "simplify_opus": 1, - "preflight_compile": 1, - "preflight_lint": 1, "simplify_gpt": 1, - "toolchain": 1, + "simplify_opus": 1, + "implement": 1, "start": 1, "verify": 1, - "implement": 1 + "preflight_lint": 1, + "preflight_compile": 1, + "toolchain": 1 } }, - "diff": {} + "diff": { + "patch": "diff --git a/.claude/skills/docs/references/mapping.md b/.claude/skills/docs/references/mapping.md\nindex 93b867938..0a7c9e329 100644\n--- a/.claude/skills/docs/references/mapping.md\n+++ b/.claude/skills/docs/references/mapping.md\n@@ -29,7 +29,6 @@ Which source files affect which doc pages. Use this as guidance — also apply j\n | `lib/crates/fabro-agent/src/subagent.rs` | `docs/public/agents/subagents.mdx` |\n | `lib/crates/fabro-agent/src/mcp_integration.rs` | `docs/public/agents/mcp.mdx` |\n | `lib/crates/fabro-llm/src/catalog.rs`, `lib/crates/fabro-llm/src/providers/*.rs` | `docs/public/core-concepts/models.mdx` |\n-| `lib/crates/fabro-devcontainer/src/*.rs` | `docs/public/execution/devcontainers.mdx` |\n | `lib/crates/fabro-slack/src/*.rs` | `docs/public/integrations/slack.mdx` |\n | `lib/crates/fabro-mcp/src/*.rs` | `docs/public/agents/mcp.mdx` |\n | `lib/crates/fabro-api/src/*.rs` | `docs/public/api-reference/overview.mdx`, `docs/public/api-reference/demo-mode.mdx` |\ndiff --git a/.config/nextest.toml b/.config/nextest.toml\nindex 2d5880982..fe7f4f778 100644\n--- a/.config/nextest.toml\n+++ b/.config/nextest.toml\n@@ -15,10 +15,6 @@ leak-timeout = \"500ms\"\n filter = \"package(fabro-server) & test(all_spec_routes_are_routable)\"\n slow-timeout = { period = \"15s\", terminate-after = 4 }\n \n- [[profile.default.overrides]]\n- filter = \"package(fabro-devcontainer) & test(resolve_features_integration)\"\n- slow-timeout = { period = \"10s\", terminate-after = 3 }\n-\n [[profile.default.overrides]]\n filter = \"package(fabro-workflow)\"\n slow-timeout = { period = \"2s\", terminate-after = 3 }\ndiff --git a/AGENTS.md b/AGENTS.md\nindex c97bcec4c..d77efe095 100644\n--- a/AGENTS.md\n+++ b/AGENTS.md\n@@ -118,7 +118,6 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as\n - **fabro-github** — GitHub App auth (JWT signing, installation tokens, PR creation)\n - **fabro-mcp** — Model Context Protocol client/server\n - **fabro-slack** — Slack integration (socket mode, blocks API)\n-- **fabro-devcontainer** — Parses `.devcontainer/devcontainer.json` for container setup\n - **fabro-checkpoint** — Git-based checkpoint storage with branch store and metadata branches\n - **fabro-telemetry** — CLI analytics (Segment) and crash reporting (Sentry), with anonymous IDs, command sanitization, and detached subprocess delivery\n - **fabro-util** — Shared utilities (redaction, terminal formatting)\ndiff --git a/Cargo.lock b/Cargo.lock\nindex ebd8e2593..97b4be2eb 100644\n--- a/Cargo.lock\n+++ b/Cargo.lock\n@@ -1827,7 +1827,6 @@ dependencies = [\n \"fabro-checkpoint\",\n \"fabro-client\",\n \"fabro-config\",\n- \"fabro-devcontainer\",\n \"fabro-dump\",\n \"fabro-github\",\n \"fabro-graphviz\",\n@@ -1992,23 +1991,6 @@ dependencies = [\n \"walkdir\",\n ]\n \n-[[package]]\n-name = \"fabro-devcontainer\"\n-version = \"0.246.0-nightly.0\"\n-dependencies = [\n- \"fabro-http\",\n- \"fabro-static\",\n- \"fabro-util\",\n- \"insta\",\n- \"serde\",\n- \"serde_json\",\n- \"serde_yaml\",\n- \"tempfile\",\n- \"thiserror 2.0.18\",\n- \"tokio\",\n- \"tracing\",\n-]\n-\n [[package]]\n name = \"fabro-dump\"\n version = \"0.246.0-nightly.0\"\n@@ -2693,7 +2675,6 @@ dependencies = [\n \"fabro-checkpoint\",\n \"fabro-config\",\n \"fabro-core\",\n- \"fabro-devcontainer\",\n \"fabro-dump\",\n \"fabro-github\",\n \"fabro-graphviz\",\ndiff --git a/apps/fabro-web/app/components/run-summary-panel.test.tsx b/apps/fabro-web/app/components/run-summary-panel.test.tsx\nindex f93a3fd8a..52e79cf0f 100644\n--- a/apps/fabro-web/app/components/run-summary-panel.test.tsx\n+++ b/apps/fabro-web/app/components/run-summary-panel.test.tsx\n@@ -83,6 +83,58 @@ describe(\"RunSummaryPanelView\", () => {\n expect(instanceText(cellAfterLabel(tree, \"Sandbox\"))).toBe(EMPTY_VALUE);\n });\n \n+ test(\"renders planned sandbox on a failed run as not created\", () => {\n+ const tree = render({\n+ run: makeRun({\n+ lifecycle: { status: { kind: \"failed\", reason: \"sandbox_init_failed\" } },\n+ sandbox: {\n+ kind: \"planned\",\n+ plan: { provider: \"docker\", image: null, snapshot: null },\n+ },\n+ }),\n+ sandboxState: null,\n+ sandboxResources: null,\n+ });\n+\n+ expect(instanceText(cellAfterLabel(tree, \"Sandbox\"))).toBe(\"Not created\");\n+ });\n+\n+ test(\"renders sandbox lifecycle state before details are available\", () => {\n+ const tree = render({\n+ run: makeRun({\n+ sandbox: {\n+ kind: \"initializing\",\n+ plan: { provider: \"docker\", image: null, snapshot: null },\n+ },\n+ }),\n+ sandboxState: null,\n+ sandboxResources: null,\n+ });\n+\n+ expect(instanceText(cellAfterLabel(tree, \"Sandbox\"))).toBe(\"Initializing\");\n+ });\n+\n+ test(\"renders failed sandbox lifecycle error before details are available\", () => {\n+ const tree = render({\n+ run: makeRun({\n+ sandbox: {\n+ kind: \"failed\",\n+ plan: { provider: \"docker\", image: null, snapshot: null },\n+ failure: {\n+ provider: \"docker\",\n+ error: \"Docker daemon unavailable\",\n+ causes: [],\n+ duration_ms: 42,\n+ },\n+ },\n+ }),\n+ sandboxState: null,\n+ sandboxResources: null,\n+ });\n+\n+ expect(instanceText(cellAfterLabel(tree, \"Sandbox\"))).toBe(\"Failed\");\n+ });\n+\n test(\"shows unavailable copy when artifacts count is zero\", () => {\n const tree = render({ run: makeRun(), artifactsCount: 0 });\n expect(instanceText(cellAfterLabel(tree, \"Artifacts\"))).toBe(EMPTY_VALUE);\ndiff --git a/apps/fabro-web/app/components/run-summary-panel.tsx b/apps/fabro-web/app/components/run-summary-panel.tsx\nindex 5d9c78ce4..20a08af5b 100644\n--- a/apps/fabro-web/app/components/run-summary-panel.tsx\n+++ b/apps/fabro-web/app/components/run-summary-panel.tsx\n@@ -13,6 +13,11 @@ import {\n } from \"../lib/format\";\n import { principalDisplay } from \"../lib/principal-display\";\n import { useRun, useRunArtifacts, useRunSandboxDetails } from \"../lib/queries\";\n+import {\n+ SANDBOX_LIFECYCLE_DISPLAY,\n+ sandboxIsReady,\n+ sandboxLifecycleKind,\n+} from \"../lib/run-sandbox-lifecycle\";\n import { SANDBOX_STATE_DISPLAY } from \"../lib/sandbox-state\";\n import { Tooltip } from \"./ui\";\n \n@@ -83,6 +88,25 @@ function SandboxValue({\n );\n }\n \n+function SandboxLifecycleValue({\n+ kind,\n+}: {\n+ kind: keyof typeof SANDBOX_LIFECYCLE_DISPLAY;\n+}) {\n+ const display = SANDBOX_LIFECYCLE_DISPLAY[kind];\n+ return (\n+
\n+ \n+ \n+ \n+ {display.label}\n+
\n+ );\n+}\n+\n export function RunSummaryPanelView({\n run,\n runLoading,\n@@ -95,6 +119,7 @@ export function RunSummaryPanelView({\n const created = run?.created_by ? principalDisplay(run.created_by) : null;\n const diff = run?.diff ?? null;\n const cost = formatUsdMicros(run?.billing?.total_usd_micros);\n+ const sandboxKind = sandboxLifecycleKind(run?.sandbox);\n \n return (\n
\n@@ -135,6 +160,8 @@ export function RunSummaryPanelView({\n \n ) : sandboxState ? (\n \n+ ) : sandboxKind ? (\n+ \n ) : (\n \n )}\n@@ -177,8 +204,11 @@ export function RunSummaryPanelView({\n \n export function RunSummaryPanel({ runId }: { runId: string }) {\n const runQuery = useRun(runId);\n- const sandboxQuery = useRunSandboxDetails(runId);\n+ const sandboxQuery = useRunSandboxDetails(\n+ sandboxIsReady(runQuery.data?.sandbox) ? runId : undefined,\n+ );\n const artifactsQuery = useRunArtifacts(runId);\n+ const sandboxReady = sandboxIsReady(runQuery.data?.sandbox);\n \n return (\n \ndiff --git a/apps/fabro-web/app/components/terminal-view-helpers.ts b/apps/fabro-web/app/components/terminal-view-helpers.ts\nindex ce49f8117..7c3023736 100644\n--- a/apps/fabro-web/app/components/terminal-view-helpers.ts\n+++ b/apps/fabro-web/app/components/terminal-view-helpers.ts\n@@ -1,4 +1,5 @@\n import type { RunSandbox } from \"@qltysh/fabro-api-client\";\n+import { sandboxInstance, sandboxRuntime } from \"../lib/run-sandbox-lifecycle\";\n \n export const TERMINAL_DOCK_CLEARANCE_CLASS =\n \"pb-[calc(0.125rem+var(--fabro-interview-dock-clearance,0px))]\";\n@@ -40,5 +41,6 @@ export function terminalAccessCommandLabel(provider: string | null): string | nu\n }\n \n export function sandboxStatusDetail(sandbox: RunSandbox | null | undefined): string | null {\n- return sandbox?.runtime?.id ?? sandbox?.provider ?? null;\n+ const instance = sandboxInstance(sandbox);\n+ return sandboxRuntime(sandbox)?.id ?? instance?.provider ?? null;\n }\ndiff --git a/apps/fabro-web/app/components/terminal-view.test.ts b/apps/fabro-web/app/components/terminal-view.test.ts\nindex ceff00617..6e154b698 100644\n--- a/apps/fabro-web/app/components/terminal-view.test.ts\n+++ b/apps/fabro-web/app/components/terminal-view.test.ts\n@@ -78,7 +78,11 @@ describe(\"terminal view helpers\", () => {\n image: null,\n snapshot: null,\n runtime: null,\n- })).toBe(\"docker\");\n+ })).toBeNull();\n+ expect(sandboxStatusDetail({\n+ kind: \"planned\",\n+ plan: { provider: \"docker\" },\n+ })).toBeNull();\n expect(sandboxStatusDetail(null)).toBeNull();\n });\n });\ndiff --git a/apps/fabro-web/app/components/terminal-view.tsx b/apps/fabro-web/app/components/terminal-view.tsx\nindex 89fb91352..f4beb4142 100644\n--- a/apps/fabro-web/app/components/terminal-view.tsx\n+++ b/apps/fabro-web/app/components/terminal-view.tsx\n@@ -15,6 +15,7 @@ import { ErrorState } from \"./state\";\n import { useToast } from \"./toast\";\n import { apiData, humanInTheLoopApi } from \"../lib/api-client\";\n import { useRunState } from \"../lib/queries\";\n+import { sandboxInstance } from \"../lib/run-sandbox-lifecycle\";\n import {\n buildFullScreenTerminalUrl,\n sandboxStatusDetail,\n@@ -107,7 +108,7 @@ export default function TerminalView({\n const { push } = useToast();\n const stateQuery = useRunState(runId);\n const sandbox = stateQuery.data?.sandbox ?? null;\n- const provider = sandbox?.provider ?? null;\n+ const provider = sandboxInstance(sandbox)?.provider ?? null;\n const sandboxDetail = sandboxStatusDetail(sandbox);\n const accessCommandLabel = terminalAccessCommandLabel(provider);\n const [connectionKey, reconnectTerminal] = useReducer((key: number) => key + 1, 0);\ndiff --git a/apps/fabro-web/app/data/runs.ts b/apps/fabro-web/app/data/runs.ts\nindex f820b24c1..2b277b6d0 100644\n--- a/apps/fabro-web/app/data/runs.ts\n+++ b/apps/fabro-web/app/data/runs.ts\n@@ -6,6 +6,7 @@ import {\n type RunSize,\n type RunStatus as ApiRunStatus,\n } from \"@qltysh/fabro-api-client\";\n+import { sandboxRuntime } from \"../lib/run-sandbox-lifecycle\";\n \n export type CiStatus = \"passing\" | \"failing\" | \"pending\";\n \n@@ -89,7 +90,7 @@ function runStatusKind(status: ApiRunStatus | null | undefined): RunStatus | nul\n \n export function mapRunListItem(item: Run): RunItem {\n const lifecycleStatus = item.lifecycle.archived ? \"archived\" : runStatusKind(item.lifecycle.status);\n- const runtime = item.sandbox?.runtime;\n+ const runtime = sandboxRuntime(item.sandbox);\n return {\n id: item.id,\n repo: displayRepoName(item.repository?.name ?? \"unknown\"),\ndiff --git a/apps/fabro-web/app/lib/run-sandbox-lifecycle.ts b/apps/fabro-web/app/lib/run-sandbox-lifecycle.ts\nnew file mode 100644\nindex 000000000..462e39672\n--- /dev/null\n+++ b/apps/fabro-web/app/lib/run-sandbox-lifecycle.ts\n@@ -0,0 +1,91 @@\n+import type {\n+ Run,\n+ RunProjection,\n+ RunSandbox,\n+ RunSandboxInstance,\n+ RunSandboxKind,\n+ RunSandboxRuntime,\n+} from \"@qltysh/fabro-api-client\";\n+\n+export type SandboxLifecycleKind = RunSandboxKind;\n+\n+export type MaybeSandbox = Run[\"sandbox\"] | RunProjection[\"sandbox\"] | null | undefined;\n+\n+export const SANDBOX_LIFECYCLE_DISPLAY: Record<\n+ SandboxLifecycleKind,\n+ { label: string; description: string; dot: string; text: string }\n+> = {\n+ planned: {\n+ label: \"Not created\",\n+ description: \"The sandbox instance was not created.\",\n+ dot: \"bg-fg-muted\",\n+ text: \"text-fg-muted\",\n+ },\n+ initializing: {\n+ label: \"Initializing\",\n+ description: \"The sandbox is being created.\",\n+ dot: \"bg-amber\",\n+ text: \"text-amber\",\n+ },\n+ ready: {\n+ label: \"Ready\",\n+ description: \"The sandbox instance is available.\",\n+ dot: \"bg-teal-500\",\n+ text: \"text-teal-500\",\n+ },\n+ failed: {\n+ label: \"Failed\",\n+ description: \"Sandbox creation failed.\",\n+ dot: \"bg-coral\",\n+ text: \"text-coral\",\n+ },\n+};\n+\n+export function sandboxLifecycleKind(\n+ sandbox: MaybeSandbox,\n+): SandboxLifecycleKind | null {\n+ if (!sandbox) return null;\n+ const value = sandbox as RunSandbox & {\n+ provider?: unknown;\n+ runtime?: unknown;\n+ };\n+ if (value.kind) return value.kind as SandboxLifecycleKind;\n+ return value.runtime ? \"ready\" : \"planned\";\n+}\n+\n+export function sandboxInstance(\n+ sandbox: MaybeSandbox,\n+): RunSandboxInstance | null {\n+ if (!sandbox) return null;\n+ const value = sandbox as RunSandbox & {\n+ provider?: RunSandboxInstance[\"provider\"];\n+ image?: string | null;\n+ snapshot?: string | null;\n+ runtime?: RunSandboxRuntime | null;\n+ };\n+ if (value.instance) return value.instance;\n+ if (value.runtime && value.provider) {\n+ return {\n+ provider: value.provider,\n+ image: value.image ?? null,\n+ snapshot: value.snapshot ?? null,\n+ runtime: value.runtime,\n+ };\n+ }\n+ return null;\n+}\n+\n+export function sandboxRuntime(\n+ sandbox: MaybeSandbox,\n+): RunSandboxRuntime | null {\n+ return sandboxInstance(sandbox)?.runtime ?? null;\n+}\n+\n+export function sandboxTabVisible(sandbox: MaybeSandbox): boolean {\n+ const kind = sandboxLifecycleKind(sandbox);\n+ return kind === \"initializing\" || kind === \"ready\" || kind === \"failed\";\n+}\n+\n+export function sandboxIsReady(sandbox: MaybeSandbox): boolean {\n+ return sandboxLifecycleKind(sandbox) === \"ready\" && sandboxInstance(sandbox) != null;\n+}\ndiff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts\nindex e30d38da8..844321220 100644\n--- a/apps/fabro-web/app/routes/run-detail.test.ts\n+++ b/apps/fabro-web/app/routes/run-detail.test.ts\n@@ -664,8 +664,79 @@ describe(\"RunDetail full-height child routes\", () => {\n expect(navigated).toEqual([\"/runs/run_retry\"]);\n });\n \n- test(\"shows the Sandbox tab when the run has a sandbox\", async () => {\n- currentRunState = { sandbox: { provider: \"docker\", id: \"container-1\" } };\n+ test(\"hides the Sandbox tab for a planned sandbox without an instance\", async () => {\n+ currentRunState = {\n+ sandbox: {\n+ kind: \"planned\",\n+ plan: { provider: \"docker\", image: null, snapshot: null },\n+ },\n+ };\n+ const renderer = await renderRunDetail({\n+ initialEntry: \"/runs/run_1\",\n+ });\n+\n+ const sandboxLinks = renderer.root.findAll(\n+ (node) =>\n+ node.type === \"a\" &&\n+ node.props.href === \"/runs/run_1/sandbox\",\n+ );\n+ expect(sandboxLinks).toHaveLength(0);\n+ });\n+\n+ for (const kind of [\"initializing\", \"ready\", \"failed\"] as const) {\n+ test(`shows the Sandbox tab for ${kind} sandbox state`, async () => {\n+ currentRunState = {\n+ sandbox: {\n+ kind,\n+ plan: { provider: \"docker\", image: null, snapshot: null },\n+ instance: kind === \"ready\"\n+ ? {\n+ provider: \"docker\",\n+ image: null,\n+ snapshot: null,\n+ runtime: {\n+ id: \"container-1\",\n+ working_directory: \"/workspace\",\n+ repo_cloned: null,\n+ clone_origin_url: null,\n+ clone_branch: null,\n+ },\n+ }\n+ : undefined,\n+ failure: kind === \"failed\"\n+ ? {\n+ provider: \"docker\",\n+ error: \"Docker daemon unavailable\",\n+ causes: [],\n+ duration_ms: 42,\n+ }\n+ : undefined,\n+ },\n+ };\n+ const renderer = await renderRunDetail({\n+ initialEntry: \"/runs/run_1\",\n+ });\n+\n+ const sandboxLinks = renderer.root.findAll(\n+ (node) =>\n+ node.type === \"a\" &&\n+ node.props.href === \"/runs/run_1/sandbox\" &&\n+ node.children.includes(\"Sandbox\"),\n+ );\n+ expect(sandboxLinks).toHaveLength(1);\n+ });\n+ }\n+\n+ test(\"shows the Sandbox tab for legacy sandbox state with runtime metadata\", async () => {\n+ currentRunState = {\n+ sandbox: {\n+ provider: \"docker\",\n+ runtime: {\n+ id: \"container-1\",\n+ working_directory: \"/workspace\",\n+ },\n+ },\n+ };\n const renderer = await renderRunDetail({\n initialEntry: \"/runs/run_1\",\n });\ndiff --git a/apps/fabro-web/app/routes/run-detail/header.tsx b/apps/fabro-web/app/routes/run-detail/header.tsx\nindex a8293585d..efc68f09b 100644\n--- a/apps/fabro-web/app/routes/run-detail/header.tsx\n+++ b/apps/fabro-web/app/routes/run-detail/header.tsx\n@@ -36,6 +36,7 @@ import {\n formatRelativeTime,\n } from \"../../lib/format\";\n import { useRunPullRequest } from \"../../lib/queries\";\n+import { sandboxRuntime } from \"../../lib/run-sandbox-lifecycle\";\n import { ActionsMenu, type ActionsMenuProps } from \"./actions\";\n import { classNames, type RunDetailRun } from \"./model\";\n \n@@ -135,7 +136,7 @@ export function RunDetailHeader({\n content={\n \n }\n >\ndiff --git a/apps/fabro-web/app/routes/run-detail/tabs-shell.tsx b/apps/fabro-web/app/routes/run-detail/tabs-shell.tsx\nindex fc047e4b1..cca7630bd 100644\n--- a/apps/fabro-web/app/routes/run-detail/tabs-shell.tsx\n+++ b/apps/fabro-web/app/routes/run-detail/tabs-shell.tsx\n@@ -1,5 +1,6 @@\n import { Link, Outlet, type UIMatch } from \"react-router\";\n \n+import { sandboxTabVisible, type MaybeSandbox } from \"../../lib/run-sandbox-lifecycle\";\n import { classNames } from \"./model\";\n \n interface RunDetailTabDefinition {\n@@ -21,12 +22,10 @@ const allTabs: RunDetailTabDefinition[] = [\n export type RunDetailTab = RunDetailTabDefinition;\n \n export function runHasSandbox(runState: unknown): boolean {\n- return !!(\n- runState &&\n- typeof runState === \"object\" &&\n- \"sandbox\" in runState &&\n- (runState as { sandbox?: unknown }).sandbox\n- );\n+ if (!runState || typeof runState !== \"object\" || !(\"sandbox\" in runState)) {\n+ return false;\n+ }\n+ return sandboxTabVisible((runState as { sandbox?: MaybeSandbox }).sandbox);\n }\n \n export function buildRunDetailTabs({\ndiff --git a/apps/fabro-web/app/routes/run-sandbox.test.tsx b/apps/fabro-web/app/routes/run-sandbox.test.tsx\nindex 915330d29..958d0a217 100644\n--- a/apps/fabro-web/app/routes/run-sandbox.test.tsx\n+++ b/apps/fabro-web/app/routes/run-sandbox.test.tsx\n@@ -6,10 +6,25 @@ import { MemoryRouter, Route, Routes } from \"react-router\";\n import type { SandboxDetails } from \"@qltysh/fabro-api-client\";\n \n let currentDetails: SandboxDetails | null = null;\n+let currentRunState: any = null;\n let currentLoading = false;\n let currentError: Error | null = null;\n \n mock.module(\"../lib/queries\", () => ({\n+ useRun: () => ({\n+ data: null,\n+ error: null,\n+ isLoading: false,\n+ isValidating: false,\n+ mutate: mock(() => Promise.resolve(null)),\n+ }),\n+ useRunState: () => ({\n+ data: currentRunState,\n+ error: null,\n+ isLoading: false,\n+ isValidating: false,\n+ mutate: mock(() => Promise.resolve(currentRunState)),\n+ }),\n useRunSandboxDetails: () => ({\n data: currentDetails,\n error: currentError,\n@@ -95,14 +110,15 @@ function sandboxDetails(\n } = {},\n ): SandboxDetails {\n const sandbox = overrides.sandbox ?? {};\n+ const { sandbox: _sandboxOverride, ...detailOverrides } = overrides;\n return {\n sandbox: {\n provider: \"docker\",\n image: null,\n snapshot: null,\n runtime: {\n- id: null,\n- working_directory: null,\n+ id: \"\",\n+ working_directory: \"\",\n repo_cloned: null,\n clone_origin_url: null,\n clone_branch: null,\n@@ -117,7 +133,7 @@ function sandboxDetails(\n network: networkDetails(),\n labels: {},\n timestamps: { created_at: null, last_activity_at: null },\n- ...overrides,\n+ ...detailOverrides,\n };\n }\n \n@@ -166,6 +182,7 @@ afterEach(() => {\n act(() => renderer.unmount());\n }\n currentDetails = null;\n+ currentRunState = null;\n currentLoading = false;\n currentError = null;\n });\n@@ -352,7 +369,52 @@ describe(\"RunSandbox route\", () => {\n Array.isArray(node.children) &&\n node.children.includes(\"No sandbox\"),\n );\n- expect(titles).toHaveLength(1);\n+ expect(titles).toHaveLength(2);\n+ });\n+\n+ test(\"renders a planned sandbox as not created without controls\", () => {\n+ currentRunState = {\n+ sandbox: {\n+ kind: \"planned\",\n+ plan: { provider: \"docker\", image: null, snapshot: null },\n+ },\n+ };\n+ currentDetails = null;\n+ currentError = new Error(\"Run sandbox was not created.\");\n+ const renderer = renderRoute();\n+\n+ expect(textContent(renderer)).toContain(\"Not created\");\n+ const tabs = renderer.root.findAll(\n+ (node) => node.type === \"button\" && node.props.role === \"tab\",\n+ );\n+ expect(tabs).toHaveLength(0);\n+ });\n+\n+ test(\"renders a failed sandbox lifecycle without service or file controls\", () => {\n+ currentRunState = {\n+ sandbox: {\n+ kind: \"failed\",\n+ plan: { provider: \"docker\", image: null, snapshot: null },\n+ failure: {\n+ provider: \"docker\",\n+ error: \"Docker daemon unavailable\",\n+ causes: [\"connection refused\"],\n+ duration_ms: 42,\n+ },\n+ },\n+ };\n+ currentDetails = null;\n+ currentError = new Error(\"Run sandbox was not created.\");\n+ const renderer = renderRoute(\"/runs/run_1/sandbox?mode=services\");\n+\n+ const copy = textContent(renderer);\n+ expect(copy).toContain(\"Failed\");\n+ expect(copy).toContain(\"Docker daemon unavailable\");\n+ expect(copy).toContain(\"connection refused\");\n+ const tabs = renderer.root.findAll(\n+ (node) => node.type === \"button\" && node.props.role === \"tab\",\n+ );\n+ expect(tabs).toHaveLength(0);\n });\n \n test(\"Terminal is the default right-column mode\", () => {\ndiff --git a/apps/fabro-web/app/routes/run-sandbox.tsx b/apps/fabro-web/app/routes/run-sandbox.tsx\nindex 09ce2d2c4..d190bd5e0 100644\n--- a/apps/fabro-web/app/routes/run-sandbox.tsx\n+++ b/apps/fabro-web/app/routes/run-sandbox.tsx\n@@ -10,9 +10,17 @@ import {\n formatBytesAsMemory,\n formatCpuCores,\n } from \"../lib/format\";\n-import { useRunSandboxDetails } from \"../lib/queries\";\n+import { useRun, useRunSandboxDetails, useRunState } from \"../lib/queries\";\n+import {\n+ SANDBOX_LIFECYCLE_DISPLAY,\n+ sandboxInstance,\n+ sandboxIsReady,\n+ sandboxLifecycleKind,\n+ sandboxRuntime,\n+} from \"../lib/run-sandbox-lifecycle\";\n import { SANDBOX_STATE_DISPLAY } from \"../lib/sandbox-state\";\n import type {\n+ RunSandbox,\n SandboxDetails,\n SandboxNetwork,\n SandboxResources,\n@@ -259,9 +267,51 @@ function DetailsColumn({ details }: { details: SandboxDetails | null }) {\n );\n }\n \n+function SandboxLifecycleStateView({\n+ sandbox,\n+ compact = false,\n+}: {\n+ sandbox: RunSandbox | null | undefined;\n+ compact?: boolean;\n+}) {\n+ const kind = sandboxLifecycleKind(sandbox);\n+ const failure = sandbox?.failure ?? null;\n+ const display = kind ? SANDBOX_LIFECYCLE_DISPLAY[kind] : null;\n+ const title = display?.label ?? \"No sandbox\";\n+ const description =\n+ kind === \"planned\"\n+ ? \"Run sandbox was not created.\"\n+ : kind === \"failed\"\n+ ? failure?.error ?? display?.description\n+ : display?.description\n+ ?? \"This run has no sandbox or its provider does not expose details.\";\n+\n+ const action = failure?.causes?.length ? (\n+
\n+ {failure.causes.map((cause) => (\n+

{cause}

\n+ ))}\n+
\n+ ) : null;\n+\n+ return ;\n+}\n+\n export default function RunSandbox({ params }: { params: { id: string } }) {\n- const sandboxQuery = useRunSandboxDetails(params.id);\n- const provider = sandboxQuery.data?.sandbox.provider ?? null;\n+ const runStateQuery = useRunState(params.id);\n+ const runQuery = useRun(params.id);\n+ const lifecycleSandbox = runStateQuery.data?.sandbox ?? runQuery.data?.sandbox ?? null;\n+ const lifecycleReady = sandboxIsReady(lifecycleSandbox);\n+ const lifecycleSourcesLoading = runStateQuery.isLoading || runQuery.isLoading;\n+ const shouldLoadDetails =\n+ lifecycleReady || (!lifecycleSandbox && !lifecycleSourcesLoading);\n+ const sandboxQuery = useRunSandboxDetails(shouldLoadDetails ? params.id : undefined);\n+ const details = sandboxQuery.data ?? null;\n+ const provider =\n+ details?.sandbox.provider\n+ ?? sandboxInstance(lifecycleSandbox)?.provider\n+ ?? null;\n+ const ready = lifecycleReady || !!details;\n const [searchParams, setSearchParams] = useSearchParams();\n const requestedMode = useMemo(\n () => normalizeSandboxMode(searchParams.get(\"mode\")),\n@@ -288,14 +338,14 @@ export default function RunSandbox({ params }: { params: { id: string } }) {\n }, [setSearchParams]);\n \n const modeToggle = useMemo(\n- () => (\n+ () => ready ? (\n \n- ),\n- [mode, provider, setMode],\n+ ) : null,\n+ [mode, provider, ready, setMode],\n );\n \n // The outer flex spans from the tab bar's bottom border down to the\n@@ -307,7 +357,9 @@ export default function RunSandbox({ params }: { params: { id: string } }) {\n \n- {sandboxQuery.error ? (\n+ {!ready && lifecycleSandbox ? (\n+ \n+ ) : sandboxQuery.error ? (\n \n ) : sandboxQuery.isLoading && !sandboxQuery.data ? null : (\n- \n+ \n )}\n \n
\n \n- {(() => {\n+ {!ready ? (\n+
\n+ \n+
\n+ ) : (() => {\n if (mode === \"terminal\") {\n return ;\n }\n@@ -332,7 +388,10 @@ export default function RunSandbox({ params }: { params: { id: string } }) {\n return ;\n }\n if (mode === \"filesystem\") {\n- const rootDirectory = sandboxQuery.data?.sandbox.runtime?.working_directory ?? null;\n+ const rootDirectory =\n+ details?.sandbox?.runtime?.working_directory\n+ ?? sandboxRuntime(lifecycleSandbox)?.working_directory\n+ ?? null;\n return (\n ` / `Bytes` body extractors (a `Parts`-taking helper would force full-`Request` extraction everywhere and break body handling). Each extractor returns the already-parsed run-id (and secondary path params) so handlers drop their own `Path` + `parse_*` dance. Replaces `_auth: AuthenticatedService` and the existing `authorize_artifact_upload` inline call. One fall-through behavior (worker token first, else user JWT) shared across all three. `authorize_worker_token` remains a `pub(crate)` internal helper used by the extractors. |\n-| Env scrubbing at two sites: `LocalSandbox::execute` (stage execution) and host-mode hooks | Stage commands run user-supplied code → MUST NOT see `FABRO_WORKER_TOKEN`. `LocalSandbox::execute` filters both inherited env AND the explicit `env_vars` extras path (today's code appends extras AFTER the filter — defense-in-depth gap this plan closes). Host-mode hooks get a targeted `env_remove(\"FABRO_WORKER_TOKEN\")` (shell commands have no business reading the worker token, even when operator-configured). Trusted internal subprocesses (`gh auth token`, MCP server stdio, devcontainer setup, git) are NOT scrubbed — they may legitimately need credentials, and they aren't user-attack surfaces. |\n+| Env scrubbing at two sites: `LocalSandbox::execute` (stage execution) and host-mode hooks | Stage commands run user-supplied code → MUST NOT see `FABRO_WORKER_TOKEN`. `LocalSandbox::execute` filters both inherited env AND the explicit `env_vars` extras path (today's code appends extras AFTER the filter — defense-in-depth gap this plan closes). Host-mode hooks get a targeted `env_remove(\"FABRO_WORKER_TOKEN\")` (shell commands have no business reading the worker token, even when operator-configured). Trusted internal subprocesses (`gh auth token`, MCP server stdio, git) are NOT scrubbed — they may legitimately need credentials, and they aren't user-attack surfaces. |\n | `authorize_worker_token` lives in `worker_token.rs` and takes `&WorkerTokenKeys` directly (NOT `&AppState`) | Sibling modules can't access private `AppState` fields. Mirroring `maybe_authorize_artifact_upload_token`'s signature (which already takes the typed keys) keeps the helper testable without a fixture `AppState`. The thin `authorize_run_scoped(parts, state, run_id)` adapter lives where it can see `AppState` and pulls `&state.worker_tokens` into the call. |\n | Missing/invalid `FABRO_WORKER_TOKEN` → worker errors at startup with a clear message; mid-run 401/403 flow through normal client error handling | No special exit codes. Distinct operational telemetry isn't worth the machinery for the current scale. |\n \n@@ -140,7 +140,7 @@ These are the **only** routes that gain worker-token acceptance. Lifecycle/admin\n - New `RunAuthMethod::Worker` variant: no — worker token bypasses `AuthenticatedSubject` entirely.\n - Stamp worker events server-side vs. worker-side: worker-side, in a dedicated sink wrapper inside the worker's `RunEventSink::fanout` chain.\n - Multi-token-per-run on rapid pause/resume: accept and document. Each prior token remains valid up to 72h `exp`. Bounded by run-id; out-of-scope to fix here.\n-- Env scrubbing scope: workflow stage-execution chokepoint at `LocalSandbox::execute` (inherited env + explicit `env_vars` extras) AND host-mode hooks at `fabro-hooks/src/executor.rs`. Trusted internal subprocesses (`gh auth token`, MCP stdio, devcontainer features, git) are not scrubbed.\n+- Env scrubbing scope: workflow stage-execution chokepoint at `LocalSandbox::execute` (inherited env + explicit `env_vars` extras) AND host-mode hooks at `fabro-hooks/src/executor.rs`. Trusted internal subprocesses (`gh auth token`, MCP stdio, git) are not scrubbed.\n - Auth-failure exit codes: no — generic error handling.\n \n ### Deferred to Implementation\n@@ -407,7 +407,7 @@ The spawn site that runs user-supplied workflow stage commands must NOT see `FAB\n \n - Modify: `lib/crates/fabro-hooks/src/executor.rs:186` — `cmd.env_remove(\"FABRO_WORKER_TOKEN\")` (and the same six server-secret names listed above) on host-mode hook spawns. Targeted, defense-in-depth. Hooks remain operator-trusted; this just keeps the worker token out of their env.\n \n-**Out of scope for env scrubbing:** trusted internal subprocesses that run server-controlled code and may legitimately need credentials in their env: `gh auth token` (`fabro-github/src/lib.rs:129`), MCP server stdio (`fabro-mcp/src/client.rs:47`), devcontainer features (`fabro-devcontainer/src/features.rs:67-199`), git (`fabro-workflow/src/git.rs:35`). These are not user-attack surfaces. Do NOT scrub them.\n+**Out of scope for env scrubbing:** trusted internal subprocesses that run server-controlled code and may legitimately need credentials in their env: `gh auth token` (`fabro-github/src/lib.rs:129`), MCP server stdio (`fabro-mcp/src/client.rs:47`), git (`fabro-workflow/src/git.rs:35`). These are not user-attack surfaces. Do NOT scrub them.\n \n **Approach:**\n - `connect_server_target_with_bearer` is the smallest possible surface: it skips the `AuthStore`/`OAuthSession` machinery entirely. The user-facing `connect_server_target` and `connect_server_with_settings` are unchanged.\n@@ -535,7 +535,7 @@ The spawn site that runs user-supplied workflow stage commands must NOT see `FAB\n | `FABRO_WORKER_TOKEN` readable via `/proc//environ` to same-UID processes on Linux | Documented in Threat Model: env-var transport does not protect against same-UID reads. Multi-tenant deployments must isolate per-tenant via separate UIDs / containers. NOT a property of this design. |\n | Workflow stage child processes (sandbox-executed Bash) inherit `FABRO_WORKER_TOKEN` via env or via explicitly-supplied `env_vars` extras | `LocalSandbox::execute` filters BOTH the inherited env (existing safelist + denylist) AND the `env_vars` extras path (new in Unit 4). Two regression tests prove both paths. |\n | Host-mode hook commands inherit `FABRO_WORKER_TOKEN` | `fabro-hooks/src/executor.rs` does targeted `cmd.env_remove(\"FABRO_WORKER_TOKEN\")` (and the same six server-secret names) on host-mode hook spawns. Hooks remain operator-trusted; this is defense-in-depth — shell commands have no business reading the worker token. |\n-| Trusted internal subprocesses (`gh`, MCP, devcontainer features, git) inherit env including `FABRO_WORKER_TOKEN` | NOT scrubbed by design — these run server-controlled code, may legitimately need credentials, and are not user-attack surfaces. Documented in Unit 4. |\n+| Trusted internal subprocesses (`gh`, MCP, git) inherit env including `FABRO_WORKER_TOKEN` | NOT scrubbed by design — these run server-controlled code, may legitimately need credentials, and are not user-attack surfaces. Documented in Unit 4. |\n | `client.upload_stage_artifact_*` API requires a per-call bearer parameter | Per Unit 4: `HttpArtifactUploader` holds the token in a `worker_token: String` field (same string read from `FABRO_WORKER_TOKEN`) and threads it per call. No `Client::credential()` accessor today; per-call threading is the path of least churn. |\n | Same-run concurrent worker spawn (scheduler race) → two valid tokens for one `run_id` racing on event/state appends | Scheduler's at-most-one-worker-per-run guarantee is assumed but not verified by this plan. If a race exists today, follow-up plan adds a server-side spawn lock or a per-spawn nonce. Out of scope here. |\n | Rapid pause/resume cycles leave multiple valid tokens per run | Each prior worker token remains valid up to its 72h `exp`. Multiplicative compromise window bounded by run-id. Accepted; out of scope to fix here. |\ndiff --git a/docs/plans/2026-04-25-001-refactor-docker-sandbox-clone-based-plan.md b/docs/plans/2026-04-25-001-refactor-docker-sandbox-clone-based-plan.md\nindex a216c369b..8aa3b9aaf 100644\n--- a/docs/plans/2026-04-25-001-refactor-docker-sandbox-clone-based-plan.md\n+++ b/docs/plans/2026-04-25-001-refactor-docker-sandbox-clone-based-plan.md\n@@ -129,7 +129,7 @@ These calls live in `lib/crates/fabro-workflow/src/pipeline/initialize.rs:77-204\n - **Docker socket permission management.** No GID shim, `group_add` automation, or Docker Desktop-specific setup logic in Fabro.\n - **Non-GitHub clone origins.** GitLab, Bitbucket, arbitrary SSH/HTTPS remotes, and generic credentials are follow-up work. With `skip_clone = false`, present non-GitHub origins fail clearly. With `skip_clone = true`, the provider creates an empty workspace as an escape hatch, but repository files are not present.\n - **Exact-SHA execution.** Branch-based clone behavior matches Daytona's current model. Optional submitted-SHA pinning is a follow-up.\n-- **Devcontainer integration with Docker provider.** Today's devcontainer code resolves config against the host filesystem before sandbox init; the clone-only model breaks that. Devcontainer-mode runs require a follow-up plan that resolves devcontainer config against the cloned `/workspace`.\n+- **Repository-derived setup with Docker provider.** Host-resolved setup metadata would break under the clone-only model. Any repository-derived setup must resolve against the cloned `/workspace`.\n - **Auto-fallback to local when Docker is unreachable.** If `connect_with_local_defaults()` fails, the run fails with the Docker connection error.\n - **Named-volume copy-from-host as an alternative to clone.** Evaluated and rejected: it reintroduces host-CLI / server-Docker coupling.\n - **Real DinD nesting.** Socket-mounted sibling containers through the host daemon are sufficient for self-hosting.\ndiff --git a/docs/plans/2026-05-03-fix-agent-stage-cancellation-plan.md b/docs/plans/2026-05-03-fix-agent-stage-cancellation-plan.md\nindex 51c961f80..551d3485b 100644\n--- a/docs/plans/2026-05-03-fix-agent-stage-cancellation-plan.md\n+++ b/docs/plans/2026-05-03-fix-agent-stage-cancellation-plan.md\n@@ -29,7 +29,7 @@\n - Update `RunServices::new(...)` and add a doc comment: production construction is expected to happen from pipeline initialization with the run's root token; use `with_cancel_token(...)` only with the same root token or a `child_token()` derived from it.\n - Make `with_cancel_token(token: CancellationToken)` `pub(crate)`. It must document that the token semantically means \"cancel this run or child run,\" not a generic shutdown signal.\n - Update `lib/crates/fabro-workflow/src/pipeline/execute.rs` to pass `run_options.cancel_token.clone()` into `ExecutorBuilder::cancel_token(...)`.\n- - Update setup/devcontainer paths in `lib/crates/fabro-workflow/src/pipeline/initialize.rs` and `lib/crates/fabro-workflow/src/devcontainer_bridge.rs` to pass `Some(run_options.cancel_token.child_token())` into sandbox commands instead of creating a new bridge from an atomic.\n+ - Update setup paths in `lib/crates/fabro-workflow/src/pipeline/initialize.rs` to pass `Some(run_options.cancel_token.child_token())` into sandbox commands instead of creating a new bridge from an atomic.\n - Update `lib/crates/fabro-workflow/src/handler/command.rs` to pass `Some(services.run.cancel_token().child_token())` into `exec_command_streaming` instead of calling `services.run.sandbox_cancel_token()`.\n - Do not wire stall timeout into the run cancel token. If `lib/crates/fabro-core/src/stall.rs` is migrated away from `Arc`, give it a field named `stall_token: CancellationToken` and call `stall_token.cancel()` on timeout. The executor must continue racing node execution against `ExecutorOptions.stall_token` and returning `Error::StallTimeout { node_id }` from that select branch.\n - Update CLI and server run entry points (`lib/crates/fabro-cli/src/commands/run/runner.rs`, `lib/crates/fabro-server/src/server.rs`, `lib/crates/fabro-workflow/src/operations/start.rs`) to create/store/cancel `CancellationToken` directly. `StartServices.cancel_token` and `RunSession.cancel_token` must become non-optional `CancellationToken` fields; managed server run state and CLI worker-control/signal handlers must use `CancellationToken`; places that currently call `load(Ordering::SeqCst)` must use `token.is_cancelled()`.\ndiff --git a/docs/plans/2026-05-21-wall-and-active-time-metrics-plan.md b/docs/plans/2026-05-21-wall-and-active-time-metrics-plan.md\nindex 055ae856c..90438d3b5 100644\n--- a/docs/plans/2026-05-21-wall-and-active-time-metrics-plan.md\n+++ b/docs/plans/2026-05-21-wall-and-active-time-metrics-plan.md\n@@ -46,7 +46,7 @@ visits. Parallel work is summed, so run active time can exceed run wall time.\n not `runtime_secs`.\n - Keep `duration_ms` only for unrelated subsystem-specific operational events\n where the name is still local and unambiguous, such as sandbox setup,\n- metadata snapshot, devcontainer lifecycle, and hook execution. The cleanup\n+ metadata snapshot, setup commands, and hook execution. The cleanup\n target is public run/stage runtime semantics.\n - Update OpenAPI and regenerate the Rust and TypeScript API clients after\n schema edits.\ndiff --git a/docs/plans/2026-05-22-002-remove-session-sandboxes-feature-flag-plan.md b/docs/plans/2026-05-22-002-remove-session-sandboxes-feature-flag-plan.md\nindex 1c8dc9f82..d6b07f9f6 100644\n--- a/docs/plans/2026-05-22-002-remove-session-sandboxes-feature-flag-plan.md\n+++ b/docs/plans/2026-05-22-002-remove-session-sandboxes-feature-flag-plan.md\n@@ -23,7 +23,7 @@ Remove the `session_sandboxes` feature flag and the now-empty `[features]` setti\n - Regenerate Rust API types and TypeScript Axios client.\n - Update current docs:\n - Remove `[features]` from active configuration docs, generated options docs, API docs, and unknown-key guidance.\n- - Do not touch unrelated meanings of \"features\" such as Cargo features, LLM model features, or devcontainer features.\n+ - Do not touch unrelated meanings of \"features\" such as Cargo features or LLM model features.\n \n ## Test Plan\n \ndiff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml\nindex 67d8e5830..be6931e70 100644\n--- a/docs/public/api-reference/fabro-api.yaml\n+++ b/docs/public/api-reference/fabro-api.yaml\n@@ -10438,13 +10438,55 @@ components:\n - docker\n - daytona\n \n+ RunSandboxKind:\n+ description: Lifecycle state for a run sandbox request.\n+ type: string\n+ enum:\n+ - planned\n+ - initializing\n+ - ready\n+ - failed\n+\n+ RunSandboxPlan:\n+ description: Requested sandbox provider and base image/snapshot from run settings.\n+ type: object\n+ required:\n+ - provider\n+ properties:\n+ provider:\n+ $ref: \"#/components/schemas/SandboxProviderKind\"\n+ image:\n+ type: [\"string\", \"null\"]\n+ snapshot:\n+ type: [\"string\", \"null\"]\n+\n RunSandbox:\n- description: Canonical sandbox environment record for a run.\n+ description: Sandbox lifecycle record for a run. A run can have a requested sandbox plan before it has an initialized sandbox instance.\n+ type: object\n+ required:\n+ - kind\n+ - plan\n+ properties:\n+ kind:\n+ $ref: \"#/components/schemas/RunSandboxKind\"\n+ plan:\n+ $ref: \"#/components/schemas/RunSandboxPlan\"\n+ instance:\n+ oneOf:\n+ - $ref: \"#/components/schemas/RunSandboxInstance\"\n+ - type: \"null\"\n+ description: Present only when `kind` is `ready`.\n+ failure:\n+ oneOf:\n+ - $ref: \"#/components/schemas/RunSandboxFailure\"\n+ - type: \"null\"\n+ description: Present only when `kind` is `failed`.\n+\n+ RunSandboxInstance:\n+ description: Initialized sandbox provider and runtime metadata.\n type: object\n required:\n - provider\n- - image\n- - snapshot\n - runtime\n properties:\n provider:\n@@ -10454,9 +10496,30 @@ components:\n snapshot:\n type: [\"string\", \"null\"]\n runtime:\n- oneOf:\n- - $ref: \"#/components/schemas/RunSandboxRuntime\"\n- - type: \"null\"\n+ $ref: \"#/components/schemas/RunSandboxRuntime\"\n+\n+ RunSandboxFailure:\n+ description: Sandbox initialization failure details.\n+ type: object\n+ required:\n+ - provider\n+ - error\n+ - causes\n+ - duration_ms\n+ properties:\n+ provider:\n+ type: string\n+ description: Provider reported by the sandbox initialization event.\n+ error:\n+ type: string\n+ causes:\n+ type: array\n+ items:\n+ type: string\n+ duration_ms:\n+ type: integer\n+ format: uint64\n+ minimum: 0\n \n RunSandboxRuntime:\n type: object\n@@ -11326,7 +11389,7 @@ components:\n - timestamps\n properties:\n sandbox:\n- $ref: \"#/components/schemas/RunSandbox\"\n+ $ref: \"#/components/schemas/RunSandboxInstance\"\n state:\n $ref: \"#/components/schemas/SandboxState\"\n native_state:\ndiff --git a/docs/public/changelog/2026-02-26.mdx b/docs/public/changelog/2026-02-26.mdx\nindex 96cef428b..659047c5e 100644\n--- a/docs/public/changelog/2026-02-26.mdx\n+++ b/docs/public/changelog/2026-02-26.mdx\n@@ -5,7 +5,7 @@ date: \"2026-02-26\"\n \n ## Daytona cloud sandboxes\n \n-Workflows can now execute in Daytona cloud environments — full dev containers with SSH access, persistent storage, and network isolation. Previously, Docker was the only sandbox option, which meant running everything locally. Daytona moves execution to the cloud, freeing up your machine and providing a more production-like environment.\n+Workflows can now execute in Daytona cloud environments with SSH access, persistent storage, and network isolation. Previously, Docker was the only sandbox option, which meant running everything locally. Daytona moves execution to the cloud, freeing up your machine and providing a more production-like environment.\n \n ```bash\n fabro run start --execution-env daytona my-workflow.fabro\ndiff --git a/docs/public/changelog/2026-03-02.mdx b/docs/public/changelog/2026-03-02.mdx\nindex 94cad1f13..ae4527cda 100644\n--- a/docs/public/changelog/2026-03-02.mdx\n+++ b/docs/public/changelog/2026-03-02.mdx\n@@ -1,14 +1,8 @@\n ---\n-title: \"Devcontainer support and sessions\"\n+title: \"Sessions and workflow improvements\"\n date: \"2026-03-02\"\n ---\n \n-## Devcontainer support\n-\n-Sandbox environments can now be defined using standard `devcontainer.json` files. Fabro parses and resolves the full devcontainer spec — features, lifecycle hooks (`onCreateCommand`, `postStartCommand`), build args, `containerEnv`, feature dependencies, and Compose-based configurations.\n-\n-If your project already has a `.devcontainer/devcontainer.json`, Fabro can use it directly instead of requiring a separate sandbox configuration.\n-\n ## Sessions\n \n Persistent chat sessions with SQLite storage. Start a conversation with an agent, close the terminal, and pick up where you left off. Sessions track messages, model, and conversation state.\ndiff --git a/docs/public/changelog/2026-03-10.mdx b/docs/public/changelog/2026-03-10.mdx\nindex 10e74bd50..1ae046b09 100644\n--- a/docs/public/changelog/2026-03-10.mdx\n+++ b/docs/public/changelog/2026-03-10.mdx\n@@ -1,5 +1,5 @@\n ---\n-title: \"One-line installer, MCP servers, devcontainers, and new CLI commands\"\n+title: \"One-line installer, MCP servers, and new CLI commands\"\n date: \"2026-03-10\"\n ---\n \n@@ -25,15 +25,6 @@ command = [\"npx\", \"@playwright/mcp@latest\", \"--port\", \"3100\", \"--headless\"]\n port = 3100\n ```\n \n-## Devcontainer support in sandboxes\n-\n-Workflows can now use your project's `devcontainer.json` to configure sandbox environments. When `devcontainer = true` is set in the sandbox config, Fabro resolves the devcontainer from the repo, uses its Dockerfile for the Daytona snapshot, runs lifecycle hooks (`onCreateCommand`, `postCreateCommand`, `postStartCommand`), and merges devcontainer environment variables into the sandbox. Unsupported `COPY`/`ADD` instructions in base Dockerfiles are detected and reported.\n-\n-```toml title=\"workflow.toml\"\n-[sandbox]\n-devcontainer = true\n-```\n-\n \n **Historical note.** This release temporarily standardized on `~/.fabro/.env`, but later releases removed automatic dotenv loading in favor of server-owned secrets plus explicit process environment variables.\n \ndiff --git a/docs/public/changelog/2026-03-11.mdx b/docs/public/changelog/2026-03-11.mdx\nindex b4230d74a..b85361ba1 100644\n--- a/docs/public/changelog/2026-03-11.mdx\n+++ b/docs/public/changelog/2026-03-11.mdx\n@@ -52,10 +52,6 @@ fabro graph -o flow.svg # SVG to file\n - Retro step now shows tool call progress while the retro agent works\n \n \n-\n-- Added `devcontainer` field to `SandboxConfiguration` OpenAPI schema\n-\n-\n \n - Context compaction now produces higher-quality summaries: the summarization prompt is framed as a handoff document, and recent user messages are preserved through compaction so the agent retains the user's actual words\n - Retro agent limits increased to 20 tool rounds and a 3-minute timeout for complex runs\ndiff --git a/docs/public/changelog/2026-04-08.mdx b/docs/public/changelog/2026-04-08.mdx\nindex d4bfd153b..33c52b7f1 100644\n--- a/docs/public/changelog/2026-04-08.mdx\n+++ b/docs/public/changelog/2026-04-08.mdx\n@@ -25,7 +25,6 @@ fabro uninstall --force # skip confirmation\n \n - Fixed GitHub App setup flow failing on nullable webhook secrets, duplicate POST requests, and incorrect port detection\n - Fixed session cookie decryption errors on server restart\n-- Fixed devcontainer lifecycle commands not being cancelled during shutdown\n - Fixed setup commands continuing to run after the server received a shutdown signal\n - Fixed dark theme not being selected by default for new users\n \ndiff --git a/docs/public/changelog/2026-04-23.mdx b/docs/public/changelog/2026-04-23.mdx\nindex ad0014a10..d92180a6d 100644\n--- a/docs/public/changelog/2026-04-23.mdx\n+++ b/docs/public/changelog/2026-04-23.mdx\n@@ -13,15 +13,15 @@ To migrate:\n \n ## Tighter server secret boundaries\n \n-Server startup now validates authority-bearing secrets at the server boundary instead of letting every subprocess inherit whatever happened to be in the parent environment. Worker subprocesses receive a scoped worker token when they need one, then scrub it from their process environment before launching hooks, sandbox commands, devcontainer setup, MCP stdio, or other descendants.\n+Server startup now validates authority-bearing secrets at the server boundary instead of letting every subprocess inherit whatever happened to be in the parent environment. Worker subprocesses receive a scoped worker token when they need one, then scrub it from their process environment before launching hooks, sandbox commands, MCP stdio, or other descendants.\n \n That change reduces the chance of leaking server-level credentials into user-controlled command paths while preserving authenticated run operations. It also makes install-time and startup-time secret handling easier to reason about for self-hosted deployments.\n \n-## Faster workflow finishing and devcontainer setup\n+## Faster workflow finishing\n \n-Several workflow phases now do less serialized work. Retros load the event log once, devcontainer `Command::Parallel` entries actually run concurrently, and final patch creation can overlap with finalize commit work.\n+Several workflow phases now do less serialized work. Retros load the event log once, and final patch creation can overlap with finalize commit work.\n \n-Users should notice this most on longer workflows with large event logs, devcontainer initialization, or expensive final patch generation. The behavior is the same, but the slow tail of a run has fewer avoidable waits.\n+Users should notice this most on longer workflows with large event logs or expensive final patch generation. The behavior is the same, but the slow tail of a run has fewer avoidable waits.\n \n ## More\n \ndiff --git a/docs/public/docs.json b/docs/public/docs.json\nindex 51da121d0..c60ddb57b 100644\n--- a/docs/public/docs.json\n+++ b/docs/public/docs.json\n@@ -57,8 +57,7 @@\n \"execution/checkpoints\",\n \"execution/outcomes\",\n \"execution/failures\",\n- \"execution/observability\",\n- \"execution/devcontainers\"\n+ \"execution/observability\"\n ]\n },\n {\ndiff --git a/docs/public/execution/devcontainers.mdx b/docs/public/execution/devcontainers.mdx\ndeleted file mode 100644\nindex 972d4e808..000000000\n--- a/docs/public/execution/devcontainers.mdx\n+++ /dev/null\n@@ -1,32 +0,0 @@\n----\n-title: \"Devcontainers\"\n-description: \"Using repository devcontainer metadata with Fabro environments\"\n----\n-\n-Named environments are the supported configuration surface for run execution. Define reusable environments under `[environments.]` and select one with `[run.environment] id = \"...\"`.\n-\n-Devcontainer-specific run configuration (`[run.sandbox] devcontainer = true`) has been removed with the named environments configuration break. To use a devcontainer-style image today, build or reference it through an environment image:\n-\n-```toml\n-[run.environment]\n-id = \"dev\"\n-\n-[environments.dev]\n-provider = \"docker\"\n-\n-[environments.dev.image]\n-docker = \"ghcr.io/acme/project-devcontainer:latest\"\n-```\n-\n-For Daytona snapshot creation, provide a Dockerfile path on the selected environment:\n-\n-```toml\n-[run.environment]\n-id = \"cloud-dev\"\n-\n-[environments.cloud-dev]\n-provider = \"daytona\"\n-\n-[environments.cloud-dev.image]\n-dockerfile = { path = \".devcontainer/Dockerfile\" }\n-```\ndiff --git a/docs/superpowers/plans/2026-04-30-preserve-exec-failure-diagnostics.md b/docs/superpowers/plans/2026-04-30-preserve-exec-failure-diagnostics.md\nindex 260cead67..dd8130969 100644\n--- a/docs/superpowers/plans/2026-04-30-preserve-exec-failure-diagnostics.md\n+++ b/docs/superpowers/plans/2026-04-30-preserve-exec-failure-diagnostics.md\n@@ -21,13 +21,13 @@\n - Modify `lib/crates/fabro-workflow/src/event.rs`: carry `exec_output_tail` through internal events and event-body conversion; trace only safe metadata about tails, not tail content.\n - Modify `lib/crates/fabro-workflow/src/sandbox_metadata.rs`, `lib/crates/fabro-workflow/src/lifecycle/git.rs`, and `lib/crates/fabro-workflow/src/pipeline/finalize.rs`: preserve push/write diagnostic projections without storing `fabro_sandbox::Error` inside `MetadataSnapshot`.\n - Modify `lib/crates/fabro-workflow/src/pipeline/initialize.rs`: add output-tail diagnostics to setup failures while preserving existing `stderr` field for compatibility.\n-- Modify `lib/crates/fabro-workflow/src/devcontainer_bridge.rs`: add output-tail diagnostics to devcontainer lifecycle failures while preserving existing `stderr` field for compatibility.\n+- Modify `lib/crates/fabro-workflow/src/pipeline/initialize.rs`: add output-tail diagnostics to setup failures while preserving existing `stderr` field for compatibility.\n - Modify `lib/crates/fabro-workflow/src/handler/llm/cli.rs`: replace CLI install's ad hoc 500-character embedded error detail with `exec_output_tail`.\n - Modify `docs/internal/logging-strategy.md`: document the policy that event payloads may contain bounded redacted tails, while tracing logs must not contain tail content by default.\n \n ## Explicit Non-Goals\n \n-- Do not remove, deprecate, or stop populating `SetupFailedProps.stderr` or `DevcontainerLifecycleFailedProps.stderr` in this change. Any future removal requires a separate public event-contract deprecation plan.\n+- Do not remove, deprecate, or stop populating `SetupFailedProps.stderr` in this change. Any future removal requires a separate public event-contract deprecation plan.\n - Do not add stdout/stderr tail content to `server.log`. Tracing should record safe metadata only: whether a tail exists, stream lengths, truncation booleans, and the existing safe error message.\n - Do not change `HookDecision::Block.reason` to include stdout/stderr. That is user-visible hook semantics and needs a separate design if we want durable hook diagnostics later.\n - Do not broadly refactor `sandbox_git.rs` error plumbing beyond constructor/signature updates needed by the `Error::Exec` refactor.\n@@ -84,14 +84,14 @@ Keep `is_false` private to the module. Do not add another full process result ty\n \n - [x] **Step 2: Add `exec_output_tail` additively to failure props**\n \n-Add this optional field to `MetadataSnapshotFailedProps`, `SetupFailedProps`, `CliEnsureFailedProps`, and `DevcontainerLifecycleFailedProps`:\n+Add this optional field to `MetadataSnapshotFailedProps`, `SetupFailedProps`, and `CliEnsureFailedProps`:\n \n ```rust\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub exec_output_tail: Option,\n ```\n \n-Do not remove existing fields, including `stderr` on setup/devcontainer failure props.\n+Do not remove existing fields, including `stderr` on setup failure props.\n \n - [x] **Step 3: Re-export the projection**\n \n@@ -360,9 +360,9 @@ Add `exec_output_tail: Option` to:\n - `MetadataSnapshotFailed`\n - `SetupFailed`\n - `CliEnsureFailed`\n-- `DevcontainerLifecycleFailed`\n+- `SetupFailed`\n \n-Keep existing `stderr` fields on `SetupFailed` and `DevcontainerLifecycleFailed`.\n+Keep existing `stderr` fields on `SetupFailed`.\n \n - [x] **Step 2: Map tails into `EventBody`**\n \n@@ -543,11 +543,11 @@ cargo nextest run -p fabro-workflow metadata_snapshot\n \n Expected: metadata push/write failure events contain `exec_output_tail` when command output exists.\n \n-## Task 5: Add Tails To Setup, Devcontainer, And CLI Install Failures\n+## Task 5: Add Tails To Setup And CLI Install Failures\n \n **Files:**\n - Modify: `lib/crates/fabro-workflow/src/pipeline/initialize.rs`\n-- Modify: `lib/crates/fabro-workflow/src/devcontainer_bridge.rs`\n+- Modify: `lib/crates/fabro-workflow/src/pipeline/initialize.rs`\n - Modify: `lib/crates/fabro-workflow/src/handler/llm/cli.rs`\n \n - [x] **Step 1: Add setup failure tails without removing `stderr`**\n@@ -567,13 +567,13 @@ options.emitter.emit(&Event::SetupFailed {\n \n Keep the existing `stderr` value for compatibility in this change.\n \n-- [x] **Step 2: Add devcontainer failure tails without removing `stderr`**\n+- [x] **Step 2: Add setup failure tails without removing `stderr`**\n \n For both parallel and single-command lifecycle failures, emit:\n \n ```rust\n let exec_output_tail = result.default_redacted_output_tail();\n-emitter.emit(&Event::DevcontainerLifecycleFailed {\n+emitter.emit(&Event::SetupFailed {\n phase: phase.clone(),\n command: name.clone(),\n index,\n@@ -611,14 +611,14 @@ Add or update tests so that:\n \n - setup failure with stderr preserves `props.stderr` and adds `props.exec_output_tail.stderr`.\n - setup failure with stdout-only output adds `props.exec_output_tail.stdout`.\n-- devcontainer lifecycle failure adds the nested tail while preserving `stderr`.\n+- setup failure adds the nested tail while preserving `stderr`.\n - CLI ensure failure no longer embeds command output in `error`, but includes `exec_output_tail`.\n \n Run:\n \n ```bash\n cargo nextest run -p fabro-workflow setup\n-cargo nextest run -p fabro-workflow devcontainer\n+cargo nextest run -p fabro-workflow setup\n cargo nextest run -p fabro-workflow cli\n ```\n \ndiff --git a/docs/superpowers/plans/2026-05-08-run-owned-sandbox-lifecycle.md b/docs/superpowers/plans/2026-05-08-run-owned-sandbox-lifecycle.md\nindex 6ead35204..f962f3983 100644\n--- a/docs/superpowers/plans/2026-05-08-run-owned-sandbox-lifecycle.md\n+++ b/docs/superpowers/plans/2026-05-08-run-owned-sandbox-lifecycle.md\n@@ -131,7 +131,7 @@ Attach-existing must not:\n - create an empty provider workspace\n - overwrite persisted sandbox identity\n \n-Setup-command/devcontainer behavior on resume should remain checkpoint-aware: do not rerun provider creation, clone, or devcontainer snapshot creation. Only rerun explicit resume setup commands already defined by the sandbox/provider when needed to reattach to the existing run branch.\n+Setup-command behavior on resume should remain checkpoint-aware: do not rerun provider creation or clone. Only rerun explicit resume setup commands already defined by the sandbox/provider when needed to reattach to the existing run branch.\n \n ## Delete And Preserve API Semantics\n \ndiff --git a/docs/superpowers/plans/2026-05-10-sandbox-services-backend.md b/docs/superpowers/plans/2026-05-10-sandbox-services-backend.md\nindex 81e4ae550..a127e30e6 100644\n--- a/docs/superpowers/plans/2026-05-10-sandbox-services-backend.md\n+++ b/docs/superpowers/plans/2026-05-10-sandbox-services-backend.md\n@@ -16,7 +16,7 @@\n - Use only `ss -H -ltnp` for v1; do not add `netstat` or `lsof` fallback.\n - Return all parsed listening TCP ports, including ports outside Daytona's preview range.\n - Compute preview support outside the sandbox command: `provider == \"daytona\" && 3000 <= port <= 9999`.\n-- Do not probe HTTP readiness and do not read `devcontainer.json`.\n+- Do not probe HTTP readiness or read repository setup metadata.\n \n ## Files\n \ndiff --git a/docs/superpowers/plans/2026-05-10-sandbox-services-frontend.md b/docs/superpowers/plans/2026-05-10-sandbox-services-frontend.md\nindex 30d27c918..996ab4eeb 100644\n--- a/docs/superpowers/plans/2026-05-10-sandbox-services-frontend.md\n+++ b/docs/superpowers/plans/2026-05-10-sandbox-services-frontend.md\n@@ -16,7 +16,7 @@\n - Place it between **Terminal** and **Filesystem**.\n - List all services returned by the backend.\n - Show a **Preview** action only for rows with `preview_supported: true`.\n-- Do not read `devcontainer.json`.\n+- Do not read repository setup metadata.\n - Do not perform browser-side HTTP checks.\n - Do not implement polling beyond normal SWR refresh/manual refresh behavior.\n \ndiff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs\nindex 8483a12b0..6f02e6b96 100644\n--- a/lib/crates/fabro-api/build.rs\n+++ b/lib/crates/fabro-api/build.rs\n@@ -534,6 +534,10 @@ fn main() {\n &[],\n ),\n (\"RunSandboxRuntime\", \"fabro_types::RunSandboxRuntime\", &[]),\n+ (\"RunSandboxKind\", \"fabro_types::RunSandboxKind\", &[]),\n+ (\"RunSandboxPlan\", \"fabro_types::RunSandboxPlan\", &[]),\n+ (\"RunSandboxInstance\", \"fabro_types::RunSandboxInstance\", &[]),\n+ (\"RunSandboxFailure\", \"fabro_types::RunSandboxFailure\", &[]),\n (\"PullRequestUser\", \"fabro_types::PullRequestUser\", &[]),\n (\"PullRequestRef\", \"fabro_types::PullRequestRef\", &[]),\n (\ndiff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs\nindex 26bbd1b2e..9c5e4c0af 100644\n--- a/lib/crates/fabro-api/src/lib.rs\n+++ b/lib/crates/fabro-api/src/lib.rs\n@@ -48,12 +48,13 @@ pub mod types {\n PullRequestLink, PullRequestMeta, PullRequestResponse, QuestionType, RepositoryRef, Run,\n RunApproval, RunApprovalState, RunClientProvenance, RunEvent, RunEventDetailContentKind,\n RunEventDetailResponse, RunFailure, RunPairStatusResponse, RunProjection, RunProvenance,\n- RunRunnableSource, RunSandbox, RunSandboxRuntime, RunServerProvenance, RunSize,\n- SandboxDetails, SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxNetwork,\n- SandboxNetworkPolicy, SandboxNetworkPolicyMode, SandboxProviderKind,\n- SandboxProviderLookupError, SandboxResources, SandboxService, SandboxServiceListResponse,\n- SandboxState, SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail,\n- SessionId, SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn,\n+ RunRunnableSource, RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxKind,\n+ RunSandboxPlan, RunSandboxRuntime, RunServerProvenance, RunSize, SandboxDetails,\n+ SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy,\n+ SandboxNetworkPolicyMode, SandboxProviderKind, SandboxProviderLookupError,\n+ SandboxResources, SandboxService, SandboxServiceListResponse, SandboxState,\n+ SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail, SessionId,\n+ SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn,\n SkillsProjection, StageCompletion, StageContextWindow, StageContextWindowBreakdownItem,\n StageContextWindowCategory, StageContextWindowCountMethod, StageContextWindowProjection,\n StageContextWindowStaleness, StageContextWindowUnavailableReason,\ndiff --git a/lib/crates/fabro-api/tests/run_projection_round_trip.rs b/lib/crates/fabro-api/tests/run_projection_round_trip.rs\nindex bd3d521d7..9a2a0f310 100644\n--- a/lib/crates/fabro-api/tests/run_projection_round_trip.rs\n+++ b/lib/crates/fabro-api/tests/run_projection_round_trip.rs\n@@ -35,13 +35,19 @@ fn run_projection_round_trips_populated_projection() {\n ],\n \"conclusion\": null,\n \"sandbox\": {\n- \"provider\": \"docker\",\n- \"runtime\": {\n- \"id\": \"container-abc123\",\n- \"working_directory\": \"/workspace\",\n- \"repo_cloned\": true,\n- \"clone_origin_url\": \"https://github.com/fabro-sh/fabro.git\",\n- \"clone_branch\": \"main\"\n+ \"kind\": \"ready\",\n+ \"plan\": {\n+ \"provider\": \"docker\"\n+ },\n+ \"instance\": {\n+ \"provider\": \"docker\",\n+ \"runtime\": {\n+ \"id\": \"container-abc123\",\n+ \"working_directory\": \"/workspace\",\n+ \"repo_cloned\": true,\n+ \"clone_origin_url\": \"https://github.com/fabro-sh/fabro.git\",\n+ \"clone_branch\": \"main\"\n+ }\n }\n },\n \"pull_request\": null,\ndiff --git a/lib/crates/fabro-api/tests/run_sandbox_round_trip.rs b/lib/crates/fabro-api/tests/run_sandbox_round_trip.rs\nindex 5c7da541a..0e30c3ab7 100644\n--- a/lib/crates/fabro-api/tests/run_sandbox_round_trip.rs\n+++ b/lib/crates/fabro-api/tests/run_sandbox_round_trip.rs\n@@ -1,49 +1,69 @@\n use std::any::{TypeId, type_name};\n \n-use fabro_api::types::{RunSandbox as ApiRunSandbox, SandboxProviderKind as ApiSandboxProvider};\n-use fabro_types::{RunSandbox, RunSandboxRuntime, SandboxProviderKind};\n+use fabro_api::types::{\n+ RunSandbox as ApiRunSandbox, RunSandboxInstance as ApiRunSandboxInstance,\n+ RunSandboxPlan as ApiRunSandboxPlan, SandboxProviderKind as ApiSandboxProvider,\n+};\n+use fabro_types::{\n+ RunSandbox, RunSandboxInstance, RunSandboxPlan, RunSandboxRuntime, SandboxProviderKind,\n+};\n use serde_json::json;\n \n #[test]\n fn run_sandbox_reuses_domain_types() {\n assert_same_type::();\n+ assert_same_type::();\n+ assert_same_type::();\n assert_same_type::();\n }\n \n #[test]\n fn run_sandbox_json_matches_openapi_shape() {\n- let sandbox = RunSandbox {\n- provider: SandboxProviderKind::Docker,\n- image: Some(\"ghcr.io/fabro/sandbox:latest\".to_string()),\n- snapshot: None,\n- runtime: Some(RunSandboxRuntime {\n- id: \"container-abc123\".to_string(),\n- working_directory: \"/workspace\".to_string(),\n- repo_cloned: Some(false),\n- clone_origin_url: Some(\"https://github.com/fabro-sh/fabro.git\".to_string()),\n- clone_branch: Some(\"main\".to_string()),\n- workspace_root: Some(\"/workspace\".to_string()),\n- repos_root: Some(\"/repos\".to_string()),\n- primary_repo_path: None,\n- primary_repo_link: None,\n- }),\n- };\n+ let sandbox = RunSandbox::ready(\n+ RunSandboxPlan {\n+ provider: SandboxProviderKind::Docker,\n+ image: Some(\"ghcr.io/fabro/sandbox:latest\".to_string()),\n+ snapshot: None,\n+ },\n+ RunSandboxInstance {\n+ provider: SandboxProviderKind::Docker,\n+ image: None,\n+ snapshot: None,\n+ runtime: RunSandboxRuntime {\n+ id: \"container-abc123\".to_string(),\n+ working_directory: \"/workspace\".to_string(),\n+ repo_cloned: Some(false),\n+ clone_origin_url: Some(\"https://github.com/fabro-sh/fabro.git\".to_string()),\n+ clone_branch: Some(\"main\".to_string()),\n+ workspace_root: Some(\"/workspace\".to_string()),\n+ repos_root: Some(\"/repos\".to_string()),\n+ primary_repo_path: None,\n+ primary_repo_link: None,\n+ },\n+ },\n+ );\n \n let value = serde_json::to_value(&sandbox).unwrap();\n \n assert_eq!(\n value,\n json!({\n- \"provider\": \"docker\",\n- \"image\": \"ghcr.io/fabro/sandbox:latest\",\n- \"runtime\": {\n- \"id\": \"container-abc123\",\n- \"working_directory\": \"/workspace\",\n- \"repo_cloned\": false,\n- \"clone_origin_url\": \"https://github.com/fabro-sh/fabro.git\",\n- \"clone_branch\": \"main\",\n- \"workspace_root\": \"/workspace\",\n- \"repos_root\": \"/repos\"\n+ \"kind\": \"ready\",\n+ \"plan\": {\n+ \"provider\": \"docker\",\n+ \"image\": \"ghcr.io/fabro/sandbox:latest\"\n+ },\n+ \"instance\": {\n+ \"provider\": \"docker\",\n+ \"runtime\": {\n+ \"id\": \"container-abc123\",\n+ \"working_directory\": \"/workspace\",\n+ \"repo_cloned\": false,\n+ \"clone_origin_url\": \"https://github.com/fabro-sh/fabro.git\",\n+ \"clone_branch\": \"main\",\n+ \"workspace_root\": \"/workspace\",\n+ \"repos_root\": \"/repos\"\n+ }\n }\n })\n );\ndiff --git a/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs b/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs\nindex a485ac4eb..712f22a7a 100644\n--- a/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs\n+++ b/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs\n@@ -10,7 +10,7 @@ use fabro_api::types::{\n SandboxState as ApiSandboxState, SandboxTimestamps as ApiSandboxTimestamps,\n };\n use fabro_types::{\n- RunSandbox, RunSandboxRuntime, SandboxDetails, SandboxNetwork, SandboxNetworkPolicy,\n+ RunSandboxInstance, RunSandboxRuntime, SandboxDetails, SandboxNetwork, SandboxNetworkPolicy,\n SandboxNetworkPolicyMode, SandboxProviderKind, SandboxResources, SandboxState,\n SandboxTimestamps,\n };\n@@ -32,11 +32,11 @@ fn sandbox_details_reuses_domain_types() {\n fn sandbox_details_json_matches_openapi_shape() {\n let created_at = Utc.with_ymd_and_hms(2026, 5, 9, 12, 0, 0).unwrap();\n let details = SandboxDetails {\n- sandbox: RunSandbox {\n+ sandbox: RunSandboxInstance {\n provider: SandboxProviderKind::Docker,\n image: Some(\"ghcr.io/fabro/sandbox:latest\".to_string()),\n snapshot: None,\n- runtime: Some(RunSandboxRuntime {\n+ runtime: RunSandboxRuntime {\n id: \"container-abc123\".to_string(),\n working_directory: \"/workspace\".to_string(),\n repo_cloned: None,\n@@ -46,7 +46,7 @@ fn sandbox_details_json_matches_openapi_shape() {\n repos_root: Some(\"/repos\".to_string()),\n primary_repo_path: Some(\"/repos/fabro-sh/fabro\".to_string()),\n primary_repo_link: Some(\"/workspace/fabro\".to_string()),\n- }),\n+ },\n },\n state: SandboxState::Running,\n native_state: Some(\"running\".to_string()),\n@@ -132,20 +132,12 @@ fn sandbox_details_deserializes_when_optional_fields_are_absent() {\n \n assert_eq!(details.sandbox.provider, SandboxProviderKind::Local);\n assert_eq!(\n- details\n- .sandbox\n- .runtime\n- .as_ref()\n- .map(|runtime| runtime.id.as_str()),\n- Some(\"local:01JNQVR7M0EJ5GKAT2SC4ERS1Z\")\n+ details.sandbox.runtime.id.as_str(),\n+ \"local:01JNQVR7M0EJ5GKAT2SC4ERS1Z\"\n );\n assert_eq!(\n- details\n- .sandbox\n- .runtime\n- .as_ref()\n- .map(|runtime| runtime.working_directory.as_str()),\n- Some(\"/Users/client/project\")\n+ details.sandbox.runtime.working_directory.as_str(),\n+ \"/Users/client/project\"\n );\n assert_eq!(details.state, SandboxState::Unknown);\n assert!(details.sandbox.image.is_none());\ndiff --git a/lib/crates/fabro-cli/Cargo.toml b/lib/crates/fabro-cli/Cargo.toml\nindex 4bff98f8d..a4d15b5b5 100644\n--- a/lib/crates/fabro-cli/Cargo.toml\n+++ b/lib/crates/fabro-cli/Cargo.toml\n@@ -25,7 +25,6 @@ fabro-model = { path = \"../fabro-model\" }\n fabro-oauth = { path = \"../fabro-oauth\" }\n fabro-github = { path = \"../fabro-github\" }\n fabro-agent = { path = \"../fabro-agent\" }\n-fabro-devcontainer = { path = \"../fabro-devcontainer\" }\n fabro-dump = { path = \"../fabro-dump\" }\n fabro-hooks = { path = \"../fabro-hooks\" }\n fabro-install = { path = \"../fabro-install\" }\ndiff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs\nindex 9c4d3a747..a4a72f0bc 100644\n--- a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs\n+++ b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs\n@@ -100,32 +100,6 @@ pub(super) enum ProgressEvent {\n CliEnsureFailed {\n cli_name: String,\n },\n- DevcontainerResolved {\n- dockerfile_lines: u64,\n- environment_count: u64,\n- lifecycle_command_count: u64,\n- workspace_folder: String,\n- },\n- DevcontainerLifecycleStarted {\n- phase: String,\n- command_count: u64,\n- },\n- DevcontainerLifecycleCompleted {\n- phase: String,\n- duration_ms: u64,\n- },\n- DevcontainerLifecycleFailed {\n- phase: String,\n- command: String,\n- exit_code: i64,\n- stderr: String,\n- },\n- DevcontainerLifecycleCommandCompleted {\n- command: String,\n- command_index: u64,\n- exit_code: i64,\n- duration_ms: u64,\n- },\n StageStarted {\n node_id: String,\n name: String,\n@@ -308,40 +282,6 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option {\n EventBody::CliEnsureFailed(props) => Some(ProgressEvent::CliEnsureFailed {\n cli_name: props.cli_name.clone(),\n }),\n- EventBody::DevcontainerResolved(props) => Some(ProgressEvent::DevcontainerResolved {\n- dockerfile_lines: props.dockerfile_lines as u64,\n- environment_count: props.environment_count as u64,\n- lifecycle_command_count: props.lifecycle_command_count as u64,\n- workspace_folder: props.workspace_folder.clone(),\n- }),\n- EventBody::DevcontainerLifecycleStarted(props) => {\n- Some(ProgressEvent::DevcontainerLifecycleStarted {\n- phase: props.phase.clone(),\n- command_count: props.command_count as u64,\n- })\n- }\n- EventBody::DevcontainerLifecycleCompleted(props) => {\n- Some(ProgressEvent::DevcontainerLifecycleCompleted {\n- phase: props.phase.clone(),\n- duration_ms: props.duration_ms,\n- })\n- }\n- EventBody::DevcontainerLifecycleFailed(props) => {\n- Some(ProgressEvent::DevcontainerLifecycleFailed {\n- phase: props.phase.clone(),\n- command: props.command.clone(),\n- exit_code: i64::from(props.exit_code),\n- stderr: props.stderr.clone(),\n- })\n- }\n- EventBody::DevcontainerLifecycleCommandCompleted(props) => {\n- Some(ProgressEvent::DevcontainerLifecycleCommandCompleted {\n- command: props.command.clone(),\n- command_index: props.index as u64,\n- exit_code: i64::from(props.exit_code),\n- duration_ms: props.duration_ms,\n- })\n- }\n EventBody::StageStarted(_) => Some(ProgressEvent::StageStarted {\n node_id,\n name: node_label,\ndiff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs\nindex c6a03674a..75d7fc0ff 100644\n--- a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs\n+++ b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs\n@@ -195,55 +195,6 @@ impl ProgressUI {\n ProgressEvent::CliEnsureFailed { cli_name } => {\n self.setup.on_cli_ensure_failed(renderer, &cli_name);\n }\n- ProgressEvent::DevcontainerResolved {\n- dockerfile_lines,\n- environment_count,\n- lifecycle_command_count,\n- workspace_folder,\n- } => {\n- SetupDisplay::on_devcontainer_resolved(\n- renderer,\n- dockerfile_lines,\n- environment_count,\n- lifecycle_command_count,\n- &workspace_folder,\n- );\n- }\n- ProgressEvent::DevcontainerLifecycleStarted {\n- phase,\n- command_count,\n- } => {\n- self.setup\n- .on_devcontainer_lifecycle_started(renderer, &phase, command_count);\n- }\n- ProgressEvent::DevcontainerLifecycleCompleted { phase, duration_ms } => {\n- self.setup\n- .on_devcontainer_lifecycle_completed(renderer, &phase, duration_ms);\n- }\n- ProgressEvent::DevcontainerLifecycleFailed {\n- phase,\n- command,\n- exit_code,\n- stderr,\n- } => {\n- self.setup.on_devcontainer_lifecycle_failed(\n- renderer, &phase, &command, exit_code, &stderr,\n- );\n- }\n- ProgressEvent::DevcontainerLifecycleCommandCompleted {\n- command,\n- command_index,\n- exit_code,\n- duration_ms,\n- } => {\n- self.setup.on_devcontainer_lifecycle_command_completed(\n- renderer,\n- &command,\n- command_index,\n- exit_code,\n- duration_ms,\n- );\n- }\n ProgressEvent::StageStarted {\n node_id,\n name,\n@@ -810,21 +761,6 @@ mod tests {\n duration_ms: 2200,\n },\n Event::SetupCompleted { duration_ms: 2200 },\n- Event::DevcontainerLifecycleStarted {\n- phase: \"postCreate\".into(),\n- command_count: 1,\n- },\n- Event::DevcontainerLifecycleCommandCompleted {\n- phase: \"postCreate\".into(),\n- command: \"npm run setup\".into(),\n- index: 0,\n- exit_code: 0,\n- duration_ms: 1400,\n- },\n- Event::DevcontainerLifecycleCompleted {\n- phase: \"postCreate\".into(),\n- duration_ms: 1400,\n- },\n ];\n \n let (mut event_ui, event_buffer) = capture_ui(true);\n@@ -903,31 +839,12 @@ mod tests {\n duration_ms: 600,\n }),\n );\n- emit(&mut ui, Event::DevcontainerResolved {\n- dockerfile_lines: 24,\n- environment_count: 3,\n- lifecycle_command_count: 2,\n- workspace_folder: \"/workspace\".into(),\n- });\n- emit(&mut ui, Event::DevcontainerLifecycleStarted {\n- phase: \"postCreate\".into(),\n- command_count: 2,\n- });\n- emit(&mut ui, Event::DevcontainerLifecycleCompleted {\n- phase: \"postCreate\".into(),\n- duration_ms: 1800,\n- });\n-\n insta::assert_snapshot!(rendered(&buffer), @r\"\n Sandbox: daytona (ready in 2s)\n sandbox-1 (4 cpu, 8 GB)\n ssh daytona@example\n Setup: 2 commands (8s)\n CLI: gh (installed, 600ms)\n- Devcontainer: resolved\n- 24 Dockerfile lines, 3 env vars, 2 lifecycle cmds, /workspace\n- Running devcontainer postCreate (2 commands)...\n- Devcontainer: postCreate (1s)\n \");\n }\n \n@@ -1209,21 +1126,6 @@ mod tests {\n duration_ms: 2200,\n });\n emit(&mut ui, Event::SetupCompleted { duration_ms: 2200 });\n- emit(&mut ui, Event::DevcontainerLifecycleStarted {\n- phase: \"postCreate\".into(),\n- command_count: 1,\n- });\n- emit(&mut ui, Event::DevcontainerLifecycleCommandCompleted {\n- phase: \"postCreate\".into(),\n- command: \"npm run setup\".into(),\n- index: 0,\n- exit_code: 0,\n- duration_ms: 1400,\n- });\n- emit(&mut ui, Event::DevcontainerLifecycleCompleted {\n- phase: \"postCreate\".into(),\n- duration_ms: 1400,\n- });\n emit(&mut ui, stage_completed(\"code\", \"Code\"));\n \n insta::assert_snapshot!(rendered(&buffer), @r#\"\n@@ -1235,9 +1137,6 @@ mod tests {\n ✓ subagent[a1] (3 turns)\n ✓ [1/1] bun install 2s\n Setup: 1 command (2s)\n- Running devcontainer postCreate (1 commands)...\n- ✓ [1/1] npm run setup 1s\n- Devcontainer: postCreate (1s)\n ✓ Code 5s (1 turns, 0 tools, 1.5k toks)\n \"#);\n }\ndiff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/setup_display.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/setup_display.rs\nindex 1e0d980a8..ac509d948 100644\n--- a/lib/crates/fabro-cli/src/commands/run/run_progress/setup_display.rs\n+++ b/lib/crates/fabro-cli/src/commands/run/run_progress/setup_display.rs\n@@ -12,8 +12,6 @@ pub(super) struct SetupDisplay {\n pub(super) sandbox_bar: Option,\n pub(super) setup_bar: Option,\n pub(super) setup_command_count: u64,\n- pub(super) devcontainer_bar: Option,\n- pub(super) devcontainer_command_count: u64,\n pub(super) cli_ensure_bar: Option,\n }\n \n@@ -25,8 +23,6 @@ impl SetupDisplay {\n sandbox_bar: None,\n setup_bar: None,\n setup_command_count: 0,\n- devcontainer_bar: None,\n- devcontainer_command_count: 0,\n cli_ensure_bar: None,\n }\n }\n@@ -38,9 +34,6 @@ impl SetupDisplay {\n if let Some(bar) = self.setup_bar.take() {\n bar.finish_and_clear();\n }\n- if let Some(bar) = self.devcontainer_bar.take() {\n- bar.finish_and_clear();\n- }\n if let Some(bar) = self.cli_ensure_bar.take() {\n bar.finish_and_clear();\n }\n@@ -305,143 +298,6 @@ impl SetupDisplay {\n renderer.print_line(4, &message);\n }\n }\n-\n- pub(super) fn on_devcontainer_resolved(\n- renderer: &ProgressRenderer,\n- dockerfile_lines: u64,\n- environment_count: u64,\n- lifecycle_command_count: u64,\n- workspace_folder: &str,\n- ) {\n- let detail = format!(\n- \"{dockerfile_lines} Dockerfile lines, {environment_count} env vars, \\\n- {lifecycle_command_count} lifecycle cmds, {workspace_folder}\"\n- );\n-\n- if renderer.is_tty() {\n- let bar = renderer.add_spinner();\n- bar.set_style(styles::style_header_done());\n- bar.finish_with_message(\"Devcontainer: resolved\".to_string());\n- let detail_bar = renderer.insert_after(&bar);\n- detail_bar.set_style(styles::style_sandbox_detail());\n- detail_bar.finish_with_message(detail);\n- } else {\n- renderer.print_line(4, \"Devcontainer: resolved\");\n- renderer.print_line(13, &detail);\n- }\n- }\n-\n- pub(super) fn on_devcontainer_lifecycle_started(\n- &mut self,\n- renderer: &ProgressRenderer,\n- phase: &str,\n- command_count: u64,\n- ) {\n- self.devcontainer_command_count = command_count;\n-\n- if renderer.is_tty() {\n- let bar = renderer.add_spinner();\n- bar.set_style(styles::style_header_running());\n- bar.set_message(format!(\n- \"Running devcontainer {phase} ({command_count} commands)...\"\n- ));\n- bar.enable_steady_tick(Duration::from_millis(100));\n- self.devcontainer_bar = Some(bar);\n- } else {\n- renderer.print_line(\n- 4,\n- &format!(\"Running devcontainer {phase} ({command_count} commands)...\"),\n- );\n- }\n- }\n-\n- pub(super) fn on_devcontainer_lifecycle_completed(\n- &mut self,\n- renderer: &ProgressRenderer,\n- phase: &str,\n- duration_ms: u64,\n- ) {\n- let dur = format_duration_ms(duration_ms);\n-\n- if renderer.is_tty() {\n- if let Some(bar) = self.devcontainer_bar.take() {\n- bar.set_style(styles::style_header_done());\n- bar.set_prefix(dur);\n- bar.finish_with_message(format!(\"Devcontainer: {phase}\"));\n- }\n- } else {\n- renderer.print_line(4, &format!(\"Devcontainer: {phase} ({dur})\"));\n- }\n- }\n-\n- pub(super) fn on_devcontainer_lifecycle_failed(\n- &mut self,\n- renderer: &ProgressRenderer,\n- phase: &str,\n- command: &str,\n- exit_code: i64,\n- stderr: &str,\n- ) {\n- if let Some(bar) = self.devcontainer_bar.take() {\n- bar.abandon();\n- }\n-\n- let summary = if stderr.len() > 120 {\n- &stderr[..120]\n- } else {\n- stderr\n- };\n- let message = format!(\n- \"{} Devcontainer {phase} command failed (exit {exit_code}): {command}\\n {summary}\",\n- renderer.styles().red.apply_to(\"Error:\")\n- );\n-\n- if renderer.is_tty() {\n- let bar = renderer.add_spinner();\n- bar.set_style(styles::style_static_dim());\n- bar.finish_with_message(message);\n- } else {\n- renderer.print_line(4, &message);\n- }\n- }\n-\n- pub(super) fn on_devcontainer_lifecycle_command_completed(\n- &self,\n- renderer: &ProgressRenderer,\n- command: &str,\n- command_index: u64,\n- exit_code: i64,\n- duration_ms: u64,\n- ) {\n- if !self.verbose {\n- return;\n- }\n-\n- let glyph = if exit_code == 0 {\n- styles::green_check(renderer.styles())\n- } else {\n- styles::red_cross(renderer.styles())\n- };\n- let msg = format!(\n- \"{glyph} [{}/{}] {}\",\n- command_index + 1,\n- self.devcontainer_command_count,\n- styles::truncate(command, 60)\n- );\n- let dur = format_duration_ms(duration_ms);\n-\n- if renderer.is_tty() {\n- let bar = match &self.devcontainer_bar {\n- Some(devcontainer_bar) => renderer.insert_before(devcontainer_bar),\n- None => renderer.add_spinner(),\n- };\n- bar.set_style(styles::style_tool_done());\n- bar.set_prefix(dur);\n- bar.finish_with_message(msg);\n- } else {\n- renderer.print_line(6, &format!(\"{msg} {dur}\"));\n- }\n- }\n }\n \n fn initializing_message(provider: &str) -> String {\ndiff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs\nindex 3081e1ee3..9579f8e79 100644\n--- a/lib/crates/fabro-cli/src/main.rs\n+++ b/lib/crates/fabro-cli/src/main.rs\n@@ -53,7 +53,7 @@ async fn main() {\n \n // Capture the worker bearer token immediately and scrub it from the process\n // env before any subprocess can be spawned. Every descendant of the worker\n- // (hooks, sandbox commands, devcontainer setup, MCP stdio, etc.) therefore\n+ // (hooks, sandbox commands, MCP stdio, etc.) therefore\n // inherits a process env that no longer contains this credential, so an\n // unscrubbed spawn site cannot leak it. The token flows to `runner::execute`\n // through explicit function arguments instead of the environment.\ndiff --git a/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs b/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs\nindex 5a13fe29a..e79bffbd9 100644\n--- a/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs\n+++ b/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs\n@@ -50,7 +50,7 @@ fn sandbox_cp_run_without_sandbox_json_errors_cleanly() {\n exit_code: 1\n ----- stdout -----\n ----- stderr -----\n- × run sandbox missing runtime metadata\n+ × Run sandbox was not created.\n \");\n }\n \ndiff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs\nindex 9ac2e7673..2cc3e7c1f 100644\n--- a/lib/crates/fabro-cli/tests/it/cmd/support.rs\n+++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs\n@@ -1816,7 +1816,7 @@ pub(crate) fn compact_inspect(output: &Output) -> Value {\n }),\n \"sandbox\": sandbox.as_object().map(|_| {\n serde_json::json!({\n- \"provider\": sandbox[\"provider\"],\n+ \"provider\": compact_sandbox_provider(&sandbox),\n })\n }),\n })\n@@ -1879,7 +1879,7 @@ pub(crate) fn compact_git_inspect(output: &Output) -> Value {\n }),\n \"sandbox\": sandbox.as_object().map(|_| {\n serde_json::json!({\n- \"provider\": sandbox[\"provider\"],\n+ \"provider\": compact_sandbox_provider(&sandbox),\n \"working_directory\": \"[WORKTREE]\",\n })\n }),\n@@ -1889,6 +1889,16 @@ pub(crate) fn compact_git_inspect(output: &Output) -> Value {\n )\n }\n \n+fn compact_sandbox_provider(sandbox: &Value) -> Value {\n+ sandbox\n+ .pointer(\"/instance/provider\")\n+ .or_else(|| sandbox.pointer(\"/plan/provider\"))\n+ .or_else(|| sandbox.pointer(\"/failure/provider\"))\n+ .or_else(|| sandbox.get(\"provider\"))\n+ .cloned()\n+ .unwrap_or(Value::Null)\n+}\n+\n fn write_text_file(path: &Path, content: &str) {\n if let Some(parent) = path.parent() {\n std::fs::create_dir_all(parent)\ndiff --git a/lib/crates/fabro-devcontainer/Cargo.toml b/lib/crates/fabro-devcontainer/Cargo.toml\ndeleted file mode 100644\nindex 5139fab07..000000000\n--- a/lib/crates/fabro-devcontainer/Cargo.toml\n+++ /dev/null\n@@ -1,29 +0,0 @@\n-[package]\n-name = \"fabro-devcontainer\"\n-edition.workspace = true\n-version.workspace = true\n-publish = false\n-license.workspace = true\n-description = \"Parse and resolve devcontainer.json into Dockerfiles and lifecycle hooks\"\n-\n-[lib]\n-doctest = false\n-\n-[lints]\n-workspace = true\n-\n-[dependencies]\n-fabro-static.workspace = true\n-fabro-util = { path = \"../fabro-util\" }\n-fabro-http.workspace = true\n-serde = { workspace = true }\n-serde_json = { workspace = true }\n-serde_yaml = \"0.9\"\n-thiserror = { workspace = true }\n-tracing = { workspace = true }\n-tokio = { workspace = true }\n-\n-[dev-dependencies]\n-insta = { workspace = true }\n-tokio = { workspace = true, features = [\"test-util\", \"macros\"] }\n-tempfile = \"3\"\ndiff --git a/lib/crates/fabro-devcontainer/DEVCONTAINER-COMPATIBILITY.md b/lib/crates/fabro-devcontainer/DEVCONTAINER-COMPATIBILITY.md\ndeleted file mode 100644\nindex 39850d175..000000000\n--- a/lib/crates/fabro-devcontainer/DEVCONTAINER-COMPATIBILITY.md\n+++ /dev/null\n@@ -1,121 +0,0 @@\n-# Devcontainer Spec Compatibility Matrix\n-\n-Compatibility of `fabro-devcontainer` with the [devcontainer.json reference](https://containers.dev/implementors/json_reference/).\n-\n-**Legend**: Yes = fully supported, Partial = parsed but incomplete, No = not supported, Planned = intended for future\n-\n-## General\n-\n-| Property | Status | Notes |\n-|---|---|---|\n-| `name` | No | Silently ignored by serde (unknown fields are skipped); not exposed in `DevcontainerConfig` |\n-| `forwardPorts` | Yes | Numeric and string formats (e.g., `\"8080:80\"`, `\"9090\"`) extracted into `DevcontainerConfig::forwarded_ports`; merged with compose ports in compose mode |\n-| `portsAttributes` | No | Not parsed |\n-| `otherPortsAttributes` | No | Not parsed |\n-| `updateRemoteUserUID` | No | Not parsed |\n-| `containerEnv` | Yes | Baked into generated Dockerfile as `ENV` directives; also exposed in `DevcontainerConfig::container_env` |\n-| `remoteEnv` | Yes | Merged into `DevcontainerConfig::environment` with variable substitution |\n-| `containerUser` | No | Parsed but unused; not exposed in `DevcontainerConfig` |\n-| `remoteUser` | Yes | Exposed as `DevcontainerConfig::remote_user` |\n-| `userEnvProbe` | No | Not parsed |\n-| `overrideCommand` | No | Parsed but unused |\n-| `shutdownAction` | No | Not parsed |\n-\n-## Image\n-\n-| Property | Status | Notes |\n-|---|---|---|\n-| `image` | Yes | Used as `FROM` line when no Dockerfile is specified; defaults to `mcr.microsoft.com/devcontainers/base:ubuntu` |\n-\n-## Build (Dockerfile)\n-\n-| Property | Status | Notes |\n-|---|---|---|\n-| `build.dockerfile` | Yes | Resolved relative to devcontainer.json; content read and used as base Dockerfile |\n-| `build.context` | Yes | Resolved with variable substitution; passed as `DevcontainerConfig::build_context` |\n-| `build.args` | Yes | Parsed and exposed in `DevcontainerConfig::build_args` for passing to `docker build --build-arg` |\n-| `build.target` | Yes | Parsed with variable substitution; exposed as `DevcontainerConfig::build_target` for passing to `docker build --target` |\n-| `build.cacheFrom` | No | Not parsed |\n-| `build.options` | No | Not parsed |\n-\n-## Compose\n-\n-| Property | Status | Notes |\n-|---|---|---|\n-| `dockerComposeFile` | Yes | Single path and array of paths supported; multiple files are merged (last wins for image/build/user; ports accumulate; environment overrides) |\n-| `service` | Yes | Required when `dockerComposeFile` is set; used to extract service config |\n-| `runServices` | No | Not parsed; all services assumed |\n-| `shutdownAction` | No | Not parsed |\n-| `overrideCommand` | No | Parsed but unused |\n-| `workspaceFolder` | Yes | Defaults to `/workspaces/{repo-name}` |\n-| `workspaceMount` | No | Parsed but unused |\n-\n-## Features\n-\n-| Property | Status | Notes |\n-|---|---|---|\n-| `features` | Yes | Fetched via `oras` CLI (OCI refs), local path copy (`./`/`../`), or HTTPS download. Topologically sorted by `installsAfter` and `dependsOn`. Dockerfile layers generated with options as env vars |\n-| `features` (reference types) | Yes | OCI registry refs (default), local paths (`./feature`), and HTTPS URLs (`https://...feature.tgz`) |\n-| Feature `dependsOn` | Yes | Hard dependencies auto-installed if missing; used for topological ordering alongside `installsAfter` |\n-| Feature `containerEnv` | Yes | Collected from each feature in install order; merged with devcontainer.json `containerEnv` (devcontainer.json wins on conflicts) |\n-| Feature `onCreateCommand` | Yes | Collected in install order and appended after devcontainer.json `onCreateCommand` |\n-| Feature `postCreateCommand` | Yes | Collected in install order and appended after devcontainer.json `postCreateCommand` |\n-| Feature `postStartCommand` | Yes | Collected in install order and appended after devcontainer.json `postStartCommand` |\n-| `overrideFeatureInstallOrder` | No | Not parsed |\n-\n-## Lifecycle\n-\n-| Property | Status | Notes |\n-|---|---|---|\n-| `initializeCommand` | Yes | All three forms supported: string, array, object (parallel). Exposed as `DevcontainerConfig::initialize_commands` |\n-| `onCreateCommand` | Yes | All three forms supported. Exposed as `DevcontainerConfig::on_create_commands` |\n-| `updateContentCommand` | No | Not parsed |\n-| `postCreateCommand` | Yes | All three forms supported. Exposed as `DevcontainerConfig::post_create_commands` |\n-| `postStartCommand` | Yes | All three forms supported. Exposed as `DevcontainerConfig::post_start_commands` |\n-| `postAttachCommand` | No | Not parsed |\n-| `waitFor` | No | Not parsed |\n-\n-## Host\n-\n-| Property | Status | Notes |\n-|---|---|---|\n-| `hostRequirements` | No | Not parsed |\n-| `init` | No | Not parsed |\n-| `privileged` | No | Not parsed |\n-| `capAdd` | No | Not parsed |\n-| `securityOpt` | No | Not parsed |\n-| `mounts` | No | Not parsed |\n-| `gpuRequest` | No | Not parsed |\n-\n-## Customizations\n-\n-| Property | Status | Notes |\n-|---|---|---|\n-| `customizations` | No | Unknown fields are silently ignored by serde, so `customizations` is accepted but not processed |\n-\n-## Variables\n-\n-| Variable | Status | Notes |\n-|---|---|---|\n-| `${localWorkspaceFolder}` | Yes | Substituted via `VariableContext` |\n-| `${localWorkspaceFolderBasename}` | Yes | Substituted via `VariableContext` |\n-| `${containerWorkspaceFolder}` | Yes | Substituted via `VariableContext` |\n-| `${containerWorkspaceFolderBasename}` | Yes | Derived from `containerWorkspaceFolder` by splitting on `/` |\n-| `${localEnv:VAR}` | Yes | Reads from host environment; supports `:default` syntax |\n-| `${containerEnv:VAR}` | No | Not implemented (requires running container) |\n-| `${devcontainerId}` | No | Not implemented |\n-\n-## JSONC Support\n-\n-The parser supports JSONC (JSON with Comments):\n-- Line comments (`//`)\n-- Block comments (`/* */`)\n-- Trailing commas before `}` and `]`\n-\n-## File Discovery\n-\n-Searched in order:\n-1. `/.devcontainer/devcontainer.json`\n-2. `/.devcontainer.json`\n-3. Direct path if it ends in `devcontainer.json`\n-4. Subdirectory format: `/.devcontainer//devcontainer.json` — subdirectories sorted alphabetically, first match used\ndiff --git a/lib/crates/fabro-devcontainer/INTEGRATION.md b/lib/crates/fabro-devcontainer/INTEGRATION.md\ndeleted file mode 100644\nindex 2aec58188..000000000\n--- a/lib/crates/fabro-devcontainer/INTEGRATION.md\n+++ /dev/null\n@@ -1,191 +0,0 @@\n-# Integrating DevcontainerConfig with DaytonaSandbox\n-\n-How to wire the parsed `DevcontainerConfig` into sandbox creation.\n-\n-## Overview\n-\n-`DevcontainerResolver::resolve(repo_path)` reads a repository's devcontainer.json and produces a `DevcontainerConfig` containing everything needed to build and configure a sandbox:\n-\n-```rust\n-pub struct DevcontainerConfig {\n- pub dockerfile: String, // Generated Dockerfile content\n- pub build_context: PathBuf, // Directory for docker build\n- pub build_args: HashMap, // docker build --build-arg flags\n- pub build_target: Option, // docker build --target\n- pub initialize_commands: Vec, // Host-side pre-build commands\n- pub on_create_commands: Vec, // Container after first creation\n- pub post_create_commands: Vec, // Container post-creation setup\n- pub post_start_commands: Vec, // Container on-each-start commands\n- pub environment: HashMap, // remoteEnv merged\n- pub container_env: HashMap, // containerEnv (also in Dockerfile)\n- pub remote_user: Option, // Non-root user\n- pub workspace_folder: String, // Working directory inside container\n- pub forwarded_ports: Vec, // Ports to expose\n- pub compose_files: Vec, // Compose file paths (empty if not compose mode)\n- pub compose_service: Option,\n-}\n-```\n-\n-## Mapping Devcontainer to Daytona\n-\n-### Dockerfile and Image Build\n-\n-`config.dockerfile` contains the full Dockerfile content (not a path). For image-only configs, this is a single `FROM` line. For Dockerfile configs, it is the file content with feature layers appended.\n-\n-- Build a Docker image from `config.dockerfile` using `config.build_context` as the build context directory.\n-- Use this image as the Daytona sandbox snapshot/base image.\n-\n-### Environment Variables\n-\n-`config.environment` contains the `remoteEnv` values (with variables already substituted). These are runtime-only environment variables, not baked into the Dockerfile. `config.container_env` contains `containerEnv` values (baked into the generated Dockerfile as `ENV` directives).\n-\n-- Pass `config.environment` as runtime environment variables when starting the sandbox.\n-- `containerEnv` values are already in the Dockerfile; `config.container_env` is available for reference.\n-\n-### Workspace Folder\n-\n-`config.workspace_folder` defaults to `/workspaces/{repo-name}`.\n-\n-- Set this as the sandbox working directory.\n-- Mount or clone the repository into this path.\n-\n-### Remote User\n-\n-`config.remote_user` specifies the non-root user for running dev tools.\n-\n-- Use this as the sandbox exec user when running lifecycle commands and user sessions.\n-- Falls back to root if not set.\n-\n-### Forwarded Ports\n-\n-`config.forwarded_ports` lists ports to expose (first port = default preview).\n-\n-- Use the first port as the default preview URL for the sandbox.\n-- Forward all listed ports from the sandbox to the user.\n-\n-## Docker Compose DinD Flow\n-\n-When `config.compose_files` is non-empty, the devcontainer uses Docker Compose mode.\n-\n-### Strategy\n-\n-Run Docker-in-Docker (DinD) inside the Daytona sandbox:\n-\n-1. Create a sandbox using the extracted Dockerfile from the compose service.\n-2. Install Docker daemon inside the sandbox (or use a DinD-capable base image).\n-3. Copy the compose file and related context into the sandbox.\n-4. Run `docker compose up` inside the sandbox to start all services.\n-5. The compose service ports become available on localhost inside the sandbox.\n-6. Forward those ports from the sandbox to the user.\n-\n-### Port Forwarding\n-\n-Ports come from the compose service's `ports` configuration (parsed by `compose::parse_compose`). The compose parser extracts container-side ports from formats like `\"8080:80\"`, `\"3000\"`, and `5432`.\n-\n-## Lifecycle Hook Execution Order\n-\n-The devcontainer spec defines this execution order:\n-\n-| Hook | Where | When | `DevcontainerConfig` field |\n-|---|---|---|---|\n-| `initializeCommand` | Host | Before build | `initialize_commands` |\n-| `onCreateCommand` | Container | After first creation | `on_create_commands` |\n-| `updateContentCommand` | Container | After create/content update | Not captured (not parsed) |\n-| `postCreateCommand` | Container | After create/content update | `post_create_commands` |\n-| `postStartCommand` | Container | On each start | `post_start_commands` |\n-| `postAttachCommand` | Container | On each attach | Not captured (not parsed) |\n-\n-### Command Types\n-\n-Each command is represented as a `Command` enum:\n-\n-```rust\n-pub enum Command {\n- Shell(String), // \"npm install\"\n- Args(Vec), // [\"npm\", \"install\"]\n- Parallel(HashMap), // {\"install\": \"npm install\", \"build\": \"npm run build\"}\n-}\n-```\n-\n-- `Shell` -- execute via `sh -c \"\"`\n-- `Args` -- execute directly as argv\n-- `Parallel` -- execute all values concurrently, wait for all to complete\n-\n-### Execution in Sandbox\n-\n-```\n-1. Run initialize_commands on HOST (before sandbox creation)\n-2. Build image from config.dockerfile (pass config.build_args as --build-arg flags)\n-3. Create sandbox from image\n-4. Run on_create_commands in sandbox (as remote_user if set)\n-5. Run post_create_commands in sandbox (as remote_user if set)\n-6. Run post_start_commands in sandbox (as remote_user if set)\n-```\n-\n-## Example Integration Code\n-\n-```rust\n-use arc_devcontainer::{DevcontainerResolver, DevcontainerConfig, Command};\n-\n-async fn create_sandbox_from_devcontainer(repo_path: &Path) -> Result {\n- let config = DevcontainerResolver::resolve(repo_path).await?;\n-\n- // 1. Run host-side init commands\n- for cmd in &config.initialize_commands {\n- run_host_command(cmd).await?;\n- }\n-\n- // 2. Build image and create sandbox\n- let sandbox = if !config.compose_files.is_empty() {\n- // Compose mode: build from extracted service Dockerfile, then run compose inside\n- let sandbox = daytona.create_from_dockerfile(\n- &config.dockerfile,\n- &config.build_context,\n- ).await?;\n- setup_dind(&sandbox).await?;\n- sandbox.exec(\"docker compose up -d\").await?;\n- sandbox\n- } else {\n- // Image/Dockerfile mode: build directly\n- daytona.create_from_dockerfile(\n- &config.dockerfile,\n- &config.build_context,\n- ).await?\n- };\n-\n- // 3. Configure environment\n- for (key, value) in &config.environment {\n- sandbox.set_env(key, value).await?;\n- }\n-\n- // 4. Set working directory\n- sandbox.set_workdir(&config.workspace_folder).await?;\n-\n- // 5. Run lifecycle hooks\n- let user = config.remote_user.as_deref();\n- for cmd in &config.on_create_commands {\n- sandbox.exec_command(cmd, user).await?;\n- }\n- for cmd in &config.post_create_commands {\n- sandbox.exec_command(cmd, user).await?;\n- }\n- for cmd in &config.post_start_commands {\n- sandbox.exec_command(cmd, user).await?;\n- }\n-\n- // 6. Set up port forwarding\n- if let Some(port) = config.forwarded_ports.first() {\n- sandbox.set_preview_port(*port).await?;\n- }\n-\n- Ok(sandbox)\n-}\n-```\n-\n-## Edge Cases and Limitations\n-\n-- **Features require `oras`**: Feature resolution shells out to `oras` CLI for OCI registry pulls. The resolver attempts auto-install if `oras` is not on PATH.\n-- **No `updateContentCommand`**: This lifecycle hook is not parsed.\n-- **No `postAttachCommand`**: Not parsed. Attach-time hooks would need to run on each user session connection.\n-- **`${containerEnv:VAR}` not supported**: Variable substitution only covers host-side variables. Container-side env vars require a running container.\n-- **Port forwarding**: Both numeric and string port formats (e.g., `\"8080:80\"`, `\"9090\"`) are supported in `forwardPorts`. In compose mode, `forwardPorts` are merged with compose service ports.\ndiff --git a/lib/crates/fabro-devcontainer/src/compose.rs b/lib/crates/fabro-devcontainer/src/compose.rs\ndeleted file mode 100644\nindex 8b4a5a95f..000000000\n--- a/lib/crates/fabro-devcontainer/src/compose.rs\n+++ /dev/null\n@@ -1,437 +0,0 @@\n-use std::collections::HashMap;\n-use std::path::{Path, PathBuf};\n-\n-use tokio::fs;\n-\n-/// Extracted configuration from a Docker Compose service.\n-#[derive(Debug, Clone, Default)]\n-pub(crate) struct ComposeServiceSpec {\n- pub image: Option,\n- pub build: Option,\n- pub ports: Vec,\n- pub environment: HashMap,\n- pub user: Option,\n-}\n-\n-/// Build configuration from a Docker Compose service.\n-#[derive(Debug, Clone)]\n-pub(crate) struct ComposeBuild {\n- pub context: String,\n- pub dockerfile: Option,\n-}\n-\n-/// Parse a Docker Compose file and extract config for the named service.\n-pub(crate) async fn parse_compose(\n- compose_path: &Path,\n- service_name: &str,\n-) -> Result {\n- let contents = fs::read_to_string(compose_path).await.map_err(|e| {\n- format!(\n- \"failed to read compose file {}: {e}\",\n- compose_path.display()\n- )\n- })?;\n-\n- let doc: serde_yaml::Value = serde_yaml::from_str(&contents)\n- .map_err(|e| format!(\"failed to parse YAML {}: {e}\", compose_path.display()))?;\n-\n- let service = doc\n- .get(\"services\")\n- .and_then(|s| s.get(service_name))\n- .ok_or_else(|| format!(\"service '{service_name}' not found in compose file\"))?;\n-\n- let image = service\n- .get(\"image\")\n- .and_then(|v| v.as_str())\n- .map(String::from);\n-\n- let build = parse_build(service);\n- let ports = parse_ports(service);\n- let environment = parse_environment(service);\n-\n- let user = service\n- .get(\"user\")\n- .and_then(|v| v.as_str())\n- .map(String::from);\n-\n- Ok(ComposeServiceSpec {\n- image,\n- build,\n- ports,\n- environment,\n- user,\n- })\n-}\n-\n-fn parse_build(service: &serde_yaml::Value) -> Option {\n- let build_val = service.get(\"build\")?;\n-\n- if let Some(context) = build_val.as_str() {\n- return Some(ComposeBuild {\n- context: context.to_string(),\n- dockerfile: None,\n- });\n- }\n-\n- if build_val.is_mapping() {\n- let context = build_val\n- .get(\"context\")\n- .and_then(|v| v.as_str())\n- .unwrap_or(\".\")\n- .to_string();\n- let dockerfile = build_val\n- .get(\"dockerfile\")\n- .and_then(|v| v.as_str())\n- .map(String::from);\n- return Some(ComposeBuild {\n- context,\n- dockerfile,\n- });\n- }\n-\n- None\n-}\n-\n-fn parse_ports(service: &serde_yaml::Value) -> Vec {\n- let Some(ports_val) = service.get(\"ports\") else {\n- return Vec::new();\n- };\n- let Some(ports_seq) = ports_val.as_sequence() else {\n- return Vec::new();\n- };\n-\n- ports_seq\n- .iter()\n- .filter_map(|entry| {\n- if let Some(n) = entry.as_u64() {\n- return u16::try_from(n).ok();\n- }\n- if let Some(s) = entry.as_str() {\n- // Formats: \"8080:80\", \"3000\", \"8080:80/tcp\"\n- let s = s.split('/').next().unwrap_or(s); // strip protocol\n- return if let Some((_host, container)) = s.split_once(':') {\n- container.parse::().ok()\n- } else {\n- s.parse::().ok()\n- };\n- }\n- None\n- })\n- .collect()\n-}\n-\n-fn parse_environment(service: &serde_yaml::Value) -> HashMap {\n- let Some(env_val) = service.get(\"environment\") else {\n- return HashMap::new();\n- };\n-\n- // Array form: [\"KEY=VALUE\", ...]\n- if let Some(seq) = env_val.as_sequence() {\n- return seq\n- .iter()\n- .filter_map(|v| {\n- let s = v.as_str()?;\n- let (key, value) = s.split_once('=')?;\n- Some((key.to_string(), value.to_string()))\n- })\n- .collect();\n- }\n-\n- // Object form: { KEY: VALUE, ... }\n- if let Some(mapping) = env_val.as_mapping() {\n- return mapping\n- .iter()\n- .filter_map(|(k, v)| {\n- let key = k.as_str()?.to_string();\n- let value = match v {\n- serde_yaml::Value::String(s) => s.clone(),\n- serde_yaml::Value::Number(n) => n.to_string(),\n- serde_yaml::Value::Bool(b) => b.to_string(),\n- serde_yaml::Value::Null => String::new(),\n- _ => return None,\n- };\n- Some((key, value))\n- })\n- .collect();\n- }\n-\n- HashMap::new()\n-}\n-\n-/// Parse multiple Docker Compose files and merge config for the named service.\n-/// Later files override earlier files for image/build/user; ports accumulate\n-/// (deduped); environment keys from later files override earlier ones.\n-pub(crate) async fn parse_compose_multi(\n- compose_paths: &[PathBuf],\n- service_name: &str,\n-) -> Result {\n- let mut merged = ComposeServiceSpec::default();\n- let mut found_service = false;\n-\n- for path in compose_paths {\n- let contents = fs::read_to_string(path)\n- .await\n- .map_err(|e| format!(\"failed to read compose file {}: {e}\", path.display()))?;\n-\n- let doc: serde_yaml::Value = serde_yaml::from_str(&contents)\n- .map_err(|e| format!(\"failed to parse YAML {}: {e}\", path.display()))?;\n-\n- let Some(service) = doc.get(\"services\").and_then(|s| s.get(service_name)) else {\n- continue;\n- };\n- found_service = true;\n-\n- if let Some(image) = service.get(\"image\").and_then(|v| v.as_str()) {\n- merged.image = Some(image.to_string());\n- }\n-\n- if let Some(build) = parse_build(service) {\n- merged.build = Some(build);\n- }\n-\n- if let Some(user) = service.get(\"user\").and_then(|v| v.as_str()) {\n- merged.user = Some(user.to_string());\n- }\n-\n- for port in parse_ports(service) {\n- if !merged.ports.contains(&port) {\n- merged.ports.push(port);\n- }\n- }\n-\n- for (k, v) in parse_environment(service) {\n- merged.environment.insert(k, v);\n- }\n- }\n-\n- if !found_service {\n- return Err(format!(\n- \"service '{service_name}' not found in any compose file\"\n- ));\n- }\n-\n- Ok(merged)\n-}\n-\n-#[cfg(test)]\n-#[expect(\n- clippy::disallowed_types,\n- reason = \"test helpers write compose fixtures to temp files via sync std::io\"\n-)]\n-mod tests {\n- use std::io::Write;\n-\n- use super::*;\n-\n- fn write_compose(content: &str) -> tempfile::NamedTempFile {\n- let mut f = tempfile::NamedTempFile::new().unwrap();\n- f.write_all(content.as_bytes()).unwrap();\n- f\n- }\n-\n- #[tokio::test]\n- async fn service_with_image_only() {\n- let f = write_compose(\n- r\"\n-services:\n- web:\n- image: nginx:latest\n-\",\n- );\n- let cfg = parse_compose(f.path(), \"web\").await.unwrap();\n- assert_eq!(cfg.image.as_deref(), Some(\"nginx:latest\"));\n- assert!(cfg.build.is_none());\n- assert!(cfg.ports.is_empty());\n- assert!(cfg.environment.is_empty());\n- assert!(cfg.user.is_none());\n- }\n-\n- #[tokio::test]\n- async fn service_with_build_string() {\n- let f = write_compose(\n- r\"\n-services:\n- app:\n- build: ./src\n-\",\n- );\n- let cfg = parse_compose(f.path(), \"app\").await.unwrap();\n- let build = cfg.build.unwrap();\n- assert_eq!(build.context, \"./src\");\n- assert!(build.dockerfile.is_none());\n- }\n-\n- #[tokio::test]\n- async fn service_with_build_object() {\n- let f = write_compose(\n- r\"\n-services:\n- app:\n- build:\n- context: ./app\n- dockerfile: Dockerfile.dev\n-\",\n- );\n- let cfg = parse_compose(f.path(), \"app\").await.unwrap();\n- let build = cfg.build.unwrap();\n- assert_eq!(build.context, \"./app\");\n- assert_eq!(build.dockerfile.as_deref(), Some(\"Dockerfile.dev\"));\n- }\n-\n- #[tokio::test]\n- async fn ports_various_formats() {\n- let f = write_compose(\n- r#\"\n-services:\n- web:\n- image: nginx\n- ports:\n- - \"8080:80\"\n- - \"3000\"\n- - 5432\n- - \"9090:9090/tcp\"\n-\"#,\n- );\n- let cfg = parse_compose(f.path(), \"web\").await.unwrap();\n- assert_eq!(cfg.ports, vec![80, 3000, 5432, 9090]);\n- }\n-\n- #[tokio::test]\n- async fn environment_as_array() {\n- let f = write_compose(\n- r#\"\n-services:\n- app:\n- image: myapp\n- environment:\n- - \"DATABASE_URL=postgres://localhost/db\"\n- - \"DEBUG=true\"\n-\"#,\n- );\n- let cfg = parse_compose(f.path(), \"app\").await.unwrap();\n- assert_eq!(cfg.environment.len(), 2);\n- assert_eq!(cfg.environment[\"DATABASE_URL\"], \"postgres://localhost/db\");\n- assert_eq!(cfg.environment[\"DEBUG\"], \"true\");\n- }\n-\n- #[tokio::test]\n- async fn environment_as_object() {\n- let f = write_compose(\n- r\"\n-services:\n- app:\n- image: myapp\n- environment:\n- RAILS_ENV: production\n- PORT: 3000\n-\",\n- );\n- let cfg = parse_compose(f.path(), \"app\").await.unwrap();\n- assert_eq!(cfg.environment.len(), 2);\n- assert_eq!(cfg.environment[\"RAILS_ENV\"], \"production\");\n- assert_eq!(cfg.environment[\"PORT\"], \"3000\");\n- }\n-\n- #[tokio::test]\n- async fn service_not_found() {\n- let f = write_compose(\n- r\"\n-services:\n- web:\n- image: nginx\n-\",\n- );\n- let err = parse_compose(f.path(), \"missing\").await.unwrap_err();\n- assert!(err.contains(\"service 'missing' not found\"));\n- }\n-\n- #[tokio::test]\n- async fn file_not_found() {\n- let err = parse_compose(Path::new(\"/nonexistent/docker-compose.yml\"), \"web\")\n- .await\n- .unwrap_err();\n- assert!(err.contains(\"failed to read compose file\"));\n- }\n-\n- #[tokio::test]\n- async fn service_with_user() {\n- let f = write_compose(\n- r#\"\n-services:\n- app:\n- image: myapp\n- user: \"1000:1000\"\n-\"#,\n- );\n- let cfg = parse_compose(f.path(), \"app\").await.unwrap();\n- assert_eq!(cfg.user.as_deref(), Some(\"1000:1000\"));\n- }\n-\n- #[tokio::test]\n- async fn multi_compose_merge() {\n- let base = write_compose(\n- r#\"\n-services:\n- app:\n- image: node:20\n- ports:\n- - \"3000:3000\"\n- environment:\n- - \"NODE_ENV=development\"\n-\"#,\n- );\n- let over = write_compose(\n- r#\"\n-services:\n- app:\n- image: node:22\n- ports:\n- - \"3000:3000\"\n- - \"9229:9229\"\n- environment:\n- - \"DEBUG=true\"\n-\"#,\n- );\n- let paths = vec![base.path().to_path_buf(), over.path().to_path_buf()];\n- let cfg = parse_compose_multi(&paths, \"app\").await.unwrap();\n- assert_eq!(cfg.image.as_deref(), Some(\"node:22\"));\n- assert_eq!(cfg.ports, vec![3000, 9229]);\n- assert_eq!(cfg.environment[\"NODE_ENV\"], \"development\");\n- assert_eq!(cfg.environment[\"DEBUG\"], \"true\");\n- }\n-\n- #[tokio::test]\n- async fn multi_compose_service_not_found() {\n- let f = write_compose(\n- r\"\n-services:\n- web:\n- image: nginx\n-\",\n- );\n- let paths = vec![f.path().to_path_buf()];\n- let err = parse_compose_multi(&paths, \"missing\").await.unwrap_err();\n- assert!(err.contains(\"service 'missing' not found\"));\n- }\n-\n- #[tokio::test]\n- async fn multi_compose_skips_file_without_service() {\n- let base = write_compose(\n- r\"\n-services:\n- db:\n- image: postgres:15\n-\",\n- );\n- let over = write_compose(\n- r\"\n-services:\n- app:\n- image: node:22\n-\",\n- );\n- let paths = vec![base.path().to_path_buf(), over.path().to_path_buf()];\n- let cfg = parse_compose_multi(&paths, \"app\").await.unwrap();\n- assert_eq!(cfg.image.as_deref(), Some(\"node:22\"));\n- }\n-}\ndiff --git a/lib/crates/fabro-devcontainer/src/dockerfile.rs b/lib/crates/fabro-devcontainer/src/dockerfile.rs\ndeleted file mode 100644\nindex 24e11268b..000000000\n--- a/lib/crates/fabro-devcontainer/src/dockerfile.rs\n+++ /dev/null\n@@ -1,221 +0,0 @@\n-use std::collections::HashMap;\n-\n-use crate::features::FeatureLayer;\n-\n-/// Generate a combined Dockerfile from base + features + env + user.\n-pub(crate) fn generate(\n- base_dockerfile: &str,\n- feature_layers: &[FeatureLayer],\n- container_env: &HashMap,\n- remote_user: Option<&str>,\n-) -> String {\n- let mut sections: Vec = Vec::new();\n-\n- sections.push(\"# Generated by fabro-devcontainer\".to_string());\n- sections.push(base_dockerfile.to_string());\n-\n- for layer in feature_layers {\n- sections.push(layer.dockerfile_snippet.clone());\n- }\n-\n- if !container_env.is_empty() {\n- let mut keys: Vec<&String> = container_env.keys().collect();\n- keys.sort();\n- let env_lines: Vec = keys\n- .iter()\n- .map(|k| format!(\"ENV {}={}\", k, container_env[*k]))\n- .collect();\n- sections.push(env_lines.join(\"\\n\"));\n- }\n-\n- if let Some(user) = remote_user {\n- sections.push(format!(\"USER {user}\"));\n- }\n-\n- let mut result = sections.join(\"\\n\\n\");\n- result.push('\\n');\n- result\n-}\n-\n-#[cfg(test)]\n-mod tests {\n- use super::*;\n-\n- fn make_layer(id: &str, dir_name: &str, snippet: &str) -> FeatureLayer {\n- FeatureLayer {\n- id: id.to_string(),\n- dir_name: dir_name.to_string(),\n- dockerfile_snippet: snippet.to_string(),\n- }\n- }\n-\n- #[test]\n- fn base_image_only() {\n- let result = generate(\"FROM ubuntu:22.04\", &[], &HashMap::new(), None);\n- insta::assert_snapshot!(result, @r\"\n- # Generated by fabro-devcontainer\n-\n- FROM ubuntu:22.04\n- \");\n- }\n-\n- #[test]\n- fn base_dockerfile_preserved_as_is() {\n- let base = \"FROM ubuntu:22.04\\nRUN apt-get update\\nRUN apt-get install -y curl\";\n- let result = generate(base, &[], &HashMap::new(), None);\n- insta::assert_snapshot!(result, @r\"\n- # Generated by fabro-devcontainer\n-\n- FROM ubuntu:22.04\n- RUN apt-get update\n- RUN apt-get install -y curl\n- \");\n- }\n-\n- #[test]\n- fn with_feature_layers() {\n- let layers = vec![\n- make_layer(\"node\", \"node-1\", \"RUN install-node.sh\"),\n- make_layer(\"python\", \"python-1\", \"RUN install-python.sh\"),\n- ];\n- let result = generate(\"FROM ubuntu:22.04\", &layers, &HashMap::new(), None);\n- insta::assert_snapshot!(result, @r\"\n- # Generated by fabro-devcontainer\n-\n- FROM ubuntu:22.04\n-\n- RUN install-node.sh\n-\n- RUN install-python.sh\n- \");\n- }\n-\n- #[test]\n- fn with_env_sorted() {\n- let mut env = HashMap::new();\n- env.insert(\"ZEBRA\".to_string(), \"stripes\".to_string());\n- env.insert(\"APPLE\".to_string(), \"red\".to_string());\n- env.insert(\"MANGO\".to_string(), \"yellow\".to_string());\n- let result = generate(\"FROM alpine\", &[], &env, None);\n- insta::assert_snapshot!(result, @r\"\n- # Generated by fabro-devcontainer\n-\n- FROM alpine\n-\n- ENV APPLE=red\n- ENV MANGO=yellow\n- ENV ZEBRA=stripes\n- \");\n- }\n-\n- #[test]\n- fn with_remote_user() {\n- let result = generate(\"FROM alpine\", &[], &HashMap::new(), Some(\"vscode\"));\n- insta::assert_snapshot!(result, @r\"\n- # Generated by fabro-devcontainer\n-\n- FROM alpine\n-\n- USER vscode\n- \");\n- }\n-\n- #[test]\n- fn all_combined() {\n- let layers = vec![make_layer(\"node\", \"node-1\", \"RUN install-node.sh\")];\n- let mut env = HashMap::new();\n- env.insert(\"PATH\".to_string(), \"/usr/local/bin\".to_string());\n- env.insert(\"HOME\".to_string(), \"/home/vscode\".to_string());\n- let result = generate(\"FROM ubuntu:22.04\", &layers, &env, Some(\"vscode\"));\n- insta::assert_snapshot!(result, @r\"\n- # Generated by fabro-devcontainer\n-\n- FROM ubuntu:22.04\n-\n- RUN install-node.sh\n-\n- ENV HOME=/home/vscode\n- ENV PATH=/usr/local/bin\n-\n- USER vscode\n- \");\n- }\n-\n- #[test]\n- fn empty_feature_layers_no_extra_blank_lines() {\n- let result = generate(\"FROM alpine\", &[], &HashMap::new(), Some(\"dev\"));\n- insta::assert_snapshot!(result, @r\"\n- # Generated by fabro-devcontainer\n-\n- FROM alpine\n-\n- USER dev\n- \");\n- }\n-\n- #[test]\n- fn empty_env_map_treated_as_none() {\n- let env = HashMap::new();\n- let result = generate(\"FROM alpine\", &[], &env, None);\n- insta::assert_snapshot!(result, @r\"\n- # Generated by fabro-devcontainer\n-\n- FROM alpine\n- \");\n- }\n-\n- #[test]\n- fn multiline_base_dockerfile() {\n- let base = \"FROM ubuntu:22.04 AS builder\\n\\\n- RUN apt-get update && apt-get install -y build-essential\\n\\\n- COPY . /app\\n\\\n- RUN make\\n\\\n- \\n\\\n- FROM ubuntu:22.04\\n\\\n- COPY --from=builder /app/bin /usr/local/bin\";\n- let result = generate(base, &[], &HashMap::new(), None);\n- insta::assert_snapshot!(result, @r\"\n- # Generated by fabro-devcontainer\n-\n- FROM ubuntu:22.04 AS builder\n- RUN apt-get update && apt-get install -y build-essential\n- COPY . /app\n- RUN make\n-\n- FROM ubuntu:22.04\n- COPY --from=builder /app/bin /usr/local/bin\n- \");\n- }\n-\n- #[test]\n- fn container_env_only() {\n- let mut cenv = HashMap::new();\n- cenv.insert(\"DEBIAN_FRONTEND\".to_string(), \"noninteractive\".to_string());\n- let result = generate(\"FROM alpine\", &[], &cenv, None);\n- insta::assert_snapshot!(result, @r\"\n- # Generated by fabro-devcontainer\n-\n- FROM alpine\n-\n- ENV DEBIAN_FRONTEND=noninteractive\n- \");\n- }\n-\n- #[test]\n- fn container_env_with_multiple_keys() {\n- let mut cenv = HashMap::new();\n- cenv.insert(\"ALPHA\".to_string(), \"first\".to_string());\n- cenv.insert(\"BETA\".to_string(), \"second\".to_string());\n- cenv.insert(\"GAMMA\".to_string(), \"third\".to_string());\n- let result = generate(\"FROM alpine\", &[], &cenv, None);\n- insta::assert_snapshot!(result, @r\"\n- # Generated by fabro-devcontainer\n-\n- FROM alpine\n-\n- ENV ALPHA=first\n- ENV BETA=second\n- ENV GAMMA=third\n- \");\n- }\n-}\ndiff --git a/lib/crates/fabro-devcontainer/src/features.rs b/lib/crates/fabro-devcontainer/src/features.rs\ndeleted file mode 100644\nindex 14aef01ee..000000000\n--- a/lib/crates/fabro-devcontainer/src/features.rs\n+++ /dev/null\n@@ -1,1292 +0,0 @@\n-use std::collections::{HashMap, HashSet, VecDeque};\n-use std::fmt::Write;\n-use std::path::Path;\n-\n-use fabro_static::EnvVars;\n-use tokio::fs;\n-use tokio::process::Command;\n-use tracing::info;\n-\n-use crate::DevcontainerError;\n-use crate::types::{FeatureMetadata, LifecycleCommand};\n-\n-/// A resolved feature layer ready to be inserted into a Dockerfile.\n-#[derive(Debug, Clone)]\n-pub(crate) struct FeatureLayer {\n- /// Feature identifier (e.g. \"ghcr.io/devcontainers/features/node:1\")\n- pub id: String,\n- /// Directory name for COPY\n- pub dir_name: String,\n- /// Dockerfile snippet for this feature\n- pub dockerfile_snippet: String,\n-}\n-\n-/// All resolved feature data: layers, environment, and lifecycle hooks.\n-#[derive(Debug, Clone, Default)]\n-pub(crate) struct ResolvedFeatures {\n- pub layers: Vec,\n- pub container_env: HashMap,\n- pub on_create_commands: Vec,\n- pub post_create_commands: Vec,\n- pub post_start_commands: Vec,\n-}\n-\n-/// Extract the directory name from a feature ID.\n-/// Handles OCI refs (\"ghcr.io/devcontainers/features/node:1\" → \"node\"),\n-/// local paths (\"./my-feature\" → \"my-feature\"),\n-/// and HTTPS URLs (\"https://example.com/feature.tgz\" → \"feature\").\n-fn dir_name_from_id(feature_id: &str) -> String {\n- // Local path: strip leading ./ or ../\n- if feature_id.starts_with(\"./\") || feature_id.starts_with(\"../\") {\n- let stripped = feature_id\n- .trim_start_matches(\"../\")\n- .trim_start_matches(\"./\");\n- return stripped.rsplit('/').next().unwrap_or(stripped).to_string();\n- }\n-\n- // HTTPS URL: take filename, strip .tgz extension\n- if feature_id.starts_with(\"https://\") {\n- let filename = feature_id.rsplit('/').next().unwrap_or(feature_id);\n- return filename\n- .strip_suffix(\".tgz\")\n- .or_else(|| filename.strip_suffix(\".tar.gz\"))\n- .unwrap_or(filename)\n- .to_string();\n- }\n-\n- // OCI ref: strip tag, take last path segment\n- let without_tag = feature_id.split(':').next().unwrap_or(feature_id);\n- without_tag\n- .rsplit('/')\n- .next()\n- .unwrap_or(without_tag)\n- .to_string()\n-}\n-\n-/// Ensure `oras` CLI is available, installing it if necessary.\n-#[expect(\n- clippy::disallowed_methods,\n- reason = \"OCI feature fetching installs oras under the user's HOME on Linux.\"\n-)]\n-async fn ensure_oras() -> crate::Result<()> {\n- let check = Command::new(\"which\")\n- .arg(\"oras\")\n- .output()\n- .await\n- .map_err(|e| DevcontainerError::OrasInstall(format!(\"failed to check for oras: {e}\")))?;\n-\n- if check.status.success() {\n- return Ok(());\n- }\n-\n- info!(\"oras not found, attempting to install\");\n-\n- if cfg!(target_os = \"macos\") {\n- let status = Command::new(\"brew\")\n- .args([\"install\", \"oras\"])\n- .status()\n- .await\n- .map_err(|e| {\n- DevcontainerError::OrasInstall(format!(\"failed to run brew install oras: {e}\"))\n- })?;\n-\n- if !status.success() {\n- return Err(DevcontainerError::OrasInstall(\n- \"brew install oras failed\".to_string(),\n- ));\n- }\n- } else {\n- // Linux: download from GitHub releases to ~/.local/bin/\n- let home = std::env::var(EnvVars::HOME)\n- .map_err(|_| DevcontainerError::OrasInstall(\"HOME not set\".to_string()))?;\n- let bin_dir = format!(\"{home}/.local/bin\");\n-\n- fs::create_dir_all(&bin_dir).await.map_err(|e| {\n- DevcontainerError::OrasInstall(format!(\"failed to create {bin_dir}: {e}\"))\n- })?;\n-\n- let version = \"1.2.0\";\n- let arch = if cfg!(target_arch = \"aarch64\") {\n- \"arm64\"\n- } else {\n- \"amd64\"\n- };\n- let url = format!(\n- \"https://github.com/oras-project/oras/releases/download/v{version}/oras_{version}_linux_{arch}.tar.gz\"\n- );\n-\n- let status = Command::new(\"sh\")\n- .args([\n- \"-c\",\n- &format!(\"curl -fsSL '{url}' | tar xzf - -C '{bin_dir}' oras\"),\n- ])\n- .status()\n- .await\n- .map_err(|e| DevcontainerError::OrasInstall(format!(\"failed to download oras: {e}\")))?;\n-\n- if !status.success() {\n- return Err(DevcontainerError::OrasInstall(\n- \"downloading oras from GitHub releases failed\".to_string(),\n- ));\n- }\n- }\n-\n- Ok(())\n-}\n-\n-/// Find the first `.tgz` file in a directory.\n-async fn find_tgz(dir: &Path) -> Option {\n- let mut entries = fs::read_dir(dir).await.ok()?;\n- while let Ok(Some(entry)) = entries.next_entry().await {\n- if let Some(name) = entry.file_name().to_str() {\n- if Path::new(name)\n- .extension()\n- .is_some_and(|ext| ext.eq_ignore_ascii_case(\"tgz\"))\n- {\n- return Some(name.to_string());\n- }\n- }\n- }\n- None\n-}\n-\n-/// Extract a tgz archive in the given directory.\n-async fn extract_tgz(feature_dir: &Path, tgz_name: &str, feature_id: &str) -> crate::Result<()> {\n- let status = Command::new(\"tar\")\n- .args([\"xzf\", tgz_name])\n- .current_dir(feature_dir)\n- .status()\n- .await\n- .map_err(|e| DevcontainerError::Feature(format!(\"failed to extract tgz: {e}\")))?;\n-\n- if !status.success() {\n- return Err(DevcontainerError::Feature(format!(\n- \"tar extraction failed for {feature_id}\"\n- )));\n- }\n- Ok(())\n-}\n-\n-/// Read and parse devcontainer-feature.json from a feature directory.\n-async fn read_feature_metadata(feature_dir: &Path) -> crate::Result {\n- let metadata_path = feature_dir.join(\"devcontainer-feature.json\");\n- let metadata_str = fs::read_to_string(&metadata_path).await.map_err(|e| {\n- DevcontainerError::Feature(format!(\"failed to read {}: {e}\", metadata_path.display()))\n- })?;\n-\n- serde_json::from_str(&metadata_str).map_err(|e| {\n- DevcontainerError::Feature(format!(\"failed to parse {}: {e}\", metadata_path.display()))\n- })\n-}\n-\n-/// Create a feature output directory under the temp dir.\n-async fn create_feature_dir(\n- output_dir: &Path,\n- feature_id: &str,\n-) -> crate::Result {\n- let dir_name = dir_name_from_id(feature_id);\n- let feature_dir = output_dir.join(&dir_name);\n- fs::create_dir_all(&feature_dir).await.map_err(|e| {\n- DevcontainerError::Feature(format!(\n- \"failed to create dir {}: {e}\",\n- feature_dir.display()\n- ))\n- })?;\n- Ok(feature_dir)\n-}\n-\n-/// Fetch a single OCI feature using `oras pull` and extract its contents.\n-async fn fetch_feature_oci(feature_id: &str, output_dir: &Path) -> crate::Result {\n- let feature_dir = create_feature_dir(output_dir, feature_id).await?;\n-\n- info!(feature_id, \"pulling feature with oras\");\n-\n- let output = Command::new(\"oras\")\n- .args([\"pull\", feature_id, \"-o\"])\n- .arg(&feature_dir)\n- .output()\n- .await\n- .map_err(|e| DevcontainerError::OrasCommand(format!(\"failed to run oras pull: {e}\")))?;\n-\n- if !output.status.success() {\n- let stderr = String::from_utf8_lossy(&output.stderr);\n- return Err(DevcontainerError::OrasCommand(format!(\n- \"oras pull {feature_id} failed: {stderr}\"\n- )));\n- }\n-\n- // OCI registries may name the tgz with a feature suffix (e.g.\n- // devcontainer-feature-node.tgz)\n- if let Some(tgz) = find_tgz(&feature_dir).await {\n- extract_tgz(&feature_dir, &tgz, feature_id).await?;\n- }\n-\n- read_feature_metadata(&feature_dir).await\n-}\n-\n-/// Fetch a local feature by copying its directory.\n-async fn fetch_feature_local(\n- feature_id: &str,\n- output_dir: &Path,\n- devcontainer_dir: &Path,\n-) -> crate::Result {\n- let local_path = devcontainer_dir.join(feature_id);\n- if !local_path.is_dir() {\n- return Err(DevcontainerError::Feature(format!(\n- \"local feature path not found: {}\",\n- local_path.display()\n- )));\n- }\n-\n- let feature_dir = create_feature_dir(output_dir, feature_id).await?;\n- copy_dir_recursive(&local_path, &feature_dir).await?;\n-\n- read_feature_metadata(&feature_dir).await\n-}\n-\n-/// Fetch a feature from an HTTPS URL (tgz archive).\n-async fn fetch_feature_https(\n- feature_id: &str,\n- output_dir: &Path,\n-) -> crate::Result {\n- let feature_dir = create_feature_dir(output_dir, feature_id).await?;\n-\n- info!(feature_id, \"downloading feature from HTTPS\");\n-\n- let response = fabro_http::http_client()\n- .map_err(|e| DevcontainerError::Feature(format!(\"failed to build HTTP client: {e}\")))?\n- .get(feature_id)\n- .send()\n- .await\n- .map_err(|e| DevcontainerError::Feature(format!(\"failed to download {feature_id}: {e}\")))?;\n-\n- if !response.status().is_success() {\n- return Err(DevcontainerError::Feature(format!(\n- \"HTTP {} downloading {feature_id}\",\n- response.status()\n- )));\n- }\n-\n- let bytes = response.bytes().await.map_err(|e| {\n- DevcontainerError::Feature(format!(\"failed to read response for {feature_id}: {e}\"))\n- })?;\n-\n- let tgz_path = feature_dir.join(\"devcontainer-feature.tgz\");\n- fs::write(&tgz_path, &bytes).await.map_err(|e| {\n- DevcontainerError::Feature(format!(\"failed to write {}: {e}\", tgz_path.display()))\n- })?;\n-\n- extract_tgz(&feature_dir, \"devcontainer-feature.tgz\", feature_id).await?;\n-\n- read_feature_metadata(&feature_dir).await\n-}\n-\n-/// Dispatch feature fetch based on the feature ID prefix.\n-async fn fetch_feature_dispatch(\n- feature_id: &str,\n- output_dir: &Path,\n- devcontainer_dir: &Path,\n- oras_checked: &mut bool,\n-) -> crate::Result {\n- if feature_id.starts_with(\"./\") || feature_id.starts_with(\"../\") {\n- fetch_feature_local(feature_id, output_dir, devcontainer_dir).await\n- } else if feature_id.starts_with(\"https://\") {\n- fetch_feature_https(feature_id, output_dir).await\n- } else {\n- if !*oras_checked {\n- ensure_oras().await?;\n- *oras_checked = true;\n- }\n- fetch_feature_oci(feature_id, output_dir).await\n- }\n-}\n-\n-/// Recursively copy a directory.\n-async fn copy_dir_recursive(src: &Path, dst: &Path) -> crate::Result<()> {\n- fs::create_dir_all(dst).await.map_err(|e| {\n- DevcontainerError::Feature(format!(\"failed to create dir {}: {e}\", dst.display()))\n- })?;\n-\n- let mut entries = fs::read_dir(src).await.map_err(|e| {\n- DevcontainerError::Feature(format!(\"failed to read dir {}: {e}\", src.display()))\n- })?;\n-\n- while let Some(entry) = entries\n- .next_entry()\n- .await\n- .map_err(|e| DevcontainerError::Feature(format!(\"failed to read dir entry: {e}\")))?\n- {\n- let entry_path = entry.path();\n- let dest_path = dst.join(entry.file_name());\n-\n- if entry_path.is_dir() {\n- Box::pin(copy_dir_recursive(&entry_path, &dest_path)).await?;\n- } else {\n- fs::copy(&entry_path, &dest_path).await.map_err(|e| {\n- DevcontainerError::Feature(format!(\n- \"failed to copy {} to {}: {e}\",\n- entry_path.display(),\n- dest_path.display()\n- ))\n- })?;\n- }\n- }\n-\n- Ok(())\n-}\n-\n-/// Topological sort of features based on `installsAfter` and `dependsOn`\n-/// dependencies. Uses Kahn's algorithm. Features without ordering constraints\n-/// maintain input order.\n-fn topo_sort(\n- feature_ids: &[String],\n- metadata_map: &HashMap,\n-) -> Vec {\n- if feature_ids.is_empty() {\n- return Vec::new();\n- }\n-\n- let id_set: HashSet<&str> = feature_ids\n- .iter()\n- .map(std::string::String::as_str)\n- .collect();\n-\n- // Build adjacency list and in-degree count.\n- // An edge from A -> B means \"A must be installed before B\".\n- // Use a set of (from, to) pairs to deduplicate edges when the same dep\n- // appears in both installsAfter and dependsOn.\n- let mut in_degree: HashMap<&str, usize> = HashMap::new();\n- let mut edges: HashMap<&str, Vec<&str>> = HashMap::new();\n- let mut edge_set: HashSet<(&str, &str)> = HashSet::new();\n-\n- for id in feature_ids {\n- in_degree.entry(id.as_str()).or_insert(0);\n- edges.entry(id.as_str()).or_default();\n- }\n-\n- for id in feature_ids {\n- if let Some(meta) = metadata_map.get(id) {\n- // Collect dependency refs from both installsAfter and dependsOn\n- let mut dep_refs: Vec<&str> = meta\n- .installs_after\n- .iter()\n- .map(std::string::String::as_str)\n- .collect();\n- for dep_id in meta.depends_on.keys() {\n- dep_refs.push(dep_id.as_str());\n- }\n-\n- for dep in dep_refs {\n- let dep_dir = dir_name_from_id(dep);\n- for candidate in feature_ids {\n- if (candidate == dep || dir_name_from_id(candidate) == dep_dir)\n- && id_set.contains(candidate.as_str())\n- {\n- // candidate -> id (candidate must come before id)\n- let edge = (candidate.as_str(), id.as_str());\n- if edge_set.insert(edge) {\n- edges\n- .entry(candidate.as_str())\n- .or_default()\n- .push(id.as_str());\n- *in_degree.entry(id.as_str()).or_insert(0) += 1;\n- }\n- }\n- }\n- }\n- }\n- }\n-\n- // Kahn's algorithm preserving input order for ties\n- let mut queue: VecDeque<&str> = VecDeque::new();\n- for id in feature_ids {\n- if in_degree.get(id.as_str()).copied().unwrap_or(0) == 0 {\n- queue.push_back(id.as_str());\n- }\n- }\n-\n- let mut sorted: Vec = Vec::new();\n- while let Some(node) = queue.pop_front() {\n- sorted.push(node.to_string());\n- if let Some(neighbors) = edges.get(node) {\n- for neighbor in neighbors {\n- if let Some(deg) = in_degree.get_mut(neighbor) {\n- *deg -= 1;\n- if *deg == 0 {\n- queue.push_back(neighbor);\n- }\n- }\n- }\n- }\n- }\n-\n- // If there are cycles, append remaining features in input order\n- if sorted.len() < feature_ids.len() {\n- for id in feature_ids {\n- if !sorted.contains(id) {\n- sorted.push(id.clone());\n- }\n- }\n- }\n-\n- sorted\n-}\n-\n-/// Convert an option ID to an environment variable name per the dev container\n-/// spec. Replaces non-alphanumeric, non-underscore chars with `_`, strips\n-/// leading digits/underscores, and uppercases the result.\n-fn option_id_to_env_name(id: &str) -> String {\n- let replaced: String = id\n- .chars()\n- .map(|c| {\n- if c.is_alphanumeric() || c == '_' {\n- c\n- } else {\n- '_'\n- }\n- })\n- .collect();\n- let trimmed = replaced.trim_start_matches(|c: char| c == '_' || c.is_ascii_digit());\n- if trimmed.is_empty() {\n- \"_\".to_string()\n- } else {\n- trimmed.to_uppercase()\n- }\n-}\n-\n-/// Generate a Dockerfile snippet for a single feature layer.\n-fn generate_layer(\n- feature_id: &str,\n- dir_name: &str,\n- options: &serde_json::Value,\n- metadata: &FeatureMetadata,\n- remote_user: Option<&str>,\n-) -> String {\n- let mut env_lines = Vec::new();\n-\n- // Emit built-in user env vars expected by community features\n- let ru = remote_user.unwrap_or(\"root\");\n- let ru_home = if ru == \"root\" {\n- \"/root\".to_string()\n- } else {\n- format!(\"/home/{ru}\")\n- };\n- env_lines.push(format!(\" export _REMOTE_USER=\\\"{ru}\\\" && \\\\\"));\n- env_lines.push(\" export _CONTAINER_USER=\\\"root\\\" && \\\\\".to_string());\n- env_lines.push(format!(\" export _REMOTE_USER_HOME=\\\"{ru_home}\\\" && \\\\\"));\n- env_lines.push(\" export _CONTAINER_USER_HOME=\\\"/root\\\" && \\\\\".to_string());\n-\n- // Normalize shorthand version syntax: \"1.18\" → {\"version\": \"1.18\"}\n- let options_obj = match options {\n- serde_json::Value::String(s) => {\n- let mut map = serde_json::Map::new();\n- map.insert(\"version\".to_string(), serde_json::Value::String(s.clone()));\n- map\n- }\n- serde_json::Value::Object(obj) => obj.clone(),\n- _ => serde_json::Map::new(),\n- };\n-\n- // Collect all option names from metadata to set defaults\n- let user_options: HashMap = options_obj\n- .iter()\n- .map(|(k, v)| {\n- let val = match v {\n- serde_json::Value::String(s) => s.clone(),\n- other => other.to_string(),\n- };\n- (k.clone(), val)\n- })\n- .collect();\n-\n- // Merge metadata defaults with user-provided options\n- let mut merged_options: Vec<(String, String)> = Vec::new();\n- for (opt_name, opt_def) in &metadata.options {\n- let value = if let Some(user_val) = user_options.get(opt_name) {\n- user_val.clone()\n- } else if let Some(default_val) = &opt_def.default {\n- match default_val {\n- serde_json::Value::String(s) => s.clone(),\n- serde_json::Value::Bool(b) => b.to_string(),\n- other => other.to_string(),\n- }\n- } else {\n- continue;\n- };\n- merged_options.push((opt_name.clone(), value));\n- }\n-\n- // Also add any user options not in metadata\n- for (key, val) in &user_options {\n- if !metadata.options.contains_key(key) {\n- merged_options.push((key.clone(), val.clone()));\n- }\n- }\n-\n- // Sort for deterministic output\n- merged_options.sort_by(|a, b| a.0.cmp(&b.0));\n-\n- for (name, value) in &merged_options {\n- let env_name = option_id_to_env_name(name);\n- env_lines.push(format!(\" export {env_name}=\\\"{value}\\\" && \\\\\"));\n- }\n-\n- let mut snippet = format!(\"# Feature: {feature_id}\\n\");\n- let _ = writeln!(\n- snippet,\n- \"COPY {dir_name}/ /tmp/devcontainer-features/{dir_name}/\"\n- );\n- let _ = writeln!(\n- snippet,\n- \"RUN cd /tmp/devcontainer-features/{dir_name} && \\\\\"\n- );\n- for line in &env_lines {\n- snippet.push_str(line);\n- snippet.push('\\n');\n- }\n- snippet.push_str(\" chmod +x install.sh && \\\\\\n\");\n- snippet.push_str(\" ./install.sh\");\n-\n- snippet\n-}\n-\n-/// Fetch, order, and resolve features into Dockerfile layers.\n-pub(crate) async fn resolve_features(\n- features: &HashMap,\n- devcontainer_dir: &Path,\n- remote_user: Option<&str>,\n-) -> crate::Result {\n- if features.is_empty() {\n- return Ok(ResolvedFeatures::default());\n- }\n-\n- let unique_id = format!(\n- \"devcontainer-features-{}-{}\",\n- std::process::id(),\n- std::time::SystemTime::now()\n- .duration_since(std::time::UNIX_EPOCH)\n- .unwrap_or_default()\n- .as_nanos()\n- );\n- let tmp_dir = std::env::temp_dir().join(unique_id);\n- fs::create_dir_all(&tmp_dir).await.map_err(|e| {\n- DevcontainerError::Feature(format!(\n- \"failed to create temp dir {}: {e}\",\n- tmp_dir.display()\n- ))\n- })?;\n-\n- // Collect feature IDs in a stable order\n- let mut feature_ids: Vec = features.keys().cloned().collect();\n-\n- // Track options for each feature (including auto-injected ones)\n- let mut all_options: HashMap = features.clone();\n-\n- // Fetch all features and collect metadata\n- let mut oras_checked = false;\n- let mut metadata_map: HashMap = HashMap::new();\n- for feature_id in &feature_ids {\n- let metadata =\n- fetch_feature_dispatch(feature_id, &tmp_dir, devcontainer_dir, &mut oras_checked)\n- .await?;\n- metadata_map.insert(feature_id.clone(), metadata);\n- }\n-\n- // Auto-inject missing dependsOn targets\n- let mut injected = true;\n- while injected {\n- injected = false;\n- let current_ids: Vec = feature_ids.clone();\n- for id in ¤t_ids {\n- if let Some(meta) = metadata_map.get(id).cloned() {\n- for (dep_id, dep_options) in &meta.depends_on {\n- // Check if dep is already present (by full ID or dir name)\n- let dep_dir = dir_name_from_id(dep_id);\n- let already_present = feature_ids.iter().any(|existing| {\n- existing == dep_id || dir_name_from_id(existing) == dep_dir\n- });\n- if !already_present {\n- info!(dep_id, \"auto-injecting missing dependsOn target\");\n- let dep_metadata = fetch_feature_dispatch(\n- dep_id,\n- &tmp_dir,\n- devcontainer_dir,\n- &mut oras_checked,\n- )\n- .await?;\n- metadata_map.insert(dep_id.clone(), dep_metadata);\n- feature_ids.push(dep_id.clone());\n- all_options.insert(dep_id.clone(), dep_options.clone());\n- injected = true;\n- }\n- }\n- }\n- }\n- }\n-\n- // Topologically sort features\n- let sorted_ids = topo_sort(&feature_ids, &metadata_map);\n-\n- // Generate layers and collect container_env\n- let mut resolved = ResolvedFeatures::default();\n- for id in &sorted_ids {\n- let dir_name = dir_name_from_id(id);\n- let options = all_options\n- .get(id)\n- .cloned()\n- .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));\n- let metadata = metadata_map\n- .get(id)\n- .cloned()\n- .unwrap_or_else(|| FeatureMetadata {\n- id: None,\n- name: None,\n- version: None,\n- options: HashMap::new(),\n- installs_after: Vec::new(),\n- depends_on: HashMap::new(),\n- container_env: HashMap::new(),\n- on_create_command: None,\n- post_create_command: None,\n- post_start_command: None,\n- });\n-\n- // Collect feature containerEnv (later features override earlier)\n- for (k, v) in &metadata.container_env {\n- resolved.container_env.insert(k.clone(), v.clone());\n- }\n-\n- // Collect feature lifecycle hooks\n- if let Some(cmd) = &metadata.on_create_command {\n- resolved.on_create_commands.push(cmd.clone());\n- }\n- if let Some(cmd) = &metadata.post_create_command {\n- resolved.post_create_commands.push(cmd.clone());\n- }\n- if let Some(cmd) = &metadata.post_start_command {\n- resolved.post_start_commands.push(cmd.clone());\n- }\n-\n- let dockerfile_snippet = generate_layer(id, &dir_name, &options, &metadata, remote_user);\n- resolved.layers.push(FeatureLayer {\n- id: id.clone(),\n- dir_name,\n- dockerfile_snippet,\n- });\n- }\n-\n- Ok(resolved)\n-}\n-\n-#[cfg(test)]\n-mod tests {\n- use super::*;\n- use crate::types::FeatureOption;\n-\n- #[test]\n- fn dir_name_from_full_id() {\n- assert_eq!(\n- dir_name_from_id(\"ghcr.io/devcontainers/features/node:1\"),\n- \"node\"\n- );\n- }\n-\n- #[test]\n- fn dir_name_from_id_no_tag() {\n- assert_eq!(\n- dir_name_from_id(\"ghcr.io/devcontainers/features/python\"),\n- \"python\"\n- );\n- }\n-\n- #[test]\n- fn dir_name_from_id_simple() {\n- assert_eq!(dir_name_from_id(\"node\"), \"node\");\n- }\n-\n- #[test]\n- fn dir_name_from_local_path() {\n- assert_eq!(dir_name_from_id(\"./my-feature\"), \"my-feature\");\n- assert_eq!(dir_name_from_id(\"./sub/my-feature\"), \"my-feature\");\n- assert_eq!(dir_name_from_id(\"../shared-feature\"), \"shared-feature\");\n- }\n-\n- #[test]\n- fn dir_name_from_https_url() {\n- assert_eq!(\n- dir_name_from_id(\"https://example.com/features/node.tgz\"),\n- \"node\"\n- );\n- assert_eq!(\n- dir_name_from_id(\"https://example.com/features/python.tar.gz\"),\n- \"python\"\n- );\n- assert_eq!(dir_name_from_id(\"https://example.com/features/go\"), \"go\");\n- }\n-\n- #[test]\n- fn fetch_feature_dispatch_routes() {\n- // Verify the routing logic by checking prefix detection\n- assert!(\"./local-feature\".starts_with(\"./\"));\n- assert!(\"../parent-feature\".starts_with(\"../\"));\n- assert!(\"https://example.com/feature.tgz\".starts_with(\"https://\"));\n- assert!(!\"ghcr.io/foo/bar:1\".starts_with(\"./\"));\n- assert!(!\"ghcr.io/foo/bar:1\".starts_with(\"../\"));\n- assert!(!\"ghcr.io/foo/bar:1\".starts_with(\"https://\"));\n- }\n-\n- #[tokio::test]\n- #[expect(\n- clippy::disallowed_methods,\n- reason = \"test fixture setup uses sync std::fs::write to create a fake feature directory\"\n- )]\n- async fn fetch_feature_local_integration() {\n- let tmp_src = tempfile::tempdir().unwrap();\n- let feature_dir = tmp_src.path().join(\"my-feature\");\n- std::fs::create_dir_all(&feature_dir).unwrap();\n-\n- // Create a minimal devcontainer-feature.json\n- std::fs::write(\n- feature_dir.join(\"devcontainer-feature.json\"),\n- r#\"{\"id\": \"my-feature\", \"version\": \"1.0.0\"}\"#,\n- )\n- .unwrap();\n-\n- // Create a dummy install.sh\n- std::fs::write(feature_dir.join(\"install.sh\"), \"#!/bin/sh\\necho hi\").unwrap();\n-\n- let tmp_out = tempfile::tempdir().unwrap();\n- let metadata = fetch_feature_local(\"./my-feature\", tmp_out.path(), tmp_src.path())\n- .await\n- .unwrap();\n- assert_eq!(metadata.id.as_deref(), Some(\"my-feature\"));\n- assert!(tmp_out.path().join(\"my-feature/install.sh\").exists());\n- }\n-\n- #[test]\n- fn topo_sort_no_dependencies() {\n- let ids = vec![\"a\".to_string(), \"b\".to_string(), \"c\".to_string()];\n- let metadata: HashMap = ids\n- .iter()\n- .map(|id| {\n- (id.clone(), FeatureMetadata {\n- id: Some(id.clone()),\n- name: None,\n- version: None,\n- options: HashMap::new(),\n- installs_after: Vec::new(),\n- depends_on: HashMap::new(),\n- container_env: HashMap::new(),\n- on_create_command: None,\n- post_create_command: None,\n- post_start_command: None,\n- })\n- })\n- .collect();\n-\n- let sorted = topo_sort(&ids, &metadata);\n- assert_eq!(sorted, vec![\"a\", \"b\", \"c\"]);\n- }\n-\n- #[test]\n- fn topo_sort_simple_chain() {\n- // A depends on B (A installs after B), so B should come first\n- let ids = vec![\"a\".to_string(), \"b\".to_string()];\n- let mut metadata: HashMap = HashMap::new();\n- metadata.insert(\"a\".to_string(), FeatureMetadata {\n- id: Some(\"a\".to_string()),\n- name: None,\n- version: None,\n- options: HashMap::new(),\n- installs_after: vec![\"b\".to_string()],\n- depends_on: HashMap::new(),\n- container_env: HashMap::new(),\n- on_create_command: None,\n- post_create_command: None,\n- post_start_command: None,\n- });\n- metadata.insert(\"b\".to_string(), FeatureMetadata {\n- id: Some(\"b\".to_string()),\n- name: None,\n- version: None,\n- options: HashMap::new(),\n- installs_after: Vec::new(),\n- depends_on: HashMap::new(),\n- container_env: HashMap::new(),\n- on_create_command: None,\n- post_create_command: None,\n- post_start_command: None,\n- });\n-\n- let sorted = topo_sort(&ids, &metadata);\n- assert_eq!(sorted, vec![\"b\", \"a\"]);\n- }\n-\n- #[test]\n- fn topo_sort_diamond() {\n- // D depends on B and C; B and C depend on A\n- // Expected: A, B, C, D (or A, C, B, D — both valid, but we preserve input order\n- // for ties)\n- let ids = vec![\n- \"d\".to_string(),\n- \"b\".to_string(),\n- \"c\".to_string(),\n- \"a\".to_string(),\n- ];\n- let mut metadata: HashMap = HashMap::new();\n- metadata.insert(\"a\".to_string(), FeatureMetadata {\n- id: Some(\"a\".to_string()),\n- name: None,\n- version: None,\n- options: HashMap::new(),\n- installs_after: Vec::new(),\n- depends_on: HashMap::new(),\n- container_env: HashMap::new(),\n- on_create_command: None,\n- post_create_command: None,\n- post_start_command: None,\n- });\n- metadata.insert(\"b\".to_string(), FeatureMetadata {\n- id: Some(\"b\".to_string()),\n- name: None,\n- version: None,\n- options: HashMap::new(),\n- installs_after: vec![\"a\".to_string()],\n- depends_on: HashMap::new(),\n- container_env: HashMap::new(),\n- on_create_command: None,\n- post_create_command: None,\n- post_start_command: None,\n- });\n- metadata.insert(\"c\".to_string(), FeatureMetadata {\n- id: Some(\"c\".to_string()),\n- name: None,\n- version: None,\n- options: HashMap::new(),\n- installs_after: vec![\"a\".to_string()],\n- depends_on: HashMap::new(),\n- container_env: HashMap::new(),\n- on_create_command: None,\n- post_create_command: None,\n- post_start_command: None,\n- });\n- metadata.insert(\"d\".to_string(), FeatureMetadata {\n- id: Some(\"d\".to_string()),\n- name: None,\n- version: None,\n- options: HashMap::new(),\n- installs_after: vec![\"b\".to_string(), \"c\".to_string()],\n- depends_on: HashMap::new(),\n- container_env: HashMap::new(),\n- on_create_command: None,\n- post_create_command: None,\n- post_start_command: None,\n- });\n-\n- let sorted = topo_sort(&ids, &metadata);\n- // A must come before B and C; B and C must come before D\n- let pos_a = sorted.iter().position(|x| x == \"a\").unwrap();\n- let pos_b = sorted.iter().position(|x| x == \"b\").unwrap();\n- let pos_c = sorted.iter().position(|x| x == \"c\").unwrap();\n- let pos_d = sorted.iter().position(|x| x == \"d\").unwrap();\n- assert!(pos_a < pos_b);\n- assert!(pos_a < pos_c);\n- assert!(pos_b < pos_d);\n- assert!(pos_c < pos_d);\n- }\n-\n- #[test]\n- fn generate_layer_with_options() {\n- let options = serde_json::json!({\"version\": \"20\"});\n- let mut meta_options = HashMap::new();\n- meta_options.insert(\"version\".to_string(), FeatureOption {\n- option_type: Some(\"string\".to_string()),\n- default: Some(serde_json::Value::String(\"lts\".to_string())),\n- description: Some(\"Node.js version\".to_string()),\n- });\n- let metadata = FeatureMetadata {\n- id: Some(\"node\".to_string()),\n- name: Some(\"Node.js\".to_string()),\n- version: Some(\"1.0.0\".to_string()),\n- options: meta_options,\n- installs_after: Vec::new(),\n- depends_on: HashMap::new(),\n- container_env: HashMap::new(),\n- on_create_command: None,\n- post_create_command: None,\n- post_start_command: None,\n- };\n-\n- let snippet = generate_layer(\n- \"ghcr.io/devcontainers/features/node:1\",\n- \"node\",\n- &options,\n- &metadata,\n- None,\n- );\n-\n- insta::assert_snapshot!(snippet, @r#\"\n- # Feature: ghcr.io/devcontainers/features/node:1\n- COPY node/ /tmp/devcontainer-features/node/\n- RUN cd /tmp/devcontainer-features/node && \\\n- export _REMOTE_USER=\"root\" && \\\n- export _CONTAINER_USER=\"root\" && \\\n- export _REMOTE_USER_HOME=\"/root\" && \\\n- export _CONTAINER_USER_HOME=\"/root\" && \\\n- export VERSION=\"20\" && \\\n- chmod +x install.sh && \\\n- ./install.sh\n- \"#);\n- }\n-\n- #[test]\n- fn generate_layer_with_defaults() {\n- let options = serde_json::json!({});\n- let mut meta_options = HashMap::new();\n- meta_options.insert(\"version\".to_string(), FeatureOption {\n- option_type: Some(\"string\".to_string()),\n- default: Some(serde_json::Value::String(\"lts\".to_string())),\n- description: Some(\"Node.js version\".to_string()),\n- });\n- let metadata = FeatureMetadata {\n- id: Some(\"node\".to_string()),\n- name: None,\n- version: None,\n- options: meta_options,\n- installs_after: Vec::new(),\n- depends_on: HashMap::new(),\n- container_env: HashMap::new(),\n- on_create_command: None,\n- post_create_command: None,\n- post_start_command: None,\n- };\n-\n- let snippet = generate_layer(\n- \"ghcr.io/devcontainers/features/node:1\",\n- \"node\",\n- &options,\n- &metadata,\n- None,\n- );\n-\n- insta::assert_snapshot!(snippet, @r#\"\n- # Feature: ghcr.io/devcontainers/features/node:1\n- COPY node/ /tmp/devcontainer-features/node/\n- RUN cd /tmp/devcontainer-features/node && \\\n- export _REMOTE_USER=\"root\" && \\\n- export _CONTAINER_USER=\"root\" && \\\n- export _REMOTE_USER_HOME=\"/root\" && \\\n- export _CONTAINER_USER_HOME=\"/root\" && \\\n- export VERSION=\"lts\" && \\\n- chmod +x install.sh && \\\n- ./install.sh\n- \"#);\n- }\n-\n- #[test]\n- fn generate_layer_no_options() {\n- let options = serde_json::json!({});\n- let metadata = FeatureMetadata {\n- id: Some(\"common-utils\".to_string()),\n- name: None,\n- version: None,\n- options: HashMap::new(),\n- installs_after: Vec::new(),\n- depends_on: HashMap::new(),\n- container_env: HashMap::new(),\n- on_create_command: None,\n- post_create_command: None,\n- post_start_command: None,\n- };\n-\n- let snippet = generate_layer(\n- \"ghcr.io/devcontainers/features/common-utils:1\",\n- \"common-utils\",\n- &options,\n- &metadata,\n- None,\n- );\n-\n- insta::assert_snapshot!(snippet, @r#\"\n- # Feature: ghcr.io/devcontainers/features/common-utils:1\n- COPY common-utils/ /tmp/devcontainer-features/common-utils/\n- RUN cd /tmp/devcontainer-features/common-utils && \\\n- export _REMOTE_USER=\"root\" && \\\n- export _CONTAINER_USER=\"root\" && \\\n- export _REMOTE_USER_HOME=\"/root\" && \\\n- export _CONTAINER_USER_HOME=\"/root\" && \\\n- chmod +x install.sh && \\\n- ./install.sh\n- \"#);\n- }\n-\n- #[test]\n- fn topo_sort_depends_on_present() {\n- // A dependsOn B, both present → B before A\n- let ids = vec![\"a\".to_string(), \"b\".to_string()];\n- let mut metadata: HashMap = HashMap::new();\n- let mut depends = HashMap::new();\n- depends.insert(\"b\".to_string(), serde_json::json!({}));\n- metadata.insert(\"a\".to_string(), FeatureMetadata {\n- id: Some(\"a\".to_string()),\n- name: None,\n- version: None,\n- options: HashMap::new(),\n- installs_after: Vec::new(),\n- depends_on: depends,\n- container_env: HashMap::new(),\n- on_create_command: None,\n- post_create_command: None,\n- post_start_command: None,\n- });\n- metadata.insert(\"b\".to_string(), FeatureMetadata {\n- id: Some(\"b\".to_string()),\n- name: None,\n- version: None,\n- options: HashMap::new(),\n- installs_after: Vec::new(),\n- depends_on: HashMap::new(),\n- container_env: HashMap::new(),\n- on_create_command: None,\n- post_create_command: None,\n- post_start_command: None,\n- });\n-\n- let sorted = topo_sort(&ids, &metadata);\n- assert_eq!(sorted, vec![\"b\", \"a\"]);\n- }\n-\n- #[test]\n- fn topo_sort_depends_on_and_installs_after_deduped() {\n- // A has both dependsOn B and installsAfter B — should not double-count\n- let ids = vec![\"a\".to_string(), \"b\".to_string()];\n- let mut metadata: HashMap = HashMap::new();\n- let mut depends = HashMap::new();\n- depends.insert(\"b\".to_string(), serde_json::json!({}));\n- metadata.insert(\"a\".to_string(), FeatureMetadata {\n- id: Some(\"a\".to_string()),\n- name: None,\n- version: None,\n- options: HashMap::new(),\n- installs_after: vec![\"b\".to_string()],\n- depends_on: depends,\n- container_env: HashMap::new(),\n- on_create_command: None,\n- post_create_command: None,\n- post_start_command: None,\n- });\n- metadata.insert(\"b\".to_string(), FeatureMetadata {\n- id: Some(\"b\".to_string()),\n- name: None,\n- version: None,\n- options: HashMap::new(),\n- installs_after: Vec::new(),\n- depends_on: HashMap::new(),\n- container_env: HashMap::new(),\n- on_create_command: None,\n- post_create_command: None,\n- post_start_command: None,\n- });\n-\n- let sorted = topo_sort(&ids, &metadata);\n- assert_eq!(sorted, vec![\"b\", \"a\"]);\n- }\n-\n- #[tokio::test]\n- #[ignore = \"requires oras\"]\n- #[expect(\n- clippy::disallowed_methods,\n- reason = \"Ignored OCI integration test is opt-in via a documented process-env flag.\"\n- )]\n- async fn fetch_feature_oci_integration() {\n- if std::env::var_os(EnvVars::FABRO_ENABLE_FETCH_FEATURE_OCI_INTEGRATION).is_none() {\n- return;\n- }\n-\n- let tmp = tempfile::tempdir().unwrap();\n- let metadata = fetch_feature_oci(\"ghcr.io/devcontainers/features/node:1\", tmp.path())\n- .await\n- .unwrap();\n- assert!(metadata.id.is_some());\n- assert!(tmp.path().join(\"node/install.sh\").exists());\n- }\n-\n- #[tokio::test]\n- #[ignore = \"requires oras\"]\n- async fn resolve_features_integration() {\n- let tmp = tempfile::tempdir().unwrap();\n- let mut features = HashMap::new();\n- features.insert(\n- \"ghcr.io/devcontainers/features/node:1\".to_string(),\n- serde_json::json!({\"version\": \"20\"}),\n- );\n- let resolved = resolve_features(&features, tmp.path(), None).await.unwrap();\n- assert_eq!(resolved.layers.len(), 1);\n- assert_eq!(resolved.layers[0].dir_name, \"node\");\n- assert!(\n- resolved.layers[0]\n- .dockerfile_snippet\n- .contains(\"export VERSION=\\\"20\\\"\")\n- );\n- }\n-\n- #[test]\n- fn feature_container_env_collected() {\n- // Simulate what resolve_features does: collect container_env from metadata in\n- // sort order\n- let mut resolved = ResolvedFeatures::default();\n-\n- let meta_a = FeatureMetadata {\n- id: Some(\"a\".to_string()),\n- name: None,\n- version: None,\n- options: HashMap::new(),\n- installs_after: Vec::new(),\n- depends_on: HashMap::new(),\n- container_env: {\n- let mut env = HashMap::new();\n- env.insert(\"FOO\".to_string(), \"from_a\".to_string());\n- env.insert(\"BAR\".to_string(), \"from_a\".to_string());\n- env\n- },\n- on_create_command: None,\n- post_create_command: None,\n- post_start_command: None,\n- };\n- let meta_b = FeatureMetadata {\n- id: Some(\"b\".to_string()),\n- name: None,\n- version: None,\n- options: HashMap::new(),\n- installs_after: Vec::new(),\n- depends_on: HashMap::new(),\n- container_env: {\n- let mut env = HashMap::new();\n- env.insert(\"FOO\".to_string(), \"from_b\".to_string());\n- env\n- },\n- on_create_command: None,\n- post_create_command: None,\n- post_start_command: None,\n- };\n-\n- // A is sorted first, then B — B's FOO overrides A's\n- for meta in [&meta_a, &meta_b] {\n- for (k, v) in &meta.container_env {\n- resolved.container_env.insert(k.clone(), v.clone());\n- }\n- }\n-\n- assert_eq!(\n- resolved.container_env.get(\"FOO\").map(String::as_str),\n- Some(\"from_b\")\n- );\n- assert_eq!(\n- resolved.container_env.get(\"BAR\").map(String::as_str),\n- Some(\"from_a\")\n- );\n- }\n-\n- #[test]\n- fn feature_lifecycle_hooks_collected() {\n- let mut resolved = ResolvedFeatures::default();\n-\n- let cmds = [\n- LifecycleCommand::String(\"setup-a\".to_string()),\n- LifecycleCommand::Array(vec![\"make\".to_string(), \"build\".to_string()]),\n- ];\n-\n- // Simulate collecting from two features\n- resolved.on_create_commands.push(cmds[0].clone());\n- resolved.post_create_commands.push(cmds[1].clone());\n- resolved.post_start_commands.push(cmds[0].clone());\n-\n- assert_eq!(resolved.on_create_commands.len(), 1);\n- assert!(\n- matches!(&resolved.on_create_commands[0], LifecycleCommand::String(s) if s == \"setup-a\")\n- );\n- assert_eq!(resolved.post_create_commands.len(), 1);\n- assert!(\n- matches!(&resolved.post_create_commands[0], LifecycleCommand::Array(arr) if arr.len() == 2)\n- );\n- assert_eq!(resolved.post_start_commands.len(), 1);\n- }\n-\n- #[test]\n- fn option_id_to_env_name_hyphenated() {\n- assert_eq!(option_id_to_env_name(\"node-version\"), \"NODE_VERSION\");\n- }\n-\n- #[test]\n- fn option_id_to_env_name_leading_digit() {\n- assert_eq!(option_id_to_env_name(\"2fast\"), \"FAST\");\n- }\n-\n- #[test]\n- fn option_id_to_env_name_simple() {\n- assert_eq!(option_id_to_env_name(\"simple\"), \"SIMPLE\");\n- }\n-\n- #[test]\n- fn generate_layer_shorthand_version() {\n- let options = serde_json::json!(\"20\");\n- let mut meta_options = HashMap::new();\n- meta_options.insert(\"version\".to_string(), FeatureOption {\n- option_type: Some(\"string\".to_string()),\n- default: Some(serde_json::Value::String(\"lts\".to_string())),\n- description: Some(\"Node.js version\".to_string()),\n- });\n- let metadata = FeatureMetadata {\n- id: Some(\"node\".to_string()),\n- name: None,\n- version: None,\n- options: meta_options,\n- installs_after: Vec::new(),\n- depends_on: HashMap::new(),\n- container_env: HashMap::new(),\n- on_create_command: None,\n- post_create_command: None,\n- post_start_command: None,\n- };\n-\n- let snippet = generate_layer(\n- \"ghcr.io/devcontainers/features/node:1\",\n- \"node\",\n- &options,\n- &metadata,\n- None,\n- );\n-\n- assert!(snippet.contains(\"export VERSION=\\\"20\\\"\"));\n- }\n-\n- #[test]\n- fn generate_layer_install_env_vars() {\n- let options = serde_json::json!({});\n- let metadata = FeatureMetadata {\n- id: Some(\"node\".to_string()),\n- name: None,\n- version: None,\n- options: HashMap::new(),\n- installs_after: Vec::new(),\n- depends_on: HashMap::new(),\n- container_env: HashMap::new(),\n- on_create_command: None,\n- post_create_command: None,\n- post_start_command: None,\n- };\n-\n- let snippet = generate_layer(\n- \"ghcr.io/devcontainers/features/node:1\",\n- \"node\",\n- &options,\n- &metadata,\n- Some(\"vscode\"),\n- );\n-\n- assert!(snippet.contains(\"_REMOTE_USER=\\\"vscode\\\"\"));\n- assert!(snippet.contains(\"_CONTAINER_USER=\\\"root\\\"\"));\n- assert!(snippet.contains(\"_REMOTE_USER_HOME=\\\"/home/vscode\\\"\"));\n- assert!(snippet.contains(\"_CONTAINER_USER_HOME=\\\"/root\\\"\"));\n- }\n-}\ndiff --git a/lib/crates/fabro-devcontainer/src/jsonc.rs b/lib/crates/fabro-devcontainer/src/jsonc.rs\ndeleted file mode 100644\nindex e219bc61c..000000000\n--- a/lib/crates/fabro-devcontainer/src/jsonc.rs\n+++ /dev/null\n@@ -1,280 +0,0 @@\n-/// Strip JSONC comments and trailing commas, producing valid JSON.\n-pub(crate) fn strip_jsonc(input: &str) -> String {\n- let mut out = String::with_capacity(input.len());\n- let bytes = input.as_bytes();\n- let len = bytes.len();\n- let mut i = 0;\n-\n- while i < len {\n- match bytes[i] {\n- // String literal — copy verbatim (including any comment-like content)\n- b'\"' => {\n- out.push('\"');\n- i += 1;\n- while i < len {\n- match bytes[i] {\n- b'\\\\' => {\n- // Escaped character — copy both backslash and next char\n- out.push('\\\\');\n- i += 1;\n- if i < len {\n- out.push(bytes[i] as char);\n- i += 1;\n- }\n- }\n- b'\"' => {\n- out.push('\"');\n- i += 1;\n- break;\n- }\n- _ => {\n- out.push(bytes[i] as char);\n- i += 1;\n- }\n- }\n- }\n- }\n-\n- // Potential comment start\n- b'/' if i + 1 < len => {\n- match bytes[i + 1] {\n- // Line comment — skip until end of line\n- b'/' => {\n- i += 2;\n- while i < len && bytes[i] != b'\\n' {\n- i += 1;\n- }\n- }\n- // Block comment — skip until */\n- b'*' => {\n- i += 2;\n- while i + 1 < len {\n- if bytes[i] == b'*' && bytes[i + 1] == b'/' {\n- i += 2;\n- break;\n- }\n- i += 1;\n- }\n- // Handle unterminated block comment at end of input\n- if i >= len {\n- break;\n- }\n- }\n- _ => {\n- out.push('/');\n- i += 1;\n- }\n- }\n- }\n-\n- // Comma — check if it's a trailing comma before } or ]\n- b',' => {\n- // Look ahead past whitespace for } or ]\n- let mut j = i + 1;\n- while j < len && bytes[j].is_ascii_whitespace() {\n- j += 1;\n- }\n- // Also skip comments after the comma\n- while j < len {\n- if j + 1 < len && bytes[j] == b'/' && bytes[j + 1] == b'/' {\n- j += 2;\n- while j < len && bytes[j] != b'\\n' {\n- j += 1;\n- }\n- while j < len && bytes[j].is_ascii_whitespace() {\n- j += 1;\n- }\n- } else if j + 1 < len && bytes[j] == b'/' && bytes[j + 1] == b'*' {\n- j += 2;\n- while j + 1 < len {\n- if bytes[j] == b'*' && bytes[j + 1] == b'/' {\n- j += 2;\n- break;\n- }\n- j += 1;\n- }\n- while j < len && bytes[j].is_ascii_whitespace() {\n- j += 1;\n- }\n- } else {\n- break;\n- }\n- }\n-\n- if j < len && (bytes[j] == b'}' || bytes[j] == b']') {\n- // Trailing comma — skip it\n- i += 1;\n- } else {\n- out.push(',');\n- i += 1;\n- }\n- }\n-\n- _ => {\n- out.push(bytes[i] as char);\n- i += 1;\n- }\n- }\n- }\n-\n- out\n-}\n-\n-#[cfg(test)]\n-mod tests {\n- use super::*;\n-\n- #[test]\n- fn passthrough_valid_json() {\n- let json = r#\"{\"key\": \"value\"}\"#;\n- assert_eq!(strip_jsonc(json), json);\n- }\n-\n- #[test]\n- fn strip_line_comments() {\n- let input = r#\"{\n- // this is a comment\n- \"key\": \"value\"\n-}\"#;\n- // Leading whitespace on the comment line remains but that's valid JSON\n- let expected = \"{\\n \\n \\\"key\\\": \\\"value\\\"\\n}\";\n- assert_eq!(strip_jsonc(input), expected);\n- }\n-\n- #[test]\n- fn strip_line_comment_at_end_of_line() {\n- let input = r#\"{\"key\": \"value\" // inline comment\n-}\"#;\n- // Space before the comment remains\n- let expected = \"{\\\"key\\\": \\\"value\\\" \\n}\";\n- assert_eq!(strip_jsonc(input), expected);\n- }\n-\n- #[test]\n- fn strip_block_comments() {\n- let input = r#\"{\"key\": /* comment */ \"value\"}\"#;\n- let expected = r#\"{\"key\": \"value\"}\"#;\n- assert_eq!(strip_jsonc(input), expected);\n- }\n-\n- #[test]\n- fn strip_multiline_block_comment() {\n- let input = r#\"{\n- /* this is\n- a multi-line\n- comment */\n- \"key\": \"value\"\n-}\"#;\n- // Leading whitespace before block comment remains\n- let expected = \"{\\n \\n \\\"key\\\": \\\"value\\\"\\n}\";\n- assert_eq!(strip_jsonc(input), expected);\n- }\n-\n- #[test]\n- fn strip_trailing_comma_before_brace() {\n- let input = r#\"{\"a\": 1, \"b\": 2,}\"#;\n- let expected = r#\"{\"a\": 1, \"b\": 2}\"#;\n- assert_eq!(strip_jsonc(input), expected);\n- }\n-\n- #[test]\n- fn strip_trailing_comma_before_bracket() {\n- let input = r\"[1, 2, 3,]\";\n- let expected = r\"[1, 2, 3]\";\n- assert_eq!(strip_jsonc(input), expected);\n- }\n-\n- #[test]\n- fn trailing_comma_with_whitespace() {\n- let input = r#\"{\n- \"a\": 1,\n- \"b\": 2,\n-}\"#;\n- let expected = r#\"{\n- \"a\": 1,\n- \"b\": 2\n-}\"#;\n- assert_eq!(strip_jsonc(input), expected);\n- }\n-\n- #[test]\n- fn comments_inside_strings_preserved() {\n- let input = r#\"{\"key\": \"value // not a comment\"}\"#;\n- assert_eq!(strip_jsonc(input), input);\n- }\n-\n- #[test]\n- fn block_comment_inside_string_preserved() {\n- let input = r#\"{\"key\": \"value /* not a comment */ still here\"}\"#;\n- assert_eq!(strip_jsonc(input), input);\n- }\n-\n- #[test]\n- fn mixed_comments_and_trailing_commas() {\n- let input = r#\"{\n- // first comment\n- \"name\": \"test\", /* inline */\n- \"items\": [\n- 1,\n- 2, // trailing\n- ],\n-}\"#;\n- // Whitespace around stripped comments remains; trailing commas removed\n- let expected = \"{\\n \\n \\\"name\\\": \\\"test\\\", \\n \\\"items\\\": [\\n 1,\\n 2 \\n ]\\n}\";\n- assert_eq!(strip_jsonc(input), expected);\n- }\n-\n- #[test]\n- fn empty_input() {\n- assert_eq!(strip_jsonc(\"\"), \"\");\n- }\n-\n- #[test]\n- fn escaped_quote_in_string() {\n- let input = r#\"{\"key\": \"val\\\"ue // not a comment\"}\"#;\n- assert_eq!(strip_jsonc(input), input);\n- }\n-\n- #[test]\n- fn trailing_comma_with_comment_before_close() {\n- let input = r#\"{\"a\": 1, // comment\n-}\"#;\n- // Trailing comma removed; space before comment remains\n- let expected = \"{\\\"a\\\": 1 \\n}\";\n- assert_eq!(strip_jsonc(input), expected);\n- }\n-\n- #[test]\n- fn trailing_comma_with_block_comment_before_close() {\n- let input = r#\"{\"a\": 1, /* comment */ }\"#;\n- // Trailing comma removed; spaces around stripped comment remain\n- let expected = \"{\\\"a\\\": 1 }\";\n- assert_eq!(strip_jsonc(input), expected);\n- }\n-\n- #[test]\n- fn only_comments() {\n- let input = \"// just a comment\\n/* block */\";\n- let expected = \"\\n\";\n- assert_eq!(strip_jsonc(input), expected);\n- }\n-\n- #[test]\n- fn produces_valid_json() {\n- let input = r#\"{\n- // devcontainer settings\n- \"image\": \"mcr.microsoft.com/devcontainers/base:ubuntu\",\n- \"features\": {\n- \"ghcr.io/devcontainers/features/rust:1\": {},\n- },\n- /* forwarded ports */\n- \"forwardPorts\": [3000, 8080,],\n- \"remoteEnv\": {\n- \"EDITOR\": \"code\", // default editor\n- },\n-}\"#;\n- let result = strip_jsonc(input);\n- let parsed: serde_json::Result = serde_json::from_str(&result);\n- assert!(parsed.is_ok(), \"should produce valid JSON, got: {result}\");\n- }\n-}\ndiff --git a/lib/crates/fabro-devcontainer/src/lib.rs b/lib/crates/fabro-devcontainer/src/lib.rs\ndeleted file mode 100644\nindex e1926152e..000000000\n--- a/lib/crates/fabro-devcontainer/src/lib.rs\n+++ /dev/null\n@@ -1,678 +0,0 @@\n-#![allow(\n- dead_code,\n- reason = \"This crate keeps parsing helpers available while integration points are still landing.\"\n-)]\n-\n-mod compose;\n-mod dockerfile;\n-mod features;\n-mod jsonc;\n-mod types;\n-mod variables;\n-\n-use std::collections::HashMap;\n-use std::path::{Path, PathBuf};\n-\n-use fabro_util::env::SystemEnv;\n-use tokio::fs;\n-pub use types::DevcontainerJson;\n-\n-/// Lifecycle command — string, array, or object (parallel) form.\n-#[derive(Debug, Clone, PartialEq)]\n-pub enum Command {\n- Shell(String),\n- Args(Vec),\n- Parallel(HashMap),\n-}\n-\n-/// Parsed and resolved devcontainer configuration — everything needed to create\n-/// a sandbox.\n-#[derive(Debug, Clone)]\n-pub struct DevcontainerSpec {\n- /// Generated Dockerfile content\n- pub dockerfile: String,\n- /// Directory for docker build context\n- pub build_context: PathBuf,\n- /// Build arguments (docker build --build-arg)\n- pub build_args: HashMap,\n- /// Multi-stage build target (docker build --target)\n- pub build_target: Option,\n- /// Run on host before build\n- pub initialize_commands: Vec,\n- /// Run in container after first creation (before updateContentCommand)\n- pub on_create_commands: Vec,\n- /// Run in container after creation\n- pub post_create_commands: Vec,\n- /// Run in container on each start\n- pub post_start_commands: Vec,\n- /// remoteEnv merged\n- pub environment: HashMap,\n- /// containerEnv — baked into Dockerfile as ENV directives\n- pub container_env: HashMap,\n- pub remote_user: Option,\n- /// default: /workspaces/{repo-name}\n- pub workspace_folder: String,\n- /// first = default preview port\n- pub forwarded_ports: Vec,\n- /// Compose file paths (empty if not in compose mode)\n- pub compose_files: Vec,\n- pub compose_service: Option,\n-}\n-\n-#[derive(Debug, thiserror::Error)]\n-pub enum DevcontainerError {\n- #[error(\"no devcontainer.json found in {0}\")]\n- NotFound(PathBuf),\n-\n- #[error(\"parsing devcontainer.json: {0}\")]\n- Parse(#[from] serde_json::Error),\n-\n- #[error(\"reading file {path}: {source}\")]\n- ReadFile {\n- path: PathBuf,\n- source: std::io::Error,\n- },\n-\n- #[error(\"compose file error: {0}\")]\n- Compose(String),\n-\n- #[error(\"feature error: {0}\")]\n- Feature(String),\n-\n- #[error(\"oras not found and auto-install failed: {0}\")]\n- OrasInstall(String),\n-\n- #[error(\"oras command failed: {0}\")]\n- OrasCommand(String),\n-\n- #[error(\"variable substitution error: {0}\")]\n- Variable(String),\n-\n- #[error(\n- \"base Dockerfile contains COPY or ADD instructions that reference build context files, which is not supported by Daytona snapshots: {0}\"\n- )]\n- UnsupportedCopyAdd(String),\n-}\n-\n-pub type Result = std::result::Result;\n-\n-/// Check that a Dockerfile does not contain COPY or ADD instructions that\n-/// reference build context files. Multi-stage `COPY --from=` and `ADD\n-/// http(s)://` are allowed.\n-fn check_no_build_context_copies(dockerfile: &str) -> Result<()> {\n- let mut offending = Vec::new();\n- let mut continuation = String::new();\n-\n- for raw_line in dockerfile.lines() {\n- let trimmed = raw_line.trim();\n-\n- // Handle line continuations\n- if !continuation.is_empty() {\n- continuation.push(' ');\n- continuation.push_str(trimmed);\n- if trimmed.ends_with('\\\\') {\n- continuation.truncate(continuation.len() - 1);\n- continue;\n- }\n- let full_line = std::mem::take(&mut continuation);\n- check_single_line(&full_line, &mut offending);\n- continue;\n- }\n-\n- if trimmed.is_empty() || trimmed.starts_with('#') {\n- continue;\n- }\n-\n- if trimmed.ends_with('\\\\') {\n- continuation = trimmed.trim_end_matches('\\\\').to_string();\n- continue;\n- }\n-\n- check_single_line(trimmed, &mut offending);\n- }\n-\n- // Handle unterminated continuation\n- if !continuation.is_empty() {\n- check_single_line(&continuation, &mut offending);\n- }\n-\n- if offending.is_empty() {\n- Ok(())\n- } else {\n- Err(DevcontainerError::UnsupportedCopyAdd(offending.join(\"; \")))\n- }\n-}\n-\n-fn check_single_line(line: &str, offending: &mut Vec) {\n- let upper = line.to_ascii_uppercase();\n- if upper.starts_with(\"COPY \") {\n- // Allow COPY --from=\n- let rest = line[5..].trim_start();\n- if !rest.starts_with(\"--from=\") && !rest.to_ascii_uppercase().starts_with(\"--FROM=\") {\n- offending.push(line.to_string());\n- }\n- } else if upper.starts_with(\"ADD \") {\n- // Allow ADD http:// or https://\n- let rest = line[4..].trim_start();\n- if !rest.starts_with(\"http://\") && !rest.starts_with(\"https://\") {\n- offending.push(line.to_string());\n- }\n- }\n-}\n-\n-/// Parse and resolve a devcontainer config from a repo directory.\n-pub struct DevcontainerResolver;\n-\n-impl DevcontainerResolver {\n- /// path: repo root (or explicit .devcontainer/ path)\n- pub async fn resolve(path: &Path) -> Result {\n- let (json_path, devcontainer) = Self::find_and_parse(path).await?;\n- let repo_root = Self::repo_root_from_json_path(&json_path, path);\n- let base_dir = json_path.parent().unwrap_or(path);\n-\n- let repo_name = repo_root\n- .file_name()\n- .and_then(|n| n.to_str())\n- .unwrap_or(\"workspace\")\n- .to_string();\n-\n- // Variable substitution — two-pass: first resolve workspace_folder itself,\n- // then create final context with the resolved value.\n- let raw_workspace_folder = devcontainer\n- .workspace_folder\n- .clone()\n- .unwrap_or_else(|| format!(\"/workspaces/{repo_name}\"));\n-\n- let system_env = SystemEnv;\n- let preliminary_vars = variables::VariableContext {\n- local_workspace_folder: repo_root.to_string_lossy().to_string(),\n- local_workspace_folder_basename: repo_name.clone(),\n- container_workspace_folder: raw_workspace_folder.clone(),\n- env: &system_env,\n- };\n- let workspace_folder = variables::substitute(&raw_workspace_folder, &preliminary_vars);\n-\n- let vars = variables::VariableContext {\n- local_workspace_folder: repo_root.to_string_lossy().to_string(),\n- local_workspace_folder_basename: repo_name.clone(),\n- container_workspace_folder: workspace_folder.clone(),\n- env: &system_env,\n- };\n-\n- // Handle compose mode\n- if let Some(compose_ref) = &devcontainer.docker_compose_file {\n- let compose_paths: Vec = compose_ref\n- .paths()\n- .iter()\n- .map(|p| base_dir.join(variables::substitute(p, &vars)))\n- .collect();\n- let service_name = devcontainer\n- .service\n- .as_ref()\n- .ok_or_else(|| {\n- DevcontainerError::Compose(\n- \"dockerComposeFile requires 'service' field\".to_string(),\n- )\n- })?\n- .clone();\n-\n- let compose_config = compose::parse_compose_multi(&compose_paths, &service_name)\n- .await\n- .map_err(DevcontainerError::Compose)?;\n-\n- let mut environment = HashMap::new();\n- for (k, v) in compose_config.environment {\n- environment.insert(k, variables::substitute(&v, &vars));\n- }\n- if let Some(env) = &devcontainer.remote_env {\n- for (k, v) in env {\n- environment.insert(k.clone(), variables::substitute(v, &vars));\n- }\n- }\n-\n- // Use the first compose file's parent as build context base\n- let compose_base_dir = compose_paths\n- .first()\n- .and_then(|p| p.parent())\n- .unwrap_or(base_dir);\n-\n- let dockerfile = if let Some(build) = &compose_config.build {\n- let df_path = compose_base_dir\n- .join(&build.context)\n- .join(build.dockerfile.as_deref().unwrap_or(\"Dockerfile\"));\n- fs::read_to_string(&df_path).await.map_err(|source| {\n- DevcontainerError::ReadFile {\n- path: df_path,\n- source,\n- }\n- })?\n- } else {\n- format!(\n- \"FROM {}\",\n- compose_config.image.as_deref().unwrap_or(\"ubuntu\")\n- )\n- };\n-\n- check_no_build_context_copies(&dockerfile)?;\n-\n- return Ok(DevcontainerSpec {\n- dockerfile,\n- build_context: compose_base_dir.to_path_buf(),\n- build_args: HashMap::new(),\n- build_target: None,\n- initialize_commands: Self::collect_commands(\n- devcontainer.initialize_command.as_ref(),\n- &vars,\n- ),\n- on_create_commands: Self::collect_commands(\n- devcontainer.on_create_command.as_ref(),\n- &vars,\n- ),\n- post_create_commands: Self::collect_commands(\n- devcontainer.post_create_command.as_ref(),\n- &vars,\n- ),\n- post_start_commands: Self::collect_commands(\n- devcontainer.post_start_command.as_ref(),\n- &vars,\n- ),\n- environment,\n- container_env: Self::collect_container_env(\n- devcontainer.container_env.as_ref(),\n- &vars,\n- ),\n- remote_user: devcontainer.remote_user.clone().or(compose_config.user),\n- workspace_folder,\n- forwarded_ports: {\n- let mut ports = compose_config.ports;\n- for port in Self::parse_forward_ports(&devcontainer.forward_ports) {\n- if !ports.contains(&port) {\n- ports.push(port);\n- }\n- }\n- ports\n- },\n- compose_files: compose_paths,\n- compose_service: Some(service_name),\n- });\n- }\n-\n- // Image or Dockerfile mode\n- let (base_dockerfile, build_context, build_args, build_target) =\n- if let Some(build) = &devcontainer.build {\n- let context_dir = build.context.as_ref().map_or_else(\n- || base_dir.to_path_buf(),\n- |c| base_dir.join(variables::substitute(c, &vars)),\n- );\n- let df_path = base_dir.join(variables::substitute(\n- build.dockerfile.as_deref().unwrap_or(\"Dockerfile\"),\n- &vars,\n- ));\n- let content = fs::read_to_string(&df_path).await.map_err(|source| {\n- DevcontainerError::ReadFile {\n- path: df_path,\n- source,\n- }\n- })?;\n- check_no_build_context_copies(&content)?;\n- let args: HashMap = build\n- .args\n- .iter()\n- .map(|(k, v)| (k.clone(), variables::substitute(v, &vars)))\n- .collect();\n- let target = build\n- .target\n- .as_ref()\n- .map(|t| variables::substitute(t, &vars));\n- (content, context_dir, args, target)\n- } else {\n- let image = devcontainer\n- .image\n- .as_deref()\n- .unwrap_or(\"mcr.microsoft.com/devcontainers/base:ubuntu\");\n- (\n- format!(\"FROM {image}\"),\n- base_dir.to_path_buf(),\n- HashMap::new(),\n- None,\n- )\n- };\n-\n- // Features\n- let resolved_features = if devcontainer.features.is_empty() {\n- features::ResolvedFeatures::default()\n- } else {\n- features::resolve_features(\n- &devcontainer.features,\n- base_dir,\n- devcontainer.remote_user.as_deref(),\n- )\n- .await?\n- };\n-\n- // Merge feature containerEnv with devcontainer.json containerEnv\n- // (devcontainer.json wins on conflicts)\n- let mut merged_container_env = resolved_features.container_env;\n- if let Some(env) = &devcontainer.container_env {\n- for (k, v) in env {\n- merged_container_env.insert(k.clone(), variables::substitute(v, &vars));\n- }\n- }\n- // Generate final Dockerfile\n- let dockerfile_content = dockerfile::generate(\n- &base_dockerfile,\n- &resolved_features.layers,\n- &merged_container_env,\n- devcontainer.remote_user.as_deref(),\n- );\n-\n- let mut environment = HashMap::new();\n- if let Some(env) = &devcontainer.remote_env {\n- for (k, v) in env {\n- environment.insert(k.clone(), variables::substitute(v, &vars));\n- }\n- }\n-\n- let forwarded_ports = Self::parse_forward_ports(&devcontainer.forward_ports);\n-\n- // Collect devcontainer.json lifecycle commands, then append feature lifecycle\n- // commands\n- let mut on_create_commands =\n- Self::collect_commands(devcontainer.on_create_command.as_ref(), &vars);\n- let mut post_create_commands =\n- Self::collect_commands(devcontainer.post_create_command.as_ref(), &vars);\n- let mut post_start_commands =\n- Self::collect_commands(devcontainer.post_start_command.as_ref(), &vars);\n-\n- for cmd in &resolved_features.on_create_commands {\n- on_create_commands.push(Self::convert_lifecycle_command(cmd));\n- }\n- for cmd in &resolved_features.post_create_commands {\n- post_create_commands.push(Self::convert_lifecycle_command(cmd));\n- }\n- for cmd in &resolved_features.post_start_commands {\n- post_start_commands.push(Self::convert_lifecycle_command(cmd));\n- }\n-\n- Ok(DevcontainerSpec {\n- dockerfile: dockerfile_content,\n- build_context,\n- build_args,\n- build_target,\n- initialize_commands: Self::collect_commands(\n- devcontainer.initialize_command.as_ref(),\n- &vars,\n- ),\n- on_create_commands,\n- post_create_commands,\n- post_start_commands,\n- environment,\n- container_env: merged_container_env,\n- remote_user: devcontainer.remote_user.clone(),\n- workspace_folder,\n- forwarded_ports,\n- compose_files: Vec::new(),\n- compose_service: None,\n- })\n- }\n-\n- async fn find_and_parse(path: &Path) -> Result<(PathBuf, DevcontainerJson)> {\n- // Check standard locations\n- let candidates = [\n- path.join(\".devcontainer/devcontainer.json\"),\n- path.join(\".devcontainer.json\"),\n- ];\n-\n- for candidate in &candidates {\n- if candidate.exists() {\n- let raw = fs::read_to_string(candidate).await.map_err(|source| {\n- DevcontainerError::ReadFile {\n- path: candidate.clone(),\n- source,\n- }\n- })?;\n- let stripped = jsonc::strip_jsonc(&raw);\n- let parsed: DevcontainerJson = serde_json::from_str(&stripped)?;\n- return Ok((candidate.clone(), parsed));\n- }\n- }\n-\n- // Check if path itself is a devcontainer.json\n- if path.is_file() && path.file_name().is_some_and(|n| n == \"devcontainer.json\") {\n- let raw =\n- fs::read_to_string(path)\n- .await\n- .map_err(|source| DevcontainerError::ReadFile {\n- path: path.to_path_buf(),\n- source,\n- })?;\n- let stripped = jsonc::strip_jsonc(&raw);\n- let parsed: DevcontainerJson = serde_json::from_str(&stripped)?;\n- return Ok((path.to_path_buf(), parsed));\n- }\n-\n- // Subdirectory format: scan .devcontainer/ for subdirs containing\n- // devcontainer.json\n- let devcontainer_dir = path.join(\".devcontainer\");\n- if devcontainer_dir.is_dir() {\n- let mut entries = fs::read_dir(&devcontainer_dir).await.map_err(|source| {\n- DevcontainerError::ReadFile {\n- path: devcontainer_dir.clone(),\n- source,\n- }\n- })?;\n- let mut subdirs = Vec::new();\n-\n- while let Some(entry) =\n- entries\n- .next_entry()\n- .await\n- .map_err(|source| DevcontainerError::ReadFile {\n- path: devcontainer_dir.clone(),\n- source,\n- })?\n- {\n- let entry_path = entry.path();\n- let file_type =\n- entry\n- .file_type()\n- .await\n- .map_err(|source| DevcontainerError::ReadFile {\n- path: entry_path.clone(),\n- source,\n- })?;\n- if file_type.is_dir() && entry_path.join(\"devcontainer.json\").exists() {\n- subdirs.push(entry_path);\n- }\n- }\n-\n- // Sort alphabetically to get deterministic first pick\n- subdirs.sort();\n-\n- if let Some(subdir) = subdirs.first() {\n- let candidate = subdir.join(\"devcontainer.json\");\n- let raw = fs::read_to_string(&candidate).await.map_err(|source| {\n- DevcontainerError::ReadFile {\n- path: candidate.clone(),\n- source,\n- }\n- })?;\n- let stripped = jsonc::strip_jsonc(&raw);\n- let parsed: DevcontainerJson = serde_json::from_str(&stripped)?;\n- return Ok((candidate, parsed));\n- }\n- }\n-\n- Err(DevcontainerError::NotFound(path.to_path_buf()))\n- }\n-\n- fn repo_root_from_json_path<'a>(json_path: &Path, original_path: &'a Path) -> &'a Path {\n- // If json_path is inside .devcontainer//, the repo root is two levels\n- // up If json_path is inside .devcontainer/, the repo root is one level\n- // up\n- if let Some(parent) = json_path.parent() {\n- if parent.file_name().is_some_and(|n| n == \".devcontainer\") {\n- if let Some(repo_root) = parent.parent() {\n- let _ = repo_root;\n- }\n- } else if let Some(grandparent) = parent.parent() {\n- if grandparent\n- .file_name()\n- .is_some_and(|n| n == \".devcontainer\")\n- {\n- if let Some(repo_root) = grandparent.parent() {\n- let _ = repo_root;\n- }\n- }\n- }\n- }\n- original_path\n- }\n-\n- fn collect_container_env(\n- env: Option<&HashMap>,\n- vars: &variables::VariableContext,\n- ) -> HashMap {\n- match env {\n- None => HashMap::new(),\n- Some(map) => map\n- .iter()\n- .map(|(k, v)| (k.clone(), variables::substitute(v, vars)))\n- .collect(),\n- }\n- }\n-\n- fn convert_lifecycle_command(cmd: &types::LifecycleCommand) -> Command {\n- match cmd {\n- types::LifecycleCommand::String(s) => Command::Shell(s.clone()),\n- types::LifecycleCommand::Array(arr) => Command::Args(arr.clone()),\n- types::LifecycleCommand::Object(map) => Command::Parallel(map.clone()),\n- }\n- }\n-\n- fn collect_commands(\n- cmd: Option<&types::LifecycleCommand>,\n- vars: &variables::VariableContext,\n- ) -> Vec {\n- match cmd {\n- None => Vec::new(),\n- Some(types::LifecycleCommand::String(s)) => {\n- vec![Command::Shell(variables::substitute(s, vars))]\n- }\n- Some(types::LifecycleCommand::Array(arr)) => {\n- vec![Command::Args(\n- arr.iter().map(|s| variables::substitute(s, vars)).collect(),\n- )]\n- }\n- Some(types::LifecycleCommand::Object(map)) => {\n- vec![Command::Parallel(\n- map.iter()\n- .map(|(k, v)| (k.clone(), variables::substitute(v, vars)))\n- .collect(),\n- )]\n- }\n- }\n- }\n-\n- fn parse_forward_ports(ports: &[serde_json::Value]) -> Vec {\n- ports\n- .iter()\n- .filter_map(|p| match p {\n- serde_json::Value::Number(n) => n.as_u64().and_then(|n| u16::try_from(n).ok()),\n- serde_json::Value::String(s) => {\n- let s = s.split('/').next().unwrap_or(s); // strip protocol\n- if let Some((_host, container)) = s.split_once(':') {\n- container.parse::().ok()\n- } else {\n- s.parse::().ok()\n- }\n- }\n- _ => None,\n- })\n- .collect()\n- }\n-}\n-\n-#[cfg(test)]\n-mod tests {\n- use super::*;\n-\n- #[test]\n- fn copy_local_file_is_rejected() {\n- let dockerfile = \"FROM ubuntu\\nCOPY . /app\\n\";\n- let err = check_no_build_context_copies(dockerfile).unwrap_err();\n- assert!(\n- matches!(err, DevcontainerError::UnsupportedCopyAdd(_)),\n- \"expected UnsupportedCopyAdd, got: {err:?}\"\n- );\n- assert!(err.to_string().contains(\"COPY . /app\"));\n- }\n-\n- #[test]\n- fn add_local_file_is_rejected() {\n- let dockerfile = \"FROM ubuntu\\nADD local.tar.gz /opt/\\n\";\n- let err = check_no_build_context_copies(dockerfile).unwrap_err();\n- assert!(err.to_string().contains(\"ADD local.tar.gz /opt/\"));\n- }\n-\n- #[test]\n- fn copy_from_stage_is_allowed() {\n- let dockerfile =\n- \"FROM builder AS build\\nRUN make\\nFROM ubuntu\\nCOPY --from=builder /app /app\\n\";\n- check_no_build_context_copies(dockerfile).unwrap();\n- }\n-\n- #[test]\n- fn add_url_is_allowed() {\n- let dockerfile = \"FROM ubuntu\\nADD https://example.com/file.tar.gz /opt/\\n\";\n- check_no_build_context_copies(dockerfile).unwrap();\n- }\n-\n- #[test]\n- fn add_http_url_is_allowed() {\n- let dockerfile = \"FROM ubuntu\\nADD http://example.com/file.tar.gz /opt/\\n\";\n- check_no_build_context_copies(dockerfile).unwrap();\n- }\n-\n- #[test]\n- fn only_from_and_run_is_allowed() {\n- let dockerfile = \"FROM ubuntu\\nRUN apt-get update\\nENV FOO=bar\\n\";\n- check_no_build_context_copies(dockerfile).unwrap();\n- }\n-\n- #[test]\n- fn multiline_continuation_copy_is_rejected() {\n- let dockerfile = \"FROM ubuntu\\nCOPY \\\\\\n . /app\\n\";\n- let err = check_no_build_context_copies(dockerfile).unwrap_err();\n- assert!(matches!(err, DevcontainerError::UnsupportedCopyAdd(_)));\n- }\n-\n- #[test]\n- fn case_insensitive_copy_is_rejected() {\n- let dockerfile = \"FROM ubuntu\\ncopy . /app\\n\";\n- let err = check_no_build_context_copies(dockerfile).unwrap_err();\n- assert!(err.to_string().contains(\"copy . /app\"));\n- }\n-\n- #[test]\n- fn case_insensitive_add_is_rejected() {\n- let dockerfile = \"FROM ubuntu\\nadd local.tar.gz /opt/\\n\";\n- let err = check_no_build_context_copies(dockerfile).unwrap_err();\n- assert!(err.to_string().contains(\"add local.tar.gz /opt/\"));\n- }\n-\n- #[test]\n- fn multiple_offending_lines_reported() {\n- let dockerfile = \"FROM ubuntu\\nCOPY . /app\\nADD foo.tar /opt/\\n\";\n- let err = check_no_build_context_copies(dockerfile).unwrap_err();\n- let msg = err.to_string();\n- assert!(msg.contains(\"COPY . /app\"));\n- assert!(msg.contains(\"ADD foo.tar /opt/\"));\n- }\n-\n- #[test]\n- fn comments_and_empty_lines_are_skipped() {\n- let dockerfile = \"FROM ubuntu\\n\\n# COPY . /app\\n \\nRUN echo hi\\n\";\n- check_no_build_context_copies(dockerfile).unwrap();\n- }\n-}\ndiff --git a/lib/crates/fabro-devcontainer/src/types.rs b/lib/crates/fabro-devcontainer/src/types.rs\ndeleted file mode 100644\nindex 2e97d4e43..000000000\n--- a/lib/crates/fabro-devcontainer/src/types.rs\n+++ /dev/null\n@@ -1,336 +0,0 @@\n-use std::collections::HashMap;\n-\n-use serde::Deserialize;\n-\n-/// Top-level devcontainer.json schema (subset of the spec we support).\n-#[derive(Debug, Clone, Deserialize, Default)]\n-#[serde(rename_all = \"camelCase\")]\n-pub struct DevcontainerJson {\n- /// Base image (image mode)\n- pub image: Option,\n-\n- /// Dockerfile build config\n- pub build: Option,\n-\n- /// Docker Compose file path(s) (compose mode)\n- pub docker_compose_file: Option,\n-\n- /// Service name for compose mode\n- pub service: Option,\n-\n- /// Features to install: feature ID → options object\n- #[serde(default)]\n- pub features: HashMap,\n-\n- /// Ports to forward\n- #[serde(default, alias = \"forwardPorts\")]\n- pub forward_ports: Vec,\n-\n- /// Environment variables set in the container\n- #[serde(default)]\n- pub remote_env: Option>,\n-\n- /// Environment variables set in the container (containerEnv)\n- #[serde(default)]\n- pub container_env: Option>,\n-\n- /// Non-root user to run as\n- pub remote_user: Option,\n-\n- /// Container user\n- pub container_user: Option,\n-\n- /// Workspace folder path inside container\n- pub workspace_folder: Option,\n-\n- /// Workspace mount string\n- pub workspace_mount: Option,\n-\n- /// Run on host before anything else\n- pub initialize_command: Option,\n-\n- /// Run in container after first creation (before updateContentCommand)\n- pub on_create_command: Option,\n-\n- /// Run in container after creation\n- pub post_create_command: Option,\n-\n- /// Run in container on every start\n- pub post_start_command: Option,\n-\n- /// Override the default command\n- pub override_command: Option,\n-}\n-\n-/// Build configuration for Dockerfile mode.\n-#[derive(Debug, Clone, Deserialize)]\n-pub struct BuildSpec {\n- /// Path to Dockerfile (relative to devcontainer.json)\n- pub dockerfile: Option,\n-\n- /// Build context directory (relative to devcontainer.json)\n- pub context: Option,\n-\n- /// Build arguments\n- #[serde(default)]\n- pub args: HashMap,\n-\n- /// Multi-stage build target\n- pub target: Option,\n-}\n-\n-/// A reference to one or more Docker Compose files.\n-#[derive(Debug, Clone, Deserialize)]\n-#[serde(untagged)]\n-pub enum ComposeFileRef {\n- Single(String),\n- Multiple(Vec),\n-}\n-\n-impl ComposeFileRef {\n- pub fn paths(&self) -> Vec<&str> {\n- match self {\n- Self::Single(s) => vec![s.as_str()],\n- Self::Multiple(v) => v.iter().map(String::as_str).collect(),\n- }\n- }\n-}\n-\n-/// A lifecycle command can be a string, array of strings, or object of named\n-/// commands.\n-#[derive(Debug, Clone, Deserialize)]\n-#[serde(untagged)]\n-pub enum LifecycleCommand {\n- String(String),\n- Array(Vec),\n- Object(HashMap),\n-}\n-\n-/// Metadata from a devcontainer-feature.json file.\n-#[derive(Debug, Clone, Deserialize)]\n-#[serde(rename_all = \"camelCase\")]\n-pub(crate) struct FeatureMetadata {\n- pub id: Option,\n- pub name: Option,\n- pub version: Option,\n-\n- #[serde(default)]\n- pub options: HashMap,\n-\n- /// Feature IDs that this feature should be installed after\n- #[serde(default)]\n- pub installs_after: Vec,\n-\n- /// Hard dependencies: feature IDs that must be present (auto-installed if\n- /// missing)\n- #[serde(default)]\n- pub depends_on: HashMap,\n-\n- /// Environment variables contributed by this feature\n- #[serde(default)]\n- pub container_env: HashMap,\n-\n- /// Lifecycle hooks contributed by this feature\n- pub on_create_command: Option,\n- pub post_create_command: Option,\n- pub post_start_command: Option,\n-}\n-\n-/// A single option for a devcontainer feature.\n-#[derive(Debug, Clone, Deserialize)]\n-pub(crate) struct FeatureOption {\n- #[serde(rename = \"type\")]\n- pub option_type: Option,\n- pub default: Option,\n- pub description: Option,\n-}\n-\n-#[cfg(test)]\n-mod tests {\n- use super::*;\n-\n- #[test]\n- fn parse_image_only() {\n- let json = r#\"{\"image\": \"mcr.microsoft.com/devcontainers/base:ubuntu\"}\"#;\n- let config: DevcontainerJson = serde_json::from_str(json).unwrap();\n- assert_eq!(\n- config.image.as_deref(),\n- Some(\"mcr.microsoft.com/devcontainers/base:ubuntu\")\n- );\n- }\n-\n- #[test]\n- fn parse_with_features() {\n- let json = r#\"{\n- \"image\": \"ubuntu\",\n- \"features\": {\n- \"ghcr.io/devcontainers/features/node:1\": {\"version\": \"20\"},\n- \"ghcr.io/devcontainers/features/python:1\": {}\n- }\n- }\"#;\n- let config: DevcontainerJson = serde_json::from_str(json).unwrap();\n- assert_eq!(config.features.len(), 2);\n- }\n-\n- #[test]\n- fn parse_lifecycle_string() {\n- let json = r#\"{\"postCreateCommand\": \"npm install\"}\"#;\n- let config: DevcontainerJson = serde_json::from_str(json).unwrap();\n- assert!(matches!(\n- config.post_create_command,\n- Some(LifecycleCommand::String(ref s)) if s == \"npm install\"\n- ));\n- }\n-\n- #[test]\n- fn parse_lifecycle_array() {\n- let json = r#\"{\"postCreateCommand\": [\"npm\", \"install\"]}\"#;\n- let config: DevcontainerJson = serde_json::from_str(json).unwrap();\n- assert!(matches!(\n- config.post_create_command,\n- Some(LifecycleCommand::Array(ref arr)) if arr == &[\"npm\", \"install\"]\n- ));\n- }\n-\n- #[test]\n- fn parse_lifecycle_object() {\n- let json = r#\"{\"postCreateCommand\": {\"install\": \"npm install\", \"build\": \"npm run build\"}}\"#;\n- let config: DevcontainerJson = serde_json::from_str(json).unwrap();\n- assert!(matches!(\n- config.post_create_command,\n- Some(LifecycleCommand::Object(ref map)) if map.len() == 2\n- ));\n- }\n-\n- #[test]\n- fn parse_build_config() {\n- let json = r#\"{\n- \"build\": {\n- \"dockerfile\": \"Dockerfile\",\n- \"context\": \"..\",\n- \"args\": {\"VARIANT\": \"3.9\"},\n- \"target\": \"dev\"\n- }\n- }\"#;\n- let config: DevcontainerJson = serde_json::from_str(json).unwrap();\n- let build = config.build.unwrap();\n- assert_eq!(build.dockerfile.as_deref(), Some(\"Dockerfile\"));\n- assert_eq!(build.context.as_deref(), Some(\"..\"));\n- assert_eq!(build.args.get(\"VARIANT\").map(String::as_str), Some(\"3.9\"));\n- assert_eq!(build.target.as_deref(), Some(\"dev\"));\n- }\n-\n- #[test]\n- fn parse_compose_mode() {\n- let json = r#\"{\n- \"dockerComposeFile\": \"docker-compose.yml\",\n- \"service\": \"app\",\n- \"workspaceFolder\": \"/workspace\"\n- }\"#;\n- let config: DevcontainerJson = serde_json::from_str(json).unwrap();\n- assert_eq!(config.docker_compose_file.as_ref().unwrap().paths(), vec![\n- \"docker-compose.yml\"\n- ]);\n- assert_eq!(config.service.as_deref(), Some(\"app\"));\n- assert_eq!(config.workspace_folder.as_deref(), Some(\"/workspace\"));\n- }\n-\n- #[test]\n- fn parse_compose_mode_array() {\n- let json = r#\"{\n- \"dockerComposeFile\": [\"docker-compose.yml\", \"docker-compose.override.yml\"],\n- \"service\": \"app\"\n- }\"#;\n- let config: DevcontainerJson = serde_json::from_str(json).unwrap();\n- assert_eq!(config.docker_compose_file.as_ref().unwrap().paths(), vec![\n- \"docker-compose.yml\",\n- \"docker-compose.override.yml\"\n- ]);\n- }\n-\n- #[test]\n- fn unknown_fields_ignored() {\n- let json = r#\"{\"image\": \"ubuntu\", \"unknownField\": true, \"customizations\": {}}\"#;\n- let config: DevcontainerJson = serde_json::from_str(json).unwrap();\n- assert_eq!(config.image.as_deref(), Some(\"ubuntu\"));\n- }\n-\n- #[test]\n- fn parse_feature_metadata_lifecycle_hooks() {\n- let json = r#\"{\n- \"id\": \"python\",\n- \"onCreateCommand\": \"pip install -r requirements.txt\",\n- \"postCreateCommand\": [\"python\", \"setup.py\"],\n- \"postStartCommand\": {\"server\": \"python app.py\"}\n- }\"#;\n- let meta: FeatureMetadata = serde_json::from_str(json).unwrap();\n- assert!(\n- matches!(meta.on_create_command, Some(LifecycleCommand::String(ref s)) if s == \"pip install -r requirements.txt\")\n- );\n- assert!(\n- matches!(meta.post_create_command, Some(LifecycleCommand::Array(ref arr)) if arr == &[\"python\", \"setup.py\"])\n- );\n- assert!(\n- matches!(meta.post_start_command, Some(LifecycleCommand::Object(ref map)) if map.len() == 1)\n- );\n- }\n-\n- #[test]\n- fn parse_feature_metadata_container_env() {\n- let json = r#\"{\n- \"id\": \"node\",\n- \"containerEnv\": {\n- \"NODE_ENV\": \"development\",\n- \"PATH\": \"/usr/local/bin:${PATH}\"\n- }\n- }\"#;\n- let meta: FeatureMetadata = serde_json::from_str(json).unwrap();\n- assert_eq!(meta.container_env.len(), 2);\n- assert_eq!(\n- meta.container_env.get(\"NODE_ENV\").map(String::as_str),\n- Some(\"development\")\n- );\n- }\n-\n- #[test]\n- fn parse_feature_metadata_depends_on() {\n- let json = r#\"{\n- \"id\": \"python\",\n- \"dependsOn\": {\n- \"ghcr.io/devcontainers/features/common-utils:1\": {},\n- \"ghcr.io/devcontainers/features/node:1\": {\"version\": \"20\"}\n- }\n- }\"#;\n- let meta: FeatureMetadata = serde_json::from_str(json).unwrap();\n- assert_eq!(meta.depends_on.len(), 2);\n- assert!(\n- meta.depends_on\n- .contains_key(\"ghcr.io/devcontainers/features/common-utils:1\")\n- );\n- assert_eq!(\n- meta.depends_on.get(\"ghcr.io/devcontainers/features/node:1\"),\n- Some(&serde_json::json!({\"version\": \"20\"}))\n- );\n- }\n-\n- #[test]\n- fn parse_feature_metadata() {\n- let json = r#\"{\n- \"id\": \"node\",\n- \"name\": \"Node.js\",\n- \"version\": \"1.0.0\",\n- \"options\": {\n- \"version\": {\n- \"type\": \"string\",\n- \"default\": \"lts\",\n- \"description\": \"Node.js version\"\n- }\n- },\n- \"installsAfter\": [\"ghcr.io/devcontainers/features/common-utils\"]\n- }\"#;\n- let meta: FeatureMetadata = serde_json::from_str(json).unwrap();\n- assert_eq!(meta.id.as_deref(), Some(\"node\"));\n- assert_eq!(meta.options.len(), 1);\n- assert_eq!(meta.installs_after.len(), 1);\n- }\n-}\ndiff --git a/lib/crates/fabro-devcontainer/src/variables.rs b/lib/crates/fabro-devcontainer/src/variables.rs\ndeleted file mode 100644\nindex 0e71ab096..000000000\n--- a/lib/crates/fabro-devcontainer/src/variables.rs\n+++ /dev/null\n@@ -1,251 +0,0 @@\n-use fabro_util::env::Env;\n-\n-/// Context for variable substitution.\n-pub(crate) struct VariableContext<'a> {\n- pub local_workspace_folder: String,\n- pub local_workspace_folder_basename: String,\n- pub container_workspace_folder: String,\n- pub env: &'a dyn Env,\n-}\n-\n-/// Replace devcontainer variables in a string value.\n-pub(crate) fn substitute(input: &str, ctx: &VariableContext) -> String {\n- let mut result = String::with_capacity(input.len());\n- let mut rest = input;\n-\n- while let Some(start) = rest.find(\"${\") {\n- result.push_str(&rest[..start]);\n- let after_open = &rest[start + 2..];\n-\n- if let Some(close) = after_open.find('}') {\n- let expr = &after_open[..close];\n- let replacement = resolve_variable(expr, ctx);\n- match replacement {\n- Some(val) => result.push_str(&val),\n- None => {\n- // Unknown variable — leave as-is\n- result.push_str(&rest[start..=(start + 2 + close)]);\n- }\n- }\n- rest = &after_open[close + 1..];\n- } else {\n- // No closing brace — copy literally\n- result.push_str(&rest[start..]);\n- rest = \"\";\n- }\n- }\n-\n- result.push_str(rest);\n- result\n-}\n-\n-fn resolve_variable(expr: &str, ctx: &VariableContext) -> Option {\n- match expr {\n- \"localWorkspaceFolder\" => Some(ctx.local_workspace_folder.clone()),\n- \"localWorkspaceFolderBasename\" => Some(ctx.local_workspace_folder_basename.clone()),\n- \"containerWorkspaceFolder\" => Some(ctx.container_workspace_folder.clone()),\n- \"containerWorkspaceFolderBasename\" => {\n- let basename = ctx\n- .container_workspace_folder\n- .rsplit('/')\n- .next()\n- .unwrap_or(&ctx.container_workspace_folder);\n- Some(basename.to_string())\n- }\n- _ if expr.starts_with(\"localEnv:\") => {\n- let var_part = &expr[\"localEnv:\".len()..];\n- // Split on first colon for default value\n- if let Some(colon_pos) = var_part.find(':') {\n- let var_name = &var_part[..colon_pos];\n- let default = &var_part[colon_pos + 1..];\n- Some(\n- ctx.env\n- .var(var_name)\n- .unwrap_or_else(|_| default.to_string()),\n- )\n- } else {\n- Some(ctx.env.var(var_part).unwrap_or_default())\n- }\n- }\n- _ => None,\n- }\n-}\n-\n-#[cfg(test)]\n-mod tests {\n- use std::collections::HashMap;\n-\n- use fabro_util::env::{SystemEnv, TestEnv};\n-\n- use super::*;\n-\n- fn test_ctx() -> VariableContext<'static> {\n- // Tests that don't exercise localEnv don't care about the env impl.\n- // Use SystemEnv which has no lifetime/allocation concerns.\n- VariableContext {\n- local_workspace_folder: \"/home/user/project\".to_string(),\n- local_workspace_folder_basename: \"project\".to_string(),\n- container_workspace_folder: \"/workspaces/project\".to_string(),\n- env: &SystemEnv,\n- }\n- }\n-\n- #[test]\n- fn no_variables() {\n- let ctx = test_ctx();\n- assert_eq!(substitute(\"hello\", &ctx), \"hello\");\n- }\n-\n- #[test]\n- fn local_workspace_folder() {\n- let ctx = test_ctx();\n- assert_eq!(\n- substitute(\"${localWorkspaceFolder}/src\", &ctx),\n- \"/home/user/project/src\"\n- );\n- }\n-\n- #[test]\n- fn local_workspace_folder_basename() {\n- let ctx = test_ctx();\n- assert_eq!(\n- substitute(\"name: ${localWorkspaceFolderBasename}\", &ctx),\n- \"name: project\"\n- );\n- }\n-\n- #[test]\n- fn container_workspace_folder() {\n- let ctx = test_ctx();\n- assert_eq!(\n- substitute(\"${containerWorkspaceFolder}/app\", &ctx),\n- \"/workspaces/project/app\"\n- );\n- }\n-\n- #[test]\n- fn container_workspace_folder_basename() {\n- let ctx = test_ctx();\n- assert_eq!(\n- substitute(\"${containerWorkspaceFolderBasename}\", &ctx),\n- \"project\"\n- );\n- }\n-\n- #[test]\n- fn container_workspace_folder_basename_nested() {\n- let ctx = VariableContext {\n- local_workspace_folder: \"/home/user/repos/my-app\".to_string(),\n- local_workspace_folder_basename: \"my-app\".to_string(),\n- container_workspace_folder: \"/workspaces/repos/my-app\".to_string(),\n- env: &SystemEnv,\n- };\n- assert_eq!(\n- substitute(\"${containerWorkspaceFolderBasename}\", &ctx),\n- \"my-app\"\n- );\n- }\n-\n- #[test]\n- fn multiple_variables() {\n- let ctx = test_ctx();\n- assert_eq!(\n- substitute(\n- \"${localWorkspaceFolder} and ${containerWorkspaceFolder}\",\n- &ctx\n- ),\n- \"/home/user/project and /workspaces/project\"\n- );\n- }\n-\n- #[test]\n- fn unknown_variable_left_as_is() {\n- let ctx = test_ctx();\n- assert_eq!(substitute(\"${unknownVariable}\", &ctx), \"${unknownVariable}\");\n- }\n-\n- #[test]\n- fn local_env_with_set_variable() {\n- let env = TestEnv(HashMap::from([(\n- \"FABRO_TEST_VAR_SET\".into(),\n- \"hello\".into(),\n- )]));\n- let ctx = VariableContext {\n- env: &env,\n- ..test_ctx()\n- };\n- assert_eq!(substitute(\"${localEnv:FABRO_TEST_VAR_SET}\", &ctx), \"hello\");\n- }\n-\n- #[test]\n- fn local_env_unset_returns_empty() {\n- let env = TestEnv(HashMap::new());\n- let ctx = VariableContext {\n- env: &env,\n- ..test_ctx()\n- };\n- assert_eq!(substitute(\"${localEnv:FABRO_TEST_VAR_UNSET_123}\", &ctx), \"\");\n- }\n-\n- #[test]\n- fn local_env_with_default_when_unset() {\n- let env = TestEnv(HashMap::new());\n- let ctx = VariableContext {\n- env: &env,\n- ..test_ctx()\n- };\n- assert_eq!(\n- substitute(\"${localEnv:FABRO_TEST_VAR_DEFAULT_456:fallback}\", &ctx),\n- \"fallback\"\n- );\n- }\n-\n- #[test]\n- fn local_env_with_default_when_set() {\n- let env = TestEnv(HashMap::from([(\n- \"FABRO_TEST_VAR_DEFAULT_SET\".into(),\n- \"actual\".into(),\n- )]));\n- let ctx = VariableContext {\n- env: &env,\n- ..test_ctx()\n- };\n- assert_eq!(\n- substitute(\"${localEnv:FABRO_TEST_VAR_DEFAULT_SET:fallback}\", &ctx),\n- \"actual\"\n- );\n- }\n-\n- #[test]\n- fn no_closing_brace() {\n- let ctx = test_ctx();\n- assert_eq!(\n- substitute(\"${localWorkspaceFolder\", &ctx),\n- \"${localWorkspaceFolder\"\n- );\n- }\n-\n- #[test]\n- fn empty_input() {\n- let ctx = test_ctx();\n- assert_eq!(substitute(\"\", &ctx), \"\");\n- }\n-\n- #[test]\n- fn dollar_without_brace() {\n- let ctx = test_ctx();\n- assert_eq!(substitute(\"$notavar\", &ctx), \"$notavar\");\n- }\n-\n- #[test]\n- fn adjacent_variables() {\n- let ctx = test_ctx();\n- assert_eq!(\n- substitute(\n- \"${localWorkspaceFolderBasename}${containerWorkspaceFolderBasename}\",\n- &ctx\n- ),\n- \"projectproject\"\n- );\n- }\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/all-lifecycle/.devcontainer/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/all-lifecycle/.devcontainer/devcontainer.json\ndeleted file mode 100644\nindex 38ba7b416..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/all-lifecycle/.devcontainer/devcontainer.json\n+++ /dev/null\n@@ -1,10 +0,0 @@\n-{\n- \"image\": \"ubuntu:22.04\",\n- \"initializeCommand\": \"echo pre-build\",\n- \"onCreateCommand\": [\"make\", \"setup\"],\n- \"postCreateCommand\": {\n- \"install\": \"npm install\",\n- \"build\": \"npm run build\"\n- },\n- \"postStartCommand\": \"echo started\"\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/compose-mode/.devcontainer/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/compose-mode/.devcontainer/devcontainer.json\ndeleted file mode 100644\nindex 9e7be272c..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/compose-mode/.devcontainer/devcontainer.json\n+++ /dev/null\n@@ -1,11 +0,0 @@\n-{\n- \"dockerComposeFile\": \"docker-compose.yml\",\n- \"service\": \"app\",\n- \"workspaceFolder\": \"/workspace\",\n- \"remoteUser\": \"node\",\n- \"forwardPorts\": [3000, 5173],\n- \"postCreateCommand\": \"npm install\",\n- \"remoteEnv\": {\n- \"NODE_ENV\": \"development\"\n- }\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/compose-mode/.devcontainer/docker-compose.yml b/lib/crates/fabro-devcontainer/tests/fixtures/compose-mode/.devcontainer/docker-compose.yml\ndeleted file mode 100644\nindex b8726871c..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/compose-mode/.devcontainer/docker-compose.yml\n+++ /dev/null\n@@ -1,13 +0,0 @@\n-services:\n- app:\n- image: node:20\n- ports:\n- - \"3000:3000\"\n- - \"9229:9229\"\n- environment:\n- - \"NODE_ENV=development\"\n- - \"DEBUG=true\"\n- db:\n- image: postgres:15\n- ports:\n- - \"5432:5432\"\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/compose-multi/.devcontainer/base.yml b/lib/crates/fabro-devcontainer/tests/fixtures/compose-multi/.devcontainer/base.yml\ndeleted file mode 100644\nindex d6fd09beb..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/compose-multi/.devcontainer/base.yml\n+++ /dev/null\n@@ -1,5 +0,0 @@\n-services:\n- app:\n- image: node:20\n- ports:\n- - \"3000:3000\"\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/compose-multi/.devcontainer/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/compose-multi/.devcontainer/devcontainer.json\ndeleted file mode 100644\nindex c25db063d..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/compose-multi/.devcontainer/devcontainer.json\n+++ /dev/null\n@@ -1,5 +0,0 @@\n-{\n- \"dockerComposeFile\": [\"base.yml\", \"override.yml\"],\n- \"service\": \"app\",\n- \"workspaceFolder\": \"/workspace\"\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/compose-multi/.devcontainer/override.yml b/lib/crates/fabro-devcontainer/tests/fixtures/compose-multi/.devcontainer/override.yml\ndeleted file mode 100644\nindex 55023a393..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/compose-multi/.devcontainer/override.yml\n+++ /dev/null\n@@ -1,5 +0,0 @@\n-services:\n- app:\n- image: node:22\n- environment:\n- - \"OVERRIDE_VAR=true\"\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/Dockerfile b/lib/crates/fabro-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/Dockerfile\ndeleted file mode 100644\nindex 3c45d3a1a..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/Dockerfile\n+++ /dev/null\n@@ -1,3 +0,0 @@\n-FROM node:20\n-RUN apt-get update && apt-get install -y git\n-WORKDIR /workspace\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/devcontainer.json\ndeleted file mode 100644\nindex 4de5408de..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/devcontainer.json\n+++ /dev/null\n@@ -1,12 +0,0 @@\n-{\n- // This is a JSONC file with comments\n- \"build\": {\n- \"dockerfile\": \"Dockerfile\",\n- \"context\": \"..\",\n- \"args\": {\"NODE_VERSION\": \"20\"},\n- \"target\": \"dev\"\n- },\n- \"remoteUser\": \"developer\",\n- \"postCreateCommand\": \"npm install\",\n- \"forwardPorts\": [4000],\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/feature-options/.devcontainer/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/feature-options/.devcontainer/devcontainer.json\ndeleted file mode 100644\nindex 6f67c9c6e..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/feature-options/.devcontainer/devcontainer.json\n+++ /dev/null\n@@ -1,7 +0,0 @@\n-{\n- \"image\": \"mcr.microsoft.com/devcontainers/base:ubuntu\",\n- \"features\": {\n- \"./go-feature\": \"1.21\"\n- },\n- \"remoteUser\": \"developer\"\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/feature-options/.devcontainer/go-feature/devcontainer-feature.json b/lib/crates/fabro-devcontainer/tests/fixtures/feature-options/.devcontainer/go-feature/devcontainer-feature.json\ndeleted file mode 100644\nindex 7caf37a37..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/feature-options/.devcontainer/go-feature/devcontainer-feature.json\n+++ /dev/null\n@@ -1,16 +0,0 @@\n-{\n- \"id\": \"go-feature\",\n- \"version\": \"1.0.0\",\n- \"options\": {\n- \"version\": {\n- \"type\": \"string\",\n- \"default\": \"latest\",\n- \"description\": \"Go version\"\n- },\n- \"node-version\": {\n- \"type\": \"string\",\n- \"default\": \"none\",\n- \"description\": \"Optional Node.js version (hyphenated option ID)\"\n- }\n- }\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/feature-options/.devcontainer/go-feature/install.sh b/lib/crates/fabro-devcontainer/tests/fixtures/feature-options/.devcontainer/go-feature/install.sh\ndeleted file mode 100644\nindex 5cd5409ed..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/feature-options/.devcontainer/go-feature/install.sh\n+++ /dev/null\n@@ -1,2 +0,0 @@\n-#!/bin/sh\n-echo \"Installing go ${VERSION} with node ${NODE_VERSION}\"\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/image-only/.devcontainer/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/image-only/.devcontainer/devcontainer.json\ndeleted file mode 100644\nindex 061bd2055..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/image-only/.devcontainer/devcontainer.json\n+++ /dev/null\n@@ -1,13 +0,0 @@\n-{\n- \"image\": \"mcr.microsoft.com/devcontainers/base:ubuntu\",\n- \"forwardPorts\": [3000, \"8080:80\", \"9090\"],\n- \"remoteUser\": \"vscode\",\n- \"remoteEnv\": {\n- \"EDITOR\": \"code\"\n- },\n- \"containerEnv\": {\n- \"DEBIAN_FRONTEND\": \"noninteractive\"\n- },\n- \"onCreateCommand\": \"setup.sh\",\n- \"postCreateCommand\": \"echo hello\"\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/base-utils/devcontainer-feature.json b/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/base-utils/devcontainer-feature.json\ndeleted file mode 100644\nindex 0141f558d..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/base-utils/devcontainer-feature.json\n+++ /dev/null\n@@ -1,8 +0,0 @@\n-{\n- \"id\": \"base-utils\",\n- \"version\": \"1.0.0\",\n- \"containerEnv\": {\n- \"BASE_UTILS_INSTALLED\": \"true\"\n- },\n- \"onCreateCommand\": \"echo base-utils-setup\"\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/base-utils/install.sh b/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/base-utils/install.sh\ndeleted file mode 100644\nindex 0dec9c182..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/base-utils/install.sh\n+++ /dev/null\n@@ -1,2 +0,0 @@\n-#!/bin/sh\n-echo \"Installing base-utils\"\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/devcontainer.json\ndeleted file mode 100644\nindex 918b7b59c..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/devcontainer.json\n+++ /dev/null\n@@ -1,13 +0,0 @@\n-{\n- \"image\": \"mcr.microsoft.com/devcontainers/base:ubuntu\",\n- \"features\": {\n- \"./node-feature\": {\"version\": \"20\"},\n- \"./python-feature\": {}\n- },\n- \"remoteUser\": \"vscode\",\n- \"containerEnv\": {\n- \"DEVCONTAINER\": \"true\"\n- },\n- \"onCreateCommand\": \"echo devcontainer-setup\",\n- \"postCreateCommand\": \"echo devcontainer-post-create\"\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/node-feature/devcontainer-feature.json b/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/node-feature/devcontainer-feature.json\ndeleted file mode 100644\nindex fa130bd8f..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/node-feature/devcontainer-feature.json\n+++ /dev/null\n@@ -1,21 +0,0 @@\n-{\n- \"id\": \"node-feature\",\n- \"version\": \"1.0.0\",\n- \"options\": {\n- \"version\": {\n- \"type\": \"string\",\n- \"default\": \"lts\",\n- \"description\": \"Node.js version\"\n- }\n- },\n- \"dependsOn\": {\n- \"./base-utils\": {}\n- },\n- \"installsAfter\": [],\n- \"containerEnv\": {\n- \"NODE_INSTALLED\": \"true\",\n- \"NODE_PATH\": \"/usr/local/lib/node_modules\"\n- },\n- \"onCreateCommand\": \"echo node-setup\",\n- \"postStartCommand\": \"echo node-started\"\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/node-feature/install.sh b/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/node-feature/install.sh\ndeleted file mode 100644\nindex cc0121c4b..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/node-feature/install.sh\n+++ /dev/null\n@@ -1,2 +0,0 @@\n-#!/bin/sh\n-echo \"Installing node ${VERSION}\"\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/python-feature/devcontainer-feature.json b/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/python-feature/devcontainer-feature.json\ndeleted file mode 100644\nindex b4063b4e6..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/python-feature/devcontainer-feature.json\n+++ /dev/null\n@@ -1,9 +0,0 @@\n-{\n- \"id\": \"python-feature\",\n- \"version\": \"1.0.0\",\n- \"installsAfter\": [\"./node-feature\"],\n- \"containerEnv\": {\n- \"PYTHON_INSTALLED\": \"true\"\n- },\n- \"postCreateCommand\": \"echo python-post-create\"\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/python-feature/install.sh b/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/python-feature/install.sh\ndeleted file mode 100644\nindex 944a488fc..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/python-feature/install.sh\n+++ /dev/null\n@@ -1,2 +0,0 @@\n-#!/bin/sh\n-echo \"Installing python\"\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/realistic-compose/.devcontainer/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/realistic-compose/.devcontainer/devcontainer.json\ndeleted file mode 100644\nindex 4ac94f01d..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/realistic-compose/.devcontainer/devcontainer.json\n+++ /dev/null\n@@ -1,18 +0,0 @@\n-{\n- \"dockerComposeFile\": [\"docker-compose.yml\", \"docker-compose.override.yml\"],\n- \"service\": \"app\",\n- \"workspaceFolder\": \"/workspace\",\n- \"remoteUser\": \"node\",\n- \"forwardPorts\": [8080],\n- \"containerEnv\": {\n- \"TERM\": \"xterm-256color\",\n- \"EDITOR\": \"vim\"\n- },\n- \"remoteEnv\": {\n- \"DATABASE_URL\": \"postgres://postgres:devpass@db:5432/myapp_dev\",\n- \"REDIS_URL\": \"redis://redis:6379\"\n- },\n- \"onCreateCommand\": \"npm ci\",\n- \"postCreateCommand\": \"npm run db:migrate\",\n- \"postStartCommand\": \"npm run dev\"\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/realistic-compose/.devcontainer/docker-compose.override.yml b/lib/crates/fabro-devcontainer/tests/fixtures/realistic-compose/.devcontainer/docker-compose.override.yml\ndeleted file mode 100644\nindex ef2061b1b..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/realistic-compose/.devcontainer/docker-compose.override.yml\n+++ /dev/null\n@@ -1,7 +0,0 @@\n-services:\n- app:\n- environment:\n- - \"DEBUG=true\"\n- - \"LOG_LEVEL=verbose\"\n- ports:\n- - \"4000:4000\"\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/realistic-compose/.devcontainer/docker-compose.yml b/lib/crates/fabro-devcontainer/tests/fixtures/realistic-compose/.devcontainer/docker-compose.yml\ndeleted file mode 100644\nindex 7937512cd..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/realistic-compose/.devcontainer/docker-compose.yml\n+++ /dev/null\n@@ -1,22 +0,0 @@\n-services:\n- app:\n- image: node:20-bookworm\n- ports:\n- - \"3000:3000\"\n- - \"9229:9229\"\n- environment:\n- - \"NODE_ENV=development\"\n- user: \"node\"\n- volumes:\n- - ..:/workspace:cached\n- db:\n- image: postgres:16\n- ports:\n- - \"5432:5432\"\n- environment:\n- POSTGRES_PASSWORD: devpass\n- POSTGRES_DB: myapp_dev\n- redis:\n- image: redis:7-alpine\n- ports:\n- - \"6379:6379\"\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/realistic-python/.devcontainer/Dockerfile b/lib/crates/fabro-devcontainer/tests/fixtures/realistic-python/.devcontainer/Dockerfile\ndeleted file mode 100644\nindex 16f9220a8..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/realistic-python/.devcontainer/Dockerfile\n+++ /dev/null\n@@ -1,10 +0,0 @@\n-ARG PYTHON_VERSION=3.11\n-FROM python:${PYTHON_VERSION}-slim\n-\n-RUN apt-get update && apt-get install -y --no-install-recommends \\\n- git \\\n- curl \\\n- && rm -rf /var/lib/apt/lists/*\n-\n-RUN useradd -m -s /bin/bash developer\n-WORKDIR /workspaces/app\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/realistic-python/.devcontainer/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/realistic-python/.devcontainer/devcontainer.json\ndeleted file mode 100644\nindex 6fd7a715b..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/realistic-python/.devcontainer/devcontainer.json\n+++ /dev/null\n@@ -1,23 +0,0 @@\n-{\n- // Realistic Python project devcontainer\n- \"build\": {\n- \"dockerfile\": \"Dockerfile\",\n- \"args\": {\n- \"PYTHON_VERSION\": \"3.12\"\n- }\n- },\n- \"containerEnv\": {\n- \"PYTHONDONTWRITEBYTECODE\": \"1\",\n- \"PYTHONUNBUFFERED\": \"1\",\n- \"PIP_NO_CACHE_DIR\": \"1\"\n- },\n- \"remoteEnv\": {\n- \"PYTHONPATH\": \"${containerWorkspaceFolder}/src\",\n- \"PYTHONUNBUFFERED\": \"yes\"\n- },\n- \"remoteUser\": \"developer\",\n- \"forwardPorts\": [8000, 5432],\n- \"onCreateCommand\": \"pip install -r requirements.txt\",\n- \"postCreateCommand\": \"python manage.py migrate\",\n- \"postStartCommand\": \"python manage.py runserver 0.0.0.0:8000\",\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-mode/.devcontainer/python/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-mode/.devcontainer/python/devcontainer.json\ndeleted file mode 100644\nindex 726cfad36..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-mode/.devcontainer/python/devcontainer.json\n+++ /dev/null\n@@ -1,4 +0,0 @@\n-{\n- \"image\": \"mcr.microsoft.com/devcontainers/python:3.12\",\n- \"remoteUser\": \"vscode\"\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-multiple/.devcontainer/alpha/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-multiple/.devcontainer/alpha/devcontainer.json\ndeleted file mode 100644\nindex a52df2dae..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-multiple/.devcontainer/alpha/devcontainer.json\n+++ /dev/null\n@@ -1,4 +0,0 @@\n-{\n- \"image\": \"mcr.microsoft.com/devcontainers/base:ubuntu\",\n- \"remoteUser\": \"alpha-user\"\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-multiple/.devcontainer/beta/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-multiple/.devcontainer/beta/devcontainer.json\ndeleted file mode 100644\nindex b0085ad51..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-multiple/.devcontainer/beta/devcontainer.json\n+++ /dev/null\n@@ -1,4 +0,0 @@\n-{\n- \"image\": \"node:20\",\n- \"remoteUser\": \"beta-user\"\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-with-standard/.devcontainer/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-with-standard/.devcontainer/devcontainer.json\ndeleted file mode 100644\nindex e58b2dce4..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-with-standard/.devcontainer/devcontainer.json\n+++ /dev/null\n@@ -1,4 +0,0 @@\n-{\n- \"image\": \"mcr.microsoft.com/devcontainers/base:ubuntu\",\n- \"remoteUser\": \"standard-user\"\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-with-standard/.devcontainer/python/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-with-standard/.devcontainer/python/devcontainer.json\ndeleted file mode 100644\nindex e419cd97d..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-with-standard/.devcontainer/python/devcontainer.json\n+++ /dev/null\n@@ -1,4 +0,0 @@\n-{\n- \"image\": \"mcr.microsoft.com/devcontainers/python:3.12\",\n- \"remoteUser\": \"python-user\"\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/variables/.devcontainer/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/variables/.devcontainer/devcontainer.json\ndeleted file mode 100644\nindex 63083ea09..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/variables/.devcontainer/devcontainer.json\n+++ /dev/null\n@@ -1,9 +0,0 @@\n-{\n- \"image\": \"ubuntu:22.04\",\n- \"workspaceFolder\": \"/workspaces/${localWorkspaceFolderBasename}\",\n- \"remoteEnv\": {\n- \"PROJECT_ROOT\": \"${containerWorkspaceFolder}\",\n- \"PROJECT_NAME\": \"${containerWorkspaceFolderBasename}\"\n- },\n- \"postCreateCommand\": \"echo ${containerWorkspaceFolder}\"\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/fixtures/with-features/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/with-features/devcontainer.json\ndeleted file mode 100644\nindex 2d477b073..000000000\n--- a/lib/crates/fabro-devcontainer/tests/fixtures/with-features/devcontainer.json\n+++ /dev/null\n@@ -1,9 +0,0 @@\n-{\n- \"image\": \"mcr.microsoft.com/devcontainers/base:ubuntu\",\n- \"features\": {\n- \"ghcr.io/devcontainers/features/node:1\": {\n- \"version\": \"20\"\n- },\n- \"ghcr.io/devcontainers/features/python:1\": {}\n- }\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/it/e2e.rs b/lib/crates/fabro-devcontainer/tests/it/e2e.rs\ndeleted file mode 100644\nindex e5ad60c8d..000000000\n--- a/lib/crates/fabro-devcontainer/tests/it/e2e.rs\n+++ /dev/null\n@@ -1,594 +0,0 @@\n-//! End-to-end tests exercising full resolver pipeline with realistic\n-//! devcontainer configs. These tests verify the 4 critical gaps are wired\n-//! correctly through the entire stack:\n-//! 1. onCreateCommand\n-//! 2. build.args\n-//! 3. containerEnv\n-//! 4. dockerComposeFile array\n-\n-use fabro_devcontainer::{Command, DevcontainerResolver};\n-\n-use super::helpers::fixture_path;\n-\n-/// Realistic Python project: Dockerfile + build.args + containerEnv +\n-/// onCreateCommand + remoteEnv Verifies all 4 gaps work together in a single\n-/// config.\n-#[tokio::test]\n-async fn realistic_python_project() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"realistic-python\"))\n- .await\n- .unwrap();\n-\n- // Gap 2: build.args exposed for docker build --build-arg\n- assert_eq!(\n- config.build_args.get(\"PYTHON_VERSION\").map(String::as_str),\n- Some(\"3.12\")\n- );\n-\n- // Gap 3: containerEnv baked into Dockerfile as ENV directives\n- assert!(config.dockerfile.contains(\"ENV PIP_NO_CACHE_DIR=1\"));\n- assert!(config.dockerfile.contains(\"ENV PYTHONDONTWRITEBYTECODE=1\"));\n- assert_eq!(\n- config\n- .container_env\n- .get(\"PIP_NO_CACHE_DIR\")\n- .map(String::as_str),\n- Some(\"1\")\n- );\n-\n- // After fix: only containerEnv is baked into Dockerfile (remoteEnv is\n- // runtime-only)\n- assert!(config.dockerfile.contains(\"ENV PYTHONUNBUFFERED=1\"));\n- // environment HashMap gets the remoteEnv value\n- assert_eq!(\n- config\n- .environment\n- .get(\"PYTHONUNBUFFERED\")\n- .map(String::as_str),\n- Some(\"yes\")\n- );\n-\n- // Gap 3: remoteEnv with variable substitution\n- assert_eq!(\n- config.environment.get(\"PYTHONPATH\").map(String::as_str),\n- Some(\"/workspaces/realistic-python/src\")\n- );\n-\n- // Gap 1: onCreateCommand parsed and exposed\n- assert_eq!(config.on_create_commands.len(), 1);\n- assert!(\n- matches!(&config.on_create_commands[0], Command::Shell(s) if s == \"pip install -r requirements.txt\")\n- );\n-\n- // Other lifecycle commands still work\n- assert_eq!(config.post_create_commands.len(), 1);\n- assert!(\n- matches!(&config.post_create_commands[0], Command::Shell(s) if s == \"python manage.py migrate\")\n- );\n- assert_eq!(config.post_start_commands.len(), 1);\n- assert!(\n- matches!(&config.post_start_commands[0], Command::Shell(s) if s == \"python manage.py runserver 0.0.0.0:8000\")\n- );\n-\n- // Dockerfile content is the actual file (not generated FROM line)\n- assert!(config.dockerfile.contains(\"ARG PYTHON_VERSION=3.11\"));\n- assert!(config.dockerfile.contains(\"apt-get update\"));\n-\n- // Standard fields\n- assert_eq!(config.remote_user.as_deref(), Some(\"developer\"));\n- assert_eq!(config.forwarded_ports, vec![8000, 5432]);\n- assert!(config.compose_files.is_empty());\n-}\n-\n-/// Realistic compose project: multi-file compose + containerEnv +\n-/// onCreateCommand + remoteEnv Verifies gaps 1, 3, 4 work together in compose\n-/// mode.\n-#[tokio::test]\n-async fn realistic_compose_project() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"realistic-compose\"))\n- .await\n- .unwrap();\n-\n- // Gap 4: multiple compose files resolved\n- assert_eq!(config.compose_files.len(), 2);\n- assert_eq!(config.compose_service.as_deref(), Some(\"app\"));\n-\n- // Gap 4: image from base compose file (override doesn't change image)\n- assert!(config.dockerfile.contains(\"FROM node:20-bookworm\"));\n-\n- // Ports merged from both compose files (base: 3000, 9229; override: 4000) +\n- // forwardPorts (8080)\n- assert!(config.forwarded_ports.contains(&3000));\n- assert!(config.forwarded_ports.contains(&9229));\n- assert!(config.forwarded_ports.contains(&4000));\n- assert!(config.forwarded_ports.contains(&8080));\n- assert_eq!(config.forwarded_ports.len(), 4);\n-\n- // Gap 4: environment merged from both compose files + remoteEnv\n- assert_eq!(\n- config.environment.get(\"NODE_ENV\").map(String::as_str),\n- Some(\"development\")\n- );\n- assert_eq!(\n- config.environment.get(\"DEBUG\").map(String::as_str),\n- Some(\"true\")\n- );\n- assert_eq!(\n- config.environment.get(\"LOG_LEVEL\").map(String::as_str),\n- Some(\"verbose\")\n- );\n- // remoteEnv values\n- assert_eq!(\n- config.environment.get(\"DATABASE_URL\").map(String::as_str),\n- Some(\"postgres://postgres:devpass@db:5432/myapp_dev\")\n- );\n- assert_eq!(\n- config.environment.get(\"REDIS_URL\").map(String::as_str),\n- Some(\"redis://redis:6379\")\n- );\n-\n- // Gap 3: containerEnv exposed on config\n- assert_eq!(\n- config.container_env.get(\"TERM\").map(String::as_str),\n- Some(\"xterm-256color\")\n- );\n- assert_eq!(\n- config.container_env.get(\"EDITOR\").map(String::as_str),\n- Some(\"vim\")\n- );\n-\n- // Gap 1: onCreateCommand in compose mode\n- assert_eq!(config.on_create_commands.len(), 1);\n- assert!(matches!(&config.on_create_commands[0], Command::Shell(s) if s == \"npm ci\"));\n-\n- // Other lifecycle commands\n- assert_eq!(config.post_create_commands.len(), 1);\n- assert!(\n- matches!(&config.post_create_commands[0], Command::Shell(s) if s == \"npm run db:migrate\")\n- );\n- assert_eq!(config.post_start_commands.len(), 1);\n- assert!(matches!(&config.post_start_commands[0], Command::Shell(s) if s == \"npm run dev\"));\n-\n- // User comes from compose (node) but remoteUser also set to node\n- assert_eq!(config.remote_user.as_deref(), Some(\"node\"));\n- assert_eq!(config.workspace_folder, \"/workspace\");\n-}\n-\n-/// All lifecycle commands in different forms: string, array, object, and the\n-/// new onCreateCommand.\n-#[tokio::test]\n-async fn all_lifecycle_command_forms() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"all-lifecycle\"))\n- .await\n- .unwrap();\n-\n- // initializeCommand as string\n- assert_eq!(config.initialize_commands.len(), 1);\n- assert!(matches!(&config.initialize_commands[0], Command::Shell(s) if s == \"echo pre-build\"));\n-\n- // Gap 1: onCreateCommand as array\n- assert_eq!(config.on_create_commands.len(), 1);\n- assert!(\n- matches!(&config.on_create_commands[0], Command::Args(args) if args == &[\"make\", \"setup\"])\n- );\n-\n- // postCreateCommand as object (parallel)\n- assert_eq!(config.post_create_commands.len(), 1);\n- assert!(matches!(&config.post_create_commands[0], Command::Parallel(map) if map.len() == 2));\n-\n- // postStartCommand as string\n- assert_eq!(config.post_start_commands.len(), 1);\n- assert!(matches!(&config.post_start_commands[0], Command::Shell(s) if s == \"echo started\"));\n-}\n-\n-/// Verify containerEnv doesn't pollute the environment HashMap (which is\n-/// remoteEnv only).\n-#[tokio::test]\n-async fn container_env_separate_from_environment() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"realistic-python\"))\n- .await\n- .unwrap();\n-\n- // container_env has containerEnv values\n- assert!(config.container_env.contains_key(\"PYTHONDONTWRITEBYTECODE\"));\n- assert!(config.container_env.contains_key(\"PIP_NO_CACHE_DIR\"));\n-\n- // environment only has remoteEnv values (not containerEnv-only keys)\n- assert!(!config.environment.contains_key(\"PYTHONDONTWRITEBYTECODE\"));\n- assert!(!config.environment.contains_key(\"PIP_NO_CACHE_DIR\"));\n- // PYTHONUNBUFFERED is in both - environment gets remoteEnv value\n- assert_eq!(\n- config\n- .environment\n- .get(\"PYTHONUNBUFFERED\")\n- .map(String::as_str),\n- Some(\"yes\")\n- );\n-}\n-\n-/// Verify build_args default to empty in non-dockerfile modes.\n-#[tokio::test]\n-async fn build_args_empty_in_image_and_compose_modes() {\n- let image_config = DevcontainerResolver::resolve(&fixture_path(\"image-only\"))\n- .await\n- .unwrap();\n- assert!(image_config.build_args.is_empty());\n-\n- let compose_config = DevcontainerResolver::resolve(&fixture_path(\"compose-mode\"))\n- .await\n- .unwrap();\n- assert!(compose_config.build_args.is_empty());\n-}\n-\n-/// Verify build_target is None for image-only and compose modes.\n-#[tokio::test]\n-async fn build_target_none_in_image_and_compose_modes() {\n- let image_config = DevcontainerResolver::resolve(&fixture_path(\"image-only\"))\n- .await\n- .unwrap();\n- assert!(image_config.build_target.is_none());\n-\n- let compose_config = DevcontainerResolver::resolve(&fixture_path(\"compose-mode\"))\n- .await\n- .unwrap();\n- assert!(compose_config.build_target.is_none());\n-}\n-\n-/// Gap 1: remoteEnv values must NOT appear as ENV directives in the generated\n-/// Dockerfile. Only containerEnv should be baked in.\n-#[tokio::test]\n-async fn remote_env_excluded_from_dockerfile() {\n- // image-only fixture has remoteEnv: {\"EDITOR\": \"code\"} and containerEnv:\n- // {\"DEBIAN_FRONTEND\": \"noninteractive\"}\n- let config = DevcontainerResolver::resolve(&fixture_path(\"image-only\"))\n- .await\n- .unwrap();\n-\n- // containerEnv IS in the Dockerfile\n- assert!(\n- config\n- .dockerfile\n- .contains(\"ENV DEBIAN_FRONTEND=noninteractive\")\n- );\n-\n- // remoteEnv is NOT in the Dockerfile\n- assert!(!config.dockerfile.contains(\"EDITOR=code\"));\n-\n- // remoteEnv IS in the environment HashMap (runtime-only)\n- assert_eq!(\n- config.environment.get(\"EDITOR\").map(String::as_str),\n- Some(\"code\")\n- );\n-}\n-\n-/// Gap 2: forwardPorts in compose mode are merged with compose service ports,\n-/// with deduplication.\n-#[tokio::test]\n-async fn forward_ports_merged_and_deduped_in_compose() {\n- // compose-mode fixture has compose ports [3000, 9229] and forwardPorts [3000,\n- // 5173]\n- let config = DevcontainerResolver::resolve(&fixture_path(\"compose-mode\"))\n- .await\n- .unwrap();\n-\n- // 3000 appears in both compose ports and forwardPorts — should NOT be\n- // duplicated\n- assert_eq!(config.forwarded_ports, vec![3000, 9229, 5173]);\n-}\n-\n-/// Gap 3: build.target is parsed and exposed in dockerfile mode.\n-#[tokio::test]\n-async fn build_target_in_dockerfile_mode() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"dockerfile-mode\"))\n- .await\n- .unwrap();\n-\n- assert_eq!(config.build_target.as_deref(), Some(\"dev\"));\n-}\n-\n-/// Gap 4: forwardPorts string formats (\"host:container\", \"port\") are parsed\n-/// correctly.\n-#[tokio::test]\n-async fn forward_ports_string_formats() {\n- // image-only fixture has forwardPorts: [3000, \"8080:80\", \"9090\"]\n- let config = DevcontainerResolver::resolve(&fixture_path(\"image-only\"))\n- .await\n- .unwrap();\n-\n- // 3000 is a plain number\n- assert!(config.forwarded_ports.contains(&3000));\n- // \"8080:80\" extracts container port 80\n- assert!(config.forwarded_ports.contains(&80));\n- // \"9090\" is parsed as a plain port number\n- assert!(config.forwarded_ports.contains(&9090));\n- // host port 8080 should NOT appear (only container port matters)\n- assert!(!config.forwarded_ports.contains(&8080));\n-\n- assert_eq!(config.forwarded_ports, vec![3000, 80, 9090]);\n-}\n-\n-/// Verify compose_files is empty for non-compose modes.\n-#[tokio::test]\n-async fn compose_files_empty_in_non_compose_modes() {\n- let image_config = DevcontainerResolver::resolve(&fixture_path(\"image-only\"))\n- .await\n- .unwrap();\n- assert!(image_config.compose_files.is_empty());\n-\n- let df_config = DevcontainerResolver::resolve(&fixture_path(\"dockerfile-mode\"))\n- .await\n- .unwrap();\n- assert!(df_config.compose_files.is_empty());\n-}\n-\n-/// Verify on_create_commands defaults to empty when not specified.\n-#[tokio::test]\n-async fn on_create_commands_empty_when_not_specified() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"variables\"))\n- .await\n- .unwrap();\n- assert!(config.on_create_commands.is_empty());\n-}\n-\n-/// Verify container_env defaults to empty when not specified.\n-#[tokio::test]\n-async fn container_env_empty_when_not_specified() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"variables\"))\n- .await\n- .unwrap();\n- assert!(config.container_env.is_empty());\n-}\n-\n-// === Gap e2e tests: local features exercising dependsOn, containerEnv,\n-// lifecycle hooks ===\n-\n-/// Gap 5: Local path feature references are resolved through the full pipeline.\n-#[tokio::test]\n-async fn local_feature_refs_resolved() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"local-features\"))\n- .await\n- .unwrap();\n-\n- // Base image preserved\n- assert!(\n- config\n- .dockerfile\n- .contains(\"FROM mcr.microsoft.com/devcontainers/base:ubuntu\")\n- );\n-\n- // Feature install.sh snippets are in the Dockerfile\n- assert!(config.dockerfile.contains(\"node-feature\"));\n- assert!(config.dockerfile.contains(\"python-feature\"));\n-\n- // Node feature option \"version=20\" passed as env var\n- assert!(config.dockerfile.contains(\"export VERSION=\\\"20\\\"\"));\n-}\n-\n-/// Gap 1: dependsOn auto-injects missing features through the full pipeline.\n-/// node-feature dependsOn ./base-utils which is NOT listed in devcontainer.json\n-/// features.\n-#[tokio::test]\n-async fn depends_on_auto_injects_missing_feature() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"local-features\"))\n- .await\n- .unwrap();\n-\n- // base-utils was auto-injected and its install.sh snippet is in the Dockerfile\n- assert!(config.dockerfile.contains(\"base-utils\"));\n-\n- // base-utils must appear before node-feature (dependency ordering)\n- let base_pos = config.dockerfile.find(\"base-utils\").unwrap();\n- let node_pos = config.dockerfile.find(\"node-feature\").unwrap();\n- assert!(\n- base_pos < node_pos,\n- \"base-utils (pos {base_pos}) should appear before node-feature (pos {node_pos})\"\n- );\n-}\n-\n-/// Gap 2: Feature containerEnv is merged into the Dockerfile and config.\n-#[tokio::test]\n-async fn feature_container_env_merged() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"local-features\"))\n- .await\n- .unwrap();\n-\n- // Feature containerEnv values baked into Dockerfile\n- assert!(config.dockerfile.contains(\"ENV NODE_INSTALLED=true\"));\n- assert!(\n- config\n- .dockerfile\n- .contains(\"ENV NODE_PATH=/usr/local/lib/node_modules\")\n- );\n- assert!(config.dockerfile.contains(\"ENV PYTHON_INSTALLED=true\"));\n- assert!(config.dockerfile.contains(\"ENV BASE_UTILS_INSTALLED=true\"));\n-\n- // Devcontainer.json containerEnv also present\n- assert!(config.dockerfile.contains(\"ENV DEVCONTAINER=true\"));\n-\n- // All values in config.container_env\n- assert_eq!(\n- config\n- .container_env\n- .get(\"NODE_INSTALLED\")\n- .map(String::as_str),\n- Some(\"true\")\n- );\n- assert_eq!(\n- config\n- .container_env\n- .get(\"PYTHON_INSTALLED\")\n- .map(String::as_str),\n- Some(\"true\")\n- );\n- assert_eq!(\n- config\n- .container_env\n- .get(\"BASE_UTILS_INSTALLED\")\n- .map(String::as_str),\n- Some(\"true\")\n- );\n- assert_eq!(\n- config.container_env.get(\"DEVCONTAINER\").map(String::as_str),\n- Some(\"true\")\n- );\n-}\n-\n-/// Gap 3: Feature lifecycle hooks are appended after devcontainer.json\n-/// lifecycle commands.\n-#[tokio::test]\n-async fn feature_lifecycle_hooks_appended() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"local-features\"))\n- .await\n- .unwrap();\n-\n- // onCreateCommand: devcontainer.json first, then features\n- // devcontainer.json: \"echo devcontainer-setup\"\n- // base-utils: \"echo base-utils-setup\"\n- // node-feature: \"echo node-setup\"\n- assert!(config.on_create_commands.len() >= 2);\n- assert!(\n- matches!(&config.on_create_commands[0], Command::Shell(s) if s == \"echo devcontainer-setup\")\n- );\n-\n- // Feature on_create_commands appear after devcontainer.json's\n- let feature_on_create: Vec<&str> = config.on_create_commands[1..]\n- .iter()\n- .filter_map(|cmd| match cmd {\n- Command::Shell(s) => Some(s.as_str()),\n- _ => None,\n- })\n- .collect();\n- assert!(feature_on_create.contains(&\"echo base-utils-setup\"));\n- assert!(feature_on_create.contains(&\"echo node-setup\"));\n-\n- // postCreateCommand: devcontainer.json first, then python-feature\n- assert!(config.post_create_commands.len() >= 2);\n- assert!(\n- matches!(&config.post_create_commands[0], Command::Shell(s) if s == \"echo devcontainer-post-create\")\n- );\n- let feature_post_create: Vec<&str> = config.post_create_commands[1..]\n- .iter()\n- .filter_map(|cmd| match cmd {\n- Command::Shell(s) => Some(s.as_str()),\n- _ => None,\n- })\n- .collect();\n- assert!(feature_post_create.contains(&\"echo python-post-create\"));\n-\n- // postStartCommand: only node-feature contributes (no devcontainer.json\n- // postStartCommand)\n- assert!(!config.post_start_commands.is_empty());\n- let post_start: Vec<&str> = config\n- .post_start_commands\n- .iter()\n- .filter_map(|cmd| match cmd {\n- Command::Shell(s) => Some(s.as_str()),\n- _ => None,\n- })\n- .collect();\n- assert!(post_start.contains(&\"echo node-started\"));\n-}\n-\n-/// Fix 1: Shorthand version syntax \"1.21\" is normalized to {\"version\": \"1.21\"}.\n-#[tokio::test]\n-async fn feature_shorthand_version_syntax() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"feature-options\"))\n- .await\n- .unwrap();\n-\n- // \"1.21\" string should become version=1.21 env var\n- assert!(\n- config.dockerfile.contains(\"export VERSION=\\\"1.21\\\"\"),\n- \"shorthand string \\\"1.21\\\" should set VERSION env var, got:\\n{}\",\n- config.dockerfile,\n- );\n-}\n-\n-/// Fix 2: Hyphenated option IDs are converted to valid env var names\n-/// (node-version → NODE_VERSION).\n-#[tokio::test]\n-async fn feature_option_id_hyphen_to_underscore() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"feature-options\"))\n- .await\n- .unwrap();\n-\n- // node-version default \"none\" should export as NODE_VERSION (not NODE-VERSION)\n- assert!(\n- config.dockerfile.contains(\"export NODE_VERSION=\\\"none\\\"\"),\n- \"hyphenated option 'node-version' should become NODE_VERSION env var, got:\\n{}\",\n- config.dockerfile,\n- );\n- assert!(\n- !config.dockerfile.contains(\"NODE-VERSION\"),\n- \"NODE-VERSION (with hyphen) should not appear in Dockerfile\",\n- );\n-}\n-\n-/// Fix 3: _REMOTE_USER and related env vars are emitted in feature install\n-/// snippets.\n-#[tokio::test]\n-async fn feature_install_user_env_vars() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"feature-options\"))\n- .await\n- .unwrap();\n-\n- // remoteUser is \"developer\", so _REMOTE_USER should be \"developer\"\n- assert!(\n- config.dockerfile.contains(\"_REMOTE_USER=\\\"developer\\\"\"),\n- \"_REMOTE_USER should be set to remoteUser value, got:\\n{}\",\n- config.dockerfile,\n- );\n- assert!(\n- config.dockerfile.contains(\"_CONTAINER_USER=\\\"root\\\"\"),\n- \"_CONTAINER_USER should always be root\",\n- );\n- assert!(\n- config\n- .dockerfile\n- .contains(\"_REMOTE_USER_HOME=\\\"/home/developer\\\"\"),\n- \"_REMOTE_USER_HOME should be /home/developer\",\n- );\n- assert!(\n- config.dockerfile.contains(\"_CONTAINER_USER_HOME=\\\"/root\\\"\"),\n- \"_CONTAINER_USER_HOME should always be /root\",\n- );\n-}\n-\n-/// Fix 3: _REMOTE_USER defaults to root when remoteUser is not set.\n-#[tokio::test]\n-async fn feature_install_user_env_vars_default_root() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"local-features\"))\n- .await\n- .unwrap();\n-\n- // local-features has remoteUser: \"vscode\"\n- assert!(\n- config.dockerfile.contains(\"_REMOTE_USER=\\\"vscode\\\"\"),\n- \"_REMOTE_USER should be set to vscode, got:\\n{}\",\n- config.dockerfile,\n- );\n- assert!(\n- config\n- .dockerfile\n- .contains(\"_REMOTE_USER_HOME=\\\"/home/vscode\\\"\"),\n- \"_REMOTE_USER_HOME should be /home/vscode\",\n- );\n-}\n-\n-/// Gap 2+3: Feature ordering affects both containerEnv and lifecycle hook\n-/// collection. python-feature installsAfter node-feature, so node's env/hooks\n-/// come first.\n-#[tokio::test]\n-async fn feature_ordering_preserved_in_env_and_hooks() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"local-features\"))\n- .await\n- .unwrap();\n-\n- // In the Dockerfile, node-feature layers come before python-feature layers\n- let node_layer_pos = config.dockerfile.find(\"node-feature\").unwrap();\n- let python_layer_pos = config.dockerfile.find(\"python-feature\").unwrap();\n- assert!(\n- node_layer_pos < python_layer_pos,\n- \"node-feature (pos {node_layer_pos}) should be installed before python-feature (pos {python_layer_pos})\"\n- );\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/it/helpers.rs b/lib/crates/fabro-devcontainer/tests/it/helpers.rs\ndeleted file mode 100644\nindex 98f42af26..000000000\n--- a/lib/crates/fabro-devcontainer/tests/it/helpers.rs\n+++ /dev/null\n@@ -1,7 +0,0 @@\n-use std::path::PathBuf;\n-\n-pub(super) fn fixture_path(name: &str) -> PathBuf {\n- PathBuf::from(env!(\"CARGO_MANIFEST_DIR\"))\n- .join(\"tests/fixtures\")\n- .join(name)\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/it/integration.rs b/lib/crates/fabro-devcontainer/tests/it/integration.rs\ndeleted file mode 100644\nindex 8ac6cf692..000000000\n--- a/lib/crates/fabro-devcontainer/tests/it/integration.rs\n+++ /dev/null\n@@ -1,209 +0,0 @@\n-use fabro_devcontainer::{Command, DevcontainerResolver};\n-\n-use super::helpers::fixture_path;\n-\n-#[tokio::test]\n-async fn resolve_image_only() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"image-only\"))\n- .await\n- .unwrap();\n-\n- assert!(\n- config\n- .dockerfile\n- .contains(\"FROM mcr.microsoft.com/devcontainers/base:ubuntu\")\n- );\n- assert_eq!(config.remote_user.as_deref(), Some(\"vscode\"));\n- assert_eq!(config.forwarded_ports, vec![3000, 80, 9090]);\n- assert_eq!(\n- config.environment.get(\"EDITOR\").map(String::as_str),\n- Some(\"code\")\n- );\n- assert_eq!(config.workspace_folder, \"/workspaces/image-only\");\n- assert!(config.compose_files.is_empty());\n- assert!(config.compose_service.is_none());\n-\n- assert_eq!(config.post_create_commands.len(), 1);\n- assert!(matches!(&config.post_create_commands[0], Command::Shell(s) if s == \"echo hello\"));\n-\n- // onCreateCommand\n- assert_eq!(config.on_create_commands.len(), 1);\n- assert!(matches!(&config.on_create_commands[0], Command::Shell(s) if s == \"setup.sh\"));\n-\n- // containerEnv baked into Dockerfile\n- assert!(\n- config\n- .dockerfile\n- .contains(\"ENV DEBIAN_FRONTEND=noninteractive\")\n- );\n- assert_eq!(\n- config\n- .container_env\n- .get(\"DEBIAN_FRONTEND\")\n- .map(String::as_str),\n- Some(\"noninteractive\")\n- );\n-}\n-\n-#[tokio::test]\n-async fn resolve_dockerfile_mode() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"dockerfile-mode\"))\n- .await\n- .unwrap();\n-\n- // Should read the actual Dockerfile content\n- assert!(config.dockerfile.contains(\"FROM node:20\"));\n- assert!(config.dockerfile.contains(\"apt-get update\"));\n- assert_eq!(config.remote_user.as_deref(), Some(\"developer\"));\n- assert_eq!(config.forwarded_ports, vec![4000]);\n-\n- assert_eq!(config.post_create_commands.len(), 1);\n- assert!(matches!(&config.post_create_commands[0], Command::Shell(s) if s == \"npm install\"));\n-\n- // build.args\n- assert_eq!(\n- config.build_args.get(\"NODE_VERSION\").map(String::as_str),\n- Some(\"20\")\n- );\n-\n- // build.target\n- assert_eq!(config.build_target.as_deref(), Some(\"dev\"));\n-}\n-\n-#[tokio::test]\n-async fn resolve_compose_mode() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"compose-mode\"))\n- .await\n- .unwrap();\n-\n- // In compose mode, the dockerfile is derived from the compose service's image\n- assert!(config.dockerfile.contains(\"FROM node:20\"));\n- assert_eq!(config.workspace_folder, \"/workspace\");\n- assert_eq!(config.remote_user.as_deref(), Some(\"node\"));\n- assert_eq!(config.compose_files.len(), 1);\n- assert_eq!(config.compose_service.as_deref(), Some(\"app\"));\n-\n- // Ports come from compose + forwardPorts merged\n- assert_eq!(config.forwarded_ports, vec![3000, 9229, 5173]);\n-\n- // Environment merged from compose + remoteEnv\n- assert_eq!(\n- config.environment.get(\"NODE_ENV\").map(String::as_str),\n- Some(\"development\")\n- );\n- assert_eq!(\n- config.environment.get(\"DEBUG\").map(String::as_str),\n- Some(\"true\")\n- );\n-}\n-\n-#[tokio::test]\n-async fn resolve_variables() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"variables\"))\n- .await\n- .unwrap();\n-\n- assert_eq!(config.workspace_folder, \"/workspaces/variables\");\n- assert_eq!(\n- config.environment.get(\"PROJECT_ROOT\").map(String::as_str),\n- Some(\"/workspaces/variables\")\n- );\n- assert_eq!(\n- config.environment.get(\"PROJECT_NAME\").map(String::as_str),\n- Some(\"variables\")\n- );\n-}\n-\n-#[tokio::test]\n-async fn resolve_compose_multi() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"compose-multi\"))\n- .await\n- .unwrap();\n-\n- // Override file wins for image\n- assert!(config.dockerfile.contains(\"FROM node:22\"));\n- assert_eq!(config.workspace_folder, \"/workspace\");\n- assert_eq!(config.compose_files.len(), 2);\n- assert_eq!(config.compose_service.as_deref(), Some(\"app\"));\n-\n- // Port from base.yml\n- assert_eq!(config.forwarded_ports, vec![3000]);\n-\n- // Environment from override.yml\n- assert_eq!(\n- config.environment.get(\"OVERRIDE_VAR\").map(String::as_str),\n- Some(\"true\")\n- );\n-}\n-\n-#[tokio::test]\n-async fn resolve_not_found() {\n- let result = DevcontainerResolver::resolve(&fixture_path(\"nonexistent\")).await;\n- assert!(result.is_err());\n- let err = result.unwrap_err();\n- assert!(err.to_string().contains(\"no devcontainer.json found\"));\n-}\n-\n-#[tokio::test]\n-async fn resolve_subdirectory_mode() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"subdirectory-mode\"))\n- .await\n- .unwrap();\n-\n- assert!(\n- config\n- .dockerfile\n- .contains(\"FROM mcr.microsoft.com/devcontainers/python:3.12\")\n- );\n- assert_eq!(config.remote_user.as_deref(), Some(\"vscode\"));\n- assert_eq!(config.workspace_folder, \"/workspaces/subdirectory-mode\");\n-}\n-\n-#[tokio::test]\n-async fn resolve_subdirectory_multiple_picks_alphabetical_first() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"subdirectory-multiple\"))\n- .await\n- .unwrap();\n-\n- // \"alpha\" sorts before \"beta\", so alpha's config is used\n- assert!(\n- config\n- .dockerfile\n- .contains(\"FROM mcr.microsoft.com/devcontainers/base:ubuntu\")\n- );\n- assert_eq!(config.remote_user.as_deref(), Some(\"alpha-user\"));\n-}\n-\n-#[tokio::test]\n-async fn resolve_subdirectory_standard_wins_over_subdirs() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"subdirectory-with-standard\"))\n- .await\n- .unwrap();\n-\n- // Standard .devcontainer/devcontainer.json takes priority over subdirectory\n- // format\n- assert!(\n- config\n- .dockerfile\n- .contains(\"FROM mcr.microsoft.com/devcontainers/base:ubuntu\")\n- );\n- assert_eq!(config.remote_user.as_deref(), Some(\"standard-user\"));\n-}\n-\n-#[tokio::test]\n-async fn generated_dockerfile_is_well_formed() {\n- let config = DevcontainerResolver::resolve(&fixture_path(\"image-only\"))\n- .await\n- .unwrap();\n-\n- // Should start with the generated header\n- assert!(\n- config\n- .dockerfile\n- .contains(\"# Generated by fabro-devcontainer\")\n- );\n- // Should have the base image\n- assert!(config.dockerfile.contains(\"FROM\"));\n- // Should end with a newline\n- assert!(config.dockerfile.ends_with('\\n'));\n-}\ndiff --git a/lib/crates/fabro-devcontainer/tests/it/main.rs b/lib/crates/fabro-devcontainer/tests/it/main.rs\ndeleted file mode 100644\nindex 7c72eb25a..000000000\n--- a/lib/crates/fabro-devcontainer/tests/it/main.rs\n+++ /dev/null\n@@ -1,3 +0,0 @@\n-mod e2e;\n-mod helpers;\n-mod integration;\ndiff --git a/lib/crates/fabro-dump/src/lib.rs b/lib/crates/fabro-dump/src/lib.rs\nindex f042cef01..397d55a3f 100644\n--- a/lib/crates/fabro-dump/src/lib.rs\n+++ b/lib/crates/fabro-dump/src/lib.rs\n@@ -473,9 +473,9 @@ mod tests {\n use fabro_types::graph::Graph;\n use fabro_types::run::RunSpec;\n use fabro_types::{\n- Checkpoint, CheckpointRecord, Conclusion, RunDiff, RunSandbox, RunStatus,\n- SandboxProviderKind, StageCompletion, StageModelUsage, StageOutcome, StartRecord,\n- SuccessReason, WorkflowSettings, first_event_seq, fixtures,\n+ Checkpoint, CheckpointRecord, Conclusion, RunDiff, RunSandbox, RunSandboxInstance,\n+ RunSandboxPlan, RunStatus, SandboxProviderKind, StageCompletion, StageModelUsage,\n+ StageOutcome, StartRecord, SuccessReason, WorkflowSettings, first_event_seq, fixtures,\n };\n use futures::executor;\n \n@@ -558,22 +558,29 @@ mod tests {\n total_retries: 0,\n diff: RunDiff::default(),\n });\n- projection.sandbox = Some(RunSandbox {\n- provider: SandboxProviderKind::Local,\n- image: None,\n- snapshot: None,\n- runtime: Some(fabro_types::RunSandboxRuntime {\n- id: \"sandbox-1\".to_string(),\n- working_directory: \"/tmp/project\".to_string(),\n- repo_cloned: None,\n- clone_origin_url: None,\n- clone_branch: None,\n- workspace_root: None,\n- repos_root: None,\n- primary_repo_path: None,\n- primary_repo_link: None,\n- }),\n- });\n+ projection.sandbox = Some(RunSandbox::ready(\n+ RunSandboxPlan {\n+ provider: SandboxProviderKind::Local,\n+ image: None,\n+ snapshot: None,\n+ },\n+ RunSandboxInstance {\n+ provider: SandboxProviderKind::Local,\n+ image: None,\n+ snapshot: None,\n+ runtime: fabro_types::RunSandboxRuntime {\n+ id: \"sandbox-1\".to_string(),\n+ working_directory: \"/tmp/project\".to_string(),\n+ repo_cloned: None,\n+ clone_origin_url: None,\n+ clone_branch: None,\n+ workspace_root: None,\n+ repos_root: None,\n+ primary_repo_path: None,\n+ primary_repo_link: None,\n+ },\n+ },\n+ ));\n let stage =\n projection.stage_entry(stage_id.node_id(), stage_id.visit(), first_event_seq(2));\n stage.prompt = Some(\"plan\".to_string());\ndiff --git a/lib/crates/fabro-sandbox/src/details.rs b/lib/crates/fabro-sandbox/src/details.rs\nindex e6b65bd9b..2ada66652 100644\n--- a/lib/crates/fabro-sandbox/src/details.rs\n+++ b/lib/crates/fabro-sandbox/src/details.rs\n@@ -4,8 +4,8 @@ use anyhow::Result;\n #[cfg(any(feature = \"docker\", feature = \"daytona\"))]\n use chrono::{DateTime, Utc};\n use fabro_types::{\n- RunId, RunSandbox, SandboxDetails, SandboxNetwork, SandboxProviderKind, SandboxResources,\n- SandboxState, SandboxTimestamps,\n+ RunId, RunSandboxInstance, SandboxDetails, SandboxNetwork, SandboxProviderKind,\n+ SandboxResources, SandboxState, SandboxTimestamps,\n };\n \n /// Inspect the sandbox identified by `record` and return provider-neutral\n@@ -20,7 +20,7 @@ use fabro_types::{\n reason = \"Feature-gated providers consume some parameters only when enabled.\"\n )]\n pub async fn sandbox_details(\n- record: &RunSandbox,\n+ record: &RunSandboxInstance,\n daytona_api_key: Option,\n daytona_organization_id: Option,\n run_id: Option,\n@@ -44,7 +44,7 @@ pub async fn sandbox_details(\n }\n }\n \n-fn local_details(record: &RunSandbox) -> SandboxDetails {\n+fn local_details(record: &RunSandboxInstance) -> SandboxDetails {\n SandboxDetails {\n sandbox: record.clone(),\n state: SandboxState::Running,\n@@ -74,23 +74,21 @@ pub(crate) mod docker {\n use bollard::container::InspectContainerOptions;\n use bollard::models::{ContainerInspectResponse, ContainerStateStatusEnum, HostConfig};\n use fabro_types::{\n- RunId, RunSandbox, SandboxDetails, SandboxInfo, SandboxNetwork, SandboxNetworkPolicy,\n- SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps,\n+ RunId, RunSandboxInstance, SandboxDetails, SandboxInfo, SandboxNetwork,\n+ SandboxNetworkPolicy, SandboxProviderKind, SandboxResources, SandboxState,\n+ SandboxTimestamps,\n };\n \n use super::parse_rfc3339_utc;\n use crate::docker::WORKING_DIRECTORY;\n \n pub(super) async fn docker_details(\n- record: &RunSandbox,\n+ record: &RunSandboxInstance,\n _run_id: Option,\n ) -> Result {\n let docker =\n Docker::connect_with_local_defaults().context(\"Failed to connect to Docker daemon\")?;\n- let runtime = record\n- .runtime\n- .as_ref()\n- .context(\"Docker run sandbox missing runtime metadata\")?;\n+ let runtime = &record.runtime;\n let inspect = docker\n .inspect_container(&runtime.id, None::)\n .await\n@@ -120,13 +118,13 @@ pub(crate) mod docker {\n \n pub(super) fn map_docker_inspect(\n inspect: &ContainerInspectResponse,\n- record: &RunSandbox,\n+ record: &RunSandboxInstance,\n ) -> SandboxDetails {\n let fields = docker_fields_from_inspect(inspect);\n let image = fields.image.clone().or_else(|| record.image.clone());\n \n SandboxDetails {\n- sandbox: RunSandbox {\n+ sandbox: RunSandboxInstance {\n image,\n ..record.clone()\n },\n@@ -274,18 +272,18 @@ pub(crate) mod docker {\n mod tests {\n use bollard::models::HostConfig;\n use fabro_types::{\n- RunSandbox, RunSandboxRuntime, SandboxNetwork, SandboxNetworkPolicy,\n+ RunSandboxInstance, RunSandboxRuntime, SandboxNetwork, SandboxNetworkPolicy,\n SandboxProviderKind,\n };\n \n use super::*;\n \n- fn record() -> RunSandbox {\n- RunSandbox {\n+ fn record() -> RunSandboxInstance {\n+ RunSandboxInstance {\n provider: SandboxProviderKind::Docker,\n image: None,\n snapshot: None,\n- runtime: Some(RunSandboxRuntime {\n+ runtime: RunSandboxRuntime {\n id: \"container-abc123\".to_string(),\n working_directory: \"/workspace\".to_string(),\n repo_cloned: Some(true),\n@@ -295,7 +293,7 @@ pub(crate) mod docker {\n repos_root: None,\n primary_repo_path: None,\n primary_repo_link: None,\n- }),\n+ },\n }\n }\n \n@@ -389,7 +387,7 @@ pub(crate) mod docker {\n ..Default::default()\n };\n let details = map_docker_inspect(&inspect, &record());\n- let runtime = details.sandbox.runtime.expect(\"runtime\");\n+ let runtime = details.sandbox.runtime;\n assert_eq!(runtime.id, \"container-abc123\");\n assert_eq!(runtime.working_directory, \"/workspace\");\n }\n@@ -497,7 +495,7 @@ pub(crate) mod daytona {\n use anyhow::{Context, Result, anyhow};\n use daytona_api_client::models::SandboxState as DaytonaState;\n use fabro_types::{\n- RunSandbox, SandboxDetails, SandboxInfo, SandboxNetwork, SandboxNetworkPolicy,\n+ RunSandboxInstance, SandboxDetails, SandboxInfo, SandboxNetwork, SandboxNetworkPolicy,\n SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps,\n };\n \n@@ -505,13 +503,10 @@ pub(crate) mod daytona {\n use crate::daytona::{DAYTONA_DASHBOARD_SANDBOXES_URL, DaytonaSandbox, WORKING_DIRECTORY};\n \n pub(super) async fn daytona_details(\n- record: &RunSandbox,\n+ record: &RunSandboxInstance,\n daytona_api_key: Option,\n ) -> Result {\n- let runtime = record\n- .runtime\n- .as_ref()\n- .context(\"Daytona run sandbox missing runtime metadata\")?;\n+ let runtime = &record.runtime;\n let repo_cloned = runtime\n .repo_cloned\n .context(\"Daytona run sandbox missing clone metadata\")?;\n@@ -555,11 +550,11 @@ pub(crate) mod daytona {\n \n pub(super) fn map_daytona_sandbox(\n sandbox: &daytona_sdk::Sandbox,\n- record: &RunSandbox,\n+ record: &RunSandboxInstance,\n ) -> SandboxDetails {\n let fields = daytona_fields_from_sdk_sandbox(sandbox);\n SandboxDetails {\n- sandbox: RunSandbox {\n+ sandbox: RunSandboxInstance {\n snapshot: sandbox.snapshot.clone().or_else(|| record.snapshot.clone()),\n ..record.clone()\n },\n@@ -817,11 +812,11 @@ mod tests {\n \n #[test]\n fn local_details_returns_running_with_no_metadata() {\n- let record = RunSandbox {\n+ let record = RunSandboxInstance {\n provider: SandboxProviderKind::Local,\n image: None,\n snapshot: None,\n- runtime: Some(fabro_types::RunSandboxRuntime {\n+ runtime: fabro_types::RunSandboxRuntime {\n id: \"local:01JNQVR7M0EJ5GKAT2SC4ERS1Z\".to_string(),\n working_directory: \"/Users/client/project\".to_string(),\n repo_cloned: None,\n@@ -831,12 +826,12 @@ mod tests {\n repos_root: None,\n primary_repo_path: None,\n primary_repo_link: None,\n- }),\n+ },\n };\n let details = local_details(&record);\n assert_eq!(details.sandbox.provider, SandboxProviderKind::Local);\n assert_eq!(details.state, SandboxState::Running);\n- let runtime = details.sandbox.runtime.as_ref().unwrap();\n+ let runtime = &details.sandbox.runtime;\n assert_eq!(runtime.id, \"local:01JNQVR7M0EJ5GKAT2SC4ERS1Z\");\n assert_eq!(runtime.working_directory, \"/Users/client/project\");\n assert!(details.region.is_none());\ndiff --git a/lib/crates/fabro-sandbox/src/lib.rs b/lib/crates/fabro-sandbox/src/lib.rs\nindex 2fb0f0b4b..e53b25756 100644\n--- a/lib/crates/fabro-sandbox/src/lib.rs\n+++ b/lib/crates/fabro-sandbox/src/lib.rs\n@@ -40,7 +40,7 @@ pub use details::sandbox_details;\n #[cfg(feature = \"docker\")]\n pub use docker::{DockerSandbox, DockerSandboxOptions};\n pub use error::{Error, Result, default_redacted_output_tail, display_for_log};\n-pub use fabro_types::{RunSandbox, SandboxProviderKind};\n+pub use fabro_types::{RunSandboxInstance, SandboxProviderKind};\n pub use local::LocalSandbox;\n #[cfg(feature = \"daytona\")]\n pub use provider::daytona::DaytonaSandboxProvider;\ndiff --git a/lib/crates/fabro-sandbox/src/reconnect.rs b/lib/crates/fabro-sandbox/src/reconnect.rs\nindex c71b6dc4e..b0b950477 100644\n--- a/lib/crates/fabro-sandbox/src/reconnect.rs\n+++ b/lib/crates/fabro-sandbox/src/reconnect.rs\n@@ -5,7 +5,7 @@ use std::path::PathBuf;\n reason = \"Feature-gated branches consume these imports when optional backends are enabled.\"\n )]\n use anyhow::{Context, Result, bail};\n-use fabro_types::{RunId, RunSandbox, SandboxProviderKind};\n+use fabro_types::{RunId, RunSandboxInstance, SandboxProviderKind};\n \n use crate::SandboxEventCallback;\n #[cfg(feature = \"daytona\")]\n@@ -24,7 +24,7 @@ use crate::local::LocalSandbox;\n reason = \"Feature-gated sandbox backends leave some parameters unused on partial builds.\"\n )]\n pub async fn reconnect(\n- record: &RunSandbox,\n+ record: &RunSandboxInstance,\n daytona_api_key: Option,\n ) -> Result> {\n reconnect_for_run(record, daytona_api_key, None).await\n@@ -35,7 +35,7 @@ pub async fn reconnect(\n reason = \"Feature-gated sandbox backends leave parameters unused on partial builds.\"\n )]\n pub async fn reconnect_for_run(\n- record: &RunSandbox,\n+ record: &RunSandboxInstance,\n daytona_api_key: Option,\n run_id: Option,\n ) -> Result> {\n@@ -47,15 +47,12 @@ pub async fn reconnect_for_run(\n reason = \"Feature-gated sandbox backends leave parameters unused on partial builds.\"\n )]\n pub async fn reconnect_for_run_with_callback(\n- record: &RunSandbox,\n+ record: &RunSandboxInstance,\n daytona_api_key: Option,\n run_id: Option,\n event_callback: Option,\n ) -> Result> {\n- let runtime = record\n- .runtime\n- .as_ref()\n- .context(\"run sandbox missing runtime metadata\")?;\n+ let runtime = &record.runtime;\n match record.provider {\n SandboxProviderKind::Local => {\n let mut sandbox = LocalSandbox::new(PathBuf::from(&runtime.working_directory));\ndiff --git a/lib/crates/fabro-sandbox/src/sandbox_spec.rs b/lib/crates/fabro-sandbox/src/sandbox_spec.rs\nindex 32677c769..b6a56bd40 100644\n--- a/lib/crates/fabro-sandbox/src/sandbox_spec.rs\n+++ b/lib/crates/fabro-sandbox/src/sandbox_spec.rs\n@@ -9,12 +9,12 @@ use fabro_github::GitHubCredentials;\n unused_imports,\n reason = \"Daytona-enabled builds persist RunId in the sandbox spec.\"\n )]\n-use fabro_types::{RunId, RunSandbox, RunSandboxRuntime, SandboxProviderKind};\n+use fabro_types::{RunId, RunSandboxInstance, RunSandboxRuntime, SandboxProviderKind};\n \n #[cfg(any(feature = \"docker\", feature = \"daytona\"))]\n use crate::clone_source;\n #[cfg(feature = \"daytona\")]\n-use crate::daytona::{self, DaytonaConfig, DaytonaSandbox, DaytonaSnapshotConfig};\n+use crate::daytona::{self, DaytonaConfig, DaytonaSandbox};\n #[cfg(feature = \"docker\")]\n use crate::docker::{self, DockerSandbox, DockerSandboxOptions};\n use crate::local::LocalSandbox;\n@@ -63,8 +63,12 @@ impl SandboxSpec {\n }\n }\n \n- /// Build a RunSandbox for persistence.\n- pub fn to_run_sandbox(&self, sandbox: &dyn Sandbox, run_id: RunId) -> RunSandbox {\n+ /// Build initialized sandbox metadata for persistence.\n+ pub fn to_run_sandbox_instance(\n+ &self,\n+ sandbox: &dyn Sandbox,\n+ run_id: RunId,\n+ ) -> RunSandboxInstance {\n let working_directory = sandbox.working_directory().to_string();\n let id = {\n let info = sandbox.sandbox_info();\n@@ -93,11 +97,11 @@ impl SandboxSpec {\n docker::WORKING_DIRECTORY,\n docker::REPOS_ROOT,\n );\n- RunSandbox {\n+ RunSandboxInstance {\n provider: self.provider(),\n image: (!config.image.is_empty()).then(|| config.image.clone()),\n snapshot: None,\n- runtime: Some(RunSandboxRuntime {\n+ runtime: RunSandboxRuntime {\n id,\n working_directory: working_directory.clone(),\n repo_cloned,\n@@ -113,7 +117,7 @@ impl SandboxSpec {\n primary_repo_link: layout\n .as_ref()\n .map(|layout| layout.primary_repo_link.clone()),\n- }),\n+ },\n }\n }\n #[cfg(feature = \"daytona\")]\n@@ -133,11 +137,11 @@ impl SandboxSpec {\n daytona::WORKING_DIRECTORY,\n daytona::REPOS_ROOT,\n );\n- RunSandbox {\n+ RunSandboxInstance {\n provider: self.provider(),\n image: None,\n snapshot: sandbox.snapshot_info(),\n- runtime: Some(RunSandboxRuntime {\n+ runtime: RunSandboxRuntime {\n id,\n working_directory: working_directory.clone(),\n repo_cloned,\n@@ -153,14 +157,14 @@ impl SandboxSpec {\n primary_repo_link: layout\n .as_ref()\n .map(|layout| layout.primary_repo_link.clone()),\n- }),\n+ },\n }\n }\n- _ => RunSandbox {\n+ _ => RunSandboxInstance {\n provider: self.provider(),\n image: None,\n snapshot: None,\n- runtime: Some(RunSandboxRuntime {\n+ runtime: RunSandboxRuntime {\n id,\n working_directory,\n repo_cloned: None,\n@@ -170,19 +174,11 @@ impl SandboxSpec {\n repos_root: None,\n primary_repo_path: None,\n primary_repo_link: None,\n- }),\n+ },\n },\n }\n }\n \n- /// Apply devcontainer snapshot config. Only Daytona uses this.\n- #[cfg(feature = \"daytona\")]\n- pub fn apply_devcontainer_snapshot(&mut self, snapshot: DaytonaSnapshotConfig) {\n- if let Self::Daytona { config, .. } = self {\n- config.snapshot = Some(snapshot);\n- }\n- }\n-\n #[allow(\n clippy::unused_async,\n reason = \"Only Daytona construction awaits; local and Docker builds share the async API.\"\n@@ -285,8 +281,8 @@ mod tests {\n sandbox.working_dir = \"/workspace/rack-test\";\n \n let run_id: RunId = \"01HY0000000000000000000000\".parse().unwrap();\n- let record = spec.to_run_sandbox(&sandbox, run_id);\n- let runtime = record.runtime.expect(\"runtime\");\n+ let record = spec.to_run_sandbox_instance(&sandbox, run_id);\n+ let runtime = record.runtime;\n \n assert_eq!(runtime.working_directory, \"/workspace/rack-test\");\n assert_eq!(runtime.repo_cloned, Some(true));\n@@ -323,8 +319,8 @@ mod tests {\n sandbox.working_dir = \"/workspace\";\n \n let run_id: RunId = \"01HY0000000000000000000000\".parse().unwrap();\n- let record = spec.to_run_sandbox(&sandbox, run_id);\n- let runtime = record.runtime.expect(\"runtime\");\n+ let record = spec.to_run_sandbox_instance(&sandbox, run_id);\n+ let runtime = record.runtime;\n \n assert_eq!(runtime.working_directory, \"/workspace\");\n assert_eq!(runtime.repo_cloned, Some(false));\ndiff --git a/lib/crates/fabro-sandbox/src/terminal.rs b/lib/crates/fabro-sandbox/src/terminal.rs\nindex 67dfa29b6..44081f9bb 100644\n--- a/lib/crates/fabro-sandbox/src/terminal.rs\n+++ b/lib/crates/fabro-sandbox/src/terminal.rs\n@@ -1,9 +1,8 @@\n use async_trait::async_trait;\n #[cfg(feature = \"daytona\")]\n use fabro_static::EnvVars;\n-use fabro_types::{RunId, SandboxProviderKind};\n+use fabro_types::{RunId, RunSandboxInstance, SandboxProviderKind};\n \n-use crate::RunSandbox;\n #[cfg(any(feature = \"daytona\", feature = \"docker\"))]\n use crate::Sandbox;\n #[cfg(feature = \"daytona\")]\n@@ -35,17 +34,14 @@ pub trait TerminalSession: Send + Sync {\n }\n \n pub async fn open_terminal_for_run(\n- record: &RunSandbox,\n+ record: &RunSandboxInstance,\n daytona_api_key: Option,\n daytona_organization_id: Option,\n run_id: Option,\n size: TerminalSize,\n ) -> crate::Result> {\n #[cfg(any(feature = \"daytona\", feature = \"docker\"))]\n- let runtime = record\n- .runtime\n- .as_ref()\n- .ok_or_else(|| crate::Error::message(\"Run sandbox is missing runtime metadata\"))?;\n+ let runtime = &record.runtime;\n #[cfg(not(feature = \"daytona\"))]\n let _ = (&daytona_api_key, &daytona_organization_id);\n #[cfg(not(feature = \"docker\"))]\ndiff --git a/lib/crates/fabro-server/src/run_files.rs b/lib/crates/fabro-server/src/run_files.rs\nindex 645c53424..1f358821b 100644\n--- a/lib/crates/fabro-server/src/run_files.rs\n+++ b/lib/crates/fabro-server/src/run_files.rs\n@@ -1213,8 +1213,10 @@ async fn reconnect_run_sandbox(\n ) -> std::result::Result, ApiError> {\n let record = projection\n .sandbox\n- .clone()\n- .ok_or_else(|| ApiError::new(StatusCode::CONFLICT, \"Run has no active sandbox.\"))?;\n+ .as_ref()\n+ .and_then(fabro_types::RunSandbox::instance)\n+ .cloned()\n+ .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, \"Run sandbox was not created.\"))?;\n let daytona_api_key = state.vault_secret(EnvVars::DAYTONA_API_KEY);\n let sandbox = reconnect_for_run(&record, daytona_api_key, Some(*run_id))\n .await\ndiff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs\nindex 1c83dd57b..43721bec3 100644\n--- a/lib/crates/fabro-server/src/server.rs\n+++ b/lib/crates/fabro-server/src/server.rs\n@@ -1059,7 +1059,7 @@ impl AskFabroReadiness {\n } else if run\n .sandbox\n .as_ref()\n- .and_then(|sandbox| sandbox.runtime.as_ref())\n+ .and_then(fabro_types::RunSandbox::instance)\n .is_none()\n {\n Some(AskFabroUnavailableReason::SandboxNotReady)\n@@ -2439,12 +2439,15 @@ async fn delete_run_sandbox_resource(\n .environment\n .lifecycle\n .preserve;\n- let Some(record) = projection.sandbox else {\n- return Ok(SandboxDeleteOutcome::Cleaned);\n- };\n- let Some(runtime) = record.runtime.as_ref() else {\n+ let Some(record) = projection\n+ .sandbox\n+ .as_ref()\n+ .and_then(fabro_types::RunSandbox::instance)\n+ .cloned()\n+ else {\n return Ok(SandboxDeleteOutcome::Cleaned);\n };\n+ let runtime = &record.runtime;\n if preserve {\n return Ok(SandboxDeleteOutcome::Preserved(DeleteRunResponse {\n deleted: true,\ndiff --git a/lib/crates/fabro-server/src/server/handler/sandbox.rs b/lib/crates/fabro-server/src/server/handler/sandbox.rs\nindex bfba9c242..61d8b7ee9 100644\n--- a/lib/crates/fabro-server/src/server/handler/sandbox.rs\n+++ b/lib/crates/fabro-server/src/server/handler/sandbox.rs\n@@ -5,7 +5,9 @@ use std::sync::Arc;\n \n use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};\n use fabro_sandbox::{TerminalSize, open_terminal_for_run};\n-use fabro_types::{SandboxProviderKind, SandboxServiceDiscoverySource, SandboxServiceListMeta};\n+use fabro_types::{\n+ RunSandboxInstance, SandboxProviderKind, SandboxServiceDiscoverySource, SandboxServiceListMeta,\n+};\n use futures_util::FutureExt;\n use futures_util::future::BoxFuture;\n \n@@ -103,7 +105,7 @@ async fn retrieve_run_sandbox(\n Ok(id) => id,\n Err(response) => return response,\n };\n- let record = match load_run_sandbox_or_not_found(&state, &id).await {\n+ let record = match load_run_sandbox_instance(&state, &id).await {\n Ok(record) => record,\n Err(response) => return response,\n };\n@@ -216,7 +218,7 @@ async fn run_terminal(\n }\n \n async fn terminal_websocket(mut socket: WebSocket, state: Arc, id: RunId) {\n- let record = match load_run_sandbox(&state, &id).await {\n+ let record = match load_run_sandbox_instance(&state, &id).await {\n Ok(record) => record,\n Err(response) => {\n let message = terminal_error_from_status(response.status());\n@@ -395,14 +397,14 @@ async fn create_ssh_access(\n Ok(id) => id,\n Err(response) => return response,\n };\n- let record = match load_run_sandbox(&state, &id).await {\n+ let record = match load_run_sandbox_instance(&state, &id).await {\n Ok(record) => record,\n Err(response) => return response,\n };\n \n match record.provider {\n SandboxProviderKind::Daytona => {\n- let sandbox = match reconnect_daytona_sandbox(&state, &id).await {\n+ let sandbox = match reconnect_daytona_sandbox_instance(&state, &record).await {\n Ok(sandbox) => sandbox,\n Err(response) => return response,\n };\n@@ -416,7 +418,7 @@ async fn create_ssh_access(\n }\n }\n SandboxProviderKind::Docker => {\n- let sandbox = match reconnect_run_sandbox(&state, &id).await {\n+ let sandbox = match reconnect_run_sandbox_instance(&state, &id, &record).await {\n Ok(sandbox) => sandbox,\n Err(response) => return response,\n };\n@@ -451,7 +453,7 @@ async fn create_sandbox_vnc_preview(\n Ok(id) => id,\n Err(response) => return response,\n };\n- let record = match load_run_sandbox(&state, &id).await {\n+ let record = match load_run_sandbox_instance(&state, &id).await {\n Ok(record) => record,\n Err(response) => return response,\n };\n@@ -462,7 +464,7 @@ async fn create_sandbox_vnc_preview(\n )\n .into_response();\n }\n- let sandbox = match reconnect_daytona_sandbox(&state, &id).await {\n+ let sandbox = match reconnect_daytona_sandbox_instance(&state, &record).await {\n Ok(sandbox) => sandbox,\n Err(response) => return response,\n };\n@@ -555,12 +557,12 @@ async fn list_sandbox_services(\n Ok(id) => id,\n Err(response) => return response,\n };\n- let record = match load_run_sandbox(&state, &id).await {\n+ let record = match load_run_sandbox_instance(&state, &id).await {\n Ok(record) => record,\n Err(response) => return response,\n };\n let provider = record.provider;\n- let sandbox = match reconnect_run_sandbox(&state, &id).await {\n+ let sandbox = match reconnect_run_sandbox_instance(&state, &id, &record).await {\n Ok(sandbox) => sandbox,\n Err(response) => return response,\n };\n@@ -848,9 +850,17 @@ async fn reconnect_run_sandbox(\n state: &Arc,\n run_id: &RunId,\n ) -> Result, Response> {\n- let record = load_run_sandbox(state, run_id).await?;\n+ let record = load_run_sandbox_instance(state, run_id).await?;\n+ reconnect_run_sandbox_instance(state, run_id, &record).await\n+}\n+\n+async fn reconnect_run_sandbox_instance(\n+ state: &Arc,\n+ run_id: &RunId,\n+ record: &RunSandboxInstance,\n+) -> Result, Response> {\n let daytona_api_key = state.vault_secret(EnvVars::DAYTONA_API_KEY);\n- let sandbox = reconnect_for_run(&record, daytona_api_key, Some(*run_id))\n+ let sandbox = reconnect_for_run(record, daytona_api_key, Some(*run_id))\n .await\n .map_err(|err| {\n let detail = render_with_causes(&err.to_string(), &collect_causes(err.as_ref()));\n@@ -866,7 +876,14 @@ async fn reconnect_daytona_sandbox(\n state: &Arc,\n run_id: &RunId,\n ) -> Result {\n- let record = load_run_sandbox(state, run_id).await?;\n+ let record = load_run_sandbox_instance(state, run_id).await?;\n+ reconnect_daytona_sandbox_instance(state, &record).await\n+}\n+\n+async fn reconnect_daytona_sandbox_instance(\n+ state: &Arc,\n+ record: &RunSandboxInstance,\n+) -> Result {\n if record.provider != SandboxProviderKind::Daytona {\n return Err(ApiError::new(\n StatusCode::CONFLICT,\n@@ -874,13 +891,7 @@ async fn reconnect_daytona_sandbox(\n )\n .into_response());\n }\n- let Some(runtime) = record.runtime.as_ref() else {\n- return Err(ApiError::new(\n- StatusCode::CONFLICT,\n- \"Sandbox record is missing runtime metadata.\",\n- )\n- .into_response());\n- };\n+ let runtime = &record.runtime;\n let Some(repo_cloned) = runtime.repo_cloned else {\n return Err(ApiError::new(\n StatusCode::CONFLICT,\n@@ -907,35 +918,16 @@ async fn reconnect_daytona_sandbox(\n Ok(sandbox)\n }\n \n-async fn load_run_sandbox(\n+async fn load_run_sandbox_instance(\n state: &Arc,\n run_id: &RunId,\n-) -> Result {\n- match state.store.open_run_reader(run_id).await {\n- Ok(run_store) => match run_store.state().await {\n- Ok(run_state) => run_state.sandbox.ok_or_else(|| {\n- ApiError::new(StatusCode::CONFLICT, \"Run has no active sandbox.\").into_response()\n- }),\n- Err(err) => Err(\n- ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),\n- ),\n- },\n- Err(_) => Err(ApiError::not_found(\"Run not found.\").into_response()),\n- }\n-}\n-\n-/// Same as `load_run_sandbox`, but treats a missing sandbox as\n-/// `404 Not Found` instead of `409 Conflict`. Used by the inspection endpoint\n-/// where there is no resource to act on if the run never had a sandbox.\n-async fn load_run_sandbox_or_not_found(\n- state: &Arc,\n- run_id: &RunId,\n-) -> Result {\n+) -> Result {\n match state.store.open_run_reader(run_id).await {\n Ok(run_store) => match run_store.state().await {\n Ok(run_state) => run_state\n .sandbox\n- .ok_or_else(|| ApiError::not_found(\"Run has no sandbox.\").into_response()),\n+ .and_then(fabro_types::RunSandbox::into_instance)\n+ .ok_or_else(|| ApiError::not_found(\"Run sandbox was not created.\").into_response()),\n Err(err) => Err(\n ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),\n ),\n@@ -1378,6 +1370,38 @@ mod retrieve_sandbox_tests {\n run_store.append_event(&payload).await.unwrap();\n }\n \n+ async fn append_sandbox_failed(run_store: &fabro_store::RunDatabase, run_id: &RunId) {\n+ let payload = fabro_store::EventPayload::new(\n+ json!({\n+ \"id\": \"evt-sandbox-failed\",\n+ \"ts\": \"2026-05-09T12:00:00Z\",\n+ \"run_id\": run_id,\n+ \"event\": \"sandbox.failed\",\n+ \"properties\": {\n+ \"provider\": \"docker\",\n+ \"error\": \"Docker daemon unavailable\",\n+ \"causes\": [\"connection refused\"],\n+ \"duration_ms\": 42,\n+ },\n+ }),\n+ run_id,\n+ )\n+ .expect(\"sandbox.failed payload should validate\");\n+ run_store.append_event(&payload).await.unwrap();\n+ }\n+\n+ async fn assert_sandbox_not_created_response(response: axum::response::Response) {\n+ assert_eq!(response.status(), StatusCode::NOT_FOUND);\n+ let body = body_json(response).await;\n+ assert!(\n+ body[\"errors\"][0][\"detail\"]\n+ .as_str()\n+ .unwrap_or_default()\n+ .contains(\"Run sandbox was not created.\"),\n+ \"unexpected body: {body}\"\n+ );\n+ }\n+\n #[tokio::test]\n async fn missing_run_returns_404() {\n let app = build_test_router(test_app_state());\n@@ -1398,7 +1422,7 @@ mod retrieve_sandbox_tests {\n }\n \n #[tokio::test]\n- async fn run_without_sandbox_runtime_returns_planned_sandbox_details() {\n+ async fn planned_sandbox_returns_404_from_details_endpoint() {\n let state = test_app_state();\n let app = build_test_router(state.clone());\n let run_id = RunId::new();\n@@ -1412,10 +1436,52 @@ mod retrieve_sandbox_tests {\n .oneshot(req_get(&format!(\"/api/v1/runs/{run_id}/sandbox\")))\n .await\n .unwrap();\n- assert_eq!(response.status(), StatusCode::OK);\n- let body = body_json(response).await;\n- assert_eq!(body[\"sandbox\"][\"provider\"], \"local\");\n- assert!(body[\"sandbox\"][\"runtime\"].is_null());\n+ assert_sandbox_not_created_response(response).await;\n+ }\n+\n+ #[tokio::test]\n+ async fn planned_sandbox_rejects_live_operations() {\n+ let state = test_app_state();\n+ let app = build_test_router(state.clone());\n+ let run_id = RunId::new();\n+ let run_store = state\n+ .store_ref()\n+ .create_run(&run_id)\n+ .await\n+ .expect(\"test run should be creatable\");\n+ append_run_created(&run_store, &run_id).await;\n+\n+ for uri in [\n+ format!(\"/api/v1/runs/{run_id}/sandbox/services\"),\n+ format!(\"/api/v1/runs/{run_id}/sandbox/files?path=/workspace\"),\n+ format!(\"/api/v1/runs/{run_id}/sandbox/file?path=/workspace/README.md\"),\n+ ] {\n+ let response = app.clone().oneshot(req_get(&uri)).await.unwrap();\n+ assert_sandbox_not_created_response(response).await;\n+ }\n+ }\n+\n+ #[tokio::test]\n+ async fn failed_sandbox_rejects_live_operations() {\n+ let state = test_app_state();\n+ let app = build_test_router(state.clone());\n+ let run_id = RunId::new();\n+ let run_store = state\n+ .store_ref()\n+ .create_run(&run_id)\n+ .await\n+ .expect(\"test run should be creatable\");\n+ append_run_created(&run_store, &run_id).await;\n+ append_sandbox_failed(&run_store, &run_id).await;\n+\n+ for uri in [\n+ format!(\"/api/v1/runs/{run_id}/sandbox/services\"),\n+ format!(\"/api/v1/runs/{run_id}/sandbox/files?path=/workspace\"),\n+ format!(\"/api/v1/runs/{run_id}/sandbox/file?path=/workspace/README.md\"),\n+ ] {\n+ let response = app.clone().oneshot(req_get(&uri)).await.unwrap();\n+ assert_sandbox_not_created_response(response).await;\n+ }\n }\n \n #[tokio::test]\ndiff --git a/lib/crates/fabro-server/src/server/handler/sessions.rs b/lib/crates/fabro-server/src/server/handler/sessions.rs\nindex aaaaf368f..bad48c6d4 100644\n--- a/lib/crates/fabro-server/src/server/handler/sessions.rs\n+++ b/lib/crates/fabro-server/src/server/handler/sessions.rs\n@@ -702,13 +702,11 @@ async fn build_agent_session(\n .sandbox\n .as_ref()\n .ok_or(AskFabroBuildError::NoSandbox)?;\n- if sandbox_record.runtime.is_none() {\n- return Err(AskFabroBuildError::SandboxUnavailable(anyhow::anyhow!(\n- \"run sandbox runtime is not ready\"\n- )));\n- }\n+ let sandbox_instance = sandbox_record.instance().ok_or_else(|| {\n+ AskFabroBuildError::SandboxUnavailable(anyhow::anyhow!(\"run sandbox was not created\"))\n+ })?;\n let sandbox = reconnect_for_run(\n- sandbox_record,\n+ sandbox_instance,\n state.vault_secret(EnvVars::DAYTONA_API_KEY),\n Some(run_id),\n )\ndiff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs\nindex b0105392c..78432c1b2 100644\n--- a/lib/crates/fabro-server/src/server/tests.rs\n+++ b/lib/crates/fabro-server/src/server/tests.rs\n@@ -14330,9 +14330,13 @@ async fn list_runs_includes_live_metadata_from_run_state() {\n .expect(\"run should be in board\");\n \n assert_eq!(item[\"pull_request\"][\"number\"].as_u64(), Some(42));\n- assert_eq!(item[\"sandbox\"][\"runtime\"][\"id\"].as_str(), Some(\"sb-test\"));\n+ assert_eq!(item[\"sandbox\"][\"kind\"].as_str(), Some(\"ready\"));\n assert_eq!(\n- item[\"sandbox\"][\"runtime\"][\"working_directory\"].as_str(),\n+ item[\"sandbox\"][\"instance\"][\"runtime\"][\"id\"].as_str(),\n+ Some(\"sb-test\")\n+ );\n+ assert_eq!(\n+ item[\"sandbox\"][\"instance\"][\"runtime\"][\"working_directory\"].as_str(),\n Some(\"/sandbox/workdir\")\n );\n assert!(item[\"current_question\"].is_object());\n@@ -14391,7 +14395,7 @@ async fn list_runs_page_limit_preserves_metadata_for_paged_items() {\n assert_eq!(data.len(), 1);\n \n let item = &data[0];\n- let sandbox_id = item[\"sandbox\"][\"runtime\"][\"id\"]\n+ let sandbox_id = item[\"sandbox\"][\"instance\"][\"runtime\"][\"id\"]\n .as_str()\n .expect(\"paged item should still include sandbox metadata\");\n assert!(matches!(sandbox_id, \"sb-first\" | \"sb-second\"));\ndiff --git a/lib/crates/fabro-server/tests/it/api/run_files.rs b/lib/crates/fabro-server/tests/it/api/run_files.rs\nindex 783100d5c..a3bf7ad76 100644\n--- a/lib/crates/fabro-server/tests/it/api/run_files.rs\n+++ b/lib/crates/fabro-server/tests/it/api/run_files.rs\n@@ -14,7 +14,7 @@ use axum::body::Body;\n use axum::http::{Request, StatusCode};\n use fabro_server::test_support::test_app_state_with_store;\n use fabro_store::{ArtifactStore, Database};\n-use fabro_types::{Graph, RunId, WorkflowSettings};\n+use fabro_types::{Graph, RunId, SandboxProviderKind, WorkflowSettings};\n use fabro_workflow::event as workflow_event;\n use fabro_workflow::run_status::SuccessReason;\n use object_store::memory::InMemory as MemoryObjectStore;\n@@ -125,6 +125,33 @@ async fn append_completed_run_with_final_patch(\n .expect(\"append WorkflowRunCompleted\");\n }\n \n+async fn append_local_sandbox_initialized(store: &Database, run_id: &RunId) {\n+ let run_store = store.open_run(run_id).await.expect(\"open run store\");\n+ workflow_event::append_event(\n+ &run_store,\n+ run_id,\n+ &workflow_event::Event::SandboxInitialized {\n+ working_directory: std::env::current_dir()\n+ .expect(\"test should run inside a source checkout\")\n+ .display()\n+ .to_string(),\n+ provider: SandboxProviderKind::Local,\n+ id: \"local:test-sandbox\".to_string(),\n+ image: None,\n+ snapshot: None,\n+ repo_cloned: None,\n+ clone_origin_url: None,\n+ clone_branch: None,\n+ workspace_root: None,\n+ repos_root: None,\n+ primary_repo_path: None,\n+ primary_repo_link: None,\n+ },\n+ )\n+ .await\n+ .expect(\"append SandboxInitialized\");\n+}\n+\n #[tokio::test]\n async fn invalid_run_id_returns_400() {\n let app = fabro_server::test_support::build_test_router(test_app_state());\n@@ -311,6 +338,7 @@ diff --git a/.env.production b/.env.production\n +SECRET=new\n \";\n append_completed_run_with_final_patch(&store, &run_id, patch).await;\n+ append_local_sandbox_initialized(&store, &run_id).await;\n \n let req = Request::builder()\n .method(\"GET\")\n@@ -345,7 +373,7 @@ diff --git a/.env.production b/.env.production\n }\n \n #[tokio::test]\n-async fn unavailable_sandbox_falls_back_to_final_patch_for_every_scope() {\n+async fn planned_sandbox_rejects_files_for_every_scope() {\n let settings = test_settings();\n let (store, artifact_store) = store_bundle();\n let state = test_app_state_with_store(\n@@ -376,15 +404,15 @@ diff --git a/src/lib.rs b/src/lib.rs\n let resp = app.clone().oneshot(req).await.unwrap();\n let body = response_json(\n resp,\n- StatusCode::OK,\n+ StatusCode::NOT_FOUND,\n format!(\"GET /api/v1/runs/{run_id}/files?scope={scope}\"),\n )\n .await;\n \n- assert_eq!(body[\"meta\"][\"source\"].as_str(), Some(\"final_patch\"));\n- assert_eq!(body[\"meta\"][\"scope\"].as_str(), Some(\"committed\"));\n- assert_eq!(body[\"meta\"][\"degraded\"].as_bool(), Some(true));\n- assert_eq!(body[\"data\"].as_array().map(Vec::len), Some(1));\n+ assert_eq!(\n+ body[\"errors\"][0][\"detail\"].as_str(),\n+ Some(\"Run sandbox was not created.\")\n+ );\n }\n }\n \ndiff --git a/lib/crates/fabro-server/tests/it/api/sandbox_vnc.rs b/lib/crates/fabro-server/tests/it/api/sandbox_vnc.rs\nindex 49447361b..2ba6a716d 100644\n--- a/lib/crates/fabro-server/tests/it/api/sandbox_vnc.rs\n+++ b/lib/crates/fabro-server/tests/it/api/sandbox_vnc.rs\n@@ -27,7 +27,7 @@ async fn vnc_for_missing_run_returns_not_found() {\n }\n \n #[tokio::test]\n-async fn vnc_for_run_without_sandbox_returns_conflict() {\n+async fn vnc_for_run_without_sandbox_returns_not_found() {\n let app = fabro_server::test_support::build_test_router(test_app_state());\n let create_req = Request::builder()\n .method(\"POST\")\n@@ -51,7 +51,7 @@ async fn vnc_for_run_without_sandbox_returns_conflict() {\n \n response_status(\n response,\n- StatusCode::NOT_IMPLEMENTED,\n+ StatusCode::NOT_FOUND,\n format!(\"POST /api/v1/runs/{run_id}/sandbox/vnc\"),\n )\n .await;\ndiff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs\nindex 2955cbbed..cd6fa0640 100644\n--- a/lib/crates/fabro-store/src/run_state.rs\n+++ b/lib/crates/fabro-store/src/run_state.rs\n@@ -13,10 +13,11 @@ use fabro_types::{\n McpServerProjection, McpServerStatus, Outcome, PendingInterviewRecord, PendingReason,\n PullRequestLink, RepositoryRef, Run, RunApproval, RunApprovalState, RunBillingSummary,\n RunControlAction, RunDiff, RunEvent, RunId, RunLifecycle, RunLinks, RunModel, RunOrigin,\n- RunProjection, RunSandbox, RunSandboxRuntime, RunSize, RunSpec, RunStatus, RunTimestamps,\n- SandboxProviderKind, StageCompletion, StageHandler, StageId, StageModelUsage, StageOutcome,\n- StageProjection, StageState, StartRecord, SubAgentProjection, SubAgentStatus, TodoListKind,\n- TodoListProjection, TodoProjection, WorkflowRef, first_event_seq,\n+ RunProjection, RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxPlan,\n+ RunSandboxRuntime, RunSize, RunSpec, RunStatus, RunTimestamps, SandboxProviderKind,\n+ StageCompletion, StageHandler, StageId, StageModelUsage, StageOutcome, StageProjection,\n+ StageState, StartRecord, SubAgentProjection, SubAgentStatus, TodoListKind, TodoListProjection,\n+ TodoProjection, WorkflowRef, first_event_seq,\n };\n use fabro_util::error::render_compact_with_causes;\n \n@@ -259,27 +260,37 @@ impl RunProjectionReducer for RunProjection {\n diff: diff_from_checkpoint_props(props),\n });\n }\n+ EventBody::SandboxInitializing(_) => {\n+ let plan = sandbox_plan_from_projection_or_settings(self);\n+ self.sandbox = Some(RunSandbox::initializing(plan));\n+ }\n+ EventBody::SandboxFailed(props) => {\n+ let plan = sandbox_plan_from_projection_or_settings(self);\n+ self.sandbox = Some(RunSandbox::failed(plan, RunSandboxFailure {\n+ provider: props.provider.clone(),\n+ error: props.error.clone(),\n+ causes: props.causes.clone(),\n+ duration_ms: props.duration_ms,\n+ }));\n+ }\n EventBody::SandboxInitialized(props) => {\n- let sandbox = self.sandbox.get_or_insert(RunSandbox {\n+ let plan = sandbox_plan_from_projection_or_settings(self);\n+ self.sandbox = Some(RunSandbox::ready(plan, RunSandboxInstance {\n provider: props.provider,\n- image: None,\n- snapshot: None,\n- runtime: None,\n- });\n- sandbox.provider = props.provider;\n- sandbox.image.clone_from(&props.image);\n- sandbox.snapshot.clone_from(&props.snapshot);\n- sandbox.runtime = Some(RunSandboxRuntime {\n- id: props.id.clone(),\n- working_directory: props.working_directory.clone(),\n- repo_cloned: props.repo_cloned,\n- clone_origin_url: props.clone_origin_url.clone(),\n- clone_branch: props.clone_branch.clone(),\n- workspace_root: props.workspace_root.clone(),\n- repos_root: props.repos_root.clone(),\n- primary_repo_path: props.primary_repo_path.clone(),\n- primary_repo_link: props.primary_repo_link.clone(),\n- });\n+ image: props.image.clone(),\n+ snapshot: props.snapshot.clone(),\n+ runtime: RunSandboxRuntime {\n+ id: props.id.clone(),\n+ working_directory: props.working_directory.clone(),\n+ repo_cloned: props.repo_cloned,\n+ clone_origin_url: props.clone_origin_url.clone(),\n+ clone_branch: props.clone_branch.clone(),\n+ workspace_root: props.workspace_root.clone(),\n+ repos_root: props.repos_root.clone(),\n+ primary_repo_path: props.primary_repo_path.clone(),\n+ primary_repo_link: props.primary_repo_link.clone(),\n+ },\n+ }));\n }\n EventBody::PullRequestCreated(props) => {\n self.pull_request = Some(PullRequestLink {\n@@ -794,20 +805,28 @@ fn projection_from_created(event: &EventEnvelope) -> Result {\n projection.parent_id = props.parent_id;\n projection.retried_from = props.retried_from;\n projection.web_url.clone_from(&props.web_url);\n- projection.sandbox = Some(planned_sandbox(&projection.spec.settings.run.environment));\n+ projection.sandbox = Some(RunSandbox::planned(sandbox_plan(\n+ &projection.spec.settings.run.environment,\n+ )));\n Ok(projection)\n }\n \n-fn planned_sandbox(settings: &RunEnvironmentSettings) -> RunSandbox {\n+fn sandbox_plan_from_projection_or_settings(state: &RunProjection) -> RunSandboxPlan {\n+ state.sandbox.as_ref().map_or_else(\n+ || sandbox_plan(&state.spec.settings.run.environment),\n+ |sandbox| sandbox.plan().clone(),\n+ )\n+}\n+\n+fn sandbox_plan(settings: &RunEnvironmentSettings) -> RunSandboxPlan {\n let provider = SandboxProviderKind::from(settings.provider);\n- RunSandbox {\n+ RunSandboxPlan {\n provider,\n image: (settings.provider == EnvironmentProvider::Docker)\n .then(|| settings.image.docker.clone())\n .flatten()\n .filter(|image| !image.is_empty()),\n snapshot: None,\n- runtime: None,\n }\n }\n \n@@ -1380,7 +1399,7 @@ mod tests {\n docker.provider = EnvironmentProvider::Docker;\n docker.image.docker = Some(\"ubuntu:24.04\".to_string());\n \n- let planned_docker = super::planned_sandbox(&docker);\n+ let planned_docker = super::sandbox_plan(&docker);\n assert_eq!(planned_docker.image.as_deref(), Some(\"ubuntu:24.04\"));\n assert_eq!(planned_docker.snapshot, None);\n \n@@ -1388,7 +1407,7 @@ mod tests {\n daytona.provider = EnvironmentProvider::Daytona;\n daytona.image.dockerfile = Some(DockerfileSource::Inline(\"FROM ubuntu:24.04\".to_string()));\n \n- let planned_daytona = super::planned_sandbox(&daytona);\n+ let planned_daytona = super::sandbox_plan(&daytona);\n assert_eq!(planned_daytona.image, None);\n assert_eq!(planned_daytona.snapshot, None);\n }\n@@ -1411,9 +1430,10 @@ mod tests {\n .unwrap();\n \n let sandbox = state.sandbox.expect(\"sandbox should be projected\");\n- assert_eq!(sandbox.image, None);\n+ let instance = sandbox.instance().expect(\"sandbox should be ready\");\n+ assert_eq!(instance.image, None);\n assert_eq!(\n- sandbox.snapshot.as_deref(),\n+ instance.snapshot.as_deref(),\n Some(\"fabro-11111111-2222-8333-8444-555555555555\")\n );\n }\n@@ -1469,6 +1489,155 @@ mod tests {\n );\n }\n \n+ #[test]\n+ fn run_created_projects_planned_sandbox_lifecycle() {\n+ let state = RunProjection::apply_events(&[test_raw_event(\n+ 1,\n+ \"run.created\",\n+ &json!({\n+ \"settings\": WorkflowSettings::default(),\n+ \"graph\": Graph::new(\"test\"),\n+ \"labels\": {},\n+ \"run_dir\": \"/tmp/run\"\n+ }),\n+ None,\n+ )])\n+ .unwrap();\n+\n+ let sandbox = serde_json::to_value(state.sandbox.as_ref().unwrap()).unwrap();\n+ assert_eq!(sandbox[\"kind\"], \"planned\");\n+ assert_eq!(sandbox[\"plan\"][\"provider\"], \"local\");\n+ assert!(sandbox.get(\"instance\").is_none());\n+ assert!(sandbox.get(\"failure\").is_none());\n+ }\n+\n+ #[test]\n+ fn sandbox_lifecycle_events_update_projected_sandbox_state() {\n+ let mut state = RunProjection::apply_events(&[test_raw_event(\n+ 1,\n+ \"run.created\",\n+ &json!({\n+ \"settings\": WorkflowSettings::default(),\n+ \"graph\": Graph::new(\"test\"),\n+ \"labels\": {},\n+ \"run_dir\": \"/tmp/run\"\n+ }),\n+ None,\n+ )])\n+ .unwrap();\n+\n+ state\n+ .apply_event(&test_raw_event(\n+ 2,\n+ \"sandbox.initializing\",\n+ &json!({ \"provider\": \"docker\" }),\n+ None,\n+ ))\n+ .unwrap();\n+ let initializing = serde_json::to_value(state.sandbox.as_ref().unwrap()).unwrap();\n+ assert_eq!(initializing[\"kind\"], \"initializing\");\n+ assert!(initializing.get(\"instance\").is_none());\n+\n+ state\n+ .apply_event(&test_raw_event(\n+ 3,\n+ \"sandbox.initialized\",\n+ &json!({\n+ \"provider\": \"docker\",\n+ \"id\": \"container-abc123\",\n+ \"working_directory\": \"/workspace\",\n+ \"image\": \"ubuntu:24.04\",\n+ \"repo_cloned\": true,\n+ \"clone_origin_url\": \"https://github.com/fabro-sh/fabro.git\",\n+ \"clone_branch\": \"main\"\n+ }),\n+ None,\n+ ))\n+ .unwrap();\n+ let ready = serde_json::to_value(state.sandbox.as_ref().unwrap()).unwrap();\n+ assert_eq!(ready[\"kind\"], \"ready\");\n+ assert_eq!(ready[\"plan\"][\"provider\"], \"local\");\n+ assert_eq!(ready[\"instance\"][\"provider\"], \"docker\");\n+ assert_eq!(ready[\"instance\"][\"image\"], \"ubuntu:24.04\");\n+ assert_eq!(ready[\"instance\"][\"runtime\"][\"id\"], \"container-abc123\");\n+ assert_eq!(\n+ ready[\"instance\"][\"runtime\"][\"working_directory\"],\n+ \"/workspace\"\n+ );\n+\n+ state\n+ .apply_event(&test_raw_event(\n+ 4,\n+ \"sandbox.failed\",\n+ &json!({\n+ \"provider\": \"docker\",\n+ \"error\": \"Docker daemon unavailable\",\n+ \"causes\": [\"connection refused\"],\n+ \"duration_ms\": 42\n+ }),\n+ None,\n+ ))\n+ .unwrap();\n+ let failed = serde_json::to_value(state.sandbox.as_ref().unwrap()).unwrap();\n+ assert_eq!(failed[\"kind\"], \"failed\");\n+ assert_eq!(failed[\"failure\"][\"provider\"], \"docker\");\n+ assert_eq!(failed[\"failure\"][\"error\"], \"Docker daemon unavailable\");\n+ assert_eq!(failed[\"failure\"][\"causes\"], json!([\"connection refused\"]));\n+ assert_eq!(failed[\"failure\"][\"duration_ms\"], 42);\n+ assert!(failed.get(\"instance\").is_none());\n+ }\n+\n+ #[test]\n+ fn run_failed_before_sandbox_events_leaves_sandbox_planned() {\n+ let state = RunProjection::apply_events(&[\n+ test_raw_event(\n+ 1,\n+ \"run.created\",\n+ &json!({\n+ \"settings\": WorkflowSettings::default(),\n+ \"graph\": Graph::new(\"test\"),\n+ \"labels\": {},\n+ \"run_dir\": \"/tmp/run\"\n+ }),\n+ None,\n+ ),\n+ test_raw_event(\n+ 2,\n+ \"run.runnable\",\n+ &json!({ \"source\": \"start_requested\" }),\n+ None,\n+ ),\n+ test_raw_event(3, \"run.starting\", &json!({}), None),\n+ test_raw_event(4, \"run.running\", &json!({}), None),\n+ test_raw_event(\n+ 5,\n+ \"run.failed\",\n+ &json!({\n+ \"failure\": {\n+ \"reason\": \"sandbox_init_failed\",\n+ \"detail\": {\n+ \"message\": \"Failed before sandbox initialized\",\n+ \"category\": \"transient_infra\"\n+ }\n+ },\n+ \"timing\": {\n+ \"wall_time_ms\": 1,\n+ \"inference_time_ms\": 0,\n+ \"tool_time_ms\": 0,\n+ \"active_time_ms\": 0\n+ }\n+ }),\n+ None,\n+ ),\n+ ])\n+ .unwrap();\n+\n+ let sandbox = serde_json::to_value(state.sandbox.as_ref().unwrap()).unwrap();\n+ assert_eq!(sandbox[\"kind\"], \"planned\");\n+ assert!(sandbox.get(\"instance\").is_none());\n+ assert!(sandbox.get(\"failure\").is_none());\n+ }\n+\n fn test_raw_event(\n seq: u32,\n event: &str,\ndiff --git a/lib/crates/fabro-store/tests/serializable_projection.rs b/lib/crates/fabro-store/tests/serializable_projection.rs\nindex db34fa664..6584a36cd 100644\n--- a/lib/crates/fabro-store/tests/serializable_projection.rs\n+++ b/lib/crates/fabro-store/tests/serializable_projection.rs\n@@ -6,9 +6,9 @@ use fabro_types::graph::Graph;\n use fabro_types::run::RunSpec;\n use fabro_types::{\n BilledModelUsage, BilledTokenCounts, Checkpoint, CheckpointRecord, InterviewQuestionRecord,\n- QuestionType, RunDiff, RunSandbox, RunSandboxRuntime, RunStatus, SandboxProviderKind,\n- StageCompletion, StageModelUsage, StageOutcome, StartRecord, WorkflowSettings, first_event_seq,\n- fixtures,\n+ QuestionType, RunDiff, RunSandbox, RunSandboxInstance, RunSandboxPlan, RunSandboxRuntime,\n+ RunStatus, SandboxProviderKind, StageCompletion, StageModelUsage, StageOutcome, StartRecord,\n+ WorkflowSettings, first_event_seq, fixtures,\n };\n use serde_json::json;\n \n@@ -99,11 +99,16 @@ fn serializable_projection_round_trips_and_trims_bulky_node_fields() {\n checkpoint: sample_checkpoint(),\n diff: RunDiff::default(),\n });\n- projection.sandbox = Some(RunSandbox {\n+ let sandbox_plan = RunSandboxPlan {\n provider: SandboxProviderKind::Local,\n image: None,\n snapshot: None,\n- runtime: Some(RunSandboxRuntime {\n+ };\n+ projection.sandbox = Some(RunSandbox::ready(sandbox_plan, RunSandboxInstance {\n+ provider: SandboxProviderKind::Local,\n+ image: None,\n+ snapshot: None,\n+ runtime: RunSandboxRuntime {\n id: \"sandbox-1\".to_string(),\n working_directory: \"/tmp/project\".to_string(),\n repo_cloned: None,\n@@ -113,8 +118,8 @@ fn serializable_projection_round_trips_and_trims_bulky_node_fields() {\n repos_root: None,\n primary_repo_path: None,\n primary_repo_link: None,\n- }),\n- });\n+ },\n+ }));\n projection.pending_interviews = BTreeMap::new();\n let stage = projection.stage_entry(stage_id.node_id(), stage_id.visit(), first_event_seq(2));\n stage.prompt = Some(\"plan the work\".to_string());\ndiff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs\nindex 31103c13a..f1a8c8b39 100644\n--- a/lib/crates/fabro-types/src/lib.rs\n+++ b/lib/crates/fabro-types/src/lib.rs\n@@ -113,7 +113,10 @@ pub use run_projection::{\n StageContextWindowStaleness, StageContextWindowUnavailableReason, StageContextWindowWarning,\n StageModelUsage, StageProjection, SubAgentProjection, SubAgentStatus, first_event_seq,\n };\n-pub use run_sandbox::{RunSandbox, RunSandboxRuntime};\n+pub use run_sandbox::{\n+ RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxKind, RunSandboxPlan,\n+ RunSandboxRuntime,\n+};\n pub use run_summary::{\n AskFabro, AskFabroUnavailableReason, AutomationRef, Run, RunApproval, RunApprovalState,\n RunBillingSummary, RunError, RunLifecycle, RunLinks, RunModel, RunOrigin, RunOriginKind,\ndiff --git a/lib/crates/fabro-types/src/run_event/infra.rs b/lib/crates/fabro-types/src/run_event/infra.rs\nindex d13302148..3abd80c12 100644\n--- a/lib/crates/fabro-types/src/run_event/infra.rs\n+++ b/lib/crates/fabro-types/src/run_event/infra.rs\n@@ -1,6 +1,6 @@\n use serde::{Deserialize, Serialize};\n \n-use crate::SandboxProviderKind;\n+use crate::{RunSandboxFailure, SandboxProviderKind};\n \n #[derive(\n Debug,\n@@ -201,14 +201,7 @@ pub struct SandboxReadyProps {\n pub url: Option,\n }\n \n-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n-pub struct SandboxFailedProps {\n- pub provider: String,\n- pub error: String,\n- #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n- pub causes: Vec,\n- pub duration_ms: u64,\n-}\n+pub type SandboxFailedProps = RunSandboxFailure;\n \n #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n pub struct SandboxCleanupStartedProps {\n@@ -409,50 +402,3 @@ pub struct CliEnsureFailedProps {\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub exec_output_tail: Option,\n }\n-\n-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n-pub struct DevcontainerResolvedProps {\n- pub dockerfile_lines: usize,\n- pub environment_count: usize,\n- pub lifecycle_command_count: usize,\n- pub workspace_folder: String,\n-}\n-\n-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n-pub struct DevcontainerLifecycleStartedProps {\n- pub phase: String,\n- pub command_count: usize,\n-}\n-\n-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n-pub struct DevcontainerLifecycleCommandStartedProps {\n- pub phase: String,\n- pub command: String,\n- pub index: usize,\n-}\n-\n-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n-pub struct DevcontainerLifecycleCommandCompletedProps {\n- pub phase: String,\n- pub command: String,\n- pub index: usize,\n- pub exit_code: i32,\n- pub duration_ms: u64,\n-}\n-\n-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n-pub struct DevcontainerLifecycleCompletedProps {\n- pub phase: String,\n- pub duration_ms: u64,\n-}\n-\n-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n-pub struct DevcontainerLifecycleFailedProps {\n- pub phase: String,\n- pub command: String,\n- pub index: usize,\n- pub exit_code: i32,\n- pub stderr: String,\n- #[serde(default, skip_serializing_if = \"Option::is_none\")]\n- pub exec_output_tail: Option,\n-}\ndiff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs\nindex 01ed04f4d..52cbb83a9 100644\n--- a/lib/crates/fabro-types/src/run_event/mod.rs\n+++ b/lib/crates/fabro-types/src/run_event/mod.rs\n@@ -358,18 +358,6 @@ pub enum EventBody {\n PullRequestUnlinked(PullRequestUnlinkedProps),\n #[serde(rename = \"pull_request.failed\")]\n PullRequestFailed(PullRequestFailedProps),\n- #[serde(rename = \"devcontainer.resolved\")]\n- DevcontainerResolved(DevcontainerResolvedProps),\n- #[serde(rename = \"devcontainer.lifecycle.started\")]\n- DevcontainerLifecycleStarted(DevcontainerLifecycleStartedProps),\n- #[serde(rename = \"devcontainer.lifecycle.command.started\")]\n- DevcontainerLifecycleCommandStarted(DevcontainerLifecycleCommandStartedProps),\n- #[serde(rename = \"devcontainer.lifecycle.command.completed\")]\n- DevcontainerLifecycleCommandCompleted(DevcontainerLifecycleCommandCompletedProps),\n- #[serde(rename = \"devcontainer.lifecycle.completed\")]\n- DevcontainerLifecycleCompleted(DevcontainerLifecycleCompletedProps),\n- #[serde(rename = \"devcontainer.lifecycle.failed\")]\n- DevcontainerLifecycleFailed(DevcontainerLifecycleFailedProps),\n Unknown {\n name: String,\n properties: Value,\n@@ -580,16 +568,6 @@ impl EventBody {\n Self::PullRequestLinked(_) => \"pull_request.linked\",\n Self::PullRequestUnlinked(_) => \"pull_request.unlinked\",\n Self::PullRequestFailed(_) => \"pull_request.failed\",\n- Self::DevcontainerResolved(_) => \"devcontainer.resolved\",\n- Self::DevcontainerLifecycleStarted(_) => \"devcontainer.lifecycle.started\",\n- Self::DevcontainerLifecycleCommandStarted(_) => {\n- \"devcontainer.lifecycle.command.started\"\n- }\n- Self::DevcontainerLifecycleCommandCompleted(_) => {\n- \"devcontainer.lifecycle.command.completed\"\n- }\n- Self::DevcontainerLifecycleCompleted(_) => \"devcontainer.lifecycle.completed\",\n- Self::DevcontainerLifecycleFailed(_) => \"devcontainer.lifecycle.failed\",\n Self::Unknown { name, .. } => name.as_str(),\n }\n }\n@@ -762,12 +740,6 @@ fn is_known_event_name(event: &str) -> bool {\n | \"pull_request.linked\"\n | \"pull_request.unlinked\"\n | \"pull_request.failed\"\n- | \"devcontainer.resolved\"\n- | \"devcontainer.lifecycle.started\"\n- | \"devcontainer.lifecycle.command.started\"\n- | \"devcontainer.lifecycle.command.completed\"\n- | \"devcontainer.lifecycle.completed\"\n- | \"devcontainer.lifecycle.failed\"\n )\n }\n \ndiff --git a/lib/crates/fabro-types/src/run_sandbox.rs b/lib/crates/fabro-types/src/run_sandbox.rs\nindex b2aedd829..91e3ec5f0 100644\n--- a/lib/crates/fabro-types/src/run_sandbox.rs\n+++ b/lib/crates/fabro-types/src/run_sandbox.rs\n@@ -1,16 +1,196 @@\n-use serde::{Deserialize, Serialize};\n+use serde::de::Error as _;\n+use serde::ser::Error as _;\n+use serde::{Deserialize, Deserializer, Serialize, Serializer};\n \n use crate::SandboxProviderKind;\n \n+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n+#[serde(rename_all = \"snake_case\")]\n+pub enum RunSandboxKind {\n+ Planned,\n+ Initializing,\n+ Ready,\n+ Failed,\n+}\n+\n #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n-pub struct RunSandbox {\n+pub struct RunSandboxPlan {\n pub provider: SandboxProviderKind,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub image: Option,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub snapshot: Option,\n+}\n+\n+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n+pub struct RunSandboxInstance {\n+ pub provider: SandboxProviderKind,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n- pub runtime: Option,\n+ pub image: Option,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ pub snapshot: Option,\n+ pub runtime: RunSandboxRuntime,\n+}\n+\n+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n+pub struct RunSandboxFailure {\n+ pub provider: String,\n+ pub error: String,\n+ #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n+ pub causes: Vec,\n+ pub duration_ms: u64,\n+}\n+\n+#[derive(Debug, Clone, PartialEq)]\n+pub struct RunSandbox {\n+ kind: RunSandboxKind,\n+ plan: RunSandboxPlan,\n+ instance: Option,\n+ failure: Option,\n+}\n+\n+impl RunSandbox {\n+ pub fn planned(plan: RunSandboxPlan) -> Self {\n+ Self {\n+ kind: RunSandboxKind::Planned,\n+ plan,\n+ instance: None,\n+ failure: None,\n+ }\n+ }\n+\n+ pub fn initializing(plan: RunSandboxPlan) -> Self {\n+ Self {\n+ kind: RunSandboxKind::Initializing,\n+ plan,\n+ instance: None,\n+ failure: None,\n+ }\n+ }\n+\n+ pub fn ready(plan: RunSandboxPlan, instance: RunSandboxInstance) -> Self {\n+ Self {\n+ kind: RunSandboxKind::Ready,\n+ plan,\n+ instance: Some(instance),\n+ failure: None,\n+ }\n+ }\n+\n+ pub fn failed(plan: RunSandboxPlan, failure: RunSandboxFailure) -> Self {\n+ Self {\n+ kind: RunSandboxKind::Failed,\n+ plan,\n+ instance: None,\n+ failure: Some(failure),\n+ }\n+ }\n+\n+ pub fn instance(&self) -> Option<&RunSandboxInstance> {\n+ self.instance.as_ref()\n+ }\n+\n+ pub fn into_instance(self) -> Option {\n+ self.instance\n+ }\n+\n+ pub fn kind(&self) -> RunSandboxKind {\n+ self.kind\n+ }\n+\n+ pub fn plan(&self) -> &RunSandboxPlan {\n+ &self.plan\n+ }\n+\n+ pub fn failure(&self) -> Option<&RunSandboxFailure> {\n+ self.failure.as_ref()\n+ }\n+\n+ fn validate(&self) -> Result<(), String> {\n+ match self.kind {\n+ RunSandboxKind::Planned | RunSandboxKind::Initializing => {\n+ if self.instance.is_some() {\n+ return Err(format!(\n+ \"{:?} sandbox must not carry an instance\",\n+ self.kind\n+ ));\n+ }\n+ if self.failure.is_some() {\n+ return Err(format!(\"{:?} sandbox must not carry a failure\", self.kind));\n+ }\n+ }\n+ RunSandboxKind::Ready => {\n+ if self.instance.is_none() {\n+ return Err(\"ready sandbox requires an instance\".to_string());\n+ }\n+ if self.failure.is_some() {\n+ return Err(\"ready sandbox must not carry a failure\".to_string());\n+ }\n+ }\n+ RunSandboxKind::Failed => {\n+ if self.instance.is_some() {\n+ return Err(\"failed sandbox must not carry an instance\".to_string());\n+ }\n+ if self.failure.is_none() {\n+ return Err(\"failed sandbox requires failure details\".to_string());\n+ }\n+ }\n+ }\n+ Ok(())\n+ }\n+}\n+\n+#[derive(Serialize, Deserialize)]\n+struct RunSandboxWire {\n+ kind: RunSandboxKind,\n+ plan: RunSandboxPlan,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ instance: Option,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ failure: Option,\n+}\n+\n+#[derive(Serialize)]\n+struct RunSandboxWireRef<'a> {\n+ kind: RunSandboxKind,\n+ plan: &'a RunSandboxPlan,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ instance: Option<&'a RunSandboxInstance>,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ failure: Option<&'a RunSandboxFailure>,\n+}\n+\n+impl Serialize for RunSandbox {\n+ fn serialize(&self, serializer: S) -> Result\n+ where\n+ S: Serializer,\n+ {\n+ self.validate().map_err(S::Error::custom)?;\n+ RunSandboxWireRef {\n+ kind: self.kind,\n+ plan: &self.plan,\n+ instance: self.instance.as_ref(),\n+ failure: self.failure.as_ref(),\n+ }\n+ .serialize(serializer)\n+ }\n+}\n+\n+impl<'de> Deserialize<'de> for RunSandbox {\n+ fn deserialize(deserializer: D) -> Result\n+ where\n+ D: Deserializer<'de>,\n+ {\n+ let wire = RunSandboxWire::deserialize(deserializer)?;\n+ let sandbox = Self {\n+ kind: wire.kind,\n+ plan: wire.plan,\n+ instance: wire.instance,\n+ failure: wire.failure,\n+ };\n+ sandbox.validate().map_err(D::Error::custom)?;\n+ Ok(sandbox)\n+ }\n }\n \n #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\ndiff --git a/lib/crates/fabro-types/src/sandbox_details.rs b/lib/crates/fabro-types/src/sandbox_details.rs\nindex 8723b00eb..118b598e9 100644\n--- a/lib/crates/fabro-types/src/sandbox_details.rs\n+++ b/lib/crates/fabro-types/src/sandbox_details.rs\n@@ -4,11 +4,11 @@ use chrono::{DateTime, Utc};\n use serde::de::Error as _;\n use serde::{Deserialize, Serialize};\n \n-use crate::RunSandbox;\n+use crate::RunSandboxInstance;\n \n #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n pub struct SandboxDetails {\n- pub sandbox: RunSandbox,\n+ pub sandbox: RunSandboxInstance,\n pub state: SandboxState,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub native_state: Option,\n@@ -187,11 +187,11 @@ mod tests {\n #[test]\n fn serializes_with_snake_case_state() {\n let details = SandboxDetails {\n- sandbox: RunSandbox {\n+ sandbox: RunSandboxInstance {\n provider: crate::SandboxProviderKind::Docker,\n image: Some(\"ghcr.io/fabro/sandbox:latest\".to_string()),\n snapshot: None,\n- runtime: Some(crate::RunSandboxRuntime {\n+ runtime: crate::RunSandboxRuntime {\n id: \"container-abc123\".to_string(),\n working_directory: \"/workspace\".to_string(),\n repo_cloned: None,\n@@ -201,7 +201,7 @@ mod tests {\n repos_root: None,\n primary_repo_path: None,\n primary_repo_link: None,\n- }),\n+ },\n },\n state: SandboxState::Running,\n native_state: Some(\"running\".to_string()),\n@@ -232,10 +232,10 @@ mod tests {\n \"sandbox\": {\n \"provider\": \"docker\",\n \"image\": \"ghcr.io/fabro/sandbox:latest\",\n- \"runtime\": {\n- \"id\": \"container-abc123\",\n- \"working_directory\": \"/workspace\"\n- }\n+ \"runtime\": {\n+ \"id\": \"container-abc123\",\n+ \"working_directory\": \"/workspace\"\n+ }\n },\n \"state\": \"running\",\n \"native_state\": \"running\",\n@@ -271,10 +271,10 @@ mod tests {\n \"provider\": \"local\",\n \"image\": null,\n \"snapshot\": null,\n- \"runtime\": {\n- \"id\": \"local:01JNQVR7M0EJ5GKAT2SC4ERS1Z\",\n- \"working_directory\": \"/Users/client/project\"\n- }\n+ \"runtime\": {\n+ \"id\": \"local:01JNQVR7M0EJ5GKAT2SC4ERS1Z\",\n+ \"working_directory\": \"/Users/client/project\"\n+ }\n },\n \"state\": \"unknown\",\n \"resources\": {},\n@@ -284,20 +284,12 @@ mod tests {\n \n assert_eq!(details.sandbox.provider, crate::SandboxProviderKind::Local);\n assert_eq!(\n- details\n- .sandbox\n- .runtime\n- .as_ref()\n- .map(|runtime| runtime.id.as_str()),\n- Some(\"local:01JNQVR7M0EJ5GKAT2SC4ERS1Z\")\n+ details.sandbox.runtime.id.as_str(),\n+ \"local:01JNQVR7M0EJ5GKAT2SC4ERS1Z\"\n );\n assert_eq!(\n- details\n- .sandbox\n- .runtime\n- .as_ref()\n- .map(|runtime| runtime.working_directory.as_str()),\n- Some(\"/Users/client/project\")\n+ details.sandbox.runtime.working_directory.as_str(),\n+ \"/Users/client/project\"\n );\n assert_eq!(details.state, SandboxState::Unknown);\n assert!(details.sandbox.image.is_none());\ndiff --git a/lib/crates/fabro-types/tests/sandbox_model_serde.rs b/lib/crates/fabro-types/tests/sandbox_model_serde.rs\nindex 4db0f34b2..d6dcc5fee 100644\n--- a/lib/crates/fabro-types/tests/sandbox_model_serde.rs\n+++ b/lib/crates/fabro-types/tests/sandbox_model_serde.rs\n@@ -2,60 +2,83 @@ use std::collections::BTreeMap;\n \n use chrono::{TimeZone, Utc};\n use fabro_types::{\n- RunSandbox, RunSandboxRuntime, SandboxDetails, SandboxNetwork, SandboxProviderKind,\n- SandboxResources, SandboxState, SandboxTimestamps,\n+ RunSandbox, RunSandboxInstance, RunSandboxPlan, RunSandboxRuntime, SandboxDetails,\n+ SandboxNetwork, SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps,\n };\n use serde_json::json;\n \n #[test]\n fn run_sandbox_serializes_canonical_identity_without_identifier() {\n- let sandbox = RunSandbox {\n- provider: SandboxProviderKind::Docker,\n- image: None,\n- snapshot: None,\n- runtime: Some(RunSandboxRuntime {\n- id: \"container-abc123\".to_string(),\n- working_directory: \"/workspace\".to_string(),\n- repo_cloned: Some(true),\n- clone_origin_url: Some(\"https://github.com/fabro-sh/fabro.git\".to_string()),\n- clone_branch: Some(\"main\".to_string()),\n- workspace_root: Some(\"/workspace\".to_string()),\n- repos_root: Some(\"/repos\".to_string()),\n- primary_repo_path: Some(\"/repos/fabro-sh/fabro\".to_string()),\n- primary_repo_link: Some(\"/workspace/fabro\".to_string()),\n- }),\n- };\n+ let sandbox = RunSandbox::ready(\n+ RunSandboxPlan {\n+ provider: SandboxProviderKind::Docker,\n+ image: None,\n+ snapshot: None,\n+ },\n+ RunSandboxInstance {\n+ provider: SandboxProviderKind::Docker,\n+ image: None,\n+ snapshot: None,\n+ runtime: RunSandboxRuntime {\n+ id: \"container-abc123\".to_string(),\n+ working_directory: \"/workspace\".to_string(),\n+ repo_cloned: Some(true),\n+ clone_origin_url: Some(\"https://github.com/fabro-sh/fabro.git\".to_string()),\n+ clone_branch: Some(\"main\".to_string()),\n+ workspace_root: Some(\"/workspace\".to_string()),\n+ repos_root: Some(\"/repos\".to_string()),\n+ primary_repo_path: Some(\"/repos/fabro-sh/fabro\".to_string()),\n+ primary_repo_link: Some(\"/workspace/fabro\".to_string()),\n+ },\n+ },\n+ );\n \n let value = serde_json::to_value(&sandbox).unwrap();\n \n assert_eq!(\n value,\n json!({\n- \"provider\": \"docker\",\n- \"runtime\": {\n- \"id\": \"container-abc123\",\n- \"working_directory\": \"/workspace\",\n- \"repo_cloned\": true,\n- \"clone_origin_url\": \"https://github.com/fabro-sh/fabro.git\",\n- \"clone_branch\": \"main\",\n- \"workspace_root\": \"/workspace\",\n- \"repos_root\": \"/repos\",\n- \"primary_repo_path\": \"/repos/fabro-sh/fabro\",\n- \"primary_repo_link\": \"/workspace/fabro\"\n+ \"kind\": \"ready\",\n+ \"plan\": {\n+ \"provider\": \"docker\"\n+ },\n+ \"instance\": {\n+ \"provider\": \"docker\",\n+ \"runtime\": {\n+ \"id\": \"container-abc123\",\n+ \"working_directory\": \"/workspace\",\n+ \"repo_cloned\": true,\n+ \"clone_origin_url\": \"https://github.com/fabro-sh/fabro.git\",\n+ \"clone_branch\": \"main\",\n+ \"workspace_root\": \"/workspace\",\n+ \"repos_root\": \"/repos\",\n+ \"primary_repo_path\": \"/repos/fabro-sh/fabro\",\n+ \"primary_repo_link\": \"/workspace/fabro\"\n+ }\n }\n })\n );\n assert!(value.get(\"identifier\").is_none());\n }\n \n+#[test]\n+fn run_sandbox_ready_requires_instance() {\n+ let sandbox = json!({\n+ \"kind\": \"ready\",\n+ \"plan\": { \"provider\": \"docker\" }\n+ });\n+\n+ assert!(serde_json::from_value::(sandbox).is_err());\n+}\n+\n #[test]\n fn sandbox_details_requires_canonical_id_and_working_directory() {\n let details = SandboxDetails {\n- sandbox: RunSandbox {\n+ sandbox: RunSandboxInstance {\n provider: SandboxProviderKind::Daytona,\n image: Some(\"ubuntu:24.04\".to_string()),\n snapshot: None,\n- runtime: Some(RunSandboxRuntime {\n+ runtime: RunSandboxRuntime {\n id: \"daytona-sandbox-name\".to_string(),\n working_directory: \"/workspace\".to_string(),\n repo_cloned: None,\n@@ -65,7 +88,7 @@ fn sandbox_details_requires_canonical_id_and_working_directory() {\n repos_root: Some(\"/home/daytona/repos\".to_string()),\n primary_repo_path: None,\n primary_repo_link: None,\n- }),\n+ },\n },\n state: SandboxState::Running,\n native_state: Some(\"started\".to_string()),\ndiff --git a/lib/crates/fabro-workflow/Cargo.toml b/lib/crates/fabro-workflow/Cargo.toml\nindex 6021bca4c..3e735f9c7 100644\n--- a/lib/crates/fabro-workflow/Cargo.toml\n+++ b/lib/crates/fabro-workflow/Cargo.toml\n@@ -25,7 +25,6 @@ fabro-config = { path = \"../fabro-config\" }\n fabro-graphviz = { path = \"../fabro-graphviz\" }\n fabro-hooks = { path = \"../fabro-hooks\" }\n fabro-validate = { path = \"../fabro-validate\" }\n-fabro-devcontainer = { path = \"../fabro-devcontainer\" }\n fabro-dump = { path = \"../fabro-dump\" }\n fabro-sandbox = { path = \"../fabro-sandbox\", features = [\"daytona\"] }\n fabro-mcp = { path = \"../fabro-mcp\" }\ndiff --git a/lib/crates/fabro-workflow/src/devcontainer_bridge.rs b/lib/crates/fabro-workflow/src/devcontainer_bridge.rs\ndeleted file mode 100644\nindex 6fc081782..000000000\n--- a/lib/crates/fabro-workflow/src/devcontainer_bridge.rs\n+++ /dev/null\n@@ -1,640 +0,0 @@\n-use std::time::Instant;\n-\n-use fabro_agent::sandbox::Sandbox;\n-use fabro_devcontainer::DevcontainerSpec;\n-use fabro_sandbox::daytona::{DaytonaSnapshotConfig, DockerfileSource};\n-use futures::future::try_join_all;\n-use tokio_util::sync::CancellationToken;\n-\n-use crate::error::Error;\n-use crate::event::{Emitter, Event};\n-\n-/// Map a `DevcontainerSpec` to a `DaytonaSnapshotConfig`.\n-pub fn devcontainer_to_snapshot_config(dc: &DevcontainerSpec) -> DaytonaSnapshotConfig {\n- DaytonaSnapshotConfig {\n- dockerfile: Some(DockerfileSource::Inline(dc.dockerfile.clone())),\n- cpu: None,\n- memory: None,\n- disk: None,\n- }\n-}\n-\n-/// Run a set of devcontainer lifecycle commands inside a sandbox.\n-///\n-/// Follows the same pattern as setup commands in `run.rs`.\n-pub async fn run_devcontainer_lifecycle(\n- sandbox: &dyn Sandbox,\n- emitter: &Emitter,\n- phase: &str,\n- commands: &[fabro_devcontainer::Command],\n- timeout_ms: u64,\n- cancel_token: CancellationToken,\n-) -> Result<(), Error> {\n- if commands.is_empty() {\n- return Ok(());\n- }\n-\n- emitter.emit(&Event::DevcontainerLifecycleStarted {\n- phase: phase.to_string(),\n- command_count: commands.len(),\n- });\n- let phase_start = Instant::now();\n-\n- for (index, cmd) in commands.iter().enumerate() {\n- match cmd {\n- fabro_devcontainer::Command::Shell(s) => {\n- run_single_lifecycle_command(\n- sandbox,\n- emitter,\n- phase,\n- &format!(\"sh -c {}\", shlex::try_quote(s).unwrap_or_else(|_| s.into())),\n- index,\n- timeout_ms,\n- cancel_token.clone(),\n- )\n- .await?;\n- }\n- fabro_devcontainer::Command::Args(args) => {\n- let joined = args\n- .iter()\n- .map(|a| shlex::try_quote(a).unwrap_or_else(|_| a.into()).to_string())\n- .collect::>()\n- .join(\" \");\n- run_single_lifecycle_command(\n- sandbox,\n- emitter,\n- phase,\n- &joined,\n- index,\n- timeout_ms,\n- cancel_token.clone(),\n- )\n- .await?;\n- }\n- fabro_devcontainer::Command::Parallel(map) => {\n- let futs: Vec<_> = map\n- .iter()\n- .map(|(name, cmd_str)| {\n- let command = format!(\n- \"sh -c {}\",\n- shlex::try_quote(cmd_str).unwrap_or_else(|_| cmd_str.into())\n- );\n- let phase = phase.to_string();\n- let name = name.clone();\n- let cancel_token = cancel_token.clone();\n- async move {\n- let cmd_start = Instant::now();\n- emitter.emit(&Event::DevcontainerLifecycleCommandStarted {\n- phase: phase.clone(),\n- command: name.clone(),\n- index,\n- });\n- let child_token = cancel_token.child_token();\n- let result = sandbox\n- .exec_command(\n- &command,\n- timeout_ms,\n- None,\n- None,\n- Some(child_token.clone()),\n- )\n- .await\n- .map_err(|e| {\n- Error::engine(format!(\n- \"Devcontainer {phase} parallel command '{name}' failed: {e}\"\n- ))\n- })?;\n- if cancel_token.is_cancelled() {\n- return Err(Error::Cancelled);\n- }\n- child_token.cancel();\n- let cmd_duration = crate::millis_u64(cmd_start.elapsed());\n- if !result.is_success() {\n- let exit_code = result.display_exit_code();\n- let exec_output_tail = result.default_redacted_output_tail();\n- emitter.emit(\n- &Event::DevcontainerLifecycleFailed {\n- phase: phase.clone(),\n- command: name.clone(),\n- index,\n- exit_code,\n- stderr: result.stderr.clone(),\n- exec_output_tail,\n- },\n- );\n- return Err(Error::engine(format!(\n- \"Devcontainer {phase} parallel command '{name}' failed (exit code {}): {}\",\n- exit_code,\n- result.stderr,\n- )));\n- }\n- let exit_code = result.exit_code.unwrap_or(0);\n- emitter.emit(\n- &Event::DevcontainerLifecycleCommandCompleted {\n- phase: phase.clone(),\n- command: name.clone(),\n- index,\n- exit_code,\n- duration_ms: cmd_duration,\n- },\n- );\n- Ok(())\n- }\n- })\n- .collect();\n- try_join_all(futs).await?;\n- }\n- }\n- }\n-\n- let phase_duration = crate::millis_u64(phase_start.elapsed());\n- emitter.emit(&Event::DevcontainerLifecycleCompleted {\n- phase: phase.to_string(),\n- duration_ms: phase_duration,\n- });\n- Ok(())\n-}\n-\n-async fn run_single_lifecycle_command(\n- sandbox: &dyn Sandbox,\n- emitter: &Emitter,\n- phase: &str,\n- command: &str,\n- index: usize,\n- timeout_ms: u64,\n- cancel_token: CancellationToken,\n-) -> Result<(), Error> {\n- emitter.emit(&Event::DevcontainerLifecycleCommandStarted {\n- phase: phase.to_string(),\n- command: command.to_string(),\n- index,\n- });\n- let cmd_start = Instant::now();\n- let child_token = cancel_token.child_token();\n- let result = sandbox\n- .exec_command(command, timeout_ms, None, None, Some(child_token.clone()))\n- .await\n- .map_err(|e| {\n- Error::engine_with_source(format!(\"Devcontainer {phase} command failed\"), e)\n- })?;\n- if cancel_token.is_cancelled() {\n- return Err(Error::Cancelled);\n- }\n- child_token.cancel();\n- let cmd_duration = crate::millis_u64(cmd_start.elapsed());\n- if !result.is_success() {\n- let exit_code = result.display_exit_code();\n- let exec_output_tail = result.default_redacted_output_tail();\n- emitter.emit(&Event::DevcontainerLifecycleFailed {\n- phase: phase.to_string(),\n- command: command.to_string(),\n- index,\n- exit_code,\n- stderr: result.stderr.clone(),\n- exec_output_tail,\n- });\n- return Err(Error::engine(format!(\n- \"Devcontainer {phase} command failed (exit code {}): {command}\\n{}\",\n- exit_code, result.stderr,\n- )));\n- }\n- let exit_code = result.exit_code.unwrap_or(0);\n- emitter.emit(&Event::DevcontainerLifecycleCommandCompleted {\n- phase: phase.to_string(),\n- command: command.to_string(),\n- index,\n- exit_code,\n- duration_ms: cmd_duration,\n- });\n- Ok(())\n-}\n-\n-#[cfg(test)]\n-mod tests {\n- use std::collections::HashMap;\n- use std::sync::{Arc, Mutex};\n-\n- use async_trait::async_trait;\n- use fabro_agent::sandbox::{ExecResult, GrepOptions, Sandbox};\n- use fabro_types::{CommandTermination, EventBody};\n- use tokio_util::sync::CancellationToken;\n-\n- use super::*;\n-\n- /// Simple test sandbox that records commands and returns a fixed exit code.\n- struct TestSandbox {\n- commands: Mutex>,\n- cancel_tokens: Mutex>,\n- exit_code: i32,\n- wait_for_cancel: bool,\n- }\n-\n- impl TestSandbox {\n- fn new() -> Self {\n- Self {\n- commands: Mutex::new(Vec::new()),\n- cancel_tokens: Mutex::new(Vec::new()),\n- exit_code: 0,\n- wait_for_cancel: false,\n- }\n- }\n-\n- fn with_exit_code(exit_code: i32) -> Self {\n- Self {\n- commands: Mutex::new(Vec::new()),\n- cancel_tokens: Mutex::new(Vec::new()),\n- exit_code,\n- wait_for_cancel: false,\n- }\n- }\n-\n- fn waiting_for_cancel() -> Self {\n- Self {\n- commands: Mutex::new(Vec::new()),\n- cancel_tokens: Mutex::new(Vec::new()),\n- exit_code: 0,\n- wait_for_cancel: true,\n- }\n- }\n-\n- fn captured_commands(&self) -> Vec {\n- self.commands.lock().unwrap().clone()\n- }\n-\n- fn captured_cancel_tokens(&self) -> Vec {\n- self.cancel_tokens.lock().unwrap().clone()\n- }\n- }\n-\n- #[async_trait]\n- impl Sandbox for TestSandbox {\n- async fn read_file_bytes(&self, _path: &str) -> fabro_sandbox::Result> {\n- Ok(Vec::new())\n- }\n- async fn write_file(&self, _path: &str, _content: &str) -> fabro_sandbox::Result<()> {\n- Ok(())\n- }\n- async fn delete_file(&self, _path: &str) -> fabro_sandbox::Result<()> {\n- Ok(())\n- }\n- async fn file_exists(&self, _path: &str) -> fabro_sandbox::Result {\n- Ok(false)\n- }\n- async fn list_directory(\n- &self,\n- _path: &str,\n- _depth: Option,\n- ) -> fabro_sandbox::Result> {\n- Ok(vec![])\n- }\n- async fn exec_command(\n- &self,\n- command: &str,\n- _timeout_ms: u64,\n- _working_dir: Option<&str>,\n- _env_vars: Option<&std::collections::HashMap>,\n- cancel_token: Option,\n- ) -> fabro_sandbox::Result {\n- self.commands.lock().unwrap().push(command.to_string());\n- self.cancel_tokens\n- .lock()\n- .unwrap()\n- .push(cancel_token.is_some());\n- if self.wait_for_cancel {\n- let token = cancel_token\n- .ok_or_else(|| fabro_sandbox::Error::message(\"missing cancel token\"))?;\n- token.cancelled().await;\n- return Ok(ExecResult {\n- stdout: String::new(),\n- stderr: \"cancelled\".to_string(),\n- exit_code: None,\n- termination: CommandTermination::Cancelled,\n- duration_ms: 10,\n- });\n- }\n- Ok(ExecResult {\n- stdout: String::new(),\n- stderr: if self.exit_code != 0 {\n- \"command failed\".to_string()\n- } else {\n- String::new()\n- },\n- exit_code: Some(self.exit_code),\n- termination: CommandTermination::Exited,\n- duration_ms: 10,\n- })\n- }\n- async fn grep(\n- &self,\n- _pattern: &str,\n- _path: &str,\n- _options: &GrepOptions,\n- ) -> fabro_sandbox::Result> {\n- Ok(vec![])\n- }\n- async fn glob(\n- &self,\n- _pattern: &str,\n- _path: Option<&str>,\n- ) -> fabro_sandbox::Result> {\n- Ok(vec![])\n- }\n- async fn download_file_to_local(\n- &self,\n- _remote_path: &str,\n- _local_path: &std::path::Path,\n- ) -> fabro_sandbox::Result<()> {\n- Ok(())\n- }\n- async fn upload_file_from_local(\n- &self,\n- _local_path: &std::path::Path,\n- _remote_path: &str,\n- ) -> fabro_sandbox::Result<()> {\n- Ok(())\n- }\n- async fn initialize(&self) -> fabro_sandbox::Result<()> {\n- Ok(())\n- }\n- async fn cleanup(&self) -> fabro_sandbox::Result<()> {\n- Ok(())\n- }\n- fn working_directory(&self) -> &str {\n- \"/work\"\n- }\n- fn platform(&self) -> &str {\n- \"linux\"\n- }\n- fn os_version(&self) -> String {\n- \"Linux 6.1.0\".into()\n- }\n- }\n-\n- #[test]\n- fn maps_dockerfile_to_inline() {\n- let dc = test_devcontainer_config(\"FROM rust:1.85\\nRUN cargo install sccache\");\n- let snapshot = devcontainer_to_snapshot_config(&dc);\n- assert_eq!(\n- snapshot.dockerfile,\n- Some(DockerfileSource::Inline(dc.dockerfile.clone()))\n- );\n- }\n-\n- #[test]\n- fn devcontainer_snapshot_uses_runtime_daytona_identity_path() {\n- let dc = test_devcontainer_config(\"FROM ubuntu:22.04\");\n- let snapshot = devcontainer_to_snapshot_config(&dc);\n- assert_eq!(snapshot.cpu, None);\n- assert_eq!(snapshot.memory, None);\n- assert_eq!(snapshot.disk, None);\n- }\n-\n- #[tokio::test]\n- async fn shell_command_executed() {\n- let sandbox = TestSandbox::new();\n- let emitter = Emitter::default();\n- let commands = vec![fabro_devcontainer::Command::Shell(\"echo hi\".to_string())];\n- run_devcontainer_lifecycle(\n- &sandbox,\n- &emitter,\n- \"on_create\",\n- &commands,\n- 300_000,\n- CancellationToken::new(),\n- )\n- .await\n- .unwrap();\n- let captured = sandbox.captured_commands();\n- assert_eq!(captured.len(), 1);\n- assert!(captured[0].contains(\"echo hi\"), \"command: {}\", captured[0]);\n- }\n-\n- #[tokio::test]\n- async fn args_command_joins() {\n- let sandbox = TestSandbox::new();\n- let emitter = Emitter::default();\n- let commands = vec![fabro_devcontainer::Command::Args(vec![\n- \"echo\".to_string(),\n- \"hi\".to_string(),\n- ])];\n- run_devcontainer_lifecycle(\n- &sandbox,\n- &emitter,\n- \"on_create\",\n- &commands,\n- 300_000,\n- CancellationToken::new(),\n- )\n- .await\n- .unwrap();\n- let captured = sandbox.captured_commands();\n- assert_eq!(captured.len(), 1);\n- assert!(\n- captured[0].contains(\"echo\") && captured[0].contains(\"hi\"),\n- \"command: {}\",\n- captured[0]\n- );\n- }\n-\n- #[tokio::test]\n- async fn emits_started_and_completed_events() {\n- let emitter = Emitter::default();\n- let events = Arc::new(Mutex::new(Vec::::new()));\n- let events_clone = Arc::clone(&events);\n- emitter.on_event(move |event| {\n- events_clone.lock().unwrap().push(event.clone());\n- });\n- let sandbox = TestSandbox::new();\n- let commands = vec![fabro_devcontainer::Command::Shell(\"echo hi\".to_string())];\n- run_devcontainer_lifecycle(\n- &sandbox,\n- &emitter,\n- \"on_create\",\n- &commands,\n- 300_000,\n- CancellationToken::new(),\n- )\n- .await\n- .unwrap();\n- let events = events.lock().unwrap();\n- let started = events[0].properties().unwrap();\n- assert_eq!(events[0].event_name(), \"devcontainer.lifecycle.started\");\n- assert_eq!(started[\"phase\"], \"on_create\");\n- assert_eq!(started[\"command_count\"], 1);\n-\n- assert_eq!(\n- events[1].event_name(),\n- \"devcontainer.lifecycle.command.started\"\n- );\n- let command_started = events[1].properties().unwrap();\n- assert_eq!(command_started[\"phase\"], \"on_create\");\n- assert_eq!(command_started[\"index\"], 0);\n-\n- assert_eq!(\n- events[2].event_name(),\n- \"devcontainer.lifecycle.command.completed\"\n- );\n- let command_completed = events[2].properties().unwrap();\n- assert_eq!(command_completed[\"phase\"], \"on_create\");\n- assert_eq!(command_completed[\"index\"], 0);\n- assert_eq!(command_completed[\"exit_code\"], 0);\n-\n- assert_eq!(events[3].event_name(), \"devcontainer.lifecycle.completed\");\n- assert_eq!(events[3].properties().unwrap()[\"phase\"], \"on_create\");\n- }\n-\n- #[tokio::test]\n- async fn failed_command_emits_failed_and_returns_error() {\n- let emitter = Emitter::default();\n- let events = Arc::new(Mutex::new(Vec::::new()));\n- let events_clone = Arc::clone(&events);\n- emitter.on_event(move |event| {\n- events_clone.lock().unwrap().push(event.clone());\n- });\n- let sandbox = TestSandbox::with_exit_code(1);\n- let commands = vec![fabro_devcontainer::Command::Shell(\"false\".to_string())];\n- let result = run_devcontainer_lifecycle(\n- &sandbox,\n- &emitter,\n- \"on_create\",\n- &commands,\n- 300_000,\n- CancellationToken::new(),\n- )\n- .await;\n- assert!(result.is_err());\n- let events = events.lock().unwrap();\n- let failed = events\n- .iter()\n- .find(|event| event.event_name() == \"devcontainer.lifecycle.failed\")\n- .expect(\"devcontainer lifecycle failed event\");\n- match &failed.body {\n- EventBody::DevcontainerLifecycleFailed(props) => {\n- assert_eq!(props.phase, \"on_create\");\n- assert_eq!(props.exit_code, 1);\n- assert_eq!(props.stderr, \"command failed\");\n- assert_eq!(\n- props\n- .exec_output_tail\n- .as_ref()\n- .and_then(|tail| tail.stderr.as_deref()),\n- Some(\"command failed\")\n- );\n- }\n- other => panic!(\"expected devcontainer lifecycle failed body, got {other:?}\"),\n- }\n- }\n-\n- #[tokio::test]\n- async fn empty_commands_is_noop() {\n- let emitter = Emitter::default();\n- let events = Arc::new(Mutex::new(Vec::new()));\n- let events_clone = Arc::clone(&events);\n- emitter.on_event(move |event| {\n- events_clone.lock().unwrap().push(event.clone());\n- });\n- let sandbox = TestSandbox::new();\n- run_devcontainer_lifecycle(\n- &sandbox,\n- &emitter,\n- \"on_create\",\n- &[],\n- 300_000,\n- CancellationToken::new(),\n- )\n- .await\n- .unwrap();\n- assert!(events.lock().unwrap().is_empty());\n- }\n-\n- #[tokio::test]\n- async fn parallel_commands_run() {\n- let sandbox = TestSandbox::new();\n- let emitter = Emitter::default();\n- let mut map = HashMap::new();\n- map.insert(\"install\".to_string(), \"npm install\".to_string());\n- map.insert(\"build\".to_string(), \"npm run build\".to_string());\n- let commands = vec![fabro_devcontainer::Command::Parallel(map)];\n- run_devcontainer_lifecycle(\n- &sandbox,\n- &emitter,\n- \"post_create\",\n- &commands,\n- 300_000,\n- CancellationToken::new(),\n- )\n- .await\n- .unwrap();\n- let captured = sandbox.captured_commands();\n- assert_eq!(captured.len(), 2);\n- }\n-\n- #[tokio::test]\n- async fn cancelled_shell_command_returns_cancelled() {\n- let sandbox = TestSandbox::waiting_for_cancel();\n- let emitter = Emitter::default();\n- let commands = vec![fabro_devcontainer::Command::Shell(\"sleep 5\".to_string())];\n- let cancel_token = CancellationToken::new();\n- cancel_token.cancel();\n-\n- let result = run_devcontainer_lifecycle(\n- &sandbox,\n- &emitter,\n- \"on_create\",\n- &commands,\n- 300_000,\n- cancel_token,\n- )\n- .await;\n-\n- assert!(matches!(result, Err(Error::Cancelled)));\n- assert_eq!(sandbox.captured_cancel_tokens(), vec![true]);\n- }\n-\n- #[tokio::test]\n- async fn cancelled_parallel_command_returns_cancelled() {\n- let sandbox = TestSandbox::waiting_for_cancel();\n- let emitter = Emitter::default();\n- let mut map = HashMap::new();\n- map.insert(\"install\".to_string(), \"sleep 5\".to_string());\n- map.insert(\"build\".to_string(), \"sleep 5\".to_string());\n- let commands = vec![fabro_devcontainer::Command::Parallel(map)];\n- let cancel_token = CancellationToken::new();\n- cancel_token.cancel();\n-\n- let result = run_devcontainer_lifecycle(\n- &sandbox,\n- &emitter,\n- \"post_create\",\n- &commands,\n- 300_000,\n- cancel_token,\n- )\n- .await;\n-\n- assert!(matches!(result, Err(Error::Cancelled)));\n- let captured = sandbox.captured_cancel_tokens();\n- assert!(!captured.is_empty());\n- assert!(captured.iter().all(|saw_token| *saw_token));\n- }\n-\n- fn test_devcontainer_config(dockerfile: &str) -> DevcontainerSpec {\n- DevcontainerSpec {\n- dockerfile: dockerfile.to_string(),\n- build_context: std::path::PathBuf::from(\".\"),\n- build_args: HashMap::new(),\n- build_target: None,\n- initialize_commands: vec![],\n- on_create_commands: vec![],\n- post_create_commands: vec![],\n- post_start_commands: vec![],\n- environment: HashMap::new(),\n- container_env: HashMap::new(),\n- remote_user: None,\n- workspace_folder: \"/workspaces/test\".to_string(),\n- forwarded_ports: vec![],\n- compose_files: vec![],\n- compose_service: None,\n- }\n- }\n-}\ndiff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs\nindex fb2f9f054..4c1f91228 100644\n--- a/lib/crates/fabro-workflow/src/event/convert.rs\n+++ b/lib/crates/fabro-workflow/src/event/convert.rs\n@@ -1306,77 +1306,6 @@ fn event_body_from_event(event: &Event) -> EventBody {\n error: error.clone(),\n })\n }\n- Event::DevcontainerResolved {\n- dockerfile_lines,\n- environment_count,\n- lifecycle_command_count,\n- workspace_folder,\n- } => EventBody::DevcontainerResolved(fabro_types::DevcontainerResolvedProps {\n- dockerfile_lines: *dockerfile_lines,\n- environment_count: *environment_count,\n- lifecycle_command_count: *lifecycle_command_count,\n- workspace_folder: workspace_folder.clone(),\n- }),\n- Event::DevcontainerLifecycleStarted {\n- phase,\n- command_count,\n- } => EventBody::DevcontainerLifecycleStarted(\n- fabro_types::DevcontainerLifecycleStartedProps {\n- phase: phase.clone(),\n- command_count: *command_count,\n- },\n- ),\n- Event::DevcontainerLifecycleCommandStarted {\n- phase,\n- command,\n- index,\n- } => EventBody::DevcontainerLifecycleCommandStarted(\n- fabro_types::DevcontainerLifecycleCommandStartedProps {\n- phase: phase.clone(),\n- command: command.clone(),\n- index: *index,\n- },\n- ),\n- Event::DevcontainerLifecycleCommandCompleted {\n- phase,\n- command,\n- index,\n- exit_code,\n- duration_ms,\n- } => EventBody::DevcontainerLifecycleCommandCompleted(\n- fabro_types::DevcontainerLifecycleCommandCompletedProps {\n- phase: phase.clone(),\n- command: command.clone(),\n- index: *index,\n- exit_code: *exit_code,\n- duration_ms: *duration_ms,\n- },\n- ),\n- Event::DevcontainerLifecycleCompleted { phase, duration_ms } => {\n- EventBody::DevcontainerLifecycleCompleted(\n- fabro_types::DevcontainerLifecycleCompletedProps {\n- phase: phase.clone(),\n- duration_ms: *duration_ms,\n- },\n- )\n- }\n- Event::DevcontainerLifecycleFailed {\n- phase,\n- command,\n- index,\n- exit_code,\n- stderr,\n- exec_output_tail,\n- } => {\n- EventBody::DevcontainerLifecycleFailed(fabro_types::DevcontainerLifecycleFailedProps {\n- phase: phase.clone(),\n- command: command.clone(),\n- index: *index,\n- exit_code: *exit_code,\n- stderr: stderr.clone(),\n- exec_output_tail: exec_output_tail.clone(),\n- })\n- }\n }\n }\n \ndiff --git a/lib/crates/fabro-workflow/src/event/events.rs b/lib/crates/fabro-workflow/src/event/events.rs\nindex 0627829c0..886868804 100644\n--- a/lib/crates/fabro-workflow/src/event/events.rs\n+++ b/lib/crates/fabro-workflow/src/event/events.rs\n@@ -737,41 +737,6 @@ pub enum Event {\n PullRequestFailed {\n error: String,\n },\n- DevcontainerResolved {\n- dockerfile_lines: usize,\n- environment_count: usize,\n- lifecycle_command_count: usize,\n- workspace_folder: String,\n- },\n- DevcontainerLifecycleStarted {\n- phase: String,\n- command_count: usize,\n- },\n- DevcontainerLifecycleCommandStarted {\n- phase: String,\n- command: String,\n- index: usize,\n- },\n- DevcontainerLifecycleCommandCompleted {\n- phase: String,\n- command: String,\n- index: usize,\n- exit_code: i32,\n- duration_ms: u64,\n- },\n- DevcontainerLifecycleCompleted {\n- phase: String,\n- duration_ms: u64,\n- },\n- DevcontainerLifecycleFailed {\n- phase: String,\n- command: String,\n- index: usize,\n- exit_code: i32,\n- stderr: String,\n- #[serde(default, skip_serializing_if = \"Option::is_none\")]\n- exec_output_tail: Option,\n- },\n }\n \n impl Event {\n@@ -1595,77 +1560,6 @@ impl Event {\n Self::PullRequestFailed { error, .. } => {\n error!(error = %error, \"Pull request creation failed\");\n }\n- Self::DevcontainerResolved {\n- dockerfile_lines,\n- environment_count,\n- lifecycle_command_count,\n- workspace_folder,\n- } => {\n- info!(\n- dockerfile_lines,\n- environment_count,\n- lifecycle_command_count,\n- workspace_folder,\n- \"Devcontainer resolved\"\n- );\n- }\n- Self::DevcontainerLifecycleStarted {\n- phase,\n- command_count,\n- } => {\n- info!(phase, command_count, \"Devcontainer lifecycle started\");\n- }\n- Self::DevcontainerLifecycleCommandStarted {\n- phase,\n- command,\n- index,\n- } => {\n- debug!(\n- phase,\n- command, index, \"Devcontainer lifecycle command started\"\n- );\n- }\n- Self::DevcontainerLifecycleCommandCompleted {\n- phase,\n- command,\n- index,\n- exit_code,\n- duration_ms,\n- } => {\n- debug!(\n- phase,\n- command,\n- index,\n- exit_code,\n- duration_ms,\n- \"Devcontainer lifecycle command completed\"\n- );\n- }\n- Self::DevcontainerLifecycleCompleted { phase, duration_ms } => {\n- info!(phase, duration_ms, \"Devcontainer lifecycle completed\");\n- }\n- Self::DevcontainerLifecycleFailed {\n- phase,\n- command,\n- index,\n- exit_code,\n- exec_output_tail,\n- ..\n- } => {\n- let tail = fabro_types::ExecOutputTail::trace_summary(exec_output_tail.as_ref());\n- error!(\n- phase,\n- command,\n- index,\n- exit_code,\n- exec_output_tail_present = tail.present,\n- exec_stdout_tail_bytes = tail.stdout_bytes,\n- exec_stderr_tail_bytes = tail.stderr_bytes,\n- exec_stdout_truncated = tail.stdout_truncated,\n- exec_stderr_truncated = tail.stderr_truncated,\n- \"Devcontainer lifecycle command failed\"\n- );\n- }\n }\n }\n }\ndiff --git a/lib/crates/fabro-workflow/src/event/names.rs b/lib/crates/fabro-workflow/src/event/names.rs\nindex bbb8b55e0..acfee89e6 100644\n--- a/lib/crates/fabro-workflow/src/event/names.rs\n+++ b/lib/crates/fabro-workflow/src/event/names.rs\n@@ -155,16 +155,6 @@ pub fn event_name(event: &Event) -> &'static str {\n Event::PullRequestLinked { .. } => \"pull_request.linked\",\n Event::PullRequestUnlinked { .. } => \"pull_request.unlinked\",\n Event::PullRequestFailed { .. } => \"pull_request.failed\",\n- Event::DevcontainerResolved { .. } => \"devcontainer.resolved\",\n- Event::DevcontainerLifecycleStarted { .. } => \"devcontainer.lifecycle.started\",\n- Event::DevcontainerLifecycleCommandStarted { .. } => {\n- \"devcontainer.lifecycle.command.started\"\n- }\n- Event::DevcontainerLifecycleCommandCompleted { .. } => {\n- \"devcontainer.lifecycle.command.completed\"\n- }\n- Event::DevcontainerLifecycleCompleted { .. } => \"devcontainer.lifecycle.completed\",\n- Event::DevcontainerLifecycleFailed { .. } => \"devcontainer.lifecycle.failed\",\n }\n }\n \ndiff --git a/lib/crates/fabro-workflow/src/lib.rs b/lib/crates/fabro-workflow/src/lib.rs\nindex 42f5c7f43..d5efaaebb 100644\n--- a/lib/crates/fabro-workflow/src/lib.rs\n+++ b/lib/crates/fabro-workflow/src/lib.rs\n@@ -289,7 +289,6 @@ pub mod billing_rollup;\n pub mod command_log;\n pub(crate) mod condition;\n pub mod context;\n-pub mod devcontainer_bridge;\n pub mod error;\n pub mod event;\n pub mod file_resolver;\ndiff --git a/lib/crates/fabro-workflow/src/operations/mod.rs b/lib/crates/fabro-workflow/src/operations/mod.rs\nindex 7bf6c5311..4710d97a9 100644\n--- a/lib/crates/fabro-workflow/src/operations/mod.rs\n+++ b/lib/crates/fabro-workflow/src/operations/mod.rs\n@@ -24,5 +24,5 @@ pub use start::{StartServices, Started, start};\n pub use timeline::{ForkTarget, RunTimeline, TimelineEntry, build_timeline, timeline};\n pub use validate::{ValidateInput, validate};\n \n-pub use crate::pipeline::{DevcontainerSpec, LlmSpec, SandboxEnvSpec};\n+pub use crate::pipeline::{LlmSpec, SandboxEnvSpec};\n pub use crate::transforms::RenderMode;\ndiff --git a/lib/crates/fabro-workflow/src/operations/retry.rs b/lib/crates/fabro-workflow/src/operations/retry.rs\nindex 66c37c022..7a80033df 100644\n--- a/lib/crates/fabro-workflow/src/operations/retry.rs\n+++ b/lib/crates/fabro-workflow/src/operations/retry.rs\n@@ -406,7 +406,7 @@ mod tests {\n retry_state\n .sandbox\n .as_ref()\n- .and_then(|sandbox| sandbox.runtime.as_ref())\n+ .and_then(fabro_types::RunSandbox::instance)\n .is_none()\n );\n \ndiff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs\nindex a885871da..9d3f08d1c 100644\n--- a/lib/crates/fabro-workflow/src/operations/start.rs\n+++ b/lib/crates/fabro-workflow/src/operations/start.rs\n@@ -38,8 +38,8 @@ use crate::handler::HandlerRegistry;\n use crate::handler::llm::routing;\n use crate::outcome::{Outcome, StageOutcome};\n use crate::pipeline::{\n- self, DevcontainerSpec, FinalizeOptions, Finalized, InitOptions, LlmSpec, Persisted,\n- PullRequestOptions, SandboxEnvSpec, build_conclusion_from_store, classify_engine_result,\n+ self, FinalizeOptions, Finalized, InitOptions, LlmSpec, Persisted, PullRequestOptions,\n+ SandboxEnvSpec, build_conclusion_from_store, classify_engine_result,\n };\n use crate::records::Checkpoint;\n use crate::run_control::RunControlState;\n@@ -62,7 +62,6 @@ struct RunSession {\n lifecycle: LifecycleOptions,\n hooks: fabro_hooks::HookSettings,\n sandbox_env: SandboxEnvSpec,\n- devcontainer: Option,\n seed_context: Option,\n run_store: RunStoreHandle,\n event_sink: RunEventSink,\n@@ -421,14 +420,11 @@ impl RunSession {\n let github_permissions: Option> =\n (!services.github_permissions.is_empty()).then(|| services.github_permissions.clone());\n let sandbox_env = SandboxEnvSpec {\n- devcontainer_env: HashMap::new(),\n toml_env,\n github_permissions,\n origin_url: record.repo_origin_url().map(str::to_string),\n };\n \n- let devcontainer = None;\n-\n let interviewer: Arc = if resolved.execution.approval == ApprovalMode::Auto\n {\n Arc::new(AutoApproveInterviewer::engine())\n@@ -458,13 +454,11 @@ impl RunSession {\n lifecycle: LifecycleOptions {\n setup_commands: resolved.prepare.commands.clone(),\n setup_command_timeout_ms: resolved.prepare.timeout_ms,\n- devcontainer_phases: Vec::new(),\n },\n hooks: fabro_hooks::HookSettings {\n hooks: resolved.hooks.iter().map(runtime_hook_definition).collect(),\n },\n sandbox_env,\n- devcontainer,\n seed_context: None,\n run_store: services.run_store,\n artifact_sink: services.artifact_sink,\n@@ -855,7 +849,6 @@ impl RunSession {\n hooks: self.hooks,\n sandbox_env: self.sandbox_env,\n vault: self.vault,\n- devcontainer: self.devcontainer,\n git: self.git,\n registry_override: self.registry_override,\n artifact_sink: self.artifact_sink,\ndiff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs\nindex 985b5e247..d05398556 100644\n--- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs\n+++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs\n@@ -177,7 +177,6 @@ fn test_lifecycle(setup_commands: Vec) -> LifecycleOptions {\n LifecycleOptions {\n setup_commands,\n setup_command_timeout_ms: 300_000,\n- devcontainer_phases: Vec::new(),\n }\n }\n \n@@ -270,20 +269,17 @@ async fn execute_test_run_with_options(\n lifecycle: LifecycleOptions {\n setup_commands: vec![],\n setup_command_timeout_ms: 1_000,\n- devcontainer_phases: vec![],\n },\n run_options,\n workflow_path: None,\n workflow_bundle: None,\n hooks: HookSettings { hooks: vec![] },\n sandbox_env: SandboxEnvSpec {\n- devcontainer_env: HashMap::new(),\n toml_env: HashMap::new(),\n github_permissions: None,\n origin_url: None,\n },\n vault: None,\n- devcontainer: None,\n git: git_options,\n run_control: None,\n registry_override,\n@@ -336,20 +332,17 @@ async fn execute_runs_start_to_exit_and_returns_final_context() {\n lifecycle: LifecycleOptions {\n setup_commands: vec![],\n setup_command_timeout_ms: 1_000,\n- devcontainer_phases: vec![],\n },\n run_options,\n workflow_path: None,\n workflow_bundle: None,\n hooks: HookSettings { hooks: vec![] },\n sandbox_env: SandboxEnvSpec {\n- devcontainer_env: HashMap::new(),\n toml_env: HashMap::new(),\n github_permissions: None,\n origin_url: None,\n },\n vault: None,\n- devcontainer: None,\n git: None,\n run_control: None,\n registry_override: None,\n@@ -416,13 +409,11 @@ async fn run_with_lifecycle(\n workflow_bundle: None,\n hooks: HookSettings { hooks: vec![] },\n sandbox_env: SandboxEnvSpec {\n- devcontainer_env: HashMap::new(),\n toml_env: HashMap::new(),\n github_permissions: None,\n origin_url: None,\n },\n vault: None,\n- devcontainer: None,\n git: None,\n run_control: None,\n registry_override: Some(Arc::new(registry)),\ndiff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs\nindex 1b97a5d15..cc1b0777a 100644\n--- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs\n+++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs\n@@ -16,15 +16,10 @@ use fabro_sandbox::{\n };\n use fabro_static::EnvVars;\n use fabro_vault::Vault;\n-use futures::future::try_join_all;\n-use shlex::try_quote;\n-use tokio::process::Command as TokioCommand;\n use tokio::runtime::Handle;\n use tokio::sync::RwLock as AsyncRwLock;\n-use tokio::time::timeout as tokio_timeout;\n \n use super::types::{InitOptions, Initialized, LlmSpec, Persisted, SandboxEnvSpec};\n-use crate::devcontainer_bridge::{devcontainer_to_snapshot_config, run_devcontainer_lifecycle};\n use crate::error::Error;\n use crate::event::{Event, RunNoticeCode, RunNoticeLevel};\n use crate::git::GitAuthor;\n@@ -90,8 +85,7 @@ fn build_sandbox_env(\n spec: &SandboxEnvSpec,\n github_app: Option<&fabro_github::GitHubCredentials>,\n ) -> Result {\n- let mut env = spec.devcontainer_env.clone();\n- env.extend(spec.toml_env.clone());\n+ let env = spec.toml_env.clone();\n \n let Some(permissions) = spec.github_permissions.as_ref().filter(|p| !p.is_empty()) else {\n return Ok((env, None));\n@@ -273,104 +267,6 @@ fn build_llm_source(vault: Option>>) -> Arc Result<(), Error> {\n- let Some(devcontainer) = options.devcontainer.clone() else {\n- return Ok(());\n- };\n- if !devcontainer.enabled {\n- return Ok(());\n- }\n-\n- let config = fabro_devcontainer::DevcontainerResolver::resolve(&devcontainer.resolve_dir)\n- .await\n- .map_err(|e| Error::engine_with_source(\"Failed to resolve devcontainer\", e))?;\n-\n- let lifecycle_command_count = config.on_create_commands.len()\n- + config.post_create_commands.len()\n- + config.post_start_commands.len();\n- options.emitter.emit(&Event::DevcontainerResolved {\n- dockerfile_lines: config.dockerfile.lines().count(),\n- environment_count: config.environment.len(),\n- lifecycle_command_count,\n- workspace_folder: config.workspace_folder.clone(),\n- });\n-\n- options\n- .sandbox\n- .apply_devcontainer_snapshot(devcontainer_to_snapshot_config(&config));\n-\n- let timeout = std::time::Duration::from_mins(5);\n- let run_shell = |shell_command: String| {\n- let cwd = devcontainer.resolve_dir.clone();\n- async move {\n- let output = tokio_timeout(\n- timeout,\n- TokioCommand::new(\"sh\")\n- .arg(\"-c\")\n- .arg(&shell_command)\n- .current_dir(&cwd)\n- .output(),\n- )\n- .await\n- .map_err(|_| {\n- Error::engine(format!(\n- \"Devcontainer initializeCommand timed out: {shell_command}\"\n- ))\n- })?\n- .map_err(|e| {\n- Error::engine_with_source(\n- format!(\"Failed to execute devcontainer initializeCommand: {shell_command}\"),\n- e,\n- )\n- })?;\n-\n- if !output.status.success() {\n- let code = output\n- .status\n- .code()\n- .map_or_else(|| \"unknown\".to_string(), |code| code.to_string());\n- let stderr = String::from_utf8_lossy(&output.stderr);\n- return Err(Error::engine(format!(\n- \"Devcontainer initializeCommand failed (exit code {code}): {shell_command}\\n{stderr}\"\n- )));\n- }\n- Ok::<(), Error>(())\n- }\n- };\n-\n- for command in &config.initialize_commands {\n- match command {\n- fabro_devcontainer::Command::Shell(shell) => run_shell(shell.clone()).await?,\n- fabro_devcontainer::Command::Args(args) => {\n- let shell_command = args\n- .iter()\n- .map(|arg| try_quote(arg).unwrap_or_else(|_| arg.into()).to_string())\n- .collect::>()\n- .join(\" \");\n- run_shell(shell_command).await?;\n- }\n- fabro_devcontainer::Command::Parallel(commands) => {\n- let futures = commands.values().cloned().map(&run_shell);\n- try_join_all(futures).await?;\n- }\n- }\n- }\n-\n- options\n- .sandbox_env\n- .devcontainer_env\n- .clone_from(&config.environment);\n- options.lifecycle.devcontainer_phases = vec![\n- (\"on_create\".to_string(), config.on_create_commands.clone()),\n- (\n- \"post_create\".to_string(),\n- config.post_create_commands.clone(),\n- ),\n- (\"post_start\".to_string(), config.post_start_commands.clone()),\n- ];\n-\n- Ok(())\n-}\n /// INITIALIZE phase: prepare the sandbox, env, and handlers for execution.\n pub async fn initialize(\n persisted: Persisted,\n@@ -397,8 +293,6 @@ pub async fn initialize(\n )))\n };\n \n- resolve_devcontainer(&mut options).await?;\n-\n let attach_existing = options.checkpoint.is_some();\n options.run_options.display_base_sha = options\n .run_options\n@@ -439,6 +333,9 @@ pub async fn initialize(\n let record = run_state.sandbox.ok_or_else(|| {\n Error::Precondition(\"cannot resume run: run sandbox is missing\".to_string())\n })?;\n+ let instance = record.instance().ok_or_else(|| {\n+ Error::Precondition(\"cannot resume run: run sandbox was not initialized\".to_string())\n+ })?;\n let daytona_api_key = match &options.vault {\n Some(vault) => vault\n .read()\n@@ -448,7 +345,7 @@ pub async fn initialize(\n None => None,\n };\n let sandbox = reconnect_for_run_with_callback(\n- &record,\n+ instance,\n daytona_api_key,\n Some(options.run_id),\n Some(Arc::clone(&sandbox_event_callback)),\n@@ -510,11 +407,8 @@ pub async fn initialize(\n if sandbox_initialized {\n let run_sandbox = options\n .sandbox\n- .to_run_sandbox(&*sandbox, options.run_options.run_id);\n- let runtime = run_sandbox\n- .runtime\n- .as_ref()\n- .ok_or_else(|| Error::engine(\"initialized sandbox missing runtime metadata\"))?;\n+ .to_run_sandbox_instance(&*sandbox, options.run_options.run_id);\n+ let runtime = &run_sandbox.runtime;\n options.emitter.emit(&Event::SandboxInitialized {\n working_directory: runtime.working_directory.clone(),\n provider: run_sandbox.provider,\n@@ -681,18 +575,6 @@ pub async fn initialize(\n });\n }\n \n- for (phase, commands) in &options.lifecycle.devcontainer_phases {\n- run_devcontainer_lifecycle(\n- sandbox.as_ref(),\n- &options.emitter,\n- phase,\n- commands,\n- options.lifecycle.setup_command_timeout_ms,\n- options.run_options.cancel_token.clone(),\n- )\n- .await?;\n- }\n-\n let metadata_writer = match build_metadata_writer(&options.run_options) {\n Ok(writer) => writer,\n Err(err) => {\n@@ -940,20 +822,17 @@ mod tests {\n lifecycle: crate::run_options::LifecycleOptions {\n setup_commands: vec![command.to_string()],\n setup_command_timeout_ms: 1_000,\n- devcontainer_phases: vec![],\n },\n run_options: test_settings(&run_dir),\n workflow_path: None,\n workflow_bundle: None,\n hooks: fabro_hooks::HookSettings { hooks: vec![] },\n sandbox_env: SandboxEnvSpec {\n- devcontainer_env: HashMap::new(),\n toml_env: HashMap::new(),\n github_permissions: None,\n origin_url: None,\n },\n vault: None,\n- devcontainer: None,\n git: None,\n run_control: None,\n registry_override: None,\n@@ -1025,20 +904,17 @@ mod tests {\n lifecycle: crate::run_options::LifecycleOptions {\n setup_commands: vec![],\n setup_command_timeout_ms: 1_000,\n- devcontainer_phases: vec![],\n },\n run_options: test_settings(&run_dir),\n workflow_path: None,\n workflow_bundle: None,\n hooks: fabro_hooks::HookSettings { hooks: vec![] },\n sandbox_env: SandboxEnvSpec {\n- devcontainer_env: HashMap::new(),\n toml_env: HashMap::from([(\"TEST_KEY\".to_string(), \"value\".to_string())]),\n github_permissions: None,\n origin_url: None,\n },\n vault: None,\n- devcontainer: None,\n git: None,\n run_control: None,\n registry_override: None,\n@@ -1226,20 +1102,17 @@ mod tests {\n lifecycle: crate::run_options::LifecycleOptions {\n setup_commands: Vec::new(),\n setup_command_timeout_ms: 1_000,\n- devcontainer_phases: Vec::new(),\n },\n run_options: test_settings(&run_dir),\n workflow_path: None,\n workflow_bundle: None,\n hooks: fabro_hooks::HookSettings { hooks: vec![] },\n sandbox_env: SandboxEnvSpec {\n- devcontainer_env: HashMap::new(),\n toml_env: HashMap::new(),\n github_permissions: None,\n origin_url: None,\n },\n vault: Some(vault),\n- devcontainer: None,\n git: None,\n run_control: None,\n registry_override: None,\n@@ -1325,20 +1198,17 @@ mod tests {\n lifecycle: crate::run_options::LifecycleOptions {\n setup_commands: vec![\"true\".to_string()],\n setup_command_timeout_ms: 1_000,\n- devcontainer_phases: vec![],\n },\n run_options: test_settings(&run_dir),\n workflow_path: None,\n workflow_bundle: None,\n hooks: fabro_hooks::HookSettings { hooks: vec![] },\n sandbox_env: SandboxEnvSpec {\n- devcontainer_env: HashMap::new(),\n toml_env: HashMap::new(),\n github_permissions: None,\n origin_url: None,\n },\n vault: None,\n- devcontainer: None,\n git: None,\n run_control: None,\n registry_override: None,\n@@ -1442,88 +1312,17 @@ mod tests {\n lifecycle: crate::run_options::LifecycleOptions {\n setup_commands: vec![\"sleep 5\".to_string()],\n setup_command_timeout_ms: 5_000,\n- devcontainer_phases: vec![],\n- },\n- run_options,\n- workflow_path: None,\n- workflow_bundle: None,\n- hooks: fabro_hooks::HookSettings { hooks: vec![] },\n- sandbox_env: SandboxEnvSpec {\n- devcontainer_env: HashMap::new(),\n- toml_env: HashMap::new(),\n- github_permissions: None,\n- origin_url: None,\n- },\n- vault: None,\n- devcontainer: None,\n- git: None,\n- run_control: None,\n- registry_override: None,\n- artifact_sink: None,\n- checkpoint: None,\n- seed_context: None,\n- fabro_run_tools: None,\n- })\n- .await;\n-\n- assert!(matches!(result, Err(Error::Cancelled)));\n- }\n-\n- #[tokio::test]\n- async fn initialize_cancelled_devcontainer_phase_returns_cancelled() {\n- let temp = tempfile::tempdir().unwrap();\n- let run_dir = temp.path().join(\"run\");\n- std::fs::create_dir_all(&run_dir).unwrap();\n- let (graph, source) = simple_graph();\n- let persisted = test_persisted(graph, source, &run_dir);\n- let cancel_token = tokio_util::sync::CancellationToken::new();\n- cancel_token.cancel();\n- let mut run_options = test_settings(&run_dir);\n- run_options.cancel_token = cancel_token;\n-\n- let emitter = Arc::new(crate::event::Emitter::new(test_run_id()));\n- let result = initialize(persisted, InitOptions {\n- run_id: test_run_id(),\n- run_store: {\n- let store = memory_store();\n- let inner = store.create_run(&test_run_id()).await.unwrap();\n- inner.into()\n- },\n- dry_run: false,\n- emitter: emitter.clone(),\n- sandbox: SandboxSpec::Local {\n- working_directory: std::env::current_dir().unwrap(),\n- },\n- llm: LlmSpec {\n- model: \"test-model\".to_string(),\n- provider_id: fabro_model::ProviderId::anthropic(),\n- fallback_chain: Vec::new(),\n- mcp_servers: Vec::new(),\n- model_controls: RunModelControls::default(),\n- dry_run: true,\n- },\n- interviewer: Arc::new(AutoApproveInterviewer::engine()),\n- steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),\n- catalog: test_catalog(),\n- lifecycle: crate::run_options::LifecycleOptions {\n- setup_commands: vec![],\n- setup_command_timeout_ms: 5_000,\n- devcontainer_phases: vec![(\"on_create\".to_string(), vec![\n- fabro_devcontainer::Command::Shell(\"sleep 5\".to_string()),\n- ])],\n },\n run_options,\n workflow_path: None,\n workflow_bundle: None,\n hooks: fabro_hooks::HookSettings { hooks: vec![] },\n sandbox_env: SandboxEnvSpec {\n- devcontainer_env: HashMap::new(),\n toml_env: HashMap::new(),\n github_permissions: None,\n origin_url: None,\n },\n vault: None,\n- devcontainer: None,\n git: None,\n run_control: None,\n registry_override: None,\ndiff --git a/lib/crates/fabro-workflow/src/pipeline/mod.rs b/lib/crates/fabro-workflow/src/pipeline/mod.rs\nindex 94d393025..ef517e9cc 100644\n--- a/lib/crates/fabro-workflow/src/pipeline/mod.rs\n+++ b/lib/crates/fabro-workflow/src/pipeline/mod.rs\n@@ -22,8 +22,8 @@ pub use pull_request::{\n };\n pub use transform::transform;\n pub use types::{\n- Concluded, DevcontainerSpec, Executed, FinalizeOptions, Finalized, InitOptions, Initialized,\n- LlmSpec, Parsed, Persisted, PullRequestOptions, SandboxEnvSpec,\n- TEMPLATE_UNDEFINED_VARIABLE_RULE, TransformOptions, Transformed, Validated,\n+ Concluded, Executed, FinalizeOptions, Finalized, InitOptions, Initialized, LlmSpec, Parsed,\n+ Persisted, PullRequestOptions, SandboxEnvSpec, TEMPLATE_UNDEFINED_VARIABLE_RULE,\n+ TransformOptions, Transformed, Validated,\n };\n pub use validate::validate;\ndiff --git a/lib/crates/fabro-workflow/src/pipeline/types.rs b/lib/crates/fabro-workflow/src/pipeline/types.rs\nindex 39085a904..541daebe1 100644\n--- a/lib/crates/fabro-workflow/src/pipeline/types.rs\n+++ b/lib/crates/fabro-workflow/src/pipeline/types.rs\n@@ -239,18 +239,11 @@ pub struct LlmSpec {\n \n #[derive(Clone)]\n pub struct SandboxEnvSpec {\n- pub devcontainer_env: HashMap,\n pub toml_env: HashMap,\n pub github_permissions: Option>,\n pub origin_url: Option,\n }\n \n-#[derive(Clone)]\n-pub struct DevcontainerSpec {\n- pub enabled: bool,\n- pub resolve_dir: PathBuf,\n-}\n-\n pub struct InitOptions {\n pub run_id: RunId,\n pub run_store: RunStoreHandle,\n@@ -268,7 +261,6 @@ pub struct InitOptions {\n pub hooks: fabro_hooks::HookSettings,\n pub sandbox_env: SandboxEnvSpec,\n pub vault: Option>>,\n- pub devcontainer: Option,\n pub git: Option,\n pub registry_override: Option>,\n pub artifact_sink: Option,\ndiff --git a/lib/crates/fabro-workflow/src/run_options.rs b/lib/crates/fabro-workflow/src/run_options.rs\nindex c1d20cdcb..23282d644 100644\n--- a/lib/crates/fabro-workflow/src/run_options.rs\n+++ b/lib/crates/fabro-workflow/src/run_options.rs\n@@ -78,6 +78,4 @@ pub struct LifecycleOptions {\n pub setup_commands: Vec,\n /// Timeout in milliseconds for each setup command.\n pub setup_command_timeout_ms: u64,\n- /// Devcontainer lifecycle phases and their commands.\n- pub devcontainer_phases: Vec<(String, Vec)>,\n }\ndiff --git a/lib/crates/fabro-workflow/src/services.rs b/lib/crates/fabro-workflow/src/services.rs\nindex e8c4b3974..44e0eca21 100644\n--- a/lib/crates/fabro-workflow/src/services.rs\n+++ b/lib/crates/fabro-workflow/src/services.rs\n@@ -232,7 +232,7 @@ pub struct EngineServices {\n /// Git state for the current run. Set via `set_git_state` at the start of\n /// `execute` and read by parallel/fan-in handlers.\n pub(crate) git_state: std::sync::RwLock>>,\n- /// Environment variables from devcontainer and `[sandbox.env]` config.\n+ /// Environment variables from `[sandbox.env]` config.\n pub base_env: HashMap,\n /// GitHub token source used to inject `GITHUB_TOKEN` at the point of use.\n pub github_token: Option>,\ndiff --git a/lib/crates/fabro-workflow/tests/it/cp_integration.rs b/lib/crates/fabro-workflow/tests/it/cp_integration.rs\nindex 60aafd9bb..6fd43fe70 100644\n--- a/lib/crates/fabro-workflow/tests/it/cp_integration.rs\n+++ b/lib/crates/fabro-workflow/tests/it/cp_integration.rs\n@@ -15,7 +15,7 @@\n )]\n \n use fabro_sandbox::reconnect::reconnect;\n-use fabro_types::{RunSandbox, RunSandboxRuntime, SandboxProviderKind};\n+use fabro_types::{RunSandboxInstance, RunSandboxRuntime, SandboxProviderKind};\n \n const DOCKER_MANAGED_LABEL: &str = \"sh.fabro.managed\";\n const DOCKER_CP_IMAGE: &str = \"buildpack-deps:noble\";\n@@ -24,12 +24,12 @@ const DOCKER_CP_IMAGE: &str = \"buildpack-deps:noble\";\n // Local sandbox\n // ---------------------------------------------------------------------------\n \n-fn local_record(working_directory: &std::path::Path) -> RunSandbox {\n- RunSandbox {\n+fn local_record(working_directory: &std::path::Path) -> RunSandboxInstance {\n+ RunSandboxInstance {\n provider: SandboxProviderKind::Local,\n image: None,\n snapshot: None,\n- runtime: Some(RunSandboxRuntime {\n+ runtime: RunSandboxRuntime {\n id: \"local:test\".to_string(),\n working_directory: working_directory.to_string_lossy().to_string(),\n repo_cloned: None,\n@@ -39,7 +39,7 @@ fn local_record(working_directory: &std::path::Path) -> RunSandbox {\n repos_root: None,\n primary_repo_path: None,\n primary_repo_link: None,\n- }),\n+ },\n }\n }\n \n@@ -135,12 +135,12 @@ async fn local_cp_creates_parent_dirs() {\n // Docker sandbox\n // ---------------------------------------------------------------------------\n \n-fn docker_record(container_id: &str) -> RunSandbox {\n- RunSandbox {\n+fn docker_record(container_id: &str) -> RunSandboxInstance {\n+ RunSandboxInstance {\n provider: SandboxProviderKind::Docker,\n image: None,\n snapshot: None,\n- runtime: Some(RunSandboxRuntime {\n+ runtime: RunSandboxRuntime {\n id: container_id.to_string(),\n working_directory: \"/workspace\".to_string(),\n repo_cloned: Some(false),\n@@ -150,7 +150,7 @@ fn docker_record(container_id: &str) -> RunSandbox {\n repos_root: Some(\"/repos\".to_string()),\n primary_repo_path: None,\n primary_repo_link: None,\n- }),\n+ },\n }\n }\n \ndiff --git a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs\nindex 2d223a550..346825e7e 100644\n--- a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs\n+++ b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs\n@@ -1703,7 +1703,7 @@ async fn daytona_toolbox_idle_diagnostic() {\n #[fabro_macros::e2e_test(live(\"DAYTONA_API_KEY\"), live(\"GITHUB_APP_PRIVATE_KEY\"))]\n async fn daytona_cp_upload_download_round_trip() {\n use fabro_sandbox::reconnect::reconnect;\n- use fabro_types::{RunSandbox, SandboxProviderKind};\n+ use fabro_types::{RunSandboxInstance, SandboxProviderKind};\n \n // 1. Create and initialize a real Daytona sandbox\n let env = create_env().await;\n@@ -1715,12 +1715,12 @@ async fn daytona_cp_upload_download_round_trip() {\n \"sandbox_info() should return the Daytona sandbox name\"\n );\n \n- // 2. Build a RunSandbox (same as `fabro run` would persist)\n- let record = RunSandbox {\n+ // 2. Build initialized sandbox metadata (same as `fabro run` would persist)\n+ let record = RunSandboxInstance {\n provider: SandboxProviderKind::Daytona,\n image: None,\n snapshot: None,\n- runtime: Some(fabro_types::RunSandboxRuntime {\n+ runtime: fabro_types::RunSandboxRuntime {\n id: sandbox_name.clone(),\n working_directory: env.working_directory().to_string(),\n repo_cloned: Some(false),\n@@ -1730,7 +1730,7 @@ async fn daytona_cp_upload_download_round_trip() {\n repos_root: Some(\"/home/daytona/repos\".to_string()),\n primary_repo_path: None,\n primary_repo_link: None,\n- }),\n+ },\n };\n \n // 3. Reconnect via the real cp::reconnect path\ndiff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES\nindex a3e517248..47d63f903 100644\n--- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES\n+++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES\n@@ -352,6 +352,10 @@ models/run-provenance.ts\n models/run-question.ts\n models/run-reference.ts\n models/run-runnable-source.ts\n+models/run-sandbox-failure.ts\n+models/run-sandbox-instance.ts\n+models/run-sandbox-kind.ts\n+models/run-sandbox-plan.ts\n models/run-sandbox-runtime.ts\n models/run-sandbox.ts\n models/run-scm-settings.ts\ndiff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts\nindex 2ee3e1593..eea7c071d 100644\n--- a/lib/packages/fabro-api-client/src/models/index.ts\n+++ b/lib/packages/fabro-api-client/src/models/index.ts\n@@ -328,6 +328,10 @@ export * from './run-question';\n export * from './run-reference';\n export * from './run-runnable-source';\n export * from './run-sandbox';\n+export * from './run-sandbox-failure';\n+export * from './run-sandbox-instance';\n+export * from './run-sandbox-kind';\n+export * from './run-sandbox-plan';\n export * from './run-sandbox-runtime';\n export * from './run-scm-settings';\n export * from './run-server-provenance';\ndiff --git a/lib/packages/fabro-api-client/src/models/run-sandbox-failure.ts b/lib/packages/fabro-api-client/src/models/run-sandbox-failure.ts\nnew file mode 100644\nindex 000000000..c30582f99\n--- /dev/null\n+++ b/lib/packages/fabro-api-client/src/models/run-sandbox-failure.ts\n@@ -0,0 +1,28 @@\n+/* tslint:disable */\n+/* eslint-disable */\n+/**\n+ * Fabro Run API\n+ * HTTP API for managing Fabro workflow run executions.\n+ *\n+ * The version of the OpenAPI document: 0.1.0\n+ *\n+ *\n+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n+ * https://openapi-generator.tech\n+ * Do not edit the class manually.\n+ */\n+\n+\n+\n+/**\n+ * Sandbox initialization failure details.\n+ */\n+export interface RunSandboxFailure {\n+ /**\n+ * Provider reported by the sandbox initialization event.\n+ */\n+ 'provider': string;\n+ 'error': string;\n+ 'causes': Array;\n+ 'duration_ms': number;\n+}\ndiff --git a/lib/packages/fabro-api-client/src/models/run-sandbox-instance.ts b/lib/packages/fabro-api-client/src/models/run-sandbox-instance.ts\nnew file mode 100644\nindex 000000000..1fd11d41c\n--- /dev/null\n+++ b/lib/packages/fabro-api-client/src/models/run-sandbox-instance.ts\n@@ -0,0 +1,31 @@\n+/* tslint:disable */\n+/* eslint-disable */\n+/**\n+ * Fabro Run API\n+ * HTTP API for managing Fabro workflow run executions.\n+ *\n+ * The version of the OpenAPI document: 0.1.0\n+ *\n+ *\n+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n+ * https://openapi-generator.tech\n+ * Do not edit the class manually.\n+ */\n+\n+\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { RunSandboxRuntime } from './run-sandbox-runtime';\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { SandboxProviderKind } from './sandbox-provider-kind';\n+\n+/**\n+ * Initialized sandbox provider and runtime metadata.\n+ */\n+export interface RunSandboxInstance {\n+ 'provider': SandboxProviderKind;\n+ 'image'?: string | null;\n+ 'snapshot'?: string | null;\n+ 'runtime': RunSandboxRuntime;\n+}\ndiff --git a/lib/packages/fabro-api-client/src/models/run-sandbox-kind.ts b/lib/packages/fabro-api-client/src/models/run-sandbox-kind.ts\nnew file mode 100644\nindex 000000000..5839dafad\n--- /dev/null\n+++ b/lib/packages/fabro-api-client/src/models/run-sandbox-kind.ts\n@@ -0,0 +1,28 @@\n+/* tslint:disable */\n+/* eslint-disable */\n+/**\n+ * Fabro Run API\n+ * HTTP API for managing Fabro workflow run executions.\n+ *\n+ * The version of the OpenAPI document: 0.1.0\n+ *\n+ *\n+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n+ * https://openapi-generator.tech\n+ * Do not edit the class manually.\n+ */\n+\n+\n+\n+/**\n+ * Lifecycle state for a run sandbox request.\n+ */\n+\n+export const RunSandboxKind = {\n+ PLANNED: 'planned',\n+ INITIALIZING: 'initializing',\n+ READY: 'ready',\n+ FAILED: 'failed'\n+} as const;\n+\n+export type RunSandboxKind = typeof RunSandboxKind[keyof typeof RunSandboxKind];\ndiff --git a/lib/packages/fabro-api-client/src/models/run-sandbox-plan.ts b/lib/packages/fabro-api-client/src/models/run-sandbox-plan.ts\nnew file mode 100644\nindex 000000000..6a7e2d032\n--- /dev/null\n+++ b/lib/packages/fabro-api-client/src/models/run-sandbox-plan.ts\n@@ -0,0 +1,27 @@\n+/* tslint:disable */\n+/* eslint-disable */\n+/**\n+ * Fabro Run API\n+ * HTTP API for managing Fabro workflow run executions.\n+ *\n+ * The version of the OpenAPI document: 0.1.0\n+ *\n+ *\n+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n+ * https://openapi-generator.tech\n+ * Do not edit the class manually.\n+ */\n+\n+\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { SandboxProviderKind } from './sandbox-provider-kind';\n+\n+/**\n+ * Requested sandbox provider and base image/snapshot from run settings.\n+ */\n+export interface RunSandboxPlan {\n+ 'provider': SandboxProviderKind;\n+ 'image'?: string | null;\n+ 'snapshot'?: string | null;\n+}\ndiff --git a/lib/packages/fabro-api-client/src/models/run-sandbox.ts b/lib/packages/fabro-api-client/src/models/run-sandbox.ts\nindex 7e5102b1b..12c15b34f 100644\n--- a/lib/packages/fabro-api-client/src/models/run-sandbox.ts\n+++ b/lib/packages/fabro-api-client/src/models/run-sandbox.ts\n@@ -15,17 +15,23 @@\n \n // May contain unused imports in some cases\n // @ts-ignore\n-import type { RunSandboxRuntime } from './run-sandbox-runtime';\n+import type { RunSandboxFailure } from './run-sandbox-failure';\n // May contain unused imports in some cases\n // @ts-ignore\n-import type { SandboxProviderKind } from './sandbox-provider-kind';\n+import type { RunSandboxInstance } from './run-sandbox-instance';\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { RunSandboxKind } from './run-sandbox-kind';\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { RunSandboxPlan } from './run-sandbox-plan';\n \n /**\n- * Canonical sandbox environment record for a run.\n+ * Sandbox lifecycle record for a run. A run can have a requested sandbox plan before it has an initialized sandbox instance.\n */\n export interface RunSandbox {\n- 'provider': SandboxProviderKind;\n- 'image': string | null;\n- 'snapshot': string | null;\n- 'runtime': RunSandboxRuntime | null;\n+ 'kind': RunSandboxKind;\n+ 'plan': RunSandboxPlan;\n+ 'instance'?: RunSandboxInstance | null;\n+ 'failure'?: RunSandboxFailure | null;\n }\ndiff --git a/lib/packages/fabro-api-client/src/models/sandbox-details.ts b/lib/packages/fabro-api-client/src/models/sandbox-details.ts\nindex 5c733abec..016fd2661 100644\n--- a/lib/packages/fabro-api-client/src/models/sandbox-details.ts\n+++ b/lib/packages/fabro-api-client/src/models/sandbox-details.ts\n@@ -15,7 +15,7 @@\n \n // May contain unused imports in some cases\n // @ts-ignore\n-import type { RunSandbox } from './run-sandbox';\n+import type { RunSandboxInstance } from './run-sandbox-instance';\n // May contain unused imports in some cases\n // @ts-ignore\n import type { SandboxNetwork } from './sandbox-network';\n@@ -33,7 +33,7 @@ import type { SandboxTimestamps } from './sandbox-timestamps';\n * Provider-neutral details about the sandbox owned by a run.\n */\n export interface SandboxDetails {\n- 'sandbox': RunSandbox;\n+ 'sandbox': RunSandboxInstance;\n 'state': SandboxState;\n /**\n * Original provider state string before normalization. Display/debugging only; UI behavior keys off `state`.\n", + "summary": { + "files_changed": 161, + "additions": 2427, + "deletions": 7029 + } + } } ], - "conclusion": null, + "conclusion": { + "timestamp": "2026-05-27T17:26:27.572366Z", + "status": "succeeded", + "timing": { + "wall_time_ms": 3530221, + "inference_time_ms": 1825544, + "tool_time_ms": 1666311, + "active_time_ms": 3491855 + }, + "final_git_commit_sha": "4d0ffd4042987a0bd0469bfc7271adb3c7ec6cfb", + "stages": [ + { + "stage_id": "start", + "stage_label": "start", + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "retries": 0 + }, + { + "stage_id": "toolchain", + "stage_label": "toolchain", + "timing": { + "wall_time_ms": 1454, + "inference_time_ms": 0, + "tool_time_ms": 1445, + "active_time_ms": 1445 + }, + "retries": 0 + }, + { + "stage_id": "preflight_compile", + "stage_label": "preflight_compile", + "timing": { + "wall_time_ms": 129003, + "inference_time_ms": 0, + "tool_time_ms": 128996, + "active_time_ms": 128996 + }, + "retries": 0 + }, + { + "stage_id": "preflight_lint", + "stage_label": "preflight_lint", + "timing": { + "wall_time_ms": 141360, + "inference_time_ms": 0, + "tool_time_ms": 141353, + "active_time_ms": 141353 + }, + "retries": 0 + }, + { + "stage_id": "implement", + "stage_label": "implement", + "timing": { + "wall_time_ms": 1894970, + "inference_time_ms": 1484989, + "tool_time_ms": 408080, + "active_time_ms": 1893069 + }, + "billing_usd_micros": 18437825, + "retries": 0 + }, + { + "stage_id": "simplify_opus", + "stage_label": "simplify_opus", + "timing": { + "wall_time_ms": 528847, + "inference_time_ms": 199764, + "tool_time_ms": 328077, + "active_time_ms": 527841 + }, + "billing_usd_micros": 2603564, + "retries": 0 + }, + { + "stage_id": "simplify_gpt", + "stage_label": "simplify_gpt", + "timing": { + "wall_time_ms": 241912, + "inference_time_ms": 140791, + "tool_time_ms": 100472, + "active_time_ms": 241263 + }, + "billing_usd_micros": 2765321, + "retries": 0 + }, + { + "stage_id": "verify", + "stage_label": "verify", + "timing": { + "wall_time_ms": 557913, + "inference_time_ms": 0, + "tool_time_ms": 557888, + "active_time_ms": 557888 + }, + "retries": 0 + } + ], + "billing": { + "input_tokens": 3415800, + "output_tokens": 30357, + "total_tokens": 12216336, + "reasoning_tokens": 9079, + "cache_read_tokens": 8549006, + "cache_write_tokens": 212094, + "total_usd_micros": 23806710 + }, + "total_retries": 0, + "diff": {} + }, "sandbox": { "provider": "daytona", "snapshot": "fabro-fdb28dec-1233-892c-b9d7-9f88f8353e7a", @@ -2371,7 +2493,12 @@ "first_event_seq": 1027, "prompt": null, "response": null, - "completion": null, + "completion": { + "outcome": "succeeded", + "notes": "Script completed: git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\\bActorRef\\b|\\bActorKind\\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\\s*==\\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", + "failure_reason": null, + "timestamp": "2026-05-27T17:26:22.860036Z" + }, "provider_used": null, "diff": null, "script_invocation": { @@ -2379,11 +2506,27 @@ "command": "exec 2>&1\ngit fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\\bActorRef\\b|\\bActorKind\\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\\s*==\\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", "language": "shell" }, - "script_timing": null, + "script_timing": { + "output": "blob://sha256/fc9defa961f2471f59d082245293de47c0a407bb3e95e7fb5ae5b3695a839fcb", + "exit_code": 0, + "duration_ms": 557888, + "termination": "exited", + "output_bytes": 216972, + "live_streaming": true + }, "parallel_results": null, "output": null, + "output_bytes": 216972, + "live_streaming": true, + "termination": "exited", "started_at": "2026-05-27T17:17:04.947679Z", "handler": "command", + "timing": { + "wall_time_ms": 557913, + "inference_time_ms": 0, + "tool_time_ms": 557888, + "active_time_ms": 557888 + }, "usage": { "input_tokens": 0, "output_tokens": 0, @@ -2392,7 +2535,7 @@ "cache_read_tokens": 0, "cache_write_tokens": 0 }, - "state": "running" + "state": "succeeded" }, "implement@1": { "first_event_seq": 52, @@ -2653,6 +2796,40 @@ "warnings": [] }, "state": "succeeded" + }, + "exit@1": { + "first_event_seq": 1037, + "prompt": null, + "response": null, + "completion": { + "outcome": "succeeded", + "notes": null, + "failure_reason": null, + "timestamp": "2026-05-27T17:26:27.491252Z" + }, + "provider_used": null, + "diff": null, + "script_invocation": null, + "script_timing": null, + "parallel_results": null, + "output": null, + "started_at": "2026-05-27T17:26:27.491200Z", + "handler": "exit", + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "reasoning_tokens": 0, + "cache_read_tokens": 0, + "cache_write_tokens": 0 + }, + "state": "succeeded" } } } \ No newline at end of file diff --git a/stages/008-verify@1/diff.patch b/stages/008-verify@1/diff.patch new file mode 100644 index 000000000..d54060e88 --- /dev/null +++ b/stages/008-verify@1/diff.patch @@ -0,0 +1,11099 @@ +diff --git a/.claude/skills/docs/references/mapping.md b/.claude/skills/docs/references/mapping.md +index 93b867938..0a7c9e329 100644 +--- a/.claude/skills/docs/references/mapping.md ++++ b/.claude/skills/docs/references/mapping.md +@@ -29,7 +29,6 @@ Which source files affect which doc pages. Use this as guidance — also apply j + | `lib/crates/fabro-agent/src/subagent.rs` | `docs/public/agents/subagents.mdx` | + | `lib/crates/fabro-agent/src/mcp_integration.rs` | `docs/public/agents/mcp.mdx` | + | `lib/crates/fabro-llm/src/catalog.rs`, `lib/crates/fabro-llm/src/providers/*.rs` | `docs/public/core-concepts/models.mdx` | +-| `lib/crates/fabro-devcontainer/src/*.rs` | `docs/public/execution/devcontainers.mdx` | + | `lib/crates/fabro-slack/src/*.rs` | `docs/public/integrations/slack.mdx` | + | `lib/crates/fabro-mcp/src/*.rs` | `docs/public/agents/mcp.mdx` | + | `lib/crates/fabro-api/src/*.rs` | `docs/public/api-reference/overview.mdx`, `docs/public/api-reference/demo-mode.mdx` | +diff --git a/.config/nextest.toml b/.config/nextest.toml +index 2d5880982..fe7f4f778 100644 +--- a/.config/nextest.toml ++++ b/.config/nextest.toml +@@ -15,10 +15,6 @@ leak-timeout = "500ms" + filter = "package(fabro-server) & test(all_spec_routes_are_routable)" + slow-timeout = { period = "15s", terminate-after = 4 } + +- [[profile.default.overrides]] +- filter = "package(fabro-devcontainer) & test(resolve_features_integration)" +- slow-timeout = { period = "10s", terminate-after = 3 } +- + [[profile.default.overrides]] + filter = "package(fabro-workflow)" + slow-timeout = { period = "2s", terminate-after = 3 } +diff --git a/AGENTS.md b/AGENTS.md +index c97bcec4c..d77efe095 100644 +--- a/AGENTS.md ++++ b/AGENTS.md +@@ -118,7 +118,6 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as + - **fabro-github** — GitHub App auth (JWT signing, installation tokens, PR creation) + - **fabro-mcp** — Model Context Protocol client/server + - **fabro-slack** — Slack integration (socket mode, blocks API) +-- **fabro-devcontainer** — Parses `.devcontainer/devcontainer.json` for container setup + - **fabro-checkpoint** — Git-based checkpoint storage with branch store and metadata branches + - **fabro-telemetry** — CLI analytics (Segment) and crash reporting (Sentry), with anonymous IDs, command sanitization, and detached subprocess delivery + - **fabro-util** — Shared utilities (redaction, terminal formatting) +diff --git a/Cargo.lock b/Cargo.lock +index ebd8e2593..97b4be2eb 100644 +--- a/Cargo.lock ++++ b/Cargo.lock +@@ -1827,7 +1827,6 @@ dependencies = [ + "fabro-checkpoint", + "fabro-client", + "fabro-config", +- "fabro-devcontainer", + "fabro-dump", + "fabro-github", + "fabro-graphviz", +@@ -1992,23 +1991,6 @@ dependencies = [ + "walkdir", + ] + +-[[package]] +-name = "fabro-devcontainer" +-version = "0.246.0-nightly.0" +-dependencies = [ +- "fabro-http", +- "fabro-static", +- "fabro-util", +- "insta", +- "serde", +- "serde_json", +- "serde_yaml", +- "tempfile", +- "thiserror 2.0.18", +- "tokio", +- "tracing", +-] +- + [[package]] + name = "fabro-dump" + version = "0.246.0-nightly.0" +@@ -2693,7 +2675,6 @@ dependencies = [ + "fabro-checkpoint", + "fabro-config", + "fabro-core", +- "fabro-devcontainer", + "fabro-dump", + "fabro-github", + "fabro-graphviz", +diff --git a/apps/fabro-web/app/components/run-summary-panel.test.tsx b/apps/fabro-web/app/components/run-summary-panel.test.tsx +index f93a3fd8a..52e79cf0f 100644 +--- a/apps/fabro-web/app/components/run-summary-panel.test.tsx ++++ b/apps/fabro-web/app/components/run-summary-panel.test.tsx +@@ -83,6 +83,58 @@ describe("RunSummaryPanelView", () => { + expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe(EMPTY_VALUE); + }); + ++ test("renders planned sandbox on a failed run as not created", () => { ++ const tree = render({ ++ run: makeRun({ ++ lifecycle: { status: { kind: "failed", reason: "sandbox_init_failed" } }, ++ sandbox: { ++ kind: "planned", ++ plan: { provider: "docker", image: null, snapshot: null }, ++ }, ++ }), ++ sandboxState: null, ++ sandboxResources: null, ++ }); ++ ++ expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe("Not created"); ++ }); ++ ++ test("renders sandbox lifecycle state before details are available", () => { ++ const tree = render({ ++ run: makeRun({ ++ sandbox: { ++ kind: "initializing", ++ plan: { provider: "docker", image: null, snapshot: null }, ++ }, ++ }), ++ sandboxState: null, ++ sandboxResources: null, ++ }); ++ ++ expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe("Initializing"); ++ }); ++ ++ test("renders failed sandbox lifecycle error before details are available", () => { ++ const tree = render({ ++ run: makeRun({ ++ sandbox: { ++ kind: "failed", ++ plan: { provider: "docker", image: null, snapshot: null }, ++ failure: { ++ provider: "docker", ++ error: "Docker daemon unavailable", ++ causes: [], ++ duration_ms: 42, ++ }, ++ }, ++ }), ++ sandboxState: null, ++ sandboxResources: null, ++ }); ++ ++ expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe("Failed"); ++ }); ++ + test("shows unavailable copy when artifacts count is zero", () => { + const tree = render({ run: makeRun(), artifactsCount: 0 }); + expect(instanceText(cellAfterLabel(tree, "Artifacts"))).toBe(EMPTY_VALUE); +diff --git a/apps/fabro-web/app/components/run-summary-panel.tsx b/apps/fabro-web/app/components/run-summary-panel.tsx +index 5d9c78ce4..20a08af5b 100644 +--- a/apps/fabro-web/app/components/run-summary-panel.tsx ++++ b/apps/fabro-web/app/components/run-summary-panel.tsx +@@ -13,6 +13,11 @@ import { + } from "../lib/format"; + import { principalDisplay } from "../lib/principal-display"; + import { useRun, useRunArtifacts, useRunSandboxDetails } from "../lib/queries"; ++import { ++ SANDBOX_LIFECYCLE_DISPLAY, ++ sandboxIsReady, ++ sandboxLifecycleKind, ++} from "../lib/run-sandbox-lifecycle"; + import { SANDBOX_STATE_DISPLAY } from "../lib/sandbox-state"; + import { Tooltip } from "./ui"; + +@@ -83,6 +88,25 @@ function SandboxValue({ + ); + } + ++function SandboxLifecycleValue({ ++ kind, ++}: { ++ kind: keyof typeof SANDBOX_LIFECYCLE_DISPLAY; ++}) { ++ const display = SANDBOX_LIFECYCLE_DISPLAY[kind]; ++ return ( ++
++ ++ ++ {display.label} ++
++ ); ++} ++ + export function RunSummaryPanelView({ + run, + runLoading, +@@ -95,6 +119,7 @@ export function RunSummaryPanelView({ + const created = run?.created_by ? principalDisplay(run.created_by) : null; + const diff = run?.diff ?? null; + const cost = formatUsdMicros(run?.billing?.total_usd_micros); ++ const sandboxKind = sandboxLifecycleKind(run?.sandbox); + + return ( +
+@@ -135,6 +160,8 @@ export function RunSummaryPanelView({ + + ) : sandboxState ? ( + ++ ) : sandboxKind ? ( ++ + ) : ( + + )} +@@ -177,8 +204,11 @@ export function RunSummaryPanelView({ + + export function RunSummaryPanel({ runId }: { runId: string }) { + const runQuery = useRun(runId); +- const sandboxQuery = useRunSandboxDetails(runId); ++ const sandboxQuery = useRunSandboxDetails( ++ sandboxIsReady(runQuery.data?.sandbox) ? runId : undefined, ++ ); + const artifactsQuery = useRunArtifacts(runId); ++ const sandboxReady = sandboxIsReady(runQuery.data?.sandbox); + + return ( + +diff --git a/apps/fabro-web/app/components/terminal-view-helpers.ts b/apps/fabro-web/app/components/terminal-view-helpers.ts +index ce49f8117..7c3023736 100644 +--- a/apps/fabro-web/app/components/terminal-view-helpers.ts ++++ b/apps/fabro-web/app/components/terminal-view-helpers.ts +@@ -1,4 +1,5 @@ + import type { RunSandbox } from "@qltysh/fabro-api-client"; ++import { sandboxInstance, sandboxRuntime } from "../lib/run-sandbox-lifecycle"; + + export const TERMINAL_DOCK_CLEARANCE_CLASS = + "pb-[calc(0.125rem+var(--fabro-interview-dock-clearance,0px))]"; +@@ -40,5 +41,6 @@ export function terminalAccessCommandLabel(provider: string | null): string | nu + } + + export function sandboxStatusDetail(sandbox: RunSandbox | null | undefined): string | null { +- return sandbox?.runtime?.id ?? sandbox?.provider ?? null; ++ const instance = sandboxInstance(sandbox); ++ return sandboxRuntime(sandbox)?.id ?? instance?.provider ?? null; + } +diff --git a/apps/fabro-web/app/components/terminal-view.test.ts b/apps/fabro-web/app/components/terminal-view.test.ts +index ceff00617..6e154b698 100644 +--- a/apps/fabro-web/app/components/terminal-view.test.ts ++++ b/apps/fabro-web/app/components/terminal-view.test.ts +@@ -78,7 +78,11 @@ describe("terminal view helpers", () => { + image: null, + snapshot: null, + runtime: null, +- })).toBe("docker"); ++ })).toBeNull(); ++ expect(sandboxStatusDetail({ ++ kind: "planned", ++ plan: { provider: "docker" }, ++ })).toBeNull(); + expect(sandboxStatusDetail(null)).toBeNull(); + }); + }); +diff --git a/apps/fabro-web/app/components/terminal-view.tsx b/apps/fabro-web/app/components/terminal-view.tsx +index 89fb91352..f4beb4142 100644 +--- a/apps/fabro-web/app/components/terminal-view.tsx ++++ b/apps/fabro-web/app/components/terminal-view.tsx +@@ -15,6 +15,7 @@ import { ErrorState } from "./state"; + import { useToast } from "./toast"; + import { apiData, humanInTheLoopApi } from "../lib/api-client"; + import { useRunState } from "../lib/queries"; ++import { sandboxInstance } from "../lib/run-sandbox-lifecycle"; + import { + buildFullScreenTerminalUrl, + sandboxStatusDetail, +@@ -107,7 +108,7 @@ export default function TerminalView({ + const { push } = useToast(); + const stateQuery = useRunState(runId); + const sandbox = stateQuery.data?.sandbox ?? null; +- const provider = sandbox?.provider ?? null; ++ const provider = sandboxInstance(sandbox)?.provider ?? null; + const sandboxDetail = sandboxStatusDetail(sandbox); + const accessCommandLabel = terminalAccessCommandLabel(provider); + const [connectionKey, reconnectTerminal] = useReducer((key: number) => key + 1, 0); +diff --git a/apps/fabro-web/app/data/runs.ts b/apps/fabro-web/app/data/runs.ts +index f820b24c1..2b277b6d0 100644 +--- a/apps/fabro-web/app/data/runs.ts ++++ b/apps/fabro-web/app/data/runs.ts +@@ -6,6 +6,7 @@ import { + type RunSize, + type RunStatus as ApiRunStatus, + } from "@qltysh/fabro-api-client"; ++import { sandboxRuntime } from "../lib/run-sandbox-lifecycle"; + + export type CiStatus = "passing" | "failing" | "pending"; + +@@ -89,7 +90,7 @@ function runStatusKind(status: ApiRunStatus | null | undefined): RunStatus | nul + + export function mapRunListItem(item: Run): RunItem { + const lifecycleStatus = item.lifecycle.archived ? "archived" : runStatusKind(item.lifecycle.status); +- const runtime = item.sandbox?.runtime; ++ const runtime = sandboxRuntime(item.sandbox); + return { + id: item.id, + repo: displayRepoName(item.repository?.name ?? "unknown"), +diff --git a/apps/fabro-web/app/lib/run-sandbox-lifecycle.ts b/apps/fabro-web/app/lib/run-sandbox-lifecycle.ts +new file mode 100644 +index 000000000..462e39672 +--- /dev/null ++++ b/apps/fabro-web/app/lib/run-sandbox-lifecycle.ts +@@ -0,0 +1,91 @@ ++import type { ++ Run, ++ RunProjection, ++ RunSandbox, ++ RunSandboxInstance, ++ RunSandboxKind, ++ RunSandboxRuntime, ++} from "@qltysh/fabro-api-client"; ++ ++export type SandboxLifecycleKind = RunSandboxKind; ++ ++export type MaybeSandbox = Run["sandbox"] | RunProjection["sandbox"] | null | undefined; ++ ++export const SANDBOX_LIFECYCLE_DISPLAY: Record< ++ SandboxLifecycleKind, ++ { label: string; description: string; dot: string; text: string } ++> = { ++ planned: { ++ label: "Not created", ++ description: "The sandbox instance was not created.", ++ dot: "bg-fg-muted", ++ text: "text-fg-muted", ++ }, ++ initializing: { ++ label: "Initializing", ++ description: "The sandbox is being created.", ++ dot: "bg-amber", ++ text: "text-amber", ++ }, ++ ready: { ++ label: "Ready", ++ description: "The sandbox instance is available.", ++ dot: "bg-teal-500", ++ text: "text-teal-500", ++ }, ++ failed: { ++ label: "Failed", ++ description: "Sandbox creation failed.", ++ dot: "bg-coral", ++ text: "text-coral", ++ }, ++}; ++ ++export function sandboxLifecycleKind( ++ sandbox: MaybeSandbox, ++): SandboxLifecycleKind | null { ++ if (!sandbox) return null; ++ const value = sandbox as RunSandbox & { ++ provider?: unknown; ++ runtime?: unknown; ++ }; ++ if (value.kind) return value.kind as SandboxLifecycleKind; ++ return value.runtime ? "ready" : "planned"; ++} ++ ++export function sandboxInstance( ++ sandbox: MaybeSandbox, ++): RunSandboxInstance | null { ++ if (!sandbox) return null; ++ const value = sandbox as RunSandbox & { ++ provider?: RunSandboxInstance["provider"]; ++ image?: string | null; ++ snapshot?: string | null; ++ runtime?: RunSandboxRuntime | null; ++ }; ++ if (value.instance) return value.instance; ++ if (value.runtime && value.provider) { ++ return { ++ provider: value.provider, ++ image: value.image ?? null, ++ snapshot: value.snapshot ?? null, ++ runtime: value.runtime, ++ }; ++ } ++ return null; ++} ++ ++export function sandboxRuntime( ++ sandbox: MaybeSandbox, ++): RunSandboxRuntime | null { ++ return sandboxInstance(sandbox)?.runtime ?? null; ++} ++ ++export function sandboxTabVisible(sandbox: MaybeSandbox): boolean { ++ const kind = sandboxLifecycleKind(sandbox); ++ return kind === "initializing" || kind === "ready" || kind === "failed"; ++} ++ ++export function sandboxIsReady(sandbox: MaybeSandbox): boolean { ++ return sandboxLifecycleKind(sandbox) === "ready" && sandboxInstance(sandbox) != null; ++} +diff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts +index e30d38da8..844321220 100644 +--- a/apps/fabro-web/app/routes/run-detail.test.ts ++++ b/apps/fabro-web/app/routes/run-detail.test.ts +@@ -664,8 +664,79 @@ describe("RunDetail full-height child routes", () => { + expect(navigated).toEqual(["/runs/run_retry"]); + }); + +- test("shows the Sandbox tab when the run has a sandbox", async () => { +- currentRunState = { sandbox: { provider: "docker", id: "container-1" } }; ++ test("hides the Sandbox tab for a planned sandbox without an instance", async () => { ++ currentRunState = { ++ sandbox: { ++ kind: "planned", ++ plan: { provider: "docker", image: null, snapshot: null }, ++ }, ++ }; ++ const renderer = await renderRunDetail({ ++ initialEntry: "/runs/run_1", ++ }); ++ ++ const sandboxLinks = renderer.root.findAll( ++ (node) => ++ node.type === "a" && ++ node.props.href === "/runs/run_1/sandbox", ++ ); ++ expect(sandboxLinks).toHaveLength(0); ++ }); ++ ++ for (const kind of ["initializing", "ready", "failed"] as const) { ++ test(`shows the Sandbox tab for ${kind} sandbox state`, async () => { ++ currentRunState = { ++ sandbox: { ++ kind, ++ plan: { provider: "docker", image: null, snapshot: null }, ++ instance: kind === "ready" ++ ? { ++ provider: "docker", ++ image: null, ++ snapshot: null, ++ runtime: { ++ id: "container-1", ++ working_directory: "/workspace", ++ repo_cloned: null, ++ clone_origin_url: null, ++ clone_branch: null, ++ }, ++ } ++ : undefined, ++ failure: kind === "failed" ++ ? { ++ provider: "docker", ++ error: "Docker daemon unavailable", ++ causes: [], ++ duration_ms: 42, ++ } ++ : undefined, ++ }, ++ }; ++ const renderer = await renderRunDetail({ ++ initialEntry: "/runs/run_1", ++ }); ++ ++ const sandboxLinks = renderer.root.findAll( ++ (node) => ++ node.type === "a" && ++ node.props.href === "/runs/run_1/sandbox" && ++ node.children.includes("Sandbox"), ++ ); ++ expect(sandboxLinks).toHaveLength(1); ++ }); ++ } ++ ++ test("shows the Sandbox tab for legacy sandbox state with runtime metadata", async () => { ++ currentRunState = { ++ sandbox: { ++ provider: "docker", ++ runtime: { ++ id: "container-1", ++ working_directory: "/workspace", ++ }, ++ }, ++ }; + const renderer = await renderRunDetail({ + initialEntry: "/runs/run_1", + }); +diff --git a/apps/fabro-web/app/routes/run-detail/header.tsx b/apps/fabro-web/app/routes/run-detail/header.tsx +index a8293585d..efc68f09b 100644 +--- a/apps/fabro-web/app/routes/run-detail/header.tsx ++++ b/apps/fabro-web/app/routes/run-detail/header.tsx +@@ -36,6 +36,7 @@ import { + formatRelativeTime, + } from "../../lib/format"; + import { useRunPullRequest } from "../../lib/queries"; ++import { sandboxRuntime } from "../../lib/run-sandbox-lifecycle"; + import { ActionsMenu, type ActionsMenuProps } from "./actions"; + import { classNames, type RunDetailRun } from "./model"; + +@@ -135,7 +136,7 @@ export function RunDetailHeader({ + content={ + + } + > +diff --git a/apps/fabro-web/app/routes/run-detail/tabs-shell.tsx b/apps/fabro-web/app/routes/run-detail/tabs-shell.tsx +index fc047e4b1..cca7630bd 100644 +--- a/apps/fabro-web/app/routes/run-detail/tabs-shell.tsx ++++ b/apps/fabro-web/app/routes/run-detail/tabs-shell.tsx +@@ -1,5 +1,6 @@ + import { Link, Outlet, type UIMatch } from "react-router"; + ++import { sandboxTabVisible, type MaybeSandbox } from "../../lib/run-sandbox-lifecycle"; + import { classNames } from "./model"; + + interface RunDetailTabDefinition { +@@ -21,12 +22,10 @@ const allTabs: RunDetailTabDefinition[] = [ + export type RunDetailTab = RunDetailTabDefinition; + + export function runHasSandbox(runState: unknown): boolean { +- return !!( +- runState && +- typeof runState === "object" && +- "sandbox" in runState && +- (runState as { sandbox?: unknown }).sandbox +- ); ++ if (!runState || typeof runState !== "object" || !("sandbox" in runState)) { ++ return false; ++ } ++ return sandboxTabVisible((runState as { sandbox?: MaybeSandbox }).sandbox); + } + + export function buildRunDetailTabs({ +diff --git a/apps/fabro-web/app/routes/run-sandbox.test.tsx b/apps/fabro-web/app/routes/run-sandbox.test.tsx +index 915330d29..958d0a217 100644 +--- a/apps/fabro-web/app/routes/run-sandbox.test.tsx ++++ b/apps/fabro-web/app/routes/run-sandbox.test.tsx +@@ -6,10 +6,25 @@ import { MemoryRouter, Route, Routes } from "react-router"; + import type { SandboxDetails } from "@qltysh/fabro-api-client"; + + let currentDetails: SandboxDetails | null = null; ++let currentRunState: any = null; + let currentLoading = false; + let currentError: Error | null = null; + + mock.module("../lib/queries", () => ({ ++ useRun: () => ({ ++ data: null, ++ error: null, ++ isLoading: false, ++ isValidating: false, ++ mutate: mock(() => Promise.resolve(null)), ++ }), ++ useRunState: () => ({ ++ data: currentRunState, ++ error: null, ++ isLoading: false, ++ isValidating: false, ++ mutate: mock(() => Promise.resolve(currentRunState)), ++ }), + useRunSandboxDetails: () => ({ + data: currentDetails, + error: currentError, +@@ -95,14 +110,15 @@ function sandboxDetails( + } = {}, + ): SandboxDetails { + const sandbox = overrides.sandbox ?? {}; ++ const { sandbox: _sandboxOverride, ...detailOverrides } = overrides; + return { + sandbox: { + provider: "docker", + image: null, + snapshot: null, + runtime: { +- id: null, +- working_directory: null, ++ id: "", ++ working_directory: "", + repo_cloned: null, + clone_origin_url: null, + clone_branch: null, +@@ -117,7 +133,7 @@ function sandboxDetails( + network: networkDetails(), + labels: {}, + timestamps: { created_at: null, last_activity_at: null }, +- ...overrides, ++ ...detailOverrides, + }; + } + +@@ -166,6 +182,7 @@ afterEach(() => { + act(() => renderer.unmount()); + } + currentDetails = null; ++ currentRunState = null; + currentLoading = false; + currentError = null; + }); +@@ -352,7 +369,52 @@ describe("RunSandbox route", () => { + Array.isArray(node.children) && + node.children.includes("No sandbox"), + ); +- expect(titles).toHaveLength(1); ++ expect(titles).toHaveLength(2); ++ }); ++ ++ test("renders a planned sandbox as not created without controls", () => { ++ currentRunState = { ++ sandbox: { ++ kind: "planned", ++ plan: { provider: "docker", image: null, snapshot: null }, ++ }, ++ }; ++ currentDetails = null; ++ currentError = new Error("Run sandbox was not created."); ++ const renderer = renderRoute(); ++ ++ expect(textContent(renderer)).toContain("Not created"); ++ const tabs = renderer.root.findAll( ++ (node) => node.type === "button" && node.props.role === "tab", ++ ); ++ expect(tabs).toHaveLength(0); ++ }); ++ ++ test("renders a failed sandbox lifecycle without service or file controls", () => { ++ currentRunState = { ++ sandbox: { ++ kind: "failed", ++ plan: { provider: "docker", image: null, snapshot: null }, ++ failure: { ++ provider: "docker", ++ error: "Docker daemon unavailable", ++ causes: ["connection refused"], ++ duration_ms: 42, ++ }, ++ }, ++ }; ++ currentDetails = null; ++ currentError = new Error("Run sandbox was not created."); ++ const renderer = renderRoute("/runs/run_1/sandbox?mode=services"); ++ ++ const copy = textContent(renderer); ++ expect(copy).toContain("Failed"); ++ expect(copy).toContain("Docker daemon unavailable"); ++ expect(copy).toContain("connection refused"); ++ const tabs = renderer.root.findAll( ++ (node) => node.type === "button" && node.props.role === "tab", ++ ); ++ expect(tabs).toHaveLength(0); + }); + + test("Terminal is the default right-column mode", () => { +diff --git a/apps/fabro-web/app/routes/run-sandbox.tsx b/apps/fabro-web/app/routes/run-sandbox.tsx +index 09ce2d2c4..d190bd5e0 100644 +--- a/apps/fabro-web/app/routes/run-sandbox.tsx ++++ b/apps/fabro-web/app/routes/run-sandbox.tsx +@@ -10,9 +10,17 @@ import { + formatBytesAsMemory, + formatCpuCores, + } from "../lib/format"; +-import { useRunSandboxDetails } from "../lib/queries"; ++import { useRun, useRunSandboxDetails, useRunState } from "../lib/queries"; ++import { ++ SANDBOX_LIFECYCLE_DISPLAY, ++ sandboxInstance, ++ sandboxIsReady, ++ sandboxLifecycleKind, ++ sandboxRuntime, ++} from "../lib/run-sandbox-lifecycle"; + import { SANDBOX_STATE_DISPLAY } from "../lib/sandbox-state"; + import type { ++ RunSandbox, + SandboxDetails, + SandboxNetwork, + SandboxResources, +@@ -259,9 +267,51 @@ function DetailsColumn({ details }: { details: SandboxDetails | null }) { + ); + } + ++function SandboxLifecycleStateView({ ++ sandbox, ++ compact = false, ++}: { ++ sandbox: RunSandbox | null | undefined; ++ compact?: boolean; ++}) { ++ const kind = sandboxLifecycleKind(sandbox); ++ const failure = sandbox?.failure ?? null; ++ const display = kind ? SANDBOX_LIFECYCLE_DISPLAY[kind] : null; ++ const title = display?.label ?? "No sandbox"; ++ const description = ++ kind === "planned" ++ ? "Run sandbox was not created." ++ : kind === "failed" ++ ? failure?.error ?? display?.description ++ : display?.description ++ ?? "This run has no sandbox or its provider does not expose details."; ++ ++ const action = failure?.causes?.length ? ( ++
++ {failure.causes.map((cause) => ( ++

{cause}

++ ))} ++
++ ) : null; ++ ++ return ; ++} ++ + export default function RunSandbox({ params }: { params: { id: string } }) { +- const sandboxQuery = useRunSandboxDetails(params.id); +- const provider = sandboxQuery.data?.sandbox.provider ?? null; ++ const runStateQuery = useRunState(params.id); ++ const runQuery = useRun(params.id); ++ const lifecycleSandbox = runStateQuery.data?.sandbox ?? runQuery.data?.sandbox ?? null; ++ const lifecycleReady = sandboxIsReady(lifecycleSandbox); ++ const lifecycleSourcesLoading = runStateQuery.isLoading || runQuery.isLoading; ++ const shouldLoadDetails = ++ lifecycleReady || (!lifecycleSandbox && !lifecycleSourcesLoading); ++ const sandboxQuery = useRunSandboxDetails(shouldLoadDetails ? params.id : undefined); ++ const details = sandboxQuery.data ?? null; ++ const provider = ++ details?.sandbox.provider ++ ?? sandboxInstance(lifecycleSandbox)?.provider ++ ?? null; ++ const ready = lifecycleReady || !!details; + const [searchParams, setSearchParams] = useSearchParams(); + const requestedMode = useMemo( + () => normalizeSandboxMode(searchParams.get("mode")), +@@ -288,14 +338,14 @@ export default function RunSandbox({ params }: { params: { id: string } }) { + }, [setSearchParams]); + + const modeToggle = useMemo( +- () => ( ++ () => ready ? ( + +- ), +- [mode, provider, setMode], ++ ) : null, ++ [mode, provider, ready, setMode], + ); + + // The outer flex spans from the tab bar's bottom border down to the +@@ -307,7 +357,9 @@ export default function RunSandbox({ params }: { params: { id: string } }) { + +
+
+- {(() => { ++ {!ready ? ( ++
++ ++
++ ) : (() => { + if (mode === "terminal") { + return ; + } +@@ -332,7 +388,10 @@ export default function RunSandbox({ params }: { params: { id: string } }) { + return ; + } + if (mode === "filesystem") { +- const rootDirectory = sandboxQuery.data?.sandbox.runtime?.working_directory ?? null; ++ const rootDirectory = ++ details?.sandbox?.runtime?.working_directory ++ ?? sandboxRuntime(lifecycleSandbox)?.working_directory ++ ?? null; + return ( + ` / `Bytes` body extractors (a `Parts`-taking helper would force full-`Request` extraction everywhere and break body handling). Each extractor returns the already-parsed run-id (and secondary path params) so handlers drop their own `Path` + `parse_*` dance. Replaces `_auth: AuthenticatedService` and the existing `authorize_artifact_upload` inline call. One fall-through behavior (worker token first, else user JWT) shared across all three. `authorize_worker_token` remains a `pub(crate)` internal helper used by the extractors. | +-| Env scrubbing at two sites: `LocalSandbox::execute` (stage execution) and host-mode hooks | Stage commands run user-supplied code → MUST NOT see `FABRO_WORKER_TOKEN`. `LocalSandbox::execute` filters both inherited env AND the explicit `env_vars` extras path (today's code appends extras AFTER the filter — defense-in-depth gap this plan closes). Host-mode hooks get a targeted `env_remove("FABRO_WORKER_TOKEN")` (shell commands have no business reading the worker token, even when operator-configured). Trusted internal subprocesses (`gh auth token`, MCP server stdio, devcontainer setup, git) are NOT scrubbed — they may legitimately need credentials, and they aren't user-attack surfaces. | ++| Env scrubbing at two sites: `LocalSandbox::execute` (stage execution) and host-mode hooks | Stage commands run user-supplied code → MUST NOT see `FABRO_WORKER_TOKEN`. `LocalSandbox::execute` filters both inherited env AND the explicit `env_vars` extras path (today's code appends extras AFTER the filter — defense-in-depth gap this plan closes). Host-mode hooks get a targeted `env_remove("FABRO_WORKER_TOKEN")` (shell commands have no business reading the worker token, even when operator-configured). Trusted internal subprocesses (`gh auth token`, MCP server stdio, git) are NOT scrubbed — they may legitimately need credentials, and they aren't user-attack surfaces. | + | `authorize_worker_token` lives in `worker_token.rs` and takes `&WorkerTokenKeys` directly (NOT `&AppState`) | Sibling modules can't access private `AppState` fields. Mirroring `maybe_authorize_artifact_upload_token`'s signature (which already takes the typed keys) keeps the helper testable without a fixture `AppState`. The thin `authorize_run_scoped(parts, state, run_id)` adapter lives where it can see `AppState` and pulls `&state.worker_tokens` into the call. | + | Missing/invalid `FABRO_WORKER_TOKEN` → worker errors at startup with a clear message; mid-run 401/403 flow through normal client error handling | No special exit codes. Distinct operational telemetry isn't worth the machinery for the current scale. | + +@@ -140,7 +140,7 @@ These are the **only** routes that gain worker-token acceptance. Lifecycle/admin + - New `RunAuthMethod::Worker` variant: no — worker token bypasses `AuthenticatedSubject` entirely. + - Stamp worker events server-side vs. worker-side: worker-side, in a dedicated sink wrapper inside the worker's `RunEventSink::fanout` chain. + - Multi-token-per-run on rapid pause/resume: accept and document. Each prior token remains valid up to 72h `exp`. Bounded by run-id; out-of-scope to fix here. +-- Env scrubbing scope: workflow stage-execution chokepoint at `LocalSandbox::execute` (inherited env + explicit `env_vars` extras) AND host-mode hooks at `fabro-hooks/src/executor.rs`. Trusted internal subprocesses (`gh auth token`, MCP stdio, devcontainer features, git) are not scrubbed. ++- Env scrubbing scope: workflow stage-execution chokepoint at `LocalSandbox::execute` (inherited env + explicit `env_vars` extras) AND host-mode hooks at `fabro-hooks/src/executor.rs`. Trusted internal subprocesses (`gh auth token`, MCP stdio, git) are not scrubbed. + - Auth-failure exit codes: no — generic error handling. + + ### Deferred to Implementation +@@ -407,7 +407,7 @@ The spawn site that runs user-supplied workflow stage commands must NOT see `FAB + + - Modify: `lib/crates/fabro-hooks/src/executor.rs:186` — `cmd.env_remove("FABRO_WORKER_TOKEN")` (and the same six server-secret names listed above) on host-mode hook spawns. Targeted, defense-in-depth. Hooks remain operator-trusted; this just keeps the worker token out of their env. + +-**Out of scope for env scrubbing:** trusted internal subprocesses that run server-controlled code and may legitimately need credentials in their env: `gh auth token` (`fabro-github/src/lib.rs:129`), MCP server stdio (`fabro-mcp/src/client.rs:47`), devcontainer features (`fabro-devcontainer/src/features.rs:67-199`), git (`fabro-workflow/src/git.rs:35`). These are not user-attack surfaces. Do NOT scrub them. ++**Out of scope for env scrubbing:** trusted internal subprocesses that run server-controlled code and may legitimately need credentials in their env: `gh auth token` (`fabro-github/src/lib.rs:129`), MCP server stdio (`fabro-mcp/src/client.rs:47`), git (`fabro-workflow/src/git.rs:35`). These are not user-attack surfaces. Do NOT scrub them. + + **Approach:** + - `connect_server_target_with_bearer` is the smallest possible surface: it skips the `AuthStore`/`OAuthSession` machinery entirely. The user-facing `connect_server_target` and `connect_server_with_settings` are unchanged. +@@ -535,7 +535,7 @@ The spawn site that runs user-supplied workflow stage commands must NOT see `FAB + | `FABRO_WORKER_TOKEN` readable via `/proc//environ` to same-UID processes on Linux | Documented in Threat Model: env-var transport does not protect against same-UID reads. Multi-tenant deployments must isolate per-tenant via separate UIDs / containers. NOT a property of this design. | + | Workflow stage child processes (sandbox-executed Bash) inherit `FABRO_WORKER_TOKEN` via env or via explicitly-supplied `env_vars` extras | `LocalSandbox::execute` filters BOTH the inherited env (existing safelist + denylist) AND the `env_vars` extras path (new in Unit 4). Two regression tests prove both paths. | + | Host-mode hook commands inherit `FABRO_WORKER_TOKEN` | `fabro-hooks/src/executor.rs` does targeted `cmd.env_remove("FABRO_WORKER_TOKEN")` (and the same six server-secret names) on host-mode hook spawns. Hooks remain operator-trusted; this is defense-in-depth — shell commands have no business reading the worker token. | +-| Trusted internal subprocesses (`gh`, MCP, devcontainer features, git) inherit env including `FABRO_WORKER_TOKEN` | NOT scrubbed by design — these run server-controlled code, may legitimately need credentials, and are not user-attack surfaces. Documented in Unit 4. | ++| Trusted internal subprocesses (`gh`, MCP, git) inherit env including `FABRO_WORKER_TOKEN` | NOT scrubbed by design — these run server-controlled code, may legitimately need credentials, and are not user-attack surfaces. Documented in Unit 4. | + | `client.upload_stage_artifact_*` API requires a per-call bearer parameter | Per Unit 4: `HttpArtifactUploader` holds the token in a `worker_token: String` field (same string read from `FABRO_WORKER_TOKEN`) and threads it per call. No `Client::credential()` accessor today; per-call threading is the path of least churn. | + | Same-run concurrent worker spawn (scheduler race) → two valid tokens for one `run_id` racing on event/state appends | Scheduler's at-most-one-worker-per-run guarantee is assumed but not verified by this plan. If a race exists today, follow-up plan adds a server-side spawn lock or a per-spawn nonce. Out of scope here. | + | Rapid pause/resume cycles leave multiple valid tokens per run | Each prior worker token remains valid up to its 72h `exp`. Multiplicative compromise window bounded by run-id. Accepted; out of scope to fix here. | +diff --git a/docs/plans/2026-04-25-001-refactor-docker-sandbox-clone-based-plan.md b/docs/plans/2026-04-25-001-refactor-docker-sandbox-clone-based-plan.md +index a216c369b..8aa3b9aaf 100644 +--- a/docs/plans/2026-04-25-001-refactor-docker-sandbox-clone-based-plan.md ++++ b/docs/plans/2026-04-25-001-refactor-docker-sandbox-clone-based-plan.md +@@ -129,7 +129,7 @@ These calls live in `lib/crates/fabro-workflow/src/pipeline/initialize.rs:77-204 + - **Docker socket permission management.** No GID shim, `group_add` automation, or Docker Desktop-specific setup logic in Fabro. + - **Non-GitHub clone origins.** GitLab, Bitbucket, arbitrary SSH/HTTPS remotes, and generic credentials are follow-up work. With `skip_clone = false`, present non-GitHub origins fail clearly. With `skip_clone = true`, the provider creates an empty workspace as an escape hatch, but repository files are not present. + - **Exact-SHA execution.** Branch-based clone behavior matches Daytona's current model. Optional submitted-SHA pinning is a follow-up. +-- **Devcontainer integration with Docker provider.** Today's devcontainer code resolves config against the host filesystem before sandbox init; the clone-only model breaks that. Devcontainer-mode runs require a follow-up plan that resolves devcontainer config against the cloned `/workspace`. ++- **Repository-derived setup with Docker provider.** Host-resolved setup metadata would break under the clone-only model. Any repository-derived setup must resolve against the cloned `/workspace`. + - **Auto-fallback to local when Docker is unreachable.** If `connect_with_local_defaults()` fails, the run fails with the Docker connection error. + - **Named-volume copy-from-host as an alternative to clone.** Evaluated and rejected: it reintroduces host-CLI / server-Docker coupling. + - **Real DinD nesting.** Socket-mounted sibling containers through the host daemon are sufficient for self-hosting. +diff --git a/docs/plans/2026-05-03-fix-agent-stage-cancellation-plan.md b/docs/plans/2026-05-03-fix-agent-stage-cancellation-plan.md +index 51c961f80..551d3485b 100644 +--- a/docs/plans/2026-05-03-fix-agent-stage-cancellation-plan.md ++++ b/docs/plans/2026-05-03-fix-agent-stage-cancellation-plan.md +@@ -29,7 +29,7 @@ + - Update `RunServices::new(...)` and add a doc comment: production construction is expected to happen from pipeline initialization with the run's root token; use `with_cancel_token(...)` only with the same root token or a `child_token()` derived from it. + - Make `with_cancel_token(token: CancellationToken)` `pub(crate)`. It must document that the token semantically means "cancel this run or child run," not a generic shutdown signal. + - Update `lib/crates/fabro-workflow/src/pipeline/execute.rs` to pass `run_options.cancel_token.clone()` into `ExecutorBuilder::cancel_token(...)`. +- - Update setup/devcontainer paths in `lib/crates/fabro-workflow/src/pipeline/initialize.rs` and `lib/crates/fabro-workflow/src/devcontainer_bridge.rs` to pass `Some(run_options.cancel_token.child_token())` into sandbox commands instead of creating a new bridge from an atomic. ++ - Update setup paths in `lib/crates/fabro-workflow/src/pipeline/initialize.rs` to pass `Some(run_options.cancel_token.child_token())` into sandbox commands instead of creating a new bridge from an atomic. + - Update `lib/crates/fabro-workflow/src/handler/command.rs` to pass `Some(services.run.cancel_token().child_token())` into `exec_command_streaming` instead of calling `services.run.sandbox_cancel_token()`. + - Do not wire stall timeout into the run cancel token. If `lib/crates/fabro-core/src/stall.rs` is migrated away from `Arc`, give it a field named `stall_token: CancellationToken` and call `stall_token.cancel()` on timeout. The executor must continue racing node execution against `ExecutorOptions.stall_token` and returning `Error::StallTimeout { node_id }` from that select branch. + - Update CLI and server run entry points (`lib/crates/fabro-cli/src/commands/run/runner.rs`, `lib/crates/fabro-server/src/server.rs`, `lib/crates/fabro-workflow/src/operations/start.rs`) to create/store/cancel `CancellationToken` directly. `StartServices.cancel_token` and `RunSession.cancel_token` must become non-optional `CancellationToken` fields; managed server run state and CLI worker-control/signal handlers must use `CancellationToken`; places that currently call `load(Ordering::SeqCst)` must use `token.is_cancelled()`. +diff --git a/docs/plans/2026-05-21-wall-and-active-time-metrics-plan.md b/docs/plans/2026-05-21-wall-and-active-time-metrics-plan.md +index 055ae856c..90438d3b5 100644 +--- a/docs/plans/2026-05-21-wall-and-active-time-metrics-plan.md ++++ b/docs/plans/2026-05-21-wall-and-active-time-metrics-plan.md +@@ -46,7 +46,7 @@ visits. Parallel work is summed, so run active time can exceed run wall time. + not `runtime_secs`. + - Keep `duration_ms` only for unrelated subsystem-specific operational events + where the name is still local and unambiguous, such as sandbox setup, +- metadata snapshot, devcontainer lifecycle, and hook execution. The cleanup ++ metadata snapshot, setup commands, and hook execution. The cleanup + target is public run/stage runtime semantics. + - Update OpenAPI and regenerate the Rust and TypeScript API clients after + schema edits. +diff --git a/docs/plans/2026-05-22-002-remove-session-sandboxes-feature-flag-plan.md b/docs/plans/2026-05-22-002-remove-session-sandboxes-feature-flag-plan.md +index 1c8dc9f82..d6b07f9f6 100644 +--- a/docs/plans/2026-05-22-002-remove-session-sandboxes-feature-flag-plan.md ++++ b/docs/plans/2026-05-22-002-remove-session-sandboxes-feature-flag-plan.md +@@ -23,7 +23,7 @@ Remove the `session_sandboxes` feature flag and the now-empty `[features]` setti + - Regenerate Rust API types and TypeScript Axios client. + - Update current docs: + - Remove `[features]` from active configuration docs, generated options docs, API docs, and unknown-key guidance. +- - Do not touch unrelated meanings of "features" such as Cargo features, LLM model features, or devcontainer features. ++ - Do not touch unrelated meanings of "features" such as Cargo features or LLM model features. + + ## Test Plan + +diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml +index 67d8e5830..be6931e70 100644 +--- a/docs/public/api-reference/fabro-api.yaml ++++ b/docs/public/api-reference/fabro-api.yaml +@@ -10438,13 +10438,55 @@ components: + - docker + - daytona + ++ RunSandboxKind: ++ description: Lifecycle state for a run sandbox request. ++ type: string ++ enum: ++ - planned ++ - initializing ++ - ready ++ - failed ++ ++ RunSandboxPlan: ++ description: Requested sandbox provider and base image/snapshot from run settings. ++ type: object ++ required: ++ - provider ++ properties: ++ provider: ++ $ref: "#/components/schemas/SandboxProviderKind" ++ image: ++ type: ["string", "null"] ++ snapshot: ++ type: ["string", "null"] ++ + RunSandbox: +- description: Canonical sandbox environment record for a run. ++ description: Sandbox lifecycle record for a run. A run can have a requested sandbox plan before it has an initialized sandbox instance. ++ type: object ++ required: ++ - kind ++ - plan ++ properties: ++ kind: ++ $ref: "#/components/schemas/RunSandboxKind" ++ plan: ++ $ref: "#/components/schemas/RunSandboxPlan" ++ instance: ++ oneOf: ++ - $ref: "#/components/schemas/RunSandboxInstance" ++ - type: "null" ++ description: Present only when `kind` is `ready`. ++ failure: ++ oneOf: ++ - $ref: "#/components/schemas/RunSandboxFailure" ++ - type: "null" ++ description: Present only when `kind` is `failed`. ++ ++ RunSandboxInstance: ++ description: Initialized sandbox provider and runtime metadata. + type: object + required: + - provider +- - image +- - snapshot + - runtime + properties: + provider: +@@ -10454,9 +10496,30 @@ components: + snapshot: + type: ["string", "null"] + runtime: +- oneOf: +- - $ref: "#/components/schemas/RunSandboxRuntime" +- - type: "null" ++ $ref: "#/components/schemas/RunSandboxRuntime" ++ ++ RunSandboxFailure: ++ description: Sandbox initialization failure details. ++ type: object ++ required: ++ - provider ++ - error ++ - causes ++ - duration_ms ++ properties: ++ provider: ++ type: string ++ description: Provider reported by the sandbox initialization event. ++ error: ++ type: string ++ causes: ++ type: array ++ items: ++ type: string ++ duration_ms: ++ type: integer ++ format: uint64 ++ minimum: 0 + + RunSandboxRuntime: + type: object +@@ -11326,7 +11389,7 @@ components: + - timestamps + properties: + sandbox: +- $ref: "#/components/schemas/RunSandbox" ++ $ref: "#/components/schemas/RunSandboxInstance" + state: + $ref: "#/components/schemas/SandboxState" + native_state: +diff --git a/docs/public/changelog/2026-02-26.mdx b/docs/public/changelog/2026-02-26.mdx +index 96cef428b..659047c5e 100644 +--- a/docs/public/changelog/2026-02-26.mdx ++++ b/docs/public/changelog/2026-02-26.mdx +@@ -5,7 +5,7 @@ date: "2026-02-26" + + ## Daytona cloud sandboxes + +-Workflows can now execute in Daytona cloud environments — full dev containers with SSH access, persistent storage, and network isolation. Previously, Docker was the only sandbox option, which meant running everything locally. Daytona moves execution to the cloud, freeing up your machine and providing a more production-like environment. ++Workflows can now execute in Daytona cloud environments with SSH access, persistent storage, and network isolation. Previously, Docker was the only sandbox option, which meant running everything locally. Daytona moves execution to the cloud, freeing up your machine and providing a more production-like environment. + + ```bash + fabro run start --execution-env daytona my-workflow.fabro +diff --git a/docs/public/changelog/2026-03-02.mdx b/docs/public/changelog/2026-03-02.mdx +index 94cad1f13..ae4527cda 100644 +--- a/docs/public/changelog/2026-03-02.mdx ++++ b/docs/public/changelog/2026-03-02.mdx +@@ -1,14 +1,8 @@ + --- +-title: "Devcontainer support and sessions" ++title: "Sessions and workflow improvements" + date: "2026-03-02" + --- + +-## Devcontainer support +- +-Sandbox environments can now be defined using standard `devcontainer.json` files. Fabro parses and resolves the full devcontainer spec — features, lifecycle hooks (`onCreateCommand`, `postStartCommand`), build args, `containerEnv`, feature dependencies, and Compose-based configurations. +- +-If your project already has a `.devcontainer/devcontainer.json`, Fabro can use it directly instead of requiring a separate sandbox configuration. +- + ## Sessions + + Persistent chat sessions with SQLite storage. Start a conversation with an agent, close the terminal, and pick up where you left off. Sessions track messages, model, and conversation state. +diff --git a/docs/public/changelog/2026-03-10.mdx b/docs/public/changelog/2026-03-10.mdx +index 10e74bd50..1ae046b09 100644 +--- a/docs/public/changelog/2026-03-10.mdx ++++ b/docs/public/changelog/2026-03-10.mdx +@@ -1,5 +1,5 @@ + --- +-title: "One-line installer, MCP servers, devcontainers, and new CLI commands" ++title: "One-line installer, MCP servers, and new CLI commands" + date: "2026-03-10" + --- + +@@ -25,15 +25,6 @@ command = ["npx", "@playwright/mcp@latest", "--port", "3100", "--headless"] + port = 3100 + ``` + +-## Devcontainer support in sandboxes +- +-Workflows can now use your project's `devcontainer.json` to configure sandbox environments. When `devcontainer = true` is set in the sandbox config, Fabro resolves the devcontainer from the repo, uses its Dockerfile for the Daytona snapshot, runs lifecycle hooks (`onCreateCommand`, `postCreateCommand`, `postStartCommand`), and merges devcontainer environment variables into the sandbox. Unsupported `COPY`/`ADD` instructions in base Dockerfiles are detected and reported. +- +-```toml title="workflow.toml" +-[sandbox] +-devcontainer = true +-``` +- + + **Historical note.** This release temporarily standardized on `~/.fabro/.env`, but later releases removed automatic dotenv loading in favor of server-owned secrets plus explicit process environment variables. + +diff --git a/docs/public/changelog/2026-03-11.mdx b/docs/public/changelog/2026-03-11.mdx +index b4230d74a..b85361ba1 100644 +--- a/docs/public/changelog/2026-03-11.mdx ++++ b/docs/public/changelog/2026-03-11.mdx +@@ -52,10 +52,6 @@ fabro graph -o flow.svg # SVG to file + - Retro step now shows tool call progress while the retro agent works + + +- +-- Added `devcontainer` field to `SandboxConfiguration` OpenAPI schema +- +- + + - Context compaction now produces higher-quality summaries: the summarization prompt is framed as a handoff document, and recent user messages are preserved through compaction so the agent retains the user's actual words + - Retro agent limits increased to 20 tool rounds and a 3-minute timeout for complex runs +diff --git a/docs/public/changelog/2026-04-08.mdx b/docs/public/changelog/2026-04-08.mdx +index d4bfd153b..33c52b7f1 100644 +--- a/docs/public/changelog/2026-04-08.mdx ++++ b/docs/public/changelog/2026-04-08.mdx +@@ -25,7 +25,6 @@ fabro uninstall --force # skip confirmation + + - Fixed GitHub App setup flow failing on nullable webhook secrets, duplicate POST requests, and incorrect port detection + - Fixed session cookie decryption errors on server restart +-- Fixed devcontainer lifecycle commands not being cancelled during shutdown + - Fixed setup commands continuing to run after the server received a shutdown signal + - Fixed dark theme not being selected by default for new users + +diff --git a/docs/public/changelog/2026-04-23.mdx b/docs/public/changelog/2026-04-23.mdx +index ad0014a10..d92180a6d 100644 +--- a/docs/public/changelog/2026-04-23.mdx ++++ b/docs/public/changelog/2026-04-23.mdx +@@ -13,15 +13,15 @@ To migrate: + + ## Tighter server secret boundaries + +-Server startup now validates authority-bearing secrets at the server boundary instead of letting every subprocess inherit whatever happened to be in the parent environment. Worker subprocesses receive a scoped worker token when they need one, then scrub it from their process environment before launching hooks, sandbox commands, devcontainer setup, MCP stdio, or other descendants. ++Server startup now validates authority-bearing secrets at the server boundary instead of letting every subprocess inherit whatever happened to be in the parent environment. Worker subprocesses receive a scoped worker token when they need one, then scrub it from their process environment before launching hooks, sandbox commands, MCP stdio, or other descendants. + + That change reduces the chance of leaking server-level credentials into user-controlled command paths while preserving authenticated run operations. It also makes install-time and startup-time secret handling easier to reason about for self-hosted deployments. + +-## Faster workflow finishing and devcontainer setup ++## Faster workflow finishing + +-Several workflow phases now do less serialized work. Retros load the event log once, devcontainer `Command::Parallel` entries actually run concurrently, and final patch creation can overlap with finalize commit work. ++Several workflow phases now do less serialized work. Retros load the event log once, and final patch creation can overlap with finalize commit work. + +-Users should notice this most on longer workflows with large event logs, devcontainer initialization, or expensive final patch generation. The behavior is the same, but the slow tail of a run has fewer avoidable waits. ++Users should notice this most on longer workflows with large event logs or expensive final patch generation. The behavior is the same, but the slow tail of a run has fewer avoidable waits. + + ## More + +diff --git a/docs/public/docs.json b/docs/public/docs.json +index 51da121d0..c60ddb57b 100644 +--- a/docs/public/docs.json ++++ b/docs/public/docs.json +@@ -57,8 +57,7 @@ + "execution/checkpoints", + "execution/outcomes", + "execution/failures", +- "execution/observability", +- "execution/devcontainers" ++ "execution/observability" + ] + }, + { +diff --git a/docs/public/execution/devcontainers.mdx b/docs/public/execution/devcontainers.mdx +deleted file mode 100644 +index 972d4e808..000000000 +--- a/docs/public/execution/devcontainers.mdx ++++ /dev/null +@@ -1,32 +0,0 @@ +---- +-title: "Devcontainers" +-description: "Using repository devcontainer metadata with Fabro environments" +---- +- +-Named environments are the supported configuration surface for run execution. Define reusable environments under `[environments.]` and select one with `[run.environment] id = "..."`. +- +-Devcontainer-specific run configuration (`[run.sandbox] devcontainer = true`) has been removed with the named environments configuration break. To use a devcontainer-style image today, build or reference it through an environment image: +- +-```toml +-[run.environment] +-id = "dev" +- +-[environments.dev] +-provider = "docker" +- +-[environments.dev.image] +-docker = "ghcr.io/acme/project-devcontainer:latest" +-``` +- +-For Daytona snapshot creation, provide a Dockerfile path on the selected environment: +- +-```toml +-[run.environment] +-id = "cloud-dev" +- +-[environments.cloud-dev] +-provider = "daytona" +- +-[environments.cloud-dev.image] +-dockerfile = { path = ".devcontainer/Dockerfile" } +-``` +diff --git a/docs/superpowers/plans/2026-04-30-preserve-exec-failure-diagnostics.md b/docs/superpowers/plans/2026-04-30-preserve-exec-failure-diagnostics.md +index 260cead67..dd8130969 100644 +--- a/docs/superpowers/plans/2026-04-30-preserve-exec-failure-diagnostics.md ++++ b/docs/superpowers/plans/2026-04-30-preserve-exec-failure-diagnostics.md +@@ -21,13 +21,13 @@ + - Modify `lib/crates/fabro-workflow/src/event.rs`: carry `exec_output_tail` through internal events and event-body conversion; trace only safe metadata about tails, not tail content. + - Modify `lib/crates/fabro-workflow/src/sandbox_metadata.rs`, `lib/crates/fabro-workflow/src/lifecycle/git.rs`, and `lib/crates/fabro-workflow/src/pipeline/finalize.rs`: preserve push/write diagnostic projections without storing `fabro_sandbox::Error` inside `MetadataSnapshot`. + - Modify `lib/crates/fabro-workflow/src/pipeline/initialize.rs`: add output-tail diagnostics to setup failures while preserving existing `stderr` field for compatibility. +-- Modify `lib/crates/fabro-workflow/src/devcontainer_bridge.rs`: add output-tail diagnostics to devcontainer lifecycle failures while preserving existing `stderr` field for compatibility. ++- Modify `lib/crates/fabro-workflow/src/pipeline/initialize.rs`: add output-tail diagnostics to setup failures while preserving existing `stderr` field for compatibility. + - Modify `lib/crates/fabro-workflow/src/handler/llm/cli.rs`: replace CLI install's ad hoc 500-character embedded error detail with `exec_output_tail`. + - Modify `docs/internal/logging-strategy.md`: document the policy that event payloads may contain bounded redacted tails, while tracing logs must not contain tail content by default. + + ## Explicit Non-Goals + +-- Do not remove, deprecate, or stop populating `SetupFailedProps.stderr` or `DevcontainerLifecycleFailedProps.stderr` in this change. Any future removal requires a separate public event-contract deprecation plan. ++- Do not remove, deprecate, or stop populating `SetupFailedProps.stderr` in this change. Any future removal requires a separate public event-contract deprecation plan. + - Do not add stdout/stderr tail content to `server.log`. Tracing should record safe metadata only: whether a tail exists, stream lengths, truncation booleans, and the existing safe error message. + - Do not change `HookDecision::Block.reason` to include stdout/stderr. That is user-visible hook semantics and needs a separate design if we want durable hook diagnostics later. + - Do not broadly refactor `sandbox_git.rs` error plumbing beyond constructor/signature updates needed by the `Error::Exec` refactor. +@@ -84,14 +84,14 @@ Keep `is_false` private to the module. Do not add another full process result ty + + - [x] **Step 2: Add `exec_output_tail` additively to failure props** + +-Add this optional field to `MetadataSnapshotFailedProps`, `SetupFailedProps`, `CliEnsureFailedProps`, and `DevcontainerLifecycleFailedProps`: ++Add this optional field to `MetadataSnapshotFailedProps`, `SetupFailedProps`, and `CliEnsureFailedProps`: + + ```rust + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exec_output_tail: Option, + ``` + +-Do not remove existing fields, including `stderr` on setup/devcontainer failure props. ++Do not remove existing fields, including `stderr` on setup failure props. + + - [x] **Step 3: Re-export the projection** + +@@ -360,9 +360,9 @@ Add `exec_output_tail: Option` to: + - `MetadataSnapshotFailed` + - `SetupFailed` + - `CliEnsureFailed` +-- `DevcontainerLifecycleFailed` ++- `SetupFailed` + +-Keep existing `stderr` fields on `SetupFailed` and `DevcontainerLifecycleFailed`. ++Keep existing `stderr` fields on `SetupFailed`. + + - [x] **Step 2: Map tails into `EventBody`** + +@@ -543,11 +543,11 @@ cargo nextest run -p fabro-workflow metadata_snapshot + + Expected: metadata push/write failure events contain `exec_output_tail` when command output exists. + +-## Task 5: Add Tails To Setup, Devcontainer, And CLI Install Failures ++## Task 5: Add Tails To Setup And CLI Install Failures + + **Files:** + - Modify: `lib/crates/fabro-workflow/src/pipeline/initialize.rs` +-- Modify: `lib/crates/fabro-workflow/src/devcontainer_bridge.rs` ++- Modify: `lib/crates/fabro-workflow/src/pipeline/initialize.rs` + - Modify: `lib/crates/fabro-workflow/src/handler/llm/cli.rs` + + - [x] **Step 1: Add setup failure tails without removing `stderr`** +@@ -567,13 +567,13 @@ options.emitter.emit(&Event::SetupFailed { + + Keep the existing `stderr` value for compatibility in this change. + +-- [x] **Step 2: Add devcontainer failure tails without removing `stderr`** ++- [x] **Step 2: Add setup failure tails without removing `stderr`** + + For both parallel and single-command lifecycle failures, emit: + + ```rust + let exec_output_tail = result.default_redacted_output_tail(); +-emitter.emit(&Event::DevcontainerLifecycleFailed { ++emitter.emit(&Event::SetupFailed { + phase: phase.clone(), + command: name.clone(), + index, +@@ -611,14 +611,14 @@ Add or update tests so that: + + - setup failure with stderr preserves `props.stderr` and adds `props.exec_output_tail.stderr`. + - setup failure with stdout-only output adds `props.exec_output_tail.stdout`. +-- devcontainer lifecycle failure adds the nested tail while preserving `stderr`. ++- setup failure adds the nested tail while preserving `stderr`. + - CLI ensure failure no longer embeds command output in `error`, but includes `exec_output_tail`. + + Run: + + ```bash + cargo nextest run -p fabro-workflow setup +-cargo nextest run -p fabro-workflow devcontainer ++cargo nextest run -p fabro-workflow setup + cargo nextest run -p fabro-workflow cli + ``` + +diff --git a/docs/superpowers/plans/2026-05-08-run-owned-sandbox-lifecycle.md b/docs/superpowers/plans/2026-05-08-run-owned-sandbox-lifecycle.md +index 6ead35204..f962f3983 100644 +--- a/docs/superpowers/plans/2026-05-08-run-owned-sandbox-lifecycle.md ++++ b/docs/superpowers/plans/2026-05-08-run-owned-sandbox-lifecycle.md +@@ -131,7 +131,7 @@ Attach-existing must not: + - create an empty provider workspace + - overwrite persisted sandbox identity + +-Setup-command/devcontainer behavior on resume should remain checkpoint-aware: do not rerun provider creation, clone, or devcontainer snapshot creation. Only rerun explicit resume setup commands already defined by the sandbox/provider when needed to reattach to the existing run branch. ++Setup-command behavior on resume should remain checkpoint-aware: do not rerun provider creation or clone. Only rerun explicit resume setup commands already defined by the sandbox/provider when needed to reattach to the existing run branch. + + ## Delete And Preserve API Semantics + +diff --git a/docs/superpowers/plans/2026-05-10-sandbox-services-backend.md b/docs/superpowers/plans/2026-05-10-sandbox-services-backend.md +index 81e4ae550..a127e30e6 100644 +--- a/docs/superpowers/plans/2026-05-10-sandbox-services-backend.md ++++ b/docs/superpowers/plans/2026-05-10-sandbox-services-backend.md +@@ -16,7 +16,7 @@ + - Use only `ss -H -ltnp` for v1; do not add `netstat` or `lsof` fallback. + - Return all parsed listening TCP ports, including ports outside Daytona's preview range. + - Compute preview support outside the sandbox command: `provider == "daytona" && 3000 <= port <= 9999`. +-- Do not probe HTTP readiness and do not read `devcontainer.json`. ++- Do not probe HTTP readiness or read repository setup metadata. + + ## Files + +diff --git a/docs/superpowers/plans/2026-05-10-sandbox-services-frontend.md b/docs/superpowers/plans/2026-05-10-sandbox-services-frontend.md +index 30d27c918..996ab4eeb 100644 +--- a/docs/superpowers/plans/2026-05-10-sandbox-services-frontend.md ++++ b/docs/superpowers/plans/2026-05-10-sandbox-services-frontend.md +@@ -16,7 +16,7 @@ + - Place it between **Terminal** and **Filesystem**. + - List all services returned by the backend. + - Show a **Preview** action only for rows with `preview_supported: true`. +-- Do not read `devcontainer.json`. ++- Do not read repository setup metadata. + - Do not perform browser-side HTTP checks. + - Do not implement polling beyond normal SWR refresh/manual refresh behavior. + +diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs +index 8483a12b0..6f02e6b96 100644 +--- a/lib/crates/fabro-api/build.rs ++++ b/lib/crates/fabro-api/build.rs +@@ -534,6 +534,10 @@ fn main() { + &[], + ), + ("RunSandboxRuntime", "fabro_types::RunSandboxRuntime", &[]), ++ ("RunSandboxKind", "fabro_types::RunSandboxKind", &[]), ++ ("RunSandboxPlan", "fabro_types::RunSandboxPlan", &[]), ++ ("RunSandboxInstance", "fabro_types::RunSandboxInstance", &[]), ++ ("RunSandboxFailure", "fabro_types::RunSandboxFailure", &[]), + ("PullRequestUser", "fabro_types::PullRequestUser", &[]), + ("PullRequestRef", "fabro_types::PullRequestRef", &[]), + ( +diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs +index 26bbd1b2e..9c5e4c0af 100644 +--- a/lib/crates/fabro-api/src/lib.rs ++++ b/lib/crates/fabro-api/src/lib.rs +@@ -48,12 +48,13 @@ pub mod types { + PullRequestLink, PullRequestMeta, PullRequestResponse, QuestionType, RepositoryRef, Run, + RunApproval, RunApprovalState, RunClientProvenance, RunEvent, RunEventDetailContentKind, + RunEventDetailResponse, RunFailure, RunPairStatusResponse, RunProjection, RunProvenance, +- RunRunnableSource, RunSandbox, RunSandboxRuntime, RunServerProvenance, RunSize, +- SandboxDetails, SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxNetwork, +- SandboxNetworkPolicy, SandboxNetworkPolicyMode, SandboxProviderKind, +- SandboxProviderLookupError, SandboxResources, SandboxService, SandboxServiceListResponse, +- SandboxState, SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail, +- SessionId, SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn, ++ RunRunnableSource, RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxKind, ++ RunSandboxPlan, RunSandboxRuntime, RunServerProvenance, RunSize, SandboxDetails, ++ SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy, ++ SandboxNetworkPolicyMode, SandboxProviderKind, SandboxProviderLookupError, ++ SandboxResources, SandboxService, SandboxServiceListResponse, SandboxState, ++ SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail, SessionId, ++ SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn, + SkillsProjection, StageCompletion, StageContextWindow, StageContextWindowBreakdownItem, + StageContextWindowCategory, StageContextWindowCountMethod, StageContextWindowProjection, + StageContextWindowStaleness, StageContextWindowUnavailableReason, +diff --git a/lib/crates/fabro-api/tests/run_projection_round_trip.rs b/lib/crates/fabro-api/tests/run_projection_round_trip.rs +index bd3d521d7..9a2a0f310 100644 +--- a/lib/crates/fabro-api/tests/run_projection_round_trip.rs ++++ b/lib/crates/fabro-api/tests/run_projection_round_trip.rs +@@ -35,13 +35,19 @@ fn run_projection_round_trips_populated_projection() { + ], + "conclusion": null, + "sandbox": { +- "provider": "docker", +- "runtime": { +- "id": "container-abc123", +- "working_directory": "/workspace", +- "repo_cloned": true, +- "clone_origin_url": "https://github.com/fabro-sh/fabro.git", +- "clone_branch": "main" ++ "kind": "ready", ++ "plan": { ++ "provider": "docker" ++ }, ++ "instance": { ++ "provider": "docker", ++ "runtime": { ++ "id": "container-abc123", ++ "working_directory": "/workspace", ++ "repo_cloned": true, ++ "clone_origin_url": "https://github.com/fabro-sh/fabro.git", ++ "clone_branch": "main" ++ } + } + }, + "pull_request": null, +diff --git a/lib/crates/fabro-api/tests/run_sandbox_round_trip.rs b/lib/crates/fabro-api/tests/run_sandbox_round_trip.rs +index 5c7da541a..0e30c3ab7 100644 +--- a/lib/crates/fabro-api/tests/run_sandbox_round_trip.rs ++++ b/lib/crates/fabro-api/tests/run_sandbox_round_trip.rs +@@ -1,49 +1,69 @@ + use std::any::{TypeId, type_name}; + +-use fabro_api::types::{RunSandbox as ApiRunSandbox, SandboxProviderKind as ApiSandboxProvider}; +-use fabro_types::{RunSandbox, RunSandboxRuntime, SandboxProviderKind}; ++use fabro_api::types::{ ++ RunSandbox as ApiRunSandbox, RunSandboxInstance as ApiRunSandboxInstance, ++ RunSandboxPlan as ApiRunSandboxPlan, SandboxProviderKind as ApiSandboxProvider, ++}; ++use fabro_types::{ ++ RunSandbox, RunSandboxInstance, RunSandboxPlan, RunSandboxRuntime, SandboxProviderKind, ++}; + use serde_json::json; + + #[test] + fn run_sandbox_reuses_domain_types() { + assert_same_type::(); ++ assert_same_type::(); ++ assert_same_type::(); + assert_same_type::(); + } + + #[test] + fn run_sandbox_json_matches_openapi_shape() { +- let sandbox = RunSandbox { +- provider: SandboxProviderKind::Docker, +- image: Some("ghcr.io/fabro/sandbox:latest".to_string()), +- snapshot: None, +- runtime: Some(RunSandboxRuntime { +- id: "container-abc123".to_string(), +- working_directory: "/workspace".to_string(), +- repo_cloned: Some(false), +- clone_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()), +- clone_branch: Some("main".to_string()), +- workspace_root: Some("/workspace".to_string()), +- repos_root: Some("/repos".to_string()), +- primary_repo_path: None, +- primary_repo_link: None, +- }), +- }; ++ let sandbox = RunSandbox::ready( ++ RunSandboxPlan { ++ provider: SandboxProviderKind::Docker, ++ image: Some("ghcr.io/fabro/sandbox:latest".to_string()), ++ snapshot: None, ++ }, ++ RunSandboxInstance { ++ provider: SandboxProviderKind::Docker, ++ image: None, ++ snapshot: None, ++ runtime: RunSandboxRuntime { ++ id: "container-abc123".to_string(), ++ working_directory: "/workspace".to_string(), ++ repo_cloned: Some(false), ++ clone_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()), ++ clone_branch: Some("main".to_string()), ++ workspace_root: Some("/workspace".to_string()), ++ repos_root: Some("/repos".to_string()), ++ primary_repo_path: None, ++ primary_repo_link: None, ++ }, ++ }, ++ ); + + let value = serde_json::to_value(&sandbox).unwrap(); + + assert_eq!( + value, + json!({ +- "provider": "docker", +- "image": "ghcr.io/fabro/sandbox:latest", +- "runtime": { +- "id": "container-abc123", +- "working_directory": "/workspace", +- "repo_cloned": false, +- "clone_origin_url": "https://github.com/fabro-sh/fabro.git", +- "clone_branch": "main", +- "workspace_root": "/workspace", +- "repos_root": "/repos" ++ "kind": "ready", ++ "plan": { ++ "provider": "docker", ++ "image": "ghcr.io/fabro/sandbox:latest" ++ }, ++ "instance": { ++ "provider": "docker", ++ "runtime": { ++ "id": "container-abc123", ++ "working_directory": "/workspace", ++ "repo_cloned": false, ++ "clone_origin_url": "https://github.com/fabro-sh/fabro.git", ++ "clone_branch": "main", ++ "workspace_root": "/workspace", ++ "repos_root": "/repos" ++ } + } + }) + ); +diff --git a/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs b/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs +index a485ac4eb..712f22a7a 100644 +--- a/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs ++++ b/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs +@@ -10,7 +10,7 @@ use fabro_api::types::{ + SandboxState as ApiSandboxState, SandboxTimestamps as ApiSandboxTimestamps, + }; + use fabro_types::{ +- RunSandbox, RunSandboxRuntime, SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, ++ RunSandboxInstance, RunSandboxRuntime, SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, + SandboxNetworkPolicyMode, SandboxProviderKind, SandboxResources, SandboxState, + SandboxTimestamps, + }; +@@ -32,11 +32,11 @@ fn sandbox_details_reuses_domain_types() { + fn sandbox_details_json_matches_openapi_shape() { + let created_at = Utc.with_ymd_and_hms(2026, 5, 9, 12, 0, 0).unwrap(); + let details = SandboxDetails { +- sandbox: RunSandbox { ++ sandbox: RunSandboxInstance { + provider: SandboxProviderKind::Docker, + image: Some("ghcr.io/fabro/sandbox:latest".to_string()), + snapshot: None, +- runtime: Some(RunSandboxRuntime { ++ runtime: RunSandboxRuntime { + id: "container-abc123".to_string(), + working_directory: "/workspace".to_string(), + repo_cloned: None, +@@ -46,7 +46,7 @@ fn sandbox_details_json_matches_openapi_shape() { + repos_root: Some("/repos".to_string()), + primary_repo_path: Some("/repos/fabro-sh/fabro".to_string()), + primary_repo_link: Some("/workspace/fabro".to_string()), +- }), ++ }, + }, + state: SandboxState::Running, + native_state: Some("running".to_string()), +@@ -132,20 +132,12 @@ fn sandbox_details_deserializes_when_optional_fields_are_absent() { + + assert_eq!(details.sandbox.provider, SandboxProviderKind::Local); + assert_eq!( +- details +- .sandbox +- .runtime +- .as_ref() +- .map(|runtime| runtime.id.as_str()), +- Some("local:01JNQVR7M0EJ5GKAT2SC4ERS1Z") ++ details.sandbox.runtime.id.as_str(), ++ "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z" + ); + assert_eq!( +- details +- .sandbox +- .runtime +- .as_ref() +- .map(|runtime| runtime.working_directory.as_str()), +- Some("/Users/client/project") ++ details.sandbox.runtime.working_directory.as_str(), ++ "/Users/client/project" + ); + assert_eq!(details.state, SandboxState::Unknown); + assert!(details.sandbox.image.is_none()); +diff --git a/lib/crates/fabro-cli/Cargo.toml b/lib/crates/fabro-cli/Cargo.toml +index 4bff98f8d..a4d15b5b5 100644 +--- a/lib/crates/fabro-cli/Cargo.toml ++++ b/lib/crates/fabro-cli/Cargo.toml +@@ -25,7 +25,6 @@ fabro-model = { path = "../fabro-model" } + fabro-oauth = { path = "../fabro-oauth" } + fabro-github = { path = "../fabro-github" } + fabro-agent = { path = "../fabro-agent" } +-fabro-devcontainer = { path = "../fabro-devcontainer" } + fabro-dump = { path = "../fabro-dump" } + fabro-hooks = { path = "../fabro-hooks" } + fabro-install = { path = "../fabro-install" } +diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs +index 9c4d3a747..a4a72f0bc 100644 +--- a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs ++++ b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs +@@ -100,32 +100,6 @@ pub(super) enum ProgressEvent { + CliEnsureFailed { + cli_name: String, + }, +- DevcontainerResolved { +- dockerfile_lines: u64, +- environment_count: u64, +- lifecycle_command_count: u64, +- workspace_folder: String, +- }, +- DevcontainerLifecycleStarted { +- phase: String, +- command_count: u64, +- }, +- DevcontainerLifecycleCompleted { +- phase: String, +- duration_ms: u64, +- }, +- DevcontainerLifecycleFailed { +- phase: String, +- command: String, +- exit_code: i64, +- stderr: String, +- }, +- DevcontainerLifecycleCommandCompleted { +- command: String, +- command_index: u64, +- exit_code: i64, +- duration_ms: u64, +- }, + StageStarted { + node_id: String, + name: String, +@@ -308,40 +282,6 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option { + EventBody::CliEnsureFailed(props) => Some(ProgressEvent::CliEnsureFailed { + cli_name: props.cli_name.clone(), + }), +- EventBody::DevcontainerResolved(props) => Some(ProgressEvent::DevcontainerResolved { +- dockerfile_lines: props.dockerfile_lines as u64, +- environment_count: props.environment_count as u64, +- lifecycle_command_count: props.lifecycle_command_count as u64, +- workspace_folder: props.workspace_folder.clone(), +- }), +- EventBody::DevcontainerLifecycleStarted(props) => { +- Some(ProgressEvent::DevcontainerLifecycleStarted { +- phase: props.phase.clone(), +- command_count: props.command_count as u64, +- }) +- } +- EventBody::DevcontainerLifecycleCompleted(props) => { +- Some(ProgressEvent::DevcontainerLifecycleCompleted { +- phase: props.phase.clone(), +- duration_ms: props.duration_ms, +- }) +- } +- EventBody::DevcontainerLifecycleFailed(props) => { +- Some(ProgressEvent::DevcontainerLifecycleFailed { +- phase: props.phase.clone(), +- command: props.command.clone(), +- exit_code: i64::from(props.exit_code), +- stderr: props.stderr.clone(), +- }) +- } +- EventBody::DevcontainerLifecycleCommandCompleted(props) => { +- Some(ProgressEvent::DevcontainerLifecycleCommandCompleted { +- command: props.command.clone(), +- command_index: props.index as u64, +- exit_code: i64::from(props.exit_code), +- duration_ms: props.duration_ms, +- }) +- } + EventBody::StageStarted(_) => Some(ProgressEvent::StageStarted { + node_id, + name: node_label, +diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs +index c6a03674a..75d7fc0ff 100644 +--- a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs ++++ b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs +@@ -195,55 +195,6 @@ impl ProgressUI { + ProgressEvent::CliEnsureFailed { cli_name } => { + self.setup.on_cli_ensure_failed(renderer, &cli_name); + } +- ProgressEvent::DevcontainerResolved { +- dockerfile_lines, +- environment_count, +- lifecycle_command_count, +- workspace_folder, +- } => { +- SetupDisplay::on_devcontainer_resolved( +- renderer, +- dockerfile_lines, +- environment_count, +- lifecycle_command_count, +- &workspace_folder, +- ); +- } +- ProgressEvent::DevcontainerLifecycleStarted { +- phase, +- command_count, +- } => { +- self.setup +- .on_devcontainer_lifecycle_started(renderer, &phase, command_count); +- } +- ProgressEvent::DevcontainerLifecycleCompleted { phase, duration_ms } => { +- self.setup +- .on_devcontainer_lifecycle_completed(renderer, &phase, duration_ms); +- } +- ProgressEvent::DevcontainerLifecycleFailed { +- phase, +- command, +- exit_code, +- stderr, +- } => { +- self.setup.on_devcontainer_lifecycle_failed( +- renderer, &phase, &command, exit_code, &stderr, +- ); +- } +- ProgressEvent::DevcontainerLifecycleCommandCompleted { +- command, +- command_index, +- exit_code, +- duration_ms, +- } => { +- self.setup.on_devcontainer_lifecycle_command_completed( +- renderer, +- &command, +- command_index, +- exit_code, +- duration_ms, +- ); +- } + ProgressEvent::StageStarted { + node_id, + name, +@@ -810,21 +761,6 @@ mod tests { + duration_ms: 2200, + }, + Event::SetupCompleted { duration_ms: 2200 }, +- Event::DevcontainerLifecycleStarted { +- phase: "postCreate".into(), +- command_count: 1, +- }, +- Event::DevcontainerLifecycleCommandCompleted { +- phase: "postCreate".into(), +- command: "npm run setup".into(), +- index: 0, +- exit_code: 0, +- duration_ms: 1400, +- }, +- Event::DevcontainerLifecycleCompleted { +- phase: "postCreate".into(), +- duration_ms: 1400, +- }, + ]; + + let (mut event_ui, event_buffer) = capture_ui(true); +@@ -903,31 +839,12 @@ mod tests { + duration_ms: 600, + }), + ); +- emit(&mut ui, Event::DevcontainerResolved { +- dockerfile_lines: 24, +- environment_count: 3, +- lifecycle_command_count: 2, +- workspace_folder: "/workspace".into(), +- }); +- emit(&mut ui, Event::DevcontainerLifecycleStarted { +- phase: "postCreate".into(), +- command_count: 2, +- }); +- emit(&mut ui, Event::DevcontainerLifecycleCompleted { +- phase: "postCreate".into(), +- duration_ms: 1800, +- }); +- + insta::assert_snapshot!(rendered(&buffer), @r" + Sandbox: daytona (ready in 2s) + sandbox-1 (4 cpu, 8 GB) + ssh daytona@example + Setup: 2 commands (8s) + CLI: gh (installed, 600ms) +- Devcontainer: resolved +- 24 Dockerfile lines, 3 env vars, 2 lifecycle cmds, /workspace +- Running devcontainer postCreate (2 commands)... +- Devcontainer: postCreate (1s) + "); + } + +@@ -1209,21 +1126,6 @@ mod tests { + duration_ms: 2200, + }); + emit(&mut ui, Event::SetupCompleted { duration_ms: 2200 }); +- emit(&mut ui, Event::DevcontainerLifecycleStarted { +- phase: "postCreate".into(), +- command_count: 1, +- }); +- emit(&mut ui, Event::DevcontainerLifecycleCommandCompleted { +- phase: "postCreate".into(), +- command: "npm run setup".into(), +- index: 0, +- exit_code: 0, +- duration_ms: 1400, +- }); +- emit(&mut ui, Event::DevcontainerLifecycleCompleted { +- phase: "postCreate".into(), +- duration_ms: 1400, +- }); + emit(&mut ui, stage_completed("code", "Code")); + + insta::assert_snapshot!(rendered(&buffer), @r#" +@@ -1235,9 +1137,6 @@ mod tests { + ✓ subagent[a1] (3 turns) + ✓ [1/1] bun install 2s + Setup: 1 command (2s) +- Running devcontainer postCreate (1 commands)... +- ✓ [1/1] npm run setup 1s +- Devcontainer: postCreate (1s) + ✓ Code 5s (1 turns, 0 tools, 1.5k toks) + "#); + } +diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/setup_display.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/setup_display.rs +index 1e0d980a8..ac509d948 100644 +--- a/lib/crates/fabro-cli/src/commands/run/run_progress/setup_display.rs ++++ b/lib/crates/fabro-cli/src/commands/run/run_progress/setup_display.rs +@@ -12,8 +12,6 @@ pub(super) struct SetupDisplay { + pub(super) sandbox_bar: Option, + pub(super) setup_bar: Option, + pub(super) setup_command_count: u64, +- pub(super) devcontainer_bar: Option, +- pub(super) devcontainer_command_count: u64, + pub(super) cli_ensure_bar: Option, + } + +@@ -25,8 +23,6 @@ impl SetupDisplay { + sandbox_bar: None, + setup_bar: None, + setup_command_count: 0, +- devcontainer_bar: None, +- devcontainer_command_count: 0, + cli_ensure_bar: None, + } + } +@@ -38,9 +34,6 @@ impl SetupDisplay { + if let Some(bar) = self.setup_bar.take() { + bar.finish_and_clear(); + } +- if let Some(bar) = self.devcontainer_bar.take() { +- bar.finish_and_clear(); +- } + if let Some(bar) = self.cli_ensure_bar.take() { + bar.finish_and_clear(); + } +@@ -305,143 +298,6 @@ impl SetupDisplay { + renderer.print_line(4, &message); + } + } +- +- pub(super) fn on_devcontainer_resolved( +- renderer: &ProgressRenderer, +- dockerfile_lines: u64, +- environment_count: u64, +- lifecycle_command_count: u64, +- workspace_folder: &str, +- ) { +- let detail = format!( +- "{dockerfile_lines} Dockerfile lines, {environment_count} env vars, \ +- {lifecycle_command_count} lifecycle cmds, {workspace_folder}" +- ); +- +- if renderer.is_tty() { +- let bar = renderer.add_spinner(); +- bar.set_style(styles::style_header_done()); +- bar.finish_with_message("Devcontainer: resolved".to_string()); +- let detail_bar = renderer.insert_after(&bar); +- detail_bar.set_style(styles::style_sandbox_detail()); +- detail_bar.finish_with_message(detail); +- } else { +- renderer.print_line(4, "Devcontainer: resolved"); +- renderer.print_line(13, &detail); +- } +- } +- +- pub(super) fn on_devcontainer_lifecycle_started( +- &mut self, +- renderer: &ProgressRenderer, +- phase: &str, +- command_count: u64, +- ) { +- self.devcontainer_command_count = command_count; +- +- if renderer.is_tty() { +- let bar = renderer.add_spinner(); +- bar.set_style(styles::style_header_running()); +- bar.set_message(format!( +- "Running devcontainer {phase} ({command_count} commands)..." +- )); +- bar.enable_steady_tick(Duration::from_millis(100)); +- self.devcontainer_bar = Some(bar); +- } else { +- renderer.print_line( +- 4, +- &format!("Running devcontainer {phase} ({command_count} commands)..."), +- ); +- } +- } +- +- pub(super) fn on_devcontainer_lifecycle_completed( +- &mut self, +- renderer: &ProgressRenderer, +- phase: &str, +- duration_ms: u64, +- ) { +- let dur = format_duration_ms(duration_ms); +- +- if renderer.is_tty() { +- if let Some(bar) = self.devcontainer_bar.take() { +- bar.set_style(styles::style_header_done()); +- bar.set_prefix(dur); +- bar.finish_with_message(format!("Devcontainer: {phase}")); +- } +- } else { +- renderer.print_line(4, &format!("Devcontainer: {phase} ({dur})")); +- } +- } +- +- pub(super) fn on_devcontainer_lifecycle_failed( +- &mut self, +- renderer: &ProgressRenderer, +- phase: &str, +- command: &str, +- exit_code: i64, +- stderr: &str, +- ) { +- if let Some(bar) = self.devcontainer_bar.take() { +- bar.abandon(); +- } +- +- let summary = if stderr.len() > 120 { +- &stderr[..120] +- } else { +- stderr +- }; +- let message = format!( +- "{} Devcontainer {phase} command failed (exit {exit_code}): {command}\n {summary}", +- renderer.styles().red.apply_to("Error:") +- ); +- +- if renderer.is_tty() { +- let bar = renderer.add_spinner(); +- bar.set_style(styles::style_static_dim()); +- bar.finish_with_message(message); +- } else { +- renderer.print_line(4, &message); +- } +- } +- +- pub(super) fn on_devcontainer_lifecycle_command_completed( +- &self, +- renderer: &ProgressRenderer, +- command: &str, +- command_index: u64, +- exit_code: i64, +- duration_ms: u64, +- ) { +- if !self.verbose { +- return; +- } +- +- let glyph = if exit_code == 0 { +- styles::green_check(renderer.styles()) +- } else { +- styles::red_cross(renderer.styles()) +- }; +- let msg = format!( +- "{glyph} [{}/{}] {}", +- command_index + 1, +- self.devcontainer_command_count, +- styles::truncate(command, 60) +- ); +- let dur = format_duration_ms(duration_ms); +- +- if renderer.is_tty() { +- let bar = match &self.devcontainer_bar { +- Some(devcontainer_bar) => renderer.insert_before(devcontainer_bar), +- None => renderer.add_spinner(), +- }; +- bar.set_style(styles::style_tool_done()); +- bar.set_prefix(dur); +- bar.finish_with_message(msg); +- } else { +- renderer.print_line(6, &format!("{msg} {dur}")); +- } +- } + } + + fn initializing_message(provider: &str) -> String { +diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs +index 3081e1ee3..9579f8e79 100644 +--- a/lib/crates/fabro-cli/src/main.rs ++++ b/lib/crates/fabro-cli/src/main.rs +@@ -53,7 +53,7 @@ async fn main() { + + // Capture the worker bearer token immediately and scrub it from the process + // env before any subprocess can be spawned. Every descendant of the worker +- // (hooks, sandbox commands, devcontainer setup, MCP stdio, etc.) therefore ++ // (hooks, sandbox commands, MCP stdio, etc.) therefore + // inherits a process env that no longer contains this credential, so an + // unscrubbed spawn site cannot leak it. The token flows to `runner::execute` + // through explicit function arguments instead of the environment. +diff --git a/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs b/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs +index 5a13fe29a..e79bffbd9 100644 +--- a/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs ++++ b/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs +@@ -50,7 +50,7 @@ fn sandbox_cp_run_without_sandbox_json_errors_cleanly() { + exit_code: 1 + ----- stdout ----- + ----- stderr ----- +- × run sandbox missing runtime metadata ++ × Run sandbox was not created. + "); + } + +diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs +index 9ac2e7673..2cc3e7c1f 100644 +--- a/lib/crates/fabro-cli/tests/it/cmd/support.rs ++++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs +@@ -1816,7 +1816,7 @@ pub(crate) fn compact_inspect(output: &Output) -> Value { + }), + "sandbox": sandbox.as_object().map(|_| { + serde_json::json!({ +- "provider": sandbox["provider"], ++ "provider": compact_sandbox_provider(&sandbox), + }) + }), + }) +@@ -1879,7 +1879,7 @@ pub(crate) fn compact_git_inspect(output: &Output) -> Value { + }), + "sandbox": sandbox.as_object().map(|_| { + serde_json::json!({ +- "provider": sandbox["provider"], ++ "provider": compact_sandbox_provider(&sandbox), + "working_directory": "[WORKTREE]", + }) + }), +@@ -1889,6 +1889,16 @@ pub(crate) fn compact_git_inspect(output: &Output) -> Value { + ) + } + ++fn compact_sandbox_provider(sandbox: &Value) -> Value { ++ sandbox ++ .pointer("/instance/provider") ++ .or_else(|| sandbox.pointer("/plan/provider")) ++ .or_else(|| sandbox.pointer("/failure/provider")) ++ .or_else(|| sandbox.get("provider")) ++ .cloned() ++ .unwrap_or(Value::Null) ++} ++ + fn write_text_file(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) +diff --git a/lib/crates/fabro-devcontainer/Cargo.toml b/lib/crates/fabro-devcontainer/Cargo.toml +deleted file mode 100644 +index 5139fab07..000000000 +--- a/lib/crates/fabro-devcontainer/Cargo.toml ++++ /dev/null +@@ -1,29 +0,0 @@ +-[package] +-name = "fabro-devcontainer" +-edition.workspace = true +-version.workspace = true +-publish = false +-license.workspace = true +-description = "Parse and resolve devcontainer.json into Dockerfiles and lifecycle hooks" +- +-[lib] +-doctest = false +- +-[lints] +-workspace = true +- +-[dependencies] +-fabro-static.workspace = true +-fabro-util = { path = "../fabro-util" } +-fabro-http.workspace = true +-serde = { workspace = true } +-serde_json = { workspace = true } +-serde_yaml = "0.9" +-thiserror = { workspace = true } +-tracing = { workspace = true } +-tokio = { workspace = true } +- +-[dev-dependencies] +-insta = { workspace = true } +-tokio = { workspace = true, features = ["test-util", "macros"] } +-tempfile = "3" +diff --git a/lib/crates/fabro-devcontainer/DEVCONTAINER-COMPATIBILITY.md b/lib/crates/fabro-devcontainer/DEVCONTAINER-COMPATIBILITY.md +deleted file mode 100644 +index 39850d175..000000000 +--- a/lib/crates/fabro-devcontainer/DEVCONTAINER-COMPATIBILITY.md ++++ /dev/null +@@ -1,121 +0,0 @@ +-# Devcontainer Spec Compatibility Matrix +- +-Compatibility of `fabro-devcontainer` with the [devcontainer.json reference](https://containers.dev/implementors/json_reference/). +- +-**Legend**: Yes = fully supported, Partial = parsed but incomplete, No = not supported, Planned = intended for future +- +-## General +- +-| Property | Status | Notes | +-|---|---|---| +-| `name` | No | Silently ignored by serde (unknown fields are skipped); not exposed in `DevcontainerConfig` | +-| `forwardPorts` | Yes | Numeric and string formats (e.g., `"8080:80"`, `"9090"`) extracted into `DevcontainerConfig::forwarded_ports`; merged with compose ports in compose mode | +-| `portsAttributes` | No | Not parsed | +-| `otherPortsAttributes` | No | Not parsed | +-| `updateRemoteUserUID` | No | Not parsed | +-| `containerEnv` | Yes | Baked into generated Dockerfile as `ENV` directives; also exposed in `DevcontainerConfig::container_env` | +-| `remoteEnv` | Yes | Merged into `DevcontainerConfig::environment` with variable substitution | +-| `containerUser` | No | Parsed but unused; not exposed in `DevcontainerConfig` | +-| `remoteUser` | Yes | Exposed as `DevcontainerConfig::remote_user` | +-| `userEnvProbe` | No | Not parsed | +-| `overrideCommand` | No | Parsed but unused | +-| `shutdownAction` | No | Not parsed | +- +-## Image +- +-| Property | Status | Notes | +-|---|---|---| +-| `image` | Yes | Used as `FROM` line when no Dockerfile is specified; defaults to `mcr.microsoft.com/devcontainers/base:ubuntu` | +- +-## Build (Dockerfile) +- +-| Property | Status | Notes | +-|---|---|---| +-| `build.dockerfile` | Yes | Resolved relative to devcontainer.json; content read and used as base Dockerfile | +-| `build.context` | Yes | Resolved with variable substitution; passed as `DevcontainerConfig::build_context` | +-| `build.args` | Yes | Parsed and exposed in `DevcontainerConfig::build_args` for passing to `docker build --build-arg` | +-| `build.target` | Yes | Parsed with variable substitution; exposed as `DevcontainerConfig::build_target` for passing to `docker build --target` | +-| `build.cacheFrom` | No | Not parsed | +-| `build.options` | No | Not parsed | +- +-## Compose +- +-| Property | Status | Notes | +-|---|---|---| +-| `dockerComposeFile` | Yes | Single path and array of paths supported; multiple files are merged (last wins for image/build/user; ports accumulate; environment overrides) | +-| `service` | Yes | Required when `dockerComposeFile` is set; used to extract service config | +-| `runServices` | No | Not parsed; all services assumed | +-| `shutdownAction` | No | Not parsed | +-| `overrideCommand` | No | Parsed but unused | +-| `workspaceFolder` | Yes | Defaults to `/workspaces/{repo-name}` | +-| `workspaceMount` | No | Parsed but unused | +- +-## Features +- +-| Property | Status | Notes | +-|---|---|---| +-| `features` | Yes | Fetched via `oras` CLI (OCI refs), local path copy (`./`/`../`), or HTTPS download. Topologically sorted by `installsAfter` and `dependsOn`. Dockerfile layers generated with options as env vars | +-| `features` (reference types) | Yes | OCI registry refs (default), local paths (`./feature`), and HTTPS URLs (`https://...feature.tgz`) | +-| Feature `dependsOn` | Yes | Hard dependencies auto-installed if missing; used for topological ordering alongside `installsAfter` | +-| Feature `containerEnv` | Yes | Collected from each feature in install order; merged with devcontainer.json `containerEnv` (devcontainer.json wins on conflicts) | +-| Feature `onCreateCommand` | Yes | Collected in install order and appended after devcontainer.json `onCreateCommand` | +-| Feature `postCreateCommand` | Yes | Collected in install order and appended after devcontainer.json `postCreateCommand` | +-| Feature `postStartCommand` | Yes | Collected in install order and appended after devcontainer.json `postStartCommand` | +-| `overrideFeatureInstallOrder` | No | Not parsed | +- +-## Lifecycle +- +-| Property | Status | Notes | +-|---|---|---| +-| `initializeCommand` | Yes | All three forms supported: string, array, object (parallel). Exposed as `DevcontainerConfig::initialize_commands` | +-| `onCreateCommand` | Yes | All three forms supported. Exposed as `DevcontainerConfig::on_create_commands` | +-| `updateContentCommand` | No | Not parsed | +-| `postCreateCommand` | Yes | All three forms supported. Exposed as `DevcontainerConfig::post_create_commands` | +-| `postStartCommand` | Yes | All three forms supported. Exposed as `DevcontainerConfig::post_start_commands` | +-| `postAttachCommand` | No | Not parsed | +-| `waitFor` | No | Not parsed | +- +-## Host +- +-| Property | Status | Notes | +-|---|---|---| +-| `hostRequirements` | No | Not parsed | +-| `init` | No | Not parsed | +-| `privileged` | No | Not parsed | +-| `capAdd` | No | Not parsed | +-| `securityOpt` | No | Not parsed | +-| `mounts` | No | Not parsed | +-| `gpuRequest` | No | Not parsed | +- +-## Customizations +- +-| Property | Status | Notes | +-|---|---|---| +-| `customizations` | No | Unknown fields are silently ignored by serde, so `customizations` is accepted but not processed | +- +-## Variables +- +-| Variable | Status | Notes | +-|---|---|---| +-| `${localWorkspaceFolder}` | Yes | Substituted via `VariableContext` | +-| `${localWorkspaceFolderBasename}` | Yes | Substituted via `VariableContext` | +-| `${containerWorkspaceFolder}` | Yes | Substituted via `VariableContext` | +-| `${containerWorkspaceFolderBasename}` | Yes | Derived from `containerWorkspaceFolder` by splitting on `/` | +-| `${localEnv:VAR}` | Yes | Reads from host environment; supports `:default` syntax | +-| `${containerEnv:VAR}` | No | Not implemented (requires running container) | +-| `${devcontainerId}` | No | Not implemented | +- +-## JSONC Support +- +-The parser supports JSONC (JSON with Comments): +-- Line comments (`//`) +-- Block comments (`/* */`) +-- Trailing commas before `}` and `]` +- +-## File Discovery +- +-Searched in order: +-1. `/.devcontainer/devcontainer.json` +-2. `/.devcontainer.json` +-3. Direct path if it ends in `devcontainer.json` +-4. Subdirectory format: `/.devcontainer//devcontainer.json` — subdirectories sorted alphabetically, first match used +diff --git a/lib/crates/fabro-devcontainer/INTEGRATION.md b/lib/crates/fabro-devcontainer/INTEGRATION.md +deleted file mode 100644 +index 2aec58188..000000000 +--- a/lib/crates/fabro-devcontainer/INTEGRATION.md ++++ /dev/null +@@ -1,191 +0,0 @@ +-# Integrating DevcontainerConfig with DaytonaSandbox +- +-How to wire the parsed `DevcontainerConfig` into sandbox creation. +- +-## Overview +- +-`DevcontainerResolver::resolve(repo_path)` reads a repository's devcontainer.json and produces a `DevcontainerConfig` containing everything needed to build and configure a sandbox: +- +-```rust +-pub struct DevcontainerConfig { +- pub dockerfile: String, // Generated Dockerfile content +- pub build_context: PathBuf, // Directory for docker build +- pub build_args: HashMap, // docker build --build-arg flags +- pub build_target: Option, // docker build --target +- pub initialize_commands: Vec, // Host-side pre-build commands +- pub on_create_commands: Vec, // Container after first creation +- pub post_create_commands: Vec, // Container post-creation setup +- pub post_start_commands: Vec, // Container on-each-start commands +- pub environment: HashMap, // remoteEnv merged +- pub container_env: HashMap, // containerEnv (also in Dockerfile) +- pub remote_user: Option, // Non-root user +- pub workspace_folder: String, // Working directory inside container +- pub forwarded_ports: Vec, // Ports to expose +- pub compose_files: Vec, // Compose file paths (empty if not compose mode) +- pub compose_service: Option, +-} +-``` +- +-## Mapping Devcontainer to Daytona +- +-### Dockerfile and Image Build +- +-`config.dockerfile` contains the full Dockerfile content (not a path). For image-only configs, this is a single `FROM` line. For Dockerfile configs, it is the file content with feature layers appended. +- +-- Build a Docker image from `config.dockerfile` using `config.build_context` as the build context directory. +-- Use this image as the Daytona sandbox snapshot/base image. +- +-### Environment Variables +- +-`config.environment` contains the `remoteEnv` values (with variables already substituted). These are runtime-only environment variables, not baked into the Dockerfile. `config.container_env` contains `containerEnv` values (baked into the generated Dockerfile as `ENV` directives). +- +-- Pass `config.environment` as runtime environment variables when starting the sandbox. +-- `containerEnv` values are already in the Dockerfile; `config.container_env` is available for reference. +- +-### Workspace Folder +- +-`config.workspace_folder` defaults to `/workspaces/{repo-name}`. +- +-- Set this as the sandbox working directory. +-- Mount or clone the repository into this path. +- +-### Remote User +- +-`config.remote_user` specifies the non-root user for running dev tools. +- +-- Use this as the sandbox exec user when running lifecycle commands and user sessions. +-- Falls back to root if not set. +- +-### Forwarded Ports +- +-`config.forwarded_ports` lists ports to expose (first port = default preview). +- +-- Use the first port as the default preview URL for the sandbox. +-- Forward all listed ports from the sandbox to the user. +- +-## Docker Compose DinD Flow +- +-When `config.compose_files` is non-empty, the devcontainer uses Docker Compose mode. +- +-### Strategy +- +-Run Docker-in-Docker (DinD) inside the Daytona sandbox: +- +-1. Create a sandbox using the extracted Dockerfile from the compose service. +-2. Install Docker daemon inside the sandbox (or use a DinD-capable base image). +-3. Copy the compose file and related context into the sandbox. +-4. Run `docker compose up` inside the sandbox to start all services. +-5. The compose service ports become available on localhost inside the sandbox. +-6. Forward those ports from the sandbox to the user. +- +-### Port Forwarding +- +-Ports come from the compose service's `ports` configuration (parsed by `compose::parse_compose`). The compose parser extracts container-side ports from formats like `"8080:80"`, `"3000"`, and `5432`. +- +-## Lifecycle Hook Execution Order +- +-The devcontainer spec defines this execution order: +- +-| Hook | Where | When | `DevcontainerConfig` field | +-|---|---|---|---| +-| `initializeCommand` | Host | Before build | `initialize_commands` | +-| `onCreateCommand` | Container | After first creation | `on_create_commands` | +-| `updateContentCommand` | Container | After create/content update | Not captured (not parsed) | +-| `postCreateCommand` | Container | After create/content update | `post_create_commands` | +-| `postStartCommand` | Container | On each start | `post_start_commands` | +-| `postAttachCommand` | Container | On each attach | Not captured (not parsed) | +- +-### Command Types +- +-Each command is represented as a `Command` enum: +- +-```rust +-pub enum Command { +- Shell(String), // "npm install" +- Args(Vec), // ["npm", "install"] +- Parallel(HashMap), // {"install": "npm install", "build": "npm run build"} +-} +-``` +- +-- `Shell` -- execute via `sh -c ""` +-- `Args` -- execute directly as argv +-- `Parallel` -- execute all values concurrently, wait for all to complete +- +-### Execution in Sandbox +- +-``` +-1. Run initialize_commands on HOST (before sandbox creation) +-2. Build image from config.dockerfile (pass config.build_args as --build-arg flags) +-3. Create sandbox from image +-4. Run on_create_commands in sandbox (as remote_user if set) +-5. Run post_create_commands in sandbox (as remote_user if set) +-6. Run post_start_commands in sandbox (as remote_user if set) +-``` +- +-## Example Integration Code +- +-```rust +-use arc_devcontainer::{DevcontainerResolver, DevcontainerConfig, Command}; +- +-async fn create_sandbox_from_devcontainer(repo_path: &Path) -> Result { +- let config = DevcontainerResolver::resolve(repo_path).await?; +- +- // 1. Run host-side init commands +- for cmd in &config.initialize_commands { +- run_host_command(cmd).await?; +- } +- +- // 2. Build image and create sandbox +- let sandbox = if !config.compose_files.is_empty() { +- // Compose mode: build from extracted service Dockerfile, then run compose inside +- let sandbox = daytona.create_from_dockerfile( +- &config.dockerfile, +- &config.build_context, +- ).await?; +- setup_dind(&sandbox).await?; +- sandbox.exec("docker compose up -d").await?; +- sandbox +- } else { +- // Image/Dockerfile mode: build directly +- daytona.create_from_dockerfile( +- &config.dockerfile, +- &config.build_context, +- ).await? +- }; +- +- // 3. Configure environment +- for (key, value) in &config.environment { +- sandbox.set_env(key, value).await?; +- } +- +- // 4. Set working directory +- sandbox.set_workdir(&config.workspace_folder).await?; +- +- // 5. Run lifecycle hooks +- let user = config.remote_user.as_deref(); +- for cmd in &config.on_create_commands { +- sandbox.exec_command(cmd, user).await?; +- } +- for cmd in &config.post_create_commands { +- sandbox.exec_command(cmd, user).await?; +- } +- for cmd in &config.post_start_commands { +- sandbox.exec_command(cmd, user).await?; +- } +- +- // 6. Set up port forwarding +- if let Some(port) = config.forwarded_ports.first() { +- sandbox.set_preview_port(*port).await?; +- } +- +- Ok(sandbox) +-} +-``` +- +-## Edge Cases and Limitations +- +-- **Features require `oras`**: Feature resolution shells out to `oras` CLI for OCI registry pulls. The resolver attempts auto-install if `oras` is not on PATH. +-- **No `updateContentCommand`**: This lifecycle hook is not parsed. +-- **No `postAttachCommand`**: Not parsed. Attach-time hooks would need to run on each user session connection. +-- **`${containerEnv:VAR}` not supported**: Variable substitution only covers host-side variables. Container-side env vars require a running container. +-- **Port forwarding**: Both numeric and string port formats (e.g., `"8080:80"`, `"9090"`) are supported in `forwardPorts`. In compose mode, `forwardPorts` are merged with compose service ports. +diff --git a/lib/crates/fabro-devcontainer/src/compose.rs b/lib/crates/fabro-devcontainer/src/compose.rs +deleted file mode 100644 +index 8b4a5a95f..000000000 +--- a/lib/crates/fabro-devcontainer/src/compose.rs ++++ /dev/null +@@ -1,437 +0,0 @@ +-use std::collections::HashMap; +-use std::path::{Path, PathBuf}; +- +-use tokio::fs; +- +-/// Extracted configuration from a Docker Compose service. +-#[derive(Debug, Clone, Default)] +-pub(crate) struct ComposeServiceSpec { +- pub image: Option, +- pub build: Option, +- pub ports: Vec, +- pub environment: HashMap, +- pub user: Option, +-} +- +-/// Build configuration from a Docker Compose service. +-#[derive(Debug, Clone)] +-pub(crate) struct ComposeBuild { +- pub context: String, +- pub dockerfile: Option, +-} +- +-/// Parse a Docker Compose file and extract config for the named service. +-pub(crate) async fn parse_compose( +- compose_path: &Path, +- service_name: &str, +-) -> Result { +- let contents = fs::read_to_string(compose_path).await.map_err(|e| { +- format!( +- "failed to read compose file {}: {e}", +- compose_path.display() +- ) +- })?; +- +- let doc: serde_yaml::Value = serde_yaml::from_str(&contents) +- .map_err(|e| format!("failed to parse YAML {}: {e}", compose_path.display()))?; +- +- let service = doc +- .get("services") +- .and_then(|s| s.get(service_name)) +- .ok_or_else(|| format!("service '{service_name}' not found in compose file"))?; +- +- let image = service +- .get("image") +- .and_then(|v| v.as_str()) +- .map(String::from); +- +- let build = parse_build(service); +- let ports = parse_ports(service); +- let environment = parse_environment(service); +- +- let user = service +- .get("user") +- .and_then(|v| v.as_str()) +- .map(String::from); +- +- Ok(ComposeServiceSpec { +- image, +- build, +- ports, +- environment, +- user, +- }) +-} +- +-fn parse_build(service: &serde_yaml::Value) -> Option { +- let build_val = service.get("build")?; +- +- if let Some(context) = build_val.as_str() { +- return Some(ComposeBuild { +- context: context.to_string(), +- dockerfile: None, +- }); +- } +- +- if build_val.is_mapping() { +- let context = build_val +- .get("context") +- .and_then(|v| v.as_str()) +- .unwrap_or(".") +- .to_string(); +- let dockerfile = build_val +- .get("dockerfile") +- .and_then(|v| v.as_str()) +- .map(String::from); +- return Some(ComposeBuild { +- context, +- dockerfile, +- }); +- } +- +- None +-} +- +-fn parse_ports(service: &serde_yaml::Value) -> Vec { +- let Some(ports_val) = service.get("ports") else { +- return Vec::new(); +- }; +- let Some(ports_seq) = ports_val.as_sequence() else { +- return Vec::new(); +- }; +- +- ports_seq +- .iter() +- .filter_map(|entry| { +- if let Some(n) = entry.as_u64() { +- return u16::try_from(n).ok(); +- } +- if let Some(s) = entry.as_str() { +- // Formats: "8080:80", "3000", "8080:80/tcp" +- let s = s.split('/').next().unwrap_or(s); // strip protocol +- return if let Some((_host, container)) = s.split_once(':') { +- container.parse::().ok() +- } else { +- s.parse::().ok() +- }; +- } +- None +- }) +- .collect() +-} +- +-fn parse_environment(service: &serde_yaml::Value) -> HashMap { +- let Some(env_val) = service.get("environment") else { +- return HashMap::new(); +- }; +- +- // Array form: ["KEY=VALUE", ...] +- if let Some(seq) = env_val.as_sequence() { +- return seq +- .iter() +- .filter_map(|v| { +- let s = v.as_str()?; +- let (key, value) = s.split_once('=')?; +- Some((key.to_string(), value.to_string())) +- }) +- .collect(); +- } +- +- // Object form: { KEY: VALUE, ... } +- if let Some(mapping) = env_val.as_mapping() { +- return mapping +- .iter() +- .filter_map(|(k, v)| { +- let key = k.as_str()?.to_string(); +- let value = match v { +- serde_yaml::Value::String(s) => s.clone(), +- serde_yaml::Value::Number(n) => n.to_string(), +- serde_yaml::Value::Bool(b) => b.to_string(), +- serde_yaml::Value::Null => String::new(), +- _ => return None, +- }; +- Some((key, value)) +- }) +- .collect(); +- } +- +- HashMap::new() +-} +- +-/// Parse multiple Docker Compose files and merge config for the named service. +-/// Later files override earlier files for image/build/user; ports accumulate +-/// (deduped); environment keys from later files override earlier ones. +-pub(crate) async fn parse_compose_multi( +- compose_paths: &[PathBuf], +- service_name: &str, +-) -> Result { +- let mut merged = ComposeServiceSpec::default(); +- let mut found_service = false; +- +- for path in compose_paths { +- let contents = fs::read_to_string(path) +- .await +- .map_err(|e| format!("failed to read compose file {}: {e}", path.display()))?; +- +- let doc: serde_yaml::Value = serde_yaml::from_str(&contents) +- .map_err(|e| format!("failed to parse YAML {}: {e}", path.display()))?; +- +- let Some(service) = doc.get("services").and_then(|s| s.get(service_name)) else { +- continue; +- }; +- found_service = true; +- +- if let Some(image) = service.get("image").and_then(|v| v.as_str()) { +- merged.image = Some(image.to_string()); +- } +- +- if let Some(build) = parse_build(service) { +- merged.build = Some(build); +- } +- +- if let Some(user) = service.get("user").and_then(|v| v.as_str()) { +- merged.user = Some(user.to_string()); +- } +- +- for port in parse_ports(service) { +- if !merged.ports.contains(&port) { +- merged.ports.push(port); +- } +- } +- +- for (k, v) in parse_environment(service) { +- merged.environment.insert(k, v); +- } +- } +- +- if !found_service { +- return Err(format!( +- "service '{service_name}' not found in any compose file" +- )); +- } +- +- Ok(merged) +-} +- +-#[cfg(test)] +-#[expect( +- clippy::disallowed_types, +- reason = "test helpers write compose fixtures to temp files via sync std::io" +-)] +-mod tests { +- use std::io::Write; +- +- use super::*; +- +- fn write_compose(content: &str) -> tempfile::NamedTempFile { +- let mut f = tempfile::NamedTempFile::new().unwrap(); +- f.write_all(content.as_bytes()).unwrap(); +- f +- } +- +- #[tokio::test] +- async fn service_with_image_only() { +- let f = write_compose( +- r" +-services: +- web: +- image: nginx:latest +-", +- ); +- let cfg = parse_compose(f.path(), "web").await.unwrap(); +- assert_eq!(cfg.image.as_deref(), Some("nginx:latest")); +- assert!(cfg.build.is_none()); +- assert!(cfg.ports.is_empty()); +- assert!(cfg.environment.is_empty()); +- assert!(cfg.user.is_none()); +- } +- +- #[tokio::test] +- async fn service_with_build_string() { +- let f = write_compose( +- r" +-services: +- app: +- build: ./src +-", +- ); +- let cfg = parse_compose(f.path(), "app").await.unwrap(); +- let build = cfg.build.unwrap(); +- assert_eq!(build.context, "./src"); +- assert!(build.dockerfile.is_none()); +- } +- +- #[tokio::test] +- async fn service_with_build_object() { +- let f = write_compose( +- r" +-services: +- app: +- build: +- context: ./app +- dockerfile: Dockerfile.dev +-", +- ); +- let cfg = parse_compose(f.path(), "app").await.unwrap(); +- let build = cfg.build.unwrap(); +- assert_eq!(build.context, "./app"); +- assert_eq!(build.dockerfile.as_deref(), Some("Dockerfile.dev")); +- } +- +- #[tokio::test] +- async fn ports_various_formats() { +- let f = write_compose( +- r#" +-services: +- web: +- image: nginx +- ports: +- - "8080:80" +- - "3000" +- - 5432 +- - "9090:9090/tcp" +-"#, +- ); +- let cfg = parse_compose(f.path(), "web").await.unwrap(); +- assert_eq!(cfg.ports, vec![80, 3000, 5432, 9090]); +- } +- +- #[tokio::test] +- async fn environment_as_array() { +- let f = write_compose( +- r#" +-services: +- app: +- image: myapp +- environment: +- - "DATABASE_URL=postgres://localhost/db" +- - "DEBUG=true" +-"#, +- ); +- let cfg = parse_compose(f.path(), "app").await.unwrap(); +- assert_eq!(cfg.environment.len(), 2); +- assert_eq!(cfg.environment["DATABASE_URL"], "postgres://localhost/db"); +- assert_eq!(cfg.environment["DEBUG"], "true"); +- } +- +- #[tokio::test] +- async fn environment_as_object() { +- let f = write_compose( +- r" +-services: +- app: +- image: myapp +- environment: +- RAILS_ENV: production +- PORT: 3000 +-", +- ); +- let cfg = parse_compose(f.path(), "app").await.unwrap(); +- assert_eq!(cfg.environment.len(), 2); +- assert_eq!(cfg.environment["RAILS_ENV"], "production"); +- assert_eq!(cfg.environment["PORT"], "3000"); +- } +- +- #[tokio::test] +- async fn service_not_found() { +- let f = write_compose( +- r" +-services: +- web: +- image: nginx +-", +- ); +- let err = parse_compose(f.path(), "missing").await.unwrap_err(); +- assert!(err.contains("service 'missing' not found")); +- } +- +- #[tokio::test] +- async fn file_not_found() { +- let err = parse_compose(Path::new("/nonexistent/docker-compose.yml"), "web") +- .await +- .unwrap_err(); +- assert!(err.contains("failed to read compose file")); +- } +- +- #[tokio::test] +- async fn service_with_user() { +- let f = write_compose( +- r#" +-services: +- app: +- image: myapp +- user: "1000:1000" +-"#, +- ); +- let cfg = parse_compose(f.path(), "app").await.unwrap(); +- assert_eq!(cfg.user.as_deref(), Some("1000:1000")); +- } +- +- #[tokio::test] +- async fn multi_compose_merge() { +- let base = write_compose( +- r#" +-services: +- app: +- image: node:20 +- ports: +- - "3000:3000" +- environment: +- - "NODE_ENV=development" +-"#, +- ); +- let over = write_compose( +- r#" +-services: +- app: +- image: node:22 +- ports: +- - "3000:3000" +- - "9229:9229" +- environment: +- - "DEBUG=true" +-"#, +- ); +- let paths = vec![base.path().to_path_buf(), over.path().to_path_buf()]; +- let cfg = parse_compose_multi(&paths, "app").await.unwrap(); +- assert_eq!(cfg.image.as_deref(), Some("node:22")); +- assert_eq!(cfg.ports, vec![3000, 9229]); +- assert_eq!(cfg.environment["NODE_ENV"], "development"); +- assert_eq!(cfg.environment["DEBUG"], "true"); +- } +- +- #[tokio::test] +- async fn multi_compose_service_not_found() { +- let f = write_compose( +- r" +-services: +- web: +- image: nginx +-", +- ); +- let paths = vec![f.path().to_path_buf()]; +- let err = parse_compose_multi(&paths, "missing").await.unwrap_err(); +- assert!(err.contains("service 'missing' not found")); +- } +- +- #[tokio::test] +- async fn multi_compose_skips_file_without_service() { +- let base = write_compose( +- r" +-services: +- db: +- image: postgres:15 +-", +- ); +- let over = write_compose( +- r" +-services: +- app: +- image: node:22 +-", +- ); +- let paths = vec![base.path().to_path_buf(), over.path().to_path_buf()]; +- let cfg = parse_compose_multi(&paths, "app").await.unwrap(); +- assert_eq!(cfg.image.as_deref(), Some("node:22")); +- } +-} +diff --git a/lib/crates/fabro-devcontainer/src/dockerfile.rs b/lib/crates/fabro-devcontainer/src/dockerfile.rs +deleted file mode 100644 +index 24e11268b..000000000 +--- a/lib/crates/fabro-devcontainer/src/dockerfile.rs ++++ /dev/null +@@ -1,221 +0,0 @@ +-use std::collections::HashMap; +- +-use crate::features::FeatureLayer; +- +-/// Generate a combined Dockerfile from base + features + env + user. +-pub(crate) fn generate( +- base_dockerfile: &str, +- feature_layers: &[FeatureLayer], +- container_env: &HashMap, +- remote_user: Option<&str>, +-) -> String { +- let mut sections: Vec = Vec::new(); +- +- sections.push("# Generated by fabro-devcontainer".to_string()); +- sections.push(base_dockerfile.to_string()); +- +- for layer in feature_layers { +- sections.push(layer.dockerfile_snippet.clone()); +- } +- +- if !container_env.is_empty() { +- let mut keys: Vec<&String> = container_env.keys().collect(); +- keys.sort(); +- let env_lines: Vec = keys +- .iter() +- .map(|k| format!("ENV {}={}", k, container_env[*k])) +- .collect(); +- sections.push(env_lines.join("\n")); +- } +- +- if let Some(user) = remote_user { +- sections.push(format!("USER {user}")); +- } +- +- let mut result = sections.join("\n\n"); +- result.push('\n'); +- result +-} +- +-#[cfg(test)] +-mod tests { +- use super::*; +- +- fn make_layer(id: &str, dir_name: &str, snippet: &str) -> FeatureLayer { +- FeatureLayer { +- id: id.to_string(), +- dir_name: dir_name.to_string(), +- dockerfile_snippet: snippet.to_string(), +- } +- } +- +- #[test] +- fn base_image_only() { +- let result = generate("FROM ubuntu:22.04", &[], &HashMap::new(), None); +- insta::assert_snapshot!(result, @r" +- # Generated by fabro-devcontainer +- +- FROM ubuntu:22.04 +- "); +- } +- +- #[test] +- fn base_dockerfile_preserved_as_is() { +- let base = "FROM ubuntu:22.04\nRUN apt-get update\nRUN apt-get install -y curl"; +- let result = generate(base, &[], &HashMap::new(), None); +- insta::assert_snapshot!(result, @r" +- # Generated by fabro-devcontainer +- +- FROM ubuntu:22.04 +- RUN apt-get update +- RUN apt-get install -y curl +- "); +- } +- +- #[test] +- fn with_feature_layers() { +- let layers = vec![ +- make_layer("node", "node-1", "RUN install-node.sh"), +- make_layer("python", "python-1", "RUN install-python.sh"), +- ]; +- let result = generate("FROM ubuntu:22.04", &layers, &HashMap::new(), None); +- insta::assert_snapshot!(result, @r" +- # Generated by fabro-devcontainer +- +- FROM ubuntu:22.04 +- +- RUN install-node.sh +- +- RUN install-python.sh +- "); +- } +- +- #[test] +- fn with_env_sorted() { +- let mut env = HashMap::new(); +- env.insert("ZEBRA".to_string(), "stripes".to_string()); +- env.insert("APPLE".to_string(), "red".to_string()); +- env.insert("MANGO".to_string(), "yellow".to_string()); +- let result = generate("FROM alpine", &[], &env, None); +- insta::assert_snapshot!(result, @r" +- # Generated by fabro-devcontainer +- +- FROM alpine +- +- ENV APPLE=red +- ENV MANGO=yellow +- ENV ZEBRA=stripes +- "); +- } +- +- #[test] +- fn with_remote_user() { +- let result = generate("FROM alpine", &[], &HashMap::new(), Some("vscode")); +- insta::assert_snapshot!(result, @r" +- # Generated by fabro-devcontainer +- +- FROM alpine +- +- USER vscode +- "); +- } +- +- #[test] +- fn all_combined() { +- let layers = vec![make_layer("node", "node-1", "RUN install-node.sh")]; +- let mut env = HashMap::new(); +- env.insert("PATH".to_string(), "/usr/local/bin".to_string()); +- env.insert("HOME".to_string(), "/home/vscode".to_string()); +- let result = generate("FROM ubuntu:22.04", &layers, &env, Some("vscode")); +- insta::assert_snapshot!(result, @r" +- # Generated by fabro-devcontainer +- +- FROM ubuntu:22.04 +- +- RUN install-node.sh +- +- ENV HOME=/home/vscode +- ENV PATH=/usr/local/bin +- +- USER vscode +- "); +- } +- +- #[test] +- fn empty_feature_layers_no_extra_blank_lines() { +- let result = generate("FROM alpine", &[], &HashMap::new(), Some("dev")); +- insta::assert_snapshot!(result, @r" +- # Generated by fabro-devcontainer +- +- FROM alpine +- +- USER dev +- "); +- } +- +- #[test] +- fn empty_env_map_treated_as_none() { +- let env = HashMap::new(); +- let result = generate("FROM alpine", &[], &env, None); +- insta::assert_snapshot!(result, @r" +- # Generated by fabro-devcontainer +- +- FROM alpine +- "); +- } +- +- #[test] +- fn multiline_base_dockerfile() { +- let base = "FROM ubuntu:22.04 AS builder\n\ +- RUN apt-get update && apt-get install -y build-essential\n\ +- COPY . /app\n\ +- RUN make\n\ +- \n\ +- FROM ubuntu:22.04\n\ +- COPY --from=builder /app/bin /usr/local/bin"; +- let result = generate(base, &[], &HashMap::new(), None); +- insta::assert_snapshot!(result, @r" +- # Generated by fabro-devcontainer +- +- FROM ubuntu:22.04 AS builder +- RUN apt-get update && apt-get install -y build-essential +- COPY . /app +- RUN make +- +- FROM ubuntu:22.04 +- COPY --from=builder /app/bin /usr/local/bin +- "); +- } +- +- #[test] +- fn container_env_only() { +- let mut cenv = HashMap::new(); +- cenv.insert("DEBIAN_FRONTEND".to_string(), "noninteractive".to_string()); +- let result = generate("FROM alpine", &[], &cenv, None); +- insta::assert_snapshot!(result, @r" +- # Generated by fabro-devcontainer +- +- FROM alpine +- +- ENV DEBIAN_FRONTEND=noninteractive +- "); +- } +- +- #[test] +- fn container_env_with_multiple_keys() { +- let mut cenv = HashMap::new(); +- cenv.insert("ALPHA".to_string(), "first".to_string()); +- cenv.insert("BETA".to_string(), "second".to_string()); +- cenv.insert("GAMMA".to_string(), "third".to_string()); +- let result = generate("FROM alpine", &[], &cenv, None); +- insta::assert_snapshot!(result, @r" +- # Generated by fabro-devcontainer +- +- FROM alpine +- +- ENV ALPHA=first +- ENV BETA=second +- ENV GAMMA=third +- "); +- } +-} +diff --git a/lib/crates/fabro-devcontainer/src/features.rs b/lib/crates/fabro-devcontainer/src/features.rs +deleted file mode 100644 +index 14aef01ee..000000000 +--- a/lib/crates/fabro-devcontainer/src/features.rs ++++ /dev/null +@@ -1,1292 +0,0 @@ +-use std::collections::{HashMap, HashSet, VecDeque}; +-use std::fmt::Write; +-use std::path::Path; +- +-use fabro_static::EnvVars; +-use tokio::fs; +-use tokio::process::Command; +-use tracing::info; +- +-use crate::DevcontainerError; +-use crate::types::{FeatureMetadata, LifecycleCommand}; +- +-/// A resolved feature layer ready to be inserted into a Dockerfile. +-#[derive(Debug, Clone)] +-pub(crate) struct FeatureLayer { +- /// Feature identifier (e.g. "ghcr.io/devcontainers/features/node:1") +- pub id: String, +- /// Directory name for COPY +- pub dir_name: String, +- /// Dockerfile snippet for this feature +- pub dockerfile_snippet: String, +-} +- +-/// All resolved feature data: layers, environment, and lifecycle hooks. +-#[derive(Debug, Clone, Default)] +-pub(crate) struct ResolvedFeatures { +- pub layers: Vec, +- pub container_env: HashMap, +- pub on_create_commands: Vec, +- pub post_create_commands: Vec, +- pub post_start_commands: Vec, +-} +- +-/// Extract the directory name from a feature ID. +-/// Handles OCI refs ("ghcr.io/devcontainers/features/node:1" → "node"), +-/// local paths ("./my-feature" → "my-feature"), +-/// and HTTPS URLs ("https://example.com/feature.tgz" → "feature"). +-fn dir_name_from_id(feature_id: &str) -> String { +- // Local path: strip leading ./ or ../ +- if feature_id.starts_with("./") || feature_id.starts_with("../") { +- let stripped = feature_id +- .trim_start_matches("../") +- .trim_start_matches("./"); +- return stripped.rsplit('/').next().unwrap_or(stripped).to_string(); +- } +- +- // HTTPS URL: take filename, strip .tgz extension +- if feature_id.starts_with("https://") { +- let filename = feature_id.rsplit('/').next().unwrap_or(feature_id); +- return filename +- .strip_suffix(".tgz") +- .or_else(|| filename.strip_suffix(".tar.gz")) +- .unwrap_or(filename) +- .to_string(); +- } +- +- // OCI ref: strip tag, take last path segment +- let without_tag = feature_id.split(':').next().unwrap_or(feature_id); +- without_tag +- .rsplit('/') +- .next() +- .unwrap_or(without_tag) +- .to_string() +-} +- +-/// Ensure `oras` CLI is available, installing it if necessary. +-#[expect( +- clippy::disallowed_methods, +- reason = "OCI feature fetching installs oras under the user's HOME on Linux." +-)] +-async fn ensure_oras() -> crate::Result<()> { +- let check = Command::new("which") +- .arg("oras") +- .output() +- .await +- .map_err(|e| DevcontainerError::OrasInstall(format!("failed to check for oras: {e}")))?; +- +- if check.status.success() { +- return Ok(()); +- } +- +- info!("oras not found, attempting to install"); +- +- if cfg!(target_os = "macos") { +- let status = Command::new("brew") +- .args(["install", "oras"]) +- .status() +- .await +- .map_err(|e| { +- DevcontainerError::OrasInstall(format!("failed to run brew install oras: {e}")) +- })?; +- +- if !status.success() { +- return Err(DevcontainerError::OrasInstall( +- "brew install oras failed".to_string(), +- )); +- } +- } else { +- // Linux: download from GitHub releases to ~/.local/bin/ +- let home = std::env::var(EnvVars::HOME) +- .map_err(|_| DevcontainerError::OrasInstall("HOME not set".to_string()))?; +- let bin_dir = format!("{home}/.local/bin"); +- +- fs::create_dir_all(&bin_dir).await.map_err(|e| { +- DevcontainerError::OrasInstall(format!("failed to create {bin_dir}: {e}")) +- })?; +- +- let version = "1.2.0"; +- let arch = if cfg!(target_arch = "aarch64") { +- "arm64" +- } else { +- "amd64" +- }; +- let url = format!( +- "https://github.com/oras-project/oras/releases/download/v{version}/oras_{version}_linux_{arch}.tar.gz" +- ); +- +- let status = Command::new("sh") +- .args([ +- "-c", +- &format!("curl -fsSL '{url}' | tar xzf - -C '{bin_dir}' oras"), +- ]) +- .status() +- .await +- .map_err(|e| DevcontainerError::OrasInstall(format!("failed to download oras: {e}")))?; +- +- if !status.success() { +- return Err(DevcontainerError::OrasInstall( +- "downloading oras from GitHub releases failed".to_string(), +- )); +- } +- } +- +- Ok(()) +-} +- +-/// Find the first `.tgz` file in a directory. +-async fn find_tgz(dir: &Path) -> Option { +- let mut entries = fs::read_dir(dir).await.ok()?; +- while let Ok(Some(entry)) = entries.next_entry().await { +- if let Some(name) = entry.file_name().to_str() { +- if Path::new(name) +- .extension() +- .is_some_and(|ext| ext.eq_ignore_ascii_case("tgz")) +- { +- return Some(name.to_string()); +- } +- } +- } +- None +-} +- +-/// Extract a tgz archive in the given directory. +-async fn extract_tgz(feature_dir: &Path, tgz_name: &str, feature_id: &str) -> crate::Result<()> { +- let status = Command::new("tar") +- .args(["xzf", tgz_name]) +- .current_dir(feature_dir) +- .status() +- .await +- .map_err(|e| DevcontainerError::Feature(format!("failed to extract tgz: {e}")))?; +- +- if !status.success() { +- return Err(DevcontainerError::Feature(format!( +- "tar extraction failed for {feature_id}" +- ))); +- } +- Ok(()) +-} +- +-/// Read and parse devcontainer-feature.json from a feature directory. +-async fn read_feature_metadata(feature_dir: &Path) -> crate::Result { +- let metadata_path = feature_dir.join("devcontainer-feature.json"); +- let metadata_str = fs::read_to_string(&metadata_path).await.map_err(|e| { +- DevcontainerError::Feature(format!("failed to read {}: {e}", metadata_path.display())) +- })?; +- +- serde_json::from_str(&metadata_str).map_err(|e| { +- DevcontainerError::Feature(format!("failed to parse {}: {e}", metadata_path.display())) +- }) +-} +- +-/// Create a feature output directory under the temp dir. +-async fn create_feature_dir( +- output_dir: &Path, +- feature_id: &str, +-) -> crate::Result { +- let dir_name = dir_name_from_id(feature_id); +- let feature_dir = output_dir.join(&dir_name); +- fs::create_dir_all(&feature_dir).await.map_err(|e| { +- DevcontainerError::Feature(format!( +- "failed to create dir {}: {e}", +- feature_dir.display() +- )) +- })?; +- Ok(feature_dir) +-} +- +-/// Fetch a single OCI feature using `oras pull` and extract its contents. +-async fn fetch_feature_oci(feature_id: &str, output_dir: &Path) -> crate::Result { +- let feature_dir = create_feature_dir(output_dir, feature_id).await?; +- +- info!(feature_id, "pulling feature with oras"); +- +- let output = Command::new("oras") +- .args(["pull", feature_id, "-o"]) +- .arg(&feature_dir) +- .output() +- .await +- .map_err(|e| DevcontainerError::OrasCommand(format!("failed to run oras pull: {e}")))?; +- +- if !output.status.success() { +- let stderr = String::from_utf8_lossy(&output.stderr); +- return Err(DevcontainerError::OrasCommand(format!( +- "oras pull {feature_id} failed: {stderr}" +- ))); +- } +- +- // OCI registries may name the tgz with a feature suffix (e.g. +- // devcontainer-feature-node.tgz) +- if let Some(tgz) = find_tgz(&feature_dir).await { +- extract_tgz(&feature_dir, &tgz, feature_id).await?; +- } +- +- read_feature_metadata(&feature_dir).await +-} +- +-/// Fetch a local feature by copying its directory. +-async fn fetch_feature_local( +- feature_id: &str, +- output_dir: &Path, +- devcontainer_dir: &Path, +-) -> crate::Result { +- let local_path = devcontainer_dir.join(feature_id); +- if !local_path.is_dir() { +- return Err(DevcontainerError::Feature(format!( +- "local feature path not found: {}", +- local_path.display() +- ))); +- } +- +- let feature_dir = create_feature_dir(output_dir, feature_id).await?; +- copy_dir_recursive(&local_path, &feature_dir).await?; +- +- read_feature_metadata(&feature_dir).await +-} +- +-/// Fetch a feature from an HTTPS URL (tgz archive). +-async fn fetch_feature_https( +- feature_id: &str, +- output_dir: &Path, +-) -> crate::Result { +- let feature_dir = create_feature_dir(output_dir, feature_id).await?; +- +- info!(feature_id, "downloading feature from HTTPS"); +- +- let response = fabro_http::http_client() +- .map_err(|e| DevcontainerError::Feature(format!("failed to build HTTP client: {e}")))? +- .get(feature_id) +- .send() +- .await +- .map_err(|e| DevcontainerError::Feature(format!("failed to download {feature_id}: {e}")))?; +- +- if !response.status().is_success() { +- return Err(DevcontainerError::Feature(format!( +- "HTTP {} downloading {feature_id}", +- response.status() +- ))); +- } +- +- let bytes = response.bytes().await.map_err(|e| { +- DevcontainerError::Feature(format!("failed to read response for {feature_id}: {e}")) +- })?; +- +- let tgz_path = feature_dir.join("devcontainer-feature.tgz"); +- fs::write(&tgz_path, &bytes).await.map_err(|e| { +- DevcontainerError::Feature(format!("failed to write {}: {e}", tgz_path.display())) +- })?; +- +- extract_tgz(&feature_dir, "devcontainer-feature.tgz", feature_id).await?; +- +- read_feature_metadata(&feature_dir).await +-} +- +-/// Dispatch feature fetch based on the feature ID prefix. +-async fn fetch_feature_dispatch( +- feature_id: &str, +- output_dir: &Path, +- devcontainer_dir: &Path, +- oras_checked: &mut bool, +-) -> crate::Result { +- if feature_id.starts_with("./") || feature_id.starts_with("../") { +- fetch_feature_local(feature_id, output_dir, devcontainer_dir).await +- } else if feature_id.starts_with("https://") { +- fetch_feature_https(feature_id, output_dir).await +- } else { +- if !*oras_checked { +- ensure_oras().await?; +- *oras_checked = true; +- } +- fetch_feature_oci(feature_id, output_dir).await +- } +-} +- +-/// Recursively copy a directory. +-async fn copy_dir_recursive(src: &Path, dst: &Path) -> crate::Result<()> { +- fs::create_dir_all(dst).await.map_err(|e| { +- DevcontainerError::Feature(format!("failed to create dir {}: {e}", dst.display())) +- })?; +- +- let mut entries = fs::read_dir(src).await.map_err(|e| { +- DevcontainerError::Feature(format!("failed to read dir {}: {e}", src.display())) +- })?; +- +- while let Some(entry) = entries +- .next_entry() +- .await +- .map_err(|e| DevcontainerError::Feature(format!("failed to read dir entry: {e}")))? +- { +- let entry_path = entry.path(); +- let dest_path = dst.join(entry.file_name()); +- +- if entry_path.is_dir() { +- Box::pin(copy_dir_recursive(&entry_path, &dest_path)).await?; +- } else { +- fs::copy(&entry_path, &dest_path).await.map_err(|e| { +- DevcontainerError::Feature(format!( +- "failed to copy {} to {}: {e}", +- entry_path.display(), +- dest_path.display() +- )) +- })?; +- } +- } +- +- Ok(()) +-} +- +-/// Topological sort of features based on `installsAfter` and `dependsOn` +-/// dependencies. Uses Kahn's algorithm. Features without ordering constraints +-/// maintain input order. +-fn topo_sort( +- feature_ids: &[String], +- metadata_map: &HashMap, +-) -> Vec { +- if feature_ids.is_empty() { +- return Vec::new(); +- } +- +- let id_set: HashSet<&str> = feature_ids +- .iter() +- .map(std::string::String::as_str) +- .collect(); +- +- // Build adjacency list and in-degree count. +- // An edge from A -> B means "A must be installed before B". +- // Use a set of (from, to) pairs to deduplicate edges when the same dep +- // appears in both installsAfter and dependsOn. +- let mut in_degree: HashMap<&str, usize> = HashMap::new(); +- let mut edges: HashMap<&str, Vec<&str>> = HashMap::new(); +- let mut edge_set: HashSet<(&str, &str)> = HashSet::new(); +- +- for id in feature_ids { +- in_degree.entry(id.as_str()).or_insert(0); +- edges.entry(id.as_str()).or_default(); +- } +- +- for id in feature_ids { +- if let Some(meta) = metadata_map.get(id) { +- // Collect dependency refs from both installsAfter and dependsOn +- let mut dep_refs: Vec<&str> = meta +- .installs_after +- .iter() +- .map(std::string::String::as_str) +- .collect(); +- for dep_id in meta.depends_on.keys() { +- dep_refs.push(dep_id.as_str()); +- } +- +- for dep in dep_refs { +- let dep_dir = dir_name_from_id(dep); +- for candidate in feature_ids { +- if (candidate == dep || dir_name_from_id(candidate) == dep_dir) +- && id_set.contains(candidate.as_str()) +- { +- // candidate -> id (candidate must come before id) +- let edge = (candidate.as_str(), id.as_str()); +- if edge_set.insert(edge) { +- edges +- .entry(candidate.as_str()) +- .or_default() +- .push(id.as_str()); +- *in_degree.entry(id.as_str()).or_insert(0) += 1; +- } +- } +- } +- } +- } +- } +- +- // Kahn's algorithm preserving input order for ties +- let mut queue: VecDeque<&str> = VecDeque::new(); +- for id in feature_ids { +- if in_degree.get(id.as_str()).copied().unwrap_or(0) == 0 { +- queue.push_back(id.as_str()); +- } +- } +- +- let mut sorted: Vec = Vec::new(); +- while let Some(node) = queue.pop_front() { +- sorted.push(node.to_string()); +- if let Some(neighbors) = edges.get(node) { +- for neighbor in neighbors { +- if let Some(deg) = in_degree.get_mut(neighbor) { +- *deg -= 1; +- if *deg == 0 { +- queue.push_back(neighbor); +- } +- } +- } +- } +- } +- +- // If there are cycles, append remaining features in input order +- if sorted.len() < feature_ids.len() { +- for id in feature_ids { +- if !sorted.contains(id) { +- sorted.push(id.clone()); +- } +- } +- } +- +- sorted +-} +- +-/// Convert an option ID to an environment variable name per the dev container +-/// spec. Replaces non-alphanumeric, non-underscore chars with `_`, strips +-/// leading digits/underscores, and uppercases the result. +-fn option_id_to_env_name(id: &str) -> String { +- let replaced: String = id +- .chars() +- .map(|c| { +- if c.is_alphanumeric() || c == '_' { +- c +- } else { +- '_' +- } +- }) +- .collect(); +- let trimmed = replaced.trim_start_matches(|c: char| c == '_' || c.is_ascii_digit()); +- if trimmed.is_empty() { +- "_".to_string() +- } else { +- trimmed.to_uppercase() +- } +-} +- +-/// Generate a Dockerfile snippet for a single feature layer. +-fn generate_layer( +- feature_id: &str, +- dir_name: &str, +- options: &serde_json::Value, +- metadata: &FeatureMetadata, +- remote_user: Option<&str>, +-) -> String { +- let mut env_lines = Vec::new(); +- +- // Emit built-in user env vars expected by community features +- let ru = remote_user.unwrap_or("root"); +- let ru_home = if ru == "root" { +- "/root".to_string() +- } else { +- format!("/home/{ru}") +- }; +- env_lines.push(format!(" export _REMOTE_USER=\"{ru}\" && \\")); +- env_lines.push(" export _CONTAINER_USER=\"root\" && \\".to_string()); +- env_lines.push(format!(" export _REMOTE_USER_HOME=\"{ru_home}\" && \\")); +- env_lines.push(" export _CONTAINER_USER_HOME=\"/root\" && \\".to_string()); +- +- // Normalize shorthand version syntax: "1.18" → {"version": "1.18"} +- let options_obj = match options { +- serde_json::Value::String(s) => { +- let mut map = serde_json::Map::new(); +- map.insert("version".to_string(), serde_json::Value::String(s.clone())); +- map +- } +- serde_json::Value::Object(obj) => obj.clone(), +- _ => serde_json::Map::new(), +- }; +- +- // Collect all option names from metadata to set defaults +- let user_options: HashMap = options_obj +- .iter() +- .map(|(k, v)| { +- let val = match v { +- serde_json::Value::String(s) => s.clone(), +- other => other.to_string(), +- }; +- (k.clone(), val) +- }) +- .collect(); +- +- // Merge metadata defaults with user-provided options +- let mut merged_options: Vec<(String, String)> = Vec::new(); +- for (opt_name, opt_def) in &metadata.options { +- let value = if let Some(user_val) = user_options.get(opt_name) { +- user_val.clone() +- } else if let Some(default_val) = &opt_def.default { +- match default_val { +- serde_json::Value::String(s) => s.clone(), +- serde_json::Value::Bool(b) => b.to_string(), +- other => other.to_string(), +- } +- } else { +- continue; +- }; +- merged_options.push((opt_name.clone(), value)); +- } +- +- // Also add any user options not in metadata +- for (key, val) in &user_options { +- if !metadata.options.contains_key(key) { +- merged_options.push((key.clone(), val.clone())); +- } +- } +- +- // Sort for deterministic output +- merged_options.sort_by(|a, b| a.0.cmp(&b.0)); +- +- for (name, value) in &merged_options { +- let env_name = option_id_to_env_name(name); +- env_lines.push(format!(" export {env_name}=\"{value}\" && \\")); +- } +- +- let mut snippet = format!("# Feature: {feature_id}\n"); +- let _ = writeln!( +- snippet, +- "COPY {dir_name}/ /tmp/devcontainer-features/{dir_name}/" +- ); +- let _ = writeln!( +- snippet, +- "RUN cd /tmp/devcontainer-features/{dir_name} && \\" +- ); +- for line in &env_lines { +- snippet.push_str(line); +- snippet.push('\n'); +- } +- snippet.push_str(" chmod +x install.sh && \\\n"); +- snippet.push_str(" ./install.sh"); +- +- snippet +-} +- +-/// Fetch, order, and resolve features into Dockerfile layers. +-pub(crate) async fn resolve_features( +- features: &HashMap, +- devcontainer_dir: &Path, +- remote_user: Option<&str>, +-) -> crate::Result { +- if features.is_empty() { +- return Ok(ResolvedFeatures::default()); +- } +- +- let unique_id = format!( +- "devcontainer-features-{}-{}", +- std::process::id(), +- std::time::SystemTime::now() +- .duration_since(std::time::UNIX_EPOCH) +- .unwrap_or_default() +- .as_nanos() +- ); +- let tmp_dir = std::env::temp_dir().join(unique_id); +- fs::create_dir_all(&tmp_dir).await.map_err(|e| { +- DevcontainerError::Feature(format!( +- "failed to create temp dir {}: {e}", +- tmp_dir.display() +- )) +- })?; +- +- // Collect feature IDs in a stable order +- let mut feature_ids: Vec = features.keys().cloned().collect(); +- +- // Track options for each feature (including auto-injected ones) +- let mut all_options: HashMap = features.clone(); +- +- // Fetch all features and collect metadata +- let mut oras_checked = false; +- let mut metadata_map: HashMap = HashMap::new(); +- for feature_id in &feature_ids { +- let metadata = +- fetch_feature_dispatch(feature_id, &tmp_dir, devcontainer_dir, &mut oras_checked) +- .await?; +- metadata_map.insert(feature_id.clone(), metadata); +- } +- +- // Auto-inject missing dependsOn targets +- let mut injected = true; +- while injected { +- injected = false; +- let current_ids: Vec = feature_ids.clone(); +- for id in ¤t_ids { +- if let Some(meta) = metadata_map.get(id).cloned() { +- for (dep_id, dep_options) in &meta.depends_on { +- // Check if dep is already present (by full ID or dir name) +- let dep_dir = dir_name_from_id(dep_id); +- let already_present = feature_ids.iter().any(|existing| { +- existing == dep_id || dir_name_from_id(existing) == dep_dir +- }); +- if !already_present { +- info!(dep_id, "auto-injecting missing dependsOn target"); +- let dep_metadata = fetch_feature_dispatch( +- dep_id, +- &tmp_dir, +- devcontainer_dir, +- &mut oras_checked, +- ) +- .await?; +- metadata_map.insert(dep_id.clone(), dep_metadata); +- feature_ids.push(dep_id.clone()); +- all_options.insert(dep_id.clone(), dep_options.clone()); +- injected = true; +- } +- } +- } +- } +- } +- +- // Topologically sort features +- let sorted_ids = topo_sort(&feature_ids, &metadata_map); +- +- // Generate layers and collect container_env +- let mut resolved = ResolvedFeatures::default(); +- for id in &sorted_ids { +- let dir_name = dir_name_from_id(id); +- let options = all_options +- .get(id) +- .cloned() +- .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); +- let metadata = metadata_map +- .get(id) +- .cloned() +- .unwrap_or_else(|| FeatureMetadata { +- id: None, +- name: None, +- version: None, +- options: HashMap::new(), +- installs_after: Vec::new(), +- depends_on: HashMap::new(), +- container_env: HashMap::new(), +- on_create_command: None, +- post_create_command: None, +- post_start_command: None, +- }); +- +- // Collect feature containerEnv (later features override earlier) +- for (k, v) in &metadata.container_env { +- resolved.container_env.insert(k.clone(), v.clone()); +- } +- +- // Collect feature lifecycle hooks +- if let Some(cmd) = &metadata.on_create_command { +- resolved.on_create_commands.push(cmd.clone()); +- } +- if let Some(cmd) = &metadata.post_create_command { +- resolved.post_create_commands.push(cmd.clone()); +- } +- if let Some(cmd) = &metadata.post_start_command { +- resolved.post_start_commands.push(cmd.clone()); +- } +- +- let dockerfile_snippet = generate_layer(id, &dir_name, &options, &metadata, remote_user); +- resolved.layers.push(FeatureLayer { +- id: id.clone(), +- dir_name, +- dockerfile_snippet, +- }); +- } +- +- Ok(resolved) +-} +- +-#[cfg(test)] +-mod tests { +- use super::*; +- use crate::types::FeatureOption; +- +- #[test] +- fn dir_name_from_full_id() { +- assert_eq!( +- dir_name_from_id("ghcr.io/devcontainers/features/node:1"), +- "node" +- ); +- } +- +- #[test] +- fn dir_name_from_id_no_tag() { +- assert_eq!( +- dir_name_from_id("ghcr.io/devcontainers/features/python"), +- "python" +- ); +- } +- +- #[test] +- fn dir_name_from_id_simple() { +- assert_eq!(dir_name_from_id("node"), "node"); +- } +- +- #[test] +- fn dir_name_from_local_path() { +- assert_eq!(dir_name_from_id("./my-feature"), "my-feature"); +- assert_eq!(dir_name_from_id("./sub/my-feature"), "my-feature"); +- assert_eq!(dir_name_from_id("../shared-feature"), "shared-feature"); +- } +- +- #[test] +- fn dir_name_from_https_url() { +- assert_eq!( +- dir_name_from_id("https://example.com/features/node.tgz"), +- "node" +- ); +- assert_eq!( +- dir_name_from_id("https://example.com/features/python.tar.gz"), +- "python" +- ); +- assert_eq!(dir_name_from_id("https://example.com/features/go"), "go"); +- } +- +- #[test] +- fn fetch_feature_dispatch_routes() { +- // Verify the routing logic by checking prefix detection +- assert!("./local-feature".starts_with("./")); +- assert!("../parent-feature".starts_with("../")); +- assert!("https://example.com/feature.tgz".starts_with("https://")); +- assert!(!"ghcr.io/foo/bar:1".starts_with("./")); +- assert!(!"ghcr.io/foo/bar:1".starts_with("../")); +- assert!(!"ghcr.io/foo/bar:1".starts_with("https://")); +- } +- +- #[tokio::test] +- #[expect( +- clippy::disallowed_methods, +- reason = "test fixture setup uses sync std::fs::write to create a fake feature directory" +- )] +- async fn fetch_feature_local_integration() { +- let tmp_src = tempfile::tempdir().unwrap(); +- let feature_dir = tmp_src.path().join("my-feature"); +- std::fs::create_dir_all(&feature_dir).unwrap(); +- +- // Create a minimal devcontainer-feature.json +- std::fs::write( +- feature_dir.join("devcontainer-feature.json"), +- r#"{"id": "my-feature", "version": "1.0.0"}"#, +- ) +- .unwrap(); +- +- // Create a dummy install.sh +- std::fs::write(feature_dir.join("install.sh"), "#!/bin/sh\necho hi").unwrap(); +- +- let tmp_out = tempfile::tempdir().unwrap(); +- let metadata = fetch_feature_local("./my-feature", tmp_out.path(), tmp_src.path()) +- .await +- .unwrap(); +- assert_eq!(metadata.id.as_deref(), Some("my-feature")); +- assert!(tmp_out.path().join("my-feature/install.sh").exists()); +- } +- +- #[test] +- fn topo_sort_no_dependencies() { +- let ids = vec!["a".to_string(), "b".to_string(), "c".to_string()]; +- let metadata: HashMap = ids +- .iter() +- .map(|id| { +- (id.clone(), FeatureMetadata { +- id: Some(id.clone()), +- name: None, +- version: None, +- options: HashMap::new(), +- installs_after: Vec::new(), +- depends_on: HashMap::new(), +- container_env: HashMap::new(), +- on_create_command: None, +- post_create_command: None, +- post_start_command: None, +- }) +- }) +- .collect(); +- +- let sorted = topo_sort(&ids, &metadata); +- assert_eq!(sorted, vec!["a", "b", "c"]); +- } +- +- #[test] +- fn topo_sort_simple_chain() { +- // A depends on B (A installs after B), so B should come first +- let ids = vec!["a".to_string(), "b".to_string()]; +- let mut metadata: HashMap = HashMap::new(); +- metadata.insert("a".to_string(), FeatureMetadata { +- id: Some("a".to_string()), +- name: None, +- version: None, +- options: HashMap::new(), +- installs_after: vec!["b".to_string()], +- depends_on: HashMap::new(), +- container_env: HashMap::new(), +- on_create_command: None, +- post_create_command: None, +- post_start_command: None, +- }); +- metadata.insert("b".to_string(), FeatureMetadata { +- id: Some("b".to_string()), +- name: None, +- version: None, +- options: HashMap::new(), +- installs_after: Vec::new(), +- depends_on: HashMap::new(), +- container_env: HashMap::new(), +- on_create_command: None, +- post_create_command: None, +- post_start_command: None, +- }); +- +- let sorted = topo_sort(&ids, &metadata); +- assert_eq!(sorted, vec!["b", "a"]); +- } +- +- #[test] +- fn topo_sort_diamond() { +- // D depends on B and C; B and C depend on A +- // Expected: A, B, C, D (or A, C, B, D — both valid, but we preserve input order +- // for ties) +- let ids = vec![ +- "d".to_string(), +- "b".to_string(), +- "c".to_string(), +- "a".to_string(), +- ]; +- let mut metadata: HashMap = HashMap::new(); +- metadata.insert("a".to_string(), FeatureMetadata { +- id: Some("a".to_string()), +- name: None, +- version: None, +- options: HashMap::new(), +- installs_after: Vec::new(), +- depends_on: HashMap::new(), +- container_env: HashMap::new(), +- on_create_command: None, +- post_create_command: None, +- post_start_command: None, +- }); +- metadata.insert("b".to_string(), FeatureMetadata { +- id: Some("b".to_string()), +- name: None, +- version: None, +- options: HashMap::new(), +- installs_after: vec!["a".to_string()], +- depends_on: HashMap::new(), +- container_env: HashMap::new(), +- on_create_command: None, +- post_create_command: None, +- post_start_command: None, +- }); +- metadata.insert("c".to_string(), FeatureMetadata { +- id: Some("c".to_string()), +- name: None, +- version: None, +- options: HashMap::new(), +- installs_after: vec!["a".to_string()], +- depends_on: HashMap::new(), +- container_env: HashMap::new(), +- on_create_command: None, +- post_create_command: None, +- post_start_command: None, +- }); +- metadata.insert("d".to_string(), FeatureMetadata { +- id: Some("d".to_string()), +- name: None, +- version: None, +- options: HashMap::new(), +- installs_after: vec!["b".to_string(), "c".to_string()], +- depends_on: HashMap::new(), +- container_env: HashMap::new(), +- on_create_command: None, +- post_create_command: None, +- post_start_command: None, +- }); +- +- let sorted = topo_sort(&ids, &metadata); +- // A must come before B and C; B and C must come before D +- let pos_a = sorted.iter().position(|x| x == "a").unwrap(); +- let pos_b = sorted.iter().position(|x| x == "b").unwrap(); +- let pos_c = sorted.iter().position(|x| x == "c").unwrap(); +- let pos_d = sorted.iter().position(|x| x == "d").unwrap(); +- assert!(pos_a < pos_b); +- assert!(pos_a < pos_c); +- assert!(pos_b < pos_d); +- assert!(pos_c < pos_d); +- } +- +- #[test] +- fn generate_layer_with_options() { +- let options = serde_json::json!({"version": "20"}); +- let mut meta_options = HashMap::new(); +- meta_options.insert("version".to_string(), FeatureOption { +- option_type: Some("string".to_string()), +- default: Some(serde_json::Value::String("lts".to_string())), +- description: Some("Node.js version".to_string()), +- }); +- let metadata = FeatureMetadata { +- id: Some("node".to_string()), +- name: Some("Node.js".to_string()), +- version: Some("1.0.0".to_string()), +- options: meta_options, +- installs_after: Vec::new(), +- depends_on: HashMap::new(), +- container_env: HashMap::new(), +- on_create_command: None, +- post_create_command: None, +- post_start_command: None, +- }; +- +- let snippet = generate_layer( +- "ghcr.io/devcontainers/features/node:1", +- "node", +- &options, +- &metadata, +- None, +- ); +- +- insta::assert_snapshot!(snippet, @r#" +- # Feature: ghcr.io/devcontainers/features/node:1 +- COPY node/ /tmp/devcontainer-features/node/ +- RUN cd /tmp/devcontainer-features/node && \ +- export _REMOTE_USER="root" && \ +- export _CONTAINER_USER="root" && \ +- export _REMOTE_USER_HOME="/root" && \ +- export _CONTAINER_USER_HOME="/root" && \ +- export VERSION="20" && \ +- chmod +x install.sh && \ +- ./install.sh +- "#); +- } +- +- #[test] +- fn generate_layer_with_defaults() { +- let options = serde_json::json!({}); +- let mut meta_options = HashMap::new(); +- meta_options.insert("version".to_string(), FeatureOption { +- option_type: Some("string".to_string()), +- default: Some(serde_json::Value::String("lts".to_string())), +- description: Some("Node.js version".to_string()), +- }); +- let metadata = FeatureMetadata { +- id: Some("node".to_string()), +- name: None, +- version: None, +- options: meta_options, +- installs_after: Vec::new(), +- depends_on: HashMap::new(), +- container_env: HashMap::new(), +- on_create_command: None, +- post_create_command: None, +- post_start_command: None, +- }; +- +- let snippet = generate_layer( +- "ghcr.io/devcontainers/features/node:1", +- "node", +- &options, +- &metadata, +- None, +- ); +- +- insta::assert_snapshot!(snippet, @r#" +- # Feature: ghcr.io/devcontainers/features/node:1 +- COPY node/ /tmp/devcontainer-features/node/ +- RUN cd /tmp/devcontainer-features/node && \ +- export _REMOTE_USER="root" && \ +- export _CONTAINER_USER="root" && \ +- export _REMOTE_USER_HOME="/root" && \ +- export _CONTAINER_USER_HOME="/root" && \ +- export VERSION="lts" && \ +- chmod +x install.sh && \ +- ./install.sh +- "#); +- } +- +- #[test] +- fn generate_layer_no_options() { +- let options = serde_json::json!({}); +- let metadata = FeatureMetadata { +- id: Some("common-utils".to_string()), +- name: None, +- version: None, +- options: HashMap::new(), +- installs_after: Vec::new(), +- depends_on: HashMap::new(), +- container_env: HashMap::new(), +- on_create_command: None, +- post_create_command: None, +- post_start_command: None, +- }; +- +- let snippet = generate_layer( +- "ghcr.io/devcontainers/features/common-utils:1", +- "common-utils", +- &options, +- &metadata, +- None, +- ); +- +- insta::assert_snapshot!(snippet, @r#" +- # Feature: ghcr.io/devcontainers/features/common-utils:1 +- COPY common-utils/ /tmp/devcontainer-features/common-utils/ +- RUN cd /tmp/devcontainer-features/common-utils && \ +- export _REMOTE_USER="root" && \ +- export _CONTAINER_USER="root" && \ +- export _REMOTE_USER_HOME="/root" && \ +- export _CONTAINER_USER_HOME="/root" && \ +- chmod +x install.sh && \ +- ./install.sh +- "#); +- } +- +- #[test] +- fn topo_sort_depends_on_present() { +- // A dependsOn B, both present → B before A +- let ids = vec!["a".to_string(), "b".to_string()]; +- let mut metadata: HashMap = HashMap::new(); +- let mut depends = HashMap::new(); +- depends.insert("b".to_string(), serde_json::json!({})); +- metadata.insert("a".to_string(), FeatureMetadata { +- id: Some("a".to_string()), +- name: None, +- version: None, +- options: HashMap::new(), +- installs_after: Vec::new(), +- depends_on: depends, +- container_env: HashMap::new(), +- on_create_command: None, +- post_create_command: None, +- post_start_command: None, +- }); +- metadata.insert("b".to_string(), FeatureMetadata { +- id: Some("b".to_string()), +- name: None, +- version: None, +- options: HashMap::new(), +- installs_after: Vec::new(), +- depends_on: HashMap::new(), +- container_env: HashMap::new(), +- on_create_command: None, +- post_create_command: None, +- post_start_command: None, +- }); +- +- let sorted = topo_sort(&ids, &metadata); +- assert_eq!(sorted, vec!["b", "a"]); +- } +- +- #[test] +- fn topo_sort_depends_on_and_installs_after_deduped() { +- // A has both dependsOn B and installsAfter B — should not double-count +- let ids = vec!["a".to_string(), "b".to_string()]; +- let mut metadata: HashMap = HashMap::new(); +- let mut depends = HashMap::new(); +- depends.insert("b".to_string(), serde_json::json!({})); +- metadata.insert("a".to_string(), FeatureMetadata { +- id: Some("a".to_string()), +- name: None, +- version: None, +- options: HashMap::new(), +- installs_after: vec!["b".to_string()], +- depends_on: depends, +- container_env: HashMap::new(), +- on_create_command: None, +- post_create_command: None, +- post_start_command: None, +- }); +- metadata.insert("b".to_string(), FeatureMetadata { +- id: Some("b".to_string()), +- name: None, +- version: None, +- options: HashMap::new(), +- installs_after: Vec::new(), +- depends_on: HashMap::new(), +- container_env: HashMap::new(), +- on_create_command: None, +- post_create_command: None, +- post_start_command: None, +- }); +- +- let sorted = topo_sort(&ids, &metadata); +- assert_eq!(sorted, vec!["b", "a"]); +- } +- +- #[tokio::test] +- #[ignore = "requires oras"] +- #[expect( +- clippy::disallowed_methods, +- reason = "Ignored OCI integration test is opt-in via a documented process-env flag." +- )] +- async fn fetch_feature_oci_integration() { +- if std::env::var_os(EnvVars::FABRO_ENABLE_FETCH_FEATURE_OCI_INTEGRATION).is_none() { +- return; +- } +- +- let tmp = tempfile::tempdir().unwrap(); +- let metadata = fetch_feature_oci("ghcr.io/devcontainers/features/node:1", tmp.path()) +- .await +- .unwrap(); +- assert!(metadata.id.is_some()); +- assert!(tmp.path().join("node/install.sh").exists()); +- } +- +- #[tokio::test] +- #[ignore = "requires oras"] +- async fn resolve_features_integration() { +- let tmp = tempfile::tempdir().unwrap(); +- let mut features = HashMap::new(); +- features.insert( +- "ghcr.io/devcontainers/features/node:1".to_string(), +- serde_json::json!({"version": "20"}), +- ); +- let resolved = resolve_features(&features, tmp.path(), None).await.unwrap(); +- assert_eq!(resolved.layers.len(), 1); +- assert_eq!(resolved.layers[0].dir_name, "node"); +- assert!( +- resolved.layers[0] +- .dockerfile_snippet +- .contains("export VERSION=\"20\"") +- ); +- } +- +- #[test] +- fn feature_container_env_collected() { +- // Simulate what resolve_features does: collect container_env from metadata in +- // sort order +- let mut resolved = ResolvedFeatures::default(); +- +- let meta_a = FeatureMetadata { +- id: Some("a".to_string()), +- name: None, +- version: None, +- options: HashMap::new(), +- installs_after: Vec::new(), +- depends_on: HashMap::new(), +- container_env: { +- let mut env = HashMap::new(); +- env.insert("FOO".to_string(), "from_a".to_string()); +- env.insert("BAR".to_string(), "from_a".to_string()); +- env +- }, +- on_create_command: None, +- post_create_command: None, +- post_start_command: None, +- }; +- let meta_b = FeatureMetadata { +- id: Some("b".to_string()), +- name: None, +- version: None, +- options: HashMap::new(), +- installs_after: Vec::new(), +- depends_on: HashMap::new(), +- container_env: { +- let mut env = HashMap::new(); +- env.insert("FOO".to_string(), "from_b".to_string()); +- env +- }, +- on_create_command: None, +- post_create_command: None, +- post_start_command: None, +- }; +- +- // A is sorted first, then B — B's FOO overrides A's +- for meta in [&meta_a, &meta_b] { +- for (k, v) in &meta.container_env { +- resolved.container_env.insert(k.clone(), v.clone()); +- } +- } +- +- assert_eq!( +- resolved.container_env.get("FOO").map(String::as_str), +- Some("from_b") +- ); +- assert_eq!( +- resolved.container_env.get("BAR").map(String::as_str), +- Some("from_a") +- ); +- } +- +- #[test] +- fn feature_lifecycle_hooks_collected() { +- let mut resolved = ResolvedFeatures::default(); +- +- let cmds = [ +- LifecycleCommand::String("setup-a".to_string()), +- LifecycleCommand::Array(vec!["make".to_string(), "build".to_string()]), +- ]; +- +- // Simulate collecting from two features +- resolved.on_create_commands.push(cmds[0].clone()); +- resolved.post_create_commands.push(cmds[1].clone()); +- resolved.post_start_commands.push(cmds[0].clone()); +- +- assert_eq!(resolved.on_create_commands.len(), 1); +- assert!( +- matches!(&resolved.on_create_commands[0], LifecycleCommand::String(s) if s == "setup-a") +- ); +- assert_eq!(resolved.post_create_commands.len(), 1); +- assert!( +- matches!(&resolved.post_create_commands[0], LifecycleCommand::Array(arr) if arr.len() == 2) +- ); +- assert_eq!(resolved.post_start_commands.len(), 1); +- } +- +- #[test] +- fn option_id_to_env_name_hyphenated() { +- assert_eq!(option_id_to_env_name("node-version"), "NODE_VERSION"); +- } +- +- #[test] +- fn option_id_to_env_name_leading_digit() { +- assert_eq!(option_id_to_env_name("2fast"), "FAST"); +- } +- +- #[test] +- fn option_id_to_env_name_simple() { +- assert_eq!(option_id_to_env_name("simple"), "SIMPLE"); +- } +- +- #[test] +- fn generate_layer_shorthand_version() { +- let options = serde_json::json!("20"); +- let mut meta_options = HashMap::new(); +- meta_options.insert("version".to_string(), FeatureOption { +- option_type: Some("string".to_string()), +- default: Some(serde_json::Value::String("lts".to_string())), +- description: Some("Node.js version".to_string()), +- }); +- let metadata = FeatureMetadata { +- id: Some("node".to_string()), +- name: None, +- version: None, +- options: meta_options, +- installs_after: Vec::new(), +- depends_on: HashMap::new(), +- container_env: HashMap::new(), +- on_create_command: None, +- post_create_command: None, +- post_start_command: None, +- }; +- +- let snippet = generate_layer( +- "ghcr.io/devcontainers/features/node:1", +- "node", +- &options, +- &metadata, +- None, +- ); +- +- assert!(snippet.contains("export VERSION=\"20\"")); +- } +- +- #[test] +- fn generate_layer_install_env_vars() { +- let options = serde_json::json!({}); +- let metadata = FeatureMetadata { +- id: Some("node".to_string()), +- name: None, +- version: None, +- options: HashMap::new(), +- installs_after: Vec::new(), +- depends_on: HashMap::new(), +- container_env: HashMap::new(), +- on_create_command: None, +- post_create_command: None, +- post_start_command: None, +- }; +- +- let snippet = generate_layer( +- "ghcr.io/devcontainers/features/node:1", +- "node", +- &options, +- &metadata, +- Some("vscode"), +- ); +- +- assert!(snippet.contains("_REMOTE_USER=\"vscode\"")); +- assert!(snippet.contains("_CONTAINER_USER=\"root\"")); +- assert!(snippet.contains("_REMOTE_USER_HOME=\"/home/vscode\"")); +- assert!(snippet.contains("_CONTAINER_USER_HOME=\"/root\"")); +- } +-} +diff --git a/lib/crates/fabro-devcontainer/src/jsonc.rs b/lib/crates/fabro-devcontainer/src/jsonc.rs +deleted file mode 100644 +index e219bc61c..000000000 +--- a/lib/crates/fabro-devcontainer/src/jsonc.rs ++++ /dev/null +@@ -1,280 +0,0 @@ +-/// Strip JSONC comments and trailing commas, producing valid JSON. +-pub(crate) fn strip_jsonc(input: &str) -> String { +- let mut out = String::with_capacity(input.len()); +- let bytes = input.as_bytes(); +- let len = bytes.len(); +- let mut i = 0; +- +- while i < len { +- match bytes[i] { +- // String literal — copy verbatim (including any comment-like content) +- b'"' => { +- out.push('"'); +- i += 1; +- while i < len { +- match bytes[i] { +- b'\\' => { +- // Escaped character — copy both backslash and next char +- out.push('\\'); +- i += 1; +- if i < len { +- out.push(bytes[i] as char); +- i += 1; +- } +- } +- b'"' => { +- out.push('"'); +- i += 1; +- break; +- } +- _ => { +- out.push(bytes[i] as char); +- i += 1; +- } +- } +- } +- } +- +- // Potential comment start +- b'/' if i + 1 < len => { +- match bytes[i + 1] { +- // Line comment — skip until end of line +- b'/' => { +- i += 2; +- while i < len && bytes[i] != b'\n' { +- i += 1; +- } +- } +- // Block comment — skip until */ +- b'*' => { +- i += 2; +- while i + 1 < len { +- if bytes[i] == b'*' && bytes[i + 1] == b'/' { +- i += 2; +- break; +- } +- i += 1; +- } +- // Handle unterminated block comment at end of input +- if i >= len { +- break; +- } +- } +- _ => { +- out.push('/'); +- i += 1; +- } +- } +- } +- +- // Comma — check if it's a trailing comma before } or ] +- b',' => { +- // Look ahead past whitespace for } or ] +- let mut j = i + 1; +- while j < len && bytes[j].is_ascii_whitespace() { +- j += 1; +- } +- // Also skip comments after the comma +- while j < len { +- if j + 1 < len && bytes[j] == b'/' && bytes[j + 1] == b'/' { +- j += 2; +- while j < len && bytes[j] != b'\n' { +- j += 1; +- } +- while j < len && bytes[j].is_ascii_whitespace() { +- j += 1; +- } +- } else if j + 1 < len && bytes[j] == b'/' && bytes[j + 1] == b'*' { +- j += 2; +- while j + 1 < len { +- if bytes[j] == b'*' && bytes[j + 1] == b'/' { +- j += 2; +- break; +- } +- j += 1; +- } +- while j < len && bytes[j].is_ascii_whitespace() { +- j += 1; +- } +- } else { +- break; +- } +- } +- +- if j < len && (bytes[j] == b'}' || bytes[j] == b']') { +- // Trailing comma — skip it +- i += 1; +- } else { +- out.push(','); +- i += 1; +- } +- } +- +- _ => { +- out.push(bytes[i] as char); +- i += 1; +- } +- } +- } +- +- out +-} +- +-#[cfg(test)] +-mod tests { +- use super::*; +- +- #[test] +- fn passthrough_valid_json() { +- let json = r#"{"key": "value"}"#; +- assert_eq!(strip_jsonc(json), json); +- } +- +- #[test] +- fn strip_line_comments() { +- let input = r#"{ +- // this is a comment +- "key": "value" +-}"#; +- // Leading whitespace on the comment line remains but that's valid JSON +- let expected = "{\n \n \"key\": \"value\"\n}"; +- assert_eq!(strip_jsonc(input), expected); +- } +- +- #[test] +- fn strip_line_comment_at_end_of_line() { +- let input = r#"{"key": "value" // inline comment +-}"#; +- // Space before the comment remains +- let expected = "{\"key\": \"value\" \n}"; +- assert_eq!(strip_jsonc(input), expected); +- } +- +- #[test] +- fn strip_block_comments() { +- let input = r#"{"key": /* comment */ "value"}"#; +- let expected = r#"{"key": "value"}"#; +- assert_eq!(strip_jsonc(input), expected); +- } +- +- #[test] +- fn strip_multiline_block_comment() { +- let input = r#"{ +- /* this is +- a multi-line +- comment */ +- "key": "value" +-}"#; +- // Leading whitespace before block comment remains +- let expected = "{\n \n \"key\": \"value\"\n}"; +- assert_eq!(strip_jsonc(input), expected); +- } +- +- #[test] +- fn strip_trailing_comma_before_brace() { +- let input = r#"{"a": 1, "b": 2,}"#; +- let expected = r#"{"a": 1, "b": 2}"#; +- assert_eq!(strip_jsonc(input), expected); +- } +- +- #[test] +- fn strip_trailing_comma_before_bracket() { +- let input = r"[1, 2, 3,]"; +- let expected = r"[1, 2, 3]"; +- assert_eq!(strip_jsonc(input), expected); +- } +- +- #[test] +- fn trailing_comma_with_whitespace() { +- let input = r#"{ +- "a": 1, +- "b": 2, +-}"#; +- let expected = r#"{ +- "a": 1, +- "b": 2 +-}"#; +- assert_eq!(strip_jsonc(input), expected); +- } +- +- #[test] +- fn comments_inside_strings_preserved() { +- let input = r#"{"key": "value // not a comment"}"#; +- assert_eq!(strip_jsonc(input), input); +- } +- +- #[test] +- fn block_comment_inside_string_preserved() { +- let input = r#"{"key": "value /* not a comment */ still here"}"#; +- assert_eq!(strip_jsonc(input), input); +- } +- +- #[test] +- fn mixed_comments_and_trailing_commas() { +- let input = r#"{ +- // first comment +- "name": "test", /* inline */ +- "items": [ +- 1, +- 2, // trailing +- ], +-}"#; +- // Whitespace around stripped comments remains; trailing commas removed +- let expected = "{\n \n \"name\": \"test\", \n \"items\": [\n 1,\n 2 \n ]\n}"; +- assert_eq!(strip_jsonc(input), expected); +- } +- +- #[test] +- fn empty_input() { +- assert_eq!(strip_jsonc(""), ""); +- } +- +- #[test] +- fn escaped_quote_in_string() { +- let input = r#"{"key": "val\"ue // not a comment"}"#; +- assert_eq!(strip_jsonc(input), input); +- } +- +- #[test] +- fn trailing_comma_with_comment_before_close() { +- let input = r#"{"a": 1, // comment +-}"#; +- // Trailing comma removed; space before comment remains +- let expected = "{\"a\": 1 \n}"; +- assert_eq!(strip_jsonc(input), expected); +- } +- +- #[test] +- fn trailing_comma_with_block_comment_before_close() { +- let input = r#"{"a": 1, /* comment */ }"#; +- // Trailing comma removed; spaces around stripped comment remain +- let expected = "{\"a\": 1 }"; +- assert_eq!(strip_jsonc(input), expected); +- } +- +- #[test] +- fn only_comments() { +- let input = "// just a comment\n/* block */"; +- let expected = "\n"; +- assert_eq!(strip_jsonc(input), expected); +- } +- +- #[test] +- fn produces_valid_json() { +- let input = r#"{ +- // devcontainer settings +- "image": "mcr.microsoft.com/devcontainers/base:ubuntu", +- "features": { +- "ghcr.io/devcontainers/features/rust:1": {}, +- }, +- /* forwarded ports */ +- "forwardPorts": [3000, 8080,], +- "remoteEnv": { +- "EDITOR": "code", // default editor +- }, +-}"#; +- let result = strip_jsonc(input); +- let parsed: serde_json::Result = serde_json::from_str(&result); +- assert!(parsed.is_ok(), "should produce valid JSON, got: {result}"); +- } +-} +diff --git a/lib/crates/fabro-devcontainer/src/lib.rs b/lib/crates/fabro-devcontainer/src/lib.rs +deleted file mode 100644 +index e1926152e..000000000 +--- a/lib/crates/fabro-devcontainer/src/lib.rs ++++ /dev/null +@@ -1,678 +0,0 @@ +-#![allow( +- dead_code, +- reason = "This crate keeps parsing helpers available while integration points are still landing." +-)] +- +-mod compose; +-mod dockerfile; +-mod features; +-mod jsonc; +-mod types; +-mod variables; +- +-use std::collections::HashMap; +-use std::path::{Path, PathBuf}; +- +-use fabro_util::env::SystemEnv; +-use tokio::fs; +-pub use types::DevcontainerJson; +- +-/// Lifecycle command — string, array, or object (parallel) form. +-#[derive(Debug, Clone, PartialEq)] +-pub enum Command { +- Shell(String), +- Args(Vec), +- Parallel(HashMap), +-} +- +-/// Parsed and resolved devcontainer configuration — everything needed to create +-/// a sandbox. +-#[derive(Debug, Clone)] +-pub struct DevcontainerSpec { +- /// Generated Dockerfile content +- pub dockerfile: String, +- /// Directory for docker build context +- pub build_context: PathBuf, +- /// Build arguments (docker build --build-arg) +- pub build_args: HashMap, +- /// Multi-stage build target (docker build --target) +- pub build_target: Option, +- /// Run on host before build +- pub initialize_commands: Vec, +- /// Run in container after first creation (before updateContentCommand) +- pub on_create_commands: Vec, +- /// Run in container after creation +- pub post_create_commands: Vec, +- /// Run in container on each start +- pub post_start_commands: Vec, +- /// remoteEnv merged +- pub environment: HashMap, +- /// containerEnv — baked into Dockerfile as ENV directives +- pub container_env: HashMap, +- pub remote_user: Option, +- /// default: /workspaces/{repo-name} +- pub workspace_folder: String, +- /// first = default preview port +- pub forwarded_ports: Vec, +- /// Compose file paths (empty if not in compose mode) +- pub compose_files: Vec, +- pub compose_service: Option, +-} +- +-#[derive(Debug, thiserror::Error)] +-pub enum DevcontainerError { +- #[error("no devcontainer.json found in {0}")] +- NotFound(PathBuf), +- +- #[error("parsing devcontainer.json: {0}")] +- Parse(#[from] serde_json::Error), +- +- #[error("reading file {path}: {source}")] +- ReadFile { +- path: PathBuf, +- source: std::io::Error, +- }, +- +- #[error("compose file error: {0}")] +- Compose(String), +- +- #[error("feature error: {0}")] +- Feature(String), +- +- #[error("oras not found and auto-install failed: {0}")] +- OrasInstall(String), +- +- #[error("oras command failed: {0}")] +- OrasCommand(String), +- +- #[error("variable substitution error: {0}")] +- Variable(String), +- +- #[error( +- "base Dockerfile contains COPY or ADD instructions that reference build context files, which is not supported by Daytona snapshots: {0}" +- )] +- UnsupportedCopyAdd(String), +-} +- +-pub type Result = std::result::Result; +- +-/// Check that a Dockerfile does not contain COPY or ADD instructions that +-/// reference build context files. Multi-stage `COPY --from=` and `ADD +-/// http(s)://` are allowed. +-fn check_no_build_context_copies(dockerfile: &str) -> Result<()> { +- let mut offending = Vec::new(); +- let mut continuation = String::new(); +- +- for raw_line in dockerfile.lines() { +- let trimmed = raw_line.trim(); +- +- // Handle line continuations +- if !continuation.is_empty() { +- continuation.push(' '); +- continuation.push_str(trimmed); +- if trimmed.ends_with('\\') { +- continuation.truncate(continuation.len() - 1); +- continue; +- } +- let full_line = std::mem::take(&mut continuation); +- check_single_line(&full_line, &mut offending); +- continue; +- } +- +- if trimmed.is_empty() || trimmed.starts_with('#') { +- continue; +- } +- +- if trimmed.ends_with('\\') { +- continuation = trimmed.trim_end_matches('\\').to_string(); +- continue; +- } +- +- check_single_line(trimmed, &mut offending); +- } +- +- // Handle unterminated continuation +- if !continuation.is_empty() { +- check_single_line(&continuation, &mut offending); +- } +- +- if offending.is_empty() { +- Ok(()) +- } else { +- Err(DevcontainerError::UnsupportedCopyAdd(offending.join("; "))) +- } +-} +- +-fn check_single_line(line: &str, offending: &mut Vec) { +- let upper = line.to_ascii_uppercase(); +- if upper.starts_with("COPY ") { +- // Allow COPY --from= +- let rest = line[5..].trim_start(); +- if !rest.starts_with("--from=") && !rest.to_ascii_uppercase().starts_with("--FROM=") { +- offending.push(line.to_string()); +- } +- } else if upper.starts_with("ADD ") { +- // Allow ADD http:// or https:// +- let rest = line[4..].trim_start(); +- if !rest.starts_with("http://") && !rest.starts_with("https://") { +- offending.push(line.to_string()); +- } +- } +-} +- +-/// Parse and resolve a devcontainer config from a repo directory. +-pub struct DevcontainerResolver; +- +-impl DevcontainerResolver { +- /// path: repo root (or explicit .devcontainer/ path) +- pub async fn resolve(path: &Path) -> Result { +- let (json_path, devcontainer) = Self::find_and_parse(path).await?; +- let repo_root = Self::repo_root_from_json_path(&json_path, path); +- let base_dir = json_path.parent().unwrap_or(path); +- +- let repo_name = repo_root +- .file_name() +- .and_then(|n| n.to_str()) +- .unwrap_or("workspace") +- .to_string(); +- +- // Variable substitution — two-pass: first resolve workspace_folder itself, +- // then create final context with the resolved value. +- let raw_workspace_folder = devcontainer +- .workspace_folder +- .clone() +- .unwrap_or_else(|| format!("/workspaces/{repo_name}")); +- +- let system_env = SystemEnv; +- let preliminary_vars = variables::VariableContext { +- local_workspace_folder: repo_root.to_string_lossy().to_string(), +- local_workspace_folder_basename: repo_name.clone(), +- container_workspace_folder: raw_workspace_folder.clone(), +- env: &system_env, +- }; +- let workspace_folder = variables::substitute(&raw_workspace_folder, &preliminary_vars); +- +- let vars = variables::VariableContext { +- local_workspace_folder: repo_root.to_string_lossy().to_string(), +- local_workspace_folder_basename: repo_name.clone(), +- container_workspace_folder: workspace_folder.clone(), +- env: &system_env, +- }; +- +- // Handle compose mode +- if let Some(compose_ref) = &devcontainer.docker_compose_file { +- let compose_paths: Vec = compose_ref +- .paths() +- .iter() +- .map(|p| base_dir.join(variables::substitute(p, &vars))) +- .collect(); +- let service_name = devcontainer +- .service +- .as_ref() +- .ok_or_else(|| { +- DevcontainerError::Compose( +- "dockerComposeFile requires 'service' field".to_string(), +- ) +- })? +- .clone(); +- +- let compose_config = compose::parse_compose_multi(&compose_paths, &service_name) +- .await +- .map_err(DevcontainerError::Compose)?; +- +- let mut environment = HashMap::new(); +- for (k, v) in compose_config.environment { +- environment.insert(k, variables::substitute(&v, &vars)); +- } +- if let Some(env) = &devcontainer.remote_env { +- for (k, v) in env { +- environment.insert(k.clone(), variables::substitute(v, &vars)); +- } +- } +- +- // Use the first compose file's parent as build context base +- let compose_base_dir = compose_paths +- .first() +- .and_then(|p| p.parent()) +- .unwrap_or(base_dir); +- +- let dockerfile = if let Some(build) = &compose_config.build { +- let df_path = compose_base_dir +- .join(&build.context) +- .join(build.dockerfile.as_deref().unwrap_or("Dockerfile")); +- fs::read_to_string(&df_path).await.map_err(|source| { +- DevcontainerError::ReadFile { +- path: df_path, +- source, +- } +- })? +- } else { +- format!( +- "FROM {}", +- compose_config.image.as_deref().unwrap_or("ubuntu") +- ) +- }; +- +- check_no_build_context_copies(&dockerfile)?; +- +- return Ok(DevcontainerSpec { +- dockerfile, +- build_context: compose_base_dir.to_path_buf(), +- build_args: HashMap::new(), +- build_target: None, +- initialize_commands: Self::collect_commands( +- devcontainer.initialize_command.as_ref(), +- &vars, +- ), +- on_create_commands: Self::collect_commands( +- devcontainer.on_create_command.as_ref(), +- &vars, +- ), +- post_create_commands: Self::collect_commands( +- devcontainer.post_create_command.as_ref(), +- &vars, +- ), +- post_start_commands: Self::collect_commands( +- devcontainer.post_start_command.as_ref(), +- &vars, +- ), +- environment, +- container_env: Self::collect_container_env( +- devcontainer.container_env.as_ref(), +- &vars, +- ), +- remote_user: devcontainer.remote_user.clone().or(compose_config.user), +- workspace_folder, +- forwarded_ports: { +- let mut ports = compose_config.ports; +- for port in Self::parse_forward_ports(&devcontainer.forward_ports) { +- if !ports.contains(&port) { +- ports.push(port); +- } +- } +- ports +- }, +- compose_files: compose_paths, +- compose_service: Some(service_name), +- }); +- } +- +- // Image or Dockerfile mode +- let (base_dockerfile, build_context, build_args, build_target) = +- if let Some(build) = &devcontainer.build { +- let context_dir = build.context.as_ref().map_or_else( +- || base_dir.to_path_buf(), +- |c| base_dir.join(variables::substitute(c, &vars)), +- ); +- let df_path = base_dir.join(variables::substitute( +- build.dockerfile.as_deref().unwrap_or("Dockerfile"), +- &vars, +- )); +- let content = fs::read_to_string(&df_path).await.map_err(|source| { +- DevcontainerError::ReadFile { +- path: df_path, +- source, +- } +- })?; +- check_no_build_context_copies(&content)?; +- let args: HashMap = build +- .args +- .iter() +- .map(|(k, v)| (k.clone(), variables::substitute(v, &vars))) +- .collect(); +- let target = build +- .target +- .as_ref() +- .map(|t| variables::substitute(t, &vars)); +- (content, context_dir, args, target) +- } else { +- let image = devcontainer +- .image +- .as_deref() +- .unwrap_or("mcr.microsoft.com/devcontainers/base:ubuntu"); +- ( +- format!("FROM {image}"), +- base_dir.to_path_buf(), +- HashMap::new(), +- None, +- ) +- }; +- +- // Features +- let resolved_features = if devcontainer.features.is_empty() { +- features::ResolvedFeatures::default() +- } else { +- features::resolve_features( +- &devcontainer.features, +- base_dir, +- devcontainer.remote_user.as_deref(), +- ) +- .await? +- }; +- +- // Merge feature containerEnv with devcontainer.json containerEnv +- // (devcontainer.json wins on conflicts) +- let mut merged_container_env = resolved_features.container_env; +- if let Some(env) = &devcontainer.container_env { +- for (k, v) in env { +- merged_container_env.insert(k.clone(), variables::substitute(v, &vars)); +- } +- } +- // Generate final Dockerfile +- let dockerfile_content = dockerfile::generate( +- &base_dockerfile, +- &resolved_features.layers, +- &merged_container_env, +- devcontainer.remote_user.as_deref(), +- ); +- +- let mut environment = HashMap::new(); +- if let Some(env) = &devcontainer.remote_env { +- for (k, v) in env { +- environment.insert(k.clone(), variables::substitute(v, &vars)); +- } +- } +- +- let forwarded_ports = Self::parse_forward_ports(&devcontainer.forward_ports); +- +- // Collect devcontainer.json lifecycle commands, then append feature lifecycle +- // commands +- let mut on_create_commands = +- Self::collect_commands(devcontainer.on_create_command.as_ref(), &vars); +- let mut post_create_commands = +- Self::collect_commands(devcontainer.post_create_command.as_ref(), &vars); +- let mut post_start_commands = +- Self::collect_commands(devcontainer.post_start_command.as_ref(), &vars); +- +- for cmd in &resolved_features.on_create_commands { +- on_create_commands.push(Self::convert_lifecycle_command(cmd)); +- } +- for cmd in &resolved_features.post_create_commands { +- post_create_commands.push(Self::convert_lifecycle_command(cmd)); +- } +- for cmd in &resolved_features.post_start_commands { +- post_start_commands.push(Self::convert_lifecycle_command(cmd)); +- } +- +- Ok(DevcontainerSpec { +- dockerfile: dockerfile_content, +- build_context, +- build_args, +- build_target, +- initialize_commands: Self::collect_commands( +- devcontainer.initialize_command.as_ref(), +- &vars, +- ), +- on_create_commands, +- post_create_commands, +- post_start_commands, +- environment, +- container_env: merged_container_env, +- remote_user: devcontainer.remote_user.clone(), +- workspace_folder, +- forwarded_ports, +- compose_files: Vec::new(), +- compose_service: None, +- }) +- } +- +- async fn find_and_parse(path: &Path) -> Result<(PathBuf, DevcontainerJson)> { +- // Check standard locations +- let candidates = [ +- path.join(".devcontainer/devcontainer.json"), +- path.join(".devcontainer.json"), +- ]; +- +- for candidate in &candidates { +- if candidate.exists() { +- let raw = fs::read_to_string(candidate).await.map_err(|source| { +- DevcontainerError::ReadFile { +- path: candidate.clone(), +- source, +- } +- })?; +- let stripped = jsonc::strip_jsonc(&raw); +- let parsed: DevcontainerJson = serde_json::from_str(&stripped)?; +- return Ok((candidate.clone(), parsed)); +- } +- } +- +- // Check if path itself is a devcontainer.json +- if path.is_file() && path.file_name().is_some_and(|n| n == "devcontainer.json") { +- let raw = +- fs::read_to_string(path) +- .await +- .map_err(|source| DevcontainerError::ReadFile { +- path: path.to_path_buf(), +- source, +- })?; +- let stripped = jsonc::strip_jsonc(&raw); +- let parsed: DevcontainerJson = serde_json::from_str(&stripped)?; +- return Ok((path.to_path_buf(), parsed)); +- } +- +- // Subdirectory format: scan .devcontainer/ for subdirs containing +- // devcontainer.json +- let devcontainer_dir = path.join(".devcontainer"); +- if devcontainer_dir.is_dir() { +- let mut entries = fs::read_dir(&devcontainer_dir).await.map_err(|source| { +- DevcontainerError::ReadFile { +- path: devcontainer_dir.clone(), +- source, +- } +- })?; +- let mut subdirs = Vec::new(); +- +- while let Some(entry) = +- entries +- .next_entry() +- .await +- .map_err(|source| DevcontainerError::ReadFile { +- path: devcontainer_dir.clone(), +- source, +- })? +- { +- let entry_path = entry.path(); +- let file_type = +- entry +- .file_type() +- .await +- .map_err(|source| DevcontainerError::ReadFile { +- path: entry_path.clone(), +- source, +- })?; +- if file_type.is_dir() && entry_path.join("devcontainer.json").exists() { +- subdirs.push(entry_path); +- } +- } +- +- // Sort alphabetically to get deterministic first pick +- subdirs.sort(); +- +- if let Some(subdir) = subdirs.first() { +- let candidate = subdir.join("devcontainer.json"); +- let raw = fs::read_to_string(&candidate).await.map_err(|source| { +- DevcontainerError::ReadFile { +- path: candidate.clone(), +- source, +- } +- })?; +- let stripped = jsonc::strip_jsonc(&raw); +- let parsed: DevcontainerJson = serde_json::from_str(&stripped)?; +- return Ok((candidate, parsed)); +- } +- } +- +- Err(DevcontainerError::NotFound(path.to_path_buf())) +- } +- +- fn repo_root_from_json_path<'a>(json_path: &Path, original_path: &'a Path) -> &'a Path { +- // If json_path is inside .devcontainer//, the repo root is two levels +- // up If json_path is inside .devcontainer/, the repo root is one level +- // up +- if let Some(parent) = json_path.parent() { +- if parent.file_name().is_some_and(|n| n == ".devcontainer") { +- if let Some(repo_root) = parent.parent() { +- let _ = repo_root; +- } +- } else if let Some(grandparent) = parent.parent() { +- if grandparent +- .file_name() +- .is_some_and(|n| n == ".devcontainer") +- { +- if let Some(repo_root) = grandparent.parent() { +- let _ = repo_root; +- } +- } +- } +- } +- original_path +- } +- +- fn collect_container_env( +- env: Option<&HashMap>, +- vars: &variables::VariableContext, +- ) -> HashMap { +- match env { +- None => HashMap::new(), +- Some(map) => map +- .iter() +- .map(|(k, v)| (k.clone(), variables::substitute(v, vars))) +- .collect(), +- } +- } +- +- fn convert_lifecycle_command(cmd: &types::LifecycleCommand) -> Command { +- match cmd { +- types::LifecycleCommand::String(s) => Command::Shell(s.clone()), +- types::LifecycleCommand::Array(arr) => Command::Args(arr.clone()), +- types::LifecycleCommand::Object(map) => Command::Parallel(map.clone()), +- } +- } +- +- fn collect_commands( +- cmd: Option<&types::LifecycleCommand>, +- vars: &variables::VariableContext, +- ) -> Vec { +- match cmd { +- None => Vec::new(), +- Some(types::LifecycleCommand::String(s)) => { +- vec![Command::Shell(variables::substitute(s, vars))] +- } +- Some(types::LifecycleCommand::Array(arr)) => { +- vec![Command::Args( +- arr.iter().map(|s| variables::substitute(s, vars)).collect(), +- )] +- } +- Some(types::LifecycleCommand::Object(map)) => { +- vec![Command::Parallel( +- map.iter() +- .map(|(k, v)| (k.clone(), variables::substitute(v, vars))) +- .collect(), +- )] +- } +- } +- } +- +- fn parse_forward_ports(ports: &[serde_json::Value]) -> Vec { +- ports +- .iter() +- .filter_map(|p| match p { +- serde_json::Value::Number(n) => n.as_u64().and_then(|n| u16::try_from(n).ok()), +- serde_json::Value::String(s) => { +- let s = s.split('/').next().unwrap_or(s); // strip protocol +- if let Some((_host, container)) = s.split_once(':') { +- container.parse::().ok() +- } else { +- s.parse::().ok() +- } +- } +- _ => None, +- }) +- .collect() +- } +-} +- +-#[cfg(test)] +-mod tests { +- use super::*; +- +- #[test] +- fn copy_local_file_is_rejected() { +- let dockerfile = "FROM ubuntu\nCOPY . /app\n"; +- let err = check_no_build_context_copies(dockerfile).unwrap_err(); +- assert!( +- matches!(err, DevcontainerError::UnsupportedCopyAdd(_)), +- "expected UnsupportedCopyAdd, got: {err:?}" +- ); +- assert!(err.to_string().contains("COPY . /app")); +- } +- +- #[test] +- fn add_local_file_is_rejected() { +- let dockerfile = "FROM ubuntu\nADD local.tar.gz /opt/\n"; +- let err = check_no_build_context_copies(dockerfile).unwrap_err(); +- assert!(err.to_string().contains("ADD local.tar.gz /opt/")); +- } +- +- #[test] +- fn copy_from_stage_is_allowed() { +- let dockerfile = +- "FROM builder AS build\nRUN make\nFROM ubuntu\nCOPY --from=builder /app /app\n"; +- check_no_build_context_copies(dockerfile).unwrap(); +- } +- +- #[test] +- fn add_url_is_allowed() { +- let dockerfile = "FROM ubuntu\nADD https://example.com/file.tar.gz /opt/\n"; +- check_no_build_context_copies(dockerfile).unwrap(); +- } +- +- #[test] +- fn add_http_url_is_allowed() { +- let dockerfile = "FROM ubuntu\nADD http://example.com/file.tar.gz /opt/\n"; +- check_no_build_context_copies(dockerfile).unwrap(); +- } +- +- #[test] +- fn only_from_and_run_is_allowed() { +- let dockerfile = "FROM ubuntu\nRUN apt-get update\nENV FOO=bar\n"; +- check_no_build_context_copies(dockerfile).unwrap(); +- } +- +- #[test] +- fn multiline_continuation_copy_is_rejected() { +- let dockerfile = "FROM ubuntu\nCOPY \\\n . /app\n"; +- let err = check_no_build_context_copies(dockerfile).unwrap_err(); +- assert!(matches!(err, DevcontainerError::UnsupportedCopyAdd(_))); +- } +- +- #[test] +- fn case_insensitive_copy_is_rejected() { +- let dockerfile = "FROM ubuntu\ncopy . /app\n"; +- let err = check_no_build_context_copies(dockerfile).unwrap_err(); +- assert!(err.to_string().contains("copy . /app")); +- } +- +- #[test] +- fn case_insensitive_add_is_rejected() { +- let dockerfile = "FROM ubuntu\nadd local.tar.gz /opt/\n"; +- let err = check_no_build_context_copies(dockerfile).unwrap_err(); +- assert!(err.to_string().contains("add local.tar.gz /opt/")); +- } +- +- #[test] +- fn multiple_offending_lines_reported() { +- let dockerfile = "FROM ubuntu\nCOPY . /app\nADD foo.tar /opt/\n"; +- let err = check_no_build_context_copies(dockerfile).unwrap_err(); +- let msg = err.to_string(); +- assert!(msg.contains("COPY . /app")); +- assert!(msg.contains("ADD foo.tar /opt/")); +- } +- +- #[test] +- fn comments_and_empty_lines_are_skipped() { +- let dockerfile = "FROM ubuntu\n\n# COPY . /app\n \nRUN echo hi\n"; +- check_no_build_context_copies(dockerfile).unwrap(); +- } +-} +diff --git a/lib/crates/fabro-devcontainer/src/types.rs b/lib/crates/fabro-devcontainer/src/types.rs +deleted file mode 100644 +index 2e97d4e43..000000000 +--- a/lib/crates/fabro-devcontainer/src/types.rs ++++ /dev/null +@@ -1,336 +0,0 @@ +-use std::collections::HashMap; +- +-use serde::Deserialize; +- +-/// Top-level devcontainer.json schema (subset of the spec we support). +-#[derive(Debug, Clone, Deserialize, Default)] +-#[serde(rename_all = "camelCase")] +-pub struct DevcontainerJson { +- /// Base image (image mode) +- pub image: Option, +- +- /// Dockerfile build config +- pub build: Option, +- +- /// Docker Compose file path(s) (compose mode) +- pub docker_compose_file: Option, +- +- /// Service name for compose mode +- pub service: Option, +- +- /// Features to install: feature ID → options object +- #[serde(default)] +- pub features: HashMap, +- +- /// Ports to forward +- #[serde(default, alias = "forwardPorts")] +- pub forward_ports: Vec, +- +- /// Environment variables set in the container +- #[serde(default)] +- pub remote_env: Option>, +- +- /// Environment variables set in the container (containerEnv) +- #[serde(default)] +- pub container_env: Option>, +- +- /// Non-root user to run as +- pub remote_user: Option, +- +- /// Container user +- pub container_user: Option, +- +- /// Workspace folder path inside container +- pub workspace_folder: Option, +- +- /// Workspace mount string +- pub workspace_mount: Option, +- +- /// Run on host before anything else +- pub initialize_command: Option, +- +- /// Run in container after first creation (before updateContentCommand) +- pub on_create_command: Option, +- +- /// Run in container after creation +- pub post_create_command: Option, +- +- /// Run in container on every start +- pub post_start_command: Option, +- +- /// Override the default command +- pub override_command: Option, +-} +- +-/// Build configuration for Dockerfile mode. +-#[derive(Debug, Clone, Deserialize)] +-pub struct BuildSpec { +- /// Path to Dockerfile (relative to devcontainer.json) +- pub dockerfile: Option, +- +- /// Build context directory (relative to devcontainer.json) +- pub context: Option, +- +- /// Build arguments +- #[serde(default)] +- pub args: HashMap, +- +- /// Multi-stage build target +- pub target: Option, +-} +- +-/// A reference to one or more Docker Compose files. +-#[derive(Debug, Clone, Deserialize)] +-#[serde(untagged)] +-pub enum ComposeFileRef { +- Single(String), +- Multiple(Vec), +-} +- +-impl ComposeFileRef { +- pub fn paths(&self) -> Vec<&str> { +- match self { +- Self::Single(s) => vec![s.as_str()], +- Self::Multiple(v) => v.iter().map(String::as_str).collect(), +- } +- } +-} +- +-/// A lifecycle command can be a string, array of strings, or object of named +-/// commands. +-#[derive(Debug, Clone, Deserialize)] +-#[serde(untagged)] +-pub enum LifecycleCommand { +- String(String), +- Array(Vec), +- Object(HashMap), +-} +- +-/// Metadata from a devcontainer-feature.json file. +-#[derive(Debug, Clone, Deserialize)] +-#[serde(rename_all = "camelCase")] +-pub(crate) struct FeatureMetadata { +- pub id: Option, +- pub name: Option, +- pub version: Option, +- +- #[serde(default)] +- pub options: HashMap, +- +- /// Feature IDs that this feature should be installed after +- #[serde(default)] +- pub installs_after: Vec, +- +- /// Hard dependencies: feature IDs that must be present (auto-installed if +- /// missing) +- #[serde(default)] +- pub depends_on: HashMap, +- +- /// Environment variables contributed by this feature +- #[serde(default)] +- pub container_env: HashMap, +- +- /// Lifecycle hooks contributed by this feature +- pub on_create_command: Option, +- pub post_create_command: Option, +- pub post_start_command: Option, +-} +- +-/// A single option for a devcontainer feature. +-#[derive(Debug, Clone, Deserialize)] +-pub(crate) struct FeatureOption { +- #[serde(rename = "type")] +- pub option_type: Option, +- pub default: Option, +- pub description: Option, +-} +- +-#[cfg(test)] +-mod tests { +- use super::*; +- +- #[test] +- fn parse_image_only() { +- let json = r#"{"image": "mcr.microsoft.com/devcontainers/base:ubuntu"}"#; +- let config: DevcontainerJson = serde_json::from_str(json).unwrap(); +- assert_eq!( +- config.image.as_deref(), +- Some("mcr.microsoft.com/devcontainers/base:ubuntu") +- ); +- } +- +- #[test] +- fn parse_with_features() { +- let json = r#"{ +- "image": "ubuntu", +- "features": { +- "ghcr.io/devcontainers/features/node:1": {"version": "20"}, +- "ghcr.io/devcontainers/features/python:1": {} +- } +- }"#; +- let config: DevcontainerJson = serde_json::from_str(json).unwrap(); +- assert_eq!(config.features.len(), 2); +- } +- +- #[test] +- fn parse_lifecycle_string() { +- let json = r#"{"postCreateCommand": "npm install"}"#; +- let config: DevcontainerJson = serde_json::from_str(json).unwrap(); +- assert!(matches!( +- config.post_create_command, +- Some(LifecycleCommand::String(ref s)) if s == "npm install" +- )); +- } +- +- #[test] +- fn parse_lifecycle_array() { +- let json = r#"{"postCreateCommand": ["npm", "install"]}"#; +- let config: DevcontainerJson = serde_json::from_str(json).unwrap(); +- assert!(matches!( +- config.post_create_command, +- Some(LifecycleCommand::Array(ref arr)) if arr == &["npm", "install"] +- )); +- } +- +- #[test] +- fn parse_lifecycle_object() { +- let json = r#"{"postCreateCommand": {"install": "npm install", "build": "npm run build"}}"#; +- let config: DevcontainerJson = serde_json::from_str(json).unwrap(); +- assert!(matches!( +- config.post_create_command, +- Some(LifecycleCommand::Object(ref map)) if map.len() == 2 +- )); +- } +- +- #[test] +- fn parse_build_config() { +- let json = r#"{ +- "build": { +- "dockerfile": "Dockerfile", +- "context": "..", +- "args": {"VARIANT": "3.9"}, +- "target": "dev" +- } +- }"#; +- let config: DevcontainerJson = serde_json::from_str(json).unwrap(); +- let build = config.build.unwrap(); +- assert_eq!(build.dockerfile.as_deref(), Some("Dockerfile")); +- assert_eq!(build.context.as_deref(), Some("..")); +- assert_eq!(build.args.get("VARIANT").map(String::as_str), Some("3.9")); +- assert_eq!(build.target.as_deref(), Some("dev")); +- } +- +- #[test] +- fn parse_compose_mode() { +- let json = r#"{ +- "dockerComposeFile": "docker-compose.yml", +- "service": "app", +- "workspaceFolder": "/workspace" +- }"#; +- let config: DevcontainerJson = serde_json::from_str(json).unwrap(); +- assert_eq!(config.docker_compose_file.as_ref().unwrap().paths(), vec![ +- "docker-compose.yml" +- ]); +- assert_eq!(config.service.as_deref(), Some("app")); +- assert_eq!(config.workspace_folder.as_deref(), Some("/workspace")); +- } +- +- #[test] +- fn parse_compose_mode_array() { +- let json = r#"{ +- "dockerComposeFile": ["docker-compose.yml", "docker-compose.override.yml"], +- "service": "app" +- }"#; +- let config: DevcontainerJson = serde_json::from_str(json).unwrap(); +- assert_eq!(config.docker_compose_file.as_ref().unwrap().paths(), vec![ +- "docker-compose.yml", +- "docker-compose.override.yml" +- ]); +- } +- +- #[test] +- fn unknown_fields_ignored() { +- let json = r#"{"image": "ubuntu", "unknownField": true, "customizations": {}}"#; +- let config: DevcontainerJson = serde_json::from_str(json).unwrap(); +- assert_eq!(config.image.as_deref(), Some("ubuntu")); +- } +- +- #[test] +- fn parse_feature_metadata_lifecycle_hooks() { +- let json = r#"{ +- "id": "python", +- "onCreateCommand": "pip install -r requirements.txt", +- "postCreateCommand": ["python", "setup.py"], +- "postStartCommand": {"server": "python app.py"} +- }"#; +- let meta: FeatureMetadata = serde_json::from_str(json).unwrap(); +- assert!( +- matches!(meta.on_create_command, Some(LifecycleCommand::String(ref s)) if s == "pip install -r requirements.txt") +- ); +- assert!( +- matches!(meta.post_create_command, Some(LifecycleCommand::Array(ref arr)) if arr == &["python", "setup.py"]) +- ); +- assert!( +- matches!(meta.post_start_command, Some(LifecycleCommand::Object(ref map)) if map.len() == 1) +- ); +- } +- +- #[test] +- fn parse_feature_metadata_container_env() { +- let json = r#"{ +- "id": "node", +- "containerEnv": { +- "NODE_ENV": "development", +- "PATH": "/usr/local/bin:${PATH}" +- } +- }"#; +- let meta: FeatureMetadata = serde_json::from_str(json).unwrap(); +- assert_eq!(meta.container_env.len(), 2); +- assert_eq!( +- meta.container_env.get("NODE_ENV").map(String::as_str), +- Some("development") +- ); +- } +- +- #[test] +- fn parse_feature_metadata_depends_on() { +- let json = r#"{ +- "id": "python", +- "dependsOn": { +- "ghcr.io/devcontainers/features/common-utils:1": {}, +- "ghcr.io/devcontainers/features/node:1": {"version": "20"} +- } +- }"#; +- let meta: FeatureMetadata = serde_json::from_str(json).unwrap(); +- assert_eq!(meta.depends_on.len(), 2); +- assert!( +- meta.depends_on +- .contains_key("ghcr.io/devcontainers/features/common-utils:1") +- ); +- assert_eq!( +- meta.depends_on.get("ghcr.io/devcontainers/features/node:1"), +- Some(&serde_json::json!({"version": "20"})) +- ); +- } +- +- #[test] +- fn parse_feature_metadata() { +- let json = r#"{ +- "id": "node", +- "name": "Node.js", +- "version": "1.0.0", +- "options": { +- "version": { +- "type": "string", +- "default": "lts", +- "description": "Node.js version" +- } +- }, +- "installsAfter": ["ghcr.io/devcontainers/features/common-utils"] +- }"#; +- let meta: FeatureMetadata = serde_json::from_str(json).unwrap(); +- assert_eq!(meta.id.as_deref(), Some("node")); +- assert_eq!(meta.options.len(), 1); +- assert_eq!(meta.installs_after.len(), 1); +- } +-} +diff --git a/lib/crates/fabro-devcontainer/src/variables.rs b/lib/crates/fabro-devcontainer/src/variables.rs +deleted file mode 100644 +index 0e71ab096..000000000 +--- a/lib/crates/fabro-devcontainer/src/variables.rs ++++ /dev/null +@@ -1,251 +0,0 @@ +-use fabro_util::env::Env; +- +-/// Context for variable substitution. +-pub(crate) struct VariableContext<'a> { +- pub local_workspace_folder: String, +- pub local_workspace_folder_basename: String, +- pub container_workspace_folder: String, +- pub env: &'a dyn Env, +-} +- +-/// Replace devcontainer variables in a string value. +-pub(crate) fn substitute(input: &str, ctx: &VariableContext) -> String { +- let mut result = String::with_capacity(input.len()); +- let mut rest = input; +- +- while let Some(start) = rest.find("${") { +- result.push_str(&rest[..start]); +- let after_open = &rest[start + 2..]; +- +- if let Some(close) = after_open.find('}') { +- let expr = &after_open[..close]; +- let replacement = resolve_variable(expr, ctx); +- match replacement { +- Some(val) => result.push_str(&val), +- None => { +- // Unknown variable — leave as-is +- result.push_str(&rest[start..=(start + 2 + close)]); +- } +- } +- rest = &after_open[close + 1..]; +- } else { +- // No closing brace — copy literally +- result.push_str(&rest[start..]); +- rest = ""; +- } +- } +- +- result.push_str(rest); +- result +-} +- +-fn resolve_variable(expr: &str, ctx: &VariableContext) -> Option { +- match expr { +- "localWorkspaceFolder" => Some(ctx.local_workspace_folder.clone()), +- "localWorkspaceFolderBasename" => Some(ctx.local_workspace_folder_basename.clone()), +- "containerWorkspaceFolder" => Some(ctx.container_workspace_folder.clone()), +- "containerWorkspaceFolderBasename" => { +- let basename = ctx +- .container_workspace_folder +- .rsplit('/') +- .next() +- .unwrap_or(&ctx.container_workspace_folder); +- Some(basename.to_string()) +- } +- _ if expr.starts_with("localEnv:") => { +- let var_part = &expr["localEnv:".len()..]; +- // Split on first colon for default value +- if let Some(colon_pos) = var_part.find(':') { +- let var_name = &var_part[..colon_pos]; +- let default = &var_part[colon_pos + 1..]; +- Some( +- ctx.env +- .var(var_name) +- .unwrap_or_else(|_| default.to_string()), +- ) +- } else { +- Some(ctx.env.var(var_part).unwrap_or_default()) +- } +- } +- _ => None, +- } +-} +- +-#[cfg(test)] +-mod tests { +- use std::collections::HashMap; +- +- use fabro_util::env::{SystemEnv, TestEnv}; +- +- use super::*; +- +- fn test_ctx() -> VariableContext<'static> { +- // Tests that don't exercise localEnv don't care about the env impl. +- // Use SystemEnv which has no lifetime/allocation concerns. +- VariableContext { +- local_workspace_folder: "/home/user/project".to_string(), +- local_workspace_folder_basename: "project".to_string(), +- container_workspace_folder: "/workspaces/project".to_string(), +- env: &SystemEnv, +- } +- } +- +- #[test] +- fn no_variables() { +- let ctx = test_ctx(); +- assert_eq!(substitute("hello", &ctx), "hello"); +- } +- +- #[test] +- fn local_workspace_folder() { +- let ctx = test_ctx(); +- assert_eq!( +- substitute("${localWorkspaceFolder}/src", &ctx), +- "/home/user/project/src" +- ); +- } +- +- #[test] +- fn local_workspace_folder_basename() { +- let ctx = test_ctx(); +- assert_eq!( +- substitute("name: ${localWorkspaceFolderBasename}", &ctx), +- "name: project" +- ); +- } +- +- #[test] +- fn container_workspace_folder() { +- let ctx = test_ctx(); +- assert_eq!( +- substitute("${containerWorkspaceFolder}/app", &ctx), +- "/workspaces/project/app" +- ); +- } +- +- #[test] +- fn container_workspace_folder_basename() { +- let ctx = test_ctx(); +- assert_eq!( +- substitute("${containerWorkspaceFolderBasename}", &ctx), +- "project" +- ); +- } +- +- #[test] +- fn container_workspace_folder_basename_nested() { +- let ctx = VariableContext { +- local_workspace_folder: "/home/user/repos/my-app".to_string(), +- local_workspace_folder_basename: "my-app".to_string(), +- container_workspace_folder: "/workspaces/repos/my-app".to_string(), +- env: &SystemEnv, +- }; +- assert_eq!( +- substitute("${containerWorkspaceFolderBasename}", &ctx), +- "my-app" +- ); +- } +- +- #[test] +- fn multiple_variables() { +- let ctx = test_ctx(); +- assert_eq!( +- substitute( +- "${localWorkspaceFolder} and ${containerWorkspaceFolder}", +- &ctx +- ), +- "/home/user/project and /workspaces/project" +- ); +- } +- +- #[test] +- fn unknown_variable_left_as_is() { +- let ctx = test_ctx(); +- assert_eq!(substitute("${unknownVariable}", &ctx), "${unknownVariable}"); +- } +- +- #[test] +- fn local_env_with_set_variable() { +- let env = TestEnv(HashMap::from([( +- "FABRO_TEST_VAR_SET".into(), +- "hello".into(), +- )])); +- let ctx = VariableContext { +- env: &env, +- ..test_ctx() +- }; +- assert_eq!(substitute("${localEnv:FABRO_TEST_VAR_SET}", &ctx), "hello"); +- } +- +- #[test] +- fn local_env_unset_returns_empty() { +- let env = TestEnv(HashMap::new()); +- let ctx = VariableContext { +- env: &env, +- ..test_ctx() +- }; +- assert_eq!(substitute("${localEnv:FABRO_TEST_VAR_UNSET_123}", &ctx), ""); +- } +- +- #[test] +- fn local_env_with_default_when_unset() { +- let env = TestEnv(HashMap::new()); +- let ctx = VariableContext { +- env: &env, +- ..test_ctx() +- }; +- assert_eq!( +- substitute("${localEnv:FABRO_TEST_VAR_DEFAULT_456:fallback}", &ctx), +- "fallback" +- ); +- } +- +- #[test] +- fn local_env_with_default_when_set() { +- let env = TestEnv(HashMap::from([( +- "FABRO_TEST_VAR_DEFAULT_SET".into(), +- "actual".into(), +- )])); +- let ctx = VariableContext { +- env: &env, +- ..test_ctx() +- }; +- assert_eq!( +- substitute("${localEnv:FABRO_TEST_VAR_DEFAULT_SET:fallback}", &ctx), +- "actual" +- ); +- } +- +- #[test] +- fn no_closing_brace() { +- let ctx = test_ctx(); +- assert_eq!( +- substitute("${localWorkspaceFolder", &ctx), +- "${localWorkspaceFolder" +- ); +- } +- +- #[test] +- fn empty_input() { +- let ctx = test_ctx(); +- assert_eq!(substitute("", &ctx), ""); +- } +- +- #[test] +- fn dollar_without_brace() { +- let ctx = test_ctx(); +- assert_eq!(substitute("$notavar", &ctx), "$notavar"); +- } +- +- #[test] +- fn adjacent_variables() { +- let ctx = test_ctx(); +- assert_eq!( +- substitute( +- "${localWorkspaceFolderBasename}${containerWorkspaceFolderBasename}", +- &ctx +- ), +- "projectproject" +- ); +- } +-} +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/all-lifecycle/.devcontainer/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/all-lifecycle/.devcontainer/devcontainer.json +deleted file mode 100644 +index 38ba7b416..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/all-lifecycle/.devcontainer/devcontainer.json ++++ /dev/null +@@ -1,10 +0,0 @@ +-{ +- "image": "ubuntu:22.04", +- "initializeCommand": "echo pre-build", +- "onCreateCommand": ["make", "setup"], +- "postCreateCommand": { +- "install": "npm install", +- "build": "npm run build" +- }, +- "postStartCommand": "echo started" +-} +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/compose-mode/.devcontainer/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/compose-mode/.devcontainer/devcontainer.json +deleted file mode 100644 +index 9e7be272c..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/compose-mode/.devcontainer/devcontainer.json ++++ /dev/null +@@ -1,11 +0,0 @@ +-{ +- "dockerComposeFile": "docker-compose.yml", +- "service": "app", +- "workspaceFolder": "/workspace", +- "remoteUser": "node", +- "forwardPorts": [3000, 5173], +- "postCreateCommand": "npm install", +- "remoteEnv": { +- "NODE_ENV": "development" +- } +-} +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/compose-mode/.devcontainer/docker-compose.yml b/lib/crates/fabro-devcontainer/tests/fixtures/compose-mode/.devcontainer/docker-compose.yml +deleted file mode 100644 +index b8726871c..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/compose-mode/.devcontainer/docker-compose.yml ++++ /dev/null +@@ -1,13 +0,0 @@ +-services: +- app: +- image: node:20 +- ports: +- - "3000:3000" +- - "9229:9229" +- environment: +- - "NODE_ENV=development" +- - "DEBUG=true" +- db: +- image: postgres:15 +- ports: +- - "5432:5432" +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/compose-multi/.devcontainer/base.yml b/lib/crates/fabro-devcontainer/tests/fixtures/compose-multi/.devcontainer/base.yml +deleted file mode 100644 +index d6fd09beb..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/compose-multi/.devcontainer/base.yml ++++ /dev/null +@@ -1,5 +0,0 @@ +-services: +- app: +- image: node:20 +- ports: +- - "3000:3000" +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/compose-multi/.devcontainer/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/compose-multi/.devcontainer/devcontainer.json +deleted file mode 100644 +index c25db063d..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/compose-multi/.devcontainer/devcontainer.json ++++ /dev/null +@@ -1,5 +0,0 @@ +-{ +- "dockerComposeFile": ["base.yml", "override.yml"], +- "service": "app", +- "workspaceFolder": "/workspace" +-} +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/compose-multi/.devcontainer/override.yml b/lib/crates/fabro-devcontainer/tests/fixtures/compose-multi/.devcontainer/override.yml +deleted file mode 100644 +index 55023a393..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/compose-multi/.devcontainer/override.yml ++++ /dev/null +@@ -1,5 +0,0 @@ +-services: +- app: +- image: node:22 +- environment: +- - "OVERRIDE_VAR=true" +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/Dockerfile b/lib/crates/fabro-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/Dockerfile +deleted file mode 100644 +index 3c45d3a1a..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/Dockerfile ++++ /dev/null +@@ -1,3 +0,0 @@ +-FROM node:20 +-RUN apt-get update && apt-get install -y git +-WORKDIR /workspace +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/devcontainer.json +deleted file mode 100644 +index 4de5408de..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/devcontainer.json ++++ /dev/null +@@ -1,12 +0,0 @@ +-{ +- // This is a JSONC file with comments +- "build": { +- "dockerfile": "Dockerfile", +- "context": "..", +- "args": {"NODE_VERSION": "20"}, +- "target": "dev" +- }, +- "remoteUser": "developer", +- "postCreateCommand": "npm install", +- "forwardPorts": [4000], +-} +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/feature-options/.devcontainer/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/feature-options/.devcontainer/devcontainer.json +deleted file mode 100644 +index 6f67c9c6e..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/feature-options/.devcontainer/devcontainer.json ++++ /dev/null +@@ -1,7 +0,0 @@ +-{ +- "image": "mcr.microsoft.com/devcontainers/base:ubuntu", +- "features": { +- "./go-feature": "1.21" +- }, +- "remoteUser": "developer" +-} +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/feature-options/.devcontainer/go-feature/devcontainer-feature.json b/lib/crates/fabro-devcontainer/tests/fixtures/feature-options/.devcontainer/go-feature/devcontainer-feature.json +deleted file mode 100644 +index 7caf37a37..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/feature-options/.devcontainer/go-feature/devcontainer-feature.json ++++ /dev/null +@@ -1,16 +0,0 @@ +-{ +- "id": "go-feature", +- "version": "1.0.0", +- "options": { +- "version": { +- "type": "string", +- "default": "latest", +- "description": "Go version" +- }, +- "node-version": { +- "type": "string", +- "default": "none", +- "description": "Optional Node.js version (hyphenated option ID)" +- } +- } +-} +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/feature-options/.devcontainer/go-feature/install.sh b/lib/crates/fabro-devcontainer/tests/fixtures/feature-options/.devcontainer/go-feature/install.sh +deleted file mode 100644 +index 5cd5409ed..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/feature-options/.devcontainer/go-feature/install.sh ++++ /dev/null +@@ -1,2 +0,0 @@ +-#!/bin/sh +-echo "Installing go ${VERSION} with node ${NODE_VERSION}" +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/image-only/.devcontainer/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/image-only/.devcontainer/devcontainer.json +deleted file mode 100644 +index 061bd2055..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/image-only/.devcontainer/devcontainer.json ++++ /dev/null +@@ -1,13 +0,0 @@ +-{ +- "image": "mcr.microsoft.com/devcontainers/base:ubuntu", +- "forwardPorts": [3000, "8080:80", "9090"], +- "remoteUser": "vscode", +- "remoteEnv": { +- "EDITOR": "code" +- }, +- "containerEnv": { +- "DEBIAN_FRONTEND": "noninteractive" +- }, +- "onCreateCommand": "setup.sh", +- "postCreateCommand": "echo hello" +-} +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/base-utils/devcontainer-feature.json b/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/base-utils/devcontainer-feature.json +deleted file mode 100644 +index 0141f558d..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/base-utils/devcontainer-feature.json ++++ /dev/null +@@ -1,8 +0,0 @@ +-{ +- "id": "base-utils", +- "version": "1.0.0", +- "containerEnv": { +- "BASE_UTILS_INSTALLED": "true" +- }, +- "onCreateCommand": "echo base-utils-setup" +-} +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/base-utils/install.sh b/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/base-utils/install.sh +deleted file mode 100644 +index 0dec9c182..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/base-utils/install.sh ++++ /dev/null +@@ -1,2 +0,0 @@ +-#!/bin/sh +-echo "Installing base-utils" +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/devcontainer.json +deleted file mode 100644 +index 918b7b59c..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/devcontainer.json ++++ /dev/null +@@ -1,13 +0,0 @@ +-{ +- "image": "mcr.microsoft.com/devcontainers/base:ubuntu", +- "features": { +- "./node-feature": {"version": "20"}, +- "./python-feature": {} +- }, +- "remoteUser": "vscode", +- "containerEnv": { +- "DEVCONTAINER": "true" +- }, +- "onCreateCommand": "echo devcontainer-setup", +- "postCreateCommand": "echo devcontainer-post-create" +-} +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/node-feature/devcontainer-feature.json b/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/node-feature/devcontainer-feature.json +deleted file mode 100644 +index fa130bd8f..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/node-feature/devcontainer-feature.json ++++ /dev/null +@@ -1,21 +0,0 @@ +-{ +- "id": "node-feature", +- "version": "1.0.0", +- "options": { +- "version": { +- "type": "string", +- "default": "lts", +- "description": "Node.js version" +- } +- }, +- "dependsOn": { +- "./base-utils": {} +- }, +- "installsAfter": [], +- "containerEnv": { +- "NODE_INSTALLED": "true", +- "NODE_PATH": "/usr/local/lib/node_modules" +- }, +- "onCreateCommand": "echo node-setup", +- "postStartCommand": "echo node-started" +-} +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/node-feature/install.sh b/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/node-feature/install.sh +deleted file mode 100644 +index cc0121c4b..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/node-feature/install.sh ++++ /dev/null +@@ -1,2 +0,0 @@ +-#!/bin/sh +-echo "Installing node ${VERSION}" +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/python-feature/devcontainer-feature.json b/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/python-feature/devcontainer-feature.json +deleted file mode 100644 +index b4063b4e6..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/python-feature/devcontainer-feature.json ++++ /dev/null +@@ -1,9 +0,0 @@ +-{ +- "id": "python-feature", +- "version": "1.0.0", +- "installsAfter": ["./node-feature"], +- "containerEnv": { +- "PYTHON_INSTALLED": "true" +- }, +- "postCreateCommand": "echo python-post-create" +-} +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/python-feature/install.sh b/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/python-feature/install.sh +deleted file mode 100644 +index 944a488fc..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/local-features/.devcontainer/python-feature/install.sh ++++ /dev/null +@@ -1,2 +0,0 @@ +-#!/bin/sh +-echo "Installing python" +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/realistic-compose/.devcontainer/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/realistic-compose/.devcontainer/devcontainer.json +deleted file mode 100644 +index 4ac94f01d..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/realistic-compose/.devcontainer/devcontainer.json ++++ /dev/null +@@ -1,18 +0,0 @@ +-{ +- "dockerComposeFile": ["docker-compose.yml", "docker-compose.override.yml"], +- "service": "app", +- "workspaceFolder": "/workspace", +- "remoteUser": "node", +- "forwardPorts": [8080], +- "containerEnv": { +- "TERM": "xterm-256color", +- "EDITOR": "vim" +- }, +- "remoteEnv": { +- "DATABASE_URL": "postgres://postgres:devpass@db:5432/myapp_dev", +- "REDIS_URL": "redis://redis:6379" +- }, +- "onCreateCommand": "npm ci", +- "postCreateCommand": "npm run db:migrate", +- "postStartCommand": "npm run dev" +-} +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/realistic-compose/.devcontainer/docker-compose.override.yml b/lib/crates/fabro-devcontainer/tests/fixtures/realistic-compose/.devcontainer/docker-compose.override.yml +deleted file mode 100644 +index ef2061b1b..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/realistic-compose/.devcontainer/docker-compose.override.yml ++++ /dev/null +@@ -1,7 +0,0 @@ +-services: +- app: +- environment: +- - "DEBUG=true" +- - "LOG_LEVEL=verbose" +- ports: +- - "4000:4000" +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/realistic-compose/.devcontainer/docker-compose.yml b/lib/crates/fabro-devcontainer/tests/fixtures/realistic-compose/.devcontainer/docker-compose.yml +deleted file mode 100644 +index 7937512cd..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/realistic-compose/.devcontainer/docker-compose.yml ++++ /dev/null +@@ -1,22 +0,0 @@ +-services: +- app: +- image: node:20-bookworm +- ports: +- - "3000:3000" +- - "9229:9229" +- environment: +- - "NODE_ENV=development" +- user: "node" +- volumes: +- - ..:/workspace:cached +- db: +- image: postgres:16 +- ports: +- - "5432:5432" +- environment: +- POSTGRES_PASSWORD: devpass +- POSTGRES_DB: myapp_dev +- redis: +- image: redis:7-alpine +- ports: +- - "6379:6379" +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/realistic-python/.devcontainer/Dockerfile b/lib/crates/fabro-devcontainer/tests/fixtures/realistic-python/.devcontainer/Dockerfile +deleted file mode 100644 +index 16f9220a8..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/realistic-python/.devcontainer/Dockerfile ++++ /dev/null +@@ -1,10 +0,0 @@ +-ARG PYTHON_VERSION=3.11 +-FROM python:${PYTHON_VERSION}-slim +- +-RUN apt-get update && apt-get install -y --no-install-recommends \ +- git \ +- curl \ +- && rm -rf /var/lib/apt/lists/* +- +-RUN useradd -m -s /bin/bash developer +-WORKDIR /workspaces/app +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/realistic-python/.devcontainer/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/realistic-python/.devcontainer/devcontainer.json +deleted file mode 100644 +index 6fd7a715b..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/realistic-python/.devcontainer/devcontainer.json ++++ /dev/null +@@ -1,23 +0,0 @@ +-{ +- // Realistic Python project devcontainer +- "build": { +- "dockerfile": "Dockerfile", +- "args": { +- "PYTHON_VERSION": "3.12" +- } +- }, +- "containerEnv": { +- "PYTHONDONTWRITEBYTECODE": "1", +- "PYTHONUNBUFFERED": "1", +- "PIP_NO_CACHE_DIR": "1" +- }, +- "remoteEnv": { +- "PYTHONPATH": "${containerWorkspaceFolder}/src", +- "PYTHONUNBUFFERED": "yes" +- }, +- "remoteUser": "developer", +- "forwardPorts": [8000, 5432], +- "onCreateCommand": "pip install -r requirements.txt", +- "postCreateCommand": "python manage.py migrate", +- "postStartCommand": "python manage.py runserver 0.0.0.0:8000", +-} +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-mode/.devcontainer/python/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-mode/.devcontainer/python/devcontainer.json +deleted file mode 100644 +index 726cfad36..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-mode/.devcontainer/python/devcontainer.json ++++ /dev/null +@@ -1,4 +0,0 @@ +-{ +- "image": "mcr.microsoft.com/devcontainers/python:3.12", +- "remoteUser": "vscode" +-} +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-multiple/.devcontainer/alpha/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-multiple/.devcontainer/alpha/devcontainer.json +deleted file mode 100644 +index a52df2dae..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-multiple/.devcontainer/alpha/devcontainer.json ++++ /dev/null +@@ -1,4 +0,0 @@ +-{ +- "image": "mcr.microsoft.com/devcontainers/base:ubuntu", +- "remoteUser": "alpha-user" +-} +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-multiple/.devcontainer/beta/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-multiple/.devcontainer/beta/devcontainer.json +deleted file mode 100644 +index b0085ad51..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-multiple/.devcontainer/beta/devcontainer.json ++++ /dev/null +@@ -1,4 +0,0 @@ +-{ +- "image": "node:20", +- "remoteUser": "beta-user" +-} +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-with-standard/.devcontainer/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-with-standard/.devcontainer/devcontainer.json +deleted file mode 100644 +index e58b2dce4..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-with-standard/.devcontainer/devcontainer.json ++++ /dev/null +@@ -1,4 +0,0 @@ +-{ +- "image": "mcr.microsoft.com/devcontainers/base:ubuntu", +- "remoteUser": "standard-user" +-} +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-with-standard/.devcontainer/python/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-with-standard/.devcontainer/python/devcontainer.json +deleted file mode 100644 +index e419cd97d..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/subdirectory-with-standard/.devcontainer/python/devcontainer.json ++++ /dev/null +@@ -1,4 +0,0 @@ +-{ +- "image": "mcr.microsoft.com/devcontainers/python:3.12", +- "remoteUser": "python-user" +-} +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/variables/.devcontainer/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/variables/.devcontainer/devcontainer.json +deleted file mode 100644 +index 63083ea09..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/variables/.devcontainer/devcontainer.json ++++ /dev/null +@@ -1,9 +0,0 @@ +-{ +- "image": "ubuntu:22.04", +- "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", +- "remoteEnv": { +- "PROJECT_ROOT": "${containerWorkspaceFolder}", +- "PROJECT_NAME": "${containerWorkspaceFolderBasename}" +- }, +- "postCreateCommand": "echo ${containerWorkspaceFolder}" +-} +diff --git a/lib/crates/fabro-devcontainer/tests/fixtures/with-features/devcontainer.json b/lib/crates/fabro-devcontainer/tests/fixtures/with-features/devcontainer.json +deleted file mode 100644 +index 2d477b073..000000000 +--- a/lib/crates/fabro-devcontainer/tests/fixtures/with-features/devcontainer.json ++++ /dev/null +@@ -1,9 +0,0 @@ +-{ +- "image": "mcr.microsoft.com/devcontainers/base:ubuntu", +- "features": { +- "ghcr.io/devcontainers/features/node:1": { +- "version": "20" +- }, +- "ghcr.io/devcontainers/features/python:1": {} +- } +-} +diff --git a/lib/crates/fabro-devcontainer/tests/it/e2e.rs b/lib/crates/fabro-devcontainer/tests/it/e2e.rs +deleted file mode 100644 +index e5ad60c8d..000000000 +--- a/lib/crates/fabro-devcontainer/tests/it/e2e.rs ++++ /dev/null +@@ -1,594 +0,0 @@ +-//! End-to-end tests exercising full resolver pipeline with realistic +-//! devcontainer configs. These tests verify the 4 critical gaps are wired +-//! correctly through the entire stack: +-//! 1. onCreateCommand +-//! 2. build.args +-//! 3. containerEnv +-//! 4. dockerComposeFile array +- +-use fabro_devcontainer::{Command, DevcontainerResolver}; +- +-use super::helpers::fixture_path; +- +-/// Realistic Python project: Dockerfile + build.args + containerEnv + +-/// onCreateCommand + remoteEnv Verifies all 4 gaps work together in a single +-/// config. +-#[tokio::test] +-async fn realistic_python_project() { +- let config = DevcontainerResolver::resolve(&fixture_path("realistic-python")) +- .await +- .unwrap(); +- +- // Gap 2: build.args exposed for docker build --build-arg +- assert_eq!( +- config.build_args.get("PYTHON_VERSION").map(String::as_str), +- Some("3.12") +- ); +- +- // Gap 3: containerEnv baked into Dockerfile as ENV directives +- assert!(config.dockerfile.contains("ENV PIP_NO_CACHE_DIR=1")); +- assert!(config.dockerfile.contains("ENV PYTHONDONTWRITEBYTECODE=1")); +- assert_eq!( +- config +- .container_env +- .get("PIP_NO_CACHE_DIR") +- .map(String::as_str), +- Some("1") +- ); +- +- // After fix: only containerEnv is baked into Dockerfile (remoteEnv is +- // runtime-only) +- assert!(config.dockerfile.contains("ENV PYTHONUNBUFFERED=1")); +- // environment HashMap gets the remoteEnv value +- assert_eq!( +- config +- .environment +- .get("PYTHONUNBUFFERED") +- .map(String::as_str), +- Some("yes") +- ); +- +- // Gap 3: remoteEnv with variable substitution +- assert_eq!( +- config.environment.get("PYTHONPATH").map(String::as_str), +- Some("/workspaces/realistic-python/src") +- ); +- +- // Gap 1: onCreateCommand parsed and exposed +- assert_eq!(config.on_create_commands.len(), 1); +- assert!( +- matches!(&config.on_create_commands[0], Command::Shell(s) if s == "pip install -r requirements.txt") +- ); +- +- // Other lifecycle commands still work +- assert_eq!(config.post_create_commands.len(), 1); +- assert!( +- matches!(&config.post_create_commands[0], Command::Shell(s) if s == "python manage.py migrate") +- ); +- assert_eq!(config.post_start_commands.len(), 1); +- assert!( +- matches!(&config.post_start_commands[0], Command::Shell(s) if s == "python manage.py runserver 0.0.0.0:8000") +- ); +- +- // Dockerfile content is the actual file (not generated FROM line) +- assert!(config.dockerfile.contains("ARG PYTHON_VERSION=3.11")); +- assert!(config.dockerfile.contains("apt-get update")); +- +- // Standard fields +- assert_eq!(config.remote_user.as_deref(), Some("developer")); +- assert_eq!(config.forwarded_ports, vec![8000, 5432]); +- assert!(config.compose_files.is_empty()); +-} +- +-/// Realistic compose project: multi-file compose + containerEnv + +-/// onCreateCommand + remoteEnv Verifies gaps 1, 3, 4 work together in compose +-/// mode. +-#[tokio::test] +-async fn realistic_compose_project() { +- let config = DevcontainerResolver::resolve(&fixture_path("realistic-compose")) +- .await +- .unwrap(); +- +- // Gap 4: multiple compose files resolved +- assert_eq!(config.compose_files.len(), 2); +- assert_eq!(config.compose_service.as_deref(), Some("app")); +- +- // Gap 4: image from base compose file (override doesn't change image) +- assert!(config.dockerfile.contains("FROM node:20-bookworm")); +- +- // Ports merged from both compose files (base: 3000, 9229; override: 4000) + +- // forwardPorts (8080) +- assert!(config.forwarded_ports.contains(&3000)); +- assert!(config.forwarded_ports.contains(&9229)); +- assert!(config.forwarded_ports.contains(&4000)); +- assert!(config.forwarded_ports.contains(&8080)); +- assert_eq!(config.forwarded_ports.len(), 4); +- +- // Gap 4: environment merged from both compose files + remoteEnv +- assert_eq!( +- config.environment.get("NODE_ENV").map(String::as_str), +- Some("development") +- ); +- assert_eq!( +- config.environment.get("DEBUG").map(String::as_str), +- Some("true") +- ); +- assert_eq!( +- config.environment.get("LOG_LEVEL").map(String::as_str), +- Some("verbose") +- ); +- // remoteEnv values +- assert_eq!( +- config.environment.get("DATABASE_URL").map(String::as_str), +- Some("postgres://postgres:devpass@db:5432/myapp_dev") +- ); +- assert_eq!( +- config.environment.get("REDIS_URL").map(String::as_str), +- Some("redis://redis:6379") +- ); +- +- // Gap 3: containerEnv exposed on config +- assert_eq!( +- config.container_env.get("TERM").map(String::as_str), +- Some("xterm-256color") +- ); +- assert_eq!( +- config.container_env.get("EDITOR").map(String::as_str), +- Some("vim") +- ); +- +- // Gap 1: onCreateCommand in compose mode +- assert_eq!(config.on_create_commands.len(), 1); +- assert!(matches!(&config.on_create_commands[0], Command::Shell(s) if s == "npm ci")); +- +- // Other lifecycle commands +- assert_eq!(config.post_create_commands.len(), 1); +- assert!( +- matches!(&config.post_create_commands[0], Command::Shell(s) if s == "npm run db:migrate") +- ); +- assert_eq!(config.post_start_commands.len(), 1); +- assert!(matches!(&config.post_start_commands[0], Command::Shell(s) if s == "npm run dev")); +- +- // User comes from compose (node) but remoteUser also set to node +- assert_eq!(config.remote_user.as_deref(), Some("node")); +- assert_eq!(config.workspace_folder, "/workspace"); +-} +- +-/// All lifecycle commands in different forms: string, array, object, and the +-/// new onCreateCommand. +-#[tokio::test] +-async fn all_lifecycle_command_forms() { +- let config = DevcontainerResolver::resolve(&fixture_path("all-lifecycle")) +- .await +- .unwrap(); +- +- // initializeCommand as string +- assert_eq!(config.initialize_commands.len(), 1); +- assert!(matches!(&config.initialize_commands[0], Command::Shell(s) if s == "echo pre-build")); +- +- // Gap 1: onCreateCommand as array +- assert_eq!(config.on_create_commands.len(), 1); +- assert!( +- matches!(&config.on_create_commands[0], Command::Args(args) if args == &["make", "setup"]) +- ); +- +- // postCreateCommand as object (parallel) +- assert_eq!(config.post_create_commands.len(), 1); +- assert!(matches!(&config.post_create_commands[0], Command::Parallel(map) if map.len() == 2)); +- +- // postStartCommand as string +- assert_eq!(config.post_start_commands.len(), 1); +- assert!(matches!(&config.post_start_commands[0], Command::Shell(s) if s == "echo started")); +-} +- +-/// Verify containerEnv doesn't pollute the environment HashMap (which is +-/// remoteEnv only). +-#[tokio::test] +-async fn container_env_separate_from_environment() { +- let config = DevcontainerResolver::resolve(&fixture_path("realistic-python")) +- .await +- .unwrap(); +- +- // container_env has containerEnv values +- assert!(config.container_env.contains_key("PYTHONDONTWRITEBYTECODE")); +- assert!(config.container_env.contains_key("PIP_NO_CACHE_DIR")); +- +- // environment only has remoteEnv values (not containerEnv-only keys) +- assert!(!config.environment.contains_key("PYTHONDONTWRITEBYTECODE")); +- assert!(!config.environment.contains_key("PIP_NO_CACHE_DIR")); +- // PYTHONUNBUFFERED is in both - environment gets remoteEnv value +- assert_eq!( +- config +- .environment +- .get("PYTHONUNBUFFERED") +- .map(String::as_str), +- Some("yes") +- ); +-} +- +-/// Verify build_args default to empty in non-dockerfile modes. +-#[tokio::test] +-async fn build_args_empty_in_image_and_compose_modes() { +- let image_config = DevcontainerResolver::resolve(&fixture_path("image-only")) +- .await +- .unwrap(); +- assert!(image_config.build_args.is_empty()); +- +- let compose_config = DevcontainerResolver::resolve(&fixture_path("compose-mode")) +- .await +- .unwrap(); +- assert!(compose_config.build_args.is_empty()); +-} +- +-/// Verify build_target is None for image-only and compose modes. +-#[tokio::test] +-async fn build_target_none_in_image_and_compose_modes() { +- let image_config = DevcontainerResolver::resolve(&fixture_path("image-only")) +- .await +- .unwrap(); +- assert!(image_config.build_target.is_none()); +- +- let compose_config = DevcontainerResolver::resolve(&fixture_path("compose-mode")) +- .await +- .unwrap(); +- assert!(compose_config.build_target.is_none()); +-} +- +-/// Gap 1: remoteEnv values must NOT appear as ENV directives in the generated +-/// Dockerfile. Only containerEnv should be baked in. +-#[tokio::test] +-async fn remote_env_excluded_from_dockerfile() { +- // image-only fixture has remoteEnv: {"EDITOR": "code"} and containerEnv: +- // {"DEBIAN_FRONTEND": "noninteractive"} +- let config = DevcontainerResolver::resolve(&fixture_path("image-only")) +- .await +- .unwrap(); +- +- // containerEnv IS in the Dockerfile +- assert!( +- config +- .dockerfile +- .contains("ENV DEBIAN_FRONTEND=noninteractive") +- ); +- +- // remoteEnv is NOT in the Dockerfile +- assert!(!config.dockerfile.contains("EDITOR=code")); +- +- // remoteEnv IS in the environment HashMap (runtime-only) +- assert_eq!( +- config.environment.get("EDITOR").map(String::as_str), +- Some("code") +- ); +-} +- +-/// Gap 2: forwardPorts in compose mode are merged with compose service ports, +-/// with deduplication. +-#[tokio::test] +-async fn forward_ports_merged_and_deduped_in_compose() { +- // compose-mode fixture has compose ports [3000, 9229] and forwardPorts [3000, +- // 5173] +- let config = DevcontainerResolver::resolve(&fixture_path("compose-mode")) +- .await +- .unwrap(); +- +- // 3000 appears in both compose ports and forwardPorts — should NOT be +- // duplicated +- assert_eq!(config.forwarded_ports, vec![3000, 9229, 5173]); +-} +- +-/// Gap 3: build.target is parsed and exposed in dockerfile mode. +-#[tokio::test] +-async fn build_target_in_dockerfile_mode() { +- let config = DevcontainerResolver::resolve(&fixture_path("dockerfile-mode")) +- .await +- .unwrap(); +- +- assert_eq!(config.build_target.as_deref(), Some("dev")); +-} +- +-/// Gap 4: forwardPorts string formats ("host:container", "port") are parsed +-/// correctly. +-#[tokio::test] +-async fn forward_ports_string_formats() { +- // image-only fixture has forwardPorts: [3000, "8080:80", "9090"] +- let config = DevcontainerResolver::resolve(&fixture_path("image-only")) +- .await +- .unwrap(); +- +- // 3000 is a plain number +- assert!(config.forwarded_ports.contains(&3000)); +- // "8080:80" extracts container port 80 +- assert!(config.forwarded_ports.contains(&80)); +- // "9090" is parsed as a plain port number +- assert!(config.forwarded_ports.contains(&9090)); +- // host port 8080 should NOT appear (only container port matters) +- assert!(!config.forwarded_ports.contains(&8080)); +- +- assert_eq!(config.forwarded_ports, vec![3000, 80, 9090]); +-} +- +-/// Verify compose_files is empty for non-compose modes. +-#[tokio::test] +-async fn compose_files_empty_in_non_compose_modes() { +- let image_config = DevcontainerResolver::resolve(&fixture_path("image-only")) +- .await +- .unwrap(); +- assert!(image_config.compose_files.is_empty()); +- +- let df_config = DevcontainerResolver::resolve(&fixture_path("dockerfile-mode")) +- .await +- .unwrap(); +- assert!(df_config.compose_files.is_empty()); +-} +- +-/// Verify on_create_commands defaults to empty when not specified. +-#[tokio::test] +-async fn on_create_commands_empty_when_not_specified() { +- let config = DevcontainerResolver::resolve(&fixture_path("variables")) +- .await +- .unwrap(); +- assert!(config.on_create_commands.is_empty()); +-} +- +-/// Verify container_env defaults to empty when not specified. +-#[tokio::test] +-async fn container_env_empty_when_not_specified() { +- let config = DevcontainerResolver::resolve(&fixture_path("variables")) +- .await +- .unwrap(); +- assert!(config.container_env.is_empty()); +-} +- +-// === Gap e2e tests: local features exercising dependsOn, containerEnv, +-// lifecycle hooks === +- +-/// Gap 5: Local path feature references are resolved through the full pipeline. +-#[tokio::test] +-async fn local_feature_refs_resolved() { +- let config = DevcontainerResolver::resolve(&fixture_path("local-features")) +- .await +- .unwrap(); +- +- // Base image preserved +- assert!( +- config +- .dockerfile +- .contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu") +- ); +- +- // Feature install.sh snippets are in the Dockerfile +- assert!(config.dockerfile.contains("node-feature")); +- assert!(config.dockerfile.contains("python-feature")); +- +- // Node feature option "version=20" passed as env var +- assert!(config.dockerfile.contains("export VERSION=\"20\"")); +-} +- +-/// Gap 1: dependsOn auto-injects missing features through the full pipeline. +-/// node-feature dependsOn ./base-utils which is NOT listed in devcontainer.json +-/// features. +-#[tokio::test] +-async fn depends_on_auto_injects_missing_feature() { +- let config = DevcontainerResolver::resolve(&fixture_path("local-features")) +- .await +- .unwrap(); +- +- // base-utils was auto-injected and its install.sh snippet is in the Dockerfile +- assert!(config.dockerfile.contains("base-utils")); +- +- // base-utils must appear before node-feature (dependency ordering) +- let base_pos = config.dockerfile.find("base-utils").unwrap(); +- let node_pos = config.dockerfile.find("node-feature").unwrap(); +- assert!( +- base_pos < node_pos, +- "base-utils (pos {base_pos}) should appear before node-feature (pos {node_pos})" +- ); +-} +- +-/// Gap 2: Feature containerEnv is merged into the Dockerfile and config. +-#[tokio::test] +-async fn feature_container_env_merged() { +- let config = DevcontainerResolver::resolve(&fixture_path("local-features")) +- .await +- .unwrap(); +- +- // Feature containerEnv values baked into Dockerfile +- assert!(config.dockerfile.contains("ENV NODE_INSTALLED=true")); +- assert!( +- config +- .dockerfile +- .contains("ENV NODE_PATH=/usr/local/lib/node_modules") +- ); +- assert!(config.dockerfile.contains("ENV PYTHON_INSTALLED=true")); +- assert!(config.dockerfile.contains("ENV BASE_UTILS_INSTALLED=true")); +- +- // Devcontainer.json containerEnv also present +- assert!(config.dockerfile.contains("ENV DEVCONTAINER=true")); +- +- // All values in config.container_env +- assert_eq!( +- config +- .container_env +- .get("NODE_INSTALLED") +- .map(String::as_str), +- Some("true") +- ); +- assert_eq!( +- config +- .container_env +- .get("PYTHON_INSTALLED") +- .map(String::as_str), +- Some("true") +- ); +- assert_eq!( +- config +- .container_env +- .get("BASE_UTILS_INSTALLED") +- .map(String::as_str), +- Some("true") +- ); +- assert_eq!( +- config.container_env.get("DEVCONTAINER").map(String::as_str), +- Some("true") +- ); +-} +- +-/// Gap 3: Feature lifecycle hooks are appended after devcontainer.json +-/// lifecycle commands. +-#[tokio::test] +-async fn feature_lifecycle_hooks_appended() { +- let config = DevcontainerResolver::resolve(&fixture_path("local-features")) +- .await +- .unwrap(); +- +- // onCreateCommand: devcontainer.json first, then features +- // devcontainer.json: "echo devcontainer-setup" +- // base-utils: "echo base-utils-setup" +- // node-feature: "echo node-setup" +- assert!(config.on_create_commands.len() >= 2); +- assert!( +- matches!(&config.on_create_commands[0], Command::Shell(s) if s == "echo devcontainer-setup") +- ); +- +- // Feature on_create_commands appear after devcontainer.json's +- let feature_on_create: Vec<&str> = config.on_create_commands[1..] +- .iter() +- .filter_map(|cmd| match cmd { +- Command::Shell(s) => Some(s.as_str()), +- _ => None, +- }) +- .collect(); +- assert!(feature_on_create.contains(&"echo base-utils-setup")); +- assert!(feature_on_create.contains(&"echo node-setup")); +- +- // postCreateCommand: devcontainer.json first, then python-feature +- assert!(config.post_create_commands.len() >= 2); +- assert!( +- matches!(&config.post_create_commands[0], Command::Shell(s) if s == "echo devcontainer-post-create") +- ); +- let feature_post_create: Vec<&str> = config.post_create_commands[1..] +- .iter() +- .filter_map(|cmd| match cmd { +- Command::Shell(s) => Some(s.as_str()), +- _ => None, +- }) +- .collect(); +- assert!(feature_post_create.contains(&"echo python-post-create")); +- +- // postStartCommand: only node-feature contributes (no devcontainer.json +- // postStartCommand) +- assert!(!config.post_start_commands.is_empty()); +- let post_start: Vec<&str> = config +- .post_start_commands +- .iter() +- .filter_map(|cmd| match cmd { +- Command::Shell(s) => Some(s.as_str()), +- _ => None, +- }) +- .collect(); +- assert!(post_start.contains(&"echo node-started")); +-} +- +-/// Fix 1: Shorthand version syntax "1.21" is normalized to {"version": "1.21"}. +-#[tokio::test] +-async fn feature_shorthand_version_syntax() { +- let config = DevcontainerResolver::resolve(&fixture_path("feature-options")) +- .await +- .unwrap(); +- +- // "1.21" string should become version=1.21 env var +- assert!( +- config.dockerfile.contains("export VERSION=\"1.21\""), +- "shorthand string \"1.21\" should set VERSION env var, got:\n{}", +- config.dockerfile, +- ); +-} +- +-/// Fix 2: Hyphenated option IDs are converted to valid env var names +-/// (node-version → NODE_VERSION). +-#[tokio::test] +-async fn feature_option_id_hyphen_to_underscore() { +- let config = DevcontainerResolver::resolve(&fixture_path("feature-options")) +- .await +- .unwrap(); +- +- // node-version default "none" should export as NODE_VERSION (not NODE-VERSION) +- assert!( +- config.dockerfile.contains("export NODE_VERSION=\"none\""), +- "hyphenated option 'node-version' should become NODE_VERSION env var, got:\n{}", +- config.dockerfile, +- ); +- assert!( +- !config.dockerfile.contains("NODE-VERSION"), +- "NODE-VERSION (with hyphen) should not appear in Dockerfile", +- ); +-} +- +-/// Fix 3: _REMOTE_USER and related env vars are emitted in feature install +-/// snippets. +-#[tokio::test] +-async fn feature_install_user_env_vars() { +- let config = DevcontainerResolver::resolve(&fixture_path("feature-options")) +- .await +- .unwrap(); +- +- // remoteUser is "developer", so _REMOTE_USER should be "developer" +- assert!( +- config.dockerfile.contains("_REMOTE_USER=\"developer\""), +- "_REMOTE_USER should be set to remoteUser value, got:\n{}", +- config.dockerfile, +- ); +- assert!( +- config.dockerfile.contains("_CONTAINER_USER=\"root\""), +- "_CONTAINER_USER should always be root", +- ); +- assert!( +- config +- .dockerfile +- .contains("_REMOTE_USER_HOME=\"/home/developer\""), +- "_REMOTE_USER_HOME should be /home/developer", +- ); +- assert!( +- config.dockerfile.contains("_CONTAINER_USER_HOME=\"/root\""), +- "_CONTAINER_USER_HOME should always be /root", +- ); +-} +- +-/// Fix 3: _REMOTE_USER defaults to root when remoteUser is not set. +-#[tokio::test] +-async fn feature_install_user_env_vars_default_root() { +- let config = DevcontainerResolver::resolve(&fixture_path("local-features")) +- .await +- .unwrap(); +- +- // local-features has remoteUser: "vscode" +- assert!( +- config.dockerfile.contains("_REMOTE_USER=\"vscode\""), +- "_REMOTE_USER should be set to vscode, got:\n{}", +- config.dockerfile, +- ); +- assert!( +- config +- .dockerfile +- .contains("_REMOTE_USER_HOME=\"/home/vscode\""), +- "_REMOTE_USER_HOME should be /home/vscode", +- ); +-} +- +-/// Gap 2+3: Feature ordering affects both containerEnv and lifecycle hook +-/// collection. python-feature installsAfter node-feature, so node's env/hooks +-/// come first. +-#[tokio::test] +-async fn feature_ordering_preserved_in_env_and_hooks() { +- let config = DevcontainerResolver::resolve(&fixture_path("local-features")) +- .await +- .unwrap(); +- +- // In the Dockerfile, node-feature layers come before python-feature layers +- let node_layer_pos = config.dockerfile.find("node-feature").unwrap(); +- let python_layer_pos = config.dockerfile.find("python-feature").unwrap(); +- assert!( +- node_layer_pos < python_layer_pos, +- "node-feature (pos {node_layer_pos}) should be installed before python-feature (pos {python_layer_pos})" +- ); +-} +diff --git a/lib/crates/fabro-devcontainer/tests/it/helpers.rs b/lib/crates/fabro-devcontainer/tests/it/helpers.rs +deleted file mode 100644 +index 98f42af26..000000000 +--- a/lib/crates/fabro-devcontainer/tests/it/helpers.rs ++++ /dev/null +@@ -1,7 +0,0 @@ +-use std::path::PathBuf; +- +-pub(super) fn fixture_path(name: &str) -> PathBuf { +- PathBuf::from(env!("CARGO_MANIFEST_DIR")) +- .join("tests/fixtures") +- .join(name) +-} +diff --git a/lib/crates/fabro-devcontainer/tests/it/integration.rs b/lib/crates/fabro-devcontainer/tests/it/integration.rs +deleted file mode 100644 +index 8ac6cf692..000000000 +--- a/lib/crates/fabro-devcontainer/tests/it/integration.rs ++++ /dev/null +@@ -1,209 +0,0 @@ +-use fabro_devcontainer::{Command, DevcontainerResolver}; +- +-use super::helpers::fixture_path; +- +-#[tokio::test] +-async fn resolve_image_only() { +- let config = DevcontainerResolver::resolve(&fixture_path("image-only")) +- .await +- .unwrap(); +- +- assert!( +- config +- .dockerfile +- .contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu") +- ); +- assert_eq!(config.remote_user.as_deref(), Some("vscode")); +- assert_eq!(config.forwarded_ports, vec![3000, 80, 9090]); +- assert_eq!( +- config.environment.get("EDITOR").map(String::as_str), +- Some("code") +- ); +- assert_eq!(config.workspace_folder, "/workspaces/image-only"); +- assert!(config.compose_files.is_empty()); +- assert!(config.compose_service.is_none()); +- +- assert_eq!(config.post_create_commands.len(), 1); +- assert!(matches!(&config.post_create_commands[0], Command::Shell(s) if s == "echo hello")); +- +- // onCreateCommand +- assert_eq!(config.on_create_commands.len(), 1); +- assert!(matches!(&config.on_create_commands[0], Command::Shell(s) if s == "setup.sh")); +- +- // containerEnv baked into Dockerfile +- assert!( +- config +- .dockerfile +- .contains("ENV DEBIAN_FRONTEND=noninteractive") +- ); +- assert_eq!( +- config +- .container_env +- .get("DEBIAN_FRONTEND") +- .map(String::as_str), +- Some("noninteractive") +- ); +-} +- +-#[tokio::test] +-async fn resolve_dockerfile_mode() { +- let config = DevcontainerResolver::resolve(&fixture_path("dockerfile-mode")) +- .await +- .unwrap(); +- +- // Should read the actual Dockerfile content +- assert!(config.dockerfile.contains("FROM node:20")); +- assert!(config.dockerfile.contains("apt-get update")); +- assert_eq!(config.remote_user.as_deref(), Some("developer")); +- assert_eq!(config.forwarded_ports, vec![4000]); +- +- assert_eq!(config.post_create_commands.len(), 1); +- assert!(matches!(&config.post_create_commands[0], Command::Shell(s) if s == "npm install")); +- +- // build.args +- assert_eq!( +- config.build_args.get("NODE_VERSION").map(String::as_str), +- Some("20") +- ); +- +- // build.target +- assert_eq!(config.build_target.as_deref(), Some("dev")); +-} +- +-#[tokio::test] +-async fn resolve_compose_mode() { +- let config = DevcontainerResolver::resolve(&fixture_path("compose-mode")) +- .await +- .unwrap(); +- +- // In compose mode, the dockerfile is derived from the compose service's image +- assert!(config.dockerfile.contains("FROM node:20")); +- assert_eq!(config.workspace_folder, "/workspace"); +- assert_eq!(config.remote_user.as_deref(), Some("node")); +- assert_eq!(config.compose_files.len(), 1); +- assert_eq!(config.compose_service.as_deref(), Some("app")); +- +- // Ports come from compose + forwardPorts merged +- assert_eq!(config.forwarded_ports, vec![3000, 9229, 5173]); +- +- // Environment merged from compose + remoteEnv +- assert_eq!( +- config.environment.get("NODE_ENV").map(String::as_str), +- Some("development") +- ); +- assert_eq!( +- config.environment.get("DEBUG").map(String::as_str), +- Some("true") +- ); +-} +- +-#[tokio::test] +-async fn resolve_variables() { +- let config = DevcontainerResolver::resolve(&fixture_path("variables")) +- .await +- .unwrap(); +- +- assert_eq!(config.workspace_folder, "/workspaces/variables"); +- assert_eq!( +- config.environment.get("PROJECT_ROOT").map(String::as_str), +- Some("/workspaces/variables") +- ); +- assert_eq!( +- config.environment.get("PROJECT_NAME").map(String::as_str), +- Some("variables") +- ); +-} +- +-#[tokio::test] +-async fn resolve_compose_multi() { +- let config = DevcontainerResolver::resolve(&fixture_path("compose-multi")) +- .await +- .unwrap(); +- +- // Override file wins for image +- assert!(config.dockerfile.contains("FROM node:22")); +- assert_eq!(config.workspace_folder, "/workspace"); +- assert_eq!(config.compose_files.len(), 2); +- assert_eq!(config.compose_service.as_deref(), Some("app")); +- +- // Port from base.yml +- assert_eq!(config.forwarded_ports, vec![3000]); +- +- // Environment from override.yml +- assert_eq!( +- config.environment.get("OVERRIDE_VAR").map(String::as_str), +- Some("true") +- ); +-} +- +-#[tokio::test] +-async fn resolve_not_found() { +- let result = DevcontainerResolver::resolve(&fixture_path("nonexistent")).await; +- assert!(result.is_err()); +- let err = result.unwrap_err(); +- assert!(err.to_string().contains("no devcontainer.json found")); +-} +- +-#[tokio::test] +-async fn resolve_subdirectory_mode() { +- let config = DevcontainerResolver::resolve(&fixture_path("subdirectory-mode")) +- .await +- .unwrap(); +- +- assert!( +- config +- .dockerfile +- .contains("FROM mcr.microsoft.com/devcontainers/python:3.12") +- ); +- assert_eq!(config.remote_user.as_deref(), Some("vscode")); +- assert_eq!(config.workspace_folder, "/workspaces/subdirectory-mode"); +-} +- +-#[tokio::test] +-async fn resolve_subdirectory_multiple_picks_alphabetical_first() { +- let config = DevcontainerResolver::resolve(&fixture_path("subdirectory-multiple")) +- .await +- .unwrap(); +- +- // "alpha" sorts before "beta", so alpha's config is used +- assert!( +- config +- .dockerfile +- .contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu") +- ); +- assert_eq!(config.remote_user.as_deref(), Some("alpha-user")); +-} +- +-#[tokio::test] +-async fn resolve_subdirectory_standard_wins_over_subdirs() { +- let config = DevcontainerResolver::resolve(&fixture_path("subdirectory-with-standard")) +- .await +- .unwrap(); +- +- // Standard .devcontainer/devcontainer.json takes priority over subdirectory +- // format +- assert!( +- config +- .dockerfile +- .contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu") +- ); +- assert_eq!(config.remote_user.as_deref(), Some("standard-user")); +-} +- +-#[tokio::test] +-async fn generated_dockerfile_is_well_formed() { +- let config = DevcontainerResolver::resolve(&fixture_path("image-only")) +- .await +- .unwrap(); +- +- // Should start with the generated header +- assert!( +- config +- .dockerfile +- .contains("# Generated by fabro-devcontainer") +- ); +- // Should have the base image +- assert!(config.dockerfile.contains("FROM")); +- // Should end with a newline +- assert!(config.dockerfile.ends_with('\n')); +-} +diff --git a/lib/crates/fabro-devcontainer/tests/it/main.rs b/lib/crates/fabro-devcontainer/tests/it/main.rs +deleted file mode 100644 +index 7c72eb25a..000000000 +--- a/lib/crates/fabro-devcontainer/tests/it/main.rs ++++ /dev/null +@@ -1,3 +0,0 @@ +-mod e2e; +-mod helpers; +-mod integration; +diff --git a/lib/crates/fabro-dump/src/lib.rs b/lib/crates/fabro-dump/src/lib.rs +index f042cef01..397d55a3f 100644 +--- a/lib/crates/fabro-dump/src/lib.rs ++++ b/lib/crates/fabro-dump/src/lib.rs +@@ -473,9 +473,9 @@ mod tests { + use fabro_types::graph::Graph; + use fabro_types::run::RunSpec; + use fabro_types::{ +- Checkpoint, CheckpointRecord, Conclusion, RunDiff, RunSandbox, RunStatus, +- SandboxProviderKind, StageCompletion, StageModelUsage, StageOutcome, StartRecord, +- SuccessReason, WorkflowSettings, first_event_seq, fixtures, ++ Checkpoint, CheckpointRecord, Conclusion, RunDiff, RunSandbox, RunSandboxInstance, ++ RunSandboxPlan, RunStatus, SandboxProviderKind, StageCompletion, StageModelUsage, ++ StageOutcome, StartRecord, SuccessReason, WorkflowSettings, first_event_seq, fixtures, + }; + use futures::executor; + +@@ -558,22 +558,29 @@ mod tests { + total_retries: 0, + diff: RunDiff::default(), + }); +- projection.sandbox = Some(RunSandbox { +- provider: SandboxProviderKind::Local, +- image: None, +- snapshot: None, +- runtime: Some(fabro_types::RunSandboxRuntime { +- id: "sandbox-1".to_string(), +- working_directory: "/tmp/project".to_string(), +- repo_cloned: None, +- clone_origin_url: None, +- clone_branch: None, +- workspace_root: None, +- repos_root: None, +- primary_repo_path: None, +- primary_repo_link: None, +- }), +- }); ++ projection.sandbox = Some(RunSandbox::ready( ++ RunSandboxPlan { ++ provider: SandboxProviderKind::Local, ++ image: None, ++ snapshot: None, ++ }, ++ RunSandboxInstance { ++ provider: SandboxProviderKind::Local, ++ image: None, ++ snapshot: None, ++ runtime: fabro_types::RunSandboxRuntime { ++ id: "sandbox-1".to_string(), ++ working_directory: "/tmp/project".to_string(), ++ repo_cloned: None, ++ clone_origin_url: None, ++ clone_branch: None, ++ workspace_root: None, ++ repos_root: None, ++ primary_repo_path: None, ++ primary_repo_link: None, ++ }, ++ }, ++ )); + let stage = + projection.stage_entry(stage_id.node_id(), stage_id.visit(), first_event_seq(2)); + stage.prompt = Some("plan".to_string()); +diff --git a/lib/crates/fabro-sandbox/src/details.rs b/lib/crates/fabro-sandbox/src/details.rs +index e6b65bd9b..2ada66652 100644 +--- a/lib/crates/fabro-sandbox/src/details.rs ++++ b/lib/crates/fabro-sandbox/src/details.rs +@@ -4,8 +4,8 @@ use anyhow::Result; + #[cfg(any(feature = "docker", feature = "daytona"))] + use chrono::{DateTime, Utc}; + use fabro_types::{ +- RunId, RunSandbox, SandboxDetails, SandboxNetwork, SandboxProviderKind, SandboxResources, +- SandboxState, SandboxTimestamps, ++ RunId, RunSandboxInstance, SandboxDetails, SandboxNetwork, SandboxProviderKind, ++ SandboxResources, SandboxState, SandboxTimestamps, + }; + + /// Inspect the sandbox identified by `record` and return provider-neutral +@@ -20,7 +20,7 @@ use fabro_types::{ + reason = "Feature-gated providers consume some parameters only when enabled." + )] + pub async fn sandbox_details( +- record: &RunSandbox, ++ record: &RunSandboxInstance, + daytona_api_key: Option, + daytona_organization_id: Option, + run_id: Option, +@@ -44,7 +44,7 @@ pub async fn sandbox_details( + } + } + +-fn local_details(record: &RunSandbox) -> SandboxDetails { ++fn local_details(record: &RunSandboxInstance) -> SandboxDetails { + SandboxDetails { + sandbox: record.clone(), + state: SandboxState::Running, +@@ -74,23 +74,21 @@ pub(crate) mod docker { + use bollard::container::InspectContainerOptions; + use bollard::models::{ContainerInspectResponse, ContainerStateStatusEnum, HostConfig}; + use fabro_types::{ +- RunId, RunSandbox, SandboxDetails, SandboxInfo, SandboxNetwork, SandboxNetworkPolicy, +- SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps, ++ RunId, RunSandboxInstance, SandboxDetails, SandboxInfo, SandboxNetwork, ++ SandboxNetworkPolicy, SandboxProviderKind, SandboxResources, SandboxState, ++ SandboxTimestamps, + }; + + use super::parse_rfc3339_utc; + use crate::docker::WORKING_DIRECTORY; + + pub(super) async fn docker_details( +- record: &RunSandbox, ++ record: &RunSandboxInstance, + _run_id: Option, + ) -> Result { + let docker = + Docker::connect_with_local_defaults().context("Failed to connect to Docker daemon")?; +- let runtime = record +- .runtime +- .as_ref() +- .context("Docker run sandbox missing runtime metadata")?; ++ let runtime = &record.runtime; + let inspect = docker + .inspect_container(&runtime.id, None::) + .await +@@ -120,13 +118,13 @@ pub(crate) mod docker { + + pub(super) fn map_docker_inspect( + inspect: &ContainerInspectResponse, +- record: &RunSandbox, ++ record: &RunSandboxInstance, + ) -> SandboxDetails { + let fields = docker_fields_from_inspect(inspect); + let image = fields.image.clone().or_else(|| record.image.clone()); + + SandboxDetails { +- sandbox: RunSandbox { ++ sandbox: RunSandboxInstance { + image, + ..record.clone() + }, +@@ -274,18 +272,18 @@ pub(crate) mod docker { + mod tests { + use bollard::models::HostConfig; + use fabro_types::{ +- RunSandbox, RunSandboxRuntime, SandboxNetwork, SandboxNetworkPolicy, ++ RunSandboxInstance, RunSandboxRuntime, SandboxNetwork, SandboxNetworkPolicy, + SandboxProviderKind, + }; + + use super::*; + +- fn record() -> RunSandbox { +- RunSandbox { ++ fn record() -> RunSandboxInstance { ++ RunSandboxInstance { + provider: SandboxProviderKind::Docker, + image: None, + snapshot: None, +- runtime: Some(RunSandboxRuntime { ++ runtime: RunSandboxRuntime { + id: "container-abc123".to_string(), + working_directory: "/workspace".to_string(), + repo_cloned: Some(true), +@@ -295,7 +293,7 @@ pub(crate) mod docker { + repos_root: None, + primary_repo_path: None, + primary_repo_link: None, +- }), ++ }, + } + } + +@@ -389,7 +387,7 @@ pub(crate) mod docker { + ..Default::default() + }; + let details = map_docker_inspect(&inspect, &record()); +- let runtime = details.sandbox.runtime.expect("runtime"); ++ let runtime = details.sandbox.runtime; + assert_eq!(runtime.id, "container-abc123"); + assert_eq!(runtime.working_directory, "/workspace"); + } +@@ -497,7 +495,7 @@ pub(crate) mod daytona { + use anyhow::{Context, Result, anyhow}; + use daytona_api_client::models::SandboxState as DaytonaState; + use fabro_types::{ +- RunSandbox, SandboxDetails, SandboxInfo, SandboxNetwork, SandboxNetworkPolicy, ++ RunSandboxInstance, SandboxDetails, SandboxInfo, SandboxNetwork, SandboxNetworkPolicy, + SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps, + }; + +@@ -505,13 +503,10 @@ pub(crate) mod daytona { + use crate::daytona::{DAYTONA_DASHBOARD_SANDBOXES_URL, DaytonaSandbox, WORKING_DIRECTORY}; + + pub(super) async fn daytona_details( +- record: &RunSandbox, ++ record: &RunSandboxInstance, + daytona_api_key: Option, + ) -> Result { +- let runtime = record +- .runtime +- .as_ref() +- .context("Daytona run sandbox missing runtime metadata")?; ++ let runtime = &record.runtime; + let repo_cloned = runtime + .repo_cloned + .context("Daytona run sandbox missing clone metadata")?; +@@ -555,11 +550,11 @@ pub(crate) mod daytona { + + pub(super) fn map_daytona_sandbox( + sandbox: &daytona_sdk::Sandbox, +- record: &RunSandbox, ++ record: &RunSandboxInstance, + ) -> SandboxDetails { + let fields = daytona_fields_from_sdk_sandbox(sandbox); + SandboxDetails { +- sandbox: RunSandbox { ++ sandbox: RunSandboxInstance { + snapshot: sandbox.snapshot.clone().or_else(|| record.snapshot.clone()), + ..record.clone() + }, +@@ -817,11 +812,11 @@ mod tests { + + #[test] + fn local_details_returns_running_with_no_metadata() { +- let record = RunSandbox { ++ let record = RunSandboxInstance { + provider: SandboxProviderKind::Local, + image: None, + snapshot: None, +- runtime: Some(fabro_types::RunSandboxRuntime { ++ runtime: fabro_types::RunSandboxRuntime { + id: "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z".to_string(), + working_directory: "/Users/client/project".to_string(), + repo_cloned: None, +@@ -831,12 +826,12 @@ mod tests { + repos_root: None, + primary_repo_path: None, + primary_repo_link: None, +- }), ++ }, + }; + let details = local_details(&record); + assert_eq!(details.sandbox.provider, SandboxProviderKind::Local); + assert_eq!(details.state, SandboxState::Running); +- let runtime = details.sandbox.runtime.as_ref().unwrap(); ++ let runtime = &details.sandbox.runtime; + assert_eq!(runtime.id, "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z"); + assert_eq!(runtime.working_directory, "/Users/client/project"); + assert!(details.region.is_none()); +diff --git a/lib/crates/fabro-sandbox/src/lib.rs b/lib/crates/fabro-sandbox/src/lib.rs +index 2fb0f0b4b..e53b25756 100644 +--- a/lib/crates/fabro-sandbox/src/lib.rs ++++ b/lib/crates/fabro-sandbox/src/lib.rs +@@ -40,7 +40,7 @@ pub use details::sandbox_details; + #[cfg(feature = "docker")] + pub use docker::{DockerSandbox, DockerSandboxOptions}; + pub use error::{Error, Result, default_redacted_output_tail, display_for_log}; +-pub use fabro_types::{RunSandbox, SandboxProviderKind}; ++pub use fabro_types::{RunSandboxInstance, SandboxProviderKind}; + pub use local::LocalSandbox; + #[cfg(feature = "daytona")] + pub use provider::daytona::DaytonaSandboxProvider; +diff --git a/lib/crates/fabro-sandbox/src/reconnect.rs b/lib/crates/fabro-sandbox/src/reconnect.rs +index c71b6dc4e..b0b950477 100644 +--- a/lib/crates/fabro-sandbox/src/reconnect.rs ++++ b/lib/crates/fabro-sandbox/src/reconnect.rs +@@ -5,7 +5,7 @@ use std::path::PathBuf; + reason = "Feature-gated branches consume these imports when optional backends are enabled." + )] + use anyhow::{Context, Result, bail}; +-use fabro_types::{RunId, RunSandbox, SandboxProviderKind}; ++use fabro_types::{RunId, RunSandboxInstance, SandboxProviderKind}; + + use crate::SandboxEventCallback; + #[cfg(feature = "daytona")] +@@ -24,7 +24,7 @@ use crate::local::LocalSandbox; + reason = "Feature-gated sandbox backends leave some parameters unused on partial builds." + )] + pub async fn reconnect( +- record: &RunSandbox, ++ record: &RunSandboxInstance, + daytona_api_key: Option, + ) -> Result> { + reconnect_for_run(record, daytona_api_key, None).await +@@ -35,7 +35,7 @@ pub async fn reconnect( + reason = "Feature-gated sandbox backends leave parameters unused on partial builds." + )] + pub async fn reconnect_for_run( +- record: &RunSandbox, ++ record: &RunSandboxInstance, + daytona_api_key: Option, + run_id: Option, + ) -> Result> { +@@ -47,15 +47,12 @@ pub async fn reconnect_for_run( + reason = "Feature-gated sandbox backends leave parameters unused on partial builds." + )] + pub async fn reconnect_for_run_with_callback( +- record: &RunSandbox, ++ record: &RunSandboxInstance, + daytona_api_key: Option, + run_id: Option, + event_callback: Option, + ) -> Result> { +- let runtime = record +- .runtime +- .as_ref() +- .context("run sandbox missing runtime metadata")?; ++ let runtime = &record.runtime; + match record.provider { + SandboxProviderKind::Local => { + let mut sandbox = LocalSandbox::new(PathBuf::from(&runtime.working_directory)); +diff --git a/lib/crates/fabro-sandbox/src/sandbox_spec.rs b/lib/crates/fabro-sandbox/src/sandbox_spec.rs +index 32677c769..b6a56bd40 100644 +--- a/lib/crates/fabro-sandbox/src/sandbox_spec.rs ++++ b/lib/crates/fabro-sandbox/src/sandbox_spec.rs +@@ -9,12 +9,12 @@ use fabro_github::GitHubCredentials; + unused_imports, + reason = "Daytona-enabled builds persist RunId in the sandbox spec." + )] +-use fabro_types::{RunId, RunSandbox, RunSandboxRuntime, SandboxProviderKind}; ++use fabro_types::{RunId, RunSandboxInstance, RunSandboxRuntime, SandboxProviderKind}; + + #[cfg(any(feature = "docker", feature = "daytona"))] + use crate::clone_source; + #[cfg(feature = "daytona")] +-use crate::daytona::{self, DaytonaConfig, DaytonaSandbox, DaytonaSnapshotConfig}; ++use crate::daytona::{self, DaytonaConfig, DaytonaSandbox}; + #[cfg(feature = "docker")] + use crate::docker::{self, DockerSandbox, DockerSandboxOptions}; + use crate::local::LocalSandbox; +@@ -63,8 +63,12 @@ impl SandboxSpec { + } + } + +- /// Build a RunSandbox for persistence. +- pub fn to_run_sandbox(&self, sandbox: &dyn Sandbox, run_id: RunId) -> RunSandbox { ++ /// Build initialized sandbox metadata for persistence. ++ pub fn to_run_sandbox_instance( ++ &self, ++ sandbox: &dyn Sandbox, ++ run_id: RunId, ++ ) -> RunSandboxInstance { + let working_directory = sandbox.working_directory().to_string(); + let id = { + let info = sandbox.sandbox_info(); +@@ -93,11 +97,11 @@ impl SandboxSpec { + docker::WORKING_DIRECTORY, + docker::REPOS_ROOT, + ); +- RunSandbox { ++ RunSandboxInstance { + provider: self.provider(), + image: (!config.image.is_empty()).then(|| config.image.clone()), + snapshot: None, +- runtime: Some(RunSandboxRuntime { ++ runtime: RunSandboxRuntime { + id, + working_directory: working_directory.clone(), + repo_cloned, +@@ -113,7 +117,7 @@ impl SandboxSpec { + primary_repo_link: layout + .as_ref() + .map(|layout| layout.primary_repo_link.clone()), +- }), ++ }, + } + } + #[cfg(feature = "daytona")] +@@ -133,11 +137,11 @@ impl SandboxSpec { + daytona::WORKING_DIRECTORY, + daytona::REPOS_ROOT, + ); +- RunSandbox { ++ RunSandboxInstance { + provider: self.provider(), + image: None, + snapshot: sandbox.snapshot_info(), +- runtime: Some(RunSandboxRuntime { ++ runtime: RunSandboxRuntime { + id, + working_directory: working_directory.clone(), + repo_cloned, +@@ -153,14 +157,14 @@ impl SandboxSpec { + primary_repo_link: layout + .as_ref() + .map(|layout| layout.primary_repo_link.clone()), +- }), ++ }, + } + } +- _ => RunSandbox { ++ _ => RunSandboxInstance { + provider: self.provider(), + image: None, + snapshot: None, +- runtime: Some(RunSandboxRuntime { ++ runtime: RunSandboxRuntime { + id, + working_directory, + repo_cloned: None, +@@ -170,19 +174,11 @@ impl SandboxSpec { + repos_root: None, + primary_repo_path: None, + primary_repo_link: None, +- }), ++ }, + }, + } + } + +- /// Apply devcontainer snapshot config. Only Daytona uses this. +- #[cfg(feature = "daytona")] +- pub fn apply_devcontainer_snapshot(&mut self, snapshot: DaytonaSnapshotConfig) { +- if let Self::Daytona { config, .. } = self { +- config.snapshot = Some(snapshot); +- } +- } +- + #[allow( + clippy::unused_async, + reason = "Only Daytona construction awaits; local and Docker builds share the async API." +@@ -285,8 +281,8 @@ mod tests { + sandbox.working_dir = "/workspace/rack-test"; + + let run_id: RunId = "01HY0000000000000000000000".parse().unwrap(); +- let record = spec.to_run_sandbox(&sandbox, run_id); +- let runtime = record.runtime.expect("runtime"); ++ let record = spec.to_run_sandbox_instance(&sandbox, run_id); ++ let runtime = record.runtime; + + assert_eq!(runtime.working_directory, "/workspace/rack-test"); + assert_eq!(runtime.repo_cloned, Some(true)); +@@ -323,8 +319,8 @@ mod tests { + sandbox.working_dir = "/workspace"; + + let run_id: RunId = "01HY0000000000000000000000".parse().unwrap(); +- let record = spec.to_run_sandbox(&sandbox, run_id); +- let runtime = record.runtime.expect("runtime"); ++ let record = spec.to_run_sandbox_instance(&sandbox, run_id); ++ let runtime = record.runtime; + + assert_eq!(runtime.working_directory, "/workspace"); + assert_eq!(runtime.repo_cloned, Some(false)); +diff --git a/lib/crates/fabro-sandbox/src/terminal.rs b/lib/crates/fabro-sandbox/src/terminal.rs +index 67dfa29b6..44081f9bb 100644 +--- a/lib/crates/fabro-sandbox/src/terminal.rs ++++ b/lib/crates/fabro-sandbox/src/terminal.rs +@@ -1,9 +1,8 @@ + use async_trait::async_trait; + #[cfg(feature = "daytona")] + use fabro_static::EnvVars; +-use fabro_types::{RunId, SandboxProviderKind}; ++use fabro_types::{RunId, RunSandboxInstance, SandboxProviderKind}; + +-use crate::RunSandbox; + #[cfg(any(feature = "daytona", feature = "docker"))] + use crate::Sandbox; + #[cfg(feature = "daytona")] +@@ -35,17 +34,14 @@ pub trait TerminalSession: Send + Sync { + } + + pub async fn open_terminal_for_run( +- record: &RunSandbox, ++ record: &RunSandboxInstance, + daytona_api_key: Option, + daytona_organization_id: Option, + run_id: Option, + size: TerminalSize, + ) -> crate::Result> { + #[cfg(any(feature = "daytona", feature = "docker"))] +- let runtime = record +- .runtime +- .as_ref() +- .ok_or_else(|| crate::Error::message("Run sandbox is missing runtime metadata"))?; ++ let runtime = &record.runtime; + #[cfg(not(feature = "daytona"))] + let _ = (&daytona_api_key, &daytona_organization_id); + #[cfg(not(feature = "docker"))] +diff --git a/lib/crates/fabro-server/src/run_files.rs b/lib/crates/fabro-server/src/run_files.rs +index 645c53424..1f358821b 100644 +--- a/lib/crates/fabro-server/src/run_files.rs ++++ b/lib/crates/fabro-server/src/run_files.rs +@@ -1213,8 +1213,10 @@ async fn reconnect_run_sandbox( + ) -> std::result::Result, ApiError> { + let record = projection + .sandbox +- .clone() +- .ok_or_else(|| ApiError::new(StatusCode::CONFLICT, "Run has no active sandbox."))?; ++ .as_ref() ++ .and_then(fabro_types::RunSandbox::instance) ++ .cloned() ++ .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "Run sandbox was not created."))?; + let daytona_api_key = state.vault_secret(EnvVars::DAYTONA_API_KEY); + let sandbox = reconnect_for_run(&record, daytona_api_key, Some(*run_id)) + .await +diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs +index 1c83dd57b..43721bec3 100644 +--- a/lib/crates/fabro-server/src/server.rs ++++ b/lib/crates/fabro-server/src/server.rs +@@ -1059,7 +1059,7 @@ impl AskFabroReadiness { + } else if run + .sandbox + .as_ref() +- .and_then(|sandbox| sandbox.runtime.as_ref()) ++ .and_then(fabro_types::RunSandbox::instance) + .is_none() + { + Some(AskFabroUnavailableReason::SandboxNotReady) +@@ -2439,12 +2439,15 @@ async fn delete_run_sandbox_resource( + .environment + .lifecycle + .preserve; +- let Some(record) = projection.sandbox else { +- return Ok(SandboxDeleteOutcome::Cleaned); +- }; +- let Some(runtime) = record.runtime.as_ref() else { ++ let Some(record) = projection ++ .sandbox ++ .as_ref() ++ .and_then(fabro_types::RunSandbox::instance) ++ .cloned() ++ else { + return Ok(SandboxDeleteOutcome::Cleaned); + }; ++ let runtime = &record.runtime; + if preserve { + return Ok(SandboxDeleteOutcome::Preserved(DeleteRunResponse { + deleted: true, +diff --git a/lib/crates/fabro-server/src/server/handler/sandbox.rs b/lib/crates/fabro-server/src/server/handler/sandbox.rs +index bfba9c242..61d8b7ee9 100644 +--- a/lib/crates/fabro-server/src/server/handler/sandbox.rs ++++ b/lib/crates/fabro-server/src/server/handler/sandbox.rs +@@ -5,7 +5,9 @@ use std::sync::Arc; + + use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; + use fabro_sandbox::{TerminalSize, open_terminal_for_run}; +-use fabro_types::{SandboxProviderKind, SandboxServiceDiscoverySource, SandboxServiceListMeta}; ++use fabro_types::{ ++ RunSandboxInstance, SandboxProviderKind, SandboxServiceDiscoverySource, SandboxServiceListMeta, ++}; + use futures_util::FutureExt; + use futures_util::future::BoxFuture; + +@@ -103,7 +105,7 @@ async fn retrieve_run_sandbox( + Ok(id) => id, + Err(response) => return response, + }; +- let record = match load_run_sandbox_or_not_found(&state, &id).await { ++ let record = match load_run_sandbox_instance(&state, &id).await { + Ok(record) => record, + Err(response) => return response, + }; +@@ -216,7 +218,7 @@ async fn run_terminal( + } + + async fn terminal_websocket(mut socket: WebSocket, state: Arc, id: RunId) { +- let record = match load_run_sandbox(&state, &id).await { ++ let record = match load_run_sandbox_instance(&state, &id).await { + Ok(record) => record, + Err(response) => { + let message = terminal_error_from_status(response.status()); +@@ -395,14 +397,14 @@ async fn create_ssh_access( + Ok(id) => id, + Err(response) => return response, + }; +- let record = match load_run_sandbox(&state, &id).await { ++ let record = match load_run_sandbox_instance(&state, &id).await { + Ok(record) => record, + Err(response) => return response, + }; + + match record.provider { + SandboxProviderKind::Daytona => { +- let sandbox = match reconnect_daytona_sandbox(&state, &id).await { ++ let sandbox = match reconnect_daytona_sandbox_instance(&state, &record).await { + Ok(sandbox) => sandbox, + Err(response) => return response, + }; +@@ -416,7 +418,7 @@ async fn create_ssh_access( + } + } + SandboxProviderKind::Docker => { +- let sandbox = match reconnect_run_sandbox(&state, &id).await { ++ let sandbox = match reconnect_run_sandbox_instance(&state, &id, &record).await { + Ok(sandbox) => sandbox, + Err(response) => return response, + }; +@@ -451,7 +453,7 @@ async fn create_sandbox_vnc_preview( + Ok(id) => id, + Err(response) => return response, + }; +- let record = match load_run_sandbox(&state, &id).await { ++ let record = match load_run_sandbox_instance(&state, &id).await { + Ok(record) => record, + Err(response) => return response, + }; +@@ -462,7 +464,7 @@ async fn create_sandbox_vnc_preview( + ) + .into_response(); + } +- let sandbox = match reconnect_daytona_sandbox(&state, &id).await { ++ let sandbox = match reconnect_daytona_sandbox_instance(&state, &record).await { + Ok(sandbox) => sandbox, + Err(response) => return response, + }; +@@ -555,12 +557,12 @@ async fn list_sandbox_services( + Ok(id) => id, + Err(response) => return response, + }; +- let record = match load_run_sandbox(&state, &id).await { ++ let record = match load_run_sandbox_instance(&state, &id).await { + Ok(record) => record, + Err(response) => return response, + }; + let provider = record.provider; +- let sandbox = match reconnect_run_sandbox(&state, &id).await { ++ let sandbox = match reconnect_run_sandbox_instance(&state, &id, &record).await { + Ok(sandbox) => sandbox, + Err(response) => return response, + }; +@@ -848,9 +850,17 @@ async fn reconnect_run_sandbox( + state: &Arc, + run_id: &RunId, + ) -> Result, Response> { +- let record = load_run_sandbox(state, run_id).await?; ++ let record = load_run_sandbox_instance(state, run_id).await?; ++ reconnect_run_sandbox_instance(state, run_id, &record).await ++} ++ ++async fn reconnect_run_sandbox_instance( ++ state: &Arc, ++ run_id: &RunId, ++ record: &RunSandboxInstance, ++) -> Result, Response> { + let daytona_api_key = state.vault_secret(EnvVars::DAYTONA_API_KEY); +- let sandbox = reconnect_for_run(&record, daytona_api_key, Some(*run_id)) ++ let sandbox = reconnect_for_run(record, daytona_api_key, Some(*run_id)) + .await + .map_err(|err| { + let detail = render_with_causes(&err.to_string(), &collect_causes(err.as_ref())); +@@ -866,7 +876,14 @@ async fn reconnect_daytona_sandbox( + state: &Arc, + run_id: &RunId, + ) -> Result { +- let record = load_run_sandbox(state, run_id).await?; ++ let record = load_run_sandbox_instance(state, run_id).await?; ++ reconnect_daytona_sandbox_instance(state, &record).await ++} ++ ++async fn reconnect_daytona_sandbox_instance( ++ state: &Arc, ++ record: &RunSandboxInstance, ++) -> Result { + if record.provider != SandboxProviderKind::Daytona { + return Err(ApiError::new( + StatusCode::CONFLICT, +@@ -874,13 +891,7 @@ async fn reconnect_daytona_sandbox( + ) + .into_response()); + } +- let Some(runtime) = record.runtime.as_ref() else { +- return Err(ApiError::new( +- StatusCode::CONFLICT, +- "Sandbox record is missing runtime metadata.", +- ) +- .into_response()); +- }; ++ let runtime = &record.runtime; + let Some(repo_cloned) = runtime.repo_cloned else { + return Err(ApiError::new( + StatusCode::CONFLICT, +@@ -907,35 +918,16 @@ async fn reconnect_daytona_sandbox( + Ok(sandbox) + } + +-async fn load_run_sandbox( ++async fn load_run_sandbox_instance( + state: &Arc, + run_id: &RunId, +-) -> Result { +- match state.store.open_run_reader(run_id).await { +- Ok(run_store) => match run_store.state().await { +- Ok(run_state) => run_state.sandbox.ok_or_else(|| { +- ApiError::new(StatusCode::CONFLICT, "Run has no active sandbox.").into_response() +- }), +- Err(err) => Err( +- ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), +- ), +- }, +- Err(_) => Err(ApiError::not_found("Run not found.").into_response()), +- } +-} +- +-/// Same as `load_run_sandbox`, but treats a missing sandbox as +-/// `404 Not Found` instead of `409 Conflict`. Used by the inspection endpoint +-/// where there is no resource to act on if the run never had a sandbox. +-async fn load_run_sandbox_or_not_found( +- state: &Arc, +- run_id: &RunId, +-) -> Result { ++) -> Result { + match state.store.open_run_reader(run_id).await { + Ok(run_store) => match run_store.state().await { + Ok(run_state) => run_state + .sandbox +- .ok_or_else(|| ApiError::not_found("Run has no sandbox.").into_response()), ++ .and_then(fabro_types::RunSandbox::into_instance) ++ .ok_or_else(|| ApiError::not_found("Run sandbox was not created.").into_response()), + Err(err) => Err( + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), + ), +@@ -1378,6 +1370,38 @@ mod retrieve_sandbox_tests { + run_store.append_event(&payload).await.unwrap(); + } + ++ async fn append_sandbox_failed(run_store: &fabro_store::RunDatabase, run_id: &RunId) { ++ let payload = fabro_store::EventPayload::new( ++ json!({ ++ "id": "evt-sandbox-failed", ++ "ts": "2026-05-09T12:00:00Z", ++ "run_id": run_id, ++ "event": "sandbox.failed", ++ "properties": { ++ "provider": "docker", ++ "error": "Docker daemon unavailable", ++ "causes": ["connection refused"], ++ "duration_ms": 42, ++ }, ++ }), ++ run_id, ++ ) ++ .expect("sandbox.failed payload should validate"); ++ run_store.append_event(&payload).await.unwrap(); ++ } ++ ++ async fn assert_sandbox_not_created_response(response: axum::response::Response) { ++ assert_eq!(response.status(), StatusCode::NOT_FOUND); ++ let body = body_json(response).await; ++ assert!( ++ body["errors"][0]["detail"] ++ .as_str() ++ .unwrap_or_default() ++ .contains("Run sandbox was not created."), ++ "unexpected body: {body}" ++ ); ++ } ++ + #[tokio::test] + async fn missing_run_returns_404() { + let app = build_test_router(test_app_state()); +@@ -1398,7 +1422,7 @@ mod retrieve_sandbox_tests { + } + + #[tokio::test] +- async fn run_without_sandbox_runtime_returns_planned_sandbox_details() { ++ async fn planned_sandbox_returns_404_from_details_endpoint() { + let state = test_app_state(); + let app = build_test_router(state.clone()); + let run_id = RunId::new(); +@@ -1412,10 +1436,52 @@ mod retrieve_sandbox_tests { + .oneshot(req_get(&format!("/api/v1/runs/{run_id}/sandbox"))) + .await + .unwrap(); +- assert_eq!(response.status(), StatusCode::OK); +- let body = body_json(response).await; +- assert_eq!(body["sandbox"]["provider"], "local"); +- assert!(body["sandbox"]["runtime"].is_null()); ++ assert_sandbox_not_created_response(response).await; ++ } ++ ++ #[tokio::test] ++ async fn planned_sandbox_rejects_live_operations() { ++ let state = test_app_state(); ++ let app = build_test_router(state.clone()); ++ let run_id = RunId::new(); ++ let run_store = state ++ .store_ref() ++ .create_run(&run_id) ++ .await ++ .expect("test run should be creatable"); ++ append_run_created(&run_store, &run_id).await; ++ ++ for uri in [ ++ format!("/api/v1/runs/{run_id}/sandbox/services"), ++ format!("/api/v1/runs/{run_id}/sandbox/files?path=/workspace"), ++ format!("/api/v1/runs/{run_id}/sandbox/file?path=/workspace/README.md"), ++ ] { ++ let response = app.clone().oneshot(req_get(&uri)).await.unwrap(); ++ assert_sandbox_not_created_response(response).await; ++ } ++ } ++ ++ #[tokio::test] ++ async fn failed_sandbox_rejects_live_operations() { ++ let state = test_app_state(); ++ let app = build_test_router(state.clone()); ++ let run_id = RunId::new(); ++ let run_store = state ++ .store_ref() ++ .create_run(&run_id) ++ .await ++ .expect("test run should be creatable"); ++ append_run_created(&run_store, &run_id).await; ++ append_sandbox_failed(&run_store, &run_id).await; ++ ++ for uri in [ ++ format!("/api/v1/runs/{run_id}/sandbox/services"), ++ format!("/api/v1/runs/{run_id}/sandbox/files?path=/workspace"), ++ format!("/api/v1/runs/{run_id}/sandbox/file?path=/workspace/README.md"), ++ ] { ++ let response = app.clone().oneshot(req_get(&uri)).await.unwrap(); ++ assert_sandbox_not_created_response(response).await; ++ } + } + + #[tokio::test] +diff --git a/lib/crates/fabro-server/src/server/handler/sessions.rs b/lib/crates/fabro-server/src/server/handler/sessions.rs +index aaaaf368f..bad48c6d4 100644 +--- a/lib/crates/fabro-server/src/server/handler/sessions.rs ++++ b/lib/crates/fabro-server/src/server/handler/sessions.rs +@@ -702,13 +702,11 @@ async fn build_agent_session( + .sandbox + .as_ref() + .ok_or(AskFabroBuildError::NoSandbox)?; +- if sandbox_record.runtime.is_none() { +- return Err(AskFabroBuildError::SandboxUnavailable(anyhow::anyhow!( +- "run sandbox runtime is not ready" +- ))); +- } ++ let sandbox_instance = sandbox_record.instance().ok_or_else(|| { ++ AskFabroBuildError::SandboxUnavailable(anyhow::anyhow!("run sandbox was not created")) ++ })?; + let sandbox = reconnect_for_run( +- sandbox_record, ++ sandbox_instance, + state.vault_secret(EnvVars::DAYTONA_API_KEY), + Some(run_id), + ) +diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs +index b0105392c..78432c1b2 100644 +--- a/lib/crates/fabro-server/src/server/tests.rs ++++ b/lib/crates/fabro-server/src/server/tests.rs +@@ -14330,9 +14330,13 @@ async fn list_runs_includes_live_metadata_from_run_state() { + .expect("run should be in board"); + + assert_eq!(item["pull_request"]["number"].as_u64(), Some(42)); +- assert_eq!(item["sandbox"]["runtime"]["id"].as_str(), Some("sb-test")); ++ assert_eq!(item["sandbox"]["kind"].as_str(), Some("ready")); + assert_eq!( +- item["sandbox"]["runtime"]["working_directory"].as_str(), ++ item["sandbox"]["instance"]["runtime"]["id"].as_str(), ++ Some("sb-test") ++ ); ++ assert_eq!( ++ item["sandbox"]["instance"]["runtime"]["working_directory"].as_str(), + Some("/sandbox/workdir") + ); + assert!(item["current_question"].is_object()); +@@ -14391,7 +14395,7 @@ async fn list_runs_page_limit_preserves_metadata_for_paged_items() { + assert_eq!(data.len(), 1); + + let item = &data[0]; +- let sandbox_id = item["sandbox"]["runtime"]["id"] ++ let sandbox_id = item["sandbox"]["instance"]["runtime"]["id"] + .as_str() + .expect("paged item should still include sandbox metadata"); + assert!(matches!(sandbox_id, "sb-first" | "sb-second")); +diff --git a/lib/crates/fabro-server/tests/it/api/run_files.rs b/lib/crates/fabro-server/tests/it/api/run_files.rs +index 783100d5c..a3bf7ad76 100644 +--- a/lib/crates/fabro-server/tests/it/api/run_files.rs ++++ b/lib/crates/fabro-server/tests/it/api/run_files.rs +@@ -14,7 +14,7 @@ use axum::body::Body; + use axum::http::{Request, StatusCode}; + use fabro_server::test_support::test_app_state_with_store; + use fabro_store::{ArtifactStore, Database}; +-use fabro_types::{Graph, RunId, WorkflowSettings}; ++use fabro_types::{Graph, RunId, SandboxProviderKind, WorkflowSettings}; + use fabro_workflow::event as workflow_event; + use fabro_workflow::run_status::SuccessReason; + use object_store::memory::InMemory as MemoryObjectStore; +@@ -125,6 +125,33 @@ async fn append_completed_run_with_final_patch( + .expect("append WorkflowRunCompleted"); + } + ++async fn append_local_sandbox_initialized(store: &Database, run_id: &RunId) { ++ let run_store = store.open_run(run_id).await.expect("open run store"); ++ workflow_event::append_event( ++ &run_store, ++ run_id, ++ &workflow_event::Event::SandboxInitialized { ++ working_directory: std::env::current_dir() ++ .expect("test should run inside a source checkout") ++ .display() ++ .to_string(), ++ provider: SandboxProviderKind::Local, ++ id: "local:test-sandbox".to_string(), ++ image: None, ++ snapshot: None, ++ repo_cloned: None, ++ clone_origin_url: None, ++ clone_branch: None, ++ workspace_root: None, ++ repos_root: None, ++ primary_repo_path: None, ++ primary_repo_link: None, ++ }, ++ ) ++ .await ++ .expect("append SandboxInitialized"); ++} ++ + #[tokio::test] + async fn invalid_run_id_returns_400() { + let app = fabro_server::test_support::build_test_router(test_app_state()); +@@ -311,6 +338,7 @@ diff --git a/.env.production b/.env.production + +SECRET=new + "; + append_completed_run_with_final_patch(&store, &run_id, patch).await; ++ append_local_sandbox_initialized(&store, &run_id).await; + + let req = Request::builder() + .method("GET") +@@ -345,7 +373,7 @@ diff --git a/.env.production b/.env.production + } + + #[tokio::test] +-async fn unavailable_sandbox_falls_back_to_final_patch_for_every_scope() { ++async fn planned_sandbox_rejects_files_for_every_scope() { + let settings = test_settings(); + let (store, artifact_store) = store_bundle(); + let state = test_app_state_with_store( +@@ -376,15 +404,15 @@ diff --git a/src/lib.rs b/src/lib.rs + let resp = app.clone().oneshot(req).await.unwrap(); + let body = response_json( + resp, +- StatusCode::OK, ++ StatusCode::NOT_FOUND, + format!("GET /api/v1/runs/{run_id}/files?scope={scope}"), + ) + .await; + +- assert_eq!(body["meta"]["source"].as_str(), Some("final_patch")); +- assert_eq!(body["meta"]["scope"].as_str(), Some("committed")); +- assert_eq!(body["meta"]["degraded"].as_bool(), Some(true)); +- assert_eq!(body["data"].as_array().map(Vec::len), Some(1)); ++ assert_eq!( ++ body["errors"][0]["detail"].as_str(), ++ Some("Run sandbox was not created.") ++ ); + } + } + +diff --git a/lib/crates/fabro-server/tests/it/api/sandbox_vnc.rs b/lib/crates/fabro-server/tests/it/api/sandbox_vnc.rs +index 49447361b..2ba6a716d 100644 +--- a/lib/crates/fabro-server/tests/it/api/sandbox_vnc.rs ++++ b/lib/crates/fabro-server/tests/it/api/sandbox_vnc.rs +@@ -27,7 +27,7 @@ async fn vnc_for_missing_run_returns_not_found() { + } + + #[tokio::test] +-async fn vnc_for_run_without_sandbox_returns_conflict() { ++async fn vnc_for_run_without_sandbox_returns_not_found() { + let app = fabro_server::test_support::build_test_router(test_app_state()); + let create_req = Request::builder() + .method("POST") +@@ -51,7 +51,7 @@ async fn vnc_for_run_without_sandbox_returns_conflict() { + + response_status( + response, +- StatusCode::NOT_IMPLEMENTED, ++ StatusCode::NOT_FOUND, + format!("POST /api/v1/runs/{run_id}/sandbox/vnc"), + ) + .await; +diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs +index 2955cbbed..cd6fa0640 100644 +--- a/lib/crates/fabro-store/src/run_state.rs ++++ b/lib/crates/fabro-store/src/run_state.rs +@@ -13,10 +13,11 @@ use fabro_types::{ + McpServerProjection, McpServerStatus, Outcome, PendingInterviewRecord, PendingReason, + PullRequestLink, RepositoryRef, Run, RunApproval, RunApprovalState, RunBillingSummary, + RunControlAction, RunDiff, RunEvent, RunId, RunLifecycle, RunLinks, RunModel, RunOrigin, +- RunProjection, RunSandbox, RunSandboxRuntime, RunSize, RunSpec, RunStatus, RunTimestamps, +- SandboxProviderKind, StageCompletion, StageHandler, StageId, StageModelUsage, StageOutcome, +- StageProjection, StageState, StartRecord, SubAgentProjection, SubAgentStatus, TodoListKind, +- TodoListProjection, TodoProjection, WorkflowRef, first_event_seq, ++ RunProjection, RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxPlan, ++ RunSandboxRuntime, RunSize, RunSpec, RunStatus, RunTimestamps, SandboxProviderKind, ++ StageCompletion, StageHandler, StageId, StageModelUsage, StageOutcome, StageProjection, ++ StageState, StartRecord, SubAgentProjection, SubAgentStatus, TodoListKind, TodoListProjection, ++ TodoProjection, WorkflowRef, first_event_seq, + }; + use fabro_util::error::render_compact_with_causes; + +@@ -259,27 +260,37 @@ impl RunProjectionReducer for RunProjection { + diff: diff_from_checkpoint_props(props), + }); + } ++ EventBody::SandboxInitializing(_) => { ++ let plan = sandbox_plan_from_projection_or_settings(self); ++ self.sandbox = Some(RunSandbox::initializing(plan)); ++ } ++ EventBody::SandboxFailed(props) => { ++ let plan = sandbox_plan_from_projection_or_settings(self); ++ self.sandbox = Some(RunSandbox::failed(plan, RunSandboxFailure { ++ provider: props.provider.clone(), ++ error: props.error.clone(), ++ causes: props.causes.clone(), ++ duration_ms: props.duration_ms, ++ })); ++ } + EventBody::SandboxInitialized(props) => { +- let sandbox = self.sandbox.get_or_insert(RunSandbox { ++ let plan = sandbox_plan_from_projection_or_settings(self); ++ self.sandbox = Some(RunSandbox::ready(plan, RunSandboxInstance { + provider: props.provider, +- image: None, +- snapshot: None, +- runtime: None, +- }); +- sandbox.provider = props.provider; +- sandbox.image.clone_from(&props.image); +- sandbox.snapshot.clone_from(&props.snapshot); +- sandbox.runtime = Some(RunSandboxRuntime { +- id: props.id.clone(), +- working_directory: props.working_directory.clone(), +- repo_cloned: props.repo_cloned, +- clone_origin_url: props.clone_origin_url.clone(), +- clone_branch: props.clone_branch.clone(), +- workspace_root: props.workspace_root.clone(), +- repos_root: props.repos_root.clone(), +- primary_repo_path: props.primary_repo_path.clone(), +- primary_repo_link: props.primary_repo_link.clone(), +- }); ++ image: props.image.clone(), ++ snapshot: props.snapshot.clone(), ++ runtime: RunSandboxRuntime { ++ id: props.id.clone(), ++ working_directory: props.working_directory.clone(), ++ repo_cloned: props.repo_cloned, ++ clone_origin_url: props.clone_origin_url.clone(), ++ clone_branch: props.clone_branch.clone(), ++ workspace_root: props.workspace_root.clone(), ++ repos_root: props.repos_root.clone(), ++ primary_repo_path: props.primary_repo_path.clone(), ++ primary_repo_link: props.primary_repo_link.clone(), ++ }, ++ })); + } + EventBody::PullRequestCreated(props) => { + self.pull_request = Some(PullRequestLink { +@@ -794,20 +805,28 @@ fn projection_from_created(event: &EventEnvelope) -> Result { + projection.parent_id = props.parent_id; + projection.retried_from = props.retried_from; + projection.web_url.clone_from(&props.web_url); +- projection.sandbox = Some(planned_sandbox(&projection.spec.settings.run.environment)); ++ projection.sandbox = Some(RunSandbox::planned(sandbox_plan( ++ &projection.spec.settings.run.environment, ++ ))); + Ok(projection) + } + +-fn planned_sandbox(settings: &RunEnvironmentSettings) -> RunSandbox { ++fn sandbox_plan_from_projection_or_settings(state: &RunProjection) -> RunSandboxPlan { ++ state.sandbox.as_ref().map_or_else( ++ || sandbox_plan(&state.spec.settings.run.environment), ++ |sandbox| sandbox.plan().clone(), ++ ) ++} ++ ++fn sandbox_plan(settings: &RunEnvironmentSettings) -> RunSandboxPlan { + let provider = SandboxProviderKind::from(settings.provider); +- RunSandbox { ++ RunSandboxPlan { + provider, + image: (settings.provider == EnvironmentProvider::Docker) + .then(|| settings.image.docker.clone()) + .flatten() + .filter(|image| !image.is_empty()), + snapshot: None, +- runtime: None, + } + } + +@@ -1380,7 +1399,7 @@ mod tests { + docker.provider = EnvironmentProvider::Docker; + docker.image.docker = Some("ubuntu:24.04".to_string()); + +- let planned_docker = super::planned_sandbox(&docker); ++ let planned_docker = super::sandbox_plan(&docker); + assert_eq!(planned_docker.image.as_deref(), Some("ubuntu:24.04")); + assert_eq!(planned_docker.snapshot, None); + +@@ -1388,7 +1407,7 @@ mod tests { + daytona.provider = EnvironmentProvider::Daytona; + daytona.image.dockerfile = Some(DockerfileSource::Inline("FROM ubuntu:24.04".to_string())); + +- let planned_daytona = super::planned_sandbox(&daytona); ++ let planned_daytona = super::sandbox_plan(&daytona); + assert_eq!(planned_daytona.image, None); + assert_eq!(planned_daytona.snapshot, None); + } +@@ -1411,9 +1430,10 @@ mod tests { + .unwrap(); + + let sandbox = state.sandbox.expect("sandbox should be projected"); +- assert_eq!(sandbox.image, None); ++ let instance = sandbox.instance().expect("sandbox should be ready"); ++ assert_eq!(instance.image, None); + assert_eq!( +- sandbox.snapshot.as_deref(), ++ instance.snapshot.as_deref(), + Some("fabro-11111111-2222-8333-8444-555555555555") + ); + } +@@ -1469,6 +1489,155 @@ mod tests { + ); + } + ++ #[test] ++ fn run_created_projects_planned_sandbox_lifecycle() { ++ let state = RunProjection::apply_events(&[test_raw_event( ++ 1, ++ "run.created", ++ &json!({ ++ "settings": WorkflowSettings::default(), ++ "graph": Graph::new("test"), ++ "labels": {}, ++ "run_dir": "/tmp/run" ++ }), ++ None, ++ )]) ++ .unwrap(); ++ ++ let sandbox = serde_json::to_value(state.sandbox.as_ref().unwrap()).unwrap(); ++ assert_eq!(sandbox["kind"], "planned"); ++ assert_eq!(sandbox["plan"]["provider"], "local"); ++ assert!(sandbox.get("instance").is_none()); ++ assert!(sandbox.get("failure").is_none()); ++ } ++ ++ #[test] ++ fn sandbox_lifecycle_events_update_projected_sandbox_state() { ++ let mut state = RunProjection::apply_events(&[test_raw_event( ++ 1, ++ "run.created", ++ &json!({ ++ "settings": WorkflowSettings::default(), ++ "graph": Graph::new("test"), ++ "labels": {}, ++ "run_dir": "/tmp/run" ++ }), ++ None, ++ )]) ++ .unwrap(); ++ ++ state ++ .apply_event(&test_raw_event( ++ 2, ++ "sandbox.initializing", ++ &json!({ "provider": "docker" }), ++ None, ++ )) ++ .unwrap(); ++ let initializing = serde_json::to_value(state.sandbox.as_ref().unwrap()).unwrap(); ++ assert_eq!(initializing["kind"], "initializing"); ++ assert!(initializing.get("instance").is_none()); ++ ++ state ++ .apply_event(&test_raw_event( ++ 3, ++ "sandbox.initialized", ++ &json!({ ++ "provider": "docker", ++ "id": "container-abc123", ++ "working_directory": "/workspace", ++ "image": "ubuntu:24.04", ++ "repo_cloned": true, ++ "clone_origin_url": "https://github.com/fabro-sh/fabro.git", ++ "clone_branch": "main" ++ }), ++ None, ++ )) ++ .unwrap(); ++ let ready = serde_json::to_value(state.sandbox.as_ref().unwrap()).unwrap(); ++ assert_eq!(ready["kind"], "ready"); ++ assert_eq!(ready["plan"]["provider"], "local"); ++ assert_eq!(ready["instance"]["provider"], "docker"); ++ assert_eq!(ready["instance"]["image"], "ubuntu:24.04"); ++ assert_eq!(ready["instance"]["runtime"]["id"], "container-abc123"); ++ assert_eq!( ++ ready["instance"]["runtime"]["working_directory"], ++ "/workspace" ++ ); ++ ++ state ++ .apply_event(&test_raw_event( ++ 4, ++ "sandbox.failed", ++ &json!({ ++ "provider": "docker", ++ "error": "Docker daemon unavailable", ++ "causes": ["connection refused"], ++ "duration_ms": 42 ++ }), ++ None, ++ )) ++ .unwrap(); ++ let failed = serde_json::to_value(state.sandbox.as_ref().unwrap()).unwrap(); ++ assert_eq!(failed["kind"], "failed"); ++ assert_eq!(failed["failure"]["provider"], "docker"); ++ assert_eq!(failed["failure"]["error"], "Docker daemon unavailable"); ++ assert_eq!(failed["failure"]["causes"], json!(["connection refused"])); ++ assert_eq!(failed["failure"]["duration_ms"], 42); ++ assert!(failed.get("instance").is_none()); ++ } ++ ++ #[test] ++ fn run_failed_before_sandbox_events_leaves_sandbox_planned() { ++ let state = RunProjection::apply_events(&[ ++ test_raw_event( ++ 1, ++ "run.created", ++ &json!({ ++ "settings": WorkflowSettings::default(), ++ "graph": Graph::new("test"), ++ "labels": {}, ++ "run_dir": "/tmp/run" ++ }), ++ None, ++ ), ++ test_raw_event( ++ 2, ++ "run.runnable", ++ &json!({ "source": "start_requested" }), ++ None, ++ ), ++ test_raw_event(3, "run.starting", &json!({}), None), ++ test_raw_event(4, "run.running", &json!({}), None), ++ test_raw_event( ++ 5, ++ "run.failed", ++ &json!({ ++ "failure": { ++ "reason": "sandbox_init_failed", ++ "detail": { ++ "message": "Failed before sandbox initialized", ++ "category": "transient_infra" ++ } ++ }, ++ "timing": { ++ "wall_time_ms": 1, ++ "inference_time_ms": 0, ++ "tool_time_ms": 0, ++ "active_time_ms": 0 ++ } ++ }), ++ None, ++ ), ++ ]) ++ .unwrap(); ++ ++ let sandbox = serde_json::to_value(state.sandbox.as_ref().unwrap()).unwrap(); ++ assert_eq!(sandbox["kind"], "planned"); ++ assert!(sandbox.get("instance").is_none()); ++ assert!(sandbox.get("failure").is_none()); ++ } ++ + fn test_raw_event( + seq: u32, + event: &str, +diff --git a/lib/crates/fabro-store/tests/serializable_projection.rs b/lib/crates/fabro-store/tests/serializable_projection.rs +index db34fa664..6584a36cd 100644 +--- a/lib/crates/fabro-store/tests/serializable_projection.rs ++++ b/lib/crates/fabro-store/tests/serializable_projection.rs +@@ -6,9 +6,9 @@ use fabro_types::graph::Graph; + use fabro_types::run::RunSpec; + use fabro_types::{ + BilledModelUsage, BilledTokenCounts, Checkpoint, CheckpointRecord, InterviewQuestionRecord, +- QuestionType, RunDiff, RunSandbox, RunSandboxRuntime, RunStatus, SandboxProviderKind, +- StageCompletion, StageModelUsage, StageOutcome, StartRecord, WorkflowSettings, first_event_seq, +- fixtures, ++ QuestionType, RunDiff, RunSandbox, RunSandboxInstance, RunSandboxPlan, RunSandboxRuntime, ++ RunStatus, SandboxProviderKind, StageCompletion, StageModelUsage, StageOutcome, StartRecord, ++ WorkflowSettings, first_event_seq, fixtures, + }; + use serde_json::json; + +@@ -99,11 +99,16 @@ fn serializable_projection_round_trips_and_trims_bulky_node_fields() { + checkpoint: sample_checkpoint(), + diff: RunDiff::default(), + }); +- projection.sandbox = Some(RunSandbox { ++ let sandbox_plan = RunSandboxPlan { + provider: SandboxProviderKind::Local, + image: None, + snapshot: None, +- runtime: Some(RunSandboxRuntime { ++ }; ++ projection.sandbox = Some(RunSandbox::ready(sandbox_plan, RunSandboxInstance { ++ provider: SandboxProviderKind::Local, ++ image: None, ++ snapshot: None, ++ runtime: RunSandboxRuntime { + id: "sandbox-1".to_string(), + working_directory: "/tmp/project".to_string(), + repo_cloned: None, +@@ -113,8 +118,8 @@ fn serializable_projection_round_trips_and_trims_bulky_node_fields() { + repos_root: None, + primary_repo_path: None, + primary_repo_link: None, +- }), +- }); ++ }, ++ })); + projection.pending_interviews = BTreeMap::new(); + let stage = projection.stage_entry(stage_id.node_id(), stage_id.visit(), first_event_seq(2)); + stage.prompt = Some("plan the work".to_string()); +diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs +index 31103c13a..f1a8c8b39 100644 +--- a/lib/crates/fabro-types/src/lib.rs ++++ b/lib/crates/fabro-types/src/lib.rs +@@ -113,7 +113,10 @@ pub use run_projection::{ + StageContextWindowStaleness, StageContextWindowUnavailableReason, StageContextWindowWarning, + StageModelUsage, StageProjection, SubAgentProjection, SubAgentStatus, first_event_seq, + }; +-pub use run_sandbox::{RunSandbox, RunSandboxRuntime}; ++pub use run_sandbox::{ ++ RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxKind, RunSandboxPlan, ++ RunSandboxRuntime, ++}; + pub use run_summary::{ + AskFabro, AskFabroUnavailableReason, AutomationRef, Run, RunApproval, RunApprovalState, + RunBillingSummary, RunError, RunLifecycle, RunLinks, RunModel, RunOrigin, RunOriginKind, +diff --git a/lib/crates/fabro-types/src/run_event/infra.rs b/lib/crates/fabro-types/src/run_event/infra.rs +index d13302148..3abd80c12 100644 +--- a/lib/crates/fabro-types/src/run_event/infra.rs ++++ b/lib/crates/fabro-types/src/run_event/infra.rs +@@ -1,6 +1,6 @@ + use serde::{Deserialize, Serialize}; + +-use crate::SandboxProviderKind; ++use crate::{RunSandboxFailure, SandboxProviderKind}; + + #[derive( + Debug, +@@ -201,14 +201,7 @@ pub struct SandboxReadyProps { + pub url: Option, + } + +-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +-pub struct SandboxFailedProps { +- pub provider: String, +- pub error: String, +- #[serde(default, skip_serializing_if = "Vec::is_empty")] +- pub causes: Vec, +- pub duration_ms: u64, +-} ++pub type SandboxFailedProps = RunSandboxFailure; + + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] + pub struct SandboxCleanupStartedProps { +@@ -409,50 +402,3 @@ pub struct CliEnsureFailedProps { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exec_output_tail: Option, + } +- +-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +-pub struct DevcontainerResolvedProps { +- pub dockerfile_lines: usize, +- pub environment_count: usize, +- pub lifecycle_command_count: usize, +- pub workspace_folder: String, +-} +- +-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +-pub struct DevcontainerLifecycleStartedProps { +- pub phase: String, +- pub command_count: usize, +-} +- +-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +-pub struct DevcontainerLifecycleCommandStartedProps { +- pub phase: String, +- pub command: String, +- pub index: usize, +-} +- +-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +-pub struct DevcontainerLifecycleCommandCompletedProps { +- pub phase: String, +- pub command: String, +- pub index: usize, +- pub exit_code: i32, +- pub duration_ms: u64, +-} +- +-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +-pub struct DevcontainerLifecycleCompletedProps { +- pub phase: String, +- pub duration_ms: u64, +-} +- +-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +-pub struct DevcontainerLifecycleFailedProps { +- pub phase: String, +- pub command: String, +- pub index: usize, +- pub exit_code: i32, +- pub stderr: String, +- #[serde(default, skip_serializing_if = "Option::is_none")] +- pub exec_output_tail: Option, +-} +diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs +index 01ed04f4d..52cbb83a9 100644 +--- a/lib/crates/fabro-types/src/run_event/mod.rs ++++ b/lib/crates/fabro-types/src/run_event/mod.rs +@@ -358,18 +358,6 @@ pub enum EventBody { + PullRequestUnlinked(PullRequestUnlinkedProps), + #[serde(rename = "pull_request.failed")] + PullRequestFailed(PullRequestFailedProps), +- #[serde(rename = "devcontainer.resolved")] +- DevcontainerResolved(DevcontainerResolvedProps), +- #[serde(rename = "devcontainer.lifecycle.started")] +- DevcontainerLifecycleStarted(DevcontainerLifecycleStartedProps), +- #[serde(rename = "devcontainer.lifecycle.command.started")] +- DevcontainerLifecycleCommandStarted(DevcontainerLifecycleCommandStartedProps), +- #[serde(rename = "devcontainer.lifecycle.command.completed")] +- DevcontainerLifecycleCommandCompleted(DevcontainerLifecycleCommandCompletedProps), +- #[serde(rename = "devcontainer.lifecycle.completed")] +- DevcontainerLifecycleCompleted(DevcontainerLifecycleCompletedProps), +- #[serde(rename = "devcontainer.lifecycle.failed")] +- DevcontainerLifecycleFailed(DevcontainerLifecycleFailedProps), + Unknown { + name: String, + properties: Value, +@@ -580,16 +568,6 @@ impl EventBody { + Self::PullRequestLinked(_) => "pull_request.linked", + Self::PullRequestUnlinked(_) => "pull_request.unlinked", + Self::PullRequestFailed(_) => "pull_request.failed", +- Self::DevcontainerResolved(_) => "devcontainer.resolved", +- Self::DevcontainerLifecycleStarted(_) => "devcontainer.lifecycle.started", +- Self::DevcontainerLifecycleCommandStarted(_) => { +- "devcontainer.lifecycle.command.started" +- } +- Self::DevcontainerLifecycleCommandCompleted(_) => { +- "devcontainer.lifecycle.command.completed" +- } +- Self::DevcontainerLifecycleCompleted(_) => "devcontainer.lifecycle.completed", +- Self::DevcontainerLifecycleFailed(_) => "devcontainer.lifecycle.failed", + Self::Unknown { name, .. } => name.as_str(), + } + } +@@ -762,12 +740,6 @@ fn is_known_event_name(event: &str) -> bool { + | "pull_request.linked" + | "pull_request.unlinked" + | "pull_request.failed" +- | "devcontainer.resolved" +- | "devcontainer.lifecycle.started" +- | "devcontainer.lifecycle.command.started" +- | "devcontainer.lifecycle.command.completed" +- | "devcontainer.lifecycle.completed" +- | "devcontainer.lifecycle.failed" + ) + } + +diff --git a/lib/crates/fabro-types/src/run_sandbox.rs b/lib/crates/fabro-types/src/run_sandbox.rs +index b2aedd829..91e3ec5f0 100644 +--- a/lib/crates/fabro-types/src/run_sandbox.rs ++++ b/lib/crates/fabro-types/src/run_sandbox.rs +@@ -1,16 +1,196 @@ +-use serde::{Deserialize, Serialize}; ++use serde::de::Error as _; ++use serde::ser::Error as _; ++use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + use crate::SandboxProviderKind; + ++#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] ++#[serde(rename_all = "snake_case")] ++pub enum RunSandboxKind { ++ Planned, ++ Initializing, ++ Ready, ++ Failed, ++} ++ + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +-pub struct RunSandbox { ++pub struct RunSandboxPlan { + pub provider: SandboxProviderKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snapshot: Option, ++} ++ ++#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] ++pub struct RunSandboxInstance { ++ pub provider: SandboxProviderKind, + #[serde(default, skip_serializing_if = "Option::is_none")] +- pub runtime: Option, ++ pub image: Option, ++ #[serde(default, skip_serializing_if = "Option::is_none")] ++ pub snapshot: Option, ++ pub runtime: RunSandboxRuntime, ++} ++ ++#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] ++pub struct RunSandboxFailure { ++ pub provider: String, ++ pub error: String, ++ #[serde(default, skip_serializing_if = "Vec::is_empty")] ++ pub causes: Vec, ++ pub duration_ms: u64, ++} ++ ++#[derive(Debug, Clone, PartialEq)] ++pub struct RunSandbox { ++ kind: RunSandboxKind, ++ plan: RunSandboxPlan, ++ instance: Option, ++ failure: Option, ++} ++ ++impl RunSandbox { ++ pub fn planned(plan: RunSandboxPlan) -> Self { ++ Self { ++ kind: RunSandboxKind::Planned, ++ plan, ++ instance: None, ++ failure: None, ++ } ++ } ++ ++ pub fn initializing(plan: RunSandboxPlan) -> Self { ++ Self { ++ kind: RunSandboxKind::Initializing, ++ plan, ++ instance: None, ++ failure: None, ++ } ++ } ++ ++ pub fn ready(plan: RunSandboxPlan, instance: RunSandboxInstance) -> Self { ++ Self { ++ kind: RunSandboxKind::Ready, ++ plan, ++ instance: Some(instance), ++ failure: None, ++ } ++ } ++ ++ pub fn failed(plan: RunSandboxPlan, failure: RunSandboxFailure) -> Self { ++ Self { ++ kind: RunSandboxKind::Failed, ++ plan, ++ instance: None, ++ failure: Some(failure), ++ } ++ } ++ ++ pub fn instance(&self) -> Option<&RunSandboxInstance> { ++ self.instance.as_ref() ++ } ++ ++ pub fn into_instance(self) -> Option { ++ self.instance ++ } ++ ++ pub fn kind(&self) -> RunSandboxKind { ++ self.kind ++ } ++ ++ pub fn plan(&self) -> &RunSandboxPlan { ++ &self.plan ++ } ++ ++ pub fn failure(&self) -> Option<&RunSandboxFailure> { ++ self.failure.as_ref() ++ } ++ ++ fn validate(&self) -> Result<(), String> { ++ match self.kind { ++ RunSandboxKind::Planned | RunSandboxKind::Initializing => { ++ if self.instance.is_some() { ++ return Err(format!( ++ "{:?} sandbox must not carry an instance", ++ self.kind ++ )); ++ } ++ if self.failure.is_some() { ++ return Err(format!("{:?} sandbox must not carry a failure", self.kind)); ++ } ++ } ++ RunSandboxKind::Ready => { ++ if self.instance.is_none() { ++ return Err("ready sandbox requires an instance".to_string()); ++ } ++ if self.failure.is_some() { ++ return Err("ready sandbox must not carry a failure".to_string()); ++ } ++ } ++ RunSandboxKind::Failed => { ++ if self.instance.is_some() { ++ return Err("failed sandbox must not carry an instance".to_string()); ++ } ++ if self.failure.is_none() { ++ return Err("failed sandbox requires failure details".to_string()); ++ } ++ } ++ } ++ Ok(()) ++ } ++} ++ ++#[derive(Serialize, Deserialize)] ++struct RunSandboxWire { ++ kind: RunSandboxKind, ++ plan: RunSandboxPlan, ++ #[serde(default, skip_serializing_if = "Option::is_none")] ++ instance: Option, ++ #[serde(default, skip_serializing_if = "Option::is_none")] ++ failure: Option, ++} ++ ++#[derive(Serialize)] ++struct RunSandboxWireRef<'a> { ++ kind: RunSandboxKind, ++ plan: &'a RunSandboxPlan, ++ #[serde(default, skip_serializing_if = "Option::is_none")] ++ instance: Option<&'a RunSandboxInstance>, ++ #[serde(default, skip_serializing_if = "Option::is_none")] ++ failure: Option<&'a RunSandboxFailure>, ++} ++ ++impl Serialize for RunSandbox { ++ fn serialize(&self, serializer: S) -> Result ++ where ++ S: Serializer, ++ { ++ self.validate().map_err(S::Error::custom)?; ++ RunSandboxWireRef { ++ kind: self.kind, ++ plan: &self.plan, ++ instance: self.instance.as_ref(), ++ failure: self.failure.as_ref(), ++ } ++ .serialize(serializer) ++ } ++} ++ ++impl<'de> Deserialize<'de> for RunSandbox { ++ fn deserialize(deserializer: D) -> Result ++ where ++ D: Deserializer<'de>, ++ { ++ let wire = RunSandboxWire::deserialize(deserializer)?; ++ let sandbox = Self { ++ kind: wire.kind, ++ plan: wire.plan, ++ instance: wire.instance, ++ failure: wire.failure, ++ }; ++ sandbox.validate().map_err(D::Error::custom)?; ++ Ok(sandbox) ++ } + } + + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +diff --git a/lib/crates/fabro-types/src/sandbox_details.rs b/lib/crates/fabro-types/src/sandbox_details.rs +index 8723b00eb..118b598e9 100644 +--- a/lib/crates/fabro-types/src/sandbox_details.rs ++++ b/lib/crates/fabro-types/src/sandbox_details.rs +@@ -4,11 +4,11 @@ use chrono::{DateTime, Utc}; + use serde::de::Error as _; + use serde::{Deserialize, Serialize}; + +-use crate::RunSandbox; ++use crate::RunSandboxInstance; + + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] + pub struct SandboxDetails { +- pub sandbox: RunSandbox, ++ pub sandbox: RunSandboxInstance, + pub state: SandboxState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_state: Option, +@@ -187,11 +187,11 @@ mod tests { + #[test] + fn serializes_with_snake_case_state() { + let details = SandboxDetails { +- sandbox: RunSandbox { ++ sandbox: RunSandboxInstance { + provider: crate::SandboxProviderKind::Docker, + image: Some("ghcr.io/fabro/sandbox:latest".to_string()), + snapshot: None, +- runtime: Some(crate::RunSandboxRuntime { ++ runtime: crate::RunSandboxRuntime { + id: "container-abc123".to_string(), + working_directory: "/workspace".to_string(), + repo_cloned: None, +@@ -201,7 +201,7 @@ mod tests { + repos_root: None, + primary_repo_path: None, + primary_repo_link: None, +- }), ++ }, + }, + state: SandboxState::Running, + native_state: Some("running".to_string()), +@@ -232,10 +232,10 @@ mod tests { + "sandbox": { + "provider": "docker", + "image": "ghcr.io/fabro/sandbox:latest", +- "runtime": { +- "id": "container-abc123", +- "working_directory": "/workspace" +- } ++ "runtime": { ++ "id": "container-abc123", ++ "working_directory": "/workspace" ++ } + }, + "state": "running", + "native_state": "running", +@@ -271,10 +271,10 @@ mod tests { + "provider": "local", + "image": null, + "snapshot": null, +- "runtime": { +- "id": "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z", +- "working_directory": "/Users/client/project" +- } ++ "runtime": { ++ "id": "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z", ++ "working_directory": "/Users/client/project" ++ } + }, + "state": "unknown", + "resources": {}, +@@ -284,20 +284,12 @@ mod tests { + + assert_eq!(details.sandbox.provider, crate::SandboxProviderKind::Local); + assert_eq!( +- details +- .sandbox +- .runtime +- .as_ref() +- .map(|runtime| runtime.id.as_str()), +- Some("local:01JNQVR7M0EJ5GKAT2SC4ERS1Z") ++ details.sandbox.runtime.id.as_str(), ++ "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z" + ); + assert_eq!( +- details +- .sandbox +- .runtime +- .as_ref() +- .map(|runtime| runtime.working_directory.as_str()), +- Some("/Users/client/project") ++ details.sandbox.runtime.working_directory.as_str(), ++ "/Users/client/project" + ); + assert_eq!(details.state, SandboxState::Unknown); + assert!(details.sandbox.image.is_none()); +diff --git a/lib/crates/fabro-types/tests/sandbox_model_serde.rs b/lib/crates/fabro-types/tests/sandbox_model_serde.rs +index 4db0f34b2..d6dcc5fee 100644 +--- a/lib/crates/fabro-types/tests/sandbox_model_serde.rs ++++ b/lib/crates/fabro-types/tests/sandbox_model_serde.rs +@@ -2,60 +2,83 @@ use std::collections::BTreeMap; + + use chrono::{TimeZone, Utc}; + use fabro_types::{ +- RunSandbox, RunSandboxRuntime, SandboxDetails, SandboxNetwork, SandboxProviderKind, +- SandboxResources, SandboxState, SandboxTimestamps, ++ RunSandbox, RunSandboxInstance, RunSandboxPlan, RunSandboxRuntime, SandboxDetails, ++ SandboxNetwork, SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps, + }; + use serde_json::json; + + #[test] + fn run_sandbox_serializes_canonical_identity_without_identifier() { +- let sandbox = RunSandbox { +- provider: SandboxProviderKind::Docker, +- image: None, +- snapshot: None, +- runtime: Some(RunSandboxRuntime { +- id: "container-abc123".to_string(), +- working_directory: "/workspace".to_string(), +- repo_cloned: Some(true), +- clone_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()), +- clone_branch: Some("main".to_string()), +- workspace_root: Some("/workspace".to_string()), +- repos_root: Some("/repos".to_string()), +- primary_repo_path: Some("/repos/fabro-sh/fabro".to_string()), +- primary_repo_link: Some("/workspace/fabro".to_string()), +- }), +- }; ++ let sandbox = RunSandbox::ready( ++ RunSandboxPlan { ++ provider: SandboxProviderKind::Docker, ++ image: None, ++ snapshot: None, ++ }, ++ RunSandboxInstance { ++ provider: SandboxProviderKind::Docker, ++ image: None, ++ snapshot: None, ++ runtime: RunSandboxRuntime { ++ id: "container-abc123".to_string(), ++ working_directory: "/workspace".to_string(), ++ repo_cloned: Some(true), ++ clone_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()), ++ clone_branch: Some("main".to_string()), ++ workspace_root: Some("/workspace".to_string()), ++ repos_root: Some("/repos".to_string()), ++ primary_repo_path: Some("/repos/fabro-sh/fabro".to_string()), ++ primary_repo_link: Some("/workspace/fabro".to_string()), ++ }, ++ }, ++ ); + + let value = serde_json::to_value(&sandbox).unwrap(); + + assert_eq!( + value, + json!({ +- "provider": "docker", +- "runtime": { +- "id": "container-abc123", +- "working_directory": "/workspace", +- "repo_cloned": true, +- "clone_origin_url": "https://github.com/fabro-sh/fabro.git", +- "clone_branch": "main", +- "workspace_root": "/workspace", +- "repos_root": "/repos", +- "primary_repo_path": "/repos/fabro-sh/fabro", +- "primary_repo_link": "/workspace/fabro" ++ "kind": "ready", ++ "plan": { ++ "provider": "docker" ++ }, ++ "instance": { ++ "provider": "docker", ++ "runtime": { ++ "id": "container-abc123", ++ "working_directory": "/workspace", ++ "repo_cloned": true, ++ "clone_origin_url": "https://github.com/fabro-sh/fabro.git", ++ "clone_branch": "main", ++ "workspace_root": "/workspace", ++ "repos_root": "/repos", ++ "primary_repo_path": "/repos/fabro-sh/fabro", ++ "primary_repo_link": "/workspace/fabro" ++ } + } + }) + ); + assert!(value.get("identifier").is_none()); + } + ++#[test] ++fn run_sandbox_ready_requires_instance() { ++ let sandbox = json!({ ++ "kind": "ready", ++ "plan": { "provider": "docker" } ++ }); ++ ++ assert!(serde_json::from_value::(sandbox).is_err()); ++} ++ + #[test] + fn sandbox_details_requires_canonical_id_and_working_directory() { + let details = SandboxDetails { +- sandbox: RunSandbox { ++ sandbox: RunSandboxInstance { + provider: SandboxProviderKind::Daytona, + image: Some("ubuntu:24.04".to_string()), + snapshot: None, +- runtime: Some(RunSandboxRuntime { ++ runtime: RunSandboxRuntime { + id: "daytona-sandbox-name".to_string(), + working_directory: "/workspace".to_string(), + repo_cloned: None, +@@ -65,7 +88,7 @@ fn sandbox_details_requires_canonical_id_and_working_directory() { + repos_root: Some("/home/daytona/repos".to_string()), + primary_repo_path: None, + primary_repo_link: None, +- }), ++ }, + }, + state: SandboxState::Running, + native_state: Some("started".to_string()), +diff --git a/lib/crates/fabro-workflow/Cargo.toml b/lib/crates/fabro-workflow/Cargo.toml +index 6021bca4c..3e735f9c7 100644 +--- a/lib/crates/fabro-workflow/Cargo.toml ++++ b/lib/crates/fabro-workflow/Cargo.toml +@@ -25,7 +25,6 @@ fabro-config = { path = "../fabro-config" } + fabro-graphviz = { path = "../fabro-graphviz" } + fabro-hooks = { path = "../fabro-hooks" } + fabro-validate = { path = "../fabro-validate" } +-fabro-devcontainer = { path = "../fabro-devcontainer" } + fabro-dump = { path = "../fabro-dump" } + fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona"] } + fabro-mcp = { path = "../fabro-mcp" } +diff --git a/lib/crates/fabro-workflow/src/devcontainer_bridge.rs b/lib/crates/fabro-workflow/src/devcontainer_bridge.rs +deleted file mode 100644 +index 6fc081782..000000000 +--- a/lib/crates/fabro-workflow/src/devcontainer_bridge.rs ++++ /dev/null +@@ -1,640 +0,0 @@ +-use std::time::Instant; +- +-use fabro_agent::sandbox::Sandbox; +-use fabro_devcontainer::DevcontainerSpec; +-use fabro_sandbox::daytona::{DaytonaSnapshotConfig, DockerfileSource}; +-use futures::future::try_join_all; +-use tokio_util::sync::CancellationToken; +- +-use crate::error::Error; +-use crate::event::{Emitter, Event}; +- +-/// Map a `DevcontainerSpec` to a `DaytonaSnapshotConfig`. +-pub fn devcontainer_to_snapshot_config(dc: &DevcontainerSpec) -> DaytonaSnapshotConfig { +- DaytonaSnapshotConfig { +- dockerfile: Some(DockerfileSource::Inline(dc.dockerfile.clone())), +- cpu: None, +- memory: None, +- disk: None, +- } +-} +- +-/// Run a set of devcontainer lifecycle commands inside a sandbox. +-/// +-/// Follows the same pattern as setup commands in `run.rs`. +-pub async fn run_devcontainer_lifecycle( +- sandbox: &dyn Sandbox, +- emitter: &Emitter, +- phase: &str, +- commands: &[fabro_devcontainer::Command], +- timeout_ms: u64, +- cancel_token: CancellationToken, +-) -> Result<(), Error> { +- if commands.is_empty() { +- return Ok(()); +- } +- +- emitter.emit(&Event::DevcontainerLifecycleStarted { +- phase: phase.to_string(), +- command_count: commands.len(), +- }); +- let phase_start = Instant::now(); +- +- for (index, cmd) in commands.iter().enumerate() { +- match cmd { +- fabro_devcontainer::Command::Shell(s) => { +- run_single_lifecycle_command( +- sandbox, +- emitter, +- phase, +- &format!("sh -c {}", shlex::try_quote(s).unwrap_or_else(|_| s.into())), +- index, +- timeout_ms, +- cancel_token.clone(), +- ) +- .await?; +- } +- fabro_devcontainer::Command::Args(args) => { +- let joined = args +- .iter() +- .map(|a| shlex::try_quote(a).unwrap_or_else(|_| a.into()).to_string()) +- .collect::>() +- .join(" "); +- run_single_lifecycle_command( +- sandbox, +- emitter, +- phase, +- &joined, +- index, +- timeout_ms, +- cancel_token.clone(), +- ) +- .await?; +- } +- fabro_devcontainer::Command::Parallel(map) => { +- let futs: Vec<_> = map +- .iter() +- .map(|(name, cmd_str)| { +- let command = format!( +- "sh -c {}", +- shlex::try_quote(cmd_str).unwrap_or_else(|_| cmd_str.into()) +- ); +- let phase = phase.to_string(); +- let name = name.clone(); +- let cancel_token = cancel_token.clone(); +- async move { +- let cmd_start = Instant::now(); +- emitter.emit(&Event::DevcontainerLifecycleCommandStarted { +- phase: phase.clone(), +- command: name.clone(), +- index, +- }); +- let child_token = cancel_token.child_token(); +- let result = sandbox +- .exec_command( +- &command, +- timeout_ms, +- None, +- None, +- Some(child_token.clone()), +- ) +- .await +- .map_err(|e| { +- Error::engine(format!( +- "Devcontainer {phase} parallel command '{name}' failed: {e}" +- )) +- })?; +- if cancel_token.is_cancelled() { +- return Err(Error::Cancelled); +- } +- child_token.cancel(); +- let cmd_duration = crate::millis_u64(cmd_start.elapsed()); +- if !result.is_success() { +- let exit_code = result.display_exit_code(); +- let exec_output_tail = result.default_redacted_output_tail(); +- emitter.emit( +- &Event::DevcontainerLifecycleFailed { +- phase: phase.clone(), +- command: name.clone(), +- index, +- exit_code, +- stderr: result.stderr.clone(), +- exec_output_tail, +- }, +- ); +- return Err(Error::engine(format!( +- "Devcontainer {phase} parallel command '{name}' failed (exit code {}): {}", +- exit_code, +- result.stderr, +- ))); +- } +- let exit_code = result.exit_code.unwrap_or(0); +- emitter.emit( +- &Event::DevcontainerLifecycleCommandCompleted { +- phase: phase.clone(), +- command: name.clone(), +- index, +- exit_code, +- duration_ms: cmd_duration, +- }, +- ); +- Ok(()) +- } +- }) +- .collect(); +- try_join_all(futs).await?; +- } +- } +- } +- +- let phase_duration = crate::millis_u64(phase_start.elapsed()); +- emitter.emit(&Event::DevcontainerLifecycleCompleted { +- phase: phase.to_string(), +- duration_ms: phase_duration, +- }); +- Ok(()) +-} +- +-async fn run_single_lifecycle_command( +- sandbox: &dyn Sandbox, +- emitter: &Emitter, +- phase: &str, +- command: &str, +- index: usize, +- timeout_ms: u64, +- cancel_token: CancellationToken, +-) -> Result<(), Error> { +- emitter.emit(&Event::DevcontainerLifecycleCommandStarted { +- phase: phase.to_string(), +- command: command.to_string(), +- index, +- }); +- let cmd_start = Instant::now(); +- let child_token = cancel_token.child_token(); +- let result = sandbox +- .exec_command(command, timeout_ms, None, None, Some(child_token.clone())) +- .await +- .map_err(|e| { +- Error::engine_with_source(format!("Devcontainer {phase} command failed"), e) +- })?; +- if cancel_token.is_cancelled() { +- return Err(Error::Cancelled); +- } +- child_token.cancel(); +- let cmd_duration = crate::millis_u64(cmd_start.elapsed()); +- if !result.is_success() { +- let exit_code = result.display_exit_code(); +- let exec_output_tail = result.default_redacted_output_tail(); +- emitter.emit(&Event::DevcontainerLifecycleFailed { +- phase: phase.to_string(), +- command: command.to_string(), +- index, +- exit_code, +- stderr: result.stderr.clone(), +- exec_output_tail, +- }); +- return Err(Error::engine(format!( +- "Devcontainer {phase} command failed (exit code {}): {command}\n{}", +- exit_code, result.stderr, +- ))); +- } +- let exit_code = result.exit_code.unwrap_or(0); +- emitter.emit(&Event::DevcontainerLifecycleCommandCompleted { +- phase: phase.to_string(), +- command: command.to_string(), +- index, +- exit_code, +- duration_ms: cmd_duration, +- }); +- Ok(()) +-} +- +-#[cfg(test)] +-mod tests { +- use std::collections::HashMap; +- use std::sync::{Arc, Mutex}; +- +- use async_trait::async_trait; +- use fabro_agent::sandbox::{ExecResult, GrepOptions, Sandbox}; +- use fabro_types::{CommandTermination, EventBody}; +- use tokio_util::sync::CancellationToken; +- +- use super::*; +- +- /// Simple test sandbox that records commands and returns a fixed exit code. +- struct TestSandbox { +- commands: Mutex>, +- cancel_tokens: Mutex>, +- exit_code: i32, +- wait_for_cancel: bool, +- } +- +- impl TestSandbox { +- fn new() -> Self { +- Self { +- commands: Mutex::new(Vec::new()), +- cancel_tokens: Mutex::new(Vec::new()), +- exit_code: 0, +- wait_for_cancel: false, +- } +- } +- +- fn with_exit_code(exit_code: i32) -> Self { +- Self { +- commands: Mutex::new(Vec::new()), +- cancel_tokens: Mutex::new(Vec::new()), +- exit_code, +- wait_for_cancel: false, +- } +- } +- +- fn waiting_for_cancel() -> Self { +- Self { +- commands: Mutex::new(Vec::new()), +- cancel_tokens: Mutex::new(Vec::new()), +- exit_code: 0, +- wait_for_cancel: true, +- } +- } +- +- fn captured_commands(&self) -> Vec { +- self.commands.lock().unwrap().clone() +- } +- +- fn captured_cancel_tokens(&self) -> Vec { +- self.cancel_tokens.lock().unwrap().clone() +- } +- } +- +- #[async_trait] +- impl Sandbox for TestSandbox { +- async fn read_file_bytes(&self, _path: &str) -> fabro_sandbox::Result> { +- Ok(Vec::new()) +- } +- async fn write_file(&self, _path: &str, _content: &str) -> fabro_sandbox::Result<()> { +- Ok(()) +- } +- async fn delete_file(&self, _path: &str) -> fabro_sandbox::Result<()> { +- Ok(()) +- } +- async fn file_exists(&self, _path: &str) -> fabro_sandbox::Result { +- Ok(false) +- } +- async fn list_directory( +- &self, +- _path: &str, +- _depth: Option, +- ) -> fabro_sandbox::Result> { +- Ok(vec![]) +- } +- async fn exec_command( +- &self, +- command: &str, +- _timeout_ms: u64, +- _working_dir: Option<&str>, +- _env_vars: Option<&std::collections::HashMap>, +- cancel_token: Option, +- ) -> fabro_sandbox::Result { +- self.commands.lock().unwrap().push(command.to_string()); +- self.cancel_tokens +- .lock() +- .unwrap() +- .push(cancel_token.is_some()); +- if self.wait_for_cancel { +- let token = cancel_token +- .ok_or_else(|| fabro_sandbox::Error::message("missing cancel token"))?; +- token.cancelled().await; +- return Ok(ExecResult { +- stdout: String::new(), +- stderr: "cancelled".to_string(), +- exit_code: None, +- termination: CommandTermination::Cancelled, +- duration_ms: 10, +- }); +- } +- Ok(ExecResult { +- stdout: String::new(), +- stderr: if self.exit_code != 0 { +- "command failed".to_string() +- } else { +- String::new() +- }, +- exit_code: Some(self.exit_code), +- termination: CommandTermination::Exited, +- duration_ms: 10, +- }) +- } +- async fn grep( +- &self, +- _pattern: &str, +- _path: &str, +- _options: &GrepOptions, +- ) -> fabro_sandbox::Result> { +- Ok(vec![]) +- } +- async fn glob( +- &self, +- _pattern: &str, +- _path: Option<&str>, +- ) -> fabro_sandbox::Result> { +- Ok(vec![]) +- } +- async fn download_file_to_local( +- &self, +- _remote_path: &str, +- _local_path: &std::path::Path, +- ) -> fabro_sandbox::Result<()> { +- Ok(()) +- } +- async fn upload_file_from_local( +- &self, +- _local_path: &std::path::Path, +- _remote_path: &str, +- ) -> fabro_sandbox::Result<()> { +- Ok(()) +- } +- async fn initialize(&self) -> fabro_sandbox::Result<()> { +- Ok(()) +- } +- async fn cleanup(&self) -> fabro_sandbox::Result<()> { +- Ok(()) +- } +- fn working_directory(&self) -> &str { +- "/work" +- } +- fn platform(&self) -> &str { +- "linux" +- } +- fn os_version(&self) -> String { +- "Linux 6.1.0".into() +- } +- } +- +- #[test] +- fn maps_dockerfile_to_inline() { +- let dc = test_devcontainer_config("FROM rust:1.85\nRUN cargo install sccache"); +- let snapshot = devcontainer_to_snapshot_config(&dc); +- assert_eq!( +- snapshot.dockerfile, +- Some(DockerfileSource::Inline(dc.dockerfile.clone())) +- ); +- } +- +- #[test] +- fn devcontainer_snapshot_uses_runtime_daytona_identity_path() { +- let dc = test_devcontainer_config("FROM ubuntu:22.04"); +- let snapshot = devcontainer_to_snapshot_config(&dc); +- assert_eq!(snapshot.cpu, None); +- assert_eq!(snapshot.memory, None); +- assert_eq!(snapshot.disk, None); +- } +- +- #[tokio::test] +- async fn shell_command_executed() { +- let sandbox = TestSandbox::new(); +- let emitter = Emitter::default(); +- let commands = vec![fabro_devcontainer::Command::Shell("echo hi".to_string())]; +- run_devcontainer_lifecycle( +- &sandbox, +- &emitter, +- "on_create", +- &commands, +- 300_000, +- CancellationToken::new(), +- ) +- .await +- .unwrap(); +- let captured = sandbox.captured_commands(); +- assert_eq!(captured.len(), 1); +- assert!(captured[0].contains("echo hi"), "command: {}", captured[0]); +- } +- +- #[tokio::test] +- async fn args_command_joins() { +- let sandbox = TestSandbox::new(); +- let emitter = Emitter::default(); +- let commands = vec![fabro_devcontainer::Command::Args(vec![ +- "echo".to_string(), +- "hi".to_string(), +- ])]; +- run_devcontainer_lifecycle( +- &sandbox, +- &emitter, +- "on_create", +- &commands, +- 300_000, +- CancellationToken::new(), +- ) +- .await +- .unwrap(); +- let captured = sandbox.captured_commands(); +- assert_eq!(captured.len(), 1); +- assert!( +- captured[0].contains("echo") && captured[0].contains("hi"), +- "command: {}", +- captured[0] +- ); +- } +- +- #[tokio::test] +- async fn emits_started_and_completed_events() { +- let emitter = Emitter::default(); +- let events = Arc::new(Mutex::new(Vec::::new())); +- let events_clone = Arc::clone(&events); +- emitter.on_event(move |event| { +- events_clone.lock().unwrap().push(event.clone()); +- }); +- let sandbox = TestSandbox::new(); +- let commands = vec![fabro_devcontainer::Command::Shell("echo hi".to_string())]; +- run_devcontainer_lifecycle( +- &sandbox, +- &emitter, +- "on_create", +- &commands, +- 300_000, +- CancellationToken::new(), +- ) +- .await +- .unwrap(); +- let events = events.lock().unwrap(); +- let started = events[0].properties().unwrap(); +- assert_eq!(events[0].event_name(), "devcontainer.lifecycle.started"); +- assert_eq!(started["phase"], "on_create"); +- assert_eq!(started["command_count"], 1); +- +- assert_eq!( +- events[1].event_name(), +- "devcontainer.lifecycle.command.started" +- ); +- let command_started = events[1].properties().unwrap(); +- assert_eq!(command_started["phase"], "on_create"); +- assert_eq!(command_started["index"], 0); +- +- assert_eq!( +- events[2].event_name(), +- "devcontainer.lifecycle.command.completed" +- ); +- let command_completed = events[2].properties().unwrap(); +- assert_eq!(command_completed["phase"], "on_create"); +- assert_eq!(command_completed["index"], 0); +- assert_eq!(command_completed["exit_code"], 0); +- +- assert_eq!(events[3].event_name(), "devcontainer.lifecycle.completed"); +- assert_eq!(events[3].properties().unwrap()["phase"], "on_create"); +- } +- +- #[tokio::test] +- async fn failed_command_emits_failed_and_returns_error() { +- let emitter = Emitter::default(); +- let events = Arc::new(Mutex::new(Vec::::new())); +- let events_clone = Arc::clone(&events); +- emitter.on_event(move |event| { +- events_clone.lock().unwrap().push(event.clone()); +- }); +- let sandbox = TestSandbox::with_exit_code(1); +- let commands = vec![fabro_devcontainer::Command::Shell("false".to_string())]; +- let result = run_devcontainer_lifecycle( +- &sandbox, +- &emitter, +- "on_create", +- &commands, +- 300_000, +- CancellationToken::new(), +- ) +- .await; +- assert!(result.is_err()); +- let events = events.lock().unwrap(); +- let failed = events +- .iter() +- .find(|event| event.event_name() == "devcontainer.lifecycle.failed") +- .expect("devcontainer lifecycle failed event"); +- match &failed.body { +- EventBody::DevcontainerLifecycleFailed(props) => { +- assert_eq!(props.phase, "on_create"); +- assert_eq!(props.exit_code, 1); +- assert_eq!(props.stderr, "command failed"); +- assert_eq!( +- props +- .exec_output_tail +- .as_ref() +- .and_then(|tail| tail.stderr.as_deref()), +- Some("command failed") +- ); +- } +- other => panic!("expected devcontainer lifecycle failed body, got {other:?}"), +- } +- } +- +- #[tokio::test] +- async fn empty_commands_is_noop() { +- let emitter = Emitter::default(); +- let events = Arc::new(Mutex::new(Vec::new())); +- let events_clone = Arc::clone(&events); +- emitter.on_event(move |event| { +- events_clone.lock().unwrap().push(event.clone()); +- }); +- let sandbox = TestSandbox::new(); +- run_devcontainer_lifecycle( +- &sandbox, +- &emitter, +- "on_create", +- &[], +- 300_000, +- CancellationToken::new(), +- ) +- .await +- .unwrap(); +- assert!(events.lock().unwrap().is_empty()); +- } +- +- #[tokio::test] +- async fn parallel_commands_run() { +- let sandbox = TestSandbox::new(); +- let emitter = Emitter::default(); +- let mut map = HashMap::new(); +- map.insert("install".to_string(), "npm install".to_string()); +- map.insert("build".to_string(), "npm run build".to_string()); +- let commands = vec![fabro_devcontainer::Command::Parallel(map)]; +- run_devcontainer_lifecycle( +- &sandbox, +- &emitter, +- "post_create", +- &commands, +- 300_000, +- CancellationToken::new(), +- ) +- .await +- .unwrap(); +- let captured = sandbox.captured_commands(); +- assert_eq!(captured.len(), 2); +- } +- +- #[tokio::test] +- async fn cancelled_shell_command_returns_cancelled() { +- let sandbox = TestSandbox::waiting_for_cancel(); +- let emitter = Emitter::default(); +- let commands = vec![fabro_devcontainer::Command::Shell("sleep 5".to_string())]; +- let cancel_token = CancellationToken::new(); +- cancel_token.cancel(); +- +- let result = run_devcontainer_lifecycle( +- &sandbox, +- &emitter, +- "on_create", +- &commands, +- 300_000, +- cancel_token, +- ) +- .await; +- +- assert!(matches!(result, Err(Error::Cancelled))); +- assert_eq!(sandbox.captured_cancel_tokens(), vec![true]); +- } +- +- #[tokio::test] +- async fn cancelled_parallel_command_returns_cancelled() { +- let sandbox = TestSandbox::waiting_for_cancel(); +- let emitter = Emitter::default(); +- let mut map = HashMap::new(); +- map.insert("install".to_string(), "sleep 5".to_string()); +- map.insert("build".to_string(), "sleep 5".to_string()); +- let commands = vec![fabro_devcontainer::Command::Parallel(map)]; +- let cancel_token = CancellationToken::new(); +- cancel_token.cancel(); +- +- let result = run_devcontainer_lifecycle( +- &sandbox, +- &emitter, +- "post_create", +- &commands, +- 300_000, +- cancel_token, +- ) +- .await; +- +- assert!(matches!(result, Err(Error::Cancelled))); +- let captured = sandbox.captured_cancel_tokens(); +- assert!(!captured.is_empty()); +- assert!(captured.iter().all(|saw_token| *saw_token)); +- } +- +- fn test_devcontainer_config(dockerfile: &str) -> DevcontainerSpec { +- DevcontainerSpec { +- dockerfile: dockerfile.to_string(), +- build_context: std::path::PathBuf::from("."), +- build_args: HashMap::new(), +- build_target: None, +- initialize_commands: vec![], +- on_create_commands: vec![], +- post_create_commands: vec![], +- post_start_commands: vec![], +- environment: HashMap::new(), +- container_env: HashMap::new(), +- remote_user: None, +- workspace_folder: "/workspaces/test".to_string(), +- forwarded_ports: vec![], +- compose_files: vec![], +- compose_service: None, +- } +- } +-} +diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs +index fb2f9f054..4c1f91228 100644 +--- a/lib/crates/fabro-workflow/src/event/convert.rs ++++ b/lib/crates/fabro-workflow/src/event/convert.rs +@@ -1306,77 +1306,6 @@ fn event_body_from_event(event: &Event) -> EventBody { + error: error.clone(), + }) + } +- Event::DevcontainerResolved { +- dockerfile_lines, +- environment_count, +- lifecycle_command_count, +- workspace_folder, +- } => EventBody::DevcontainerResolved(fabro_types::DevcontainerResolvedProps { +- dockerfile_lines: *dockerfile_lines, +- environment_count: *environment_count, +- lifecycle_command_count: *lifecycle_command_count, +- workspace_folder: workspace_folder.clone(), +- }), +- Event::DevcontainerLifecycleStarted { +- phase, +- command_count, +- } => EventBody::DevcontainerLifecycleStarted( +- fabro_types::DevcontainerLifecycleStartedProps { +- phase: phase.clone(), +- command_count: *command_count, +- }, +- ), +- Event::DevcontainerLifecycleCommandStarted { +- phase, +- command, +- index, +- } => EventBody::DevcontainerLifecycleCommandStarted( +- fabro_types::DevcontainerLifecycleCommandStartedProps { +- phase: phase.clone(), +- command: command.clone(), +- index: *index, +- }, +- ), +- Event::DevcontainerLifecycleCommandCompleted { +- phase, +- command, +- index, +- exit_code, +- duration_ms, +- } => EventBody::DevcontainerLifecycleCommandCompleted( +- fabro_types::DevcontainerLifecycleCommandCompletedProps { +- phase: phase.clone(), +- command: command.clone(), +- index: *index, +- exit_code: *exit_code, +- duration_ms: *duration_ms, +- }, +- ), +- Event::DevcontainerLifecycleCompleted { phase, duration_ms } => { +- EventBody::DevcontainerLifecycleCompleted( +- fabro_types::DevcontainerLifecycleCompletedProps { +- phase: phase.clone(), +- duration_ms: *duration_ms, +- }, +- ) +- } +- Event::DevcontainerLifecycleFailed { +- phase, +- command, +- index, +- exit_code, +- stderr, +- exec_output_tail, +- } => { +- EventBody::DevcontainerLifecycleFailed(fabro_types::DevcontainerLifecycleFailedProps { +- phase: phase.clone(), +- command: command.clone(), +- index: *index, +- exit_code: *exit_code, +- stderr: stderr.clone(), +- exec_output_tail: exec_output_tail.clone(), +- }) +- } + } + } + +diff --git a/lib/crates/fabro-workflow/src/event/events.rs b/lib/crates/fabro-workflow/src/event/events.rs +index 0627829c0..886868804 100644 +--- a/lib/crates/fabro-workflow/src/event/events.rs ++++ b/lib/crates/fabro-workflow/src/event/events.rs +@@ -737,41 +737,6 @@ pub enum Event { + PullRequestFailed { + error: String, + }, +- DevcontainerResolved { +- dockerfile_lines: usize, +- environment_count: usize, +- lifecycle_command_count: usize, +- workspace_folder: String, +- }, +- DevcontainerLifecycleStarted { +- phase: String, +- command_count: usize, +- }, +- DevcontainerLifecycleCommandStarted { +- phase: String, +- command: String, +- index: usize, +- }, +- DevcontainerLifecycleCommandCompleted { +- phase: String, +- command: String, +- index: usize, +- exit_code: i32, +- duration_ms: u64, +- }, +- DevcontainerLifecycleCompleted { +- phase: String, +- duration_ms: u64, +- }, +- DevcontainerLifecycleFailed { +- phase: String, +- command: String, +- index: usize, +- exit_code: i32, +- stderr: String, +- #[serde(default, skip_serializing_if = "Option::is_none")] +- exec_output_tail: Option, +- }, + } + + impl Event { +@@ -1595,77 +1560,6 @@ impl Event { + Self::PullRequestFailed { error, .. } => { + error!(error = %error, "Pull request creation failed"); + } +- Self::DevcontainerResolved { +- dockerfile_lines, +- environment_count, +- lifecycle_command_count, +- workspace_folder, +- } => { +- info!( +- dockerfile_lines, +- environment_count, +- lifecycle_command_count, +- workspace_folder, +- "Devcontainer resolved" +- ); +- } +- Self::DevcontainerLifecycleStarted { +- phase, +- command_count, +- } => { +- info!(phase, command_count, "Devcontainer lifecycle started"); +- } +- Self::DevcontainerLifecycleCommandStarted { +- phase, +- command, +- index, +- } => { +- debug!( +- phase, +- command, index, "Devcontainer lifecycle command started" +- ); +- } +- Self::DevcontainerLifecycleCommandCompleted { +- phase, +- command, +- index, +- exit_code, +- duration_ms, +- } => { +- debug!( +- phase, +- command, +- index, +- exit_code, +- duration_ms, +- "Devcontainer lifecycle command completed" +- ); +- } +- Self::DevcontainerLifecycleCompleted { phase, duration_ms } => { +- info!(phase, duration_ms, "Devcontainer lifecycle completed"); +- } +- Self::DevcontainerLifecycleFailed { +- phase, +- command, +- index, +- exit_code, +- exec_output_tail, +- .. +- } => { +- let tail = fabro_types::ExecOutputTail::trace_summary(exec_output_tail.as_ref()); +- error!( +- phase, +- command, +- index, +- exit_code, +- exec_output_tail_present = tail.present, +- exec_stdout_tail_bytes = tail.stdout_bytes, +- exec_stderr_tail_bytes = tail.stderr_bytes, +- exec_stdout_truncated = tail.stdout_truncated, +- exec_stderr_truncated = tail.stderr_truncated, +- "Devcontainer lifecycle command failed" +- ); +- } + } + } + } +diff --git a/lib/crates/fabro-workflow/src/event/names.rs b/lib/crates/fabro-workflow/src/event/names.rs +index bbb8b55e0..acfee89e6 100644 +--- a/lib/crates/fabro-workflow/src/event/names.rs ++++ b/lib/crates/fabro-workflow/src/event/names.rs +@@ -155,16 +155,6 @@ pub fn event_name(event: &Event) -> &'static str { + Event::PullRequestLinked { .. } => "pull_request.linked", + Event::PullRequestUnlinked { .. } => "pull_request.unlinked", + Event::PullRequestFailed { .. } => "pull_request.failed", +- Event::DevcontainerResolved { .. } => "devcontainer.resolved", +- Event::DevcontainerLifecycleStarted { .. } => "devcontainer.lifecycle.started", +- Event::DevcontainerLifecycleCommandStarted { .. } => { +- "devcontainer.lifecycle.command.started" +- } +- Event::DevcontainerLifecycleCommandCompleted { .. } => { +- "devcontainer.lifecycle.command.completed" +- } +- Event::DevcontainerLifecycleCompleted { .. } => "devcontainer.lifecycle.completed", +- Event::DevcontainerLifecycleFailed { .. } => "devcontainer.lifecycle.failed", + } + } + +diff --git a/lib/crates/fabro-workflow/src/lib.rs b/lib/crates/fabro-workflow/src/lib.rs +index 42f5c7f43..d5efaaebb 100644 +--- a/lib/crates/fabro-workflow/src/lib.rs ++++ b/lib/crates/fabro-workflow/src/lib.rs +@@ -289,7 +289,6 @@ pub mod billing_rollup; + pub mod command_log; + pub(crate) mod condition; + pub mod context; +-pub mod devcontainer_bridge; + pub mod error; + pub mod event; + pub mod file_resolver; +diff --git a/lib/crates/fabro-workflow/src/operations/mod.rs b/lib/crates/fabro-workflow/src/operations/mod.rs +index 7bf6c5311..4710d97a9 100644 +--- a/lib/crates/fabro-workflow/src/operations/mod.rs ++++ b/lib/crates/fabro-workflow/src/operations/mod.rs +@@ -24,5 +24,5 @@ pub use start::{StartServices, Started, start}; + pub use timeline::{ForkTarget, RunTimeline, TimelineEntry, build_timeline, timeline}; + pub use validate::{ValidateInput, validate}; + +-pub use crate::pipeline::{DevcontainerSpec, LlmSpec, SandboxEnvSpec}; ++pub use crate::pipeline::{LlmSpec, SandboxEnvSpec}; + pub use crate::transforms::RenderMode; +diff --git a/lib/crates/fabro-workflow/src/operations/retry.rs b/lib/crates/fabro-workflow/src/operations/retry.rs +index 66c37c022..7a80033df 100644 +--- a/lib/crates/fabro-workflow/src/operations/retry.rs ++++ b/lib/crates/fabro-workflow/src/operations/retry.rs +@@ -406,7 +406,7 @@ mod tests { + retry_state + .sandbox + .as_ref() +- .and_then(|sandbox| sandbox.runtime.as_ref()) ++ .and_then(fabro_types::RunSandbox::instance) + .is_none() + ); + +diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs +index a885871da..9d3f08d1c 100644 +--- a/lib/crates/fabro-workflow/src/operations/start.rs ++++ b/lib/crates/fabro-workflow/src/operations/start.rs +@@ -38,8 +38,8 @@ use crate::handler::HandlerRegistry; + use crate::handler::llm::routing; + use crate::outcome::{Outcome, StageOutcome}; + use crate::pipeline::{ +- self, DevcontainerSpec, FinalizeOptions, Finalized, InitOptions, LlmSpec, Persisted, +- PullRequestOptions, SandboxEnvSpec, build_conclusion_from_store, classify_engine_result, ++ self, FinalizeOptions, Finalized, InitOptions, LlmSpec, Persisted, PullRequestOptions, ++ SandboxEnvSpec, build_conclusion_from_store, classify_engine_result, + }; + use crate::records::Checkpoint; + use crate::run_control::RunControlState; +@@ -62,7 +62,6 @@ struct RunSession { + lifecycle: LifecycleOptions, + hooks: fabro_hooks::HookSettings, + sandbox_env: SandboxEnvSpec, +- devcontainer: Option, + seed_context: Option, + run_store: RunStoreHandle, + event_sink: RunEventSink, +@@ -421,14 +420,11 @@ impl RunSession { + let github_permissions: Option> = + (!services.github_permissions.is_empty()).then(|| services.github_permissions.clone()); + let sandbox_env = SandboxEnvSpec { +- devcontainer_env: HashMap::new(), + toml_env, + github_permissions, + origin_url: record.repo_origin_url().map(str::to_string), + }; + +- let devcontainer = None; +- + let interviewer: Arc = if resolved.execution.approval == ApprovalMode::Auto + { + Arc::new(AutoApproveInterviewer::engine()) +@@ -458,13 +454,11 @@ impl RunSession { + lifecycle: LifecycleOptions { + setup_commands: resolved.prepare.commands.clone(), + setup_command_timeout_ms: resolved.prepare.timeout_ms, +- devcontainer_phases: Vec::new(), + }, + hooks: fabro_hooks::HookSettings { + hooks: resolved.hooks.iter().map(runtime_hook_definition).collect(), + }, + sandbox_env, +- devcontainer, + seed_context: None, + run_store: services.run_store, + artifact_sink: services.artifact_sink, +@@ -855,7 +849,6 @@ impl RunSession { + hooks: self.hooks, + sandbox_env: self.sandbox_env, + vault: self.vault, +- devcontainer: self.devcontainer, + git: self.git, + registry_override: self.registry_override, + artifact_sink: self.artifact_sink, +diff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +index 985b5e247..d05398556 100644 +--- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs ++++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +@@ -177,7 +177,6 @@ fn test_lifecycle(setup_commands: Vec) -> LifecycleOptions { + LifecycleOptions { + setup_commands, + setup_command_timeout_ms: 300_000, +- devcontainer_phases: Vec::new(), + } + } + +@@ -270,20 +269,17 @@ async fn execute_test_run_with_options( + lifecycle: LifecycleOptions { + setup_commands: vec![], + setup_command_timeout_ms: 1_000, +- devcontainer_phases: vec![], + }, + run_options, + workflow_path: None, + workflow_bundle: None, + hooks: HookSettings { hooks: vec![] }, + sandbox_env: SandboxEnvSpec { +- devcontainer_env: HashMap::new(), + toml_env: HashMap::new(), + github_permissions: None, + origin_url: None, + }, + vault: None, +- devcontainer: None, + git: git_options, + run_control: None, + registry_override, +@@ -336,20 +332,17 @@ async fn execute_runs_start_to_exit_and_returns_final_context() { + lifecycle: LifecycleOptions { + setup_commands: vec![], + setup_command_timeout_ms: 1_000, +- devcontainer_phases: vec![], + }, + run_options, + workflow_path: None, + workflow_bundle: None, + hooks: HookSettings { hooks: vec![] }, + sandbox_env: SandboxEnvSpec { +- devcontainer_env: HashMap::new(), + toml_env: HashMap::new(), + github_permissions: None, + origin_url: None, + }, + vault: None, +- devcontainer: None, + git: None, + run_control: None, + registry_override: None, +@@ -416,13 +409,11 @@ async fn run_with_lifecycle( + workflow_bundle: None, + hooks: HookSettings { hooks: vec![] }, + sandbox_env: SandboxEnvSpec { +- devcontainer_env: HashMap::new(), + toml_env: HashMap::new(), + github_permissions: None, + origin_url: None, + }, + vault: None, +- devcontainer: None, + git: None, + run_control: None, + registry_override: Some(Arc::new(registry)), +diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs +index 1b97a5d15..cc1b0777a 100644 +--- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs ++++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs +@@ -16,15 +16,10 @@ use fabro_sandbox::{ + }; + use fabro_static::EnvVars; + use fabro_vault::Vault; +-use futures::future::try_join_all; +-use shlex::try_quote; +-use tokio::process::Command as TokioCommand; + use tokio::runtime::Handle; + use tokio::sync::RwLock as AsyncRwLock; +-use tokio::time::timeout as tokio_timeout; + + use super::types::{InitOptions, Initialized, LlmSpec, Persisted, SandboxEnvSpec}; +-use crate::devcontainer_bridge::{devcontainer_to_snapshot_config, run_devcontainer_lifecycle}; + use crate::error::Error; + use crate::event::{Event, RunNoticeCode, RunNoticeLevel}; + use crate::git::GitAuthor; +@@ -90,8 +85,7 @@ fn build_sandbox_env( + spec: &SandboxEnvSpec, + github_app: Option<&fabro_github::GitHubCredentials>, + ) -> Result { +- let mut env = spec.devcontainer_env.clone(); +- env.extend(spec.toml_env.clone()); ++ let env = spec.toml_env.clone(); + + let Some(permissions) = spec.github_permissions.as_ref().filter(|p| !p.is_empty()) else { + return Ok((env, None)); +@@ -273,104 +267,6 @@ fn build_llm_source(vault: Option>>) -> Arc Result<(), Error> { +- let Some(devcontainer) = options.devcontainer.clone() else { +- return Ok(()); +- }; +- if !devcontainer.enabled { +- return Ok(()); +- } +- +- let config = fabro_devcontainer::DevcontainerResolver::resolve(&devcontainer.resolve_dir) +- .await +- .map_err(|e| Error::engine_with_source("Failed to resolve devcontainer", e))?; +- +- let lifecycle_command_count = config.on_create_commands.len() +- + config.post_create_commands.len() +- + config.post_start_commands.len(); +- options.emitter.emit(&Event::DevcontainerResolved { +- dockerfile_lines: config.dockerfile.lines().count(), +- environment_count: config.environment.len(), +- lifecycle_command_count, +- workspace_folder: config.workspace_folder.clone(), +- }); +- +- options +- .sandbox +- .apply_devcontainer_snapshot(devcontainer_to_snapshot_config(&config)); +- +- let timeout = std::time::Duration::from_mins(5); +- let run_shell = |shell_command: String| { +- let cwd = devcontainer.resolve_dir.clone(); +- async move { +- let output = tokio_timeout( +- timeout, +- TokioCommand::new("sh") +- .arg("-c") +- .arg(&shell_command) +- .current_dir(&cwd) +- .output(), +- ) +- .await +- .map_err(|_| { +- Error::engine(format!( +- "Devcontainer initializeCommand timed out: {shell_command}" +- )) +- })? +- .map_err(|e| { +- Error::engine_with_source( +- format!("Failed to execute devcontainer initializeCommand: {shell_command}"), +- e, +- ) +- })?; +- +- if !output.status.success() { +- let code = output +- .status +- .code() +- .map_or_else(|| "unknown".to_string(), |code| code.to_string()); +- let stderr = String::from_utf8_lossy(&output.stderr); +- return Err(Error::engine(format!( +- "Devcontainer initializeCommand failed (exit code {code}): {shell_command}\n{stderr}" +- ))); +- } +- Ok::<(), Error>(()) +- } +- }; +- +- for command in &config.initialize_commands { +- match command { +- fabro_devcontainer::Command::Shell(shell) => run_shell(shell.clone()).await?, +- fabro_devcontainer::Command::Args(args) => { +- let shell_command = args +- .iter() +- .map(|arg| try_quote(arg).unwrap_or_else(|_| arg.into()).to_string()) +- .collect::>() +- .join(" "); +- run_shell(shell_command).await?; +- } +- fabro_devcontainer::Command::Parallel(commands) => { +- let futures = commands.values().cloned().map(&run_shell); +- try_join_all(futures).await?; +- } +- } +- } +- +- options +- .sandbox_env +- .devcontainer_env +- .clone_from(&config.environment); +- options.lifecycle.devcontainer_phases = vec![ +- ("on_create".to_string(), config.on_create_commands.clone()), +- ( +- "post_create".to_string(), +- config.post_create_commands.clone(), +- ), +- ("post_start".to_string(), config.post_start_commands.clone()), +- ]; +- +- Ok(()) +-} + /// INITIALIZE phase: prepare the sandbox, env, and handlers for execution. + pub async fn initialize( + persisted: Persisted, +@@ -397,8 +293,6 @@ pub async fn initialize( + ))) + }; + +- resolve_devcontainer(&mut options).await?; +- + let attach_existing = options.checkpoint.is_some(); + options.run_options.display_base_sha = options + .run_options +@@ -439,6 +333,9 @@ pub async fn initialize( + let record = run_state.sandbox.ok_or_else(|| { + Error::Precondition("cannot resume run: run sandbox is missing".to_string()) + })?; ++ let instance = record.instance().ok_or_else(|| { ++ Error::Precondition("cannot resume run: run sandbox was not initialized".to_string()) ++ })?; + let daytona_api_key = match &options.vault { + Some(vault) => vault + .read() +@@ -448,7 +345,7 @@ pub async fn initialize( + None => None, + }; + let sandbox = reconnect_for_run_with_callback( +- &record, ++ instance, + daytona_api_key, + Some(options.run_id), + Some(Arc::clone(&sandbox_event_callback)), +@@ -510,11 +407,8 @@ pub async fn initialize( + if sandbox_initialized { + let run_sandbox = options + .sandbox +- .to_run_sandbox(&*sandbox, options.run_options.run_id); +- let runtime = run_sandbox +- .runtime +- .as_ref() +- .ok_or_else(|| Error::engine("initialized sandbox missing runtime metadata"))?; ++ .to_run_sandbox_instance(&*sandbox, options.run_options.run_id); ++ let runtime = &run_sandbox.runtime; + options.emitter.emit(&Event::SandboxInitialized { + working_directory: runtime.working_directory.clone(), + provider: run_sandbox.provider, +@@ -681,18 +575,6 @@ pub async fn initialize( + }); + } + +- for (phase, commands) in &options.lifecycle.devcontainer_phases { +- run_devcontainer_lifecycle( +- sandbox.as_ref(), +- &options.emitter, +- phase, +- commands, +- options.lifecycle.setup_command_timeout_ms, +- options.run_options.cancel_token.clone(), +- ) +- .await?; +- } +- + let metadata_writer = match build_metadata_writer(&options.run_options) { + Ok(writer) => writer, + Err(err) => { +@@ -940,20 +822,17 @@ mod tests { + lifecycle: crate::run_options::LifecycleOptions { + setup_commands: vec![command.to_string()], + setup_command_timeout_ms: 1_000, +- devcontainer_phases: vec![], + }, + run_options: test_settings(&run_dir), + workflow_path: None, + workflow_bundle: None, + hooks: fabro_hooks::HookSettings { hooks: vec![] }, + sandbox_env: SandboxEnvSpec { +- devcontainer_env: HashMap::new(), + toml_env: HashMap::new(), + github_permissions: None, + origin_url: None, + }, + vault: None, +- devcontainer: None, + git: None, + run_control: None, + registry_override: None, +@@ -1025,20 +904,17 @@ mod tests { + lifecycle: crate::run_options::LifecycleOptions { + setup_commands: vec![], + setup_command_timeout_ms: 1_000, +- devcontainer_phases: vec![], + }, + run_options: test_settings(&run_dir), + workflow_path: None, + workflow_bundle: None, + hooks: fabro_hooks::HookSettings { hooks: vec![] }, + sandbox_env: SandboxEnvSpec { +- devcontainer_env: HashMap::new(), + toml_env: HashMap::from([("TEST_KEY".to_string(), "value".to_string())]), + github_permissions: None, + origin_url: None, + }, + vault: None, +- devcontainer: None, + git: None, + run_control: None, + registry_override: None, +@@ -1226,20 +1102,17 @@ mod tests { + lifecycle: crate::run_options::LifecycleOptions { + setup_commands: Vec::new(), + setup_command_timeout_ms: 1_000, +- devcontainer_phases: Vec::new(), + }, + run_options: test_settings(&run_dir), + workflow_path: None, + workflow_bundle: None, + hooks: fabro_hooks::HookSettings { hooks: vec![] }, + sandbox_env: SandboxEnvSpec { +- devcontainer_env: HashMap::new(), + toml_env: HashMap::new(), + github_permissions: None, + origin_url: None, + }, + vault: Some(vault), +- devcontainer: None, + git: None, + run_control: None, + registry_override: None, +@@ -1325,20 +1198,17 @@ mod tests { + lifecycle: crate::run_options::LifecycleOptions { + setup_commands: vec!["true".to_string()], + setup_command_timeout_ms: 1_000, +- devcontainer_phases: vec![], + }, + run_options: test_settings(&run_dir), + workflow_path: None, + workflow_bundle: None, + hooks: fabro_hooks::HookSettings { hooks: vec![] }, + sandbox_env: SandboxEnvSpec { +- devcontainer_env: HashMap::new(), + toml_env: HashMap::new(), + github_permissions: None, + origin_url: None, + }, + vault: None, +- devcontainer: None, + git: None, + run_control: None, + registry_override: None, +@@ -1442,88 +1312,17 @@ mod tests { + lifecycle: crate::run_options::LifecycleOptions { + setup_commands: vec!["sleep 5".to_string()], + setup_command_timeout_ms: 5_000, +- devcontainer_phases: vec![], +- }, +- run_options, +- workflow_path: None, +- workflow_bundle: None, +- hooks: fabro_hooks::HookSettings { hooks: vec![] }, +- sandbox_env: SandboxEnvSpec { +- devcontainer_env: HashMap::new(), +- toml_env: HashMap::new(), +- github_permissions: None, +- origin_url: None, +- }, +- vault: None, +- devcontainer: None, +- git: None, +- run_control: None, +- registry_override: None, +- artifact_sink: None, +- checkpoint: None, +- seed_context: None, +- fabro_run_tools: None, +- }) +- .await; +- +- assert!(matches!(result, Err(Error::Cancelled))); +- } +- +- #[tokio::test] +- async fn initialize_cancelled_devcontainer_phase_returns_cancelled() { +- let temp = tempfile::tempdir().unwrap(); +- let run_dir = temp.path().join("run"); +- std::fs::create_dir_all(&run_dir).unwrap(); +- let (graph, source) = simple_graph(); +- let persisted = test_persisted(graph, source, &run_dir); +- let cancel_token = tokio_util::sync::CancellationToken::new(); +- cancel_token.cancel(); +- let mut run_options = test_settings(&run_dir); +- run_options.cancel_token = cancel_token; +- +- let emitter = Arc::new(crate::event::Emitter::new(test_run_id())); +- let result = initialize(persisted, InitOptions { +- run_id: test_run_id(), +- run_store: { +- let store = memory_store(); +- let inner = store.create_run(&test_run_id()).await.unwrap(); +- inner.into() +- }, +- dry_run: false, +- emitter: emitter.clone(), +- sandbox: SandboxSpec::Local { +- working_directory: std::env::current_dir().unwrap(), +- }, +- llm: LlmSpec { +- model: "test-model".to_string(), +- provider_id: fabro_model::ProviderId::anthropic(), +- fallback_chain: Vec::new(), +- mcp_servers: Vec::new(), +- model_controls: RunModelControls::default(), +- dry_run: true, +- }, +- interviewer: Arc::new(AutoApproveInterviewer::engine()), +- steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())), +- catalog: test_catalog(), +- lifecycle: crate::run_options::LifecycleOptions { +- setup_commands: vec![], +- setup_command_timeout_ms: 5_000, +- devcontainer_phases: vec![("on_create".to_string(), vec![ +- fabro_devcontainer::Command::Shell("sleep 5".to_string()), +- ])], + }, + run_options, + workflow_path: None, + workflow_bundle: None, + hooks: fabro_hooks::HookSettings { hooks: vec![] }, + sandbox_env: SandboxEnvSpec { +- devcontainer_env: HashMap::new(), + toml_env: HashMap::new(), + github_permissions: None, + origin_url: None, + }, + vault: None, +- devcontainer: None, + git: None, + run_control: None, + registry_override: None, +diff --git a/lib/crates/fabro-workflow/src/pipeline/mod.rs b/lib/crates/fabro-workflow/src/pipeline/mod.rs +index 94d393025..ef517e9cc 100644 +--- a/lib/crates/fabro-workflow/src/pipeline/mod.rs ++++ b/lib/crates/fabro-workflow/src/pipeline/mod.rs +@@ -22,8 +22,8 @@ pub use pull_request::{ + }; + pub use transform::transform; + pub use types::{ +- Concluded, DevcontainerSpec, Executed, FinalizeOptions, Finalized, InitOptions, Initialized, +- LlmSpec, Parsed, Persisted, PullRequestOptions, SandboxEnvSpec, +- TEMPLATE_UNDEFINED_VARIABLE_RULE, TransformOptions, Transformed, Validated, ++ Concluded, Executed, FinalizeOptions, Finalized, InitOptions, Initialized, LlmSpec, Parsed, ++ Persisted, PullRequestOptions, SandboxEnvSpec, TEMPLATE_UNDEFINED_VARIABLE_RULE, ++ TransformOptions, Transformed, Validated, + }; + pub use validate::validate; +diff --git a/lib/crates/fabro-workflow/src/pipeline/types.rs b/lib/crates/fabro-workflow/src/pipeline/types.rs +index 39085a904..541daebe1 100644 +--- a/lib/crates/fabro-workflow/src/pipeline/types.rs ++++ b/lib/crates/fabro-workflow/src/pipeline/types.rs +@@ -239,18 +239,11 @@ pub struct LlmSpec { + + #[derive(Clone)] + pub struct SandboxEnvSpec { +- pub devcontainer_env: HashMap, + pub toml_env: HashMap, + pub github_permissions: Option>, + pub origin_url: Option, + } + +-#[derive(Clone)] +-pub struct DevcontainerSpec { +- pub enabled: bool, +- pub resolve_dir: PathBuf, +-} +- + pub struct InitOptions { + pub run_id: RunId, + pub run_store: RunStoreHandle, +@@ -268,7 +261,6 @@ pub struct InitOptions { + pub hooks: fabro_hooks::HookSettings, + pub sandbox_env: SandboxEnvSpec, + pub vault: Option>>, +- pub devcontainer: Option, + pub git: Option, + pub registry_override: Option>, + pub artifact_sink: Option, +diff --git a/lib/crates/fabro-workflow/src/run_options.rs b/lib/crates/fabro-workflow/src/run_options.rs +index c1d20cdcb..23282d644 100644 +--- a/lib/crates/fabro-workflow/src/run_options.rs ++++ b/lib/crates/fabro-workflow/src/run_options.rs +@@ -78,6 +78,4 @@ pub struct LifecycleOptions { + pub setup_commands: Vec, + /// Timeout in milliseconds for each setup command. + pub setup_command_timeout_ms: u64, +- /// Devcontainer lifecycle phases and their commands. +- pub devcontainer_phases: Vec<(String, Vec)>, + } +diff --git a/lib/crates/fabro-workflow/src/services.rs b/lib/crates/fabro-workflow/src/services.rs +index e8c4b3974..44e0eca21 100644 +--- a/lib/crates/fabro-workflow/src/services.rs ++++ b/lib/crates/fabro-workflow/src/services.rs +@@ -232,7 +232,7 @@ pub struct EngineServices { + /// Git state for the current run. Set via `set_git_state` at the start of + /// `execute` and read by parallel/fan-in handlers. + pub(crate) git_state: std::sync::RwLock>>, +- /// Environment variables from devcontainer and `[sandbox.env]` config. ++ /// Environment variables from `[sandbox.env]` config. + pub base_env: HashMap, + /// GitHub token source used to inject `GITHUB_TOKEN` at the point of use. + pub github_token: Option>, +diff --git a/lib/crates/fabro-workflow/tests/it/cp_integration.rs b/lib/crates/fabro-workflow/tests/it/cp_integration.rs +index 60aafd9bb..6fd43fe70 100644 +--- a/lib/crates/fabro-workflow/tests/it/cp_integration.rs ++++ b/lib/crates/fabro-workflow/tests/it/cp_integration.rs +@@ -15,7 +15,7 @@ + )] + + use fabro_sandbox::reconnect::reconnect; +-use fabro_types::{RunSandbox, RunSandboxRuntime, SandboxProviderKind}; ++use fabro_types::{RunSandboxInstance, RunSandboxRuntime, SandboxProviderKind}; + + const DOCKER_MANAGED_LABEL: &str = "sh.fabro.managed"; + const DOCKER_CP_IMAGE: &str = "buildpack-deps:noble"; +@@ -24,12 +24,12 @@ const DOCKER_CP_IMAGE: &str = "buildpack-deps:noble"; + // Local sandbox + // --------------------------------------------------------------------------- + +-fn local_record(working_directory: &std::path::Path) -> RunSandbox { +- RunSandbox { ++fn local_record(working_directory: &std::path::Path) -> RunSandboxInstance { ++ RunSandboxInstance { + provider: SandboxProviderKind::Local, + image: None, + snapshot: None, +- runtime: Some(RunSandboxRuntime { ++ runtime: RunSandboxRuntime { + id: "local:test".to_string(), + working_directory: working_directory.to_string_lossy().to_string(), + repo_cloned: None, +@@ -39,7 +39,7 @@ fn local_record(working_directory: &std::path::Path) -> RunSandbox { + repos_root: None, + primary_repo_path: None, + primary_repo_link: None, +- }), ++ }, + } + } + +@@ -135,12 +135,12 @@ async fn local_cp_creates_parent_dirs() { + // Docker sandbox + // --------------------------------------------------------------------------- + +-fn docker_record(container_id: &str) -> RunSandbox { +- RunSandbox { ++fn docker_record(container_id: &str) -> RunSandboxInstance { ++ RunSandboxInstance { + provider: SandboxProviderKind::Docker, + image: None, + snapshot: None, +- runtime: Some(RunSandboxRuntime { ++ runtime: RunSandboxRuntime { + id: container_id.to_string(), + working_directory: "/workspace".to_string(), + repo_cloned: Some(false), +@@ -150,7 +150,7 @@ fn docker_record(container_id: &str) -> RunSandbox { + repos_root: Some("/repos".to_string()), + primary_repo_path: None, + primary_repo_link: None, +- }), ++ }, + } + } + +diff --git a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs +index 2d223a550..346825e7e 100644 +--- a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs ++++ b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs +@@ -1703,7 +1703,7 @@ async fn daytona_toolbox_idle_diagnostic() { + #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))] + async fn daytona_cp_upload_download_round_trip() { + use fabro_sandbox::reconnect::reconnect; +- use fabro_types::{RunSandbox, SandboxProviderKind}; ++ use fabro_types::{RunSandboxInstance, SandboxProviderKind}; + + // 1. Create and initialize a real Daytona sandbox + let env = create_env().await; +@@ -1715,12 +1715,12 @@ async fn daytona_cp_upload_download_round_trip() { + "sandbox_info() should return the Daytona sandbox name" + ); + +- // 2. Build a RunSandbox (same as `fabro run` would persist) +- let record = RunSandbox { ++ // 2. Build initialized sandbox metadata (same as `fabro run` would persist) ++ let record = RunSandboxInstance { + provider: SandboxProviderKind::Daytona, + image: None, + snapshot: None, +- runtime: Some(fabro_types::RunSandboxRuntime { ++ runtime: fabro_types::RunSandboxRuntime { + id: sandbox_name.clone(), + working_directory: env.working_directory().to_string(), + repo_cloned: Some(false), +@@ -1730,7 +1730,7 @@ async fn daytona_cp_upload_download_round_trip() { + repos_root: Some("/home/daytona/repos".to_string()), + primary_repo_path: None, + primary_repo_link: None, +- }), ++ }, + }; + + // 3. Reconnect via the real cp::reconnect path +diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES +index a3e517248..47d63f903 100644 +--- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES ++++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES +@@ -352,6 +352,10 @@ models/run-provenance.ts + models/run-question.ts + models/run-reference.ts + models/run-runnable-source.ts ++models/run-sandbox-failure.ts ++models/run-sandbox-instance.ts ++models/run-sandbox-kind.ts ++models/run-sandbox-plan.ts + models/run-sandbox-runtime.ts + models/run-sandbox.ts + models/run-scm-settings.ts +diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts +index 2ee3e1593..eea7c071d 100644 +--- a/lib/packages/fabro-api-client/src/models/index.ts ++++ b/lib/packages/fabro-api-client/src/models/index.ts +@@ -328,6 +328,10 @@ export * from './run-question'; + export * from './run-reference'; + export * from './run-runnable-source'; + export * from './run-sandbox'; ++export * from './run-sandbox-failure'; ++export * from './run-sandbox-instance'; ++export * from './run-sandbox-kind'; ++export * from './run-sandbox-plan'; + export * from './run-sandbox-runtime'; + export * from './run-scm-settings'; + export * from './run-server-provenance'; +diff --git a/lib/packages/fabro-api-client/src/models/run-sandbox-failure.ts b/lib/packages/fabro-api-client/src/models/run-sandbox-failure.ts +new file mode 100644 +index 000000000..c30582f99 +--- /dev/null ++++ b/lib/packages/fabro-api-client/src/models/run-sandbox-failure.ts +@@ -0,0 +1,28 @@ ++/* tslint:disable */ ++/* eslint-disable */ ++/** ++ * Fabro Run API ++ * HTTP API for managing Fabro workflow run executions. ++ * ++ * The version of the OpenAPI document: 0.1.0 ++ * ++ * ++ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). ++ * https://openapi-generator.tech ++ * Do not edit the class manually. ++ */ ++ ++ ++ ++/** ++ * Sandbox initialization failure details. ++ */ ++export interface RunSandboxFailure { ++ /** ++ * Provider reported by the sandbox initialization event. ++ */ ++ 'provider': string; ++ 'error': string; ++ 'causes': Array; ++ 'duration_ms': number; ++} +diff --git a/lib/packages/fabro-api-client/src/models/run-sandbox-instance.ts b/lib/packages/fabro-api-client/src/models/run-sandbox-instance.ts +new file mode 100644 +index 000000000..1fd11d41c +--- /dev/null ++++ b/lib/packages/fabro-api-client/src/models/run-sandbox-instance.ts +@@ -0,0 +1,31 @@ ++/* tslint:disable */ ++/* eslint-disable */ ++/** ++ * Fabro Run API ++ * HTTP API for managing Fabro workflow run executions. ++ * ++ * The version of the OpenAPI document: 0.1.0 ++ * ++ * ++ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). ++ * https://openapi-generator.tech ++ * Do not edit the class manually. ++ */ ++ ++ ++// May contain unused imports in some cases ++// @ts-ignore ++import type { RunSandboxRuntime } from './run-sandbox-runtime'; ++// May contain unused imports in some cases ++// @ts-ignore ++import type { SandboxProviderKind } from './sandbox-provider-kind'; ++ ++/** ++ * Initialized sandbox provider and runtime metadata. ++ */ ++export interface RunSandboxInstance { ++ 'provider': SandboxProviderKind; ++ 'image'?: string | null; ++ 'snapshot'?: string | null; ++ 'runtime': RunSandboxRuntime; ++} +diff --git a/lib/packages/fabro-api-client/src/models/run-sandbox-kind.ts b/lib/packages/fabro-api-client/src/models/run-sandbox-kind.ts +new file mode 100644 +index 000000000..5839dafad +--- /dev/null ++++ b/lib/packages/fabro-api-client/src/models/run-sandbox-kind.ts +@@ -0,0 +1,28 @@ ++/* tslint:disable */ ++/* eslint-disable */ ++/** ++ * Fabro Run API ++ * HTTP API for managing Fabro workflow run executions. ++ * ++ * The version of the OpenAPI document: 0.1.0 ++ * ++ * ++ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). ++ * https://openapi-generator.tech ++ * Do not edit the class manually. ++ */ ++ ++ ++ ++/** ++ * Lifecycle state for a run sandbox request. ++ */ ++ ++export const RunSandboxKind = { ++ PLANNED: 'planned', ++ INITIALIZING: 'initializing', ++ READY: 'ready', ++ FAILED: 'failed' ++} as const; ++ ++export type RunSandboxKind = typeof RunSandboxKind[keyof typeof RunSandboxKind]; +diff --git a/lib/packages/fabro-api-client/src/models/run-sandbox-plan.ts b/lib/packages/fabro-api-client/src/models/run-sandbox-plan.ts +new file mode 100644 +index 000000000..6a7e2d032 +--- /dev/null ++++ b/lib/packages/fabro-api-client/src/models/run-sandbox-plan.ts +@@ -0,0 +1,27 @@ ++/* tslint:disable */ ++/* eslint-disable */ ++/** ++ * Fabro Run API ++ * HTTP API for managing Fabro workflow run executions. ++ * ++ * The version of the OpenAPI document: 0.1.0 ++ * ++ * ++ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). ++ * https://openapi-generator.tech ++ * Do not edit the class manually. ++ */ ++ ++ ++// May contain unused imports in some cases ++// @ts-ignore ++import type { SandboxProviderKind } from './sandbox-provider-kind'; ++ ++/** ++ * Requested sandbox provider and base image/snapshot from run settings. ++ */ ++export interface RunSandboxPlan { ++ 'provider': SandboxProviderKind; ++ 'image'?: string | null; ++ 'snapshot'?: string | null; ++} +diff --git a/lib/packages/fabro-api-client/src/models/run-sandbox.ts b/lib/packages/fabro-api-client/src/models/run-sandbox.ts +index 7e5102b1b..12c15b34f 100644 +--- a/lib/packages/fabro-api-client/src/models/run-sandbox.ts ++++ b/lib/packages/fabro-api-client/src/models/run-sandbox.ts +@@ -15,17 +15,23 @@ + + // May contain unused imports in some cases + // @ts-ignore +-import type { RunSandboxRuntime } from './run-sandbox-runtime'; ++import type { RunSandboxFailure } from './run-sandbox-failure'; + // May contain unused imports in some cases + // @ts-ignore +-import type { SandboxProviderKind } from './sandbox-provider-kind'; ++import type { RunSandboxInstance } from './run-sandbox-instance'; ++// May contain unused imports in some cases ++// @ts-ignore ++import type { RunSandboxKind } from './run-sandbox-kind'; ++// May contain unused imports in some cases ++// @ts-ignore ++import type { RunSandboxPlan } from './run-sandbox-plan'; + + /** +- * Canonical sandbox environment record for a run. ++ * Sandbox lifecycle record for a run. A run can have a requested sandbox plan before it has an initialized sandbox instance. + */ + export interface RunSandbox { +- 'provider': SandboxProviderKind; +- 'image': string | null; +- 'snapshot': string | null; +- 'runtime': RunSandboxRuntime | null; ++ 'kind': RunSandboxKind; ++ 'plan': RunSandboxPlan; ++ 'instance'?: RunSandboxInstance | null; ++ 'failure'?: RunSandboxFailure | null; + } +diff --git a/lib/packages/fabro-api-client/src/models/sandbox-details.ts b/lib/packages/fabro-api-client/src/models/sandbox-details.ts +index 5c733abec..016fd2661 100644 +--- a/lib/packages/fabro-api-client/src/models/sandbox-details.ts ++++ b/lib/packages/fabro-api-client/src/models/sandbox-details.ts +@@ -15,7 +15,7 @@ + + // May contain unused imports in some cases + // @ts-ignore +-import type { RunSandbox } from './run-sandbox'; ++import type { RunSandboxInstance } from './run-sandbox-instance'; + // May contain unused imports in some cases + // @ts-ignore + import type { SandboxNetwork } from './sandbox-network'; +@@ -33,7 +33,7 @@ import type { SandboxTimestamps } from './sandbox-timestamps'; + * Provider-neutral details about the sandbox owned by a run. + */ + export interface SandboxDetails { +- 'sandbox': RunSandbox; ++ 'sandbox': RunSandboxInstance; + 'state': SandboxState; + /** + * Original provider state string before normalization. Display/debugging only; UI behavior keys off `state`. diff --git a/stages/008-verify@1/output.log b/stages/008-verify@1/output.log new file mode 100644 index 000000000..2e0082b07 --- /dev/null +++ b/stages/008-verify@1/output.log @@ -0,0 +1 @@ +blob://sha256/fc9defa961f2471f59d082245293de47c0a407bb3e95e7fb5ae5b3695a839fcb \ No newline at end of file diff --git a/stages/008-verify@1/script_timing.json b/stages/008-verify@1/script_timing.json new file mode 100644 index 000000000..3f42e43be --- /dev/null +++ b/stages/008-verify@1/script_timing.json @@ -0,0 +1,8 @@ +{ + "output": "blob://sha256/fc9defa961f2471f59d082245293de47c0a407bb3e95e7fb5ae5b3695a839fcb", + "exit_code": 0, + "duration_ms": 557888, + "termination": "exited", + "output_bytes": 216972, + "live_streaming": true +} \ No newline at end of file diff --git a/stages/008-verify@1/status.json b/stages/008-verify@1/status.json new file mode 100644 index 000000000..b2e7c9474 --- /dev/null +++ b/stages/008-verify@1/status.json @@ -0,0 +1,6 @@ +{ + "outcome": "succeeded", + "notes": "Script completed: git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\\bActorRef\\b|\\bActorKind\\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\\s*==\\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", + "failure_reason": null, + "timestamp": "2026-05-27T17:26:22.860036Z" +} \ No newline at end of file diff --git a/stages/009-exit@1/status.json b/stages/009-exit@1/status.json new file mode 100644 index 000000000..d3d6857ba --- /dev/null +++ b/stages/009-exit@1/status.json @@ -0,0 +1,6 @@ +{ + "outcome": "succeeded", + "notes": null, + "failure_reason": null, + "timestamp": "2026-05-27T17:26:27.491252Z" +} \ No newline at end of file