cowork-harness 0.7.1 → 0.9.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.
Files changed (71) hide show
  1. package/.claude/skills/cowork-harness/scripts/assertion-keys.json +6 -0
  2. package/.claude/skills/cowork-harness/scripts/scenario.py +38 -5
  3. package/CHANGELOG.md +247 -0
  4. package/README.md +46 -30
  5. package/baselines/desktop-1.14271.0.json +220 -0
  6. package/baselines/provisioning/rootfs-provisioning.json +179 -0
  7. package/dist/agent/session.js +79 -17
  8. package/dist/assert.js +66 -26
  9. package/dist/baseline.js +32 -7
  10. package/dist/boundary-paths.js +73 -0
  11. package/dist/boundary.js +1 -1
  12. package/dist/cli-args.js +5 -0
  13. package/dist/cli.js +328 -138
  14. package/dist/decide/decider.js +142 -14
  15. package/dist/decide/external-channel.js +3 -1
  16. package/dist/egress/proxy.js +11 -10
  17. package/dist/egress/sidecar.js +75 -4
  18. package/dist/hostloop/workspace-handler.js +134 -22
  19. package/dist/prompt.js +10 -7
  20. package/dist/run/cassette.js +389 -91
  21. package/dist/run/chat.js +122 -36
  22. package/dist/run/doctor.js +37 -11
  23. package/dist/run/execute.js +233 -16
  24. package/dist/run/renderer.js +14 -1
  25. package/dist/run/run.js +90 -20
  26. package/dist/run/runs-gc.js +16 -7
  27. package/dist/run/scaffold.js +21 -2
  28. package/dist/run/skill-files.js +92 -0
  29. package/dist/run/skill-hash.js +99 -93
  30. package/dist/run/trace-view.js +34 -21
  31. package/dist/run/verdict.js +67 -3
  32. package/dist/runtime/container.js +1 -1
  33. package/dist/runtime/hostloop-prompt.js +47 -0
  34. package/dist/runtime/hostloop.js +31 -6
  35. package/dist/runtime/image-capabilities.js +271 -0
  36. package/dist/runtime/lima.js +24 -4
  37. package/dist/runtime/protocol.js +8 -3
  38. package/dist/runtime/stage.js +24 -6
  39. package/dist/session.js +87 -26
  40. package/dist/staging/mount-naming.js +124 -0
  41. package/dist/sync/cowork-sync.js +33 -8
  42. package/dist/types.js +53 -2
  43. package/docker/Dockerfile.agent +98 -6
  44. package/docker/Dockerfile.proxy +4 -1
  45. package/docs/README.md +8 -1
  46. package/docs/boundary.md +18 -3
  47. package/docs/cassette.md +52 -23
  48. package/docs/chat.md +15 -13
  49. package/docs/cowork-spawn-contract-1.12603.1.md +6 -5
  50. package/docs/decider-dir.md +14 -4
  51. package/docs/discovery.md +31 -3
  52. package/docs/fidelity-gaps.md +6 -4
  53. package/docs/maintenance.md +15 -10
  54. package/docs/scenario.md +70 -8
  55. package/docs/session.md +18 -14
  56. package/examples/README.md +2 -2
  57. package/examples/replays/README.md +2 -2
  58. package/package.json +7 -1
  59. package/python/README.md +23 -7
  60. package/python/cowork_harness.py +92 -8
  61. package/python/test_cowork_lane.py +102 -1
  62. package/python/test_scenario_lint.py +86 -0
  63. package/schema/cassette.v5.json +107 -0
  64. package/schema/cassette.v6.json +107 -0
  65. package/schema/run-result.json +24 -1
  66. package/schema/scenario.schema.json +11 -0
  67. package/schema/session.schema.json +0 -3
  68. package/scripts/boot-rootfs-vz.ts +99 -0
  69. package/scripts/build-rootfs-image.ts +152 -0
  70. package/scripts/capture-rootfs-manifest.ts +290 -0
  71. package/scripts/gen-schema.ts +4 -1
@@ -2,6 +2,7 @@
2
2
  "$comment": "GENERATED from the Zod Assertion schema (src/types.ts) by scripts/gen-schema.ts — do not edit; run `npm run schema`.",
3
3
  "keys": [
4
4
  "allow_l0_plugin_divergence",
5
+ "allow_missing_capability",
5
6
  "allow_permissive_auto_allow",
6
7
  "artifact_json",
7
8
  "dispatch_count_max",
@@ -29,5 +30,10 @@
29
30
  "transcript_not_contains",
30
31
  "transcript_not_matches",
31
32
  "user_visible_artifact"
33
+ ],
34
+ "verdictModifierKeys": [
35
+ "allow_l0_plugin_divergence",
36
+ "allow_missing_capability",
37
+ "allow_permissive_auto_allow"
32
38
  ]
33
39
  }
@@ -38,7 +38,11 @@ import re
38
38
  import sys
39
39
  from pathlib import Path
40
40
 
41
- # --- the replay-class taxonomy (mirrors `contentKeys` in src/run/cassette.ts) ---
41
+ # --- the replay-class taxonomy ---
42
+ # NB: this is NOT a 1:1 mirror of `contentKeys` in src/run/cassette.ts. cassette.ts keeps the verdict
43
+ # modifiers (VERDICT_MODIFIER_KEYS) in its content set so they replay as no-op passes; the linter
44
+ # deliberately keeps them OUT of CONTENT_KEYS so a modifier-only scenario still trips the `replay-noop`
45
+ # warning below (a no-op pass verifies nothing real — exactly what that warning is for).
42
46
  CONTENT_KEYS = {
43
47
  "result",
44
48
  "transcript_contains",
@@ -70,7 +74,11 @@ LIVE_ONLY_KEYS = {
70
74
  }
71
75
  EGRESS_KEYS = {"egress_denied", "egress_allowed"}
72
76
  # verdict modifiers — don't verify anything themselves (e.g. suppress a default-fail)
73
- VERDICT_MODIFIER_KEYS = {"allow_permissive_auto_allow", "allow_l0_plugin_divergence"}
77
+ VERDICT_MODIFIER_KEYS = {
78
+ "allow_permissive_auto_allow",
79
+ "allow_l0_plugin_divergence",
80
+ "allow_missing_capability",
81
+ }
74
82
 
75
83
  # Every key the replay-class logic knows how to handle. `replay_protocol_fidelity` is valid-but-not-authorable
76
84
  # (errored separately below). This is also the embedded fallback for ASSERT_KEYS — kept EQUAL to the generated
@@ -282,8 +290,9 @@ def lint_doc(doc, path, raw_lines):
282
290
  "WARN",
283
291
  "replay-noop",
284
292
  "every assertion is live-only (egress / no_delete_in_outputs / self_heal_ran / "
285
- "transcript_no_host_path) — on the token-free `replay` lane they are ALL silently "
286
- "skipped, so a replay PR gate would verify nothing.",
293
+ "transcript_no_host_path) or a verdict modifier (allow_*, a no-op pass) — on the "
294
+ "token-free `replay` lane the live-only ones are silently skipped and the verdict "
295
+ "modifiers verify nothing, so a replay PR gate would verify nothing.",
287
296
  "Add a content assertion (result / transcript_* / tool_* / subagent_*) or a "
288
297
  "manifest-backed one (file_exists / user_visible_artifact / artifact_json), or run this "
289
298
  "scenario only on the live (run/record) lane.",
@@ -419,6 +428,30 @@ def _print_findings(findings, n_files):
419
428
 
420
429
  def cmd_lint(args):
421
430
  all_findings = []
431
+ # Expand directory args to their scenario files — mirrors src/run/inputs.ts `resolveInputs`: a SINGLE
432
+ # combined-sorted `*.yaml` + `*.yml` listing (non-recursive), a single file kept as-is, and an EMPTY dir
433
+ # as a loud ERROR (never a vacuous "0 files = clean"). Done in place so the lint loop AND the count below
434
+ # both see the expanded list.
435
+ expanded = []
436
+ for arg in args.files:
437
+ p = Path(arg)
438
+ if p.is_dir():
439
+ matches = sorted(str(q) for q in (list(p.glob("*.yaml")) + list(p.glob("*.yml"))))
440
+ if matches:
441
+ expanded.extend(matches)
442
+ else:
443
+ all_findings.append(
444
+ Finding(
445
+ "ERROR",
446
+ "no-scenarios",
447
+ f"no .yaml/.yml files under {arg} — nothing to do (loud non-zero, not a vacuous pass)",
448
+ "Point lint at a scenario file or a directory containing *.yaml / *.yml scenarios.",
449
+ arg,
450
+ )
451
+ )
452
+ else:
453
+ expanded.append(arg)
454
+ args.files = expanded
422
455
  # Linter self-check (B3): a valid schema key the replay-class sets don't classify can't be linted
423
456
  # correctly — surface it as a hard ERROR so it fails the gate (and --strict) until someone classifies it.
424
457
  if UNCLASSIFIED_KEYS:
@@ -598,7 +631,7 @@ def main(argv=None):
598
631
  sub = ap.add_subparsers(dest="command", required=True)
599
632
 
600
633
  lp = sub.add_parser("lint", help="lint scenario(s) for silent-false-green invariants")
601
- lp.add_argument("files", nargs="+", help="scenario YAML file(s) to lint")
634
+ lp.add_argument("files", nargs="+", help="scenario YAML file(s) or director(ies) of *.yaml/*.yml to lint")
602
635
  lp.add_argument("--json", action="store_true", help="emit findings as JSON")
603
636
  lp.add_argument("--strict", action="store_true", help="exit non-zero on WARN/INFO too, not just ERROR")
604
637
  lp.set_defaults(func=cmd_lint)
package/CHANGELOG.md CHANGED
@@ -6,6 +6,253 @@ 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] — 2026-06-22
10
+
11
+ ### Breaking changes
12
+
13
+ - **Cassette staleness fingerprint bumped to format v6 (re-record once).** The skill-hash boundary changed,
14
+ so committed cassettes recorded before this release report `recorded under an older hash format — re-record
15
+ once`. Drivers: (1) **OS-junk** files (`.DS_Store` / `Thumbs.db` / `desktop.ini`) are excluded from
16
+ `skillHash` — an out-of-band OS metadata touch can no longer re-stale a cassette (the "fresh cassette is
17
+ immediately stale" bug); (2) **`contentSig` is unified onto the same walk as `skillHash`** (same file set,
18
+ plugin.json `version` stripped, in-tree symlinks hashed by target) — `rehash` cannot bridge this algorithm
19
+ change, so a pre-v6 cassette gets an honest *"algorithm changed — re-record"* (not "content changed"); (3)
20
+ the **git-tracked file set is the default boundary** (see Added).
21
+ - **Git-tracked staleness/mount boundary is now the DEFAULT.** When a skill/plugin source dir is in a git
22
+ work tree, both the staleness hash and the sandbox mount use only its **git-tracked** files (untracked
23
+ scratch / build output / OS-junk are excluded from both, so they can't drift the hash or leak into the
24
+ sandbox). A dir that isn't a git repo falls back to the raw walk automatically. Opt out with
25
+ `COWORK_HARNESS_GITSET=0`.
26
+ - **Removed the legacy CLI aliases.** `assert` → use `assertions`; `replay --cassette <file>` → pass the
27
+ path positionally (`replay <file | dir/>`); `verify-cassettes --privacy-only` / `--staleness-only` →
28
+ `--skip-privacy` / `--skip-staleness`. (0.8.0 had documented the latter two groups as renamed/removed but
29
+ the code still accepted the old forms; they are now gone. Each removed alias exits `2` if used.)
30
+ - **`assertions --list --output-format json` now reports `command: "assertions"`** (was the stale
31
+ `"assert"`) — a JSON-envelope contract fix for anything keying on the `command` field.
32
+ - **`decide` exits `2` (not `1`) on a runtime error**, matching the documented "usage / runtime → `2`"
33
+ contract. No-match / abstain still exits `1`.
34
+
35
+ ### Added
36
+
37
+ - **`cowork-harness/secrets` package export** — `scrubField` and `collectSecrets` are now importable as a
38
+ declared subpath (`import { scrubField, collectSecrets } from "cowork-harness/secrets"`) for custom
39
+ redaction pipelines, with the documented usage corrected to `scrubField(value, collectSecrets())` (a bare
40
+ `[token]` array misses secrets embedded in encoded fields). Adding the `exports` map also **bounds the
41
+ package's public surface to this one subpath** — deep imports into `dist/` (`cowork-harness/dist/...`),
42
+ previously resolvable by accident, are now private. The CLI (`bin`) is unaffected.
43
+ - **`lint` accepts a directory** — it expands to the directory's `*.yaml` / `*.yml` scenarios
44
+ (non-recursive, sorted), the same file-or-dir ergonomics as `replay` / `verify-cassettes`. An empty
45
+ directory is a loud error, never a vacuous "0 files = clean" pass.
46
+ - **Staleness now names the EXACT changed file.** A per-file manifest (`fileSigs`) in the cassette fingerprint
47
+ lets `verify-cassettes` report e.g. `skill files changed since record — 1 changed (skills/x/SKILL.md)`
48
+ instead of a coarse bucket message (appended to the existing shared-vs-scoped diagnosis). Manifest paths are
49
+ root-relative and are scanned + redacted with the same privacy layer as `skillSources`. Omitted (with a
50
+ loud `fileSigsOmitted`) above an internal size cap.
51
+ - **`COWORK_HARNESS_DEBUG_SKILLHASH=1`** — on a staleness mismatch, dumps the exact file set feeding the hash
52
+ to stderr and flags OS-junk, so a drift source is one line instead of a black-box hunt (a one-line hint
53
+ points to it when the flag is off).
54
+ - **`COWORK_HARNESS_GITSET=0`** — opt out of the new default git-tracked boundary (see Breaking) back to the
55
+ legacy raw filesystem walk for every dir.
56
+ - **`requires_capabilities` scenario assertion** — fail a scenario unless the running tier provides *and can
57
+ verify* the declared capability families (e.g. `office_convert`, `pdf_tables`). The unmet set is persisted
58
+ in the run result (`requiresCapabilityUnmet`), so `verify-run` can't false-fail; opt out with the
59
+ `allow_missing_capability` verdict modifier when the skill's fallback is genuinely equivalent.
60
+ - **LLM decider `OTHER:` free-text directive** — on an options-bearing gate, a decider answer of
61
+ `OTHER: <text>` is matched to a label first, else passed through as free text; a bare out-of-set value
62
+ still fails loud.
63
+
64
+ ### Fixed
65
+
66
+ - **`doctor --tier microvm` now checks the right prerequisites.** It previously probed the Docker daemon +
67
+ agent image + egress-proxy image for every live tier, but the `microvm` (L2) tier runs on **Lima / Apple
68
+ Virtualization.framework**, not Docker — so it could report "not ready" on a Lima-only host, or "ready"
69
+ with no Lima installed. `microvm` now checks `limactl` (honoring `COWORK_LIMACTL`) + the staged agent
70
+ binary, and skips the Docker checks; `container`/`hostloop`/`cowork` are unchanged.
71
+ - **A freshly recorded cassette no longer reports `[stale]` immediately** because the OS rewrote a `.DS_Store`
72
+ (or other OS-junk) in the skill tree — OS-junk is excluded from the skill hash. A chronic false-positive
73
+ that pushed consumers to WARN-only (which then masked real drift).
74
+ - A standalone verdict-modifier assertion (e.g. `allow_l0_plugin_divergence: true`) no longer false-fails
75
+ as "empty assertion", and verdict modifiers no longer trigger a misleading "filesystem/egress skipped"
76
+ warning on the replay lane. The verdict modifiers are now single-sourced from one list (`assert.ts`,
77
+ `cassette.ts`, and the Python linter all derive from / are checked against it), guarded by a convention
78
+ test against drift.
79
+ - **A tail-end transport drop is no longer conflated with an agent failure.** A connection closed *after* a
80
+ clean result is classified as `resultErrorKind: "transport"` (vs `"agent"`) and surfaced as a
81
+ lane/assertion-aware `transport_error` verdict — still a failure (no false-green), but distinguishable from
82
+ a genuine skill error; a non-matching envelope falls back to the agent classification.
83
+ - **Clearer guard / capability legibility.** The run footer lists only guards that actually ran this lane
84
+ (`capabilityProbe: definitive | unverified | skipped`) — never a false check-mark for a guard that didn't
85
+ run; capability notices state their own safety net + all-clear with verdict-impact tags; the unbuilt `max`
86
+ tier is dropped from capability hints; and Docker pool-exhaustion is reframed as a concurrency limit, not a
87
+ leak.
88
+ - **Ordered interrupt cleanup.** A `SIGINT` / `SIGTERM` during a live run reaps in-flight egress resources in
89
+ order (container thunks before network thunks) and announces itself, instead of leaving them dangling.
90
+
91
+ ## [0.8.0] — 2026-06-21
92
+
93
+ ### Breaking changes
94
+
95
+ - **Work folders now mount at `mnt/<folder-name>`, not `mnt/.projects/<id>`; the folder `to:` field is
96
+ removed.** Binary-verified (asar 1.14271.0): real Cowork mounts each connected work folder at a
97
+ collision-resolved **basename** of its canonical path (e.g. `mnt/project`) with no author-chosen name —
98
+ so the session-schema `folders[].to` override is GONE (it had no Cowork analog; names are always derived).
99
+ Same-basename folders are disambiguated tier-accurately (host-loop keeps the first bare, the VM/container
100
+ tier escalates both with a `--parent` prefix). Plugins likewise move from the synthetic
101
+ `mnt/.local-plugins/cache/<…>` to the real `mnt/.local-plugins/marketplaces/<marketplace>/<plugin>` (no
102
+ `cache/`, no version segment). **Version-gated:** this applies to Desktop **≥ 1.14271.0** (current
103
+ baselines); older baselines keep the legacy `.projects/<id>` + `cache/` paths. `user_visible_artifact`
104
+ and the artifact manifest now derive their visible roots from the actual mount set (persisted as
105
+ `RunResult.userVisibleRoots`), and the cassette format bumps **v3 → v4** to store them.
106
+ - *Upgrade note:* remove `to:` from `folders[]` in session files (the name derives from the folder
107
+ basename). Reference connected-folder artifacts as `<folder-name>/…` (e.g. `project/summary.md`) instead
108
+ of `.projects/<id>/…`. A folder-artifact cassette recorded before v4 must be **re-recorded** (`rehash`
109
+ cannot migrate it — it only re-hashes skill fingerprints). A connected folder whose basename collides
110
+ with a reserved Cowork mount name (`outputs`, `uploads`, `.projects`, …) on the VM/container tier is now
111
+ rejected loudly instead of silently shadowing the fixed dir — rename the folder.
112
+
113
+ - **Run output now defaults to `~/.cowork-harness/runs`, not `<cwd>/runs`.** A `run` / `skill` / `chat` /
114
+ `record` launched from a repo no longer drops run artifacts (often sensitive skill inputs/outputs) into the
115
+ working tree — the root moved out of any working tree, matching the `~/.cowork-harness/` convention already
116
+ used for VM work dirs. The root is **flat and machine-global** (shared across every project on the machine),
117
+ not per-project. The readers (`trace` / `scaffold` / `verify-run`) resolve the same default, so a bare
118
+ `trace <run-id>` now works from any directory; the previous cwd-relative / repo-root resolution tiers were
119
+ removed. A one-time `runs → <dir>` line prints on stderr when the default is used (suppressed under
120
+ `--quiet` / `--output-format json`, or when an override is set).
121
+ - *Upgrade note (CI / scripts):* anything that reads `./runs` after a run — a CI `upload-artifact path: runs/`,
122
+ a glob over `runs/**`, a `.gitignore` entry — must now set **`COWORK_HARNESS_RUNS_DIR`** (or pass
123
+ `--run-dir`) to a workspace path so output lands where it's expected. Otherwise the step finds an empty
124
+ `./runs` (and, if it doesn't fail on empty, passes silently). The bundled CI recipe sets
125
+ `COWORK_HARNESS_RUNS_DIR: runs` on the live-scenario job for exactly this reason.
126
+
127
+ - **Agent image bumped `cowork-agent-base:1` → `:2` — REBUILD REQUIRED.** The image now mirrors the real
128
+ Cowork rootfs's preinstalled toolchain (binary-verified by mounting the rootfs): **Node 22.22.3** (was
129
+ ubuntu's node 12), the full **Python document/data stack** (openpyxl/pandas/numpy/pdfplumber/python-docx/
130
+ python-pptx/matplotlib/…), node doc-gen globals (pdf-lib/pptxgenjs/sharp/tsx/…), `ruby`/`ffmpeg`/`qpdf`,
131
+ `C.UTF-8` locale, and it now runs as **uid 1000 (`ubuntu`)** like the real VM. Rebuild with the command
132
+ `cowork-harness doctor --tier container` prints, or set `COWORK_AGENT_IMAGE` to your own tag. *(Why: the
133
+ real rootfs ships openpyxl etc. preinstalled — omitting them made skills that read `.xlsx`/PDFs falsely
134
+ appear degraded under the harness. "pypi blocked at runtime" ≠ "not preinstalled.")*
135
+ - **A green run that uses a capability the agent image OMITS now FAILS** (verdict signal `missing_capability`),
136
+ mirroring `permissive_auto_allow`/`l0_plugin_divergence`. The default "core" image omits the heavy lane
137
+ (OCR/LibreOffice/markitdown/opencv/PDF-tables); a run that uses one is a likely false negative (real Cowork
138
+ ships it). Suppress per-scenario with **`allow_missing_capability: true`** (when the fallback is equivalent),
139
+ rebuild full parity (`--build-arg COWORK_FULL_PARITY=1`), use the rootfs `max` tier, or skip the check with
140
+ `COWORK_SKIP_CAPABILITY_PROBE=1`. Live tiers only (container/hostloop/microvm).
141
+ - **Stricter, fail-loud guardrails from the codebase bug-review sweep — a few previously-silent paths now
142
+ error.** These close false-greens / false-accepts and can surface on an existing setup:
143
+ - **Negative `verify-run` assertions fail on *missing* evidence instead of passing vacuously.**
144
+ `tool_result_not_contains`, `tool_not_called`, `subagent_tool_absent`, `dispatch_count_max`,
145
+ `subagent_declared_but_unused`, `no_delete_in_outputs`, `transcript_no_host_path`, and `self_heal_ran`
146
+ now fail with an "evidence unavailable" reason when the underlying field is absent from a partial/older
147
+ `result.json` (verify-run lane only — the live and replay lanes, where the evidence is always present,
148
+ are unchanged). *Upgrade note:* re-run rather than re-assert against a fresh `result.json` if a verify-run
149
+ flips to this failure.
150
+ - **Non-strict cassette replay now fails on corrupt `controlOut`** — a malformed line, or a duplicate
151
+ `request_id` with differing bodies — instead of warning and proceeding. Corruption is a protocol-fidelity
152
+ failure, not advisory; `--strict` still additionally catches staleness/extra-data.
153
+ - **`chat --raw` now rejects `--upload` / `--folder` / `--plugin` / `--fidelity`** (it can't honor them in
154
+ native mode) instead of silently ignoring them, and an invalid `COWORK_HARNESS_FIDELITY` value is rejected
155
+ loudly instead of silently falling back to `container`. An invalid `COWORK_HARNESS_MAX_ARTIFACT_BYTES` now
156
+ errors (parity with the `--max-artifact-bytes` flag) instead of silently using the default.
157
+ - **A `web_fetch` `approved_domains` / seed entry that isn't a bare host** (a URL, scheme, path, port, empty
158
+ string, or `*` wildcard) is now rejected loudly instead of being added as an inert, never-matching entry.
159
+ - **An over-cap (> 256 KiB) control-out frame now fails the live recording immediately** with a clear error,
160
+ instead of writing an unreplayable truncation marker that only surfaced later as a replay failure.
161
+
162
+ ### Added
163
+
164
+ - **Capability fidelity detection.** The harness probes the agent runtime (Docker image via `--network none`
165
+ run, or the L2 microVM via `limactl shell`) for the document/OCR/Office capabilities the real Cowork rootfs
166
+ ships, caches the result by `(tier, identity)`, and detects (from `events.jsonl`) when a skill used an
167
+ omitted one — surfaced as the new `RunResult.missingCapabilityUse` field, a `::notice::`/`::warning::`, and
168
+ the `missing_capability` verdict above. New assertion `allow_missing_capability`.
169
+ - **Opt-in full-parity image** (`docker build --build-arg COWORK_FULL_PARITY=1 -t cowork-agent-full:2`) adds
170
+ tesseract / LibreOffice / opencv / onnxruntime+markitdown / camelot+tabula for OCR/Office/extraction skills.
171
+ - **Rootfs `max` tier (`npm run build:rootfs-image`)** — builds a Docker image from the user's OWN
172
+ `rootfs.img` (local, byte-for-byte parity; cached by rootfs mtime+size); point `COWORK_AGENT_IMAGE` at it.
173
+ - **Provisioning drift gate (`npm run capture:rootfs`)** — captures the rootfs's toolchain to
174
+ `baselines/provisioning/rootfs-provisioning.json`; `--check <image>` diffs a built image against it.
175
+ - **L2 (microVM) toolchain parity** — Lima provisioning now installs the same document/data stack
176
+ (best-effort, with 24.04 version drift accepted).
177
+ - **`--run-dir <path>` global flag** to relocate the runs root (a thin shim over `COWORK_HARNESS_RUNS_DIR`).
178
+ Precedence: `--run-dir` > `COWORK_HARNESS_RUNS_DIR` > `~/.cowork-harness/runs`. Keeps sensitive artifacts out
179
+ of a working tree without the prior `cd`-into-a-scratch-dir workaround. Both spellings (`--run-dir <path>` and
180
+ `--run-dir=<path>`) are accepted; unlike `--dotenv` it does not require the path to exist (it's an output dir).
181
+ - **Cross-project overwrite guard for pinned (`--session-id`) sessions.** On the flat shared root a pinned
182
+ `sess-<id>` run dir is deterministic, so two projects can resolve to the same path. The writer now identifies
183
+ a run by its **mounted-source content** (recorded in an `outDir/.origin` marker) and:
184
+ - **errors instead of silently `rm -rf`-ing** a dir that belongs to a different project (the old behavior
185
+ blind-deleted a colliding peer's persisted, resumable session);
186
+ - **fails closed** on a missing/partial marker (a crashed prior run) — it throws with a "delete `<dir>` to
187
+ reset" hint rather than deleting an unconfirmable dir;
188
+ - treats a **sourceless inline scenario** (which has no content to identify it, only cwd) as unconfirmable —
189
+ it throws rather than risk a cwd-collision delete (`skill <dir>` runs always mount the skill, so the common
190
+ pinned workflow is unaffected);
191
+ - blocks **`--resume`** onto a different project's session in place (override with
192
+ `COWORK_HARNESS_ALLOW_FOREIGN_RESUME=1`).
193
+ - **`runs gc` never prunes pinned `sess-*` sessions** (and they don't consume a `--keep-last` slot — partitioned
194
+ out before counting, so a retained pinned dir can't evict a newer ephemeral run). Only ephemeral `local_*`
195
+ runs are pruned. Because the default root is now shared, a bare `runs gc` prunes ephemeral runs across **all**
196
+ projects; pass an explicit `<runs-dir>` to scope it.
197
+ - **multiSelect `AskUserQuestion` gates are now answerable on every answer channel.** Scripted
198
+ `choose: [list]` already worked; the in-band `--decider-dir` channel now accepts a repeated
199
+ `--choose` (`answer <dir> --gate 1 --choose Auth --choose Audit`), and `--decider-cmd` helpers /
200
+ hand-written `resp-N.json` accept a JSON-array reply (`{"answers":{"<q>":["Auth","Audit"]}}`). All
201
+ channels deliver the binary-verified `", "`-joined wire shape; a member matching no option, an array
202
+ on a single-select gate, or an empty array each fails loud. `cowork-harness answer`'s `--choose` is
203
+ now repeatable for multiSelect gates (still single-only on single-select). Verified end-to-end
204
+ against a live model (the real agent re-reads the joined answer as multiple selections).
205
+ - **`scaffold --from-run`** flags a delivered answer that looks like a multiSelect set (contains
206
+ `", "`) with a loud comment telling the author to split `choose: "A, B"` into `choose: [A, B]`
207
+ before replay (a scaffolded multiSelect answer would otherwise not match on replay).
208
+
209
+ ### Fixed
210
+
211
+ - `doctor`'s staged-agent remedy now hints to put `COWORK_AGENT_BINARY` in `.env` so `--dotenv` covers it
212
+ (like the auth token) — avoiding a misleading "red" when `doctor` is run without the same env/flags the real
213
+ run uses.
214
+ - **`--decider-dir` / `--decider-cmd` no longer crash on a multiSelect array reply.** `coerceLabel`
215
+ previously called `.trim()` on a non-string answer and threw a bare `TypeError`, aborting the run;
216
+ it now throws a clear `UnansweredError` instead, and a multiSelect array is validated per-member and
217
+ delivered as the joined wire shape.
218
+ - **Codebase bug-review sweep — 49 validated fixes across the harness** (beyond the behavior changes noted
219
+ under Breaking):
220
+ - **CLI parsing:** `vm status --output-format json` works and emits a JSON envelope, instead of misreading
221
+ the flag as a baseline name; `skill` and common flags accept every documented `--flag=value` form;
222
+ `boundary-check` reports a missing/malformed session as a clean (JSON-aware) usage error rather than an
223
+ internal error; `chat` rejects extra positionals and empty/flag-looking value-flag arguments; `parseArgs`
224
+ rejects empty `--flag ""` values.
225
+ - **Path / boundary hardening:** named baselines can no longer escape `baselines/` via `../`; marketplace and
226
+ staged-mount symlinks that resolve out of tree are rejected (realpath, not lexical); collected artifacts
227
+ skip hardlinks (`nlink > 1`) that could inline out-of-root content into a cassette; a new
228
+ `src/boundary-paths.ts` centralizes `safeNamedBaseline` / `containedRealPath` / `normalizeHost` /
229
+ `validateBareDomain` so the egress allowlist and seed-domain paths share one policy.
230
+ - **Protocol / replay integrity:** every control-request validates its `request_id` before replying; the
231
+ AskUserQuestion body is validated at ingress (optionless / header-only gates still pass); a malformed
232
+ cassette no longer aborts the whole replay batch; a `web_fetch` decision of the wrong kind is recorded as
233
+ `mismatch→deny` with a warning; egress host matching is case- and trailing-dot-normalized.
234
+ - **host-loop fidelity:** the `web_fetch` SSRF backstop pins the vetted address through connect (closing a
235
+ DNS-rebind TOCTOU) and re-vets each redirect; a Docker infrastructure failure (daemon down, missing
236
+ container, exit 125) is reported to the model as a generic harness error and logged raw, instead of
237
+ leaking daemon text framed as a normal command exit.
238
+ - **Fidelity drift checks:** the rootfs `--check` diffs the whole Layer-A pip set (generated from the
239
+ Dockerfile) plus Node, the apt doc stack, and global npm; rootfs image tags are content-addressed; the
240
+ capability cache keys on image content rather than a mutable tag; `sync` cleans its temp extraction dir and
241
+ records a drift signal on a corrupt `config.json`.
242
+ - **decider / schema / Python:** `allow_if` predicates accept non-identifier input keys (`input["file-path"]`);
243
+ `choose`+`answer` and inert `grant` are rejected at schema time; optionless prompt/LLM gates are answerable;
244
+ the Python wrapper returns a `BatchResult` for directory/replay runs so a later failure can't hide behind a
245
+ passing first result.
246
+ - **Documentation audit sweep — stale references corrected, gaps filled.** Bumped the "current baseline" pins
247
+ across README / DESIGN / SPEC / spawn-contract / skill docs to `desktop-1.14271.0` (agent ELF 2.1.181),
248
+ re-verified end-to-end against the live staged agent at this baseline; added the missing
249
+ `tool_result_contains` / `tool_result_not_contains` and `allow_missing_capability` assertion rows plus a
250
+ verdict-signals (`prompt_asset_missing`) section to the docs; documented `doctor` / `rehash` / `runs gc` and
251
+ exit code `3` in `llms.txt` and the README command table; corrected the SPEC §11 exit-code table
252
+ (`boundary` → `3`), the `chat --folder` / `folders[].to` notes, the `trace --view tools` flag, and the
253
+ skill-bootstrap version floor (`@>=0.7.1`). **CI fix:** the agent-base image build in
254
+ `.github/workflows/ci.yml` was tagged `:1` while every code path defaults to `:2` — now `:2`.
255
+
9
256
  ## [0.7.1] — 2026-06-20
10
257
 
11
258
  ### Fixed