cool-workflow 0.2.3 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.gemini-plugin/mcp.json +10 -0
- package/.gemini-plugin/plugin.json +40 -0
- package/.opencode-plugin/mcp.json +10 -0
- package/.opencode-plugin/plugin.json +40 -0
- package/README.md +202 -48
- 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 +33 -4
- package/dist/cli/entry.js +11 -19
- package/dist/cli/global-flags.js +113 -0
- package/dist/cli/io.js +6 -20
- package/dist/cli/parseargv.js +7 -1
- package/dist/core/capability-data.js +339 -10
- package/dist/core/format/help.js +80 -4
- package/dist/core/format/recovery-hint.js +32 -0
- package/dist/core/multi-agent/collaboration.js +35 -6
- package/dist/core/multi-agent/runtime.js +7 -0
- package/dist/core/multi-agent/trust-policy.js +7 -1
- package/dist/core/pipeline/contract.js +7 -0
- package/dist/core/pipeline/error-feedback.js +2 -2
- package/dist/core/trust/evidence-grounding.js +13 -1
- package/dist/core/util/cli-args.js +55 -0
- package/dist/core/version.js +1 -1
- package/dist/mcp/dispatch.js +22 -2
- package/dist/mcp/server.js +124 -13
- package/dist/mcp-server.js +20 -0
- package/dist/shell/commit.js +8 -2
- package/dist/shell/coordinator-io.js +73 -1
- package/dist/shell/drive.js +129 -64
- package/dist/shell/error-feedback-io.js +6 -0
- package/dist/shell/execution-backend/agent.js +205 -24
- package/dist/shell/execution-backend/container.js +44 -10
- package/dist/shell/execution-backend/local.js +47 -10
- package/dist/shell/fs-atomic.js +93 -12
- package/dist/shell/ledger-cli.js +9 -2
- package/dist/shell/multi-agent-cli.js +5 -1
- package/dist/shell/onramp.js +48 -5
- package/dist/shell/pipeline-cli.js +20 -2
- package/dist/shell/pipeline.js +2 -1
- package/dist/shell/reclamation-io.js +76 -7
- package/dist/shell/registry-cli.js +4 -0
- package/dist/shell/run-export.js +52 -4
- package/dist/shell/run-registry-io.js +1 -0
- package/dist/shell/run-store.js +156 -0
- package/dist/shell/scheduler-io.js +101 -10
- package/dist/shell/telemetry-ledger-io.js +36 -24
- package/dist/shell/trust-audit.js +104 -10
- package/dist/shell/workbench-host.js +121 -10
- package/dist/shell/workbench.js +79 -5
- package/dist/shell/worker-isolation.js +1 -1
- package/dist/shell/workflow-app-loader.js +67 -1
- package/dist/wiring/capability-table/basics.js +7 -2
- package/dist/wiring/capability-table/exec-backend.js +42 -24
- package/dist/wiring/capability-table/multi-agent.js +69 -69
- package/dist/wiring/capability-table/pipeline.js +60 -28
- package/dist/wiring/capability-table/registry-core.js +29 -6
- package/dist/wiring/capability-table/reporting.js +45 -39
- package/dist/wiring/capability-table/scheduling-registry.js +66 -60
- package/dist/wiring/capability-table/state.js +34 -34
- package/dist/wiring/capability-table/trust-ledger.js +66 -24
- package/dist/wiring/capability-table/workflow-apps.js +15 -15
- package/docs/agent-delegation-drive.7.md +17 -0
- package/docs/cli-mcp-parity.7.md +43 -13
- package/docs/contract-migration-tooling.7.md +4 -0
- package/docs/control-plane-scheduling.7.md +38 -0
- package/docs/cross-agent-ledger.7.md +20 -8
- package/docs/durable-state-and-locking.7.md +4 -0
- package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
- package/docs/execution-backends.7.md +4 -0
- package/docs/fix.7.md +4 -4
- package/docs/getting-started.md +40 -32
- package/docs/index.md +17 -0
- package/docs/launch/demo.tape +4 -3
- package/docs/mcp-app-surface.7.md +6 -0
- package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
- package/docs/multi-agent-eval-replay-harness.7.md +4 -0
- package/docs/multi-agent-operator-ux.7.md +4 -0
- package/docs/node-snapshot-diff-replay.7.md +4 -0
- package/docs/observability-cost-accounting.7.md +4 -0
- package/docs/project-index.md +43 -9
- package/docs/real-execution-backends.7.md +4 -0
- package/docs/release-and-migration.7.md +4 -0
- package/docs/release-tooling.7.md +22 -0
- package/docs/routine.7.md +22 -0
- package/docs/run-registry-control-plane.7.md +4 -0
- package/docs/run-retention-reclamation.7.md +28 -3
- package/docs/state-explosion-management.7.md +4 -0
- package/docs/team-collaboration.7.md +4 -0
- package/docs/trust-audit-anchor.7.md +4 -0
- package/docs/web-desktop-workbench.7.md +26 -2
- package/docs/workflow-app-framework.7.md +31 -0
- package/manifest/plugin.manifest.json +1 -1
- package/manifest/source-context-profiles.json +4 -3
- package/package.json +5 -2
- package/scripts/agents/agent-adapter-core.js +26 -0
- package/scripts/agents/claude-p-agent.js +4 -1
- package/scripts/agents/codex-agent.js +4 -0
- package/scripts/agents/cw-attest-wrap.js +1 -1
- package/scripts/agents/gemini-agent.js +4 -0
- package/scripts/agents/opencode-agent.js +5 -0
- package/scripts/block-unapproved-tag.js +160 -0
- package/scripts/bump-version.js +27 -10
- package/scripts/canonical-apps.js +4 -4
- package/scripts/children/batch-delegate-child.js +72 -4
- package/scripts/children/http-batch-delegate-child.js +132 -0
- package/scripts/children/http-delegate-child.js +8 -0
- package/scripts/dogfood-release.js +2 -2
- package/scripts/fake-date-for-reproduction.js +1 -1
- package/scripts/golden-path.js +4 -4
- package/scripts/lang-policy-check.js +82 -0
- package/scripts/purity-baseline.json +0 -30
- package/scripts/purity-gate.js +16 -3
- package/scripts/release-check.js +2 -1
- package/scripts/release-flow.js +264 -41
- package/scripts/release-gate.js +184 -0
- package/scripts/release-oneclick.js +438 -0
- package/scripts/release-tags.js +47 -0
- package/scripts/verdict-keygen.js +2 -2
- package/scripts/verify-bump-reproduction.js +193 -0
- package/scripts/verify-verdict-signature.js +1 -1
- package/scripts/version-sync-check.js +39 -22
- package/ui/workbench/app.css +41 -9
- package/ui/workbench/app.js +160 -27
- package/ui/workbench/index.html +2 -2
- package/docs/agent-framework.md +0 -177
- package/docs/designs/handoff-ledger.md +0 -145
- package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
- package/docs/launch/launch-kit.md +0 -195
- package/docs/launch/pre-launch-checklist.md +0 -53
- package/docs/readme-v0.1.87-full.md +0 -301
- package/docs/routines.md +0 -101
- package/docs/scheduled-tasks.md +0 -80
- package/scripts/agents/claude-p-agent.sh +0 -9
- package/scripts/block-unapproved-tag.sh +0 -60
- package/scripts/release-gate.sh +0 -94
- package/scripts/verify-bump-reproduction.sh +0 -148
- package/scripts/verify-container-selfref.js +0 -64
|
@@ -94,31 +94,43 @@ function loadTelemetryLedger(run) {
|
|
|
94
94
|
/** Append one attestation record DURABLY to the append-only chain,
|
|
95
95
|
* linking it to the prior record (or genesis). Returns the committed
|
|
96
96
|
* record. Throws `TelemetryLedgerCorruptError` if the file on disk is
|
|
97
|
-
* present but unparseable — never silently re-genesis.
|
|
97
|
+
* present but unparseable — never silently re-genesis.
|
|
98
|
+
*
|
|
99
|
+
* The whole load->append->write is held under `withFileLock` (like every
|
|
100
|
+
* other read-modify-write in this codebase — see `recordTrustAuditEvent`).
|
|
101
|
+
* `writeJson` REPLACES the file, so without the lock two processes
|
|
102
|
+
* appending for the same run at once both read the same N-record ledger,
|
|
103
|
+
* both compute a record at chain position N+1, and the last atomic rename
|
|
104
|
+
* WINS — silently dropping the loser's record. The surviving chain still
|
|
105
|
+
* links correctly, so the loss is invisible to `verifyTelemetryLedger`.
|
|
106
|
+
* The `prevHash` and chain position MUST be read from the ledger loaded
|
|
107
|
+
* INSIDE the lock so the record links to the true current tail. */
|
|
98
108
|
function appendTelemetryAttestation(run, input) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
109
|
+
return (0, fs_atomic_1.withFileLock)(telemetryLedgerPath(run), () => {
|
|
110
|
+
const ledger = loadTelemetryLedger(run);
|
|
111
|
+
const now = input.now || new Date().toISOString();
|
|
112
|
+
const prevHash = ledger.records.length ? ledger.records[ledger.records.length - 1].recordHash : (0, telemetry_ledger_1.genesisPrevHash)(run.id);
|
|
113
|
+
const base = {
|
|
114
|
+
schemaVersion: 1,
|
|
115
|
+
runId: run.id,
|
|
116
|
+
recordId: (0, telemetry_ledger_1.recordId)(ledger.records.length + 1),
|
|
117
|
+
recordedAt: now,
|
|
118
|
+
workerId: input.workerId,
|
|
119
|
+
taskId: input.taskId,
|
|
120
|
+
promptDigest: input.promptDigest,
|
|
121
|
+
reportedUsageDigest: (0, telemetry_ledger_1.reportedUsageDigest)(input.reportedUsage),
|
|
122
|
+
...(input.reportedUsage ? { reportedUsage: input.reportedUsage } : {}),
|
|
123
|
+
usageSignature: input.usageSignature,
|
|
124
|
+
...(input.resultDigest ? { resultDigest: input.resultDigest } : {}),
|
|
125
|
+
attestation: input.attestation,
|
|
126
|
+
attestationReason: input.attestationReason,
|
|
127
|
+
prevHash,
|
|
128
|
+
};
|
|
129
|
+
const record = { ...base, recordHash: (0, telemetry_ledger_1.computeRecordHash)(base) };
|
|
130
|
+
ledger.records.push(record);
|
|
131
|
+
(0, fs_atomic_1.writeJson)(telemetryLedgerPath(run), ledger, { durable: true });
|
|
132
|
+
return record;
|
|
133
|
+
});
|
|
122
134
|
}
|
|
123
135
|
/** Re-prove the whole telemetry chain for a run, reading from disk.
|
|
124
136
|
* Absent -> present:false/verified:true (nothing to prove). Corrupt ->
|
|
@@ -85,8 +85,15 @@ function ensureTrustAudit(run) {
|
|
|
85
85
|
run.paths.auditDir = dir;
|
|
86
86
|
const audit = { schemaVersion: 1, ...trustAuditPaths(run) };
|
|
87
87
|
run.audit = audit;
|
|
88
|
+
// Create-if-missing must NEVER truncate: this runs OUTSIDE the append
|
|
89
|
+
// lock, so between an existsSync(false) here and a plain create, another
|
|
90
|
+
// process may have already created the log AND appended real events —
|
|
91
|
+
// a truncating create then silently deletes them (one event lost, chain
|
|
92
|
+
// still verifies "clean" from genesis; seen once on CI under coverage
|
|
93
|
+
// I/O). Flag "a" is O_CREAT without O_TRUNC: it makes the file when
|
|
94
|
+
// missing and adds zero bytes when not, with no exists-check window.
|
|
88
95
|
if (!fs.existsSync(audit.eventLogPath))
|
|
89
|
-
fs.writeFileSync(audit.eventLogPath, "", "utf8");
|
|
96
|
+
fs.writeFileSync(audit.eventLogPath, "", { encoding: "utf8", flag: "a" });
|
|
90
97
|
return audit;
|
|
91
98
|
}
|
|
92
99
|
/** Genesis prevHash for a run's chain (no prior event). Exported so
|
|
@@ -491,7 +498,22 @@ function recordTrustAuditEvent(run, input) {
|
|
|
491
498
|
event.id = createEventId(input.kind, count);
|
|
492
499
|
event.prevEventHash = prevHash;
|
|
493
500
|
event.eventHash = computeEventHash(event);
|
|
494
|
-
|
|
501
|
+
// Newline-boundary safety (fail-closed). `durableAppendFileSync` only ever
|
|
502
|
+
// ADDS bytes at the end of file and never writes a separator of its own. A
|
|
503
|
+
// completed append always leaves the log ending in "\n"; if the last byte
|
|
504
|
+
// is NOT "\n", the previous append was torn by a crash (its bytes were
|
|
505
|
+
// never a confirmed event — the append never returned). Writing this new,
|
|
506
|
+
// already-cross-linked event straight onto that partial byte-run would
|
|
507
|
+
// MERGE the two into one line that no longer parses — losing THIS event and
|
|
508
|
+
// poisoning the forward chain (the next append's prevEventHash would point
|
|
509
|
+
// into an unparseable blob), with no repair for that shape. So put the new
|
|
510
|
+
// event on its own clean line: prepend a "\n" when the log does not already
|
|
511
|
+
// end in one, confining any crash artifact to its own now-orphaned line.
|
|
512
|
+
// (Reads only the last byte, so the O(1) tail-cache path is preserved.)
|
|
513
|
+
// An empty log (currentBytes === 0, e.g. the first append) has no tail to
|
|
514
|
+
// merge with, so it never needs a leading newline.
|
|
515
|
+
const leadingNewline = currentBytes > 0 && !(0, fs_atomic_1.logEndsWithNewline)(audit.eventLogPath, currentBytes) ? "\n" : "";
|
|
516
|
+
const line = `${leadingNewline}${JSON.stringify(event)}\n`;
|
|
495
517
|
(0, fs_atomic_1.durableAppendFileSync)(audit.eventLogPath, line);
|
|
496
518
|
writeAuditTailCache(tailCachePath, { schemaVersion: 1, logBytes: currentBytes + Buffer.byteLength(line, "utf8"), count: count + 1, lastHash: event.eventHash });
|
|
497
519
|
return event;
|
|
@@ -634,9 +656,64 @@ function commitRows(events, run) {
|
|
|
634
656
|
};
|
|
635
657
|
});
|
|
636
658
|
}
|
|
659
|
+
// Perf cycle P1 (read side): every read command (`cw status`, each
|
|
660
|
+
// `writeReport` call, multi-agent status) called `summarizeTrustAudit`,
|
|
661
|
+
// which read+parsed the log via `readEventsRaw`, THEN called
|
|
662
|
+
// `verifyTrustAudit(run)`, which read+parsed the SAME log AGAIN and
|
|
663
|
+
// recomputed a sha256 for every event -- two full passes for one call.
|
|
664
|
+
// It then durably (fsync) rewrote summary.json AND index.json on EVERY
|
|
665
|
+
// call, even when nothing about the run or the log had changed since the
|
|
666
|
+
// last call (a live audit reaches ~50k events, so `writeReport` alone --
|
|
667
|
+
// which calls this once per pipeline step and per feedback op -- turned
|
|
668
|
+
// into a real, repeated cost).
|
|
669
|
+
//
|
|
670
|
+
// Fix, in two parts:
|
|
671
|
+
// 1. Read the log ONCE (`readEventsRawCounted`) and hand the SAME parsed
|
|
672
|
+
// array to `verifyEventsChain` directly -- the exact same check
|
|
673
|
+
// `verifyTrustAudit` runs with no anchor, so the integrity result is
|
|
674
|
+
// byte-identical to before. `verifyTrustAudit` itself is untouched and
|
|
675
|
+
// every OTHER caller (doctor.ts, `cw audit verify`, drive.ts,
|
|
676
|
+
// run-export.ts) still gets a real, independent, always-full check.
|
|
677
|
+
// 2. Skip the durable rewrite of summary.json/index.json when the freshly
|
|
678
|
+
// and FULLY recomputed content is unchanged from last time. This is
|
|
679
|
+
// NOT a shortcut on verification -- the full parse+rehash+chain-walk
|
|
680
|
+
// above always runs, on every call, no exceptions. A cache that
|
|
681
|
+
// reused a stale "verified" verdict would be unsound here: this
|
|
682
|
+
// codebase's chain check is a sequential field-comparison walk, not a
|
|
683
|
+
// content-addressed (Merkle) chain, so tampering with an EARLY event
|
|
684
|
+
// can leave the file's total size and its LAST event's bytes fully
|
|
685
|
+
// unchanged while still flipping `verified` to false. So the cache
|
|
686
|
+
// below only ever gates the DISK WRITE of an already fully-verified
|
|
687
|
+
// result, never the verification itself, and the function's RETURN
|
|
688
|
+
// VALUE is always the fresh `summary` object either way.
|
|
689
|
+
function summaryFingerprintPathFor(summaryPath) {
|
|
690
|
+
return path.join(path.dirname(summaryPath), "summary-fingerprint.json");
|
|
691
|
+
}
|
|
692
|
+
function readSummaryFingerprint(fingerprintPath) {
|
|
693
|
+
if (!fs.existsSync(fingerprintPath))
|
|
694
|
+
return undefined;
|
|
695
|
+
try {
|
|
696
|
+
const parsed = JSON.parse(fs.readFileSync(fingerprintPath, "utf8"));
|
|
697
|
+
if (parsed && parsed.schemaVersion === 1 && typeof parsed.hash === "string")
|
|
698
|
+
return parsed.hash;
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
// Corrupt/unreadable fingerprint -- fall back to a real rewrite below.
|
|
702
|
+
}
|
|
703
|
+
return undefined;
|
|
704
|
+
}
|
|
705
|
+
function writeSummaryFingerprint(fingerprintPath, fingerprint) {
|
|
706
|
+
try {
|
|
707
|
+
(0, fs_atomic_1.writeJson)(fingerprintPath, fingerprint);
|
|
708
|
+
}
|
|
709
|
+
catch {
|
|
710
|
+
// Best-effort: a failed fingerprint write must never break the real call
|
|
711
|
+
// -- it just means the next call falls back to a real rewrite.
|
|
712
|
+
}
|
|
713
|
+
}
|
|
637
714
|
function summarizeTrustAudit(run) {
|
|
638
715
|
const audit = ensureTrustAudit(run);
|
|
639
|
-
const events =
|
|
716
|
+
const { events, corruptLines } = readEventsRawCounted(audit.eventLogPath);
|
|
640
717
|
const ma = run.multiAgent;
|
|
641
718
|
const bb = run.blackboard;
|
|
642
719
|
const summary = {
|
|
@@ -644,7 +721,7 @@ function summarizeTrustAudit(run) {
|
|
|
644
721
|
runId: run.id,
|
|
645
722
|
generatedAt: new Date().toISOString(),
|
|
646
723
|
eventCount: events.length,
|
|
647
|
-
integrity:
|
|
724
|
+
integrity: verifyEventsChain(run.id, events, corruptLines),
|
|
648
725
|
eventLogPath: audit.eventLogPath,
|
|
649
726
|
indexPath: audit.indexPath,
|
|
650
727
|
summaryPath: audit.summaryPath,
|
|
@@ -688,11 +765,7 @@ function summarizeTrustAudit(run) {
|
|
|
688
765
|
policyViolations: events.filter((e) => e.kind === "policy.violation").length,
|
|
689
766
|
},
|
|
690
767
|
};
|
|
691
|
-
|
|
692
|
-
// them durably so a crash can't leave them pointing past the last durably-
|
|
693
|
-
// appended event. Byte-behavior port of the old build's summarizeTrustAudit.
|
|
694
|
-
(0, fs_atomic_1.writeJson)(audit.summaryPath, summary, { durable: true });
|
|
695
|
-
(0, fs_atomic_1.writeJson)(audit.indexPath, {
|
|
768
|
+
const index = {
|
|
696
769
|
schemaVersion: exports.TRUST_AUDIT_SCHEMA_VERSION,
|
|
697
770
|
runId: run.id,
|
|
698
771
|
events: events.map((event) => ({
|
|
@@ -707,7 +780,28 @@ function summarizeTrustAudit(run) {
|
|
|
707
780
|
sandboxProfileId: event.sandboxProfileId,
|
|
708
781
|
policyRef: event.policyRef,
|
|
709
782
|
})),
|
|
710
|
-
}
|
|
783
|
+
};
|
|
784
|
+
// Durable: the summary/index are the read-side view of the audit log; persist
|
|
785
|
+
// them durably so a crash can't leave them pointing past the last durably-
|
|
786
|
+
// appended event. Byte-behavior port of the old build's summarizeTrustAudit.
|
|
787
|
+
//
|
|
788
|
+
// Skip the rewrite only when the freshly, fully recomputed content is
|
|
789
|
+
// unchanged from last time (see the comment on `summaryFingerprintPathFor`
|
|
790
|
+
// above) -- `generatedAt` is dropped before fingerprinting since it is the
|
|
791
|
+
// one field that legitimately changes on every call. The RETURNED
|
|
792
|
+
// `summary` below always carries a real, fresh `generatedAt` either way;
|
|
793
|
+
// only the ON-DISK bytes may keep the previous call's `generatedAt` when
|
|
794
|
+
// nothing else changed.
|
|
795
|
+
const { generatedAt: _generatedAt, ...summaryForFingerprint } = summary;
|
|
796
|
+
void _generatedAt;
|
|
797
|
+
const fingerprintPath = summaryFingerprintPathFor(audit.summaryPath);
|
|
798
|
+
const freshFingerprint = (0, hash_1.stableHash)({ summary: summaryForFingerprint, index });
|
|
799
|
+
const priorFingerprint = readSummaryFingerprint(fingerprintPath);
|
|
800
|
+
if (priorFingerprint !== freshFingerprint) {
|
|
801
|
+
(0, fs_atomic_1.writeJson)(audit.summaryPath, summary, { durable: true });
|
|
802
|
+
(0, fs_atomic_1.writeJson)(audit.indexPath, index, { durable: true });
|
|
803
|
+
writeSummaryFingerprint(fingerprintPath, { schemaVersion: 1, hash: freshFingerprint });
|
|
804
|
+
}
|
|
711
805
|
run.audit = { schemaVersion: exports.TRUST_AUDIT_SCHEMA_VERSION, ...audit };
|
|
712
806
|
return summary;
|
|
713
807
|
}
|
|
@@ -52,13 +52,55 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
52
52
|
})();
|
|
53
53
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
54
54
|
exports.WorkbenchHost = void 0;
|
|
55
|
+
exports.formatServeHint = formatServeHint;
|
|
56
|
+
exports.printServeHint = printServeHint;
|
|
55
57
|
const crypto = __importStar(require("node:crypto"));
|
|
56
58
|
const fs = __importStar(require("node:fs"));
|
|
57
59
|
const http = __importStar(require("node:http"));
|
|
58
60
|
const path = __importStar(require("node:path"));
|
|
59
61
|
const node_url_1 = require("node:url");
|
|
60
62
|
const workbench_1 = require("./workbench");
|
|
63
|
+
// `[::1]` is the form Node's URL parser returns for a bracketed IPv6
|
|
64
|
+
// literal Host header (verified); the bare `::1` covers a non-bracketed
|
|
65
|
+
// header that our fallback split still yields. `.split(":")[0]` alone could
|
|
66
|
+
// never produce either (it returns `[` or ``), so those entries used to be
|
|
67
|
+
// dead — parseHostname below fixes that.
|
|
61
68
|
const ALLOWED_HOSTNAMES = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
69
|
+
/** Extract the hostname from a Host header, correctly handling a bracketed
|
|
70
|
+
* IPv6 literal (`[::1]:7717`) that a naive `split(":")[0]` mangles into
|
|
71
|
+
* `"["`. Returns undefined when the header cannot be parsed at all. */
|
|
72
|
+
function parseHostname(hostHeader) {
|
|
73
|
+
try {
|
|
74
|
+
return new node_url_1.URL(`http://${hostHeader}`).hostname;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// A non-bracketed IPv6 literal (`::1`) is not a valid URL authority, so
|
|
78
|
+
// URL() throws — fall back to the raw header for that one case; a
|
|
79
|
+
// hostname:port still trims the port.
|
|
80
|
+
if (hostHeader.includes("]") || hostHeader.split(":").length > 2)
|
|
81
|
+
return hostHeader;
|
|
82
|
+
return hostHeader.split(":")[0];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function isTTY(stream) {
|
|
86
|
+
return Boolean(stream.isTTY);
|
|
87
|
+
}
|
|
88
|
+
/** Pure text for the human-friendly serve hint line. Kept separate from
|
|
89
|
+
* the TTY gate below so it is directly unit-testable with no fake stream
|
|
90
|
+
* at all: just a port number in, one line of text out. */
|
|
91
|
+
function formatServeHint(boundPort) {
|
|
92
|
+
return `workbench serving at http://127.0.0.1:${boundPort} (Ctrl-C to stop)`;
|
|
93
|
+
}
|
|
94
|
+
/** Prints the human hint line to STDERR only, and only on a real
|
|
95
|
+
* interactive terminal — the same TTY-gated-nicety pattern as
|
|
96
|
+
* term.ts's printSuccessSummary: silent when the stream is not a TTY, so
|
|
97
|
+
* a piped/non-interactive run (and the existing STDOUT descriptor line)
|
|
98
|
+
* never changes at all. */
|
|
99
|
+
function printServeHint(boundPort, stream = process.stderr) {
|
|
100
|
+
if (!isTTY(stream))
|
|
101
|
+
return;
|
|
102
|
+
stream.write(`${formatServeHint(boundPort)}\n`);
|
|
103
|
+
}
|
|
62
104
|
function timingSafeEqual(a, b) {
|
|
63
105
|
const bufA = Buffer.from(a, "utf8");
|
|
64
106
|
const bufB = Buffer.from(b, "utf8");
|
|
@@ -72,6 +114,13 @@ function timingSafeEqual(a, b) {
|
|
|
72
114
|
return crypto.timingSafeEqual(bufA, bufB);
|
|
73
115
|
}
|
|
74
116
|
function sendJson(res, status, body) {
|
|
117
|
+
// If a handler already began the response (e.g. an asset read threw AFTER
|
|
118
|
+
// writeHead), a second writeHead throws ERR_HTTP_HEADERS_SENT synchronously
|
|
119
|
+
// inside the request listener and kills the whole server. Guard against it.
|
|
120
|
+
if (res.headersSent) {
|
|
121
|
+
res.end();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
75
124
|
const text = `${JSON.stringify(body, null, 2)}\n`;
|
|
76
125
|
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
|
|
77
126
|
res.end(text);
|
|
@@ -124,8 +173,8 @@ class WorkbenchHost {
|
|
|
124
173
|
try {
|
|
125
174
|
const hostHeader = req.headers.host;
|
|
126
175
|
if (hostHeader) {
|
|
127
|
-
const hostname = hostHeader
|
|
128
|
-
if (!ALLOWED_HOSTNAMES.has(hostname)) {
|
|
176
|
+
const hostname = parseHostname(hostHeader);
|
|
177
|
+
if (hostname === undefined || !ALLOWED_HOSTNAMES.has(hostname)) {
|
|
129
178
|
sendJson(res, 403, { error: "forbidden: non-localhost Host header" });
|
|
130
179
|
return;
|
|
131
180
|
}
|
|
@@ -143,10 +192,6 @@ class WorkbenchHost {
|
|
|
143
192
|
sendJson(res, 400, { error: "bad request: invalid URL" });
|
|
144
193
|
return;
|
|
145
194
|
}
|
|
146
|
-
if (!this.checkAuth(req, url)) {
|
|
147
|
-
sendJson(res, 401, { error: "unauthorized: token mismatch" });
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
195
|
// Decode the route path ONCE (old build's decodeRoutePath). Node's
|
|
151
196
|
// `new URL()` does NOT throw on a malformed percent-escape like
|
|
152
197
|
// `/%E0%A4%A`; only decodeURIComponent does — so this is where a
|
|
@@ -162,13 +207,36 @@ class WorkbenchHost {
|
|
|
162
207
|
sendJson(res, 400, { error: "bad request: malformed URL path" });
|
|
163
208
|
return;
|
|
164
209
|
}
|
|
210
|
+
// Auth is checked AFTER the route is decoded, and only where run data
|
|
211
|
+
// (or environment data) can flow. Before this, a set token made the
|
|
212
|
+
// browser's own follow-up requests for /ui/app.css and /ui/app.js fail
|
|
213
|
+
// 401 — the page rendered as broken unstyled HTML with no explanation.
|
|
214
|
+
// The three shipped UI files are generic static code with no run data,
|
|
215
|
+
// so they are served without a token. Every /api/* route carries run
|
|
216
|
+
// data and stays behind the token. The "/" route is split: an
|
|
217
|
+
// INSTALLED index.html is the same generic static code (open), but the
|
|
218
|
+
// FALLBACK page embeds the serve descriptor — which carries the
|
|
219
|
+
// absolute repo root path — so a missing UI keeps "/" behind the token.
|
|
220
|
+
if (!this.checkAuth(req, url)) {
|
|
221
|
+
const uiIndexInstalled = fs.existsSync(path.resolve((0, workbench_1.workbenchUiRoot)(), "index.html"));
|
|
222
|
+
if (route.startsWith("/api/") || (route === "/" && !uiIndexInstalled)) {
|
|
223
|
+
sendJson(res, 401, { error: "unauthorized: token mismatch" });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
165
227
|
if (route === "/api/serve") {
|
|
166
228
|
sendJson(res, 200, this.descriptor(false));
|
|
167
229
|
return;
|
|
168
230
|
}
|
|
169
231
|
if (route === "/api/index") {
|
|
170
232
|
const scope = this.args.scope === "home" ? "home" : "repo";
|
|
171
|
-
|
|
233
|
+
// Only `text` may be driven by the query string — it is the sidebar
|
|
234
|
+
// filter. Spreading ALL query params (as before) let a request
|
|
235
|
+
// override the server's computed `scope` or inject an arbitrary
|
|
236
|
+
// `cwd`, reading run data from any directory the serving user can
|
|
237
|
+
// read, past the repo the operator chose to serve.
|
|
238
|
+
const text = url.searchParams.get("text") || undefined;
|
|
239
|
+
sendJson(res, 200, (0, workbench_1.buildWorkbenchIndex)({ ...this.args, scope, ...(text ? { text } : {}) }));
|
|
172
240
|
return;
|
|
173
241
|
}
|
|
174
242
|
const runMatch = route.match(/^\/api\/run\/([^/]+)$/);
|
|
@@ -204,15 +272,30 @@ class WorkbenchHost {
|
|
|
204
272
|
sendJson(res, 404, { error: `UI asset not installed: ${relative}` });
|
|
205
273
|
return;
|
|
206
274
|
}
|
|
275
|
+
// Read the bytes BEFORE writeHead: if the asset vanished or became
|
|
276
|
+
// unreadable between the checks above and here (TOCTOU), the throw
|
|
277
|
+
// then lands on the sendJson(500) path with headers not yet sent,
|
|
278
|
+
// instead of a fatal double-writeHead.
|
|
279
|
+
const bytes = fs.readFileSync(resolved);
|
|
207
280
|
res.writeHead(200, { "Content-Type": contentTypeFor(resolved), "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
|
|
208
|
-
res.end(
|
|
281
|
+
res.end(bytes);
|
|
209
282
|
}
|
|
210
283
|
/** Binds the loopback server and resolves once listening. Returns the
|
|
211
284
|
* actually-bound port (useful for `--port 0`). */
|
|
212
285
|
listen() {
|
|
213
286
|
return new Promise((resolve, reject) => {
|
|
287
|
+
// Defense in depth: `run()` already validated the port before ever
|
|
288
|
+
// reaching here, but a direct caller of listen() still fails closed
|
|
289
|
+
// with the clear message rather than node's opaque ERR_SOCKET_BAD_PORT.
|
|
290
|
+
let requestedPort;
|
|
291
|
+
try {
|
|
292
|
+
requestedPort = (0, workbench_1.parseWorkbenchPort)(this.args.port);
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
reject(error);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
214
298
|
const server = http.createServer((req, res) => this.handleRequest(req, res));
|
|
215
|
-
const requestedPort = this.args.port !== undefined ? Number(this.args.port) : undefined;
|
|
216
299
|
server.on("error", reject);
|
|
217
300
|
server.listen(requestedPort ?? 7717, "127.0.0.1", () => {
|
|
218
301
|
this.server = server;
|
|
@@ -243,14 +326,42 @@ class WorkbenchHost {
|
|
|
243
326
|
process.exitCode = 1;
|
|
244
327
|
return;
|
|
245
328
|
}
|
|
329
|
+
// Validate --port HERE, before any dispatch. The CLI binding calls this
|
|
330
|
+
// as `void host.run()` (not awaited), so a rejection from listen() would
|
|
331
|
+
// otherwise surface as an unhandled promise rejection stack dump on the
|
|
332
|
+
// real CLI. Fail closed the same clean way as --require-token: one `cw:`
|
|
333
|
+
// stderr line + exit 1 + return, never a crash and never a bound server.
|
|
334
|
+
try {
|
|
335
|
+
(0, workbench_1.parseWorkbenchPort)(this.args.port);
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
process.stderr.write(`cw: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
339
|
+
process.exitCode = 1;
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
246
342
|
const once = Boolean(this.args.once || this.args.json);
|
|
247
343
|
if (once) {
|
|
248
344
|
process.stdout.write(`${JSON.stringify(this.descriptor(true))}\n`);
|
|
249
345
|
return;
|
|
250
346
|
}
|
|
251
|
-
|
|
347
|
+
// The bind itself can still fail at runtime (EADDRINUSE when the port
|
|
348
|
+
// is taken, EACCES on a privileged port) — listen() rejects for those.
|
|
349
|
+
// The port-VALUE check above only covers a malformed --port. Fail
|
|
350
|
+
// closed the same clean way rather than an unhandled-rejection crash.
|
|
351
|
+
let boundPort;
|
|
352
|
+
try {
|
|
353
|
+
boundPort = await this.listen();
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
357
|
+
process.stderr.write(`cw: ${message}\n`);
|
|
358
|
+
process.stderr.write(`Try: pick another port with --port <n>, or stop whatever is already serving.\n`);
|
|
359
|
+
process.exitCode = 1;
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
252
362
|
const descriptor = this.descriptor(false, boundPort);
|
|
253
363
|
process.stdout.write(`${JSON.stringify({ ...descriptor, boundPort })}\n`);
|
|
364
|
+
printServeHint(boundPort);
|
|
254
365
|
// Block forever (until the process is killed) — a real serve.
|
|
255
366
|
await new Promise(() => { });
|
|
256
367
|
}
|
package/dist/shell/workbench.js
CHANGED
|
@@ -47,6 +47,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
47
47
|
})();
|
|
48
48
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
49
49
|
exports.WORKBENCH_UI_RELATIVE = exports.WORKBENCH_DEFAULT_PORT = void 0;
|
|
50
|
+
exports.parseWorkbenchPort = parseWorkbenchPort;
|
|
50
51
|
exports.buildWorkbenchRunView = buildWorkbenchRunView;
|
|
51
52
|
exports.workbenchUiRoot = workbenchUiRoot;
|
|
52
53
|
exports.buildWorkbenchServeDescriptor = buildWorkbenchServeDescriptor;
|
|
@@ -56,6 +57,32 @@ const capability_table_1 = require("../core/capability-table");
|
|
|
56
57
|
const run_store_1 = require("./run-store");
|
|
57
58
|
exports.WORKBENCH_DEFAULT_PORT = 7717;
|
|
58
59
|
exports.WORKBENCH_UI_RELATIVE = "ui/workbench";
|
|
60
|
+
/** Parse and range-check a workbench `--port` value. Returns `undefined`
|
|
61
|
+
* when no port was given (the caller then uses WORKBENCH_DEFAULT_PORT),
|
|
62
|
+
* the validated integer otherwise. Throws a clear Error on a bad value —
|
|
63
|
+
* a non-number (`NaN`), a float, a negative, or a number over 65535 — so
|
|
64
|
+
* callers fail closed with an actionable line instead of node's opaque
|
|
65
|
+
* ERR_SOCKET_BAD_PORT or a `"port": null` descriptor. A valid port is an
|
|
66
|
+
* integer in [0, 65535]; 0 is the legitimately-supported ephemeral port. */
|
|
67
|
+
function parseWorkbenchPort(raw) {
|
|
68
|
+
if (raw === undefined)
|
|
69
|
+
return undefined;
|
|
70
|
+
const reject = () => {
|
|
71
|
+
throw new Error(`workbench serve --port must be an integer 0-65535 (got ${JSON.stringify(raw)})`);
|
|
72
|
+
};
|
|
73
|
+
// Only a string (the argv form) or a number is a real port. A valueless
|
|
74
|
+
// `--port` flag parses to boolean `true`; reject it rather than let
|
|
75
|
+
// Number(true) === 1 silently bind to port 1. A blank string is bad input
|
|
76
|
+
// too — Number("") === 0 would otherwise pass as the ephemeral port.
|
|
77
|
+
if (typeof raw !== "string" && typeof raw !== "number")
|
|
78
|
+
reject();
|
|
79
|
+
if (typeof raw === "string" && raw.trim() === "")
|
|
80
|
+
reject();
|
|
81
|
+
const port = Number(raw);
|
|
82
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
83
|
+
reject();
|
|
84
|
+
return port;
|
|
85
|
+
}
|
|
59
86
|
/** Panel groups/members, in the SPEC's declared order. Each entry names
|
|
60
87
|
* the capability id whose MCP handler is called to fill the panel. */
|
|
61
88
|
const PANEL_MAP = {
|
|
@@ -104,6 +131,21 @@ function buildWorkbenchRunView(runId, args = {}) {
|
|
|
104
131
|
resolved = false;
|
|
105
132
|
resolveError = error instanceof Error ? error.message : String(error);
|
|
106
133
|
}
|
|
134
|
+
// The run's lifecycle, from the SAME `run.show` capability handler the
|
|
135
|
+
// CLI/MCP use (the buildWorkbenchIndex composition style — never a
|
|
136
|
+
// duplicate implementation). This function's contract is never-throws,
|
|
137
|
+
// so any failure here just leaves the key out.
|
|
138
|
+
let lifecycle;
|
|
139
|
+
try {
|
|
140
|
+
const showRow = (0, capability_table_1.findCapability)("run.show");
|
|
141
|
+
const shown = showRow?.mcp ? showRow.mcp.handler({ ...args, runId, cwd }) : undefined;
|
|
142
|
+
if (shown && shown.found === true && shown.record && typeof shown.record.lifecycle === "string") {
|
|
143
|
+
lifecycle = shown.record.lifecycle;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
lifecycle = undefined;
|
|
148
|
+
}
|
|
107
149
|
const panels = {};
|
|
108
150
|
const panelArgs = { ...args, runId, cwd };
|
|
109
151
|
for (const [group, members] of Object.entries(PANEL_MAP)) {
|
|
@@ -114,7 +156,7 @@ function buildWorkbenchRunView(runId, args = {}) {
|
|
|
114
156
|
: { capability, cli: cliCommandFor(capability), mcp: mcpToolFor(capability), status: "absent", error: resolveError };
|
|
115
157
|
}
|
|
116
158
|
}
|
|
117
|
-
return { schemaVersion: 1, surface: "workbench", runId, resolved, ...(resolveError ? { error: resolveError } : {}), panels };
|
|
159
|
+
return { schemaVersion: 1, surface: "workbench", runId, resolved, ...(lifecycle ? { lifecycle } : {}), ...(resolveError ? { error: resolveError } : {}), panels };
|
|
118
160
|
}
|
|
119
161
|
/** Package-relative resolution only — never falls back to the invocation
|
|
120
162
|
* cwd. `ui/` ships as a sibling of `dist/` in the published package
|
|
@@ -143,7 +185,10 @@ function buildWorkbenchServeDescriptor(args = {}, boundPort) {
|
|
|
143
185
|
surface: "workbench",
|
|
144
186
|
command: "serve",
|
|
145
187
|
host: "127.0.0.1",
|
|
146
|
-
|
|
188
|
+
// `boundPort` (from a real listen()) is already a valid port. Otherwise
|
|
189
|
+
// validate the requested `--port` so the `--once`/`--json`/MCP descriptor
|
|
190
|
+
// path fails closed with a clear line instead of emitting `"port": null`.
|
|
191
|
+
port: boundPort ?? (parseWorkbenchPort(args.port) ?? exports.WORKBENCH_DEFAULT_PORT),
|
|
147
192
|
once: Boolean(args.once),
|
|
148
193
|
readOnly: true,
|
|
149
194
|
scope: args.scope === "home" ? "home" : "repo",
|
|
@@ -158,13 +203,42 @@ function buildWorkbenchServeDescriptor(args = {}, boundPort) {
|
|
|
158
203
|
* list`), each embedded VERBATIM from its own already-declared capability
|
|
159
204
|
* handler — the Workbench adds no new source of truth. Composed the same
|
|
160
205
|
* way the panels are (findCapability(...).mcp.handler), so `/api/index`
|
|
161
|
-
* can never drift from the standalone `cw` commands. Read-only.
|
|
206
|
+
* can never drift from the standalone `cw` commands. Read-only.
|
|
207
|
+
*
|
|
208
|
+
* When `args.text` names a non-blank filter (the Workbench UI's sidebar
|
|
209
|
+
* filter box, `ui/workbench/app.js`'s `loadIndex`), the run list is filled
|
|
210
|
+
* via the `run.search` capability instead of `run.list` — `run.list`'s own
|
|
211
|
+
* handler (`runListCli` -> `RunRegistry.list()`) never reads a `text`
|
|
212
|
+
* field, so calling it with a filter present would silently ignore it.
|
|
213
|
+
* With no `text` filter the call is unchanged: `run.list`, byte-identical
|
|
214
|
+
* to the payload before this branch existed. */
|
|
162
215
|
function buildWorkbenchIndex(args = {}) {
|
|
163
216
|
const scope = args.scope === "home" ? "home" : "repo";
|
|
164
217
|
const scoped = { ...args, scope };
|
|
165
218
|
const registryRow = (0, capability_table_1.findCapability)("registry.show");
|
|
166
|
-
const
|
|
219
|
+
const text = typeof args.text === "string" ? args.text.trim() : "";
|
|
220
|
+
const runListRow = (0, capability_table_1.findCapability)(text ? "run.search" : "run.list");
|
|
167
221
|
const registry = registryRow?.mcp ? registryRow.mcp.handler(scoped) : undefined;
|
|
168
|
-
const runs = runListRow?.mcp ? runListRow.mcp.handler
|
|
222
|
+
const runs = runListRow?.mcp ? newestRunPage(runListRow.mcp.handler, scoped) : [];
|
|
169
223
|
return { schemaVersion: 1, surface: "workbench", command: "index", scope, registry, runs };
|
|
170
224
|
}
|
|
225
|
+
/** The run list/search handler sorts oldest-first and returns only the
|
|
226
|
+
* first `limit` page — so the default page is the OLDEST runs, and a scope
|
|
227
|
+
* with more runs than the page size never shows the newest run at all. The
|
|
228
|
+
* Workbench wants the newest page: when the caller pinned no offset and the
|
|
229
|
+
* total exceeds one page, re-fetch with the offset that lands on the last
|
|
230
|
+
* page. The payload shape (total/offset/limit/records) is unchanged, so
|
|
231
|
+
* the UI can still show "showing latest N of M". */
|
|
232
|
+
function newestRunPage(handler, scoped) {
|
|
233
|
+
const first = handler(scoped);
|
|
234
|
+
if (scoped.offset !== undefined)
|
|
235
|
+
return first;
|
|
236
|
+
if (!first || typeof first !== "object")
|
|
237
|
+
return first;
|
|
238
|
+
const page = first;
|
|
239
|
+
const total = typeof page.total === "number" ? page.total : undefined;
|
|
240
|
+
const limit = typeof page.limit === "number" ? page.limit : undefined;
|
|
241
|
+
if (total === undefined || limit === undefined || total <= limit)
|
|
242
|
+
return first;
|
|
243
|
+
return handler({ ...scoped, offset: total - limit });
|
|
244
|
+
}
|
|
@@ -703,7 +703,7 @@ function recordWorkerOutput(run, workerId, resultPath, options = {}) {
|
|
|
703
703
|
artifacts: [{ id: "result", kind: "markdown", path: destination }],
|
|
704
704
|
evidence: resultNode.evidence.length ? resultNode.evidence : [{ id: "result:summary", source: "summary", summary: parsedResult.summary }],
|
|
705
705
|
metadata: { taskId: task.id, workerId, resultNodeId: resultNode.id, sandboxProfileId: scope.sandboxProfileId },
|
|
706
|
-
}, { persist: false, persistNode: node_store_1.appendRunNode });
|
|
706
|
+
}, { persist: false, persistNode: node_store_1.appendRunNode, pathExists: fs.existsSync });
|
|
707
707
|
task.verifierNodeId = verifierResult.outputNodeId;
|
|
708
708
|
// Step 5: completion — persist the worker scope with the verify-derived status.
|
|
709
709
|
const output = { workerId, taskId: task.id, resultPath: absoluteResultPath, recordedAt: new Date().toISOString(), stateNodeId: resultNode.id, verifierNodeId: task.verifierNodeId, auditEventIds: [pathAudit.id, acceptedAudit.id] };
|