diff --git a/CHANGELOG.md b/CHANGELOG.md index ce332d2f..225cf609 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,40 @@ All notable changes to Trail are documented in this file. Trail follows ## [Unreleased] +### Fixed + +- Trail's managed Colima runtime now keeps Lima VM state at the private, + socket-safe `~/.trail-lima/` path on macOS, avoiding startup failures when a + workspace-scoped profile exceeds the platform's Unix-domain socket limit. + +### Added + +- Lane-private OCI services can now use a first-class Colima provider through + `trail env runtime setup colima`. Trail creates or reuses a workspace-specific + profile without host mounts or global Docker-context activation, targets its + explicit Docker context on every operation, reports typed provider status, + and leaves VM lifecycle outside implicit lane cleanup. Existing workspaces + retain ambient Docker/Podman auto-detection. Colima file-secret mounts fail + closed until a VM-safe secret broker is available. On macOS 13+ arm64/x86_64, + explicit setup requires no prior installation: Trail downloads pinned Colima + 0.10.3, Lima 2.2.0, and Docker CLI 29.7.2 artifacts, verifies compiled + SHA-256 identities, retains their licenses, and atomically publishes them in + a Trail-owned cache. All non-setup provider operations remain network-free. + +- Managed lane commands and test/eval gates can now select a no-host-mount + Colima/Lima execution backend. Trail deterministically projects bounded lane + state into an execution-scoped guest namespace, runs direct argv with a + bounded environment and timeout, validates and imports only source changes, + checkpoints non-zero and timed-out results, and cleans up without stopping + the profile. CLI, HTTP, MCP, OpenAPI, lifecycle receipts, turn provenance, and + doctor recovery diagnostics share the same contract; `host` remains the + compatibility default. CLI, HTTP, and MCP now also share an execution + cancellation operation that terminates only Trail's recorded guest process + group, skips candidate import, preserves unrelated profile processes, and + returns the distinct `EXECUTION_CANCELLED` category (CLI exit 17 / HTTP 409). + Candidate-validation and guest-infrastructure failures are likewise distinct + machine categories (CLI exits 18/19 and HTTP 422/503). + ## [0.4.0] - 2026-08-12 ### Added diff --git a/README.md b/README.md index f14cb951..ed96af77 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,10 @@ For example: trail lane spawn fix-login --from main trail env sync all fix-login # optional prewarm; first managed execution also converges +# Optional on macOS: provision tools and execute managed commands in the VM. +trail env runtime setup colima --execution-backend colima +trail lane exec fix-login --timeout-secs 900 -- cargo test + # Work is isolated in the lane until it is reviewed and validated. trail lane record fix-login -m "Fix login validation" trail lane readiness fix-login diff --git a/docs/README.md b/docs/README.md index 8aa8a7d0..3d0547bf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -50,6 +50,7 @@ These docs are written from the current Rust code, CLI definitions, exported mod - [Lane overview](lanes/overview.md) - [Lane work model](lanes/work-model.md) +- [Colima-sandboxed lane execution](lanes/colima-sandboxed-execution.md) - [Spawn and materialize workdirs](lanes/spawn-and-materialize-workdirs.md) - [Structured patches](lanes/structured-patches.md) - [Sessions, turns, messages, and runs](lanes/sessions-turns-messages-and-runs.md) diff --git a/docs/design/guardrails-security-and-redaction.md b/docs/design/guardrails-security-and-redaction.md index dd889915..88dfc07e 100644 --- a/docs/design/guardrails-security-and-redaction.md +++ b/docs/design/guardrails-security-and-redaction.md @@ -314,6 +314,17 @@ CLI daemon routing can read the token from `--daemon-token`, `TRAIL_DAEMON_TOKEN - Patch rejected: `PATCH_REJECTED`, exit 7. - Missing capability enforcement rejects the action; it does not silently run an untrusted resolver or constructor unsandboxed. +- Colima lane execution accepts only a verified no-host-mount profile, passes + direct argv through explicit `limactl`, clears ambient credentials and Docker + endpoints, bounds projection/archive/output/time/concurrency, validates every + returned path and symlink before mutation, and rejects concurrent host source + edits. `.trail`, real Git state, host home, and the Docker socket never enter + the guest projection. +- Guest cancellation is an authenticated execution-scoped request. Trail + launches direct argv in a new guest process group, records its numeric owner + beneath the execution namespace, validates that receipt, sends TERM/KILL only + to the negative group ID, and refuses candidate import after cancellation. + It never uses a profile-wide stop as execution cleanup. - Secret-tainted output remains private and non-promotable even when its content would otherwise match a shared desired key. - Divergent ready content for one desired key and trust scope quarantines both diff --git a/docs/design/layered-lane-workspaces.md b/docs/design/layered-lane-workspaces.md index 33240851..6dfb5b49 100644 --- a/docs/design/layered-lane-workspaces.md +++ b/docs/design/layered-lane-workspaces.md @@ -1000,6 +1000,7 @@ trail lane spawn task-a --from main --workdir-mode auto trail lane mount task-a [--foreground] trail lane unmount task-a trail lane exec task-a -- +trail lane exec task-a --turn --timeout-secs 900 -- trail lane checkpoint task-a -m "checkpoint" trail lane update task-a --from main trail lane space task-a @@ -1026,6 +1027,14 @@ dispose execution-owned runtime resources, and unmount. Every phase produces a durable receipt. Generated and scratch changes are accounted for but excluded from the source checkpoint. +When `runtime.execution_backend=colima`, the mount remains a host-side input to +Trail only. Trail streams a deterministic bounded projection through verified +`limactl`, executes in a workspace/execution-derived guest namespace, exports a +bounded candidate, validates it in host staging, rejects concurrent source +edits, and applies only its source delta before the normal checkpoint. This is +a data-plane backend beneath the shared lifecycle; agents, Trail storage, and +checkpoint authority remain on the host. + Shared report types include: - `LaneWorkspaceViewReport` diff --git a/docs/design/universal-lane-environments.md b/docs/design/universal-lane-environments.md index fd5827f5..06049868 100644 --- a/docs/design/universal-lane-environments.md +++ b/docs/design/universal-lane-environments.md @@ -576,12 +576,35 @@ health state, cleanup token, lifecycle owner, and timestamps. Containers and net are generation-scoped; a private data volume is scoped to the logical lane service so a healthy image rollout does not silently discard database state. Retired containers and networks are removed after the replacement becomes healthy, while a volume remains as -long as an active generation references it. Docker and Podman are -detected at reconciliation time. A missing pinned image is pulled by digest; Trail then +long as an active generation references it. Docker and Podman are detected at +reconciliation time. A workspace may instead select Colima: Trail uses a stable +workspace-specific profile, starts it without host mounts or global context activation, +and addresses every operation through the profile's explicit Docker context. Explicit +setup reuses complete system tools or provisions pinned, digest-verified Colima, Lima, +and Docker CLI artifacts into a Trail-owned cache on supported macOS hosts. Ordinary +reconciliation never downloads tools. Managed processes isolate `COLIMA_HOME` and +`DOCKER_CONFIG` below Trail's user data directory. On macOS, `LIMA_HOME` uses the +private, shorter `~/.trail-lima/` root so workspace-scoped profiles stay within the +platform's Unix-domain socket path limit. Managed tools select Apple's `vz` backend. +Colima remains an external host provider rather than adapter authority, +and Trail never deletes +the VM profile implicitly. Because the contained profile cannot safely bind arbitrary +host paths into its Docker VM, file-secret services fail closed under Colima until a +VM-safe broker exists. A missing pinned image is pulled by digest; Trail then verifies the observed repository digest before creating anything. Existing names are adopted only when Trail ownership labels match exactly. A dead lifecycle owner is marked `orphaned` on reopen and safely inspected on the next reconcile. +Colima can also be selected as the managed-command data plane. Trail resolves +the exact profile to its Lima instance and uses a deterministic tar protocol +instead of mounting the host lane. Source and accepted portable symlinks enter +an execution namespace under bounded limits; secret/internal state does not. +Runtime bindings stay on guest loopback because both the Colima Docker daemon +and command inhabit the VM. Trail validates the exported candidate and imports +only source changes before the existing checkpoint barrier. The adapter remains +a planner, the host Trail process retains publication authority, and the agent +provider remains a separately contained host control plane. + ## Monorepos and multiple lanes Discovery roots are explicit. A monorepo can have many overlapping environment graphs, diff --git a/docs/guides/maintenance-and-recovery.md b/docs/guides/maintenance-and-recovery.md index 2f7e4548..dcce27a5 100644 --- a/docs/guides/maintenance-and-recovery.md +++ b/docs/guides/maintenance-and-recovery.md @@ -113,6 +113,26 @@ trail env artifact quarantine list resolution. Never delete rows or CAS files manually. - After restore, rerun sync for each retained lane before managed execution. +## Interrupted Colima Guest Execution + +`trail doctor` includes `managed_guest_executions` with bounded counts for +live, safely recoverable, ambiguous, and terminal receipts. Trail automatically +cleans an abandoned namespace only before candidate import. It fails closed for +an interrupted execute/export/import/checkpoint boundary because discarding or +reapplying state could overwrite lane work. + +For a live command, use `trail lane exec-cancel ` or select its event +receipt with `--execution-id`. Cancellation is acknowledged only after the +owned guest process group has stopped and candidate import has been skipped. +If the owner process dies after the request, Trail validates the durable owner, +terminates the same guest process group, and cleans only that execution's +namespace. + +For an ambiguous receipt, inspect the lane workdir and history, checkpoint +intentional source with `trail lane checkpoint `, and retry after the +state is understood. Do not edit `.trail/managed-executions`, delete a guest +namespace, or stop the shared Colima profile as a repair shortcut. + ## Schema-v1 Upgrade and Rollback Boundary Trail still accepts exactly SQLite schema v1 and has no in-place database diff --git a/docs/integrations/mcp.md b/docs/integrations/mcp.md index 16d9dbad..361f8236 100644 --- a/docs/integrations/mcp.md +++ b/docs/integrations/mcp.md @@ -50,6 +50,12 @@ task with: trail.begin_turn -> trail.add_message -> trail.span_start/span_end or trail.add_event -> trail.apply_patch or trail.sync_workdir -> trail.end_turn ``` +For commands that must see a filesystem, call `trail.lane_exec` with the open +turn's `turn_id`. This associates the resulting operation and lifecycle with +the turn. If `runtime.execution_backend=colima`, only that command data plane +runs in the no-mount Lima guest; the MCP host and Trail server remain the +contained host control plane. Test/eval tools use the same backend. + When a run pauses for approval or interruption, use `trail.run_pause` and later `trail.run_resume`. If a branch goes sideways, use `trail.lane_rewind` with `record_current=true` to preserve the failed head before returning to a diff --git a/docs/lanes/colima-sandboxed-execution.md b/docs/lanes/colima-sandboxed-execution.md new file mode 100644 index 00000000..7248b09c --- /dev/null +++ b/docs/lanes/colima-sandboxed-execution.md @@ -0,0 +1,155 @@ +# Colima-Sandboxed Lane Execution + +Trail can keep its lane database, mounts, agent coordination, and checkpoints on +the host while running managed lane commands inside the Lima virtual machine +owned by a contained Colima profile. This is an optional data-plane backend; +existing workspaces continue to execute on the host. + +## When it helps + +Use the Colima backend when a lane command may execute untrusted repository +code, install dependencies, invoke compilers, or contact lane-private services. +It gives these commands a VM boundary without giving the guest a host mount, +the Docker socket, `.trail/`, the real Git directory, the user's home, or +ambient credentials. + +Typical uses include: + +- an AI agent asking Trail to run a repository test, formatter, generator, or + build command; +- readiness test and eval gates that should use the same environment as agent + commands; +- several lanes using isolated service containers in one contained Colima VM; +- reviewing the exact source delta produced by a non-zero or timed-out command; + and +- retaining command, projection, checkpoint, cleanup, session, turn, and trace + evidence for review or handoff. + +The coding-agent process itself remains a contained host control plane. It can +use Trail through CLI, HTTP, or MCP, while `trail.lane_exec` and gate commands +run as the guest data plane. Trail reports this split explicitly; it does not +claim that an arbitrary host agent binary moved into the VM. + +## Setup + +On supported macOS hosts, setup can install Trail's pinned Colima, Lima, and +Docker CLI tools without Homebrew or a separate Colima installation: + +```sh +trail env runtime setup colima --execution-backend colima +trail env runtime provider status +``` + +Trail starts a dedicated profile with no host mounts, SSH-agent forwarding, +generated host SSH configuration, bridged address, Kubernetes, or global +context activation. Linux and unsupported macOS hosts can use the same backend +when compatible `colima`, `limactl`, and `docker` executables are already +available. Colima still downloads and owns its guest image; Trail does not +embed that image in the `trail` binary. + +`--no-start` can provision tools for later use, but it cannot enable the Colima +execution backend because Trail must verify the running guest first. + +## Execution flow + +```text +host Trail control plane + resolve/sync services and mount lane view + build deterministic bounded source projection + | + | tar stream through verified limactl (no host mount) + v +contained Colima/Lima guest + create execution namespace -> run argv -> export candidate -> delete namespace + | + | bounded candidate stream + v +host Trail control plane + validate archive -> reject concurrent host edits -> import source delta + -> checkpoint lane operation -> dispose owned services -> unmount +``` + +Each execution uses a workspace- and execution-derived directory below +`/tmp/trail-executions` in the guest. Arguments are passed directly without +shell interpolation. Environment values are allowlisted and host lane paths are +translated to the guest workspace. Runtime service bindings remain on guest +loopback because Colima's Docker daemon and the managed command share the same +VM; Docker sockets and host paths are never injected. + +The projection and candidate import are bounded by the lane workspace-view +entry, total-byte, and single-file limits (or conservative defaults). Paths are +normalized and checked for traversal, case collisions, unsupported entry kinds, +and escaping symlinks. Secret-class, Trail-internal, and real Git state are +excluded. Only validated source changes return to the lane; generated, +dependency, and scratch output remains disposable. A concurrent host source +change makes import fail closed. + +## Commands and agent use + +```sh +# Default guest timeout is 3600 seconds; accepted range is 1..86400. +trail lane exec fix-login --timeout-secs 900 -- cargo test + +# Associate the checkpoint with an existing open turn. +trail lane exec fix-login --turn turn_... -- cargo test + +# From another terminal, cancel the lane's only live guest execution. +trail lane exec-cancel fix-login + +# Select an execution when several commands are live. +trail lane exec-cancel fix-login --execution-id exec_... + +# Return to compatible host execution. +trail config set runtime.execution_backend host +``` + +HTTP `POST /v1/lanes/{lane}/exec` and MCP `trail.lane_exec` accept the same +`command`, optional `turn_id`, and optional `timeout_secs`. When `turn_id` is +present, Trail rejects an ended or cross-lane turn before launch and reports its +session, turn, and derived trace identity in the lifecycle receipt. This is the +recommended path for AI hosts: open a turn, call `trail.lane_exec` for command +work, inspect its structured lifecycle/checkpoint result, run gates, then end +the turn. + +CLI `lane exec-cancel`, HTTP `POST /v1/lanes/{lane}/exec/cancel`, and MCP +`trail.lane_exec_cancel` expose the same cancellation operation. The optional +`execution_id` is required only when more than one cancellable execution is +live for the lane. Trail writes the cancellation request before acting, +terminates only that execution's recorded guest process group, skips candidate +import, cleans its owned namespace, and retains `terminal_cancelled` evidence. +The original blocking request returns `EXECUTION_CANCELLED` (CLI exit 17, HTTP +409, and the same structured MCP error). Cancellation never stops the Colima +profile or unrelated guest processes. +HTTP lane execution is dispatched on a workspace-scoped daemon worker, leaving +the authenticated listener responsive to the matching cancellation request. +Because one MCP stdio connection processes requests in order, an agent cancels +a blocking MCP execution from a second Trail MCP session (or through the CLI or +HTTP endpoint). The cancellation operation and durable receipt are identical. + +The result distinguishes `succeeded`, `command_failed`, and `timed_out`; +cancellation is a distinct structured error rather than a command exit. +Non-zero and timed-out commands still proceed through candidate validation, +source checkpointing, service disposal, namespace cleanup, and lane unmount. +Candidate validation and infrastructure failures are returned as distinct +`EXECUTION_VALIDATION_FAILED` (CLI exit 18 / HTTP 422) and +`EXECUTION_INFRASTRUCTURE_FAILED` (CLI exit 19 / HTTP 503) Trail errors rather +than being disguised as command exits. + +## Recovery and boundaries + +Trail writes private execution manifests under `.trail/managed-executions/`. +Before another guest command it identifies live owners, safely removes abandoned +pre-import namespaces, and refuses to guess after ambiguous execute/export/import +states. `trail doctor` reports live, recoverable, ambiguous, and terminal receipt +counts without mutating them. Completed receipts are retained in a bounded set. + +If doctor reports an ambiguous execution, inspect the lane and its current +workdir first. Checkpoint intentional source with `trail lane checkpoint +`, then retry only after resolving the preserved state. Do not edit the +receipt or delete the guest namespace manually. + +The backend does not provide a general-purpose remote shell, persist arbitrary +guest build output, expose host secrets, or stop/delete the Colima profile +during lane cleanup. File-secret OCI mounts remain unsupported. VM image trust, +kernel isolation, and Colima vulnerabilities remain part of the local Colima +trust boundary. diff --git a/docs/lanes/overview.md b/docs/lanes/overview.md index 53fc0540..69abf741 100644 --- a/docs/lanes/overview.md +++ b/docs/lanes/overview.md @@ -140,6 +140,13 @@ The same receipt shape is used by exec, test, eval, terminal-agent, and materialized ACP execution; older serialized lifecycle reports remain valid with both receipts absent. +When `runtime.execution_backend=colima`, command and gate execution adds +no-mount guest projection, export, import, and cleanup phases to this lifecycle. +The host remains the authoritative lane/checkpoint and agent control plane. A +CLI `--turn` or HTTP/MCP `turn_id` associates the command checkpoint and +session/turn/trace provenance with an open lane turn. See +[Colima-sandboxed lane execution](colima-sandboxed-execution.md). + Omitting `--workdir-mode` creates a lazy `auto` layered lane on a qualified native transparent backend. Spawn does not copy source or execute ecosystem tools. The first managed command converges the desired environment; exact diff --git a/docs/lanes/work-model.md b/docs/lanes/work-model.md index eae7c863..a33d0980 100644 --- a/docs/lanes/work-model.md +++ b/docs/lanes/work-model.md @@ -188,6 +188,14 @@ Trail Relay Without such a relay, the host or script must call Trail explicitly. +When a host needs to execute repository code, it can call CLI/HTTP/MCP lane +execution with the open turn id. Trail then attaches the managed lifecycle, +checkpointed operation, and derived trace id to that turn. With the optional +Colima backend, the host agent remains the control plane while the command runs +inside a no-host-mount Lima guest; this changes containment, not the lane's +branch/session/turn model. See +[Colima-sandboxed lane execution](colima-sandboxed-execution.md). + ## Two Ways to Change a Lane ### Structured Patch Flow diff --git a/docs/reference/cli/errors-and-output-formats.md b/docs/reference/cli/errors-and-output-formats.md index 2c586e9a..840570f9 100644 --- a/docs/reference/cli/errors-and-output-formats.md +++ b/docs/reference/cli/errors-and-output-formats.md @@ -53,6 +53,11 @@ When JSON errors are enabled: | 12 | Operation not found. | | 13 | Ref not found. | | 14 | Ignored path. | +| 15 | Workspace schema reinitialization required. | +| 16 | Committed repair or changed-path reconciliation required. | +| 17 | Managed execution cancelled. | +| 18 | Managed execution candidate validation failed. | +| 19 | Managed execution infrastructure failed. | ## Stable Error Codes @@ -74,6 +79,9 @@ Examples include: - `DATABASE_CORRUPT` - `GIT_ERROR` - `INVALID_INPUT` +- `EXECUTION_CANCELLED` +- `EXECUTION_VALIDATION_FAILED` +- `EXECUTION_INFRASTRUCTURE_FAILED` - `DAEMON_UNAVAILABLE` - `DAEMON_ERROR` @@ -84,6 +92,9 @@ The HTTP daemon maps selected errors to: - `400`: invalid input, invalid path, ignored path. - `404`: missing ref, operation, or root. - `409`: conflict, dirty worktree, patch rejected, stale branch, or workspace lock. +- `409`: managed execution cancelled. +- `422`: managed execution candidate validation failed. +- `503`: managed execution infrastructure failed. - `500`: other errors. ## Code Facts Used @@ -92,4 +103,3 @@ The HTTP daemon maps selected errors to: - CLI rendering: `trail/src/cli/command/handler/errors.rs` - HTTP error responses: `trail/src/server/route/utils.rs` - Tests: `cli_json_errors_are_machine_readable` - diff --git a/docs/reference/cli/integrations-and-maintenance.md b/docs/reference/cli/integrations-and-maintenance.md index 74c01492..16a7f451 100644 --- a/docs/reference/cli/integrations-and-maintenance.md +++ b/docs/reference/cli/integrations-and-maintenance.md @@ -356,6 +356,117 @@ most recently used lane. Managed execution performs the same convergence automatically and records a skipped phase when the active generation is already current. +### Use Colima for lane runtime services + +Trail can address lane-private OCI services through a dedicated Colima Docker +profile without changing the user's active Docker context: + +```sh +# On macOS 13+, Trail installs pinned runtime tools when needed. +trail env runtime setup colima +trail env runtime provider status + +# Reconcile explicitly, or let the first managed command do it. +trail env runtime reconcile +trail env runtime stop +``` + +Add `--execution-backend colima` when managed `lane exec`, test, and eval +commands should execute inside the Lima guest rather than on the host: + +```sh +trail env runtime setup colima --execution-backend colima +trail lane exec --timeout-secs 900 -- cargo test +trail lane exec-cancel [--execution-id ] +``` + +The default remains `host`. Guest setup requires a running, preflighted profile; +it cannot be combined with `--no-start`. See +[Colima-sandboxed lane execution](../../lanes/colima-sandboxed-execution.md) for +the projection/import protocol, agent workflow, guarantees, and recovery model. +`exec-cancel` can be issued from another process. It terminates only the +Trail-owned guest process group, prevents candidate import, and returns exit 17 +to the cancelled execution as `EXECUTION_CANCELLED`. + +The default profile name is stable and workspace-derived. Override it only with +a contained profile dedicated to Trail: + +```sh +trail env runtime setup colima --profile trail-my-project +``` + +Setup starts the profile with Docker, no host mounts, no SSH-agent forwarding, +no generated host SSH configuration, no Kubernetes, no reachable bridged +address, and no automatic Docker/Kubernetes context activation. Trail invokes +Docker with `--context colima-` on every operation and removes +`DOCKER_HOST` from that child process, so an ambient Docker Desktop, remote +daemon, or other Colima profile cannot receive the lane resources. The special +Colima profile `default` maps to Docker context `colima`. + +Setup first reuses a complete system `colima`, `limactl`, and `docker` +toolchain. If any member is absent on macOS 13 or newer (Apple Silicon or +Intel), Trail downloads its pinned Colima, Lima, and Docker CLI releases into: + +```text +~/Library/Caches/trail/runtime-tools/colima/// +``` + +No Homebrew, administrator access, global `PATH` change, or Docker Desktop +installation is required. Downloads occur only during the explicit setup +operation. Each immutable version URL, archive size, SHA-256 digest, expected +executable digest, and archive path is checked before atomic publication. +Third-party notices and license texts remain in the installed tree. Status, +reconcile, HTTP, MCP, and daemon operations never download or repair tools; if +the cache is absent or corrupt they direct the user to run setup again. + +Managed provisioning currently targets macOS arm64/x86_64 hosts that can use +Apple's `vz` backend. Linux and older macOS hosts may still use the provider, +but must supply compatible `colima`, `limactl`, and `docker` executables (and +their platform virtualization prerequisites). The Colima guest image is not +stored in Trail or Git; Colima downloads and verifies it during first startup. + +Use `--no-start` to resolve or provision the complete toolchain and persist the +selection without starting a VM or downloading its guest image. With that +option, autostart remains disabled until setup is run again without it: + +```sh +trail env runtime setup colima --profile trail-ci --no-start +trail config set runtime.colima_autostart true +``` + +The typed provider report contains `provider`, `execution_backend`, `status`, +`profile`, `lima_instance`, `docker_context`, `autostart`, `started`, +`containment`, `toolchain_source`, `toolchain_version`, and `reason`. +`toolchain_source` is `system`, +`trail_managed`, or `unavailable`; managed reports include the complete pinned +version identity. Provider +setup/status and their provider report are currently workspace-local Rust and +CLI operations, so no new OpenAPI route or schema is added for that report. +HTTP and MCP retain their existing typed config and runtime-reconcile surfaces; +after `runtime.provider=colima` is configured, reconciliation can start the +profile and is classified as an open-world write. + +Trail never stops or deletes the Colima VM implicitly. Managed-execution cleanup +stops only Trail-owned lane containers, while named volumes follow the existing +lane runtime retention rules. To return to ambient Docker/Podman detection: + +```sh +trail config set runtime.execution_backend host +trail config set runtime.provider auto +``` + +Because the contained profile has no host mounts, Trail rejects OCI services +that require host file-secret bind mounts before container creation. Use a +local Docker/Podman provider for those services until a VM-safe secret broker is +available; do not work around the restriction by mounting the host home into +the Colima profile. Mutable managed state and Docker context metadata are +isolated under `~/Library/Application Support/trail/runtime/`. On macOS, Lima's +VM state uses the shorter `~/.trail-lima/` root so its generated SSH socket +remains below the platform's Unix-domain socket path limit; Trail creates it as +a private user directory. Removing or resetting either location is always +explicit. First startup can download a VM image and take several minutes. Trail +never implicitly updates, stops, or deletes a toolchain, profile, or VM image. + Use `agent continue` after a task has landed or when you want another round of edits from a known checkpoint. `agent follow-up` is an alias. diff --git a/docs/reference/config.md b/docs/reference/config.md index 27bbeded..925f1360 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -8,6 +8,10 @@ Use `trail config list`, `get`, and `set` to inspect and edit workspace config. | --- | --- | --- | --- | | `workspace.id` | string | yes | Generated workspace ID. | | `workspace.default_branch` | string | no | Existing branch ref segment. | +| `runtime.provider` | string | no | `auto`, `docker`, `podman`, or `colima`. | +| `runtime.execution_backend` | string | no | `host` (default) or `colima`; `colima` requires `runtime.provider=colima`. | +| `runtime.colima_profile` | string | no | Empty for a workspace-derived profile, or 1-63 lowercase letters, digits, and interior hyphens. | +| `runtime.colima_autostart` | bool | no | Start the selected Colima profile during runtime reconciliation. | | `recording.mode` | string | no | `save`, `manual`, `watch`. | | `recording.debounce_ms` | u64 | no | Unsigned integer, zero allowed. | | `recording.ignore_gitignored` | bool | no | Boolean parser values. | @@ -70,6 +74,23 @@ False values: Trail stores Prolly tree nodes in `.trail/index/trail.sqlite`. The read-only `storage.prolly_backend` value remains in workspace config as a format marker. +## Runtime Provider + +`runtime.provider=auto` preserves compatibility by probing the ambient Docker +CLI and then Podman. Explicit `docker` and `podman` values disable fallback. +`colima` uses `runtime.colima_profile`, or a stable workspace-derived name when +that value is empty, and targets its profile-specific Docker context without +changing the active context. Explicit setup reuses complete system tools or, +on supported macOS hosts, installs a pinned and SHA-256-verified Trail-managed +Colima/Lima/Docker CLI toolchain. Ordinary config, status, reconcile, HTTP, MCP, +and daemon operations never download tools. See [Use Colima for lane runtime services](cli/integrations-and-maintenance.md#use-colima-for-lane-runtime-services). + +`runtime.execution_backend=colima` additionally sends managed lane commands and +test/eval gates through the profile's Lima guest without a host mount. Setup +must start and verify a Trail-contained profile before this value can be +selected. The agent/coordinator remains on the host. See +[Colima-sandboxed lane execution](../lanes/colima-sandboxed-execution.md). + ## Lane Hardening Keys `lane.claim_enforcement` controls whether active write claims/leases are treated diff --git a/docs/reference/http-api.md b/docs/reference/http-api.md index 9fbf06f9..3974b993 100644 --- a/docs/reference/http-api.md +++ b/docs/reference/http-api.md @@ -10,6 +10,12 @@ the same fields serialized by Rust, CLI JSON/NDJSON, and MCP structured content. Artifact resolution, inspection, verification, quarantine, reachability, workspace accounting, and source export routes serialize the same public Rust report types used by CLI JSON/NDJSON and MCP structured content. +Runtime reconciliation resolves the configured Docker, Podman, or Colima +provider. With Colima autostart enabled, the existing reconcile route may start +the configured profile before creating lane resources, but it never downloads +runtime tools. Trail-managed provisioning is confined to the explicit local +CLI/Rust setup operation; provider setup/status reports themselves remain +CLI/Rust-only. Lane spawn reports include the shared layered-backend prerequisite report. The daemon serves JSON HTTP routes under `/v1`. @@ -116,6 +122,8 @@ x-trail-token: | POST | `/v1/lanes/{lane_or_id}/hydrate` | Hydrate sparse workdir paths. | | POST | `/v1/lanes/{lane_or_id}/sync-workdir` | Sync workdir. | | POST | `/v1/lanes/{lane_or_id}/record` | Record lane workdir. | +| POST | `/v1/lanes/{lane_or_id}/exec` | Run a managed command through the configured host or no-mount Colima backend and checkpoint validated source changes. | +| POST | `/v1/lanes/{lane_or_id}/exec/cancel` | Cancel one owned Colima execution before candidate import; accepts optional `execution_id`. | | POST | `/v1/lanes/{lane_or_id}/rewind` | Rewind lane branch. | | POST | `/v1/lanes/{lane}/merge` | Dry-run or explicitly direct-merge this lane into body field `into`. | | POST | `/v1/lanes/{lane_or_id}/tests` | Run test gate. | @@ -125,6 +133,9 @@ x-trail-token: | POST | `/v1/lanes/{lane_or_id}/environment/resolve` | Resolve or reuse one pinned component snapshot. Body: `component`, optional `path` and `refresh`. | | POST | `/v1/lanes/{lane_or_id}/environment/resolve-all` | Resolve or reuse every incomplete component snapshot. Body: optional `path` and `refresh`. | | POST | `/v1/lanes/{lane_or_id}/environment/source-export` | Export one declared generated-source subtree through normal lane source writes. Body: `component` and `export`. | +| GET | `/v1/lanes/{lane_or_id}/environment/runtime/status` | Return persisted runtime state without contacting the configured provider. | +| POST | `/v1/lanes/{lane_or_id}/environment/runtime/reconcile` | Resolve the configured provider, optionally autostart Colima, and reconcile lane-private OCI resources. | +| POST | `/v1/lanes/{lane_or_id}/environment/runtime/stop` | Stop Trail-owned containers while retaining private networks and volumes. | Patch requests accept either native `edits` or compatibility `files`; provide one non-empty array, not both. @@ -146,6 +157,26 @@ and `transparent_cow_available`. On macOS, `nfs-cow` reports `workdir_backend: "nfs"` and requires no macFUSE installation. On Windows, `dokan-cow` reports `workdir_backend: "dokan"`. +`POST /v1/lanes/{lane_or_id}/exec` requires a non-empty `command` argv and +accepts optional `turn_id` and `timeout_secs` (1 through 86400; Colima default +3600). An open same-lane turn receives the checkpoint and session/turn/trace +provenance. The response identifies the host/Colima execution backend, +`succeeded`/`command_failed`/`timed_out` classification, lifecycle phases, +projection/import receipts, checkpoint, and cleanup. Infrastructure and +candidate-validation failures remain HTTP errors rather than command exits: +`EXECUTION_VALIDATION_FAILED` uses 422 and +`EXECUTION_INFRASTRUCTURE_FAILED` uses 503. +The daemon dispatches this long-running route on a workspace-scoped worker so +the same authenticated server can continue accepting status and cancellation +requests while the execution is active. + +`POST /v1/lanes/{lane_or_id}/exec/cancel` accepts an optional `execution_id`. +When omitted, exactly one cancellable execution must exist for the lane. A +successful response records the prior phase, profile/instance, owned +process-group termination, and namespace cleanup. The cancelled blocking +request returns HTTP 409 with `EXECUTION_CANCELLED`; no candidate state is +imported and unrelated profile processes are preserved. + `POST /v1/lanes/{lane_or_id}/hydrate` accepts the same body as path-scoped `sync-workdir`, but requires at least one `paths` entry. diff --git a/docs/reference/mcp-tools.md b/docs/reference/mcp-tools.md index 781cfb5a..27f311d0 100644 --- a/docs/reference/mcp-tools.md +++ b/docs/reference/mcp-tools.md @@ -271,9 +271,32 @@ before non-dry-run apply. - `trail.lane_handoff` - `trail.lane_rewind` - `trail.lane_remove` +- `trail.lane_workspace` +- `trail.lane_mount` +- `trail.lane_unmount` +- `trail.lane_checkpoint` +- `trail.lane_exec` +- `trail.lane_exec_cancel` + +`trail.lane_exec` accepts a direct `command` argv plus optional open-lane +`turn_id` and Colima `timeout_secs` (1 through 86400). It uses the workspace's +configured execution backend and returns the shared managed lifecycle, +checkpoint, sandbox, cleanup, and session/turn/trace provenance report. The +Colima backend never exposes a host mount or Docker socket to the guest. + +`trail.lane_exec_cancel` accepts `lane` and an optional `execution_id`. It +requests cancellation before terminating the exact Trail-owned guest process +group, skips candidate import, cleans the execution namespace, and returns a +typed cancellation receipt. The original `trail.lane_exec` call returns the +structured `EXECUTION_CANCELLED` error. MCP stdio requests are ordered within a +connection, so cancel a blocking call from a second Trail MCP session or an +equivalent CLI/HTTP client. ## Lane Environments and Artifacts +- `trail.env_runtime_status` +- `trail.env_runtime_reconcile` +- `trail.env_runtime_stop` - `trail.env_resolve` - `trail.env_resolve_all` - `trail.artifact_space` @@ -286,7 +309,12 @@ before non-dry-run apply. - `trail.env_source_export` Resolve tools are open-world writes because a reviewed host-owned resolver may -execute an exact external package-manager command. Artifact space, inspection, +execute an exact external package-manager command. Runtime reconcile is also an +open-world write: it resolves the configured Docker, Podman, or Colima provider +and may autostart the selected Colima profile before creating lane resources. +It never downloads runtime tools; Trail-managed provisioning requires the +explicit local CLI/Rust setup operation. Provider setup/status reports remain +CLI/Rust-only. Artifact space, inspection, reachability, verification, and quarantine queries are guarded read-only calls. Quarantine resolution and source export are destructive writes because they change retained artifact policy or normal lane source state. Every tool returns diff --git a/openspec/changes/integrate-colima-runtime/.openspec.yaml b/openspec/changes/integrate-colima-runtime/.openspec.yaml new file mode 100644 index 00000000..b6b2d1f6 --- /dev/null +++ b/openspec/changes/integrate-colima-runtime/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-13 diff --git a/openspec/changes/integrate-colima-runtime/design.md b/openspec/changes/integrate-colima-runtime/design.md new file mode 100644 index 00000000..c2e15bfd --- /dev/null +++ b/openspec/changes/integrate-colima-runtime/design.md @@ -0,0 +1,138 @@ +## Context + +Trail's lane environment model already separates immutable OCI image identity from lane-private runtime allocations. `workspace_runtime.rs` reconciles those allocations through a private `RuntimeProvider` trait, and the first part of this change adds an explicit Colima provider with a pinned managed toolchain and no-mount profile. Managed execution already owns environment discovery and sync, runtime-service reconciliation, layered lane-view mounting, execution phases, checkpointing, receipts, disposal, and unmounting. The remaining gap is that `exec_lane_workspace` and agent launch still spawn project processes on the host. + +The integration crosses configuration, external process execution, filesystem projection, lane lifecycle, typed reports, recovery, CLI/HTTP/MCP transport, agent containment, and public documentation. Colima and Lima are independently released Go programs with VM images and large transitive supply-chain surfaces. Trail therefore needs both a bounded managed-toolchain contract and a Trail-owned guest-execution protocol rather than opaque binaries, persistent host mounts, or Lima's interactive synchronization policy. + +## Goals / Non-Goals + +**Goals:** + +- Make Colima-backed lane service containers work through one Trail setup command and on-demand startup. +- Never depend on or mutate the user's ambient Docker context. +- Keep the Trail host authoritative for image verification, names, labels, ports, health, lifecycle, and cleanup. +- Use a workspace-specific Colima profile with no host mounts, SSH-agent forwarding, global context activation, Kubernetes, or reachable bridged address. +- Preserve deterministic provider selection and actionable fail-closed errors. +- Keep existing `auto` Docker/Podman behavior compatible. +- Require no manual Colima, Lima, Docker CLI, Homebrew, or administrator installation on supported macOS hosts. +- Pin, verify, atomically publish, and report the exact managed toolchain identity. +- Make managed commands and readiness gates runnable inside the same no-mount Colima VM as lane-private services. +- Keep durable Trail objects, refs, lane roots, line identity, sessions, turns, traces, approvals, and checkpoints authoritative across guest execution. +- Project a bounded lane snapshot into the guest and import candidate results only after path, type, size, and containment validation. +- Preserve deterministic cancellation, timeout, output-limit, cleanup, and crash-recovery behavior. +- Give AI agents a single CLI/HTTP/MCP managed-command capability whose receipts identify the exact sandbox and lane state used. + +**Non-Goals:** + +- Store third-party executables in Git or inside the `trail` executable. +- Automatically download tools during ordinary status, reconcile, daemon, HTTP, or MCP operations; network installation remains confined to explicit CLI/Rust setup. +- Manage QEMU on Linux or macOS versions that cannot use Apple's Virtualization framework in this change. +- Expose `.trail/`, the original checkout, or a Docker socket inside a lane command. +- Support Colima `containerd`, Kubernetes, Incus, remote daemons, or direct Lima command execution. +- Copy secret bytes into the VM. Colima-backed services with host file-secret bindings remain blocked until a separate broker is designed. +- Promise that every third-party agent provider binary is installed inside the guest. Trail keeps the provider control process in its existing contained host launcher unless a separately pinned guest distribution is available; the sandboxed data plane is the managed lane command/gate interface. +- Treat a guest filesystem as durable lane history or allow guest writes to bypass Trail validation and checkpointing. + +## Decisions + +### Colima is a host-owned OCI provider, not an environment adapter + +Adapters continue to declare provider-neutral `oci` runtime resources. Provider selection occurs at reconciliation, below managed execution, so adapters cannot start VMs, choose contexts, or gain provider sockets. This reuses Trail's existing runtime allocation and cleanup invariants. + +Alternative: implement Colima as an adapter plugin. Rejected because plugins are planners and deliberately have no provider or process authority. + +### Use a pinned managed toolchain with system reuse + +Trail first accepts a complete system `colima`, `limactl`, and `docker` toolchain. If any member is missing during explicit setup on supported macOS hosts, Trail downloads the complete pinned toolchain from immutable upstream release URLs. Colima and the Docker CLI are direct or single-file artifacts; the matching Lima distribution is unpacked with its required `share/lima` data. Every archive is size-bounded and checked against a SHA-256 digest compiled into Trail before extraction or publication. + +Managed versions publish atomically below the user's Trail cache, never into `PATH`, `/usr/local`, Homebrew, or the workspace. Mutable VM and Docker configuration lives below Trail's user data directory, separately from the replaceable tool cache. A toolchain receipt identifies source, versions, platform, and manifest digest; bundled notices cover Colima's MIT license and Lima/Docker's Apache-2.0 licenses. + +Ordinary provider detection is network-free: it may reuse an already-published managed toolchain but never repairs or downloads one. Missing or corrupt managed state directs the user back to explicit setup. Updating pins is a reviewed Trail release change; Trail never follows an unpinned `latest` URL. + +Alternative: copy binaries into the Trail executable or repository. Rejected because it inflates every platform package, obscures third-party provenance, and prevents independent atomic replacement. The managed cache gives the same no-install user experience while retaining inspectable artifacts and receipts. + +### Add explicit workspace runtime configuration + +`TrailConfig` gains a defaulted runtime section with provider (`auto`, `docker`, `podman`, or `colima`), optional Colima profile, and Colima autostart. Missing fields deserialize to the compatibility defaults. A missing profile resolves to `trail-`, avoiding collisions while remaining stable for a workspace. + +`trail env runtime setup colima [--profile NAME] [--no-start]` resolves or provisions the toolchain, optionally starts/preflights the dedicated profile, and atomically persists the desired provider settings only after successful preflight. Read-only provider status is separately reportable and includes whether tools are system or Trail-managed. + +### Address Colima through an explicit Docker context + +Every Docker operation uses `docker --context ...` and removes `DOCKER_HOST` from the child environment. The default Colima profile maps to context `colima`; other profiles map to `colima-`. Trail never calls `docker context use` and starts Colima with automatic activation disabled. + +### Start with a contained profile contract + +When autostart is required, Trail invokes Colima with fixed safe flags: Docker runtime, Apple's `vz` backend for a managed macOS toolchain, no mounts, no SSH-agent forwarding, no generated host SSH config, no Kubernetes, no reachable VM address, and no global context activation. `COLIMA_HOME` and `DOCKER_CONFIG` point at Trail-owned user data so even context metadata is isolated. Trail waits for `docker --context ... info` to succeed before publishing configuration or reconciling resources. Diagnostic output is bounded. + +An already-running configured profile is accepted only when its explicit Docker context is healthy; the runtime receipt labels its containment as externally retained rather than claiming Trail verified historical startup flags. The workspace-derived default minimizes accidental adoption. A later hardening change may add cryptographically bound profile manifests if stronger adoption evidence is required. + +### Block host-file secrets under no-mount Colima profiles + +Colima's Docker daemon resolves bind-source paths inside the VM. Making arbitrary host secret paths visible would defeat the no-mount contract. The Colima provider therefore rejects nonempty resolved secret bindings before container creation with remediation to use Docker/Podman or a future broker. + +### Add a managed-execution backend below every public command surface + +Workspace runtime configuration gains a defaulted execution backend with `host` and `colima` values. `host` preserves existing behavior. `colima` is accepted only with the Colima provider and a ready contained profile. Backend selection happens inside managed execution after environment and service preparation, so CLI, HTTP, MCP, readiness gates, and agent workflows share one domain operation and one typed report. + +The agent provider process remains the host-side control plane under Trail's existing scrubbed home and platform containment. Agents obtain the stronger data-plane boundary by calling `trail.lane_exec` through MCP, HTTP, or CLI; readiness policies use the same operation. Trail reports these two containment layers separately and never claims the provider process ran in the VM when it did not. + +Alternative: add transport-specific Colima commands. Rejected because they would bypass the lane preparation, checkpoint, provenance, and recovery state machine. + +### Reuse the Colima profile's underlying Lima instance + +Colima profile names map deterministically to Lima instance names (`colima` for the default profile and `colima-` otherwise). Trail invokes the already-verified managed `limactl` with an explicit `LIMA_HOME` and instance name. Guest commands execute in the same VM whose Docker daemon owns lane service containers, so published service ports are guest-local and do not require exposing host loopback or a bridged VM address. + +Trail does not invoke an ambient `ssh`, generated host SSH configuration, or a shell-constructed command. The executable and each argument remain separate, environment inheritance is allowlisted, and standard input/output/error are bounded by the existing process limits. + +Alternative: create a second direct Lima VM for commands. Rejected because it duplicates lifecycle and disk cost and cannot treat Colima VM-local published service ports as local endpoints. + +### Project and import; never mount the host lane + +Each execution receives a random execution-scoped guest directory beneath a fixed Trail-owned guest root. Trail streams a deterministic, size-bounded archive of the lane-visible workspace into that directory. The archive excludes `.trail`, `.git`, ignored/private paths, sockets, devices, unsupported file kinds, and any path outside the lane view. It preserves only the portable modes and symlinks already accepted by Trail's path policy. + +After execution, Trail streams a candidate archive back into a host staging directory owned by the managed-execution context. Before touching the lane view, Trail validates archive entry count, total and per-file size, relative NFC path policy, reserved paths, collisions, symlink targets, file kinds, and containment. It computes the candidate delta against the projected input, applies that delta through the existing lane materialization barrier, and invokes the existing checkpoint/finalization path. A failed validation or apply leaves the lane root and durable refs unchanged. + +Trail deliberately does not use Lima `--sync`: its interactive accept/view/discard prompt and rsync merge policy would create a second authority for conflicts and publication. Trail owns acceptance and durable history. + +### Translate service and environment bindings for the guest + +Service allocation identity remains provider-neutral and durable. The host backend retains `127.0.0.1:` bindings. The Colima backend rewrites only the execution environment so a service address resolves inside the Colima VM, while preserving service name, published port, allocation identity, and `TRAIL_SERVICES_JSON` schema. It never passes the Docker socket. + +Host environment-generation output is projected read-only when it is inside Trail-owned generation roots and declared by the environment plan. Arbitrary host absolute paths and file-secret bindings are rejected. Writable caches are execution-scoped guest state and are not imported into source history unless the adapter explicitly declares a portable output already covered by Trail's environment contract. + +### Journal the sandbox lifecycle and recover idempotently + +Managed-execution phases expand to cover guest projection, guest execution, result export, candidate validation/import, checkpoint, and guest cleanup. The preparation receipt records the backend, profile/instance, toolchain identity, source root, projection digest, guest namespace, declared limits, and service bindings without secret values. Finalization records the output digest, exit classification, checkpoint operation, cleanup result, and any retained diagnostic namespace. + +Guest directories are disposable projections, never sources of truth. Startup and doctor/recovery paths list only the configured instance's fixed Trail execution root, validate Trail-owned manifests, and remove stale namespaces whose durable execution is terminal or missing. Ambiguous ownership, a running process, or an uncheckpointed exported candidate fails closed and returns explicit recovery guidance. Cleanup is idempotent and never stops or deletes the Colima profile. + +### Bound authority, time, and output + +Projection/import bytes, archive entries, individual files, command output, execution duration, and concurrent guest executions use explicit limits. Timeout and cancellation terminate the guest process group before export. Non-zero command exit still permits importing valid source changes under existing managed-execution semantics; infrastructure, validation, cancellation, and cleanup failures remain distinct typed states. Secret values are removed before receipts, diagnostics, logs, HTTP, MCP, or CLI output. + +## Risks / Trade-offs + +- **First startup downloads a VM image and can take minutes** → Run only after the explicit setup command or when the workspace explicitly selects Colima with autostart; surface bounded diagnostics and status. +- **Managed installation is a supply-chain boundary** → Use immutable versioned URLs, compile-time SHA-256 pins, bounded downloads/extraction, atomic publication, receipts, and retained licenses; never execute an unverified stage. +- **Linux and older macOS need QEMU** → Keep system-toolchain support there and fail with actionable guidance; installation-free managed provisioning initially supports macOS arm64/x86_64 hosts capable of `vz`. +- **A running pre-existing profile may have broader historical settings** → Use a workspace-derived name, explicit context, and honest containment reporting; never claim its configuration was revalidated. +- **No host-file secrets with Colima** → Fail before container creation rather than expose home directories; retain existing Docker/Podman support for those declarations. +- **One VM per workspace consumes disk and memory** → Profiles are stable and reused; Trail stops only lane containers, not the VM, and never deletes profile data implicitly. +- **Docker/Colima output and schemas can change** → Depend primarily on exit status and the stable Docker CLI contract; keep parsing small, bounded, and tolerant of additive JSON fields. +- **Copy-based execution is slower than a host mount** → Use deterministic archive projection, skip unchanged imports by digest, retain the long-lived VM, and prefer safety over direct mutation; add scale limits and measurements. +- **A guest command may be killed between mutation and export** → Treat the guest directory as disposable, record the phase, and leave the durable lane root unchanged unless a fully validated candidate is imported and checkpointed. +- **Service addresses differ between host and guest** → Derive backend-local bindings from the same durable allocation report and verify them before execution. +- **Host-side agent providers can still expose their own host tools** → Keep existing platform containment, distinguish control-plane from data-plane enforcement in reports, and document that strict guest enforcement applies to Trail-managed commands and gates. + +## Migration Plan + +Existing workspaces deserialize the runtime section to `provider = "auto"` and `execution_backend = "host"`, preserving current behavior. Users opt in through `trail env runtime setup colima --execution-backend colima` or the corresponding configuration command. Rollback sets `runtime.execution_backend` to `host` and optionally `runtime.provider` to `auto`. Rollback does not delete or stop the Colima profile because that persistent external state requires an explicit user action. + +Existing users with system tools continue using them. New users on supported macOS hosts receive the pinned managed toolchain during setup. Removing the cache is recoverable by rerunning setup; removing mutable VM state remains an explicit user action. + +## Open Questions + +- Whether Trail should broker runtime secrets through an in-guest ephemeral filesystem or Docker API upload without ever persisting bytes. +- Whether future strict-isolation readiness should require a signed/pinned Colima guest-image and profile manifest. +- Which agent providers should eventually gain separately pinned Linux guest distributions so their control process, not only their managed project-command data plane, can move into the VM. diff --git a/openspec/changes/integrate-colima-runtime/proposal.md b/openspec/changes/integrate-colima-runtime/proposal.md new file mode 100644 index 00000000..16d49132 --- /dev/null +++ b/openspec/changes/integrate-colima-runtime/proposal.md @@ -0,0 +1,38 @@ +## Why + +Trail can already reconcile lane-private OCI services and prepare managed lane executions, but project commands still execute on the host. On macOS, Trail needs one contained, installation-free Colima boundary that can host both lane services and untrusted command execution while Trail remains authoritative for lane state, checkpoints, provenance, and recovery. + +## What Changes + +- Add first-class runtime-provider configuration for `auto`, `docker`, `podman`, and `colima`. +- Add a one-command Colima setup flow that selects a dedicated profile, starts it when requested, and verifies its Docker endpoint without activating it globally. +- Make setup installation-free on supported macOS hosts by downloading a Trail-pinned Colima, Lima, and Docker CLI toolchain when a complete compatible system toolchain is unavailable. +- Verify every managed artifact against a compile-time SHA-256 allowlist, publish it atomically into Trail's global cache, and retain third-party license notices alongside it. +- Run every Colima-backed OCI operation through the profile's explicit Docker context rather than ambient `DOCKER_HOST` or Docker context state. +- Create/start Trail's dedicated Colima profile with no host filesystem mounts, no SSH-agent forwarding, and no automatic Docker/Kubernetes context activation. +- Report the selected provider and Colima profile/context through typed Rust and CLI JSON output. +- Fail closed for Colima-backed file-secret mounts until a VM-safe secret broker exists. +- Preserve the current Docker/Podman auto-detection behavior for existing workspaces. +- Add an explicit managed-execution backend with compatibility-preserving `host` and opt-in `colima` values. +- Project only the lane-visible workspace into an execution-scoped directory inside the no-mount Colima VM, execute there, and import validated results through Trail's existing checkpoint path. +- Route CLI, HTTP, MCP, readiness-gate, and agent-managed project commands through the same backend contract rather than duplicating lane behavior at each surface. +- Make Colima-hosted lane services reachable from guest executions through backend-specific bindings without exposing the Docker socket or host loopback assumptions. +- Persist deterministic execution receipts covering the backend, profile, input/output identity, command result, limits, checkpoint, cleanup, and recovery outcome. +- Recover or fail closed on interrupted projection, execution, import, checkpoint, and guest-cleanup phases. + +## Capabilities + +### New Capabilities + +- `colima-runtime-provider`: Safe setup, selection, lifecycle, reporting, lane-private OCI resources, and no-mount managed execution through a dedicated Colima/Lima profile. + +### Modified Capabilities + +None. + +## Impact + +- Affects workspace configuration, lane runtime reconciliation, managed execution, lane command and gate runners, agent containment reporting, CLI/HTTP/MCP contracts, typed reports, recovery, tests, reference documentation, and the changelog. +- Adds no linked Rust dependency and does not store third-party binaries in the repository or `trail` executable. Supported macOS users need no separate installation: Trail fetches pinned upstream release artifacts on explicit setup, while complete compatible system installations remain reusable. +- Existing configurations and runtime behavior remain compatible because the default provider remains `auto` and the default execution backend remains `host`; selecting `colima` makes the stronger execution boundary explicit. +- The agent provider process remains a contained host-side control plane unless a provider has a separately managed guest distribution. Its project command, test, service, and gate data plane can use the Colima backend through Trail's CLI, HTTP, or MCP interfaces without transferring broad host credentials into the VM. diff --git a/openspec/changes/integrate-colima-runtime/specs/colima-runtime-provider/spec.md b/openspec/changes/integrate-colima-runtime/specs/colima-runtime-provider/spec.md new file mode 100644 index 00000000..33b8d3fa --- /dev/null +++ b/openspec/changes/integrate-colima-runtime/specs/colima-runtime-provider/spec.md @@ -0,0 +1,193 @@ +## ADDED Requirements + +### Requirement: Explicit runtime provider selection +Trail SHALL support workspace runtime provider values `auto`, `docker`, `podman`, and `colima`, and SHALL preserve `auto` as the default for workspaces without explicit runtime configuration. + +#### Scenario: Existing workspace opens +- **WHEN** a workspace configuration has no runtime section +- **THEN** Trail selects `auto` and retains ambient Docker-then-Podman detection behavior + +#### Scenario: Invalid provider is configured +- **WHEN** a caller attempts to configure an unsupported runtime provider value +- **THEN** Trail rejects the value without changing the active configuration + +### Requirement: One-command Colima setup +Trail SHALL provide a workspace command that resolves or provisions a complete Colima runtime toolchain, configures a profile, optionally starts it, verifies the profile-specific Docker endpoint, and returns a typed provider report. + +#### Scenario: Setup starts a missing profile +- **WHEN** the user runs Colima setup with startup enabled and the workspace profile is not running +- **THEN** Trail starts the profile with the contained Trail flags, verifies its explicit Docker context, and persists the provider configuration + +#### Scenario: Setup preflight fails +- **WHEN** tool resolution or provisioning, profile startup, or Docker endpoint verification fails +- **THEN** Trail reports bounded actionable diagnostics and does not publish the requested provider configuration + +#### Scenario: Setup without startup +- **WHEN** the user configures Colima with startup disabled +- **THEN** Trail resolves or provisions and verifies the required executables, persists autostart as disabled, and reports that the profile is not yet verified as running + +### Requirement: Installation-free managed toolchain +Trail SHALL provision a complete pinned Colima, Lima, and Docker CLI toolchain during explicit setup on supported macOS architectures when a complete system toolchain is unavailable. + +#### Scenario: No tools are installed +- **WHEN** setup runs on a supported macOS host without a complete system toolchain +- **THEN** Trail downloads the platform's immutable pinned artifacts, verifies their compiled SHA-256 digests, retains license notices and a receipt, and atomically publishes a ready managed toolchain without administrator access + +#### Scenario: Artifact verification fails +- **WHEN** any managed artifact is oversized, truncated, has the wrong digest, contains an unsafe archive entry, or lacks its expected executable +- **THEN** Trail removes only its staging state, executes nothing from it, retains any prior published toolchain, and fails setup without changing workspace configuration + +#### Scenario: Ordinary runtime operation lacks tools +- **WHEN** status, reconciliation, HTTP, MCP, or daemon behavior cannot find a complete system or previously published managed toolchain +- **THEN** Trail performs no network download and directs the caller to explicit setup + +#### Scenario: Managed provisioning is unsupported +- **WHEN** setup lacks system tools on an unsupported operating system, architecture, or virtualization backend +- **THEN** Trail reports the supported managed platforms and manual prerequisite guidance without partially installing tools + +### Requirement: Ambient-context independence +Trail SHALL execute every Colima-backed OCI operation through the configured profile's explicit Docker context and SHALL NOT change the user's active Docker context. + +#### Scenario: Another Docker context is active +- **WHEN** Trail reconciles a Colima-backed runtime while another Docker context or `DOCKER_HOST` is active +- **THEN** all inspection, image, network, volume, container, start, stop, and remove operations target only the configured Colima context + +### Requirement: Contained Colima startup +Trail SHALL start a managed Colima profile without host filesystem mounts, SSH-agent forwarding, host SSH configuration, Kubernetes, reachable bridged addressing, or automatic Docker/Kubernetes context activation. + +#### Scenario: Autostart command is constructed +- **WHEN** a selected Colima profile is stopped and autostart is enabled +- **THEN** Trail invokes Colima with fixed argv flags enforcing the contained startup contract and without shell interpolation + +#### Scenario: Trail-managed macOS toolchain starts +- **WHEN** the provisioned toolchain starts a profile on supported macOS +- **THEN** Trail selects the `vz` backend, prepends only the verified tool directory for child resolution, and isolates Colima and Docker configuration below Trail-owned user data + +### Requirement: Fail-closed Colima secret handling +Trail SHALL refuse Colima-backed container creation that requires host file-secret bind mounts until a VM-safe secret broker is available. + +#### Scenario: Service declares a file secret +- **WHEN** reconciliation selects Colima for a runtime resource with one or more resolved file secrets +- **THEN** Trail fails before container creation and does not broaden the Colima host mount set + +### Requirement: Provider lifecycle ownership +Trail SHALL retain its existing lane allocation, ownership-label, image-digest, health, stop, and cleanup checks regardless of runtime provider, and SHALL NOT implicitly stop or delete a Colima VM profile. + +#### Scenario: Managed execution completes +- **WHEN** a command using Colima-backed runtime services finishes +- **THEN** Trail stops its owned lane containers according to existing lifecycle rules but leaves the Colima profile and unrelated resources intact + +### Requirement: Typed and documented public contract +Trail SHALL expose selected provider, execution backend, profile, explicit context, readiness, autostart, startup action, containment status, toolchain source, and pinned managed version in Rust and CLI JSON output, and SHALL document setup, rollback, cache/state locations, prerequisites, licenses, and limitations. + +#### Scenario: Provider status is requested +- **WHEN** a caller requests environment runtime provider status +- **THEN** Trail returns the same typed report through Rust and CLI JSON with deterministic field meanings + +### Requirement: Explicit managed-execution backend +Trail SHALL support workspace managed-execution backend values `host` and `colima`, SHALL preserve `host` as the default for existing workspaces, and SHALL reject `colima` unless the selected provider and profile satisfy the contained Colima contract. + +#### Scenario: Existing workspace executes a command +- **WHEN** a workspace has no configured execution backend +- **THEN** Trail uses the existing host managed-execution behavior without changing serialized lane or command results + +#### Scenario: Colima execution is selected without a ready profile +- **WHEN** a managed command resolves `execution_backend = "colima"` but the dedicated profile or verified Lima tool is unavailable +- **THEN** Trail fails before command execution or source mutation with actionable setup guidance + +#### Scenario: Setup enables contained execution +- **WHEN** successful Colima setup is requested with the Colima execution backend +- **THEN** Trail persists the provider and backend together only after the no-mount profile and guest-execution preflight succeed + +### Requirement: No-mount lane projection +Trail SHALL project only the lane-visible managed workspace into an execution-scoped guest namespace and SHALL NOT mount or disclose the workspace root, original checkout, `.trail`, `.git`, host home, SSH agent, Docker socket, or unrelated lane state to the guest. + +#### Scenario: Guest execution starts +- **WHEN** Trail prepares a Colima-backed managed command +- **THEN** it creates a uniquely owned guest namespace, imports a bounded deterministic source projection, verifies its manifest, and sets the guest working directory to the projected lane root + +#### Scenario: Projection contains an unsafe entry +- **WHEN** projection encounters a reserved path, escaping symlink, unsupported file kind, collision, invalid normalized path, or configured size or entry limit +- **THEN** Trail executes no guest command, publishes no durable lane mutation, and removes only its owned staging and guest state + +### Requirement: Same-VM lane service access +Trail SHALL execute Colima-backed managed commands in the same Lima instance whose Docker daemon owns the lane-private service allocations and SHALL derive guest-local service bindings without exposing the Docker socket. + +#### Scenario: Lane command uses a private service +- **WHEN** managed execution has reconciled a healthy Colima-backed service +- **THEN** the guest command receives deterministic `TRAIL_SERVICE_*` and `TRAIL_SERVICES_JSON` bindings that reach that allocation from inside the VM + +#### Scenario: Service binding cannot be verified +- **WHEN** a required service is healthy from the provider but its guest-local binding cannot be established +- **THEN** Trail fails execution before running the project command and retains the existing service lifecycle and diagnostic report + +### Requirement: Trail-authoritative result import and checkpoint +Trail SHALL treat guest files as disposable candidate state, validate a bounded result projection, apply its delta through the lane materialization boundary, and use the existing managed-execution checkpoint operation as the only publication path. + +#### Scenario: Guest command changes source files +- **WHEN** a guest command exits and its candidate projection passes validation +- **THEN** Trail imports the delta, checkpoints it as a lane operation with stable path and line identity, and returns the resulting root and operation in the managed-execution report + +#### Scenario: Guest result is unchanged +- **WHEN** the candidate digest equals the input projection digest +- **THEN** Trail skips materialization writes and reports a successful execution with no checkpointed source change + +#### Scenario: Guest result validation or apply fails +- **WHEN** the exported candidate violates path, type, size, symlink, ignore, private-path, or containment policy, or cannot be applied atomically +- **THEN** Trail leaves the durable lane root and ref unchanged, reports an infrastructure failure distinct from the command exit, and retains only bounded recovery evidence + +### Requirement: Bounded guest process lifecycle +Trail SHALL execute guest commands without shell interpolation and SHALL bound duration, output, projection bytes, archive entries, individual files, and concurrent executions. Timeout and cancellation SHALL terminate the guest process group before result export. + +#### Scenario: Command exits non-zero +- **WHEN** the guest process exits with a non-zero code without an infrastructure failure +- **THEN** Trail reports the exact bounded command result and may import valid source changes under the same checkpoint policy as host execution + +#### Scenario: Command exceeds a limit +- **WHEN** execution, output, projection, or concurrency exceeds its configured bound +- **THEN** Trail terminates or rejects the execution, classifies the limit explicitly, and does not publish an unvalidated candidate + +#### Scenario: Caller cancels execution +- **WHEN** CLI, HTTP, MCP, gate, or agent orchestration cancels a running guest command +- **THEN** Trail terminates the owned guest process group, records cancellation, performs idempotent cleanup, and leaves unrelated guest processes and the Colima profile running + +### Requirement: Shared managed-command domain operation +Trail SHALL route CLI lane execution, HTTP and MCP lane-exec calls, readiness gates, and agent-managed project commands through the same library operation, backend selection, typed result, checkpoint, and error semantics. + +#### Scenario: AI agent invokes lane execution +- **WHEN** an agent calls `trail.lane_exec` for a lane whose backend is Colima +- **THEN** Trail runs the project command in the guest while associating the result with the active lane, session, turn, trace, command fingerprint, and checkpoint provenance + +#### Scenario: Agent provider remains host-side +- **WHEN** a terminal or ACP provider control process is launched on the host while managed commands use Colima +- **THEN** Trail reports host control-plane containment and guest data-plane containment separately and does not claim the provider binary ran inside the VM + +#### Scenario: Equivalent interfaces execute the same request +- **WHEN** equivalent CLI, HTTP, or MCP requests select the same lane, root, command, and backend +- **THEN** their structured reports use aligned field meanings, lifecycle phases, and error codes + +### Requirement: Durable provenance and recovery +Trail SHALL durably record the guest backend, profile and Lima instance, verified toolchain identity, source root, projection and candidate digests, guest namespace identity, limits, service allocation identities, command classification, checkpoint outcome, and cleanup status without persisting secret values. + +#### Scenario: Execution completes normally +- **WHEN** checkpoint and cleanup finish +- **THEN** the final managed-execution receipt is terminal, references the resulting lane operation or unchanged root, and records that the guest namespace was removed + +#### Scenario: Trail restarts after interruption +- **WHEN** recovery observes an execution interrupted during projection, execution, export, import, checkpoint, or cleanup +- **THEN** Trail reconciles durable phase and ownership evidence, resumes only an idempotent safe action, or fails closed with explicit guidance while preserving the last published lane root + +#### Scenario: Stale guest namespace has ambiguous ownership +- **WHEN** doctor or recovery cannot bind a guest namespace to a terminal or abandoned Trail execution manifest +- **THEN** Trail does not delete it automatically and reports bounded manual recovery details + +### Requirement: Guest cleanup preserves provider state +Trail SHALL remove only execution-scoped guest namespaces and processes it owns and SHALL NOT implicitly stop or delete the Colima profile, lane service allocations outside their existing lifecycle, or unrelated Lima state. + +#### Scenario: Cleanup is retried +- **WHEN** finalization or recovery repeats cleanup for an already-removed guest namespace +- **THEN** cleanup succeeds idempotently and preserves the terminal execution receipt + +#### Scenario: Cleanup command fails +- **WHEN** an owned guest namespace cannot be removed after bounded retries +- **THEN** Trail reports a recoverable cleanup blocker with the namespace identity and leaves the lane's durable checkpoint result unaltered diff --git a/openspec/changes/integrate-colima-runtime/tasks.md b/openspec/changes/integrate-colima-runtime/tasks.md new file mode 100644 index 00000000..9b4e6ac3 --- /dev/null +++ b/openspec/changes/integrate-colima-runtime/tasks.md @@ -0,0 +1,73 @@ +## 1. Configuration and public model + +- [x] 1.1 Add default-compatible runtime provider configuration and validated config list/get/set behavior. +- [x] 1.2 Add the typed runtime provider status/setup report and wire-compatible serialization tests. + +## 2. Colima provider lifecycle + +- [x] 2.1 Refactor OCI CLI execution to support explicit provider arguments and removal of ambient Docker endpoint variables. +- [x] 2.2 Implement workspace-derived Colima profile/context selection, prerequisite detection, contained startup, readiness verification, and bounded diagnostics. +- [x] 2.3 Route configured runtime reconciliation and cleanup through the selected provider while preserving ownership and digest checks. +- [x] 2.4 Reject Colima host-file secret mounts before container creation and leave the VM profile outside Trail's implicit cleanup authority. + +## 3. CLI and contracts + +- [x] 3.1 Add `trail env runtime provider status` and `setup colima` parsing, dispatch, and typed rendering. +- [x] 3.2 Add CLI and library regression tests for compatibility defaults, explicit context isolation, setup failure rollback, safe startup argv, and secret rejection. +- [x] 3.3 Align OpenAPI schemas or document why the workspace-only setup surface is intentionally CLI/Rust-only. + +## 4. Documentation and verification + +- [x] 4.1 Update runtime reference/design documentation, README guidance where appropriate, and the changelog with setup, rollback, security limits, and prerequisites. +- [x] 4.2 Run formatting, targeted runtime/config/CLI tests, workspace checks, and inspect the final diff without disturbing unrelated user changes. + +## 5. Trail-managed Colima distribution + +- [x] 5.1 Add platform-pinned Colima, Lima, and Docker CLI artifact manifests with immutable URLs, SHA-256 digests, size limits, and third-party notices. +- [x] 5.2 Implement safe global cache/data paths, bounded download and archive validation, executable verification, receipts, atomic publication, and concurrent setup convergence. +- [x] 5.3 Resolve a complete system toolchain first, fall back to an existing managed toolchain without network access, and provision only during explicit setup. +- [x] 5.4 Isolate managed Colima/Docker state, select macOS `vz`, and report system versus Trail-managed toolchain identity. +- [x] 5.5 Add failure, corruption, concurrency, fallback, and CLI contract tests without contacting upstream services. +- [x] 5.6 Update documentation and changelog for zero-install setup, supported platforms, cache/state lifecycle, licenses, and offline behavior. +- [x] 5.7 Validate OpenSpec, run formatting, targeted tests, workspace check/test/Clippy gates, and update the existing PR as a single coherent change. + +## 6. Managed-execution configuration and model + +- [x] 6.1 Add the default-compatible `host`/`colima` execution-backend configuration, validation, setup persistence, and rollback behavior. +- [x] 6.2 Extend typed runtime, managed-execution, and containment reports with backend, profile/instance, projection, checkpoint, and cleanup identity without exposing secrets. +- [x] 6.3 Add CLI parsing, config list/get/set behavior, JSON fixtures, and compatibility tests for backend selection and Colima setup preflight. + +## 7. No-mount guest execution protocol + +- [x] 7.1 Resolve the configured Colima profile to its explicit Lima instance and invoke only the verified `limactl` with isolated `LIMA_HOME` and bounded subprocess handling. +- [x] 7.2 Build deterministic, bounded lane-source projections that exclude private, ignored, reserved, and unsupported filesystem state while preserving accepted portable modes and symlinks. +- [x] 7.3 Create execution-scoped guest namespaces, stream and verify projections without host mounts, run argv without shell interpolation, and address the lane working directory explicitly. +- [x] 7.4 Export guest candidate state into host staging and validate entry count, byte limits, normalized paths, collisions, file kinds, modes, symlink targets, and containment before lane mutation. +- [x] 7.5 Compute and apply the validated candidate delta through the existing lane materialization barrier and checkpoint path, including unchanged and non-zero-exit cases. + +## 8. Managed lifecycle, services, and limits + +- [x] 8.1 Add a reusable execution-backend boundary beneath managed execution while preserving byte-for-byte compatible host behavior by default. +- [x] 8.2 Translate provider-neutral runtime service allocations and allowed environment-generation bindings into verified guest-local bindings without passing host paths or the Docker socket. +- [x] 8.3 Enforce bounded duration, stdout/stderr, projection size, file size, entry count, and concurrency with distinct timeout, cancellation, command-exit, validation, and infrastructure results. +- [x] 8.4 Integrate guest projection, execution, export, import, checkpoint, disposal, and cleanup into the existing managed-execution failure and finalization state machine. + +## 9. Agent, gate, and public-interface integration + +- [x] 9.1 Route library and CLI lane execution through the selected backend and expose deterministic human, JSON, and NDJSON output. +- [x] 9.2 Align HTTP/OpenAPI and MCP `trail.lane_exec` request, response, cancellation, backend, lifecycle, and error semantics with the shared library operation. +- [x] 9.3 Route readiness and verification gate commands through the same backend and attach their evidence to the gate and lane provenance records. +- [x] 9.4 Expose the guest managed-command capability to terminal and ACP agent workflows, associate session/turn/trace provenance, and report host control-plane versus guest data-plane containment honestly. + +## 10. Provenance, cleanup, and recovery + +- [x] 10.1 Extend preparation/finalization phases and receipts with toolchain, profile/instance, namespace, source/candidate digests, limits, service identities, exit classification, checkpoint, and cleanup fields. +- [x] 10.2 Make guest process and namespace cleanup bounded, ownership-checked, and idempotent across every success and failure edge without stopping the Colima profile. +- [x] 10.3 Add doctor/recovery reconciliation for interrupted projection, execution, export, import, checkpoint, and cleanup, failing closed on ambiguous or live guest ownership. +- [x] 10.4 Add adversarial redaction, path traversal, symlink escape, archive bomb, collision, secret, cancellation, crash, retry, and unrelated-profile preservation tests. + +## 11. Documentation and verification + +- [x] 11.1 Update the lane work-model, managed execution, environment runtime, CLI, HTTP, MCP, agent, security, recovery, and changelog documentation with setup, use cases, guarantees, boundaries, and rollback. +- [x] 11.2 Add host-compatibility and Colima guest protocol unit tests plus library, CLI, HTTP, MCP, gate, agent, checkpoint, recovery, and fault-injection integration coverage using fake local tools only. +- [x] 11.3 Validate OpenSpec, run formatting, targeted lane/runtime/managed-execution gates, workspace check/test/Clippy baselines, inspect the final diff, and update the existing single PR with verified and skipped evidence. diff --git a/trail/assets/runtime-toolchain/APACHE-2.0-LICENSE b/trail/assets/runtime-toolchain/APACHE-2.0-LICENSE new file mode 100644 index 00000000..9c8e20ab --- /dev/null +++ b/trail/assets/runtime-toolchain/APACHE-2.0-LICENSE @@ -0,0 +1,191 @@ + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2013-2017 Docker, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/trail/assets/runtime-toolchain/COLIMA-LICENSE b/trail/assets/runtime-toolchain/COLIMA-LICENSE new file mode 100644 index 00000000..42bf341b --- /dev/null +++ b/trail/assets/runtime-toolchain/COLIMA-LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Abiola Ibrahim + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/trail/assets/runtime-toolchain/THIRD_PARTY_NOTICES.md b/trail/assets/runtime-toolchain/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..6bab86e2 --- /dev/null +++ b/trail/assets/runtime-toolchain/THIRD_PARTY_NOTICES.md @@ -0,0 +1,17 @@ +# Trail-managed Colima runtime toolchain + +Trail can download and install the following pinned upstream command-line +tools into a user-owned cache when `trail env runtime setup colima` is run. +They are not linked into Trail and are not stored in the Trail repository. + +- Colima 0.10.3, copyright Abiola Ibrahim and contributors, MIT License: + https://github.com/abiosoft/colima +- Lima 2.2.0, copyright the Lima contributors, Apache License 2.0: + https://github.com/lima-vm/lima +- Docker CLI 29.7.2, copyright Docker, Inc. and contributors, Apache License + 2.0: https://github.com/docker/cli + +`COLIMA-LICENSE` contains Colima's required MIT notice. +`APACHE-2.0-LICENSE` contains the Apache License 2.0 applicable to Lima and +the Docker CLI. The upstream Lima archive also retains its own documentation +and license files below `share/doc/lima`. diff --git a/trail/src/cli/command.rs b/trail/src/cli/command.rs index 808419e0..d8a2c186 100644 --- a/trail/src/cli/command.rs +++ b/trail/src/cli/command.rs @@ -522,10 +522,58 @@ mod tests { EnvironmentRuntimeSubcommand::Status(args) => ("status", args.lane), EnvironmentRuntimeSubcommand::Reconcile(args) => ("reconcile", args.lane), EnvironmentRuntimeSubcommand::Stop(args) => ("stop", args.lane), + EnvironmentRuntimeSubcommand::Provider(_) + | EnvironmentRuntimeSubcommand::Setup(_) => { + panic!("expected lane runtime lifecycle command") + } }; assert_eq!(actual, expected); assert_eq!(lane, "lane-a"); } + + let cli = Cli::try_parse_from(["trail", "env", "runtime", "provider", "status"]) + .expect("runtime provider status should parse"); + let Command::Env(EnvironmentCommand { + command: EnvironmentSubcommand::Runtime(runtime), + }) = cli.command + else { + panic!("expected environment runtime command"); + }; + assert!(matches!( + runtime.command, + EnvironmentRuntimeSubcommand::Provider(EnvironmentRuntimeProviderCommand { + command: EnvironmentRuntimeProviderSubcommand::Status + }) + )); + + let cli = Cli::try_parse_from([ + "trail", + "env", + "runtime", + "setup", + "colima", + "--profile", + "trail-ci", + "--execution-backend", + "colima", + "--no-start", + ]) + .expect("Colima runtime setup should parse"); + let Command::Env(EnvironmentCommand { + command: EnvironmentSubcommand::Runtime(runtime), + }) = cli.command + else { + panic!("expected environment runtime command"); + }; + let EnvironmentRuntimeSubcommand::Setup(EnvironmentRuntimeSetupCommand { + command: EnvironmentRuntimeSetupSubcommand::Colima(args), + }) = runtime.command + else { + panic!("expected Colima runtime setup command"); + }; + assert_eq!(args.profile.as_deref(), Some("trail-ci")); + assert_eq!(args.execution_backend.as_deref(), Some("colima")); + assert!(args.no_start); } #[test] diff --git a/trail/src/cli/command/environment_args.rs b/trail/src/cli/command/environment_args.rs index dd1a6cd1..94b2ddd5 100644 --- a/trail/src/cli/command/environment_args.rs +++ b/trail/src/cli/command/environment_args.rs @@ -262,6 +262,47 @@ pub(super) enum EnvironmentRuntimeSubcommand { Reconcile(EnvironmentStatusArgs), /// Stop active containers while retaining their lane-private volumes and networks. Stop(EnvironmentStatusArgs), + /// Inspect the selected host runtime provider without starting it. + Provider(EnvironmentRuntimeProviderCommand), + /// Configure and optionally start a contained runtime provider. + Setup(EnvironmentRuntimeSetupCommand), +} + +#[derive(Args)] +pub(super) struct EnvironmentRuntimeProviderCommand { + #[command(subcommand)] + pub(super) command: EnvironmentRuntimeProviderSubcommand, +} + +#[derive(Subcommand)] +pub(super) enum EnvironmentRuntimeProviderSubcommand { + /// Show provider selection, readiness, context, and containment status. + Status, +} + +#[derive(Args)] +pub(super) struct EnvironmentRuntimeSetupCommand { + #[command(subcommand)] + pub(super) command: EnvironmentRuntimeSetupSubcommand, +} + +#[derive(Subcommand)] +pub(super) enum EnvironmentRuntimeSetupSubcommand { + /// Configure a dedicated Colima Docker profile for lane runtime services. + Colima(EnvironmentRuntimeSetupColimaArgs), +} + +#[derive(Args)] +pub(super) struct EnvironmentRuntimeSetupColimaArgs { + /// Explicit Colima profile; defaults to a stable workspace-derived name. + #[arg(long)] + pub(super) profile: Option, + /// Persist the provider without starting or verifying the profile endpoint. + #[arg(long)] + pub(super) no_start: bool, + /// Select where managed lane commands run after setup. + #[arg(long, value_parser = ["host", "colima"])] + pub(super) execution_backend: Option, } #[derive(Args)] diff --git a/trail/src/cli/command/handler.rs b/trail/src/cli/command/handler.rs index 9813b36c..b3f9fb77 100644 --- a/trail/src/cli/command/handler.rs +++ b/trail/src/cli/command/handler.rs @@ -492,25 +492,44 @@ fn handle_environment_command(ctx: &RuntimeContext, environment: EnvironmentComm "Promoted environment output", &db.promote_workspace_environment_output(&args.lane, &args.component, &args.output)?, ), - EnvironmentSubcommand::Runtime(runtime) => { - let generation = match runtime.command { - EnvironmentRuntimeSubcommand::Status(args) => db - .active_environment_generation(&args.lane)? - .ok_or_else(|| { - Error::InvalidInput(format!( - "lane `{}` has no active environment generation", - args.lane - )) - })?, - EnvironmentRuntimeSubcommand::Reconcile(args) => { - db.reconcile_workspace_environment_runtime(&args.lane)? - } - EnvironmentRuntimeSubcommand::Stop(args) => { - db.stop_workspace_environment_runtime(&args.lane)? + EnvironmentSubcommand::Runtime(runtime) => match runtime.command { + EnvironmentRuntimeSubcommand::Status(args) => { + let generation = + db.active_environment_generation(&args.lane)? + .ok_or_else(|| { + Error::InvalidInput(format!( + "lane `{}` has no active environment generation", + args.lane + )) + })?; + render_specialist(ctx, "Environment runtime", &generation) + } + EnvironmentRuntimeSubcommand::Reconcile(args) => { + let generation = db.reconcile_workspace_environment_runtime(&args.lane)?; + render_specialist(ctx, "Environment runtime", &generation) + } + EnvironmentRuntimeSubcommand::Stop(args) => { + let generation = db.stop_workspace_environment_runtime(&args.lane)?; + render_specialist(ctx, "Environment runtime", &generation) + } + EnvironmentRuntimeSubcommand::Provider(provider) => match provider.command { + EnvironmentRuntimeProviderSubcommand::Status => render_specialist( + ctx, + "Environment runtime provider", + &db.workspace_environment_runtime_provider_status()?, + ), + }, + EnvironmentRuntimeSubcommand::Setup(setup) => match setup.command { + EnvironmentRuntimeSetupSubcommand::Colima(args) => { + let report = db.setup_colima_workspace_environment_runtime_with_backend( + args.execution_backend.as_deref(), + args.profile.as_deref(), + !args.no_start, + )?; + render_specialist(ctx, "Configured Colima runtime", &report) } - }; - render_specialist(ctx, "Environment runtime", &generation) - } + }, + }, } } diff --git a/trail/src/cli/command/handler/agent.rs b/trail/src/cli/command/handler/agent.rs index ca6b7323..b7fb199c 100644 --- a/trail/src/cli/command/handler/agent.rs +++ b/trail/src/cli/command/handler/agent.rs @@ -1664,6 +1664,8 @@ fn run_terminal_agent_task( } .to_string(), sandbox_backend, + control_plane_backend: "host".to_string(), + managed_command_backend: managed.execution_backend.clone(), filesystem_enforcement: filesystem_enforcement.clone(), lane_root: workdir.clone(), git_work_tree, diff --git a/trail/src/cli/command/handler/errors.rs b/trail/src/cli/command/handler/errors.rs index e1cde60c..5ca54f43 100644 --- a/trail/src/cli/command/handler/errors.rs +++ b/trail/src/cli/command/handler/errors.rs @@ -289,6 +289,37 @@ fn diagnostic_for_error(err: &Error) -> UiDiagnostic { }); diagnostic } + Error::ExecutionCancelled { execution_id } => { + let mut diagnostic = UiDiagnostic::new(err.code(), "Managed execution was cancelled"); + diagnostic.consequence = Some(format!( + "Execution `{execution_id}` stopped before guest changes were imported." + )); + diagnostic + } + Error::ExecutionValidation { execution_id, .. } => { + let mut diagnostic = + UiDiagnostic::new(err.code(), "Managed execution candidate failed validation"); + diagnostic.consequence = Some(format!( + "Execution `{execution_id}` did not import or checkpoint guest candidate state." + )); + diagnostic + } + Error::ExecutionInfrastructure { + execution_id, + phase, + .. + } => { + let mut diagnostic = + UiDiagnostic::new(err.code(), "Managed execution infrastructure failed"); + diagnostic.consequence = Some(format!( + "Execution `{execution_id}` stopped in `{phase}` and retained recovery evidence." + )); + diagnostic.recovery = Some(UiNextAction { + command: "trail doctor".to_string(), + reason: "Inspect guest runtime and recovery state before retrying.".to_string(), + }); + diagnostic + } Error::CloneUnsupported | Error::CloneCrossDevice | Error::NativeCowSourceUnavailable => { let mut diagnostic = UiDiagnostic::new(err.code(), "Strict native COW is unavailable"); diagnostic.consequence = Some( diff --git a/trail/src/cli/command/handler/lane.rs b/trail/src/cli/command/handler/lane.rs index 8c0bb922..4c29ceb7 100644 --- a/trail/src/cli/command/handler/lane.rs +++ b/trail/src/cli/command/handler/lane.rs @@ -164,9 +164,20 @@ pub(super) fn handle_lane_command(ctx: &RuntimeContext, lane: LaneCommand) -> Re } LaneSubcommand::Exec(args) => { let mut db = open_db(ctx)?; - let report = db.exec_lane_workspace(&args.name, &args.command)?; + let report = db.exec_lane_workspace_with_options( + &args.name, + &args.command, + args.turn.as_deref(), + args.timeout_secs, + )?; render_workspace_exec(&report, ctx.json, &ctx.render) } + LaneSubcommand::ExecCancel(args) => { + let db = open_db(ctx)?; + let report = + db.cancel_lane_workspace_execution(&args.name, args.execution_id.as_deref())?; + render_workspace_exec_cancellation(&report, ctx.json, &ctx.render) + } LaneSubcommand::Mount(args) => { let db = open_db(ctx)?; let _foreground = args.foreground; diff --git a/trail/src/cli/command/lane_args.rs b/trail/src/cli/command/lane_args.rs index 35d98980..87498729 100644 --- a/trail/src/cli/command/lane_args.rs +++ b/trail/src/cli/command/lane_args.rs @@ -75,6 +75,8 @@ pub(super) enum LaneSubcommand { Space(LaneSpaceArgs), /// Mount a layered lane for one command with isolated cache/target variables. Exec(LaneExecArgs), + /// Cancel one owned Colima managed execution before guest changes are imported. + ExecCancel(LaneExecCancelArgs), /// Own a layered lane mount in the foreground until `lane unmount` requests teardown. Mount(LaneMountArgs), /// Ask the foreground mount owner to release a layered lane safely. @@ -289,10 +291,24 @@ pub(super) struct LaneSpaceArgs { #[derive(Args)] pub(super) struct LaneExecArgs { pub(super) name: String, + /// Associate the execution checkpoint and provenance with an open lane turn. + #[arg(long)] + pub(super) turn: Option, + /// Bound Colima guest command duration (default: 3600 seconds). + #[arg(long)] + pub(super) timeout_secs: Option, #[arg(last = true, num_args = 1.., required = true)] pub(super) command: Vec, } +#[derive(Args)] +pub(super) struct LaneExecCancelArgs { + pub(super) name: String, + /// Select one execution when a lane has multiple live managed commands. + #[arg(long)] + pub(super) execution_id: Option, +} + #[derive(Args)] pub(super) struct LaneRewindArgs { pub(super) name: String, diff --git a/trail/src/cli/command/render/lane/work.rs b/trail/src/cli/command/render/lane/work.rs index ce2070da..14c2b473 100644 --- a/trail/src/cli/command/render/lane/work.rs +++ b/trail/src/cli/command/render/lane/work.rs @@ -201,6 +201,14 @@ pub(crate) fn render_workspace_exec( ("Command".to_string(), report.command.join(" ")), ("View".to_string(), report.view_id.clone()), ("Backend".to_string(), report.backend.clone()), + ( + "Execution backend".to_string(), + report.execution_backend.clone(), + ), + ( + "Exit classification".to_string(), + report.exit_classification.clone(), + ), ("Generation".to_string(), report.generation.to_string()), ( "Execution".to_string(), @@ -236,6 +244,34 @@ pub(crate) fn render_workspace_exec( render_document(&document, options) } +pub(crate) fn render_workspace_exec_cancellation( + report: &WorkspaceExecCancellationReport, + json: bool, + options: &RenderOptions, +) -> Result<()> { + if json { + return render_json(report); + } + render_document( + &TerminalDocument::new( + format!("Cancelled managed execution {}", report.execution_id), + UiTone::Success, + ) + .block(UiBlock::Metadata(vec![ + ("Lane".to_string(), report.lane_id.clone()), + ("Previous phase".to_string(), report.phase_before.clone()), + ("Profile".to_string(), report.profile.clone()), + ("Lima instance".to_string(), report.lima_instance.clone()), + ( + "Process group terminated".to_string(), + report.process_group_terminated.to_string(), + ), + ("Cleanup".to_string(), report.cleanup_status.clone()), + ])), + options, + ) +} + pub(crate) fn render_workspace_mount( report: &WorkspaceMountReport, state: &str, diff --git a/trail/src/db/change_ledger/activation.rs b/trail/src/db/change_ledger/activation.rs index 04ca2b14..139cad93 100644 --- a/trail/src/db/change_ledger/activation.rs +++ b/trail/src/db/change_ledger/activation.rs @@ -4,14 +4,14 @@ use sha2::{Digest, Sha256}; const APPROVED_PRODUCER_INVENTORY_SHA256: &str = "af2cca0566976a6d6f6cea00e99fe5089c91e357ca1d0a50fd5397edcda32833"; const APPROVED_RAW_MUTATION_INVENTORY_SHA256: &str = - "9555ad8d0be83713955c1d6e3a6dfb8524d4d861c473f9a51bdc4e3144d0518d"; + "251de4f3ec9b185c082ce88528d84e8967fed782d7d3128b977aa4309d0d0bea"; const APPROVED_ACTIVATION_AUDIT_SHA256: &str = - "9f5f462f7eae0fc6f903c4fb89146b3f59caf6bb5b415221a65938edba59e4df"; + "de0d527ef2278c78bdda7744d929ba9177a1ec76c7f81d1597724a6a0709f01c"; const ACTIVATION_AUDIT_MANIFEST: &str = concat!( "trail-changed-path-activation-v1\n", "schema=1\n", "producer=af2cca0566976a6d6f6cea00e99fe5089c91e357ca1d0a50fd5397edcda32833\n", - "raw=9555ad8d0be83713955c1d6e3a6dfb8524d4d861c473f9a51bdc4e3144d0518d\n", + "raw=251de4f3ec9b185c082ce88528d84e8967fed782d7d3128b977aa4309d0d0bea\n", "linux_suite=changed_path_ledger_linux\n", "macos_suite=changed_path_ledger_macos\n", "recovery_suite=changed_path_ledger_recovery\n", diff --git a/trail/src/db/core/doctor.rs b/trail/src/db/core/doctor.rs index 7088b76b..d5445ec2 100644 --- a/trail/src/db/core/doctor.rs +++ b/trail/src/db/core/doctor.rs @@ -27,6 +27,7 @@ impl Trail { doctor_runtime::push_write_lock_check(self, &mut checks); doctor_runtime::push_daemon_token_check(self, &mut checks); + checks.push(self.managed_guest_recovery_doctor_check()); doctor_runtime::push_fsck_check(self, &mut checks); doctor_activity::push_pending_approvals_check(self, &mut checks); diff --git a/trail/src/db/lane/gates/runner.rs b/trail/src/db/lane/gates/runner.rs index 53106636..5808fbf7 100644 --- a/trail/src/db/lane/gates/runner.rs +++ b/trail/src/db/lane/gates/runner.rs @@ -232,20 +232,38 @@ impl Trail { }; let environment = managed.environment.clone(); - let run = match run_command_with_timeout_env( - &command, - &managed.workdir, - Duration::from_secs(timeout_secs), - &environment, - ) { + let guest_execution = managed.execution_backend == "colima"; + let run_result = if guest_execution { + let view = managed.view.clone().ok_or_else(|| { + Error::InvalidInput(format!( + "lane `{lane}` does not have a layered workspace view required for Colima execution" + )) + })?; + self.run_colima_managed_command( + &mut managed, + &view, + &command, + Some(Duration::from_secs(timeout_secs)), + ) + } else { + run_command_with_timeout_env( + &command, + &managed.workdir, + Duration::from_secs(timeout_secs), + &environment, + ) + }; + let run = match run_result { Ok(run) => run, Err(error) => { - self.mark_managed_lane_execution_command( - &mut managed, - "failed", - Some(&error.to_string()), - None, - )?; + if !guest_execution { + self.mark_managed_lane_execution_command( + &mut managed, + "failed", + Some(&error.to_string()), + None, + )?; + } let lifecycle = self.finalize_managed_lane_execution( managed, Some(format!("Managed lane {kind} failed-launch checkpoint")), @@ -268,12 +286,14 @@ impl Trail { }; let execution_error = (!run.success && run.exit_code.is_none()) .then(|| String::from_utf8_lossy(&run.stderr).trim().to_string()); - self.mark_managed_lane_execution_command( - &mut managed, - if run.success { "succeeded" } else { "failed" }, - execution_error.as_deref(), - run.exit_code, - )?; + if !guest_execution { + self.mark_managed_lane_execution_command( + &mut managed, + if run.success { "succeeded" } else { "failed" }, + execution_error.as_deref(), + run.exit_code, + )?; + } let lifecycle = self.finalize_managed_lane_execution( managed, Some(format!("Managed lane {kind} checkpoint")), diff --git a/trail/src/db/lane/managed_execution.rs b/trail/src/db/lane/managed_execution.rs index a8c19ef9..18dd3fb5 100644 --- a/trail/src/db/lane/managed_execution.rs +++ b/trail/src/db/lane/managed_execution.rs @@ -22,11 +22,17 @@ pub struct ManagedExecutionContext { pub workdir: PathBuf, pub environment: Vec<(String, String)>, pub environment_generation: Option, + pub execution_backend: String, + pub(crate) session_id: Option, + pub(crate) turn_id: Option, + pub(crate) trace_id: Option, projected_source_inputs: Vec, preparation: ManagedExecutionPreparationReceipt, sealing_decisions: Vec, mount: Option>, phases: Vec, + pub(crate) sandbox_finalization: Option, + pub(crate) guest_manifest_path: Option, #[cfg(test)] injected_disposal_error: Option, } @@ -37,6 +43,33 @@ struct ManagedProjectedSourceInput { } impl Trail { + pub(crate) fn set_managed_execution_sandbox_preparation( + &self, + context: &mut ManagedExecutionContext, + receipt: ManagedExecutionSandboxPreparationReceipt, + ) { + context.preparation.sandbox = Some(receipt); + } + + pub(crate) fn set_managed_execution_sandbox_finalization( + &self, + context: &mut ManagedExecutionContext, + receipt: ManagedExecutionSandboxFinalizationReceipt, + ) { + context.sandbox_finalization = Some(receipt); + } + + pub(crate) fn record_managed_execution_context_phase( + &self, + context: &mut ManagedExecutionContext, + phase: &str, + status: &str, + error: Option<&str>, + details: Option, + ) -> Result<()> { + self.push_managed_context_phase(context, phase, status, error, details) + } + #[doc(hidden)] pub fn prepare_managed_lane_execution( &self, @@ -617,9 +650,14 @@ impl Trail { environment_generation: active_generation .as_ref() .map(|generation| generation.generation_id.clone()), + execution_backend: self.config.runtime.execution_backend.clone(), + session_id: None, + turn_id: None, + trace_id: None, projected_source_inputs, preparation: ManagedExecutionPreparationReceipt { source_root: head.root_id.clone(), + execution_backend: self.config.runtime.execution_backend.clone(), view_id: view.as_ref().map(|view| view.view_id.clone()), view_generation: view.as_ref().map(|view| view.generation), missing_resolution_policy: ManagedExecutionMissingResolutionPolicy::Explicit, @@ -628,10 +666,13 @@ impl Trail { .as_ref() .map(|generation| generation.generation_id.clone()), output_pins, + sandbox: None, }, sealing_decisions, mount, phases, + sandbox_finalization: None, + guest_manifest_path: None, #[cfg(test)] injected_disposal_error: None, }) @@ -822,6 +863,15 @@ impl Trail { (None, None, Some(code), Some(reason)) } }; + let guest_manifest_error = + super::workspace_guest_execution::finalize_guest_execution_manifest( + self, + &context, + checkpoint.as_ref(), + checkpoint_error.as_deref(), + ) + .err() + .map(|error| error.to_string()); let has_runtime = context.view.is_some() && self @@ -933,10 +983,16 @@ impl Trail { } else { "skipped" }; + let sandbox_cleanup_error = context + .sandbox_finalization + .as_ref() + .and_then(|receipt| receipt.cleanup_error.clone()); let errors = checkpoint_error .iter() .chain(disposal_error.iter()) .chain(unmount_error.iter()) + .chain(sandbox_cleanup_error.iter()) + .chain(guest_manifest_error.iter()) .cloned() .collect::>(); let finalization = ManagedExecutionFinalizationReceipt { @@ -949,12 +1005,16 @@ impl Trail { complete: errors.is_empty(), sealing_decisions, errors, + sandbox: context.sandbox_finalization, }; ManagedExecutionLifecycleReport { execution_id: context.execution_id, surface: context.surface, command_fingerprint: context.command_fingerprint, + session_id: context.session_id, + turn_id: context.turn_id, + trace_id: context.trace_id, preparation: Some(context.preparation), environment_generation: context.environment_generation, checkpoint, diff --git a/trail/src/db/lane/mod.rs b/trail/src/db/lane/mod.rs index b77538fd..21f035cb 100644 --- a/trail/src/db/lane/mod.rs +++ b/trail/src/db/lane/mod.rs @@ -39,6 +39,7 @@ mod workspace_cmake; mod workspace_environment; mod workspace_git; mod workspace_go; +mod workspace_guest_execution; mod workspace_layer; mod workspace_node; mod workspace_oci; @@ -46,6 +47,7 @@ mod workspace_plugin; mod workspace_python; mod workspace_recipe; mod workspace_runtime; +mod workspace_runtime_toolchain; mod workspace_view; #[cfg(debug_assertions)] diff --git a/trail/src/db/lane/workdir.rs b/trail/src/db/lane/workdir.rs index fb108aa8..1712e577 100644 --- a/trail/src/db/lane/workdir.rs +++ b/trail/src/db/lane/workdir.rs @@ -19,6 +19,7 @@ mod view_layout; pub(crate) use marker::materialized_lane_root_identity; pub(crate) use materialize::*; +pub(crate) use record::lane_workdir_ignore_matcher; #[cfg(debug_assertions)] pub(crate) use record::{ install_lane_record_after_c2_write_for_current_thread, diff --git a/trail/src/db/lane/workdir/record.rs b/trail/src/db/lane/workdir/record.rs index 4aa226f0..66592297 100644 --- a/trail/src/db/lane/workdir/record.rs +++ b/trail/src/db/lane/workdir/record.rs @@ -1331,7 +1331,7 @@ impl Trail { } } -fn lane_workdir_ignore_matcher(root: &Path) -> Result { +pub(crate) fn lane_workdir_ignore_matcher(root: &Path) -> Result { let mut builder = ignore::gitignore::GitignoreBuilder::new(root); for filename in [".trailignore", ".gitignore"] { let path = root.join(filename); diff --git a/trail/src/db/lane/workspace_guest_execution.rs b/trail/src/db/lane/workspace_guest_execution.rs new file mode 100644 index 00000000..1941b7f0 --- /dev/null +++ b/trail/src/db/lane/workspace_guest_execution.rs @@ -0,0 +1,2793 @@ +use super::workdir::{classify_view_path, lane_workdir_ignore_matcher, ViewPathClass}; +use super::*; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::File; +use std::io::{self, Read, Write}; +use std::process::{Child, ExitStatus, Stdio}; +use std::time::{Duration, Instant}; + +const MAX_GUEST_DIAGNOSTIC_BYTES: usize = 16 * 1024; +const DEFAULT_MAX_PROJECTION_ENTRIES: u64 = 100_000; +const DEFAULT_MAX_PROJECTION_BYTES: u64 = 512 * 1024 * 1024; +const DEFAULT_MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; +const GUEST_EXECUTION_ROOT: &str = "/tmp/trail-executions"; +const MAX_GUEST_STDOUT_BYTES: usize = 16 * 1024 * 1024; +const MAX_GUEST_STDERR_BYTES: usize = 16 * 1024 * 1024; +const GUEST_MANIFEST_SCHEMA: u32 = 1; +const MAX_GUEST_MANIFEST_BYTES: u64 = 64 * 1024; +const MAX_GUEST_MANIFESTS: usize = 4096; +const MAX_RETAINED_TERMINAL_GUEST_MANIFESTS: usize = 256; +const MAX_CONCURRENT_GUEST_EXECUTIONS: usize = 4; +const GUEST_PROTOCOL_TIMEOUT: Duration = Duration::from_secs(5 * 60); +const GUEST_CANCELLATION_WAIT: Duration = Duration::from_secs(30); +pub(super) const DEFAULT_GUEST_COMMAND_TIMEOUT_SECS: u64 = 60 * 60; +pub(super) const MAX_GUEST_COMMAND_TIMEOUT_SECS: u64 = 24 * 60 * 60; + +#[derive(Clone, Copy, Debug)] +struct ProjectionLimits { + entries: u64, + total_bytes: u64, + file_bytes: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct SnapshotEntry { + kind: String, + content: String, + mode: u32, +} + +#[derive(Debug)] +struct BuiltProjection { + archive_path: PathBuf, + input_digest: String, + projected_entries: u64, + projected_bytes: u64, + source_snapshot: BTreeMap, +} + +#[derive(Debug)] +struct CandidateProjection { + output_digest: String, + source_snapshot: BTreeMap, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct GuestExecutionManifest { + schema: u32, + execution_id: String, + lane_id: String, + profile: String, + lima_instance: String, + guest_namespace: String, + staging_path: String, + owner_pid: u32, + owner_start_token: String, + phase: String, + input_digest: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + candidate_digest: Option, + #[serde(default)] + imported_paths: Vec, + #[serde(default)] + removed_paths: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + checkpoint_root: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + checkpoint_operation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + cancellation_requested_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + cancellation_completed_at: Option, + #[serde(default)] + process_group_terminated: bool, + updated_at: i64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct GuestCancellationRequest { + schema: u32, + execution_id: String, + lane_id: String, + requested_at: i64, +} + +pub(super) fn preflight_colima_guest_execution( + toolchain: &super::workspace_runtime_toolchain::ColimaToolchain, + profile: &str, +) -> Result<()> { + let instance = super::workspace_runtime::colima_lima_instance(profile); + let result = run_guest_status(toolchain, &instance, &["true".to_string()], None, None)?; + if result.status.success() { + return Ok(()); + } + Err(Error::InvalidInput(format!( + "Colima profile `{profile}` cannot execute managed lane commands through Lima instance `{instance}`: {}", + guest_diagnostic(&result.stderr, &[]) + ))) +} + +impl Trail { + pub(crate) fn managed_guest_recovery_doctor_check(&self) -> DoctorCheck { + match inspect_guest_execution_manifests(self) { + Ok(inspection) => { + let status = if inspection.ambiguous > 0 { + "error" + } else if inspection.live > 0 || inspection.recoverable > 0 { + "warning" + } else { + "ok" + }; + let message = if inspection.ambiguous > 0 { + format!( + "{} interrupted guest execution(s) require explicit lane inspection before retry", + inspection.ambiguous + ) + } else if inspection.live > 0 || inspection.recoverable > 0 { + format!( + "{} live and {} safely recoverable guest execution(s) are recorded", + inspection.live, inspection.recoverable + ) + } else { + "no interrupted managed guest executions require recovery".to_string() + }; + doctor_check( + "managed_guest_executions", + status, + message, + Some(serde_json::json!({ + "active": inspection.active, + "live": inspection.live, + "recoverable": inspection.recoverable, + "ambiguous": inspection.ambiguous, + "terminal": inspection.terminal, + "phases": inspection.phases, + })), + ) + } + Err(error) => doctor_check( + "managed_guest_executions", + "error", + format!("could not safely inspect managed guest execution receipts: {error}"), + None, + ), + } + } + + pub(super) fn run_colima_lane_command( + &mut self, + context: &mut ManagedExecutionContext, + view: &LaneWorkspaceViewReport, + command: &[String], + timeout: Duration, + ) -> Result { + let run = self.run_colima_managed_command(context, view, command, Some(timeout))?; + io::stdout().write_all(&run.stdout)?; + io::stderr().write_all(&run.stderr)?; + Ok(run) + } + + pub(super) fn run_colima_managed_command( + &mut self, + context: &mut ManagedExecutionContext, + view: &LaneWorkspaceViewReport, + command: &[String], + timeout: Option, + ) -> Result { + let mut command_marked = false; + let result = self.run_colima_lane_command_inner( + context, + view, + command, + timeout, + &mut command_marked, + None, + ); + let result = result.map_err(|error| classify_guest_execution_error(context, error)); + if result.is_err() && !command_marked { + let error = result.as_ref().err().map(ToString::to_string); + self.mark_managed_lane_execution_command(context, "failed", error.as_deref(), None)?; + } + result + } + + pub fn cancel_lane_workspace_execution( + &self, + lane: &str, + execution_id: Option<&str>, + ) -> Result { + let (manifest_path, mut manifest, phase_before, owner_was_live, request) = { + let _cancellation_fence = + Trail::with_write_lock_wait(GUEST_CANCELLATION_WAIT, || self.acquire_write_lock())?; + let branch = self.lane_branch(lane)?; + let directory = guest_manifest_directory(self)?; + let mut candidates = Vec::new(); + for entry in fs::read_dir(&directory)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + continue; + } + let manifest = read_guest_manifest(&path)?; + validate_guest_manifest_identity(self, &manifest)?; + if manifest.lane_id != branch.lane_id { + continue; + } + if execution_id.is_some_and(|requested| requested != manifest.execution_id) { + continue; + } + if guest_cancellation_is_terminal(&manifest) { + if execution_id.is_some() { + candidates.push((path, manifest)); + } + continue; + } + if manifest.cancellation_completed_at.is_some() { + candidates.push((path, manifest)); + continue; + } + if !guest_manifest_is_cancellable(&manifest) { + if execution_id.is_some() { + return Err(Error::InvalidInput(format!( + "managed execution `{}` is in non-cancellable phase `{}`", + manifest.execution_id, manifest.phase + ))); + } + continue; + } + candidates.push((path, manifest)); + } + if candidates.is_empty() { + return Err(Error::InvalidInput(format!( + "lane `{lane}` has no matching cancellable Colima execution" + ))); + } + if candidates.len() != 1 { + return Err(Error::InvalidInput(format!( + "lane `{lane}` has {} matching Colima executions; pass --execution-id", + candidates.len() + ))); + } + let (manifest_path, manifest) = candidates.remove(0); + let phase_before = manifest.phase.clone(); + let owner_was_live = + process_matches_start_token(manifest.owner_pid, &manifest.owner_start_token); + let request = if !guest_cancellation_is_terminal(&manifest) { + let request_path = guest_cancellation_path(self, &manifest.execution_id)?; + let request = if let Some(request) = + read_guest_cancellation_request(&request_path, &manifest)? + { + request + } else { + let request = GuestCancellationRequest { + schema: GUEST_MANIFEST_SCHEMA, + execution_id: manifest.execution_id.clone(), + lane_id: manifest.lane_id.clone(), + requested_at: now_ts(), + }; + write_file_atomic(&request_path, &serde_json::to_vec_pretty(&request)?, true)?; + request + }; + Some((request_path, request)) + } else { + None + }; + ( + manifest_path, + manifest, + phase_before, + owner_was_live, + request, + ) + }; + + if let Some((request_path, request)) = request { + let started = Instant::now(); + while owner_was_live && started.elapsed() < GUEST_CANCELLATION_WAIT { + std::thread::sleep(Duration::from_millis(50)); + manifest = read_guest_manifest(&manifest_path)?; + if guest_cancellation_is_terminal(&manifest) { + break; + } + if !process_matches_start_token(manifest.owner_pid, &manifest.owner_start_token) { + break; + } + } + if !guest_cancellation_is_terminal(&manifest) + && !process_matches_start_token(manifest.owner_pid, &manifest.owner_start_token) + { + let _cancellation_fence = + Trail::with_write_lock_wait(GUEST_CANCELLATION_WAIT, || { + self.acquire_write_lock() + })?; + manifest = read_guest_manifest(&manifest_path)?; + if !guest_cancellation_is_terminal(&manifest) { + let toolchain = + super::workspace_runtime_toolchain::ColimaToolchain::resolve(false)?; + let process_group_path = format!("{}/process-group", manifest.guest_namespace); + manifest.process_group_terminated |= terminate_guest_process_group( + &toolchain, + &manifest.lima_instance, + &process_group_path, + false, + )?; + cleanup_guest_namespace( + &toolchain, + &manifest.lima_instance, + &manifest.guest_namespace, + )?; + manifest + .cancellation_requested_at + .get_or_insert(request.requested_at); + manifest + .cancellation_completed_at + .get_or_insert_with(now_ts); + update_guest_manifest( + &manifest_path, + &mut manifest, + "terminal_cancelled", + None, + )?; + let _ = fs::remove_file(&request_path); + } + } + if !guest_cancellation_is_terminal(&manifest) { + return Err(Error::InvalidInput(format!( + "cancellation and cleanup of managed execution `{}` were not acknowledged within {} seconds", + manifest.execution_id, + GUEST_CANCELLATION_WAIT.as_secs() + ))); + } + } + Ok(WorkspaceExecCancellationReport { + lane_id: manifest.lane_id, + execution_id: manifest.execution_id, + status: "cancelled".to_string(), + phase_before, + profile: manifest.profile, + lima_instance: manifest.lima_instance, + owner_was_live, + process_group_terminated: manifest.process_group_terminated, + cleanup_status: if matches!( + manifest.phase.as_str(), + "cleanup_failed" | "terminal_failed" + ) { + "failed" + } else { + "succeeded" + } + .to_string(), + }) + } + + fn run_colima_lane_command_inner( + &mut self, + context: &mut ManagedExecutionContext, + view: &LaneWorkspaceViewReport, + command: &[String], + timeout: Option, + command_marked: &mut bool, + toolchain_override: Option, + ) -> Result { + if self.config.runtime.provider != "colima" { + return Err(Error::InvalidInput( + "runtime.execution_backend colima requires runtime.provider colima".to_string(), + )); + } + let profile = super::workspace_runtime::configured_colima_profile( + &self.config.runtime, + &self.config.workspace.id.0, + )?; + let instance = super::workspace_runtime::colima_lima_instance(&profile); + let test_override = toolchain_override.is_some(); + let toolchain = match toolchain_override { + Some(toolchain) => toolchain, + None => super::workspace_runtime_toolchain::ColimaToolchain::resolve(false)?, + }; + if !test_override && !toolchain.state_is_ready() { + return Err(Error::InvalidInput( + "Trail's isolated Colima state is unavailable; run `trail env runtime setup colima --execution-backend colima`" + .to_string(), + )); + } + if !test_override && !toolchain.contained_profile_verified(&profile) { + return Err(Error::InvalidInput(format!( + "Colima profile `{profile}` lacks Trail's no-host-mount containment receipt; stop it and rerun `trail env runtime setup colima --profile {profile} --execution-backend colima`" + ))); + } + preflight_colima_guest_execution(&toolchain, &profile)?; + + let limits = projection_limits(&self.config.workspace_views); + let staging_root = self.db_dir.join("tmp/managed-execution"); + ensure_private_staging_root(&staging_root)?; + let staging = tempfile::Builder::new() + .prefix("colima-") + .tempdir_in(&staging_root)?; + let projection = build_projection(Path::new(&view.mountpoint), staging.path(), limits) + .map_err(|error| Error::ExecutionValidation { + execution_id: context.execution_id.clone(), + reason: redact_sensitive_text(&error.to_string()), + })?; + let source_ignore = lane_workdir_ignore_matcher(Path::new(&view.mountpoint))?; + let workspace_key = &sha256_hex(self.config.workspace.id.0.as_bytes())[..16]; + let guest_namespace = format!( + "{GUEST_EXECUTION_ROOT}/{workspace_key}/{}", + context.execution_id + ); + let guest_workspace = format!("{guest_namespace}/workspace"); + let guest_home = format!("{guest_namespace}/home"); + let guest_tmp = format!("{guest_namespace}/tmp"); + let manifest_path = guest_manifest_path(self, &context.execution_id)?; + let cancellation_path = guest_cancellation_path(self, &context.execution_id)?; + let mut manifest = GuestExecutionManifest { + schema: GUEST_MANIFEST_SCHEMA, + execution_id: context.execution_id.clone(), + lane_id: context.lane_id.clone(), + profile: profile.clone(), + lima_instance: instance.clone(), + guest_namespace: guest_namespace.clone(), + staging_path: staging.path().to_string_lossy().into_owned(), + owner_pid: std::process::id(), + owner_start_token: current_process_start_token(), + phase: "creating".to_string(), + input_digest: projection.input_digest.clone(), + candidate_digest: None, + imported_paths: Vec::new(), + removed_paths: Vec::new(), + checkpoint_root: None, + checkpoint_operation: None, + error: None, + cancellation_requested_at: None, + cancellation_completed_at: None, + process_group_terminated: false, + updated_at: now_ts(), + }; + { + // Recovery, admission, and publication share one workspace fence so + // concurrent callers cannot all observe the same free execution slot. + let _manifest_fence = + Trail::with_write_lock_wait(GUEST_PROTOCOL_TIMEOUT, || self.acquire_write_lock())?; + recover_guest_execution_manifests(self, &toolchain, &profile, &instance)?; + write_guest_manifest(&manifest_path, &manifest)?; + } + context.guest_manifest_path = Some(manifest_path.clone()); + self.set_managed_execution_sandbox_preparation( + context, + ManagedExecutionSandboxPreparationReceipt { + backend: "colima".to_string(), + provider: "colima".to_string(), + profile: profile.clone(), + lima_instance: instance.clone(), + guest_namespace: guest_namespace.clone(), + toolchain_source: toolchain.source.to_string(), + toolchain_version: toolchain.version.clone(), + input_digest: projection.input_digest.clone(), + projected_entries: projection.projected_entries, + projected_bytes: projection.projected_bytes, + entry_limit: limits.entries, + total_bytes_limit: limits.total_bytes, + file_bytes_limit: limits.file_bytes, + service_bindings: guest_service_binding_identities(&context.environment)?, + }, + ); + + let mut namespace_created = false; + let execution_result = (|| { + require_guest_status( + &toolchain, + &instance, + &[ + "mkdir".to_string(), + "-p".to_string(), + "--".to_string(), + guest_workspace.clone(), + guest_home.clone(), + guest_tmp.clone(), + ], + None, + None, + )?; + namespace_created = true; + update_guest_manifest(&manifest_path, &mut manifest, "namespace_created", None)?; + let archive = File::open(&projection.archive_path)?; + require_guest_status( + &toolchain, + &instance, + &[ + "tar".to_string(), + "-xpf".to_string(), + "-".to_string(), + "-C".to_string(), + guest_workspace.clone(), + ], + Some(Stdio::from(archive)), + None, + )?; + update_guest_manifest(&manifest_path, &mut manifest, "projected", None)?; + self.record_managed_execution_context_phase( + context, + "guest_project", + "succeeded", + None, + Some(serde_json::json!({ + "backend": "colima", + "profile": profile, + "lima_instance": instance, + "guest_namespace": guest_namespace, + "input_digest": projection.input_digest, + "entries": projection.projected_entries, + "bytes": projection.projected_bytes, + })), + )?; + + let environment = + guest_environment(context, view, &guest_workspace, &guest_home, &guest_tmp)?; + let mut guest_args = vec!["env".to_string(), "-i".to_string()]; + guest_args.extend(environment); + guest_args.extend(command.iter().cloned()); + update_guest_manifest(&manifest_path, &mut manifest, "executing", None)?; + let run = run_guest_command( + &toolchain, + &instance, + &guest_args, + &guest_workspace, + timeout, + &cancellation_path, + &manifest, + )?; + if run.cancelled { + let request = read_guest_cancellation_request(&cancellation_path, &manifest)? + .ok_or_else(|| { + Error::Corrupt( + "managed guest command reported cancellation without a request" + .to_string(), + ) + })?; + manifest.cancellation_requested_at = Some(request.requested_at); + manifest.cancellation_completed_at = Some(now_ts()); + manifest.process_group_terminated = run.process_group_terminated; + update_guest_manifest(&manifest_path, &mut manifest, "cancelled", None)?; + self.mark_managed_lane_execution_command( + context, + "cancelled", + None, + run.exit_code, + )?; + *command_marked = true; + return Err(Error::ExecutionCancelled { + execution_id: context.execution_id.clone(), + }); + } + update_guest_manifest(&manifest_path, &mut manifest, "executed", None)?; + self.mark_managed_lane_execution_command( + context, + if run.success { "succeeded" } else { "failed" }, + (!run.success && run.exit_code.is_none()) + .then(|| String::from_utf8_lossy(&run.stderr).trim().to_string()) + .as_deref(), + run.exit_code, + )?; + *command_marked = true; + + if let Some(request) = read_guest_cancellation_request(&cancellation_path, &manifest)? { + manifest.cancellation_requested_at = Some(request.requested_at); + manifest.cancellation_completed_at = Some(now_ts()); + update_guest_manifest(&manifest_path, &mut manifest, "cancelled", None)?; + return Err(Error::ExecutionCancelled { + execution_id: context.execution_id.clone(), + }); + } + + let candidate_archive = staging.path().join("candidate.tar"); + let candidate_file = File::create(&candidate_archive)?; + let candidate_allowance = limits.total_bytes.saturating_add(16 * 1024 * 1024); + export_guest_archive( + &toolchain, + &instance, + &guest_workspace, + candidate_file, + candidate_allowance, + )?; + let archive_bytes = fs::metadata(&candidate_archive)?.len(); + if archive_bytes > candidate_allowance { + return Err(Error::InvalidInput(format!( + "guest candidate archive is {archive_bytes} bytes, exceeding its bounded export allowance" + ))); + } + let candidate_root = staging.path().join("candidate"); + fs::create_dir(&candidate_root)?; + let candidate = validate_and_extract_candidate( + &candidate_archive, + &candidate_root, + limits, + &source_ignore, + ) + .map_err(|error| Error::ExecutionValidation { + execution_id: context.execution_id.clone(), + reason: redact_sensitive_text(&error.to_string()), + })?; + manifest.candidate_digest = Some(candidate.output_digest.clone()); + update_guest_manifest(&manifest_path, &mut manifest, "exported", None)?; + self.record_managed_execution_context_phase( + context, + "guest_export", + "succeeded", + None, + Some(serde_json::json!({ + "output_digest": candidate.output_digest, + "archive_bytes": archive_bytes, + })), + )?; + + let _cancellation_fence = + Trail::with_write_lock_wait(GUEST_CANCELLATION_WAIT, || self.acquire_write_lock())?; + update_guest_manifest(&manifest_path, &mut manifest, "importing", None)?; + if let Some(request) = read_guest_cancellation_request(&cancellation_path, &manifest)? { + manifest.cancellation_requested_at = Some(request.requested_at); + manifest.cancellation_completed_at = Some(now_ts()); + update_guest_manifest(&manifest_path, &mut manifest, "cancelled", None)?; + return Err(Error::ExecutionCancelled { + execution_id: context.execution_id.clone(), + }); + } + + let current = + source_snapshot_with_ignore(Path::new(&view.mountpoint), limits, &source_ignore)?; + if current != projection.source_snapshot { + return Err(Error::ExecutionValidation { + execution_id: context.execution_id.clone(), + reason: "lane source changed on the host while its Colima execution was running; refusing to overwrite concurrent work" + .to_string(), + }); + } + let (imported_paths, removed_paths) = apply_candidate_source( + Path::new(&view.mountpoint), + &candidate_root, + &projection.source_snapshot, + &candidate.source_snapshot, + )?; + let unchanged = imported_paths.is_empty() && removed_paths.is_empty(); + manifest.imported_paths = imported_paths.clone(); + manifest.removed_paths = removed_paths.clone(); + update_guest_manifest(&manifest_path, &mut manifest, "imported", None)?; + self.record_managed_execution_context_phase( + context, + "guest_import", + if unchanged { "skipped" } else { "succeeded" }, + None, + Some(serde_json::json!({ + "output_digest": candidate.output_digest, + "imported_paths": imported_paths, + "removed_paths": removed_paths, + })), + )?; + Ok(( + run, + candidate.output_digest, + imported_paths, + removed_paths, + unchanged, + )) + })(); + + let cleanup = if namespace_created { + cleanup_guest_namespace(&toolchain, &instance, &guest_namespace) + } else { + Ok(()) + }; + let cleanup_error = cleanup.as_ref().err().map(ToString::to_string); + if cleanup_error.is_none() && namespace_created { + let _ = update_guest_manifest(&manifest_path, &mut manifest, "cleaned", None); + } else if let Some(error) = cleanup_error.as_deref() { + let _ = + update_guest_manifest(&manifest_path, &mut manifest, "cleanup_failed", Some(error)); + } + let _ = self.record_managed_execution_context_phase( + context, + "guest_cleanup", + if cleanup_error.is_some() { + "failed" + } else if namespace_created { + "succeeded" + } else { + "skipped" + }, + cleanup_error.as_deref(), + Some(serde_json::json!({"guest_namespace": guest_namespace})), + ); + if manifest.cancellation_completed_at.is_some() { + let _ = fs::remove_file(&cancellation_path); + } + + match execution_result { + Ok((run, output_digest, imported_paths, removed_paths, unchanged)) => { + self.set_managed_execution_sandbox_finalization( + context, + ManagedExecutionSandboxFinalizationReceipt { + output_digest, + imported_paths, + removed_paths, + unchanged, + cleanup_status: if cleanup_error.is_some() { + "failed" + } else { + "succeeded" + } + .to_string(), + cleanup_error: cleanup_error.clone(), + }, + ); + if let Some(error) = cleanup_error { + Err(Error::Corrupt(format!( + "guest command completed but its owned namespace cleanup failed: {error}" + ))) + } else { + Ok(run) + } + } + Err(error) => { + if let Some(cleanup_error) = cleanup_error { + Err(Error::Corrupt(format!( + "{error}; guest namespace cleanup also failed: {cleanup_error}" + ))) + } else { + Err(error) + } + } + } + } +} + +fn classify_guest_execution_error(context: &ManagedExecutionContext, error: Error) -> Error { + if matches!( + &error, + Error::ExecutionCancelled { .. } + | Error::ExecutionValidation { .. } + | Error::ExecutionInfrastructure { .. } + ) { + return error; + } + let phase = context + .guest_manifest_path + .as_deref() + .and_then(|path| read_guest_manifest(path).ok()) + .map(|manifest| manifest.phase) + .unwrap_or_else(|| "guest_prepare".to_string()); + Error::ExecutionInfrastructure { + execution_id: context.execution_id.clone(), + phase, + reason: redact_sensitive_text(&error.to_string()), + } +} + +#[derive(Default)] +struct GuestManifestInspection { + active: usize, + live: usize, + recoverable: usize, + ambiguous: usize, + terminal: usize, + phases: BTreeMap, +} + +fn inspect_guest_execution_manifests(db: &Trail) -> Result { + let directory = db.db_dir.join("managed-executions"); + match fs::symlink_metadata(&directory) { + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(GuestManifestInspection::default()); + } + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {} + Ok(_) => { + return Err(Error::Corrupt( + "managed guest execution receipt path is not a real directory".to_string(), + )); + } + Err(error) => return Err(error.into()), + } + let mut inspection = GuestManifestInspection::default(); + let scan_limit = MAX_GUEST_MANIFESTS + MAX_RETAINED_TERMINAL_GUEST_MANIFESTS + 1; + let mut scanned = 0_usize; + for entry in fs::read_dir(directory)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + continue; + } + scanned = scanned.saturating_add(1); + if scanned > scan_limit { + return Err(Error::InvalidInput(format!( + "managed guest manifest count exceeds the doctor scan limit of {scan_limit}" + ))); + } + let manifest = read_guest_manifest(&path)?; + validate_guest_manifest_identity(db, &manifest)?; + *inspection.phases.entry(manifest.phase.clone()).or_default() += 1; + if manifest.phase.starts_with("terminal_") { + inspection.terminal = inspection.terminal.saturating_add(1); + } else { + inspection.active = inspection.active.saturating_add(1); + if process_matches_start_token(manifest.owner_pid, &manifest.owner_start_token) { + inspection.live = inspection.live.saturating_add(1); + } else if guest_manifest_is_safely_discardable(&manifest) { + inspection.recoverable = inspection.recoverable.saturating_add(1); + } else { + inspection.ambiguous = inspection.ambiguous.saturating_add(1); + } + } + } + Ok(inspection) +} + +pub(super) fn finalize_guest_execution_manifest( + db: &Trail, + context: &ManagedExecutionContext, + checkpoint: Option<&WorkspaceCheckpointReport>, + checkpoint_error: Option<&str>, +) -> Result<()> { + let Some(path) = context.guest_manifest_path.as_deref() else { + return Ok(()); + }; + let mut manifest = read_guest_manifest(path)?; + validate_guest_manifest_identity(db, &manifest)?; + if manifest.execution_id != context.execution_id + || manifest.lane_id != context.lane_id + || manifest.owner_pid != std::process::id() + || manifest.owner_start_token != current_process_start_token() + { + return Err(Error::Corrupt(format!( + "managed guest manifest `{}` no longer belongs to execution `{}`", + path.display(), + context.execution_id + ))); + } + manifest.checkpoint_root = checkpoint.map(|checkpoint| checkpoint.root_id.0.clone()); + manifest.checkpoint_operation = checkpoint + .and_then(|checkpoint| checkpoint.operation.as_ref()) + .map(|operation| operation.0.clone()); + let cleanup_failed = context + .sandbox_finalization + .as_ref() + .is_some_and(|receipt| receipt.cleanup_status == "failed"); + let terminal_phase = if manifest.cancellation_completed_at.is_some() && !cleanup_failed { + "terminal_cancelled" + } else if checkpoint.is_some() && checkpoint_error.is_none() && !cleanup_failed { + "terminal_succeeded" + } else { + "terminal_failed" + }; + update_guest_manifest(path, &mut manifest, terminal_phase, checkpoint_error) +} + +fn guest_manifest_directory(db: &Trail) -> Result { + let directory = db.db_dir.join("managed-executions"); + ensure_private_staging_root(&directory)?; + Ok(directory) +} + +fn guest_manifest_path(db: &Trail, execution_id: &str) -> Result { + if !execution_id.starts_with("exec_") + || !execution_id + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + { + return Err(Error::InvalidInput( + "managed guest execution id is not safe for durable storage".to_string(), + )); + } + Ok(guest_manifest_directory(db)?.join(format!("{execution_id}.json"))) +} + +fn guest_cancellation_path(db: &Trail, execution_id: &str) -> Result { + let _ = guest_manifest_path(db, execution_id)?; + Ok(guest_manifest_directory(db)?.join(format!("{execution_id}.cancel"))) +} + +fn read_guest_cancellation_request( + path: &Path, + manifest: &GuestExecutionManifest, +) -> Result> { + let metadata = match fs::symlink_metadata(path) { + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Ok(metadata) => metadata, + Err(error) => return Err(error.into()), + }; + if !metadata.is_file() + || metadata.file_type().is_symlink() + || metadata.len() > MAX_GUEST_MANIFEST_BYTES + { + return Err(Error::Corrupt(format!( + "managed guest cancellation request `{}` is not a bounded regular file", + path.display() + ))); + } + let request = serde_json::from_slice::(&fs::read(path)?)?; + if request.schema != GUEST_MANIFEST_SCHEMA + || request.execution_id != manifest.execution_id + || request.lane_id != manifest.lane_id + { + return Err(Error::Corrupt(format!( + "managed guest cancellation request `{}` does not match its execution receipt", + path.display() + ))); + } + Ok(Some(request)) +} + +fn write_guest_manifest(path: &Path, manifest: &GuestExecutionManifest) -> Result<()> { + write_file_atomic(path, &serde_json::to_vec_pretty(manifest)?, true) +} + +fn update_guest_manifest( + path: &Path, + manifest: &mut GuestExecutionManifest, + phase: &str, + error: Option<&str>, +) -> Result<()> { + manifest.phase = phase.to_string(); + manifest.error = error.map(redact_sensitive_text); + manifest.updated_at = now_ts(); + write_guest_manifest(path, manifest) +} + +fn read_guest_manifest(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path)?; + if !metadata.is_file() + || metadata.file_type().is_symlink() + || metadata.len() > MAX_GUEST_MANIFEST_BYTES + { + return Err(Error::Corrupt(format!( + "managed guest manifest `{}` is not a bounded regular file", + path.display() + ))); + } + let manifest = serde_json::from_slice::(&fs::read(path)?)?; + if manifest.schema != GUEST_MANIFEST_SCHEMA { + return Err(Error::Corrupt(format!( + "managed guest manifest `{}` has unsupported schema {}", + path.display(), + manifest.schema + ))); + } + Ok(manifest) +} + +fn validate_guest_manifest_identity(db: &Trail, manifest: &GuestExecutionManifest) -> Result<()> { + let expected = format!( + "{GUEST_EXECUTION_ROOT}/{}/{}", + &sha256_hex(db.config.workspace.id.0.as_bytes())[..16], + manifest.execution_id + ); + if manifest.guest_namespace != expected { + return Err(Error::Corrupt(format!( + "managed guest manifest `{}` has namespace `{}` instead of `{expected}`", + manifest.execution_id, manifest.guest_namespace + ))); + } + if !manifest.execution_id.starts_with("exec_") + || !manifest + .execution_id + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + { + return Err(Error::Corrupt( + "managed guest manifest has an invalid execution id".to_string(), + )); + } + Ok(()) +} + +fn recover_guest_execution_manifests( + db: &Trail, + toolchain: &super::workspace_runtime_toolchain::ColimaToolchain, + profile: &str, + instance: &str, +) -> Result<()> { + let directory = guest_manifest_directory(db)?; + let mut manifests = Vec::new(); + let mut terminal = BTreeMap::<(i64, String), PathBuf>::new(); + for entry in fs::read_dir(&directory)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + continue; + } + let manifest = read_guest_manifest(&path)?; + validate_guest_manifest_identity(db, &manifest)?; + if manifest.phase.starts_with("terminal_") { + let file_name = entry.file_name().into_string().map_err(|_| { + Error::Corrupt("managed guest manifest name is not Unicode".to_string()) + })?; + terminal.insert((manifest.updated_at, file_name), path); + if terminal.len() > MAX_RETAINED_TERMINAL_GUEST_MANIFESTS { + let key = terminal.keys().next().cloned().ok_or_else(|| { + Error::Corrupt("terminal guest manifest retention is empty".to_string()) + })?; + let retired = terminal.remove(&key).ok_or_else(|| { + Error::Corrupt("terminal guest manifest retention changed".to_string()) + })?; + fs::remove_file(retired)?; + } + continue; + } + if manifests.len() >= MAX_GUEST_MANIFESTS { + return Err(Error::InvalidInput(format!( + "active managed guest manifest count exceeds the recovery limit of {MAX_GUEST_MANIFESTS}" + ))); + } + manifests.push((entry.file_name(), path, manifest)); + } + manifests.sort_by(|left, right| left.0.cmp(&right.0)); + let mut live = 0_usize; + for (_, path, mut manifest) in manifests { + if manifest.profile != profile || manifest.lima_instance != instance { + continue; + } + if process_matches_start_token(manifest.owner_pid, &manifest.owner_start_token) { + live = live.saturating_add(1); + continue; + } + let cancellation_path = guest_cancellation_path(db, &manifest.execution_id)?; + if let Some(request) = read_guest_cancellation_request(&cancellation_path, &manifest)? { + let process_group_path = format!("{}/process-group", manifest.guest_namespace); + manifest.process_group_terminated = + terminate_guest_process_group(toolchain, instance, &process_group_path, false)?; + cleanup_guest_namespace(toolchain, instance, manifest.guest_namespace.as_str())?; + manifest.cancellation_requested_at = Some(request.requested_at); + manifest.cancellation_completed_at = Some(now_ts()); + update_guest_manifest(&path, &mut manifest, "terminal_cancelled", None)?; + fs::remove_file(cancellation_path)?; + continue; + } + if !guest_manifest_is_safely_discardable(&manifest) { + return Err(Error::InvalidInput(format!( + "managed guest execution `{}` was interrupted in phase `{}`; Trail preserved lane and candidate state and will not guess. Inspect the lane, run `trail lane checkpoint {}`, then retry or remove the recovered execution through doctor tooling", + manifest.execution_id, manifest.phase, manifest.lane_id + ))); + } + cleanup_guest_namespace(toolchain, instance, manifest.guest_namespace.as_str())?; + update_guest_manifest(&path, &mut manifest, "terminal_recovered_discarded", None)?; + } + if live >= MAX_CONCURRENT_GUEST_EXECUTIONS { + return Err(Error::InvalidInput(format!( + "workspace already has {live} live Colima managed executions, reaching the limit of {MAX_CONCURRENT_GUEST_EXECUTIONS}" + ))); + } + Ok(()) +} + +fn guest_manifest_is_safely_discardable(manifest: &GuestExecutionManifest) -> bool { + let imported = !manifest.imported_paths.is_empty() || !manifest.removed_paths.is_empty(); + matches!( + manifest.phase.as_str(), + "creating" + | "namespace_created" + | "projected" + | "executed" + | "cancelled" + | "cleanup_failed" + ) || (manifest.phase == "cleaned" && !imported) +} + +fn guest_manifest_is_cancellable(manifest: &GuestExecutionManifest) -> bool { + matches!( + manifest.phase.as_str(), + "creating" | "namespace_created" | "projected" | "executing" | "executed" | "exported" + ) +} + +fn guest_cancellation_is_terminal(manifest: &GuestExecutionManifest) -> bool { + manifest.cancellation_completed_at.is_some() + && matches!( + manifest.phase.as_str(), + "cleaned" | "cleanup_failed" | "terminal_cancelled" | "terminal_failed" + ) +} + +fn projection_limits(config: &WorkspaceViewsConfig) -> ProjectionLimits { + ProjectionLimits { + entries: nonzero_or(config.upper_file_count, DEFAULT_MAX_PROJECTION_ENTRIES), + total_bytes: nonzero_or(config.upper_logical_bytes, DEFAULT_MAX_PROJECTION_BYTES), + file_bytes: nonzero_or(config.single_file_bytes, DEFAULT_MAX_FILE_BYTES), + } +} + +fn guest_service_binding_identities(environment: &[(String, String)]) -> Result> { + let Some((_, services)) = environment + .iter() + .find(|(name, _)| name == "TRAIL_SERVICES_JSON") + else { + return Ok(Vec::new()); + }; + let value: serde_json::Value = serde_json::from_str(services).map_err(|error| { + Error::Corrupt(format!( + "managed runtime service bindings are not valid JSON: {error}" + )) + })?; + let object = value.as_object().ok_or_else(|| { + Error::Corrupt("managed runtime service bindings are not an object".to_string()) + })?; + let mut identities = object.keys().cloned().collect::>(); + identities.sort(); + Ok(identities) +} + +fn nonzero_or(value: u64, fallback: u64) -> u64 { + if value == 0 { + fallback + } else { + value + } +} + +fn ensure_private_staging_root(path: &Path) -> Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => Ok(()), + Ok(_) => Err(Error::InvalidPath { + path: path.to_string_lossy().into_owned(), + reason: "managed execution staging root must be a real directory".to_string(), + }), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + fs::create_dir_all(path)?; + Ok(()) + } + Err(error) => Err(error.into()), + } +} + +fn build_projection( + root: &Path, + staging: &Path, + limits: ProjectionLimits, +) -> Result { + let ignore = lane_workdir_ignore_matcher(root)?; + let archive_path = staging.join("input.tar"); + let archive_file = File::create(&archive_path)?; + let mut builder = tar::Builder::new(archive_file); + builder.follow_symlinks(false); + let mut projected_entries = 0_u64; + let mut projected_bytes = 0_u64; + let mut folded_paths = BTreeSet::new(); + let mut entries = walkdir::WalkDir::new(root) + .follow_links(false) + .into_iter() + .collect::, _>>() + .map_err(|error| Error::InvalidInput(error.to_string()))?; + entries.sort_by(|left, right| left.path().cmp(right.path())); + for entry in entries { + let relative = entry.path().strip_prefix(root).map_err(|_| { + Error::Corrupt(format!( + "projection entry `{}` escaped `{}`", + entry.path().display(), + root.display() + )) + })?; + if relative.as_os_str().is_empty() || entry.file_type().is_dir() { + continue; + } + let relative = relative.to_str().ok_or_else(|| Error::InvalidPath { + path: relative.to_string_lossy().into_owned(), + reason: "guest projections require Unicode paths".to_string(), + })?; + let relative = normalize_relative_path(&relative.replace(std::path::MAIN_SEPARATOR, "/"))?; + if ignore + .matched_path_or_any_parents(path_from_rel(&relative), entry.file_type().is_dir()) + .is_ignore() + { + continue; + } + let class = classify_view_path(&relative); + if matches!( + class, + ViewPathClass::Internal | ViewPathClass::Secret | ViewPathClass::Scratch + ) { + continue; + } + validate_projection_path_collision(&mut folded_paths, &relative)?; + projected_entries = projected_entries.saturating_add(1); + if projected_entries > limits.entries { + return Err(Error::InvalidInput(format!( + "guest projection exceeds the {}-entry limit", + limits.entries + ))); + } + let metadata = fs::symlink_metadata(entry.path())?; + let mode = portable_mode(&metadata); + let mut header = tar::Header::new_gnu(); + header.set_path(&relative)?; + header.set_uid(0); + header.set_gid(0); + header.set_mtime(0); + header.set_mode(mode); + if metadata.file_type().is_file() { + if metadata.len() > limits.file_bytes { + return Err(Error::InvalidInput(format!( + "guest projection file `{relative}` is {} bytes, exceeding the {}-byte file limit", + metadata.len(), limits.file_bytes + ))); + } + projected_bytes = projected_bytes.saturating_add(metadata.len()); + if projected_bytes > limits.total_bytes { + return Err(Error::InvalidInput(format!( + "guest projection exceeds the {}-byte total limit", + limits.total_bytes + ))); + } + header.set_entry_type(tar::EntryType::Regular); + header.set_size(metadata.len()); + header.set_cksum(); + let file = File::open(entry.path())?; + builder.append(&header, file)?; + } else if metadata.file_type().is_symlink() { + let target = fs::read_link(entry.path())?; + validate_relative_symlink(&relative, &target)?; + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_link_name(&target)?; + header.set_cksum(); + builder.append(&header, io::empty())?; + } else { + return Err(Error::InvalidPath { + path: relative, + reason: "guest projections support only regular files, directories, and relative symlinks" + .to_string(), + }); + } + } + builder.finish()?; + drop(builder); + let source_snapshot = source_snapshot_with_ignore(root, limits, &ignore)?; + let input_digest = snapshot_digest(&source_snapshot)?; + Ok(BuiltProjection { + archive_path, + input_digest, + projected_entries, + projected_bytes, + source_snapshot, + }) +} + +fn validate_and_extract_candidate( + archive_path: &Path, + output_root: &Path, + limits: ProjectionLimits, + source_ignore: &ignore::gitignore::Gitignore, +) -> Result { + let mut archive = tar::Archive::new(File::open(archive_path)?); + let mut count = 0_u64; + let mut total_bytes = 0_u64; + let mut folded_paths = BTreeSet::new(); + for entry in archive.entries()? { + let mut entry = entry?; + let raw_path = entry.path()?; + let raw_path = raw_path.to_str().ok_or_else(|| Error::InvalidPath { + path: raw_path.to_string_lossy().into_owned(), + reason: "guest candidate paths must be Unicode".to_string(), + })?; + let trimmed = raw_path.trim_start_matches("./"); + if trimmed.is_empty() { + continue; + } + let relative = normalize_relative_path(trimmed)?; + let class = classify_view_path(&relative); + if matches!(class, ViewPathClass::Internal | ViewPathClass::Secret) { + return Err(Error::InvalidPath { + path: relative, + reason: "guest candidate contains a private or internal path".to_string(), + }); + } + validate_projection_path_collision(&mut folded_paths, &relative)?; + count = count.saturating_add(1); + if count > limits.entries { + return Err(Error::InvalidInput(format!( + "guest candidate exceeds the {}-entry limit", + limits.entries + ))); + } + let destination = safe_join(output_root, &relative)?; + let entry_type = entry.header().entry_type(); + if entry_type.is_dir() { + fs::create_dir_all(&destination)?; + continue; + } + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; + } + if entry_type.is_file() { + let size = entry.header().size()?; + if size > limits.file_bytes { + return Err(Error::InvalidInput(format!( + "guest candidate file `{relative}` is {size} bytes, exceeding the {}-byte file limit", + limits.file_bytes + ))); + } + total_bytes = total_bytes.saturating_add(size); + if total_bytes > limits.total_bytes { + return Err(Error::InvalidInput(format!( + "guest candidate exceeds the {}-byte total limit", + limits.total_bytes + ))); + } + let mut output = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&destination)?; + let copied = io::copy(&mut entry, &mut output)?; + if copied != size { + return Err(Error::Corrupt(format!( + "guest candidate file `{relative}` declared {size} bytes but yielded {copied}" + ))); + } + set_portable_mode(&destination, entry.header().mode()?)?; + } else if entry_type.is_symlink() { + let target = entry + .link_name()? + .ok_or_else(|| Error::InvalidPath { + path: relative.clone(), + reason: "guest candidate symlink has no target".to_string(), + })? + .into_owned(); + validate_relative_symlink(&relative, &target)?; + create_relative_symlink(&target, &destination)?; + } else { + return Err(Error::InvalidPath { + path: relative, + reason: "guest candidate contains an unsupported archive entry type".to_string(), + }); + } + } + let source_snapshot = source_snapshot_with_ignore(output_root, limits, source_ignore)?; + let output_digest = snapshot_digest(&source_snapshot)?; + Ok(CandidateProjection { + output_digest, + source_snapshot, + }) +} + +#[cfg(test)] +fn source_snapshot( + root: &Path, + limits: ProjectionLimits, +) -> Result> { + let ignore = lane_workdir_ignore_matcher(root)?; + source_snapshot_with_ignore(root, limits, &ignore) +} + +fn source_snapshot_with_ignore( + root: &Path, + limits: ProjectionLimits, + ignore: &ignore::gitignore::Gitignore, +) -> Result> { + let mut snapshot = BTreeMap::new(); + let mut count = 0_u64; + let mut total_bytes = 0_u64; + for entry in walkdir::WalkDir::new(root).follow_links(false) { + let entry = entry.map_err(|error| Error::InvalidInput(error.to_string()))?; + if entry.file_type().is_dir() { + continue; + } + let relative = entry.path().strip_prefix(root).map_err(|_| { + Error::Corrupt(format!( + "snapshot entry `{}` escaped its root", + entry.path().display() + )) + })?; + let Some(relative) = relative.to_str() else { + return Err(Error::InvalidPath { + path: relative.to_string_lossy().into_owned(), + reason: "managed execution snapshots require Unicode paths".to_string(), + }); + }; + let relative = normalize_relative_path(&relative.replace(std::path::MAIN_SEPARATOR, "/"))?; + if ignore + .matched_path_or_any_parents(path_from_rel(&relative), entry.file_type().is_dir()) + .is_ignore() + { + continue; + } + if classify_view_path(&relative) != ViewPathClass::Source { + continue; + } + count = count.saturating_add(1); + if count > limits.entries { + return Err(Error::InvalidInput( + "source snapshot entry limit exceeded".to_string(), + )); + } + let metadata = fs::symlink_metadata(entry.path())?; + let mode = portable_mode(&metadata); + let snapshot_entry = if metadata.file_type().is_file() { + if metadata.len() > limits.file_bytes { + return Err(Error::InvalidInput(format!( + "source file `{relative}` exceeds the managed execution file limit" + ))); + } + total_bytes = total_bytes.saturating_add(metadata.len()); + if total_bytes > limits.total_bytes { + return Err(Error::InvalidInput( + "source snapshot byte limit exceeded".to_string(), + )); + } + SnapshotEntry { + kind: "file".to_string(), + content: sha256_file_hex(entry.path())?, + mode, + } + } else if metadata.file_type().is_symlink() { + let target = fs::read_link(entry.path())?; + validate_relative_symlink(&relative, &target)?; + SnapshotEntry { + kind: "symlink".to_string(), + content: target.to_string_lossy().into_owned(), + mode, + } + } else { + return Err(Error::InvalidPath { + path: relative, + reason: "source snapshot contains an unsupported file kind".to_string(), + }); + }; + if snapshot.insert(relative.clone(), snapshot_entry).is_some() { + return Err(Error::InvalidPath { + path: relative, + reason: "source snapshot contains a duplicate path".to_string(), + }); + } + } + Ok(snapshot) +} + +fn snapshot_digest(snapshot: &BTreeMap) -> Result { + Ok(sha256_hex(&serde_json::to_vec(snapshot)?)) +} + +fn apply_candidate_source( + mountpoint: &Path, + candidate_root: &Path, + input: &BTreeMap, + candidate: &BTreeMap, +) -> Result<(Vec, Vec)> { + let mut removed = input + .keys() + .filter(|path| !candidate.contains_key(*path)) + .cloned() + .collect::>(); + removed.sort_by(|left, right| { + right + .matches('/') + .count() + .cmp(&left.matches('/').count()) + .then(right.cmp(left)) + }); + for relative in &removed { + let destination = safe_join(mountpoint, relative)?; + match fs::symlink_metadata(&destination) { + Ok(metadata) if metadata.is_file() || metadata.file_type().is_symlink() => { + fs::remove_file(destination)?; + } + Ok(_) => { + return Err(Error::InvalidPath { + path: relative.clone(), + reason: "candidate deletion would remove a non-file entry".to_string(), + }); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + + let mut imported = candidate + .iter() + .filter(|(path, entry)| input.get(*path) != Some(*entry)) + .map(|(path, _)| path.clone()) + .collect::>(); + imported.sort(); + for relative in &imported { + let source = safe_join(candidate_root, relative)?; + let destination = safe_join(mountpoint, relative)?; + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; + } + match fs::symlink_metadata(&destination) { + Ok(metadata) if metadata.is_file() || metadata.file_type().is_symlink() => { + fs::remove_file(&destination)?; + } + Ok(_) => { + return Err(Error::InvalidPath { + path: relative.clone(), + reason: "candidate file would replace a directory or special entry".to_string(), + }); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + let metadata = fs::symlink_metadata(&source)?; + if metadata.file_type().is_file() { + let bytes = fs::read(&source)?; + write_file_atomic(&destination, &bytes, false)?; + set_portable_mode(&destination, portable_mode(&metadata))?; + } else if metadata.file_type().is_symlink() { + let target = fs::read_link(&source)?; + validate_relative_symlink(relative, &target)?; + create_relative_symlink(&target, &destination)?; + } else { + return Err(Error::InvalidPath { + path: relative.clone(), + reason: "validated candidate changed file kind before import".to_string(), + }); + } + } + Ok((imported, removed)) +} + +fn guest_environment( + context: &ManagedExecutionContext, + view: &LaneWorkspaceViewReport, + guest_workspace: &str, + guest_home: &str, + guest_tmp: &str, +) -> Result> { + let mountpoint = Path::new(&view.mountpoint); + let mut environment = BTreeMap::from([ + ("HOME".to_string(), guest_home.to_string()), + ("TMPDIR".to_string(), guest_tmp.to_string()), + ( + "PATH".to_string(), + "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(), + ), + ("TRAIL_EXECUTION_BACKEND".to_string(), "colima".to_string()), + ]); + for (name, value) in &context.environment { + if is_sensitive_json_key(name) || matches!(name.as_str(), "GIT_DIR" | "GIT_INDEX_FILE") { + continue; + } + let translated = if name == "TRAIL_WORKSPACE" { + guest_workspace.to_string() + } else if Path::new(value).is_absolute() { + let relative = Path::new(value).strip_prefix(mountpoint).map_err(|_| { + Error::InvalidInput(format!( + "managed guest environment `{name}` references host path `{value}` outside the lane view" + )) + })?; + if relative.as_os_str().is_empty() { + guest_workspace.to_string() + } else { + let relative = relative.to_str().ok_or_else(|| Error::InvalidPath { + path: relative.to_string_lossy().into_owned(), + reason: "guest environment paths must be Unicode".to_string(), + })?; + format!( + "{guest_workspace}/{}", + relative.replace(std::path::MAIN_SEPARATOR, "/") + ) + } + } else { + value.clone() + }; + if translated.as_bytes().contains(&0) || translated.contains('\n') { + return Err(Error::InvalidInput(format!( + "managed guest environment `{name}` contains an unsupported value" + ))); + } + environment.insert(name.clone(), translated); + } + Ok(environment + .into_iter() + .map(|(name, value)| format!("{name}={value}")) + .collect()) +} + +struct GuestProtocolResult { + status: ExitStatus, + stderr: Vec, + timed_out: bool, +} + +fn run_guest_status( + toolchain: &super::workspace_runtime_toolchain::ColimaToolchain, + instance: &str, + guest_args: &[String], + stdin: Option, + workdir: Option<&str>, +) -> Result { + let started = Instant::now(); + let mut process = toolchain.limactl_command(); + process.arg("shell"); + if let Some(workdir) = workdir { + process.args(["--workdir", workdir]); + } + process.arg(instance).arg("--").args(guest_args); + if let Some(stdin) = stdin { + process.stdin(stdin); + } else { + process.stdin(Stdio::null()); + } + process.stdout(Stdio::null()).stderr(Stdio::piped()); + let mut child = process.spawn()?; + let stderr = child.stderr.take().ok_or_else(|| { + Error::Corrupt("managed guest protocol command did not expose stderr".to_string()) + })?; + let stderr_reader = + std::thread::spawn(move || read_bounded_stream(stderr, MAX_GUEST_DIAGNOSTIC_BYTES)); + let (status, timed_out) = wait_for_child(&mut child, started, Some(GUEST_PROTOCOL_TIMEOUT))?; + let stderr = stderr_reader.join().map_err(|_| { + Error::Corrupt("managed guest protocol stderr reader panicked".to_string()) + })??; + Ok(GuestProtocolResult { + status, + stderr, + timed_out, + }) +} + +fn run_guest_command( + toolchain: &super::workspace_runtime_toolchain::ColimaToolchain, + instance: &str, + guest_args: &[String], + workdir: &str, + timeout: Option, + cancellation_path: &Path, + manifest: &GuestExecutionManifest, +) -> Result { + let started = Instant::now(); + let process_group_path = format!("{}/process-group", manifest.guest_namespace); + let mut process = toolchain.limactl_command(); + process + .args(["shell", "--workdir", workdir, instance, "--"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + process.args([ + "sh", + "-c", + "pidfile=$1; shift; setsid \"$@\" & child=$!; printf '%s\\n' \"$child\" > \"$pidfile\"; wait \"$child\"; status=$?; rm -f -- \"$pidfile\"; exit \"$status\"", + "trail-guest-launch", + &process_group_path, + ]); + if let Some(timeout) = timeout { + process.args([ + "timeout", + "--signal=TERM", + "--kill-after=5s", + &format!("{}s", timeout.as_secs()), + ]); + } + process.args(guest_args); + let mut child = process.spawn()?; + let stdout = child + .stdout + .take() + .ok_or_else(|| Error::Corrupt("managed guest command did not expose stdout".to_string()))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| Error::Corrupt("managed guest command did not expose stderr".to_string()))?; + let stdout_reader = + std::thread::spawn(move || read_bounded_stream(stdout, MAX_GUEST_STDOUT_BYTES)); + let stderr_reader = + std::thread::spawn(move || read_bounded_stream(stderr, MAX_GUEST_STDERR_BYTES)); + let host_deadline = timeout.map(|timeout| timeout.saturating_add(Duration::from_secs(15))); + let mut cancelled = false; + let mut process_group_terminated = false; + let (status, host_timed_out) = loop { + if let Some(status) = child.try_wait()? { + break (status, false); + } + if !cancelled && read_guest_cancellation_request(cancellation_path, manifest)?.is_some() { + cancelled = true; + process_group_terminated = + terminate_guest_process_group(toolchain, instance, &process_group_path, true)?; + } + if cancelled && started.elapsed() >= Duration::from_secs(15) { + let _ = child.kill(); + break (child.wait()?, false); + } + if host_deadline.is_some_and(|deadline| started.elapsed() >= deadline) { + let _ = child.kill(); + break (child.wait()?, true); + } + std::thread::sleep(Duration::from_millis(50)); + }; + let stdout = stdout_reader + .join() + .map_err(|_| Error::Corrupt("managed guest stdout reader panicked".to_string()))??; + let stderr = stderr_reader + .join() + .map_err(|_| Error::Corrupt("managed guest stderr reader panicked".to_string()))??; + let exit_code = status.code(); + let guest_timed_out = timeout.is_some() && exit_code == Some(124); + Ok(CommandRunResult { + success: status.success(), + exit_code, + timed_out: host_timed_out || guest_timed_out, + cancelled, + process_group_terminated, + duration_ms: elapsed_ms(started.elapsed()), + stdout, + stderr, + }) +} + +fn terminate_guest_process_group( + toolchain: &super::workspace_runtime_toolchain::ColimaToolchain, + instance: &str, + process_group_path: &str, + wait_for_receipt: bool, +) -> Result { + let started = Instant::now(); + let process_group = loop { + let result = run_guest_capture( + toolchain, + instance, + &[ + "cat".to_string(), + "--".to_string(), + process_group_path.to_string(), + ], + )?; + if result.status.success() { + let value = String::from_utf8_lossy(&result.stdout).trim().to_string(); + let process_group = value.parse::().map_err(|_| { + Error::Corrupt("managed guest process-group receipt is invalid".to_string()) + })?; + if process_group <= 1 { + return Err(Error::Corrupt( + "managed guest process-group receipt is unsafe".to_string(), + )); + } + break process_group; + } + if !wait_for_receipt || started.elapsed() >= Duration::from_secs(5) { + return Ok(false); + } + std::thread::sleep(Duration::from_millis(50)); + }; + let negative_group = format!("-{process_group}"); + let term = run_guest_status( + toolchain, + instance, + &[ + "kill".to_string(), + "-TERM".to_string(), + "--".to_string(), + negative_group.clone(), + ], + None, + None, + )?; + if !term.status.success() { + return Ok(false); + } + let wait_started = Instant::now(); + while wait_started.elapsed() < Duration::from_secs(2) { + let probe = run_guest_status( + toolchain, + instance, + &[ + "kill".to_string(), + "-0".to_string(), + "--".to_string(), + negative_group.clone(), + ], + None, + None, + )?; + if !probe.status.success() { + return Ok(true); + } + std::thread::sleep(Duration::from_millis(50)); + } + let _ = run_guest_status( + toolchain, + instance, + &[ + "kill".to_string(), + "-KILL".to_string(), + "--".to_string(), + negative_group, + ], + None, + None, + )?; + Ok(true) +} + +struct GuestCaptureResult { + status: ExitStatus, + stdout: Vec, +} + +fn run_guest_capture( + toolchain: &super::workspace_runtime_toolchain::ColimaToolchain, + instance: &str, + guest_args: &[String], +) -> Result { + let mut process = toolchain.limactl_command(); + process + .arg("shell") + .arg(instance) + .arg("--") + .args(guest_args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + let mut child = process.spawn()?; + let stdout = child + .stdout + .take() + .ok_or_else(|| Error::Corrupt("managed guest capture did not expose stdout".to_string()))?; + let reader = std::thread::spawn(move || read_bounded_stream(stdout, 64)); + let (status, timed_out) = + wait_for_child(&mut child, Instant::now(), Some(Duration::from_secs(10)))?; + let stdout = reader + .join() + .map_err(|_| Error::Corrupt("managed guest capture reader panicked".to_string()))??; + if timed_out { + return Err(Error::InvalidInput( + "managed guest process-group inspection timed out".to_string(), + )); + } + Ok(GuestCaptureResult { status, stdout }) +} + +fn wait_for_child( + child: &mut Child, + started: Instant, + deadline: Option, +) -> Result<(ExitStatus, bool)> { + loop { + if let Some(status) = child.try_wait()? { + return Ok((status, false)); + } + if deadline.is_some_and(|deadline| started.elapsed() >= deadline) { + let _ = child.kill(); + return Ok((child.wait()?, true)); + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +fn read_bounded_stream(mut reader: impl Read, limit: usize) -> Result> { + let mut output = Vec::new(); + let mut buffer = [0_u8; 64 * 1024]; + let mut truncated = false; + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + let remaining = limit.saturating_sub(output.len()); + let retained = remaining.min(read); + output.extend_from_slice(&buffer[..retained]); + truncated |= retained < read; + } + if truncated { + output.extend_from_slice(b"\n[Trail guest output truncated]\n"); + } + Ok(output) +} + +fn require_guest_status( + toolchain: &super::workspace_runtime_toolchain::ColimaToolchain, + instance: &str, + guest_args: &[String], + stdin: Option, + workdir: Option<&str>, +) -> Result<()> { + let result = run_guest_status(toolchain, instance, guest_args, stdin, workdir)?; + if result.status.success() { + Ok(()) + } else { + let failure = if result.timed_out { + format!( + "timed out after {} seconds", + GUEST_PROTOCOL_TIMEOUT.as_secs() + ) + } else { + format!( + "exited with code {}: {}", + result.status.code().unwrap_or(128), + guest_diagnostic(&result.stderr, &[]) + ) + }; + Err(Error::InvalidInput(format!( + "managed guest protocol command `{}` {failure}", + guest_args.first().map(String::as_str).unwrap_or("unknown"), + ))) + } +} + +fn export_guest_archive( + toolchain: &super::workspace_runtime_toolchain::ColimaToolchain, + instance: &str, + guest_workspace: &str, + output: File, + byte_limit: u64, +) -> Result<()> { + let started = Instant::now(); + let mut process = toolchain.limactl_command(); + process + .args([ + "shell", + instance, + "--", + "tar", + "-cpf", + "-", + "-C", + guest_workspace, + ".", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = process.spawn()?; + let stdout = child + .stdout + .take() + .ok_or_else(|| Error::Corrupt("managed guest export did not expose stdout".to_string()))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| Error::Corrupt("managed guest export did not expose stderr".to_string()))?; + let export_reader = + std::thread::spawn(move || copy_stream_to_bounded_file(stdout, output, byte_limit)); + let stderr_reader = + std::thread::spawn(move || read_bounded_stream(stderr, MAX_GUEST_DIAGNOSTIC_BYTES)); + let (status, timed_out) = wait_for_child(&mut child, started, Some(GUEST_PROTOCOL_TIMEOUT))?; + let export = export_reader + .join() + .map_err(|_| Error::Corrupt("managed guest export reader panicked".to_string()))??; + let stderr = stderr_reader + .join() + .map_err(|_| Error::Corrupt("managed guest export stderr reader panicked".to_string()))??; + if timed_out { + return Err(Error::InvalidInput(format!( + "managed guest export timed out after {} seconds", + GUEST_PROTOCOL_TIMEOUT.as_secs() + ))); + } + if export.exceeded { + return Err(Error::InvalidInput(format!( + "managed guest candidate archive exceeds its {byte_limit}-byte allowance" + ))); + } + if status.success() { + Ok(()) + } else { + Err(Error::InvalidInput(format!( + "could not export the managed guest candidate: {}", + guest_diagnostic(&stderr, &[]) + ))) + } +} + +struct BoundedFileCopy { + exceeded: bool, +} + +fn copy_stream_to_bounded_file( + mut reader: impl Read, + mut output: File, + byte_limit: u64, +) -> Result { + let mut written = 0_u64; + let mut exceeded = false; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + let remaining = byte_limit.saturating_sub(written); + let retained = usize::try_from(remaining.min(read as u64)).unwrap_or(read); + output.write_all(&buffer[..retained])?; + written = written.saturating_add(retained as u64); + exceeded |= retained < read; + } + output.sync_all()?; + Ok(BoundedFileCopy { exceeded }) +} + +fn cleanup_guest_namespace( + toolchain: &super::workspace_runtime_toolchain::ColimaToolchain, + instance: &str, + guest_namespace: &str, +) -> Result<()> { + if !guest_namespace.starts_with(&format!("{GUEST_EXECUTION_ROOT}/")) + || guest_namespace.contains("..") + { + return Err(Error::InvalidPath { + path: guest_namespace.to_string(), + reason: "refusing to clean an invalid guest execution namespace".to_string(), + }); + } + let result = run_guest_status( + toolchain, + instance, + &[ + "rm".to_string(), + "-rf".to_string(), + "--".to_string(), + guest_namespace.to_string(), + ], + Some(Stdio::null()), + None, + )?; + if result.status.success() { + Ok(()) + } else { + Err(Error::InvalidInput(format!( + "guest namespace cleanup failed: {}", + if result.timed_out { + "timed out".to_string() + } else { + guest_diagnostic(&result.stderr, &[]) + } + ))) + } +} + +fn validate_projection_path_collision(folded: &mut BTreeSet, path: &str) -> Result<()> { + let key = path.to_ascii_lowercase(); + if folded.insert(key) { + Ok(()) + } else { + Err(Error::InvalidPath { + path: path.to_string(), + reason: "guest projection contains a duplicate or case-colliding path".to_string(), + }) + } +} + +fn validate_relative_symlink(path: &str, target: &Path) -> Result<()> { + if target.is_absolute() { + return Err(Error::InvalidPath { + path: path.to_string(), + reason: "guest projections reject absolute symlink targets".to_string(), + }); + } + let Some(target) = target.to_str() else { + return Err(Error::InvalidPath { + path: path.to_string(), + reason: "guest symlink targets must be Unicode".to_string(), + }); + }; + let parent = Path::new(path).parent().unwrap_or_else(|| Path::new("")); + let resolved = parent.join(target); + let resolved = resolved.to_str().ok_or_else(|| Error::InvalidPath { + path: path.to_string(), + reason: "guest symlink target cannot be represented safely".to_string(), + })?; + normalize_relative_path(resolved).map(|_| ()) +} + +#[cfg(unix)] +fn portable_mode(metadata: &fs::Metadata) -> u32 { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o777 +} + +#[cfg(not(unix))] +fn portable_mode(_metadata: &fs::Metadata) -> u32 { + 0o644 +} + +#[cfg(unix)] +fn set_portable_mode(path: &Path, mode: u32) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(mode & 0o777))?; + Ok(()) +} + +#[cfg(not(unix))] +fn set_portable_mode(_path: &Path, _mode: u32) -> Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn create_relative_symlink(target: &Path, destination: &Path) -> Result<()> { + std::os::unix::fs::symlink(target, destination)?; + Ok(()) +} + +#[cfg(not(unix))] +fn create_relative_symlink(_target: &Path, destination: &Path) -> Result<()> { + Err(Error::InvalidPath { + path: destination.to_string_lossy().into_owned(), + reason: "managed Colima symlink import is unsupported on this host".to_string(), + }) +} + +fn guest_diagnostic(stderr: &[u8], stdout: &[u8]) -> String { + let bytes = if stderr.is_empty() { stdout } else { stderr }; + let limited = &bytes[..bytes.len().min(MAX_GUEST_DIAGNOSTIC_BYTES)]; + let mut message = String::from_utf8_lossy(limited).trim().to_string(); + if bytes.len() > limited.len() { + message.push_str(" [truncated]"); + } + if message.is_empty() { + "guest command failed without diagnostic output".to_string() + } else { + message + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn guest_test_manifest(namespace: String) -> GuestExecutionManifest { + GuestExecutionManifest { + schema: GUEST_MANIFEST_SCHEMA, + execution_id: "exec_test".to_string(), + lane_id: "lane_test".to_string(), + profile: "trail-test".to_string(), + lima_instance: "colima-trail-test".to_string(), + guest_namespace: namespace, + staging_path: "/private/staging".to_string(), + owner_pid: std::process::id(), + owner_start_token: current_process_start_token(), + phase: "executing".to_string(), + input_digest: "input".to_string(), + candidate_digest: None, + imported_paths: Vec::new(), + removed_paths: Vec::new(), + checkpoint_root: None, + checkpoint_operation: None, + error: None, + cancellation_requested_at: None, + cancellation_completed_at: None, + process_group_terminated: false, + updated_at: 1, + } + } + + #[cfg(unix)] + fn write_executable(path: &Path, script: &str) { + use std::os::unix::fs::PermissionsExt; + fs::write(path, script).unwrap(); + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o700); + fs::set_permissions(path, permissions).unwrap(); + } + + #[cfg(unix)] + fn write_fake_setsid(root: &Path) { + write_executable( + &root.join("setsid"), + "#!/usr/bin/env python3\nimport os, sys\nos.setsid()\nos.execvp(sys.argv[1], sys.argv[1:])\n", + ); + } + + #[cfg(unix)] + fn fake_limactl_script() -> &'static str { + "#!/bin/sh\n[ \"$1\" = shell ] || exit 91\nshift\nif [ \"$1\" = --workdir ]; then workdir=$2; shift 2; fi\nshift\n[ \"$1\" = -- ] || exit 92\nshift\n[ -z \"$workdir\" ] || cd \"$workdir\" || exit 93\nPATH=\"$(dirname \"$0\"):$PATH\"\nCOPYFILE_DISABLE=1\nexport PATH COPYFILE_DISABLE\nexec \"$@\"\n" + } + + #[test] + fn guest_diagnostic_is_bounded() { + let diagnostic = guest_diagnostic(&vec![b'x'; MAX_GUEST_DIAGNOSTIC_BYTES + 20], &[]); + assert!(diagnostic.ends_with(" [truncated]")); + assert!(diagnostic.len() <= MAX_GUEST_DIAGNOSTIC_BYTES + " [truncated]".len()); + } + + #[test] + fn guest_manifest_errors_are_redacted_before_durable_storage() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("manifest.json"); + let mut manifest = guest_test_manifest("/tmp/trail-executions/test/exec_test".to_string()); + write_guest_manifest(&path, &manifest).unwrap(); + update_guest_manifest( + &path, + &mut manifest, + "cleanup_failed", + Some("request failed with token=supersecret"), + ) + .unwrap(); + let durable = fs::read_to_string(path).unwrap(); + assert!(!durable.contains("supersecret")); + assert!(durable.contains("[REDACTED]")); + } + + #[test] + fn projection_excludes_private_paths_and_tracks_only_source_for_import() { + let root = tempfile::tempdir().unwrap(); + let staging = tempfile::tempdir().unwrap(); + fs::write(root.path().join("README.md"), "source").unwrap(); + fs::write(root.path().join(".env"), "SECRET=value").unwrap(); + fs::write(root.path().join(".trailignore"), "ignored.txt\n").unwrap(); + fs::write(root.path().join("ignored.txt"), "ignored").unwrap(); + fs::create_dir(root.path().join("target")).unwrap(); + fs::write(root.path().join("target/output.bin"), "generated").unwrap(); + let projection = build_projection( + root.path(), + staging.path(), + ProjectionLimits { + entries: 20, + total_bytes: 1024, + file_bytes: 1024, + }, + ) + .unwrap(); + let mut archive = tar::Archive::new(File::open(projection.archive_path).unwrap()); + let paths = archive + .entries() + .unwrap() + .map(|entry| { + entry + .unwrap() + .path() + .unwrap() + .to_string_lossy() + .into_owned() + }) + .collect::>(); + assert_eq!(paths, [".trailignore", "README.md", "target/output.bin"]); + assert_eq!( + projection + .source_snapshot + .keys() + .cloned() + .collect::>(), + [".trailignore", "README.md"] + ); + } + + #[cfg(unix)] + #[test] + fn projection_rejects_escaping_symlinks() { + let root = tempfile::tempdir().unwrap(); + let staging = tempfile::tempdir().unwrap(); + std::os::unix::fs::symlink("../outside", root.path().join("escape")).unwrap(); + let error = build_projection( + root.path(), + staging.path(), + ProjectionLimits { + entries: 20, + total_bytes: 1024, + file_bytes: 1024, + }, + ) + .unwrap_err(); + assert!(error.to_string().contains("stay inside the workspace")); + } + + #[test] + fn candidate_import_applies_only_validated_source_delta() { + let mount = tempfile::tempdir().unwrap(); + let candidate = tempfile::tempdir().unwrap(); + fs::write(mount.path().join("README.md"), "before").unwrap(); + fs::write(mount.path().join("removed.txt"), "remove").unwrap(); + fs::write(candidate.path().join("README.md"), "after").unwrap(); + fs::write(candidate.path().join("added.txt"), "add").unwrap(); + fs::create_dir(candidate.path().join("target")).unwrap(); + fs::write(candidate.path().join("target/generated"), "ignore").unwrap(); + let limits = ProjectionLimits { + entries: 20, + total_bytes: 1024, + file_bytes: 1024, + }; + let input = source_snapshot(mount.path(), limits).unwrap(); + let output = source_snapshot(candidate.path(), limits).unwrap(); + let (imported, removed) = + apply_candidate_source(mount.path(), candidate.path(), &input, &output).unwrap(); + assert_eq!(imported, ["README.md", "added.txt"]); + assert_eq!(removed, ["removed.txt"]); + assert_eq!( + fs::read_to_string(mount.path().join("README.md")).unwrap(), + "after" + ); + assert_eq!( + fs::read_to_string(mount.path().join("added.txt")).unwrap(), + "add" + ); + assert!(!mount.path().join("removed.txt").exists()); + assert!(!mount.path().join("target/generated").exists()); + } + + #[test] + fn candidate_archive_rejects_absolute_symlink_target() { + let root = tempfile::tempdir().unwrap(); + let archive_path = root.path().join("candidate.tar"); + let mut builder = tar::Builder::new(File::create(&archive_path).unwrap()); + let mut header = tar::Header::new_gnu(); + header.set_path("link").unwrap(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_mode(0o777); + header.set_link_name("/etc/passwd").unwrap(); + header.set_cksum(); + builder.append(&header, io::empty()).unwrap(); + builder.finish().unwrap(); + drop(builder); + let output = root.path().join("output"); + fs::create_dir(&output).unwrap(); + let ignore = lane_workdir_ignore_matcher(&output).unwrap(); + let error = validate_and_extract_candidate( + &archive_path, + &output, + ProjectionLimits { + entries: 20, + total_bytes: 1024, + file_bytes: 1024, + }, + &ignore, + ) + .unwrap_err(); + assert!(error.to_string().contains("absolute symlink")); + } + + #[test] + fn candidate_archive_rejects_entry_limit_and_case_collisions() { + let root = tempfile::tempdir().unwrap(); + let archive_path = root.path().join("candidate.tar"); + let mut builder = tar::Builder::new(File::create(&archive_path).unwrap()); + for path in ["Readme.md", "README.md"] { + let mut header = tar::Header::new_gnu(); + header.set_path(path).unwrap(); + header.set_entry_type(tar::EntryType::Regular); + header.set_size(1); + header.set_mode(0o644); + header.set_cksum(); + builder.append(&header, &b"x"[..]).unwrap(); + } + builder.finish().unwrap(); + drop(builder); + let output = root.path().join("output"); + fs::create_dir(&output).unwrap(); + let ignore = lane_workdir_ignore_matcher(&output).unwrap(); + let error = validate_and_extract_candidate( + &archive_path, + &output, + ProjectionLimits { + entries: 20, + total_bytes: 1024, + file_bytes: 1024, + }, + &ignore, + ) + .unwrap_err(); + assert!(error.to_string().contains("case-colliding")); + + fs::remove_dir_all(&output).unwrap(); + fs::create_dir(&output).unwrap(); + let ignore = lane_workdir_ignore_matcher(&output).unwrap(); + let error = validate_and_extract_candidate( + &archive_path, + &output, + ProjectionLimits { + entries: 0, + total_bytes: 1024, + file_bytes: 1024, + }, + &ignore, + ) + .unwrap_err(); + assert!(error.to_string().contains("entry limit")); + } + + #[test] + fn output_capture_drains_but_retains_only_the_bound() { + let captured = read_bounded_stream(&b"0123456789"[..], 4).unwrap(); + assert!(captured.starts_with(b"0123")); + assert!(captured.ends_with(b"[Trail guest output truncated]\n")); + assert!(!captured.windows(4).any(|window| window == b"4567")); + } + + #[test] + fn archive_capture_drains_but_never_writes_past_the_bound() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("candidate.tar"); + let result = + copy_stream_to_bounded_file(&b"0123456789"[..], File::create(&path).unwrap(), 4) + .unwrap(); + assert!(result.exceeded); + assert_eq!(fs::read(path).unwrap(), b"0123"); + } + + #[test] + fn service_receipt_records_only_sorted_service_identities() { + let bindings = guest_service_binding_identities(&[( + "TRAIL_SERVICES_JSON".to_string(), + r#"{"z-service":{"port":5432},"a-service":{"port":1234}}"#.to_string(), + )]) + .unwrap(); + assert_eq!(bindings, ["a-service", "z-service"]); + } + + #[cfg(unix)] + #[test] + fn fake_limactl_protocol_preserves_direct_command_arguments() { + let root = tempfile::tempdir().unwrap(); + let limactl = root.path().join("limactl"); + write_executable(&limactl, fake_limactl_script()); + write_fake_setsid(root.path()); + let toolchain = + super::super::workspace_runtime_toolchain::ColimaToolchain::for_guest_protocol_test( + limactl, + root.path(), + ); + let manifest = guest_test_manifest(root.path().to_string_lossy().into_owned()); + let run = run_guest_command( + &toolchain, + "colima-test", + &[ + "/bin/echo".to_string(), + "literal;$(touch should-not-exist)".to_string(), + ], + root.path().to_str().unwrap(), + None, + &root.path().join("exec_test.cancel"), + &manifest, + ) + .unwrap(); + assert!(run.success); + assert_eq!( + String::from_utf8(run.stdout).unwrap(), + "literal;$(touch should-not-exist)\n" + ); + assert!(!root.path().join("should-not-exist").exists()); + } + + #[cfg(unix)] + #[test] + fn cancellation_terminates_only_owned_guest_process_group() { + let root = tempfile::tempdir().unwrap(); + let limactl = root.path().join("limactl"); + write_executable(&limactl, fake_limactl_script()); + write_fake_setsid(root.path()); + let toolchain = + super::super::workspace_runtime_toolchain::ColimaToolchain::for_guest_protocol_test( + limactl, + root.path(), + ); + let manifest = guest_test_manifest(root.path().to_string_lossy().into_owned()); + let cancellation_path = root.path().join("exec_test.cancel"); + let started_path = root.path().join("started"); + let completed_path = root.path().join("completed"); + let request_path = cancellation_path.clone(); + let request_manifest = manifest.clone(); + let started_for_request = started_path.clone(); + let requester = std::thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(5); + while !started_for_request.exists() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert!(started_for_request.exists()); + let request = GuestCancellationRequest { + schema: GUEST_MANIFEST_SCHEMA, + execution_id: request_manifest.execution_id, + lane_id: request_manifest.lane_id, + requested_at: now_ts(), + }; + write_file_atomic( + &request_path, + &serde_json::to_vec_pretty(&request).unwrap(), + true, + ) + .unwrap(); + }); + let mut unrelated = Command::new("sleep").arg("30").spawn().unwrap(); + let run = run_guest_command( + &toolchain, + "colima-test", + &[ + "/bin/sh".to_string(), + "-c".to_string(), + "touch \"$1\"; sleep 30; touch \"$2\"".to_string(), + "trail-cancel-test".to_string(), + started_path.to_string_lossy().into_owned(), + completed_path.to_string_lossy().into_owned(), + ], + root.path().to_str().unwrap(), + None, + &cancellation_path, + &manifest, + ) + .unwrap(); + requester.join().unwrap(); + assert!(run.cancelled); + assert!(run.process_group_terminated); + assert!(!run.success); + assert!(!completed_path.exists()); + assert!(unrelated.try_wait().unwrap().is_none()); + unrelated.kill().unwrap(); + unrelated.wait().unwrap(); + } + + #[test] + fn recovery_discards_only_preimport_terminally_safe_phases() { + let mut manifest = GuestExecutionManifest { + schema: GUEST_MANIFEST_SCHEMA, + execution_id: "exec_0123".to_string(), + lane_id: "lane".to_string(), + profile: "trail-test".to_string(), + lima_instance: "colima-trail-test".to_string(), + guest_namespace: "/tmp/trail-executions/workspace/exec_0123".to_string(), + staging_path: "/private/staging".to_string(), + owner_pid: 1, + owner_start_token: "owner".to_string(), + phase: "projected".to_string(), + input_digest: "input".to_string(), + candidate_digest: None, + imported_paths: Vec::new(), + removed_paths: Vec::new(), + checkpoint_root: None, + checkpoint_operation: None, + error: None, + cancellation_requested_at: None, + cancellation_completed_at: None, + process_group_terminated: false, + updated_at: 1, + }; + assert!(guest_manifest_is_safely_discardable(&manifest)); + manifest.phase = "executing".to_string(); + assert!(!guest_manifest_is_safely_discardable(&manifest)); + manifest.phase = "exported".to_string(); + assert!(!guest_manifest_is_safely_discardable(&manifest)); + manifest.phase = "cleaned".to_string(); + assert!(guest_manifest_is_safely_discardable(&manifest)); + manifest.imported_paths.push("README.md".to_string()); + assert!(!guest_manifest_is_safely_discardable(&manifest)); + } + + #[test] + fn doctor_reports_ambiguous_guest_execution_without_mutating_it() { + let root = tempfile::tempdir().unwrap(); + fs::write(root.path().join("README.md"), "root\n").unwrap(); + Trail::init(root.path(), "main", InitImportMode::WorkingTree, false).unwrap(); + let db = Trail::open(root.path()).unwrap(); + let execution_id = "exec_0123"; + let workspace_hash = &sha256_hex(db.config.workspace.id.0.as_bytes())[..16]; + let manifest = GuestExecutionManifest { + schema: GUEST_MANIFEST_SCHEMA, + execution_id: execution_id.to_string(), + lane_id: "lane".to_string(), + profile: "trail-test".to_string(), + lima_instance: "colima-trail-test".to_string(), + guest_namespace: format!("{GUEST_EXECUTION_ROOT}/{workspace_hash}/{execution_id}"), + staging_path: "/private/staging".to_string(), + owner_pid: u32::MAX, + owner_start_token: "not-live".to_string(), + phase: "exported".to_string(), + input_digest: "input".to_string(), + candidate_digest: Some("candidate".to_string()), + imported_paths: Vec::new(), + removed_paths: Vec::new(), + checkpoint_root: None, + checkpoint_operation: None, + error: None, + cancellation_requested_at: None, + cancellation_completed_at: None, + process_group_terminated: false, + updated_at: 1, + }; + let path = guest_manifest_path(&db, execution_id).unwrap(); + write_guest_manifest(&path, &manifest).unwrap(); + + let check = db.managed_guest_recovery_doctor_check(); + assert_eq!(check.name, "managed_guest_executions"); + assert_eq!(check.status, "error"); + assert_eq!(check.details.unwrap()["ambiguous"], 1); + assert!(path.exists(), "doctor must remain read-only"); + } + + #[test] + fn public_cancellation_report_reopens_terminal_receipt_by_execution_id() { + let root = tempfile::tempdir().unwrap(); + fs::write(root.path().join("README.md"), "root\n").unwrap(); + Trail::init(root.path(), "main", InitImportMode::WorkingTree, false).unwrap(); + let mut db = Trail::open(root.path()).unwrap(); + let lane = db + .spawn_lane("cancel-lane", Some("main"), false, None, None) + .unwrap(); + let execution_id = "exec_cancelled"; + let workspace_hash = &sha256_hex(db.config.workspace.id.0.as_bytes())[..16]; + let mut manifest = guest_test_manifest(format!( + "{GUEST_EXECUTION_ROOT}/{workspace_hash}/{execution_id}" + )); + manifest.execution_id = execution_id.to_string(); + manifest.lane_id = lane.lane_id.clone(); + manifest.phase = "terminal_cancelled".to_string(); + manifest.cancellation_requested_at = Some(1); + manifest.cancellation_completed_at = Some(2); + manifest.process_group_terminated = true; + write_guest_manifest(&guest_manifest_path(&db, execution_id).unwrap(), &manifest).unwrap(); + + let report = db + .cancel_lane_workspace_execution("cancel-lane", Some(execution_id)) + .unwrap(); + assert_eq!(report.execution_id, execution_id); + assert_eq!(report.status, "cancelled"); + assert!(report.process_group_terminated); + assert_eq!(report.cleanup_status, "succeeded"); + } + + #[cfg(unix)] + #[test] + fn fake_guest_full_protocol_imports_source_and_cleans_namespace() { + let root = tempfile::tempdir().unwrap(); + fs::write(root.path().join("README.md"), "root\n").unwrap(); + Trail::init(root.path(), "main", InitImportMode::WorkingTree, false).unwrap(); + let mut db = Trail::open(root.path()).unwrap(); + let lane = db + .spawn_lane("guest-protocol", Some("main"), true, None, None) + .unwrap(); + let workdir = PathBuf::from(lane.workdir.unwrap()); + let view = LaneWorkspaceViewReport { + view_id: "view_fake_guest".to_string(), + lane_id: lane.lane_id, + base_change: lane.base_change, + base_root: db.get_ref("refs/lanes/guest-protocol").unwrap().root_id, + backend: "fake".to_string(), + mountpoint: workdir.to_string_lossy().into_owned(), + source_upper: workdir.to_string_lossy().into_owned(), + generated_upper: workdir.join("target").to_string_lossy().into_owned(), + scratch_upper: workdir.join(".scratch").to_string_lossy().into_owned(), + meta_dir: workdir.join(".meta").to_string_lossy().into_owned(), + journal_path: workdir.join(".journal").to_string_lossy().into_owned(), + generation: 1, + checkpoint_seq: 0, + checkpoint_root: None, + status: "ready".to_string(), + owner_pid: None, + owner_start_token: None, + heartbeat_at: None, + created_at: 1, + updated_at: 1, + }; + db.config.runtime.provider = "colima".to_string(); + db.config.runtime.execution_backend = "colima".to_string(); + db.config.runtime.colima_profile = Some("trail-test".to_string()); + let mut context = db + .prepare_managed_lane_execution("guest-protocol", "lane_exec", &["/bin/sh".to_string()]) + .unwrap(); + let fake = tempfile::tempdir().unwrap(); + let limactl = fake.path().join("limactl"); + write_executable(&limactl, fake_limactl_script()); + write_fake_setsid(fake.path()); + let toolchain = + super::super::workspace_runtime_toolchain::ColimaToolchain::for_guest_protocol_test( + limactl, + fake.path(), + ); + let mut marked = false; + let run = db + .run_colima_lane_command_inner( + &mut context, + &view, + &[ + "/bin/sh".to_string(), + "-c".to_string(), + "printf guest > guest-source.txt; mkdir -p target; printf generated > target/build.bin" + .to_string(), + ], + None, + &mut marked, + Some(toolchain), + ) + .unwrap(); + assert!(run.success); + assert!(marked); + assert_eq!( + fs::read_to_string(workdir.join("guest-source.txt")).unwrap(), + "guest" + ); + assert!(!workdir.join("target/build.bin").exists()); + let manifest_path = context.guest_manifest_path.clone().unwrap(); + let manifest = read_guest_manifest(&manifest_path).unwrap(); + assert_eq!(manifest.phase, "cleaned"); + assert!(manifest.guest_namespace.starts_with(GUEST_EXECUTION_ROOT)); + assert!(!Path::new(&manifest.guest_namespace).exists()); + let lifecycle = db.finalize_managed_lane_execution( + context, + Some("Fake guest protocol checkpoint".to_string()), + ); + assert!(lifecycle + .checkpoint + .as_ref() + .unwrap() + .source_paths + .iter() + .any(|path| path == "guest-source.txt")); + assert_eq!( + read_guest_manifest(&manifest_path).unwrap().phase, + "terminal_succeeded" + ); + } + + #[cfg(unix)] + #[test] + fn recovery_discards_safe_owned_namespace_and_preserves_unrelated_profile() { + let root = tempfile::tempdir().unwrap(); + fs::write(root.path().join("README.md"), "root\n").unwrap(); + Trail::init(root.path(), "main", InitImportMode::WorkingTree, false).unwrap(); + let db = Trail::open(root.path()).unwrap(); + let limactl = root.path().join("limactl"); + write_executable( + &limactl, + "#!/bin/sh\ncase \" $* \" in *' cat -- '*) exit 1 ;; esac\nexit 0\n", + ); + let toolchain = + super::super::workspace_runtime_toolchain::ColimaToolchain::for_guest_protocol_test( + limactl, + root.path(), + ); + toolchain.prepare_state().unwrap(); + let workspace_hash = &sha256_hex(db.config.workspace.id.0.as_bytes())[..16]; + for (execution_id, profile, phase) in [ + ("exec_safe", "trail-test", "projected"), + ("exec_cancel", "trail-test", "executing"), + ("exec_other", "other-profile", "exported"), + ] { + let manifest = GuestExecutionManifest { + schema: GUEST_MANIFEST_SCHEMA, + execution_id: execution_id.to_string(), + lane_id: "lane".to_string(), + profile: profile.to_string(), + lima_instance: format!("colima-{profile}"), + guest_namespace: format!("{GUEST_EXECUTION_ROOT}/{workspace_hash}/{execution_id}"), + staging_path: "/private/staging".to_string(), + owner_pid: u32::MAX, + owner_start_token: "not-live".to_string(), + phase: phase.to_string(), + input_digest: "input".to_string(), + candidate_digest: None, + imported_paths: Vec::new(), + removed_paths: Vec::new(), + checkpoint_root: None, + checkpoint_operation: None, + error: None, + cancellation_requested_at: None, + cancellation_completed_at: None, + process_group_terminated: false, + updated_at: 1, + }; + write_guest_manifest(&guest_manifest_path(&db, execution_id).unwrap(), &manifest) + .unwrap(); + if execution_id == "exec_cancel" { + let request = GuestCancellationRequest { + schema: GUEST_MANIFEST_SCHEMA, + execution_id: execution_id.to_string(), + lane_id: "lane".to_string(), + requested_at: 2, + }; + write_file_atomic( + &guest_cancellation_path(&db, execution_id).unwrap(), + &serde_json::to_vec_pretty(&request).unwrap(), + true, + ) + .unwrap(); + } + } + + recover_guest_execution_manifests(&db, &toolchain, "trail-test", "colima-trail-test") + .unwrap(); + assert_eq!( + read_guest_manifest(&guest_manifest_path(&db, "exec_safe").unwrap()) + .unwrap() + .phase, + "terminal_recovered_discarded" + ); + assert_eq!( + read_guest_manifest(&guest_manifest_path(&db, "exec_cancel").unwrap()) + .unwrap() + .phase, + "terminal_cancelled" + ); + assert!(!guest_cancellation_path(&db, "exec_cancel") + .unwrap() + .exists()); + assert_eq!( + read_guest_manifest(&guest_manifest_path(&db, "exec_other").unwrap()) + .unwrap() + .phase, + "exported" + ); + } +} diff --git a/trail/src/db/lane/workspace_runtime.rs b/trail/src/db/lane/workspace_runtime.rs index b94c84d2..b761d66f 100644 --- a/trail/src/db/lane/workspace_runtime.rs +++ b/trail/src/db/lane/workspace_runtime.rs @@ -91,35 +91,36 @@ trait RuntimeProvider { struct CliRuntimeProvider { name: String, executable: PathBuf, + prefix_args: Vec, + reject_file_secrets: bool, workspace_id: String, + colima_toolchain: Option, } impl CliRuntimeProvider { - fn detect(workspace_id: &str) -> Result { + fn detect(config: &RuntimeConfig, workspace_id: &str) -> Result { + match config.provider.as_str() { + "auto" => Self::detect_ambient(workspace_id), + "docker" | "podman" => Self::detect_cli( + &config.provider, + &config.provider, + Vec::new(), + false, + workspace_id, + ), + "colima" => Self::detect_colima(config, workspace_id).map(|(provider, _)| provider), + other => Err(Error::Corrupt(format!( + "persisted runtime provider `{other}` is unsupported" + ))), + } + } + + fn detect_ambient(workspace_id: &str) -> Result { let mut failures = Vec::new(); for name in ["docker", "podman"] { - let tool = match super::workspace_environment::resolve_workspace_tool_executable(name) { - Ok(tool) => tool, - Err(err) => { - failures.push(err.to_string()); - continue; - } - }; - let output = Command::new(&tool.path) - .args(["info", "--format", "{{json .ServerVersion}}"]) - .output(); - match output { - Ok(output) if output.status.success() => { - return Ok(Self { - name: name.to_string(), - executable: tool.path, - workspace_id: workspace_id.to_string(), - }); - } - Ok(output) => { - failures.push(format!("{name}: {}", provider_output_diagnostic(&output))) - } - Err(err) => failures.push(format!("{name}: {err}")), + match Self::detect_cli(name, name, Vec::new(), false, workspace_id) { + Ok(provider) => return Ok(provider), + Err(err) => failures.push(err.to_string()), } } Err(Error::InvalidInput(format!( @@ -128,8 +129,143 @@ impl CliRuntimeProvider { ))) } + fn detect_cli( + executable_name: &str, + provider_name: &str, + prefix_args: Vec, + reject_file_secrets: bool, + workspace_id: &str, + ) -> Result { + let tool = + super::workspace_environment::resolve_workspace_tool_executable(executable_name)?; + let provider = Self { + name: provider_name.to_string(), + executable: tool.path, + prefix_args, + reject_file_secrets, + workspace_id: workspace_id.to_string(), + colima_toolchain: None, + }; + let output = provider + .command() + .args(["info", "--format", "{{json .ServerVersion}}"]) + .output()?; + if !output.status.success() { + return Err(Error::InvalidInput(format!( + "{} runtime is unavailable: {}", + provider.name, + provider_output_diagnostic(&output) + ))); + } + Ok(provider) + } + + fn detect_colima( + config: &RuntimeConfig, + workspace_id: &str, + ) -> Result<(Self, EnvironmentRuntimeProviderReport)> { + let toolchain = super::workspace_runtime_toolchain::ColimaToolchain::resolve(false)?; + Self::detect_colima_with_toolchain(config, workspace_id, toolchain) + } + + fn detect_colima_with_toolchain( + config: &RuntimeConfig, + workspace_id: &str, + toolchain: super::workspace_runtime_toolchain::ColimaToolchain, + ) -> Result<(Self, EnvironmentRuntimeProviderReport)> { + let profile = configured_colima_profile(config, workspace_id)?; + let context = colima_docker_context(&profile); + if !toolchain.state_is_ready() { + return Err(Error::InvalidInput( + "Trail's isolated Colima state is unavailable; run `trail env runtime setup colima`" + .to_string(), + )); + } + let running = colima_profile_running(&toolchain, &profile)?; + let mut started = false; + if !running { + if !config.colima_autostart { + return Err(Error::InvalidInput(format!( + "Colima profile `{profile}` is not running and runtime.colima_autostart is false; run `trail env runtime setup colima --profile {profile}` or start it explicitly" + ))); + } + start_contained_colima_profile(&toolchain, &profile)?; + started = true; + } + let provider = Self { + name: "colima".to_string(), + executable: toolchain.docker.clone(), + prefix_args: vec!["--context".to_string(), context.clone()], + reject_file_secrets: true, + workspace_id: workspace_id.to_string(), + colima_toolchain: Some(toolchain.clone()), + }; + provider.verify_ready()?; + if started { + toolchain.record_contained_profile(&profile)?; + } else if config.execution_backend == "colima" + && !toolchain.contained_profile_verified(&profile) + { + return Err(Error::InvalidInput(format!( + "Colima profile `{profile}` is running without Trail's no-host-mount containment receipt; stop that profile and rerun `trail env runtime setup colima --profile {profile} --execution-backend colima`" + ))); + } + let containment_verified = toolchain.contained_profile_verified(&profile); + Ok(( + provider, + EnvironmentRuntimeProviderReport { + provider: "colima".to_string(), + execution_backend: config.execution_backend.clone(), + status: "ready".to_string(), + profile: Some(profile.clone()), + lima_instance: Some(colima_lima_instance(&profile)), + docker_context: Some(context), + autostart: config.colima_autostart, + started, + containment: if containment_verified { + "trail_no_host_mounts_v1" + } else { + "externally_started_profile" + } + .to_string(), + toolchain_source: toolchain.source.to_string(), + toolchain_version: toolchain.version.clone(), + reason: None, + }, + )) + } + + fn command(&self) -> Command { + let mut command = if let Some(toolchain) = &self.colima_toolchain { + toolchain.docker_command() + } else { + Command::new(&self.executable) + }; + command.args(&self.prefix_args); + if !self.prefix_args.is_empty() { + command.env_remove("DOCKER_HOST"); + } + command + } + + fn verify_ready(&self) -> Result<()> { + let output = self + .command() + .args(["info", "--format", "{{json .ServerVersion}}"]) + .output()?; + if output.status.success() { + Ok(()) + } else { + Err(Error::InvalidInput(format!( + "{} runtime endpoint is unavailable: {}", + self.name, + provider_output_diagnostic(&output) + ))) + } + } + fn run(&self, operation: &str, args: &[String]) -> Result { - let output = Command::new(&self.executable).args(args).output()?; + let output = self.command().args(args).output()?; if output.status.success() { Ok(output) } else { @@ -146,7 +282,8 @@ impl CliRuntimeProvider { kind: &str, name: &str, ) -> Result>> { - let output = Command::new(&self.executable) + let output = self + .command() .args([kind, "inspect", name, "--format", "{{json .Labels}}"]) .output()?; if !output.status.success() { @@ -217,7 +354,8 @@ impl RuntimeProvider for CliRuntimeProvider { } fn ensure_image(&self, reference: &str, expected_digest: &str) -> Result<()> { - let inspect = Command::new(&self.executable) + let inspect = self + .command() .args([ "image", "inspect", @@ -293,7 +431,8 @@ impl RuntimeProvider for CliRuntimeProvider { &self, allocation: &RuntimeAllocation, ) -> Result> { - let output = Command::new(&self.executable) + let output = self + .command() .args([ "container", "inspect", @@ -358,6 +497,12 @@ impl RuntimeProvider for CliRuntimeProvider { allocation: &RuntimeAllocation, secrets: &[ResolvedRuntimeSecret], ) -> Result { + if self.reject_file_secrets && !secrets.is_empty() { + return Err(Error::InvalidInput(format!( + "Colima runtime service `{}/{}` requires host file-secret mounts, which Trail refuses because its contained Colima profile exposes no host filesystem; use Docker or Podman, or remove the service secret until a VM-safe secret broker is available", + allocation.component_id, allocation.resource_name + ))); + } let host_port = allocation.host_port.ok_or_else(|| { Error::Corrupt(format!( "runtime allocation `{}` has no reserved host port", @@ -517,7 +662,330 @@ impl RuntimeProvider for CliRuntimeProvider { } } +pub(super) fn configured_colima_profile( + config: &RuntimeConfig, + workspace_id: &str, +) -> Result { + if let Some(profile) = config.colima_profile.as_deref() { + validate_colima_profile(profile)?; + return Ok(profile.to_string()); + } + let digest = sha256_hex(workspace_id.as_bytes()); + Ok(format!("trail-{}", &digest[..12])) +} + +fn colima_docker_context(profile: &str) -> String { + if profile == "default" { + "colima".to_string() + } else { + format!("colima-{profile}") + } +} + +pub(super) fn colima_lima_instance(profile: &str) -> String { + if profile == "default" { + "colima".to_string() + } else { + format!("colima-{profile}") + } +} + +fn colima_profile_running( + toolchain: &super::workspace_runtime_toolchain::ColimaToolchain, + profile: &str, +) -> Result { + let output = toolchain + .colima_command() + .args(["--profile", profile, "status", "--json"]) + .output()?; + Ok(output.status.success()) +} + +fn start_contained_colima_profile( + toolchain: &super::workspace_runtime_toolchain::ColimaToolchain, + profile: &str, +) -> Result<()> { + let mut command = toolchain.colima_command(); + command.args(contained_colima_start_args(profile, toolchain.managed_vz)); + let output = command.output()?; + if output.status.success() { + Ok(()) + } else { + Err(Error::InvalidInput(format!( + "Colima profile `{profile}` failed contained startup: {}", + provider_output_diagnostic(&output) + ))) + } +} + +fn contained_colima_start_args(profile: &str, managed_vz: bool) -> Vec { + let mut args = [ + "--profile", + profile, + "start", + "--runtime=docker", + "--mount=none", + "--activate=false", + "--ssh-config=false", + "--ssh-agent=false", + "--kubernetes=false", + "--network-address=false", + "--network-preferred-route=false", + "--port-forwarder=ssh", + "--save-config=true", + ] + .into_iter() + .map(str::to_string) + .collect::>(); + if managed_vz { + args.push("--vm-type=vz".to_string()); + } + args +} + impl Trail { + pub fn workspace_environment_runtime_provider_status( + &self, + ) -> Result { + match self.config.runtime.provider.as_str() { + "auto" | "docker" | "podman" => { + let configured = self.config.runtime.provider.as_str(); + let detected = if configured == "auto" { + CliRuntimeProvider::detect_ambient(&self.config.workspace.id.0) + } else { + CliRuntimeProvider::detect_cli( + configured, + configured, + Vec::new(), + false, + &self.config.workspace.id.0, + ) + }; + Ok(match detected { + Ok(provider) => EnvironmentRuntimeProviderReport { + provider: provider.name, + execution_backend: self.config.runtime.execution_backend.clone(), + status: "ready".to_string(), + profile: None, + lima_instance: None, + docker_context: None, + autostart: false, + started: false, + containment: "ambient_cli_endpoint".to_string(), + toolchain_source: "system".to_string(), + toolchain_version: None, + reason: None, + }, + Err(error) => EnvironmentRuntimeProviderReport { + provider: configured.to_string(), + execution_backend: self.config.runtime.execution_backend.clone(), + status: "unavailable".to_string(), + profile: None, + lima_instance: None, + docker_context: None, + autostart: false, + started: false, + containment: "ambient_cli_endpoint".to_string(), + toolchain_source: "unavailable".to_string(), + toolchain_version: None, + reason: Some(error.to_string()), + }, + }) + } + "colima" => { + let profile = + configured_colima_profile(&self.config.runtime, &self.config.workspace.id.0)?; + let context = colima_docker_context(&profile); + let toolchain = + match super::workspace_runtime_toolchain::ColimaToolchain::resolve(false) { + Ok(toolchain) => toolchain, + Err(error) => { + return Ok(EnvironmentRuntimeProviderReport { + provider: "colima".to_string(), + execution_backend: self.config.runtime.execution_backend.clone(), + status: "unavailable".to_string(), + profile: Some(profile.clone()), + lima_instance: Some(colima_lima_instance(&profile)), + docker_context: Some(context), + autostart: self.config.runtime.colima_autostart, + started: false, + containment: "not_verified".to_string(), + toolchain_source: "unavailable".to_string(), + toolchain_version: None, + reason: Some(error.to_string()), + }); + } + }; + if !toolchain.state_is_ready() { + return Ok(EnvironmentRuntimeProviderReport { + provider: "colima".to_string(), + execution_backend: self.config.runtime.execution_backend.clone(), + status: "stopped".to_string(), + profile: Some(profile.clone()), + lima_instance: Some(colima_lima_instance(&profile)), + docker_context: Some(context), + autostart: self.config.runtime.colima_autostart, + started: false, + containment: "not_verified".to_string(), + toolchain_source: toolchain.source.to_string(), + toolchain_version: toolchain.version.clone(), + reason: Some( + "Trail's isolated Colima state is absent; run `trail env runtime setup colima`" + .to_string(), + ), + }); + } + let running = colima_profile_running(&toolchain, &profile)?; + let ready = if running { + CliRuntimeProvider { + name: "colima".to_string(), + executable: toolchain.docker.clone(), + prefix_args: vec!["--context".to_string(), context.clone()], + reject_file_secrets: true, + workspace_id: self.config.workspace.id.0.clone(), + colima_toolchain: Some(toolchain.clone()), + } + .verify_ready() + .is_ok() + } else { + false + }; + let containment_verified = toolchain.contained_profile_verified(&profile); + let execution_ready = + self.config.runtime.execution_backend != "colima" || containment_verified; + Ok(EnvironmentRuntimeProviderReport { + provider: "colima".to_string(), + execution_backend: self.config.runtime.execution_backend.clone(), + status: if ready && execution_ready { + "ready" + } else if running { + "unavailable" + } else { + "stopped" + } + .to_string(), + profile: Some(profile.clone()), + lima_instance: Some(colima_lima_instance(&profile)), + docker_context: Some(context), + autostart: self.config.runtime.colima_autostart, + started: false, + containment: if containment_verified { + "trail_no_host_mounts_v1" + } else if ready { + "externally_started_profile" + } else { + "not_verified" + } + .to_string(), + toolchain_source: toolchain.source.to_string(), + toolchain_version: toolchain.version.clone(), + reason: if ready && !execution_ready { + Some( + "the running profile lacks Trail's no-host-mount containment receipt; stop it and rerun Colima setup before guest execution" + .to_string(), + ) + } else if ready { + None + } else if running { + Some( + "the Colima profile is running but its Docker context is unavailable" + .to_string(), + ) + } else { + Some("the Colima profile is not running".to_string()) + }, + }) + } + other => Err(Error::Corrupt(format!( + "persisted runtime provider `{other}` is unsupported" + ))), + } + } + + pub fn setup_colima_workspace_environment_runtime( + &mut self, + profile: Option<&str>, + start: bool, + ) -> Result { + self.setup_colima_workspace_environment_runtime_with_backend(None, profile, start) + } + + pub fn setup_colima_workspace_environment_runtime_with_backend( + &mut self, + execution_backend: Option<&str>, + profile: Option<&str>, + start: bool, + ) -> Result { + let profile = profile + .map(str::to_string) + .unwrap_or(configured_colima_profile( + &self.config.runtime, + &self.config.workspace.id.0, + )?); + validate_colima_profile(&profile)?; + let toolchain = super::workspace_runtime_toolchain::ColimaToolchain::resolve(true)?; + toolchain.prepare_state()?; + + let mut desired = self.config.runtime.clone(); + desired.provider = "colima".to_string(); + if let Some(execution_backend) = execution_backend { + match execution_backend { + "host" | "colima" => { + desired.execution_backend = execution_backend.to_string(); + } + other => { + return Err(Error::InvalidInput(format!( + "runtime.execution_backend must be host or colima, got `{other}`" + ))); + } + } + } + if desired.execution_backend == "colima" && !start { + return Err(Error::InvalidInput( + "Colima managed execution requires a started and verified profile; remove --no-start or select --execution-backend host" + .to_string(), + )); + } + desired.colima_profile = Some(profile.clone()); + desired.colima_autostart = start; + let report = if start { + let report = CliRuntimeProvider::detect_colima_with_toolchain( + &desired, + &self.config.workspace.id.0, + toolchain.clone(), + )? + .1; + if desired.execution_backend == "colima" { + super::workspace_guest_execution::preflight_colima_guest_execution( + &toolchain, &profile, + )?; + } + report + } else { + EnvironmentRuntimeProviderReport { + provider: "colima".to_string(), + execution_backend: desired.execution_backend.clone(), + status: "configured".to_string(), + profile: Some(profile.clone()), + lima_instance: Some(colima_lima_instance(&profile)), + docker_context: Some(colima_docker_context(&profile)), + autostart: false, + started: false, + containment: "not_verified".to_string(), + toolchain_source: toolchain.source.to_string(), + toolchain_version: toolchain.version.clone(), + reason: Some("profile startup was disabled".to_string()), + } + }; + + let _lock = self.acquire_write_lock()?; + let mut next = self.config.clone(); + next.runtime = desired; + write_config(&self.db_dir, &next)?; + self.config = next; + Ok(report) + } + pub(crate) fn recover_workspace_runtime_leases(&self) -> Result<()> { let rows = self.runtime_allocations_with_live_statuses(&["allocating", "stopping"])?; for allocation in rows { @@ -557,7 +1025,8 @@ impl Trail { return Ok(generation); } self.recover_workspace_runtime_leases()?; - let provider = CliRuntimeProvider::detect(&self.config.workspace.id.0)?; + let provider = + CliRuntimeProvider::detect(&self.config.runtime, &self.config.workspace.id.0)?; self.reconcile_workspace_environment_runtime_with(&provider, &allocations)?; self.active_environment_generation(lane)?.ok_or_else(|| { Error::Corrupt("active generation disappeared after runtime reconcile".to_string()) @@ -578,7 +1047,8 @@ impl Trail { return Ok(generation); } self.recover_workspace_runtime_leases()?; - let provider = CliRuntimeProvider::detect(&self.config.workspace.id.0)?; + let provider = + CliRuntimeProvider::detect(&self.config.runtime, &self.config.workspace.id.0)?; for allocation in allocations.iter().rev() { self.claim_runtime_allocation(allocation, "stopping", "stopped")?; let stopped = (|| -> Result<()> { @@ -638,7 +1108,8 @@ impl Trail { if allocations.is_empty() { return Ok(generation); } - let provider = CliRuntimeProvider::detect(&self.config.workspace.id.0)?; + let provider = + CliRuntimeProvider::detect(&self.config.runtime, &self.config.workspace.id.0)?; self.cleanup_retired_workspace_environment_runtime_with(&provider, &allocations)?; Ok(generation) } @@ -659,7 +1130,8 @@ impl Trail { if allocations.is_empty() { return Ok(()); } - let provider = CliRuntimeProvider::detect(&self.config.workspace.id.0)?; + let provider = + CliRuntimeProvider::detect(&self.config.runtime, &self.config.workspace.id.0)?; self.cleanup_retired_workspace_environment_runtime_for_view_with(view_id, &provider) } @@ -1626,6 +2098,66 @@ mod tests { } } + #[test] + fn colima_provider_rejects_host_file_secret_mounts_before_launch() { + let secret_root = tempfile::tempdir().unwrap(); + let secret_path = secret_root.path().join("database-password"); + fs::write(&secret_path, "secret-canary").unwrap(); + #[cfg(unix)] + { + let mut permissions = fs::metadata(&secret_path).unwrap().permissions(); + permissions.set_mode(0o600); + fs::set_permissions(&secret_path, permissions).unwrap(); + } + let (_workspace, db) = runtime_workspace_with_secret("colima-secret", &secret_path); + let generation = db + .active_environment_generation("colima-secret") + .unwrap() + .unwrap(); + let allocation = db + .runtime_allocations_for_generation(&generation.generation_id) + .unwrap() + .remove(0); + let provider = CliRuntimeProvider { + name: "colima".to_string(), + executable: PathBuf::from("docker-must-not-run"), + prefix_args: vec!["--context".to_string(), "colima-trail-test".to_string()], + reject_file_secrets: true, + workspace_id: db.config.workspace.id.0.clone(), + colima_toolchain: None, + }; + let error = provider + .create_container( + &allocation, + &[ResolvedRuntimeSecret { + source_path: secret_path, + target: "/run/secrets/database-password".to_string(), + environment: Some("DATABASE_PASSWORD_FILE".to_string()), + }], + ) + .unwrap_err(); + assert!(error.to_string().contains("VM-safe secret broker")); + } + + #[test] + fn managed_colima_start_selects_vz_without_weakening_containment() { + let args = contained_colima_start_args("trail-managed", true); + for required in [ + "--vm-type=vz", + "--mount=none", + "--activate=false", + "--ssh-config=false", + "--ssh-agent=false", + "--kubernetes=false", + "--network-address=false", + ] { + assert!(args.iter().any(|arg| arg == required), "missing {required}"); + } + assert!(!contained_colima_start_args("trail-system", false) + .iter() + .any(|arg| arg == "--vm-type=vz")); + } + #[test] fn reconcile_is_idempotent_and_recovers_a_dead_lifecycle_owner() { let lane = "runtime-reconcile"; diff --git a/trail/src/db/lane/workspace_runtime_toolchain.rs b/trail/src/db/lane/workspace_runtime_toolchain.rs new file mode 100644 index 00000000..90477a56 --- /dev/null +++ b/trail/src/db/lane/workspace_runtime_toolchain.rs @@ -0,0 +1,1155 @@ +use std::ffi::OsString; +use std::fs::{self, File}; +use std::io::{self, Read, Write}; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use flate2::read::GzDecoder; +use reqwest::blocking::Client; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use super::*; + +const COLIMA_VERSION: &str = "0.10.3"; +const LIMA_VERSION: &str = "2.2.0"; +const DOCKER_VERSION: &str = "29.7.2"; +const RECEIPT_SCHEMA: u32 = 1; +const MAX_ARCHIVE_ENTRIES: usize = 8_192; +const MAX_EXPANDED_BYTES: u64 = 768 * 1024 * 1024; +const MAX_RECEIPT_BYTES: u64 = 64 * 1024; +const CONTAINED_PROFILE_RECEIPT_SCHEMA: u32 = 1; +const CONTAINED_PROFILE_CONTRACT: &str = "trail_no_host_mounts_v1"; +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(10 * 60); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const THIRD_PARTY_NOTICES: &[u8] = + include_bytes!("../../../assets/runtime-toolchain/THIRD_PARTY_NOTICES.md"); +const COLIMA_LICENSE: &[u8] = include_bytes!("../../../assets/runtime-toolchain/COLIMA-LICENSE"); +const APACHE_LICENSE: &[u8] = + include_bytes!("../../../assets/runtime-toolchain/APACHE-2.0-LICENSE"); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ArtifactKind { + Executable, + LimaArchive, + DockerArchive, +} + +#[derive(Clone, Copy, Debug)] +struct ManagedArtifact { + name: &'static str, + url: &'static str, + sha256: &'static str, + max_bytes: u64, + kind: ArtifactKind, +} + +#[derive(Clone, Copy, Debug)] +struct ManagedManifest { + platform: &'static str, + artifacts: &'static [ManagedArtifact], + colima_executable_sha256: &'static str, + limactl_executable_sha256: &'static str, + docker_executable_sha256: &'static str, +} + +const DARWIN_ARM64_ARTIFACTS: &[ManagedArtifact] = &[ + ManagedArtifact { + name: "colima-Darwin-arm64", + url: "https://github.com/abiosoft/colima/releases/download/v0.10.3/colima-Darwin-arm64", + sha256: "980ad8bf61a4ca370243f4cb41401a61276dcd2c2502bee7b9b86f9250169f34", + max_bytes: 32 * 1024 * 1024, + kind: ArtifactKind::Executable, + }, + ManagedArtifact { + name: "lima-2.2.0-Darwin-arm64.tar.gz", + url: "https://github.com/lima-vm/lima/releases/download/v2.2.0/lima-2.2.0-Darwin-arm64.tar.gz", + sha256: "bbdef91774885a0d05f7b048c4eb89ae2bcf3a0c252ae7ca7934e63df76d93c3", + max_bytes: 96 * 1024 * 1024, + kind: ArtifactKind::LimaArchive, + }, + ManagedArtifact { + name: "docker-29.7.2.tgz", + url: "https://download.docker.com/mac/static/stable/aarch64/docker-29.7.2.tgz", + sha256: "b8683ed19d1f06048a496f9b8429e2c71d0b088d475b7487c054ea3666c02a3c", + max_bytes: 96 * 1024 * 1024, + kind: ArtifactKind::DockerArchive, + }, +]; + +const DARWIN_X86_64_ARTIFACTS: &[ManagedArtifact] = &[ + ManagedArtifact { + name: "colima-Darwin-x86_64", + url: "https://github.com/abiosoft/colima/releases/download/v0.10.3/colima-Darwin-x86_64", + sha256: "3082737fe8a98afda11cba7d9a20b6e56fe80c6153464beda04bec630758770b", + max_bytes: 32 * 1024 * 1024, + kind: ArtifactKind::Executable, + }, + ManagedArtifact { + name: "lima-2.2.0-Darwin-x86_64.tar.gz", + url: "https://github.com/lima-vm/lima/releases/download/v2.2.0/lima-2.2.0-Darwin-x86_64.tar.gz", + sha256: "0d6f99c19f6e4bc3c92730c4c29d929e6927f0cb0a0ba1a84383367135a8ff31", + max_bytes: 96 * 1024 * 1024, + kind: ArtifactKind::LimaArchive, + }, + ManagedArtifact { + name: "docker-29.7.2.tgz", + url: "https://download.docker.com/mac/static/stable/x86_64/docker-29.7.2.tgz", + sha256: "fb1f1aa7ac7af4364165b9eadfda92e96c8ced508fca74f53079719891367438", + max_bytes: 96 * 1024 * 1024, + kind: ArtifactKind::DockerArchive, + }, +]; + +const DARWIN_ARM64_MANIFEST: ManagedManifest = ManagedManifest { + platform: "darwin-arm64", + artifacts: DARWIN_ARM64_ARTIFACTS, + colima_executable_sha256: "980ad8bf61a4ca370243f4cb41401a61276dcd2c2502bee7b9b86f9250169f34", + limactl_executable_sha256: "f19a4fca3875e1017a5285672be4a62699c1e55918fb6a7afce86a14199e10d9", + docker_executable_sha256: "a078469d8b77683b81e1604ee35af488ef143a8a0230897f05f0839b2f42d1dd", +}; + +const DARWIN_X86_64_MANIFEST: ManagedManifest = ManagedManifest { + platform: "darwin-x86_64", + artifacts: DARWIN_X86_64_ARTIFACTS, + colima_executable_sha256: "3082737fe8a98afda11cba7d9a20b6e56fe80c6153464beda04bec630758770b", + limactl_executable_sha256: "a02801d546fe8f3d59fe0a8b7d8831c2e4acec06a0c61cce0badb4e781be3535", + docker_executable_sha256: "c38429dd6b8803858e891d1c2e703ed28b9dd65e146d24f35f614049db36096e", +}; + +#[derive(Clone, Debug)] +pub(super) struct ColimaToolchain { + pub(super) colima: PathBuf, + pub(super) limactl: PathBuf, + pub(super) docker: PathBuf, + managed_path: Option, + state: ColimaStatePaths, + pub(super) source: &'static str, + pub(super) version: Option, + pub(super) managed_vz: bool, +} + +#[derive(Clone, Debug)] +struct ColimaStatePaths { + colima_home: PathBuf, + lima_home: PathBuf, + docker_config: PathBuf, + colima_cache: PathBuf, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ManagedToolchainReceipt { + schema: u32, + toolchain: String, + platform: String, + manifest_sha256: String, + colima_version: String, + lima_version: String, + docker_version: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ContainedProfileReceipt { + schema: u32, + profile: String, + contract: String, + toolchain_source: String, + toolchain_version: Option, +} + +impl ColimaToolchain { + #[cfg(test)] + pub(super) fn for_guest_protocol_test(limactl: PathBuf, state_root: &Path) -> Self { + Self { + colima: limactl.clone(), + limactl: limactl.clone(), + docker: limactl, + managed_path: None, + state: ColimaStatePaths { + colima_home: state_root.join("colima"), + lima_home: state_root.join("lima"), + docker_config: state_root.join("docker"), + colima_cache: state_root.join("cache"), + }, + source: "system", + version: None, + managed_vz: false, + } + } + + pub(super) fn resolve(allow_install: bool) -> Result { + if let Ok(system) = Self::resolve_system() { + return Ok(system); + } + let manifest = current_manifest()?; + let cache_root = managed_cache_root()?; + let install_dir = managed_install_dir(&cache_root, manifest); + if let Ok(managed) = Self::resolve_managed(manifest, &install_dir) { + return Ok(managed); + } + if !allow_install { + return Err(Error::InvalidInput( + "a complete Colima toolchain is unavailable; run `trail env runtime setup colima` to install Trail's pinned runtime tools" + .to_string(), + )); + } + ensure_managed_host_supported()?; + provision_managed_toolchain(manifest, &cache_root)?; + Self::resolve_managed(manifest, &install_dir) + } + + fn resolve_system() -> Result { + let colima = super::workspace_environment::resolve_workspace_tool_executable("colima")?; + let limactl = super::workspace_environment::resolve_workspace_tool_executable("limactl")?; + let docker = super::workspace_environment::resolve_workspace_tool_executable("docker")?; + Ok(Self { + colima: colima.path, + limactl: limactl.path, + docker: docker.path, + managed_path: None, + state: colima_state_paths()?, + source: "system", + version: None, + managed_vz: false, + }) + } + + fn resolve_managed(manifest: &ManagedManifest, install_dir: &Path) -> Result { + validate_managed_toolchain(manifest, install_dir)?; + Ok(Self { + colima: install_dir.join("bin/colima"), + limactl: install_dir.join("bin/limactl"), + docker: install_dir.join("bin/docker"), + managed_path: Some(prepend_path(&install_dir.join("bin"))?), + state: colima_state_paths()?, + source: "trail_managed", + version: Some(toolchain_version()), + managed_vz: true, + }) + } + + pub(super) fn state_is_ready(&self) -> bool { + [ + &self.state.colima_home, + &self.state.lima_home, + &self.state.docker_config, + &self.state.colima_cache, + ] + .into_iter() + .all(|path| path.is_dir() && !path.is_symlink()) + } + + pub(super) fn prepare_state(&self) -> Result<()> { + for path in [ + &self.state.colima_home, + &self.state.lima_home, + &self.state.docker_config, + &self.state.colima_cache, + ] { + ensure_private_directory(path)?; + } + Ok(()) + } + + pub(super) fn colima_command(&self) -> Command { + self.command(&self.colima) + } + + pub(super) fn docker_command(&self) -> Command { + let mut command = self.command(&self.docker); + command.env_remove("DOCKER_HOST"); + command + } + + pub(super) fn limactl_command(&self) -> Command { + self.command(&self.limactl) + } + + pub(super) fn record_contained_profile(&self, profile: &str) -> Result<()> { + let directory = self.state.colima_home.join("trail-profile-receipts"); + ensure_private_directory(&directory)?; + let receipt = ContainedProfileReceipt { + schema: CONTAINED_PROFILE_RECEIPT_SCHEMA, + profile: profile.to_string(), + contract: CONTAINED_PROFILE_CONTRACT.to_string(), + toolchain_source: self.source.to_string(), + toolchain_version: self.version.clone(), + }; + write_file_atomic( + &directory.join(format!("{profile}.json")), + &serde_json::to_vec_pretty(&receipt)?, + false, + ) + } + + pub(super) fn contained_profile_verified(&self, profile: &str) -> bool { + let path = self + .state + .colima_home + .join("trail-profile-receipts") + .join(format!("{profile}.json")); + let Ok(metadata) = fs::symlink_metadata(&path) else { + return false; + }; + if !metadata.is_file() + || metadata.file_type().is_symlink() + || metadata.len() > MAX_RECEIPT_BYTES + { + return false; + } + let Ok(bytes) = fs::read(path) else { + return false; + }; + let Ok(receipt) = serde_json::from_slice::(&bytes) else { + return false; + }; + receipt.schema == CONTAINED_PROFILE_RECEIPT_SCHEMA + && receipt.profile == profile + && receipt.contract == CONTAINED_PROFILE_CONTRACT + && receipt.toolchain_source == self.source + && receipt.toolchain_version == self.version + } + + fn command(&self, executable: &Path) -> Command { + let mut command = Command::new(executable); + command + .env("COLIMA_HOME", &self.state.colima_home) + .env("LIMA_HOME", &self.state.lima_home) + .env("DOCKER_CONFIG", &self.state.docker_config) + .env("COLIMA_CACHE_HOME", &self.state.colima_cache); + if let Some(path) = &self.managed_path { + command.env("PATH", path); + } + command + } +} + +fn current_manifest() -> Result<&'static ManagedManifest> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => Ok(&DARWIN_ARM64_MANIFEST), + ("macos", "x86_64") => Ok(&DARWIN_X86_64_MANIFEST), + (os, arch) => Err(Error::InvalidInput(format!( + "Trail-managed Colima tools support macOS arm64 and x86_64; `{os}-{arch}` requires system-installed colima, limactl, and docker" + ))), + } +} + +fn ensure_managed_host_supported() -> Result<()> { + #[cfg(target_os = "macos")] + { + let output = Command::new("/usr/bin/sw_vers") + .arg("-productVersion") + .output() + .map_err(|error| { + Error::InvalidInput(format!("could not determine the macOS version: {error}")) + })?; + let version = String::from_utf8_lossy(&output.stdout); + let major = version + .trim() + .split('.') + .next() + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| { + Error::InvalidInput("could not parse the macOS product version".to_string()) + })?; + if !output.status.success() || major < 13 { + return Err(Error::InvalidInput( + "Trail-managed Colima requires macOS 13 or newer for Apple's `vz` virtualization backend; install Colima, Lima, Docker, and QEMU manually on this host" + .to_string(), + )); + } + Ok(()) + } + #[cfg(not(target_os = "macos"))] + { + Err(Error::InvalidInput( + "Trail-managed Colima requires macOS 13 or newer; install colima, limactl, and docker manually on this host" + .to_string(), + )) + } +} + +fn toolchain_version() -> String { + format!("colima-{COLIMA_VERSION}+lima-{LIMA_VERSION}+docker-{DOCKER_VERSION}") +} + +fn managed_install_dir(cache_root: &Path, manifest: &ManagedManifest) -> PathBuf { + cache_root + .join("colima") + .join(toolchain_version()) + .join(manifest.platform) +} + +fn managed_cache_root() -> Result { + #[cfg(target_os = "macos")] + let root = std::env::var_os("HOME") + .map(PathBuf::from) + .map(|home| home.join("Library/Caches")); + #[cfg(target_os = "windows")] + let root = std::env::var_os("LOCALAPPDATA").map(PathBuf::from); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + let root = std::env::var_os("XDG_CACHE_HOME") + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .map(PathBuf::from) + .map(|home| home.join(".cache")) + }); + root.map(|root| root.join("trail/runtime-tools")) + .ok_or_else(|| { + Error::InvalidInput("cannot resolve Trail's user cache directory".to_string()) + }) +} + +fn colima_state_paths() -> Result { + #[cfg(target_os = "macos")] + let state = std::env::var_os("HOME") + .map(PathBuf::from) + .map(|home| macos_colima_state_roots(&home)); + #[cfg(target_os = "windows")] + let state = std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .map(|root| { + let data_root = root.join("trail/runtime"); + let lima_home = data_root.join("colima/_lima"); + (data_root, lima_home) + }); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + let state = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .map(PathBuf::from) + .map(|home| home.join(".local/share")) + }) + .map(|root| { + let data_root = root.join("trail/runtime"); + let lima_home = data_root.join("colima/_lima"); + (data_root, lima_home) + }); + let (data_root, lima_home) = state.ok_or_else(|| { + Error::InvalidInput("cannot resolve Trail's user data directory".to_string()) + })?; + let colima_home = data_root.join("colima"); + Ok(ColimaStatePaths { + lima_home, + docker_config: data_root.join("docker"), + colima_cache: data_root.join("cache/colima"), + colima_home, + }) +} + +#[cfg(target_os = "macos")] +fn macos_colima_state_roots(home: &Path) -> (PathBuf, PathBuf) { + ( + home.join("Library/Application Support/trail/runtime"), + // Lima appends the instance name plus a randomized SSH socket suffix + // to LIMA_HOME. Keeping that root below Application Support exceeds + // macOS's 104-byte AF_UNIX limit for Trail's workspace-scoped names. + home.join(".trail-lima"), + ) +} + +fn ensure_private_directory(path: &Path) -> Result<()> { + if path.exists() { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(Error::InvalidInput(format!( + "Trail runtime state path `{}` is not a real directory", + path.display() + ))); + } + } else { + fs::create_dir_all(path)?; + } + #[cfg(unix)] + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + Ok(()) +} + +fn prepend_path(bin_dir: &Path) -> Result { + let mut paths = vec![bin_dir.to_path_buf()]; + if let Some(path) = std::env::var_os("PATH") { + paths.extend(std::env::split_paths(&path)); + } + std::env::join_paths(paths).map_err(|error| { + Error::InvalidInput(format!("could not construct managed tool PATH: {error}")) + }) +} + +fn provision_managed_toolchain(manifest: &ManagedManifest, cache_root: &Path) -> Result<()> { + let client = Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .timeout(DOWNLOAD_TIMEOUT) + .user_agent(format!( + "trail/{}/runtime-toolchain", + env!("CARGO_PKG_VERSION") + )) + .build() + .map_err(|error| { + Error::InvalidInput(format!("could not create runtime download client: {error}")) + })?; + provision_managed_toolchain_with(manifest, cache_root, |artifact, destination| { + download_artifact(&client, artifact, destination) + }) +} + +fn provision_managed_toolchain_with( + manifest: &ManagedManifest, + cache_root: &Path, + mut fetch: F, +) -> Result<()> +where + F: FnMut(&ManagedArtifact, &Path) -> Result<()>, +{ + let install_dir = managed_install_dir(cache_root, manifest); + if validate_managed_toolchain(manifest, &install_dir).is_ok() { + return Ok(()); + } + if install_dir.exists() { + return Err(Error::InvalidInput(format!( + "Trail-managed runtime cache `{}` is corrupt; remove that version directory and rerun setup", + install_dir.display() + ))); + } + let parent = install_dir.parent().ok_or_else(|| { + Error::InvalidInput("managed runtime install path has no parent".to_string()) + })?; + fs::create_dir_all(parent)?; + let staging = parent.join(format!( + ".{}.staging-{}-{}", + manifest.platform, + std::process::id(), + nonce() + )); + if staging.exists() { + return Err(Error::InvalidInput( + "managed runtime staging path already exists".to_string(), + )); + } + fs::create_dir(&staging)?; + let result = install_into(manifest, &staging, &mut fetch); + if let Err(error) = result { + let _ = fs::remove_dir_all(&staging); + return Err(error); + } + match fs::rename(&staging, &install_dir) { + Ok(()) => { + super::sync_directory_strict(parent)?; + Ok(()) + } + Err(_error) if validate_managed_toolchain(manifest, &install_dir).is_ok() => { + let _ = fs::remove_dir_all(&staging); + Ok(()) + } + Err(error) => { + let _ = fs::remove_dir_all(&staging); + Err(Error::Io(error)) + } + } +} + +fn install_into(manifest: &ManagedManifest, staging: &Path, fetch: &mut F) -> Result<()> +where + F: FnMut(&ManagedArtifact, &Path) -> Result<()>, +{ + fs::create_dir(staging.join("bin"))?; + for (index, artifact) in manifest.artifacts.iter().enumerate() { + require_https(artifact.url)?; + let download = staging.join(format!(".download-{index}")); + fetch(artifact, &download)?; + verify_file_digest(&download, artifact.sha256, artifact.max_bytes)?; + match artifact.kind { + ArtifactKind::Executable => { + fs::rename(&download, staging.join("bin/colima"))?; + } + ArtifactKind::LimaArchive => { + unpack_lima_archive(&download, staging)?; + fs::remove_file(&download)?; + } + ArtifactKind::DockerArchive => { + unpack_docker_archive(&download, staging)?; + fs::remove_file(&download)?; + } + } + } + let license_dir = staging.join("licenses"); + fs::create_dir(&license_dir)?; + write_new_file( + &license_dir.join("THIRD_PARTY_NOTICES.md"), + THIRD_PARTY_NOTICES, + )?; + write_new_file(&license_dir.join("COLIMA-LICENSE"), COLIMA_LICENSE)?; + write_new_file(&license_dir.join("APACHE-2.0-LICENSE"), APACHE_LICENSE)?; + set_executable(&staging.join("bin/colima"))?; + set_executable(&staging.join("bin/limactl"))?; + set_executable(&staging.join("bin/docker"))?; + validate_executable_digests(manifest, staging)?; + let receipt = ManagedToolchainReceipt { + schema: RECEIPT_SCHEMA, + toolchain: toolchain_version(), + platform: manifest.platform.to_string(), + manifest_sha256: manifest_identity(manifest), + colima_version: COLIMA_VERSION.to_string(), + lima_version: LIMA_VERSION.to_string(), + docker_version: DOCKER_VERSION.to_string(), + }; + write_new_file( + &staging.join("receipt.json"), + &serde_json::to_vec_pretty(&receipt)?, + )?; + validate_managed_toolchain(manifest, staging) +} + +fn download_artifact( + client: &Client, + artifact: &ManagedArtifact, + destination: &Path, +) -> Result<()> { + let response = client + .get(artifact.url) + .send() + .and_then(reqwest::blocking::Response::error_for_status) + .map_err(|error| { + Error::InvalidInput(format!( + "could not download managed runtime artifact `{}`: {error}", + artifact.name + )) + })?; + if response + .content_length() + .is_some_and(|length| length > artifact.max_bytes) + { + return Err(Error::InvalidInput(format!( + "managed runtime artifact `{}` exceeds its {} MiB limit", + artifact.name, + artifact.max_bytes / (1024 * 1024) + ))); + } + let mut file = File::create(destination)?; + let copied = io::copy( + &mut response.take(artifact.max_bytes.saturating_add(1)), + &mut file, + )?; + if copied > artifact.max_bytes { + return Err(Error::InvalidInput(format!( + "managed runtime artifact `{}` exceeds its {} MiB limit", + artifact.name, + artifact.max_bytes / (1024 * 1024) + ))); + } + file.flush()?; + file.sync_all()?; + Ok(()) +} + +fn validate_managed_toolchain(manifest: &ManagedManifest, install_dir: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(install_dir).map_err(|_| { + Error::InvalidInput("Trail-managed runtime toolchain is not installed".to_string()) + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(Error::InvalidInput( + "Trail-managed runtime toolchain path is not a real directory".to_string(), + )); + } + let receipt_bytes = read_bounded(&install_dir.join("receipt.json"), MAX_RECEIPT_BYTES)?; + let receipt: ManagedToolchainReceipt = serde_json::from_slice(&receipt_bytes)?; + if receipt.schema != RECEIPT_SCHEMA + || receipt.toolchain != toolchain_version() + || receipt.platform != manifest.platform + || receipt.manifest_sha256 != manifest_identity(manifest) + || receipt.colima_version != COLIMA_VERSION + || receipt.lima_version != LIMA_VERSION + || receipt.docker_version != DOCKER_VERSION + { + return Err(Error::InvalidInput( + "Trail-managed runtime receipt does not match this Trail release".to_string(), + )); + } + validate_executable_digests(manifest, install_dir)?; + for path in [install_dir.join("share/lima"), install_dir.join("licenses")] { + let metadata = fs::symlink_metadata(&path)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(Error::InvalidInput(format!( + "managed runtime path `{}` is not a real directory", + path.display() + ))); + } + } + Ok(()) +} + +fn validate_executable_digests(manifest: &ManagedManifest, root: &Path) -> Result<()> { + for (relative, expected) in [ + ("bin/colima", manifest.colima_executable_sha256), + ("bin/limactl", manifest.limactl_executable_sha256), + ("bin/docker", manifest.docker_executable_sha256), + ] { + let path = root.join(relative); + let metadata = fs::symlink_metadata(&path)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(Error::InvalidInput(format!( + "managed runtime executable `{relative}` is missing or unsafe" + ))); + } + let actual = sha256_file(&path, 256 * 1024 * 1024)?; + if actual != expected { + return Err(Error::InvalidInput(format!( + "managed runtime executable `{relative}` failed SHA-256 verification" + ))); + } + } + Ok(()) +} + +fn manifest_identity(manifest: &ManagedManifest) -> String { + let mut digest = Sha256::new(); + digest.update(b"trail-colima-toolchain-v1\0"); + digest.update(manifest.platform.as_bytes()); + for artifact in manifest.artifacts { + digest.update(b"\0"); + digest.update(artifact.name.as_bytes()); + digest.update(b"\0"); + digest.update(artifact.url.as_bytes()); + digest.update(b"\0"); + digest.update(artifact.sha256.as_bytes()); + digest.update(b"\0"); + digest.update(artifact.max_bytes.to_le_bytes()); + digest.update([artifact.kind as u8]); + } + hex::encode(digest.finalize()) +} + +fn verify_file_digest(path: &Path, expected: &str, max_bytes: u64) -> Result<()> { + let actual = sha256_file(path, max_bytes)?; + if actual == expected { + Ok(()) + } else { + Err(Error::InvalidInput(format!( + "managed runtime artifact `{}` failed SHA-256 verification", + path.display() + ))) + } +} + +fn sha256_file(path: &Path, max_bytes: u64) -> Result { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > max_bytes { + return Err(Error::InvalidInput(format!( + "managed runtime file `{}` is missing, unsafe, or oversized", + path.display() + ))); + } + let mut file = File::open(path)?; + let mut digest = Sha256::new(); + let mut total = 0u64; + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + total = total.saturating_add(read as u64); + if total > max_bytes { + return Err(Error::InvalidInput(format!( + "managed runtime file `{}` exceeds its safety limit", + path.display() + ))); + } + digest.update(&buffer[..read]); + } + Ok(hex::encode(digest.finalize())) +} + +fn unpack_lima_archive(path: &Path, destination: &Path) -> Result<()> { + unpack_tar_gz(path, destination, |relative, entry_type| { + if entry_type.is_symlink() && relative == Path::new("share/doc/lima/templates") { + return Ok(ArchiveDisposition::Skip); + } + if entry_type.is_dir() || entry_type.is_file() { + Ok(ArchiveDisposition::Extract(relative.to_path_buf())) + } else { + Err(Error::InvalidInput(format!( + "Lima archive contains unsupported entry `{}`", + relative.display() + ))) + } + }) +} + +fn unpack_docker_archive(path: &Path, destination: &Path) -> Result<()> { + unpack_tar_gz(path, destination, |relative, entry_type| { + if entry_type.is_dir() { + Ok(ArchiveDisposition::Skip) + } else if entry_type.is_file() && relative == Path::new("docker/docker") { + Ok(ArchiveDisposition::Extract(PathBuf::from("bin/docker"))) + } else { + Err(Error::InvalidInput(format!( + "Docker CLI archive contains unexpected entry `{}`", + relative.display() + ))) + } + }) +} + +enum ArchiveDisposition { + Skip, + Extract(PathBuf), +} + +fn unpack_tar_gz(path: &Path, destination: &Path, mut classify: F) -> Result<()> +where + F: FnMut(&Path, tar::EntryType) -> Result, +{ + let decoder = GzDecoder::new(File::open(path)?); + let mut archive = tar::Archive::new(decoder); + let mut entries = 0usize; + let mut expanded = 0u64; + for entry in archive.entries()? { + let mut entry = entry?; + entries = entries.saturating_add(1); + if entries > MAX_ARCHIVE_ENTRIES { + return Err(Error::InvalidInput( + "managed runtime archive contains too many entries".to_string(), + )); + } + let relative = safe_archive_path(&entry.path()?)?; + if relative.as_os_str().is_empty() { + continue; + } + let entry_type = entry.header().entry_type(); + let disposition = classify(&relative, entry_type)?; + let ArchiveDisposition::Extract(output_relative) = disposition else { + continue; + }; + let output_relative = safe_archive_path(&output_relative)?; + let output = destination.join(output_relative); + if entry_type.is_dir() { + fs::create_dir_all(&output)?; + continue; + } + if !entry_type.is_file() { + return Err(Error::InvalidInput( + "managed runtime archive extraction accepted a non-file entry".to_string(), + )); + } + expanded = expanded.saturating_add(entry.size()); + if expanded > MAX_EXPANDED_BYTES { + return Err(Error::InvalidInput( + "managed runtime archive exceeds its expanded size limit".to_string(), + )); + } + let parent = output.parent().ok_or_else(|| { + Error::InvalidInput("managed runtime archive output has no parent".to_string()) + })?; + fs::create_dir_all(parent)?; + if output.exists() { + return Err(Error::InvalidInput(format!( + "managed runtime archive repeats `{}`", + output.display() + ))); + } + let mut file = File::create(&output)?; + let copied = io::copy(&mut entry, &mut file)?; + if copied != entry.size() { + return Err(Error::InvalidInput(format!( + "managed runtime archive entry `{}` was truncated", + relative.display() + ))); + } + file.flush()?; + } + Ok(()) +} + +fn safe_archive_path(path: &Path) -> Result { + let mut safe = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::Normal(value) => safe.push(value), + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err(Error::InvalidInput(format!( + "managed runtime archive path `{}` escapes its staging directory", + path.display() + ))); + } + } + } + Ok(safe) +} + +fn set_executable(path: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(Error::InvalidInput(format!( + "managed runtime executable `{}` is missing or unsafe", + path.display() + ))); + } + #[cfg(unix)] + fs::set_permissions(path, fs::Permissions::from_mode(0o555))?; + Ok(()) +} + +fn write_new_file(path: &Path, bytes: &[u8]) -> Result<()> { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + let mut file = options.open(path)?; + file.write_all(bytes)?; + file.flush()?; + file.sync_all()?; + Ok(()) +} + +fn read_bounded(path: &Path, limit: u64) -> Result> { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > limit { + return Err(Error::InvalidInput(format!( + "managed runtime receipt `{}` is missing, unsafe, or oversized", + path.display() + ))); + } + let mut bytes = Vec::new(); + File::open(path)? + .take(limit.saturating_add(1)) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > limit { + return Err(Error::InvalidInput( + "managed runtime receipt exceeds its safety limit".to_string(), + )); + } + Ok(bytes) +} + +fn require_https(url: &str) -> Result<()> { + let parsed = url::Url::parse(url).map_err(|error| { + Error::InvalidInput(format!("invalid managed runtime URL `{url}`: {error}")) + })?; + if parsed.scheme() != "https" || parsed.host_str().is_none() { + return Err(Error::InvalidInput(format!( + "managed runtime URL `{url}` must use HTTPS" + ))); + } + Ok(()) +} + +fn nonce() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::write::GzEncoder; + use flate2::Compression; + use tar::Builder; + + fn fixture_archive(entries: &[(&str, &[u8], u32)]) -> Vec { + let encoder = GzEncoder::new(Vec::new(), Compression::default()); + let mut builder = Builder::new(encoder); + for (path, bytes, mode) in entries { + let mut header = tar::Header::new_gnu(); + header.set_path(path).unwrap(); + header.set_size(bytes.len() as u64); + header.set_mode(*mode); + header.set_cksum(); + builder.append(&header, *bytes).unwrap(); + } + builder.into_inner().unwrap().finish().unwrap() + } + + fn fixture_manifest( + colima: &'static [u8], + lima: &'static [u8], + docker: &'static [u8], + ) -> (ManagedManifest, Vec, Vec) { + let lima_archive = fixture_archive(&[ + ("bin/limactl", lima, 0o755), + ("share/lima/guestagent", b"guest", 0o644), + ("share/doc/lima/LICENSE", b"license", 0o644), + ]); + let docker_archive = fixture_archive(&[("docker/docker", docker, 0o755)]); + let artifacts = vec![ + ManagedArtifact { + name: "colima", + url: "https://fixtures.invalid/colima", + sha256: Box::leak(sha256_bytes(colima).into_boxed_str()), + max_bytes: 1024, + kind: ArtifactKind::Executable, + }, + ManagedArtifact { + name: "lima.tar.gz", + url: "https://fixtures.invalid/lima", + sha256: Box::leak(sha256_bytes(&lima_archive).into_boxed_str()), + max_bytes: 1024 * 1024, + kind: ArtifactKind::LimaArchive, + }, + ManagedArtifact { + name: "docker.tgz", + url: "https://fixtures.invalid/docker", + sha256: Box::leak(sha256_bytes(&docker_archive).into_boxed_str()), + max_bytes: 1024 * 1024, + kind: ArtifactKind::DockerArchive, + }, + ]; + let artifacts = Box::leak(artifacts.into_boxed_slice()); + ( + ManagedManifest { + platform: "fixture", + artifacts, + colima_executable_sha256: Box::leak(sha256_bytes(colima).into_boxed_str()), + limactl_executable_sha256: Box::leak(sha256_bytes(lima).into_boxed_str()), + docker_executable_sha256: Box::leak(sha256_bytes(docker).into_boxed_str()), + }, + lima_archive, + docker_archive, + ) + } + + fn sha256_bytes(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) + } + + #[test] + fn managed_install_verifies_publishes_reuses_and_rejects_corruption() { + let cache = tempfile::tempdir().unwrap(); + let (manifest, lima_archive, docker_archive) = + fixture_manifest(b"colima", b"limactl", b"docker"); + let mut downloads = 0usize; + provision_managed_toolchain_with(&manifest, cache.path(), |artifact, destination| { + downloads += 1; + let bytes = match artifact.kind { + ArtifactKind::Executable => b"colima".to_vec(), + ArtifactKind::LimaArchive => lima_archive.clone(), + ArtifactKind::DockerArchive => docker_archive.clone(), + }; + fs::write(destination, bytes).map_err(Error::from) + }) + .unwrap(); + assert_eq!(downloads, 3); + provision_managed_toolchain_with(&manifest, cache.path(), |_artifact, _destination| { + panic!("valid published toolchain must not download again") + }) + .unwrap(); + + let install = managed_install_dir(cache.path(), &manifest); + let docker = install.join("bin/docker"); + #[cfg(unix)] + fs::set_permissions(&docker, fs::Permissions::from_mode(0o700)).unwrap(); + fs::write(&docker, b"tampered").unwrap(); + let error = + provision_managed_toolchain_with(&manifest, cache.path(), |_artifact, _destination| { + panic!("corrupt published state fails before download") + }) + .unwrap_err(); + assert!(error.to_string().contains("cache") && error.to_string().contains("corrupt")); + } + + #[test] + fn managed_install_failure_never_publishes_stage() { + let cache = tempfile::tempdir().unwrap(); + let (manifest, _lima_archive, _docker_archive) = + fixture_manifest(b"colima", b"limactl", b"docker"); + let error = + provision_managed_toolchain_with(&manifest, cache.path(), |artifact, destination| { + let bytes = if artifact.kind == ArtifactKind::Executable { + b"wrong".as_slice() + } else { + b"unused".as_slice() + }; + fs::write(destination, bytes).map_err(Error::from) + }) + .unwrap_err(); + assert!(error.to_string().contains("SHA-256")); + assert!(!managed_install_dir(cache.path(), &manifest).exists()); + assert!( + fs::read_dir(cache.path().join("colima").join(toolchain_version())) + .unwrap() + .all(|entry| !entry + .unwrap() + .file_name() + .to_string_lossy() + .contains("staging")) + ); + } + + #[test] + fn concurrent_managed_installers_converge_on_one_verified_tree() { + let cache = tempfile::tempdir().unwrap(); + let (manifest, lima_archive, docker_archive) = + fixture_manifest(b"colima", b"limactl", b"docker"); + std::thread::scope(|scope| { + for _ in 0..2 { + let lima_archive = lima_archive.clone(); + let docker_archive = docker_archive.clone(); + let thread_manifest = manifest; + let cache_path = cache.path().to_path_buf(); + scope.spawn(move || { + provision_managed_toolchain_with( + &thread_manifest, + &cache_path, + |artifact, destination| { + let bytes = match artifact.kind { + ArtifactKind::Executable => b"colima".to_vec(), + ArtifactKind::LimaArchive => lima_archive.clone(), + ArtifactKind::DockerArchive => docker_archive.clone(), + }; + fs::write(destination, bytes).map_err(Error::from) + }, + ) + .unwrap(); + }); + } + }); + validate_managed_toolchain(&manifest, &managed_install_dir(cache.path(), &manifest)) + .unwrap(); + assert!( + fs::read_dir(cache.path().join("colima").join(toolchain_version())) + .unwrap() + .all(|entry| !entry + .unwrap() + .file_name() + .to_string_lossy() + .contains("staging")) + ); + } + + #[test] + fn archive_paths_reject_parent_traversal() { + assert!(safe_archive_path(Path::new("../escape")).is_err()); + assert!(safe_archive_path(Path::new("/absolute")).is_err()); + assert_eq!( + safe_archive_path(Path::new("./bin/limactl")).unwrap(), + Path::new("bin/limactl") + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_lima_home_leaves_room_for_workspace_profile_socket() { + let home = Path::new("/Users/abcdefghijklmnopqrstuvwxyzabcde"); + let (data_root, lima_home) = macos_colima_state_roots(home); + let socket = lima_home + .join("colima-trail-0123456789ab") + .join("ssh.sock.1234567890123456"); + + assert_eq!( + data_root, + home.join("Library/Application Support/trail/runtime") + ); + assert!( + socket.as_os_str().len() < 104, + "representative Lima SSH socket must fit macOS AF_UNIX: {}", + socket.display() + ); + } +} diff --git a/trail/src/db/lane/workspace_view.rs b/trail/src/db/lane/workspace_view.rs index 6039b539..9ab64f28 100644 --- a/trail/src/db/lane/workspace_view.rs +++ b/trail/src/db/lane/workspace_view.rs @@ -856,36 +856,109 @@ impl Trail { lane: &str, command: &[String], ) -> Result { + self.exec_lane_workspace_for_turn(lane, command, None) + } + + pub fn exec_lane_workspace_for_turn( + &mut self, + lane: &str, + command: &[String], + turn_id: Option<&str>, + ) -> Result { + self.exec_lane_workspace_with_options(lane, command, turn_id, None) + } + + pub fn exec_lane_workspace_with_options( + &mut self, + lane: &str, + command: &[String], + turn_id: Option<&str>, + timeout_secs: Option, + ) -> Result { + if timeout_secs == Some(0) + || timeout_secs.is_some_and(|timeout| { + timeout > super::workspace_guest_execution::MAX_GUEST_COMMAND_TIMEOUT_SECS + }) + { + return Err(Error::InvalidInput(format!( + "managed lane execution timeout must be between 1 and {} seconds", + super::workspace_guest_execution::MAX_GUEST_COMMAND_TIMEOUT_SECS + ))); + } + let turn = turn_id.map(|turn_id| self.lane_turn(turn_id)).transpose()?; + if let Some(turn) = turn.as_ref() { + let branch = self.lane_branch(lane)?; + if turn.lane_id != branch.lane_id { + return Err(Error::InvalidInput(format!( + "turn `{}` does not belong to lane `{lane}`", + turn.turn_id + ))); + } + if turn.ended_at.is_some() { + return Err(Error::InvalidInput(format!( + "turn `{}` is already ended", + turn.turn_id + ))); + } + } let mut context = self.prepare_managed_lane_execution(lane, "lane_exec", command)?; + if let Some(turn) = turn { + context.session_id = turn.session_id; + context.trace_id = Some(default_trace_id_for_turn(&turn.turn_id)); + context.turn_id = Some(turn.turn_id); + } let view = context.view.clone().ok_or_else(|| { Error::InvalidInput(format!( "lane `{lane}` does not have a layered workspace view" )) })?; - let exit_code = match self.run_workspace_command(&view, &context.source_root, command) { + let guest_execution = context.execution_backend == "colima"; + let mut timed_out = false; + let command_result = if guest_execution { + self.run_colima_lane_command( + &mut context, + &view, + command, + Duration::from_secs(timeout_secs.unwrap_or( + super::workspace_guest_execution::DEFAULT_GUEST_COMMAND_TIMEOUT_SECS, + )), + ) + .map(|run| { + timed_out = run.timed_out; + run.exit_code.unwrap_or(128) + }) + } else { + self.run_workspace_command(&view, &context.source_root, command) + }; + let exit_code = match command_result { Ok(exit_code) => { - self.mark_managed_lane_execution_command( - &mut context, - if exit_code == 0 { - "succeeded" - } else { - "failed" - }, - None, - Some(exit_code), - )?; + if !guest_execution { + self.mark_managed_lane_execution_command( + &mut context, + if exit_code == 0 { + "succeeded" + } else { + "failed" + }, + None, + Some(exit_code), + )?; + } exit_code } Err(error) => { - self.mark_managed_lane_execution_command( - &mut context, - "failed", - Some(&error.to_string()), - None, - )?; - let lifecycle = self.finalize_managed_lane_execution( + if !guest_execution { + self.mark_managed_lane_execution_command( + &mut context, + "failed", + Some(&error.to_string()), + None, + )?; + } + let lifecycle = self.finalize_managed_lane_execution_for_turn( context, Some("Managed lane execution checkpoint".to_string()), + turn_id, ); let finalization_errors = [ lifecycle.checkpoint_error.as_deref(), @@ -903,9 +976,10 @@ impl Trail { ))); } }; - let lifecycle = self.finalize_managed_lane_execution( + let lifecycle = self.finalize_managed_lane_execution_for_turn( context, Some("Managed lane execution checkpoint".to_string()), + turn_id, ); self.insert_lane_event( &view.lane_id, @@ -920,6 +994,9 @@ impl Trail { "command_fingerprint": sha256_hex(&serde_json::to_vec(command)?), "exit_code": exit_code, "execution_id": lifecycle.execution_id, + "session_id": lifecycle.session_id, + "turn_id": lifecycle.turn_id, + "trace_id": lifecycle.trace_id, }), )?; Ok(WorkspaceExecReport { @@ -933,8 +1010,22 @@ impl Trail { generation: view.generation, environment_generation: lifecycle.environment_generation.clone(), backend: view.backend, + execution_backend: lifecycle + .preparation + .as_ref() + .map(|preparation| preparation.execution_backend.clone()) + .unwrap_or_else(|| "host".to_string()), command: command.to_vec(), exit_code, + timed_out, + exit_classification: if timed_out { + "timed_out" + } else if exit_code == 0 { + "succeeded" + } else { + "command_failed" + } + .to_string(), lifecycle, }) } diff --git a/trail/src/db/mod.rs b/trail/src/db/mod.rs index fb1592ac..1bf1ceed 100644 --- a/trail/src/db/mod.rs +++ b/trail/src/db/mod.rs @@ -2756,6 +2756,8 @@ pub(crate) struct CommandRunResult { success: bool, exit_code: Option, timed_out: bool, + cancelled: bool, + process_group_terminated: bool, duration_ms: u64, stdout: Vec, stderr: Vec, diff --git a/trail/src/db/util/command_run.rs b/trail/src/db/util/command_run.rs index aafaabd7..d5ce6b04 100644 --- a/trail/src/db/util/command_run.rs +++ b/trail/src/db/util/command_run.rs @@ -22,6 +22,8 @@ pub(crate) fn run_command_with_timeout_env( success: false, exit_code: None, timed_out: false, + cancelled: false, + process_group_terminated: false, duration_ms: elapsed_ms(started.elapsed()), stdout: Vec::new(), stderr: err.to_string().into_bytes(), @@ -36,6 +38,8 @@ pub(crate) fn run_command_with_timeout_env( success: output.status.success(), exit_code: output.status.code(), timed_out: false, + cancelled: false, + process_group_terminated: false, duration_ms: elapsed_ms(started.elapsed()), stdout: output.stdout, stderr: output.stderr, @@ -48,6 +52,8 @@ pub(crate) fn run_command_with_timeout_env( success: false, exit_code: output.status.code(), timed_out: true, + cancelled: false, + process_group_terminated: false, duration_ms: elapsed_ms(started.elapsed()), stdout: output.stdout, stderr: output.stderr, diff --git a/trail/src/db/util/config/entries.rs b/trail/src/db/util/config/entries.rs index fde247f5..854cddfa 100644 --- a/trail/src/db/util/config/entries.rs +++ b/trail/src/db/util/config/entries.rs @@ -226,6 +226,30 @@ pub(crate) fn config_entries_from(config: &TrailConfig) -> Vec { "u64", false, ), + config_entry( + "runtime.provider", + &config.runtime.provider, + "string", + false, + ), + config_entry( + "runtime.execution_backend", + &config.runtime.execution_backend, + "string", + false, + ), + config_entry( + "runtime.colima_profile", + config.runtime.colima_profile.as_deref().unwrap_or_default(), + "string", + false, + ), + config_entry( + "runtime.colima_autostart", + config.runtime.colima_autostart, + "bool", + false, + ), ] } diff --git a/trail/src/db/util/config/set.rs b/trail/src/db/util/config/set.rs index 614ce032..2f66ef4a 100644 --- a/trail/src/db/util/config/set.rs +++ b/trail/src/db/util/config/set.rs @@ -155,6 +155,52 @@ pub(crate) fn set_config_value( config.guardrails.policy = value.to_string(); Ok(()) } + "runtime.provider" => match value { + "auto" | "docker" | "podman" | "colima" => { + if value != "colima" && config.runtime.execution_backend == "colima" { + return Err(Error::InvalidInput( + "runtime.provider cannot move away from colima while runtime.execution_backend is colima; set runtime.execution_backend to host first" + .to_string(), + )); + } + config.runtime.provider = value.to_string(); + Ok(()) + } + other => Err(Error::InvalidInput(format!( + "runtime.provider must be auto, docker, podman, or colima, got `{other}`" + ))), + }, + "runtime.execution_backend" => match value { + "host" => { + config.runtime.execution_backend = value.to_string(); + Ok(()) + } + "colima" if config.runtime.provider == "colima" => { + config.runtime.execution_backend = value.to_string(); + Ok(()) + } + "colima" => Err(Error::InvalidInput( + "runtime.execution_backend colima requires runtime.provider colima; run `trail env runtime setup colima --execution-backend colima`" + .to_string(), + )), + other => Err(Error::InvalidInput(format!( + "runtime.execution_backend must be host or colima, got `{other}`" + ))), + }, + "runtime.colima_profile" => { + let profile = value.trim(); + if profile.is_empty() { + config.runtime.colima_profile = None; + return Ok(()); + } + validate_colima_profile(profile)?; + config.runtime.colima_profile = Some(profile.to_string()); + Ok(()) + } + "runtime.colima_autostart" => { + config.runtime.colima_autostart = parse_config_bool(key, value)?; + Ok(()) + } "workspace_views.upper_logical_bytes" => { config.workspace_views.upper_logical_bytes = parse_config_u64(key, value, true)?; Ok(()) @@ -202,3 +248,19 @@ pub(crate) fn set_config_value( _ => Err(Error::InvalidInput(format!("unknown config key `{key}`"))), } } + +pub(crate) fn validate_colima_profile(profile: &str) -> Result<()> { + if profile.len() > 63 + || !profile + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + || profile.starts_with('-') + || profile.ends_with('-') + { + return Err(Error::InvalidInput( + "runtime.colima_profile must contain 1-63 lowercase ASCII letters, digits, or hyphens and cannot start or end with a hyphen" + .to_string(), + )); + } + Ok(()) +} diff --git a/trail/src/error.rs b/trail/src/error.rs index 2530d980..1077bbd4 100644 --- a/trail/src/error.rs +++ b/trail/src/error.rs @@ -107,6 +107,21 @@ pub enum Error { GitDeltaExportRequired(String), #[error("invalid input: {0}")] InvalidInput(String), + #[error("managed execution `{execution_id}` was cancelled")] + ExecutionCancelled { execution_id: String }, + #[error("managed execution `{execution_id}` candidate validation failed: {reason}")] + ExecutionValidation { + execution_id: String, + reason: String, + }, + #[error( + "managed execution `{execution_id}` infrastructure failed in phase `{phase}`: {reason}" + )] + ExecutionInfrastructure { + execution_id: String, + phase: String, + reason: String, + }, #[error("native COW is unsupported for this source and destination")] CloneUnsupported, #[error("native COW source and destination are on different filesystems")] @@ -168,6 +183,9 @@ impl Error { Error::GitWorktreeDirty(_) => "GIT_WORKTREE_DIRTY", Error::GitDeltaExportRequired(_) => "GIT_DELTA_EXPORT_REQUIRED", Error::InvalidInput(_) => "INVALID_INPUT", + Error::ExecutionCancelled { .. } => "EXECUTION_CANCELLED", + Error::ExecutionValidation { .. } => "EXECUTION_VALIDATION_FAILED", + Error::ExecutionInfrastructure { .. } => "EXECUTION_INFRASTRUCTURE_FAILED", Error::CloneUnsupported => "CLONE_UNSUPPORTED", Error::CloneCrossDevice => "CLONE_CROSS_DEVICE", Error::NativeCowSourceUnavailable => "NATIVE_COW_SOURCE_UNAVAILABLE", @@ -215,6 +233,9 @@ impl Error { | Error::CloneUnsupported | Error::CloneCrossDevice | Error::NativeCowSourceUnavailable => 2, + Error::ExecutionCancelled { .. } => 17, + Error::ExecutionValidation { .. } => 18, + Error::ExecutionInfrastructure { .. } => 19, Error::DaemonUnavailable(_) => 11, Error::DaemonError { exit_code, .. } => *exit_code, _ => 1, diff --git a/trail/src/mcp/tool_call/lane.rs b/trail/src/mcp/tool_call/lane.rs index 915fc1ea..5ca667df 100644 --- a/trail/src/mcp/tool_call/lane.rs +++ b/trail/src/mcp/tool_call/lane.rs @@ -134,7 +134,17 @@ pub(super) fn handle(db: &mut Trail, name: &str, arguments: &Value) -> Result { let args: WorkspaceExecArgs = parse_args(arguments)?; let lane = db.resolve_lane_handle(&args.lane)?; - tool_result(db.exec_lane_workspace(&lane, &args.command)?) + tool_result(db.exec_lane_workspace_with_options( + &lane, + &args.command, + args.turn_id.as_deref(), + args.timeout_secs, + )?) + } + "trail.lane_exec_cancel" => { + let args: WorkspaceExecCancellationArgs = parse_args(arguments)?; + let lane = db.resolve_lane_handle(&args.lane)?; + tool_result(db.cancel_lane_workspace_execution(&lane, args.execution_id.as_deref())?) } "trail.deps_status" => { let args: LaneHandleArgs = parse_args(arguments)?; diff --git a/trail/src/mcp/tools/annotations.rs b/trail/src/mcp/tools/annotations.rs index 676ccdc9..d804ff03 100644 --- a/trail/src/mcp/tools/annotations.rs +++ b/trail/src/mcp/tools/annotations.rs @@ -246,6 +246,7 @@ fn classified_tool_risk_class(name: &str) -> Option { | "trail.run_test" | "trail.run_eval" | "trail.lane_exec" + | "trail.lane_exec_cancel" | "trail.deps_sync" | "trail.env_sync" | "trail.env_sync_all" diff --git a/trail/src/mcp/tools/lane.rs b/trail/src/mcp/tools/lane.rs index 621bde11..4dc0f86b 100644 --- a/trail/src/mcp/tools/lane.rs +++ b/trail/src/mcp/tools/lane.rs @@ -222,12 +222,23 @@ pub(super) fn tools() -> Value { { "name": "trail.lane_exec", "title": "Execute In Lane Workspace", - "description": "Mount a layered lane for one open-world command with isolated cache and target variables.", + "description": "Run one managed lane command through the workspace's configured host or no-mount Colima backend, then validate and checkpoint source changes with optional open-turn provenance.", "inputSchema": object_schema(json!({ "lane": { "type": "string" }, - "command": { "type": "array", "items": { "type": "string" }, "minItems": 1 } + "command": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, + "turn_id": { "type": "string" }, + "timeout_secs": { "type": "integer", "minimum": 1, "maximum": 86400 } }), vec!["lane", "command"]) }, + { + "name": "trail.lane_exec_cancel", + "title": "Cancel Lane Workspace Execution", + "description": "Cancel one Trail-owned Colima execution, terminate only its guest process group, skip candidate import, and retain a terminal receipt.", + "inputSchema": object_schema(json!({ + "lane": { "type": "string" }, + "execution_id": { "type": "string" } + }), vec!["lane"]) + }, { "name": "trail.deps_status", "title": "Dependency Environment Status", @@ -435,7 +446,7 @@ pub(super) fn tools() -> Value { { "name": "trail.env_runtime_reconcile", "title": "Reconcile Environment Runtime", - "description": "Idempotently create or adopt declared lane-private OCI resources and wait for their health contracts.", + "description": "Resolve the configured Docker, Podman, or explicit Colima provider without downloading tools; optionally start the selected Colima profile; then idempotently create or adopt declared lane-private OCI resources and wait for health.", "inputSchema": object_schema(json!({ "lane": { "type": "string" } }), vec!["lane"]) diff --git a/trail/src/mcp/types/lane.rs b/trail/src/mcp/types/lane.rs index b93d011f..d6cf7a4d 100644 --- a/trail/src/mcp/types/lane.rs +++ b/trail/src/mcp/types/lane.rs @@ -193,6 +193,19 @@ pub(crate) struct WorkspaceExecArgs { #[serde(alias = "lane_or_id", alias = "name")] pub(crate) lane: String, pub(crate) command: Vec, + #[serde(default)] + pub(crate) turn_id: Option, + #[serde(default)] + pub(crate) timeout_secs: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct WorkspaceExecCancellationArgs { + #[serde(alias = "lane_or_id", alias = "name")] + pub(crate) lane: String, + #[serde(default)] + pub(crate) execution_id: Option, } #[derive(Debug, Deserialize)] diff --git a/trail/src/model/domain/config.rs b/trail/src/model/domain/config.rs index bd19157e..96818a5f 100644 --- a/trail/src/model/domain/config.rs +++ b/trail/src/model/domain/config.rs @@ -21,6 +21,8 @@ pub struct TrailConfig { pub guardrails: GuardrailsConfig, #[serde(default = "default_workspace_views_config")] pub workspace_views: WorkspaceViewsConfig, + #[serde(default = "default_runtime_config")] + pub runtime: RuntimeConfig, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] @@ -115,6 +117,18 @@ pub struct WorkspaceViewsConfig { pub prefetch_max_entries: u64, } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RuntimeConfig { + #[serde(default = "default_runtime_provider")] + pub provider: String, + #[serde(default = "default_runtime_execution_backend")] + pub execution_backend: String, + #[serde(default)] + pub colima_profile: Option, + #[serde(default = "default_colima_autostart")] + pub colima_autostart: bool, +} + fn default_storage_config() -> StorageConfig { StorageConfig { prolly_backend: default_prolly_backend(), @@ -147,6 +161,27 @@ fn default_workspace_views_config() -> WorkspaceViewsConfig { } } +fn default_runtime_config() -> RuntimeConfig { + RuntimeConfig { + provider: default_runtime_provider(), + execution_backend: default_runtime_execution_backend(), + colima_profile: None, + colima_autostart: default_colima_autostart(), + } +} + +fn default_runtime_provider() -> String { + "auto".to_string() +} + +fn default_runtime_execution_backend() -> String { + "host".to_string() +} + +fn default_colima_autostart() -> bool { + true +} + fn default_prefetch_bytes() -> u64 { 256 * 1024 * 1024 } @@ -282,6 +317,7 @@ impl TrailConfig { storage: default_storage_config(), guardrails: default_guardrails_config(), workspace_views: default_workspace_views_config(), + runtime: default_runtime_config(), } } } diff --git a/trail/src/model/lane/activity.rs b/trail/src/model/lane/activity.rs index 50cb5de6..62e28f5a 100644 --- a/trail/src/model/lane/activity.rs +++ b/trail/src/model/lane/activity.rs @@ -1242,6 +1242,10 @@ pub struct AgentLaunchContainmentReport { pub project_integrations: String, pub environment_policy: String, pub sandbox_backend: String, + #[serde(default = "default_agent_control_plane_backend")] + pub control_plane_backend: String, + #[serde(default = "default_agent_managed_command_backend")] + pub managed_command_backend: String, pub filesystem_enforcement: String, pub lane_root: String, pub git_work_tree: Option, @@ -1251,6 +1255,14 @@ pub struct AgentLaunchContainmentReport { pub original_checkout_unchanged: bool, } +fn default_agent_control_plane_backend() -> String { + "host".to_string() +} + +fn default_agent_managed_command_backend() -> String { + "host".to_string() +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct AgentContinueReport { pub source_task: AgentTaskReport, diff --git a/trail/src/model/reports/lane.rs b/trail/src/model/reports/lane.rs index 5dcd1606..88cc4905 100644 --- a/trail/src/model/reports/lane.rs +++ b/trail/src/model/reports/lane.rs @@ -725,6 +725,30 @@ pub struct EnvironmentRuntimeResourceReport { pub secret_statuses: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct EnvironmentRuntimeProviderReport { + pub provider: String, + #[serde(default = "default_runtime_execution_backend_report")] + pub execution_backend: String, + pub status: String, + pub profile: Option, + #[serde(default)] + pub lima_instance: Option, + pub docker_context: Option, + pub autostart: bool, + pub started: bool, + pub containment: String, + #[serde(default)] + pub toolchain_source: String, + #[serde(default)] + pub toolchain_version: Option, + pub reason: Option, +} + +fn default_runtime_execution_backend_report() -> String { + "host".to_string() +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct EnvironmentGenerationDependencyReport { pub component_id: String, @@ -1094,11 +1118,34 @@ pub struct WorkspaceExecReport { pub generation: u64, pub environment_generation: Option, pub backend: String, + #[serde(default = "default_runtime_execution_backend_report")] + pub execution_backend: String, pub command: Vec, pub exit_code: i32, + #[serde(default)] + pub timed_out: bool, + #[serde(default = "default_workspace_exec_classification")] + pub exit_classification: String, pub lifecycle: ManagedExecutionLifecycleReport, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkspaceExecCancellationReport { + pub lane_id: String, + pub execution_id: String, + pub status: String, + pub phase_before: String, + pub profile: String, + pub lima_instance: String, + pub owner_was_live: bool, + pub process_group_terminated: bool, + pub cleanup_status: String, +} + +fn default_workspace_exec_classification() -> String { + "legacy_unclassified".to_string() +} + #[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ManagedExecutionMissingResolutionPolicy { @@ -1139,6 +1186,8 @@ pub struct ManagedExecutionOutputPin { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct ManagedExecutionPreparationReceipt { pub source_root: ObjectId, + #[serde(default = "default_runtime_execution_backend_report")] + pub execution_backend: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub view_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1148,6 +1197,39 @@ pub struct ManagedExecutionPreparationReceipt { #[serde(default, skip_serializing_if = "Option::is_none")] pub environment_generation: Option, pub output_pins: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ManagedExecutionSandboxPreparationReceipt { + pub backend: String, + pub provider: String, + pub profile: String, + pub lima_instance: String, + pub guest_namespace: String, + pub toolchain_source: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub toolchain_version: Option, + pub input_digest: String, + pub projected_entries: u64, + pub projected_bytes: u64, + pub entry_limit: u64, + pub total_bytes_limit: u64, + pub file_bytes_limit: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub service_bindings: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ManagedExecutionSandboxFinalizationReceipt { + pub output_digest: String, + pub imported_paths: Vec, + pub removed_paths: Vec, + pub unchanged: bool, + pub cleanup_status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleanup_error: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -1174,6 +1256,8 @@ pub struct ManagedExecutionFinalizationReceipt { pub complete: bool, pub sealing_decisions: Vec, pub errors: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, } #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -1318,6 +1402,12 @@ pub struct ManagedExecutionLifecycleReport { pub surface: String, pub command_fingerprint: String, #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub preparation: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub environment_generation: Option, @@ -1766,6 +1856,48 @@ mod workdir_mode_tests { assert_eq!(value["recovery_actions"], serde_json::json!([])); } + #[test] + fn runtime_provider_report_has_stable_explicit_fields() { + let report = EnvironmentRuntimeProviderReport { + provider: "colima".to_string(), + execution_backend: "colima".to_string(), + status: "ready".to_string(), + profile: Some("trail-workspace".to_string()), + lima_instance: Some("colima-trail-workspace".to_string()), + docker_context: Some("colima-trail-workspace".to_string()), + autostart: true, + started: true, + containment: "trail_no_host_mounts_v1".to_string(), + toolchain_source: "trail_managed".to_string(), + toolchain_version: Some( + "colima-0.10.3+lima-2.2.0+docker-29.7.2".to_string(), + ), + reason: None, + }; + let value = serde_json::to_value(&report).unwrap(); + assert_eq!( + value, + serde_json::json!({ + "provider": "colima", + "execution_backend": "colima", + "status": "ready", + "profile": "trail-workspace", + "lima_instance": "colima-trail-workspace", + "docker_context": "colima-trail-workspace", + "autostart": true, + "started": true, + "containment": "trail_no_host_mounts_v1", + "toolchain_source": "trail_managed", + "toolchain_version": "colima-0.10.3+lima-2.2.0+docker-29.7.2", + "reason": null + }) + ); + assert_eq!( + serde_json::from_value::(value).unwrap(), + report + ); + } + #[test] fn managed_execution_receipts_are_additive_and_wire_stable() { let legacy: ManagedExecutionLifecycleReport = serde_json::from_value( @@ -1779,12 +1911,18 @@ mod workdir_mode_tests { .unwrap(); assert!(legacy.preparation.is_none()); assert!(legacy.finalization.is_none()); + assert!(legacy.session_id.is_none()); + assert!(legacy.turn_id.is_none()); + assert!(legacy.trace_id.is_none()); let current: ManagedExecutionLifecycleReport = serde_json::from_value( serde_json::json!({ "execution_id": "exec-current", "surface": "lane_test", "command_fingerprint": "command", + "session_id": "session-1", + "turn_id": "turn-1", + "trace_id": "trace-1", "preparation": { "source_root": "object_source", "view_id": "view-1", @@ -1828,6 +1966,9 @@ mod workdir_mode_tests { ) .unwrap(); let value = serde_json::to_value(current).unwrap(); + assert_eq!(value["session_id"], "session-1"); + assert_eq!(value["turn_id"], "turn-1"); + assert_eq!(value["trace_id"], "trace-1"); assert_eq!( value["preparation"]["missing_resolution_policy"], "explicit" diff --git a/trail/src/model/reports/maintenance.rs b/trail/src/model/reports/maintenance.rs index 9bfafa57..e9cad2fb 100644 --- a/trail/src/model/reports/maintenance.rs +++ b/trail/src/model/reports/maintenance.rs @@ -147,7 +147,10 @@ impl StructuredErrorEnvelope { | crate::Error::ChangeLedgerReconcileRequired { .. } | crate::Error::CommittedRepairRequired { .. } | crate::Error::LaneInitializationConflict { .. } - | crate::Error::LaneInitializationInProgress { .. } => 409, + | crate::Error::LaneInitializationInProgress { .. } + | crate::Error::ExecutionCancelled { .. } => 409, + crate::Error::ExecutionValidation { .. } => 422, + crate::Error::ExecutionInfrastructure { .. } => 503, crate::Error::InvalidInput(_) | crate::Error::InvalidPath { .. } | crate::Error::IgnoredPath(_) @@ -233,6 +236,25 @@ impl StructuredErrorEnvelope { "operation_id": operation_id, "retry_command": retry_command, })), + crate::Error::ExecutionCancelled { execution_id } => Some(serde_json::json!({ + "execution_id": execution_id, + })), + crate::Error::ExecutionValidation { + execution_id, + reason, + } => Some(serde_json::json!({ + "execution_id": execution_id, + "reason": reason, + })), + crate::Error::ExecutionInfrastructure { + execution_id, + phase, + reason, + } => Some(serde_json::json!({ + "execution_id": execution_id, + "phase": phase, + "reason": reason, + })), _ => None, }; Self { @@ -378,6 +400,48 @@ mod maintenance_tests { ); } + #[test] + fn managed_execution_outcomes_have_distinct_shared_contracts() { + for (error, code, status, exit) in [ + ( + crate::Error::ExecutionCancelled { + execution_id: "exec_cancel".into(), + }, + "EXECUTION_CANCELLED", + 409, + 17, + ), + ( + crate::Error::ExecutionValidation { + execution_id: "exec_validation".into(), + reason: "unsafe candidate".into(), + }, + "EXECUTION_VALIDATION_FAILED", + 422, + 18, + ), + ( + crate::Error::ExecutionInfrastructure { + execution_id: "exec_infrastructure".into(), + phase: "guest_export".into(), + reason: "runtime unavailable".into(), + }, + "EXECUTION_INFRASTRUCTURE_FAILED", + 503, + 19, + ), + ] { + let value = serde_json::to_value(StructuredErrorEnvelope::from_error(&error)).unwrap(); + assert_eq!(value["error"]["code"], code); + assert_eq!(value["error"]["status"], status); + assert_eq!(value["error"]["exit"], exit); + assert!(value["error"]["details"]["execution_id"] + .as_str() + .unwrap() + .starts_with("exec_")); + } + } + #[test] fn legacy_index_rebuild_report_defaults_path_index_repairs() { let report: IndexRebuildReport = serde_json::from_value(serde_json::json!({ diff --git a/trail/src/server/openapi/paths/lanes.rs b/trail/src/server/openapi/paths/lanes.rs index 7a611b98..340a8225 100644 --- a/trail/src/server/openapi/paths/lanes.rs +++ b/trail/src/server/openapi/paths/lanes.rs @@ -232,7 +232,7 @@ pub(super) fn lane_paths() -> Value { ], None, "EnvironmentGenerationReportNullable", true) }, "/v1/lanes/{lane_or_id}/environment/runtime/reconcile": { - "post": openapi_operation_with_response_schema("laneEnvironmentRuntimeReconcile", "Reconcile environment runtime", "Idempotently create or adopt declared lane-private OCI resources and wait for health.", vec![ + "post": openapi_operation_with_response_schema("laneEnvironmentRuntimeReconcile", "Reconcile environment runtime", "Resolve the configured Docker, Podman, or explicit Colima provider without downloading tools; optionally start the selected Colima profile; then idempotently create or adopt declared lane-private OCI resources and wait for health.", vec![ openapi_path_param("lane_or_id", "string") ], None, "EnvironmentGenerationReport", true) }, @@ -247,10 +247,15 @@ pub(super) fn lane_paths() -> Value { ], Some("WorkspaceCheckpointRequest"), "WorkspaceCheckpointReport", true) }, "/v1/lanes/{lane_or_id}/exec": { - "post": openapi_operation_with_response_schema("laneWorkspaceExec", "Execute in lane workspace", "Discover, synchronize, reconcile, mount, execute, checkpoint source changes, dispose runtime artifacts, and unmount one managed lane command.", vec![ + "post": openapi_operation_with_response_schema("laneWorkspaceExec", "Execute in lane workspace", "Discover, synchronize, reconcile, mount, execute through the configured host or no-mount Colima backend, validate and checkpoint source changes, dispose runtime artifacts, and unmount one managed lane command.", vec![ openapi_path_param("lane_or_id", "string") ], Some("WorkspaceExecRequest"), "WorkspaceExecReport", true) }, + "/v1/lanes/{lane_or_id}/exec/cancel": { + "post": openapi_operation_with_response_schema("laneWorkspaceExecCancel", "Cancel lane workspace execution", "Request cancellation of one Trail-owned Colima execution, terminate only its guest process group, skip candidate import, clean its namespace, and retain a terminal receipt.", vec![ + openapi_path_param("lane_or_id", "string") + ], Some("WorkspaceExecCancellationRequest"), "WorkspaceExecCancellationReport", true) + }, "/v1/lanes/{lane_or_id}/diff": { "get": openapi_operation("laneDiff", "Lane diff", "Show the diff from a lane branch base to head.", vec![ openapi_path_param("lane_or_id", "string"), diff --git a/trail/src/server/openapi/schemas/lane.rs b/trail/src/server/openapi/schemas/lane.rs index 82156b6e..c624fa48 100644 --- a/trail/src/server/openapi/schemas/lane.rs +++ b/trail/src/server/openapi/schemas/lane.rs @@ -302,7 +302,7 @@ pub(super) fn lane_schemas() -> Value { "required": ["phase", "status"], "additionalProperties": false, "properties": { - "phase": { "type": "string", "enum": ["resolve", "discover_plan", "sync_all", "prefetch", "reconcile", "mount", "execute", "checkpoint", "dispose", "unmount"] }, + "phase": { "type": "string", "enum": ["resolve", "discover_plan", "sync_all", "prefetch", "reconcile", "mount", "guest_project", "execute", "guest_export", "guest_import", "guest_cleanup", "checkpoint", "dispose", "unmount"] }, "status": { "type": "string", "enum": ["succeeded", "failed", "skipped"] }, "error": { "type": "string" }, "details": { "$ref": "#/components/schemas/JsonValue" } @@ -339,16 +339,52 @@ pub(super) fn lane_schemas() -> Value { }, "ManagedExecutionPreparationReceipt": { "type": "object", - "required": ["source_root", "missing_resolution_policy", "resolution_pins", "output_pins"], + "required": ["source_root", "execution_backend", "missing_resolution_policy", "resolution_pins", "output_pins"], "additionalProperties": false, "properties": { "source_root": { "type": "string" }, + "execution_backend": { "type": "string", "enum": ["host", "colima"] }, "view_id": { "type": "string" }, "view_generation": { "type": "integer", "minimum": 0 }, "missing_resolution_policy": { "type": "string", "enum": ["explicit"] }, "resolution_pins": { "type": "array", "items": { "$ref": "#/components/schemas/ManagedExecutionResolutionPin" } }, "environment_generation": { "type": "string" }, - "output_pins": { "type": "array", "items": { "$ref": "#/components/schemas/ManagedExecutionOutputPin" } } + "output_pins": { "type": "array", "items": { "$ref": "#/components/schemas/ManagedExecutionOutputPin" } }, + "sandbox": { "$ref": "#/components/schemas/ManagedExecutionSandboxPreparationReceipt" } + } + }, + "ManagedExecutionSandboxPreparationReceipt": { + "type": "object", + "required": ["backend", "provider", "profile", "lima_instance", "guest_namespace", "toolchain_source", "input_digest", "projected_entries", "projected_bytes", "entry_limit", "total_bytes_limit", "file_bytes_limit"], + "additionalProperties": false, + "properties": { + "backend": { "type": "string", "enum": ["colima"] }, + "provider": { "type": "string", "enum": ["colima"] }, + "profile": { "type": "string" }, + "lima_instance": { "type": "string" }, + "guest_namespace": { "type": "string" }, + "toolchain_source": { "type": "string", "enum": ["system", "trail_managed"] }, + "toolchain_version": { "type": "string" }, + "input_digest": { "type": "string" }, + "projected_entries": { "type": "integer", "minimum": 0 }, + "projected_bytes": { "type": "integer", "minimum": 0 }, + "entry_limit": { "type": "integer", "minimum": 1 }, + "total_bytes_limit": { "type": "integer", "minimum": 1 }, + "file_bytes_limit": { "type": "integer", "minimum": 1 }, + "service_bindings": { "type": "array", "items": { "type": "string" } } + } + }, + "ManagedExecutionSandboxFinalizationReceipt": { + "type": "object", + "required": ["output_digest", "imported_paths", "removed_paths", "unchanged", "cleanup_status"], + "additionalProperties": false, + "properties": { + "output_digest": { "type": "string" }, + "imported_paths": { "type": "array", "items": { "type": "string" } }, + "removed_paths": { "type": "array", "items": { "type": "string" } }, + "unchanged": { "type": "boolean" }, + "cleanup_status": { "type": "string", "enum": ["succeeded", "failed"] }, + "cleanup_error": { "type": "string" } } }, "ManagedExecutionSealingDecision": { @@ -378,7 +414,8 @@ pub(super) fn lane_schemas() -> Value { "unmount_status": { "type": "string", "enum": ["succeeded", "failed", "skipped"] }, "complete": { "type": "boolean" }, "sealing_decisions": { "type": "array", "items": { "$ref": "#/components/schemas/ManagedExecutionSealingDecision" } }, - "errors": { "type": "array", "items": { "type": "string" } } + "errors": { "type": "array", "items": { "type": "string" } }, + "sandbox": { "$ref": "#/components/schemas/ManagedExecutionSandboxFinalizationReceipt" } } }, "ManagedExecutionLifecycleReport": { @@ -389,6 +426,9 @@ pub(super) fn lane_schemas() -> Value { "execution_id": { "type": "string" }, "surface": { "type": "string", "enum": ["lane_exec", "lane_test", "lane_eval", "terminal_agent", "acp_prompt"] }, "command_fingerprint": { "type": "string" }, + "session_id": { "type": "string" }, + "turn_id": { "type": "string" }, + "trace_id": { "type": "string" }, "preparation": { "$ref": "#/components/schemas/ManagedExecutionPreparationReceipt" }, "environment_generation": { "type": "string" }, "checkpoint": { "$ref": "#/components/schemas/WorkspaceCheckpointReport" }, @@ -402,7 +442,7 @@ pub(super) fn lane_schemas() -> Value { }, "WorkspaceExecReport": { "type": "object", - "required": ["view_id", "lane_id", "source_root", "generation", "environment_generation", "backend", "command", "exit_code", "lifecycle"], + "required": ["view_id", "lane_id", "source_root", "generation", "environment_generation", "backend", "execution_backend", "command", "exit_code", "timed_out", "exit_classification", "lifecycle"], "additionalProperties": false, "properties": { "view_id": { "type": "string" }, @@ -411,11 +451,30 @@ pub(super) fn lane_schemas() -> Value { "generation": { "type": "integer", "minimum": 0 }, "environment_generation": { "type": ["string", "null"] }, "backend": { "type": "string" }, + "execution_backend": { "type": "string", "enum": ["host", "colima"] }, "command": { "type": "array", "items": { "type": "string" } }, "exit_code": { "type": "integer" }, + "timed_out": { "type": "boolean" }, + "exit_classification": { "type": "string", "enum": ["succeeded", "command_failed", "timed_out", "legacy_unclassified"] }, "lifecycle": { "$ref": "#/components/schemas/ManagedExecutionLifecycleReport" } } }, + "WorkspaceExecCancellationReport": { + "type": "object", + "required": ["lane_id", "execution_id", "status", "phase_before", "profile", "lima_instance", "owner_was_live", "process_group_terminated", "cleanup_status"], + "additionalProperties": false, + "properties": { + "lane_id": { "type": "string" }, + "execution_id": { "type": "string" }, + "status": { "type": "string", "enum": ["cancelled"] }, + "phase_before": { "type": "string" }, + "profile": { "type": "string" }, + "lima_instance": { "type": "string" }, + "owner_was_live": { "type": "boolean" }, + "process_group_terminated": { "type": "boolean" }, + "cleanup_status": { "type": "string", "enum": ["succeeded", "failed"] } + } + }, "LaneTestReport": { "type": "object", "required": ["lane_id", "turn_id", "session_id", "workdir", "source_root", "command", "kind", "status", "success", "exit_code", "timed_out", "duration_ms", "stdout_object", "stderr_object", "stdout_bytes", "stderr_bytes", "stdout_preview", "stderr_preview", "stdout_truncated", "stderr_truncated", "started_event_id", "finished_event_id", "lifecycle"], @@ -877,7 +936,16 @@ pub(super) fn lane_schemas() -> Value { "type": "array", "items": { "type": "string" }, "minItems": 1 - } + }, + "turn_id": { "type": "string", "description": "Optional open lane turn receiving the checkpoint and session/turn/trace provenance." }, + "timeout_secs": { "type": "integer", "minimum": 1, "maximum": 86400, "description": "Optional Colima guest command timeout; defaults to 3600 seconds." } + } + }, + "WorkspaceExecCancellationRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "execution_id": { "type": "string" } } }, "DependencySyncRequest": { diff --git a/trail/src/server/request_types/lane.rs b/trail/src/server/request_types/lane.rs index bb87de42..3d038fc1 100644 --- a/trail/src/server/request_types/lane.rs +++ b/trail/src/server/request_types/lane.rs @@ -182,6 +182,17 @@ fn default_update_source() -> String { #[serde(deny_unknown_fields)] pub(crate) struct WorkspaceExecRequest { pub(crate) command: Vec, + #[serde(default)] + pub(crate) turn_id: Option, + #[serde(default)] + pub(crate) timeout_secs: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct WorkspaceExecCancellationRequest { + #[serde(default)] + pub(crate) execution_id: Option, } #[derive(Debug, Default, Deserialize)] diff --git a/trail/src/server/route/lane/lanes.rs b/trail/src/server/route/lane/lanes.rs index 45dedae2..0581586a 100644 --- a/trail/src/server/route/lane/lanes.rs +++ b/trail/src/server/route/lane/lanes.rs @@ -6,7 +6,8 @@ use crate::server::request_types::{ DependencySyncRequest, EnvironmentPromoteRequest, EnvironmentResolveAllRequest, EnvironmentResolveRequest, EnvironmentSyncRequest, LaneClaimRequest, LaneReadFileRequest, LaneRecordRequest, LaneRewindRequest, LaneTestRequest, LaneUpdateRequest, SpawnLaneRequest, - SyncWorkdirRequest, WorkspaceCheckpointRequest, WorkspaceExecRequest, + SyncWorkdirRequest, WorkspaceCheckpointRequest, WorkspaceExecCancellationRequest, + WorkspaceExecRequest, }; use crate::server::route::utils::{ json_response, parse_patch_request, query_flag, query_line_ids_flag, query_usize, query_value, @@ -181,6 +182,23 @@ pub(super) fn handle_lane_resources( return Ok(Some(json_response(200, "OK", &report)?)); } + if parts.len() == 5 + && parts[0] == "v1" + && parts[1] == "lanes" + && parts[3] == "exec" + && parts[4] == "cancel" + && request.method == "POST" + { + let lane = db.resolve_lane_handle(parts[2])?; + let body: WorkspaceExecCancellationRequest = if request.body.is_empty() { + WorkspaceExecCancellationRequest::default() + } else { + serde_json::from_slice(&request.body)? + }; + let report = db.cancel_lane_workspace_execution(&lane, body.execution_id.as_deref())?; + return Ok(Some(json_response(200, "OK", &report)?)); + } + if parts.len() == 4 && parts[0] == "v1" && parts[1] == "lanes" @@ -589,7 +607,12 @@ pub(super) fn handle_lane_resources( { let lane = db.resolve_lane_handle(parts[2])?; let body: WorkspaceExecRequest = serde_json::from_slice(&request.body)?; - let report = db.exec_lane_workspace(&lane, &body.command)?; + let report = db.exec_lane_workspace_with_options( + &lane, + &body.command, + body.turn_id.as_deref(), + body.timeout_secs, + )?; return Ok(Some(json_response(200, "OK", &report)?)); } diff --git a/trail/src/server/transport.rs b/trail/src/server/transport.rs index ed7f0ca4..a119dd47 100644 --- a/trail/src/server/transport.rs +++ b/trail/src/server/transport.rs @@ -269,6 +269,21 @@ fn handle_unix_connection( return Ok(false); } }; + if is_long_running_lane_exec(&request, &db.config().runtime.execution_backend) { + let workspace = db.workspace_root().to_path_buf(); + let auth = auth.clone(); + let mut worker_stream = stream.try_clone()?; + std::thread::spawn(move || { + let response = match Trail::open(&workspace) { + Ok(mut worker_db) => route::route_request(&mut worker_db, request, &auth), + Err(error) => route::error_response(&error), + }; + let _ = worker_stream + .write_all(&response.to_http_bytes()) + .and_then(|_| worker_stream.flush()); + }); + return Ok(false); + } #[cfg(debug_assertions)] let request_method = request.method.clone(); #[cfg(debug_assertions)] @@ -325,6 +340,20 @@ fn handle_connection( stream.flush()?; return Ok(()); } + if is_long_running_lane_exec(&request, &db.config().runtime.execution_backend) { + let workspace = db.workspace_root().to_path_buf(); + let auth = auth.clone(); + std::thread::spawn(move || { + let response = match Trail::open(&workspace) { + Ok(mut worker_db) => route::route_request(&mut worker_db, request, &auth), + Err(error) => route::error_response(&error), + }; + let _ = stream + .write_all(&response.to_http_bytes()) + .and_then(|_| stream.flush()); + }); + return Ok(()); + } #[cfg(debug_assertions)] let request_method = request.method.clone(); #[cfg(debug_assertions)] @@ -337,6 +366,15 @@ fn handle_connection( Ok(()) } +fn is_long_running_lane_exec(request: &HttpRequest, execution_backend: &str) -> bool { + if execution_backend != "colima" || request.method != "POST" { + return false; + } + let path = request.path.split('?').next().unwrap_or(&request.path); + let parts = path.trim_matches('/').split('/').collect::>(); + parts.len() == 4 && parts[0] == "v1" && parts[1] == "lanes" && parts[3] == "exec" +} + #[cfg(debug_assertions)] fn delay_daemon_response_for_test(request_method: &str, request_path: &str) { let Some(expected_path) = std::env::var_os("TRAIL_TEST_DAEMON_RESPONSE_DELAY_PATH") else { @@ -825,6 +863,36 @@ mod tests { use std::io::Cursor; use std::thread; + #[test] + fn only_blocking_lane_exec_is_dispatched_to_a_worker() { + let request = |method: &str, path: &str| HttpRequest { + method: method.to_string(), + path: path.to_string(), + headers: BTreeMap::new(), + body: Vec::new(), + }; + assert!(is_long_running_lane_exec( + &request("POST", "/v1/lanes/agent/exec"), + "colima" + )); + assert!(is_long_running_lane_exec( + &request("POST", "/v1/lanes/agent/exec?trace=true"), + "colima" + )); + assert!(!is_long_running_lane_exec( + &request("POST", "/v1/lanes/agent/exec/cancel"), + "colima" + )); + assert!(!is_long_running_lane_exec( + &request("GET", "/v1/lanes/agent/exec"), + "colima" + )); + assert!(!is_long_running_lane_exec( + &request("POST", "/v1/lanes/agent/exec"), + "host" + )); + } + #[test] fn socket_line_reader_rejects_oversized_line_without_newline() { let mut reader = Cursor::new(vec![b'x'; MAX_HTTP_REQUEST_BYTES + 1]); diff --git a/trail/tests/changed_path_ledger_activation.rs b/trail/tests/changed_path_ledger_activation.rs index e876b9ab..ddb90048 100644 --- a/trail/tests/changed_path_ledger_activation.rs +++ b/trail/tests/changed_path_ledger_activation.rs @@ -90,11 +90,11 @@ fn authority_requires_every_checked_gate_and_supported_platform() { ); assert_eq!( complete["raw_mutation_inventory_sha256"], - "9555ad8d0be83713955c1d6e3a6dfb8524d4d861c473f9a51bdc4e3144d0518d" + "251de4f3ec9b185c082ce88528d84e8967fed782d7d3128b977aa4309d0d0bea" ); assert_eq!( complete["activation_audit_sha256"], - "9f5f462f7eae0fc6f903c4fb89146b3f59caf6bb5b415221a65938edba59e4df" + "de0d527ef2278c78bdda7744d929ba9177a1ec76c7f81d1597724a6a0709f01c" ); assert!(!trail::test_support::changed_path_authority_enabled_for("windows").unwrap()); assert!(!trail::test_support::changed_path_authority_enabled_for("freebsd").unwrap()); diff --git a/trail/tests/colima_runtime.rs b/trail/tests/colima_runtime.rs new file mode 100644 index 00000000..474b6ec9 --- /dev/null +++ b/trail/tests/colima_runtime.rs @@ -0,0 +1,351 @@ +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use trail::{InitImportMode, Trail}; + +fn trail_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_trail")) +} + +fn initialize_workspace() -> tempfile::TempDir { + let workspace = tempfile::tempdir().unwrap(); + fs::write(workspace.path().join("README.md"), "colima runtime\n").unwrap(); + Trail::init(workspace.path(), "main", InitImportMode::WorkingTree, false).unwrap(); + workspace +} + +fn write_executable(path: &Path, script: &str) { + fs::write(path, script).unwrap(); + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o700); + fs::set_permissions(path, permissions).unwrap(); +} + +fn fake_path(bin: &Path) -> std::ffi::OsString { + let mut paths = vec![bin.to_path_buf()]; + paths.extend(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + )); + std::env::join_paths(paths).unwrap() +} + +fn write_fake_limactl(bin: &Path) { + write_executable( + &bin.join("limactl"), + "#!/bin/sh\nif [ -n \"${TRAIL_TEST_LIMACTL_LOG:-}\" ]; then printf '%s\\n' \"$*\" >> \"$TRAIL_TEST_LIMACTL_LOG\"; fi\nexit 0\n", + ); +} + +#[test] +fn runtime_config_defaults_and_validates_provider_and_profile() { + let workspace = initialize_workspace(); + let mut db = Trail::open(workspace.path()).unwrap(); + + assert_eq!(db.config_get("runtime.provider").unwrap().value, "auto"); + assert_eq!( + db.config_get("runtime.execution_backend").unwrap().value, + "host" + ); + assert_eq!(db.config_get("runtime.colima_profile").unwrap().value, ""); + assert_eq!( + db.config_get("runtime.colima_autostart").unwrap().value, + "true" + ); + + db.config_set("runtime.provider", "colima").unwrap(); + db.config_set("runtime.execution_backend", "colima") + .unwrap(); + db.config_set("runtime.colima_profile", "trail-project") + .unwrap(); + db.config_set("runtime.colima_autostart", "false").unwrap(); + let reopened = Trail::open(workspace.path()).unwrap(); + assert_eq!(reopened.config().runtime.provider, "colima"); + assert_eq!(reopened.config().runtime.execution_backend, "colima"); + assert_eq!( + reopened.config().runtime.colima_profile.as_deref(), + Some("trail-project") + ); + assert!(!reopened.config().runtime.colima_autostart); + + assert!(db.config_set("runtime.provider", "lima").is_err()); + assert!(db.config_set("runtime.execution_backend", "lima").is_err()); + assert!(db.config_set("runtime.provider", "auto").is_err()); + db.config_set("runtime.execution_backend", "host").unwrap(); + db.config_set("runtime.provider", "auto").unwrap(); + assert!(db + .config_set("runtime.colima_profile", "../escape") + .is_err()); +} + +#[test] +fn lane_exec_cli_rejects_invalid_guest_timeout_before_launch() { + let workspace = initialize_workspace(); + let output = Command::new(trail_bin()) + .current_dir(workspace.path()) + .args([ + "lane", + "exec", + "not-launched", + "--timeout-secs", + "0", + "--", + "/bin/true", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr).contains("between 1 and 86400 seconds"), + "unexpected error: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn lane_exec_cancel_cli_has_structured_no_active_execution_result() { + let workspace = initialize_workspace(); + let mut db = Trail::open(workspace.path()).unwrap(); + db.spawn_lane("cancel-lane", Some("main"), false, None, None) + .unwrap(); + drop(db); + let output = Command::new(trail_bin()) + .current_dir(workspace.path()) + .args(["--format", "json", "lane", "exec-cancel", "cancel-lane"]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); + let error: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(error["error"]["code"], "INVALID_INPUT"); + assert!(error["error"]["message"] + .as_str() + .unwrap() + .contains("no matching cancellable")); +} + +#[test] +fn setup_starts_contained_colima_and_uses_only_its_explicit_context() { + let workspace = initialize_workspace(); + let fake = tempfile::tempdir().unwrap(); + let colima_log = fake.path().join("colima.log"); + let docker_log = fake.path().join("docker.log"); + let limactl_log = fake.path().join("limactl.log"); + write_fake_limactl(fake.path()); + write_executable( + &fake.path().join("colima"), + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> {}\ncase \" $* \" in\n *' status '*) exit 1 ;;\n *' start '*) exit 0 ;;\nesac\nexit 2\n", + colima_log.display() + ), + ); + write_executable( + &fake.path().join("docker"), + &format!( + "#!/bin/sh\n[ -z \"${{DOCKER_HOST:-}}\" ] || exit 19\nprintf '%s\\n' \"$*\" >> {}\n[ \"$1\" = --context ] || exit 20\n[ \"$2\" = colima-trail-e2e ] || exit 21\n[ \"$3\" = info ] || exit 22\nprintf '\"fake-server\"\\n'\n", + docker_log.display() + ), + ); + + let output = Command::new(trail_bin()) + .current_dir(workspace.path()) + .args([ + "--format", + "json", + "env", + "runtime", + "setup", + "colima", + "--profile", + "trail-e2e", + "--execution-backend", + "colima", + ]) + .env("PATH", fake_path(fake.path())) + .env("HOME", fake.path().join("home")) + .env("TRAIL_TEST_LIMACTL_LOG", &limactl_log) + .env("DOCKER_HOST", "tcp://wrong.example.invalid:2375") + .output() + .unwrap(); + assert!( + output.status.success(), + "setup failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["provider"], "colima"); + assert_eq!(report["execution_backend"], "colima"); + assert_eq!(report["profile"], "trail-e2e"); + assert_eq!(report["lima_instance"], "colima-trail-e2e"); + assert_eq!(report["docker_context"], "colima-trail-e2e"); + assert_eq!(report["status"], "ready"); + assert_eq!(report["started"], true); + assert_eq!(report["containment"], "trail_no_host_mounts_v1"); + assert_eq!(report["toolchain_source"], "system"); + assert_eq!(report["toolchain_version"], serde_json::Value::Null); + + let colima_args = fs::read_to_string(colima_log).unwrap(); + assert!(colima_args.contains("--profile trail-e2e status --json")); + assert!(colima_args.contains("--profile trail-e2e start")); + for required in [ + "--runtime=docker", + "--mount=none", + "--activate=false", + "--ssh-config=false", + "--ssh-agent=false", + "--kubernetes=false", + "--network-address=false", + ] { + assert!(colima_args.contains(required), "missing {required}"); + } + assert_eq!( + fs::read_to_string(docker_log).unwrap().trim(), + "--context colima-trail-e2e info --format {{json .ServerVersion}}" + ); + assert_eq!( + fs::read_to_string(limactl_log).unwrap().trim(), + "shell colima-trail-e2e -- true" + ); + + let reopened = Trail::open(workspace.path()).unwrap(); + assert_eq!(reopened.config().runtime.provider, "colima"); + assert_eq!(reopened.config().runtime.execution_backend, "colima"); + assert_eq!( + reopened.config().runtime.colima_profile.as_deref(), + Some("trail-e2e") + ); + assert!(reopened.config().runtime.colima_autostart); +} + +#[test] +fn failed_colima_preflight_does_not_publish_configuration() { + let workspace = initialize_workspace(); + let fake = tempfile::tempdir().unwrap(); + write_fake_limactl(fake.path()); + write_executable( + &fake.path().join("colima"), + "#!/bin/sh\ncase \" $* \" in\n *' status '*) exit 1 ;;\n *' start '*) echo 'injected startup failure' >&2; exit 7 ;;\nesac\nexit 2\n", + ); + write_executable(&fake.path().join("docker"), "#!/bin/sh\nexit 8\n"); + + let output = Command::new(trail_bin()) + .current_dir(workspace.path()) + .args([ + "--format", + "json", + "env", + "runtime", + "setup", + "colima", + "--profile", + "trail-failure", + ]) + .env("PATH", fake_path(fake.path())) + .env("HOME", fake.path().join("home")) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("injected startup failure")); + + let reopened = Trail::open(workspace.path()).unwrap(); + assert_eq!(reopened.config().runtime.provider, "auto"); + assert_eq!(reopened.config().runtime.execution_backend, "host"); + assert!(reopened.config().runtime.colima_profile.is_none()); +} + +#[test] +fn failed_guest_execution_preflight_does_not_publish_configuration() { + let workspace = initialize_workspace(); + let fake = tempfile::tempdir().unwrap(); + write_executable( + &fake.path().join("limactl"), + "#!/bin/sh\necho 'guest shell unavailable' >&2\nexit 23\n", + ); + write_executable( + &fake.path().join("colima"), + "#!/bin/sh\ncase \" $* \" in\n *' status '*) exit 1 ;;\n *' start '*) exit 0 ;;\nesac\nexit 2\n", + ); + write_executable( + &fake.path().join("docker"), + "#!/bin/sh\nprintf '\"fake-server\"\\n'\nexit 0\n", + ); + + let output = Command::new(trail_bin()) + .current_dir(workspace.path()) + .args([ + "--format", + "json", + "env", + "runtime", + "setup", + "colima", + "--profile", + "trail-guest-failure", + "--execution-backend", + "colima", + ]) + .env("PATH", fake_path(fake.path())) + .env("HOME", fake.path().join("home")) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr).contains("guest shell unavailable"), + "unexpected setup failure: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let reopened = Trail::open(workspace.path()).unwrap(); + assert_eq!(reopened.config().runtime.provider, "auto"); + assert_eq!(reopened.config().runtime.execution_backend, "host"); + assert!(reopened.config().runtime.colima_profile.is_none()); +} + +#[test] +fn no_start_setup_is_explicitly_unverified_and_status_is_read_only() { + let workspace = initialize_workspace(); + let fake = tempfile::tempdir().unwrap(); + write_fake_limactl(fake.path()); + write_executable(&fake.path().join("colima"), "#!/bin/sh\nexit 1\n"); + write_executable(&fake.path().join("docker"), "#!/bin/sh\nexit 1\n"); + + let setup = Command::new(trail_bin()) + .current_dir(workspace.path()) + .args([ + "--format", + "json", + "env", + "runtime", + "setup", + "colima", + "--profile", + "trail-stopped", + "--no-start", + ]) + .env("PATH", fake_path(fake.path())) + .env("HOME", fake.path().join("home")) + .output() + .unwrap(); + assert!(setup.status.success()); + let setup: serde_json::Value = serde_json::from_slice(&setup.stdout).unwrap(); + assert_eq!(setup["status"], "configured"); + assert_eq!(setup["execution_backend"], "host"); + assert_eq!(setup["autostart"], false); + assert_eq!(setup["containment"], "not_verified"); + assert_eq!(setup["toolchain_source"], "system"); + + let status = Command::new(trail_bin()) + .current_dir(workspace.path()) + .args(["--format", "json", "env", "runtime", "provider", "status"]) + .env("PATH", fake_path(fake.path())) + .env("HOME", fake.path().join("home")) + .output() + .unwrap(); + assert!(status.status.success()); + let status: serde_json::Value = serde_json::from_slice(&status.stdout).unwrap(); + assert_eq!(status["status"], "stopped"); + assert_eq!(status["profile"], "trail-stopped"); + assert_eq!(status["started"], false); +} diff --git a/trail/tests/e2e.rs b/trail/tests/e2e.rs index 1b527bd5..cac39558 100644 --- a/trail/tests/e2e.rs +++ b/trail/tests/e2e.rs @@ -11825,6 +11825,19 @@ fn layered_workspace_reports_have_http_mcp_and_openapi_parity() { .unwrap() .contains("requires a command") ); + let http_cancel_error = trail::server::handle_http_request( + &mut db, + &api_request( + "POST", + "/v1/lanes/surface/exec/cancel", + serde_json::json!({}), + ), + ); + assert_eq!(http_cancel_error.status, 400); + assert_eq!( + http_cancel_error.body_json::().unwrap()["error"]["code"], + "INVALID_INPUT" + ); let http_sync_error = trail::server::handle_http_request( &mut db, @@ -11978,6 +11991,17 @@ fn layered_workspace_reports_have_http_mcp_and_openapi_parity() { mcp_update["result"]["structuredContent"]["source_ref"], "refs/branches/main" ); + let mcp_cancel_error = mcp_call( + &mut db, + 32, + "trail.lane_exec_cancel", + serde_json::json!({"lane": "surface"}), + ); + assert_eq!(mcp_cancel_error["result"]["isError"], true); + assert_eq!( + mcp_cancel_error["result"]["structuredContent"]["error"]["code"], + "INVALID_INPUT" + ); let http_adapters = trail::server::handle_http_request( &mut db, @@ -12044,6 +12068,7 @@ fn layered_workspace_reports_have_http_mcp_and_openapi_parity() { ("trail.lane_checkpoint", false, false, false), ("trail.lane_update", false, false, false), ("trail.lane_exec", false, false, true), + ("trail.lane_exec_cancel", false, false, true), ("trail.deps_sync", false, false, true), ("trail.env_adapters", true, false, false), ("trail.env_status", true, false, false), @@ -12078,6 +12103,7 @@ fn layered_workspace_reports_have_http_mcp_and_openapi_parity() { "/v1/lanes/{lane_or_id}/update", "/v1/lanes/{lane_or_id}/space", "/v1/lanes/{lane_or_id}/exec", + "/v1/lanes/{lane_or_id}/exec/cancel", "/v1/lanes/{lane_or_id}/dependencies", "/v1/lanes/{lane_or_id}/dependencies/sync", "/v1/lanes/{lane_or_id}/environment", diff --git a/trail/tests/fixtures/changed_path_raw_mutations.v1 b/trail/tests/fixtures/changed_path_raw_mutations.v1 index 6bc7244b..1edbf241 100644 --- a/trail/tests/fixtures/changed_path_raw_mutations.v1 +++ b/trail/tests/fixtures/changed_path_raw_mutations.v1 @@ -338,6 +338,34 @@ reviewed|db/lane/workspace_plugin.rs|install_environment_adapter_plugin|fs::crea reviewed|db/lane/workspace_plugin.rs|install_environment_adapter_plugin|fs::remove_dir_all|2 reviewed|db/lane/workspace_plugin.rs|install_environment_adapter_plugin|fs::rename|2 reviewed|db/lane/workspace_plugin.rs|install_environment_adapter_plugin|fs::set_permissions|1 +reviewed|db/lane/workspace_runtime_toolchain.rs|download_artifact|File::create|1 +reviewed|db/lane/workspace_runtime_toolchain.rs|ensure_private_directory|fs::create_dir_all|1 +reviewed|db/lane/workspace_runtime_toolchain.rs|ensure_private_directory|fs::set_permissions|1 +reviewed|db/lane/workspace_runtime_toolchain.rs|install_into|fs::create_dir|2 +reviewed|db/lane/workspace_runtime_toolchain.rs|install_into|fs::remove_file|2 +reviewed|db/lane/workspace_runtime_toolchain.rs|install_into|fs::rename|1 +reviewed|db/lane/workspace_runtime_toolchain.rs|provision_managed_toolchain_with|fs::create_dir|1 +reviewed|db/lane/workspace_runtime_toolchain.rs|provision_managed_toolchain_with|fs::create_dir_all|1 +reviewed|db/lane/workspace_runtime_toolchain.rs|provision_managed_toolchain_with|fs::remove_dir_all|3 +reviewed|db/lane/workspace_runtime_toolchain.rs|provision_managed_toolchain_with|fs::rename|1 +reviewed|db/lane/workspace_runtime_toolchain.rs|set_executable|fs::set_permissions|1 +reviewed|db/lane/workspace_runtime_toolchain.rs|unpack_tar_gz|File::create|1 +reviewed|db/lane/workspace_guest_execution.rs|apply_candidate_source|fs::create_dir_all|1 +reviewed|db/lane/workspace_guest_execution.rs|apply_candidate_source|fs::remove_file|2 +reviewed|db/lane/workspace_guest_execution.rs|build_projection|File::create|1 +reviewed|db/lane/workspace_guest_execution.rs|cancel_lane_workspace_execution|fs::remove_file|1 +reviewed|db/lane/workspace_guest_execution.rs|ensure_private_staging_root|fs::create_dir_all|1 +reviewed|db/lane/workspace_guest_execution.rs|recover_guest_execution_manifests|fs::remove_file|2 +reviewed|db/lane/workspace_guest_execution.rs|run_colima_lane_command_inner|File::create|1 +reviewed|db/lane/workspace_guest_execution.rs|run_colima_lane_command_inner|fs::create_dir|1 +reviewed|db/lane/workspace_guest_execution.rs|run_colima_lane_command_inner|fs::remove_file|1 +reviewed|db/lane/workspace_guest_execution.rs|set_portable_mode|fs::set_permissions|1 +reviewed|db/lane/workspace_guest_execution.rs|validate_and_extract_candidate|OpenOptions::create_new|1 +reviewed|db/lane/workspace_guest_execution.rs|validate_and_extract_candidate|OpenOptions::write|1 +reviewed|db/lane/workspace_guest_execution.rs|validate_and_extract_candidate|fs::create_dir_all|2 +reviewed|db/lane/workspace_runtime_toolchain.rs|unpack_tar_gz|fs::create_dir_all|2 +reviewed|db/lane/workspace_runtime_toolchain.rs|write_new_file|OpenOptions::create_new|1 +reviewed|db/lane/workspace_runtime_toolchain.rs|write_new_file|OpenOptions::write|1 reviewed|db/lane/workspace_plugin.rs|install_environment_adapter_plugin|fs::write|1 reviewed|db/lane/workspace_plugin.rs|invoke_environment_plugin|fs::copy|1 reviewed|db/lane/workspace_plugin.rs|invoke_environment_plugin|fs::create_dir|2 diff --git a/trail/tests/managed_execution.rs b/trail/tests/managed_execution.rs index c7e95e1f..8069bbbe 100644 --- a/trail/tests/managed_execution.rs +++ b/trail/tests/managed_execution.rs @@ -24,9 +24,17 @@ fn lane_exec_runs_the_ordered_managed_lifecycle_and_checkpoints_only_source() { false, ) .unwrap(); + let turn = db + .begin_lane_turn( + "managed", + None, + Some("managed execution provenance".to_string()), + None, + ) + .unwrap(); let report = db - .exec_lane_workspace( + .exec_lane_workspace_for_turn( "managed", &[ "/bin/sh".into(), @@ -34,11 +42,22 @@ fn lane_exec_runs_the_ordered_managed_lifecycle_and_checkpoints_only_source() { "printf durable > source.txt; mkdir -p target; printf disposable > target/build.bin; exit 7" .into(), ], + Some(&turn.turn.turn_id), ) .unwrap(); assert_eq!(report.exit_code, 7); assert_eq!(report.lifecycle.surface, "lane_exec"); + assert_eq!(report.lifecycle.turn_id, Some(turn.turn.turn_id.clone())); + assert_eq!( + report.lifecycle.session_id, + Some(turn.session.session_id.clone()) + ); + assert!(report + .lifecycle + .trace_id + .as_deref() + .is_some_and(|trace_id| trace_id.starts_with("trace_"))); let checkpoint = report .lifecycle .checkpoint @@ -205,6 +224,44 @@ fn root_managed_preparation_ignores_unrelated_nested_environment_components() { assert!(context.environment_generation.is_none()); } +#[test] +fn lane_exec_rejects_cross_lane_turn_before_command_launch() { + let root = tempfile::tempdir().unwrap(); + fs::write(root.path().join("README.md"), "root\n").unwrap(); + Trail::init(root.path(), "main", InitImportMode::WorkingTree, false).unwrap(); + let mut db = Trail::open(root.path()).unwrap(); + for lane in ["first", "second"] { + db.spawn_lane_with_workdir_mode_paths_and_neighbors( + lane, + Some("main"), + LaneWorkdirMode::PortableCopy, + None, + None, + None, + &[], + false, + ) + .unwrap(); + } + let turn = db + .begin_lane_turn("second", None, Some("second lane".to_string()), None) + .unwrap(); + let marker = root.path().join("must-not-run"); + let error = db + .exec_lane_workspace_for_turn( + "first", + &[ + "/bin/sh".to_string(), + "-c".to_string(), + format!("touch {}", marker.display()), + ], + Some(&turn.turn.turn_id), + ) + .unwrap_err(); + assert!(error.to_string().contains("does not belong to lane")); + assert!(!marker.exists()); +} + #[test] fn missing_gate_program_still_finalizes_checkpoint_disposal_and_unmount() { let root = tempfile::tempdir().unwrap();