cowork-harness 0.16.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 +65 -0
- package/README.md +2 -2
- 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 +116 -31
- package/dist/decide/decider.js +95 -22
- 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 +289 -206
- 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/cassette.md +4 -2
- package/docs/scenario.md +11 -0
- package/examples/replays/example-pdf-skill.cassette.json +32 -29
- package/package.json +1 -1
- package/schema/cassette.v7.json +131 -0
- package/schema/scenario.schema.json +13 -7
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,71 @@ 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
|
+
|
|
9
74
|
## [0.16.0] — 2026-06-26
|
|
10
75
|
|
|
11
76
|
### Fixed
|
package/README.md
CHANGED
|
@@ -148,7 +148,7 @@ This repo ships a **companion skill** (`.claude/skills/cowork-harness/`) that te
|
|
|
148
148
|
/plugin install cowork-harness@cowork-harness
|
|
149
149
|
```
|
|
150
150
|
|
|
151
|
-
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.
|
|
152
152
|
|
|
153
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.)
|
|
154
154
|
|
|
@@ -481,7 +481,7 @@ Most runs need **none** of these — the defaults are correct. They're grouped b
|
|
|
481
481
|
- `COWORK_CONTAINER_RUNTIME=podman` — use Podman instead of Docker.
|
|
482
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.
|
|
483
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).
|
|
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_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.
|
|
485
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.
|
|
486
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`).
|
|
487
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).
|
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. */
|
package/dist/cli.js
CHANGED
|
@@ -310,6 +310,26 @@ const COMMANDS = [
|
|
|
310
310
|
"rehash",
|
|
311
311
|
"prune",
|
|
312
312
|
];
|
|
313
|
+
// #29: --dotenv / --run-dir are GLOBAL flags honored ONLY in leading position (before the subcommand),
|
|
314
|
+
// so a per-command value beginning with `--dotenv=`/`--run-dir=` (e.g. `skill ./s "p" --answer
|
|
315
|
+
// "--dotenv=x=foo"`) is never hijacked by the pre-dispatch scan. Walk from the front consuming only
|
|
316
|
+
// leading global-flag tokens (and their space-form values); stop at the first token that isn't one —
|
|
317
|
+
// that token is the subcommand. Returns the count of leading tokens; the scans slice argv to it. Because
|
|
318
|
+
// the region is a prefix, an index found within it is the same index into argv (so the later splice/value
|
|
319
|
+
// reads against argv stay aligned). MUST be recomputed after a splice mutates argv.
|
|
320
|
+
function leadingGlobalCount(av) {
|
|
321
|
+
let i = 0;
|
|
322
|
+
while (i < av.length) {
|
|
323
|
+
const t = av[i];
|
|
324
|
+
if (t === "--dotenv" || t === "--run-dir")
|
|
325
|
+
i += 2; // flag + space-form value
|
|
326
|
+
else if (t.startsWith("--dotenv=") || t.startsWith("--run-dir="))
|
|
327
|
+
i += 1; // equals form
|
|
328
|
+
else
|
|
329
|
+
break; // first non-global token = subcommand
|
|
330
|
+
}
|
|
331
|
+
return i;
|
|
332
|
+
}
|
|
313
333
|
async function main() {
|
|
314
334
|
const argv = process.argv.slice(2);
|
|
315
335
|
// `--dotenv <path>` is a GLOBAL flag — parse + strip it before command dispatch so a skill run from
|
|
@@ -321,8 +341,11 @@ async function main() {
|
|
|
321
341
|
// form was missed by indexOf("--dotenv") → the whole `--dotenv=...` token fell through to dispatch as
|
|
322
342
|
// the command name ("unknown command"). Find either spelling and apply the SAME existence + command-
|
|
323
343
|
// name guards.
|
|
324
|
-
|
|
325
|
-
|
|
344
|
+
// Scan only the leading global-flag region (computed over the not-yet-mutated argv), so a `--dotenv=…`
|
|
345
|
+
// token sitting AFTER the subcommand (as a per-command flag value) is left for the command parser (#29).
|
|
346
|
+
const dotenvLead = argv.slice(0, leadingGlobalCount(argv));
|
|
347
|
+
const eqIdx = dotenvLead.findIndex((a) => a.startsWith("--dotenv="));
|
|
348
|
+
const spaceIdx = dotenvLead.indexOf("--dotenv");
|
|
326
349
|
const envFileIdx = spaceIdx >= 0 ? spaceIdx : eqIdx;
|
|
327
350
|
const isEquals = spaceIdx < 0 && eqIdx >= 0;
|
|
328
351
|
const explicitEnvFile = isEquals ? argv[eqIdx].slice("--dotenv=".length) : envFileIdx >= 0 ? argv[envFileIdx + 1] : undefined;
|
|
@@ -354,8 +377,12 @@ async function main() {
|
|
|
354
377
|
// shim over COWORK_HARNESS_RUNS_DIR: setting it here makes runsWriteRoot()/runsRoot() pick it up with no
|
|
355
378
|
// writer/reader changes. Precedence: flag > COWORK_HARNESS_RUNS_DIR > ~/.cowork-harness/runs. Unlike
|
|
356
379
|
// --dotenv it does NOT require the path to exist (it's an output dir, created on first write).
|
|
357
|
-
|
|
358
|
-
|
|
380
|
+
// Recompute the leading region over the CURRENT (post-dotenv-splice) argv — the --dotenv strip above may
|
|
381
|
+
// have shifted token positions, so a lead captured before it would be stale and misalign the indices the
|
|
382
|
+
// splice below relies on (#29).
|
|
383
|
+
const runDirLead = argv.slice(0, leadingGlobalCount(argv));
|
|
384
|
+
const rdEq = runDirLead.findIndex((a) => a.startsWith("--run-dir="));
|
|
385
|
+
const rdSpace = runDirLead.indexOf("--run-dir");
|
|
359
386
|
const rdIdx = rdSpace >= 0 ? rdSpace : rdEq;
|
|
360
387
|
const rdIsEquals = rdSpace < 0 && rdEq >= 0;
|
|
361
388
|
const runDirVal = rdIsEquals ? argv[rdEq].slice("--run-dir=".length) : rdIdx >= 0 ? argv[rdIdx + 1] : undefined;
|
|
@@ -396,6 +423,16 @@ async function main() {
|
|
|
396
423
|
// absent from the map and fall through to their own handling).
|
|
397
424
|
if (hasHelp(rest) && cmd in SUBCOMMAND_USAGE)
|
|
398
425
|
return void log(SUBCOMMAND_USAGE[cmd]);
|
|
426
|
+
// #27: validate COWORK_HARNESS_OUTPUT_FORMAT here — AFTER the --version/--help/per-subcommand-help
|
|
427
|
+
// short-circuits above (so `COWORK_HARNESS_OUTPUT_FORMAT=garbage cowork-harness --version|--help` is NOT
|
|
428
|
+
// a regression-causing usage error, matching the --output-format flag which never blocks --version) and
|
|
429
|
+
// BEFORE dispatch (so every dispatched command sees a validated env, and #4's env fallback only ever
|
|
430
|
+
// reads text|json|unset). The --output-format flag rejects an invalid value with exit 2; the env form
|
|
431
|
+
// silently degraded to text — bring it to parity. An explicit --output-format=json flag still selects the
|
|
432
|
+
// json error envelope via isJsonOutput (the garbage env value falls back to text there).
|
|
433
|
+
const envOutFmt = process.env.COWORK_HARNESS_OUTPUT_FORMAT;
|
|
434
|
+
if (envOutFmt !== undefined && envOutFmt !== "text" && envOutFmt !== "json")
|
|
435
|
+
fail(cmd, "usage", `COWORK_HARNESS_OUTPUT_FORMAT must be "text" or "json" (got "${envOutFmt}")`, undefined, isJsonOutput(rest));
|
|
399
436
|
switch (cmd) {
|
|
400
437
|
case "run":
|
|
401
438
|
return cmdRun(rest);
|
|
@@ -447,15 +484,23 @@ async function main() {
|
|
|
447
484
|
process.exit(2);
|
|
448
485
|
}
|
|
449
486
|
}
|
|
450
|
-
/** Shared json-output predicate so the parser and the top-level catch can never drift.
|
|
487
|
+
/** Shared json-output predicate so the parser and the top-level catch can never drift. An explicit
|
|
488
|
+
* `--output-format text|json` flag (first occurrence wins, matching parseOutputFormat's
|
|
489
|
+
* first-occurrence-authoritative semantics) takes precedence; absent any flag, fall back to the
|
|
490
|
+
* documented COWORK_HARNESS_OUTPUT_FORMAT env var so an env-only JSON consumer still gets an envelope
|
|
491
|
+
* from the top-level catch (#4). */
|
|
451
492
|
function isJsonOutput(args) {
|
|
452
493
|
for (let i = 0; i < args.length; i++) {
|
|
453
494
|
if (args[i] === "--output-format" && args[i + 1] === "json")
|
|
454
495
|
return true;
|
|
455
496
|
if (args[i] === "--output-format=json")
|
|
456
497
|
return true;
|
|
498
|
+
if (args[i] === "--output-format" && args[i + 1] === "text")
|
|
499
|
+
return false;
|
|
500
|
+
if (args[i] === "--output-format=text")
|
|
501
|
+
return false;
|
|
457
502
|
}
|
|
458
|
-
return
|
|
503
|
+
return process.env.COWORK_HARNESS_OUTPUT_FORMAT === "json";
|
|
459
504
|
}
|
|
460
505
|
/** Validate `--output-format` is text|json for the ad-hoc commands (trace/decide/gates) the way the
|
|
461
506
|
* common parser already does for run/skill — an invalid value is a usage error, not a silent text degrade. */
|
|
@@ -762,7 +807,9 @@ async function cmdRun(rawArgs) {
|
|
|
762
807
|
const a = rawArgs[i];
|
|
763
808
|
if (a === "--decider-model") {
|
|
764
809
|
deciderModel = rawArgs[++i];
|
|
765
|
-
|
|
810
|
+
// #28: reject "" too — the equals branch below rejects an empty value, so the spaced form must match
|
|
811
|
+
// (otherwise `--decider-model ""` forwards an empty model id and fails obscurely later).
|
|
812
|
+
if (deciderModel === undefined || deciderModel === "" || deciderModel.startsWith("-"))
|
|
766
813
|
fail("run", "usage", "--decider-model requires a value (a model id)", undefined, isJsonOutput(rawArgs));
|
|
767
814
|
}
|
|
768
815
|
else if (a.startsWith("--decider-model=")) {
|
|
@@ -1086,18 +1133,15 @@ function baselineFilePath(name) {
|
|
|
1086
1133
|
* harness understands, guest-image hints, the resolved VM instance + its current status, and any
|
|
1087
1134
|
* warnings (e.g. status the VM tooling could not determine). Keep the shape additive — JSON callers
|
|
1088
1135
|
* pin field names, so add fields rather than rename/remove. */
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
if (status === "Absent")
|
|
1094
|
-
warnings.push(`no VM exists for ${instance} (run \`vm init\` to create it)`);
|
|
1136
|
+
// Shared vm JSON envelope shell. Every subcommand (#7: init/delete/prune too, not just status) emits the
|
|
1137
|
+
// same tool/command/instance/baseline/image block under a per-subcommand discriminator, with the
|
|
1138
|
+
// subcommand-specific outcome spread in by the caller.
|
|
1139
|
+
function vmEnvelopeBase(subcommand, baselineName, baseline, instance, warnings) {
|
|
1095
1140
|
return {
|
|
1096
1141
|
tool: "cowork-harness",
|
|
1097
1142
|
command: "vm",
|
|
1098
|
-
subcommand
|
|
1143
|
+
subcommand,
|
|
1099
1144
|
instance,
|
|
1100
|
-
status,
|
|
1101
1145
|
baseline: {
|
|
1102
1146
|
name: baselineName,
|
|
1103
1147
|
path: baselineFilePath(baselineName),
|
|
@@ -1114,6 +1158,14 @@ function vmStatusEnvelope(baselineName, baseline, instance, status) {
|
|
|
1114
1158
|
warnings,
|
|
1115
1159
|
};
|
|
1116
1160
|
}
|
|
1161
|
+
function vmStatusEnvelope(baselineName, baseline, instance, status) {
|
|
1162
|
+
const warnings = [];
|
|
1163
|
+
// vmStatus returns "Absent" when no Lima VM exists for this config hash (see lima.ts) — surface that
|
|
1164
|
+
// as a warning so a JSON caller doesn't read "Absent" as a running VM.
|
|
1165
|
+
if (status === "Absent")
|
|
1166
|
+
warnings.push(`no VM exists for ${instance} (run \`vm init\` to create it)`);
|
|
1167
|
+
return { ...vmEnvelopeBase("status", baselineName, baseline, instance, warnings), status };
|
|
1168
|
+
}
|
|
1117
1169
|
const VM_SUB_HELP = {
|
|
1118
1170
|
init: "usage: vm init [<baseline>] [--output-format text|json] — create the L2 Apple-VZ microVM",
|
|
1119
1171
|
status: "usage: vm status [<baseline>] [--output-format text|json] — show running VM state",
|
|
@@ -1174,15 +1226,25 @@ function cmdVm(args) {
|
|
|
1174
1226
|
}
|
|
1175
1227
|
else if (sub === "init") {
|
|
1176
1228
|
const { status } = vmInit(baseline);
|
|
1177
|
-
|
|
1229
|
+
// #7: honor --output-format json (init/delete/prune printed text unconditionally; only status did).
|
|
1230
|
+
if (vmJson)
|
|
1231
|
+
out(JSON.stringify({ ...vmEnvelopeBase("init", baselineName, baseline, instance, []), status }));
|
|
1232
|
+
else
|
|
1233
|
+
log(`${instance}: ${status}`);
|
|
1178
1234
|
}
|
|
1179
1235
|
else if (sub === "delete") {
|
|
1180
1236
|
vmDelete(instance);
|
|
1181
|
-
|
|
1237
|
+
if (vmJson)
|
|
1238
|
+
out(JSON.stringify({ ...vmEnvelopeBase("delete", baselineName, baseline, instance, []), deleted: true }));
|
|
1239
|
+
else
|
|
1240
|
+
log(`${instance} deleted`);
|
|
1182
1241
|
}
|
|
1183
1242
|
else if (sub === "prune") {
|
|
1184
1243
|
const pruned = vmPrune(instance);
|
|
1185
|
-
|
|
1244
|
+
if (vmJson)
|
|
1245
|
+
out(JSON.stringify({ ...vmEnvelopeBase("prune", baselineName, baseline, instance, []), pruned }));
|
|
1246
|
+
else
|
|
1247
|
+
log(pruned.length ? `pruned ${pruned.length} orphaned VM(s): ${pruned.join(", ")}` : `no orphaned VMs (current: ${instance})`);
|
|
1186
1248
|
}
|
|
1187
1249
|
else {
|
|
1188
1250
|
// #11: an invalid/absent subcommand must exit non-zero — a bare `log` exits 0, so a CI script
|
|
@@ -1342,7 +1404,10 @@ function cmdSync(args) {
|
|
|
1342
1404
|
for (const g of Object.values(res.gates)) {
|
|
1343
1405
|
const key = `${g.name}:${g.id}`;
|
|
1344
1406
|
const prev = baseGates[key];
|
|
1345
|
-
|
|
1407
|
+
// #30: for a legacy prose entry the authoritative state is the LEADING `on(...)`/`off(...)` token
|
|
1408
|
+
// (the note stripper just below anchors on the same pattern) — a bare substring scan of the whole
|
|
1409
|
+
// string would let a human note containing "on"/"force" mask a real off→on flip.
|
|
1410
|
+
const prevOn = typeof prev === "string" ? /^\s*on\(/i.test(prev) : !!prev?.on;
|
|
1346
1411
|
// Preserve the human annotation: from a prose string, drop the leading "on(force) " token; from
|
|
1347
1412
|
// a structured entry, keep its `note`.
|
|
1348
1413
|
const prevNote = typeof prev === "string"
|
|
@@ -1495,6 +1560,11 @@ async function cmdDecide(args) {
|
|
|
1495
1560
|
// stray positional (isJsonOutput/ensureOutputFormat handle the actual parsing).
|
|
1496
1561
|
else if (a === "--output-format")
|
|
1497
1562
|
i++;
|
|
1563
|
+
// #5: the equals form is fully supported by the parser; recognize it here as a no-op (it carries its
|
|
1564
|
+
// own value) so it doesn't fall through to the unknown-flag guard below and exit 2 like an outlier.
|
|
1565
|
+
else if (a === "--output-format=json" || a === "--output-format=text") {
|
|
1566
|
+
/* recognized; parsed by isJsonOutput/ensureOutputFormat */
|
|
1567
|
+
}
|
|
1498
1568
|
// --decider-dir is rejected explicitly below with a redirect message; consume its value here so the
|
|
1499
1569
|
// value isn't flagged as a stray positional before that guard fires.
|
|
1500
1570
|
// check that the next token exists and doesn't look like a flag before consuming it.
|
|
@@ -1628,7 +1698,11 @@ function cmdAnswer(args) {
|
|
|
1628
1698
|
ensureOutputFormat("answer", args);
|
|
1629
1699
|
const json = isJsonOutput(args);
|
|
1630
1700
|
// #15: skip flag values so `answer --gate 1 --choose Yes <dir>` doesn't read `1` as the directory.
|
|
1631
|
-
const
|
|
1701
|
+
const answerPositionals = positionals(args, ["--gate", "--choose", "--answer", "--output-format"]);
|
|
1702
|
+
// #31: reject extra positionals rather than silently writing to the first dir (mirrors `gates`).
|
|
1703
|
+
if (answerPositionals.length > 1)
|
|
1704
|
+
return void fail("answer", "usage", "answer takes one <dir>", undefined, json);
|
|
1705
|
+
const dir = answerPositionals[0];
|
|
1632
1706
|
let seq;
|
|
1633
1707
|
// --choose accumulates: a single value answers a single-select gate; multiple values answer a
|
|
1634
1708
|
// multiSelect gate (validated once the gate is read — a multi --choose on a single-select gate is
|
|
@@ -1719,20 +1793,30 @@ function cmdScaffold(args) {
|
|
|
1719
1793
|
// Reject unknown flags rather than silently ignoring a typo (e.g. `--form-run`).
|
|
1720
1794
|
rejectUnknownFlags("scaffold", args, ["--from-run", "--out", "--output-format", "--output-format=json", "--output-format=text"], json);
|
|
1721
1795
|
// Validate --out FIRST (flag-looking value is a usage error regardless of --from-run presence).
|
|
1722
|
-
|
|
1796
|
+
// #6: honor BOTH the spaced (`--out foo`) and equals (`--out=foo`) forms — indexOf("--out") missed the
|
|
1797
|
+
// equals token, so `--out=foo.yaml` was accepted by rejectUnknownFlags then silently ignored (output went
|
|
1798
|
+
// to stdout). The equals value also gets the same flag-looking guard the spaced form has.
|
|
1799
|
+
const outSpaceIdx = args.indexOf("--out");
|
|
1800
|
+
const outEqIdx = args.findIndex((a) => a.startsWith("--out="));
|
|
1723
1801
|
let outPath;
|
|
1724
|
-
if (
|
|
1725
|
-
outPath = flagValue(args,
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1802
|
+
if (outSpaceIdx >= 0)
|
|
1803
|
+
outPath = flagValue(args, outSpaceIdx, "--out");
|
|
1804
|
+
else if (outEqIdx >= 0)
|
|
1805
|
+
outPath = args[outEqIdx].slice("--out=".length);
|
|
1806
|
+
if (outPath !== undefined && (outPath === "" || outPath.startsWith("-")))
|
|
1807
|
+
return void fail("scaffold", "usage", `--out requires a file path${outPath === "" ? " (got empty)" : `, got a flag: ${outPath}`}`, undefined, json);
|
|
1729
1808
|
// Validate --from-run flag-looking value before computing positionals (so the error is specific).
|
|
1730
|
-
|
|
1809
|
+
// #6: same dual-form handling for --from-run (the equals form fell through to a spurious usage failure).
|
|
1810
|
+
const fromSpaceIdx = args.indexOf("--from-run");
|
|
1811
|
+
const fromEqIdx = args.findIndex((a) => a.startsWith("--from-run="));
|
|
1731
1812
|
let fromRunVal;
|
|
1732
|
-
if (
|
|
1733
|
-
fromRunVal = flagValue(args,
|
|
1734
|
-
|
|
1735
|
-
|
|
1813
|
+
if (fromSpaceIdx >= 0)
|
|
1814
|
+
fromRunVal = flagValue(args, fromSpaceIdx, "--from-run");
|
|
1815
|
+
else if (fromEqIdx >= 0)
|
|
1816
|
+
fromRunVal = args[fromEqIdx].slice("--from-run=".length);
|
|
1817
|
+
if (fromRunVal !== undefined) {
|
|
1818
|
+
if (fromRunVal === "" || fromRunVal.startsWith("-"))
|
|
1819
|
+
return void fail("scaffold", "usage", `--from-run requires a run id/dir${fromRunVal === "" ? " (got empty)" : `, got a flag: ${fromRunVal}`}`, undefined, json);
|
|
1736
1820
|
log("note: --from-run is deprecated; prefer: scaffold <run-id | run-dir>\n");
|
|
1737
1821
|
}
|
|
1738
1822
|
// Positional is canonical; --from-run is a backward-compatible alias.
|
|
@@ -1925,6 +2009,7 @@ async function cmdVerifyRun(args) {
|
|
|
1925
2009
|
subagents: result.subagents ?? [],
|
|
1926
2010
|
gateDeliveries: result.gateDeliveries ?? [],
|
|
1927
2011
|
toolResultTexts: (result.toolResults ?? []).map((r) => r.assertText ?? r.text),
|
|
2012
|
+
toolResultsTruncated: (result.toolResults ?? []).map((r) => r.assertText === undefined),
|
|
1928
2013
|
transcriptMissing: sidecarTranscript === null,
|
|
1929
2014
|
questionsMissing: sidecarQuestions === null,
|
|
1930
2015
|
// Evidence-missing flags: set ONLY when the underlying field is undefined (partial/old result.json),
|