cowork-harness 0.15.0 → 0.17.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/CHANGELOG.md +110 -0
- package/README.md +79 -69
- package/dist/agent/session.js +16 -2
- package/dist/assert.js +26 -8
- package/dist/baseline.js +5 -1
- package/dist/boundary.js +18 -6
- package/dist/cli.js +171 -38
- package/dist/decide/decider.js +256 -57
- package/dist/decide/external-channel.js +11 -5
- package/dist/decide/llm-transport.js +123 -39
- package/dist/dotenv.js +3 -4
- package/dist/egress/proxy.js +34 -16
- package/dist/hostloop/workspace-handler.js +10 -3
- package/dist/io.js +24 -0
- package/dist/loop-decision.js +1 -1
- package/dist/run/cassette.js +307 -208
- package/dist/run/chat.js +13 -13
- package/dist/run/doctor.js +5 -5
- package/dist/run/execute.js +69 -28
- package/dist/run/inspect-view.js +7 -1
- package/dist/run/renderer.js +4 -1
- package/dist/run/scaffold.js +13 -2
- package/dist/run/skill-files.js +4 -4
- package/dist/run/skill-hash.js +22 -18
- package/dist/run/trace-view.js +29 -5
- package/dist/run/verdict.js +1 -1
- package/dist/runtime/container.js +7 -1
- package/dist/runtime/image-capabilities.js +16 -2
- package/dist/runtime/protocol.js +7 -3
- package/dist/runtime/stage.js +4 -1
- package/dist/session.js +54 -8
- package/dist/sync/cowork-sync.js +9 -2
- package/dist/types.js +9 -4
- package/docs/README.md +3 -1
- package/docs/boundary.md +8 -2
- package/docs/cassette.md +20 -3
- package/docs/chat.md +7 -5
- package/docs/cowork-spawn-contract-1.12603.1.md +3 -3
- package/docs/debugging.md +72 -0
- package/docs/decider-dir.md +4 -2
- package/docs/discovery.md +4 -1
- package/docs/fidelity-gaps.md +12 -12
- package/docs/maintenance.md +9 -1
- package/docs/scenario.md +40 -9
- package/docs/session.md +43 -25
- package/examples/README.md +2 -1
- package/examples/replays/example-pdf-skill.cassette.json +32 -29
- package/package.json +1 -1
- package/python/README.md +18 -4
- package/schema/cassette.v7.json +131 -0
- package/schema/scenario.schema.json +13 -7
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,116 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.17.0] — 2026-06-26
|
|
10
|
+
|
|
11
|
+
### Upgrade notes
|
|
12
|
+
|
|
13
|
+
- **Re-record all cassettes after upgrading** (`cowork-harness record cassettes/ --rerecord-stale`).
|
|
14
|
+
The skill-hash delimiter changed (v6 → v7); `verify-cassettes` reports which cassettes need it.
|
|
15
|
+
- **`transcript_no_host_path: false` is now rejected by the schema.** Remove the key or change it
|
|
16
|
+
to `true`. (It was never meaningful as `false`.)
|
|
17
|
+
- **`is_null: false` on an absent path now fails loud** instead of silently passing. Add
|
|
18
|
+
`exists: true` if you intend to assert presence before the null check.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **The microVM egress proxy port is now allocated via bind-port-0 instead of freePort().** The
|
|
23
|
+
previous approach (bind :0 → read port → close → re-bind real proxy) had a TOCTOU gap: another
|
|
24
|
+
process could grab the port between the probe close and the proxy bind. The proxy now binds on `:0`
|
|
25
|
+
directly; `actualPort` is read from the live socket and threaded into the guest firewall rule and
|
|
26
|
+
`HTTP(S)_PROXY` env after the proxy is already bound. L1 (container/hostloop) was unaffected
|
|
27
|
+
(uses a fixed port inside Docker's per-run network namespace); L0 (protocol) has no proxy.
|
|
28
|
+
- **Cassette fingerprint format is v7.** The skill-hash uses a NUL byte (`\0`) to delimit entries
|
|
29
|
+
(`F:`, `D:`, `L:`) — unambiguous for all POSIX-valid filenames. `CONTENTSIG_ALGO` is 3.
|
|
30
|
+
`verify-cassettes` reports `recorded under an older hash format (v6 → v7)` for stale cassettes;
|
|
31
|
+
re-record with `--rerecord-stale` to clear.
|
|
32
|
+
- **`transcript_no_host_path` only accepts `true`.** Omit the key to skip the check; `false` is
|
|
33
|
+
rejected by the schema (`const: true`).
|
|
34
|
+
- **`is_null: false` requires the path to be present.** An absent path fails loud. To assert "exists
|
|
35
|
+
and is not null", write `exists: true` alongside `is_null: false`.
|
|
36
|
+
- **The egress proxy no longer crashes on a double-end or EPIPE.** When an upstream TLS error
|
|
37
|
+
arrived after the response had already been ended (e.g. the client disconnected mid-stream),
|
|
38
|
+
calling `res.writeHead(502)` on an already-sent response threw, taking down the proxy for the
|
|
39
|
+
remainder of the run. The guard now skips the `writeHead` call when `res.headersSent` is true and
|
|
40
|
+
swallows the resulting EPIPE.
|
|
41
|
+
- **L0 (protocol) containment check now uses `realpathSync` to guard against symlink escape.** The
|
|
42
|
+
previous path comparison used the raw strings; a symlinked workspace folder could resolve outside
|
|
43
|
+
the declared root and the check would miss it.
|
|
44
|
+
- **Session `enabledPlugins` now emits the correct `{ "name@mp": true }` object-map** that
|
|
45
|
+
`claude --settings` requires, rather than an array of plugin-name strings that is silently ignored.
|
|
46
|
+
Plugin loading via `--settings` was silently broken for any session with `enabled_plugins:` set.
|
|
47
|
+
- **`probeMicrovmOmitted` no longer issues a `limactl shell` probe when the Lima VM is not Running.**
|
|
48
|
+
A cold (Absent) or stopped VM cannot be probed; the harness now skips the capability probe
|
|
49
|
+
entirely and returns `null` rather than trying to shell into a non-running instance.
|
|
50
|
+
- **`is_null: true` on an absent path now directs to `absent: true`** with a clear error rather than
|
|
51
|
+
silently treating absent-as-null (the two are semantically distinct: absent = the key doesn't
|
|
52
|
+
exist; null = it exists with a JSON null value).
|
|
53
|
+
- **Boundary `/host` probe split into two independent checks.** The previous single-command probe
|
|
54
|
+
could false-pass when the host filesystem was sealed but the `/host` directory existed and was
|
|
55
|
+
empty. The probe now AND-combines a listing check and a no-denial text check.
|
|
56
|
+
- **`--decider-llm` transport now bounded-retries a transient `claude -p` exit and surfaces *why* it
|
|
57
|
+
failed.** A single `claude -p` decider spawn can exit non-zero on a transient upstream hiccup
|
|
58
|
+
(rate-limit/overload/network) during a long back-to-back batch — observed 1/8 live runs, not reproducible
|
|
59
|
+
on demand. The non-zero-exit class is now retried (default 2 attempts, small linear backoff;
|
|
60
|
+
`COWORK_HARNESS_LLM_RETRIES=0` to disable) so a transient exit doesn't kill a 10-minute paid run at the
|
|
61
|
+
final gate. Retry never double-answers: the transport has no harness side effects, and a non-zero exit
|
|
62
|
+
delivers no answer, so the gate is answered exactly once downstream of a successful call. The timeout /
|
|
63
|
+
`maxBytes`-overflow / spawn-`ENOENT` classes are not transient and still fail loud on the first attempt.
|
|
64
|
+
(A *deterministic* non-zero exit — bad `--decider-model`, auth — is also retried the full count before
|
|
65
|
+
failing loud; the cost is bounded and the captured output names the cause.) The exit error now folds in the
|
|
66
|
+
child's captured **stdout** (where `claude -p` writes its operational diagnosis — verified) and stderr, so
|
|
67
|
+
`exited 1` is no longer undiagnosable.
|
|
68
|
+
- **`--decider-llm` now binds a markdown-/quote-wrapped `OTHER:` free-text directive.** A model often
|
|
69
|
+
code-fences a verbatim directive (`` `OTHER: …` ``, `"OTHER: …"`); the leading backtick/quote previously
|
|
70
|
+
defeated the `^\s*OTHER:` anchor and the gate whiffed → fail-loud stall (observed live). The sentinel now
|
|
71
|
+
matches on the `trimNearMiss` form (wrapping quotes/backticks stripped, `:` preserved), so a real
|
|
72
|
+
`OTHER:`-named option label still wins first via the exact-label tier.
|
|
73
|
+
|
|
74
|
+
## [0.16.0] — 2026-06-26
|
|
75
|
+
|
|
76
|
+
### Fixed
|
|
77
|
+
|
|
78
|
+
- **`--decider-llm` no longer whiffs when the answering model echoes the rendered option line (H10).** The
|
|
79
|
+
model is now prompted to reply with the option **NUMBER** (the prompt renders options numbered, with
|
|
80
|
+
descriptions on their own line) and the harness maps the number to the exact canonical label — so the
|
|
81
|
+
reproduced `"Seed / AI/ML: Seed stage…"` (model parroting the `label: description` bullet) and similar
|
|
82
|
+
whiffs can't occur on the common path. A backstop still binds a `label: description` echo (label is a
|
|
83
|
+
boundary-prefix of the reply at a `:` boundary, longest-wins) and the `(Recommended)` suffix; conversational
|
|
84
|
+
asides (`"No, I disagree…"`, `"Seed (probably) but Series A"`) and bare prose stay **loud, never a guess**.
|
|
85
|
+
The LLM decider's unanswered error now also surfaces the `closest:` label.
|
|
86
|
+
- **`--decider-llm` now answers multi-select gates.** The LLM path had no `multiSelect` branch (a
|
|
87
|
+
"select all that apply" gate could pick only one option); it now accepts a comma-list of option numbers
|
|
88
|
+
(`1, 3`) and a mixed digit+label reply fails loud.
|
|
89
|
+
|
|
90
|
+
### Added
|
|
91
|
+
|
|
92
|
+
- **`--decider-model <id>`** (on `skill`, `decide`, and `record`) overrides the `--decider-llm` answering
|
|
93
|
+
model — flag > env `COWORK_HARNESS_DECIDER_MODEL` > the Haiku default. Use a stronger model for genuinely
|
|
94
|
+
ambiguous *judgment* gates; it does not make an under-specified gate deterministic. Requires `--decider-llm`.
|
|
95
|
+
- **Scripted `choose:` (and `--answer`) accepts a stable partial anchor** for skills whose option labels
|
|
96
|
+
drift run-to-run: `choose: "Israeli company"` binds whichever single option starts with it (boundary-anchored,
|
|
97
|
+
uniqueness-guarded). It **fails loud** if the anchor matches two options — drift-tolerance, not strict CI
|
|
98
|
+
reproducibility (for that, pin a full exact label or use a free-text `answer:`).
|
|
99
|
+
|
|
100
|
+
### Docs
|
|
101
|
+
|
|
102
|
+
- **Added a debugging on-ramp (`docs/debugging.md`).** A router for "my skill misbehaved"
|
|
103
|
+
(`inspect` → `trace` → `chat` → `verify-run`) vs. "I don't trust this green" (the false-green hunt:
|
|
104
|
+
Gotchas, `lint`, `verify-cassettes`, `COWORK_HARNESS_DEBUG_SKILLHASH`), wired into every doc index
|
|
105
|
+
(README, `docs/README.md`, `llms.txt`, the companion skill).
|
|
106
|
+
- **Documentation review sweep — doc-vs-code discrepancies corrected, DX/clarity/structure gaps closed
|
|
107
|
+
(26 files).** Adversarially verified against `src/`. Highlights: startup `--folder` is a **staged fresh
|
|
108
|
+
copy** (writes land in the run's `mnt/<folder>`, not the host original), not a live bind mount
|
|
109
|
+
(`fidelity-gaps.md`, `chat.md`); `microvm` is **Lima + Apple-VZ**, not Docker (`python/README.md`);
|
|
110
|
+
`llms.txt` command list corrected (`prune`/`inspect`, not the non-existent `runs gc`); the
|
|
111
|
+
`verify-cassettes` JSON envelope documents its `coverage{}` field (`SPEC.md`); the discovery version-gate
|
|
112
|
+
sits on **local** plugins while `.remote-plugins` is unconditional (`discovery.md`); `extra_allow` is
|
|
113
|
+
**session-level** (`DESIGN.md`); same-repo release-branch PRs **do** run the live scenario suite
|
|
114
|
+
(`RELEASING.md`); the `ci-recipe.md` live-lane gate uses a valid guard-step output (the prior
|
|
115
|
+
`if: ${{ secrets.… }}` is not a valid Actions context); plus assertion-operator (`artifact_json`'s `in`),
|
|
116
|
+
host-path-set, and exit-code corrections. DX: the `claude` CLI named as a prerequisite, the README
|
|
117
|
+
reordered to a zero-infra-first ramp, and the three doc indexes reconciled by audience.
|
|
118
|
+
|
|
9
119
|
## [0.15.0] — 2026-06-25
|
|
10
120
|
|
|
11
121
|
### Fixed
|
package/README.md
CHANGED
|
@@ -13,11 +13,11 @@
|
|
|
13
13
|
|
|
14
14
|
Scriptable, CI-friendly test harness that reproduces **Claude Cowork's observable runtime contract** closely enough to test the skills you write — across many scenarios, headless, in CI — without the (locked) Desktop app. It reproduces not just Cowork's *behavior* but its *limitations*: sealed filesystem, default-deny egress, MCP-only cross-boundary — so a green test means green in real Cowork.
|
|
15
15
|
|
|
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)
|
|
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) · [Debugging](./docs/debugging.md) · [Testing & CI/CD](#testing--cicd) · [Maintenance](#maintenance-parity-between-releases) · [Docs](#documentation)
|
|
17
17
|
|
|
18
18
|
> **Requirements at a glance**
|
|
19
19
|
> - **Free demo (`replay`):** Node ≥ 20 — nothing else (no Docker, token, or Claude Desktop).
|
|
20
|
-
> - **Live tiers**: **Claude Desktop opened once** (stages the agent — nothing is bundled) · a **Claude token** (real per-run cost; runs take minutes) · plus a runtime — **Docker (arm64)** for `container` (default) / `hostloop`, or **Lima (Apple-VZ)** for `microvm`. The `protocol` tier skips the runtime + the staged agent but still calls a real model, so it needs the token. (`doctor --tier <t>` reports exactly what a given tier needs.)
|
|
20
|
+
> - **Live tiers**: **Claude Desktop opened once** (stages the agent — nothing is bundled) · a **Claude token** (real per-run cost; runs take minutes — mint one with `claude setup-token`, which needs the **`claude` CLI**: `npm i -g @anthropic-ai/claude-code`) · plus a runtime — **Docker (arm64)** for `container` (default) / `hostloop`, or **Lima (Apple-VZ)** for `microvm`. The `protocol` tier skips the runtime + the staged agent but still calls a real model, so it needs the token. (`doctor --tier <t>` reports exactly what a given tier needs.)
|
|
21
21
|
> - **Platform:** best on **macOS Apple Silicon**; **Windows is not supported** for the live tiers (use the token-free `replay`); `sync` and `microvm` are **macOS-arm64 only**. Full detail in [Quick start → prerequisites](#quick-start).
|
|
22
22
|
|
|
23
23
|
> **New here?** Start by running a committed cassette `replay` and browsing [`examples/`](./examples/) (see [examples/README.md](./examples/README.md)) to see green runs before any setup — then read [docs/boundary.md](./docs/boundary.md) (the limitations model) and [docs/session.md](./docs/session.md) (the file you'll author).
|
|
@@ -93,7 +93,7 @@ Skill testing is the headline use, but the tool is a general harness over the Co
|
|
|
93
93
|
| `skill <folder> "<prompt>"` | Run a local skill/plugin folder once against the staged agent | ad-hoc "is the skill alive / does it do X?" — the fast inner loop |
|
|
94
94
|
| `run <scenario.yaml \| dir/>` | Run authored scenarios with `assert:` + a CI-ready exit code | you want a repeatable, **asserted regression test** |
|
|
95
95
|
| `chat <folder> [prompt]` | Interactive multi-turn REPL against a skill (TTY); optional seed prompt is sent as the first turn. `--upload <file>` / `--folder <dir>` (repeatable) attach files/project folders; `--verbose` / `-V` shows thinking blocks + tool inputs; `--fidelity protocol\|container\|hostloop` | debugging a multi-turn flow by hand |
|
|
96
|
-
| `record` / `replay` | `record` saves a control-protocol cassette (one scenario, or batch a `dir/`; `--rerecord-stale` refreshes only drifted ones; `--concurrency <N>` records a batch N at a time — runs are fully isolated, so it just bounds Docker/API pressure; `--max-artifact-bytes` raises the 64 KiB inline-body cap; `--dry-run` previews the scenarios + token/binary checks without recording; answer gates **live during the recording** with `--decider-dir`/`--decider-llm`/`--on-unanswered first` instead of pre-scripting them — a live decider flags the cassette non-deterministic but it still replays deterministically); `replay <file.cassette.json \| dir/>` runs a cassette **file or a `dir/` of `*.cassette.json`** deterministically (`--strict` fails on any stale one, `--fail-on-skill-drift` only on skill-source drift; staleness is also reported per-result in `staleness[]` for a JSON gate; exits on the worst verdict) | **token-free, Docker-free CI** from a once-recorded run |
|
|
96
|
+
| `record` / `replay` | **Record a live run once → replay it token-free, Docker-free thereafter.** `record` saves a control-protocol cassette (one scenario, or batch a `dir/`; `--rerecord-stale` refreshes only drifted ones; `--concurrency <N>` records a batch N at a time — runs are fully isolated, so it just bounds Docker/API pressure; `--max-artifact-bytes` raises the 64 KiB inline-body cap; `--dry-run` previews the scenarios + token/binary checks without recording; answer gates **live during the recording** with `--decider-dir`/`--decider-llm`/`--on-unanswered first` instead of pre-scripting them — a live decider flags the cassette non-deterministic but it still replays deterministically); `replay <file.cassette.json \| dir/>` runs a cassette **file or a `dir/` of `*.cassette.json`** deterministically (`--strict` fails on any stale one, `--fail-on-skill-drift` only on skill-source drift; staleness is also reported per-result in `staleness[]` for a JSON gate; exits on the worst verdict) | **token-free, Docker-free CI** from a once-recorded run |
|
|
97
97
|
| `verify-cassettes <file\|dir>` | Token-free CI gate over committed cassettes: a privacy scan (email/currency/domain → exit 1; whole-token allows via `--allow` / class-scoped `--allow-domain` / `--allow-email` / `--allow-file`) + a staleness check (`--skip-privacy` or `--skip-staleness` to run only one half). A dir argument scans `*.cassette.json` in that dir only (**non-recursive**) | gating **committed cassettes** against PII leaks + "edited the skill, forgot to re-record" |
|
|
98
98
|
| `verify-run <run-dir> <scenario.yaml>` | Re-evaluate a scenario's `assert:` (and, when the scenario declares `answers:`, whether they still match the run's actual gates) against an already-kept run dir — **no live agent, no tokens, no Docker** (~1s) | iterating on a wrong assertion or a drifted `answer` without a full live re-record |
|
|
99
99
|
| `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?" |
|
|
@@ -115,66 +115,6 @@ There's also a **Python `cowork` pytest lane** (`python/`) for driving any of th
|
|
|
115
115
|
|
|
116
116
|
---
|
|
117
117
|
|
|
118
|
-
## Test a local skill in one command
|
|
119
|
-
|
|
120
|
-
The fastest path — point at a **local folder**, no repo, no `claude plugin install`, no marketplace, no version bump, no cache layers. The folder is copied **fresh into the session on every run**, so you edit and re-run and your changes are live immediately:
|
|
121
|
-
|
|
122
|
-
```bash
|
|
123
|
-
# Auth once — export it, OR put it in a .env file (resolved: env > --dotenv > ./.env > <install>/.env):
|
|
124
|
-
# cp .env.example .env && echo "CLAUDE_CODE_OAUTH_TOKEN=$(claude setup-token)" >> .env
|
|
125
|
-
export CLAUDE_CODE_OAUTH_TOKEN=$(claude setup-token)
|
|
126
|
-
|
|
127
|
-
cowork-harness skill ~/my-plugin 'Use my-skill to do X' # single-quote: no $ expansion
|
|
128
|
-
cowork-harness skill ~/my-plugin --prompt-file ./prompt.txt # prompt verbatim (raw bytes)
|
|
129
|
-
cowork-harness skill ~/my-plugin "..." --answer "which format=Markdown" # script AskUserQuestion
|
|
130
|
-
cowork-harness skill ~/my-plugin "..." --fidelity protocol # fast, no sandbox
|
|
131
|
-
cowork-harness skill ~/my-plugin "..." --dry-run # resolve & print the launch plan, don't run
|
|
132
|
-
cowork-harness skill ~/my-plugin "..." --keep # print the run dir to inspect
|
|
133
|
-
cowork-harness skill ~/my-plugin "..." --output-format json # machine-readable result on stdout
|
|
134
|
-
cowork-harness skill ~/my-plugin "..." --on-unanswered fail # never fabricate an answer (CI/agents)
|
|
135
|
-
cowork-harness skill ~/my-plugin "..." --decider-cmd 'node answer.js' # answer LIVE stochastic questions via a helper
|
|
136
|
-
cowork-harness skill ~/my-plugin 'review this deck' --upload deck.pdf # attach a file → mnt/uploads (deck-review etc.)
|
|
137
|
-
cowork-harness skill ~/my-plugin "..." --session-id s1 # pin a session…
|
|
138
|
-
cowork-harness skill ~/my-plugin '<next turn>' --session-id s1 --resume # …then resume it (gated/checkpoint skills)
|
|
139
|
-
cowork-harness skill ~/my-plugin "..." --keep # then: trace the run
|
|
140
|
-
cowork-harness trace <run-id> --view tools # tool calls + sub-agent dispatches from events.jsonl
|
|
141
|
-
cowork-harness skill --help # full per-command flag reference
|
|
142
|
-
|
|
143
|
-
cowork-harness chat ~/my-plugin # interactive multi-turn REPL (full harness: egress sandbox + control protocol)
|
|
144
|
-
# chat --raw → native interactive cowork mode via `docker run -it` (needs Docker + the arm64
|
|
145
|
-
# cowork-agent-base:2 image; the egress sandbox is NOT applied in --raw)
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
**Input policy — no silent false-greens.** When an AskUserQuestion arrives with no scripted
|
|
149
|
-
`--answer`, the policy is explicit: `fail` (error + the exact `--answer` to add — the default for
|
|
150
|
-
`run`/CI), `prompt` (ask at the TTY — the default for `skill` when interactive), or `first` (pick
|
|
151
|
-
option 1, loudly warn). Pick with `--on-unanswered`; left unset, `skill` is **adaptive** (`prompt` on
|
|
152
|
-
a TTY, `fail` when piped/CI) and `run` is always `fail`. Exit codes: `0`
|
|
153
|
-
pass · `1` assertion/agent failure · `2` usage / unanswered-under-`fail` / runtime · `3` boundary/integrity.
|
|
154
|
-
After a run, the footer **echoes every auto-answered
|
|
155
|
-
question as a copy-pasteable `--answer "<q>=<choice>"` line** — run once exploratorily, then paste them
|
|
156
|
-
back to lock in a deterministic re-run.
|
|
157
|
-
|
|
158
|
-
**Output.** `skill` **renders the agent's work** (assistant text + tool calls) and a metered footer —
|
|
159
|
-
you see *what it did*, not just a green. `run` is verdict-first but **prints the failing transcript
|
|
160
|
-
inline** on a `FAIL` (no spelunking `runs/…`). Tune with `--quiet` (verdict only) / `--verbose`/`-V`
|
|
161
|
-
(+ thinking, tool inputs, sub-agent tree). `--output-format json` emits a stable machine envelope on stdout
|
|
162
|
-
(`{tool, version, command, ok, results[], error}`; errors are `{ok:false, error:{category,message,hint}}`)
|
|
163
|
-
— see [SPEC §11](./SPEC.md). Human output is stderr, machine output is stdout, so `--output-format json` pipes
|
|
164
|
-
cleanly. Honors `NO_COLOR`.
|
|
165
|
-
|
|
166
|
-
**Test a specific local plugin version** — just point at the folder at that version (it's copied fresh; no install, no version bump). Add more with `--plugin`:
|
|
167
|
-
```bash
|
|
168
|
-
cowork-harness skill ~/my-plugin "..." --plugin ~/other-plugin
|
|
169
|
-
```
|
|
170
|
-
|
|
171
|
-
**Test a specific local marketplace version** — point at the marketplace dir (the one with `.claude-plugin/marketplace.json`); it's registered fresh each run via `claude plugin marketplace add`, no clone/cache:
|
|
172
|
-
```bash
|
|
173
|
-
cowork-harness skill --marketplace ~/my-marketplace --enable my-skill@my-marketplace "Use my-skill"
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
It mounts the folder(s) at the Cowork plugin path, runs the staged agent in cowork mode, and prints PASS/RESULT (add `--keep` to print the run dir, or `--output-format json` for the machine-readable result). No YAML to author. (Author `scenarios/*.yaml` only for repeatable, asserted regression tests.)
|
|
177
|
-
|
|
178
118
|
## Quick start
|
|
179
119
|
|
|
180
120
|
**Install from npm:**
|
|
@@ -187,7 +127,7 @@ npm install -g cowork-harness # puts the `cowork-harness` command on your PAT
|
|
|
187
127
|
|
|
188
128
|
```bash
|
|
189
129
|
git clone https://github.com/yaniv-golan/cowork-harness && cd cowork-harness
|
|
190
|
-
npm
|
|
130
|
+
npm ci && npm run build && npm link # puts the `cowork-harness` command on your PATH
|
|
191
131
|
# …or skip the link and call it directly: node dist/cli.js <cmd>
|
|
192
132
|
```
|
|
193
133
|
|
|
@@ -201,14 +141,14 @@ Only the committed-cassette `replay` above is fully self-contained. Live `run`/`
|
|
|
201
141
|
|
|
202
142
|
### Drive it from Claude Code (companion skill)
|
|
203
143
|
|
|
204
|
-
This repo ships a **companion skill** (`.claude/skills/cowork-harness/`) that teaches an agent how to drive the harness — author scenarios, pick a fidelity tier, script answers, place assertions in the right CI lane, and avoid the "✓ passed ≠ correct" traps. Install it
|
|
144
|
+
This repo ships a **companion skill** (`.claude/skills/cowork-harness/`) that teaches an agent how to drive the harness — author scenarios, pick a fidelity tier, script answers, place assertions in the right CI lane, and avoid the "✓ passed ≠ correct" traps. Install it via the bundled marketplace — run these **in Claude Code** (slash commands, not your shell):
|
|
205
145
|
|
|
206
146
|
```bash
|
|
207
147
|
/plugin marketplace add yaniv-golan/cowork-harness
|
|
208
148
|
/plugin install cowork-harness@cowork-harness
|
|
209
149
|
```
|
|
210
150
|
|
|
211
|
-
The skill **self-bootstraps the CLI**: if `cowork-harness` isn't on your PATH it falls back to `npx cowork-harness@>=0.
|
|
151
|
+
The skill **self-bootstraps the CLI**: if `cowork-harness` isn't on your PATH it falls back to `npx cowork-harness@>=0.17.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.
|
|
212
152
|
|
|
213
153
|
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.)
|
|
214
154
|
|
|
@@ -219,7 +159,7 @@ It also follows the open [Agent Skills](https://github.com/vercel-labs/skills) s
|
|
|
219
159
|
(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):
|
|
220
160
|
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…".
|
|
221
161
|
2. **Docker (arm64)** + the agent image: `docker build --platform linux/arm64 -t cowork-agent-base:2 -f docker/Dockerfile.agent .` (override the tag with `COWORK_AGENT_IMAGE`). This **core** image mirrors the real Cowork rootfs's document/data toolchain (Node 22, openpyxl/pandas/pdf/docx/pptx, …). For OCR / LibreOffice / markitdown / opencv / PDF-table skills, build the **full-parity** image instead: `docker build --platform linux/arm64 --build-arg COWORK_FULL_PARITY=1 -t cowork-agent-full:2 -f docker/Dockerfile.agent .` and point `COWORK_AGENT_IMAGE` at it. (A run on the core image that uses an omitted capability is flagged — see capability detection.) The `-f docker/Dockerfile.agent .` paths are **repo-relative** — run it from a source checkout, not a global `npm install -g`. (Global install? Run `cowork-harness doctor --tier container` — it prints the exact `docker build` command with the correct package-local Dockerfile path.) The **`microvm` (L2)** tier uses **Lima / Apple-VZ** instead of Docker (`brew install lima`; no agent image to build) — `doctor --tier microvm` checks for that, not Docker.
|
|
222
|
-
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.)
|
|
162
|
+
3. **An auth token** — either `export CLAUDE_CODE_OAUTH_TOKEN=$(claude setup-token)` (the `claude setup-token` step needs the **`claude` CLI**: `npm i -g @anthropic-ai/claude-code`) 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.)
|
|
223
163
|
|
|
224
164
|
> `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.)
|
|
225
165
|
|
|
@@ -282,6 +222,70 @@ cowork-harness lint examples/scenarios/*.yaml
|
|
|
282
222
|
`@pytest.mark.cowork` + a `cowork` fixture over the `--output-format json` surface, selectable with
|
|
283
223
|
`-m cowork` (opt-in, beside your fast tests).
|
|
284
224
|
|
|
225
|
+
## Test a local skill in one command
|
|
226
|
+
|
|
227
|
+
> **First time here?** The commands below assume the `cowork-harness` CLI is installed and a token is set —
|
|
228
|
+
> see [Quick start](#quick-start) for both, and run `cowork-harness doctor` to check what's missing. For the
|
|
229
|
+
> zero-infra path (no Docker, no token), start with the token-free [`replay` demo](#quick-start).
|
|
230
|
+
|
|
231
|
+
The fastest path — point at a **local folder**, no repo, no `claude plugin install`, no marketplace, no version bump, no cache layers. The folder is copied **fresh into the session on every run**, so you edit and re-run and your changes are live immediately:
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
# Auth once — export it, OR put it in a .env file (resolved: env > --dotenv > ./.env > <install>/.env):
|
|
235
|
+
# cp .env.example .env && echo "CLAUDE_CODE_OAUTH_TOKEN=$(claude setup-token)" >> .env
|
|
236
|
+
export CLAUDE_CODE_OAUTH_TOKEN=$(claude setup-token)
|
|
237
|
+
|
|
238
|
+
cowork-harness skill ~/my-plugin 'Use my-skill to do X' # single-quote: no $ expansion
|
|
239
|
+
cowork-harness skill ~/my-plugin --prompt-file ./prompt.txt # prompt verbatim (raw bytes)
|
|
240
|
+
cowork-harness skill ~/my-plugin "..." --answer "which format=Markdown" # script AskUserQuestion
|
|
241
|
+
cowork-harness skill ~/my-plugin "..." --fidelity protocol # fast, no sandbox
|
|
242
|
+
cowork-harness skill ~/my-plugin "..." --dry-run # resolve & print the launch plan, don't run
|
|
243
|
+
cowork-harness skill ~/my-plugin "..." --keep # print the run dir to inspect
|
|
244
|
+
cowork-harness skill ~/my-plugin "..." --output-format json # machine-readable result on stdout
|
|
245
|
+
cowork-harness skill ~/my-plugin "..." --on-unanswered fail # never fabricate an answer (CI/agents)
|
|
246
|
+
cowork-harness skill ~/my-plugin "..." --decider-cmd 'node answer.js' # answer LIVE stochastic questions via a helper
|
|
247
|
+
cowork-harness skill ~/my-plugin 'review this deck' --upload deck.pdf # attach a file → mnt/uploads (deck-review etc.)
|
|
248
|
+
cowork-harness skill ~/my-plugin "..." --session-id s1 # pin a session…
|
|
249
|
+
cowork-harness skill ~/my-plugin '<next turn>' --session-id s1 --resume # …then resume it (gated/checkpoint skills)
|
|
250
|
+
cowork-harness skill ~/my-plugin "..." --keep # then: trace the run
|
|
251
|
+
cowork-harness trace <run-id> --view tools # tool calls + sub-agent dispatches from events.jsonl
|
|
252
|
+
cowork-harness skill --help # full per-command flag reference
|
|
253
|
+
|
|
254
|
+
cowork-harness chat ~/my-plugin # interactive multi-turn REPL (full harness: egress sandbox + control protocol)
|
|
255
|
+
# chat --raw → native interactive cowork mode via `docker run -it` (needs Docker + the arm64
|
|
256
|
+
# cowork-agent-base:2 image; the egress sandbox is NOT applied in --raw)
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
**Input policy — no silent false-greens.** When an AskUserQuestion arrives with no scripted
|
|
260
|
+
`--answer`, the policy is explicit: `fail` (error + the exact `--answer` to add — the default for
|
|
261
|
+
`run`/CI), `prompt` (ask at the TTY — the default for `skill` when interactive), or `first` (pick
|
|
262
|
+
option 1, loudly warn). Pick with `--on-unanswered`; left unset, `skill` is **adaptive** (`prompt` on
|
|
263
|
+
a TTY, `fail` when piped/CI) and `run` is always `fail`. Exit codes: `0`
|
|
264
|
+
pass · `1` assertion/agent failure · `2` usage / unanswered-under-`fail` / runtime · `3` boundary/integrity.
|
|
265
|
+
After a run, the footer **echoes every auto-answered
|
|
266
|
+
question as a copy-pasteable `--answer "<q>=<choice>"` line** — run once exploratorily, then paste them
|
|
267
|
+
back to lock in a deterministic re-run.
|
|
268
|
+
|
|
269
|
+
**Output.** `skill` **renders the agent's work** (assistant text + tool calls) and a metered footer —
|
|
270
|
+
you see *what it did*, not just a green. `run` is verdict-first but **prints the failing transcript
|
|
271
|
+
inline** on a `FAIL` (no spelunking `runs/…`). Tune with `--quiet` (verdict only) / `--verbose`/`-V`
|
|
272
|
+
(+ thinking, tool inputs, sub-agent tree). `--output-format json` emits a stable machine envelope on stdout
|
|
273
|
+
(`{tool, version, command, ok, results[], error}`; errors are `{ok:false, error:{category,message,hint}}`)
|
|
274
|
+
— see [SPEC §11](./SPEC.md). Human output is stderr, machine output is stdout, so `--output-format json` pipes
|
|
275
|
+
cleanly. Honors `NO_COLOR`.
|
|
276
|
+
|
|
277
|
+
**Test a specific local plugin version** — just point at the folder at that version (it's copied fresh; no install, no version bump). Add more with `--plugin`:
|
|
278
|
+
```bash
|
|
279
|
+
cowork-harness skill ~/my-plugin "..." --plugin ~/other-plugin
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
**Test a specific local marketplace version** — point at the marketplace dir (the one with `.claude-plugin/marketplace.json`); it's registered fresh each run via `claude plugin marketplace add`, no clone/cache:
|
|
283
|
+
```bash
|
|
284
|
+
cowork-harness skill --marketplace ~/my-marketplace --enable my-skill@my-marketplace "Use my-skill"
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
It mounts the folder(s) at the Cowork plugin path, runs the staged agent in cowork mode, and prints PASS/RESULT (add `--keep` to print the run dir, or `--output-format json` for the machine-readable result). No YAML to author. (Author `scenarios/*.yaml` only for repeatable, asserted regression tests.)
|
|
288
|
+
|
|
285
289
|
---
|
|
286
290
|
|
|
287
291
|
## Two files: session + scenario
|
|
@@ -392,6 +396,8 @@ agent.stderr.log the agent process's stderr (auth errors, flag rejects)
|
|
|
392
396
|
|
|
393
397
|
Secrets (the injected OAuth token / API key) are scrubbed from every persisted log by value.
|
|
394
398
|
|
|
399
|
+
**Debugging a run** — when a run misbehaves or a green looks too good to trust, [docs/debugging.md](./docs/debugging.md) is the map: `inspect` → `trace` → `chat` → `verify-run` for a misbehaving skill, and the false-green hunt for a green you don't trust.
|
|
400
|
+
|
|
395
401
|
---
|
|
396
402
|
|
|
397
403
|
## Architecture
|
|
@@ -469,11 +475,13 @@ Unit tests cover the scripted-answer logic, the egress allowlist matcher, the se
|
|
|
469
475
|
|
|
470
476
|
### Reproducibility knobs
|
|
471
477
|
|
|
478
|
+
Most runs need **none** of these — the defaults are correct. They're grouped by theme below (container/runtime, networking/loop, strictness, staleness, secret-scrubbing, L2 microVM, `skill`/`chat` defaults); reach for one only when a default doesn't fit. Every var name is `COWORK_*` / `CLAUDE_*`, so a search across this section finds it fast.
|
|
479
|
+
|
|
472
480
|
- `COWORK_LOCKDOWN=off` — relax container hardening for debugging (default `on`). With it `on`, an L2 microVM whose guest egress firewall fails to apply **aborts loudly** rather than running un-isolated.
|
|
473
481
|
- `COWORK_CONTAINER_RUNTIME=podman` — use Podman instead of Docker.
|
|
474
482
|
- `COWORK_AGENT_IMAGE=<tag>` — override the agent image name (default `cowork-agent-base:2`); `COWORK_AGENT_BINARY=<path>` — override the auto-detected staged agent ELF.
|
|
475
483
|
- `COWORK_SKIP_CAPABILITY_PROBE=1` — skip the per-run capability probe (the harness otherwise probes the agent image/VM for the document/OCR/Office capabilities the real Cowork rootfs ships and **fails a run that uses one the image omits** — a likely false negative; suppress per-scenario with `allow_missing_capability: true`, or rebuild full parity).
|
|
476
|
-
- `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.
|
|
484
|
+
- `COWORK_HARNESS_DECIDER_MODEL` — the default `--decider-llm` answering model (overridden by the `--decider-model` flag; falls back to the Haiku default). `COWORK_HARNESS_DECIDER_DIR_POLL_MS` / `_TIMEOUT_MS` — tune the `--decider-dir` rendezvous poll/backstop; `COWORK_HARNESS_DECIDER_CMD_TIMEOUT_MS` / `COWORK_HARNESS_LLM_TIMEOUT_MS` — backstop a hung `--decider-cmd` helper / `--decider-llm` model call (default 600 s, fail loud); `COWORK_HARNESS_LLM_RETRIES` — bounded retries for a transient non-zero `claude -p` exit in the `--decider-llm` transport (default 2, clamped to 0–10; set `0` to disable, e.g. deterministic CI); `COWORK_HARNESS_DIALOG_TIMEOUT_MS` — override the 6 s dialog auto-cancel.
|
|
477
485
|
- `COWORK_HARNESS_RUNS_DIR` (or the `--run-dir <path>` flag) — override the default run-output root `~/.cowork-harness/runs` (kept out of any working tree so sensitive skill inputs/outputs don't land in a repo). Precedence: `--run-dir` > env > default. The root is flat and machine-global (shared across projects); pinned `--session-id` runs are guarded against cross-project overwrite, and `prune` never prunes them. In CI, set it to a workspace path (e.g. `runs`) so artifact upload can collect the runs. `COWORK_HARNESS_ALLOW_FOREIGN_RESUME=1` overrides the guard that blocks `--resume` onto another project's pinned session.
|
|
478
486
|
- **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`).
|
|
479
487
|
- **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).
|
|
@@ -541,6 +549,7 @@ This repo is built to be driven by agents, not just read by humans:
|
|
|
541
549
|
| [docs/session.md](./docs/session.md) | Every `sessions/*.yaml` field and its Cowork mapping. |
|
|
542
550
|
| [docs/scenario.md](./docs/scenario.md) | `scenarios/*.yaml` — prompt, scripted answers, assertions. |
|
|
543
551
|
| [docs/chat.md](./docs/chat.md) | The interactive `chat` REPL — multi-turn debugging, flags, attaching files/folders. |
|
|
552
|
+
| [docs/debugging.md](./docs/debugging.md) | Debugging a run — `inspect`/`trace`/`chat`/`verify-run` for a misbehaving skill; the false-green hunt for a green you don't trust. |
|
|
544
553
|
| [docs/cassette.md](./docs/cassette.md) | `record`/`replay` cassettes — what replay checks, which assertions are skipped. |
|
|
545
554
|
| [docs/fidelity-gaps.md](./docs/fidelity-gaps.md) | The known deltas vs. real Cowork — what the harness does and doesn't reproduce. |
|
|
546
555
|
| [docs/decider-dir.md](./docs/decider-dir.md) | The `--decider-dir` recipe — a driving agent answers live gates in-band via `gates`/`answer` + a Monitor. |
|
|
@@ -552,6 +561,7 @@ This repo is built to be driven by agents, not just read by humans:
|
|
|
552
561
|
| [python/README.md](./python/README.md) | The `cowork` pytest lane for driving the harness from Python. |
|
|
553
562
|
| [examples/README.md](./examples/README.md) | The worked examples to copy — sessions, scenarios, and skills you can run end-to-end. |
|
|
554
563
|
| [SECURITY.md](./SECURITY.md) | Threat model — the sandbox is a fidelity fixture, not a security boundary. |
|
|
564
|
+
| [RELEASING.md](./RELEASING.md) | The release flow — branch → PR → tag → npm publish. |
|
|
555
565
|
|
|
556
566
|
## Status
|
|
557
567
|
|
|
@@ -559,7 +569,7 @@ This repo is built to be driven by agents, not just read by humans:
|
|
|
559
569
|
|
|
560
570
|
- ✅ **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**.
|
|
561
571
|
- ✅ **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.
|
|
562
|
-
- ✅ **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). **multiSelect gates** are answerable on every channel — repeat `--choose` (or
|
|
572
|
+
- ✅ **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; it replies with the option **number**, mapped to the exact label, so it can't whiff by parroting the label text, and an out-of-set reply fails loud; raise the answering model for ambiguous judgment gates with **`--decider-model <id>`**; 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 — accepts a **stable partial anchor** for skills whose labels drift run-to-run), `--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). **multiSelect gates** are answerable on every channel — repeat `--choose` (or, for `--decider-llm`, a comma-list of option numbers; or a JSON-array reply from a `--decider-cmd` helper / hand-written `resp-N.json`); all deliver the binary-verified `", "`-joined wire shape. 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`.
|
|
563
573
|
- ✅ **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.
|
|
564
574
|
- ✅ **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`).
|
|
565
575
|
- ✅ **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.
|
package/dist/agent/session.js
CHANGED
|
@@ -153,8 +153,16 @@ export function deserializeDecision(req, body) {
|
|
|
153
153
|
if (req.kind === "question") {
|
|
154
154
|
// AskUserQuestion: body is { behavior:"allow", updatedInput:{ questions, answers } }
|
|
155
155
|
// We read `answers` back; `questions` was preserved in recording for the O7 guard.
|
|
156
|
+
// Validate `answers` instead of a blind cast — a non-object, array, or answers map with
|
|
157
|
+
// non-string values coerces to {} which will NOT re-serialize to the recorded body, so the
|
|
158
|
+
// O7 replay_protocol_fidelity guard trips loud rather than silently coercing corrupt data.
|
|
156
159
|
const ui = (body.updatedInput ?? {});
|
|
157
|
-
|
|
160
|
+
const raw = ui.answers;
|
|
161
|
+
const isValidAnswers = raw !== null &&
|
|
162
|
+
typeof raw === "object" &&
|
|
163
|
+
!Array.isArray(raw) &&
|
|
164
|
+
Object.values(raw).every((v) => typeof v === "string");
|
|
165
|
+
return { kind: "question", answers: isValidAnswers ? raw : {} };
|
|
158
166
|
}
|
|
159
167
|
if (req.kind === "dialog") {
|
|
160
168
|
return {
|
|
@@ -288,7 +296,13 @@ export class LiveAgentSession {
|
|
|
288
296
|
yield { type: "raw", line };
|
|
289
297
|
continue;
|
|
290
298
|
}
|
|
291
|
-
|
|
299
|
+
try {
|
|
300
|
+
yield* this.translate(msg);
|
|
301
|
+
}
|
|
302
|
+
catch (e) {
|
|
303
|
+
yield { type: "error", source: "protocol", message: e?.message ?? String(e) };
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
292
306
|
}
|
|
293
307
|
// stdout closed. Give a pending 'exit' one tick to land (NOT a blocking wait on 'close' — a
|
|
294
308
|
// mock/fake child may never emit it), then surface a nonzero/signal exit as a typed error — a
|
package/dist/assert.js
CHANGED
|
@@ -163,12 +163,25 @@ function check(a, ctx) {
|
|
|
163
163
|
results.push(ctx.toolResultTexts.some((t) => t.includes(a.tool_result_contains))
|
|
164
164
|
? ok()
|
|
165
165
|
: fail(`no tool result contained "${a.tool_result_contains}"`));
|
|
166
|
-
if (a.tool_result_not_contains !== undefined)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
166
|
+
if (a.tool_result_not_contains !== undefined) {
|
|
167
|
+
if (ctx.toolResultsMissing) {
|
|
168
|
+
results.push(fail(`evidence unavailable: tool results absent from result.json — cannot evaluate tool_result_not_contains`));
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
const forbidden = a.tool_result_not_contains;
|
|
172
|
+
const positiveHit = ctx.toolResultTexts.some((t) => t.includes(forbidden));
|
|
173
|
+
if (positiveHit) {
|
|
174
|
+
results.push(fail(`a tool result unexpectedly contained "${forbidden}"`));
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
const hasTruncatedAbsence = ctx.toolResultsTruncated !== undefined &&
|
|
178
|
+
ctx.toolResultTexts.some((t, i) => !t.includes(forbidden) && ctx.toolResultsTruncated[i] === true);
|
|
179
|
+
results.push(hasTruncatedAbsence
|
|
180
|
+
? fail(`evidence unavailable: one or more tool results are display-truncated (no assertText) — cannot rule out forbidden substring`)
|
|
181
|
+
: ok());
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
172
185
|
// Fuzzy content for stochastic prose. All regex-building assertions are try/catch-wrapped —
|
|
173
186
|
// `evaluate()` is a bare `.map(check)` with no error boundary, so a malformed pattern must be a
|
|
174
187
|
// clean assertion failure, not an uncaught throw. Case-insensitive ("i").
|
|
@@ -428,8 +441,13 @@ function check(a, ctx) {
|
|
|
428
441
|
}
|
|
429
442
|
if (aj.is_null !== undefined) {
|
|
430
443
|
any = true;
|
|
431
|
-
|
|
432
|
-
|
|
444
|
+
if (!present) {
|
|
445
|
+
results.push(fail(`artifact_json: "${aj.path ?? "(root)"}" is_null: path is absent — cannot determine null-ness (use absent: true to assert absence)`));
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
const isNull = val === null;
|
|
449
|
+
results.push(isNull === aj.is_null ? ok() : fail(`artifact_json: "${aj.path ?? "(root)"}" is_null=${isNull}, expected ${aj.is_null}`));
|
|
450
|
+
}
|
|
433
451
|
}
|
|
434
452
|
if (aj.equals !== undefined) {
|
|
435
453
|
any = true;
|
package/dist/baseline.js
CHANGED
|
@@ -151,7 +151,11 @@ export function resolveMounts(baseline, sessionId, projectId = "proj1") {
|
|
|
151
151
|
const subst = (s) => s.replace("{sessionId}", sessionId).replace("{projectId}", projectId);
|
|
152
152
|
const cwd = subst(baseline.mountLayout.cwd);
|
|
153
153
|
const sessionRoot = subst(baseline.mountLayout.sessionRoot);
|
|
154
|
-
|
|
154
|
+
// TODO: container.ts:31 computes configGuest independently from sessionRoot and is NOT fixed here;
|
|
155
|
+
// the legacy desktop-1.11847.5 baseline (sessionRoot ending in /mnt, no spawn block) still produces
|
|
156
|
+
// a double-mnt configGuest path (/sessions/<id>/mnt/mnt/.claude) — that is a separate out-of-scope issue.
|
|
157
|
+
const rawSessionRoot = baseline.mountLayout.sessionRoot;
|
|
158
|
+
const mntRoot = subst(baseline.mountLayout.mntRoot ?? (rawSessionRoot.endsWith("/mnt") ? rawSessionRoot : `${rawSessionRoot}/mnt`));
|
|
155
159
|
return {
|
|
156
160
|
cwd,
|
|
157
161
|
sessionRoot,
|
package/dist/boundary.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { mkdtempSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir, userInfo, homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { startEgressSidecar } from "./egress/sidecar.js";
|
|
@@ -20,7 +20,8 @@ export function runBoundaryChecks(baseline, session) {
|
|
|
20
20
|
// Stand up the real per-run boundary (internal network + allowlist proxy), exactly
|
|
21
21
|
// what a container-fidelity scenario uses. Tear it down at the end.
|
|
22
22
|
const runId = `bchk${process.hrtime.bigint().toString(36)}`;
|
|
23
|
-
const
|
|
23
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "cowork-bchk-"));
|
|
24
|
+
const sidecar = startEgressSidecar(boundaryAllowList(baseline, session), tmpDir, runId);
|
|
24
25
|
const network = sidecar.network;
|
|
25
26
|
const proxy = sidecar.proxyUrl;
|
|
26
27
|
// Probes can throw (spawnSync setup errors, etc.); tear the sidecar down in `finally` so an unexpected
|
|
@@ -41,15 +42,20 @@ export function runBoundaryChecks(baseline, session) {
|
|
|
41
42
|
shell,
|
|
42
43
|
], { encoding: "utf8", timeout: 30_000 });
|
|
43
44
|
// 1. Host filesystem is NOT visible (no /Users, no host home bind).
|
|
45
|
+
// TWO independent probes so a leak on EITHER path fails the check — a combined-string
|
|
46
|
+
// probe can false-pass when one path leaks (real listing) but the other path's denial
|
|
47
|
+
// substring satisfies isHostFsSealed's `denied` regex on the joined output.
|
|
44
48
|
{
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
49
|
+
const combine = (r) => (r.stdout ?? "") + (r.stderr ?? "");
|
|
50
|
+
const outUsers = combine(probe("ls /Users 2>&1 || true"));
|
|
51
|
+
const outHost = combine(probe("ls /host 2>&1 || true"));
|
|
52
|
+
const blocked = isHostFsSealed(outUsers) && isHostFsSealed(outHost);
|
|
53
|
+
const detail = (outUsers + "\n" + outHost).trim().slice(0, 200);
|
|
48
54
|
results.push({
|
|
49
55
|
check: "host-fs-sealed",
|
|
50
56
|
expectation: "host paths (/Users, /host) invisible",
|
|
51
57
|
pass: blocked,
|
|
52
|
-
detail
|
|
58
|
+
detail,
|
|
53
59
|
});
|
|
54
60
|
}
|
|
55
61
|
// 2. Direct (non-proxied) egress is impossible — no route off the internal net.
|
|
@@ -89,6 +95,12 @@ export function runBoundaryChecks(baseline, session) {
|
|
|
89
95
|
}
|
|
90
96
|
finally {
|
|
91
97
|
sidecar.teardown();
|
|
98
|
+
try {
|
|
99
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* best-effort: bind-mounted log may be root-owned on Linux */
|
|
103
|
+
}
|
|
92
104
|
}
|
|
93
105
|
}
|
|
94
106
|
/** Escape regex metacharacters in a literal so it can be embedded in a RegExp. */
|