cowork-harness 0.4.3 → 0.5.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.
@@ -115,6 +115,7 @@ TOP_LEVEL_KEYS = {
115
115
  "answers",
116
116
  "expect_denied",
117
117
  "assert",
118
+ "skills", # F-6: opt-in skill-staleness hash scope
118
119
  }
119
120
  REGEX_KEYS = {
120
121
  "transcript_matches",
package/CHANGELOG.md CHANGED
@@ -6,6 +6,61 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] — 2026-06-18
10
+
11
+ ### Added
12
+
13
+ - **`verify-run <run-dir> <scenario.yaml>`** — re-evaluate a scenario's `assert:` block against an
14
+ already-kept run dir with **no live agent** (no tokens, no Docker). Fixing a wrong assertion was a full live
15
+ re-record (~17 min); this turns it into ~1s. Reconstructs the assert context from the run's `result.json` +
16
+ the `run.jsonl`/`trace.json` sidecars and routes the verdict through the same path as a real record. Refuses
17
+ (rather than false-passing) when a filesystem assertion needs a work dir that's already torn down.
18
+ - **`record --max-artifact-bytes <n>` / `COWORK_HARNESS_MAX_ARTIFACT_BYTES`** — override the 64 KiB
19
+ inline-body cap so a large structured deliverable can be inlined instead of stored hash-only. Paired with a
20
+ **record-time guard**: if an `artifact_json` asserts an artifact that had to be truncated, record now fails
21
+ (or warns under `--allow-failing`) at the cause, instead of producing a green record that goes red at replay
22
+ (no committed body to parse).
23
+ - **`verify-cassettes --allow-domain` / `--allow-email` / `--allow-file`** — class-scoped privacy allows, plus
24
+ a version-controlled allow file (one regex per line, `#` comments).
25
+ - **Scoped cassette-staleness fingerprint** — scenario **`skills: [<name>]`** narrows the staleness hash to the
26
+ named skills' dirs plus the plugin's shared roots (fail-closed to whole-tree on an unknown name); session
27
+ **`staleness.hash_ignore`** globs and a plugin-local **`.cowork-hashignore`** file (composed) drop
28
+ non-runtime paths (`tests/`, `docs/`). Default behavior is unchanged (whole-tree, byte-identical). Cassette
29
+ format bumped to **v2** (an older reader warns rather than mis-flagging a scoped cassette as stale).
30
+ - **Per-subcommand `--help`** — subcommands now print a usage line and exit 0 instead of answering `--help`
31
+ with `unknown flag` (exit 2).
32
+ - **Cowork identity in the system-prompt append** — the emulated agent now self-identifies as "Claude, the
33
+ Cowork assistant" and is told it is **not** Claude Code (verified against the installed Claude Desktop app;
34
+ reconstructed, not bundled).
35
+
36
+ ### Fixed
37
+
38
+ - **Privacy allows are whole-token + class-scoped.** A bare `--allow <regex>` previously substring-matched, so a
39
+ domain allow (`example\.com`) silently cleared an email finding (`alice@example.com`) whose domain it matched.
40
+ Allows are now anchored to the whole finding token, and `--allow-domain`/`--allow-email` can't bleed across
41
+ classes — the email tripwire stays live.
42
+ - **Staleness hash no longer over-fires.** A pure `plugin.json` `version` bump (and, with the new scoping knobs,
43
+ unrelated skills/tests/docs) no longer re-stales every cassette in a multi-skill plugin. *Upgrade note:*
44
+ because the hash now ignores the `plugin.json` `version` field, cassettes recorded before this release
45
+ recompute to a new digest and are flagged **stale once** after upgrading — re-record them
46
+ (`record --rerecord-stale`). The cassette format is also bumped to **v2**.
47
+ - **`chat` is pipe/script-safe.** A piped/non-interactive stdin reaching EOF mid-turn crashed the REPL with
48
+ `ERR_USE_AFTER_CLOSE`; it now exits cleanly.
49
+ - **Outputs-delete findings show the `rm` itself.** A long `VAR=…` assignment prefix used to push the operative
50
+ delete past the truncation; the finding now isolates and variable-resolves the delete target.
51
+ - **Clearer record/run messaging.** The record freeze-refusal separates the run *result* from the *verdict* and
52
+ names the failing signal; the run log states the unscripted-question *policy* instead of reading as a failure
53
+ on clean runs.
54
+ - **`sync` warns when a synced baseline lacks its host-loop prompt asset** — previously host-loop records
55
+ silently ran with an empty shell-access section.
56
+
57
+ ### Internal
58
+
59
+ - Corrected the system-prompt fidelity note in `docs/boundary.md` (Cowork appends onto the `claude_code` preset
60
+ by default rather than replacing it).
61
+ - Assertion docs steer content checks to `artifact_json` / stable lexical markers (not paraphrasable prose).
62
+ - `vitest` excludes `runs/` from test discovery (ephemeral live output could crash the walk with EACCES).
63
+
9
64
  ## [0.4.3] — 2026-06-18
10
65
 
11
66
  ### Fixed
package/README.md CHANGED
@@ -15,6 +15,11 @@ Scriptable, CI-friendly test harness that reproduces **Claude Cowork's observabl
15
15
 
16
16
  **Contents:** [Why it works](#why-this-works-for-skill-testing) · [Fidelity tiers](#fidelity-tiers-pick-per-scenario--per-ci-job) · [Quick start](#quick-start) · [Session + scenario](#two-files-session--scenario) · [Boundary](#sandboxing-container-vs-the-real-vm) · [Discovery](#discovery-marketplaces-plugins-skills-mcp) · [Testing & CI/CD](#testing--cicd) · [Maintenance](#maintenance-parity-between-releases) · [Docs](#documentation)
17
17
 
18
+ > **Requirements at a glance**
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.
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
+
18
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).
19
24
 
20
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.
@@ -74,15 +79,18 @@ Skill testing is the headline use, but the tool is a general harness over the Co
74
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 |
75
80
  | `run <scenario.yaml \| dir/>` | Run authored scenarios with `assert:` + a CI-ready exit code | you want a repeatable, **asserted regression test** |
76
81
  | `chat <folder>` | Interactive multi-turn REPL against a skill (TTY) | debugging a multi-turn flow by hand |
77
- | `record` / `replay` | `record` saves a control-protocol cassette (one scenario, or batch a `dir/`; `--rerecord-stale` refreshes only drifted ones); `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 |
78
- | `verify-cassettes <file\|dir>` | Token-free CI gate over committed cassettes: a privacy scan (email/currency/domain → exit 1) + 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" |
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" |
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 |
79
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?" |
80
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 |
87
+ | `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 |
81
88
  | `assert --list` | List the available scenario assertions (generated from the schema) | "what can I assert?" without grepping the source |
82
89
  | `decide` | Validate a decider against a sample question in ~2 s (no run) | sanity-check a `--decider-*` / `--answer` wiring before a long run |
83
90
  | `gates` / `answer` | Stream / answer in-band gates for `--decider-dir` | a **driving agent** answers live questions via a Monitor |
84
- | `boundary-check [baseline]` | Prove the sandbox enforces Cowork's limitations | verifying the harness's own fidelity |
91
+ | `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 |
85
92
  | `sync` / `list` | Derive/refresh & list platform baselines from the Desktop install | after Claude Desktop updates (baselines ship, so it's optional otherwise) |
93
+ | `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 |
86
94
  | `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` |
87
95
 
88
96
  There's also a **Python `cowork` pytest lane** (`python/`) for driving any of this from `pytest` beside your normal tests — see [`python/README.md`](./python/README.md).
@@ -182,7 +190,7 @@ This repo ships a **companion skill** (`.claude/skills/cowork-harness/`) that te
182
190
  /plugin install cowork-harness@cowork-harness
183
191
  ```
184
192
 
185
- The skill **self-bootstraps the CLI**: if `cowork-harness` isn't on your PATH it falls back to `npx cowork-harness@>=0.3.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.
193
+ The skill **self-bootstraps the CLI**: if `cowork-harness` isn't on your PATH it falls back to `npx cowork-harness@>=0.5.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.
186
194
 
187
195
  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.)
188
196
 
@@ -209,14 +217,15 @@ cowork-harness run examples/scenarios/ --output-format json
209
217
 
210
218
  # 4. Record a cassette once, then replay it deterministically (no token, no Docker)
211
219
  # (without --out, the cassette is named after the scenario — its `name:`, or the filename)
212
- cowork-harness record examples/scenarios/example-pdf-skill.yaml --out cassettes/example-pdf-skill.cassette.json
213
- cowork-harness replay --cassette cassettes/example-pdf-skill.cassette.json
220
+ # Commit cassettes under examples/replays/ (this repo) or cassettes/ (conventional skill-repo name).
221
+ cowork-harness record examples/scenarios/example-pdf-skill.yaml --out examples/replays/example-pdf-skill.cassette.json
222
+ cowork-harness replay --cassette examples/replays/example-pdf-skill.cassette.json
214
223
 
215
224
  # A committed synthetic fixture is ready to replay on a fresh clone (no record step needed):
216
225
  cowork-harness replay --cassette examples/replays/example-pdf-skill.cassette.json
217
226
 
218
227
  # Cassettes are COMMITTED fixtures — record against synthetic data, and gate them in CI:
219
- cowork-harness verify-cassettes cassettes/ # privacy scan (email/currency/domain) + staleness; exit 1 on a finding
228
+ cowork-harness verify-cassettes examples/replays/ # privacy scan (email/currency/domain) + staleness; exit 1 on a finding
220
229
  ```
221
230
 
222
231
  > **Privacy:** a cassette snapshots the transcript and the `outputs/` JSON bodies, so it's committed PII
@@ -403,11 +412,11 @@ Author scenarios in your own `scenarios/` dir, run the lot, get a non-zero exit
403
412
  cowork-harness run scenarios/ # your repo's scenarios; runs every *.yaml, CI-ready exit code
404
413
  ```
405
414
 
406
- The provided [GitHub Actions workflow](.github/workflows/ci.yml) runs a **four-stage pipeline** you can copy into your skill repo:
415
+ The provided [GitHub Actions workflow](.github/workflows/ci.yml) runs a **four-stage pipeline**. The **unit** stage is the token-free gate you can copy into your skill repo; the `boundary`, `scenarios`, and `parity-drift` stages are this repo's own fidelity self-tests and are not directly portable (they build the harness's Docker image and run harness-specific e2e scenarios — see [`ci-recipe.md`](./.claude/skills/cowork-harness/references/ci-recipe.md) for the skill-repo template):
407
416
 
408
417
  | Stage | Runs | Needs | Gates |
409
418
  |---|---|---|---|
410
- | **unit** | format check · typecheck · unit tests · build · CLI smoke · token-free `replay` + `verify-cassettes` gates | nothing | every push/PR |
419
+ | **unit** | format check · typecheck · unit tests · build · CLI smoke · token-free `replay` · `verify-cassettes` · `lint` | nothing | every push/PR |
411
420
  | **boundary** | builds the pinned agent image, brings up the default-deny network, runs `boundary-check` | Docker, arm64 runner | proves the sandbox enforces Cowork's limits — **no API key** |
412
421
  | **scenarios** | the live scenario suite at `container` fidelity, uploads transcripts/egress logs as artifacts | `ANTHROPIC_API_KEY` (or `CLAUDE_CODE_OAUTH_TOKEN`) | fork PRs: the whole job is skipped (`if:` guard); same-repo without a key: warns and exits 0 |
413
422
  | **parity-drift** | reminder to re-`sync` when Desktop updates | nothing | informational, never blocks |
@@ -433,7 +442,7 @@ Unit tests cover the scripted-answer logic, the egress allowlist matcher, the se
433
442
  - `COWORK_AGENT_IMAGE=<tag>` — override the agent image name (default `cowork-agent-base:1`); `COWORK_AGENT_BINARY=<path>` — override the auto-detected staged agent ELF.
434
443
  - `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.
435
444
  - `COWORK_HARNESS_RUNS_DIR` — relocate the `runs/` output root (so `trace` resolves runs from any directory).
436
- - **Networking / loop:** `COWORK_EGRESS_PROXY` overrides the egress-proxy URL injected into the sandbox; `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`).
445
+ - **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`).
437
446
  - **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).
438
447
  - **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.
439
448
  - 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.
@@ -508,7 +517,7 @@ This repo is built to be driven by agents, not just read by humans:
508
517
 
509
518
  ## Status
510
519
 
511
- **Verified end-to-end against the live staged agent (2.1.170 / asar 1.12603.1).**
520
+ **Verified end-to-end against the live staged agent (2.1.177 / asar 1.13576.1).**
512
521
 
513
522
  - ✅ **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**.
514
523
  - ✅ **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.
@@ -6,6 +6,18 @@
6
6
  `initialize` (appendSubagentSystemPrompt). Tokens substituted by src/prompt.ts
7
7
  mirroring the Desktop builder `y8r`. -->
8
8
 
9
+ <!-- <application_details> reconstructed from the real Cowork prompt. Binary-confirmed in
10
+ Claude.app app.asar 1.13576.1 at byte offset ~6,971,088 as a STATIC module constant —
11
+ the default cowork prompt body, NOT gated behind a server-pushed spVariant — and
12
+ cross-checked against behavioral capture (2026-06-18). Real Cowork = claude_code preset
13
+ + this append; the base preset alone makes the agent say "I'm Claude Code", so the
14
+ identity correction must live in the append (layered after the preset via
15
+ --append-system-prompt). Paraphrased, NOT copied verbatim, per the repo's no-bundling
16
+ rule; model name comes from the <env> block, not hardcoded here. -->
17
+ <application_details>
18
+ You are Claude, an AI assistant made by Anthropic. You are powering Cowork mode, a feature of the Claude desktop app that is currently a research preview. You are implemented on top of Claude Code and the Claude Agent SDK, but you are not Claude Code and should not refer to yourself as such — refer to yourself as Claude. Do not mention implementation details like this (including Claude Code or the Claude Agent SDK) unless it is relevant to the user's request.
19
+ </application_details>
20
+
9
21
  <high_level_computer_use_explanation>
10
22
  Claude runs in a lightweight Linux VM (Ubuntu 22) on the user's computer. This VM provides a secure sandbox for executing code while allowing controlled access to user files. The working directory is {{cwd}}; user-attached folders are mounted under {{cwd}}/mnt/.
11
23
  </high_level_computer_use_explanation>
@@ -0,0 +1,11 @@
1
+ <!-- host-loop "Shell access" section. Carried forward from desktop-1.12603.1 (the shell-access
2
+ semantics — independent bash calls, {{vmMnt}} path translation, unmounted ${CLAUDE_PLUGIN_ROOT}
3
+ self-heal — are version-stable across this Desktop range). Re-verify against the desktop-1.13576.1
4
+ asar (D8r host branch) when an asar copy is available. Appended to the system prompt only in
5
+ host-loop. {{vmMnt}} substituted. -->
6
+
7
+ ## Shell access
8
+
9
+ Shell commands use `mcp__workspace__bash` and run in an isolated Linux environment. Each call is independent — no cwd or env carryover between calls. Use absolute paths.
10
+
11
+ Paths in bash differ from what file tools (Read/Write/Edit) see. Your connected folders are mounted in bash under {{vmMnt}}/. A file you Read at a host path is reached in bash under {{vmMnt}}/ — translate host paths to their {{vmMnt}} equivalent before using them in bash. In particular, `${CLAUDE_PLUGIN_ROOT}` is a host path; to run a plugin's scripts in bash, locate them under {{vmMnt}} (e.g. `find {{vmMnt}} -path '*/skills/*/scripts'`) rather than using the host path directly.
package/dist/cli.js CHANGED
@@ -16,6 +16,7 @@ import { cmdChat } from "./run/chat.js";
16
16
  import { cmdRecord, cmdReplay, cmdVerifyCassettes } from "./run/cassette.js";
17
17
  import { resolveInputs } from "./run/inputs.js";
18
18
  import { cmdLint } from "./run/scenario-tool.js";
19
+ import { cmdDoctor } from "./run/doctor.js";
19
20
  import { parseArgs } from "./cli-args.js";
20
21
  import { loadDotenv } from "./dotenv.js";
21
22
  import { makeRenderer, renderStart, renderFooter, startHeartbeat } from "./run/renderer.js";
@@ -23,6 +24,7 @@ import { resolveEventsFile, buildTrace, formatTrace, buildGateTrace, formatGateT
23
24
  import { buildScaffold } from "./run/scaffold.js";
24
25
  import { pkgVersion, jsonEnvelope, jsonError, parseOutputFormat } from "./run/envelope.js";
25
26
  import { computeVerdict } from "./run/verdict.js";
27
+ import { evaluate, hostMatches } from "./assert.js";
26
28
  import { spawnChannel, fileChannel, streamGates, answerGate, readGate } from "./decide/external-channel.js";
27
29
  // Synchronous writes (fd 1/2): `process.stdout.write` + `process.exit()` truncates on a PIPE, which
28
30
  // would lose the json envelope for any agent/CI that pipes us. writeSync flushes before exit.
@@ -52,12 +54,15 @@ const HELP = `cowork-harness <command> (v${"$VERSION"})
52
54
  (--fidelity container|hostloop, default container; --model <id>)
53
55
  record <scenario.yaml> run + save a control-protocol cassette
54
56
  [--out <file>] cassette path (default: cassettes/<scenario-name>.cassette.json)
57
+ [--max-artifact-bytes <n>] inline-body cap (default 65536 / $COWORK_HARNESS_MAX_ARTIFACT_BYTES)
55
58
  replay <file|dir> deterministic protocol-replay of a cassette or a dir of them (no token)
56
59
  [--cassette <file>] explicit single-cassette form [--strict] [--output-format json]
57
60
  lint <scenario.yaml>… check scenarios for silent false-greens (bundled scenario.py; needs python3 + PyYAML)
58
61
  [--strict] escalate a cassette-staleness warning (baseline/skill drift) to a failure
59
62
  verify-cassettes <file|dir> CI gate (no token): privacy scan + staleness — exit 1 on a PII finding or drift
60
- [--privacy-only|--staleness-only] [--allow <regex>]... [--output-format json]
63
+ [--privacy-only|--staleness-only] [--allow <regex>]... [--allow-domain/-email <regex>]... [--allow-file <path>]... [--output-format json]
64
+ verify-run <run-dir> <scenario.yaml> re-evaluate a scenario's assert: against a kept run dir (no live agent)
65
+ [--output-format json] (fast assert iteration: fix an assertion + re-check in ~1s instead of a full re-record)
61
66
  trace <run-id | dir | path> digest a run's events.jsonl (tools+result status, dispatches, decisions)
62
67
  [--tools] tool/dispatch rows only [--gates] gate lifecycle (question→answer→delivered)
63
68
  [--dispatches] sub-agent dispatch tree + the real total (read off dispatch_count_max)
@@ -76,6 +81,7 @@ const HELP = `cowork-harness <command> (v${"$VERSION"})
76
81
  list list available platform baselines
77
82
  boundary-check [baseline] prove the sandbox enforces Cowork's limitations
78
83
  vm <init|status|delete|prune> manage the L2 Apple-VZ microVM (fidelity: microvm); prune drops orphaned VMs
84
+ doctor [--tier <tier>] read-only prerequisite check (Docker, staged agent, token, baseline)
79
85
 
80
86
  Global: --dotenv <path> load a .env before the command (host-side creds; never mounted).
81
87
  Auth resolves from process.env > --dotenv > ./.env > <install>/.env.
@@ -185,6 +191,29 @@ function printHelp() {
185
191
  function hasHelp(args) {
186
192
  return args.includes("--help") || args.includes("-h");
187
193
  }
194
+ // F-7: per-subcommand `--help`. `run`/`skill` already print their own help via hasHelp(); `lint` delegates
195
+ // to the Python argparse path (which has its own --help). Every OTHER subcommand goes straight to parseArgs,
196
+ // where `--help` was an "unknown flag" error — so you could only discover flags by triggering a bad
197
+ // invocation. Intercept `--help`/`-h` at dispatch and print the command's usage (exit 0). One concise line
198
+ // per command, kept in sync with each command's own bad-invocation `usage:` string.
199
+ const SUBCOMMAND_USAGE = {
200
+ sync: "usage: sync [--diff] (re-sync the platform baseline from the installed Cowork app)",
201
+ list: "usage: list (list available platform baselines)",
202
+ "boundary-check": "usage: boundary-check [<baseline>] [--session <file>]",
203
+ vm: "usage: vm <init|status|delete|prune>",
204
+ chat: "usage: chat <skill-folder> [--raw] [--fidelity container|hostloop] [--model <id>]",
205
+ record: "usage: record <scenario.yaml | dir/> [--out <file>] [--output-format text|json] [--rerecord-stale] [--no-redact] [--allow-failing] [--max-artifact-bytes <n>]",
206
+ replay: "usage: replay <file.cassette.json | dir/> [--cassette <file>] [--strict] [--output-format text|json]",
207
+ "verify-cassettes": "usage: verify-cassettes <file|dir> [--privacy-only|--staleness-only] [--allow <regex>]... [--allow-domain <regex>]... [--allow-email <regex>]... [--allow-file <path>]... [--output-format json]",
208
+ trace: "usage: trace <run-id | run-dir | events.jsonl> [--tools | --gates | --dispatches] [--output-format json]",
209
+ assert: "usage: assert --list [--output-format json]",
210
+ scaffold: "usage: scaffold --from-run <run-id | run-dir> [--out <file.yaml>]",
211
+ decide: 'usage: decide [--question <q>] [--option <o>]... [--decider-cmd <cmd> | --decider-llm] [--answer "<q>=<label>"]... [--answer-policy <p>] [--intent <s>] [--output-format json]',
212
+ gates: "usage: gates <dir> [--follow]",
213
+ answer: 'usage: answer <dir> --gate <N> (--choose <label> | --answer "<q>=<label>")',
214
+ "verify-run": "usage: verify-run <run-dir> <scenario.yaml> [--output-format json] (re-evaluate a scenario's assert: against a kept run dir; no live agent)",
215
+ doctor: "usage: doctor [--tier protocol|container|microvm|hostloop|cowork] [--output-format json] (read-only prerequisite check)",
216
+ };
188
217
  async function main() {
189
218
  const argv = process.argv.slice(2);
190
219
  // `--dotenv <path>` is a GLOBAL flag — parse + strip it before command dispatch so a skill run from
@@ -218,6 +247,7 @@ async function main() {
218
247
  "record",
219
248
  "replay",
220
249
  "verify-cassettes",
250
+ "verify-run",
221
251
  "trace",
222
252
  "assert",
223
253
  "scaffold",
@@ -229,6 +259,7 @@ async function main() {
229
259
  "boundary-check",
230
260
  "vm",
231
261
  "lint",
262
+ "doctor",
232
263
  ];
233
264
  // The command-name footgun only applies to the space form (the equals form can't swallow the next
234
265
  // token as its value), but checking both is harmless and keeps the guard uniform.
@@ -264,6 +295,10 @@ async function main() {
264
295
  return void out(pkgVersion());
265
296
  if (cmd === undefined || cmd === "--help" || cmd === "-h" || cmd === "help")
266
297
  return printHelp();
298
+ // F-7: per-subcommand --help for the parseArgs-direct commands (run/skill/lint self-handle, so they're
299
+ // absent from the map and fall through to their own handling).
300
+ if (hasHelp(rest) && cmd in SUBCOMMAND_USAGE)
301
+ return void log(SUBCOMMAND_USAGE[cmd]);
267
302
  switch (cmd) {
268
303
  case "run":
269
304
  return cmdRun(rest);
@@ -271,6 +306,8 @@ async function main() {
271
306
  return cmdSync(rest);
272
307
  case "list":
273
308
  return cmdList();
309
+ case "doctor":
310
+ return cmdDoctor(rest);
274
311
  case "boundary-check":
275
312
  return cmdBoundary(rest);
276
313
  case "vm":
@@ -287,6 +324,8 @@ async function main() {
287
324
  return cmdLint(rest);
288
325
  case "verify-cassettes":
289
326
  return cmdVerifyCassettes(rest);
327
+ case "verify-run":
328
+ return cmdVerifyRun(rest);
290
329
  case "trace":
291
330
  return cmdTrace(rest);
292
331
  case "assert":
@@ -984,6 +1023,14 @@ function cmdSync(args) {
984
1023
  mkdirSync(BASELINES_DIR, { recursive: true });
985
1024
  writeFileSync(baselinePath, JSON.stringify(next, null, 2));
986
1025
  log(`wrote ${baselinePath}`);
1026
+ // F-3: the host-loop prompt asset is hand-authored (not extracted), so a new baseline silently lands
1027
+ // WITHOUT it — and every host-loop record then runs with an EMPTY shell-access section. Warn loudly here,
1028
+ // tying the missing asset to the baseline just synced (mirrors the agentBinary.stagedPath warn above).
1029
+ const hostLoopAsset = join(BASELINES_DIR, "prompts", `desktop-${res.appVersion}`, "host-loop-append.md");
1030
+ if (!existsSync(hostLoopAsset)) {
1031
+ log(`WARNING: host-loop prompt asset missing for the synced baseline: ${hostLoopAsset} — host-loop records will run with an EMPTY shell-access section. ` +
1032
+ `Author it (carry forward baselines/prompts/desktop-1.12603.1/host-loop-append.md and verify against the desktop-${res.appVersion} asar).`);
1033
+ }
987
1034
  }
988
1035
  }
989
1036
  function cmdList() {
@@ -1241,6 +1288,145 @@ function cmdScaffold(args) {
1241
1288
  else
1242
1289
  out(yaml);
1243
1290
  }
1291
+ /** Read the persisted transcript from a kept run's `run.jsonl` (the `{t:"transcript"}` line). */
1292
+ function readTranscriptSidecar(file) {
1293
+ try {
1294
+ for (const line of readFileSync(file, "utf8").split("\n")) {
1295
+ if (!line.trim())
1296
+ continue;
1297
+ const o = JSON.parse(line);
1298
+ if (o && o.t === "transcript")
1299
+ return String(o.text ?? "");
1300
+ }
1301
+ }
1302
+ catch {
1303
+ /* sidecar absent/unreadable — empty transcript (content asserts over it simply won't match) */
1304
+ }
1305
+ return "";
1306
+ }
1307
+ /** Read the AskUserQuestion question texts from a kept run's `trace.json` (`questions` array). */
1308
+ function readQuestionsSidecar(file) {
1309
+ try {
1310
+ const t = JSON.parse(readFileSync(file, "utf8"));
1311
+ if (Array.isArray(t.questions))
1312
+ return t.questions.map(String);
1313
+ }
1314
+ catch {
1315
+ /* sidecar absent/unreadable */
1316
+ }
1317
+ return [];
1318
+ }
1319
+ /**
1320
+ * F-1: `verify-run <run-dir> <scenario.yaml>` — re-evaluate a scenario's `assert:` block against an
1321
+ * already-kept run dir, WITHOUT a live agent (no tokens, no Docker). Fixing a wrong assertion was previously
1322
+ * a full live re-record (~17 min); this turns it into ~1s. Reconstructs the AssertContext from the run dir's
1323
+ * persisted `result.json` + the `run.jsonl`/`trace.json` sidecars (transcript + questions live there, not in
1324
+ * result.json), reproduces the `expect_denied` expansion that `evaluate()` doesn't do, and routes the verdict
1325
+ * through the same `computeVerdict(…, "live")` as a real record.
1326
+ *
1327
+ * Limits (vs a fresh live record): sidecars are SCRUBBED at record time, so an assertion over a redacted
1328
+ * secret value is not faithfully re-checkable; and filesystem assertions (`file_exists`/`artifact_json`/
1329
+ * `user_visible_artifact`) need the run's `workDir` to still exist on disk — if it's gone (container/microvm
1330
+ * teardown), verify-run refuses rather than false-failing them.
1331
+ */
1332
+ function cmdVerifyRun(args) {
1333
+ let p;
1334
+ try {
1335
+ p = parseArgs(args, { values: ["--output-format"], enums: { "--output-format": ["text", "json"] } });
1336
+ }
1337
+ catch (e) {
1338
+ log(e.message);
1339
+ return process.exit(2);
1340
+ }
1341
+ const json = p.options["--output-format"] === "json";
1342
+ const [runDir, scenarioFile] = p.positionals;
1343
+ if (!runDir || !scenarioFile) {
1344
+ log("usage: verify-run <run-dir> <scenario.yaml> [--output-format json]");
1345
+ return process.exit(2);
1346
+ }
1347
+ if (p.positionals.length > 2) {
1348
+ log(`verify-run takes <run-dir> <scenario.yaml> (got ${p.positionals.length}: ${p.positionals.join(", ")})`);
1349
+ return process.exit(2);
1350
+ }
1351
+ const resultPath = join(runDir, "result.json");
1352
+ if (!existsSync(resultPath)) {
1353
+ log(`verify-run: no result.json under ${runDir} (is this a kept run dir? e.g. runs/<scenario>/<sessionId>/)`);
1354
+ return process.exit(2);
1355
+ }
1356
+ let result;
1357
+ try {
1358
+ result = JSON.parse(readFileSync(resultPath, "utf8"));
1359
+ }
1360
+ catch (e) {
1361
+ log(`verify-run: cannot read ${resultPath}: ${e.message}`);
1362
+ return process.exit(2);
1363
+ }
1364
+ let scenario;
1365
+ try {
1366
+ scenario = parseScenarioFile(scenarioFile);
1367
+ }
1368
+ catch (e) {
1369
+ log(`verify-run: cannot load scenario ${scenarioFile}: ${e.message}`);
1370
+ return process.exit(2);
1371
+ }
1372
+ const workRoot = result.workDir ?? "";
1373
+ const scan = result.scan ?? { outputsDeletes: [], hostPathLeaked: false, selfHealRan: false };
1374
+ // FS-class assertions resolve under workRoot; if it's gone we can't faithfully re-check them — refuse
1375
+ // rather than report a false fail. Content-only re-asserts stay valid without it.
1376
+ const FS_KEYS = ["file_exists", "user_visible_artifact", "artifact_json"];
1377
+ const hasFsAssert = scenario.assert.some((a) => FS_KEYS.some((k) => a[k] !== undefined));
1378
+ if (hasFsAssert && !existsSync(workRoot)) {
1379
+ log(`verify-run: work dir not found (${workRoot || "<unset>"}) — filesystem assertions ` +
1380
+ `(file_exists/artifact_json/user_visible_artifact) cannot be re-evaluated from this run dir; re-record. (can't verify ⇒ not green)`);
1381
+ return process.exit(2);
1382
+ }
1383
+ const ctx = {
1384
+ transcript: readTranscriptSidecar(join(runDir, "run.jsonl")),
1385
+ toolsCalled: new Set(Object.keys(result.toolCounts ?? {})),
1386
+ subagentTools: new Set((result.subagents ?? []).flatMap((s) => s.toolsUsed ?? [])),
1387
+ egress: result.egress ?? [],
1388
+ result: result.result === "error" ? "error" : "success",
1389
+ workRoot,
1390
+ userVisiblePrefixes: ["outputs", ".projects"],
1391
+ outputsDeletes: scan.outputsDeletes,
1392
+ questions: readQuestionsSidecar(join(runDir, "trace.json")),
1393
+ hostPathLeaked: scan.hostPathLeaked,
1394
+ selfHealRan: scan.selfHealRan,
1395
+ subagents: result.subagents ?? [],
1396
+ gateDeliveries: result.gateDeliveries ?? [],
1397
+ };
1398
+ const assertions = evaluate(scenario.assert, ctx);
1399
+ // Reproduce execute.ts's expect_denied → egress_denied expansion (evaluate() does not handle it).
1400
+ for (const host of scenario.expect_denied) {
1401
+ assertions.push({
1402
+ assertion: { egress_denied: host },
1403
+ pass: ctx.egress.some((e) => hostMatches(e.host, host) && e.decision === "deny"),
1404
+ message: `expected ${host} to be denied`,
1405
+ });
1406
+ }
1407
+ // Verdict via the SAME path as a live record (the run dir is a live run, so the "live" lane honors the
1408
+ // scan/parity signals already persisted in result.json).
1409
+ const verdict = computeVerdict({ ...result, assertions }, "live");
1410
+ const failed = assertions.filter((a) => !a.pass);
1411
+ if (json) {
1412
+ out(JSON.stringify({
1413
+ command: "verify-run",
1414
+ pass: verdict.pass,
1415
+ assertions: assertions.map((a) => ({ assertion: a.assertion, pass: a.pass, message: a.message })),
1416
+ signals: verdict.signals,
1417
+ }));
1418
+ }
1419
+ else {
1420
+ for (const a of assertions)
1421
+ log(`${a.pass ? "✓" : "✗"} ${Object.keys(a.assertion).join("+") || "(assertion)"}${a.message ? ` — ${a.message}` : ""}`);
1422
+ for (const s of verdict.signals.filter((s) => s.code !== "assertion"))
1423
+ log(`${s.severity === "fail" ? "✗" : "·"} ${s.code}: ${s.message}`);
1424
+ log(verdict.pass
1425
+ ? `✓ verify-run: all ${assertions.length} assertion(s) pass (no live agent)`
1426
+ : `✗ verify-run: ${failed.length}/${assertions.length} assertion(s) failed`);
1427
+ }
1428
+ return process.exit(verdict.exitCode);
1429
+ }
1244
1430
  /** `assert --list` (#8) — enumerate the available assertion keys + one-line semantics, generated from the
1245
1431
  * Zod `Assertion` schema (`Assertion.shape[k].description`) so the list can NEVER drift from the schema. */
1246
1432
  function cmdAssert(args) {