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.
- package/.claude/skills/cowork-harness/scripts/scenario.py +1 -0
- package/CHANGELOG.md +55 -0
- package/README.md +20 -11
- package/baselines/prompts/desktop-1.12603.1/system-prompt-append.md +12 -0
- package/baselines/prompts/desktop-1.13576.1/host-loop-append.md +11 -0
- package/dist/cli.js +187 -1
- package/dist/run/cassette.js +132 -28
- package/dist/run/chat.js +16 -2
- package/dist/run/doctor.js +205 -0
- package/dist/run/execute.js +24 -3
- package/dist/run/skill-hash.js +140 -10
- package/dist/scan.js +17 -5
- package/dist/session.js +12 -0
- package/dist/types.js +7 -0
- package/docs/README.md +3 -2
- package/docs/boundary.md +3 -3
- package/docs/cassette.md +29 -12
- package/docs/cowork-spawn-contract-1.12603.1.md +6 -0
- package/docs/scenario.md +56 -4
- package/docs/session.md +14 -2
- package/examples/README.md +72 -0
- package/examples/replays/README.md +38 -0
- package/examples/replays/example-pdf-skill.cassette.json +33 -0
- package/package.json +2 -1
- package/python/README.md +2 -1
- package/schema/scenario.schema.json +7 -0
- package/schema/session.schema.json +16 -0
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.
|
|
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
|
-
|
|
213
|
-
cowork-harness
|
|
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
|
|
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`
|
|
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.
|
|
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) {
|