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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +42 -1
- package/apps/architecture-review/app.json +1 -1
- package/apps/architecture-review-fast/app.json +1 -1
- package/apps/end-to-end-golden-path/app.json +1 -1
- package/apps/pr-review-fix-ci/app.json +1 -1
- package/apps/release-cut/app.json +1 -1
- package/apps/research-synthesis/app.json +1 -1
- package/dist/capability-core.js +151 -9
- package/dist/capability-registry.js +6 -0
- package/dist/cli/command-surface.js +143 -6
- package/dist/cli.js +26 -1
- package/dist/clones.js +162 -0
- package/dist/drive.js +37 -2
- package/dist/mcp/tool-call.js +4 -0
- package/dist/mcp/tool-definitions.js +5 -0
- package/dist/orchestrator/report.js +6 -0
- package/dist/orchestrator.js +15 -4
- package/dist/remote-source.js +444 -0
- package/dist/reporter.js +67 -0
- package/dist/term.js +127 -9
- package/dist/version.js +1 -1
- package/docs/agent-delegation-drive.7.md +41 -22
- package/docs/cli-mcp-parity.7.md +9 -2
- package/docs/contract-migration-tooling.7.md +4 -0
- package/docs/control-plane-scheduling.7.md +4 -0
- 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/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 +15 -4
- package/docs/real-execution-backends.7.md +4 -0
- package/docs/release-and-migration.7.md +4 -0
- package/docs/release-tooling.7.md +4 -0
- package/docs/remote-source-review.7.md +88 -0
- package/docs/run-registry-control-plane.7.md +4 -0
- package/docs/run-retention-reclamation.7.md +4 -0
- package/docs/state-explosion-management.7.md +4 -0
- package/docs/team-collaboration.7.md +4 -0
- package/docs/web-desktop-workbench.7.md +4 -0
- package/manifest/plugin.manifest.json +1 -1
- package/manifest/source-context-profiles.json +1 -1
- package/package.json +1 -1
- package/scripts/agents/agent-adapter-core.js +137 -3
- package/scripts/agents/claude-p-agent.js +24 -15
- package/scripts/agents/codex-agent.js +11 -8
- package/scripts/agents/gemini-agent.js +11 -8
- package/scripts/agents/opencode-agent.js +11 -8
- package/scripts/canonical-apps.js +4 -4
- package/scripts/dogfood-release.js +1 -1
- 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
|
+
}
|
package/dist/reporter.js
ADDED
|
@@ -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);
|