cowork-harness 0.4.2 → 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/assertion-keys.json +30 -0
- package/.claude/skills/cowork-harness/scripts/scenario.py +90 -21
- package/CHANGELOG.md +70 -1
- 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 +9 -1
- 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 +4 -1
- package/python/README.md +2 -1
- package/python/test_scenario_lint.py +75 -0
- package/schema/scenario.schema.json +7 -0
- package/schema/session.schema.json +16 -0
- package/scripts/gen-schema.ts +23 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "GENERATED from the Zod Assertion schema (src/types.ts) by scripts/gen-schema.ts — do not edit; run `npm run schema`.",
|
|
3
|
+
"keys": [
|
|
4
|
+
"allow_permissive_auto_allow",
|
|
5
|
+
"artifact_json",
|
|
6
|
+
"dispatch_count_max",
|
|
7
|
+
"egress_allowed",
|
|
8
|
+
"egress_denied",
|
|
9
|
+
"file_exists",
|
|
10
|
+
"gate_answers_delivered",
|
|
11
|
+
"no_delete_in_outputs",
|
|
12
|
+
"question_asked",
|
|
13
|
+
"questions_count_max",
|
|
14
|
+
"replay_protocol_fidelity",
|
|
15
|
+
"result",
|
|
16
|
+
"self_heal_ran",
|
|
17
|
+
"subagent_declared_but_unused",
|
|
18
|
+
"subagent_dispatched",
|
|
19
|
+
"subagent_tool_absent",
|
|
20
|
+
"subagent_tool_used",
|
|
21
|
+
"tool_called",
|
|
22
|
+
"tool_not_called",
|
|
23
|
+
"transcript_contains",
|
|
24
|
+
"transcript_matches",
|
|
25
|
+
"transcript_no_host_path",
|
|
26
|
+
"transcript_not_contains",
|
|
27
|
+
"transcript_not_matches",
|
|
28
|
+
"user_visible_artifact"
|
|
29
|
+
]
|
|
30
|
+
}
|
|
@@ -55,19 +55,55 @@ CONTENT_KEYS = {
|
|
|
55
55
|
}
|
|
56
56
|
# content keys, but only evaluated on replay when the cassette carries controlOut
|
|
57
57
|
GATE_KEYS = {"question_asked", "questions_count_max", "gate_answers_delivered"}
|
|
58
|
-
#
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
# manifest-backed: replay-checkable when the cassette carries an `artifacts` manifest (record snapshots one);
|
|
59
|
+
# a manifest-less cassette skips them. Since the 0.3.0 artifact-manifest these are NOT always live-only.
|
|
60
|
+
MANIFEST_KEYS = {"file_exists", "user_visible_artifact", "artifact_json"}
|
|
61
|
+
# live-only: ALWAYS silently skipped on replay (no filesystem, no network on the token-free lane)
|
|
62
|
+
LIVE_ONLY_KEYS = {
|
|
63
|
+
"egress_denied",
|
|
64
|
+
"egress_allowed",
|
|
62
65
|
"no_delete_in_outputs",
|
|
63
66
|
"self_heal_ran",
|
|
64
67
|
"transcript_no_host_path",
|
|
65
|
-
"egress_denied",
|
|
66
|
-
"egress_allowed",
|
|
67
68
|
}
|
|
68
69
|
EGRESS_KEYS = {"egress_denied", "egress_allowed"}
|
|
69
|
-
#
|
|
70
|
-
|
|
70
|
+
# verdict modifiers — don't verify anything themselves (e.g. suppress a default-fail)
|
|
71
|
+
VERDICT_MODIFIER_KEYS = {"allow_permissive_auto_allow"}
|
|
72
|
+
|
|
73
|
+
# Every key the replay-class logic knows how to handle. `replay_protocol_fidelity` is valid-but-not-authorable
|
|
74
|
+
# (errored separately below). This is also the embedded fallback for ASSERT_KEYS — kept EQUAL to the generated
|
|
75
|
+
# list (test-enforced) so a missing assertion-keys.json can't silently reintroduce key drift.
|
|
76
|
+
_CLASSIFIED_KEYS = CONTENT_KEYS | GATE_KEYS | MANIFEST_KEYS | LIVE_ONLY_KEYS | VERDICT_MODIFIER_KEYS | {"replay_protocol_fidelity"}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _load_assert_keys():
|
|
80
|
+
"""The authoritative `assert:` key set, generated from the Zod Assertion schema into a sibling
|
|
81
|
+
`assertion-keys.json` (so the unknown-key check can't drift). Falls back to the embedded
|
|
82
|
+
`_CLASSIFIED_KEYS` (kept equal to the generated list) with a loud warning if the file is missing."""
|
|
83
|
+
p = Path(__file__).resolve().parent / "assertion-keys.json"
|
|
84
|
+
try:
|
|
85
|
+
return set(json.loads(p.read_text(encoding="utf-8"))["keys"])
|
|
86
|
+
except Exception:
|
|
87
|
+
print(
|
|
88
|
+
f"::warning:: assertion-keys.json not found next to scenario.py ({p}) — "
|
|
89
|
+
"using a built-in key list that may be stale (run `npm run schema`).",
|
|
90
|
+
file=sys.stderr,
|
|
91
|
+
)
|
|
92
|
+
return set(_CLASSIFIED_KEYS)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# every valid key inside an `assert:` list item (generated from the zod schema; see _load_assert_keys)
|
|
96
|
+
ASSERT_KEYS = _load_assert_keys()
|
|
97
|
+
|
|
98
|
+
# Self-check: every valid assertion key must be classified, else the replay-class lint logic mishandles it.
|
|
99
|
+
# Surfaced loudly at load AND as a lint ERROR in cmd_lint (so --strict / exit codes flow). Never sys.exit here.
|
|
100
|
+
UNCLASSIFIED_KEYS = sorted(ASSERT_KEYS - _CLASSIFIED_KEYS)
|
|
101
|
+
if UNCLASSIFIED_KEYS:
|
|
102
|
+
print(
|
|
103
|
+
f"::warning:: scenario.py: assertion key(s) {UNCLASSIFIED_KEYS} are in the schema but not classified "
|
|
104
|
+
"— add them to the linter's CONTENT/GATE/MANIFEST/LIVE_ONLY/VERDICT_MODIFIER sets.",
|
|
105
|
+
file=sys.stderr,
|
|
106
|
+
)
|
|
71
107
|
# every valid top-level scenario key
|
|
72
108
|
TOP_LEVEL_KEYS = {
|
|
73
109
|
"name",
|
|
@@ -79,6 +115,7 @@ TOP_LEVEL_KEYS = {
|
|
|
79
115
|
"answers",
|
|
80
116
|
"expect_denied",
|
|
81
117
|
"assert",
|
|
118
|
+
"skills", # F-6: opt-in skill-staleness hash scope
|
|
82
119
|
}
|
|
83
120
|
REGEX_KEYS = {
|
|
84
121
|
"transcript_matches",
|
|
@@ -232,40 +269,59 @@ def lint_doc(doc, path, raw_lines):
|
|
|
232
269
|
)
|
|
233
270
|
)
|
|
234
271
|
|
|
235
|
-
# W:
|
|
272
|
+
# W: nothing replay-checkable → a replay PR gate verifies nothing. Content/gate are replay-checkable, and
|
|
273
|
+
# manifest-backed keys are too WHEN the cassette carries an artifacts manifest — so only an all-live-only
|
|
274
|
+
# (egress / no_delete / self_heal / host-path) assert set genuinely no-ops on replay.
|
|
236
275
|
if items:
|
|
237
|
-
|
|
238
|
-
if not
|
|
276
|
+
replay_checkable = bool(assert_keys & (CONTENT_KEYS | GATE_KEYS | MANIFEST_KEYS))
|
|
277
|
+
if not replay_checkable:
|
|
239
278
|
findings.append(
|
|
240
279
|
Finding(
|
|
241
280
|
"WARN",
|
|
242
281
|
"replay-noop",
|
|
243
|
-
"every assertion is
|
|
244
|
-
"
|
|
245
|
-
"
|
|
282
|
+
"every assertion is live-only (egress / no_delete_in_outputs / self_heal_ran / "
|
|
283
|
+
"transcript_no_host_path) — on the token-free `replay` lane they are ALL silently "
|
|
284
|
+
"skipped, so a replay PR gate would verify nothing.",
|
|
285
|
+
"Add a content assertion (result / transcript_* / tool_* / subagent_*) or a "
|
|
286
|
+
"manifest-backed one (file_exists / user_visible_artifact / artifact_json), or run this "
|
|
246
287
|
"scenario only on the live (run/record) lane.",
|
|
247
288
|
path,
|
|
248
289
|
)
|
|
249
290
|
)
|
|
250
291
|
|
|
251
|
-
# W: mixed-class assert item →
|
|
292
|
+
# W: mixed-class assert item → the live-only half is dropped on replay (manifest-backed keys are NOT)
|
|
252
293
|
for idx, item in enumerate(items):
|
|
253
294
|
ks = set(item.keys())
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if
|
|
295
|
+
kept_half = ks & (CONTENT_KEYS | GATE_KEYS | MANIFEST_KEYS)
|
|
296
|
+
live_half = ks & LIVE_ONLY_KEYS
|
|
297
|
+
if kept_half and live_half:
|
|
257
298
|
findings.append(
|
|
258
299
|
Finding(
|
|
259
300
|
"WARN",
|
|
260
301
|
"mixed-assert-item",
|
|
261
|
-
f"assert item #{idx} mixes
|
|
262
|
-
f"
|
|
263
|
-
"
|
|
302
|
+
f"assert item #{idx} mixes replay-checkable {sorted(kept_half)} with "
|
|
303
|
+
f"live-only {sorted(live_half)} — on replay the live-only half is dropped "
|
|
304
|
+
"(only the replay-checkable half is evaluated).",
|
|
264
305
|
"Split into separate list items: one per concern.",
|
|
265
306
|
path,
|
|
266
307
|
)
|
|
267
308
|
)
|
|
268
309
|
|
|
310
|
+
# I: manifest-backed keys need an artifacts manifest on replay
|
|
311
|
+
manifest_present = sorted(assert_keys & MANIFEST_KEYS)
|
|
312
|
+
if manifest_present:
|
|
313
|
+
findings.append(
|
|
314
|
+
Finding(
|
|
315
|
+
"INFO",
|
|
316
|
+
"manifest-needs-snapshot",
|
|
317
|
+
f"assertion(s) {manifest_present} evaluate on replay only when the cassette carries an "
|
|
318
|
+
"`artifacts` manifest (`record` snapshots one). A manifest-less cassette skips them "
|
|
319
|
+
"(with a loud warning).",
|
|
320
|
+
"Record with a current harness so the cassette carries the artifacts manifest.",
|
|
321
|
+
path,
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
|
|
269
325
|
# I: gate keys need a controlOut cassette on replay
|
|
270
326
|
gate_present = sorted(assert_keys & GATE_KEYS)
|
|
271
327
|
if gate_present:
|
|
@@ -361,6 +417,19 @@ def _print_findings(findings, n_files):
|
|
|
361
417
|
|
|
362
418
|
def cmd_lint(args):
|
|
363
419
|
all_findings = []
|
|
420
|
+
# Linter self-check (B3): a valid schema key the replay-class sets don't classify can't be linted
|
|
421
|
+
# correctly — surface it as a hard ERROR so it fails the gate (and --strict) until someone classifies it.
|
|
422
|
+
if UNCLASSIFIED_KEYS:
|
|
423
|
+
all_findings.append(
|
|
424
|
+
Finding(
|
|
425
|
+
"ERROR",
|
|
426
|
+
"linter-unclassified-key",
|
|
427
|
+
f"linter is out of date: assertion key(s) {UNCLASSIFIED_KEYS} are valid (in the schema) but "
|
|
428
|
+
"scenario.py doesn't classify their replay behavior, so they can't be linted.",
|
|
429
|
+
"Add them to the linter's CONTENT/GATE/MANIFEST/LIVE_ONLY/VERDICT_MODIFIER sets.",
|
|
430
|
+
"(scenario.py)",
|
|
431
|
+
)
|
|
432
|
+
)
|
|
364
433
|
for f in args.files:
|
|
365
434
|
all_findings.extend(lint_file(f))
|
|
366
435
|
if args.json:
|
package/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,76 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
-
## [0.
|
|
9
|
+
## [0.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
|
+
|
|
64
|
+
## [0.4.3] — 2026-06-18
|
|
65
|
+
|
|
66
|
+
### Fixed
|
|
67
|
+
|
|
68
|
+
- **`cowork-harness lint` no longer flags `artifact_json` / `allow_permissive_auto_allow` as unknown keys.**
|
|
69
|
+
The linter's assertion-key list is now **generated from the Zod `Assertion` schema** (the same source
|
|
70
|
+
`assert --list` uses) into a file shipped next to `scenario.py`, with a CI drift-guard — so it can't lag the
|
|
71
|
+
schema again. Its replay-class warnings were also reconciled with the 0.3.0 artifact-manifest: `file_exists`,
|
|
72
|
+
`user_visible_artifact`, and `artifact_json` are now treated as **manifest-backed** (replay-checkable when the
|
|
73
|
+
cassette carries an `artifacts` manifest) rather than always-skipped, so a scenario asserting only those is no
|
|
74
|
+
longer a false `replay-noop`. A self-check fails the linter if a future schema key isn't classified.
|
|
75
|
+
|
|
76
|
+
### Internal
|
|
77
|
+
|
|
78
|
+
- The npm tarball no longer ships `docs/internal/` (internal planning docs were being published).
|
|
10
79
|
|
|
11
80
|
### Added
|
|
12
81
|
|
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.
|