cowork-harness 0.5.0 → 0.6.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.
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "$comment": "GENERATED from the Zod Assertion schema (src/types.ts) by scripts/gen-schema.ts — do not edit; run `npm run schema`.",
3
3
  "keys": [
4
+ "allow_l0_plugin_divergence",
4
5
  "allow_permissive_auto_allow",
5
6
  "artifact_json",
6
7
  "dispatch_count_max",
@@ -68,7 +68,7 @@ LIVE_ONLY_KEYS = {
68
68
  }
69
69
  EGRESS_KEYS = {"egress_denied", "egress_allowed"}
70
70
  # verdict modifiers — don't verify anything themselves (e.g. suppress a default-fail)
71
- VERDICT_MODIFIER_KEYS = {"allow_permissive_auto_allow"}
71
+ VERDICT_MODIFIER_KEYS = {"allow_permissive_auto_allow", "allow_l0_plugin_divergence"}
72
72
 
73
73
  # Every key the replay-class logic knows how to handle. `replay_protocol_fidelity` is valid-but-not-authorable
74
74
  # (errored separately below). This is also the embedded fallback for ASSERT_KEYS — kept EQUAL to the generated
package/CHANGELOG.md CHANGED
@@ -6,6 +6,87 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.6.0] — 2026-06-19
10
+
11
+ ### Breaking changes
12
+
13
+ - **Exit code 3 for boundary/integrity violations.** Commands that previously exited `2` for a
14
+ boundary constraint (e.g. `skill` hitting the egress sandbox, `run` with a `boundary`-category
15
+ failure) now exit `3`. Exit `2` is narrowed to usage errors, unknown flags, and runtime errors.
16
+ Scripts that check `$? -eq 2` to detect boundary failures must be updated to `$? -eq 3`.
17
+ - **`verify-cassettes --staleness-only` / `--privacy-only` removed.** Replaced by
18
+ `--skip-staleness` (run privacy scan only) and `--skip-privacy` (run staleness scan only). The
19
+ old flags are not aliased — they now exit `2` as unknown flags.
20
+ - **`decide` with no configuration exits `2` instead of `1`.** Previously, calling `decide` with no
21
+ `--decider-*`, `--answer`, or `--answer-policy` would fall through to a `ScriptedDecider([])` and
22
+ exit `1` ("no rule matched"). It now fails early with exit `2` ("no decider configured") and a
23
+ clear message.
24
+
25
+ ### Added
26
+
27
+ - **`chat` — full flag parity for interactive debugging:**
28
+ - `[prompt]` — optional seed prompt sent as the first turn before the REPL opens.
29
+ - `--upload <file>` (repeatable) — attach a file at `mnt/uploads/<basename>`; live at session start.
30
+ - `--folder <dir>` (repeatable) — connect a project folder at `mnt/.projects/<basename>` as a live bind mount.
31
+ - `--verbose` / `-V` — show thinking blocks, tool inputs, and the sub-agent tree (previously hardcoded off).
32
+ - `--fidelity protocol` — no-Docker fastest tier; accepted alongside `container` and `hostloop`.
33
+ - `--model` in `--raw` mode — previously silently dropped; now passed as `--model <id>` to the docker argv.
34
+ - Idle heartbeat wired in all three fidelity branches (protocol / hostloop / container).
35
+ - Run ID printed at session start (before first turn) so a mid-session crash still tells you where the transcript is.
36
+ - **`assertions` command** — canonical rename of `assert`; `assert` is kept as a deprecated alias
37
+ that prints a migration notice. `assertions --list` is the new canonical form.
38
+ - **`scaffold <run-id>` positional** — canonical form; `--from-run <id>` is kept as a deprecated
39
+ alias that prints a migration notice.
40
+ - **`trace --view tools|questions|dispatches`** — replaces the three separate `--tools` / `--gates`
41
+ / `--dispatches` flags with a single `--view` enum. Legacy flags are kept as backward-compat
42
+ aliases (`--gates` maps to `--view questions`).
43
+ - **Env-var defaults for all live commands:**
44
+ - `COWORK_HARNESS_FIDELITY` — default fidelity tier for `skill` and `chat` (validated; exits 2 on an invalid value).
45
+ - `COWORK_HARNESS_MODEL` — default model override for `skill` and `chat`.
46
+ - `COWORK_HARNESS_OUTPUT_FORMAT` — default `--output-format` for all commands (`text` or `json`).
47
+ - **`decide` no-decider guard** — calling `decide` with no configuration fails immediately with a
48
+ clear message and exit `2` instead of falling through to a vacuous "no rule matched" exit `1`.
49
+ - **`vm` per-subcommand `--help`** — `vm <sub> --help` prints the subcommand usage and exits `0`.
50
+ - **`--quiet` / `-q` accepted in `decide`** — no-op flag for flag-surface consistency with `skill` / `run`.
51
+
52
+ ### Fixed
53
+
54
+ - **`--rerecord-stale` now prefers on-disk scenario over embedded snapshot (G-1).** When a
55
+ `scenarios/<name>.yaml` exists alongside the cassette dir, `--rerecord-stale` re-records from it
56
+ instead of the embedded copy. Edits to the scenario (e.g. adding `skills:` for staleness scoping)
57
+ now take effect. Falls back to the embedded snapshot when no on-disk file is found, with a clear
58
+ warning.
59
+ - **Staleness message distinguishes format-version bump from real content change (G-2).** After a
60
+ harness upgrade that changes the hash algorithm, `verify-cassettes` now reports
61
+ `recorded under an older hash format (vN → vM)` instead of the misleading
62
+ `local skill/plugin dir contents changed`.
63
+ - **`.cowork-hashignore` leading-slash patterns now correctly anchor to the mount root (G-3).**
64
+ `/tests` previously compiled to a regex that never matched (no leading slash in relative paths);
65
+ it now matches only the top-level `tests/` dir, as expected.
66
+ - **Scoped staleness findings now name the changed bucket (G-4).** When a cassette was recorded with
67
+ `skills: [<name>]` scoping, `verify-cassettes` now reports `skills/<name> changed` or
68
+ `shared root changed` rather than the generic `local skill/plugin dir contents changed`.
69
+ - **Unknown flags in `chat` now exit `2`.** Previously ignored silently; any unrecognised flag now
70
+ exits `2` with a clear message.
71
+ - **`chat --model` bounds-checked.** If `--model` is the last argument (no value following), the
72
+ command now exits `2` with a clear message instead of silently using `undefined`.
73
+ - **`cmdSync` platform guard fires before argument parsing.** On non-macOS platforms the guard now
74
+ exits before `parseArgs`, so `sync --help` on Linux no longer crashes on a missing flag.
75
+ - **`record` conflict check.** Passing both a positional and `--cassette` now exits `2` immediately
76
+ instead of silently preferring one.
77
+ - **L0 plugin divergence signal.** `spawnProtocol` now reports whether the skill resolved at L0
78
+ (no container) vs. L1, so callers can surface the divergence accurately.
79
+ - **System-prompt threading.** `systemPromptAppend` is now passed through all runtime paths that
80
+ previously dropped it.
81
+ - **Strict agent binary resolution.** When the exact staged binary path is missing, the harness now
82
+ fails with a clear message pointing to `COWORK_HARNESS_ALLOW_AGENT_FALLBACK=1` instead of
83
+ silently falling back to an arbitrary sibling version.
84
+ - **Path traversal guard on plugin sources.** Plugin source paths are now validated to be
85
+ directories (not files or traversal strings) before mounting.
86
+ - **Cassette correctness (batch):** privacy/staleness coverage tracking, redaction verdict
87
+ preservation across re-records, base64-encoded artifact body scanning, and exhaustiveness
88
+ checks on the replay assertion set.
89
+
9
90
  ## [0.5.0] — 2026-06-18
10
91
 
11
92
  ### Added
package/README.md CHANGED
@@ -78,14 +78,15 @@ Skill testing is the headline use, but the tool is a general harness over the Co
78
78
  |---|---|---|
79
79
  | `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 |
80
80
  | `run <scenario.yaml \| dir/>` | Run authored scenarios with `assert:` + a CI-ready exit code | you want a repeatable, **asserted regression test** |
81
- | `chat <folder>` | Interactive multi-turn REPL against a skill (TTY) | debugging a multi-turn flow by hand |
81
+ | `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 |
82
82
  | `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 |
83
- | `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 (`--staleness-only`). 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" |
83
+ | `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" |
84
84
  | `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 |
85
- | `trace <run-id>` | Digest a run's `events.jsonl` (`--tools`, `--gates`, `--dispatches` for the sub-agent dispatch tree + total) | "how many sub-agents *actually* dispatched, and which?" |
86
- | `scaffold --from-run <id>` | Turn a kept run into a starter scenario YAML (gates→answers, artifacts→`file_exists`) | authoring a scenario from a real run instead of guessing |
85
+ | `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?" |
86
+ | `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 |
87
+ | `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 |
87
88
  | `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 |
88
- | `assert --list` | List the available scenario assertions (generated from the schema) | "what can I assert?" without grepping the source |
89
+ | `assertions --list` | List the available scenario assertions (generated from the schema); `assert` is a deprecated alias | "what can I assert?" without grepping the source |
89
90
  | `decide` | Validate a decider against a sample question in ~2 s (no run) | sanity-check a `--decider-*` / `--answer` wiring before a long run |
90
91
  | `gates` / `answer` | Stream / answer in-band gates for `--decider-dir` | a **driving agent** answers live questions via a Monitor |
91
92
  | `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 |
@@ -119,7 +120,7 @@ cowork-harness skill ~/my-plugin 'review this deck' --upload deck.pdf # att
119
120
  cowork-harness skill ~/my-plugin "..." --session-id s1 # pin a session…
120
121
  cowork-harness skill ~/my-plugin '<next turn>' --session-id s1 --resume # …then resume it (gated/checkpoint skills)
121
122
  cowork-harness skill ~/my-plugin "..." --keep # then: trace the run
122
- cowork-harness trace <run-id> --tools # tool calls + sub-agent dispatches from events.jsonl
123
+ cowork-harness trace <run-id> --view tools # tool calls + sub-agent dispatches from events.jsonl
123
124
  cowork-harness skill --help # full per-command flag reference
124
125
 
125
126
  cowork-harness chat ~/my-plugin # interactive multi-turn REPL (full harness: egress sandbox + control protocol)
@@ -132,8 +133,8 @@ cowork-harness chat ~/my-plugin # interactive multi-turn REPL (
132
133
  `run`/CI), `prompt` (ask at the TTY — the default for `skill` when interactive), or `first` (pick
133
134
  option 1, loudly warn). Pick with `--on-unanswered`; left unset, `skill` is **adaptive** (`prompt` on
134
135
  a TTY, `fail` when piped/CI) and `run` is always `fail`. Exit codes: `0`
135
- pass · `1` assertion/agent failure · `2` usage /
136
- unanswered-under-`fail` / boundary / runtime. After a run, the footer **echoes every auto-answered
136
+ pass · `1` assertion/agent failure · `2` usage / unanswered-under-`fail` / runtime · `3` boundary/integrity.
137
+ After a run, the footer **echoes every auto-answered
137
138
  question as a copy-pasteable `--answer "<q>=<choice>"` line** — run once exploratorily, then paste them
138
139
  back to lock in a deterministic re-run.
139
140
 
@@ -194,9 +195,11 @@ The skill **self-bootstraps the CLI**: if `cowork-harness` isn't on your PATH it
194
195
 
195
196
  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.)
196
197
 
198
+ > 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.
199
+
197
200
  **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):
198
201
  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…".
199
- 2. **Docker (arm64)** + the agent image: `docker build --platform linux/arm64 -t cowork-agent-base:1 -f docker/Dockerfile.agent .` (override the tag with `COWORK_AGENT_IMAGE`). The `-f docker/Dockerfile.agent .` paths are **repo-relative** — run it from a source checkout, not a global `npm install -g` (where the Dockerfile lives under the package dir).
202
+ 2. **Docker (arm64)** + the agent image: `docker build --platform linux/arm64 -t cowork-agent-base:1 -f docker/Dockerfile.agent .` (override the tag with `COWORK_AGENT_IMAGE`). The `-f docker/Dockerfile.agent .` paths are **repo-relative** — run it from a source checkout, not a global `npm install -g` (where the Dockerfile lives under the package dir). (Global install? Run `cowork-harness doctor --tier container` — it prints the exact `docker build` command with the correct package-local Dockerfile path.)
200
203
  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.)
201
204
 
202
205
  > `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.)
@@ -221,9 +224,6 @@ cowork-harness run examples/scenarios/ --output-format json
221
224
  cowork-harness record examples/scenarios/example-pdf-skill.yaml --out examples/replays/example-pdf-skill.cassette.json
222
225
  cowork-harness replay --cassette examples/replays/example-pdf-skill.cassette.json
223
226
 
224
- # A committed synthetic fixture is ready to replay on a fresh clone (no record step needed):
225
- cowork-harness replay --cassette examples/replays/example-pdf-skill.cassette.json
226
-
227
227
  # Cassettes are COMMITTED fixtures — record against synthetic data, and gate them in CI:
228
228
  cowork-harness verify-cassettes examples/replays/ # privacy scan (email/currency/domain) + staleness; exit 1 on a finding
229
229
  ```
@@ -239,7 +239,8 @@ cowork-harness verify-cassettes examples/replays/ # privacy scan (email/curren
239
239
  > orchestration from both, re-evaluates the **content** assertions, and re-exercises
240
240
  > `serializeDecision` as a token-free O7 guard (the AskUserQuestion `{questions,answers}` answer-shape
241
241
  > invariant). Evaluated on replay: `transcript_*`, `tool_*`, `subagent_*`, `dispatch_count_max`,
242
- > `result`. **`question_asked`, `questions_count_max`, and `gate_answers_delivered` are also evaluated —
242
+ > `result`, `allow_permissive_auto_allow` (always-on Cowork-parity scan). **`question_asked`,
243
+ > `questions_count_max`, and `gate_answers_delivered` are also evaluated —
243
244
  > but only when the cassette carries `controlOut` (full-fidelity)**; old cassettes without it get a
244
245
  > loud warning and those three keys are excluded (not vacuously passed). **Filesystem assertions
245
246
  > (`file_exists`, `user_visible_artifact`, `artifact_json`) are evaluated when the cassette carries an
@@ -296,7 +297,7 @@ assert:
296
297
  ```
297
298
 
298
299
  ```yaml
299
- # sessions/default.yaml (abridged — see the file for every field)
300
+ # examples/sessions/default.yaml (abridged — see the file for every field)
300
301
  # Relative paths below resolve from THIS file's dir (absolute and ~ are used as-is).
301
302
  model: claude-opus-4-8
302
303
  effort: high
@@ -522,7 +523,7 @@ This repo is built to be driven by agents, not just read by humans:
522
523
  - ✅ **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**.
523
524
  - ✅ **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.
524
525
  - ✅ **Answering live questions, every way you'd need** — `--decider-llm --intent "<one line>"` (a small model picks per question, steered by your test intent — the ergonomic default; non-determinism is flagged so a green isn't mistaken for a scripted pass), `--answer-policy <yaml>`/`--answer "rx=c"` (declarative regex→label, deterministic CI), `--decider-cmd '<helper>'` (custom logic — the Python `serve_decider(fn)` adapter pre-builds the wire loop so the helper writes only the decision), and **`--decider-dir <dir>`** (the *driving agent* answers each gate **in-band** with full context — it arms a Monitor on `cowork-harness gates <dir> --follow` and replies with `cowork-harness answer <dir> --gate <N> --choose <label>`; the session-under-test stays live, no resume/re-worded question — binary-verified). Every channel keeps stdout free, so all compose with `--output-format json`. A question is **never silently answered with option 1** — unhandled fails loud. Validate any decider in ~2s with `cowork-harness decide`.
525
- - ✅ **Sub-agent aggregation + `trace`** — recognizes the real `Agent` dispatch tool (binary-verified; `Task` is its alias) so `subagent_dispatched`/`dispatch_count_max` fire under `--fidelity cowork`, excluding the `TaskCreate` todo list; `cowork-harness trace <id> --tools` digests `events.jsonl` (each tool row now shows its **result status** `ok`/`error`), and `trace <id> --gates` shows the gate lifecycle (**question → injected answer → delivered result**); `result.json`/`--keep` surface the deep `mnt/outputs` deliverable path.
526
+ - ✅ **Sub-agent aggregation + `trace`** — recognizes the real `Agent` dispatch tool (binary-verified; `Task` is its alias) so `subagent_dispatched`/`dispatch_count_max` fire under `--fidelity cowork`, excluding the `TaskCreate` todo list; `cowork-harness trace <id> --view tools` digests `events.jsonl` (each tool row now shows its **result status** `ok`/`error`), and `trace <id> --view questions` shows the gate lifecycle (**question → injected answer → delivered result**); `result.json`/`--keep` surface the deep `mnt/outputs` deliverable path.
526
527
  - ✅ **Answer delivery is verified, not assumed** — an AskUserQuestion answer is injected as the binary's full tool input (`{questions, answers}`, ELF-verified), so the answer actually reaches the model; `RunResult.gateDeliveries[]` + the `gate_answers_delivered` assertion catch any delivery failure, and `RunResult.toolCounts` gives the **truthful** per-tool call count (host-routed `WebSearch` shows here, not the always-0 `usage.server_tool_use`).
527
528
  - ✅ **Binary-grounded fidelity** — cwd `/sessions/<id>`, the three-channel MCP model (`--mcp-config` honored in plain cowork mode; host/API-routed `web_fetch`; SDK-server delivery), host-loop tool partition, auth-env token-only drop, and production GrowthBook gates pinned per release.
528
529
  - ✅ The agent binary is **bind-mounted from your own install** at run time — nothing Anthropic-owned is in any image or distributed.
@@ -361,8 +361,19 @@ export class LiveAgentSession {
361
361
  // (If large control frames ever become legitimate, switch to a drain-aware queue, making writes async.)
362
362
  if (line.length > 256 * 1024)
363
363
  throw new Error(`control frame is ${line.length} bytes (> 256 KiB safe limit) — refusing to write to avoid partial stdin buffering; this indicates an unexpectedly large control payload`);
364
+ // Bug 34: check stream writability before writing — a closed/destroyed stdin after a child crash
365
+ // loses decision frames silently. Throw immediately so callers surface the failure rather than
366
+ // hanging or silently dropping frames.
367
+ if (this.proc.stdin.destroyed || !this.proc.stdin.writable)
368
+ throw new Error("control-protocol write failed: child stdin is no longer writable (process may have crashed)");
364
369
  this.controlOut.write(line + "\n");
365
- this.proc.stdin.write(line + "\n");
370
+ // Attach a one-shot error handler on the write so stream errors surface via rejectError rather
371
+ // than as unhandled 'error' events (the permanent 'error' handler on constructor covers stdin
372
+ // errors that fire outside a write, e.g. EPIPE after a crash).
373
+ this.proc.stdin.write(line + "\n", (err) => {
374
+ if (err && this.rejectError)
375
+ this.rejectError(err instanceof Error ? err : new Error(String(err)));
376
+ });
366
377
  }
367
378
  }
368
379
  /** Pure translation of one parsed stream-json message → AgentEvents (no side-effects). Shared by
@@ -447,15 +458,22 @@ export function parseMessage(msg) {
447
458
  }
448
459
  return ev;
449
460
  }
450
- /** Flatten a tool_result `content` (a string, or an array of `{type:"text",text}` blocks), capped at
461
+ /** Flatten a tool_result `content` (a string, or an array of content blocks), capped at
451
462
  * `max` chars. The 500-char DISPLAY value (toolResultText) keeps the recorder/trace compact; the larger
452
- * PROVENANCE value (toolResultRaw) is what seeds web_fetch provenance, so a URL past char 500 isn't lost. */
463
+ * PROVENANCE value (toolResultRaw) is what seeds web_fetch provenance, so a URL past char 500 isn't lost.
464
+ * Bug 35: preserve all content block types — text blocks use their text value; all other block types
465
+ * (json, resource, link, unknown shapes) are JSON-stringified so no content is silently dropped. */
453
466
  function flattenToolResult(content, max) {
454
467
  if (typeof content === "string")
455
468
  return content.slice(0, max);
456
469
  if (Array.isArray(content))
457
470
  return content
458
- .map((b) => (b && typeof b === "object" && "text" in b ? String(b.text) : ""))
471
+ .map((b) => {
472
+ if (b && typeof b === "object" && "text" in b)
473
+ return String(b.text);
474
+ // Non-text blocks (json, resource, link, unknown): JSON-stringify to preserve all content.
475
+ return JSON.stringify(b);
476
+ })
459
477
  .join(" ")
460
478
  .slice(0, max);
461
479
  return "";
package/dist/assert.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { resolve, relative, isAbsolute, sep } from "node:path";
3
3
  import { compileUserRegex } from "./regex.js";
4
4
  /** Resolve a user-authored assertion path under `workRoot`, rejecting absolute paths and any `..` that
@@ -209,6 +209,9 @@ function check(a, ctx) {
209
209
  // evidence the assertion requires, so it fails loud ("no silent false-greens"). `delivered:
210
210
  // false` is a real errored tool_result; `null` is "no tool_result observed for this gate".
211
211
  if (a.gate_answers_delivered) {
212
+ if (ctx.gateDeliveries.length === 0) {
213
+ results.push(fail("gate_answers_delivered: no gates were recorded during this run — either no gate fired, or gate delivery tracking is not wired. Expected at least one delivered gate."));
214
+ }
212
215
  const bad = ctx.gateDeliveries.filter((g) => g.delivered !== true);
213
216
  results.push(bad.length === 0
214
217
  ? ok()
@@ -236,8 +239,15 @@ function check(a, ctx) {
236
239
  else {
237
240
  let doc;
238
241
  let parsed = true;
242
+ const fileSizeLimit = 10 * 1024 * 1024;
243
+ const fileSize = statSync(file).size;
244
+ if (fileSize > fileSizeLimit) {
245
+ results.push(fail(`artifact_json: file too large to parse as JSON (${fileSize} bytes, limit 10 MiB)`));
246
+ parsed = false;
247
+ }
239
248
  try {
240
- doc = JSON.parse(readFileSync(file, "utf8"));
249
+ if (parsed)
250
+ doc = JSON.parse(readFileSync(file, "utf8"));
241
251
  }
242
252
  catch (e) {
243
253
  parsed = false;
package/dist/baseline.js CHANGED
@@ -15,13 +15,20 @@ export function resolveAgentBinary(baseline) {
15
15
  const staged = (baseline.agentBinary?.stagedPath ?? "").replace(/^~(?=$|\/)/, homedir());
16
16
  if (staged && existsSync(staged))
17
17
  return resolve(staged);
18
- // The baseline's exact version dir is gone (e.g. Claude Desktop updated). Fall back to the
19
- // newest sibling binary under the same claude-code-vm/ root before giving up.
18
+ // The baseline's exact version dir is gone (e.g. Claude Desktop updated).
19
+ // By default this is a hard failure — a different agent version can silently change behavior.
20
+ // Set COWORK_HARNESS_ALLOW_AGENT_FALLBACK=1 to opt in to using the newest sibling binary.
21
+ const exactPath = staged || "(unknown)";
20
22
  const fallback = staged ? newestStagedBinary(staged) : undefined;
21
- if (fallback) {
23
+ if (fallback && process.env.COWORK_HARNESS_ALLOW_AGENT_FALLBACK === "1") {
22
24
  process.stderr.write(`cowork-harness: staged agent binary "${staged}" not found; ` + `falling back to newest sibling "${fallback}".\n`);
23
25
  return fallback;
24
26
  }
27
+ if (fallback) {
28
+ // A fallback exists but the opt-in env is not set — fail explicitly rather than silently using a
29
+ // different agent version that could make the run appear green while running the wrong binary.
30
+ throw new Error(`cowork-harness: baseline agent binary not found: ${exactPath}. Set COWORK_HARNESS_ALLOW_AGENT_FALLBACK=1 to use the newest available.`);
31
+ }
25
32
  throw new Error(`Staged agent binary not found at "${staged}". It is extracted from your Claude Desktop install ` +
26
33
  `(claude-code-vm/<ver>/claude). Open Cowork once to stage it, or set COWORK_AGENT_BINARY to its path.`);
27
34
  }