cool-workflow 0.1.89 → 0.1.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +42 -1
  4. package/apps/architecture-review/app.json +1 -1
  5. package/apps/architecture-review-fast/app.json +1 -1
  6. package/apps/end-to-end-golden-path/app.json +1 -1
  7. package/apps/pr-review-fix-ci/app.json +1 -1
  8. package/apps/release-cut/app.json +1 -1
  9. package/apps/research-synthesis/app.json +1 -1
  10. package/dist/capability-core.js +151 -9
  11. package/dist/capability-registry.js +6 -0
  12. package/dist/cli/command-surface.js +143 -6
  13. package/dist/cli.js +26 -1
  14. package/dist/clones.js +162 -0
  15. package/dist/drive.js +37 -2
  16. package/dist/mcp/tool-call.js +4 -0
  17. package/dist/mcp/tool-definitions.js +5 -0
  18. package/dist/orchestrator/report.js +6 -0
  19. package/dist/orchestrator.js +15 -4
  20. package/dist/remote-source.js +444 -0
  21. package/dist/reporter.js +67 -0
  22. package/dist/term.js +127 -9
  23. package/dist/version.js +1 -1
  24. package/docs/agent-delegation-drive.7.md +41 -22
  25. package/docs/cli-mcp-parity.7.md +9 -2
  26. package/docs/contract-migration-tooling.7.md +4 -0
  27. package/docs/control-plane-scheduling.7.md +4 -0
  28. package/docs/durable-state-and-locking.7.md +4 -0
  29. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  30. package/docs/execution-backends.7.md +4 -0
  31. package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
  32. package/docs/multi-agent-eval-replay-harness.7.md +4 -0
  33. package/docs/multi-agent-operator-ux.7.md +4 -0
  34. package/docs/node-snapshot-diff-replay.7.md +4 -0
  35. package/docs/observability-cost-accounting.7.md +4 -0
  36. package/docs/project-index.md +15 -4
  37. package/docs/real-execution-backends.7.md +4 -0
  38. package/docs/release-and-migration.7.md +4 -0
  39. package/docs/release-tooling.7.md +4 -0
  40. package/docs/remote-source-review.7.md +88 -0
  41. package/docs/run-registry-control-plane.7.md +4 -0
  42. package/docs/run-retention-reclamation.7.md +4 -0
  43. package/docs/state-explosion-management.7.md +4 -0
  44. package/docs/team-collaboration.7.md +4 -0
  45. package/docs/web-desktop-workbench.7.md +4 -0
  46. package/manifest/plugin.manifest.json +1 -1
  47. package/manifest/source-context-profiles.json +1 -1
  48. package/package.json +1 -1
  49. package/scripts/agents/agent-adapter-core.js +137 -3
  50. package/scripts/agents/claude-p-agent.js +24 -15
  51. package/scripts/agents/codex-agent.js +11 -8
  52. package/scripts/agents/gemini-agent.js +11 -8
  53. package/scripts/agents/opencode-agent.js +11 -8
  54. package/scripts/canonical-apps.js +4 -4
  55. package/scripts/dogfood-release.js +1 -1
  56. package/scripts/golden-path.js +4 -4
@@ -0,0 +1,444 @@
1
+ "use strict";
2
+ // src/remote-source.ts — materialize a REMOTE repository (a URL) into a LOCAL checkout
3
+ // so the existing review pipeline can run against it unchanged.
4
+ //
5
+ // This lives in the CAPABILITY layer and is imported ONLY by capability-core (+ smokes),
6
+ // never by the orchestrator/drive core — cloning is non-deterministic network I/O and the
7
+ // core must stay replay-deterministic. After materialize(), the caller points `args.repo`
8
+ // at the returned local path and everything downstream is identical to a local run.
9
+ //
10
+ // Zero runtime deps: `git` via spawnSync (the gitLines/gitOne shape from onramp.ts), plus
11
+ // node:crypto for the cache key. Fail closed: a bad URL / blocked scheme / network / auth
12
+ // failure throws an explicit error (never a fabricated success). All diagnostics are in the
13
+ // thrown message (the caller routes them to stderr); this module writes nothing to stdout.
14
+ var __importDefault = (this && this.__importDefault) || function (mod) {
15
+ return (mod && mod.__esModule) ? mod : { "default": mod };
16
+ };
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.classifyRemote = classifyRemote;
19
+ exports.isRemoteUrl = isRemoteUrl;
20
+ exports.sanitizeUrl = sanitizeUrl;
21
+ exports.validateRemoteUrl = validateRemoteUrl;
22
+ exports.gitAvailable = gitAvailable;
23
+ exports.redactCredentials = redactCredentials;
24
+ exports.materializeRemote = materializeRemote;
25
+ const node_child_process_1 = require("node:child_process");
26
+ const node_crypto_1 = require("node:crypto");
27
+ const node_fs_1 = __importDefault(require("node:fs"));
28
+ const node_path_1 = __importDefault(require("node:path"));
29
+ const node_url_1 = require("node:url");
30
+ const run_registry_1 = require("./run-registry");
31
+ /** Upper bound on a downloaded archive (200 MiB) — a review target should be source, not a
32
+ * binary blob; a giant download fails closed rather than exhausting disk/memory. */
33
+ const MAX_ARCHIVE_BYTES = 200 * 1024 * 1024;
34
+ /** Upper bound on the EXTRACTED tree (1 GiB) — a small archive can decompress to terabytes
35
+ * (a "zip bomb"). We reject by declared size before extracting AND by actual size after. */
36
+ const MAX_EXTRACTED_BYTES = 1024 * 1024 * 1024;
37
+ /** git transport schemes we will hand to `git clone`. http is permitted (some internal
38
+ * servers), but `ext::`/`fd::` remote-helpers and anything else are rejected. */
39
+ const ALLOWED_SCHEMES = new Set(["https", "http", "ssh", "git", "file"]);
40
+ const ARCHIVE_EXT = /\.(tar\.gz|tgz|tar|zip)$/i;
41
+ const SCP_LIKE = /^[^/@\s]+@[^/@\s:]+:/; // git@host:owner/repo (no scheme)
42
+ const HELPER_LIKE = /^[a-z][a-z0-9+.-]*::/i; // ext::, fd::, transport::address
43
+ /** Classify a flag value as a git URL, an archive URL, or a local path. Conservative:
44
+ * a value with NO remote marker is "local", so a real directory is never mis-fetched. */
45
+ function classifyRemote(value) {
46
+ if (!value)
47
+ return "local";
48
+ const v = value.trim();
49
+ const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(v);
50
+ const isScp = SCP_LIKE.test(v) && !v.includes("://");
51
+ const isHelper = HELPER_LIKE.test(v);
52
+ if (!hasScheme && !isScp && !isHelper)
53
+ return "local";
54
+ // Remote-ish. Archive by path extension (helpers/scp are always git-ish).
55
+ const pathPart = hasScheme ? safePathname(v) : v;
56
+ if (ARCHIVE_EXT.test(pathPart))
57
+ return "archive";
58
+ return "git";
59
+ }
60
+ /** Convenience: is this value something we should materialize (vs treat as a local path)? */
61
+ function isRemoteUrl(value) {
62
+ return classifyRemote(value) !== "local";
63
+ }
64
+ /** Strip credentials (userinfo) from a URL so it is safe to print/persist/record. scp-style
65
+ * (`git@host:…`) and local paths pass through unchanged (the `git@` user is not a secret). */
66
+ function sanitizeUrl(value) {
67
+ const v = value.trim();
68
+ if (SCP_LIKE.test(v) && !v.includes("://"))
69
+ return v;
70
+ try {
71
+ const u = new URL(v);
72
+ u.username = "";
73
+ u.password = "";
74
+ return u.toString();
75
+ }
76
+ catch {
77
+ return v;
78
+ }
79
+ }
80
+ /** Fail-closed validation BEFORE any subprocess: reject option-injection, blocked transport
81
+ * helpers, and unknown schemes. Throws with an explicit reason; never returns a verdict. */
82
+ function assertSafeUrl(value) {
83
+ const v = value.trim();
84
+ if (v.startsWith("-"))
85
+ throw new Error(`refusing a URL that begins with '-' (option injection): ${v}`);
86
+ if (HELPER_LIKE.test(v))
87
+ throw new Error(`blocked git transport helper in URL (e.g. ext::/fd::): ${v}`);
88
+ if (SCP_LIKE.test(v) && !v.includes("://"))
89
+ return; // scp-style is ssh
90
+ let scheme;
91
+ try {
92
+ scheme = new URL(v).protocol.replace(/:$/, "").toLowerCase();
93
+ }
94
+ catch {
95
+ throw new Error(`unparseable remote URL: ${v}`);
96
+ }
97
+ if (!ALLOWED_SCHEMES.has(scheme)) {
98
+ throw new Error(`unsupported URL scheme '${scheme}:' (allowed: https, http, ssh, git, file): ${v}`);
99
+ }
100
+ }
101
+ /** Validate a remote URL's shape WITHOUT any network I/O (used by `--check`): recognized,
102
+ * scheme-allowlisted, no option-injection or blocked transport helper. Never fetches. */
103
+ function validateRemoteUrl(value) {
104
+ const kind = classifyRemote(value);
105
+ const url = sanitizeUrl(value);
106
+ if (kind === "local")
107
+ return { ok: false, kind, url, reason: "not a recognized remote URL (expected https/ssh/git/file or git@host:repo)" };
108
+ try {
109
+ assertSafeUrl(value);
110
+ }
111
+ catch (error) {
112
+ return { ok: false, kind, url, reason: error.message };
113
+ }
114
+ return { ok: true, kind, url };
115
+ }
116
+ /** True when `git` is on PATH (used by `--check`; materialize asserts this too). */
117
+ function gitAvailable(env) {
118
+ return (0, node_child_process_1.spawnSync)("git", ["--version"], { encoding: "utf8", env: env || process.env }).status === 0;
119
+ }
120
+ function safePathname(value) {
121
+ try {
122
+ return new URL(value).pathname;
123
+ }
124
+ catch {
125
+ return value;
126
+ }
127
+ }
128
+ function cacheRoot(opts) {
129
+ return node_path_1.default.join(opts.home || (0, run_registry_1.resolveCwHome)(opts.env), "clones");
130
+ }
131
+ function cacheDirFor(root, sanitizedUrl, ref) {
132
+ const key = (0, node_crypto_1.createHash)("sha256").update(`${sanitizedUrl}\0${ref || ""}`).digest("hex").slice(0, 24);
133
+ return node_path_1.default.join(root, key);
134
+ }
135
+ /** Defense in depth: strip `user[:pass]@` userinfo from ANY URL in arbitrary text. git's own
136
+ * diagnostics can echo a credential-bearing URL on auth failure (version/transport dependent),
137
+ * so we never relay git's stderr/stdout verbatim — we redact first. Exported for testing. */
138
+ function redactCredentials(text) {
139
+ return String(text).replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]+@/gi, "$1");
140
+ }
141
+ /** Run a git subprocess, returning trimmed stdout; throws with a credential-REDACTED stderr
142
+ * tail on failure (never relays git output verbatim). */
143
+ function git(args, opts, cwd) {
144
+ const env = {
145
+ ...(opts.env || process.env),
146
+ GIT_TERMINAL_PROMPT: "0", // fail closed instead of hanging on an auth prompt
147
+ GIT_ASKPASS: "",
148
+ GCM_INTERACTIVE: "never"
149
+ };
150
+ const result = (0, node_child_process_1.spawnSync)("git", args, {
151
+ cwd,
152
+ env,
153
+ encoding: "utf8",
154
+ timeout: opts.timeoutMs || 120000,
155
+ stdio: ["ignore", "pipe", "pipe"]
156
+ });
157
+ if (result.error)
158
+ throw new Error(`git ${args[0]} could not run: ${redactCredentials(result.error.message)}`);
159
+ if (result.status !== 0) {
160
+ const raw = String(result.stderr || result.stdout || "").trim();
161
+ const tail = redactCredentials(raw).split(/\r?\n/).slice(-3).join("; ");
162
+ throw new Error(`git ${args[0]} failed: ${tail || `exit ${result.status}`}`);
163
+ }
164
+ return String(result.stdout || "").trim();
165
+ }
166
+ function assertGitAvailable(opts) {
167
+ const result = (0, node_child_process_1.spawnSync)("git", ["--version"], { encoding: "utf8", env: opts.env || process.env });
168
+ if (result.status !== 0)
169
+ throw new Error("git is required to review a remote repository but was not found on PATH");
170
+ }
171
+ function writeCloneMeta(dir, meta) {
172
+ try {
173
+ node_fs_1.default.writeFileSync(node_path_1.default.join(dir, ".cw-clone-meta.json"), `${JSON.stringify(meta, null, 2)}\n`, "utf8");
174
+ }
175
+ catch {
176
+ /* meta is advisory (used by `cw clones`); never fail a clone over it */
177
+ }
178
+ }
179
+ function cloneGit(rawUrl, sanitizedUrl, opts) {
180
+ const root = cacheRoot(opts);
181
+ const dir = cacheDirFor(root, sanitizedUrl, opts.ref);
182
+ // Containment: the target MUST be inside clones/ (the key is hex, so this always holds —
183
+ // assert anyway so a future change can never write or, later, gc outside the cache root).
184
+ if (!node_path_1.default.resolve(dir).startsWith(node_path_1.default.resolve(root) + node_path_1.default.sep)) {
185
+ throw new Error(`refusing clone target outside the cache root: ${dir}`);
186
+ }
187
+ if (node_fs_1.default.existsSync(node_path_1.default.join(dir, ".git")) && !opts.refresh) {
188
+ const commit = git(["-C", dir, "rev-parse", "HEAD"], opts);
189
+ if (commit)
190
+ return { localPath: dir, url: sanitizedUrl, commit, kind: "git", ref: opts.ref, cached: true };
191
+ // Corrupt cache entry — fall through to a fresh clone.
192
+ }
193
+ node_fs_1.default.rmSync(dir, { recursive: true, force: true });
194
+ node_fs_1.default.mkdirSync(root, { recursive: true });
195
+ // Array argv + `--` before the URL (never a shell string); shallow + single-branch; repo
196
+ // hooks disabled. The raw URL (which may carry credentials) is passed ONLY as an argv
197
+ // element — it never reaches a shell and never gets persisted (we store the sanitized one).
198
+ const args = [
199
+ "clone",
200
+ "--depth",
201
+ "1",
202
+ "--single-branch",
203
+ ...(opts.ref ? ["--branch", opts.ref] : []),
204
+ "-c",
205
+ "core.hooksPath=",
206
+ "--",
207
+ rawUrl,
208
+ dir
209
+ ];
210
+ try {
211
+ git(args, opts);
212
+ }
213
+ catch (error) {
214
+ node_fs_1.default.rmSync(dir, { recursive: true, force: true });
215
+ throw new Error(`could not clone ${sanitizedUrl}: ${error.message}`);
216
+ }
217
+ const commit = git(["-C", dir, "rev-parse", "HEAD"], opts);
218
+ if (!commit) {
219
+ node_fs_1.default.rmSync(dir, { recursive: true, force: true });
220
+ throw new Error(`cloned ${sanitizedUrl} but could not resolve HEAD`);
221
+ }
222
+ writeCloneMeta(dir, { url: sanitizedUrl, kind: "git", ref: opts.ref || null, commit, fetchedAt: new Date().toISOString() });
223
+ return { localPath: dir, url: sanitizedUrl, commit, kind: "git", ref: opts.ref, cached: false };
224
+ }
225
+ /** Materialize a remote URL into a local checkout. Throws (fail closed) on any bad URL,
226
+ * blocked scheme, missing git, or fetch failure. The caller must have already decided the
227
+ * value is remote (classifyRemote !== "local"). */
228
+ function materializeRemote(value, opts = {}) {
229
+ const kind = classifyRemote(value);
230
+ if (kind === "local")
231
+ throw new Error(`not a remote URL: ${value}`);
232
+ assertSafeUrl(value);
233
+ assertGitAvailable(opts);
234
+ const sanitized = sanitizeUrl(value);
235
+ if (kind === "archive")
236
+ return downloadArchive(value, sanitized, opts);
237
+ return cloneGit(value, sanitized, opts);
238
+ }
239
+ // ---- archive download + extract (.tar.gz / .tgz / .tar / .zip) --------------------------
240
+ /** Read a cached archive checkout's content sha from its meta, or undefined to re-fetch. */
241
+ function cachedArchive(dir, sanitizedUrl, opts) {
242
+ if (opts.refresh || !node_fs_1.default.existsSync(node_path_1.default.join(dir, ".git")))
243
+ return undefined;
244
+ try {
245
+ const meta = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(dir, ".cw-clone-meta.json"), "utf8"));
246
+ if (meta && typeof meta.commit === "string" && meta.commit) {
247
+ return { localPath: dir, url: sanitizedUrl, commit: meta.commit, kind: "archive", ref: opts.ref, cached: true };
248
+ }
249
+ }
250
+ catch {
251
+ /* corrupt/absent meta — fall through to a fresh download */
252
+ }
253
+ return undefined;
254
+ }
255
+ /** Fetch the archive bytes to a temp file: `file://` is read directly; http(s) is fetched in a
256
+ * short node subprocess (Node's built-in fetch — zero deps — run synchronously via spawnSync,
257
+ * since the whole CLI flow is synchronous). The URL is an argv element, never a shell string.
258
+ * SSRF-hardened: redirects are followed MANUALLY and each hop is re-validated (http(s) scheme
259
+ * only, no private/loopback/link-local host) BEFORE we connect to it — so a public URL cannot
260
+ * redirect us into an internal service. The operator's ORIGINAL host is their own choice. */
261
+ function fetchArchiveBytes(rawUrl, sanitizedUrl, dest, opts) {
262
+ if (rawUrl.startsWith("file://")) {
263
+ const src = (0, node_url_1.fileURLToPath)(rawUrl);
264
+ const size = node_fs_1.default.statSync(src).size;
265
+ if (size > MAX_ARCHIVE_BYTES)
266
+ throw new Error(`archive ${sanitizedUrl} is too large (${size} bytes > ${MAX_ARCHIVE_BYTES})`);
267
+ node_fs_1.default.copyFileSync(src, dest);
268
+ return;
269
+ }
270
+ const child = [
271
+ "const fs=require('fs');",
272
+ "const [url,out,cap]=[process.argv[1],process.argv[2],Number(process.argv[3])];",
273
+ "const priv=(h)=>{h=String(h).replace(/^\\[|\\]$/g,'').toLowerCase();",
274
+ "if(h==='localhost'||h.endsWith('.localhost')||h.endsWith('.local'))return true;",
275
+ "if(h==='::1'||h==='0.0.0.0'||h==='::'||h.startsWith('fe80:')||h.startsWith('fc')||h.startsWith('fd'))return true;",
276
+ "const m=h.match(/^(\\d+)\\.(\\d+)\\.(\\d+)\\.(\\d+)$/);",
277
+ "if(m){const a=+m[1],b=+m[2];if(a===127||a===10||a===0||(a===192&&b===168)||(a===172&&b>=16&&b<=31)||(a===169&&b===254))return true;}",
278
+ "return false;};",
279
+ "(async()=>{let u=url;",
280
+ "for(let i=0;i<6;i++){",
281
+ "const r=await fetch(u,{redirect:'manual'});",
282
+ "if(r.status>=300&&r.status<400&&r.headers.get('location')){",
283
+ "const nx=new URL(r.headers.get('location'),u);",
284
+ "if(!/^https?:$/.test(nx.protocol)){process.stderr.write('redirect to disallowed scheme '+nx.protocol);process.exit(5);}",
285
+ "if(priv(nx.hostname)){process.stderr.write('redirect to a private/internal host was blocked');process.exit(5);}",
286
+ "u=nx.href;continue;}",
287
+ "if(!r.ok){process.stderr.write('HTTP '+r.status+' '+r.statusText);process.exit(2);}",
288
+ "const len=Number(r.headers.get('content-length')||0);",
289
+ "if(cap&&len>cap){process.stderr.write('archive too large');process.exit(3);}",
290
+ "const buf=Buffer.from(await r.arrayBuffer());",
291
+ "if(cap&&buf.length>cap){process.stderr.write('archive too large');process.exit(3);}",
292
+ "fs.writeFileSync(out,buf);return;}",
293
+ "process.stderr.write('too many redirects');process.exit(6);",
294
+ "})().catch(e=>{process.stderr.write(String((e&&e.message)||e));process.exit(4);});"
295
+ ].join("");
296
+ const result = (0, node_child_process_1.spawnSync)(process.execPath, ["-e", child, rawUrl, dest, String(MAX_ARCHIVE_BYTES)], {
297
+ encoding: "utf8",
298
+ timeout: opts.timeoutMs || 120000,
299
+ env: opts.env || process.env,
300
+ stdio: ["ignore", "ignore", "pipe"]
301
+ });
302
+ if (result.status !== 0) {
303
+ node_fs_1.default.rmSync(dest, { force: true });
304
+ throw new Error(`could not download ${sanitizedUrl}: ${redactCredentials(String(result.stderr || "").trim()) || `exit ${result.status}`}`);
305
+ }
306
+ }
307
+ /** List an archive's entry NAMES WITHOUT extracting (the zip-slip/tar-slip name guard runs on
308
+ * this). Symlinks/specials and decompression bombs are caught separately (below). */
309
+ function listArchive(file, isZip) {
310
+ const cmd = isZip ? ["unzip", "-Z1", "--", file] : ["tar", "-tf", file];
311
+ const result = (0, node_child_process_1.spawnSync)(cmd[0], cmd.slice(1), { encoding: "utf8" });
312
+ if (result.status !== 0) {
313
+ // Distinguish "unzip not installed" (ENOENT) from "archive is corrupt" — never conflate the
314
+ // two (a corrupt .tar mislabeled .zip used to surface a bogus "unzip not found").
315
+ if (isZip && result.error?.code === "ENOENT") {
316
+ throw new Error("unzip is required to review a .zip link but was not found on PATH (use a .tar.gz or a git URL)");
317
+ }
318
+ throw new Error(`could not read archive: ${String(result.stderr || "").trim() || `exit ${result.status}`}`);
319
+ }
320
+ return String(result.stdout || "").split(/\r?\n/).filter(Boolean);
321
+ }
322
+ /** Reject any entry that would escape the extraction dir (absolute path or a `..` segment). */
323
+ function assertNoTraversal(entries, sanitizedUrl) {
324
+ for (const entry of entries) {
325
+ const normalized = entry.replace(/\\/g, "/");
326
+ if (node_path_1.default.isAbsolute(normalized) || normalized.startsWith("/") || /(^|\/)\.\.(\/|$)/.test(normalized)) {
327
+ throw new Error(`refusing archive ${sanitizedUrl}: unsafe path escapes the extraction dir: ${entry}`);
328
+ }
329
+ }
330
+ }
331
+ /** Declared uncompressed size, read WITHOUT extracting — gzip's ISIZE trailer for `.tar.gz`/
332
+ * `.tgz`, or `unzip -l`'s total for `.zip`. Best-effort (undefined when unknown); the
333
+ * post-extraction walk is authoritative. Lets us reject a bomb BEFORE it fills the disk. */
334
+ function declaredUncompressedSize(file, isZip) {
335
+ try {
336
+ if (isZip) {
337
+ const r = (0, node_child_process_1.spawnSync)("unzip", ["-l", "--", file], { encoding: "utf8" });
338
+ if (r.status !== 0)
339
+ return undefined;
340
+ const lines = String(r.stdout || "").trim().split(/\r?\n/);
341
+ const m = (lines[lines.length - 1] || "").match(/^\s*(\d+)\s+\d+\s+files?/);
342
+ return m ? Number(m[1]) : undefined;
343
+ }
344
+ const fd = node_fs_1.default.openSync(file, "r");
345
+ try {
346
+ const head = Buffer.alloc(2);
347
+ node_fs_1.default.readSync(fd, head, 0, 2, 0);
348
+ if (head[0] !== 0x1f || head[1] !== 0x8b)
349
+ return undefined; // not gzip (plain .tar — its file size already ≤ the download cap)
350
+ const size = node_fs_1.default.fstatSync(fd).size;
351
+ const tail = Buffer.alloc(4);
352
+ node_fs_1.default.readSync(fd, tail, 0, 4, size - 4);
353
+ return tail.readUInt32LE(0); // ISIZE (mod 2^32)
354
+ }
355
+ finally {
356
+ node_fs_1.default.closeSync(fd);
357
+ }
358
+ }
359
+ catch {
360
+ return undefined;
361
+ }
362
+ }
363
+ /** Walk the EXTRACTED tree (without following symlinks) and fail closed on anything a reviewed
364
+ * source archive must not contain: a symlink or other non-regular entry (defends the symlink
365
+ * traversal class regardless of tar/unzip version), or a total size over the bomb cap. */
366
+ function assertSafeTree(root, sanitizedUrl) {
367
+ let total = 0;
368
+ const walk = (dir) => {
369
+ for (const name of node_fs_1.default.readdirSync(dir)) {
370
+ const p = node_path_1.default.join(dir, name);
371
+ const st = node_fs_1.default.lstatSync(p); // lstat: do NOT follow symlinks
372
+ if (st.isSymbolicLink()) {
373
+ throw new Error(`refusing archive ${sanitizedUrl}: contains a symlink (${node_path_1.default.relative(root, p)}); symlinks are not allowed in a reviewed source archive`);
374
+ }
375
+ if (st.isDirectory()) {
376
+ walk(p);
377
+ continue;
378
+ }
379
+ if (!st.isFile()) {
380
+ throw new Error(`refusing archive ${sanitizedUrl}: contains a non-regular entry (${node_path_1.default.relative(root, p)})`);
381
+ }
382
+ total += st.size;
383
+ if (total > MAX_EXTRACTED_BYTES) {
384
+ throw new Error(`refusing archive ${sanitizedUrl}: extracted size exceeds ${MAX_EXTRACTED_BYTES} bytes (possible decompression bomb)`);
385
+ }
386
+ }
387
+ };
388
+ walk(root);
389
+ }
390
+ function gitSnapshot(dir, message, opts) {
391
+ const base = ["-c", "user.email=cw@local", "-c", "user.name=cw", "-c", "commit.gpgsign=false", "-c", "core.hooksPath="];
392
+ git([...base, "-C", dir, "init", "-q"], opts);
393
+ git([...base, "-C", dir, "add", "-A"], opts);
394
+ git([...base, "-C", dir, "commit", "-q", "--allow-empty", "-m", message], opts);
395
+ }
396
+ function downloadArchive(rawUrl, sanitizedUrl, opts) {
397
+ assertGitAvailable(opts); // we snapshot the extracted tree into a local git repo
398
+ const root = cacheRoot(opts);
399
+ const dir = cacheDirFor(root, sanitizedUrl, opts.ref);
400
+ if (!node_path_1.default.resolve(dir).startsWith(node_path_1.default.resolve(root) + node_path_1.default.sep)) {
401
+ throw new Error(`refusing extract target outside the cache root: ${dir}`);
402
+ }
403
+ const reuse = cachedArchive(dir, sanitizedUrl, opts);
404
+ if (reuse)
405
+ return reuse;
406
+ node_fs_1.default.mkdirSync(root, { recursive: true });
407
+ // Detect zip from the URL path only (consistent with classifyRemote); the staging dir is
408
+ // mkdtemp-unique, so the temp download file can never collide with a concurrent same-URL run.
409
+ const isZip = /\.zip$/i.test(safePathname(rawUrl));
410
+ const staging = node_fs_1.default.mkdtempSync(node_path_1.default.join(root, ".stage-"));
411
+ const tmpFile = node_path_1.default.join(staging, isZip ? "archive.zip" : "archive.tar");
412
+ try {
413
+ fetchArchiveBytes(rawUrl, sanitizedUrl, tmpFile, opts);
414
+ const commit = (0, node_crypto_1.createHash)("sha256").update(node_fs_1.default.readFileSync(tmpFile)).digest("hex"); // content address
415
+ // Bomb defense (BEFORE extracting, to avoid filling the disk): reject a declared
416
+ // uncompressed size over the cap. assertSafeTree re-checks the ACTUAL size afterward.
417
+ const declared = declaredUncompressedSize(tmpFile, isZip);
418
+ if (declared !== undefined && declared > MAX_EXTRACTED_BYTES) {
419
+ throw new Error(`refusing archive ${sanitizedUrl}: declared uncompressed size ${declared} exceeds ${MAX_EXTRACTED_BYTES} bytes (possible decompression bomb)`);
420
+ }
421
+ assertNoTraversal(listArchive(tmpFile, isZip), sanitizedUrl);
422
+ const extractDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(staging, "x-"));
423
+ const ex = isZip
424
+ ? (0, node_child_process_1.spawnSync)("unzip", ["-q", "-o", "-d", extractDir, "--", tmpFile], { encoding: "utf8" })
425
+ : (0, node_child_process_1.spawnSync)("tar", ["-xf", tmpFile, "-C", extractDir], { encoding: "utf8" });
426
+ if (ex.status !== 0)
427
+ throw new Error(`could not extract archive ${sanitizedUrl}: ${String(ex.stderr || "").trim() || `exit ${ex.status}`}`);
428
+ // Fail closed on symlinks/specials and on an over-cap actual extracted size.
429
+ assertSafeTree(extractDir, sanitizedUrl);
430
+ // Many archives (e.g. GitHub tarballs) wrap everything in a single top-level dir; descend
431
+ // into it so the review sees the project root, not a one-entry wrapper. lstat (NOT stat) so
432
+ // a top-level SYMLINK is never treated as a directory (assertSafeTree already rejected one).
433
+ const top = node_fs_1.default.readdirSync(extractDir);
434
+ const contentRoot = top.length === 1 && node_fs_1.default.lstatSync(node_path_1.default.join(extractDir, top[0])).isDirectory() ? node_path_1.default.join(extractDir, top[0]) : extractDir;
435
+ node_fs_1.default.rmSync(dir, { recursive: true, force: true });
436
+ node_fs_1.default.renameSync(contentRoot, dir); // same filesystem (both under clones/) — atomic move
437
+ gitSnapshot(dir, `snapshot of ${sanitizedUrl}`, opts); // make it a real local repo at HEAD
438
+ writeCloneMeta(dir, { url: sanitizedUrl, kind: "archive", ref: opts.ref || null, commit, fetchedAt: new Date().toISOString() });
439
+ return { localPath: dir, url: sanitizedUrl, commit, kind: "archive", ref: opts.ref, cached: false };
440
+ }
441
+ finally {
442
+ node_fs_1.default.rmSync(staging, { recursive: true, force: true });
443
+ }
444
+ }
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ // src/reporter.ts — the cw-side run reporter.
3
+ //
4
+ // Rendering lives behind a small interface so the drive/command-surface stay logic-only: the
5
+ // orchestrator emits events (progress lines, the end-of-run summary) and the Reporter decides how
6
+ // to draw them. The live AGENT view (spinner / streamed tokens / folding tool lines) is rendered
7
+ // by the async agent wrapper — cw is blocked in spawnSync during a run, so cw only renders the
8
+ // calm orchestration BETWEEN agents plus the final summary. Everything here goes to STDERR; the
9
+ // MACHINE payloads on stdout (the cw:result fence + cw's --json via printJson) carry NO term
10
+ // styling, so they stay byte-exact under any color env. Color is TTY-gated and honors
11
+ // NO_COLOR/CW_NO_COLOR(--no-color)/FORCE_COLOR via term — FORCE_COLOR may color human-readable
12
+ // output (its purpose) but never the machine payloads, which use no styling at all.
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.reporter = void 0;
15
+ exports.createReporter = createReporter;
16
+ const term_1 = require("./term");
17
+ function isTTY(stream) {
18
+ return Boolean(stream.isTTY);
19
+ }
20
+ class StderrReporter {
21
+ s;
22
+ constructor(s) {
23
+ this.s = s;
24
+ }
25
+ progress(line) {
26
+ // The caller already decided WHETHER to emit (drive's CW_DRIVE_PROGRESS/TTY gate) and has
27
+ // already styled the line (phaseProgressLine etc.) — the reporter just centralizes the write
28
+ // so all orchestration output flows through one interface. No extra styling (avoids ANSI nesting).
29
+ this.s.write(`${line}\n`);
30
+ }
31
+ runSummary(f) {
32
+ if (!isTTY(this.s))
33
+ return; // summary is human chrome; piped/--json stdout already carries the data
34
+ const s = this.s;
35
+ const counts = (typeof f.completedWorkers === "number" && typeof f.plannedWorkers === "number")
36
+ ? ` — ${f.completedWorkers}/${f.plannedWorkers}` : "";
37
+ s.write("\n");
38
+ if (f.findings && f.findings.length)
39
+ s.write(`${(0, term_1.formatFindingsSummary)(f.findings, s)}\n\n`);
40
+ s.write(`${(0, term_1.green)("✓", s)} Report: ${f.reportPath}\n`);
41
+ if (f.status === "complete") {
42
+ s.write(` ${(0, term_1.green)("✓", s)} Status: complete${counts}\n`);
43
+ if (f.runDir)
44
+ s.write(` ${(0, term_1.dim)(`Transcript: ${f.runDir}`, s)}\n`);
45
+ s.write(` ${(0, term_1.nextHint)(`cw report ${f.runId} --show`, s)}\n`);
46
+ }
47
+ else {
48
+ s.write(` ${(0, term_1.yellow)("!", s)} Status: ${f.status}${counts}\n`);
49
+ if (f.agentConfigured === false)
50
+ s.write(` ${(0, term_1.tryHint)("cw doctor", s)}\n`);
51
+ else
52
+ s.write(` ${(0, term_1.nextHint)(`cw status ${f.runId}`, s)}\n`);
53
+ }
54
+ if (typeof f.fullReport === "string" && f.fullReport.trim()) {
55
+ s.write(`\n${(0, term_1.dim)("──── full report ────", s)}\n${f.fullReport.trim()}\n`);
56
+ }
57
+ }
58
+ }
59
+ /** Build a reporter over an explicit stream. Used by the default singleton and by tests, which
60
+ * drive a fake `{ isTTY, write }` stream to assert the TTY path renders (findings table + report
61
+ * path + hints) and the non-TTY path stays silent. */
62
+ function createReporter(stream) {
63
+ return new StderrReporter(stream);
64
+ }
65
+ /** The default reporter writes the orchestration view to stderr. (A future non-TTY-specific
66
+ * reporter could differ; today the single impl gates internally on isTTY, matching emitProgress.) */
67
+ exports.reporter = createReporter(process.stderr);