cool-workflow 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/apps/architecture-review/app.json +1 -1
- package/apps/architecture-review-fast/app.json +1 -1
- package/apps/end-to-end-golden-path/app.json +1 -1
- package/apps/pr-review-fix-ci/app.json +1 -1
- package/apps/release-cut/app.json +1 -1
- package/apps/research-synthesis/app.json +1 -1
- package/dist/cli/dispatch.js +13 -6
- package/dist/cli/entry.js +41 -1
- package/dist/cli/io.js +12 -22
- package/dist/cli/parseargv.js +1 -0
- package/dist/core/capability-data.js +3 -2
- package/dist/core/format/completion.js +68 -0
- package/dist/core/format/help.js +31 -6
- package/dist/core/format/safe-json.js +73 -0
- package/dist/core/format/state-explosion-text.js +1 -1
- package/dist/core/multi-agent/candidate-scoring.js +5 -1
- package/dist/core/multi-agent/collaboration.js +3 -3
- package/dist/core/multi-agent/coordinator.js +5 -5
- package/dist/core/multi-agent/runtime.js +4 -4
- package/dist/core/multi-agent/topology.js +3 -3
- package/dist/core/pipeline/dispatch.js +18 -4
- package/dist/core/pipeline/drive-decide.js +2 -1
- package/dist/core/state/migrations.js +16 -1
- package/dist/core/state/state-explosion/digest.js +13 -13
- package/dist/core/state/state-explosion/graph.js +17 -11
- package/dist/core/state/state-explosion/report.js +6 -6
- package/dist/core/util/cli-args.js +33 -0
- package/dist/core/util/numeric-flag.js +40 -0
- package/dist/core/version.js +1 -1
- package/dist/mcp/server.js +99 -11
- package/dist/shell/audit-cli.js +57 -25
- package/dist/shell/coordinator-io.js +73 -13
- package/dist/shell/dispatch.js +1 -1
- package/dist/shell/doctor.js +80 -1
- package/dist/shell/drive.js +251 -49
- package/dist/shell/eval-text.js +2 -2
- package/dist/shell/evidence-reasoning.js +4 -4
- package/dist/shell/execution-backend/agent.js +30 -2
- package/dist/shell/execution-backend/container.js +4 -1
- package/dist/shell/execution-backend/local.js +19 -11
- package/dist/shell/feedback-cli.js +6 -6
- package/dist/shell/fs-atomic.js +218 -29
- package/dist/shell/man-cli.js +6 -0
- package/dist/shell/metrics-cli.js +2 -1
- package/dist/shell/multi-agent-cli.js +367 -323
- package/dist/shell/multi-agent-host.js +9 -9
- package/dist/shell/multi-agent-operator-ux.js +80 -38
- package/dist/shell/node-store.js +10 -4
- package/dist/shell/observability.js +1 -1
- package/dist/shell/operator-ux-text.js +22 -22
- package/dist/shell/operator-ux.js +15 -14
- package/dist/shell/orchestrator.js +49 -38
- package/dist/shell/pipeline-cli.js +107 -42
- package/dist/shell/reclamation-io.js +88 -10
- package/dist/shell/registry-cli.js +23 -17
- package/dist/shell/remote-source.js +13 -8
- package/dist/shell/report-cli.js +45 -0
- package/dist/shell/report.js +2 -1
- package/dist/shell/run-registry-io.js +78 -19
- package/dist/shell/run-store.js +74 -2
- package/dist/shell/scheduling-io.js +12 -13
- package/dist/shell/state-cli.js +2 -7
- package/dist/shell/state-explosion-cli.js +17 -9
- package/dist/shell/topology-io.js +36 -5
- package/dist/shell/trust-audit.js +224 -22
- package/dist/shell/trust-policy-io.js +1 -1
- package/dist/shell/worker-cli.js +35 -31
- package/dist/shell/workflow-app-loader.js +67 -1
- package/dist/wiring/capability-table/basics.js +33 -8
- package/dist/wiring/capability-table/exec-backend.js +28 -19
- package/dist/wiring/capability-table/multi-agent.js +187 -180
- package/dist/wiring/capability-table/parity.js +1 -0
- package/dist/wiring/capability-table/pipeline.js +64 -52
- package/dist/wiring/capability-table/registry-core.js +30 -11
- package/dist/wiring/capability-table/reporting.js +147 -106
- package/dist/wiring/capability-table/scheduling-registry.js +201 -167
- package/dist/wiring/capability-table/state.js +59 -51
- package/dist/wiring/capability-table/trust-ledger.js +52 -28
- package/dist/wiring/capability-table/workflow-apps.js +85 -70
- package/docs/agent-delegation-drive.7.md +4 -0
- package/docs/candidate-scoring.7.md +1 -1
- package/docs/canonical-workflow-apps.7.md +7 -7
- package/docs/cli-mcp-parity.7.md +26 -16
- package/docs/contract-migration-tooling.7.md +4 -0
- package/docs/control-plane-scheduling.7.md +4 -0
- package/docs/coordinator-blackboard.7.md +17 -17
- package/docs/dogfood-one-real-repo.7.md +11 -11
- package/docs/durable-state-and-locking.7.md +4 -0
- package/docs/end-to-end-golden-path.7.md +14 -14
- package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
- package/docs/execution-backends.7.md +4 -0
- package/docs/getting-started.md +37 -37
- package/docs/multi-agent-cli-mcp-surface.7.md +21 -17
- package/docs/multi-agent-eval-replay-harness.7.md +17 -13
- package/docs/multi-agent-operator-ux.7.md +23 -19
- package/docs/multi-agent-runtime-core.7.md +22 -22
- package/docs/multi-agent-topologies.7.md +6 -6
- package/docs/multi-agent-trust-policy-audit.7.md +11 -11
- package/docs/node-snapshot-diff-replay.7.md +4 -0
- package/docs/observability-cost-accounting.7.md +4 -0
- package/docs/operator-ux.7.md +34 -34
- package/docs/pipeline-runner.7.md +4 -4
- package/docs/project-index.md +31 -4
- package/docs/real-execution-backends.7.md +4 -0
- package/docs/release-and-migration.7.md +5 -1
- package/docs/release-tooling.7.md +22 -0
- package/docs/routines.md +4 -4
- package/docs/run-registry-control-plane.7.md +23 -19
- package/docs/run-retention-reclamation.7.md +24 -3
- package/docs/scheduled-tasks.md +14 -14
- package/docs/security-trust-hardening.7.md +15 -15
- package/docs/state-explosion-management.7.md +14 -10
- package/docs/team-collaboration.7.md +4 -0
- package/docs/trust-audit-anchor.7.md +4 -0
- package/docs/unix-principles.md +3 -1
- package/docs/verifier-gated-commit.7.md +1 -1
- package/docs/web-desktop-workbench.7.md +4 -0
- package/docs/workflow-app-framework.7.md +13 -13
- package/manifest/plugin.manifest.json +1 -1
- package/package.json +3 -2
- package/scripts/agents/claude-p-agent.js +2 -2
- package/scripts/block-unapproved-tag.sh +42 -6
- package/scripts/bump-version.js +27 -10
- package/scripts/canonical-apps.js +4 -4
- package/scripts/children/batch-delegate-child.js +52 -2
- package/scripts/dogfood-release.js +1 -1
- package/scripts/fake-date-for-reproduction.js +44 -0
- package/scripts/golden-path.js +4 -4
- package/scripts/purity-baseline.json +0 -27
- package/scripts/release-flow.js +194 -13
- package/scripts/release-oneclick.js +407 -0
- package/scripts/verdict-keygen.js +83 -0
- package/scripts/verify-bump-reproduction.sh +148 -0
- package/scripts/verify-verdict-signature.js +61 -0
- package/scripts/version-sync-check.js +39 -22
- package/skills/cool-workflow/SKILL.md +9 -9
- package/skills/cool-workflow/references/commands.md +89 -88
- package/ui/workbench/app.css +37 -1
- package/ui/workbench/app.js +124 -6
|
@@ -52,6 +52,8 @@ exports.recordResultRun = recordResultRun;
|
|
|
52
52
|
exports.commitRun = commitRun;
|
|
53
53
|
const fs = __importStar(require("node:fs"));
|
|
54
54
|
const path = __importStar(require("node:path"));
|
|
55
|
+
const readlinePromises = __importStar(require("node:readline/promises"));
|
|
56
|
+
const numeric_flag_1 = require("../core/util/numeric-flag");
|
|
55
57
|
const pipeline_1 = require("./pipeline");
|
|
56
58
|
const workflow_app_loader_1 = require("./workflow-app-loader");
|
|
57
59
|
const drive_1 = require("./drive");
|
|
@@ -157,6 +159,24 @@ function planInputsFor(args) {
|
|
|
157
159
|
function invocationCwd(args) {
|
|
158
160
|
return typeof args.cwd === "string" && args.cwd.trim() ? path.resolve(args.cwd) : process.cwd();
|
|
159
161
|
}
|
|
162
|
+
/** Same as invocationCwd, but ALSO falls back to `--repo` before
|
|
163
|
+
* process.cwd() -- for resolving an EXISTING run's directory specifically
|
|
164
|
+
* (loadRunFromCwd's target), never for a fresh plan()'s repo input (see
|
|
165
|
+
* planRun's own comment on why THAT path deliberately never auto-fills
|
|
166
|
+
* repo from cwd). A caller resuming a run they didn't originally start
|
|
167
|
+
* from this exact directory (the common case: `cw run resume <id>
|
|
168
|
+
* --drive` or `cw quickstart <app> --resume --run <id>` typed from
|
|
169
|
+
* wherever the terminal happens to be, not necessarily the run's own
|
|
170
|
+
* --repo) must not have that already-known fact silently discarded in
|
|
171
|
+
* favor of process.cwd() -- this mirrors the --preview branch's own
|
|
172
|
+
* repoCwd fallback a few lines below, which already gets this right. */
|
|
173
|
+
function existingRunCwd(args) {
|
|
174
|
+
if (typeof args.cwd === "string" && args.cwd.trim())
|
|
175
|
+
return path.resolve(args.cwd);
|
|
176
|
+
if (typeof args.repo === "string" && args.repo.trim())
|
|
177
|
+
return path.resolve(args.repo);
|
|
178
|
+
return process.cwd();
|
|
179
|
+
}
|
|
160
180
|
/** True for a value that counts as "not supplied" — byte-exact to the old
|
|
161
181
|
* build's cli-options.isMissing (undefined / null / empty string). */
|
|
162
182
|
function isMissingInput(value) {
|
|
@@ -223,8 +243,11 @@ function runDrivePreview(args) {
|
|
|
223
243
|
return (0, drive_1.drivePreview)(runId, cwd, args);
|
|
224
244
|
}
|
|
225
245
|
/** `cw run <app|--run id> --drive [--once]` — plans a fresh run (unless
|
|
226
|
-
* `--run` continues an existing one) and drives it.
|
|
227
|
-
|
|
246
|
+
* `--run` continues an existing one) and drives it. Uses driveAsync (not
|
|
247
|
+
* the plain synchronous drive()) so a live multi-round run actually
|
|
248
|
+
* responds to Ctrl-C/SIGTERM instead of silently ignoring it — see
|
|
249
|
+
* shell/drive.ts's driveAsync doc comment. */
|
|
250
|
+
async function runDriveStep(args) {
|
|
228
251
|
const existingRunId = String(args.runId || args.run || "");
|
|
229
252
|
const options = {
|
|
230
253
|
once: Boolean(args.once),
|
|
@@ -234,9 +257,9 @@ function runDriveStep(args) {
|
|
|
234
257
|
incremental: Boolean(args.incremental),
|
|
235
258
|
};
|
|
236
259
|
if (existingRunId) {
|
|
237
|
-
const cwd =
|
|
260
|
+
const cwd = existingRunCwd(args);
|
|
238
261
|
const run = (0, run_store_1.loadRunFromCwd)(existingRunId, cwd);
|
|
239
|
-
return (0, drive_1.
|
|
262
|
+
return (0, drive_1.driveAsync)(existingRunId, run.cwd, options);
|
|
240
263
|
}
|
|
241
264
|
const appId = String(args.appId || args.app || args.positionalApp || "");
|
|
242
265
|
if (!appId)
|
|
@@ -245,7 +268,7 @@ function runDriveStep(args) {
|
|
|
245
268
|
args.repo = invocationCwd(args);
|
|
246
269
|
const app = (0, workflow_app_loader_1.loadWorkflowApp)(appId);
|
|
247
270
|
const run = (0, pipeline_1.plan)(app, planInputsFor(args));
|
|
248
|
-
return (0, drive_1.
|
|
271
|
+
return (0, drive_1.driveAsync)(run.id, run.cwd, options);
|
|
249
272
|
}
|
|
250
273
|
/** `cw quickstart [app] --check` — read-only preflight: does the app
|
|
251
274
|
* resolve, is the repo readable/writable, is a question set, is an
|
|
@@ -385,6 +408,21 @@ function remoteQuickstartCheck(appId, args, candidate) {
|
|
|
385
408
|
const nextCommand = `cw quickstart ${shellWord(appId)} --link ${shellWord(validation.url)}${question ? ` --question ${shellWord(question)}` : ""}`;
|
|
386
409
|
return { schemaVersion: 1, mode: "check", ok, appId, repo: validation.url, checks, nextCommand };
|
|
387
410
|
}
|
|
411
|
+
/** Prompts `Question: ` on STDERR (stdout stays data-only, matching the
|
|
412
|
+
* Rule of Silence) and reads one line from stdin. Returns the trimmed
|
|
413
|
+
* answer, or `undefined` for a blank line. Callers must only invoke this
|
|
414
|
+
* when `process.stdin.isTTY` — it would otherwise block forever on a
|
|
415
|
+
* piped/CI invocation. */
|
|
416
|
+
async function promptForQuestion() {
|
|
417
|
+
const rl = readlinePromises.createInterface({ input: process.stdin, output: process.stderr });
|
|
418
|
+
try {
|
|
419
|
+
const answer = await rl.question("Question: ");
|
|
420
|
+
return answer.trim() || undefined;
|
|
421
|
+
}
|
|
422
|
+
finally {
|
|
423
|
+
rl.close();
|
|
424
|
+
}
|
|
425
|
+
}
|
|
388
426
|
/** `cw quickstart [app] --question ...` — composes plan -> runDrive ->
|
|
389
427
|
* report in one call. Default app is architecture-review. `--check` is a
|
|
390
428
|
* read-only preflight that never plans/drives/writes (see
|
|
@@ -392,7 +430,7 @@ function remoteQuickstartCheck(appId, args, candidate) {
|
|
|
392
430
|
* projection (never drives), `--resume` advances one step (no --run) or
|
|
393
431
|
* continues a named run to completion (--run <id>) — both ported byte-for-
|
|
394
432
|
* byte from the old build's src/capability-core.ts quickstart(). */
|
|
395
|
-
function quickstartRun(args) {
|
|
433
|
+
async function quickstartRun(args) {
|
|
396
434
|
const appId = String(args.appId || args.app || args.workflowId || QUICKSTART_DEFAULT_APP);
|
|
397
435
|
// Remote source: a `--link <url>` — or a URL passed to `--repo`/`-dir` — is
|
|
398
436
|
// materialized to a LOCAL checkout HERE (capability/shell layer). Cloning is
|
|
@@ -406,6 +444,37 @@ function quickstartRun(args) {
|
|
|
406
444
|
args.repo = invocationCwd(args);
|
|
407
445
|
if (Boolean(args.check))
|
|
408
446
|
return quickstartCheck(appId, args, remoteCandidate);
|
|
447
|
+
// `--resume`: a discoverability flag over the existing continuation. With no
|
|
448
|
+
// `--run`, advance exactly ONE step (reuse the `--once` path) and print a
|
|
449
|
+
// copy-pasteable continue line; with `--run <id>`, continue that run to
|
|
450
|
+
// completion (the default drive). It adds no new execution path. Byte-exact to
|
|
451
|
+
// the old build's src/capability-core.ts quickstart(). Hoisted above the TTY
|
|
452
|
+
// prompt below: `existingRunId` is the one fact (mirrored at both the
|
|
453
|
+
// `--preview` branch's `if (!previewRunId)` and the main path's `if
|
|
454
|
+
// (existingRunId) {...} else { run = plan(...) }`) that decides whether THIS
|
|
455
|
+
// invocation plans a fresh run at all — an existing run never needs a
|
|
456
|
+
// question, no matter which mode (--preview/--resume/plain) is asking for it.
|
|
457
|
+
const resume = Boolean(args.resume);
|
|
458
|
+
const existingRunId = String(args.runId || args.run || "");
|
|
459
|
+
const resumeRunId = resume && existingRunId ? existingRunId : undefined;
|
|
460
|
+
// On an interactive TTY with no --question, ask for one instead of
|
|
461
|
+
// failing closed with "Missing required input: question" (byte-behavior
|
|
462
|
+
// port of the old build's TTY prompt — SPEC/cli-surface.md:34,502). A
|
|
463
|
+
// blank/empty answer leaves `question` unset, same as never passing
|
|
464
|
+
// --question at all — plan()'s own required-input check still fails
|
|
465
|
+
// closed for an app that declares `question` required. Never fires
|
|
466
|
+
// under --check (returned above; --check reports a missing question as
|
|
467
|
+
// a preflight issue, not something to prompt for), off a TTY (a piped
|
|
468
|
+
// invocation must never block on stdin), or when `existingRunId` is set
|
|
469
|
+
// (a `--preview --run <id>` or `--resume --run <id>` continues an
|
|
470
|
+
// already-planned run and never calls `plan()` again, so it never
|
|
471
|
+
// needs a question — prompting there would block on stdin for no
|
|
472
|
+
// reason; a review on this cycle caught exactly that regression).
|
|
473
|
+
if (process.stdin.isTTY && !existingRunId && !(typeof args.question === "string" && args.question.trim())) {
|
|
474
|
+
const answer = await promptForQuestion();
|
|
475
|
+
if (answer)
|
|
476
|
+
args.question = answer;
|
|
477
|
+
}
|
|
409
478
|
// Materialize the remote NOW — after `--check` (never fetches) and before any
|
|
410
479
|
// plan/drive — so the core only ever sees the local checkout. Fails closed: a
|
|
411
480
|
// bad URL / blocked scheme / missing git / fetch failure throws here.
|
|
@@ -424,14 +493,6 @@ function quickstartRun(args) {
|
|
|
424
493
|
if (remoteSource.ref)
|
|
425
494
|
args.sourceRef = remoteSource.ref;
|
|
426
495
|
}
|
|
427
|
-
// `--resume`: a discoverability flag over the existing continuation. With no
|
|
428
|
-
// `--run`, advance exactly ONE step (reuse the `--once` path) and print a
|
|
429
|
-
// copy-pasteable continue line; with `--run <id>`, continue that run to
|
|
430
|
-
// completion (the default drive). It adds no new execution path. Byte-exact to
|
|
431
|
-
// the old build's src/capability-core.ts quickstart().
|
|
432
|
-
const resume = Boolean(args.resume);
|
|
433
|
-
const existingRunId = String(args.runId || args.run || "");
|
|
434
|
-
const resumeRunId = resume && existingRunId ? existingRunId : undefined;
|
|
435
496
|
// `--preview`: read-only, deterministic next-step projection (no spawn, no
|
|
436
497
|
// commit). Plan a fresh run (the read-only first verb) then project the next
|
|
437
498
|
// drive step. Never drives.
|
|
@@ -459,12 +520,12 @@ function quickstartRun(args) {
|
|
|
459
520
|
};
|
|
460
521
|
let run;
|
|
461
522
|
if (existingRunId) {
|
|
462
|
-
run = (0, run_store_1.loadRunFromCwd)(existingRunId,
|
|
523
|
+
run = (0, run_store_1.loadRunFromCwd)(existingRunId, existingRunCwd(args));
|
|
463
524
|
}
|
|
464
525
|
else {
|
|
465
526
|
run = (0, pipeline_1.plan)(resolveWorkflowAppForPlan(appId), planInputsFor(args));
|
|
466
527
|
}
|
|
467
|
-
const result = (0, drive_1.
|
|
528
|
+
const result = await (0, drive_1.driveAsync)(run.id, run.cwd, options);
|
|
468
529
|
const finalRun = (0, run_store_1.loadRunFromCwd)(run.id, run.cwd);
|
|
469
530
|
(0, report_1.writeReport)(finalRun);
|
|
470
531
|
// Tamper-evident provenance: bind the remote origin (url@sha) into the run's
|
|
@@ -560,7 +621,7 @@ function dispatchRun(args) {
|
|
|
560
621
|
const v = args[kebab] ?? args[camel];
|
|
561
622
|
return typeof v === "string" && v.trim() ? v : undefined;
|
|
562
623
|
};
|
|
563
|
-
const manifest = (0, dispatch_1.createDispatchManifest)(run,
|
|
624
|
+
const manifest = (0, dispatch_1.createDispatchManifest)(run, (0, numeric_flag_1.requiredNumberFlag)(args.limit, "--limit"), {
|
|
564
625
|
sandboxProfileId: typeof args.sandbox === "string" ? args.sandbox : undefined,
|
|
565
626
|
sandbox: typeof args.sandbox === "string" ? args.sandbox : undefined,
|
|
566
627
|
backendId: typeof args.backend === "string" ? args.backend : undefined,
|
|
@@ -649,29 +710,33 @@ function recordResultRun(args) {
|
|
|
649
710
|
* still leaves the run's report/state current on disk. */
|
|
650
711
|
function commitRun(args) {
|
|
651
712
|
const runId = String(args.runId);
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
const
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
713
|
+
// Hold the state.json lock across the whole load -> commit -> save (both the
|
|
714
|
+
// success and the fail-closed catch persist) so a concurrent run mutation
|
|
715
|
+
// cannot drop this commit (lost-update class, matching dispatchRun/recordResultRun).
|
|
716
|
+
return (0, run_store_1.withRunStateLock)(runId, invocationCwd(args), (run) => {
|
|
717
|
+
const allowCheckpoint = Boolean(args.allowUnverifiedCheckpoint || args["allow-unverified-checkpoint"]);
|
|
718
|
+
const hasGateOption = Boolean(args.verifier || args.verifierNode || args["verifier-node"] || args.candidate || args.selection);
|
|
719
|
+
try {
|
|
720
|
+
const commit = (0, commit_1.commitState)(run, {
|
|
721
|
+
reason: typeof args.reason === "string" && args.reason ? args.reason : "manual",
|
|
722
|
+
verifierNodeId: (typeof args.verifier === "string" && args.verifier) ||
|
|
723
|
+
(typeof args.verifierNode === "string" && args.verifierNode) ||
|
|
724
|
+
(typeof args["verifier-node"] === "string" && args["verifier-node"]) ||
|
|
725
|
+
undefined,
|
|
726
|
+
candidateId: typeof args.candidate === "string" ? args.candidate : undefined,
|
|
727
|
+
selectionId: typeof args.selection === "string" ? args.selection : undefined,
|
|
728
|
+
verifierGated: hasGateOption || !allowCheckpoint,
|
|
729
|
+
allowUnverifiedCheckpoint: allowCheckpoint,
|
|
730
|
+
source: "cli",
|
|
731
|
+
});
|
|
732
|
+
(0, report_1.writeReport)(run);
|
|
733
|
+
(0, run_store_1.saveCheckpoint)(run);
|
|
734
|
+
return { runId: run.id, commit };
|
|
735
|
+
}
|
|
736
|
+
catch (error) {
|
|
737
|
+
(0, report_1.writeReport)(run);
|
|
738
|
+
(0, run_store_1.saveCheckpoint)(run);
|
|
739
|
+
throw error;
|
|
740
|
+
}
|
|
741
|
+
});
|
|
677
742
|
}
|
|
@@ -472,6 +472,31 @@ function planReclamation(run, policy = {}) {
|
|
|
472
472
|
}
|
|
473
473
|
}
|
|
474
474
|
}
|
|
475
|
+
// (3) Superseded, non-verifier-gated commit snapshots. Each commitState()
|
|
476
|
+
// call embeds the FULL run into commits/<id>.json, so these grow without
|
|
477
|
+
// bound (both in count and per-file size) with no reclamation path today.
|
|
478
|
+
// Only the run's LATEST commit and any verifier-gated commit (the actual
|
|
479
|
+
// audit-significant milestones) are kept — an intermediate, non-gated
|
|
480
|
+
// "checkpoint" commit's only value is as a point-in-time snapshot, and
|
|
481
|
+
// state.json (not commits/) is the source of truth for resume. Not
|
|
482
|
+
// reconstructable (no recipe): a commit snapshot is a genuine
|
|
483
|
+
// point-in-time capture, not a projection derivable from retained data.
|
|
484
|
+
let reclaimedCommitSnapshot = false;
|
|
485
|
+
if (!policy.keepCommits) {
|
|
486
|
+
const commits = run.commits || [];
|
|
487
|
+
for (let i = 0; i < commits.length - 1; i++) {
|
|
488
|
+
const commit = commits[i];
|
|
489
|
+
if (commit.verifierGated)
|
|
490
|
+
continue;
|
|
491
|
+
if (!commit.snapshotPath || !fs.existsSync(commit.snapshotPath))
|
|
492
|
+
continue;
|
|
493
|
+
const bytes = dirBytes(commit.snapshotPath);
|
|
494
|
+
if (bytes <= 0)
|
|
495
|
+
continue;
|
|
496
|
+
freeable.push({ path: rel(commit.snapshotPath), absPath: commit.snapshotPath, kind: "commit-snapshot", bytes });
|
|
497
|
+
reclaimedCommitSnapshot = true;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
475
500
|
// Determinism (HARD constraint): sort by path BEFORE anything hashes it,
|
|
476
501
|
// so tombstoneHash is reproducible across hosts regardless of
|
|
477
502
|
// fs.readdirSync's filesystem-dependent order.
|
|
@@ -484,7 +509,15 @@ function planReclamation(run, policy = {}) {
|
|
|
484
509
|
}
|
|
485
510
|
let capability = "re-runnable";
|
|
486
511
|
let capabilityReason = "scratch-only-reclaimed";
|
|
487
|
-
|
|
512
|
+
// A reclaimed commit snapshot is never reconstructable (a genuine
|
|
513
|
+
// point-in-time capture, no recipe) — it caps capability at "verify-only"
|
|
514
|
+
// regardless of what node-snapshot reclamation achieved, same as an
|
|
515
|
+
// unreconstructable node snapshot would.
|
|
516
|
+
if (reclaimedCommitSnapshot) {
|
|
517
|
+
capability = "verify-only";
|
|
518
|
+
capabilityReason = "snapshot-reclaimed-no-reconstruction";
|
|
519
|
+
}
|
|
520
|
+
else if (reclaimedSnapshot && reconstructableSnapshot) {
|
|
488
521
|
capability = "re-runnable-by-reconstruction";
|
|
489
522
|
capabilityReason = "inputs-and-expectdigest-retained";
|
|
490
523
|
}
|
|
@@ -595,13 +628,22 @@ function commitTombstone(run, tombstone) {
|
|
|
595
628
|
function prepareFree(run, tombstone) {
|
|
596
629
|
const runDir = run.paths.runDir;
|
|
597
630
|
const scratchDirs = tombstone.freed.filter((f) => f.kind === "scratch").map((f) => (0, fs_atomic_1.realResolve)(path.join(runDir, f.path)));
|
|
598
|
-
|
|
631
|
+
const commitSnapshotPaths = tombstone.freed.filter((f) => f.kind === "commit-snapshot").map((f) => (0, fs_atomic_1.realResolve)(path.join(runDir, f.path)));
|
|
632
|
+
if (!scratchDirs.length && !commitSnapshotPaths.length)
|
|
599
633
|
return;
|
|
600
634
|
const repointed = new Set();
|
|
601
635
|
for (const scratchDir of scratchDirs) {
|
|
602
636
|
for (const id of repointResultNodeArtifacts(run, scratchDir))
|
|
603
637
|
repointed.add(id);
|
|
604
638
|
}
|
|
639
|
+
// Unlike scratch (which has a retained "result" artifact to repoint to),
|
|
640
|
+
// a reclaimed commit snapshot has no surviving alternative — its OWN
|
|
641
|
+
// StateNode's "snapshot" artifact (recordCommitNode, shell/commit.ts) is
|
|
642
|
+
// the only reference to it, so it is stripped outright rather than
|
|
643
|
+
// repointed. node.outputs.snapshotPath (a plain metadata string, not an
|
|
644
|
+
// artifact the check below inspects) is left as a historical record,
|
|
645
|
+
// same as commit.snapshotPath itself staying in state.json.
|
|
646
|
+
stripCommitSnapshotArtifacts(run, commitSnapshotPaths);
|
|
605
647
|
persistRunDurable(run);
|
|
606
648
|
for (const node of run.nodes || []) {
|
|
607
649
|
for (const artifact of node.artifacts || []) {
|
|
@@ -613,6 +655,9 @@ function prepareFree(run, tombstone) {
|
|
|
613
655
|
throw new ReclamationError("repoint-incomplete", `node ${node.id} artifact ${artifact.id} still references freed scratch path ${artifact.path}`, { nodeId: node.id, artifactId: artifact.id, path: artifact.path });
|
|
614
656
|
}
|
|
615
657
|
}
|
|
658
|
+
if (commitSnapshotPaths.includes(resolved)) {
|
|
659
|
+
throw new ReclamationError("repoint-incomplete", `node ${node.id} artifact ${artifact.id} still references freed commit snapshot ${artifact.path}`, { nodeId: node.id, artifactId: artifact.id, path: artifact.path });
|
|
660
|
+
}
|
|
616
661
|
}
|
|
617
662
|
}
|
|
618
663
|
for (const nodeId of repointed) {
|
|
@@ -639,6 +684,16 @@ function freeBulk(run, tombstone) {
|
|
|
639
684
|
let freedBytes = 0;
|
|
640
685
|
for (const entry of tombstone.freed) {
|
|
641
686
|
const abs = path.join(runDir, entry.path);
|
|
687
|
+
// planReclamation always derives entry.path as a relative path already
|
|
688
|
+
// confined under runDir, but a tampered/imported state.json could carry a
|
|
689
|
+
// worker/artifact path that resolves outside it — re-check containment
|
|
690
|
+
// right before the recursive delete so that can never turn into an
|
|
691
|
+
// out-of-tree rmSync.
|
|
692
|
+
if (!(0, fs_atomic_1.isContainedPath)(abs, runDir)) {
|
|
693
|
+
throw new ReclamationError("unsafe-free-path", `refusing to free path outside the run directory: ${entry.path}`, {
|
|
694
|
+
path: entry.path,
|
|
695
|
+
});
|
|
696
|
+
}
|
|
642
697
|
const before = dirBytes(abs);
|
|
643
698
|
fs.rmSync(abs, { recursive: true, force: true });
|
|
644
699
|
freedBytes += before;
|
|
@@ -672,6 +727,28 @@ function repointResultNodeArtifacts(run, freedScratchDir) {
|
|
|
672
727
|
}
|
|
673
728
|
return changedIds;
|
|
674
729
|
}
|
|
730
|
+
/** Removes the "snapshot" artifact entry from any node that references one
|
|
731
|
+
* of `freedCommitSnapshotPaths` — there is no retained alternative to
|
|
732
|
+
* repoint to (unlike a scratch dir's "result" copy), so the reference is
|
|
733
|
+
* dropped outright. StateArtifact.path is a required string, so the
|
|
734
|
+
* artifact entry is filtered out rather than nulled. */
|
|
735
|
+
function stripCommitSnapshotArtifacts(run, freedCommitSnapshotPaths) {
|
|
736
|
+
if (!freedCommitSnapshotPaths.length)
|
|
737
|
+
return [];
|
|
738
|
+
const freedSet = new Set(freedCommitSnapshotPaths);
|
|
739
|
+
const changedIds = [];
|
|
740
|
+
for (const node of run.nodes || []) {
|
|
741
|
+
if (!node.artifacts || !node.artifacts.length)
|
|
742
|
+
continue;
|
|
743
|
+
const before = node.artifacts.length;
|
|
744
|
+
node.artifacts = node.artifacts.filter((artifact) => !artifact.path || !freedSet.has((0, fs_atomic_1.realResolve)(artifact.path)));
|
|
745
|
+
if (node.artifacts.length !== before) {
|
|
746
|
+
node.updatedAt = new Date().toISOString();
|
|
747
|
+
changedIds.push(node.id);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return changedIds;
|
|
751
|
+
}
|
|
675
752
|
/** Execute the write-ahead, fail-closed reclamation transaction. */
|
|
676
753
|
function runReclamation(run, options = {}) {
|
|
677
754
|
const skeleton = extractSkeleton(run);
|
|
@@ -817,7 +894,7 @@ function gcPlan(host, options = {}) {
|
|
|
817
894
|
let plan;
|
|
818
895
|
try {
|
|
819
896
|
const run = host.loadRun(record.repo, record.runId);
|
|
820
|
-
plan = planReclamation(run, { keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots });
|
|
897
|
+
plan = planReclamation(run, { keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots, keepCommits: policy.keepCommits });
|
|
821
898
|
}
|
|
822
899
|
catch {
|
|
823
900
|
entries.push({
|
|
@@ -861,13 +938,14 @@ function gcPlan(host, options = {}) {
|
|
|
861
938
|
reclaimAfterArchiveDays: policy.reclaimAfterArchiveDays ?? 0,
|
|
862
939
|
keepSnapshots: Boolean(policy.keepSnapshots),
|
|
863
940
|
keepScratch: Boolean(policy.keepScratch),
|
|
941
|
+
keepCommits: Boolean(policy.keepCommits),
|
|
864
942
|
reclaimStates: policy.reclaimStates && policy.reclaimStates.length ? policy.reclaimStates : ["completed", "failed"],
|
|
865
943
|
},
|
|
866
944
|
total: entries.length,
|
|
867
945
|
eligibleCount,
|
|
868
946
|
bytesToFree,
|
|
869
947
|
entries,
|
|
870
|
-
nextAction: eligibleCount ? "
|
|
948
|
+
nextAction: eligibleCount ? "cw gc run" : "cw run search",
|
|
871
949
|
};
|
|
872
950
|
}
|
|
873
951
|
function gcRun(host, options = {}) {
|
|
@@ -901,8 +979,8 @@ function gcRun(host, options = {}) {
|
|
|
901
979
|
const result = runReclamation(run, {
|
|
902
980
|
now: nowIso,
|
|
903
981
|
actor: options.actor,
|
|
904
|
-
policy: { reclaimAfterArchiveDays: policy.reclaimAfterArchiveDays, keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots },
|
|
905
|
-
reclaimPolicy: { keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots },
|
|
982
|
+
policy: { reclaimAfterArchiveDays: policy.reclaimAfterArchiveDays, keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots, keepCommits: policy.keepCommits },
|
|
983
|
+
reclaimPolicy: { keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots, keepCommits: policy.keepCommits },
|
|
906
984
|
});
|
|
907
985
|
reclaimed.push({
|
|
908
986
|
runId: record.runId,
|
|
@@ -936,7 +1014,7 @@ function gcRun(host, options = {}) {
|
|
|
936
1014
|
reclaimed,
|
|
937
1015
|
refused,
|
|
938
1016
|
totalBytesFreed,
|
|
939
|
-
nextAction: reclaimed.length ? "
|
|
1017
|
+
nextAction: reclaimed.length ? "cw gc verify <run-id>" : "cw gc plan",
|
|
940
1018
|
};
|
|
941
1019
|
}
|
|
942
1020
|
function gcVerify(host, runId, options = {}) {
|
|
@@ -952,7 +1030,7 @@ function gcVerify(host, runId, options = {}) {
|
|
|
952
1030
|
capability: "re-runnable",
|
|
953
1031
|
chainLength: 0,
|
|
954
1032
|
checks: [{ name: "located", pass: false, code: "not-reclaimed", detail: "run source not found" }],
|
|
955
|
-
nextAction: "
|
|
1033
|
+
nextAction: "cw registry refresh" + (scope === "home" ? " --scope home" : ""),
|
|
956
1034
|
};
|
|
957
1035
|
}
|
|
958
1036
|
const run = host.loadRun(located.record.repo, runId);
|
|
@@ -985,7 +1063,7 @@ function gcVerify(host, runId, options = {}) {
|
|
|
985
1063
|
tombstoneHash: last?.tombstoneHash,
|
|
986
1064
|
chainLength: result.tombstones.length,
|
|
987
1065
|
checks,
|
|
988
|
-
nextAction: verified ? "
|
|
1066
|
+
nextAction: verified ? "cw run show " + runId : "cw gc plan",
|
|
989
1067
|
};
|
|
990
1068
|
}
|
|
991
1069
|
// ---------------------------------------------------------------------------
|
|
@@ -994,7 +1072,7 @@ function gcVerify(host, runId, options = {}) {
|
|
|
994
1072
|
function formatGcPlan(result) {
|
|
995
1073
|
const lines = [
|
|
996
1074
|
`GC Plan (${result.scope}): ${result.eligibleCount}/${result.total} eligible, ${result.bytesToFree} byte(s) would be freed [DRY-RUN, frees nothing]`,
|
|
997
|
-
` policy: reclaimAfterArchiveDays=${result.policy.reclaimAfterArchiveDays} keepScratch=${result.policy.keepScratch} keepSnapshots=${result.policy.keepSnapshots}`,
|
|
1075
|
+
` policy: reclaimAfterArchiveDays=${result.policy.reclaimAfterArchiveDays} keepScratch=${result.policy.keepScratch} keepSnapshots=${result.policy.keepSnapshots} keepCommits=${result.policy.keepCommits}`,
|
|
998
1076
|
];
|
|
999
1077
|
for (const entry of result.entries) {
|
|
1000
1078
|
if (entry.eligible) {
|
|
@@ -82,6 +82,7 @@ exports.clonesListCli = clonesListCli;
|
|
|
82
82
|
exports.clonesGcCli = clonesGcCli;
|
|
83
83
|
const fs = __importStar(require("node:fs"));
|
|
84
84
|
const path = __importStar(require("node:path"));
|
|
85
|
+
const numeric_flag_1 = require("../core/util/numeric-flag");
|
|
85
86
|
const run_registry_io_1 = require("./run-registry-io");
|
|
86
87
|
const pipeline_1 = require("./pipeline");
|
|
87
88
|
const workflow_app_loader_1 = require("./workflow-app-loader");
|
|
@@ -186,16 +187,16 @@ function runSearchCli(options = {}) {
|
|
|
186
187
|
since: optionalString(options.since),
|
|
187
188
|
until: optionalString(options.until),
|
|
188
189
|
includeArchived: options.includeArchived === undefined ? undefined : Boolean(options.includeArchived),
|
|
189
|
-
limit:
|
|
190
|
-
offset:
|
|
190
|
+
limit: (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit"),
|
|
191
|
+
offset: (0, numeric_flag_1.requiredNumberFlag)(options.offset, "--offset"),
|
|
191
192
|
});
|
|
192
193
|
}
|
|
193
194
|
function runListCli(options = {}) {
|
|
194
195
|
return new run_registry_io_1.RunRegistry(resolveCwd(options)).list({
|
|
195
196
|
scope: scopeOf(options, "home"),
|
|
196
197
|
includeArchived: options.includeArchived === undefined ? undefined : Boolean(options.includeArchived),
|
|
197
|
-
limit:
|
|
198
|
-
offset:
|
|
198
|
+
limit: (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit"),
|
|
199
|
+
offset: (0, numeric_flag_1.requiredNumberFlag)(options.offset, "--offset"),
|
|
199
200
|
});
|
|
200
201
|
}
|
|
201
202
|
function runShowCli(runId, options = {}) {
|
|
@@ -205,21 +206,21 @@ function runShowCli(runId, options = {}) {
|
|
|
205
206
|
* is read-only and byte-identical to the registry resume payload; with
|
|
206
207
|
* `--drive`/`--once` the SAME run (nothing re-planned) is handed to the
|
|
207
208
|
* real drive loop and the payload gains a `drive: DriveResult` field. */
|
|
208
|
-
function runResumeCli(runId, options = {}) {
|
|
209
|
+
async function runResumeCli(runId, options = {}) {
|
|
209
210
|
const base = new run_registry_io_1.RunRegistry(resolveCwd(options)).resume(runId, {
|
|
210
211
|
scope: scopeOf(options, "home"),
|
|
211
|
-
limit:
|
|
212
|
+
limit: (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit"),
|
|
212
213
|
});
|
|
213
214
|
if (!options.drive && !options.once)
|
|
214
215
|
return base;
|
|
215
|
-
const drive = (0, pipeline_cli_1.runDriveStep)({ ...options, runId: base.runId, repo: base.repo, once: Boolean(options.once) });
|
|
216
|
+
const drive = await (0, pipeline_cli_1.runDriveStep)({ ...options, runId: base.runId, repo: base.repo, once: Boolean(options.once) });
|
|
216
217
|
return { ...base, drive };
|
|
217
218
|
}
|
|
218
219
|
function runArchiveCli(runId, options = {}) {
|
|
219
220
|
const registry = new run_registry_io_1.RunRegistry(resolveCwd(options));
|
|
220
221
|
if (!runId) {
|
|
221
|
-
const olderThanDays =
|
|
222
|
-
if (
|
|
222
|
+
const olderThanDays = (0, numeric_flag_1.requiredNumberFlag)(options.olderThanDays ?? options["older-than-days"], "--older-than-days");
|
|
223
|
+
if (olderThanDays === undefined)
|
|
223
224
|
throw new Error("Missing run id (or --older-than-days N for the retention policy path).");
|
|
224
225
|
const states = Array.isArray(options.state) ? options.state : options.state ? [options.state] : undefined;
|
|
225
226
|
return registry.archiveByPolicy({
|
|
@@ -253,8 +254,8 @@ function historyCli(options = {}) {
|
|
|
253
254
|
scope: scopeOf(options, "home"),
|
|
254
255
|
app: optionalString(options.app),
|
|
255
256
|
status: optionalString(options.status),
|
|
256
|
-
limit:
|
|
257
|
-
offset:
|
|
257
|
+
limit: (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit"),
|
|
258
|
+
offset: (0, numeric_flag_1.requiredNumberFlag)(options.offset, "--offset"),
|
|
258
259
|
});
|
|
259
260
|
}
|
|
260
261
|
function queueAddCli(options = {}) {
|
|
@@ -263,7 +264,7 @@ function queueAddCli(options = {}) {
|
|
|
263
264
|
appId: optionalString(options.app || options.appId),
|
|
264
265
|
workflowId: optionalString(options.workflow || options.workflowId),
|
|
265
266
|
repo: optionalString(options.repo),
|
|
266
|
-
priority:
|
|
267
|
+
priority: (0, numeric_flag_1.requiredNumberFlag)(options.priority, "--priority"),
|
|
267
268
|
note: optionalString(options.note),
|
|
268
269
|
id: optionalString(options.id),
|
|
269
270
|
});
|
|
@@ -276,7 +277,7 @@ function queueShowCli(id, options = {}) {
|
|
|
276
277
|
}
|
|
277
278
|
function queueDrainCli(options = {}) {
|
|
278
279
|
return new run_registry_io_1.RunRegistry(resolveCwd(options)).queueDrain({
|
|
279
|
-
limit:
|
|
280
|
+
limit: (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit"),
|
|
280
281
|
repo: optionalString(options.repo),
|
|
281
282
|
});
|
|
282
283
|
}
|
|
@@ -285,8 +286,9 @@ function queueDrainCli(options = {}) {
|
|
|
285
286
|
// ---------------------------------------------------------------------
|
|
286
287
|
function gcPolicyOverridesFrom(options) {
|
|
287
288
|
const overrides = {};
|
|
288
|
-
|
|
289
|
-
|
|
289
|
+
const reclaimAfterArchiveDays = (0, numeric_flag_1.requiredNumberFlag)(options.reclaimAfterArchiveDays, "--reclaimAfterArchiveDays");
|
|
290
|
+
if (reclaimAfterArchiveDays !== undefined)
|
|
291
|
+
overrides.reclaimAfterArchiveDays = reclaimAfterArchiveDays;
|
|
290
292
|
if (options.keepScratch !== undefined)
|
|
291
293
|
overrides.keepScratch = Boolean(options.keepScratch);
|
|
292
294
|
if (options["keep-scratch"] !== undefined)
|
|
@@ -295,6 +297,10 @@ function gcPolicyOverridesFrom(options) {
|
|
|
295
297
|
overrides.keepSnapshots = Boolean(options.keepSnapshots);
|
|
296
298
|
if (options["keep-snapshots"] !== undefined)
|
|
297
299
|
overrides.keepSnapshots = Boolean(options["keep-snapshots"]);
|
|
300
|
+
if (options.keepCommits !== undefined)
|
|
301
|
+
overrides.keepCommits = Boolean(options.keepCommits);
|
|
302
|
+
if (options["keep-commits"] !== undefined)
|
|
303
|
+
overrides.keepCommits = Boolean(options["keep-commits"]);
|
|
298
304
|
if (options.state !== undefined)
|
|
299
305
|
overrides.reclaimStates = Array.isArray(options.state) ? options.state : [options.state];
|
|
300
306
|
return overrides;
|
|
@@ -316,7 +322,7 @@ function gcRunCli(runId, options = {}) {
|
|
|
316
322
|
policy: gcPolicyOverridesFrom(options),
|
|
317
323
|
now: optionalString(options.now),
|
|
318
324
|
actor: optionalString(options.actor),
|
|
319
|
-
limit:
|
|
325
|
+
limit: (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit"),
|
|
320
326
|
});
|
|
321
327
|
}
|
|
322
328
|
function gcVerifyCli(runId, options = {}) {
|
|
@@ -331,7 +337,7 @@ function orphansGcCli(options = {}) {
|
|
|
331
337
|
const registry = new run_registry_io_1.RunRegistry(resolveCwd(options));
|
|
332
338
|
return (0, reclamation_io_1.gcOrphanRuns)(registry, {
|
|
333
339
|
scope: scopeOf(options, "home"),
|
|
334
|
-
minAgeMinutes:
|
|
340
|
+
minAgeMinutes: (0, numeric_flag_1.requiredNumberFlag)(options.minAgeMinutes ?? options["min-age-minutes"], "--min-age-minutes"),
|
|
335
341
|
all: Boolean(options.all),
|
|
336
342
|
now: optionalString(options.now),
|
|
337
343
|
});
|
|
@@ -310,9 +310,12 @@ function fetchArchiveBytes(rawUrl, sanitizedUrl, dest, opts) {
|
|
|
310
310
|
}
|
|
311
311
|
/** List an archive's entry NAMES WITHOUT extracting (the zip-slip/tar-slip name guard runs on
|
|
312
312
|
* this). Symlinks/specials and decompression bombs are caught separately (below). */
|
|
313
|
-
function listArchive(file, isZip) {
|
|
313
|
+
function listArchive(file, isZip, timeoutMs) {
|
|
314
314
|
const cmd = isZip ? ["unzip", "-Z1", "--", file] : ["tar", "-tf", file];
|
|
315
|
-
|
|
315
|
+
// Same unset-timeout hazard as a hung local/container task (post-v0.2.2
|
|
316
|
+
// robustness hardening): a pathological archive must not hang the CLI
|
|
317
|
+
// forever with no kill path. Matches git()'s own fallback below.
|
|
318
|
+
const result = (0, node_child_process_1.spawnSync)(cmd[0], cmd.slice(1), { encoding: "utf8", timeout: timeoutMs || 120000 });
|
|
316
319
|
if (result.status !== 0) {
|
|
317
320
|
// Distinguish "unzip not installed" (ENOENT) from "archive is corrupt" — never conflate the
|
|
318
321
|
// two (a corrupt .tar mislabeled .zip used to surface a bogus "unzip not found").
|
|
@@ -338,10 +341,10 @@ function assertNoTraversal(entries, sanitizedUrl) {
|
|
|
338
341
|
/** Declared uncompressed size, read WITHOUT extracting — gzip's ISIZE trailer for `.tar.gz`/
|
|
339
342
|
* `.tgz`, or `unzip -l`'s total for `.zip`. Best-effort (undefined when unknown); the
|
|
340
343
|
* post-extraction walk is authoritative. Lets us reject a bomb BEFORE it fills the disk. */
|
|
341
|
-
function declaredUncompressedSize(file, isZip) {
|
|
344
|
+
function declaredUncompressedSize(file, isZip, timeoutMs) {
|
|
342
345
|
try {
|
|
343
346
|
if (isZip) {
|
|
344
|
-
const r = (0, node_child_process_1.spawnSync)("unzip", ["-l", "--", file], { encoding: "utf8" });
|
|
347
|
+
const r = (0, node_child_process_1.spawnSync)("unzip", ["-l", "--", file], { encoding: "utf8", timeout: timeoutMs || 120000 });
|
|
345
348
|
if (r.status !== 0)
|
|
346
349
|
return undefined;
|
|
347
350
|
const lines = String(r.stdout || "").trim().split(/\r?\n/);
|
|
@@ -421,15 +424,17 @@ function downloadArchive(rawUrl, sanitizedUrl, opts) {
|
|
|
421
424
|
const commit = (0, node_crypto_1.createHash)("sha256").update(node_fs_1.default.readFileSync(tmpFile)).digest("hex"); // content address
|
|
422
425
|
// Bomb defense (BEFORE extracting, to avoid filling the disk): reject a declared
|
|
423
426
|
// uncompressed size over the cap. assertSafeTree re-checks the ACTUAL size afterward.
|
|
424
|
-
const declared = declaredUncompressedSize(tmpFile, isZip);
|
|
427
|
+
const declared = declaredUncompressedSize(tmpFile, isZip, opts.timeoutMs);
|
|
425
428
|
if (declared !== undefined && declared > MAX_EXTRACTED_BYTES) {
|
|
426
429
|
throw new Error(`refusing archive ${sanitizedUrl}: declared uncompressed size ${declared} exceeds ${MAX_EXTRACTED_BYTES} bytes (possible decompression bomb)`);
|
|
427
430
|
}
|
|
428
|
-
assertNoTraversal(listArchive(tmpFile, isZip), sanitizedUrl);
|
|
431
|
+
assertNoTraversal(listArchive(tmpFile, isZip, opts.timeoutMs), sanitizedUrl);
|
|
429
432
|
const extractDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(staging, "x-"));
|
|
433
|
+
// Same unset-timeout hazard as above: extraction runs on downloaded,
|
|
434
|
+
// attacker-influenced bytes and must not hang forever.
|
|
430
435
|
const ex = isZip
|
|
431
|
-
? (0, node_child_process_1.spawnSync)("unzip", ["-q", "-o", "-d", extractDir, "--", tmpFile], { encoding: "utf8" })
|
|
432
|
-
: (0, node_child_process_1.spawnSync)("tar", ["-xf", tmpFile, "-C", extractDir], { encoding: "utf8" });
|
|
436
|
+
? (0, node_child_process_1.spawnSync)("unzip", ["-q", "-o", "-d", extractDir, "--", tmpFile], { encoding: "utf8", timeout: opts.timeoutMs || 120000 })
|
|
437
|
+
: (0, node_child_process_1.spawnSync)("tar", ["-xf", tmpFile, "-C", extractDir], { encoding: "utf8", timeout: opts.timeoutMs || 120000 });
|
|
433
438
|
if (ex.status !== 0)
|
|
434
439
|
throw new Error(`could not extract archive ${sanitizedUrl}: ${String(ex.stderr || "").trim() || `exit ${ex.status}`}`);
|
|
435
440
|
// Fail closed on symlinks/specials and on an over-cap actual extracted size.
|