cowork-harness 0.6.0 → 0.7.1

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.
@@ -21,6 +21,8 @@
21
21
  "subagent_tool_used",
22
22
  "tool_called",
23
23
  "tool_not_called",
24
+ "tool_result_contains",
25
+ "tool_result_not_contains",
24
26
  "transcript_contains",
25
27
  "transcript_matches",
26
28
  "transcript_no_host_path",
@@ -45,6 +45,8 @@ CONTENT_KEYS = {
45
45
  "transcript_not_contains",
46
46
  "transcript_matches",
47
47
  "transcript_not_matches",
48
+ "tool_result_contains",
49
+ "tool_result_not_contains",
48
50
  "tool_called",
49
51
  "tool_not_called",
50
52
  "subagent_tool_used",
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.7.1] — 2026-06-20
10
+
11
+ ### Fixed
12
+
13
+ - **`file_exists` and `user_visible_artifact` now pass for truncated (large) cassette artifacts.**
14
+ A truncated manifest entry (`truncated: true`) carries `path`, `bytes`, and `sha256` — positive
15
+ proof the file existed at record time. 0.7.0 incorrectly failed these existence/promotion
16
+ assertions with `"was truncated in the cassette — content was not committed; assertion cannot
17
+ pass"`, producing false-REDs for any cassette whose artifacts exceeded the 64 KiB inline cap.
18
+ Only **content** assertions (`artifact_json`) require the inlined body; existence assertions
19
+ now correctly pass from the manifest. `artifact_json` on a truncated artifact continues to fail
20
+ (the 0-byte placeholder is not valid JSON). Regression test added.
21
+
22
+ ## [0.7.0] — 2026-06-19
23
+
24
+ ### Added
25
+
26
+ - **`chat --plugin <dir>` (repeatable)** — load additional local plugins into a `chat` session alongside
27
+ the primary skill folder. Each `--plugin <dir>` is appended to `plugins.local_plugins` so multi-plugin
28
+ interactive debugging no longer requires a custom session YAML. In `--raw` mode (native Docker), `--plugin`
29
+ flags are silently ignored with a warning: `chat --raw: --plugin flags are ignored in --raw mode`.
30
+ - **`/help` in the `chat` REPL** — typing `/help` in an interactive session now prints
31
+ `Commands: /exit /quit /help` and continues rather than forwarding the text to the model. The startup
32
+ prompt was updated from "type your message, /exit to quit" to "type your message (/help for commands)".
33
+ - **`scrubField(value, secrets)` exported from `src/secrets.ts`** — a new multi-pass field-level scrubber
34
+ that covers token appearances beyond direct substring matches:
35
+ - Pass 1: direct `scrub()` — catches literal, base64-encoded, and `encodeURIComponent`-encoded tokens.
36
+ - Pass 2: whole-field base64 decode (≥20-char pure base64 strings) — if the decoded form contains a
37
+ secret hit, returns `[REDACTED:base64]`.
38
+ - Pass 3: whole-field URI decode (values containing `%`) — if the decoded form contains a secret hit,
39
+ returns `[REDACTED:uri]`.
40
+ Applied in `cassette.ts` artifact scrubbing: base64 artifacts (`encoding === "base64"`) are replaced
41
+ wholesale with `[REDACTED:base64]`, the encoding marker is cleared (so replay decodes as UTF-8), and the
42
+ sha256 is recomputed over the marker bytes (with a `::warning::` that artifact assertions will fail at
43
+ replay). UTF-8 artifacts pass through `scrubField` safely.
44
+ - **`prompt_asset_missing` VerdictSignal** — `computeVerdict()` now pushes a `{ code: "prompt_asset_missing",
45
+ severity: "warn" }` signal when `result.fidelityWarnings` contains a "referenced asset not found" entry,
46
+ making a missing prompt asset visible in the verdict output rather than buried in the run log.
47
+ - **`onInfraError` callback in `makeWorkspaceHandler`** — an optional sixth parameter
48
+ `onInfraError?: (message: string) => void` lets callers intercept infrastructure errors
49
+ (ETIMEDOUT / killed / no code+stdout+stderr) separately from model-visible error text.
50
+ `spawnHostLoop()` wires this to append `{ type: "infra_error", ts, message }` to `events.jsonl` so
51
+ infrastructure failures are structured and queryable rather than only appearing in the model-visible
52
+ response string.
53
+
54
+ ### Fixed
55
+
56
+ - **`ExternalDecider` no longer coerces `"first"` to option 1.** `coerceLabel` gained an
57
+ `enableFirstShorthand` parameter (default `true`). External deciders (`--decider-cmd`,
58
+ `--decider-dir` helpers) now call `coerceLabel(raw, labels, false)`, so a helper script that
59
+ accidentally returns the string `"first"` must match an actual label named `"first"` — it is no
60
+ longer silently promoted to the first option. The shorthand remains active for internal (scripted)
61
+ use.
62
+ - **`flagValue` and `chat --model` reject empty strings.** `flagValue()` in `src/cli.ts` now exits `2`
63
+ with a clear message when the supplied value is blank or whitespace-only. `chat` additionally guards
64
+ the `--model` value inline after parsing, so `--model ""` and `--model $UNSET_VAR` both fail loudly
65
+ instead of passing an empty model ID to the runtime.
66
+ - **`redactCassette` skips `[REDACTED*]` marker bodies.** The per-line JSON redaction pass
67
+ (`redactJsonLine`) is now bypassed for artifact bodies that already start with `[REDACTED` — preventing
68
+ the sha256 from being corrupted by a second redaction pass over the marker string.
69
+ - **TLD list extended from 22 to 51 entries.** The domain scanner in `src/scan.ts` now recognises
70
+ major European, Asian, and Latin American ccTLDs:
71
+ `ch|nl|se|no|it|jp|br|nz|in|sg|kr|mx|es|pt|pl|be|at|dk|fi|ie|ru|cn|tw|hu|cz|ro|il|za|ar|cl|pe|tr`.
72
+ Domain findings that were previously missed on these TLDs now fire correctly.
73
+
9
74
  ## [0.6.0] — 2026-06-19
10
75
 
11
76
  ### Breaking changes
package/README.md CHANGED
@@ -24,6 +24,15 @@ Scriptable, CI-friendly test harness that reproduces **Claude Cowork's observabl
24
24
 
25
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.
26
26
 
27
+ **Zero-friction preview — no token, no Docker.** A committed cassette replays on a fresh clone:
28
+
29
+ ```bash
30
+ npm install -g cowork-harness
31
+ cowork-harness replay examples/replays/example-pdf-skill.cassette.json
32
+ ```
33
+
34
+ Full setup → [Quick start](#quick-start).
35
+
27
36
  ---
28
37
 
29
38
  ## Why this works for skill testing
@@ -226,6 +235,9 @@ cowork-harness replay --cassette examples/replays/example-pdf-skill.cassette.jso
226
235
 
227
236
  # Cassettes are COMMITTED fixtures — record against synthetic data, and gate them in CI:
228
237
  cowork-harness verify-cassettes examples/replays/ # privacy scan (email/currency/domain) + staleness; exit 1 on a finding
238
+
239
+ # 5. Lint scenarios before committing (catches silent false-greens in assertion placement)
240
+ cowork-harness lint scenarios/*.yaml
229
241
  ```
230
242
 
231
243
  > **Privacy:** a cassette snapshots the transcript and the `outputs/` JSON bodies, so it's committed PII
@@ -162,6 +162,10 @@ export class LiveAgentSession {
162
162
  rejectError;
163
163
  /** Bounded tail of the child's stderr, for the nonzero-exit error message. */
164
164
  stderrTail = "";
165
+ writeQueue = [];
166
+ pumping = false;
167
+ queueIdle;
168
+ closing = false;
165
169
  constructor(proc, outDir) {
166
170
  this.proc = proc;
167
171
  this.outDir = outDir;
@@ -180,7 +184,7 @@ export class LiveAgentSession {
180
184
  if (this.rejectError)
181
185
  this.rejectError(e);
182
186
  // else: the error fired before/after the generator — log it but don't throw
183
- else
187
+ else if (!this.closing)
184
188
  this.events.write(JSON.stringify({ _emu: "stdin_error", message: String(e) }) + "\n");
185
189
  });
186
190
  }
@@ -214,7 +218,8 @@ export class LiveAgentSession {
214
218
  // below rejects and the generator yields a typed {type:"error"} event instead of hanging on
215
219
  // stdout that will never arrive.
216
220
  this.proc.on("error", (e) => {
217
- this.events.write(JSON.stringify({ _emu: "spawn_error", message: String(e) }) + "\n");
221
+ if (!this.closing)
222
+ this.events.write(JSON.stringify({ _emu: "spawn_error", message: String(e) }) + "\n");
218
223
  if (this.rejectError)
219
224
  this.rejectError(e instanceof Error ? e : new Error(String(e)));
220
225
  });
@@ -238,7 +243,8 @@ export class LiveAgentSession {
238
243
  const line = next.value;
239
244
  if (!line.trim())
240
245
  continue;
241
- this.events.write(line + "\n");
246
+ if (!this.closing)
247
+ this.events.write(line + "\n");
242
248
  let msg;
243
249
  try {
244
250
  msg = JSON.parse(line);
@@ -266,6 +272,8 @@ export class LiveAgentSession {
266
272
  }
267
273
  finally {
268
274
  this.rejectError = undefined; // generator is done; stop routing errors here
275
+ this.closing = true; // discard any late write() from translate()'s async hook/mcp paths
276
+ await this.drainAll(); // queue empty + all callbacks confirmed before ending either stream
269
277
  // #46: AWAIT the stream flush before the generator resolves. executeScenario reads/scans/scrubs
270
278
  // events.jsonl + control-out.jsonl immediately after `drive()` returns; a fire-and-forget end()
271
279
  // races the final buffered writes. end(cb) fires the callback on 'finish' (fully flushed).
@@ -344,6 +352,9 @@ export class LiveAgentSession {
344
352
  if (req.kind !== r.kind)
345
353
  warn(`::warning:: decider returned kind "${r.kind}" for a "${req.kind}" request (id ${decisionId}) → sending a safe deny/cancel; the agent did NOT receive an answer\n`);
346
354
  this.write(serializeDecision(req, r));
355
+ // Invariant: each decision id is answered at most once. Delete after a successful write so
356
+ // stale entries don't accumulate (live sessions may process thousands of decisions per run).
357
+ this.reqById.delete(decisionId);
347
358
  }
348
359
  close() {
349
360
  try {
@@ -355,26 +366,99 @@ export class LiveAgentSession {
355
366
  }
356
367
  write(obj) {
357
368
  const line = JSON.stringify(obj);
358
- // The control protocol writes small single-line JSON frames, so stdin backpressure effectively never
359
- // engages; we ignore the write() return / drain here. A frame past the safe threshold is anomalous —
360
- // hard-FAIL rather than risk a partially-buffered write that silently corrupts the protocol stream.
361
- // (If large control frames ever become legitimate, switch to a drain-aware queue, making writes async.)
362
- if (line.length > 256 * 1024)
363
- throw new Error(`control frame is ${line.length} bytes (> 256 KiB safe limit) — refusing to write to avoid partial stdin buffering; this indicates an unexpectedly large control payload`);
369
+ if (this.closing)
370
+ return; // session draining — discard silently
364
371
  // Bug 34: check stream writability before writing — a closed/destroyed stdin after a child crash
365
372
  // loses decision frames silently. Throw immediately so callers surface the failure rather than
366
373
  // hanging or silently dropping frames.
367
374
  if (this.proc.stdin.destroyed || !this.proc.stdin.writable)
368
375
  throw new Error("control-protocol write failed: child stdin is no longer writable (process may have crashed)");
369
- this.controlOut.write(line + "\n");
370
- // Attach a one-shot error handler on the write so stream errors surface via rejectError rather
371
- // than as unhandled 'error' events (the permanent 'error' handler on constructor covers stdin
372
- // errors that fire outside a write, e.g. EPIPE after a crash).
373
- this.proc.stdin.write(line + "\n", (err) => {
374
- if (err && this.rejectError)
376
+ this.writeQueue.push(line + "\n");
377
+ void this.pump().catch((err) => {
378
+ // Route unexpected pump errors through rejectError so they surface as a typed error + clean
379
+ // teardown instead of a silent hang. Known failure modes call rejectError themselves before
380
+ // the pump returns, so this only fires on unanticipated throws.
381
+ if (this.rejectError)
375
382
  this.rejectError(err instanceof Error ? err : new Error(String(err)));
376
383
  });
377
384
  }
385
+ async pump() {
386
+ if (this.pumping)
387
+ return;
388
+ this.pumping = true;
389
+ try {
390
+ while (this.writeQueue.length) {
391
+ const frame = this.writeQueue.shift();
392
+ if (this.proc.stdin.destroyed || !this.proc.stdin.writable) {
393
+ this.events.write(JSON.stringify({ _emu: "control_undelivered", frame }) + "\n");
394
+ if (this.rejectError)
395
+ this.rejectError(new Error("stdin no longer writable while draining queue"));
396
+ while (this.writeQueue.length) {
397
+ const remaining = this.writeQueue.shift();
398
+ this.events.write(JSON.stringify({ _emu: "control_undelivered", frame: remaining }) + "\n");
399
+ }
400
+ break;
401
+ }
402
+ // Await the write callback directly — the ONLY reliable signal for child-process stdin
403
+ // pipes (EPIPE goes to the callback, not an 'error' event; 'drain'/'close' are unreliable).
404
+ // Awaiting inline also bounds in-flight depth to 1, giving deterministic replay ordering.
405
+ const writeErr = await new Promise((resolve) => {
406
+ try {
407
+ this.proc.stdin.write(frame, (err) => {
408
+ resolve(err ?? null);
409
+ });
410
+ }
411
+ catch (e) {
412
+ resolve(e instanceof Error ? e : new Error(String(e)));
413
+ }
414
+ });
415
+ if (writeErr) {
416
+ this.events.write(JSON.stringify({ _emu: "control_undelivered", frame }) + "\n");
417
+ if (this.rejectError)
418
+ this.rejectError(writeErr);
419
+ while (this.writeQueue.length) {
420
+ const remaining = this.writeQueue.shift();
421
+ this.events.write(JSON.stringify({ _emu: "control_undelivered", frame: remaining }) + "\n");
422
+ }
423
+ break;
424
+ }
425
+ else {
426
+ if (frame.length > 256 * 1024) {
427
+ // Frame too large to mirror verbatim — record a truncation marker instead.
428
+ this.controlOut.write(JSON.stringify({ _emu: "control_out_truncated", bytes: frame.length, head: frame.slice(0, 4096) }) + "\n");
429
+ }
430
+ else {
431
+ this.controlOut.write(frame);
432
+ }
433
+ }
434
+ }
435
+ }
436
+ finally {
437
+ this.pumping = false;
438
+ // Unconditionally drain any remaining frames — handles a synchronous throw from
439
+ // controlOut.write/events.write inside the try body, which would otherwise leave
440
+ // writeQueue non-empty and queueIdle unfired, hanging drainAll().
441
+ while (this.writeQueue.length) {
442
+ const remaining = this.writeQueue.shift();
443
+ try {
444
+ this.events.write(JSON.stringify({ _emu: "control_undelivered", frame: remaining }) + "\n");
445
+ }
446
+ catch {
447
+ /* events may already be ended; discard silently */
448
+ }
449
+ }
450
+ const idle = this.queueIdle;
451
+ this.queueIdle = undefined;
452
+ idle?.();
453
+ }
454
+ }
455
+ drainAll() {
456
+ if (!this.pumping && this.writeQueue.length === 0)
457
+ return Promise.resolve();
458
+ return new Promise((resolve) => {
459
+ this.queueIdle = resolve;
460
+ });
461
+ }
378
462
  }
379
463
  /** Pure translation of one parsed stream-json message → AgentEvents (no side-effects). Shared by
380
464
  * LiveAgentSession and CassetteAgentSession. mcp_message is handled by Live before this is called. */
@@ -396,8 +480,13 @@ export function parseMessage(msg) {
396
480
  break;
397
481
  }
398
482
  case "assistant": {
399
- const parentToolUseId = msg.parent_tool_use_id ? String(msg.parent_tool_use_id) : undefined;
483
+ // Protocol v1: parentToolUseId is message-level. Block-level parent_tool_use_id (if present)
484
+ // is canonical — prefer it when both exist (block-level is more precise for nested dispatches).
485
+ const msgParentToolUseId = msg.parent_tool_use_id ? String(msg.parent_tool_use_id) : undefined;
486
+ let blockIndex = 0;
400
487
  for (const block of msg.message?.content ?? []) {
488
+ // Block-level parent wins over message-level when both are present (see comment above).
489
+ const parentToolUseId = block.parent_tool_use_id ? String(block.parent_tool_use_id) : msgParentToolUseId;
401
490
  if (block.type === "text")
402
491
  ev.push({ type: "assistant_text", text: block.text, parentToolUseId });
403
492
  else if (block.type === "thinking")
@@ -422,9 +511,12 @@ export function parseMessage(msg) {
422
511
  : Array.isArray(inp.allowedTools)
423
512
  ? inp.allowedTools.map(String)
424
513
  : []; // the `Agent` tool declares no tools list → []; declared-but-unused is legacy-`Task`-only
514
+ // When block.id is absent, synthesize a fallback to avoid collapsing all anonymous
515
+ // dispatches into the empty-string identity, which breaks dispatch tracking.
516
+ const toolUseId = block.id ? String(block.id) : `unpaired-${blockIndex}`;
425
517
  ev.push({
426
518
  type: "subagent_dispatch",
427
- toolUseId: String(block.id ?? ""),
519
+ toolUseId,
428
520
  parentToolUseId,
429
521
  // Skills often dispatch with only {description, prompt} (no subagent_type) → agentType is
430
522
  // "unknown" but the description still identifies the dispatch (e.g. "TOP_DOWN market sizing").
@@ -434,6 +526,7 @@ export function parseMessage(msg) {
434
526
  });
435
527
  }
436
528
  }
529
+ blockIndex++;
437
530
  }
438
531
  break;
439
532
  }
@@ -449,6 +542,7 @@ export function parseMessage(msg) {
449
542
  isError: !!block.is_error,
450
543
  text: toolResultText(block.content),
451
544
  provenanceText: toolResultRaw(block.content),
545
+ assertText: toolResultAssertText(block.content),
452
546
  });
453
547
  }
454
548
  break;
@@ -472,7 +566,11 @@ function flattenToolResult(content, max) {
472
566
  if (b && typeof b === "object" && "text" in b)
473
567
  return String(b.text);
474
568
  // Non-text blocks (json, resource, link, unknown): JSON-stringify to preserve all content.
475
- return JSON.stringify(b);
569
+ // JSON.stringify returns `undefined` for blocks that contain functions or other
570
+ // non-serializable values; wrap those in an explicit marker so downstream code can identify
571
+ // and skip them rather than treating the literal string "undefined" as block content.
572
+ const serialized = JSON.stringify(b);
573
+ return serialized !== undefined ? serialized : JSON.stringify({ type: "unsupported", raw: "[unserializable]" });
476
574
  })
477
575
  .join(" ")
478
576
  .slice(0, max);
@@ -486,8 +584,19 @@ function toolResultText(content) {
486
584
  function toolResultRaw(content) {
487
585
  return flattenToolResult(content, 200_000);
488
586
  }
587
+ /** Assertion-fidelity cap — enough to cover realistic tool outputs for content assertions without
588
+ * blowing up result.json; deliberately larger than the 500-char display cap so text past the display
589
+ * truncation is still assertable. */
590
+ function toolResultAssertText(content) {
591
+ return flattenToolResult(content, 10_240);
592
+ }
489
593
  export function toDecisionRequest(msg) {
490
594
  const sub = msg.request?.subtype;
595
+ // Validate request_id early — every DecisionRequest.id is a non-empty string. A missing or
596
+ // non-string request_id means the agent sent a malformed control_request; reject loudly rather
597
+ // than silently building a DecisionRequest with an unusable id (respond() would always miss it).
598
+ if (typeof msg.request_id !== "string" || msg.request_id === "")
599
+ throw new Error(`control-in: malformed request_id: ${JSON.stringify(msg.request_id)}`);
491
600
  const id = msg.request_id;
492
601
  if (sub === "can_use_tool") {
493
602
  const tool = msg.request.tool_name ?? "";