cool-workflow 0.1.86 → 0.1.88
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/README.md +111 -68
- 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/agent-config.js +42 -1
- package/dist/capability-core.js +9 -4
- package/dist/capability-registry.js +48 -0
- package/dist/cli/command-surface.js +182 -11
- package/dist/cli.js +2 -1
- package/dist/doctor.js +27 -5
- package/dist/drive.js +222 -16
- package/dist/execution-backend.js +12 -4
- package/dist/loop-expansion.js +60 -0
- package/dist/onramp.js +25 -0
- package/dist/operator-ux/format.js +21 -15
- package/dist/operator-ux.js +2 -1
- package/dist/orchestrator/lifecycle-operations.js +134 -3
- package/dist/orchestrator.js +122 -76
- package/dist/run-export.js +106 -2
- package/dist/state-node.js +13 -3
- package/dist/state.js +21 -0
- package/dist/telemetry-attestation.js +30 -6
- package/dist/telemetry-demo.js +29 -1
- package/dist/telemetry-ledger.js +6 -0
- package/dist/term.js +93 -0
- package/dist/version.js +1 -1
- package/dist/worker-accept/telemetry-ledger.js +12 -2
- package/dist/workflow-api.js +33 -0
- package/dist/workflow-app-framework.js +20 -0
- package/docs/agent-delegation-drive.7.md +28 -6
- package/docs/capability-topology-registry.7.md +69 -46
- package/docs/cli-mcp-parity.7.md +22 -2
- package/docs/contract-migration-tooling.7.md +8 -0
- package/docs/control-plane-scheduling.7.md +8 -0
- package/docs/durable-state-and-locking.7.md +8 -0
- package/docs/evidence-adoption-reasoning-chain.7.md +8 -0
- package/docs/execution-backends.7.md +8 -0
- package/docs/launch/launch-kit.md +9 -9
- package/docs/multi-agent-cli-mcp-surface.7.md +8 -0
- package/docs/multi-agent-eval-replay-harness.7.md +8 -0
- package/docs/multi-agent-operator-ux.7.md +8 -0
- package/docs/node-snapshot-diff-replay.7.md +8 -0
- package/docs/observability-cost-accounting.7.md +8 -0
- package/docs/project-index.md +26 -5
- package/docs/readme-v0.1.87-full.md +301 -0
- package/docs/real-execution-backends.7.md +8 -0
- package/docs/release-and-migration.7.md +8 -0
- package/docs/release-history.md +10 -0
- package/docs/release-tooling.7.md +16 -0
- package/docs/report-verifiable-bundle.7.md +34 -2
- package/docs/run-registry-control-plane.7.md +18 -0
- package/docs/run-retention-reclamation.7.md +8 -0
- package/docs/state-explosion-management.7.md +8 -0
- package/docs/team-collaboration.7.md +8 -0
- package/docs/trust-model.md +6 -4
- package/docs/web-desktop-workbench.7.md +8 -0
- package/manifest/plugin.manifest.json +1 -1
- package/manifest/source-context-profiles.json +1 -1
- package/package.json +8 -5
- package/scripts/agents/agent-adapter-core.js +180 -0
- package/scripts/agents/builtin-templates.json +5 -1
- package/scripts/agents/claude-p-agent.js +7 -33
- package/scripts/agents/codex-agent.js +134 -0
- package/scripts/agents/cw-attest-wrap.js +9 -1
- package/scripts/agents/gemini-agent.js +115 -0
- package/scripts/agents/opencode-agent.js +119 -0
- package/scripts/canonical-apps.js +4 -4
- package/scripts/coverage-gate.js +15 -1
- package/scripts/cw.js +0 -0
- package/scripts/dogfood-release.js +1 -1
- package/scripts/golden-path.js +4 -4
- package/scripts/parity-check.js +6 -5
- package/scripts/release-check.js +3 -3
- package/scripts/release-flow.js +49 -2
- package/scripts/release-gate.sh +1 -1
- package/tsconfig.json +3 -1
|
@@ -27,6 +27,8 @@ const harness_1 = require("../harness");
|
|
|
27
27
|
const workflow_app_framework_1 = require("../workflow-app-framework");
|
|
28
28
|
const workflow_api_1 = require("../workflow-api");
|
|
29
29
|
const observability_1 = require("../observability");
|
|
30
|
+
const compare_1 = require("../compare");
|
|
31
|
+
const loop_expansion_1 = require("../loop-expansion");
|
|
30
32
|
const dispatch_1 = require("../dispatch");
|
|
31
33
|
const verifier_1 = require("../verifier");
|
|
32
34
|
const trust_audit_1 = require("../trust-audit");
|
|
@@ -53,7 +55,13 @@ function plan(appRecord, options) {
|
|
|
53
55
|
inputs[declared.name] = declared.default ?? "";
|
|
54
56
|
}
|
|
55
57
|
const cwd = node_path_1.default.resolve(String(inputs.cwd || inputs.repo || process.cwd()));
|
|
56
|
-
|
|
58
|
+
// A caller (e.g. an inline sub-workflow task) may inject a DETERMINISTIC run id so
|
|
59
|
+
// the child run id is reproducible across re-runs; otherwise mint one. `runId` is
|
|
60
|
+
// never a declared workflow input, so strip it from inputs to keep run.inputs (and
|
|
61
|
+
// the digests derived from it) clean — POLA for every normal plan.
|
|
62
|
+
const injectedRunId = typeof options.runId === "string" && options.runId.trim() ? options.runId.trim() : undefined;
|
|
63
|
+
delete inputs.runId;
|
|
64
|
+
const runId = injectedRunId || createRunId(workflow.id);
|
|
57
65
|
const runDir = node_path_1.default.join(cwd, ".cw", "runs", runId);
|
|
58
66
|
const paths = (0, state_1.createRunPaths)(runDir);
|
|
59
67
|
(0, state_1.ensureRunDirs)(paths);
|
|
@@ -79,7 +87,10 @@ function plan(appRecord, options) {
|
|
|
79
87
|
status: "pending",
|
|
80
88
|
taskIds: phase.tasks.map((task) => task.id),
|
|
81
89
|
// parallel() DSL: the drive loop reads this to size its concurrent round.
|
|
82
|
-
...(phase.mode ? { mode: phase.mode } : {})
|
|
90
|
+
...(phase.mode ? { mode: phase.mode } : {}),
|
|
91
|
+
// loop() DSL: the ORIGIN phase carries the loop spec + round 1; the expander
|
|
92
|
+
// appends round-2+ phases after each round (loop-expansion / maybeExpandLoop).
|
|
93
|
+
...(phase.loop ? { loop: phase.loop, loopRound: 1 } : {})
|
|
83
94
|
})),
|
|
84
95
|
tasks,
|
|
85
96
|
dispatches: [],
|
|
@@ -323,6 +334,9 @@ function recordWorkerOutput(run, workerId, resultPath, options = {}) {
|
|
|
323
334
|
}
|
|
324
335
|
run.loopStage = "observe";
|
|
325
336
|
(0, dispatch_1.updatePhaseStatuses)(run);
|
|
337
|
+
// Bounded dynamic loops: after a round's tasks complete, evaluate the predicate
|
|
338
|
+
// and either append the next round or mark the loop done (no-op for non-loop runs).
|
|
339
|
+
maybeExpandLoop(run);
|
|
326
340
|
(0, verifier_1.validateRunGates)(run);
|
|
327
341
|
(0, commit_1.commitState)(run, `worker:${workerId}:result`);
|
|
328
342
|
(0, report_1.writeReport)(run);
|
|
@@ -410,6 +424,120 @@ function validateInputs(workflow, inputs) {
|
|
|
410
424
|
}
|
|
411
425
|
}
|
|
412
426
|
}
|
|
427
|
+
/** Bounded dynamic loop expansion. After a worker result is recorded: if the just-
|
|
428
|
+
* completed phase is the LATEST round of a loop whose origin is not yet done, evaluate
|
|
429
|
+
* the registered predicate over the round's recorded results and either append the
|
|
430
|
+
* next round (clone the round-1 template tasks into a fresh phase, materialized like
|
|
431
|
+
* plan() does) or mark the loop done. One deterministic `loop-control` node is recorded
|
|
432
|
+
* per round boundary — the replay source of truth. No-op when the run has no loop
|
|
433
|
+
* phases (POLA). Expands at most ONE loop boundary per call; the next accept handles
|
|
434
|
+
* the next. Bounded: a loop never exceeds `maxRounds` (fail-closed); an unregistered
|
|
435
|
+
* predicate stops the loop rather than spinning. */
|
|
436
|
+
function maybeExpandLoop(run) {
|
|
437
|
+
for (const phase of [...run.phases]) {
|
|
438
|
+
const originId = phase.loop ? phase.id : phase.loopOrigin;
|
|
439
|
+
if (!originId)
|
|
440
|
+
continue;
|
|
441
|
+
const origin = run.phases.find((p) => p.id === originId);
|
|
442
|
+
if (!origin || !origin.loop || origin.loopDone)
|
|
443
|
+
continue;
|
|
444
|
+
// Act only from the LATEST round phase of this loop.
|
|
445
|
+
const loopPhases = run.phases.filter((p) => p.id === originId || p.loopOrigin === originId);
|
|
446
|
+
const latest = loopPhases.reduce((a, b) => ((b.loopRound || 1) >= (a.loopRound || 1) ? b : a));
|
|
447
|
+
if (phase.id !== latest.id)
|
|
448
|
+
continue;
|
|
449
|
+
const roundTasks = run.tasks.filter((t) => latest.taskIds.includes(t.id));
|
|
450
|
+
if (roundTasks.length === 0 || !roundTasks.every((t) => t.status === "completed"))
|
|
451
|
+
continue;
|
|
452
|
+
const round = latest.loopRound || 1;
|
|
453
|
+
const ordered = (tasks) => tasks.slice().sort((a, b) => (0, compare_1.compareBytes)(a.id, b.id)).map((t) => t.result);
|
|
454
|
+
const roundResults = ordered(roundTasks);
|
|
455
|
+
const allLoopTasks = run.tasks.filter((t) => t.status === "completed" && loopPhases.some((p) => p.taskIds.includes(t.id)));
|
|
456
|
+
const allResults = ordered(allLoopTasks);
|
|
457
|
+
const ctx = { round, roundResults, allResults, usageTotals: (0, observability_1.deriveUsageTotals)(run).totals, inputs: run.inputs };
|
|
458
|
+
const until = origin.loop.until;
|
|
459
|
+
let decision;
|
|
460
|
+
if (until.kind === "budget-target") {
|
|
461
|
+
// Budget-aware scaling: keep spawning rounds while RECORDED (attested-only) usage
|
|
462
|
+
// stays under the target. Composes with the fail-closed cap (limits.tokenBudget),
|
|
463
|
+
// which the drive enforces before each spawn and which remains the absolute
|
|
464
|
+
// backstop — whichever fires first wins, and the cap can never be overshot.
|
|
465
|
+
const spent = ctx.usageTotals.totalTokens;
|
|
466
|
+
decision = { done: spent >= until.target, reason: `budget-target: ${spent}/${until.target} recorded tokens` };
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
const predicate = (0, loop_expansion_1.getLoopPredicate)(until.ref);
|
|
470
|
+
decision = predicate
|
|
471
|
+
? predicate(ctx)
|
|
472
|
+
: { done: true, reason: `loop predicate "${until.ref}" not registered — stopping fail-closed` };
|
|
473
|
+
}
|
|
474
|
+
const atCap = round >= origin.loop.maxRounds;
|
|
475
|
+
const done = decision.done || atCap;
|
|
476
|
+
// Record the decision under a deterministic id (the replay source of truth).
|
|
477
|
+
(0, state_node_1.appendRunNode)(run, (0, state_node_1.createStateNode)({
|
|
478
|
+
id: `${run.id}:loop-control:${originId}:r${round}`,
|
|
479
|
+
kind: "loop-control",
|
|
480
|
+
status: "completed",
|
|
481
|
+
loopStage: "adjust",
|
|
482
|
+
outputs: { round, done, atCap, reason: decision.reason },
|
|
483
|
+
metadata: { originPhaseId: originId, until: until.kind === "predicate" ? until.ref : `budget-target:${until.target}`, round, done, atCap, reason: decision.reason }
|
|
484
|
+
}));
|
|
485
|
+
if (done) {
|
|
486
|
+
origin.loopDone = true;
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
// Expand: clone the ROUND-1 template tasks into a fresh phase appended right after.
|
|
490
|
+
const nextRound = round + 1;
|
|
491
|
+
const nextPhaseName = `${origin.name} (round ${nextRound})`;
|
|
492
|
+
const templateTasks = run.tasks.filter((t) => origin.taskIds.includes(t.id));
|
|
493
|
+
const newTasks = templateTasks.map((t) => ({
|
|
494
|
+
id: `${t.id.replace(/@r\d+$/, "")}@r${nextRound}`,
|
|
495
|
+
kind: t.kind,
|
|
496
|
+
phase: nextPhaseName,
|
|
497
|
+
status: "pending",
|
|
498
|
+
requiresEvidence: t.requiresEvidence,
|
|
499
|
+
prompt: t.prompt,
|
|
500
|
+
taskPath: "",
|
|
501
|
+
resultPath: "",
|
|
502
|
+
loopStage: "interpret",
|
|
503
|
+
loopRound: nextRound,
|
|
504
|
+
...(t.sandboxProfileId ? { sandboxProfileId: t.sandboxProfileId } : {}),
|
|
505
|
+
...(t.label ? { label: t.label } : {}),
|
|
506
|
+
...(t.model ? { model: t.model } : {}),
|
|
507
|
+
...(t.agentType ? { agentType: t.agentType } : {}),
|
|
508
|
+
...(t.schema ? { schema: t.schema } : {})
|
|
509
|
+
}));
|
|
510
|
+
const nextPhase = {
|
|
511
|
+
id: `${originId}@r${nextRound}`,
|
|
512
|
+
name: nextPhaseName,
|
|
513
|
+
status: "pending",
|
|
514
|
+
taskIds: newTasks.map((t) => t.id),
|
|
515
|
+
loopOrigin: originId,
|
|
516
|
+
loopRound: nextRound,
|
|
517
|
+
...(origin.mode ? { mode: origin.mode } : {})
|
|
518
|
+
};
|
|
519
|
+
const insertAt = run.phases.findIndex((p) => p.id === latest.id);
|
|
520
|
+
run.phases.splice(insertAt + 1, 0, nextPhase);
|
|
521
|
+
run.tasks.push(...newTasks);
|
|
522
|
+
// Materialize: task files + a plan-stage contract node per new task (mirrors plan()).
|
|
523
|
+
(0, harness_1.writeTaskFiles)(run);
|
|
524
|
+
const contractId = run.contracts && run.contracts[0] ? run.contracts[0].id : undefined;
|
|
525
|
+
const inputNodeId = `${run.id}:input`;
|
|
526
|
+
const pipeline = (0, pipeline_runner_1.createPipelineRunner)({ contractId, persist: false });
|
|
527
|
+
for (const t of newTasks) {
|
|
528
|
+
const result = pipeline.runPipelineStage(run, "plan", inputNodeId, {
|
|
529
|
+
outputNodeId: `${run.id}:task:${t.id}`,
|
|
530
|
+
outputStatus: "pending",
|
|
531
|
+
loopStage: "interpret",
|
|
532
|
+
artifacts: [{ id: "task", kind: "markdown", path: t.taskPath }],
|
|
533
|
+
metadata: { workflowId: run.workflow.id, taskId: t.id, phase: t.phase, taskKind: t.kind, requiresEvidence: t.requiresEvidence, sandboxProfileId: t.sandboxProfileId }
|
|
534
|
+
});
|
|
535
|
+
t.stateNodeId = result.outputNodeId;
|
|
536
|
+
}
|
|
537
|
+
(0, dispatch_1.updatePhaseStatuses)(run);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
413
541
|
function flattenTasks(workflow, inputs) {
|
|
414
542
|
const seen = new Set();
|
|
415
543
|
const tasks = [];
|
|
@@ -437,7 +565,10 @@ function flattenTasks(workflow, inputs) {
|
|
|
437
565
|
...(task.label ? { label: task.label } : {}),
|
|
438
566
|
...(task.model ? { model: task.model } : {}),
|
|
439
567
|
...(task.agentType ? { agentType: task.agentType } : {}),
|
|
440
|
-
...(task.resultCache ? { resultCache: task.resultCache } : {})
|
|
568
|
+
...(task.resultCache ? { resultCache: task.resultCache } : {}),
|
|
569
|
+
...(task.subWorkflow ? { subWorkflow: task.subWorkflow } : {}),
|
|
570
|
+
// A loop phase's tasks are round 1 of the loop; the expander clones them.
|
|
571
|
+
...(phase.loop ? { loopRound: 1 } : {})
|
|
441
572
|
});
|
|
442
573
|
}
|
|
443
574
|
}
|
package/dist/orchestrator.js
CHANGED
|
@@ -36,8 +36,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.CoolWorkflowRunner = void 0;
|
|
39
|
+
exports.KNOWN_COMMANDS = exports.CoolWorkflowRunner = void 0;
|
|
40
40
|
exports.parseArgv = parseArgv;
|
|
41
|
+
exports.suggestCommand = suggestCommand;
|
|
42
|
+
exports.formatSearchResults = formatSearchResults;
|
|
43
|
+
exports.formatInfo = formatInfo;
|
|
41
44
|
exports.formatHelp = formatHelp;
|
|
42
45
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
43
46
|
const node_path_1 = __importDefault(require("node:path"));
|
|
@@ -68,6 +71,7 @@ const feedbackOps = __importStar(require("./orchestrator/feedback-operations"));
|
|
|
68
71
|
const topologyOps = __importStar(require("./orchestrator/topology-operations"));
|
|
69
72
|
const lifecycleOps = __importStar(require("./orchestrator/lifecycle-operations"));
|
|
70
73
|
const migrationOps = __importStar(require("./orchestrator/migration-operations"));
|
|
74
|
+
const term_1 = require("./term");
|
|
71
75
|
// CoolWorkflowRunner — the single FACADE both surfaces (cli.ts and the MCP server)
|
|
72
76
|
// call through. It is deliberately WIDE but THIN: each method either
|
|
73
77
|
// (a) loads the run's durable state and delegates to a domain function in
|
|
@@ -796,10 +800,20 @@ function parseArgv(argv) {
|
|
|
796
800
|
positionals.push(rest[restIndex]);
|
|
797
801
|
break;
|
|
798
802
|
}
|
|
799
|
-
if (!token.startsWith("
|
|
803
|
+
if (!token.startsWith("-")) {
|
|
800
804
|
positionals.push(token);
|
|
801
805
|
continue;
|
|
802
806
|
}
|
|
807
|
+
if (!token.startsWith("--")) {
|
|
808
|
+
// Single-dash short flag aliases: -q → question, -r → repo, -a → agent-command, -h → help, -v → version
|
|
809
|
+
const shortMap = { q: "question", r: "repo", a: "agent-command", h: "help", v: "version" };
|
|
810
|
+
const flag = token.slice(1);
|
|
811
|
+
// Handle combined short flags like -qr (not common but safe to ignore)
|
|
812
|
+
const key = shortMap[flag] || flag;
|
|
813
|
+
const val = rest[index + 1] && !rest[index + 1].startsWith("-") ? rest[++index] : true;
|
|
814
|
+
appendOption(options, key, val);
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
803
817
|
const withoutPrefix = token.slice(2);
|
|
804
818
|
const equalsIndex = withoutPrefix.indexOf("=");
|
|
805
819
|
let key;
|
|
@@ -816,84 +830,116 @@ function parseArgv(argv) {
|
|
|
816
830
|
}
|
|
817
831
|
return { command, positionals, options };
|
|
818
832
|
}
|
|
833
|
+
/** All known top-level CW commands. Used for "did you mean?" suggestions. */
|
|
834
|
+
exports.KNOWN_COMMANDS = new Set([
|
|
835
|
+
"help", "list", "doctor", "info", "search", "man", "init", "quickstart", "plan", "status", "next",
|
|
836
|
+
"dispatch", "result", "state", "commit", "report", "app", "sandbox",
|
|
837
|
+
"backend", "contract", "node", "feedback", "worker", "audit", "candidate",
|
|
838
|
+
"review", "loop", "schedule", "routine", "registry", "run", "queue",
|
|
839
|
+
"history", "audit-run", "multi-agent", "topology", "summary", "blackboard",
|
|
840
|
+
"coordinator", "metrics", "operator", "sched", "gc", "telemetry",
|
|
841
|
+
"migration", "demo", "workbench", "approve", "reject", "comment", "handoff",
|
|
842
|
+
"graph", "eval", "version", "update", "fix"
|
|
843
|
+
]);
|
|
844
|
+
/** Levenshtein distance between two short strings. */
|
|
845
|
+
function levenshtein(a, b) {
|
|
846
|
+
const m = a.length;
|
|
847
|
+
const n = b.length;
|
|
848
|
+
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
|
849
|
+
let curr = new Array(n + 1);
|
|
850
|
+
for (let i = 1; i <= m; i++) {
|
|
851
|
+
curr[0] = i;
|
|
852
|
+
for (let j = 1; j <= n; j++) {
|
|
853
|
+
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
854
|
+
}
|
|
855
|
+
[prev, curr] = [curr, prev];
|
|
856
|
+
}
|
|
857
|
+
return prev[n];
|
|
858
|
+
}
|
|
859
|
+
/** Suggest the closest known command for a typo. Returns undefined if no match
|
|
860
|
+
* within half the length of the input (avoiding wild guesses on short strings). */
|
|
861
|
+
function suggestCommand(input) {
|
|
862
|
+
if (!input || input.length < 2)
|
|
863
|
+
return undefined;
|
|
864
|
+
const lower = input.toLowerCase();
|
|
865
|
+
let best = "";
|
|
866
|
+
let bestDist = Infinity;
|
|
867
|
+
for (const cmd of exports.KNOWN_COMMANDS) {
|
|
868
|
+
const dist = levenshtein(lower, cmd);
|
|
869
|
+
if (dist < bestDist) {
|
|
870
|
+
best = cmd;
|
|
871
|
+
bestDist = dist;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
// Threshold: distance must be <= half the input length AND <= 3
|
|
875
|
+
if (bestDist <= 3 && bestDist <= lower.length / 2)
|
|
876
|
+
return best;
|
|
877
|
+
return undefined;
|
|
878
|
+
}
|
|
879
|
+
function formatSearchResults(keyword, results) {
|
|
880
|
+
if (!results.length)
|
|
881
|
+
return `No workflows matched "${keyword}".\n Tip: cw list for all available workflows.`;
|
|
882
|
+
return [
|
|
883
|
+
(0, term_1.bold)(`${results.length} workflow${results.length !== 1 ? "s" : ""} matching "${keyword}"`),
|
|
884
|
+
...results.map((r) => ` ${r.id} — ${r.title}\n ${(0, term_1.dim)(r.summary.slice(0, 120))}${r.summary.length > 120 ? "…" : ""}`),
|
|
885
|
+
"",
|
|
886
|
+
(0, term_1.dim)("Use cw info <id> for full details.")
|
|
887
|
+
].join("\n");
|
|
888
|
+
}
|
|
889
|
+
function formatInfo(appId, data) {
|
|
890
|
+
const app = (data.app || {});
|
|
891
|
+
const inputs = (Array.isArray(data.inputs) ? data.inputs : []);
|
|
892
|
+
const phases = (Array.isArray(data.phases) ? data.phases : []);
|
|
893
|
+
const lines = [(0, term_1.bold)(`cw info ${appId}`)];
|
|
894
|
+
if (data.title)
|
|
895
|
+
lines.push(` Title: ${data.title}`);
|
|
896
|
+
if (data.version)
|
|
897
|
+
lines.push(` Version: ${data.version}`);
|
|
898
|
+
if (data.summary)
|
|
899
|
+
lines.push(` Summary: ${data.summary}`);
|
|
900
|
+
if (data.author)
|
|
901
|
+
lines.push(` Author: ${typeof data.author === "object" ? data.author.name : data.author}`);
|
|
902
|
+
if (data.compatible !== undefined)
|
|
903
|
+
lines.push(` Compatible: ${data.compatible ? "yes" : "no"}`);
|
|
904
|
+
if (inputs.length > 0) {
|
|
905
|
+
lines.push(" Inputs:");
|
|
906
|
+
for (const input of inputs) {
|
|
907
|
+
const name = input.name || "";
|
|
908
|
+
const type = input.type || "string";
|
|
909
|
+
const required = input.required ? ", required" : "";
|
|
910
|
+
const def = input.default ? `, default: ${input.default}` : "";
|
|
911
|
+
const desc = input.description ? ` — ${input.description}` : "";
|
|
912
|
+
lines.push(` - ${name} (${type}${required}${def})${desc}`);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
if (Array.isArray(data.sandboxProfiles) && data.sandboxProfiles.length > 0) {
|
|
916
|
+
lines.push(` Sandbox: ${data.sandboxProfiles.join(", ")}`);
|
|
917
|
+
}
|
|
918
|
+
const taskCount = data.taskCount || 0;
|
|
919
|
+
if (phases.length > 0) {
|
|
920
|
+
lines.push(` Phases: ${phases.length} phase${phases.length !== 1 ? "s" : ""}, ${taskCount} task${taskCount !== 1 ? "s" : ""}`);
|
|
921
|
+
}
|
|
922
|
+
lines.push(` Run: cw quickstart ${appId} --repo . --question "..."`);
|
|
923
|
+
return lines.join("\n");
|
|
924
|
+
}
|
|
819
925
|
function formatHelp() {
|
|
820
926
|
return [
|
|
821
|
-
"Cool Workflow",
|
|
927
|
+
(0, term_1.bold)("Cool Workflow"),
|
|
928
|
+
"",
|
|
929
|
+
" -q \"question\" [-claude|-codex|-deepseek] Ask a question, get a report",
|
|
930
|
+
" version Show version",
|
|
931
|
+
" update Update to latest release",
|
|
932
|
+
" doctor Check setup",
|
|
933
|
+
" fix Show fix commands for setup issues",
|
|
822
934
|
"",
|
|
823
|
-
|
|
824
|
-
"
|
|
825
|
-
"
|
|
935
|
+
(0, term_1.bold)("Flags"),
|
|
936
|
+
" -q, --question TEXT The task or question to answer",
|
|
937
|
+
" -r, --repo PATH Target repository path (default: .)",
|
|
938
|
+
" -claude Use Claude agent",
|
|
939
|
+
" -codex Use Codex agent",
|
|
940
|
+
" -deepseek Use DeepSeek (via opencode)",
|
|
826
941
|
"",
|
|
827
|
-
"
|
|
828
|
-
" list",
|
|
829
|
-
" doctor [--json] [--onramp] [--changed-from REF] (check setup and show the shortest safe next steps)",
|
|
830
|
-
" init <workflow-id> [--title TEXT] [--output PATH]",
|
|
831
|
-
" quickstart [app-id] [--repo PATH] [--question TEXT] [--agent-command CMD] [--check] [--once] [--preview]",
|
|
832
|
-
" plan <workflow-id> [--repo PATH] [--question TEXT] [--invariant TEXT]",
|
|
833
|
-
" status <run-id> [--json|--format json]",
|
|
834
|
-
" next <run-id> [--limit N]",
|
|
835
|
-
" graph <run-id> [--json]",
|
|
836
|
-
" dispatch <run-id> [--limit N] [--sandbox PROFILE] [--backend node|bun|shell|container|remote|ci]",
|
|
837
|
-
" result <run-id> <task-id> <result-file>",
|
|
838
|
-
" state check <run-id> [--state PATH] [--write]",
|
|
839
|
-
" commit <run-id> --verifier <node-id> [--reason TEXT]",
|
|
840
|
-
" commit <run-id> --candidate <candidate-id> [--reason TEXT]",
|
|
841
|
-
" commit <run-id> --selection <selection-id> [--reason TEXT]",
|
|
842
|
-
" commit <run-id> --allow-unverified-checkpoint [--reason TEXT]",
|
|
843
|
-
" commit summary <run-id> [--json]",
|
|
844
|
-
" report <run-id> [--show|--summary]",
|
|
845
|
-
" app list|show|validate|init|package",
|
|
846
|
-
" sandbox list|show|validate",
|
|
847
|
-
" backend list|show|probe [backend-id]",
|
|
848
|
-
" contract show <run-id> [contract-id]",
|
|
849
|
-
" node list|show|graph <run-id>",
|
|
850
|
-
" feedback list|summary|show|collect|task|resolve <run-id>",
|
|
851
|
-
" worker list|summary|show|manifest|output|fail|validate <run-id>",
|
|
852
|
-
" audit summary <run-id>",
|
|
853
|
-
" audit worker <run-id> <worker-id>",
|
|
854
|
-
" audit provenance <run-id> [--worker ID|--candidate ID|--commit ID]",
|
|
855
|
-
" audit multi-agent <run-id> [--json]",
|
|
856
|
-
" audit policy <run-id> [--json]",
|
|
857
|
-
" audit role <run-id> <role-id> [--json]",
|
|
858
|
-
" audit blackboard <run-id> [--json]",
|
|
859
|
-
" audit judge <run-id> [--json]",
|
|
860
|
-
" audit attest <run-id> [--worker ID] [--hostEnforced true] [--env NAME]",
|
|
861
|
-
" audit decision <run-id> <worker-id> [--path PATH|--command CMD|--network TARGET|--env NAME]",
|
|
862
|
-
" candidate list|summary|register|score|rank|select|reject <run-id>",
|
|
863
|
-
" eval snapshot|replay|compare|score|gate|report",
|
|
864
|
-
" summary refresh|show <run-id> [--json]",
|
|
865
|
-
" blackboard summary|summarize|graph|resolve <run-id>",
|
|
866
|
-
" blackboard topic create <run-id> --id <topic-id> --title TEXT",
|
|
867
|
-
" blackboard message post|list <run-id>",
|
|
868
|
-
" blackboard context put <run-id>",
|
|
869
|
-
" blackboard artifact add|list <run-id>",
|
|
870
|
-
" blackboard snapshot <run-id>",
|
|
871
|
-
" coordinator summary <run-id>",
|
|
872
|
-
" coordinator decision <run-id> --kind KIND --outcome OUTCOME --reason TEXT",
|
|
873
|
-
" multi-agent run|status|step|blackboard|score|select|summary|summarize|graph|dependencies|failures|evidence <run-id>",
|
|
874
|
-
" multi-agent graph <run-id> --view full|compact|critical-path|failures|evidence|trust|topology|blackboard|candidate|commit-gate [--focus ID] [--depth N]",
|
|
875
|
-
" topology list|show|validate|apply|summary|graph",
|
|
876
|
-
" schedule create|list|due|complete|pause|resume|run-now|history|daemon|delete",
|
|
877
|
-
" routine create|fire|list|events|delete",
|
|
878
|
-
" registry refresh|show [--scope repo|home] [--json]",
|
|
879
|
-
" run search|list|show|resume|archive|rerun|export|import|verify-import [run-id|archive] [--scope repo|home] [--json]",
|
|
880
|
-
" queue add|list|drain|show [queue-id] [--repo PATH] [--priority N]",
|
|
881
|
-
" history [--scope repo|home] [--app ID] [--status STATE] [--json]",
|
|
882
|
-
" audit-run <app-id> [--repo PATH] [--question TEXT] [--agent-command CMD]",
|
|
883
|
-
" metrics show|summary <run-id> [--scope repo|home] [--json]",
|
|
884
|
-
" telemetry verify <run-id> [--pubkey PEM|PATH] [--json]",
|
|
885
|
-
" gc plan|run|verify [run-id] [--json]",
|
|
886
|
-
" sched plan|lease|release|complete|reclaim|reset|policy [--json]",
|
|
887
|
-
" migration list|check|prove [target] [--json]",
|
|
888
|
-
" operator status|report <run-id> [--json]",
|
|
889
|
-
" review status|policy <run-id> [--json]",
|
|
890
|
-
" approve|reject|comment <kind> <run-id> <target-id> [--reason TEXT]",
|
|
891
|
-
" handoff <kind> <run-id> <target-id> [--to ROLE]",
|
|
892
|
-
" loop --prompt TEXT [--interval-minutes N]",
|
|
893
|
-
" demo tamper",
|
|
894
|
-
" workbench view <run-id> [--json]",
|
|
895
|
-
" workbench serve [--port N] [--scope repo|home] [--once|--json]",
|
|
896
|
-
""
|
|
942
|
+
" list|search|info|init|plan|status|next|dispatch|result|state|commit|report|app|sandbox|backend|contract|node|feedback|worker|audit|candidate|review|loop|schedule|routine|registry|run|queue|history|quickstart|audit-run|multi-agent|topology|summary|blackboard|coordinator|metrics|operator|sched|gc|telemetry|migration|demo|workbench|approve|reject|comment|handoff|graph|eval|man|version|update|fix More commands"
|
|
897
943
|
].join("\n");
|
|
898
944
|
}
|
|
899
945
|
function appendOption(options, key, value) {
|
package/dist/run-export.js
CHANGED
|
@@ -26,6 +26,7 @@ const version_1 = require("./version");
|
|
|
26
26
|
const telemetry_ledger_1 = require("./telemetry-ledger");
|
|
27
27
|
const telemetry_attestation_1 = require("./telemetry-attestation");
|
|
28
28
|
const trust_audit_1 = require("./trust-audit");
|
|
29
|
+
const execution_backend_1 = require("./execution-backend");
|
|
29
30
|
const compare_1 = require("./compare");
|
|
30
31
|
/** Export a run to a portable JSON archive with run-local bytes and digests. */
|
|
31
32
|
function exportRun(run, outputPath, options = {}) {
|
|
@@ -84,9 +85,21 @@ function importRun(exportPath, targetDir) {
|
|
|
84
85
|
const archiveSha256 = sha256Bytes(node_fs_1.default.readFileSync(exportPath));
|
|
85
86
|
const files = normalizeArchiveFiles(raw);
|
|
86
87
|
verifyArchiveFileDigests(files, raw.integrity);
|
|
88
|
+
if (!raw.run || typeof raw.run !== "object") {
|
|
89
|
+
throw new Error("Invalid run export: missing run object");
|
|
90
|
+
}
|
|
91
|
+
// The run id from the archive becomes a directory name under the target's
|
|
92
|
+
// runs root; a crafted id like "../../etc" would otherwise escape it. Refuse
|
|
93
|
+
// any id that is not a single safe path segment, then assert containment as
|
|
94
|
+
// defense-in-depth (catches a symlinked runs root too) before any write.
|
|
95
|
+
const runId = (0, state_1.assertSafeRunId)(raw.run.id);
|
|
96
|
+
const runsRoot = node_path_1.default.join(targetDir, ".cw", "runs");
|
|
97
|
+
const runDir = node_path_1.default.join(runsRoot, runId);
|
|
98
|
+
if (!(0, state_1.isContainedPath)(runDir, runsRoot)) {
|
|
99
|
+
throw new Error(`Run id escapes the runs directory: ${JSON.stringify(raw.run.id)}`);
|
|
100
|
+
}
|
|
87
101
|
const oldRunDir = raw.run.paths.runDir;
|
|
88
102
|
const oldCwd = raw.run.cwd;
|
|
89
|
-
const runDir = node_path_1.default.join(targetDir, ".cw", "runs", raw.run.id);
|
|
90
103
|
const paths = (0, state_1.createRunPaths)(runDir);
|
|
91
104
|
(0, state_1.ensureRunDirs)(paths);
|
|
92
105
|
for (const file of files) {
|
|
@@ -294,6 +307,28 @@ function importManifestPath(run) {
|
|
|
294
307
|
*
|
|
295
308
|
* Key precedence is bundle > argument > environment, so the artifact verifies the
|
|
296
309
|
* same on any machine; only when the bundle omits a key do the override/env apply. */
|
|
310
|
+
/** True when report.md embeds `expected` (the trimmed result) at the task's OWN
|
|
311
|
+
* section, exactly as orchestrator/report.ts renderResults emits it: a
|
|
312
|
+
* `### <taskId>` heading, a `Result: <path>` line, then the result body — and the
|
|
313
|
+
* body STARTS WITH `expected`. Anchoring to the section (not a whole-file substring)
|
|
314
|
+
* means a decoy copy buried elsewhere does not satisfy it; matching from the heading
|
|
315
|
+
* forward (rather than to the next heading) means a result body that itself contains
|
|
316
|
+
* `###` cannot break the bound. The `Result:` path is matched loosely since it is
|
|
317
|
+
* host-specific (rebased on import). */
|
|
318
|
+
function reportSectionEmbedsResult(reportMd, taskId, expected) {
|
|
319
|
+
const needle = `### ${taskId}\n`;
|
|
320
|
+
// Walk EVERY `### <taskId>` occurrence — not just the first — so a stray heading
|
|
321
|
+
// inside an earlier task's result body (which is not followed by the `Result:`
|
|
322
|
+
// structure) is skipped rather than mis-anchoring the check (a false positive on a
|
|
323
|
+
// legitimate, fully-signed bundle whose findings contain markdown headings).
|
|
324
|
+
for (let from = reportMd.indexOf(needle); from >= 0; from = reportMd.indexOf(needle, from + 1)) {
|
|
325
|
+
const after = reportMd.slice(from);
|
|
326
|
+
const prefix = after.match(/^### [^\n]*\n\nResult: [^\n]*\n\n/);
|
|
327
|
+
if (prefix && after.slice(prefix[0].length).startsWith(expected))
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
297
332
|
function verifyReportBundle(archivePath, options = {}) {
|
|
298
333
|
const inspect = inspectArchive(archivePath);
|
|
299
334
|
const failedChecks = inspect.checks
|
|
@@ -312,6 +347,8 @@ function verifyReportBundle(archivePath, options = {}) {
|
|
|
312
347
|
signaturesChecked: 0,
|
|
313
348
|
signaturesReverified: 0,
|
|
314
349
|
signaturesFailed: 0,
|
|
350
|
+
trustLevel: "unsigned",
|
|
351
|
+
reportFindingsVerified: false,
|
|
315
352
|
failedChecks
|
|
316
353
|
};
|
|
317
354
|
// A bundle that is not even a supported archive cannot be restored — report the
|
|
@@ -348,6 +385,8 @@ function verifyReportBundle(archivePath, options = {}) {
|
|
|
348
385
|
let signaturesChecked = 0;
|
|
349
386
|
let signaturesReverified = 0;
|
|
350
387
|
let signaturesFailed = 0;
|
|
388
|
+
let signaturesResultBound = 0;
|
|
389
|
+
let reportFindingsOk = true;
|
|
351
390
|
let reportExtractedTo;
|
|
352
391
|
try {
|
|
353
392
|
// Restore into the throwaway tree. importRun digest-checks every file and throws
|
|
@@ -370,9 +409,48 @@ function verifyReportBundle(archivePath, options = {}) {
|
|
|
370
409
|
signaturesChecked = sig.checked;
|
|
371
410
|
signaturesReverified = sig.reverified;
|
|
372
411
|
signaturesFailed = sig.failed;
|
|
412
|
+
signaturesResultBound = sig.resultBound.length;
|
|
373
413
|
for (const check of sig.checks)
|
|
374
414
|
if (!check.pass)
|
|
375
415
|
failedChecks.push({ name: check.name, code: check.code });
|
|
416
|
+
// Report ⇄ result ⇄ signature cross-check — three links so the agent's findings
|
|
417
|
+
// cannot be altered undetected on a signed bundle. CRUCIALLY this is driven by
|
|
418
|
+
// sig.resultBound (the records whose signature actually COVERED the result
|
|
419
|
+
// digest), NOT the run.tasks list — run.tasks is in the archive but bound by
|
|
420
|
+
// nothing (not the manifest digest, the chain, or the signature), so iterating it
|
|
421
|
+
// would let an attacker silence the check by dropping/un-completing a task. The
|
|
422
|
+
// obligation comes from the chained+signed ledger record instead:
|
|
423
|
+
// 1. each bound record's resultDigest is anchored by the executor signature
|
|
424
|
+
// (coversResult re-verified above) — a 4-field signature is excluded, so an
|
|
425
|
+
// injected resultDigest is never trusted;
|
|
426
|
+
// 2. the matching completed task's RESTORED result file must hash to that signed
|
|
427
|
+
// digest (a missing/un-completed task or edited/empty result is a forgery);
|
|
428
|
+
// 3. report.md must embed that result at the task's own `### <taskId>` section.
|
|
429
|
+
// Editing the report breaks link 3; editing the result breaks link 2; editing both
|
|
430
|
+
// to one consistent lie still breaks link 2 (the signed digest does not move);
|
|
431
|
+
// dropping the task fails link 2 (the signed obligation remains).
|
|
432
|
+
const reportPath = imported.run.paths.report;
|
|
433
|
+
const reportMd = reportPath && node_fs_1.default.existsSync(reportPath) ? node_fs_1.default.readFileSync(reportPath, "utf8") : "";
|
|
434
|
+
const completedById = new Map(imported.run.tasks.filter((task) => task.status === "completed").map((task) => [task.id, task]));
|
|
435
|
+
for (const bound of sig.resultBound) {
|
|
436
|
+
const failBound = (code) => {
|
|
437
|
+
reportFindingsOk = false;
|
|
438
|
+
failedChecks.push({ name: "report-findings", code: `${code}:${bound.taskId}` });
|
|
439
|
+
};
|
|
440
|
+
const task = completedById.get(bound.taskId);
|
|
441
|
+
if (!task || !task.resultPath || !node_fs_1.default.existsSync(task.resultPath)) {
|
|
442
|
+
failBound("result-missing");
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
const resultRaw = node_fs_1.default.readFileSync(task.resultPath, "utf8");
|
|
446
|
+
if ((0, execution_backend_1.sha256)(resultRaw) !== bound.resultDigest) {
|
|
447
|
+
failBound("result-digest-mismatch");
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (!reportSectionEmbedsResult(reportMd, bound.taskId, resultRaw.trim())) {
|
|
451
|
+
failBound("report-result-mismatch");
|
|
452
|
+
}
|
|
453
|
+
}
|
|
376
454
|
if (options.extractReportTo && reportContent !== undefined) {
|
|
377
455
|
reportExtractedTo = node_path_1.default.resolve(options.extractReportTo);
|
|
378
456
|
node_fs_1.default.writeFileSync(reportExtractedTo, reportContent);
|
|
@@ -389,6 +467,23 @@ function verifyReportBundle(archivePath, options = {}) {
|
|
|
389
467
|
const strictShortfall = Boolean(options.strictSignatures) && signaturesChecked > 0 && !trustKey;
|
|
390
468
|
if (strictShortfall)
|
|
391
469
|
failedChecks.push({ name: "signatures", code: "signature-key-required" });
|
|
470
|
+
// Trust level + requireSignatures — closes the prior fail-open. "signed" means the
|
|
471
|
+
// agent's SIGNED findings are present and unaltered: at least one result-COVERING
|
|
472
|
+
// signature re-verified against a key, none failed, and each signed result is
|
|
473
|
+
// faithfully embedded in report.md (the forward cross-check held). A usage-only
|
|
474
|
+
// (4-field) signature, an unverifiable one (no key), or a tampered signed finding
|
|
475
|
+
// all yield "unsigned". requireSignatures refuses "unsigned".
|
|
476
|
+
//
|
|
477
|
+
// SCOPE (honest): "signed" attests the SIGNED findings, NOT that the report is
|
|
478
|
+
// exhaustively signed. CW holds no key to sign the rendered report and the ledger
|
|
479
|
+
// chain is self-recomputable, so the report MAY carry additional unsigned content
|
|
480
|
+
// (prose, or extra sections), and a determined re-chainer can OMIT a signed
|
|
481
|
+
// finding. Verify findings against the signed results; full report-completeness
|
|
482
|
+
// needs an external anchor. See report-verifiable-bundle.7.md / trust-model.md.
|
|
483
|
+
const trustLevel = signaturesResultBound > 0 && signaturesFailed === 0 && reportFindingsOk ? "signed" : "unsigned";
|
|
484
|
+
const unsignedShortfall = Boolean(options.requireSignatures) && trustLevel === "unsigned";
|
|
485
|
+
if (unsignedShortfall)
|
|
486
|
+
failedChecks.push({ name: "signatures", code: "signatures-required" });
|
|
392
487
|
// Extraction was requested but could not be fulfilled (no report.md in the bundle,
|
|
393
488
|
// or the write failed): fail closed rather than silently green a missing artifact —
|
|
394
489
|
// otherwise `report bundle <run> --extract-report r.md && send r.md` would ship
|
|
@@ -401,7 +496,14 @@ function verifyReportBundle(archivePath, options = {}) {
|
|
|
401
496
|
schemaVersion: 1,
|
|
402
497
|
archivePath,
|
|
403
498
|
runId: inspect.runId,
|
|
404
|
-
ok: inspect.ok &&
|
|
499
|
+
ok: inspect.ok &&
|
|
500
|
+
telemetryVerified &&
|
|
501
|
+
trustAuditVerified &&
|
|
502
|
+
signaturesFailed === 0 &&
|
|
503
|
+
reportFindingsOk &&
|
|
504
|
+
!strictShortfall &&
|
|
505
|
+
!extractShortfall &&
|
|
506
|
+
!unsignedShortfall,
|
|
405
507
|
archiveOk: inspect.ok,
|
|
406
508
|
telemetryVerified,
|
|
407
509
|
trustAuditVerified,
|
|
@@ -410,6 +512,8 @@ function verifyReportBundle(archivePath, options = {}) {
|
|
|
410
512
|
signaturesChecked,
|
|
411
513
|
signaturesReverified,
|
|
412
514
|
signaturesFailed,
|
|
515
|
+
trustLevel,
|
|
516
|
+
reportFindingsVerified: reportFindingsOk,
|
|
413
517
|
reportExtractedTo,
|
|
414
518
|
failedChecks
|
|
415
519
|
};
|
package/dist/state-node.js
CHANGED
|
@@ -164,10 +164,20 @@ function linkStateNodes(parent, child) {
|
|
|
164
164
|
];
|
|
165
165
|
}
|
|
166
166
|
function appendRunNode(run, node) {
|
|
167
|
-
|
|
167
|
+
// Mutate run.nodes in place rather than rebuilding the whole array on every
|
|
168
|
+
// append. The old code did `nodes.map(...)` / `[...nodes, node]` per call, so a
|
|
169
|
+
// run that appends N nodes allocated arrays of size 1..N — O(N^2) churn + GC on
|
|
170
|
+
// a hot path (every dispatch/result/blackboard node). In place: O(1) per append
|
|
171
|
+
// for the common new-node case, and the resulting array — content AND order — is
|
|
172
|
+
// byte-identical to before (push appends at the end, replace keeps the slot), so
|
|
173
|
+
// the persisted state.json is unchanged. appendRunNode is the only writer of
|
|
174
|
+
// run.nodes, so the array reference stays stable across a run.
|
|
175
|
+
const nodes = run.nodes || (run.nodes = []);
|
|
168
176
|
const index = nodes.findIndex((candidate) => candidate.id === node.id);
|
|
169
|
-
|
|
170
|
-
|
|
177
|
+
if (index >= 0)
|
|
178
|
+
nodes[index] = node;
|
|
179
|
+
else
|
|
180
|
+
nodes.push(node);
|
|
171
181
|
writeRunNode(run, node);
|
|
172
182
|
return node;
|
|
173
183
|
}
|
package/dist/state.js
CHANGED
|
@@ -19,6 +19,7 @@ exports.realResolve = realResolve;
|
|
|
19
19
|
exports.isContainedPath = isContainedPath;
|
|
20
20
|
exports.withFileLock = withFileLock;
|
|
21
21
|
exports.safeFileName = safeFileName;
|
|
22
|
+
exports.assertSafeRunId = assertSafeRunId;
|
|
22
23
|
exports.hashArtifactFile = hashArtifactFile;
|
|
23
24
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
24
25
|
const node_path_1 = __importDefault(require("node:path"));
|
|
@@ -293,6 +294,26 @@ function withFileLock(targetPath, fn) {
|
|
|
293
294
|
function safeFileName(value) {
|
|
294
295
|
return String(value).replace(/[^a-zA-Z0-9_.:-]+/g, "_");
|
|
295
296
|
}
|
|
297
|
+
/** Refuse a run id that is not a single safe path segment, so an id taken from
|
|
298
|
+
* an untrusted source (an imported run archive / bundle) can never escape the
|
|
299
|
+
* runs directory via a separator or a `..`/`.` component (path traversal). A
|
|
300
|
+
* real run id is `${workflowId}-${stamp}-${suffix}` (createRunId), and a
|
|
301
|
+
* workflow id is alnum-bounded [a-z0-9.-] (validateWorkflowId) — all within
|
|
302
|
+
* [A-Za-z0-9._-]. Because the charset already forbids any separator, the whole
|
|
303
|
+
* id is ONE path component, so the only values that could traverse are the
|
|
304
|
+
* components `.` and `..` themselves; an embedded `..` (e.g. a workflow id like
|
|
305
|
+
* `v1..2`) is a safe directory name and is allowed. A separator, an absolute
|
|
306
|
+
* path, or an empty value is refused, fail-closed. Returns the id on success so
|
|
307
|
+
* callers can use it inline. */
|
|
308
|
+
function assertSafeRunId(value, context = "run id") {
|
|
309
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
310
|
+
throw new Error(`Invalid ${context}: expected a non-empty string`);
|
|
311
|
+
}
|
|
312
|
+
if (!/^[A-Za-z0-9._-]+$/.test(value) || value === "." || value === "..") {
|
|
313
|
+
throw new Error(`Unsafe ${context}: ${JSON.stringify(value)} must be a single path segment ([A-Za-z0-9._-], not '.' or '..')`);
|
|
314
|
+
}
|
|
315
|
+
return value;
|
|
316
|
+
}
|
|
296
317
|
/** Compute and set SHA256 + sizeBytes on a StateArtifact from its file path
|
|
297
318
|
* (v0.1.73). Fails silently when the file doesn't exist — does not throw. */
|
|
298
319
|
function hashArtifactFile(artifact) {
|