impel-cli 0.20.38 → 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.
- package/README.md +4 -3
- package/RELEASE_NOTES.md +9 -0
- package/bin/impel.js +10 -1
- package/docs/native-agent-host-capability-matrix.md +4 -3
- package/package.json +1 -1
- package/scripts/capture-release-fixtures.mjs +56 -0
- package/scripts/clean-machine/README.md +51 -0
- package/scripts/clean-machine/run-clean-windows.sh +115 -0
- package/scripts/clean-machine/userdata.ps1.template +102 -0
- package/scripts/regen-managed-artifacts.mjs +475 -0
- package/scripts/verify-vendor-pins.mjs +207 -0
- package/src/agents.js +47 -15
- package/src/apps.js +233 -2
- package/src/commands/apps.js +25 -2
- package/src/commands/mcp.js +1 -1
- package/src/commands/sessions.js +44 -11
- package/src/commands/token.js +23 -3
- package/src/macSetup.js +9 -8
- package/src/nativeAgentTransport.js +13 -4
- package/src/vendorCliBinaries.js +36 -7
- package/src/verbatimRelay.js +9 -1
|
@@ -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.`);
|