cool-workflow 0.2.4 → 0.2.5

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 (132) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/.gemini-plugin/mcp.json +10 -0
  4. package/.gemini-plugin/plugin.json +40 -0
  5. package/.opencode-plugin/mcp.json +10 -0
  6. package/.opencode-plugin/plugin.json +40 -0
  7. package/README.md +202 -48
  8. package/apps/architecture-review/app.json +1 -1
  9. package/apps/architecture-review-fast/app.json +1 -1
  10. package/apps/end-to-end-golden-path/app.json +1 -1
  11. package/apps/pr-review-fix-ci/app.json +1 -1
  12. package/apps/release-cut/app.json +1 -1
  13. package/apps/research-synthesis/app.json +1 -1
  14. package/dist/cli/dispatch.js +32 -4
  15. package/dist/cli/entry.js +11 -19
  16. package/dist/cli/global-flags.js +113 -0
  17. package/dist/cli/parseargv.js +7 -1
  18. package/dist/core/capability-data.js +337 -8
  19. package/dist/core/format/help.js +73 -3
  20. package/dist/core/format/recovery-hint.js +32 -0
  21. package/dist/core/multi-agent/collaboration.js +35 -6
  22. package/dist/core/multi-agent/runtime.js +7 -0
  23. package/dist/core/multi-agent/trust-policy.js +7 -1
  24. package/dist/core/pipeline/contract.js +7 -0
  25. package/dist/core/pipeline/error-feedback.js +2 -2
  26. package/dist/core/trust/evidence-grounding.js +13 -1
  27. package/dist/core/util/cli-args.js +22 -0
  28. package/dist/core/version.js +1 -1
  29. package/dist/mcp/dispatch.js +22 -2
  30. package/dist/mcp/server.js +124 -13
  31. package/dist/mcp-server.js +20 -0
  32. package/dist/shell/commit.js +8 -2
  33. package/dist/shell/coordinator-io.js +73 -1
  34. package/dist/shell/drive.js +120 -63
  35. package/dist/shell/error-feedback-io.js +6 -0
  36. package/dist/shell/execution-backend/agent.js +195 -23
  37. package/dist/shell/execution-backend/container.js +44 -10
  38. package/dist/shell/execution-backend/local.js +32 -0
  39. package/dist/shell/fs-atomic.js +93 -12
  40. package/dist/shell/ledger-cli.js +9 -2
  41. package/dist/shell/multi-agent-cli.js +5 -1
  42. package/dist/shell/onramp.js +48 -5
  43. package/dist/shell/pipeline.js +2 -1
  44. package/dist/shell/reclamation-io.js +10 -9
  45. package/dist/shell/run-export.js +52 -4
  46. package/dist/shell/run-store.js +156 -0
  47. package/dist/shell/scheduler-io.js +101 -10
  48. package/dist/shell/telemetry-ledger-io.js +36 -24
  49. package/dist/shell/trust-audit.js +104 -10
  50. package/dist/shell/workbench-host.js +121 -10
  51. package/dist/shell/workbench.js +79 -5
  52. package/dist/shell/worker-isolation.js +1 -1
  53. package/dist/wiring/capability-table/basics.js +5 -0
  54. package/dist/wiring/capability-table/exec-backend.js +40 -22
  55. package/dist/wiring/capability-table/pipeline.js +32 -0
  56. package/dist/wiring/capability-table/registry-core.js +26 -3
  57. package/dist/wiring/capability-table/reporting.js +7 -1
  58. package/dist/wiring/capability-table/scheduling-registry.js +8 -2
  59. package/dist/wiring/capability-table/trust-ledger.js +54 -12
  60. package/docs/agent-delegation-drive.7.md +15 -0
  61. package/docs/cli-mcp-parity.7.md +25 -0
  62. package/docs/contract-migration-tooling.7.md +2 -0
  63. package/docs/control-plane-scheduling.7.md +36 -0
  64. package/docs/cross-agent-ledger.7.md +20 -8
  65. package/docs/durable-state-and-locking.7.md +2 -0
  66. package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
  67. package/docs/execution-backends.7.md +2 -0
  68. package/docs/fix.7.md +4 -4
  69. package/docs/getting-started.md +40 -32
  70. package/docs/index.md +17 -0
  71. package/docs/launch/demo.tape +4 -3
  72. package/docs/mcp-app-surface.7.md +6 -0
  73. package/docs/multi-agent-cli-mcp-surface.7.md +2 -0
  74. package/docs/multi-agent-eval-replay-harness.7.md +2 -0
  75. package/docs/multi-agent-operator-ux.7.md +2 -0
  76. package/docs/node-snapshot-diff-replay.7.md +2 -0
  77. package/docs/observability-cost-accounting.7.md +2 -0
  78. package/docs/project-index.md +39 -9
  79. package/docs/real-execution-backends.7.md +2 -0
  80. package/docs/release-and-migration.7.md +2 -0
  81. package/docs/release-tooling.7.md +2 -0
  82. package/docs/routine.7.md +22 -0
  83. package/docs/run-registry-control-plane.7.md +2 -0
  84. package/docs/run-retention-reclamation.7.md +10 -4
  85. package/docs/state-explosion-management.7.md +2 -0
  86. package/docs/team-collaboration.7.md +2 -0
  87. package/docs/trust-audit-anchor.7.md +2 -0
  88. package/docs/web-desktop-workbench.7.md +24 -2
  89. package/docs/workflow-app-framework.7.md +31 -0
  90. package/manifest/plugin.manifest.json +1 -1
  91. package/manifest/source-context-profiles.json +4 -3
  92. package/package.json +4 -2
  93. package/scripts/agents/agent-adapter-core.js +26 -0
  94. package/scripts/agents/claude-p-agent.js +4 -1
  95. package/scripts/agents/codex-agent.js +4 -0
  96. package/scripts/agents/cw-attest-wrap.js +1 -1
  97. package/scripts/agents/gemini-agent.js +4 -0
  98. package/scripts/agents/opencode-agent.js +5 -0
  99. package/scripts/block-unapproved-tag.js +160 -0
  100. package/scripts/canonical-apps.js +4 -4
  101. package/scripts/children/batch-delegate-child.js +72 -4
  102. package/scripts/children/http-batch-delegate-child.js +132 -0
  103. package/scripts/children/http-delegate-child.js +8 -0
  104. package/scripts/dogfood-release.js +2 -2
  105. package/scripts/fake-date-for-reproduction.js +1 -1
  106. package/scripts/golden-path.js +4 -4
  107. package/scripts/lang-policy-check.js +82 -0
  108. package/scripts/purity-gate.js +16 -3
  109. package/scripts/release-check.js +2 -1
  110. package/scripts/release-flow.js +128 -30
  111. package/scripts/release-gate.js +184 -0
  112. package/scripts/release-oneclick.js +38 -7
  113. package/scripts/release-tags.js +47 -0
  114. package/scripts/verdict-keygen.js +2 -2
  115. package/scripts/verify-bump-reproduction.js +193 -0
  116. package/scripts/verify-verdict-signature.js +1 -1
  117. package/ui/workbench/app.css +41 -9
  118. package/ui/workbench/app.js +160 -27
  119. package/ui/workbench/index.html +2 -2
  120. package/docs/agent-framework.md +0 -177
  121. package/docs/designs/handoff-ledger.md +0 -145
  122. package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
  123. package/docs/launch/launch-kit.md +0 -195
  124. package/docs/launch/pre-launch-checklist.md +0 -53
  125. package/docs/readme-v0.1.87-full.md +0 -301
  126. package/docs/routines.md +0 -101
  127. package/docs/scheduled-tasks.md +0 -80
  128. package/scripts/agents/claude-p-agent.sh +0 -9
  129. package/scripts/block-unapproved-tag.sh +0 -75
  130. package/scripts/release-gate.sh +0 -94
  131. package/scripts/verify-bump-reproduction.sh +0 -148
  132. package/scripts/verify-container-selfref.js +0 -64
@@ -5,7 +5,10 @@
5
5
  // MILESTONE 5 (docs/rebuild/PLAN.md build order, step 5). Byte-exact port of
6
6
  // plugins/cool-workflow/src/execution-backend/agent.ts. This module holds
7
7
  // the PURE data-transform helpers (invocation resolution, arg substitution,
8
- // secret redaction, report parsing) plus the batch delegate-child spawn.
8
+ // secret redaction, report parsing) plus the batch delegate-child spawn — both
9
+ // the CLI-binary batch (runAgentBatchOutcomes) and, added later, its
10
+ // HTTP-endpoint sibling (runEndpointBatchOutcomes) so `--concurrency N` gives
11
+ // endpoint-mode agents real concurrency instead of a serial per-task spawn.
9
12
  //
10
13
  // THE RED LINE: CW spawns the agent and records its attested output. It
11
14
  // NEVER imports a model SDK, holds an API key, or constructs a model API
@@ -49,6 +52,7 @@ var __importStar = (this && this.__importStar) || (function () {
49
52
  };
50
53
  })();
51
54
  Object.defineProperty(exports, "__esModule", { value: true });
55
+ exports.reapRecordedVendor = reapRecordedVendor;
52
56
  exports.buildAgentChildEnv = buildAgentChildEnv;
53
57
  exports.resolveAgentInvocation = resolveAgentInvocation;
54
58
  exports.stripSecretArgs = stripSecretArgs;
@@ -59,12 +63,15 @@ exports.recordedAgentHandle = recordedAgentHandle;
59
63
  exports.extractEndpointResult = extractEndpointResult;
60
64
  exports.agentHandle = agentHandle;
61
65
  exports.prepareAgentSpawn = prepareAgentSpawn;
66
+ exports.prepareEndpointJob = prepareEndpointJob;
62
67
  exports.reconcileBatchOutcomes = reconcileBatchOutcomes;
63
68
  exports.runAgentBatchOutcomes = runAgentBatchOutcomes;
69
+ exports.runEndpointBatchOutcomes = runEndpointBatchOutcomes;
64
70
  exports.shouldStreamAgentStderr = shouldStreamAgentStderr;
65
71
  exports.runAgentProcess = runAgentProcess;
66
72
  const fs = __importStar(require("node:fs"));
67
73
  const path = __importStar(require("node:path"));
74
+ const os = __importStar(require("node:os"));
68
75
  const node_child_process_1 = require("node:child_process");
69
76
  const local_1 = require("./local");
70
77
  const envelopes_1 = require("./envelopes");
@@ -85,6 +92,46 @@ const AGENT_PROVIDER_KEY_ENV_RE = /^(CW_|ANTHROPIC_|OPENAI_|GEMINI_|DEEPSEEK_|CO
85
92
  * call it, so the allowlist cannot drift into a second silent copy again.
86
93
  * Returns the forwarded var NAMES too (never values) for the
87
94
  * worker.agent-env trust-audit event. */
95
+ /** A unique sidecar path a shipped wrapper writes its vendor child's PID to
96
+ * (via the env CW_AGENT_VENDOR_PIDFILE we set below), so cw can reap the
97
+ * vendor if it has to SIGKILL the wrapper on a timeout. */
98
+ function vendorPidFilePath() {
99
+ return path.join(os.tmpdir(), `cw-agent-vendor-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.pid`);
100
+ }
101
+ /** Reap the vendor process a shipped wrapper recorded, then remove the sidecar.
102
+ * A wrapper that exited cleanly already removed the file, so this is a no-op
103
+ * then; only a wrapper cw SIGKILLed on a timeout leaves a live vendor PID
104
+ * here. Best-effort and race-tolerant: a missing file, an unparseable/too-low
105
+ * PID, or an already-gone process are all silently fine. Scope: kills the
106
+ * vendor process itself (the token spender), not a deeper grandchild tree,
107
+ * and only for the shipped vendor wrappers (claude, codex, gemini, and
108
+ * opencode/deepseek) -- an arbitrary CW_AGENT_COMMAND records no PID and is
109
+ * not covered. Returns true only if it
110
+ * actually signalled a process. Exported for tests; agent.ts is not part of
111
+ * the package public surface (index.ts). */
112
+ function reapRecordedVendor(pidFile) {
113
+ let raw;
114
+ try {
115
+ raw = fs.readFileSync(pidFile, "utf8");
116
+ }
117
+ catch {
118
+ return false; // no sidecar => the wrapper cleaned up (or never recorded one)
119
+ }
120
+ try {
121
+ fs.unlinkSync(pidFile);
122
+ }
123
+ catch { /* already gone */ }
124
+ const pid = Number(raw.trim());
125
+ if (!Number.isInteger(pid) || pid <= 1)
126
+ return false;
127
+ try {
128
+ process.kill(pid, "SIGKILL");
129
+ return true;
130
+ }
131
+ catch {
132
+ return false; // already exited, or a pid we may not signal
133
+ }
134
+ }
88
135
  function buildAgentChildEnv(policy, baseEnv = process.env) {
89
136
  const env = (0, local_1.buildChildEnv)(policy, baseEnv);
90
137
  // deny must win here too: buildChildEnv already applied it above, but the
@@ -98,6 +145,14 @@ function buildAgentChildEnv(policy, baseEnv = process.env) {
98
145
  for (const key of Object.keys(baseEnv)) {
99
146
  if (denied.has(key))
100
147
  continue;
148
+ // Parent-only CW secrets are never re-added, even though they match the
149
+ // CW_ arm of AGENT_PROVIDER_KEY_ENV_RE and even if the operator forgot to
150
+ // deny them. buildChildEnv already stripped them above; this keeps the
151
+ // re-add loop from putting them back (and out of the trust-audit
152
+ // `forwarded` list). CW_AGENT_ATTEST_PRIVKEY is intentionally NOT in that
153
+ // set, so the attest wrapper still gets its signing key.
154
+ if (local_1.CW_NEVER_FORWARD_ENV.has(key))
155
+ continue;
101
156
  if (AGENT_PROVIDER_KEY_ENV_RE.test(key)) {
102
157
  env[key] = baseEnv[key];
103
158
  forwarded.push(key);
@@ -297,9 +352,9 @@ function agentHandle(request, env = process.env) {
297
352
  },
298
353
  };
299
354
  }
300
- /** Resolve a request to a spawn-style batch job, or undefined when the agent
301
- * is endpoint-configured/unconfigured (those settle through the serial
302
- * path). */
355
+ /** Resolve a request to a spawn-style (CLI-binary) batch job, or undefined when
356
+ * the agent is endpoint-configured (see prepareEndpointJob) or unconfigured
357
+ * (those settle through the serial path). */
303
358
  function prepareAgentSpawn(request) {
304
359
  const resolved = resolveAgentInvocation(request);
305
360
  if (!resolved.binary)
@@ -312,6 +367,27 @@ function prepareAgentSpawn(request) {
312
367
  timeoutMs: resolved.timeoutMs || 600000,
313
368
  };
314
369
  }
370
+ /** Resolve a request to an endpoint (HTTP-delegate) batch job, or undefined when
371
+ * the agent has a CLI binary (see prepareAgentSpawn) or is unconfigured. The
372
+ * POST body mirrors runAgentEndpoint's exactly: {manifest, prompt, model,
373
+ * resultPath, sandboxProfileId} — sandboxProfileId comes from
374
+ * request.sandboxPolicy.id, which runBackend sets to the same policy the serial
375
+ * path passes (registry.ts: `const policy = request.sandboxPolicy`), so the two
376
+ * paths post identical bytes. */
377
+ function prepareEndpointJob(request) {
378
+ const resolved = resolveAgentInvocation(request);
379
+ if (resolved.binary || !resolved.endpoint)
380
+ return undefined;
381
+ const manifest = request.manifest;
382
+ const job = JSON.stringify({
383
+ manifest,
384
+ prompt: manifest?.prompt,
385
+ model: resolved.model,
386
+ resultPath: manifest?.resultPath,
387
+ sandboxProfileId: request.sandboxPolicy.id,
388
+ });
389
+ return { endpoint: resolved.endpoint, job, timeoutMs: resolved.timeoutMs || 600000 };
390
+ }
315
391
  // __dirname is dist/shell/execution-backend at runtime — THREE levels up
316
392
  // (execution-backend -> shell -> dist -> package root) reaches scripts/,
317
393
  // a sibling of dist/, not two (that was a bug: it resolved to a
@@ -376,6 +452,36 @@ function runAgentBatchOutcomes(jobs) {
376
452
  }
377
453
  return reconcileBatchOutcomes(jobs, child);
378
454
  }
455
+ // Same package-root depth fix as BATCH_DELEGATE_CHILD_SCRIPT above.
456
+ const HTTP_BATCH_DELEGATE_CHILD_SCRIPT = path.resolve(__dirname, "..", "..", "..", "scripts", "children", "http-batch-delegate-child.js");
457
+ /** Run a batch of ENDPOINT (HTTP-delegate) jobs concurrently; outcomes
458
+ * index-align with jobs. The endpoint sibling of runAgentBatchOutcomes: one
459
+ * spawnSync of http-batch-delegate-child.js POSTs all N at once from a single
460
+ * process, so `--concurrency N` with an endpoint agent runs N delegations in
461
+ * parallel instead of serially. The child streams the SAME NDJSON line shape as
462
+ * batch-delegate-child.js — `{i, spawnError?, exitCode, stdout}` — so the same
463
+ * reconcileBatchOutcomes reads it (byte-boundary split on a Buffer, index-aligned,
464
+ * every job that produced no full line failed closed). Env is inherited (the
465
+ * serial runAgentEndpoint likewise runs the child with the full process env for
466
+ * any endpoint auth); no sandbox child-env is built here, matching the serial
467
+ * endpoint path. */
468
+ function runEndpointBatchOutcomes(jobs) {
469
+ if (!jobs.length)
470
+ return [];
471
+ const maxTimeout = Math.max(...jobs.map((job) => job.timeoutMs));
472
+ let child;
473
+ try {
474
+ child = (0, node_child_process_1.spawnSync)(process.execPath, [HTTP_BATCH_DELEGATE_CHILD_SCRIPT], {
475
+ input: JSON.stringify(jobs),
476
+ maxBuffer: 34 * 1024 * 1024 * jobs.length,
477
+ timeout: maxTimeout + 30000,
478
+ });
479
+ }
480
+ catch (error) {
481
+ child = { error: error instanceof Error ? error : new Error(String(error)), status: null, stdout: null };
482
+ }
483
+ return reconcileBatchOutcomes(jobs, child);
484
+ }
379
485
  // ---------------------------------------------------------------------------
380
486
  // The stateful agent runners (spawn + settle). Byte-exact port of
381
487
  // src/execution-backend.ts's runAgentProcess (:915-995), runAgentEndpoint
@@ -415,9 +521,15 @@ function runAgentProcess(descriptor, policy, request, label, attestation) {
415
521
  else {
416
522
  const streamStderr = shouldStreamAgentStderr(process.env, Boolean(process.stderr.isTTY));
417
523
  const built = buildAgentChildEnv(policy);
418
- const childEnv = built.env;
419
524
  forwardedEnvVars = built.forwarded;
420
525
  const timeoutMs = resolved.timeoutMs || 600000;
526
+ // A shipped wrapper (claude/codex/gemini) records its vendor child's PID
527
+ // here so we can reap the vendor if the timeout below SIGKILLs the
528
+ // wrapper -- a SIGKILL is uncatchable, so the wrapper cannot forward the
529
+ // stop itself. Not a forwarded secret; it is cw plumbing, so it stays out
530
+ // of `built.forwarded` / the trust-audit forwarded list.
531
+ const vendorPidFile = vendorPidFilePath();
532
+ const childEnv = { ...built.env, CW_AGENT_VENDOR_PIDFILE: vendorPidFile };
421
533
  const child = (0, node_child_process_1.spawnSync)(resolved.binary, realArgs, {
422
534
  cwd: request.cwd,
423
535
  env: childEnv,
@@ -439,12 +551,30 @@ function runAgentProcess(descriptor, policy, request, label, attestation) {
439
551
  // and the null-exit-code "timed out or killed" branch was dead code
440
552
  // for real timeouts.
441
553
  if (child.error && child.error.code === "ETIMEDOUT") {
554
+ // spawnSync SIGKILLed the wrapper (uncatchable, so the wrapper could
555
+ // not forward the stop to its vendor child). Reap the vendor a shipped
556
+ // wrapper recorded, so a timed-out agent's vendor process does not live
557
+ // on as an orphan and keep spending. Scoped: reaps the vendor process
558
+ // itself for the shipped wrappers (claude, codex, gemini, opencode/
559
+ // deepseek), not a deeper grandchild tree nor an arbitrary
560
+ // CW_AGENT_COMMAND (which records no PID). We do
561
+ // NOT use `detached:true` -- that would make the wrapper its own group
562
+ // leader, so an interactive Ctrl-C / a group SIGINT to cw would no
563
+ // longer reach it (strictly worse); reaping from the recorded PID keeps
564
+ // the wrapper in cw's group.
565
+ reapRecordedVendor(vendorPidFile);
442
566
  const handleOut = recordedAgentHandle(resolved.binary, undefined, recordedArgs, resolved.model, "unreported", undefined, undefined, forwardedEnvVars);
443
567
  return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-failed", `agent process timed out after ${timeoutMs}ms and was killed (SIGKILL)`, {
444
568
  ...attestation,
445
569
  handle: handleOut,
446
570
  });
447
571
  }
572
+ // Non-timeout paths: a wrapper that ran to any normal end removed its own
573
+ // sidecar on exit; clear any stray file best-effort so tmp cannot grow.
574
+ try {
575
+ fs.unlinkSync(vendorPidFile);
576
+ }
577
+ catch { /* usually already gone */ }
448
578
  outcome = {
449
579
  ...(child.error ? { spawnError: messageOf(child.error) } : {}),
450
580
  exitCode: typeof child.status === "number" ? child.status : null,
@@ -473,12 +603,70 @@ function runAgentProcess(descriptor, policy, request, label, attestation) {
473
603
  }
474
604
  return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-target-missing", `Backend ${descriptor.id} has no command-template or endpoint configured`, attestation);
475
605
  }
606
+ /** Settle a completed endpoint delegation (a numeric exitCode + stdout) into
607
+ * its envelope: parse the agent report, write result.md as transport when the
608
+ * runner returned a body and none exists yet, record the handle. Shared by the
609
+ * serial spawn path and the concurrent prepared-outcome path so the two never
610
+ * drift. The result-write stays HERE (the cw shell process), never in the batch
611
+ * child, so the child does no filesystem writes into the run dir. */
612
+ function settleEndpointResult(descriptor, label, endpoint, resolvedModel, manifest, attestation, exitCode, stdout) {
613
+ const report = parseAgentReport(stdout);
614
+ if (manifest?.resultPath && report.usage === undefined) {
615
+ const body = extractEndpointResult(stdout);
616
+ if (body && !fs.existsSync(manifest.resultPath)) {
617
+ try {
618
+ fs.writeFileSync(manifest.resultPath, body, "utf8");
619
+ }
620
+ catch {
621
+ /* the accept layer will fail closed on a missing result.md */
622
+ }
623
+ }
624
+ }
625
+ const reportedModel = report.model && report.model.trim() ? report.model.trim() : "unreported";
626
+ const handleOut = recordedAgentHandle(undefined, endpoint, [], resolvedModel, reportedModel, report.usage, report.usageSignature);
627
+ return (0, envelopes_1.delegatedEnvelope)(descriptor, label, handleOut, { ...attestation, handle: handleOut }, "agent-endpoint", [endpoint], exitCode, stdout);
628
+ }
476
629
  /** Agent HTTP endpoint variant — POSTs the worker manifest/prompt to a
477
630
  * configured agent endpoint via the shared Node delegate child. Byte-exact
478
- * port of src/execution-backend.ts:1002-1062. */
631
+ * port of src/execution-backend.ts:1002-1062, plus a prepared-outcome branch:
632
+ * in a concurrent round the POST already ran in the batch child
633
+ * (runEndpointBatchOutcomes), so settle that pre-collected outcome instead of
634
+ * spawning again. The serial (no prepared outcome) path is unchanged. */
479
635
  function runAgentEndpoint(descriptor, policy, request, label, resolved, attestation) {
480
636
  const endpoint = resolved.endpoint;
481
637
  const manifest = request.manifest;
638
+ const baseHandle = recordedAgentHandle(undefined, endpoint, [], resolved.model, "unreported");
639
+ // Concurrent round: the batch child already POSTed and settled this job. Any
640
+ // failure (network, non-2xx, poll, unparseable, timeout, no exitCode) is a
641
+ // spawnError; a success carries a numeric exitCode + stdout.
642
+ if (request.preparedAgentOutcome) {
643
+ const prep = request.preparedAgentOutcome;
644
+ if (prep.spawnError) {
645
+ // Match the SERIAL path's refusal wording so a concurrent round records the
646
+ // same reason bytes as a serial one. A per-job HTTP failure (non-2xx, poll,
647
+ // timeout, no exitCode) is the serial `{error}` branch — `agent endpoint
648
+ // error:`. A whole-batch-child process failure surfaces as
649
+ // reconcileBatchOutcomes' `batch delegate failed:` fallback — the serial
650
+ // analog is `child.error` — `agent endpoint delegation failed:`.
651
+ const summary = prep.spawnError.startsWith("batch delegate failed:")
652
+ ? `agent endpoint delegation failed: ${prep.spawnError}`
653
+ : `agent endpoint error: ${prep.spawnError}`;
654
+ return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-failed", summary, {
655
+ ...attestation,
656
+ handle: baseHandle,
657
+ });
658
+ }
659
+ // Defensive: the batch child always pairs a null exitCode with a spawnError,
660
+ // so this is unreachable in practice — kept as a fail-closed backstop with
661
+ // the same wording the serial no-exitCode refusal uses.
662
+ if (typeof prep.exitCode !== "number") {
663
+ return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-failed", `agent endpoint error: no exitCode reported`, {
664
+ ...attestation,
665
+ handle: baseHandle,
666
+ });
667
+ }
668
+ return settleEndpointResult(descriptor, label, endpoint, resolved.model, manifest, attestation, prep.exitCode, prep.stdout);
669
+ }
482
670
  const job = JSON.stringify({
483
671
  manifest,
484
672
  prompt: manifest?.prompt,
@@ -493,7 +681,6 @@ function runAgentEndpoint(descriptor, policy, request, label, resolved, attestat
493
681
  timeout: resolved.timeoutMs || 600000,
494
682
  maxBuffer: 32 * 1024 * 1024,
495
683
  });
496
- const baseHandle = recordedAgentHandle(undefined, endpoint, [], resolved.model, "unreported");
497
684
  if (child.error) {
498
685
  return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-failed", `agent endpoint delegation failed: ${messageOf(child.error)}`, {
499
686
  ...attestation,
@@ -516,20 +703,5 @@ function runAgentEndpoint(descriptor, policy, request, label, resolved, attestat
516
703
  handle: baseHandle,
517
704
  });
518
705
  }
519
- const stdout = String(parsed.stdout || "");
520
- const report = parseAgentReport(stdout);
521
- if (manifest?.resultPath && report.usage === undefined) {
522
- const body = extractEndpointResult(stdout);
523
- if (body && !fs.existsSync(manifest.resultPath)) {
524
- try {
525
- fs.writeFileSync(manifest.resultPath, body, "utf8");
526
- }
527
- catch {
528
- /* the accept layer will fail closed on a missing result.md */
529
- }
530
- }
531
- }
532
- const reportedModel = report.model && report.model.trim() ? report.model.trim() : "unreported";
533
- const handleOut = recordedAgentHandle(undefined, endpoint, [], resolved.model, reportedModel, report.usage, report.usageSignature);
534
- return (0, envelopes_1.delegatedEnvelope)(descriptor, label, handleOut, { ...attestation, handle: handleOut }, "agent-endpoint", [endpoint], parsed.exitCode, stdout);
706
+ return settleEndpointResult(descriptor, label, endpoint, resolved.model, manifest, attestation, parsed.exitCode, String(parsed.stdout || ""));
535
707
  }
@@ -7,14 +7,49 @@
7
7
  //
8
8
  // Evidence: SPEC/execution-backend.md "container driver (runContainer)".
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.buildContainerEnvArgs = buildContainerEnvArgs;
10
11
  exports.containerHandle = containerHandle;
11
12
  exports.runContainer = runContainer;
12
13
  const node_child_process_1 = require("node:child_process");
13
14
  const probes_1 = require("./probes");
15
+ const local_1 = require("./local");
14
16
  const envelopes_1 = require("./envelopes");
15
17
  function messageOf(error) {
16
18
  return error instanceof Error ? error.message : String(error);
17
19
  }
20
+ /** buildContainerEnvArgs — build the `-e NAME=value` args passed into the
21
+ * container run command. Pulled out as its own pure function so it can be
22
+ * checked without a real docker/podman on the box (see local.ts's
23
+ * buildChildEnv for the same shape of test-only split).
24
+ *
25
+ * deny must win here too: an operator who sets inherit:true plus a deny
26
+ * list (a valid, normalized combination — see sandbox-profile.ts's
27
+ * normalizeEnv) means "everything EXCEPT these". Before this fix, deny was
28
+ * never read here, so a secret like AWS_SECRET_ACCESS_KEY that the operator
29
+ * named in deny still got copied into the container's `-e` args.
30
+ *
31
+ * This backend builds its `-e` args straight from baseEnv rather than through
32
+ * buildChildEnv, so it must apply CW_NEVER_FORWARD_ENV itself — otherwise
33
+ * inherit:true would still copy CW's own parent-only secrets (the release
34
+ * signing key, the workbench token) into the container. */
35
+ function buildContainerEnvArgs(policy, baseEnv = process.env) {
36
+ const args = [];
37
+ if (policy.env.inherit || (policy.env.expose && policy.env.expose.length)) {
38
+ const deny = new Set(policy.env.deny || []);
39
+ for (const name of policy.env.inherit ? Object.keys(baseEnv) : policy.env.expose || []) {
40
+ if (name === "PATH" || name === "HOME")
41
+ continue;
42
+ if (deny.has(name))
43
+ continue;
44
+ if (local_1.CW_NEVER_FORWARD_ENV.has(name))
45
+ continue; // parent-only secrets never enter the container
46
+ const value = baseEnv[name];
47
+ if (value !== undefined)
48
+ args.push("-e", `${name}=${value}`);
49
+ }
50
+ }
51
+ return args;
52
+ }
18
53
  function containerHandle(request, env = process.env) {
19
54
  const delegation = request.delegation || {};
20
55
  const image = delegation.image || (env.CW_CONTAINER_IMAGE || "").trim() || undefined;
@@ -46,20 +81,19 @@ function runContainer(descriptor, policy, request, label, handle, attestation) {
46
81
  if (policy.network.mode !== "any")
47
82
  runArgs.push("--network", "none");
48
83
  runArgs.push("-v", `${cwd}:${cwd}:ro`, "-w", cwd);
49
- if (policy.env.inherit || (policy.env.expose && policy.env.expose.length)) {
50
- for (const name of policy.env.inherit ? Object.keys(process.env) : policy.env.expose || []) {
51
- if (name === "PATH" || name === "HOME")
52
- continue;
53
- const value = process.env[name];
54
- if (value !== undefined)
55
- runArgs.push("-e", `${name}=${value}`);
56
- }
57
- }
84
+ runArgs.push(...buildContainerEnvArgs(policy));
58
85
  runArgs.push(handle.ref, command, ...args);
59
86
  // An unset timeoutMs must not mean "no timeout" — spawnSync would then
60
87
  // block forever on a hung container with no kill path. 600000 matches the
61
88
  // agent backend's own default fallback (execution-backend/agent.ts).
62
- const result = (0, node_child_process_1.spawnSync)(runtime, runArgs, { cwd, encoding: "utf8", timeout: request.timeoutMs || 600000, maxBuffer: 32 * 1024 * 1024 });
89
+ // killSignal SIGKILL, not the default SIGTERM: a container runtime that
90
+ // ignores SIGTERM would leave this blocking spawnSync waiting forever (no
91
+ // second-stage escalation is possible from inside a sync call), so the
92
+ // timeout above would not actually bound anything. SIGKILL is uncatchable,
93
+ // so the runtime always dies and spawnSync returns; a timed-out run is
94
+ // classified the same either way (status null, ETIMEDOUT message does not
95
+ // name the signal, result.signal is never recorded). Mirrors agent.ts.
96
+ const result = (0, node_child_process_1.spawnSync)(runtime, runArgs, { cwd, encoding: "utf8", timeout: request.timeoutMs || 600000, maxBuffer: 32 * 1024 * 1024, killSignal: "SIGKILL" });
63
97
  if (result.error) {
64
98
  return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-failed", `${runtime} run failed: ${messageOf(result.error)}`, attestation);
65
99
  }
@@ -8,6 +8,7 @@
8
8
  //
9
9
  // Evidence: SPEC/execution-backend.md "Local execution (executeLocal)".
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.CW_NEVER_FORWARD_ENV = void 0;
11
12
  exports.buildChildEnv = buildChildEnv;
12
13
  exports.checkShellGuard = checkShellGuard;
13
14
  exports.runtimeNoteFor = runtimeNoteFor;
@@ -18,6 +19,19 @@ const probes_1 = require("./probes");
18
19
  function messageOf(error) {
19
20
  return error instanceof Error ? error.message : String(error);
20
21
  }
22
+ /** CW's OWN secrets that must NEVER reach ANY child process — on any backend
23
+ * (local/container/remote/agent), regardless of policy.env.inherit / expose /
24
+ * deny. Each is read only by parent-side tooling, so no spawned child ever
25
+ * needs it; stripping them here, unconditionally, is a fail-closed backstop
26
+ * that does not depend on an operator remembering to list them in
27
+ * policy.env.deny. Do NOT add the agent-attest private key
28
+ * (CW_AGENT_ATTEST_PRIVKEY) here: the attest wrapper
29
+ * (scripts/agents/cw-attest-wrap.js) is spawned AS the agent and must receive
30
+ * it to sign telemetry. */
31
+ exports.CW_NEVER_FORWARD_ENV = new Set([
32
+ "CW_RELEASE_VERDICT_PRIVKEY", // release verdict signing key — release-flow.js only
33
+ "CW_WORKBENCH_TOKEN", // workbench HTTP bearer token — workbench-host.ts only
34
+ ]);
21
35
  function buildChildEnv(policy, baseEnv = process.env) {
22
36
  // deny must win regardless of inherit: a custom profile combining
23
37
  // inherit:true with deny:[...] (a valid, normalized combination — see
@@ -38,6 +52,10 @@ function buildChildEnv(policy, baseEnv = process.env) {
38
52
  for (const name of policy.env.deny || []) {
39
53
  delete env[name];
40
54
  }
55
+ // Fail-closed backstop: parent-only secrets never cross into a child, even
56
+ // when inherit:true forwarded them above or an operator exposed one by name.
57
+ for (const name of exports.CW_NEVER_FORWARD_ENV)
58
+ delete env[name];
41
59
  return env;
42
60
  }
43
61
  /** Shell injection guard (SPEC/execution-backend.md "Local execution"): for
@@ -75,6 +93,20 @@ function executeLocal(descriptor, request, label, attestation, spawnStyle) {
75
93
  // agent backend's own default fallback (execution-backend/agent.ts).
76
94
  timeout: request.timeoutMs || 600000,
77
95
  maxBuffer: 32 * 1024 * 1024,
96
+ // SIGKILL, not the default SIGTERM: a child that ignores SIGTERM would
97
+ // leave this blocking spawnSync waiting forever (no second-stage
98
+ // escalation is possible from inside a sync call), so the `timeout` above
99
+ // would not actually bound anything. SIGKILL is uncatchable, so the child
100
+ // always dies and spawnSync returns. A timed-out run is classified as
101
+ // failed regardless of the signal (status stays null, the ETIMEDOUT
102
+ // message does not name the signal, and this backend never records
103
+ // result.signal). The exact captured stdout/stderr on a timeout is not
104
+ // guaranteed identical to the old SIGTERM path: a child that catches
105
+ // SIGTERM could flush a last partial write or exit with a code before it
106
+ // dies, while under SIGKILL it cannot — but that output belongs to a run
107
+ // we already treat as failed, so the hard kill loses nothing that counts.
108
+ // Mirrors execution-backend/agent.ts's fix.
109
+ killSignal: "SIGKILL",
78
110
  };
79
111
  if (spawnStyle === "shell") {
80
112
  checkShellGuard(command, args);
@@ -48,6 +48,8 @@ exports.assertSafeRunId = assertSafeRunId;
48
48
  exports.realResolve = realResolve;
49
49
  exports.isContainedPath = isContainedPath;
50
50
  exports.durableAppendFileSync = durableAppendFileSync;
51
+ exports.logEndsWithNewline = logEndsWithNewline;
52
+ exports.nextBackoffMs = nextBackoffMs;
51
53
  exports.withFileLock = withFileLock;
52
54
  const fs = __importStar(require("node:fs"));
53
55
  const path = __importStar(require("node:path"));
@@ -196,18 +198,55 @@ function durableAppendFileSync(file, data) {
196
198
  fs.closeSync(fd);
197
199
  }
198
200
  }
201
+ /** True when `file`'s final byte is "\n", given its already-known `size`.
202
+ * A COMPLETED `durableAppendFileSync` always leaves the file ending in
203
+ * "\n" (every append is `<line>\n`), so a non-newline last byte means the
204
+ * previous append was torn by a crash — its bytes were never a confirmed
205
+ * record. Reads ONLY the last byte at `size-1`, so callers on an append
206
+ * hot path stay O(1) and never re-read the whole file. A read failure
207
+ * returns false (treat as "not newline-terminated") — the safe side: an
208
+ * extra leading newline is harmless for an NDJSON reader that skips blank
209
+ * lines, while a MISSED torn boundary would merge two records into one
210
+ * unparseable line. Shared by trust-audit's events.jsonl and the
211
+ * blackboard's messages.jsonl append paths — same file shape, same
212
+ * torn-tail risk, one implementation. */
213
+ function logEndsWithNewline(file, size) {
214
+ if (size <= 0)
215
+ return false;
216
+ let fd;
217
+ try {
218
+ fd = fs.openSync(file, "r");
219
+ const buf = Buffer.alloc(1);
220
+ fs.readSync(fd, buf, 0, 1, size - 1);
221
+ return buf[0] === 0x0a; // "\n"
222
+ }
223
+ catch {
224
+ return false;
225
+ }
226
+ finally {
227
+ if (fd !== undefined)
228
+ fs.closeSync(fd);
229
+ }
230
+ }
199
231
  // ---------------------------------------------------------------------------
200
232
  // withFileLock — portable advisory cross-process lock.
201
233
  //
202
234
  // Lock file: `<targetPath>.lock`, created by hard-linking a per-attempt temp
203
235
  // file onto the lock path (single-winner `link(2)`), body `"<pid>@<ISO>\n"`.
204
- // Up to 240 tries; on EEXIST, a lock whose mtime is older than
205
- // FILE_LOCK_STALE_MS (30_000) is stolen — judged and deleted only while
206
- // holding the single-winner `<lock>.steal` guard (see
207
- // stealStaleLockUnderGuard) and retried AT ONCE; else sleep 25ms
208
- // (Atomics.wait busy-safe sleep) and retry. Any non-EEXIST link error is
209
- // rethrown. No lock after 240 tries throws
210
- // `could not acquire file lock for <targetPath>`.
236
+ // On EEXIST, a lock whose mtime is older than FILE_LOCK_STALE_MS (30_000) is
237
+ // stolen — judged and deleted only while holding the single-winner
238
+ // `<lock>.steal` guard (see stealStaleLockUnderGuard) — and retried AT ONCE
239
+ // (so is a lock that vanished between the EEXIST and the stat). Otherwise the
240
+ // thread sleeps a short, growing backoff (Atomics.wait busy-safe sleep:
241
+ // FILE_LOCK_BACKOFF_BASE_MS doubling toward FILE_LOCK_BACKOFF_MAX_MS, with
242
+ // jitter so many processes contending on ONE lock do not retry in lock-step)
243
+ // and tries again. The WHOLE acquire is bounded by wall-clock, not a try
244
+ // count: after FILE_LOCK_ACQUIRE_BUDGET_MS (~6s, kept well under the 30s steal
245
+ // window so a fresh orphan lock fails fast rather than being waited out) with
246
+ // no lock, it throws `could not acquire file lock for <targetPath>`. The tiny
247
+ // early sleeps re-grab a briefly-held lock in a few ms; the wall-clock bound
248
+ // (not the try count) is what keeps the worst case at ~6s. Any non-EEXIST
249
+ // link error is rethrown.
211
250
  //
212
251
  // Right before fn() the lock mtime is refreshed (utimesSync, best-effort).
213
252
  // After fn() returns, the lock body is re-read: if it no longer starts with
@@ -227,15 +266,37 @@ const FILE_LOCK_FORCE_STALE_MS = FILE_LOCK_STALE_MS * 10;
227
266
  // guarded window (which is a handful of syscalls, microseconds for any live
228
267
  // process) — remove it so stealing cannot wedge forever.
229
268
  const FILE_LOCK_STEAL_GUARD_STALE_MS = FILE_LOCK_STALE_MS;
269
+ // Acquire-retry pacing (all internal to withFileLock — no wire/protocol
270
+ // meaning). The wall-clock BUDGET, not a fixed try count, bounds how long a
271
+ // contended acquire blocks the calling thread; kept at ~6s so it stays well
272
+ // under the 30s steal window (a fresh orphan lock is failed fast, not waited
273
+ // out — a later command past 30s steals it). Between misses the thread sleeps
274
+ // a backoff that starts at BASE and doubles to MAX, so a briefly-held lock is
275
+ // re-grabbed in a few ms while a genuinely busy one is not hot-spun. Jitter on
276
+ // each sleep (applied at the call site) de-syncs many contenders on one lock.
277
+ // MAX_ATTEMPTS is only a loop-termination backstop for the (never-observed)
278
+ // case of a clock that fails to advance; the budget is the real bound.
279
+ const FILE_LOCK_ACQUIRE_BUDGET_MS = 6_000;
280
+ const FILE_LOCK_BACKOFF_BASE_MS = 1;
281
+ const FILE_LOCK_BACKOFF_MAX_MS = 50;
282
+ const FILE_LOCK_MAX_ATTEMPTS = 10_000;
230
283
  // Lock paths this process holds right now. A nested withFileLock on the
231
284
  // SAME target runs its fn directly (re-entrant) instead of waiting on its
232
- // own lock file until the 240 tries run out — that lets a whole
285
+ // own lock file until the acquire budget runs out — that lets a whole
233
286
  // load -> change -> save cycle hold one lock while the save path inside
234
287
  // it keeps its own withFileLock call unchanged.
235
288
  const HELD_LOCKS = new Set();
236
289
  function sleepSync(ms) {
237
290
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
238
291
  }
292
+ /** Next backoff ceiling (ms) given the previous one: double it, capped at
293
+ * FILE_LOCK_BACKOFF_MAX_MS. Pure and deterministic (the per-sleep jitter is
294
+ * applied separately at the call site), so the retry schedule is
295
+ * unit-testable without any timing. Exported for that test only — fs-atomic
296
+ * is not part of the public index.ts surface. */
297
+ function nextBackoffMs(previous) {
298
+ return Math.min(previous * 2, FILE_LOCK_BACKOFF_MAX_MS);
299
+ }
239
300
  /** Whether the pid recorded in a lock body ("<pid>@<ISO>\n") is still a
240
301
  * live process on this machine. Locks here are local-advisory only (no
241
302
  * network-shared run directories in this tool's model), so
@@ -375,7 +436,9 @@ function withFileLock(targetPath, fn) {
375
436
  fs.mkdirSync(path.dirname(lock), { recursive: true });
376
437
  const pid = String(process.pid);
377
438
  let acquired = false;
378
- for (let attempt = 0; attempt < 240 && !acquired; attempt++) {
439
+ const deadline = Date.now() + FILE_LOCK_ACQUIRE_BUDGET_MS;
440
+ let backoff = FILE_LOCK_BACKOFF_BASE_MS;
441
+ for (let attempt = 0; attempt < FILE_LOCK_MAX_ATTEMPTS && !acquired; attempt++) {
379
442
  // Acquire via `linkSync`, not `open(lock, "wx")`: directly testing the
380
443
  // retry loop under contention showed `open(O_CREAT|O_EXCL)` is NOT
381
444
  // reliably single-winner on every Node version this runs on — under
@@ -421,16 +484,34 @@ function withFileLock(targetPath, fn) {
421
484
  // The steal itself runs under a single-winner guard lock — see
422
485
  // stealStaleLockUnderGuard for why judging staleness and deleting
423
486
  // the lock without one can never be made safe from here.
424
- if (stealStaleLockUnderGuard(lock, pid, attempt))
487
+ if (stealStaleLockUnderGuard(lock, pid, attempt)) {
488
+ // Verdict rendered and the lock likely just freed — retry AT ONCE
489
+ // and reset the backoff so we grab it before another waiter does.
490
+ backoff = FILE_LOCK_BACKOFF_BASE_MS;
425
491
  continue;
492
+ }
426
493
  // Guard was busy — another process is mid-steal. Back off like
427
- // ordinary contention instead of hot-spinning the attempt budget.
494
+ // ordinary contention instead of hot-spinning.
428
495
  }
429
496
  }
430
497
  catch {
498
+ // The lock vanished between the EEXIST and the stat — it is likely free
499
+ // now, so retry AT ONCE (no sleep) with a reset backoff.
500
+ backoff = FILE_LOCK_BACKOFF_BASE_MS;
431
501
  continue;
432
502
  }
433
- sleepSync(25);
503
+ // Give up on wall-clock, never on a raw try count: this keeps the worst-
504
+ // case block at ~6s even though the per-sleep backoff grew. Bounding by a
505
+ // fixed try count instead (as the old flat-25ms loop's 240 tries did)
506
+ // would have let the growing backoff roughly double that worst case.
507
+ if (Date.now() >= deadline)
508
+ break;
509
+ // Sleep a jittered span in [BASE, backoff] — never 0, so never a busy
510
+ // spin — then grow the backoff toward the cap. The jitter de-syncs many
511
+ // processes contending on the SAME lock so they do not retry in lock-step.
512
+ const span = backoff - FILE_LOCK_BACKOFF_BASE_MS;
513
+ sleepSync(FILE_LOCK_BACKOFF_BASE_MS + Math.floor(Math.random() * (span + 1)));
514
+ backoff = nextBackoffMs(backoff);
434
515
  }
435
516
  if (!acquired)
436
517
  throw new Error(`could not acquire file lock for ${targetPath}`);
@@ -188,10 +188,17 @@ function ledgerApplyCli(options) {
188
188
  return (0, ledger_1.applyLedgerProposal)(parsed);
189
189
  }
190
190
  function ledgerListCli(options) {
191
- const dirs = Array.isArray(options.dir) ? options.dir.map(String).filter(Boolean) : [];
191
+ // `--ledger-dir` is the preferred flag: the global CLI front door
192
+ // (cli/entry.ts) treats `--dir` as an alias of `--repo` for EVERY
193
+ // command, so `cw ledger list --dir X` made one flag mean two things.
194
+ // `--dir` keeps working unchanged as the legacy spelling; when both are
195
+ // given, `--ledger-dir` wins. Repeated flags become an array via
196
+ // parseArgv's append behavior, same as `--dir` always has.
197
+ const input = options["ledger-dir"] ?? options.dir;
198
+ const dirs = Array.isArray(input) ? input.map(String).filter(Boolean) : [];
192
199
  if (dirs.length > 1)
193
200
  return (0, ledger_io_1.unionLedgerEntries)(dirs);
194
- const dir = required(dirs[0] || stringOption(options.dir), "--dir <ledger-directory>");
201
+ const dir = required(dirs[0] || stringOption(input), "--ledger-dir <ledger-directory>");
195
202
  return (0, ledger_io_1.listLedgerEntries)(dir);
196
203
  }
197
204
  /** MCP-facing verify/apply take the entry OBJECT directly (not a file/