cowork-harness 0.8.0 → 0.9.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 (44) hide show
  1. package/.claude/skills/cowork-harness/scripts/assertion-keys.json +5 -0
  2. package/.claude/skills/cowork-harness/scripts/scenario.py +33 -4
  3. package/CHANGELOG.md +82 -0
  4. package/README.md +28 -15
  5. package/dist/agent/session.js +9 -1
  6. package/dist/assert.js +7 -7
  7. package/dist/cli.js +12 -15
  8. package/dist/decide/decider.js +25 -5
  9. package/dist/egress/sidecar.js +75 -4
  10. package/dist/run/cassette.js +158 -33
  11. package/dist/run/chat.js +18 -1
  12. package/dist/run/doctor.js +35 -11
  13. package/dist/run/execute.js +66 -7
  14. package/dist/run/renderer.js +14 -1
  15. package/dist/run/run.js +41 -1
  16. package/dist/run/skill-files.js +92 -0
  17. package/dist/run/skill-hash.js +97 -91
  18. package/dist/run/verdict.js +57 -5
  19. package/dist/runtime/protocol.js +7 -2
  20. package/dist/runtime/stage.js +7 -2
  21. package/dist/session.js +5 -1
  22. package/dist/types.js +19 -1
  23. package/docs/README.md +5 -0
  24. package/docs/boundary.md +17 -2
  25. package/docs/cassette.md +43 -19
  26. package/docs/chat.md +2 -2
  27. package/docs/cowork-spawn-contract-1.12603.1.md +1 -1
  28. package/docs/decider-dir.md +4 -4
  29. package/docs/discovery.md +31 -3
  30. package/docs/fidelity-gaps.md +3 -1
  31. package/docs/maintenance.md +15 -10
  32. package/docs/scenario.md +52 -4
  33. package/docs/session.md +9 -7
  34. package/examples/README.md +2 -2
  35. package/examples/replays/README.md +2 -2
  36. package/package.json +5 -1
  37. package/python/README.md +16 -6
  38. package/python/cowork_harness.py +1 -1
  39. package/python/test_scenario_lint.py +86 -0
  40. package/schema/cassette.v5.json +107 -0
  41. package/schema/cassette.v6.json +107 -0
  42. package/schema/run-result.json +18 -0
  43. package/schema/scenario.schema.json +8 -1
  44. package/scripts/gen-schema.ts +4 -1
@@ -30,5 +30,10 @@
30
30
  "transcript_not_contains",
31
31
  "transcript_not_matches",
32
32
  "user_visible_artifact"
33
+ ],
34
+ "verdictModifierKeys": [
35
+ "allow_l0_plugin_divergence",
36
+ "allow_missing_capability",
37
+ "allow_permissive_auto_allow"
33
38
  ]
34
39
  }
@@ -38,7 +38,11 @@ import re
38
38
  import sys
39
39
  from pathlib import Path
40
40
 
41
- # --- the replay-class taxonomy (mirrors `contentKeys` in src/run/cassette.ts) ---
41
+ # --- the replay-class taxonomy ---
42
+ # NB: this is NOT a 1:1 mirror of `contentKeys` in src/run/cassette.ts. cassette.ts keeps the verdict
43
+ # modifiers (VERDICT_MODIFIER_KEYS) in its content set so they replay as no-op passes; the linter
44
+ # deliberately keeps them OUT of CONTENT_KEYS so a modifier-only scenario still trips the `replay-noop`
45
+ # warning below (a no-op pass verifies nothing real — exactly what that warning is for).
42
46
  CONTENT_KEYS = {
43
47
  "result",
44
48
  "transcript_contains",
@@ -286,8 +290,9 @@ def lint_doc(doc, path, raw_lines):
286
290
  "WARN",
287
291
  "replay-noop",
288
292
  "every assertion is live-only (egress / no_delete_in_outputs / self_heal_ran / "
289
- "transcript_no_host_path) — on the token-free `replay` lane they are ALL silently "
290
- "skipped, so a replay PR gate would verify nothing.",
293
+ "transcript_no_host_path) or a verdict modifier (allow_*, a no-op pass) — on the "
294
+ "token-free `replay` lane the live-only ones are silently skipped and the verdict "
295
+ "modifiers verify nothing, so a replay PR gate would verify nothing.",
291
296
  "Add a content assertion (result / transcript_* / tool_* / subagent_*) or a "
292
297
  "manifest-backed one (file_exists / user_visible_artifact / artifact_json), or run this "
293
298
  "scenario only on the live (run/record) lane.",
@@ -423,6 +428,30 @@ def _print_findings(findings, n_files):
423
428
 
424
429
  def cmd_lint(args):
425
430
  all_findings = []
431
+ # Expand directory args to their scenario files — mirrors src/run/inputs.ts `resolveInputs`: a SINGLE
432
+ # combined-sorted `*.yaml` + `*.yml` listing (non-recursive), a single file kept as-is, and an EMPTY dir
433
+ # as a loud ERROR (never a vacuous "0 files = clean"). Done in place so the lint loop AND the count below
434
+ # both see the expanded list.
435
+ expanded = []
436
+ for arg in args.files:
437
+ p = Path(arg)
438
+ if p.is_dir():
439
+ matches = sorted(str(q) for q in (list(p.glob("*.yaml")) + list(p.glob("*.yml"))))
440
+ if matches:
441
+ expanded.extend(matches)
442
+ else:
443
+ all_findings.append(
444
+ Finding(
445
+ "ERROR",
446
+ "no-scenarios",
447
+ f"no .yaml/.yml files under {arg} — nothing to do (loud non-zero, not a vacuous pass)",
448
+ "Point lint at a scenario file or a directory containing *.yaml / *.yml scenarios.",
449
+ arg,
450
+ )
451
+ )
452
+ else:
453
+ expanded.append(arg)
454
+ args.files = expanded
426
455
  # Linter self-check (B3): a valid schema key the replay-class sets don't classify can't be linted
427
456
  # correctly — surface it as a hard ERROR so it fails the gate (and --strict) until someone classifies it.
428
457
  if UNCLASSIFIED_KEYS:
@@ -602,7 +631,7 @@ def main(argv=None):
602
631
  sub = ap.add_subparsers(dest="command", required=True)
603
632
 
604
633
  lp = sub.add_parser("lint", help="lint scenario(s) for silent-false-green invariants")
605
- lp.add_argument("files", nargs="+", help="scenario YAML file(s) to lint")
634
+ lp.add_argument("files", nargs="+", help="scenario YAML file(s) or director(ies) of *.yaml/*.yml to lint")
606
635
  lp.add_argument("--json", action="store_true", help="emit findings as JSON")
607
636
  lp.add_argument("--strict", action="store_true", help="exit non-zero on WARN/INFO too, not just ERROR")
608
637
  lp.set_defaults(func=cmd_lint)
package/CHANGELOG.md CHANGED
@@ -6,6 +6,88 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.0] — 2026-06-22
10
+
11
+ ### Breaking changes
12
+
13
+ - **Cassette staleness fingerprint bumped to format v6 (re-record once).** The skill-hash boundary changed,
14
+ so committed cassettes recorded before this release report `recorded under an older hash format — re-record
15
+ once`. Drivers: (1) **OS-junk** files (`.DS_Store` / `Thumbs.db` / `desktop.ini`) are excluded from
16
+ `skillHash` — an out-of-band OS metadata touch can no longer re-stale a cassette (the "fresh cassette is
17
+ immediately stale" bug); (2) **`contentSig` is unified onto the same walk as `skillHash`** (same file set,
18
+ plugin.json `version` stripped, in-tree symlinks hashed by target) — `rehash` cannot bridge this algorithm
19
+ change, so a pre-v6 cassette gets an honest *"algorithm changed — re-record"* (not "content changed"); (3)
20
+ the **git-tracked file set is the default boundary** (see Added).
21
+ - **Git-tracked staleness/mount boundary is now the DEFAULT.** When a skill/plugin source dir is in a git
22
+ work tree, both the staleness hash and the sandbox mount use only its **git-tracked** files (untracked
23
+ scratch / build output / OS-junk are excluded from both, so they can't drift the hash or leak into the
24
+ sandbox). A dir that isn't a git repo falls back to the raw walk automatically. Opt out with
25
+ `COWORK_HARNESS_GITSET=0`.
26
+ - **Removed the legacy CLI aliases.** `assert` → use `assertions`; `replay --cassette <file>` → pass the
27
+ path positionally (`replay <file | dir/>`); `verify-cassettes --privacy-only` / `--staleness-only` →
28
+ `--skip-privacy` / `--skip-staleness`. (0.8.0 had documented the latter two groups as renamed/removed but
29
+ the code still accepted the old forms; they are now gone. Each removed alias exits `2` if used.)
30
+ - **`assertions --list --output-format json` now reports `command: "assertions"`** (was the stale
31
+ `"assert"`) — a JSON-envelope contract fix for anything keying on the `command` field.
32
+ - **`decide` exits `2` (not `1`) on a runtime error**, matching the documented "usage / runtime → `2`"
33
+ contract. No-match / abstain still exits `1`.
34
+
35
+ ### Added
36
+
37
+ - **`cowork-harness/secrets` package export** — `scrubField` and `collectSecrets` are now importable as a
38
+ declared subpath (`import { scrubField, collectSecrets } from "cowork-harness/secrets"`) for custom
39
+ redaction pipelines, with the documented usage corrected to `scrubField(value, collectSecrets())` (a bare
40
+ `[token]` array misses secrets embedded in encoded fields). Adding the `exports` map also **bounds the
41
+ package's public surface to this one subpath** — deep imports into `dist/` (`cowork-harness/dist/...`),
42
+ previously resolvable by accident, are now private. The CLI (`bin`) is unaffected.
43
+ - **`lint` accepts a directory** — it expands to the directory's `*.yaml` / `*.yml` scenarios
44
+ (non-recursive, sorted), the same file-or-dir ergonomics as `replay` / `verify-cassettes`. An empty
45
+ directory is a loud error, never a vacuous "0 files = clean" pass.
46
+ - **Staleness now names the EXACT changed file.** A per-file manifest (`fileSigs`) in the cassette fingerprint
47
+ lets `verify-cassettes` report e.g. `skill files changed since record — 1 changed (skills/x/SKILL.md)`
48
+ instead of a coarse bucket message (appended to the existing shared-vs-scoped diagnosis). Manifest paths are
49
+ root-relative and are scanned + redacted with the same privacy layer as `skillSources`. Omitted (with a
50
+ loud `fileSigsOmitted`) above an internal size cap.
51
+ - **`COWORK_HARNESS_DEBUG_SKILLHASH=1`** — on a staleness mismatch, dumps the exact file set feeding the hash
52
+ to stderr and flags OS-junk, so a drift source is one line instead of a black-box hunt (a one-line hint
53
+ points to it when the flag is off).
54
+ - **`COWORK_HARNESS_GITSET=0`** — opt out of the new default git-tracked boundary (see Breaking) back to the
55
+ legacy raw filesystem walk for every dir.
56
+ - **`requires_capabilities` scenario assertion** — fail a scenario unless the running tier provides *and can
57
+ verify* the declared capability families (e.g. `office_convert`, `pdf_tables`). The unmet set is persisted
58
+ in the run result (`requiresCapabilityUnmet`), so `verify-run` can't false-fail; opt out with the
59
+ `allow_missing_capability` verdict modifier when the skill's fallback is genuinely equivalent.
60
+ - **LLM decider `OTHER:` free-text directive** — on an options-bearing gate, a decider answer of
61
+ `OTHER: <text>` is matched to a label first, else passed through as free text; a bare out-of-set value
62
+ still fails loud.
63
+
64
+ ### Fixed
65
+
66
+ - **`doctor --tier microvm` now checks the right prerequisites.** It previously probed the Docker daemon +
67
+ agent image + egress-proxy image for every live tier, but the `microvm` (L2) tier runs on **Lima / Apple
68
+ Virtualization.framework**, not Docker — so it could report "not ready" on a Lima-only host, or "ready"
69
+ with no Lima installed. `microvm` now checks `limactl` (honoring `COWORK_LIMACTL`) + the staged agent
70
+ binary, and skips the Docker checks; `container`/`hostloop`/`cowork` are unchanged.
71
+ - **A freshly recorded cassette no longer reports `[stale]` immediately** because the OS rewrote a `.DS_Store`
72
+ (or other OS-junk) in the skill tree — OS-junk is excluded from the skill hash. A chronic false-positive
73
+ that pushed consumers to WARN-only (which then masked real drift).
74
+ - A standalone verdict-modifier assertion (e.g. `allow_l0_plugin_divergence: true`) no longer false-fails
75
+ as "empty assertion", and verdict modifiers no longer trigger a misleading "filesystem/egress skipped"
76
+ warning on the replay lane. The verdict modifiers are now single-sourced from one list (`assert.ts`,
77
+ `cassette.ts`, and the Python linter all derive from / are checked against it), guarded by a convention
78
+ test against drift.
79
+ - **A tail-end transport drop is no longer conflated with an agent failure.** A connection closed *after* a
80
+ clean result is classified as `resultErrorKind: "transport"` (vs `"agent"`) and surfaced as a
81
+ lane/assertion-aware `transport_error` verdict — still a failure (no false-green), but distinguishable from
82
+ a genuine skill error; a non-matching envelope falls back to the agent classification.
83
+ - **Clearer guard / capability legibility.** The run footer lists only guards that actually ran this lane
84
+ (`capabilityProbe: definitive | unverified | skipped`) — never a false check-mark for a guard that didn't
85
+ run; capability notices state their own safety net + all-clear with verdict-impact tags; the unbuilt `max`
86
+ tier is dropped from capability hints; and Docker pool-exhaustion is reframed as a concurrency limit, not a
87
+ leak.
88
+ - **Ordered interrupt cleanup.** A `SIGINT` / `SIGTERM` during a live run reaps in-flight egress resources in
89
+ order (container thunks before network thunks) and announces itself, instead of leaving them dangling.
90
+
9
91
  ## [0.8.0] — 2026-06-21
10
92
 
11
93
  ### Breaking changes
package/README.md CHANGED
@@ -17,10 +17,10 @@ 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
- > - **Live tiers** (`container` default / `microvm` / `hostloop`): **Docker (arm64)** · **Claude Desktop opened once** (stages the agent — nothing is bundled) · a **Claude token** (real per-run cost; runs take minutes). The `protocol` tier skips Docker + the staged agent but still calls a real model, so it needs the token.
20
+ > - **Live tiers**: **Claude Desktop opened once** (stages the agent — nothing is bundled) · a **Claude token** (real per-run cost; runs take minutes) · 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
21
  > - **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
22
 
23
- > **New here?** Read [docs/boundary.md](./docs/boundary.md) (the limitations model) and [docs/session.md](./docs/session.md) (the file you'll author).
23
+ > **New here?** Start by running a committed cassette `replay` and browsing [`examples/`](./examples/) (see [examples/README.md](./examples/README.md)) to see green runs before any setup — then read [docs/boundary.md](./docs/boundary.md) (the limitations model) and [docs/session.md](./docs/session.md) (the file you'll author).
24
24
 
25
25
  > **What this is and isn't.** This is an *emulator of the contract*, not the Desktop runtime. Cowork's real session control plane lives behind the Desktop renderer's IPC (per-build UUID + `senderFrame` origin checks) and the app ships with remote debugging disabled (verified: `--remote-debugging-port` opens no listener; Electron `EnableNodeCliInspectArguments` fuse is OFF). So you **cannot** drive the real Apple Virtualization.framework microVM from a script. What you *can* faithfully reproduce is everything that actually changes how a **skill** behaves: the same agent binary in **cowork mode** (`CLAUDE_CODE_IS_COWORK=1` — there is no `--cowork` flag), the same mount layout, the same egress allowlist, and the same permission/question protocol. That's what this project does.
26
26
 
@@ -54,7 +54,7 @@ The permission/question protocol is the backbone, and it's the *most stable* sur
54
54
 
55
55
  ```
56
56
  L0 protocol-only claude -p stream-json on the host. No sandbox, no egress control.
57
- Fastest. Tests skill logic + scripted answers. CI default for unit-style.
57
+ Fastest. Pure-logic / inner-loop assertions.
58
58
 
59
59
  L1 container parity Pinned agent in cowork mode inside an arm64 Linux container with the real
60
60
  (recommended) mount layout and a default-deny egress proxy enforcing the synced allowlist.
@@ -75,7 +75,7 @@ L2 microvm parity Optional. Agent inside a real Linux microVM (Lima/Apple-VZ
75
75
  gate 1143815894 decoded in the synced baseline. "Do what real Cowork does."
76
76
  ```
77
77
 
78
- Most skill testing runs **L1 (`container`)**. Use **L0 (`protocol`)** for fast inner-loop and pure-logic assertions; **L2 (`microvm`)** for VM-grade escape isolation of untrusted code (rare — it does **not** improve network-transport fidelity over L1); **`hostloop`/`cowork`** to reproduce Cowork's production split-execution model. Set the tier with `fidelity:` in a scenario or `--fidelity` on `skill`.
78
+ Most skill testing runs **L1 (`container`)**. Use **L0 (`protocol`)** for fast inner-loop and pure-logic assertions; **L2 (`microvm`)** for VM-grade escape isolation of untrusted code (rare — it does **not** improve network-transport fidelity over L1); **`hostloop`/`cowork`** to reproduce Cowork's production split-execution model. Set the tier with `fidelity:` in a scenario or `--fidelity` on `skill` or `chat`.
79
79
 
80
80
  ---
81
81
 
@@ -88,19 +88,19 @@ Skill testing is the headline use, but the tool is a general harness over the Co
88
88
  | `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 |
89
89
  | `run <scenario.yaml \| dir/>` | Run authored scenarios with `assert:` + a CI-ready exit code | you want a repeatable, **asserted regression test** |
90
90
  | `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 |
91
- | `record` / `replay` | `record` saves a control-protocol cassette (one scenario, or batch a `dir/`; `--rerecord-stale` refreshes only drifted ones; `--max-artifact-bytes` raises the 64 KiB inline-body cap); `replay` runs a cassette **file or a `dir/` of `*.cassette.json`** deterministically (`<file\|dir>`, `--cassette <file>` for the explicit single form; `--strict` fails on a stale one; exits on the worst verdict) | **token-free, Docker-free CI** from a once-recorded run |
91
+ | `record` / `replay` | `record` saves a control-protocol cassette (one scenario, or batch a `dir/`; `--rerecord-stale` refreshes only drifted ones; `--max-artifact-bytes` raises the 64 KiB inline-body cap); `replay <file.cassette.json \| dir/>` runs a cassette **file or a `dir/` of `*.cassette.json`** deterministically (`--strict` fails on a stale one; exits on the worst verdict) | **token-free, Docker-free CI** from a once-recorded run |
92
92
  | `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" |
93
93
  | `verify-run <run-dir> <scenario.yaml>` | Re-evaluate a scenario's `assert:` against an already-kept run dir — **no live agent, no tokens, no Docker** (~1s) | iterating on a wrong assertion without a full live re-record |
94
94
  | `trace <run-id>` | Digest a run's `events.jsonl` (`--view tools\|questions\|dispatches`; legacy `--tools` / `--gates` / `--dispatches` aliases still accepted) | "how many sub-agents *actually* dispatched, and which?" |
95
95
  | `scaffold <run-id>` | Turn a kept run into a starter scenario YAML (gates→answers, artifacts→`file_exists`); `--from-run <id>` is a deprecated alias for the positional | authoring a scenario from a real run instead of guessing |
96
- | `python3 …/scenario.py scaffold --name <name> --skill <dir>` | Generate a starter scenario skeleton from scratch (the `…` is `.claude/skills/cowork-harness/scripts/`; `cowork-harness lint --help` shows the resolved path) | starting a new scenario when you have no prior run |
97
- | `lint <scenario.yaml>…` | Check scenarios for silent false-greens — assertions placed on the wrong CI lane, mixed content/live keys, missing `controlOut`-required keys (bundled `scenario.py`; needs python3 + PyYAML) | before committing a new scenario or after changing assertions |
96
+ | `python3 …/scenario.py scaffold --name <name> --skill <dir>` | Generate a starter scenario skeleton from scratch (the `…` is `.claude/skills/cowork-harness/scripts/`) | starting a new scenario when you have no prior run |
97
+ | `lint <scenario.yaml \| dir/>…` | Check scenarios for silent false-greens — assertions placed on the wrong CI lane, mixed content/live keys, missing `controlOut`-required keys (files or a directory of `*.yaml`/`*.yml`; bundled `scenario.py`; needs python3 + PyYAML) | before committing a new scenario or after changing assertions |
98
98
  | `assertions --list` | List the available scenario assertions (generated from the schema); `assert` is a deprecated alias | "what can I assert?" without grepping the source |
99
99
  | `decide` | Validate a decider against a sample question in ~2 s (no run) | sanity-check a `--decider-*` / `--answer` wiring before a long run |
100
100
  | `gates` / `answer` | Stream / answer in-band gates for `--decider-dir` | a **driving agent** answers live questions via a Monitor |
101
101
  | `boundary-check [baseline] [--session <file>]` | Prove the sandbox enforces Cowork's limitations; `--session` folds a session's `egress.extra_allow` into the probe allowlist | verifying the harness's own fidelity |
102
102
  | `sync` / `list` | Derive/refresh & list platform baselines from the Desktop install | after Claude Desktop updates (baselines ship, so it's optional otherwise) |
103
- | `doctor [--tier <t>]` | Read-only prerequisite check (Docker, staged agent, token, baseline); prints the exact `docker build` line if the agent image is missing | "can I run the live tiers — what's missing?" before a first live run |
103
+ | `doctor [--tier <t>]` | Read-only prerequisite check, per tier (Docker + agent image for `container`/`hostloop`; **Lima** for `microvm`; plus staged agent, token, baseline); prints the exact `docker build` line if the agent image is missing | "can I run the live tiers — what's missing?" before a first live run |
104
104
  | `runs gc [--keep-last <n>]` | Prune accumulated run dirs (keeps the N most recent per scenario; pinned `--session-id` runs are never pruned); `--dry-run` | the machine-global runs root has grown and you want space back |
105
105
  | `rehash <dir/>` | Migrate cassette fingerprints to the current format version when the content is provably unchanged (`--dry-run`); no re-record needed | a cassette-format bump flagged committed fixtures as stale |
106
106
  | `vm <init\|status\|delete\|prune>` | Manage the L2 Apple-VZ / Lima microVM (`prune` removes orphaned VMs left by config/agent-version changes) | running `--fidelity microvm` |
@@ -202,20 +202,26 @@ This repo ships a **companion skill** (`.claude/skills/cowork-harness/`) that te
202
202
  /plugin install cowork-harness@cowork-harness
203
203
  ```
204
204
 
205
- The skill **self-bootstraps the CLI**: if `cowork-harness` isn't on your PATH it falls back to `npx cowork-harness@>=0.8.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.
205
+ The skill **self-bootstraps the CLI**: if `cowork-harness` isn't on your PATH it falls back to `npx cowork-harness@>=0.9.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.
206
206
 
207
207
  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.)
208
208
 
209
209
  > Note: `npm install -g cowork-harness` ships `scenario.py` and assertion keys (enough for `lint` in CI) but not the full SKILL.md teaching document; install via the marketplace command above to get the teaching skill.
210
210
 
211
- **Prerequisites for anything above `protocol` fidelity** (the `protocol` tier skips items 1–2 — no Docker, no staged agent — but still calls a real model via the host `claude`, so it needs item 3, the auth token; only a committed-cassette `replay` needs nothing at all):
211
+ ### Prerequisites for anything above `protocol` fidelity
212
+
213
+ (The `protocol` tier skips items 1–2 — no Docker, no staged agent — but still calls a real model via the host `claude`, so it needs item 3, the auth token; only a committed-cassette `replay` needs nothing at all):
212
214
  1. **Claude Desktop, opened once.** The Cowork agent binary is **bind-mounted from your own install** at run time — nothing Anthropic-owned is bundled. Open Cowork once so the agent ELF is staged (`…/claude-code-vm/<ver>/claude`); the harness auto-detects it, or set `COWORK_AGENT_BINARY=<path>` to point at it. Without a staged agent, container/cowork runs fail with "Open Cowork once to stage it…".
213
- 2. **Docker (arm64)** + the agent image: `docker build --platform linux/arm64 -t cowork-agent-base:2 -f docker/Dockerfile.agent .` (override the tag with `COWORK_AGENT_IMAGE`). This **core** image mirrors the real Cowork rootfs's document/data toolchain (Node 22, openpyxl/pandas/pdf/docx/pptx, …). For OCR / LibreOffice / markitdown / opencv / PDF-table skills, build the **full-parity** image instead: `docker build --platform linux/arm64 --build-arg COWORK_FULL_PARITY=1 -t cowork-agent-full:2 -f docker/Dockerfile.agent .` and point `COWORK_AGENT_IMAGE` at it. (A run on the core image that uses an omitted capability is flagged — see capability detection.) The `-f docker/Dockerfile.agent .` paths are **repo-relative** — run it from a source checkout, not a global `npm install -g`. (Global install? Run `cowork-harness doctor --tier container` — it prints the exact `docker build` command with the correct package-local Dockerfile path.)
215
+ 2. **Docker (arm64)** + the agent image: `docker build --platform linux/arm64 -t cowork-agent-base:2 -f docker/Dockerfile.agent .` (override the tag with `COWORK_AGENT_IMAGE`). This **core** image mirrors the real Cowork rootfs's document/data toolchain (Node 22, openpyxl/pandas/pdf/docx/pptx, …). For OCR / LibreOffice / markitdown / opencv / PDF-table skills, build the **full-parity** image instead: `docker build --platform linux/arm64 --build-arg COWORK_FULL_PARITY=1 -t cowork-agent-full:2 -f docker/Dockerfile.agent .` and point `COWORK_AGENT_IMAGE` at it. (A run on the core image that uses an omitted capability is flagged — see capability detection.) The `-f docker/Dockerfile.agent .` paths are **repo-relative** — run it from a source checkout, not a global `npm install -g`. (Global install? Run `cowork-harness doctor --tier container` — it prints the exact `docker build` command with the correct package-local Dockerfile path.) The **`microvm` (L2)** tier uses **Lima / Apple-VZ** instead of Docker (`brew install lima`; no agent image to build) — `doctor --tier microvm` checks for that, not Docker.
214
216
  3. **An auth token** — either `export CLAUDE_CODE_OAUTH_TOKEN=$(claude setup-token)` or a **`.env`** file (copy `.env.example` → `.env`; gitignored). The token resolves in priority order: exported env > `--dotenv <path>` > `./.env` (cwd) > `<install>/.env` (the package root), so a `npm link`ed install works from any directory. Keep `.env` at a working-dir or install root, never inside a mounted skill/project folder. (Use `--dotenv`, not `--env-file` — Node reserves the latter.)
215
217
 
216
218
  > `sync` (below) is **optional for a first run** — the repo ships `baselines/desktop-*.json`, so `baseline: latest` already resolves. Run `sync` only to refresh the platform baseline after Claude Desktop updates. (`sync` is **macOS-only** today; on Linux/Windows use the committed baselines — they work cross-platform.)
217
219
 
218
220
  ```bash
221
+ # 0. Before the first live run: check prerequisites (Docker, staged agent, token, baseline).
222
+ cowork-harness doctor # read-only: what's present, what's missing
223
+ cowork-harness doctor --tier container # scope the check to a specific tier
224
+
219
225
  # 1. (Optional · macOS-only) Sync a platform baseline from your installed Claude Desktop.
220
226
  # Skippable on a first run — the repo ships baselines; `baseline: latest` already resolves.
221
227
  cowork-harness sync # writes baselines/desktop-<appVersion>.json
@@ -253,7 +259,8 @@ cowork-harness lint scenarios/*.yaml
253
259
  > orchestration from both, re-evaluates the **content** assertions, and re-exercises
254
260
  > `serializeDecision` as a token-free O7 guard (the AskUserQuestion `{questions,answers}` answer-shape
255
261
  > invariant). Evaluated on replay: `transcript_*`, `tool_*`, `subagent_*`, `dispatch_count_max`,
256
- > `result`, `allow_permissive_auto_allow` (always-on Cowork-parity scan). **`question_asked`,
262
+ > `result`, and the verdict modifiers `allow_permissive_auto_allow` / `allow_missing_capability` /
263
+ `allow_l0_plugin_divergence` (no-op passes, kept on replay). **`question_asked`,
257
264
  > `questions_count_max`, and `gate_answers_delivered` are also evaluated —
258
265
  > but only when the cassette carries `controlOut` (full-fidelity)**; old cassettes without it get a
259
266
  > loud warning and those three keys are excluded (not vacuously passed). **Filesystem assertions
@@ -292,8 +299,10 @@ prompt: |
292
299
 
293
300
  # Scripted answers — the can_use_tool control channel, same as Desktop's question UI
294
301
  answers:
295
- - when_question: "Which output format" # substring/regex on AskUserQuestion
296
- choose: "Markdown"
302
+ - when_question: "Which output format" # regex (case-insensitive) on AskUserQuestion
303
+ choose: "Markdown" # by label; or choose: "2" for the 2nd option BY POSITION
304
+ # (index survives regenerated labels; ".*" matches any phrasing —
305
+ # last-resort, single gate/turn, after specific rules)
297
306
  - when_tool: Bash # tool-permission decisions
298
307
  allow_if: "!command.includes('rm -rf')"
299
308
  else: deny
@@ -460,6 +469,7 @@ Unit tests cover the scripted-answer logic, the egress allowlist matcher, the se
460
469
  - `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 `runs gc` 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.
461
470
  - **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`).
462
471
  - **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).
472
+ - **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`).
463
473
  - **Secret scrubbing:** `COWORK_HARNESS_SCRUB_KEYS=<KEY1,KEY2>` adds extra env-var names whose values are redacted from logs (beyond the known auth tokens + `ANTHROPIC_CUSTOM_HEADERS`); `COWORK_HARNESS_SCRUB_VALUES=<v1,v2>` redacts literal values regardless of env. **Committed-cassette redaction:** `COWORK_HARNESS_REDACT_PATTERNS=<rx1,rx2>` / `COWORK_HARNESS_REDACT_KEYS=<k1,k2>` extend the privacy layer that scrubs recorded `controlOut` before a cassette is written for commit.
464
474
  - L2 microVM: `COWORK_VM_GATEWAY` overrides the Lima host-proxy gateway IP (default `192.168.5.2`); `COWORK_VM_PROXY_PORT` the proxy port. The Lima instance is named `cowork-vm-<config-hash>` (a config change → a fresh VM); `COWORK_LIMA_INSTANCE` pins a fixed name, and `vm prune` removes orphaned ones.
465
475
  - Pin `baseline: desktop-<ver>` and `model:` in a session for byte-stable runs; use `latest` to track.
@@ -474,7 +484,7 @@ When a new Claude Desktop ships:
474
484
  cowork-harness sync --diff
475
485
  ```
476
486
 
477
- `cowork-sync` reads your **live install** and the **app.asar** and re-derives the baseline:
487
+ `cowork-harness sync` reads your **live install** and the **app.asar** and re-derives the baseline:
478
488
 
479
489
  | Baseline field | Source (auto-detected) |
480
490
  |---|---|
@@ -521,7 +531,9 @@ This repo is built to be driven by agents, not just read by humans:
521
531
  | [docs/boundary.md](./docs/boundary.md) | The limitations model — sealed FS, default-deny egress, MCP-only crossing; how each tier enforces it; how to verify. |
522
532
  | [docs/session.md](./docs/session.md) | Every `sessions/*.yaml` field and its Cowork mapping. |
523
533
  | [docs/scenario.md](./docs/scenario.md) | `scenarios/*.yaml` — prompt, scripted answers, assertions. |
534
+ | [docs/chat.md](./docs/chat.md) | The interactive `chat` REPL — multi-turn debugging, flags, attaching files/folders. |
524
535
  | [docs/cassette.md](./docs/cassette.md) | `record`/`replay` cassettes — what replay checks, which assertions are skipped. |
536
+ | [docs/fidelity-gaps.md](./docs/fidelity-gaps.md) | The known deltas vs. real Cowork — what the harness does and doesn't reproduce. |
525
537
  | [docs/decider-dir.md](./docs/decider-dir.md) | The `--decider-dir` recipe — a driving agent answers live gates in-band via `gates`/`answer` + a Monitor. |
526
538
  | [docs/discovery.md](./docs/discovery.md) | Where plugins/skills/MCP are found + overrides. |
527
539
  | [docs/maintenance.md](./docs/maintenance.md) | Parity across Desktop releases via `sync`. |
@@ -529,6 +541,7 @@ This repo is built to be driven by agents, not just read by humans:
529
541
  | [SPEC.md](./SPEC.md) | The authoritative testable contract (scenario/session schema, `RunResult`, exit codes). |
530
542
  | [CHANGELOG.md](./CHANGELOG.md) | Release history. |
531
543
  | [python/README.md](./python/README.md) | The `cowork` pytest lane for driving the harness from Python. |
544
+ | [examples/README.md](./examples/README.md) | The worked examples to copy — sessions, scenarios, and skills you can run end-to-end. |
532
545
  | [SECURITY.md](./SECURITY.md) | Threat model — the sandbox is a fidelity fixture, not a security boundary. |
533
546
 
534
547
  ## Status
@@ -595,7 +595,15 @@ export function parseMessage(msg) {
595
595
  }
596
596
  break;
597
597
  case "result":
598
- ev.push({ type: "result", isError: !!msg.is_error, usage: msg.usage });
598
+ ev.push({
599
+ type: "result",
600
+ isError: !!msg.is_error,
601
+ usage: msg.usage,
602
+ // Fix 5: preserve the SDK result payload + subtype so run.ts can tell a transport drop
603
+ // (e.g. "API Error: Connection closed", subtype error_during_execution) from a skill failure.
604
+ resultText: typeof msg.result === "string" ? msg.result : undefined,
605
+ subtype: typeof msg.subtype === "string" ? msg.subtype : undefined,
606
+ });
599
607
  break;
600
608
  }
601
609
  return ev;
package/dist/assert.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, statSync, realpathSync } from "node:fs";
2
2
  import { resolve, relative, isAbsolute, sep } from "node:path";
3
+ import { VERDICT_MODIFIER_KEYS } from "./types.js";
3
4
  import { compileUserRegex } from "./regex.js";
4
5
  import { normalizeHost } from "./boundary-paths.js";
5
6
  /** Resolve a user-authored assertion path under `workRoot`, rejecting absolute paths and any `..` that
@@ -318,13 +319,12 @@ function check(a, ctx) {
318
319
  : ctx.selfHealRan === a.self_heal_ran
319
320
  ? ok()
320
321
  : fail(`self_heal_ran was ${ctx.selfHealRan}, expected ${a.self_heal_ran}`));
321
- // Verdict modifier (consumed by computeVerdict, not here). It always "passes" as an assertion so a
322
- // standalone `{allow_permissive_auto_allow: true}` is a valid non-empty assertion, not "empty assertion".
323
- if (a.allow_permissive_auto_allow !== undefined)
324
- results.push(ok());
325
- // Verdict modifier (same pattern): standalone `{allow_missing_capability: true}` must not be "empty".
326
- if (a.allow_missing_capability !== undefined)
327
- results.push(ok());
322
+ // Verdict modifiers (consumed by computeVerdict, not here) each always "pass" as an assertion, so a
323
+ // standalone `{allow_*: true}` is a valid non-empty assertion, not "empty assertion". Derived from the
324
+ // single VERDICT_MODIFIER_KEYS list so a newly-added modifier can never miss this branch again.
325
+ for (const k of VERDICT_MODIFIER_KEYS)
326
+ if (a[k] !== undefined)
327
+ results.push(ok());
328
328
  if (a.transcript_no_host_path !== undefined)
329
329
  results.push(ctx.scanMissing
330
330
  ? fail(`evidence unavailable: post-run scan absent from result.json — cannot evaluate transcript_no_host_path`)
package/dist/cli.js CHANGED
@@ -248,11 +248,10 @@ const SUBCOMMAND_USAGE = {
248
248
  vm: "usage: vm <init|status|delete|prune> [--output-format text|json] (macOS arm64 only)\n init create the L2 Apple-VZ microVM\n status show running VM state\n delete remove a named VM\n prune drop all orphaned VMs",
249
249
  chat: "usage: chat <skill-folder> [prompt] [--fidelity protocol|container|hostloop] [--model <id>]\n [--upload <file>]... [--folder <dir>]... [--plugin <dir>]... [--verbose] [--raw]\n --raw: native cowork mode via docker run -it; egress sandbox NOT applied; rejects --upload/--folder/--plugin/--fidelity (only --model applies)\n --fidelity: protocol/container/hostloop only (no microvm/cowork); protocol = no Docker, no sandbox",
250
250
  record: "usage: record <scenario.yaml | dir/> [--out <file>] [--output-format text|json] [--rerecord-stale] [--no-redact] [--allow-failing] [--max-artifact-bytes <n>] [--dry-run]",
251
- replay: "usage: replay <file.cassette.json | dir/> [--strict] [--output-format text|json]\n Positional path is the canonical form. --cassette <file> is a legacy alias (providing both is an error).",
251
+ replay: "usage: replay <file.cassette.json | dir/> [--strict] [--output-format text|json]",
252
252
  "verify-cassettes": "usage: verify-cassettes <file|dir> [--skip-privacy|--skip-staleness] [--allow <regex>]... [--allow-domain <regex>]... [--allow-email <regex>]... [--allow-file <path>]... [--output-format json]",
253
253
  trace: "usage: trace <run-id | run-dir | events.jsonl> [--view tools|questions|dispatches] [--output-format json]\n --view tools tool call / result rows\n --view questions gate lifecycle (question → answer → delivered)\n --view dispatches sub-agent dispatch tree + dispatch_count_max\n (default: all views)",
254
254
  assertions: "usage: assertions --list [--output-format json]",
255
- assert: "usage: assertions --list [--output-format json] (use 'assertions' — 'assert' is the legacy name)",
256
255
  scaffold: "usage: scaffold <run-id | run-dir> [--out <file.yaml>]\n Turns a kept run into a starter scenario YAML (gates→answers, artifacts→file_exists).\n Positional <run-id | run-dir> is the canonical form.",
257
256
  decide: 'usage: decide [--question <q>] [--option <o>]... [--decider-cmd <cmd> | --decider-llm] [--answer "<q>=<label>"]... [--answer-policy <p>] [--intent <s>] [--output-format json]',
258
257
  gates: "usage: gates <dir> [--follow] (stream pending in-band gates as JSON lines; pair with --decider-dir)",
@@ -274,7 +273,6 @@ const COMMANDS = [
274
273
  "verify-run",
275
274
  "trace",
276
275
  "assertions",
277
- "assert",
278
276
  "scaffold",
279
277
  "decide",
280
278
  "gates",
@@ -407,9 +405,6 @@ async function main() {
407
405
  return cmdTrace(rest);
408
406
  case "assertions":
409
407
  return cmdAssert(rest);
410
- case "assert": // legacy alias — keep working, show deprecation notice
411
- log("note: 'assert' has been renamed to 'assertions'; please update your scripts.\n");
412
- return cmdAssert(rest);
413
408
  case "scaffold":
414
409
  return cmdScaffold(rest);
415
410
  case "decide":
@@ -1541,7 +1536,9 @@ async function cmdDecide(args) {
1541
1536
  out(jsonError("decide", "runtime", String(e.message)));
1542
1537
  else
1543
1538
  log(`✗ decider error: ${String(e.message)}`);
1544
- process.exit(1);
1539
+ // Runtime failure → exit 2, matching the global "usage/runtime → 2" contract (SPEC §11). The JSON
1540
+ // envelope already tags this category "runtime"; the exit code now agrees. No-match/ABSTAIN stays 1.
1541
+ process.exit(2);
1545
1542
  }
1546
1543
  }
1547
1544
  /** `gates <dir> [--follow]` — the gate stream for the in-band `--decider-dir` path. Emits one clean
@@ -1857,24 +1854,24 @@ function cmdVerifyRun(args) {
1857
1854
  }
1858
1855
  return process.exit(verdict.exitCode);
1859
1856
  }
1860
- /** `assert --list` (#8) — enumerate the available assertion keys + one-line semantics, generated from the
1857
+ /** `assertions --list` (#8) — enumerate the available assertion keys + one-line semantics, generated from the
1861
1858
  * Zod `Assertion` schema (`Assertion.shape[k].description`) so the list can NEVER drift from the schema. */
1862
1859
  function cmdAssert(args) {
1863
1860
  const json = isJsonOutput(args);
1864
1861
  // validate --output-format is text|json (an invalid value was a silent text degrade).
1865
- ensureOutputFormat("assert", args);
1862
+ ensureOutputFormat("assertions", args);
1866
1863
  if (!args.includes("--list"))
1867
- return void fail("assert", "usage", "usage: assert --list [--output-format json]", undefined, json);
1868
- // `assert --list` takes no positionals and no other flags; reject stray ones rather than
1869
- // silently ignoring them (e.g. `assert --list extra` or `assert --list --bogus`).
1864
+ return void fail("assertions", "usage", "usage: assertions --list [--output-format json]", undefined, json);
1865
+ // `assertions --list` takes no positionals and no other flags; reject stray ones rather than
1866
+ // silently ignoring them (e.g. `assertions --list extra` or `assertions --list --bogus`).
1870
1867
  const stray = positionals(args, ["--output-format"]);
1871
1868
  if (stray.length)
1872
- return void fail("assert", "usage", `assert --list takes no positional arguments (got: ${stray.join(", ")})`, undefined, json);
1873
- rejectUnknownFlags("assert", args, ["--list", "--output-format", "--output-format=json", "--output-format=text"], json);
1869
+ return void fail("assertions", "usage", `assertions --list takes no positional arguments (got: ${stray.join(", ")})`, undefined, json);
1870
+ rejectUnknownFlags("assertions", args, ["--list", "--output-format", "--output-format=json", "--output-format=text"], json);
1874
1871
  const shape = Assertion.shape;
1875
1872
  const keys = Object.keys(shape).map((k) => ({ key: k, description: shape[k].description ?? "" }));
1876
1873
  if (json)
1877
- return void out(JSON.stringify({ tool: "cowork-harness", command: "assert", assertions: keys }));
1874
+ return void out(JSON.stringify({ tool: "cowork-harness", command: "assertions", assertions: keys }));
1878
1875
  const width = Math.max(...keys.map((k) => k.key.length));
1879
1876
  out(`available assertions (${keys.length}) — use under a scenario's \`assert:\` list:\n`);
1880
1877
  for (const { key, description } of keys)
@@ -316,11 +316,29 @@ export class LlmDecider {
316
316
  continue;
317
317
  }
318
318
  const raw = await this.complete(this.prompt(text, q.options, ctx), this.model);
319
+ // Match a LABEL first (matchLabel is fuzzy=false): keeps the common path unchanged and means a real
320
+ // option literally named "OTHER: …" is selected as a label, not hijacked by the free-text branch.
319
321
  const pick = matchLabel(raw, labels);
320
- if (!pick)
321
- throw new UnansweredError(`LLM decider answer "${raw.trim().slice(0, 60)}" is not one of the options for "${text}"`, `options were: ${labels.join(" | ")}`);
322
- process.stderr.write(`[llm-decider] "${text}" → "${pick}"${this.intent ? ` (intent: ${this.intent})` : ""}\n`);
323
- answers[text] = pick;
322
+ if (pick) {
323
+ process.stderr.write(`[llm-decider] "${text}" → "${pick}"${this.intent ? ` (intent: ${this.intent})` : ""}\n`);
324
+ answers[text] = pick;
325
+ continue;
326
+ }
327
+ // No label matched. Cowork auto-provides a free-text "Other" path on EVERY gate (binary-verified), so
328
+ // accept an explicit `OTHER: <value>` directive as free text — symmetric with ScriptedDecider's
329
+ // `answer:` escape hatch — delivered verbatim. A reply that is neither a label nor the explicit OTHER
330
+ // channel stays a LOUD failure: we do NOT coerce an arbitrary string into free text, or we'd mask a
331
+ // model that picked a wrong/hallucinated option label.
332
+ const other = /^\s*OTHER:\s*([\s\S]+)/i.exec(raw);
333
+ if (other) {
334
+ const free = other[1].trim();
335
+ if (free === "")
336
+ throw new UnansweredError(`LLM decider returned an empty OTHER answer for "${text}"`, "an OTHER free-text directive needs a non-empty value");
337
+ process.stderr.write(`[llm-decider] "${text}" → free-text (Other)${this.intent ? ` (intent: ${this.intent})` : ""}\n`);
338
+ answers[text] = free;
339
+ continue;
340
+ }
341
+ throw new UnansweredError(`LLM decider answer "${raw.trim().slice(0, 60)}" is not one of the options for "${text}"`, `options were: ${labels.join(" | ")}. For a free-text / name gate the model must reply \`OTHER: <value>\` (Cowork's auto-provided "Other" path) — a bare out-of-set value is rejected.`);
324
342
  }
325
343
  return { response: { kind: "question", answers }, by: "llm", rationale: this.intent ?? "LLM judgment", model: this.model };
326
344
  }
@@ -349,7 +367,9 @@ export class LlmDecider {
349
367
  tail ? `Recent context (transcript tail):\n${tail}` : "",
350
368
  `Question: ${question}`,
351
369
  `Options:\n${options.map((o) => `- ${o.label}${o.description ? `: ${o.description}` : ""}`).join("\n")}`,
352
- `Reply with ONLY the exact label of the single best option. No explanation, no punctuation — just the label.`,
370
+ `Reply with ONLY the exact label of the single best option — no explanation, no punctuation. ` +
371
+ `If none of the options fits and the intent calls for a custom value (the gate always allows a ` +
372
+ `free-text "Other" entry), reply with exactly \`OTHER: <your value>\` instead.`,
353
373
  ]
354
374
  .filter(Boolean)
355
375
  .join("\n\n");
@@ -4,6 +4,50 @@ import { mkdirSync, readFileSync, existsSync } from "node:fs";
4
4
  import { join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  const PROXY_IMAGE = process.env.COWORK_PROXY_IMAGE ?? "cowork-egress-proxy:1";
7
+ const cleanupRegistry = new Set();
8
+ let signalHandlerInstalled = false;
9
+ /** Register a signal-time cleanup thunk; returns a de-register fn to call from the normal `finally` path
10
+ * (so a clean exit doesn't double-run it — and `teardown()`/`rm -f` are idempotent regardless). */
11
+ export function registerCleanup(entry) {
12
+ cleanupRegistry.add(entry);
13
+ installSignalHandlerOnce();
14
+ return () => cleanupRegistry.delete(entry);
15
+ }
16
+ /** Drain all registered cleanups in phase order — "container" thunks BEFORE "network" thunks (load-bearing:
17
+ * `network rm` fails while a container is still attached). Returns the number of thunks run. Exported for
18
+ * tests; the signal handler calls this then exits. */
19
+ export function drainCleanups() {
20
+ const entries = [...cleanupRegistry];
21
+ for (const e of entries)
22
+ if (e.phase === "container")
23
+ tryRun(e.run);
24
+ for (const e of entries)
25
+ if (e.phase === "network")
26
+ tryRun(e.run);
27
+ return entries.length;
28
+ }
29
+ function installSignalHandlerOnce() {
30
+ if (signalHandlerInstalled)
31
+ return;
32
+ signalHandlerInstalled = true;
33
+ const handler = (sig) => {
34
+ const n = cleanupRegistry.size;
35
+ if (n)
36
+ warn(`::warning:: [cleanup] ${sig} — reaping ${n} in-flight egress resource(s) before exit\n`);
37
+ drainCleanups();
38
+ process.exit(sig === "SIGINT" ? 130 : 143);
39
+ };
40
+ process.on("SIGINT", handler);
41
+ process.on("SIGTERM", handler);
42
+ }
43
+ function tryRun(fn) {
44
+ try {
45
+ fn();
46
+ }
47
+ catch {
48
+ /* best-effort during signal teardown */
49
+ }
50
+ }
7
51
  export function startEgressSidecar(allow, outDir, runId) {
8
52
  const runner = process.env.COWORK_CONTAINER_RUNTIME ?? "docker";
9
53
  const intNet = `cowork-int-${runId}`;
@@ -49,8 +93,19 @@ export function startEgressSidecar(allow, outDir, runId) {
49
93
  catch (e) {
50
94
  for (const undo of rollback.reverse())
51
95
  undo();
52
- throw e;
96
+ // 6e: re-frame Docker's opaque address-pool-exhaustion error (which surfaces at create, connect, OR
97
+ // `run --network`) into an actionable diagnosis — it reads as a "leak" but is concurrency pressure;
98
+ // each run reaps its own networks on exit. Rollback above already ran, so this only changes the message.
99
+ throw reframeEgressError(e);
53
100
  }
101
+ const reap = () => {
102
+ d(runner, ["rm", "-f", proxyName], true); // proxy container before its networks (attached)
103
+ d(runner, ["network", "rm", intNet], true);
104
+ d(runner, ["network", "rm", outNet], true);
105
+ };
106
+ // 2a: cover Ctrl-C — reap this run's proxy+networks on a signal too. Registered as the "network" phase so a
107
+ // caller-registered agent-container reap ("container" phase) runs first (network rm needs the container gone).
108
+ const deregister = registerCleanup({ phase: "network", run: reap });
54
109
  return {
55
110
  proxyUrl: `http://${proxyName}:8080`,
56
111
  network: intNet,
@@ -65,9 +120,8 @@ export function startEgressSidecar(allow, outDir, runId) {
65
120
  .filter((x) => x !== null);
66
121
  },
67
122
  teardown() {
68
- d(runner, ["rm", "-f", proxyName], true);
69
- d(runner, ["network", "rm", intNet], true);
70
- d(runner, ["network", "rm", outNet], true);
123
+ deregister();
124
+ reap();
71
125
  },
72
126
  };
73
127
  }
@@ -126,6 +180,23 @@ function ensureProxyImage(runner) {
126
180
  if (build.status !== 0)
127
181
  throw new Error(`failed to build ${PROXY_IMAGE}`);
128
182
  }
183
+ /** 6e: turn Docker's `all predefined address pools have been fully subnetted` (and its connect/run
184
+ * variants) into a self-answering message — it is NOT a leak (each run reaps its own networks on exit),
185
+ * it is concurrency pressure against the daemon's address pool. Any non-matching error passes through. */
186
+ export function reframeEgressError(e) {
187
+ const msg = e instanceof Error ? e.message : String(e);
188
+ // Match Docker's two canonical pool-exhaustion messages without over-matching unrelated docker errors:
189
+ // "all predefined address pools have been fully subnetted"
190
+ // "could not find an available, non-overlapping IPv4 address pool among the defaults to assign ..."
191
+ const poolExhausted = /address pool/i.test(msg) && /predefined|available|non-overlapping|subnet|defaults|exhaust/i.test(msg);
192
+ if (poolExhausted) {
193
+ return new Error("egress network create failed — Docker address pool exhausted. This is concurrency pressure " +
194
+ "(live runs × 2 networks each), NOT a leak: each run reaps its own networks on exit. Orphans only " +
195
+ "persist from SIGKILL'd runs → `docker network prune`. For higher parallelism, widen the daemon " +
196
+ `\`default-address-pools\`. (original: ${msg.slice(0, 200)})`);
197
+ }
198
+ return e;
199
+ }
129
200
  function d(runner, args, ignoreError = false) {
130
201
  const r = spawnSync(runner, args, { encoding: "utf8" });
131
202
  if (r.status !== 0 && !ignoreError) {