From 1cc139d56ed5e91b1840047bca41ea3f0ac75e5e Mon Sep 17 00:00:00 2001 From: forhappy Date: Wed, 12 Aug 2026 04:09:56 -0700 Subject: [PATCH] Certify framework handoffs and agent containment --- .github/workflows/ci.yml | 1 + .github/workflows/layered-workspaces.yml | 2 + CHANGELOG.md | 58 ++- README.md | 4 +- docs/design/environment-adapter-contract.md | 61 ++- docs/design/layered-lane-workspaces.md | 4 +- docs/design/universal-lane-environments.md | 10 +- .../performance-and-scale-benchmarks.md | 25 +- docs/lanes/large-repository-environments.md | 18 +- .../cli/integrations-and-maintenance.md | 52 +- scripts/check-real-framework-handoff.py | 93 +++- scripts/edit-real-framework-semantic.py | 201 ++++++++ scripts/test_check_real_framework_handoff.py | 53 +- scripts/test_edit_real_framework_semantic.py | 98 ++++ scripts/verify-real-framework-handoff.sh | 148 +++++- trail/src/cli/command/handler/agent.rs | 470 +++++++++++++++++- trail/src/db/change_ledger/activation.rs | 6 +- trail/src/db/lane/managed_execution.rs | 94 ++-- trail/src/db/lane/workdir/record.rs | 70 ++- trail/src/db/lane/workdir/view_layout.rs | 6 +- trail/src/db/lane/workspace_environment.rs | 197 +++++++- trail/src/db/lane/workspace_git.rs | 56 ++- trail/src/db/lane/workspace_node.rs | 27 +- trail/src/db/lane/workspace_python.rs | 429 +++++++++++++--- trail/src/db/lane/workspace_view.rs | 29 +- trail/src/db/storage/root_diff.rs | 162 ++---- trail/src/model/lane/activity.rs | 17 + trail/tests/changed_path_ledger_activation.rs | 4 +- trail/tests/e2e.rs | 176 ++++++- .../fixtures/changed_path_raw_mutations.v1 | 7 +- trail/tests/managed_execution.rs | 12 +- 31 files changed, 2162 insertions(+), 428 deletions(-) create mode 100644 scripts/edit-real-framework-semantic.py create mode 100644 scripts/test_edit_real_framework_semantic.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b90642da..e796118c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,7 @@ jobs: python3 -m unittest scripts/test_verify_real_repo_lane_scale.py -v python3 -m unittest scripts/test_check_real_repo_lane_scale.py -v python3 -m unittest scripts/test_check_real_framework_handoff.py -v + python3 -m unittest scripts/test_edit_real_framework_semantic.py -v - name: Check real-repository harness syntax run: bash -n scripts/verify-real-repo-lane-scale.sh diff --git a/.github/workflows/layered-workspaces.yml b/.github/workflows/layered-workspaces.yml index f8b60704..6b626d1a 100644 --- a/.github/workflows/layered-workspaces.yml +++ b/.github/workflows/layered-workspaces.yml @@ -16,6 +16,8 @@ on: - "scripts/verify-real-framework-handoff.sh" - "scripts/check-real-framework-handoff.py" - "scripts/test_check_real_framework_handoff.py" + - "scripts/edit-real-framework-semantic.py" + - "scripts/test_edit_real_framework_semantic.py" - "scripts/verify-environment-adapter-plugin.sh" - "scripts/verify-artifact-adapter-conformance.sh" - "scripts/verify-artifact-real-tool-gates.sh" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fa2c1fe..1a212861 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,16 @@ All notable changes to Trail are documented in this file. Trail follows Cargo/npm defaults globally. Go, pnpm/npm/Yarn/Bun, Python, and CMake commands receive isolated framework-native caches and exact tool paths, while inactive frameworks no longer leak variables into the command. -- Node dependency executables and CMake build trees now bind directly from the +- Managed execution now rejects environment-bearing materialized lanes before + emitting an impossible resolution command and recommends a new + `--workdir-mode auto` lane. Layered workspace backends remain required for + managed dependency and build projections. +- Node dependency executables, Python virtual environments, and CMake build trees now bind directly from the lane's generated upper, avoiding metadata-heavy build/dependency traversal through macOS NFS while preserving a mounted source path and lane-private - mutation. Python also exposes its path-correct interpreter through - `TRAIL_VENV_PYTHON`. + mutation. Python exposes the physical private environment through + `VIRTUAL_ENV`, `PATH`, and `TRAIL_VENV_PYTHON` while `.venv` remains visible + at its conventional lane path. - macOS NFS lane mounts now retain attributes and negative lookups for up to 60 seconds within a mounted execution. Same-client mutations still invalidate cached entries and synchronous writes remain enabled, while unchanged Go and @@ -24,10 +29,37 @@ All notable changes to Trail are documented in this file. Trail follows - Lane/root diff addition and deletion totals now come from the emitted text diff rather than stable-line identity churn, so statistics agree with the unified patch while line-identity inspection remains available separately. -- Built-in Claude terminal tasks now start with project instructions, plugins, - hooks, MCP servers, skills, and agents disabled. The new +- Built-in Claude and Codex terminal tasks now use contained launch profiles. + Claude disables project instructions, plugins, hooks, MCP servers, skills, + browser integration, and agents; Codex uses strict configuration, an explicit + lane root, workspace-write sandboxing, and an empty MCP map. Both receive an + isolated runtime home, an allowlisted environment, a lane Git shadow, and a + typed containment receipt. On macOS, `sandbox-exec` enforces declared writable + roots and protects the original checkout. The new `--allow-project-integrations` flag restores the previous project-integrated - launch explicitly; custom commands after `--` remain unchanged. + launch explicitly; custom commands after `--` remain unchanged. Contained + Claude launches now preserve its documented OAuth token, OAuth-token file + descriptor, API-key, and workload-identity variables without copying or + discovering host keychain secrets. Claude's internal temporary directory is + redirected into Trail's private launch runtime, and `acceptEdits` permits + noninteractive built-in edits without disabling Bash or other higher-risk + permission checks. An exact allowlist imports Claude credential and provider + endpoint variables from user-level settings without importing hooks, + plugins, permissions, or other project/global integrations; explicit process + variables take precedence. +- Python `uv.lock` environments now select a checked `.python-version` + major/minor interpreter, install the frozen dependency set into a copy-based + lane-private venv, and report bounded redacted initializer diagnostics. + Hash-pinned `requirements.lock` and verified Trail-managed lock snapshots use + hash-required `uv pip sync`; unfrozen requirements and unsupported lock + formats fail with recovery guidance instead of producing an empty venv. +- Transparent-COW checkpoints now apply `.trailignore` to newly created journal + candidates before source recording, and classify `dist-node` as generated + output. Ignored or conventional build artifacts no longer enter source merely + because they were first observed by the native change journal. +- pnpm workspace roots now fail explicitly instead of attempting an incomplete + `--ignore-workspace` install. Independent non-workspace pnpm projects retain + frozen dependency-layer reuse. - Managed execution now scopes environment discovery and synchronization to the command's component root, projects a verified manifest-only Cargo lock snapshot only for the command, and removes it before checkpointing. Unrelated @@ -65,9 +97,12 @@ All notable changes to Trail are documented in this file. Trail follows ### Added - Added an opt-in macOS real-framework qualification matrix for pinned bbolt, - date-fns, uuid, httpx, and LevelDB revisions. Each row produces checksummed - Agent A → B → C evidence for source-only handoff, parent-generation - inheritance, framework checks, cache/layer reuse, and lane-private outputs. + Polymarket CLOB TypeScript client, uuid, tappy, and LevelDB revisions. Each row + makes deterministic production-and-test source edits and produces checksummed + Agent A → B → C evidence for semantic ancestry, parent-generation + inheritance, real framework tests/builds, stable dependency identity, + cache/layer reuse, fresh lane-private outputs, incremental CMake recompilation, + stale-output rejection, and exact checkpoint paths. - Added deterministic 10k/100k/1M artifact and source scale matrices for 1/5/20 lanes, fail-closed JSON evidence validation, and compositional owning-host NFS/FUSE/Dokan qualification that distinguishes mounted backend @@ -113,8 +148,9 @@ All notable changes to Trail are documented in this file. Trail follows declarations remain external metadata, and repository v2 keeps its independent desired-key v2 identity. - Python environments can now bind an optional uv-generated, hash-bearing requirements - snapshot, warm a performance-only wheel/download cache, and still keep `.venv`, its - bytecode, and embedded path state entirely lane-private. + snapshot, warm a performance-only wheel/download cache, install that snapshot with + hash enforcement, and still keep `.venv`, its bytecode, and embedded path state + entirely lane-private. - `trail.environment/v2` framework fixtures now compose Next.js and Vite build/state components over the Node dependency component: `.next` and `.vite` remain lane-private, while validated Vite `dist` content can use an independently keyed immutable layer. diff --git a/README.md b/README.md index 77d890f5..1e9fba16 100644 --- a/README.md +++ b/README.md @@ -203,8 +203,8 @@ target, platform, or build-policy changes remain hard compatibility misses. Managed command bindings are adapter-owned rather than Cargo-specific. Active Go components receive shared module/build caches and an exact `TRAIL_GO`; Node components receive package-manager caches plus a direct private -`TRAIL_NODE_MODULES`; Python components receive a lane-private `VIRTUAL_ENV` -and `TRAIL_VENV_PYTHON`; and CMake components receive a direct lane-private +`TRAIL_NODE_MODULES`; Python components receive a direct lane-private +`VIRTUAL_ENV`, venv `PATH`, and `TRAIL_VENV_PYTHON`; and CMake components receive a direct lane-private `TRAIL_CMAKE_BUILD_DIR`. Inactive frameworks inject no cache, tool, or output variables into the command. diff --git a/docs/design/environment-adapter-contract.md b/docs/design/environment-adapter-contract.md index 5ff1e226..37a181a0 100644 --- a/docs/design/environment-adapter-contract.md +++ b/docs/design/environment-adapter-contract.md @@ -24,14 +24,15 @@ generation activation. The first built-ins are: lane so its source path names the stable lane workdir rather than disposable staging; the writable build directory is bound directly from the generated upper so configure, compilation, and tests do not route build-tree metadata through NFS/FUSE/Dokan; -- `trail/python-venv@1`: recognizes `pyproject.toml` and the common uv, Poetry, PDM, - Pipenv, and requirements lock/manifest files, provisions a layer-free lane-private - `.venv`, and keys it by every present dependency file plus the resolved Python - executable. An optional uv-generated hash-bearing requirements snapshot remains Trail - metadata and warms a shared performance-only wheel/download cache. Trail automatically - creates the virtual environment through an ephemeral candidate view at the lane's final - mountpoint, so scripts, bytecode, and prefix metadata stay private and embed the correct - absolute path without exposing partial state; +- `trail/python-venv@1`: recognizes `pyproject.toml`, `.python-version`, and common + lock/manifest markers, but installs only from `uv.lock`, hash-pinned + `requirements.lock`, or a verified Trail-managed hashed snapshot. Other recognized + formats return explicit unsupported-contract guidance. It provisions a layer-free + lane-private `.venv`, keys it by every present dependency file plus the selected + Python and uv executable identities, and warms a shared performance-only download + cache. Trail creates and installs the environment in an ephemeral candidate's + physical private upper, then exposes direct bindings so scripts, bytecode, and prefix + metadata stay private without routing venv I/O through the mounted source transport; - `trail/oci-image@1`: reads `trail.oci.toml`, accepts only lowercase SHA-256 digest-pinned OCI references with an explicit platform, and records provider-owned image identities without commands, caches, mounts, or manufactured directories; @@ -1306,26 +1307,37 @@ delete a user-owned image, container, volume, or remote builder cache. The experimental `trail/python-venv@1` built-in implements the safe baseline today. It owns `/.venv` as `writable_private`, publishes no shared layer, and preserves -the directory across a compatible re-sync. Synchronization automatically runs -`python -m venv --without-pip .venv` at the lane's final mountpoint: +the directory across a compatible re-sync. If `.python-version` is present, Trail +selects its CPython major/minor executable from `PATH`; otherwise it resolves +`python3`/`python`. Synchronization creates a copy-based virtual environment in the +physical lane-private generated upper and installs only from a frozen contract: ```sh -trail env sync component python.venv --lane --adapter trail/python-venv@1 -# Optionally let a lockfile-aware tool populate the initialized private path: -trail lane exec -- uv sync --frozen +trail env sync component python-venv --lane --adapter trail/python-venv@1 +trail lane exec -- "$TRAIL_VENV_PYTHON" -m pytest ``` -Mounted initialization never runs against the active lane upper. Trail constructs an +The supported install contracts are `uv.lock`, a hash-pinned +`requirements.lock`, or a verified Trail-managed hash-bearing requirements snapshot. +`uv.lock` uses `uv sync --frozen --no-install-project`; requirements snapshots use +`uv pip sync --require-hashes`. A plain `requirements.txt`, Poetry/PDM/Pipenv lock, or +unhashed requirements file is rejected until an adapter can implement its exact frozen +installation semantics. + +Initialization never runs against the active lane upper. Trail constructs an ephemeral candidate view with the pinned source root, desired immutable bindings, and -prepared private seeds; source and unrelated writes land in disposable uppers. After -the command succeeds, Trail rejects every write outside the newly prepared private -outputs, copies those outputs back to staging, and performs the ordinary atomic -generation activation. A non-zero exit, undeclared write, process kill, or host crash -therefore leaves the predecessor generation and its private upper unchanged. Recovery -also removes abandoned candidate directories. +prepared private seeds. Host-expanded output references direct the interpreter and uv +to the candidate's physical private upper, avoiding metadata-heavy venv I/O through +FUSE/NFS/Dokan while source reads retain lane semantics. Source and unrelated writes +land in disposable uppers. After the command succeeds, Trail rejects every write +outside the newly prepared private outputs, copies those outputs back to staging, and +performs the ordinary atomic generation activation. A non-zero exit, undeclared write, +process kill, or host crash therefore leaves the predecessor generation and its private +upper unchanged. Recovery also removes abandoned candidate directories. Initializer +stderr is drained, bounded to 64 KiB, secret-redacted, and included on failure. Real Linux/FUSE, macOS/NFS, and Windows/Dokan conformance creates two virtual -environments, verifies that `sys.prefix` identifies each mounted lane, mutates one +environments, verifies that `sys.prefix` identifies each direct private upper, mutates one environment, and requires the other lane to remain unchanged. It also verifies that a compatible re-sync preserves the private environment and creates no shared layer. Additional native fixtures cover multi-component `env sync all`, initializer failure, @@ -1335,9 +1347,10 @@ An optional resolver plan pins the complete source root, exact uv executable, of hash-generation policy, and `pyproject.toml`, then stores the resulting `requirements.lock` as verified environment metadata. The host projects it only into attempt staging and runs hash-required `pip download` into a host-owned -`cache_shared_content` namespace. Evicting that cache affects performance only. Trail -still must not represent a path-bearing virtual environment, its bytecode, or embedded -scripts as a portable immutable artifact without relocation validation. +`cache_shared_content` namespace before the mounted initializer performs an offline, +hash-required `uv pip sync`. Evicting that cache affects performance only. Trail still +must not represent a path-bearing virtual environment, its bytecode, or embedded scripts +as a portable immutable artifact without relocation validation. | Concern | Inputs | Policy/binding | | --- | --- | --- | diff --git a/docs/design/layered-lane-workspaces.md b/docs/design/layered-lane-workspaces.md index dcb0865b..33240851 100644 --- a/docs/design/layered-lane-workspaces.md +++ b/docs/design/layered-lane-workspaces.md @@ -744,8 +744,8 @@ The workspace host does not inject Cargo or npm variables unconditionally. Each active built-in adapter declares its fixed policy values, resolved tools, cache namespace paths, and output paths. Go receives `GOMODCACHE`, `GOCACHE`, and `TRAIL_GO`; Node receives manager caches and a direct lane-private -`TRAIL_NODE_MODULES`; Python receives its mounted `VIRTUAL_ENV` and -`TRAIL_VENV_PYTHON`; CMake receives a direct writable-private +`TRAIL_NODE_MODULES`; Python receives a direct lane-private `VIRTUAL_ENV`, +`TRAIL_VENV_PYTHON`, and venv `PATH`; CMake receives a direct writable-private `TRAIL_CMAKE_BUILD_DIR`. Cargo retains direct private `CARGO_TARGET_DIR` and managed `CARGO_HOME`/compiler-cache bindings. Output bindings may bypass the mounted transport only for private-seeded, writable-private, or disposable diff --git a/docs/design/universal-lane-environments.md b/docs/design/universal-lane-environments.md index f1518d20..a723fa5d 100644 --- a/docs/design/universal-lane-environments.md +++ b/docs/design/universal-lane-environments.md @@ -1001,10 +1001,12 @@ backend: - `trail/cmake-build@1` provisions a layer-free private build tree and defers configure to the mounted lane so absolute cache paths remain valid; - `trail/python-venv@1` provisions a layer-free private `.venv`, keys it by dependency - manifests/locks and interpreter identity, optionally binds a hash-bearing managed - requirements snapshot to a performance-only wheel/download cache, and automatically - initializes it through an ephemeral candidate view at the final mountpoint so embedded - prefixes and bytecode stay lane-private without weakening atomic generation activation; + manifests/locks and interpreter identity, accepts `uv.lock`, hash-pinned + `requirements.lock`, or a verified managed requirements snapshot, binds downloads to + a performance-only cache, and automatically initializes the venv in the candidate's + physical private upper. Managed commands use direct `VIRTUAL_ENV`, `PATH`, and + `TRAIL_VENV_PYTHON` bindings so embedded prefixes and metadata-heavy package/test I/O + stay lane-private without traversing NFS or weakening atomic generation activation; - existing layer references are imported into an initial environment generation without copying tree content. diff --git a/docs/guides/performance-and-scale-benchmarks.md b/docs/guides/performance-and-scale-benchmarks.md index 14cafef7..fe24ac5f 100644 --- a/docs/guides/performance-and-scale-benchmarks.md +++ b/docs/guides/performance-and-scale-benchmarks.md @@ -122,9 +122,10 @@ scripts/cli-scale-bench.sh Framework adapters have a separate opt-in macOS qualification matrix. Each row fetches an exact upstream revision, uses the candidate Trail binary, and moves -three native NFS lanes through a source edit, environment synchronization, and -real framework check. The gate covers bbolt/Go, date-fns/pnpm, uuid/npm, -httpx/Python virtual environments, and LevelDB/CMake: +three transparent-COW lanes through deterministic production-and-test edits, +environment synchronization, and real framework checks. The pinned matrix uses +bbolt/Go, Polymarket's CLOB TypeScript client/pnpm, uuid/npm, tappy/Python, and +LevelDB/CMake: ```sh TRAIL_BIN=/absolute/path/to/trail \ @@ -136,14 +137,20 @@ scripts/verify-real-framework-handoff.sh go Valid selectors are `go`, `pnpm`, `npm`, `python`, and `cmake`. The output and optional work directories must not exist. When omitted, the work directory defaults to `.work` and remains outside the uploaded evidence set. +macOS defaults to `nfs-cow`; prepared hosts can set +`TRAIL_FRAMEWORK_WORKDIR_MODE=fuse-cow` or `dokan-cow` to qualify another +transparent backend. `evidence.json` pins the repository/revision, three distinct source roots, active component keys, layer identities, cache -namespaces, lane-private output identities, and SHA-256 digests of every raw -Trail report. The checker requires each edit to checkpoint only `README.md`, -each child to inherit its parent's active generation before editing, and every -framework command to pass. Go additionally requires compatible-predecessor -seeding with nonzero avoided bytes; npm/pnpm require one exact immutable layer; -Python and CMake require three distinct writable-private outputs. +namespaces, lane-private output contracts, and SHA-256 digests of every raw +Trail report. The checker requires each edit to checkpoint only the expected +framework production and test paths, B to start from A's semantic checkpoint, +C to start from B's, and parent and edited semantics to pass. Go additionally +requires compatible-predecessor seeding with nonzero avoided bytes; npm/pnpm +require one exact immutable dependency layer; Python/CMake require fresh +lane-private outputs with stable dependency/build identity. The CMake row also +hashes affected and unaffected objects, requires selective recompilation, and +links/runs a marker executable so stale output cannot pass. Dispatch `layered-workspaces.yml` with `run_real_framework_handoffs=true` to run all five rows on clean macOS hosts diff --git a/docs/lanes/large-repository-environments.md b/docs/lanes/large-repository-environments.md index 24d98f94..07807a00 100644 --- a/docs/lanes/large-repository-environments.md +++ b/docs/lanes/large-repository-environments.md @@ -99,9 +99,9 @@ The other built-ins use the same lane handoff: trail env sync all agent-a trail lane exec agent-a -- sh -c '"$TRAIL_GO" test ./...' -# Python: a lane-private, path-correct virtual environment. +# Python: a frozen, lane-private virtual environment bound outside mounted I/O. trail env sync all agent-a -trail lane exec agent-a -- sh -c '"$TRAIL_VENV_PYTHON" -m compileall -q .' +trail lane exec agent-a -- sh -c '"$TRAIL_VENV_PYTHON" -m pytest -q tests/test_line.py' # CMake: configure and build outside the NFS/FUSE/Dokan transport. trail env sync all agent-a @@ -112,12 +112,22 @@ trail lane exec agent-a -- sh -c ' ``` Go binds `GOMODCACHE`, `GOCACHE`, `TRAIL_GO`, and a local-toolchain/offline -vendor policy. Python binds `PIP_CACHE_DIR`, `UV_CACHE_DIR`, `VIRTUAL_ENV`, -`TRAIL_VENV_PYTHON`, and the venv executable directory. CMake binds the exact +vendor policy. Python accepts `uv.lock`, hash-pinned `requirements.lock`, or a +verified Trail-managed hashed snapshot; binds `PIP_CACHE_DIR`, `UV_CACHE_DIR`, +`VIRTUAL_ENV`, `TRAIL_VENV_PYTHON`, and the direct private venv executable +directory; and rejects unfrozen install inputs. CMake binds the exact host executable as `TRAIL_CMAKE` and a lane-private direct build path as `TRAIL_CMAKE_BUILD_DIR`. A login shell may replace `PATH`; use the absolute `TRAIL_*` executable variables in automated lane commands. +For release evidence, `scripts/verify-real-framework-handoff.sh` runs pinned +bbolt, Polymarket CLOB client, uuid, tappy, or LevelDB through Agent A → B → C. +The checker requires each child to start at its parent's semantic checkpoint, +real typecheck/test/build behavior to pass before and after each deterministic +production/test edit, stable dependency identities for source-only changes, +fresh private Python/CMake outputs, exact source checkpoint paths, and CMake +object/link evidence that rejects a stale build. + The framework-neutral TOML above is executable as `trail.environment.toml`. Create its declared input, record it, and run: diff --git a/docs/reference/cli/integrations-and-maintenance.md b/docs/reference/cli/integrations-and-maintenance.md index c4fc3e63..edce1154 100644 --- a/docs/reference/cli/integrations-and-maintenance.md +++ b/docs/reference/cli/integrations-and-maintenance.md @@ -44,10 +44,40 @@ trail agent start aider trail agent start opencode ``` -Built-in Claude launches use Claude's safe mode and strict MCP configuration by -default so repository hooks, plugins, instructions, skills, agents, and MCP -servers cannot redirect work outside the lane. Opt in only for integrations -that are independently path-contained: +Built-in Claude and Codex launches use contained profiles by default. Claude +uses safe mode, strict MCP configuration, no browser integration, an +`acceptEdits` permission mode, and a private provider temp root. Codex +uses strict configuration, an explicit lane `--cd`, workspace-write sandboxing, +and an empty MCP map. Trail clears the inherited environment, restores only an +allowlist plus provider credentials, gives the process an ephemeral HOME/XDG +tree, rewrites Trail workspace variables to the lane, and binds Git to a +lane-rooted shadow repository. Opt in only for project integrations that are +independently path-contained: + +Claude's normal interactive login may be stored in host-scoped secure storage +and is intentionally not copied into the ephemeral home. For unattended +contained launches, configure one of Claude's explicit credential channels in +your user-level `.claude/settings.json` or inject it through a secret manager: + +```sh +CLAUDE_CODE_OAUTH_TOKEN="$(your-secret-manager read claude-oauth-token)" \ + trail agent start claude-code +``` + +A long-lived token produced by `claude setup-token`, an Anthropic API key, or +Claude's documented file-descriptor channel are portable alternatives. Trail +preserves only the documented Claude credential variables after clearing the +environment. It also imports only those credential and provider endpoint +variables from the user-level `.claude/settings.json`; hooks, plugins, +permissions, and other settings are not copied. Trail never reads a provider +keychain or writes a token into the lane. Explicit process variables override +user settings. Avoid placing literal secrets in shell history—prefer an +environment manager or the file-descriptor channel for durable automation. + +`acceptEdits` permits Claude's built-in Edit tool inside the kernel-confined +writable roots. Bash and other higher-risk tools retain Claude's normal +permission checks; Trail does not use `--dangerously-skip-permissions` because +the profile does not claim read or network isolation. ```sh trail agent start claude-code --allow-project-integrations @@ -55,6 +85,15 @@ trail agent start claude-code --allow-project-integrations An explicit custom command after `--` is never rewritten by Trail. +On macOS, Trail additionally runs terminal agents under `sandbox-exec`: the +lane, private environment outputs/caches, Git shadow, and ephemeral runtime are +writable, while the original checkout is protected. Other hosts currently +report `environment_only` rather than claiming kernel filesystem enforcement. +The JSON `containment` receipt exposes the selected profile, integration and +environment policy, sandbox backend, enforcement level, lane/Git roots, +protected and writable roots, ambient-variable scrubbing, and whether original +checkout immutability was kernel-enforced. + The default `--workdir-mode auto` selects the host transparent COW backend when one is available, which lets environment-backed tasks use layered workspace views without copying the full tree. Use `--workdir-mode fuse-cow` when a large @@ -64,6 +103,11 @@ repository should be exposed as a mounted COW filesystem view explicitly: trail agent start codex --workdir-mode fuse-cow ``` +Materialized modes such as `native-cow` and `portable-copy` do not host managed +environment layers. Trail rejects them before suggesting environment resolution +and reports `--workdir-mode auto` as the recovery; retry the task in a new lane +because an existing lane's workspace backend is immutable. + For an unsupported terminal agent, pass the exact command after `--`: ```sh diff --git a/scripts/check-real-framework-handoff.py b/scripts/check-real-framework-handoff.py index 742f8c65..e189d683 100755 --- a/scripts/check-real-framework-handoff.py +++ b/scripts/check-real-framework-handoff.py @@ -11,6 +11,16 @@ LANES = ("agent-a", "agent-b", "agent-c") +SOURCE_PATHS = { + "go": ["version/version.go", "version/version_test.go"], + "pnpm": [ + "src/constants.ts", + "tests/http-helpers/index.test.ts", + ], + "npm": ["src/test/version.test.ts", "src/version.ts"], + "python": ["src/tap/line.py", "tests/test_line.py"], + "cmake": ["util/hash.cc", "util/hash.h"], +} def load_report(raw: Path, name: str) -> dict[str, Any]: @@ -45,12 +55,17 @@ def check_evidence( raise AssertionError(f"unsupported framework {framework!r}") raw = evidence_dir / "raw" generations = [load_report(raw, f"generation-{lane}") for lane in LANES] + before_generations = [ + load_report(raw, f"generation-before-edit-{lane}") for lane in LANES + ] syncs = [load_report(raw, f"sync-{lane}") for lane in LANES] spawns = [load_report(raw, f"spawn-{lane}") for lane in LANES] + prechecks = [load_report(raw, f"precheck-{lane}") for lane in LANES] edits = [load_report(raw, f"edit-{lane}") for lane in LANES] checks = [load_report(raw, f"check-{lane}") for lane in LANES] components = [select_component(generation, component_id) for generation in generations] workdirs = [spawn["workdir"] for spawn in spawns] + workdir_modes = [spawn.get("workdir_mode") for spawn in spawns] if not all(generation["state"] == "active" for generation in generations): raise AssertionError("all final generations must be active") @@ -58,12 +73,26 @@ def check_evidence( raise AssertionError("A, B, and C must have distinct source roots") if len(set(workdirs)) != 3 or any(not Path(workdir).is_absolute() for workdir in workdirs): raise AssertionError("A, B, and C must have distinct absolute lane workdirs") - if not all(check["exit_code"] == 0 for check in checks): - raise AssertionError("every framework check must exit successfully") + if len(set(workdir_modes)) != 1 or workdir_modes[0] not in { + "fuse-cow", + "nfs-cow", + "dokan-cow", + }: + raise AssertionError( + f"A, B, and C must use one transparent-COW backend: {workdir_modes!r}" + ) + if not all(report["exit_code"] == 0 for report in [*prechecks, *checks]): + raise AssertionError("every parent and edited framework check must exit successfully") + for lane, report in zip(LANES, [*prechecks], strict=True): + checkpoint = report["lifecycle"]["checkpoint"] + if checkpoint["source_paths"]: + raise AssertionError( + f"{lane} parent build checkpointed source paths: {checkpoint!r}" + ) generated_dirty_paths = [] for lane, edit in zip(LANES, edits, strict=True): checkpoint = edit["lifecycle"]["checkpoint"] - if checkpoint["source_paths"] != ["README.md"]: + if checkpoint["source_paths"] != SOURCE_PATHS[framework]: raise AssertionError(f"{lane} checkpointed unexpected source paths: {checkpoint!r}") generated_dirty = checkpoint["generated_dirty_paths"] if not isinstance(generated_dirty, int) or generated_dirty < 0: @@ -71,6 +100,23 @@ def check_evidence( f"{lane} reported invalid generated-path accounting: {checkpoint!r}" ) generated_dirty_paths.append(generated_dirty) + for lane, report in zip(LANES, checks, strict=True): + checkpoint = report["lifecycle"]["checkpoint"] + if checkpoint["source_paths"]: + raise AssertionError( + f"{lane} edited build checkpointed source paths: {checkpoint!r}" + ) + + for child_index, child in enumerate(LANES[1:], start=1): + parent_operation = edits[child_index - 1]["lifecycle"]["checkpoint"]["operation"] + if spawns[child_index]["base_change"] != parent_operation: + raise AssertionError( + f"{child} did not start from its parent semantic checkpoint" + ) + for lane, generation, edit in zip(LANES, generations, edits, strict=True): + checkpoint = edit["lifecycle"]["checkpoint"] + if generation["source_root"] != checkpoint["root_id"]: + raise AssertionError(f"{lane} final generation is not pinned to its edited source") cache_namespaces = [ {cache["name"]: cache["namespace_id"] for cache in item["caches"]} @@ -134,9 +180,7 @@ def check_evidence( if shared_outputs: if not isinstance(inheritance, dict) or inheritance.get("status") != "inherited": raise AssertionError(f"{child} did not report inherited outputs: {inheritance!r}") - inherited = select_component( - load_report(raw, f"generation-before-edit-{child}"), component_id - ) + inherited = select_component(before_generations[child_index], component_id) parent = select_component(parent_generation, component_id) if inherited["component_key"] != parent["component_key"]: raise AssertionError(f"{child} did not inherit its parent component key") @@ -167,6 +211,21 @@ def check_evidence( raise AssertionError( f"{child} did not require a fresh lane-private output: {decisions!r}" ) + before = select_component(before_generations[child_index], component_id) + parent = select_component(parent_generation, component_id) + if before["component_key"] != parent["component_key"]: + raise AssertionError( + f"{child} private environment changed dependency identity before its edit" + ) + if before["caches"] != parent["caches"]: + raise AssertionError(f"{child} private environment lost parent caches") + + first_before = select_component(before_generations[0], component_id) + if framework == "go": + if first_before["component_key"] == components[0]["component_key"]: + raise AssertionError("Go source-sensitive vendor identity did not change after edit") + elif first_before["component_key"] != components[0]["component_key"]: + raise AssertionError("source-only edit changed dependency/build environment identity") raw_hashes = { path.name: hashlib.sha256(path.read_bytes()).hexdigest() @@ -175,18 +234,13 @@ def check_evidence( expected_names = { "init.json", *(f"spawn-{lane}.json" for lane in LANES), + *(f"precheck-{lane}.json" for lane in LANES), + *(f"generation-before-edit-{lane}.json" for lane in LANES), *(f"edit-{lane}.json" for lane in LANES), *(f"sync-{lane}.json" for lane in LANES), *(f"check-{lane}.json" for lane in LANES), *(f"generation-{lane}.json" for lane in LANES), } - if shared_outputs: - expected_names.update( - { - "generation-before-edit-agent-b.json", - "generation-before-edit-agent-c.json", - } - ) if set(raw_hashes) != expected_names: raise AssertionError( f"raw evidence set mismatch: missing={sorted(expected_names - set(raw_hashes))!r} " @@ -194,11 +248,11 @@ def check_evidence( ) return { - "schema": "trail.real-framework-handoff/v1", + "schema": "trail.real-framework-handoff/v2", "framework": framework, "repository": repository, "revision": revision, - "backend": "nfs-cow", + "backend": workdir_modes[0], "lanes": list(LANES), "workdirs": workdirs, "component_id": component_id, @@ -210,9 +264,16 @@ def check_evidence( "generated_dirty_paths": generated_dirty_paths, "assertions": { "three_distinct_source_roots": True, + "each_child_spawned_from_parent_semantic_checkpoint": True, "shared_parent_generation_inherited_before_each_child_edit": shared_outputs, "private_outputs_reprovisioned_per_child_lane": not shared_outputs, - "exactly_one_readme_source_path_per_edit": True, + "exact_framework_source_and_test_paths_per_edit": True, + "parent_semantics_valid_before_each_edit": True, + "edited_semantics_valid_after_each_edit": True, + "dependency_identity_stable_for_source_independent_adapters": framework != "go", + "go_vendor_identity_tracks_source_sensitive_vendor_inputs": framework == "go", + "cmake_incremental_recompile_and_link_behavior_verified": framework == "cmake", + "stale_framework_output_rejected_by_lane_marker": True, "generated_paths_excluded_from_source_checkpoint": True, "all_framework_checks_passed": True, "framework_reuse_contract_passed": True, diff --git a/scripts/edit-real-framework-semantic.py b/scripts/edit-real-framework-semantic.py new file mode 100644 index 00000000..dcb09860 --- /dev/null +++ b/scripts/edit-real-framework-semantic.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Apply or verify one deterministic semantic edit in a qualification lane.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + + +START = "TRAIL REAL FRAMEWORK QUALIFICATION START" +END = "TRAIL REAL FRAMEWORK QUALIFICATION END" +LANES = {"agent-a", "agent-b", "agent-c"} + + +def block(comment: str, body: str) -> str: + return f"{comment} {START}\n{body.rstrip()}\n{comment} {END}\n" + + +def replace_block(path: Path, expected: str | None, replacement: str) -> None: + text = path.read_text(encoding="utf-8") if path.exists() else "" + if text.count(START) > 1 or text.count(END) > 1: + raise AssertionError(f"{path} contains multiple qualification blocks") + start = text.find(START) + end = text.find(END) + if expected is None: + if start != -1 or end != -1: + raise AssertionError(f"{path} already contains a qualification block") + separator = "" if not text or text.endswith("\n\n") else "\n" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text + separator + replacement, encoding="utf-8") + return + if start == -1 or end == -1 or end < start: + raise AssertionError(f"{path} has no complete qualification block") + line_start = text.rfind("\n", 0, start) + 1 + line_end = text.find("\n", end) + line_end = len(text) if line_end == -1 else line_end + 1 + current = text[line_start:line_end] + if expected not in current: + raise AssertionError( + f"{path} qualification block does not contain expected marker {expected!r}" + ) + path.write_text(text[:line_start] + replacement + text[line_end:], encoding="utf-8") + + +def verify_block(path: Path, expected: str | None) -> None: + text = path.read_text(encoding="utf-8") if path.exists() else "" + start_count = text.count(START) + end_count = text.count(END) + if expected is None: + if start_count or end_count: + raise AssertionError(f"{path} unexpectedly contains a qualification block") + return + if start_count != 1 or end_count != 1: + raise AssertionError(f"{path} does not contain exactly one qualification block") + start = text.find(START) + end = text.find(END) + if end < start: + raise AssertionError(f"{path} qualification block is malformed") + line_start = text.rfind("\n", 0, start) + 1 + line_end = text.find("\n", end) + line_end = len(text) if line_end == -1 else line_end + 1 + if expected not in text[line_start:line_end]: + raise AssertionError(f"{path} does not contain qualification marker {expected!r}") + + +def contract(framework: str, marker: str) -> list[tuple[Path, str]]: + if framework == "go": + return [ + ( + Path("version/version.go"), + block("//", f'const TrailQualificationMarker = "{marker}"'), + ), + ( + Path("version/version_test.go"), + block( + "//", + "package version\n\n" + 'import "testing"\n\n' + "func TestTrailQualificationMarker(t *testing.T) {\n" + f'\tif TrailQualificationMarker != "{marker}" {{\n' + f'\t\tt.Fatalf("stale qualification marker: got %q, want {marker}", TrailQualificationMarker)\n' + "\t}\n" + "}", + ), + ), + ] + if framework == "pnpm": + return [ + ( + Path("src/constants.ts"), + block("//", f'export const trailQualificationMarker = "{marker}";'), + ), + ( + Path("tests/http-helpers/index.test.ts"), + block( + "//", + 'describe("Trail qualification marker", () => {\n' + ' it("executes the current lane source", async () => {\n' + ' const { trailQualificationMarker } = await import("../../src/constants");\n' + f' expect(trailQualificationMarker).toBe("{marker}");\n' + " });\n" + "});", + ), + ), + ] + if framework == "npm": + return [ + ( + Path("src/version.ts"), + block("//", f"export const trailQualificationMarker = '{marker}';"), + ), + ( + Path("src/test/version.test.ts"), + block( + "//", + "import { trailQualificationMarker } from '../version.js';\n\n" + "describe('Trail qualification marker', () => {\n" + " test('executes the current lane build', () => {\n" + f" assert.strictEqual(trailQualificationMarker, '{marker}');\n" + " });\n" + "});", + ), + ), + ] + if framework == "python": + return [ + ( + Path("src/tap/line.py"), + block("#", f'TRAIL_QUALIFICATION_MARKER = "{marker}"'), + ), + ( + Path("tests/test_line.py"), + block( + "#", + "class TestTrailQualificationMarker(unittest.TestCase):\n" + " def test_trail_qualification_marker(self):\n" + " from tap.line import TRAIL_QUALIFICATION_MARKER\n\n" + f' self.assertEqual(TRAIL_QUALIFICATION_MARKER, "{marker}")', + ), + ), + ] + if framework == "cmake": + return [ + ( + Path("util/hash.h"), + block( + "//", + "namespace leveldb {\n" + "const char* TrailQualificationMarker();\n" + "} // namespace leveldb\n" + f"// marker: {marker}", + ), + ), + ( + Path("util/hash.cc"), + block( + "//", + "namespace leveldb {\n" + "const char* TrailQualificationMarker() {\n" + f' return "{marker}";\n' + "}\n" + "} // namespace leveldb", + ), + ), + ] + raise AssertionError(f"unsupported framework {framework!r}") + + +def expected_previous(marker: str) -> str | None: + return { + "baseline": None, + "agent-a": "agent-a", + "agent-b": "agent-b", + "agent-c": "agent-c", + }[marker] + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("action", choices=("edit", "verify")) + parser.add_argument("framework", choices=("go", "pnpm", "npm", "python", "cmake")) + parser.add_argument("marker", choices=("baseline", *sorted(LANES))) + args = parser.parse_args() + + if args.action == "edit": + if args.marker not in LANES: + raise AssertionError("baseline cannot be applied as an edit") + previous = {"agent-a": None, "agent-b": "agent-a", "agent-c": "agent-b"}[ + args.marker + ] + for path, replacement in contract(args.framework, args.marker): + replace_block(path, previous, replacement) + else: + expected = expected_previous(args.marker) + for path, _ in contract(args.framework, args.marker if expected else "agent-a"): + verify_block(path, expected) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_check_real_framework_handoff.py b/scripts/test_check_real_framework_handoff.py index dfd7ed84..4fa8506d 100644 --- a/scripts/test_check_real_framework_handoff.py +++ b/scripts/test_check_real_framework_handoff.py @@ -72,8 +72,19 @@ def fixture(self, root, framework): encoding="utf-8" ) ) - if framework in {"go", "pnpm", "npm"}: - self.write_report(raw, f"generation-before-edit-{lane}", parent) + before = parent + if framework in {"python", "cmake"}: + before = json.loads(json.dumps(parent)) + before["components"][0]["outputs"][0]["storage_identity"] = ( + f"private_before_{index}" + ) + else: + before = json.loads(json.dumps(generation)) + before["source_root"] = "root-baseline" + if framework == "go": + before["components"][0]["component_key"] = "key-baseline" + before["components"][0]["layer_id"] = "layer-baseline" + self.write_report(raw, f"generation-before-edit-{lane}", before) decision = { "component_id": component_id, "decision_source": ( @@ -82,19 +93,46 @@ def fixture(self, root, framework): "bytes_avoided": 100 if framework == "go" else 0, } self.write_report(raw, f"sync-{lane}", {"decisions": [decision]}) + self.write_report( + raw, + f"precheck-{lane}", + { + "exit_code": 0, + "lifecycle": { + "checkpoint": { + "source_paths": [], + "generated_dirty_paths": index + 1, + } + }, + }, + ) self.write_report( raw, f"edit-{lane}", { "lifecycle": { "checkpoint": { - "source_paths": ["README.md"], + "operation": f"change-{index}", + "root_id": f"root-{index}", + "source_paths": CHECKER.SOURCE_PATHS[framework], "generated_dirty_paths": index + 1, } } }, ) - self.write_report(raw, f"check-{lane}", {"exit_code": 0}) + self.write_report( + raw, + f"check-{lane}", + { + "exit_code": 0, + "lifecycle": { + "checkpoint": { + "source_paths": [], + "generated_dirty_paths": index + 1, + } + }, + }, + ) inheritance = None if index: if framework in {"go", "pnpm", "npm"}: @@ -121,6 +159,8 @@ def fixture(self, root, framework): { "lane": lane, "workdir": f"/workspace/{lane}", + "workdir_mode": "nfs-cow", + "base_change": "main" if not index else f"change-{index - 1}", "environment_inheritance": inheritance, }, ) @@ -142,10 +182,9 @@ def test_accepts_each_framework_contract(self): self.assertEqual(evidence["framework"], framework) self.assertTrue(evidence["assertions"]["framework_reuse_contract_passed"]) self.assertEqual(evidence["generated_dirty_paths"], [1, 2, 3]) - expected_raw = 18 if framework in {"go", "pnpm", "npm"} else 16 - self.assertEqual(len(evidence["raw_sha256"]), expected_raw) + self.assertEqual(len(evidence["raw_sha256"]), 22) - def test_rejects_an_edit_that_captures_more_than_the_readme(self): + def test_rejects_an_edit_that_captures_an_unexpected_path(self): with tempfile.TemporaryDirectory() as temp: root = pathlib.Path(temp) component_id = self.fixture(root, "pnpm") diff --git a/scripts/test_edit_real_framework_semantic.py b/scripts/test_edit_real_framework_semantic.py new file mode 100644 index 00000000..fdd73a2a --- /dev/null +++ b/scripts/test_edit_real_framework_semantic.py @@ -0,0 +1,98 @@ +import importlib.util +import os +import pathlib +import tempfile +import unittest + + +SCRIPT = pathlib.Path(__file__).with_name("edit-real-framework-semantic.py") +SPEC = importlib.util.spec_from_file_location("real_framework_editor", SCRIPT) +assert SPEC and SPEC.loader +EDITOR = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(EDITOR) + + +BASE_FILES = { + "go": { + "version/version.go": "package version\n", + "version/version_test.go": "", + }, + "pnpm": { + "src/constants.ts": "export const value = 1;\n", + "tests/http-helpers/index.test.ts": "import { describe, expect, it } from 'vitest';\n", + }, + "npm": { + "src/version.ts": "export default function version() {}\n", + "src/test/version.test.ts": "import assert from 'node:assert';\n", + }, + "python": { + "src/tap/line.py": "class Line:\n pass\n", + "tests/test_line.py": "import unittest\n", + }, + "cmake": { + "util/hash.cc": "namespace leveldb {}\n", + "util/hash.h": "#pragma once\n", + }, +} + + +class RealFrameworkSemanticEditorTests(unittest.TestCase): + def fixture(self, root: pathlib.Path, framework: str) -> None: + for relative, contents in BASE_FILES[framework].items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents, encoding="utf-8") + + def in_root(self, root: pathlib.Path): + class WorkingDirectory: + def __enter__(self_inner): + self_inner.previous = pathlib.Path.cwd() + os.chdir(root) + + def __exit__(self_inner, *_): + os.chdir(self_inner.previous) + + return WorkingDirectory() + + def test_applies_cumulative_lane_markers_without_touching_other_paths(self): + for framework in BASE_FILES: + with self.subTest(framework=framework), tempfile.TemporaryDirectory() as temp: + root = pathlib.Path(temp) + self.fixture(root, framework) + before = sorted(path.relative_to(root) for path in root.rglob("*") if path.is_file()) + with self.in_root(root): + for path, _ in EDITOR.contract(framework, "agent-a"): + EDITOR.verify_block(path, None) + previous = None + for lane in ("agent-a", "agent-b", "agent-c"): + for path, replacement in EDITOR.contract(framework, lane): + EDITOR.replace_block(path, previous, replacement) + EDITOR.verify_block(path, lane) + previous = lane + after = sorted(path.relative_to(root) for path in root.rglob("*") if path.is_file()) + self.assertEqual(after, before) + + def test_rejects_skipping_a_parent_marker(self): + with tempfile.TemporaryDirectory() as temp: + root = pathlib.Path(temp) + self.fixture(root, "go") + with self.in_root(root): + with self.assertRaisesRegex(AssertionError, "no complete qualification block"): + for path, replacement in EDITOR.contract("go", "agent-b"): + EDITOR.replace_block(path, "agent-a", replacement) + + def test_rejects_duplicate_or_out_of_block_markers(self): + with tempfile.TemporaryDirectory() as temp: + root = pathlib.Path(temp) + self.fixture(root, "python") + with self.in_root(root): + path, replacement = EDITOR.contract("python", "agent-a")[0] + path.write_text( + f'# agent-a outside\n{replacement}{replacement}', encoding="utf-8" + ) + with self.assertRaisesRegex(AssertionError, "exactly one"): + EDITOR.verify_block(path, "agent-a") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-real-framework-handoff.sh b/scripts/verify-real-framework-handoff.sh index ab99233c..e7cb4e15 100755 --- a/scripts/verify-real-framework-handoff.sh +++ b/scripts/verify-real-framework-handoff.sh @@ -3,6 +3,7 @@ # handoff. This is an opt-in release evidence gate, not a networked unit test. set -euo pipefail SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +SEMANTIC_EDITOR=$SCRIPT_DIR/edit-real-framework-semantic.py die() { echo "verify-real-framework-handoff: $*" >&2 @@ -15,12 +16,21 @@ framework=$1 : "${TRAIL_FRAMEWORK_EVIDENCE_DIR:?set TRAIL_FRAMEWORK_EVIDENCE_DIR to a new output directory}" [[ $TRAIL_BIN == /* ]] || die "TRAIL_BIN must be absolute" [[ -x $TRAIL_BIN ]] || die "TRAIL_BIN is not executable: $TRAIL_BIN" +[[ -f $SEMANTIC_EDITOR ]] || die "semantic editor is missing: $SEMANTIC_EDITOR" [[ $TRAIL_FRAMEWORK_EVIDENCE_DIR == /* ]] || die "TRAIL_FRAMEWORK_EVIDENCE_DIR must be absolute" [[ ! -e $TRAIL_FRAMEWORK_EVIDENCE_DIR ]] || die "evidence directory already exists" qualification_root=${TRAIL_FRAMEWORK_WORK_ROOT:-$TRAIL_FRAMEWORK_EVIDENCE_DIR.work} [[ $qualification_root == /* ]] || die "TRAIL_FRAMEWORK_WORK_ROOT must be absolute when set" [[ ! -e $qualification_root ]] || die "qualification work directory already exists" +# NFS remains the installation-free macOS qualification backend. The explicit +# override keeps FUSE and Dokan independently qualifiable on prepared hosts. +workdir_mode=${TRAIL_FRAMEWORK_WORKDIR_MODE:-nfs-cow} +case "$workdir_mode" in + fuse-cow|nfs-cow|dokan-cow) ;; + *) die "TRAIL_FRAMEWORK_WORKDIR_MODE must be fuse-cow, nfs-cow, or dokan-cow" ;; +esac + case "$framework" in go) repository=https://github.com/etcd-io/bbolt.git @@ -29,8 +39,8 @@ case "$framework" in component_id=go-vendor ;; pnpm) - repository=https://github.com/date-fns/date-fns.git - revision=4098115cf705e3af7f663d8e5b0686e39a9f478a + repository=https://github.com/Polymarket/clob-client-v2.git + revision=f3e1a05f868a1fd0c34ef85dfc45c6ce78f5bb69 component_selector=node component_id=node ;; @@ -41,8 +51,8 @@ case "$framework" in component_id=node ;; python) - repository=https://github.com/encode/httpx.git - revision=b5addb64f0161ff6bfe94c124ef76f6a1fba5254 + repository=https://github.com/python-tap/tappy.git + revision=d050f1c52fcc51a145652aa57ed54856070abfdc component_selector=python component_id=python-venv ;; @@ -64,6 +74,7 @@ git -C "$repository_root" remote add origin "$repository" git -C "$repository_root" fetch -q --depth=1 origin "$revision" git -C "$repository_root" checkout -q --detach FETCH_HEAD [[ $(git -C "$repository_root" rev-parse HEAD) == "$revision" ]] || die "pinned revision mismatch" +python3_bin=$(command -v python3) || die "python3 is required" run_json() { local output=$1 shift @@ -78,9 +89,65 @@ run_json() { run_edit() { local lane=$1 - run_json "edit-$lane" lane exec "$lane" -- /bin/sh -c \ - 'printf "\nTrail real-framework qualification %s.\n" "$1" >> README.md' \ - trail "$lane" + run_json "edit-$lane" lane exec "$lane" -- \ + "$python3_bin" "$SEMANTIC_EDITOR" edit "$framework" "$lane" +} + +run_framework_precheck() { + local lane=$1 + local expected=$2 + case "$framework" in + go) + run_json "precheck-$lane" lane exec "$lane" -- /bin/sh -c \ + 'set -eu + "$1" "$2" verify go "$3" + exec "$TRAIL_GO" test ./version 1>&2' \ + trail "$python3_bin" "$SEMANTIC_EDITOR" "$expected" + ;; + pnpm) + run_json "precheck-$lane" lane exec "$lane" -- /bin/sh -c \ + 'set -eu + "$1" "$2" verify pnpm "$3" + "$TRAIL_PNPM" exec tsc --noEmit 1>&2 + if test "$3" = baseline; then + exec "$TRAIL_PNPM" exec vitest run tests/http-helpers/index.test.ts 1>&2 + fi + exec "$TRAIL_PNPM" exec vitest run tests/http-helpers/index.test.ts -t "Trail qualification marker" 1>&2' \ + trail "$python3_bin" "$SEMANTIC_EDITOR" "$expected" + ;; + npm) + run_json "precheck-$lane" lane exec "$lane" -- /bin/sh -c \ + 'set -eu + "$1" "$2" verify npm "$3" + "$TRAIL_NPM" run build -- --no-pack 1>&2 + exec "$TRAIL_NODE" --test --enable-source-maps dist-node/test/version.test.js 1>&2' \ + trail "$python3_bin" "$SEMANTIC_EDITOR" "$expected" + ;; + python) + run_json "precheck-$lane" lane exec "$lane" -- /bin/sh -c \ + 'set -eu + "$1" "$2" verify python "$3" + "$TRAIL_VENV_PYTHON" -c '\''import os,sys; prefix=os.path.realpath(os.environ["VIRTUAL_ENV"]); assert os.path.realpath(sys.prefix) == prefix; assert os.path.commonpath([prefix, os.path.realpath(os.environ["TRAIL_VENV_PYTHON"])]) == prefix'\'' + if test "$3" = baseline; then + exec "$TRAIL_VENV_PYTHON" -m pytest -q tests/test_line.py 1>&2 + fi + exec "$TRAIL_VENV_PYTHON" -m pytest -q tests/test_line.py -k trail_qualification_marker 1>&2' \ + trail "$python3_bin" "$SEMANTIC_EDITOR" "$expected" + ;; + cmake) + run_json "precheck-$lane" lane exec "$lane" -- /bin/sh -c \ + 'set -eu + "$1" "$2" verify cmake "$3" + "$TRAIL_CMAKE" -S . -B "$TRAIL_CMAKE_BUILD_DIR" -DLEVELDB_BUILD_TESTS=OFF -DLEVELDB_BUILD_BENCHMARKS=OFF 1>&2 + "$TRAIL_CMAKE" --build "$TRAIL_CMAKE_BUILD_DIR" --target leveldb --parallel 2 1>&2 + hash_object=$(find "$TRAIL_CMAKE_BUILD_DIR" -path "*CMakeFiles/leveldb.dir/util/hash.cc.o" -print -quit) + status_object=$(find "$TRAIL_CMAKE_BUILD_DIR" -path "*CMakeFiles/leveldb.dir/util/status.cc.o" -print -quit) + test -n "$hash_object" && test -n "$status_object" + shasum -a 256 "$hash_object" | awk "{print \$1}" > "$TRAIL_CMAKE_BUILD_DIR/trail-hash-before.sha256" + shasum -a 256 "$status_object" | awk "{print \$1}" > "$TRAIL_CMAKE_BUILD_DIR/trail-status-before.sha256"' \ + trail "$python3_bin" "$SEMANTIC_EDITOR" "$expected" + ;; + esac } run_framework_check() { @@ -88,42 +155,87 @@ run_framework_check() { case "$framework" in go) run_json "check-$lane" lane exec "$lane" -- /bin/sh -c \ - 'exec "$TRAIL_GO" test ./... -run "^TestTxStats_add$" 1>&2' + 'set -eu + "$1" "$2" verify go "$3" + exec "$TRAIL_GO" test ./version -run "^TestTrailQualificationMarker$" -count=1 1>&2' \ + trail "$python3_bin" "$SEMANTIC_EDITOR" "$lane" ;; pnpm) run_json "check-$lane" lane exec "$lane" -- /bin/sh -c \ - 'exec "$TRAIL_NODE" "$TRAIL_NODE_MODULES/oxfmt/bin/oxfmt" --check README.md 1>&2' + 'set -eu + "$1" "$2" verify pnpm "$3" + "$TRAIL_PNPM" exec tsc --noEmit 1>&2 + "$TRAIL_PNPM" run build 1>&2 + exec "$TRAIL_PNPM" exec vitest run tests/http-helpers/index.test.ts -t "Trail qualification marker" 1>&2' \ + trail "$python3_bin" "$SEMANTIC_EDITOR" "$lane" ;; npm) run_json "check-$lane" lane exec "$lane" -- /bin/sh -c \ - 'exec "$TRAIL_NODE" "$TRAIL_NODE_MODULES/typescript/bin/tsc" --version 1>&2' + 'set -eu + "$1" "$2" verify npm "$3" + "$TRAIL_NPM" run build -- --no-pack 1>&2 + exec "$TRAIL_NODE" --test --enable-source-maps dist-node/test/version.test.js 1>&2' \ + trail "$python3_bin" "$SEMANTIC_EDITOR" "$lane" ;; python) run_json "check-$lane" lane exec "$lane" -- /bin/sh -c \ - '"$TRAIL_VENV_PYTHON" -c '\''import os,sys; expected=os.path.join(os.getcwd(), ".venv"); assert os.path.realpath(sys.prefix) == os.path.realpath(os.environ["VIRTUAL_ENV"]) == os.path.realpath(expected)'\'' && exec "$TRAIL_VENV_PYTHON" -m compileall -q httpx 1>&2' + 'set -eu + "$1" "$2" verify python "$3" + "$TRAIL_VENV_PYTHON" -m compileall -q src/tap + exec "$TRAIL_VENV_PYTHON" -m pytest -q tests/test_line.py -k trail_qualification_marker 1>&2' \ + trail "$python3_bin" "$SEMANTIC_EDITOR" "$lane" ;; cmake) run_json "check-$lane" lane exec "$lane" -- /bin/sh -c \ 'set -eu - "$TRAIL_CMAKE" -S . -B "$TRAIL_CMAKE_BUILD_DIR" -DLEVELDB_BUILD_TESTS=OFF -DLEVELDB_BUILD_BENCHMARKS=OFF 1>&2 - exec "$TRAIL_CMAKE" --build "$TRAIL_CMAKE_BUILD_DIR" --target leveldb --parallel 2 1>&2' + "$1" "$2" verify cmake "$3" + rebuild_log="$TRAIL_CMAKE_BUILD_DIR/trail-rebuild.log" + "$TRAIL_CMAKE" --build "$TRAIL_CMAKE_BUILD_DIR" --target leveldb --parallel 2 >"$rebuild_log" 2>&1 + cat "$rebuild_log" >&2 + grep "hash.cc.o" "$rebuild_log" >/dev/null + if grep "status.cc.o" "$rebuild_log" >/dev/null; then + echo "unaffected status.cc was recompiled" >&2 + exit 1 + fi + hash_object=$(find "$TRAIL_CMAKE_BUILD_DIR" -path "*CMakeFiles/leveldb.dir/util/hash.cc.o" -print -quit) + status_object=$(find "$TRAIL_CMAKE_BUILD_DIR" -path "*CMakeFiles/leveldb.dir/util/status.cc.o" -print -quit) + hash_after=$(shasum -a 256 "$hash_object" | awk "{print \$1}") + status_after=$(shasum -a 256 "$status_object" | awk "{print \$1}") + test "$hash_after" != "$(cat "$TRAIL_CMAKE_BUILD_DIR/trail-hash-before.sha256")" + test "$status_after" = "$(cat "$TRAIL_CMAKE_BUILD_DIR/trail-status-before.sha256")" + cat > "$TRAIL_CMAKE_BUILD_DIR/trail-check.cc" < +#include "util/hash.h" +int main() { return std::string(leveldb::TrailQualificationMarker()) == "$3" ? 0 : 1; } +EOF + c++ -std=c++11 -I. "$TRAIL_CMAKE_BUILD_DIR/trail-check.cc" "$TRAIL_CMAKE_BUILD_DIR/libleveldb.a" -pthread -o "$TRAIL_CMAKE_BUILD_DIR/trail-check" + exec "$TRAIL_CMAKE_BUILD_DIR/trail-check"' \ + trail "$python3_bin" "$SEMANTIC_EDITOR" "$lane" ;; esac } cd "$repository_root" run_json init init --from-git +if [[ $framework == npm ]]; then + "$TRAIL_BIN" --quiet ignore add 'dist-node/' + "$TRAIL_BIN" --quiet ignore check 'dist-node/version.js' +fi previous= for lane in agent-a agent-b agent-c; do if [[ -z $previous ]]; then - run_json "spawn-$lane" lane spawn "$lane" --from main --workdir-mode nfs-cow + run_json "spawn-$lane" lane spawn "$lane" --from main --workdir-mode "$workdir_mode" else - run_json "spawn-$lane" lane spawn "$lane" --from "$previous" --workdir-mode nfs-cow - if [[ $framework == go || $framework == pnpm || $framework == npm ]]; then - run_json "generation-before-edit-$lane" env generation "$lane" - fi + run_json "spawn-$lane" lane spawn "$lane" --from "$previous" --workdir-mode "$workdir_mode" fi + case "$lane" in + agent-a) expected=baseline ;; + agent-b) expected=agent-a ;; + agent-c) expected=agent-b ;; + esac + run_framework_precheck "$lane" "$expected" + run_json "generation-before-edit-$lane" env generation "$lane" run_edit "$lane" run_json "sync-$lane" env sync component "$component_id" \ --adapter "$component_selector" --lane "$lane" diff --git a/trail/src/cli/command/handler/agent.rs b/trail/src/cli/command/handler/agent.rs index 351c2bd9..ca6b7323 100644 --- a/trail/src/cli/command/handler/agent.rs +++ b/trail/src/cli/command/handler/agent.rs @@ -1,6 +1,8 @@ use super::*; use crate::cli::command::render::render_agent_timeline; -use std::path::Path; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; use std::process::{Command as ProcessCommand, Stdio}; use trail::agent_hooks::{ apply_agent_hook_install_plan, build_agent_hook_install_plan, inspect_agent_hook_installation, @@ -8,8 +10,9 @@ use trail::agent_hooks::{ AgentHookInstallRequest, AgentHookInstallScope, AgentProviderRegistry, }; use trail::{ - AgentCaptureTransport, AgentContinueReport, AgentHookReceiptInput, AgentReviewAction, - AgentRunReport, LaneWorkdirMode, StatusSuggestion, + AgentCaptureTransport, AgentContinueReport, AgentHookReceiptInput, + AgentLaunchContainmentReport, AgentReviewAction, AgentRunReport, LaneWorkdirMode, + StatusSuggestion, }; pub(super) fn handle_agent_command(ctx: &RuntimeContext, agent: AgentCommand) -> Result<()> { @@ -1496,6 +1499,7 @@ fn run_terminal_agent_task( None }; let command_is_default = launch.command.is_empty(); + let contained_launch = command_is_default && !launch.allow_project_integrations; let mut command = if command_is_default { default_terminal_agent_command(&provider)? } else { @@ -1510,10 +1514,32 @@ fn run_terminal_agent_task( } else { command.push("--safe-mode".to_string()); command.push("--strict-mcp-config".to_string()); + command.push("--no-chrome".to_string()); + command.push("--permission-mode".to_string()); + command.push("acceptEdits".to_string()); } } + if command_is_default && provider == "codex" && !launch.allow_project_integrations { + command.extend([ + "--strict-config".to_string(), + "--cd".to_string(), + workdir.clone(), + "--sandbox".to_string(), + "workspace-write".to_string(), + "--config".to_string(), + "mcp_servers={}".to_string(), + ]); + } let mut managed = db.prepare_managed_lane_execution(&lane, "terminal_agent", &command)?; + let launch_runtime = prepare_agent_launch_runtime(db.db_dir(), &provider, contained_launch)?; let mut workspace_environment = managed.environment.clone(); + if contained_launch { + for (name, value) in &mut workspace_environment { + if name == "TRAIL_WORKSPACE" { + *value = workdir.clone(); + } + } + } workspace_environment.extend([ ("TRAIL_CAPTURE_MODE".to_string(), "terminal".to_string()), ( @@ -1522,13 +1548,41 @@ fn run_terminal_agent_task( ), ( "TRAIL_CAPTURE_WORKSPACE".to_string(), - db.workspace_root().to_string_lossy().into_owned(), + if contained_launch { + workdir.clone() + } else { + db.workspace_root().to_string_lossy().into_owned() + }, ), ( "TRAIL_CAPTURE_LANE".to_string(), capture_run.lane_id.clone().unwrap_or_else(|| lane.clone()), ), + ("TRAIL_AGENT_ROOT".to_string(), workdir.clone()), + ( + "TRAIL_AGENT_LAUNCH_PROFILE".to_string(), + agent_launch_profile(&provider, contained_launch).to_string(), + ), + ( + "TRAIL_PROJECT_INTEGRATIONS".to_string(), + if launch.allow_project_integrations { + "allowed" + } else { + "disabled" + } + .to_string(), + ), ]); + let ambient_environment = contained_agent_environment(&provider, &launch_runtime); + let writable_roots = agent_writable_roots( + Path::new(&workdir), + launch_runtime.root(), + &workspace_environment, + )?; + let git_work_tree = workspace_environment + .iter() + .find(|(name, _)| name == "GIT_WORK_TREE") + .map(|(_, value)| value.clone()); let workspace_root = db.workspace_root().to_path_buf(); drop(db); @@ -1543,8 +1597,14 @@ fn run_terminal_agent_task( &ctx.render, )?; } - let (launch_program, launch_args) = - confined_terminal_agent_command(&command, &workspace_root, Path::new(&workdir))?; + let (launch_program, launch_args, sandbox_backend, filesystem_enforcement) = + confined_terminal_agent_command( + &command, + &workspace_root, + Path::new(&workdir), + &writable_roots, + contained_launch, + )?; let mut git_ceiling_directories = std::env::var_os("GIT_CEILING_DIRECTORIES") .map(|paths| std::env::split_paths(&paths).collect::>()) .unwrap_or_default(); @@ -1558,9 +1618,21 @@ fn run_terminal_agent_task( std::env::join_paths(git_ceiling_directories).map_err(|error| { Error::InvalidInput(format!("cannot construct Git discovery ceiling: {error}")) })?; - Ok((launch_program, launch_args, git_ceiling_directories)) + Ok(( + launch_program, + launch_args, + git_ceiling_directories, + sandbox_backend, + filesystem_enforcement, + )) })(); - let (launch_program, launch_args, git_ceiling_directories) = match launch_setup { + let ( + launch_program, + launch_args, + git_ceiling_directories, + sandbox_backend, + filesystem_enforcement, + ) = match launch_setup { Ok(setup) => setup, Err(error) => { let mut db = open_db(ctx)?; @@ -1577,10 +1649,38 @@ fn run_terminal_agent_task( return Err(error); } }; + let containment = AgentLaunchContainmentReport { + profile: agent_launch_profile(&provider, contained_launch).to_string(), + project_integrations: if launch.allow_project_integrations { + "explicitly_allowed" + } else { + "disabled" + } + .to_string(), + environment_policy: if contained_launch { + "clear-and-explicit-v1" + } else { + "compatibility-inherited" + } + .to_string(), + sandbox_backend, + filesystem_enforcement: filesystem_enforcement.clone(), + lane_root: workdir.clone(), + git_work_tree, + protected_roots: vec![workspace_root.to_string_lossy().into_owned()], + writable_roots: writable_roots + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(), + ambient_repository_variables_scrubbed: contained_launch, + original_checkout_unchanged: filesystem_enforcement == "kernel_enforced", + }; let mut process = ProcessCommand::new(launch_program); + process.args(launch_args).current_dir(&workdir); + if contained_launch { + process.env_clear().envs(ambient_environment); + } process - .args(launch_args) - .current_dir(&workdir) .envs(workspace_environment) .env("PWD", &workdir) .env("OLDPWD", &workdir) @@ -1686,6 +1786,7 @@ fn run_terminal_agent_task( recorded, status: status.to_string(), lifecycle: Some(lifecycle), + containment: Some(containment), }; Ok(report) } @@ -3650,11 +3751,305 @@ fn default_terminal_agent_command(provider: &str) -> Result> { trail::acp::terminal_agent_command(provider) } +struct AgentLaunchRuntime { + _directory: tempfile::TempDir, + home: PathBuf, + temporary: PathBuf, + xdg_config: PathBuf, + xdg_cache: PathBuf, + xdg_data: PathBuf, + xdg_state: PathBuf, + codex_home: Option, + provider_environment: Vec<(String, String)>, +} + +impl AgentLaunchRuntime { + fn root(&self) -> &Path { + self._directory.path() + } +} + +fn prepare_agent_launch_runtime( + db_dir: &Path, + provider: &str, + copy_credentials: bool, +) -> Result { + let parent = db_dir.join("tmp").join("agent-launches"); + fs::create_dir_all(&parent)?; + let directory = tempfile::Builder::new() + .prefix("contained-") + .tempdir_in(&parent)?; + let home = directory.path().join("home"); + let temporary = directory.path().join("tmp"); + let xdg_config = directory.path().join("xdg/config"); + let xdg_cache = directory.path().join("xdg/cache"); + let xdg_data = directory.path().join("xdg/data"); + let xdg_state = directory.path().join("xdg/state"); + for path in [ + &home, + &temporary, + &xdg_config, + &xdg_cache, + &xdg_data, + &xdg_state, + ] { + fs::create_dir_all(path)?; + } + + let host_home = std::env::var_os("HOME").map(PathBuf::from); + let mut provider_environment = Vec::new(); + let codex_home = if provider == "codex" { + let destination = directory.path().join("codex"); + fs::create_dir_all(&destination)?; + if copy_credentials + && let Some(source) = host_home.as_ref().map(|home| home.join(".codex/auth.json")) + && source.is_file() + { + fs::copy(source, destination.join("auth.json"))?; + } + Some(destination) + } else { + if copy_credentials + && provider == "claude-code" + && let Some(host_home) = host_home.as_ref() + { + let source = host_home.join(".claude.json"); + if source.is_file() { + fs::copy(source, home.join(".claude.json"))?; + } + provider_environment = claude_user_settings_environment(host_home)?; + } + None + }; + + Ok(AgentLaunchRuntime { + _directory: directory, + home, + temporary, + xdg_config, + xdg_cache, + xdg_data, + xdg_state, + codex_home, + provider_environment, + }) +} + +fn claude_user_settings_environment(host_home: &Path) -> Result> { + const MAX_SETTINGS_BYTES: u64 = 1024 * 1024; + const ALLOWED_SETTINGS_ENVIRONMENT: &[&str] = &[ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_VERTEX", + "ANTHROPIC_FEDERATION_RULE_ID", + "ANTHROPIC_ORGANIZATION_ID", + "AWS_REGION", + "AWS_PROFILE", + "GOOGLE_CLOUD_PROJECT", + "CLOUD_ML_REGION", + ]; + + let path = host_home.join(".claude/settings.json"); + if !path.is_file() { + return Ok(Vec::new()); + } + let metadata = fs::metadata(&path)?; + if metadata.len() > MAX_SETTINGS_BYTES { + return Err(Error::InvalidInput(format!( + "Claude user settings exceed the {MAX_SETTINGS_BYTES}-byte contained-launch limit: {}", + path.display() + ))); + } + let settings: serde_json::Value = + serde_json::from_slice(&fs::read(&path)?).map_err(|error| { + Error::InvalidInput(format!( + "cannot parse Claude user settings for contained credential import at {}: {error}", + path.display() + )) + })?; + let Some(values) = settings.get("env").and_then(serde_json::Value::as_object) else { + return Ok(Vec::new()); + }; + Ok(ALLOWED_SETTINGS_ENVIRONMENT + .iter() + .filter_map(|name| { + values + .get(*name) + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(|value| ((*name).to_string(), value.to_string())) + }) + .collect()) +} + +fn contained_agent_environment( + provider: &str, + runtime: &AgentLaunchRuntime, +) -> Vec<(String, String)> { + let mut environment = BTreeMap::::new(); + environment.extend(runtime.provider_environment.iter().cloned()); + for name in [ + "PATH", + "USER", + "LOGNAME", + "SHELL", + "TERM", + "COLORTERM", + "LANG", + "LC_ALL", + "NO_COLOR", + "FORCE_COLOR", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + ] { + if let Ok(value) = std::env::var(name) { + environment.insert(name.to_string(), value); + } + } + let credential_names: &[&str] = match provider { + "claude-code" => &[ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_VERTEX", + "ANTHROPIC_FEDERATION_RULE_ID", + "ANTHROPIC_ORGANIZATION_ID", + "AWS_REGION", + "AWS_PROFILE", + "GOOGLE_CLOUD_PROJECT", + "CLOUD_ML_REGION", + ], + "codex" => &["OPENAI_API_KEY"], + _ => &[ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "OPENAI_API_KEY", + ], + }; + for name in credential_names { + if let Ok(value) = std::env::var(name) { + environment.insert((*name).to_string(), value); + } + } + environment.extend([ + ( + "HOME".to_string(), + runtime.home.to_string_lossy().into_owned(), + ), + ( + "TMPDIR".to_string(), + runtime.temporary.to_string_lossy().into_owned(), + ), + ( + "TMP".to_string(), + runtime.temporary.to_string_lossy().into_owned(), + ), + ( + "TEMP".to_string(), + runtime.temporary.to_string_lossy().into_owned(), + ), + ( + "XDG_CONFIG_HOME".to_string(), + runtime.xdg_config.to_string_lossy().into_owned(), + ), + ( + "XDG_CACHE_HOME".to_string(), + runtime.xdg_cache.to_string_lossy().into_owned(), + ), + ( + "XDG_DATA_HOME".to_string(), + runtime.xdg_data.to_string_lossy().into_owned(), + ), + ( + "XDG_STATE_HOME".to_string(), + runtime.xdg_state.to_string_lossy().into_owned(), + ), + ]); + if let Some(codex_home) = &runtime.codex_home { + environment.insert( + "CODEX_HOME".to_string(), + codex_home.to_string_lossy().into_owned(), + ); + } + if provider == "claude-code" { + environment.insert( + "CLAUDE_CODE_TMPDIR".to_string(), + runtime.temporary.to_string_lossy().into_owned(), + ); + } + environment.into_iter().collect() +} + +fn agent_launch_profile(provider: &str, contained: bool) -> &str { + if !contained { + return "custom-compatibility-v1"; + } + match provider { + "claude-code" => "claude-code-contained-v1", + "codex" => "codex-contained-v1", + _ => "generic-contained-v1", + } +} + +fn agent_writable_roots( + workdir: &Path, + runtime_root: &Path, + environment: &[(String, String)], +) -> Result> { + const PATH_BINDINGS: &[&str] = &[ + "CARGO_HOME", + "CARGO_TARGET_DIR", + "GOCACHE", + "GOMODCACHE", + "GIT_DIR", + "GIT_INDEX_FILE", + "SCCACHE_DIR", + "TRAIL_CMAKE_BUILD_DIR", + "TRAIL_NODE_MODULES", + "VIRTUAL_ENV", + "npm_config_cache", + "PNPM_HOME", + "PNPM_STORE_DIR", + "YARN_CACHE_FOLDER", + "BUN_INSTALL_CACHE_DIR", + ]; + let mut roots = vec![fs::canonicalize(workdir)?, fs::canonicalize(runtime_root)?]; + for (name, value) in environment { + if !PATH_BINDINGS.contains(&name.as_str()) { + continue; + } + let path = PathBuf::from(value); + if !path.is_absolute() || !path.exists() { + continue; + } + let path = fs::canonicalize(path)?; + roots.push(if path.is_file() { + path.parent().unwrap_or(&path).to_path_buf() + } else { + path + }); + } + roots.sort(); + roots.dedup(); + Ok(roots) +} + fn confined_terminal_agent_command( command: &[String], workspace_root: &Path, workdir: &Path, -) -> Result<(std::ffi::OsString, Vec)> { + writable_roots: &[PathBuf], + contained: bool, +) -> Result<(std::ffi::OsString, Vec, String, String)> { #[cfg(target_os = "macos")] { let sandbox = PathBuf::from("/usr/bin/sandbox-exec"); @@ -3665,35 +4060,64 @@ fn confined_terminal_agent_command( } let workspace_root = workspace_root.canonicalize()?; let workdir = workdir.canonicalize()?; - let trail_internal = workspace_root.join(".trail").canonicalize()?; + if !writable_roots.iter().any(|path| path == &workdir) { + return Err(Error::InvalidInput( + "contained terminal agent is missing its writable lane root".to_string(), + )); + } let escape = |path: &Path| { path.to_string_lossy() .replace('\\', "\\\\") .replace('"', "\\\"") }; - let profile = format!( - "(version 1)\n\ - (allow default)\n\ - (deny file-write* (subpath \"{}\"))\n\ - (allow file-write* (subpath \"{}\") (subpath \"{}\"))", - escape(&workspace_root), - escape(&trail_internal), - escape(&workdir), - ); + let profile = if contained { + let writable_rules = writable_roots + .iter() + .map(|path| format!("(subpath \"{}\")", escape(path))) + .collect::>() + .join(" "); + format!( + "(version 1)\n\ + (allow default)\n\ + (deny file-write*)\n\ + (deny file-write* (subpath \"{}\"))\n\ + (allow file-write* {} (literal \"/dev/null\") (literal \"/dev/tty\"))", + escape(&workspace_root), + writable_rules, + ) + } else { + let trail_internal = workspace_root.join(".trail").canonicalize()?; + format!( + "(version 1)\n\ + (allow default)\n\ + (deny file-write* (subpath \"{}\"))\n\ + (allow file-write* (subpath \"{}\") (subpath \"{}\"))", + escape(&workspace_root), + escape(&trail_internal), + escape(&workdir), + ) + }; let mut args = vec![ std::ffi::OsString::from("-p"), std::ffi::OsString::from(profile), std::ffi::OsString::from(&command[0]), ]; args.extend(command[1..].iter().map(std::ffi::OsString::from)); - Ok((sandbox.into_os_string(), args)) + Ok(( + sandbox.into_os_string(), + args, + "macos-sandbox-exec".to_string(), + "kernel_enforced".to_string(), + )) } #[cfg(not(target_os = "macos"))] { - let _ = (workspace_root, workdir); + let _ = (workspace_root, workdir, writable_roots, contained); Ok(( std::ffi::OsString::from(&command[0]), command[1..].iter().map(std::ffi::OsString::from).collect(), + "unavailable".to_string(), + "environment_only".to_string(), )) } } diff --git a/trail/src/db/change_ledger/activation.rs b/trail/src/db/change_ledger/activation.rs index 9fcfe473..1c30d9a0 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 = - "e5ae5921b0e33e791935741d2e26c4d4677e8614b340586656d99a7d140665bc"; + "b019b6ae19373c56d71f3216008cca8cef1a5fda85d5781136e76781d0408530"; const APPROVED_ACTIVATION_AUDIT_SHA256: &str = - "fabf41598953a960a986e8008c23848b5be6685b6bf124127f3f2d8aa45b028d"; + "58c8857047844e15d91807540225941fa92724deb46f15b4604cbb1edfa565d5"; const ACTIVATION_AUDIT_MANIFEST: &str = concat!( "trail-changed-path-activation-v1\n", "schema=1\n", "producer=af2cca0566976a6d6f6cea00e99fe5089c91e357ca1d0a50fd5397edcda32833\n", - "raw=e5ae5921b0e33e791935741d2e26c4d4677e8614b340586656d99a7d140665bc\n", + "raw=b019b6ae19373c56d71f3216008cca8cef1a5fda85d5781136e76781d0408530\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/lane/managed_execution.rs b/trail/src/db/lane/managed_execution.rs index c568c4cd..a8c19ef9 100644 --- a/trail/src/db/lane/managed_execution.rs +++ b/trail/src/db/lane/managed_execution.rs @@ -142,21 +142,7 @@ impl Trail { return Err(error); } }; - let resolution_pins = self.managed_execution_resolution_pins(&discovered)?; - if let Some(unresolved) = resolution_pins - .iter() - .find(|pin| pin.status != EnvironmentComponentProposalStatus::Ready) - { - let recovery = unresolved - .recovery_command - .as_ref() - .map(|command| command.join(" ")) - .unwrap_or_else(|| format!("trail env discover {lane}")); - let error = Error::InvalidInput(format!( - "managed execution requires explicit resolution for environment component `{}` ({}); run `{recovery}`", - unresolved.component_id, - unresolved.status.as_str() - )); + if view.is_none() && !discovered.components.is_empty() { self.push_managed_execution_phase( &mut phases, &branch.lane_id, @@ -164,17 +150,17 @@ impl Trail { surface, &command_fingerprint, "discover_plan", - "failed", - Some(&error.to_string()), + "succeeded", + None, Some(serde_json::json!({ - "missing_resolution_policy": ManagedExecutionMissingResolutionPolicy::Explicit, - "resolution_pins": resolution_pins, - "recovery_command": unresolved.recovery_command, + "component_count": discovered.components.len(), + "graph_nodes": 0, + "graph_edges": 0, })), )?; - return Err(error); - } - if view.is_none() && !discovered.components.is_empty() { + let error = Error::InvalidInput(format!( + "lane `{lane}` declares workspace environments but uses a materialized workdir; create the lane with `--workdir-mode auto` (or an explicit layered COW backend) before resolving or running its environment" + )); self.push_managed_execution_phase( &mut phases, &branch.lane_id, @@ -182,16 +168,29 @@ impl Trail { surface, &command_fingerprint, "discover_plan", - "succeeded", - None, + "failed", + Some(&error.to_string()), Some(serde_json::json!({ - "component_count": discovered.components.len(), - "graph_nodes": 0, - "graph_edges": 0, + "required_workdir_mode": "layered_cow", + "recommended_workdir_mode": "auto", })), )?; + return Err(error); + } + let resolution_pins = self.managed_execution_resolution_pins(&discovered)?; + if let Some(unresolved) = resolution_pins + .iter() + .find(|pin| pin.status != EnvironmentComponentProposalStatus::Ready) + { + let recovery = unresolved + .recovery_command + .as_ref() + .map(|command| command.join(" ")) + .unwrap_or_else(|| format!("trail env discover {lane}")); let error = Error::InvalidInput(format!( - "lane `{lane}` declares workspace environments but does not use a layered COW workdir" + "managed execution requires explicit resolution for environment component `{}` ({}); run `{recovery}`", + unresolved.component_id, + unresolved.status.as_str() )); self.push_managed_execution_phase( &mut phases, @@ -199,10 +198,14 @@ impl Trail { &execution_id, surface, &command_fingerprint, - "sync_all", + "discover_plan", "failed", Some(&error.to_string()), - None, + Some(serde_json::json!({ + "missing_resolution_policy": ManagedExecutionMissingResolutionPolicy::Explicit, + "resolution_pins": resolution_pins, + "recovery_command": unresolved.recovery_command, + })), )?; return Err(error); } @@ -525,7 +528,7 @@ impl Trail { { Ok(Some(result)) => result, Ok(None) => { - let environment = vec![ + let mut environment = vec![ ( "TRAIL_WORKSPACE".to_string(), self.workspace_root.to_string_lossy().into_owned(), @@ -533,6 +536,24 @@ impl Trail { ("TRAIL_LANE".to_string(), branch.lane_id.clone()), ("TRAIL_SOURCE_ROOT".to_string(), head.root_id.0.clone()), ]; + if let Some(shadow) = self.ensure_materialized_lane_git_shadow( + &branch.lane_id, + &workdir, + &head.root_id, + )? { + environment.extend([ + ("GIT_DIR".to_string(), shadow.git_dir.clone()), + ("GIT_WORK_TREE".to_string(), shadow.work_tree.clone()), + ( + "GIT_INDEX_FILE".to_string(), + Path::new(&shadow.git_dir) + .join("index") + .to_string_lossy() + .into_owned(), + ), + ("TRAIL_GIT_SHADOW_HEAD".to_string(), shadow.pinned_head), + ]); + } (environment, Vec::new()) } Err(error) => { @@ -1864,7 +1885,7 @@ mod tests { } #[test] - fn managed_preparation_requires_explicit_resolution_with_exact_recovery() { + fn managed_preparation_rejects_materialized_environment_before_resolution() { let root = tempfile::tempdir().unwrap(); std::fs::write( root.path().join("Cargo.toml"), @@ -1894,11 +1915,10 @@ mod tests { .err() .unwrap(); assert!(error.to_string().contains( - "managed execution requires explicit resolution for environment component `cargo-target-seed` (resolvable)" + "lane `needs-resolution` declares workspace environments but uses a materialized workdir" )); - assert!(error - .to_string() - .contains("trail env resolve component cargo-target-seed --lane needs-resolution")); + assert!(error.to_string().contains("--workdir-mode auto")); + assert!(!error.to_string().contains("trail env resolve")); } #[test] diff --git a/trail/src/db/lane/workdir/record.rs b/trail/src/db/lane/workdir/record.rs index 78273b0a..4aa226f0 100644 --- a/trail/src/db/lane/workdir/record.rs +++ b/trail/src/db/lane/workdir/record.rs @@ -338,7 +338,12 @@ impl Trail { } else if workdir_mode.is_transparent_cow() { // Derive the delta from the persistent upper layer instead of an // FUSE/NFS READDIR result or a scan of the composed repository. - let candidates = self.transparent_cow_candidate_paths_for_lane(lane, &workdir_mode)?; + let mut candidates = + self.transparent_cow_candidate_paths_for_lane(lane, &workdir_mode)?; + self.apply_recording_policy_to_transparent_cow_candidates( + &head.root_id, + &mut candidates, + )?; upper_recovery_walks = candidates.upper_recovery_walks; generated_dirty_paths = candidates .generated_paths @@ -960,6 +965,23 @@ impl Trail { } } + fn apply_recording_policy_to_transparent_cow_candidates( + &self, + root_id: &ObjectId, + candidates: &mut ViewCheckpointCandidates, + ) -> Result<()> { + let paths = candidates.paths.iter().cloned().collect::>(); + let baseline = self.load_root_files_for_paths(root_id, &paths)?; + let policy = self.workspace_ignore_policy_snapshot(); + for path in paths { + if !baseline.contains_key(&path) && policy.check_with_is_dir(&path, false)?.ignored { + candidates.paths.remove(&path); + candidates.generated_paths.insert(path); + } + } + Ok(()) + } + pub(crate) fn lane_cached_workdir_manifest_status( &self, workdir_path: &Path, @@ -1046,11 +1068,13 @@ impl Trail { let lane_record = self.lane_record(&branch.lane_id)?; let workdir_mode = self.lane_workdir_mode_for(&lane_record, branch)?; if workdir_mode.is_transparent_cow() { - let candidate_paths = self - .transparent_cow_candidate_paths_for_lane(&lane_record.name, &workdir_mode)? - .paths - .into_iter() - .collect::>(); + let mut candidates = + self.transparent_cow_candidate_paths_for_lane(&lane_record.name, &workdir_mode)?; + self.apply_recording_policy_to_transparent_cow_candidates( + &head.root_id, + &mut candidates, + )?; + let candidate_paths = candidates.paths.into_iter().collect::>(); let source_upper = self .workspace_view_paths_for_lane(&lane_record.name)? .source_upper; @@ -1479,3 +1503,37 @@ fn ensure_lane_record_message_has_no_secrets(message: Option<&str>) -> Result<() } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transparent_cow_candidates_apply_workspace_ignore_policy_at_checkpoint() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join(".trailignore"), "custom-output/**\n").unwrap(); + fs::write(temp.path().join("README.md"), "baseline\n").unwrap(); + Trail::init(temp.path(), "main", InitImportMode::WorkingTree, false).unwrap(); + let db = Trail::open(temp.path()).unwrap(); + let root_id = db.resolve_branch_ref("main").unwrap().root_id; + let mut candidates = ViewCheckpointCandidates { + journal_sequence: 2, + paths: BTreeSet::from([ + "custom-output/result.js".to_string(), + "src/lib.rs".to_string(), + ]), + generated_paths: BTreeSet::new(), + qualified: true, + upper_recovery_walks: 0, + }; + + db.apply_recording_policy_to_transparent_cow_candidates(&root_id, &mut candidates) + .unwrap(); + + assert_eq!(candidates.paths, BTreeSet::from(["src/lib.rs".to_string()])); + assert_eq!( + candidates.generated_paths, + BTreeSet::from(["custom-output/result.js".to_string()]) + ); + } +} diff --git a/trail/src/db/lane/workdir/view_layout.rs b/trail/src/db/lane/workdir/view_layout.rs index 8361f38d..335a79ec 100644 --- a/trail/src/db/lane/workdir/view_layout.rs +++ b/trail/src/db/lane/workdir/view_layout.rs @@ -62,7 +62,7 @@ pub(crate) fn classify_view_path(path: &str) -> ViewPathClass { if components.iter().any(|component| { matches!( *component, - "target" | "dist" | "build" | "coverage" | ".next" | ".turbo" + "target" | "dist" | "dist-node" | "build" | "coverage" | ".next" | ".turbo" ) }) { return ViewPathClass::Generated; @@ -154,6 +154,10 @@ mod tests { classify_view_path("crates/core/target/debug/core"), ViewPathClass::Generated ); + assert_eq!( + classify_view_path("dist-node/test/version.test.js"), + ViewPathClass::Generated + ); assert_eq!(classify_view_path(".env.local"), ViewPathClass::Secret); assert_eq!( classify_view_path(".git/refs/heads/main"), diff --git a/trail/src/db/lane/workspace_environment.rs b/trail/src/db/lane/workspace_environment.rs index 3a8e18da..6696088c 100644 --- a/trail/src/db/lane/workspace_environment.rs +++ b/trail/src/db/lane/workspace_environment.rs @@ -4423,6 +4423,7 @@ impl Trail { command, Path::new(&view.mountpoint), &process_root, + &candidate_layout, )?; command_index += 1; } @@ -4514,6 +4515,7 @@ impl Trail { command_plan: &WorkspaceEnvironmentCommand, mountpoint: &Path, process_root: &Path, + candidate_layout: &ViewUpperLayout, ) -> Result<()> { let working_directory = if command_plan.working_directory.is_empty() { mountpoint.to_path_buf() @@ -4537,10 +4539,23 @@ impl Trail { command_plan.program ))); } + let trusted_launcher_args = + if plan.sandbox_policy == WorkspaceEnvironmentSandboxPolicy::TrustedBuiltin { + Some(self.materialize_mounted_command_args( + plan, + command_plan, + process_root, + candidate_layout, + )?) + } else { + None + }; let (launcher, launcher_args) = match plan.sandbox_policy { WorkspaceEnvironmentSandboxPolicy::TrustedBuiltin => ( command_plan.resolved_program.clone(), - command_plan.args.iter().map(OsString::from).collect(), + trusted_launcher_args.ok_or_else(|| { + Error::Corrupt("trusted mounted initializer lost its arguments".to_string()) + })?, ), WorkspaceEnvironmentSandboxPolicy::RestrictedPluginMounted => { // Reuse the native recipe sandbox with the candidate mount as @@ -4605,18 +4620,31 @@ impl Trail { } }; let mut command = Command::new(launcher); + let command_environment = + if plan.sandbox_policy == WorkspaceEnvironmentSandboxPolicy::TrustedBuiltin { + command_plan + .environment + .iter() + .map(|(name, value)| { + materialize_mounted_output_value(plan, candidate_layout, value) + .map(|value| (name.clone(), value)) + }) + .collect::>>()? + } else { + command_plan.environment.clone() + }; command .args(launcher_args) .current_dir(&working_directory) .env_clear() - .envs(&command_plan.environment) + .envs(command_environment) .env("HOME", &isolated_home) .env("TMPDIR", &isolated_tmp) .env("TMP", &isolated_tmp) .env("TEMP", &isolated_tmp) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()); + .stderr(Stdio::piped()); if plan.sandbox_policy == WorkspaceEnvironmentSandboxPolicy::TrustedBuiltin { command .env("TRAIL_WORKSPACE", self.workspace_root()) @@ -4636,31 +4664,110 @@ impl Trail { for name in &command_plan.remove_environment { command.env_remove(name); } + let mut child = command.spawn().map_err(|err| { + Error::InvalidInput(format!( + "failed to launch mounted initializer `{}` for component `{}`: {err}", + command_plan.program, plan.component_id + )) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + Error::Corrupt(format!( + "mounted initializer for component `{}` lost its diagnostic pipe", + plan.component_id + )) + })?; + let diagnostic = spawn_bounded_environment_command_diagnostic(stderr); let status = if plan.sandbox_policy == WorkspaceEnvironmentSandboxPolicy::RestrictedPluginMounted { - run_supervised_mounted_plugin_process(&mut command).map_err(|err| { + wait_for_supervised_mounted_plugin_process(&mut child).map_err(|err| { Error::InvalidInput(format!( "failed to supervise mounted initializer `{}` for component `{}`: {err}", command_plan.program, plan.component_id )) })? } else { - command.status().map_err(|err| { - Error::InvalidInput(format!( - "failed to launch mounted initializer `{}` for component `{}`: {err}", - command_plan.program, plan.component_id - )) - })? + child.wait()? }; + let diagnostic = diagnostic + .join() + .map_err(|_| { + Error::Corrupt(format!( + "mounted initialization diagnostic reader for component `{}` panicked", + plan.component_id + )) + })? + .map_err(Error::Io)?; if !status.success() { + let diagnostic_text = + redact_sensitive_text(&String::from_utf8_lossy(&diagnostic.bytes)); + let diagnostic_text = diagnostic_text.trim(); + let details = if diagnostic_text.is_empty() { + String::new() + } else if diagnostic.truncated { + format!(": {diagnostic_text} [truncated]") + } else { + format!(": {diagnostic_text}") + }; return Err(Error::InvalidInput(format!( - "mounted initialization for component `{}` failed with {status}; the previous environment generation remains active", - plan.component_id + "mounted initialization for component `{}` failed with {status}{details}; the previous environment generation remains active", + plan.component_id, ))); } Ok(()) } + fn materialize_mounted_command_args( + &self, + plan: &WorkspaceEnvironmentPlan, + command: &WorkspaceEnvironmentCommand, + process_root: &Path, + candidate_layout: &ViewUpperLayout, + ) -> Result> { + let input_root = process_root.join("resolution-inputs"); + command + .args + .iter() + .map(|arg| { + if mounted_output_reference(arg).is_some() { + return materialize_mounted_output_value(plan, candidate_layout, arg) + .map(OsString::from); + } + let Some(source_path) = mounted_resolution_input_source(arg) else { + return Ok(OsString::from(arg)); + }; + let input = plan + .resolution_inputs + .iter() + .find(|input| input.source_path == source_path) + .ok_or_else(|| { + Error::Corrupt(format!( + "component `{}` mounted initializer references missing resolution input `{source_path}`", + plan.component_id + )) + })?; + let (snapshot, bytes) = + self.artifact_resolution_snapshot_content_by_id(&input.snapshot_id)?; + if snapshot.content_sha256 != input.content_hash + || snapshot.source_root != input.source_root + || snapshot.component_id != plan.component_id + || snapshot.adapter_identity != plan.adapter_identity + || u64::try_from(bytes.len()).unwrap_or(u64::MAX) != input.size_bytes + { + return Err(Error::Corrupt(format!( + "component `{}` mounted resolution input `{source_path}` changed after planning", + plan.component_id + ))); + } + let path = safe_join(&input_root, source_path)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + write_file_atomic(&path, &bytes, false)?; + Ok(path.into_os_string()) + }) + .collect() + } + fn writable_private_outputs_are_compatible( &self, view_id: &str, @@ -6430,10 +6537,9 @@ fn read_bounded_resolver_pipe( }) } -fn run_supervised_mounted_plugin_process( - command: &mut Command, +fn wait_for_supervised_mounted_plugin_process( + child: &mut std::process::Child, ) -> Result { - let mut child = command.spawn()?; let child_pid = child.id(); let child_start_token = match process_start_token(child_pid) { Some(token) => token, @@ -6508,6 +6614,67 @@ pub(crate) fn workspace_mounted_commands_identity( Ok(sha256_hex(&serde_json::to_vec(&commands)?)) } +const MOUNTED_RESOLUTION_INPUT_PREFIX: &str = "{trail-resolution-input:"; +const MOUNTED_OUTPUT_PREFIX: &str = "{trail-mounted-output:"; + +pub(crate) fn mounted_resolution_input_placeholder(source_path: &str) -> String { + format!("{MOUNTED_RESOLUTION_INPUT_PREFIX}{source_path}}}") +} + +fn mounted_resolution_input_source(value: &str) -> Option<&str> { + value + .strip_prefix(MOUNTED_RESOLUTION_INPUT_PREFIX) + .and_then(|value| value.strip_suffix('}')) +} + +pub(crate) fn mounted_output_placeholder(output_name: &str) -> String { + format!("{MOUNTED_OUTPUT_PREFIX}{output_name}}}") +} + +pub(crate) fn mounted_output_relative_placeholder( + output_name: &str, + relative_path: &str, +) -> String { + format!("{MOUNTED_OUTPUT_PREFIX}{output_name}:{relative_path}}}") +} + +fn mounted_output_reference(value: &str) -> Option<(&str, Option<&str>)> { + let reference = value + .strip_prefix(MOUNTED_OUTPUT_PREFIX) + .and_then(|value| value.strip_suffix('}'))?; + Some(match reference.split_once(':') { + Some((output_name, relative_path)) => (output_name, Some(relative_path)), + None => (reference, None), + }) +} + +fn materialize_mounted_output_value( + plan: &WorkspaceEnvironmentPlan, + candidate_layout: &ViewUpperLayout, + value: &str, +) -> Result { + let Some((output_name, relative_path)) = mounted_output_reference(value) else { + return Ok(value.to_string()); + }; + let output = plan + .outputs + .iter() + .find(|output| output.name == output_name) + .ok_or_else(|| { + Error::Corrupt(format!( + "component `{}` mounted initializer references missing output `{output_name}`", + plan.component_id + )) + })?; + let class = environment_upper_class(&plan.kind)?; + let root = safe_join(candidate_layout.upper_for_class(class), &output.mount_path)?; + let path = match relative_path { + Some(relative_path) => safe_join(&root, relative_path)?, + None => root, + }; + Ok(path.to_string_lossy().into_owned()) +} + fn environment_upper_class(kind: &str) -> Result { match kind { "dependency" => Ok(ViewPathClass::Dependency), diff --git a/trail/src/db/lane/workspace_git.rs b/trail/src/db/lane/workspace_git.rs index 6d473b87..bad011bd 100644 --- a/trail/src/db/lane/workspace_git.rs +++ b/trail/src/db/lane/workspace_git.rs @@ -15,7 +15,29 @@ impl Trail { view: &LaneWorkspaceViewReport, source_root: &ObjectId, ) -> Result> { - if let Some(shadow) = self.workspace_git_shadow(view)? { + self.ensure_git_shadow(&view.view_id, &view.mountpoint, source_root) + } + + pub(crate) fn ensure_materialized_lane_git_shadow( + &self, + lane_id: &str, + work_tree: &Path, + source_root: &ObjectId, + ) -> Result> { + self.ensure_git_shadow( + &format!("materialized-{lane_id}"), + &work_tree.to_string_lossy(), + source_root, + ) + } + + fn ensure_git_shadow( + &self, + shadow_id: &str, + work_tree: &str, + source_root: &ObjectId, + ) -> Result> { + if let Some(shadow) = self.git_shadow(shadow_id, work_tree)? { return self.refresh_workspace_git_shadow(&shadow).map(Some); } let pinned_head = self @@ -31,7 +53,7 @@ impl Trail { }; let common_dir = git_real_common_dir(&self.workspace_root)?; let object_dir = common_dir.join("objects").canonicalize()?; - let git_dir = self.db_dir.join("git-shadows").join(&view.view_id); + let git_dir = self.db_dir.join("git-shadows").join(shadow_id); if git_dir.exists() && fs::read_dir(&git_dir)?.next().transpose()?.is_some() { return Err(Error::InvalidInput(format!( "Git shadow directory `{}` contains untracked recovery state", @@ -69,45 +91,49 @@ impl Trail { )?; git_shadow_command( &git_dir, - Path::new(&view.mountpoint), + Path::new(work_tree), &["config", "core.bare", "false"], )?; git_shadow_command( &git_dir, - Path::new(&view.mountpoint), - &["config", "core.worktree", &view.mountpoint], + Path::new(work_tree), + &["config", "core.worktree", work_tree], )?; git_shadow_command( &git_dir, - Path::new(&view.mountpoint), + Path::new(work_tree), &["config", "advice.detachedHead", "false"], )?; - git_shadow_command( - &git_dir, - Path::new(&view.mountpoint), - &["read-tree", &pinned_head], - )?; + git_shadow_command(&git_dir, Path::new(work_tree), &["read-tree", &pinned_head])?; let now = now_ts(); self.conn.execute( "INSERT INTO workspace_git_shadows (view_id, git_dir, policy, pinned_head, current_head, status, created_at, updated_at) VALUES (?1, ?2, 'status', ?3, ?3, 'ready', ?4, ?4)", - params![view.view_id, git_dir.to_string_lossy(), pinned_head, now], + params![shadow_id, git_dir.to_string_lossy(), pinned_head, now], )?; - self.workspace_git_shadow(view) + self.git_shadow(shadow_id, work_tree) } pub(crate) fn workspace_git_shadow( &self, view: &LaneWorkspaceViewReport, + ) -> Result> { + self.git_shadow(&view.view_id, &view.mountpoint) + } + + fn git_shadow( + &self, + shadow_id: &str, + work_tree: &str, ) -> Result> { self.conn .query_row( "SELECT view_id, git_dir, policy, pinned_head, current_head, status, updated_at FROM workspace_git_shadows WHERE view_id = ?1", - params![view.view_id], + params![shadow_id], |row| { Ok(WorkspaceGitShadowReport { view_id: row.get(0)?, git_dir: row.get(1)?, - work_tree: view.mountpoint.clone(), + work_tree: work_tree.to_string(), policy: row.get(2)?, pinned_head: row.get(3)?, current_head: row.get(4)?, diff --git a/trail/src/db/lane/workspace_node.rs b/trail/src/db/lane/workspace_node.rs index 2ecde3de..3852d447 100644 --- a/trail/src/db/lane/workspace_node.rs +++ b/trail/src/db/lane/workspace_node.rs @@ -417,6 +417,19 @@ impl Trail { let node_version = tool_version("node")?; let node_tool = resolve_workspace_tool_executable("node")?; let manager_tool = resolve_workspace_tool_executable(&manager)?; + if manager == "pnpm" + && self + .root_file_entry( + root_id, + &join_repo_path(&package_root, "pnpm-workspace.yaml"), + )? + .is_some() + { + return Err(Error::InvalidInput(format!( + "Node component `{}` is a pnpm workspace root; synchronize a supported leaf package with its own lockfile until the monorepo adapter is enabled", + display_package_root(&package_root) + ))); + } if package_value.get("workspaces").is_some() { return Err(Error::InvalidInput(format!( "Node component `{}` declares workspaces; synchronize a supported leaf package explicitly until the monorepo adapter is enabled", @@ -1026,18 +1039,10 @@ mod tests { if resolve_workspace_tool_executable("node").is_ok() && resolve_workspace_tool_executable("pnpm").is_ok() { - let plan = db + let error = db .plan_workspace_environment("node-workspace", "node", None) - .unwrap(); - assert_eq!( - plan.commands[0].args, - [ - "install", - "--ignore-workspace", - "--frozen-lockfile", - "--ignore-scripts" - ] - ); + .unwrap_err(); + assert!(error.to_string().contains("is a pnpm workspace root")); } } diff --git a/trail/src/db/lane/workspace_python.rs b/trail/src/db/lane/workspace_python.rs index 0c070ae1..cd57710a 100644 --- a/trail/src/db/lane/workspace_python.rs +++ b/trail/src/db/lane/workspace_python.rs @@ -1,12 +1,14 @@ use super::workspace_environment::{ - resolve_workspace_tool_executable, workspace_mounted_commands_identity, - WorkspaceEnvironmentAdapter, WorkspaceEnvironmentAdapterMetadata, - WorkspaceEnvironmentAdapterProposal, WorkspaceEnvironmentCacheAccess, - WorkspaceEnvironmentCacheCommandBinding, WorkspaceEnvironmentCacheProtocol, - WorkspaceEnvironmentCommand, WorkspaceEnvironmentInput, WorkspaceEnvironmentOutput, - WorkspaceEnvironmentOutputCommandBinding, WorkspaceEnvironmentOutputPolicy, - WorkspaceEnvironmentPlan, WorkspaceEnvironmentResolutionInput, - WorkspaceEnvironmentSandboxPolicy, WorkspaceEnvironmentToolCommandBinding, + mounted_output_placeholder, mounted_output_relative_placeholder, + mounted_resolution_input_placeholder, resolve_workspace_tool_executable, + workspace_mounted_commands_identity, WorkspaceEnvironmentAdapter, + WorkspaceEnvironmentAdapterMetadata, WorkspaceEnvironmentAdapterProposal, + WorkspaceEnvironmentCacheAccess, WorkspaceEnvironmentCacheCommandBinding, + WorkspaceEnvironmentCacheProtocol, WorkspaceEnvironmentCommand, WorkspaceEnvironmentInput, + WorkspaceEnvironmentOutput, WorkspaceEnvironmentOutputCommandBinding, + WorkspaceEnvironmentOutputPolicy, WorkspaceEnvironmentPlan, + WorkspaceEnvironmentResolutionInput, WorkspaceEnvironmentSandboxPolicy, + WorkspaceEnvironmentToolCommandBinding, }; use super::*; use crate::ids::sha256_hex; @@ -15,8 +17,16 @@ pub(crate) struct PythonVenvAdapter; pub(crate) static PYTHON_VENV_ADAPTER: PythonVenvAdapter = PythonVenvAdapter; -const PYTHON_IDENTITY_FILES: [&str; 7] = [ +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PythonInstallContract { + UvLock, + HashedRequirements, + ManagedHashedRequirements, +} + +const PYTHON_IDENTITY_FILES: [&str; 8] = [ "pyproject.toml", + ".python-version", "uv.lock", "poetry.lock", "pdm.lock", @@ -41,7 +51,7 @@ static PYTHON_VENV_ADAPTER_METADATA: WorkspaceEnvironmentAdapterMetadata = name: "python-venv", contract_major: 1, implementation_version: env!("CARGO_PKG_VERSION"), - distribution_digest: "builtin:python-venv-plan-v3", + distribution_digest: "builtin:python-venv-plan-v6", selectors: &["trail/python-venv@1", "python-venv", "python"], kind: "dependency", layer_adapter_name: "python-venv", @@ -49,7 +59,7 @@ static PYTHON_VENV_ADAPTER_METADATA: WorkspaceEnvironmentAdapterMetadata = supported_operating_systems: &["linux", "macos", "windows"], supported_architectures: &["aarch64", "x86_64"], stability: "experimental", - description: "Automatically initialized lane-private Python virtual environment at the stable mounted lane path", + description: "Automatically initialized lane-private Python virtual environment with direct command bindings", }; const PYTHON_CACHE_COMMAND_BINDINGS: &[WorkspaceEnvironmentCacheCommandBinding] = &[ @@ -81,7 +91,7 @@ const PYTHON_OUTPUT_COMMAND_BINDINGS: &[WorkspaceEnvironmentOutputCommandBinding output_name: "venv", environment: Some("VIRTUAL_ENV"), relative_path: "", - direct: false, + direct: true, prepend_path: false, required: true, }, @@ -89,7 +99,7 @@ const PYTHON_OUTPUT_COMMAND_BINDINGS: &[WorkspaceEnvironmentOutputCommandBinding output_name: "venv", environment: None, relative_path: PYTHON_VENV_EXECUTABLE_DIRECTORY, - direct: false, + direct: true, prepend_path: true, required: true, }, @@ -97,7 +107,7 @@ const PYTHON_OUTPUT_COMMAND_BINDINGS: &[WorkspaceEnvironmentOutputCommandBinding output_name: "venv", environment: Some("TRAIL_VENV_PYTHON"), relative_path: PYTHON_VENV_EXECUTABLE, - direct: false, + direct: true, prepend_path: false, required: true, }, @@ -245,11 +255,17 @@ impl WorkspaceEnvironmentAdapter for PythonVenvAdapter { component_root: &str, ) -> Result { let component_root = normalize_python_component_root(component_root)?; - let python = resolve_python_executable()?; + let python = resolve_python_executable_for_source(db, source_root, &component_root)?; let component_id = self.component_id(&component_root)?; let mount_path = join_python_path(&component_root, ".venv"); let implementation_version = env!("CARGO_PKG_VERSION").to_string(); - let distribution_digest = "builtin:python-venv-plan-v3".to_string(); + let distribution_digest = "builtin:python-venv-plan-v6".to_string(); + let source_resolution = python_source_resolution(db, source_root, &component_root)?; + let managed_snapshot = if source_resolution.is_none() { + python_resolution_snapshot(db, source_root, &component_root)? + } else { + None + }; let mut mounted_args = vec![ "-m".to_string(), "venv".to_string(), @@ -261,8 +277,8 @@ impl WorkspaceEnvironmentAdapter for PythonVenvAdapter { // directly for a clean first-run experience. #[cfg(target_os = "macos")] mounted_args.push("--copies".to_string()); - mounted_args.push(".venv".to_string()); - let mounted_command = WorkspaceEnvironmentCommand { + mounted_args.push(mounted_output_placeholder("venv")); + let venv_command = WorkspaceEnvironmentCommand { program: "python".to_string(), resolved_program: python.path.clone(), executable_identity: python.identity.clone(), @@ -291,26 +307,37 @@ impl WorkspaceEnvironmentAdapter for PythonVenvAdapter { "creation_phase".to_string(), "host-mounted-initialization".to_string(), ), - ( - "mounted_action".to_string(), - workspace_mounted_commands_identity(std::slice::from_ref(&mounted_command))?, - ), ( "command_environment".to_string(), format!( - "PIP_CACHE_DIR=cache:python-downloads/pip;UV_CACHE_DIR=cache:python-downloads/uv;VIRTUAL_ENV=output:venv;TRAIL_VENV_PYTHON=output:venv/{PYTHON_VENV_EXECUTABLE};PATH+=output:venv/{PYTHON_VENV_EXECUTABLE_DIRECTORY};TRAIL_PYTHON=tool:python3|python" + "PIP_CACHE_DIR=cache:python-downloads/pip;UV_CACHE_DIR=cache:python-downloads/uv;VIRTUAL_ENV=direct:venv;TRAIL_VENV_PYTHON=direct:venv/{PYTHON_VENV_EXECUTABLE};PATH+=direct:venv/{PYTHON_VENV_EXECUTABLE_DIRECTORY};TRAIL_PYTHON=tool:python3|python" ), ), ]); - let source_resolution = python_source_resolution(db, source_root, &component_root)?; - let managed_snapshot = if source_resolution.is_none() { - python_resolution_snapshot(db, source_root, &component_root)? - } else { - None - }; let mut resolution_inputs = Vec::new(); let mut source_projection = None; let managed_resolution = managed_snapshot.is_some(); + let install_contract = match source_resolution.as_deref() { + Some(path) if path.ends_with("uv.lock") => Some(PythonInstallContract::UvLock), + Some(path) if path.ends_with("requirements.lock") => { + let entry = db.root_file_entry(source_root, path)?.ok_or_else(|| { + Error::Corrupt(format!("Python resolution input `{path}` disappeared")) + })?; + validate_python_requirements_snapshot(&db.materialize_entry_bytes(&entry)?)?; + Some(PythonInstallContract::HashedRequirements) + } + Some(path) => { + return Err(Error::InvalidInput(format!( + "Python component `{}` uses `{path}`, which is not a frozen install contract; provide uv.lock or a hash-pinned requirements.lock", + display_python_root(&component_root) + ))); + } + None if managed_resolution => Some(PythonInstallContract::ManagedHashedRequirements), + None => None, + }; + let uv = install_contract + .map(|_| resolve_workspace_tool_executable("uv")) + .transpose()?; if let Some((snapshot_id, snapshot, bytes)) = managed_snapshot { let resolution_plan = self .resolution_plan(db, source_root, &component_root)? @@ -439,6 +466,89 @@ impl WorkspaceEnvironmentAdapter for PythonVenvAdapter { } else { Vec::new() }; + let mut mounted_commands = vec![venv_command]; + if let (Some(contract), Some(uv)) = (install_contract, uv.as_ref()) { + let venv_python = mounted_output_relative_placeholder("venv", PYTHON_VENV_EXECUTABLE); + let mut args = match contract { + PythonInstallContract::UvLock => vec![ + "sync".to_string(), + "--frozen".to_string(), + "--no-install-project".to_string(), + "--no-python-downloads".to_string(), + "--active".to_string(), + ], + PythonInstallContract::HashedRequirements => vec![ + "pip".to_string(), + "sync".to_string(), + "--require-hashes".to_string(), + "--python".to_string(), + venv_python, + "requirements.lock".to_string(), + ], + PythonInstallContract::ManagedHashedRequirements => vec![ + "pip".to_string(), + "sync".to_string(), + "--require-hashes".to_string(), + "--offline".to_string(), + "--find-links".to_string(), + download_cache + .storage_path + .join("wheels") + .to_string_lossy() + .into_owned(), + "--python".to_string(), + venv_python, + mounted_resolution_input_placeholder(&join_python_path( + &component_root, + "requirements.lock", + )), + ], + }; + args.shrink_to_fit(); + mounted_commands.push(WorkspaceEnvironmentCommand { + program: "uv".to_string(), + resolved_program: uv.path.clone(), + executable_identity: uv.identity.clone(), + args, + working_directory: component_root.clone(), + environment: BTreeMap::from([ + ( + "PIP_CACHE_DIR".to_string(), + download_cache + .storage_path + .join("pip") + .to_string_lossy() + .into_owned(), + ), + ( + "UV_CACHE_DIR".to_string(), + download_cache + .storage_path + .join("uv") + .to_string_lossy() + .into_owned(), + ), + ("UV_NO_PROGRESS".to_string(), "1".to_string()), + ("UV_LINK_MODE".to_string(), "copy".to_string()), + ("UV_PYTHON_DOWNLOADS".to_string(), "never".to_string()), + ( + "VIRTUAL_ENV".to_string(), + mounted_output_placeholder("venv"), + ), + ]), + remove_environment: Vec::new(), + cache_names: Vec::new(), + }); + } + key_inputs.insert( + "mounted_action".to_string(), + workspace_mounted_commands_identity(&mounted_commands)?, + ); + let mut tool_versions = + BTreeMap::from([("python-executable".to_string(), python.identity.clone())]); + if let Some(uv) = &uv { + tool_versions.insert("uv-executable".to_string(), uv.identity.clone()); + } Ok(WorkspaceEnvironmentPlan { component_id, adapter_identity: self.identity().to_string(), @@ -453,14 +563,11 @@ impl WorkspaceEnvironmentAdapter for PythonVenvAdapter { adapter: self.layer_adapter_name().to_string(), adapter_version: 1, inputs: key_inputs, - tool_versions: BTreeMap::from([( - "python-executable".to_string(), - python.identity.clone(), - )]), + tool_versions, platform: std::env::consts::OS.to_string(), architecture: std::env::consts::ARCH.to_string(), portability_scope: "lane-private-host-python".to_string(), - strategy: "python-venv-private-mounted-init-v2".to_string(), + strategy: "python-venv-private-direct-init-v5".to_string(), }, inputs, resolution_inputs, @@ -468,10 +575,11 @@ impl WorkspaceEnvironmentAdapter for PythonVenvAdapter { source_projection, pre_commands, // Python virtual environments commonly embed absolute interpreter - // and prefix paths, so the host initializes this output through - // an ephemeral candidate view mounted at the final lane path. + // and prefix paths. The host initializes the candidate's physical + // private upper, then binds that exact path into managed commands; + // the conventional `.venv` path remains visible in the lane view. command: None, - mounted_commands: vec![mounted_command], + mounted_commands, caches: vec![download_cache], external_artifacts: Vec::new(), runtime_resources: Vec::new(), @@ -538,8 +646,23 @@ fn validate_python_requirements_snapshot(bytes: &[u8]) -> Result<()> { Error::InvalidInput("Trail-managed Python requirements snapshot is not UTF-8".to_string()) })?; let mut has_requirement = false; + let mut current_requirement_hashed = true; for line in text.lines().map(str::trim) { - if line.is_empty() || line.starts_with('#') || line.starts_with("--hash=sha256:") { + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some(hash) = line.strip_prefix("--hash=sha256:") { + let hash = hash.trim_end_matches('\\').trim(); + if !has_requirement + || hash.len() != 64 + || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(Error::InvalidInput( + "Trail-managed Python requirements snapshot contains an invalid or unbound SHA-256 hash" + .to_string(), + )); + } + current_requirement_hashed = true; continue; } if line.starts_with('-') || line.contains(" @ ") { @@ -548,6 +671,12 @@ fn validate_python_requirements_snapshot(bytes: &[u8]) -> Result<()> { .to_string(), )); } + if has_requirement && !current_requirement_hashed { + return Err(Error::InvalidInput( + "Trail-managed Python requirements snapshot contains packages without SHA-256 hashes" + .to_string(), + )); + } has_requirement = true; if !line.contains("==") { return Err(Error::InvalidInput( @@ -555,8 +684,13 @@ fn validate_python_requirements_snapshot(bytes: &[u8]) -> Result<()> { .to_string(), )); } + current_requirement_hashed = line + .split_whitespace() + .filter_map(|token| token.strip_prefix("--hash=sha256:")) + .map(|hash| hash.trim_end_matches('\\')) + .any(|hash| hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit())); } - if has_requirement && !text.contains("--hash=sha256:") { + if has_requirement && !current_requirement_hashed { return Err(Error::InvalidInput( "Trail-managed Python requirements snapshot contains packages without SHA-256 hashes" .to_string(), @@ -583,6 +717,60 @@ fn resolve_python_executable() -> Result Result { + let version_path = join_python_path(component_root, ".python-version"); + let Some(entry) = db.root_file_entry(source_root, &version_path)? else { + return resolve_python_executable(); + }; + let version_bytes = db.materialize_entry_bytes(&entry)?; + let selector = python_version_selector(&version_bytes)?; + let program = format!("python{selector}"); + resolve_workspace_tool_executable(&program).map_err(|error| { + Error::InvalidInput(format!( + "Python component `{}` pins `{selector}` in `{version_path}`, but `{program}` is unavailable on PATH: {error}", + display_python_root(component_root) + )) + }) +} + +fn python_version_selector(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes) + .map_err(|_| Error::InvalidInput(".python-version must be UTF-8".to_string()))?; + let mut lines = text.lines().map(str::trim).filter(|line| !line.is_empty()); + let raw = lines.next().ok_or_else(|| { + Error::InvalidInput(".python-version must contain one Python version".to_string()) + })?; + if lines.next().is_some() + || raw.len() > 64 + || raw + .chars() + .any(|ch| !ch.is_ascii_alphanumeric() && !matches!(ch, '.' | '-' | '_')) + { + return Err(Error::InvalidInput( + ".python-version must contain one bounded, portable Python version selector" + .to_string(), + )); + } + let numeric = raw.strip_prefix("cpython-").unwrap_or(raw); + let mut parts = numeric.split('.'); + let major = parts.next().unwrap_or_default(); + let minor = parts.next().unwrap_or_default(); + if major.is_empty() + || minor.is_empty() + || !major.chars().all(|ch| ch.is_ascii_digit()) + || !minor.chars().all(|ch| ch.is_ascii_digit()) + { + return Err(Error::InvalidInput(format!( + "unsupported Python version selector `{raw}`; use a CPython major.minor or major.minor.patch version" + ))); + } + Ok(format!("{major}.{minor}")) +} + fn normalize_python_component_root(component_root: &str) -> Result { if component_root.trim_matches('/').is_empty() { Ok(String::new()) @@ -647,12 +835,116 @@ mod tests { .unwrap_err() .to_string() .contains("without SHA-256")); + assert!(validate_python_requirements_snapshot( + b"first==1 --hash=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nsecond==2\n" + ) + .unwrap_err() + .to_string() + .contains("without SHA-256")); + assert!( + validate_python_requirements_snapshot(b"example==1 --hash=sha256:not-a-digest\n") + .unwrap_err() + .to_string() + .contains("without SHA-256") + ); + assert!(validate_python_requirements_snapshot( + b"--hash=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n" + ) + .unwrap_err() + .to_string() + .contains("invalid or unbound")); assert!(validate_python_requirements_snapshot(b"-e ../example\n") .unwrap_err() .to_string() .contains("directive or URL")); } + #[test] + fn python_version_file_selects_a_portable_major_minor_executable() { + assert_eq!(python_version_selector(b"3.12\n").unwrap(), "3.12"); + assert_eq!( + python_version_selector(b"cpython-3.13.2\n").unwrap(), + "3.13" + ); + assert!(python_version_selector(b"3.12\n3.13\n").is_err()); + assert!(python_version_selector(b"../python\n").is_err()); + assert!(python_version_selector(b"pypy-3.11\n").is_err()); + } + + #[test] + fn python_plan_rejects_unfrozen_requirements_instead_of_creating_an_empty_venv() { + if resolve_python_executable().is_err() { + return; + } + let workspace = tempfile::tempdir().unwrap(); + fs::write( + workspace.path().join("pyproject.toml"), + "[project]\nname = \"unfrozen\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + fs::write(workspace.path().join("requirements.txt"), "pytest>=8\n").unwrap(); + Trail::init(workspace.path(), "main", InitImportMode::WorkingTree, false).unwrap(); + let mut db = Trail::open(workspace.path()).unwrap(); + db.spawn_lane_with_workdir_mode_paths_and_neighbors( + "unfrozen", + Some("main"), + LaneWorkdirMode::Virtual, + None, + None, + None, + &[], + false, + ) + .unwrap(); + let error = db + .plan_workspace_environment("unfrozen", "python", None) + .unwrap_err() + .to_string(); + assert!(error.contains("not a frozen install contract"), "{error}"); + assert!(error.contains("requirements.lock"), "{error}"); + } + + #[test] + fn python_hashed_requirements_plan_uses_uv_hash_enforcement_and_direct_output() { + if resolve_python_executable().is_err() || resolve_workspace_tool_executable("uv").is_err() + { + return; + } + let workspace = tempfile::tempdir().unwrap(); + fs::write( + workspace.path().join("pyproject.toml"), + "[project]\nname = \"hashed\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + fs::write( + workspace.path().join("requirements.lock"), + "example==1 --hash=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n", + ) + .unwrap(); + Trail::init(workspace.path(), "main", InitImportMode::WorkingTree, false).unwrap(); + let mut db = Trail::open(workspace.path()).unwrap(); + db.spawn_lane_with_workdir_mode_paths_and_neighbors( + "hashed", + Some("main"), + LaneWorkdirMode::Virtual, + None, + None, + None, + &[], + false, + ) + .unwrap(); + let plan = db + .plan_workspace_environment("hashed", "python", None) + .unwrap(); + let install = &plan.commands[1]; + assert_eq!(install.program, "uv"); + assert!(install.args.iter().any(|arg| arg == "--require-hashes")); + assert!(install.args.iter().any(|arg| arg == "requirements.lock")); + let expected_python = format!("{{trail-mounted-output:venv:{PYTHON_VENV_EXECUTABLE}}}"); + assert!(install.args.iter().any(|arg| arg == &expected_python)); + } + #[test] fn managed_python_resolution_warms_download_cache_but_keeps_venv_and_bytecode_private() { let Ok(python) = resolve_python_executable() else { @@ -740,9 +1032,11 @@ mod tests { assert_eq!(plan.caches.len(), 1); assert_eq!(plan.caches[0].protocol, "content_store"); assert_eq!(plan.caches[0].authority, "performance_only"); - assert_eq!(plan.commands.len(), 2); + assert_eq!(plan.commands.len(), 3); assert_eq!(plan.commands[0].phase, "staging"); assert_eq!(plan.commands[1].phase, "mounted_initialization"); + assert_eq!(plan.commands[2].phase, "mounted_initialization"); + assert_eq!(plan.commands[2].program, "uv"); assert!(!workspace.path().join("requirements.lock").exists()); assert!(db .root_file_entry(&source_root, "requirements.lock") @@ -842,8 +1136,9 @@ mod tests { } #[test] - fn python_venv_is_keyed_private_and_initialized_at_the_mounted_lane() { - if resolve_python_executable().is_err() { + fn python_venv_is_keyed_private_and_initialized_in_the_private_upper() { + if resolve_python_executable().is_err() || resolve_workspace_tool_executable("uv").is_err() + { return; } let workspace = tempfile::tempdir().unwrap(); @@ -883,17 +1178,26 @@ mod tests { let plan = db .plan_workspace_environment("python", "trail/python-venv@1", None) .unwrap(); - assert_eq!(plan.commands.len(), 1); + assert_eq!(plan.commands.len(), 2); assert_eq!(plan.commands[0].phase, "mounted_initialization"); + assert_eq!(plan.commands[1].phase, "mounted_initialization"); + assert_eq!(plan.commands[1].program, "uv"); + assert!(plan.commands[1].args.iter().any(|arg| arg == "--frozen")); #[cfg(target_os = "macos")] assert_eq!( plan.commands[0].args, - ["-m", "venv", "--without-pip", "--copies", ".venv"] + [ + "-m", + "venv", + "--without-pip", + "--copies", + "{trail-mounted-output:venv}" + ] ); #[cfg(not(target_os = "macos"))] assert_eq!( plan.commands[0].args, - ["-m", "venv", "--without-pip", ".venv"] + ["-m", "venv", "--without-pip", "{trail-mounted-output:venv}"] ); assert_eq!(plan.outputs[0].mount_path, ".venv"); assert_eq!( @@ -1106,8 +1410,10 @@ mod tests { #[cfg(any(target_os = "linux", windows))] let mounted = db.mount_fuse_cow_workdir_for_lane("python-all").unwrap(); let workdir = PathBuf::from(db.lane_workdir("python-all").unwrap().workdir.unwrap()); + let paths = db.workspace_view_paths_for_lane("python-all").unwrap(); for component in ["services/api", "services/worker"] { let venv = workdir.join(component).join(".venv"); + let direct_venv = paths.generated_upper.join(component).join(".venv"); assert!(venv.join("pyvenv.cfg").is_file()); #[cfg(windows)] let executable = venv.join("Scripts/python.exe"); @@ -1121,12 +1427,12 @@ mod tests { #[cfg(windows)] assert_eq!( fs::canonicalize(String::from_utf8(prefix.stdout).unwrap().trim()).unwrap(), - fs::canonicalize(&venv).unwrap() + fs::canonicalize(&direct_venv).unwrap() ); #[cfg(not(windows))] assert_eq!( String::from_utf8(prefix.stdout).unwrap().trim(), - venv.to_string_lossy() + direct_venv.to_string_lossy() ); } drop(mounted); @@ -1134,7 +1440,7 @@ mod tests { #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] - fn real_python_venvs_embed_lane_paths_and_remain_isolated() { + fn real_python_venvs_use_direct_private_bindings_and_remain_isolated() { #[cfg(target_os = "linux")] if std::env::var_os("TRAIL_RUN_FUSE_COW_TESTS").as_deref() != Some(OsStr::new("1")) { return; @@ -1188,21 +1494,14 @@ mod tests { .unwrap() .into_iter() .collect::>(); - assert_eq!( - Path::new(&environment["VIRTUAL_ENV"]), - workdir.join(".venv") - ); + let paths = db.workspace_view_paths_for_lane(lane).unwrap(); + let direct_venv = paths.generated_upper.join(".venv"); + assert_eq!(Path::new(&environment["VIRTUAL_ENV"]), direct_venv); assert_eq!( Path::new(&environment["TRAIL_VENV_PYTHON"]), - workdir.join(".venv/bin/python") + direct_venv.join("bin/python") ); - assert_eq!( - std::env::split_paths(OsStr::new(&environment["PATH"])) - .next() - .unwrap(), - workdir.join(".venv/bin") - ); - let venv_python = workdir.join(".venv/bin/python"); + let venv_python = direct_venv.join("bin/python"); let prefix = Command::new(&venv_python) .args(["-c", "import sys; print(sys.prefix)"]) .current_dir(&workdir) @@ -1211,7 +1510,7 @@ mod tests { assert!(prefix.status.success()); assert_eq!( String::from_utf8(prefix.stdout).unwrap().trim(), - workdir.join(".venv").to_string_lossy() + direct_venv.to_string_lossy() ); if lane == "python-a" { fs::write(workdir.join(".venv/lane-a.txt"), "private\n").unwrap(); @@ -1281,7 +1580,13 @@ mod tests { assert!(prefix.status.success()); let actual_prefix = fs::canonicalize(String::from_utf8(prefix.stdout).unwrap().trim()).unwrap(); - let expected_prefix = fs::canonicalize(workdir.join(".venv")).unwrap(); + let expected_prefix = fs::canonicalize( + db.workspace_view_paths_for_lane(lane) + .unwrap() + .generated_upper + .join(".venv"), + ) + .unwrap(); assert_eq!(actual_prefix, expected_prefix); if lane == "python-a" { fs::write(workdir.join(".venv/lane-a.txt"), "private\n").unwrap(); diff --git a/trail/src/db/lane/workspace_view.rs b/trail/src/db/lane/workspace_view.rs index 57a2f087..9fb5df60 100644 --- a/trail/src/db/lane/workspace_view.rs +++ b/trail/src/db/lane/workspace_view.rs @@ -945,14 +945,35 @@ impl Trail { if let Some(view) = self.lane_workspace_view(lane)? { return self.workspace_command_environment(&view, &head.root_id); } - Ok(vec![ + let mut environment = vec![ ( "TRAIL_WORKSPACE".to_string(), self.workspace_root.to_string_lossy().into_owned(), ), - ("TRAIL_LANE".to_string(), branch.lane_id), - ("TRAIL_SOURCE_ROOT".to_string(), head.root_id.0), - ]) + ("TRAIL_LANE".to_string(), branch.lane_id.clone()), + ("TRAIL_SOURCE_ROOT".to_string(), head.root_id.0.clone()), + ]; + if let Some(workdir) = branch.workdir.as_deref() + && let Some(shadow) = self.ensure_materialized_lane_git_shadow( + &branch.lane_id, + Path::new(workdir), + &head.root_id, + )? + { + environment.extend([ + ("GIT_DIR".to_string(), shadow.git_dir.clone()), + ("GIT_WORK_TREE".to_string(), shadow.work_tree.clone()), + ( + "GIT_INDEX_FILE".to_string(), + Path::new(&shadow.git_dir) + .join("index") + .to_string_lossy() + .into_owned(), + ), + ("TRAIL_GIT_SHADOW_HEAD".to_string(), shadow.pinned_head), + ]); + } + Ok(environment) } fn run_workspace_command( diff --git a/trail/src/db/storage/root_diff.rs b/trail/src/db/storage/root_diff.rs index 06146742..34e31934 100644 --- a/trail/src/db/storage/root_diff.rs +++ b/trail/src/db/storage/root_diff.rs @@ -65,110 +65,17 @@ impl Trail { right_root_id: &ObjectId, probes: &mut RenameLookupProbes, ) -> Result> { - let left_root: WorktreeRoot = self.get_object(WORKTREE_ROOT_KIND, left_root_id)?; - let right_root: WorktreeRoot = self.get_object(WORKTREE_ROOT_KIND, right_root_id)?; - let left_tree = root_map_tree_from_root_hex(left_root.path_map_root.as_deref())?; - let right_tree = root_map_tree_from_root_hex(right_root.path_map_root.as_deref())?; - let diffs = self - .root_prolly - .range_diff(&left_tree, &right_tree, &[], None)?; - - let mut added = Vec::new(); - let mut removed = Vec::new(); - let mut changed = Vec::new(); - for diff in diffs { - match diff { - Diff::Added { key, val } => { - added.push((path_from_key(key)?, from_cbor::(&val)?)); - } - Diff::Removed { key, val } => { - removed.push((path_from_key(key)?, from_cbor::(&val)?)); - } - Diff::Changed { key, old, new } => { - changed.push(( - path_from_key(key)?, - from_cbor::(&old)?, - from_cbor::(&new)?, - )); - } - } - } - - added.sort_by(|left, right| left.0.cmp(&right.0)); - removed.sort_by(|left, right| left.0.cmp(&right.0)); - changed.sort_by(|left, right| left.0.cmp(&right.0)); - - let mut summaries = Vec::new(); - let mut removed_by_identity = RenameMatchIndex::default(); - for (path, entry) in &removed { - removed_by_identity.insert(path.clone(), entry.clone()); - } - - let mut consumed_removed = HashSet::new(); - for (path, new_entry) in added { - probes.note_lookup(); - let rename = removed_by_identity.take(&new_entry); - if let Some((old_path, old_entry)) = rename { - consumed_removed.insert(old_path.clone()); - summaries.push(file_diff_summary( - path, - Some(old_path), - FileChangeKind::Renamed, - Some(old_entry.content_hash), - Some(new_entry.content_hash), - )); - continue; - } - - summaries.push(file_diff_summary( - path, - None, - FileChangeKind::Added, - None, - Some(new_entry.content_hash), - )); - } - - for (path, old_entry) in removed { - if consumed_removed.contains(&path) { - continue; - } - summaries.push(file_diff_summary( - path, - None, - FileChangeKind::Deleted, - Some(old_entry.content_hash), - None, - )); - } - - for (path, old_entry, new_entry) in changed { - if old_entry.content_hash == new_entry.content_hash - && old_entry.executable == new_entry.executable - && old_entry.kind == new_entry.kind - { - continue; - } - let kind = if old_entry.kind != new_entry.kind { - FileChangeKind::TypeChanged - } else { - FileChangeKind::Modified - }; - summaries.push(file_diff_summary( - path, - None, - kind, - Some(old_entry.content_hash), - Some(new_entry.content_hash), - )); - } - - summaries.sort_by(|left, right| { - left.path - .cmp(&right.path) - .then_with(|| left.old_path.cmp(&right.old_path)) - }); - Ok(summaries) + let mut patch_left = BTreeMap::new(); + let mut patch_right = BTreeMap::new(); + Ok(self + .diff_root_file_maps_indexed( + left_root_id, + right_root_id, + &mut patch_left, + &mut patch_right, + probes, + )? + .summaries) } pub(crate) fn diff_root_file_maps( @@ -344,30 +251,37 @@ fn path_from_key(key: Vec) -> Result { String::from_utf8(key).map_err(|err| Error::Corrupt(format!("non UTF-8 path key: {err}"))) } -fn file_diff_summary( - path: String, - old_path: Option, - kind: FileChangeKind, - before_hash: Option, - after_hash: Option, -) -> FileDiffSummary { - FileDiffSummary { - path, - old_path, - kind, - before_hash, - after_hash, - additions: 0, - deletions: 0, - line_changes: Vec::new(), - patch: None, - } -} - #[cfg(test)] mod tests { use super::*; + #[test] + fn root_diff_summaries_include_text_line_counts() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("example.txt"), "before\nkept\n").unwrap(); + let init = Trail::init(temp.path(), "main", InitImportMode::WorkingTree, false).unwrap(); + + fs::write(temp.path().join("example.txt"), "after\nkept\n").unwrap(); + let mut db = Trail::open(temp.path()).unwrap(); + let record = db + .record( + Some("main"), + Some("replace line".into()), + Actor::human(), + false, + ) + .unwrap(); + + let summaries = db + .diff_root_file_summaries(&init.root_id, &record.root_id) + .unwrap(); + + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].path, "example.txt"); + assert_eq!(summaries[0].additions, 1); + assert_eq!(summaries[0].deletions, 1); + } + #[test] fn root_diff_rename_matching_is_linear_for_same_content() { const FILE_COUNT: usize = 1_000; diff --git a/trail/src/model/lane/activity.rs b/trail/src/model/lane/activity.rs index 9c884710..50cb5de6 100644 --- a/trail/src/model/lane/activity.rs +++ b/trail/src/model/lane/activity.rs @@ -1232,6 +1232,23 @@ pub struct AgentRunReport { pub status: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub lifecycle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub containment: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AgentLaunchContainmentReport { + pub profile: String, + pub project_integrations: String, + pub environment_policy: String, + pub sandbox_backend: String, + pub filesystem_enforcement: String, + pub lane_root: String, + pub git_work_tree: Option, + pub protected_roots: Vec, + pub writable_roots: Vec, + pub ambient_repository_variables_scrubbed: bool, + pub original_checkout_unchanged: bool, } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/trail/tests/changed_path_ledger_activation.rs b/trail/tests/changed_path_ledger_activation.rs index 1e50b9bd..cecc51c6 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"], - "e5ae5921b0e33e791935741d2e26c4d4677e8614b340586656d99a7d140665bc" + "b019b6ae19373c56d71f3216008cca8cef1a5fda85d5781136e76781d0408530" ); assert_eq!( complete["activation_audit_sha256"], - "fabf41598953a960a986e8008c23848b5be6685b6bf124127f3f2d8aa45b028d" + "58c8857047844e15d91807540225941fa92724deb46f15b4604cbb1edfa565d5" ); 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/e2e.rs b/trail/tests/e2e.rs index f457fc4d..97b351a0 100644 --- a/trail/tests/e2e.rs +++ b/trail/tests/e2e.rs @@ -357,6 +357,8 @@ fn terminal_agent_start_aligns_process_context_with_the_lane_workdir() { .arg("--workspace") .arg(temp.path()) .arg("--json") + .env("MCP_CONFIG", "/escaped/project/mcp.json") + .env("CODEX_THREAD_ID", "escaped-thread") .args([ "agent", "start", @@ -387,6 +389,9 @@ fn terminal_agent_start_aligns_process_context_with_the_lane_workdir() { assert!(environment .lines() .any(|line| line == format!("TRAIL_WORKSPACE={}", workspace.display()))); + assert!(environment + .lines() + .any(|line| line == format!("TRAIL_AGENT_ROOT={workdir}"))); assert!(environment .lines() .any(|line| line.starts_with("TRAIL_LANE=lane_"))); @@ -401,6 +406,15 @@ fn terminal_agent_start_aligns_process_context_with_the_lane_workdir() { .any(|path| path == workspace.to_string_lossy()) }) })); + assert_eq!(report["containment"]["profile"], "custom-compatibility-v1"); + assert_eq!( + report["containment"]["environment_policy"], + "compatibility-inherited" + ); + assert_eq!( + report["containment"]["ambient_repository_variables_scrubbed"], + false + ); } #[cfg(unix)] @@ -424,10 +438,25 @@ fn terminal_agent_start_disables_claude_project_integrations_unless_explicitly_a let settings = install["config_path"].as_str().unwrap().to_string(); let bin = tempfile::tempdir().unwrap(); + let host_home = tempfile::tempdir().unwrap(); + fs::create_dir_all(host_home.path().join(".claude")).unwrap(); + fs::write( + host_home.path().join(".claude/settings.json"), + serde_json::json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "secret-settings-token", + "ANTHROPIC_BASE_URL": "https://contained.example.invalid", + "MCP_CONFIG": "/escaped/settings/mcp.json" + }, + "enabledPlugins": {"escaped-plugin": true} + }) + .to_string(), + ) + .unwrap(); let fake_claude = bin.path().join("claude"); fs::write( &fake_claude, - "#!/bin/sh\nprintf '%s\\n' \"$@\" | tee CLAUDE_ARGS.txt >&2\n", + "#!/bin/sh\nprintf 'HOME=%s\\n' \"$HOME\" >&2\nprintf 'TRAIL_AGENT_ROOT=%s\\n' \"$TRAIL_AGENT_ROOT\" >&2\nprintf 'CLAUDE_CODE_TMPDIR=%s\\n' \"$CLAUDE_CODE_TMPDIR\" >&2\nif test -n \"${MCP_CONFIG+x}\"; then printf 'MCP_CONFIG_LEAKED=yes\\n' >&2; fi\nif test -n \"${CODEX_THREAD_ID+x}\"; then printf 'CODEX_THREAD_ID_LEAKED=yes\\n' >&2; fi\nif test \"${ANTHROPIC_AUTH_TOKEN:-}\" = secret-settings-token && test \"${ANTHROPIC_BASE_URL:-}\" = https://contained.example.invalid; then printf 'CLAUDE_SETTINGS_AUTH_AVAILABLE=yes\\n' >&2; fi\nif test -n \"${CLAUDE_CODE_OAUTH_TOKEN:-}\"; then printf 'CLAUDE_OAUTH_AVAILABLE=yes\\n' >&2; fi\nprintf '%s\\n' \"$@\" | tee CLAUDE_ARGS.txt >&2\n", ) .unwrap(); let mut permissions = fs::metadata(&fake_claude).unwrap().permissions(); @@ -443,6 +472,10 @@ fn terminal_agent_start_disables_claude_project_integrations_unless_explicitly_a .arg(temp.path()) .arg("--json") .env("PATH", &path) + .env("HOME", host_home.path()) + .env("MCP_CONFIG", "/escaped/project/mcp.json") + .env("CODEX_THREAD_ID", "escaped-thread") + .env("CLAUDE_CODE_OAUTH_TOKEN", "secret-test-token") .args([ "agent", "start", @@ -462,20 +495,49 @@ fn terminal_agent_start_disables_claude_project_integrations_unless_explicitly_a String::from_utf8_lossy(&output.stderr) ); let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!( - String::from_utf8_lossy(&output.stderr), - "--safe-mode\n--strict-mcp-config\n" - ); + let workdir = report["workdir"].as_str().unwrap(); + let environment = String::from_utf8_lossy(&output.stderr); + assert!(environment.lines().any(|line| line == "--safe-mode")); + assert!(environment + .lines() + .any(|line| line == "--strict-mcp-config")); + assert!(environment.lines().any(|line| line == "--no-chrome")); + assert!(environment.lines().any(|line| line == "--permission-mode")); + assert!(environment.lines().any(|line| line == "acceptEdits")); + assert!(environment + .lines() + .any(|line| line == "CLAUDE_OAUTH_AVAILABLE=yes")); + assert!(environment + .lines() + .any(|line| line == "CLAUDE_SETTINGS_AUTH_AVAILABLE=yes")); + assert!(!environment.contains("secret-test-token")); + assert!(!environment.contains("secret-settings-token")); assert_eq!( report["lifecycle"]["checkpoint"]["source_paths"], serde_json::json!(["CLAUDE_ARGS.txt"]) ); + assert!(environment + .lines() + .any(|line| line == format!("TRAIL_AGENT_ROOT={workdir}"))); + assert!(!environment + .lines() + .any(|line| line == "MCP_CONFIG_LEAKED=yes")); + assert!(!environment + .lines() + .any(|line| line == "CODEX_THREAD_ID_LEAKED=yes")); + assert!(!environment + .lines() + .any(|line| line == format!("HOME={}", host_home.path().display()))); + assert!(environment.lines().any(|line| { + line.starts_with("CLAUDE_CODE_TMPDIR=") && line.contains("/.trail/tmp/agent-launches/") + })); let allowed = Command::new(trail_bin()) .arg("--workspace") .arg(temp.path()) .arg("--json") .env("PATH", path) + .env("HOME", host_home.path()) .args([ "agent", "start", @@ -495,9 +557,73 @@ fn terminal_agent_start_disables_claude_project_integrations_unless_explicitly_a String::from_utf8_lossy(&allowed.stdout), String::from_utf8_lossy(&allowed.stderr) ); + assert!(String::from_utf8_lossy(&allowed.stderr) + .lines() + .collect::>() + .windows(2) + .any(|lines| lines == ["--settings", settings.as_str()])); +} + +#[cfg(unix)] +#[test] +fn terminal_agent_start_applies_the_contained_codex_profile() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("README.md"), "hello\n").unwrap(); + Trail::init(temp.path(), "main", InitImportMode::WorkingTree, false).unwrap(); + + let bin = tempfile::tempdir().unwrap(); + let host_home = tempfile::tempdir().unwrap(); + let fake_codex = bin.path().join("codex"); + fs::write( + &fake_codex, + "#!/bin/sh\nprintf '%s\\n' \"$@\" | tee CODEX_ARGS.txt >&2\n", + ) + .unwrap(); + let mut permissions = fs::metadata(&fake_codex).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&fake_codex, permissions).unwrap(); + let path = std::env::join_paths(std::iter::once(bin.path().to_path_buf()).chain( + std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()), + )) + .unwrap(); + + let output = Command::new(trail_bin()) + .arg("--workspace") + .arg(temp.path()) + .arg("--json") + .env("PATH", path) + .env("HOME", host_home.path()) + .args([ + "agent", + "start", + "--provider", + "codex", + "--name", + "contained-codex", + "--workdir-mode", + "auto", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "agent start failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let workdir = report["workdir"].as_str().unwrap(); + assert_eq!( + String::from_utf8_lossy(&output.stderr), + format!( + "--strict-config\n--cd\n{workdir}\n--sandbox\nworkspace-write\n--config\nmcp_servers={{}}\n" + ) + ); + assert_eq!(report["containment"]["profile"], "codex-contained-v1"); + assert_eq!(report["containment"]["project_integrations"], "disabled"); assert_eq!( - String::from_utf8_lossy(&allowed.stderr), - format!("--settings\n{settings}\n") + report["lifecycle"]["checkpoint"]["source_paths"], + serde_json::json!(["CODEX_ARGS.txt"]) ); } @@ -509,27 +635,40 @@ fn terminal_agent_native_cow_does_not_discover_or_write_the_parent_git_checkout( } let temp = tempfile::tempdir().unwrap(); fs::write(temp.path().join("README.md"), "root baseline\n").unwrap(); + fs::write( + temp.path().join(".trailignore"), + ".trail/\n.git/\n.env\n.env.*\n*.pem\n*.key\n*.p12\n*.pfx\nid_rsa\nid_ed25519\nnode_modules/\ntarget/\ndist/\nbuild/\ncoverage/\n", + ) + .unwrap(); run_git(temp.path(), &["init", "-q"]); run_git(temp.path(), &["config", "user.email", "trail@example.com"]); run_git(temp.path(), &["config", "user.name", "Trail Test"]); - run_git(temp.path(), &["add", "README.md"]); + run_git(temp.path(), &["add", "README.md", ".trailignore"]); run_git(temp.path(), &["commit", "-qm", "baseline"]); Trail::init(temp.path(), "main", InitImportMode::GitTracked, false).unwrap(); - let provider = tempfile::NamedTempFile::new().unwrap(); + let bin = tempfile::tempdir().unwrap(); + let host_home = tempfile::tempdir().unwrap(); + let provider = bin.path().join("claude"); fs::write( - provider.path(), - "#!/bin/sh\nset -eu\nprintf 'lane change\\n' > LANE_ONLY.md\nif root=$(git rev-parse --show-toplevel 2>/dev/null); then\n printf 'escaped\\n' > \"$root/ESCAPED.md\"\nfi\n", + &provider, + "#!/bin/sh\nset -eu\nprintf 'lane change\n' > LANE_ONLY.md\nif root=$(git rev-parse --show-toplevel 2>/dev/null); then\n printf 'escaped\n' > \"$root/ESCAPED.md\"\nfi\n", ) .unwrap(); - let mut permissions = fs::metadata(provider.path()).unwrap().permissions(); + let mut permissions = fs::metadata(&provider).unwrap().permissions(); permissions.set_mode(0o755); - fs::set_permissions(provider.path(), permissions).unwrap(); + fs::set_permissions(&provider, permissions).unwrap(); + let path = std::env::join_paths(std::iter::once(bin.path().to_path_buf()).chain( + std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()), + )) + .unwrap(); let output = Command::new(trail_bin()) .arg("--workspace") .arg(temp.path()) .arg("--json") + .env("PATH", path) + .env("HOME", host_home.path()) .args([ "agent", "start", @@ -539,9 +678,7 @@ fn terminal_agent_native_cow_does_not_discover_or_write_the_parent_git_checkout( "containment", "--workdir-mode", "native-cow", - "--", ]) - .arg(provider.path()) .output() .unwrap(); assert!( @@ -557,10 +694,19 @@ fn terminal_agent_native_cow_does_not_discover_or_write_the_parent_git_checkout( "root baseline\n" ); assert!(!temp.path().join("ESCAPED.md").exists()); + assert!(!temp.path().join("ESCAPED_ORIGINAL.md").exists()); assert_eq!( fs::read_to_string(workdir.join("LANE_ONLY.md")).unwrap(), "lane change\n" ); + assert_eq!( + report["containment"]["git_work_tree"], + workdir.to_string_lossy().as_ref() + ); + assert_eq!( + report["containment"]["original_checkout_unchanged"], + cfg!(target_os = "macos") + ); assert!(report["recorded"]["changed_paths"] .as_array() .unwrap() diff --git a/trail/tests/fixtures/changed_path_raw_mutations.v1 b/trail/tests/fixtures/changed_path_raw_mutations.v1 index 06bad6f8..5352e90f 100644 --- a/trail/tests/fixtures/changed_path_raw_mutations.v1 +++ b/trail/tests/fixtures/changed_path_raw_mutations.v1 @@ -276,12 +276,13 @@ reviewed|db/lane/workspace_environment.rs|materialize_workspace_environment_inpu reviewed|db/lane/workspace_environment.rs|materialize_workspace_environment_input|fs::create_dir_all|1 reviewed|db/lane/workspace_environment.rs|materialize_workspace_environment_input|fs::set_permissions|1 reviewed|db/lane/workspace_environment.rs|materialize_workspace_environment_input|OpenOptions::write|1 +reviewed|db/lane/workspace_environment.rs|materialize_mounted_command_args|fs::create_dir_all|1 reviewed|db/lane/workspace_environment.rs|workspace_environment_staging_parent_path_fallback|fs::create_dir_all|2 reviewed|db/lane/workspace_environment.rs|run_mounted_workspace_environment_command|fs::copy|1 reviewed|db/lane/workspace_environment.rs|run_mounted_workspace_environment_command|fs::create_dir_all|3 reviewed|db/lane/workspace_environment.rs|run_mounted_workspace_environment_command|fs::set_permissions|1 reviewed|db/lane/workspace_environment.rs|run_workspace_environment_command|fs::create_dir_all|3 -reviewed|db/lane/workspace_git.rs|ensure_workspace_git_shadow|fs::create_dir_all|3 +reviewed|db/lane/workspace_git.rs|ensure_git_shadow|fs::create_dir_all|3 reviewed|db/lane/workspace_layer.rs|acquire_environment_cache_maintenance|fs::create_dir_all|1 reviewed|db/lane/workspace_layer.rs|acquire_environment_cache_maintenance|fs::rename|1 reviewed|db/lane/workspace_layer.rs|acquire_environment_cache_maintenance|OpenOptions::create_new|1 @@ -340,12 +341,14 @@ 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 reviewed|db/lane/workspace_plugin.rs|invoke_environment_plugin|fs::set_permissions|1 reviewed|db/lane/workspace_view.rs|create_workspace_view|fs::create_dir_all|1 +reviewed|db/lane/workspace_view.rs|active_workspace_cache_path|fs::create_dir_all|2 +reviewed|db/lane/workspace_view.rs|active_workspace_command_bindings|fs::create_dir_all|2 reviewed|db/lane/workspace_view.rs|mount_lane_workspace_until_requested|fs::remove_file|2 reviewed|db/lane/workspace_view.rs|prepare_direct_private_seed|fs::create_dir_all|2 reviewed|db/lane/workspace_view.rs|prepare_workspace_view_storage_for_lane_name|fs::create_dir_all|1 reviewed|db/lane/workspace_view.rs|recover_workspace_views|fs::remove_file|1 reviewed|db/lane/workspace_view.rs|release|fs::remove_file|1 -reviewed|db/lane/workspace_view.rs|workspace_command_environment_at_root|fs::create_dir_all|1 +reviewed|db/lane/workspace_view.rs|release_current_process_workspace_mount_lease|fs::remove_file|1 reviewed|db/merge/git_export.rs|write_patch_to|fs::create_dir_all|1 reviewed|db/merge/git_export.rs|write_patch_to|fs::write|1 reviewed|db/mod.rs|acquire_workspace_lock_with_admission|OpenOptions::create|1 diff --git a/trail/tests/managed_execution.rs b/trail/tests/managed_execution.rs index c010544d..c7e95e1f 100644 --- a/trail/tests/managed_execution.rs +++ b/trail/tests/managed_execution.rs @@ -307,9 +307,8 @@ fn managed_preparation_failure_never_launches_the_command() { 30, ) .unwrap_err(); - assert!(error - .to_string() - .contains("does not use a layered COW workdir")); + assert!(error.to_string().contains("uses a materialized workdir")); + assert!(error.to_string().contains("--workdir-mode auto")); assert!(!workdir.join("PREPARATION_RAN").exists()); let phases = db @@ -322,10 +321,9 @@ fn managed_preparation_failure_never_launches_the_command() { ) .unwrap(); assert!(phases.iter().any(|event| { - event - .payload - .as_ref() - .is_some_and(|payload| payload["phase"] == "sync_all" && payload["status"] == "failed") + event.payload.as_ref().is_some_and(|payload| { + payload["phase"] == "discover_plan" && payload["status"] == "failed" + }) })); assert!(!phases.iter().any(|event| { event