impel-cli 0.20.37 → 0.20.39

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.
@@ -0,0 +1,475 @@
1
+ // Deterministic, offline regeneration of the Impel-managed on-disk artifacts
2
+ // from fixed inputs, for the released-fixture corpus (docs/testing-protocol.md
3
+ // §5 P0-2) and the version-bump tripwire (§5 P0-3, Law 7).
4
+ //
5
+ // Usage:
6
+ // node scripts/regen-managed-artifacts.mjs <output-dir> [--force]
7
+ //
8
+ // The script generates the managed profile TWICE, each time into its own
9
+ // throwaway temp HOME (never a personal ~/.claude, ~/.codex, or desktop
10
+ // profile), through the real production writers in src/ — never a
11
+ // reimplementation. Both trees are post-processed with the repo's placeholder
12
+ // convention (<HOME>, <NODE>, <REPOSITORY>) so the output is
13
+ // machine-independent, then compared byte-for-byte. Any difference is a hard
14
+ // failure: nondeterministic generation must never be captured as a fixture.
15
+ //
16
+ // Internal child mode (used for isolation because CONFIG_DIR in src/config.js
17
+ // is frozen at module load):
18
+ // node scripts/regen-managed-artifacts.mjs --emit <home> [--tenant t]
19
+ // [--tenant-name n] [--gateway url] [--generated-at ts] [--pat p]
20
+
21
+ import crypto from "node:crypto";
22
+ import fs from "node:fs";
23
+ import os from "node:os";
24
+ import path from "node:path";
25
+ import { spawnSync } from "node:child_process";
26
+ import { fileURLToPath } from "node:url";
27
+
28
+ const SCRIPT_PATH = fileURLToPath(import.meta.url);
29
+ export const REPOSITORY_ROOT = path.resolve(path.dirname(SCRIPT_PATH), "..");
30
+
31
+ // Fixed generation inputs. Changing any of these changes every captured
32
+ // fixture, so treat them as part of the fixture contract.
33
+ export const FIXED_INPUTS = Object.freeze({
34
+ generatedAt: "2026-01-01T00:00:00.000Z",
35
+ tenantId: "impel",
36
+ tenantName: "Impel",
37
+ gatewayUrl: "https://gateway.example.test",
38
+ pat: "impel_pat_TEST",
39
+ });
40
+
41
+ // Vendor-download-derived (and, for the CLI profile, wall-clock-stamped) model
42
+ // catalogs are excluded from the corpus: the honest enrichment source is the
43
+ // pinned downloaded binary, which must never be fetched under `pnpm test`.
44
+ export const EXCLUDED_BASENAMES = Object.freeze(["models.json"]);
45
+ export const EXCLUSION_REASON =
46
+ "vendor model catalogs come from the pinned downloaded binary (and the CLI "
47
+ + "catalog stamps a live fetched_at); excluded so the corpus stays offline "
48
+ + "and deterministic";
49
+
50
+ // Codex hook-trust hashes digest the machine-local node and repository paths,
51
+ // so they cannot survive placeholder substitution. hooks.json carries the same
52
+ // command content in placeholder-stable form, so normalizing the hash loses no
53
+ // tripwire coverage.
54
+ const TRUSTED_HASH_RE = /(trusted_hash = ")sha256:[0-9a-f]{64}(")/gu;
55
+ export const TRUSTED_HASH_PLACEHOLDER = "sha256:<TRUSTED-HASH>";
56
+
57
+ function replacePath(text, value, placeholder) {
58
+ const encoded = JSON.stringify(value).slice(1, -1);
59
+ return text.replaceAll(encoded, placeholder).replaceAll(value, placeholder);
60
+ }
61
+
62
+ function realPathVariants(target) {
63
+ const variants = [target];
64
+ try {
65
+ variants.push(fs.realpathSync(target));
66
+ } catch {
67
+ // A path that no longer resolves keeps its literal form only.
68
+ }
69
+ return [...new Set(variants)].sort((left, right) => right.length - left.length);
70
+ }
71
+
72
+ /**
73
+ * Platform canonicalization for the reviewed fixture comparison only (the
74
+ * same transform test/codex-config-contract.test.js applies): CRLF -> LF and
75
+ * backslash pairs -> forward slashes, so Windows-generated trees compare equal
76
+ * to the reviewed corpus. Generated profile files on disk are never rewritten.
77
+ */
78
+ export function canonicalArtifactText(text) {
79
+ return text.replaceAll("\r\n", "\n").replaceAll("\\\\", "/");
80
+ }
81
+
82
+ /** Substitute machine-specific values with the repo's placeholder convention. */
83
+ export function normalizeArtifactText(text, homeDir) {
84
+ let normalized = text;
85
+ for (const homePath of realPathVariants(homeDir)) {
86
+ normalized = replacePath(normalized, homePath, "<HOME>");
87
+ }
88
+ for (const nodePath of realPathVariants(process.execPath)) {
89
+ normalized = replacePath(normalized, nodePath, "<NODE>");
90
+ }
91
+ for (const repositoryPath of realPathVariants(REPOSITORY_ROOT)) {
92
+ normalized = replacePath(normalized, repositoryPath, "<REPOSITORY>");
93
+ }
94
+ normalized = normalized.replace(TRUSTED_HASH_RE, `$1${TRUSTED_HASH_PLACEHOLDER}$2`);
95
+ // Windows writes a few raw (non-JSON-escaped) paths, e.g. in the sh token
96
+ // helper; canonicalize the path remainder that follows a placeholder so the
97
+ // corpus compares equal across platforms.
98
+ normalized = normalized.replace(
99
+ /<(HOME|NODE|REPOSITORY)>((?:\\[^\\/:*?"<>|\r\n]+)+)/gu,
100
+ (match, name, rest) => `<${name}>${rest.replaceAll("\\", "/")}`,
101
+ );
102
+ return canonicalArtifactText(normalized);
103
+ }
104
+
105
+ // Artifacts whose embedded path strings the production writers emit through
106
+ // JSON.stringify: JSON documents, and TOML files (every path in a managed
107
+ // config.toml goes through tomlString = JSON.stringify into a TOML basic
108
+ // string, where backslash is likewise an escape character). Substituting a
109
+ // raw win32 path (C:\Users\...) into these would form invalid escapes; a real
110
+ // Windows install has the JSON-escaped form (C:\\Users\\...) on disk.
111
+ // Everything else (the sh token helper, preload/html assets) embeds raw text.
112
+ const JSON_ESCAPED_ARTIFACT_RE = /\.(?:json|toml)$/u;
113
+
114
+ /**
115
+ * Reverse the placeholder substitution to materialize a fixture on disk,
116
+ * using the escaping the production writer of that artifact would have used,
117
+ * so the materialized profile matches what a real install of that platform
118
+ * has on disk. The corpus is canonical-LF; normalize line endings first so
119
+ * materialization never silently depends on how a checkout treated the
120
+ * fixture bytes (the byte-exact guard for that lives in the tripwire's
121
+ * sha256 integrity test).
122
+ *
123
+ * `relativePath` selects the escaping context; omitting it yields raw
124
+ * substitution, which is only correct for artifacts without JSON/TOML string
125
+ * semantics (or on platforms whose paths need no escaping).
126
+ */
127
+ export function materializeArtifactText(text, homeDir, relativePath = "") {
128
+ const encode = JSON_ESCAPED_ARTIFACT_RE.test(relativePath)
129
+ ? (value) => JSON.stringify(value).slice(1, -1)
130
+ : (value) => value;
131
+ return text
132
+ .replaceAll("\r\n", "\n")
133
+ .replaceAll("<HOME>", encode(homeDir))
134
+ .replaceAll("<NODE>", encode(process.execPath))
135
+ .replaceAll("<REPOSITORY>", encode(REPOSITORY_ROOT))
136
+ .replaceAll(TRUSTED_HASH_PLACEHOLDER, `sha256:${"0".repeat(64)}`);
137
+ }
138
+
139
+ /** True when two texts differ, but only by CRLF-vs-LF line endings. */
140
+ export function eolOnlyDifference(left, right) {
141
+ return left !== right && left.replaceAll("\r\n", "\n") === right.replaceAll("\r\n", "\n");
142
+ }
143
+
144
+ function walkFiles(root) {
145
+ const files = [];
146
+ const pending = [root];
147
+ while (pending.length > 0) {
148
+ const directory = pending.pop();
149
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
150
+ const target = path.join(directory, entry.name);
151
+ if (entry.isSymbolicLink()) continue;
152
+ if (entry.isDirectory()) pending.push(target);
153
+ else if (entry.isFile()) files.push(target);
154
+ }
155
+ }
156
+ return files.sort();
157
+ }
158
+
159
+ function toPosixRelative(root, target) {
160
+ return path.relative(root, target).split(path.sep).join("/");
161
+ }
162
+
163
+ /**
164
+ * Collect every generated artifact under one temp HOME's Impel config root as
165
+ * a Map of posix-relative path -> normalized text, plus the excluded paths.
166
+ */
167
+ export function collectArtifacts(homeDir) {
168
+ const configRoot = path.join(homeDir, ".config", "impel");
169
+ const artifacts = new Map();
170
+ const excluded = [];
171
+ for (const file of walkFiles(configRoot)) {
172
+ const relative = toPosixRelative(configRoot, file);
173
+ if (EXCLUDED_BASENAMES.includes(path.basename(file))) {
174
+ excluded.push(relative);
175
+ continue;
176
+ }
177
+ artifacts.set(relative, normalizeArtifactText(fs.readFileSync(file, "utf8"), homeDir));
178
+ }
179
+ return { artifacts, excluded: excluded.sort() };
180
+ }
181
+
182
+ function firstDifferenceExcerpt(expected, actual, context = 2, width = 200) {
183
+ const expectedLines = expected.split("\n");
184
+ const actualLines = actual.split("\n");
185
+ const total = Math.max(expectedLines.length, actualLines.length);
186
+ for (let index = 0; index < total; index += 1) {
187
+ if (expectedLines[index] === actualLines[index]) continue;
188
+ const lines = [];
189
+ for (let nearby = Math.max(0, index - context); nearby < index; nearby += 1) {
190
+ lines.push(` ${expectedLines[nearby] ?? ""}`.slice(0, width));
191
+ }
192
+ if (index < expectedLines.length) lines.push(` - ${expectedLines[index]}`.slice(0, width));
193
+ if (index < actualLines.length) lines.push(` + ${actualLines[index]}`.slice(0, width));
194
+ lines.unshift(` @@ line ${index + 1} @@`);
195
+ return lines.join("\n");
196
+ }
197
+ return " @@ contents differ only in trailing lines @@";
198
+ }
199
+
200
+ /**
201
+ * Compare two normalized artifact maps. Returns [] when identical, otherwise
202
+ * one entry per differing/missing/extra file with a short unified-ish excerpt.
203
+ */
204
+ export function diffArtifactMaps(expected, actual) {
205
+ const differences = [];
206
+ for (const [relative, expectedText] of expected) {
207
+ if (!actual.has(relative)) {
208
+ differences.push({ path: relative, kind: "missing", excerpt: " @@ file missing from regenerated output @@" });
209
+ continue;
210
+ }
211
+ const actualText = actual.get(relative);
212
+ if (actualText !== expectedText) {
213
+ differences.push({
214
+ path: relative,
215
+ kind: "changed",
216
+ // Lines that differ only by CRLF-vs-LF render as visually identical
217
+ // in a plain diff excerpt; name the real cause instead.
218
+ excerpt: eolOnlyDifference(expectedText, actualText)
219
+ ? " @@ differs only in line endings (CRLF vs LF) — a checkout or writer rewrote them; ensure .gitattributes marks test/fixtures/released/** -text @@"
220
+ : firstDifferenceExcerpt(expectedText, actualText),
221
+ });
222
+ }
223
+ }
224
+ for (const relative of actual.keys()) {
225
+ if (!expected.has(relative)) {
226
+ differences.push({ path: relative, kind: "extra", excerpt: " @@ file not present in fixture @@" });
227
+ }
228
+ }
229
+ return differences.sort((left, right) => left.path.localeCompare(right.path));
230
+ }
231
+
232
+ // Artifacts are stored with an inert suffix: `node --test` (pnpm test) treats
233
+ // every *.js/*.mjs/*.cjs under test/ as a test file, and the corpus contains
234
+ // generated .cjs preloads that must never execute as tests.
235
+ export const FIXTURE_FILE_SUFFIX = ".fixture";
236
+
237
+ /** Read a fixture (or regen output) directory back into an artifact map. */
238
+ export function readArtifactTree(directory) {
239
+ const artifacts = new Map();
240
+ for (const file of walkFiles(directory)) {
241
+ const relative = toPosixRelative(directory, file);
242
+ if (relative === "metadata.json") continue;
243
+ if (!relative.endsWith(FIXTURE_FILE_SUFFIX)) {
244
+ throw new Error(`${file} is missing the ${FIXTURE_FILE_SUFFIX} suffix; not a captured artifact`);
245
+ }
246
+ artifacts.set(relative.slice(0, -FIXTURE_FILE_SUFFIX.length), fs.readFileSync(file, "utf8"));
247
+ }
248
+ return artifacts;
249
+ }
250
+
251
+ export function readFixtureMetadata(directory) {
252
+ return JSON.parse(fs.readFileSync(path.join(directory, "metadata.json"), "utf8"));
253
+ }
254
+
255
+ /** Latest <version> directory under test/fixtures/released, by semver order. */
256
+ export function latestReleasedFixtureDir(root = path.join(REPOSITORY_ROOT, "test", "fixtures", "released")) {
257
+ let entries;
258
+ try {
259
+ entries = fs.readdirSync(root, { withFileTypes: true });
260
+ } catch (error) {
261
+ if (error?.code === "ENOENT") return null;
262
+ throw error;
263
+ }
264
+ const versions = entries
265
+ .filter((entry) => entry.isDirectory() && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(entry.name))
266
+ .map((entry) => entry.name)
267
+ .sort(compareVersions);
268
+ if (versions.length === 0) return null;
269
+ return path.join(root, versions[versions.length - 1]);
270
+ }
271
+
272
+ export function compareVersions(left, right) {
273
+ const parse = (value) => {
274
+ const [core, prerelease = null] = value.split("-", 2);
275
+ return { parts: core.split(".").map(Number), prerelease };
276
+ };
277
+ const a = parse(left);
278
+ const b = parse(right);
279
+ for (let index = 0; index < 3; index += 1) {
280
+ if (a.parts[index] !== b.parts[index]) return a.parts[index] - b.parts[index];
281
+ }
282
+ if (a.prerelease === b.prerelease) return 0;
283
+ if (a.prerelease === null) return 1; // release > its own prereleases
284
+ if (b.prerelease === null) return -1;
285
+ return a.prerelease < b.prerelease ? -1 : 1;
286
+ }
287
+
288
+ function sanitizedChildEnvironment(homeDir) {
289
+ const environment = { ...process.env };
290
+ for (const name of [
291
+ "IMPEL_APP_HOME",
292
+ "IMPEL_CONFIG_DIR",
293
+ "IMPEL_GATEWAY_URL",
294
+ "IMPEL_APP_URL",
295
+ "IMPEL_CLI_RUNTIME_BRAND",
296
+ "IMPEL_CLI_EXTENSION_ENTRYPOINT",
297
+ "CODEX_HOME",
298
+ "CLAUDE_CONFIG_DIR",
299
+ ]) {
300
+ delete environment[name];
301
+ }
302
+ environment.HOME = homeDir;
303
+ environment.USERPROFILE = homeDir;
304
+ environment.IMPEL_CONFIG_DIR = path.join(homeDir, ".config", "impel");
305
+ return environment;
306
+ }
307
+
308
+ function argumentValue(argv, flag, fallback) {
309
+ const index = argv.indexOf(flag);
310
+ return index !== -1 && argv[index + 1] !== undefined ? argv[index + 1] : fallback;
311
+ }
312
+
313
+ /**
314
+ * Child mode: generate one complete managed profile into `homeDir` through the
315
+ * production writers. Runs in its own process so CONFIG_DIR (frozen at module
316
+ * load in src/config.js) can be pointed at the temp HOME before src/ loads.
317
+ */
318
+ async function emitManagedArtifacts(homeDir, argv = []) {
319
+ process.env.HOME = homeDir;
320
+ process.env.USERPROFILE = homeDir;
321
+ process.env.IMPEL_CONFIG_DIR = path.join(homeDir, ".config", "impel");
322
+ delete process.env.IMPEL_APP_HOME;
323
+ delete process.env.IMPEL_CLI_RUNTIME_BRAND;
324
+ delete process.env.IMPEL_CLI_EXTENSION_ENTRYPOINT;
325
+ delete process.env.CODEX_HOME;
326
+
327
+ const inputs = {
328
+ generatedAt: argumentValue(argv, "--generated-at", FIXED_INPUTS.generatedAt),
329
+ tenantId: argumentValue(argv, "--tenant", FIXED_INPUTS.tenantId),
330
+ tenantName: argumentValue(argv, "--tenant-name", FIXED_INPUTS.tenantName),
331
+ gatewayUrl: argumentValue(argv, "--gateway", FIXED_INPUTS.gatewayUrl),
332
+ pat: argumentValue(argv, "--pat", FIXED_INPUTS.pat),
333
+ };
334
+
335
+ // Real production generation functions only — never a reimplementation.
336
+ const { FALLBACK_MODELS, installManagedAppFiles } = await import("../src/apps.js");
337
+ const { ensureImpelClaudeProfile, ensureImpelCodexProfile } = await import("../src/cliProfiles.js");
338
+
339
+ const config = {
340
+ pat: inputs.pat,
341
+ gatewayUrl: inputs.gatewayUrl,
342
+ tenantId: inputs.tenantId,
343
+ tenantName: inputs.tenantName,
344
+ };
345
+ installManagedAppFiles({
346
+ config,
347
+ targets: ["claude", "chatgpt"],
348
+ models: FALLBACK_MODELS,
349
+ homeDir,
350
+ // Truthy non-existent paths: no vendor discovery, no vendor enrichment,
351
+ // and writeBundles:false keeps this to the pure config/manifest surface.
352
+ vendorPaths: {
353
+ claude: "/impel-regen/no-vendor/Claude.app",
354
+ chatgpt: "/impel-regen/no-vendor/ChatGPT.app",
355
+ },
356
+ writeBundles: false,
357
+ generatedAt: inputs.generatedAt,
358
+ });
359
+ ensureImpelClaudeProfile(inputs.gatewayUrl, inputs.tenantId, { crossAppModels: false });
360
+ ensureImpelCodexProfile(inputs.gatewayUrl, inputs.tenantId);
361
+ }
362
+
363
+ /**
364
+ * Run one isolated production generation pass over `homeDir` in a child
365
+ * process. Also the fixture tests' repair path: emitting over an existing
366
+ * materialized profile IS the managed regeneration/repair flow.
367
+ */
368
+ export function spawnEmit(homeDir, extraArguments = []) {
369
+ const result = spawnSync(
370
+ process.execPath,
371
+ [SCRIPT_PATH, "--emit", homeDir, ...extraArguments],
372
+ { encoding: "utf8", env: sanitizedChildEnvironment(homeDir) },
373
+ );
374
+ if (result.status !== 0 || result.error) {
375
+ throw new Error(
376
+ `managed-artifact generation failed in ${homeDir}:\n${result.stderr || result.stdout || result.error}`,
377
+ );
378
+ }
379
+ return result;
380
+ }
381
+
382
+ /**
383
+ * Regenerate the managed artifacts into `outputDir`: two independent
384
+ * generations into fresh temp HOMEs, byte-compared after placeholder
385
+ * normalization, then written once with metadata.json. Returns the metadata.
386
+ */
387
+ export async function regenerateManagedArtifacts(outputDir, { force = false } = {}) {
388
+ if (fs.existsSync(outputDir) && fs.readdirSync(outputDir).length > 0) {
389
+ if (!force) {
390
+ throw new Error(`${outputDir} already exists and is not empty (pass --force to replace it)`);
391
+ }
392
+ fs.rmSync(outputDir, { recursive: true, force: true });
393
+ }
394
+
395
+ const homes = [
396
+ fs.mkdtempSync(path.join(os.tmpdir(), "impel-regen-a-")),
397
+ fs.mkdtempSync(path.join(os.tmpdir(), "impel-regen-b-")),
398
+ ];
399
+ try {
400
+ for (const home of homes) spawnEmit(home);
401
+ const first = collectArtifacts(homes[0]);
402
+ const second = collectArtifacts(homes[1]);
403
+ const differences = diffArtifactMaps(first.artifacts, second.artifacts);
404
+ if (differences.length > 0) {
405
+ const report = differences
406
+ .map((difference) => ` ${difference.kind}: ${difference.path}\n${difference.excerpt}`)
407
+ .join("\n");
408
+ throw new Error(
409
+ "managed-artifact generation is not deterministic; refusing to write output.\n"
410
+ + "Two generations from identical fixed inputs produced different bytes:\n"
411
+ + report,
412
+ );
413
+ }
414
+
415
+ const packageJson = JSON.parse(fs.readFileSync(path.join(REPOSITORY_ROOT, "package.json"), "utf8"));
416
+ // Version markers come from the same production module the writers use.
417
+ const { CURRENT_CONFIG_VERSION, BUNDLE_BUILD_FINGERPRINT } = await import("../src/apps.js");
418
+
419
+ fs.mkdirSync(outputDir, { recursive: true });
420
+ const artifactRecords = [];
421
+ for (const [relative, text] of [...first.artifacts.entries()].sort(([a], [b]) => a.localeCompare(b))) {
422
+ const target = path.join(outputDir, ...`${relative}${FIXTURE_FILE_SUFFIX}`.split("/"));
423
+ fs.mkdirSync(path.dirname(target), { recursive: true });
424
+ fs.writeFileSync(target, text);
425
+ artifactRecords.push({
426
+ path: relative,
427
+ sha256: crypto.createHash("sha256").update(text).digest("hex"),
428
+ bytes: Buffer.byteLength(text),
429
+ });
430
+ }
431
+ const metadata = {
432
+ cliVersion: packageJson.version,
433
+ configVersion: CURRENT_CONFIG_VERSION,
434
+ bundleFingerprint: BUNDLE_BUILD_FINGERPRINT,
435
+ capturedAt: FIXED_INPUTS.generatedAt,
436
+ inputs: { ...FIXED_INPUTS },
437
+ placeholders: ["<HOME>", "<NODE>", "<REPOSITORY>", TRUSTED_HASH_PLACEHOLDER],
438
+ excluded: first.excluded.map((relative) => ({ path: relative, reason: EXCLUSION_REASON })),
439
+ artifacts: artifactRecords,
440
+ };
441
+ fs.writeFileSync(path.join(outputDir, "metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`);
442
+ return metadata;
443
+ } finally {
444
+ for (const home of homes) fs.rmSync(home, { recursive: true, force: true });
445
+ }
446
+ }
447
+
448
+ const isMain = process.argv[1]
449
+ && path.resolve(process.argv[1]) === SCRIPT_PATH;
450
+
451
+ if (isMain) {
452
+ const argv = process.argv.slice(2);
453
+ if (argv[0] === "--emit") {
454
+ if (!argv[1]) {
455
+ console.error("usage: node scripts/regen-managed-artifacts.mjs --emit <home>");
456
+ process.exit(2);
457
+ }
458
+ await emitManagedArtifacts(path.resolve(argv[1]), argv.slice(2));
459
+ } else {
460
+ const outputDir = argv.find((argument) => !argument.startsWith("--"));
461
+ if (!outputDir) {
462
+ console.error("usage: node scripts/regen-managed-artifacts.mjs <output-dir> [--force]");
463
+ process.exit(2);
464
+ }
465
+ const metadata = await regenerateManagedArtifacts(path.resolve(outputDir), {
466
+ force: argv.includes("--force"),
467
+ });
468
+ console.log(
469
+ `regenerated ${metadata.artifacts.length} managed artifacts `
470
+ + `(configVersion ${metadata.configVersion}, ${metadata.bundleFingerprint}) `
471
+ + `into ${path.resolve(outputDir)}`,
472
+ );
473
+ console.log("determinism: two independent generations were byte-identical after normalization");
474
+ }
475
+ }
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ // Vendor pin-drift monitor (testing-protocol RC13 / P2-11).
3
+ //
4
+ // Vendors have re-published different bytes under the same pinned URL, and the
5
+ // Microsoft Store has fulfilled a different build than the reviewed pin. This
6
+ // script re-verifies every pinned artifact source against the recorded pin so
7
+ // that drift becomes a red scheduled run instead of a user-discovered outage.
8
+ //
9
+ // - Direct downloads are streamed straight into SHA-256 (never written to
10
+ // disk) and compared with the pinned hash.
11
+ // - The Windows ChatGPT/Codex pin has no hashable download URL (Store
12
+ // fulfillment), so its published update manifest is compared field-for-field
13
+ // with the pin, mirroring test/windows-chatgpt-store-contract.test.js.
14
+ //
15
+ // Usage:
16
+ // node scripts/verify-vendor-pins.mjs # verify everything (CI)
17
+ // node scripts/verify-vendor-pins.mjs --only=claude:win32-x64
18
+ // node scripts/verify-vendor-pins.mjs --only=chatgpt
19
+ //
20
+ // Exit status is non-zero if any artifact mismatches or is unreachable, after
21
+ // every artifact has been attempted (the run reports all drift, not the first).
22
+
23
+ import crypto from "node:crypto";
24
+ import process from "node:process";
25
+ import { PINNED_VENDOR_APPS } from "../src/apps.js";
26
+
27
+ const DOWNLOAD_TIMEOUT_MS = 20 * 60 * 1000;
28
+ const MANIFEST_TIMEOUT_MS = 30 * 1000;
29
+ const PROGRESS_EVERY_BYTES = 64 * 1024 * 1024;
30
+
31
+ if (typeof fetch !== "function") {
32
+ console.error("verify-vendor-pins requires Node 18+ (global fetch).");
33
+ process.exit(1);
34
+ }
35
+
36
+ function collectArtifacts() {
37
+ const artifacts = [];
38
+ for (const [app, pin] of Object.entries(PINNED_VENDOR_APPS)) {
39
+ for (const [architecture, download] of Object.entries(pin.downloads ?? {})) {
40
+ artifacts.push({
41
+ label: `${app}:darwin-${architecture}`,
42
+ kind: "hash",
43
+ url: download.url,
44
+ sha256: download.sha256,
45
+ });
46
+ }
47
+ const windows = pin.windows;
48
+ if (!windows) continue;
49
+ if (windows.downloads) {
50
+ for (const [architecture, download] of Object.entries(windows.downloads)) {
51
+ artifacts.push({
52
+ label: `${app}:win32-${architecture}`,
53
+ kind: "hash",
54
+ url: download.url,
55
+ sha256: download.sha256,
56
+ });
57
+ }
58
+ } else if (windows.updateManifestUrl) {
59
+ artifacts.push({
60
+ label: `${app}:win32-store`,
61
+ kind: "store-manifest",
62
+ url: windows.updateManifestUrl,
63
+ expected: {
64
+ schemaVersion: 1,
65
+ buildVersion: windows.packageVersion,
66
+ storeProductId: windows.storeProductId,
67
+ packageIdentity: windows.packageName,
68
+ },
69
+ });
70
+ } else {
71
+ artifacts.push({
72
+ label: `${app}:win32`,
73
+ kind: "skip",
74
+ reason: "Windows pin exposes neither a hashable download URL nor an update manifest URL",
75
+ });
76
+ }
77
+ }
78
+ return artifacts;
79
+ }
80
+
81
+ async function sha256OfUrl(url, label) {
82
+ const response = await fetch(url, {
83
+ redirect: "follow",
84
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
85
+ });
86
+ if (!response.ok) throw new Error(`HTTP ${response.status} fetching ${url}`);
87
+ if (!response.body) throw new Error(`empty response body fetching ${url}`);
88
+ const hash = crypto.createHash("sha256");
89
+ let bytes = 0;
90
+ let nextProgress = PROGRESS_EVERY_BYTES;
91
+ for await (const chunk of response.body) {
92
+ hash.update(chunk);
93
+ bytes += chunk.length;
94
+ if (bytes >= nextProgress) {
95
+ console.log(` [${label}] ${(bytes / 1024 / 1024).toFixed(0)} MiB hashed...`);
96
+ nextProgress += PROGRESS_EVERY_BYTES;
97
+ }
98
+ }
99
+ return { sha256: hash.digest("hex"), bytes };
100
+ }
101
+
102
+ function canonicalJson(value) {
103
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
104
+ return JSON.stringify(value);
105
+ }
106
+ const keys = Object.keys(value).sort();
107
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
108
+ }
109
+
110
+ async function verifyStoreManifest(artifact) {
111
+ const response = await fetch(artifact.url, {
112
+ redirect: "follow",
113
+ signal: AbortSignal.timeout(MANIFEST_TIMEOUT_MS),
114
+ });
115
+ if (!response.ok) throw new Error(`HTTP ${response.status} fetching ${artifact.url}`);
116
+ const manifest = await response.json();
117
+ // Exact comparison, matching the Store contract test: an added field is
118
+ // vendor drift the pin review has not seen, and must go red too.
119
+ if (canonicalJson(manifest) !== canonicalJson(artifact.expected)) {
120
+ throw new Error(
121
+ `Store update manifest drifted from the pin.\n`
122
+ + ` expected: ${canonicalJson(artifact.expected)}\n`
123
+ + ` actual: ${canonicalJson(manifest)}`,
124
+ );
125
+ }
126
+ }
127
+
128
+ function parseOnly(argv) {
129
+ const flag = argv.find((argument) => argument.startsWith("--only="));
130
+ if (!flag) return null;
131
+ const value = flag.slice("--only=".length).trim();
132
+ if (!value) {
133
+ console.error("--only requires a value, e.g. --only=claude:win32-x64 or --only=chatgpt");
134
+ process.exit(1);
135
+ }
136
+ return value;
137
+ }
138
+
139
+ const only = parseOnly(process.argv.slice(2));
140
+ const allArtifacts = collectArtifacts();
141
+ const artifacts = only
142
+ ? allArtifacts.filter(
143
+ (artifact) => artifact.label === only || artifact.label.split(":")[0] === only,
144
+ )
145
+ : allArtifacts;
146
+ if (artifacts.length === 0) {
147
+ console.error(`--only=${only} matched no artifacts. Known labels:`);
148
+ for (const artifact of allArtifacts) console.error(` ${artifact.label}`);
149
+ process.exit(1);
150
+ }
151
+
152
+ const failures = [];
153
+ const hashCache = new Map();
154
+ for (const artifact of artifacts) {
155
+ const startedAt = Date.now();
156
+ if (artifact.kind === "skip") {
157
+ console.log(`SKIPPED ${artifact.label}: ${artifact.reason}`);
158
+ continue;
159
+ }
160
+ console.log(`verifying ${artifact.label} <- ${artifact.url}`);
161
+ try {
162
+ if (artifact.kind === "hash") {
163
+ // Identical URLs (e.g. the universal Claude macOS zip pinned for both
164
+ // architectures) are downloaded once and reused.
165
+ let result = hashCache.get(artifact.url);
166
+ if (!result) {
167
+ result = await sha256OfUrl(artifact.url, artifact.label);
168
+ hashCache.set(artifact.url, result);
169
+ }
170
+ if (result.sha256 !== artifact.sha256) {
171
+ throw new Error(
172
+ `SHA-256 drifted from the pin (vendor re-published under the same URL?).\n`
173
+ + ` expected: ${artifact.sha256}\n`
174
+ + ` actual: ${result.sha256} (${result.bytes} bytes)`,
175
+ );
176
+ }
177
+ console.log(
178
+ `OK ${artifact.label}: sha256 matches pin`
179
+ + ` (${(result.bytes / 1024 / 1024).toFixed(1)} MiB, ${((Date.now() - startedAt) / 1000).toFixed(1)}s)`,
180
+ );
181
+ } else if (artifact.kind === "store-manifest") {
182
+ await verifyStoreManifest(artifact);
183
+ console.log(
184
+ `OK ${artifact.label}: Store update manifest matches pin`
185
+ + ` (${((Date.now() - startedAt) / 1000).toFixed(1)}s)`,
186
+ );
187
+ }
188
+ } catch (error) {
189
+ const message = error instanceof Error ? error.message : String(error);
190
+ console.error(`FAIL ${artifact.label}: ${message}`);
191
+ failures.push({ label: artifact.label, url: artifact.url, message });
192
+ }
193
+ }
194
+
195
+ if (failures.length > 0) {
196
+ console.error(`\n${failures.length} pinned artifact source(s) drifted or were unreachable:`);
197
+ for (const failure of failures) {
198
+ console.error(` ${failure.label} (${failure.url})`);
199
+ }
200
+ console.error(
201
+ "\nPin drift means the vendor changed the artifact behind a reviewed pin."
202
+ + " Re-review the build and update PINNED_VENDOR_APPS through a normal"
203
+ + " vendor-upgrade release; never loosen verification to make this pass.",
204
+ );
205
+ process.exit(1);
206
+ }
207
+ console.log(`\nAll ${artifacts.length} pinned artifact source(s) match their pins.`);