cowork-harness 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +9 -8
  3. package/baselines/desktop-1.15962.0.json +220 -0
  4. package/dist/agent/session.js +16 -2
  5. package/dist/assert.js +26 -8
  6. package/dist/baseline.js +5 -1
  7. package/dist/boundary.js +18 -6
  8. package/dist/cli.js +145 -36
  9. package/dist/decide/decider.js +95 -22
  10. package/dist/decide/external-channel.js +11 -5
  11. package/dist/decide/llm-transport.js +123 -39
  12. package/dist/dotenv.js +3 -4
  13. package/dist/egress/proxy.js +34 -16
  14. package/dist/hostloop/workspace-handler.js +10 -3
  15. package/dist/io.js +24 -0
  16. package/dist/loop-decision.js +1 -1
  17. package/dist/run/cassette.js +289 -206
  18. package/dist/run/chat.js +13 -13
  19. package/dist/run/doctor.js +7 -7
  20. package/dist/run/execute.js +69 -28
  21. package/dist/run/inspect-view.js +7 -1
  22. package/dist/run/renderer.js +4 -1
  23. package/dist/run/scaffold.js +13 -2
  24. package/dist/run/skill-files.js +4 -4
  25. package/dist/run/skill-hash.js +22 -18
  26. package/dist/run/trace-view.js +29 -5
  27. package/dist/run/verdict.js +1 -1
  28. package/dist/runtime/container.js +7 -1
  29. package/dist/runtime/hostloop-prompt.js +1 -1
  30. package/dist/runtime/image-capabilities.js +16 -2
  31. package/dist/runtime/protocol.js +11 -6
  32. package/dist/runtime/stage.js +4 -1
  33. package/dist/session.js +54 -8
  34. package/dist/sync/cowork-sync.js +18 -2
  35. package/dist/types.js +9 -4
  36. package/docs/cassette.md +5 -3
  37. package/docs/cowork-spawn-contract-1.12603.1.md +4 -4
  38. package/docs/discovery.md +1 -1
  39. package/docs/scenario.md +11 -0
  40. package/examples/README.md +1 -1
  41. package/examples/replays/README.md +2 -0
  42. package/examples/replays/example-pdf-skill.cassette.json +30 -30
  43. package/package.json +1 -1
  44. package/python/README.md +3 -1
  45. package/schema/cassette.v7.json +131 -0
  46. package/schema/scenario.schema.json +13 -7
package/CHANGELOG.md CHANGED
@@ -6,6 +6,125 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.18.0] — 2026-06-27
10
+
11
+ ### Fixed
12
+
13
+ - `sync` no longer flags an unknown delta when `coworkEgressAllowedHosts` is absent from
14
+ `config.json` (the normal state for a fresh install with no user-configured custom egress
15
+ hosts). Previously the absent key was treated the same as a wrong-typed value and blocked
16
+ the baseline write entirely.
17
+ - `sync` now regenerates the baseline `$comment` with the current capture date instead of
18
+ carrying the stale string forward from the prior baseline via the `...base` spread.
19
+ - A misplaced GLOBAL flag (the space form `--dotenv <path>` / `--run-dir <path>` placed *after* the
20
+ subcommand, where the pre-0.17.0 docs put `--dotenv`) now fails with a position hint —
21
+ `--dotenv is a GLOBAL flag and must come BEFORE the subcommand (e.g. \`cowork-harness --dotenv <path> doctor …\`)`
22
+ — instead of a bare `unknown flag: --dotenv` (or, for some commands, an unrelated positional /
23
+ "unexpected argument" error) that sent users hunting for a per-command flag that doesn't exist. The
24
+ hint honors `--output-format json`, never pre-empts `--version`/`--help`, and only fires for a known
25
+ subcommand (a junk command still gets the accurate "unknown command"). The `--dotenv=<path>` equals
26
+ form is not matched — to avoid hijacking a legitimate value like `--answer "--dotenv=x=y"` — so a
27
+ misplaced equals form still gets the plain unknown-flag rejection. (A bare `--dotenv`/`--run-dir`
28
+ token used as another flag's omitted value, e.g. `decide --question --dotenv`, is pre-empted by the
29
+ hint rather than the more specific "requires a value" error; both exit 2.)
30
+ - `doctor`'s no-token remedies now show `--dotenv` in its correct **leading** position
31
+ (`cowork-harness --dotenv <path> <cmd>`). The git-worktree remedy previously printed the pre-0.17.0
32
+ `<cmd> --dotenv` form — which the new position hint above now rejects — so `doctor` was suggesting a
33
+ command the harness refuses. The generic no-token remedy also now advertises the
34
+ `--dotenv <path> <cmd>` form, so pointing at a non-cwd `.env` is discoverable.
35
+ - `skill --help` / `run --help` now label `--run-dir` as a **GLOBAL** flag that must precede the
36
+ subcommand. It was listed in each command's local "Output:" flag block, implying
37
+ `skill … --run-dir <path>`, which the command rejects (`--run-dir` is honored only before the
38
+ subcommand, like `--dotenv`).
39
+
40
+ ### Changed
41
+
42
+ - Baseline bumped to `desktop-1.15962.0` (agent `2.1.187`). Content is unchanged from
43
+ `1.15200.0` — host-loop generator, system prompt, identity, gates, and egress domains are
44
+ all byte-identical per asar analysis. Version and fingerprint fields only. The live-contract
45
+ suite was re-run green against the staged `2.1.187` agent on this baseline, so the
46
+ "verified end-to-end" claims are earned. Cassettes recorded against the `1.15200.0` baseline will
47
+ report a non-failing baseline-drift warning on `replay` (a hard fail under `--strict` /
48
+ `verify-cassettes`); re-record to clear, or ignore it since the asar content is byte-identical.
49
+
50
+ ### Documentation
51
+
52
+ - Audit-validated doc/DX fixes: corrected the `docs/cassette.md` cassette-version example
53
+ (`6`→`7`); rewrote the `protocol-smoke` row to stop referencing a rejected
54
+ `transcript_no_host_path: false` line; scoped the SKILL.md "agent binary" prerequisite to the
55
+ sandboxed live tiers (protocol/replay need none); added `python3` to the README requirements
56
+ (the `lint` linter shells out to it); moved the `/plugin` slash-command block off the `bash`
57
+ fence; pinned `cowork-harness@>=0.17.0` in the CI recipe; noted that `chat` excludes
58
+ `microvm`/`cowork`; and other small corrections (see `docs/internal/2026-06-26-doc-audit-*`).
59
+ - Refreshed all verification/version stamps that still pinned `1.15200.0` / `2.1.181`
60
+ (README, DESIGN, SPEC, the spawn-contract reference, `docs/cassette.md` fingerprint example,
61
+ and the `hostloop-prompt.ts` re-verified comment) to `1.15962.0` / `2.1.187`.
62
+
63
+ ## [0.17.0] — 2026-06-26
64
+
65
+ ### Upgrade notes
66
+
67
+ - **Re-record all cassettes after upgrading** (`cowork-harness record cassettes/ --rerecord-stale`).
68
+ The skill-hash delimiter changed (v6 → v7); `verify-cassettes` reports which cassettes need it.
69
+ - **`transcript_no_host_path: false` is now rejected by the schema.** Remove the key or change it
70
+ to `true`. (It was never meaningful as `false`.)
71
+ - **`is_null: false` on an absent path now fails loud** instead of silently passing. Add
72
+ `exists: true` if you intend to assert presence before the null check.
73
+
74
+ ### Fixed
75
+
76
+ - **The microVM egress proxy port is now allocated via bind-port-0 instead of freePort().** The
77
+ previous approach (bind :0 → read port → close → re-bind real proxy) had a TOCTOU gap: another
78
+ process could grab the port between the probe close and the proxy bind. The proxy now binds on `:0`
79
+ directly; `actualPort` is read from the live socket and threaded into the guest firewall rule and
80
+ `HTTP(S)_PROXY` env after the proxy is already bound. L1 (container/hostloop) was unaffected
81
+ (uses a fixed port inside Docker's per-run network namespace); L0 (protocol) has no proxy.
82
+ - **Cassette fingerprint format is v7.** The skill-hash uses a NUL byte (`\0`) to delimit entries
83
+ (`F:`, `D:`, `L:`) — unambiguous for all POSIX-valid filenames. `CONTENTSIG_ALGO` is 3.
84
+ `verify-cassettes` reports `recorded under an older hash format (v6 → v7)` for stale cassettes;
85
+ re-record with `--rerecord-stale` to clear.
86
+ - **`transcript_no_host_path` only accepts `true`.** Omit the key to skip the check; `false` is
87
+ rejected by the schema (`const: true`).
88
+ - **`is_null: false` requires the path to be present.** An absent path fails loud. To assert "exists
89
+ and is not null", write `exists: true` alongside `is_null: false`.
90
+ - **The egress proxy no longer crashes on a double-end or EPIPE.** When an upstream TLS error
91
+ arrived after the response had already been ended (e.g. the client disconnected mid-stream),
92
+ calling `res.writeHead(502)` on an already-sent response threw, taking down the proxy for the
93
+ remainder of the run. The guard now skips the `writeHead` call when `res.headersSent` is true and
94
+ swallows the resulting EPIPE.
95
+ - **L0 (protocol) containment check now uses `realpathSync` to guard against symlink escape.** The
96
+ previous path comparison used the raw strings; a symlinked workspace folder could resolve outside
97
+ the declared root and the check would miss it.
98
+ - **Session `enabledPlugins` now emits the correct `{ "name@mp": true }` object-map** that
99
+ `claude --settings` requires, rather than an array of plugin-name strings that is silently ignored.
100
+ Plugin loading via `--settings` was silently broken for any session with `enabled_plugins:` set.
101
+ - **`probeMicrovmOmitted` no longer issues a `limactl shell` probe when the Lima VM is not Running.**
102
+ A cold (Absent) or stopped VM cannot be probed; the harness now skips the capability probe
103
+ entirely and returns `null` rather than trying to shell into a non-running instance.
104
+ - **`is_null: true` on an absent path now directs to `absent: true`** with a clear error rather than
105
+ silently treating absent-as-null (the two are semantically distinct: absent = the key doesn't
106
+ exist; null = it exists with a JSON null value).
107
+ - **Boundary `/host` probe split into two independent checks.** The previous single-command probe
108
+ could false-pass when the host filesystem was sealed but the `/host` directory existed and was
109
+ empty. The probe now AND-combines a listing check and a no-denial text check.
110
+ - **`--decider-llm` transport now bounded-retries a transient `claude -p` exit and surfaces *why* it
111
+ failed.** A single `claude -p` decider spawn can exit non-zero on a transient upstream hiccup
112
+ (rate-limit/overload/network) during a long back-to-back batch — observed 1/8 live runs, not reproducible
113
+ on demand. The non-zero-exit class is now retried (default 2 attempts, small linear backoff;
114
+ `COWORK_HARNESS_LLM_RETRIES=0` to disable) so a transient exit doesn't kill a 10-minute paid run at the
115
+ final gate. Retry never double-answers: the transport has no harness side effects, and a non-zero exit
116
+ delivers no answer, so the gate is answered exactly once downstream of a successful call. The timeout /
117
+ `maxBytes`-overflow / spawn-`ENOENT` classes are not transient and still fail loud on the first attempt.
118
+ (A *deterministic* non-zero exit — bad `--decider-model`, auth — is also retried the full count before
119
+ failing loud; the cost is bounded and the captured output names the cause.) The exit error now folds in the
120
+ child's captured **stdout** (where `claude -p` writes its operational diagnosis — verified) and stderr, so
121
+ `exited 1` is no longer undiagnosable.
122
+ - **`--decider-llm` now binds a markdown-/quote-wrapped `OTHER:` free-text directive.** A model often
123
+ code-fences a verbatim directive (`` `OTHER: …` ``, `"OTHER: …"`); the leading backtick/quote previously
124
+ defeated the `^\s*OTHER:` anchor and the gate whiffed → fail-loud stall (observed live). The sentinel now
125
+ matches on the `trimNearMiss` form (wrapping quotes/backticks stripped, `:` preserved), so a real
126
+ `OTHER:`-named option label still wins first via the exact-label tier.
127
+
9
128
  ## [0.16.0] — 2026-06-26
10
129
 
11
130
  ### Fixed
package/README.md CHANGED
@@ -17,6 +17,7 @@ Scriptable, CI-friendly test harness that reproduces **Claude Cowork's observabl
17
17
 
18
18
  > **Requirements at a glance**
19
19
  > - **Free demo (`replay`):** Node ≥ 20 — nothing else (no Docker, token, or Claude Desktop).
20
+ > - **`lint` (optional, token-free):** also needs **`python3`** on PATH — the scenario linter shells out to it (PyYAML is bundled); a missing `python3` is a hard `exit 127`.
20
21
  > - **Live tiers**: **Claude Desktop opened once** (stages the agent — nothing is bundled) · a **Claude token** (real per-run cost; runs take minutes — mint one with `claude setup-token`, which needs the **`claude` CLI**: `npm i -g @anthropic-ai/claude-code`) · plus a runtime — **Docker (arm64)** for `container` (default) / `hostloop`, or **Lima (Apple-VZ)** for `microvm`. The `protocol` tier skips the runtime + the staged agent but still calls a real model, so it needs the token. (`doctor --tier <t>` reports exactly what a given tier needs.)
21
22
  > - **Platform:** best on **macOS Apple Silicon**; **Windows is not supported** for the live tiers (use the token-free `replay`); `sync` and `microvm` are **macOS-arm64 only**. Full detail in [Quick start → prerequisites](#quick-start).
22
23
 
@@ -92,7 +93,7 @@ Skill testing is the headline use, but the tool is a general harness over the Co
92
93
  |---|---|---|
93
94
  | `skill <folder> "<prompt>"` | Run a local skill/plugin folder once against the staged agent | ad-hoc "is the skill alive / does it do X?" — the fast inner loop |
94
95
  | `run <scenario.yaml \| dir/>` | Run authored scenarios with `assert:` + a CI-ready exit code | you want a repeatable, **asserted regression test** |
95
- | `chat <folder> [prompt]` | Interactive multi-turn REPL against a skill (TTY); optional seed prompt is sent as the first turn. `--upload <file>` / `--folder <dir>` (repeatable) attach files/project folders; `--verbose` / `-V` shows thinking blocks + tool inputs; `--fidelity protocol\|container\|hostloop` | debugging a multi-turn flow by hand |
96
+ | `chat <folder> [prompt]` | Interactive multi-turn REPL against a skill (TTY); optional seed prompt is sent as the first turn. `--upload <file>` / `--folder <dir>` (repeatable) attach files/project folders; `--verbose` / `-V` shows thinking blocks + tool inputs; `--fidelity protocol\|container\|hostloop` (no `microvm`/`cowork` in the REPL) | debugging a multi-turn flow by hand |
96
97
  | `record` / `replay` | **Record a live run once → replay it token-free, Docker-free thereafter.** `record` saves a control-protocol cassette (one scenario, or batch a `dir/`; `--rerecord-stale` refreshes only drifted ones; `--concurrency <N>` records a batch N at a time — runs are fully isolated, so it just bounds Docker/API pressure; `--max-artifact-bytes` raises the 64 KiB inline-body cap; `--dry-run` previews the scenarios + token/binary checks without recording; answer gates **live during the recording** with `--decider-dir`/`--decider-llm`/`--on-unanswered first` instead of pre-scripting them — a live decider flags the cassette non-deterministic but it still replays deterministically); `replay <file.cassette.json \| dir/>` runs a cassette **file or a `dir/` of `*.cassette.json`** deterministically (`--strict` fails on any stale one, `--fail-on-skill-drift` only on skill-source drift; staleness is also reported per-result in `staleness[]` for a JSON gate; exits on the worst verdict) | **token-free, Docker-free CI** from a once-recorded run |
97
98
  | `verify-cassettes <file\|dir>` | Token-free CI gate over committed cassettes: a privacy scan (email/currency/domain → exit 1; whole-token allows via `--allow` / class-scoped `--allow-domain` / `--allow-email` / `--allow-file`) + a staleness check (`--skip-privacy` or `--skip-staleness` to run only one half). A dir argument scans `*.cassette.json` in that dir only (**non-recursive**) | gating **committed cassettes** against PII leaks + "edited the skill, forgot to re-record" |
98
99
  | `verify-run <run-dir> <scenario.yaml>` | Re-evaluate a scenario's `assert:` (and, when the scenario declares `answers:`, whether they still match the run's actual gates) against an already-kept run dir — **no live agent, no tokens, no Docker** (~1s) | iterating on a wrong assertion or a drifted `answer` without a full live re-record |
@@ -143,12 +144,12 @@ Only the committed-cassette `replay` above is fully self-contained. Live `run`/`
143
144
 
144
145
  This repo ships a **companion skill** (`.claude/skills/cowork-harness/`) that teaches an agent how to drive the harness — author scenarios, pick a fidelity tier, script answers, place assertions in the right CI lane, and avoid the "✓ passed ≠ correct" traps. Install it via the bundled marketplace — run these **in Claude Code** (slash commands, not your shell):
145
146
 
146
- ```bash
147
+ ```text
147
148
  /plugin marketplace add yaniv-golan/cowork-harness
148
149
  /plugin install cowork-harness@cowork-harness
149
150
  ```
150
151
 
151
- The skill **self-bootstraps the CLI**: if `cowork-harness` isn't on your PATH it falls back to `npx cowork-harness@>=0.16.0` (a version floor that fails loud rather than silently fetching a too-old CLI; Node ≥ 20). Tiers above `protocol` still need Docker/Lima and a Claude Desktop agent binary — see the prerequisites below.
152
+ The skill **self-bootstraps the CLI**: if `cowork-harness` isn't on your PATH it falls back to `npx cowork-harness@>=0.18.0` (a version floor that fails loud rather than silently fetching a too-old CLI; Node ≥ 20). Tiers above `protocol` still need Docker/Lima and a Claude Desktop agent binary — see the prerequisites below.
152
153
 
153
154
  It also follows the open [Agent Skills](https://github.com/vercel-labs/skills) spec, so it installs cross-editor (Cursor, Codex, OpenCode, …) by pointing the `npx skills` CLI at `.claude/skills/cowork-harness` in this repo. (Working *inside* this repo, the skill auto-loads as a project skill — no install needed.)
154
155
 
@@ -336,7 +337,7 @@ assert:
336
337
  # Relative paths below resolve from THIS file's dir (absolute and ~ are used as-is).
337
338
  model: claude-opus-4-8
338
339
  effort: high
339
- max_thinking_tokens: 8000
340
+ max_thinking_tokens: 8000 # kept low for example cost; real default 31999 (see docs/session.md)
340
341
  permission_mode: default
341
342
  permission_parity: cowork # cowork (allow unscripted tool calls, the default) | strict (deny unscripted)
342
343
  folders:
@@ -380,7 +381,7 @@ The harness builds a **clean managed `CLAUDE_CONFIG_DIR` per run** (with a gener
380
381
 
381
382
  ## What you get out (inspectable output)
382
383
 
383
- Every run writes to `~/.cowork-harness/runs/<scenario>/<sessionId>/` (out of any working tree; relocate with `--run-dir <path>` or `COWORK_HARNESS_RUNS_DIR`):
384
+ Every run writes to `~/.cowork-harness/runs/<scenario>/<sessionId>/` (out of any working tree; relocate with the global `--run-dir <path>` flag — it goes *before* the subcommand — or `COWORK_HARNESS_RUNS_DIR`):
384
385
 
385
386
  ```
386
387
  events.jsonl full stream-json event log (child→driver; the cassette source)
@@ -481,8 +482,8 @@ Most runs need **none** of these — the defaults are correct. They're grouped b
481
482
  - `COWORK_CONTAINER_RUNTIME=podman` — use Podman instead of Docker.
482
483
  - `COWORK_AGENT_IMAGE=<tag>` — override the agent image name (default `cowork-agent-base:2`); `COWORK_AGENT_BINARY=<path>` — override the auto-detected staged agent ELF.
483
484
  - `COWORK_SKIP_CAPABILITY_PROBE=1` — skip the per-run capability probe (the harness otherwise probes the agent image/VM for the document/OCR/Office capabilities the real Cowork rootfs ships and **fails a run that uses one the image omits** — a likely false negative; suppress per-scenario with `allow_missing_capability: true`, or rebuild full parity).
484
- - `COWORK_HARNESS_DECIDER_MODEL` — the default `--decider-llm` answering model (overridden by the `--decider-model` flag; falls back to the Haiku default). `COWORK_HARNESS_DECIDER_DIR_POLL_MS` / `_TIMEOUT_MS` — tune the `--decider-dir` rendezvous poll/backstop; `COWORK_HARNESS_DECIDER_CMD_TIMEOUT_MS` / `COWORK_HARNESS_LLM_TIMEOUT_MS` — backstop a hung `--decider-cmd` helper / `--decider-llm` model call (default 600 s, fail loud); `COWORK_HARNESS_DIALOG_TIMEOUT_MS` — override the 6 s dialog auto-cancel.
485
- - `COWORK_HARNESS_RUNS_DIR` (or the `--run-dir <path>` flag) — override the default run-output root `~/.cowork-harness/runs` (kept out of any working tree so sensitive skill inputs/outputs don't land in a repo). Precedence: `--run-dir` > env > default. The root is flat and machine-global (shared across projects); pinned `--session-id` runs are guarded against cross-project overwrite, and `prune` never prunes them. In CI, set it to a workspace path (e.g. `runs`) so artifact upload can collect the runs. `COWORK_HARNESS_ALLOW_FOREIGN_RESUME=1` overrides the guard that blocks `--resume` onto another project's pinned session.
485
+ - `COWORK_HARNESS_DECIDER_MODEL` — the default `--decider-llm` answering model (overridden by the `--decider-model` flag; falls back to the Haiku default). `COWORK_HARNESS_DECIDER_DIR_POLL_MS` / `_TIMEOUT_MS` — tune the `--decider-dir` rendezvous poll/backstop; `COWORK_HARNESS_DECIDER_CMD_TIMEOUT_MS` / `COWORK_HARNESS_LLM_TIMEOUT_MS` — backstop a hung `--decider-cmd` helper / `--decider-llm` model call (default 600 s, fail loud); `COWORK_HARNESS_LLM_RETRIES` — bounded retries for a transient non-zero `claude -p` exit in the `--decider-llm` transport (default 2, clamped to 0–10; set `0` to disable, e.g. deterministic CI); `COWORK_HARNESS_DIALOG_TIMEOUT_MS` — override the 6 s dialog auto-cancel.
486
+ - `COWORK_HARNESS_RUNS_DIR` (or the `--run-dir <path>` flag — a **global** flag that must precede the subcommand, like `--dotenv`) — override the default run-output root `~/.cowork-harness/runs` (kept out of any working tree so sensitive skill inputs/outputs don't land in a repo). Precedence: `--run-dir` > env > default. The root is flat and machine-global (shared across projects); pinned `--session-id` runs are guarded against cross-project overwrite, and `prune` never prunes them. In CI, set it to a workspace path (e.g. `runs`) so artifact upload can collect the runs. `COWORK_HARNESS_ALLOW_FOREIGN_RESUME=1` overrides the guard that blocks `--resume` onto another project's pinned session.
486
487
  - **Networking / loop:** `COWORK_EGRESS_PROXY` overrides the egress-proxy URL injected into the sandbox; `COWORK_PROXY_IMAGE` overrides the egress proxy Docker image name (default `cowork-egress-proxy:1`); `COWORK_DOCKER_NETWORK` pins the Docker network the agent container joins; `CLAUDE_FORCE_HOST_LOOP=1` forces the host-loop path regardless of the baseline's loop decision (the `cowork` tier's auto-pick). `COWORK_LIMACTL` overrides the `limactl` binary path (default `/opt/homebrew/bin/limactl`).
487
488
  - **Strictness escape hatches** (the harness fails loud by default): `COWORK_HARNESS_SOFT_MISSING=1` downgrades a missing mount source from a hard error to warn-and-exclude; `COWORK_HARNESS_ALLOW_CONFIG_DIR_WRITE=1` permits writing into an existing pinned `plugins.config_dir` (otherwise refused, to avoid clobbering a real Claude config).
488
489
  - **Staleness boundary:** the cassette-staleness skill hash uses the **git-tracked** file set of each skill/plugin source dir by default (a non-repo dir falls back to a raw walk; OS-junk like `.DS_Store` is always excluded). `COWORK_HARNESS_GITSET=0` opts out to the legacy raw walk for every dir. `COWORK_HARNESS_DEBUG_SKILLHASH=1` dumps the exact file set feeding the hash on a staleness mismatch (and flags OS-junk) so a drift source is one line. Declare per-plugin non-runtime paths in a `.cowork-hashignore` file (or the session `staleness.hash_ignore`). `COWORK_HARNESS_AGENT_SCOPE=skill` (opt-in) refines a scenario's `skills:` scope so a **skill-named** `agents/<name>.md` re-stales only that skill's cassettes instead of the whole fleet (generic agents stay shared; stamped into the fingerprint, so flipping it is a one-time re-record like `GITSET`).
@@ -565,7 +566,7 @@ This repo is built to be driven by agents, not just read by humans:
565
566
 
566
567
  ## Status
567
568
 
568
- **Verified end-to-end against the live staged agent (2.1.181 / asar 1.15200.0).** Host-side parity (egress/gates/mount/web_fetch facts + the reconstructed prompt appends) re-derived for 1.15200.0 via `sync` + asar inspection; the live scenario suite passes against this baseline (the staged agent ELF is unchanged at 2.1.181).
569
+ **Verified end-to-end against the live staged agent (2.1.187 / asar 1.15962.0).** Host-side parity (egress/gates/mount/web_fetch facts + the reconstructed prompt appends) re-derived for 1.15962.0 via `sync` + asar inspection; the live scenario suite passes against this baseline (the 1.15962.0 asar content is byte-identical to 1.15200.0 — all runtime-shape fields unchanged).
569
570
 
570
571
  - ✅ **Three isolation tiers (L0/L1/L2) + two loop-mode overlays** — `protocol` (L0 control loop), `container` (L1 sandboxed arm64 + per-run default-deny egress sidecar), `microvm` (L2 real Apple-VZ Linux microVM + guest firewall); plus the loop-mode overlays `hostloop` (production split-execution: agent loop on host, shell/web via the workspace SDK-MCP server) and `cowork` (auto-picks host-loop vs container the way Cowork does — gate `1143815894`). Egress enforced at container/microvm/hostloop; `boundary-check` reports **ALL CONSTRAINTS ENFORCED**.
571
572
  - ✅ **Three-seam driver** — `AgentSession` (control protocol) → `Decider` (scripted + `on_unanswered` policy, no silent false-greens) → `Run` (turn loop, sub-agent dispatch tree, `RunRecord`). Multi-turn `chat`, deterministic cassette `record`/`replay` (no token), `run.jsonl`/`trace.json` logging with secret-scrub.
@@ -0,0 +1,220 @@
1
+ {
2
+ "baselineVersion": 1,
3
+ "appVersion": "1.15962.0",
4
+ "agentVersion": "2.1.187",
5
+ "agentBinary": {
6
+ "stagedPath": "~/Library/Application Support/Claude/claude-code-vm/2.1.187/claude",
7
+ "format": "elf-aarch64"
8
+ },
9
+ "guest": {
10
+ "os": "linux",
11
+ "arch": "arm64",
12
+ "baseImage": "ubuntu:22.04"
13
+ },
14
+ "spawn": {
15
+ "configDirInGuest": "mnt/.claude",
16
+ "settingSources": [
17
+ "user"
18
+ ],
19
+ "permissionMode": "default",
20
+ "maxThinkingTokens": 31999,
21
+ "effortDefault": "medium",
22
+ "tools": [
23
+ "Task",
24
+ "Bash",
25
+ "Glob",
26
+ "Grep",
27
+ "Read",
28
+ "Edit",
29
+ "Write",
30
+ "NotebookEdit",
31
+ "WebFetch",
32
+ "TaskCreate",
33
+ "TaskUpdate",
34
+ "TaskGet",
35
+ "TaskList",
36
+ "TaskStop",
37
+ "WebSearch",
38
+ "Skill",
39
+ "REPL",
40
+ "JavaScript",
41
+ "AskUserQuestion",
42
+ "ToolSearch"
43
+ ],
44
+ "allowedTools": [
45
+ "Task",
46
+ "Bash",
47
+ "Glob",
48
+ "Grep",
49
+ "Read",
50
+ "Edit",
51
+ "Write",
52
+ "NotebookEdit",
53
+ "WebFetch",
54
+ "TaskCreate",
55
+ "TaskUpdate",
56
+ "TaskGet",
57
+ "TaskList",
58
+ "TaskStop",
59
+ "WebSearch",
60
+ "Skill",
61
+ "REPL",
62
+ "JavaScript",
63
+ "ToolSearch"
64
+ ],
65
+ "env": {
66
+ "CLAUDE_CODE_IS_COWORK": "1",
67
+ "CLAUDE_CODE_ENTRYPOINT": "local-agent",
68
+ "CLAUDE_CODE_TAGS": "lam_session_type:chat",
69
+ "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST": "1",
70
+ "CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL": "true",
71
+ "CLAUDE_CODE_DISABLE_CRON": "1",
72
+ "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS": "1",
73
+ "CLAUDE_CODE_DISABLE_AGENTS_FLEET": "1",
74
+ "CLAUDE_CODE_ENABLE_APPEND_SUBAGENT_PROMPT": "1",
75
+ "CLAUDE_CODE_ENABLE_TASKS": "true",
76
+ "CLAUDE_CODE_DISABLE_TERMINAL_TITLE": "1",
77
+ "ENABLE_PROMPT_CACHING_1H": "1",
78
+ "DISABLE_MICROCOMPACT": "1",
79
+ "MCP_CONNECTION_NONBLOCKING": "true"
80
+ },
81
+ "promptTemplate": "prompts/desktop-1.15200.0/system-prompt-append.md",
82
+ "subagentAppend": "prompts/desktop-1.15200.0/subagent-append-vm.md",
83
+ "$comment": "Binary-verified Desktop->agent spawn contract (asar 1.12603.1). See docs/cowork-spawn-contract-1.12603.1.md.",
84
+ "$comment_notSet": "Deliberately NOT set: CLAUDE_CODE_USE_COWORK_PLUGINS (Desktop never sets it; would flip the agent to cowork_settings.json/cowork_plugins). Host-derived (TZ, account UUIDs, WORKSPACE_HOST_PATHS, OTEL) injected at runtime, not pinned.",
85
+ "$comment_prompts": "Reconstructed cowork-specific sections, re-paraphrased from asar 1.15200.0 const MZi (system prompt) + generator CVr (subagent) — not the full base prompt (not cleanly extractable), and excluding the generic refusal/safety tail. The main append is delivered via the --append-system-prompt CLI flag (layered on the agent's built-in base prompt), NOT the initialize handshake; only the subagent append goes over initialize (appendSubagentSystemPrompt), gated on CLAUDE_CODE_ENABLE_APPEND_SUBAGENT_PROMPT."
86
+ },
87
+ "mountLayout": {
88
+ "sessionRoot": "/sessions/{sessionId}",
89
+ "cwd": "/sessions/{sessionId}",
90
+ "mntRoot": "/sessions/{sessionId}/mnt",
91
+ "mounts": [
92
+ {
93
+ "name": "uploads",
94
+ "mountPath": "uploads",
95
+ "mode": "r",
96
+ "purpose": "user-uploaded files (read-only — asar 'ro')"
97
+ },
98
+ {
99
+ "name": "projects",
100
+ "mountPath": ".projects/{projectId}",
101
+ "mode": "rw",
102
+ "purpose": "RESERVED namespace (and the separate UUID project-sync feature) — NOT the work-folder path. From Desktop 1.14271.0 selected work folders mount at mnt/<collision-resolved-basename> (dynamic, derived per session by buildLaunchPlan; see MOUNT_BARE_NAME_MIN_VERSION). This decorative row is not consumed for binding (staged paths come from plan.mounts)."
103
+ },
104
+ {
105
+ "name": "local-plugins",
106
+ "mountPath": ".local-plugins/marketplaces",
107
+ "mode": "r",
108
+ "purpose": "marketplace skills/plugins, runtime-discovered"
109
+ },
110
+ {
111
+ "name": "remote-plugins",
112
+ "mountPath": ".remote-plugins",
113
+ "mode": "r",
114
+ "purpose": "org-remote plugins, runtime-discovered"
115
+ },
116
+ {
117
+ "name": "outputs",
118
+ "mountPath": "outputs",
119
+ "mode": "rw",
120
+ "purpose": "session outputs/artifacts — delete denied by default (asar IX); rwd only when approved"
121
+ }
122
+ ]
123
+ },
124
+ "network": {
125
+ "mode": "gvisor",
126
+ "allowKind": "allowlist",
127
+ "allowDomains": [
128
+ "sentry.io",
129
+ "preview.claude.ai",
130
+ "downloads.claude.ai",
131
+ "api.anthropic.com",
132
+ "a-cdn.anthropic.com",
133
+ "a-api.anthropic.com",
134
+ "assets.claude.ai",
135
+ "console.anthropic.com",
136
+ "api-staging.anthropic.com",
137
+ "www.anthropic.com",
138
+ "docs.anthropic.com",
139
+ "mcp-proxy.anthropic.com",
140
+ "pivot.claude.ai",
141
+ "support.anthropic.com"
142
+ ]
143
+ },
144
+ "bgEnvStrip": {
145
+ "knownVars": [
146
+ "CLAUDE_CODE_OAUTH_TOKEN",
147
+ "CLAUDE_CODE_SESSION_KIND",
148
+ "CLAUDE_CODE_SESSION_ID",
149
+ "CLAUDE_CODE_SESSION_NAME",
150
+ "CLAUDE_CODE_SESSION_LOG"
151
+ ]
152
+ },
153
+ "$comment": "Platform baseline auto-derived by `cowork-harness sync` from a live Claude Desktop install + app.asar. VOLATILE per-release facts only. Regenerate per release; review the diff. Captured 2026-06-26 on macOS arm64.",
154
+ "capturedAt": "2026-06-26",
155
+ "platform": "darwin-arm64",
156
+ "settings": {
157
+ "autoMountFolders": {
158
+ "key": "autoMountFolders",
159
+ "default": false
160
+ },
161
+ "localAgentModeTrustedFolders": {
162
+ "key": "localAgentModeTrustedFolders",
163
+ "default": []
164
+ }
165
+ },
166
+ "provenance": {
167
+ "asarPath": "/Applications/Claude.app/Contents/Resources/app.asar",
168
+ "asarFingerprint": "0fe570e50d97404f",
169
+ "gates": {
170
+ "$comment": "Production GrowthBook gate states decoded from ~/Library/Application Support/Claude/fcache (standard interactive Anthropic account, 2026-06-13; binary-verified app.asar 1.12603.1). Pin per release. Behavior-affecting gates the harness models: 1143815894 (loop), 1648655587 (dispatch cap), 1978029737 (web_fetch routing). Telemetry/auth-internal gates omitted.",
171
+ "bridgeSdkTransport:583857784": {
172
+ "on": true,
173
+ "source": "force",
174
+ "value": true,
175
+ "note": "— Cowork uses the SDK-based transport (control protocol), confirming the harness's sdkMcpServers/mcp_message path is the production transport."
176
+ },
177
+ "hostLoop:1143815894": {
178
+ "on": true,
179
+ "source": "force",
180
+ "value": true
181
+ },
182
+ "taskDispatchLimiter:1648655587": {
183
+ "on": true,
184
+ "source": "force",
185
+ "value": {
186
+ "perTask": 1,
187
+ "global": 3
188
+ },
189
+ "note": "perTask=1 global=3 (host-side SKIP: recordSkipAndEmit/GCA.PerTaskLimit — NOT queue/deny). A dispatch session launches <=1 sub-task; <=3 concurrent globally."
190
+ },
191
+ "coworkRuntimeConfig:1978029737": {
192
+ "on": true,
193
+ "source": "force",
194
+ "value": {
195
+ "sessionsBridgePollBlockMs": 30,
196
+ "coworkNativeFilePreview": true,
197
+ "coworkWebFetchViaApi": true,
198
+ "coworkWebFetchPrompt": true,
199
+ "workspaceBashWaitLonger": true
200
+ },
201
+ "note": "coworkWebFetchViaApi=true coworkWebFetchPrompt=true workspaceBashWaitLonger=true sessionsBridgePollBlockMs=30 — web_fetch is host/API-routed (POST /api/organizations/<org>/cowork/web_fetch), NOT container egress; gated by a separate web-fetch hostname allowlist + URL provenance."
202
+ },
203
+ "cliPlugin:2307090146": {
204
+ "on": false,
205
+ "source": "defaultValue",
206
+ "value": false,
207
+ "note": "— the CLI-plugin credential broker is dark-launched off for standard interactive accounts (Ch23/L106)."
208
+ },
209
+ "pluginSyncSparkplug:2340532315": {
210
+ "on": true,
211
+ "source": "force",
212
+ "value": true,
213
+ "note": "— startup syncPlugins(); plugins load via --plugin-dir (registry inert in-VM)."
214
+ }
215
+ },
216
+ "eipcChannelUuid": "4f426349-8d6f-45f3-ae22-280fef323564",
217
+ "$comment": "eipcChannelUuid is per-build; recorded for provenance only — the harness does not use Desktop IPC."
218
+ },
219
+ "requireFullVmSandbox": null
220
+ }
@@ -153,8 +153,16 @@ export function deserializeDecision(req, body) {
153
153
  if (req.kind === "question") {
154
154
  // AskUserQuestion: body is { behavior:"allow", updatedInput:{ questions, answers } }
155
155
  // We read `answers` back; `questions` was preserved in recording for the O7 guard.
156
+ // Validate `answers` instead of a blind cast — a non-object, array, or answers map with
157
+ // non-string values coerces to {} which will NOT re-serialize to the recorded body, so the
158
+ // O7 replay_protocol_fidelity guard trips loud rather than silently coercing corrupt data.
156
159
  const ui = (body.updatedInput ?? {});
157
- return { kind: "question", answers: (ui.answers ?? {}) };
160
+ const raw = ui.answers;
161
+ const isValidAnswers = raw !== null &&
162
+ typeof raw === "object" &&
163
+ !Array.isArray(raw) &&
164
+ Object.values(raw).every((v) => typeof v === "string");
165
+ return { kind: "question", answers: isValidAnswers ? raw : {} };
158
166
  }
159
167
  if (req.kind === "dialog") {
160
168
  return {
@@ -288,7 +296,13 @@ export class LiveAgentSession {
288
296
  yield { type: "raw", line };
289
297
  continue;
290
298
  }
291
- yield* this.translate(msg);
299
+ try {
300
+ yield* this.translate(msg);
301
+ }
302
+ catch (e) {
303
+ yield { type: "error", source: "protocol", message: e?.message ?? String(e) };
304
+ return;
305
+ }
292
306
  }
293
307
  // stdout closed. Give a pending 'exit' one tick to land (NOT a blocking wait on 'close' — a
294
308
  // mock/fake child may never emit it), then surface a nonzero/signal exit as a typed error — a
package/dist/assert.js CHANGED
@@ -163,12 +163,25 @@ function check(a, ctx) {
163
163
  results.push(ctx.toolResultTexts.some((t) => t.includes(a.tool_result_contains))
164
164
  ? ok()
165
165
  : fail(`no tool result contained "${a.tool_result_contains}"`));
166
- if (a.tool_result_not_contains !== undefined)
167
- results.push(ctx.toolResultsMissing
168
- ? fail(`evidence unavailable: tool results absent from result.json — cannot evaluate tool_result_not_contains`)
169
- : ctx.toolResultTexts.every((t) => !t.includes(a.tool_result_not_contains))
170
- ? ok()
171
- : fail(`a tool result unexpectedly contained "${a.tool_result_not_contains}"`));
166
+ if (a.tool_result_not_contains !== undefined) {
167
+ if (ctx.toolResultsMissing) {
168
+ results.push(fail(`evidence unavailable: tool results absent from result.json — cannot evaluate tool_result_not_contains`));
169
+ }
170
+ else {
171
+ const forbidden = a.tool_result_not_contains;
172
+ const positiveHit = ctx.toolResultTexts.some((t) => t.includes(forbidden));
173
+ if (positiveHit) {
174
+ results.push(fail(`a tool result unexpectedly contained "${forbidden}"`));
175
+ }
176
+ else {
177
+ const hasTruncatedAbsence = ctx.toolResultsTruncated !== undefined &&
178
+ ctx.toolResultTexts.some((t, i) => !t.includes(forbidden) && ctx.toolResultsTruncated[i] === true);
179
+ results.push(hasTruncatedAbsence
180
+ ? fail(`evidence unavailable: one or more tool results are display-truncated (no assertText) — cannot rule out forbidden substring`)
181
+ : ok());
182
+ }
183
+ }
184
+ }
172
185
  // Fuzzy content for stochastic prose. All regex-building assertions are try/catch-wrapped —
173
186
  // `evaluate()` is a bare `.map(check)` with no error boundary, so a malformed pattern must be a
174
187
  // clean assertion failure, not an uncaught throw. Case-insensitive ("i").
@@ -428,8 +441,13 @@ function check(a, ctx) {
428
441
  }
429
442
  if (aj.is_null !== undefined) {
430
443
  any = true;
431
- const isNull = present && val === null;
432
- results.push(isNull === aj.is_null ? ok() : fail(`artifact_json: "${aj.path}" is_null=${isNull}, expected ${aj.is_null}`));
444
+ if (!present) {
445
+ results.push(fail(`artifact_json: "${aj.path ?? "(root)"}" is_null: path is absent — cannot determine null-ness (use absent: true to assert absence)`));
446
+ }
447
+ else {
448
+ const isNull = val === null;
449
+ results.push(isNull === aj.is_null ? ok() : fail(`artifact_json: "${aj.path ?? "(root)"}" is_null=${isNull}, expected ${aj.is_null}`));
450
+ }
433
451
  }
434
452
  if (aj.equals !== undefined) {
435
453
  any = true;
package/dist/baseline.js CHANGED
@@ -151,7 +151,11 @@ export function resolveMounts(baseline, sessionId, projectId = "proj1") {
151
151
  const subst = (s) => s.replace("{sessionId}", sessionId).replace("{projectId}", projectId);
152
152
  const cwd = subst(baseline.mountLayout.cwd);
153
153
  const sessionRoot = subst(baseline.mountLayout.sessionRoot);
154
- const mntRoot = subst(baseline.mountLayout.mntRoot ?? `${baseline.mountLayout.sessionRoot}/mnt`);
154
+ // TODO: container.ts:31 computes configGuest independently from sessionRoot and is NOT fixed here;
155
+ // the legacy desktop-1.11847.5 baseline (sessionRoot ending in /mnt, no spawn block) still produces
156
+ // a double-mnt configGuest path (/sessions/<id>/mnt/mnt/.claude) — that is a separate out-of-scope issue.
157
+ const rawSessionRoot = baseline.mountLayout.sessionRoot;
158
+ const mntRoot = subst(baseline.mountLayout.mntRoot ?? (rawSessionRoot.endsWith("/mnt") ? rawSessionRoot : `${rawSessionRoot}/mnt`));
155
159
  return {
156
160
  cwd,
157
161
  sessionRoot,
package/dist/boundary.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { mkdtempSync } from "node:fs";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir, userInfo, homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { startEgressSidecar } from "./egress/sidecar.js";
@@ -20,7 +20,8 @@ export function runBoundaryChecks(baseline, session) {
20
20
  // Stand up the real per-run boundary (internal network + allowlist proxy), exactly
21
21
  // what a container-fidelity scenario uses. Tear it down at the end.
22
22
  const runId = `bchk${process.hrtime.bigint().toString(36)}`;
23
- const sidecar = startEgressSidecar(boundaryAllowList(baseline, session), mkdtempSync(join(tmpdir(), "cowork-bchk-")), runId);
23
+ const tmpDir = mkdtempSync(join(tmpdir(), "cowork-bchk-"));
24
+ const sidecar = startEgressSidecar(boundaryAllowList(baseline, session), tmpDir, runId);
24
25
  const network = sidecar.network;
25
26
  const proxy = sidecar.proxyUrl;
26
27
  // Probes can throw (spawnSync setup errors, etc.); tear the sidecar down in `finally` so an unexpected
@@ -41,15 +42,20 @@ export function runBoundaryChecks(baseline, session) {
41
42
  shell,
42
43
  ], { encoding: "utf8", timeout: 30_000 });
43
44
  // 1. Host filesystem is NOT visible (no /Users, no host home bind).
45
+ // TWO independent probes so a leak on EITHER path fails the check — a combined-string
46
+ // probe can false-pass when one path leaks (real listing) but the other path's denial
47
+ // substring satisfies isHostFsSealed's `denied` regex on the joined output.
44
48
  {
45
- const r = probe(`ls /Users 2>&1 || true; ls /host 2>&1 || true`);
46
- const out = (r.stdout ?? "") + (r.stderr ?? "");
47
- const blocked = isHostFsSealed(out);
49
+ const combine = (r) => (r.stdout ?? "") + (r.stderr ?? "");
50
+ const outUsers = combine(probe("ls /Users 2>&1 || true"));
51
+ const outHost = combine(probe("ls /host 2>&1 || true"));
52
+ const blocked = isHostFsSealed(outUsers) && isHostFsSealed(outHost);
53
+ const detail = (outUsers + "\n" + outHost).trim().slice(0, 200);
48
54
  results.push({
49
55
  check: "host-fs-sealed",
50
56
  expectation: "host paths (/Users, /host) invisible",
51
57
  pass: blocked,
52
- detail: out.trim().slice(0, 200),
58
+ detail,
53
59
  });
54
60
  }
55
61
  // 2. Direct (non-proxied) egress is impossible — no route off the internal net.
@@ -89,6 +95,12 @@ export function runBoundaryChecks(baseline, session) {
89
95
  }
90
96
  finally {
91
97
  sidecar.teardown();
98
+ try {
99
+ rmSync(tmpDir, { recursive: true, force: true });
100
+ }
101
+ catch {
102
+ /* best-effort: bind-mounted log may be root-owned on Linux */
103
+ }
92
104
  }
93
105
  }
94
106
  /** Escape regex metacharacters in a literal so it can be embedded in a RegExp. */