@timurproko/a1 0.1.8-dev.259 → 0.1.8-dev.269
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/bin/update-recovery.js +272 -0
- package/dist/foundation/release/bootstrap.d.ts +2 -0
- package/dist/foundation/release/bootstrap.js +4 -0
- package/dist/foundation/release/dependency-layer.d.ts +5 -1
- package/dist/foundation/release/dependency-layer.js +15 -6
- package/dist/foundation/release/index.d.ts +1 -0
- package/dist/foundation/release/index.js +1 -0
- package/dist/foundation/release/release-gc.js +2 -0
- package/dist/foundation/release/release-store.d.ts +4 -2
- package/dist/foundation/release/release-store.js +4 -4
- package/dist/foundation/release/update-recovery.d.ts +74 -0
- package/dist/foundation/release/update-recovery.js +352 -0
- package/dist/foundation/release/update-transaction.d.ts +10 -0
- package/dist/foundation/release/update-transaction.js +12 -1
- package/dist/foundation/release/update.d.ts +19 -1
- package/dist/foundation/release/update.js +67 -10
- package/dist/foundation/supervision/main.js +5 -2
- package/dist/integrations/pi/engine/conformance.d.ts +1 -1
- package/dist/integrations/pi/engine/conformance.js +9 -1
- package/dist/integrations/pi/engine/runtime-integration.js +10 -1
- package/dist/integrations/pi/engine/windows-filesystem-hygiene.d.ts +21 -0
- package/dist/integrations/pi/engine/windows-filesystem-hygiene.js +55 -0
- package/dist/integrations/pi/session-ui/session-shell-root.d.ts +1 -1
- package/dist/integrations/pi/session-ui/session-shell-root.js +29 -20
- package/dist/native/darwin-arm64/manifest.json +1 -1
- package/dist/native/linux-x64/manifest.json +1 -1
- package/dist/native/win32-x64/manifest.json +2 -2
- package/dist/native/win32-x64/process-guardian.exe +0 -0
- package/dist/runtime-payload-inventory.json +1 -0
- package/dist/ui/components/transcript-viewport.d.ts +8 -0
- package/dist/ui/components/transcript-viewport.js +30 -6
- package/package.json +1 -1
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Security: this file intentionally depends only on Node built-ins: the updater copies it into a
|
|
4
|
+
// transaction capsule before npm is allowed to rename or remove the installed package.
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import { chmod, lstat, mkdir, open, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
8
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const SCHEMA = "a1-update-recovery-v1";
|
|
12
|
+
const [, , mode, manifestPath, separator, ...forwarded] = process.argv;
|
|
13
|
+
|
|
14
|
+
if ((mode !== "--worker" && mode !== "--launch") || !manifestPath || (mode === "--launch" && separator !== "--")) {
|
|
15
|
+
throw new Error("invalid A1 update recovery invocation");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const capsule = await readCapsule(manifestPath);
|
|
19
|
+
if (mode === "--worker") {
|
|
20
|
+
await runWorker(capsule);
|
|
21
|
+
} else {
|
|
22
|
+
process.exitCode = await runRecoveredCommand(capsule, forwarded);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function runWorker(value) {
|
|
26
|
+
await rm(value.resultPath, { force: true });
|
|
27
|
+
await writeJson(value.ownerPath, {
|
|
28
|
+
schema: SCHEMA,
|
|
29
|
+
transactionId: value.transactionId,
|
|
30
|
+
pid: process.pid,
|
|
31
|
+
startIdentity: `${process.pid}:${Math.floor(Date.now() - process.uptime() * 1000)}`,
|
|
32
|
+
startedAt: new Date().toISOString(),
|
|
33
|
+
});
|
|
34
|
+
const heartbeat = setInterval(() => {
|
|
35
|
+
void writeJson(value.ownerPath, {
|
|
36
|
+
schema: SCHEMA,
|
|
37
|
+
transactionId: value.transactionId,
|
|
38
|
+
pid: process.pid,
|
|
39
|
+
startIdentity: `${process.pid}:${Math.floor(Date.now() - process.uptime() * 1000)}`,
|
|
40
|
+
heartbeatAt: new Date().toISOString(),
|
|
41
|
+
});
|
|
42
|
+
}, 500);
|
|
43
|
+
heartbeat.unref?.();
|
|
44
|
+
const child = spawn(process.execPath, [value.npmCli, ...value.npmArguments], {
|
|
45
|
+
detached: false,
|
|
46
|
+
windowsHide: true,
|
|
47
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
48
|
+
});
|
|
49
|
+
const stdout = [];
|
|
50
|
+
const stderr = [];
|
|
51
|
+
child.stdout?.on("data", chunk => stdout.push(Buffer.from(chunk)));
|
|
52
|
+
child.stderr?.on("data", chunk => stderr.push(Buffer.from(chunk)));
|
|
53
|
+
let cancelled = false;
|
|
54
|
+
let forced = false;
|
|
55
|
+
let forceTimer = null;
|
|
56
|
+
const cancellationPoll = setInterval(async () => {
|
|
57
|
+
if (cancelled || !await exists(value.cancellationPath)) return;
|
|
58
|
+
cancelled = true;
|
|
59
|
+
child.kill("SIGTERM");
|
|
60
|
+
forceTimer = setTimeout(() => { forced = true; child.kill("SIGKILL"); }, 2_000);
|
|
61
|
+
forceTimer.unref?.();
|
|
62
|
+
}, 50);
|
|
63
|
+
cancellationPoll.unref?.();
|
|
64
|
+
|
|
65
|
+
let npmExitCode = null;
|
|
66
|
+
let spawnError = null;
|
|
67
|
+
try {
|
|
68
|
+
npmExitCode = await new Promise((resolvePromise, rejectPromise) => {
|
|
69
|
+
child.once("error", rejectPromise);
|
|
70
|
+
child.once("close", code => resolvePromise(code));
|
|
71
|
+
});
|
|
72
|
+
} catch (error) {
|
|
73
|
+
spawnError = error;
|
|
74
|
+
} finally {
|
|
75
|
+
clearInterval(cancellationPoll);
|
|
76
|
+
clearInterval(heartbeat);
|
|
77
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const targetReady = await installedTargetIsCallable(value);
|
|
81
|
+
let launcherDisposition = "unavailable";
|
|
82
|
+
let outcome = "failed";
|
|
83
|
+
if (targetReady) {
|
|
84
|
+
launcherDisposition = "target";
|
|
85
|
+
outcome = "installed";
|
|
86
|
+
} else {
|
|
87
|
+
await writeRecoveryLaunchers(value);
|
|
88
|
+
if (await recoveryLaunchersAreCallable(value)) {
|
|
89
|
+
launcherDisposition = "recovery";
|
|
90
|
+
outcome = "recovery-launcher";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
await writeJson(value.resultPath, {
|
|
94
|
+
schema: SCHEMA,
|
|
95
|
+
transactionId: value.transactionId,
|
|
96
|
+
outcome,
|
|
97
|
+
npmExitCode,
|
|
98
|
+
cancelled,
|
|
99
|
+
launcherDisposition,
|
|
100
|
+
stdout: bounded(Buffer.concat(stdout).toString("utf8")),
|
|
101
|
+
stderr: bounded(`${Buffer.concat(stderr).toString("utf8")}${spawnError ? `\n${String(spawnError)}` : ""}${forced ? "\nforced npm termination after cancellation" : ""}`.trim()),
|
|
102
|
+
completedAt: new Date().toISOString(),
|
|
103
|
+
});
|
|
104
|
+
if (launcherDisposition === "unavailable") process.exitCode = 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function runRecoveredCommand(value, arguments_) {
|
|
108
|
+
let entry = await installedTargetEntry(value);
|
|
109
|
+
if (entry === null && arguments_[0] === "update") {
|
|
110
|
+
await rm(value.cancellationPath, { force: true });
|
|
111
|
+
await rm(value.resultPath, { force: true });
|
|
112
|
+
await rm(value.ownerPath, { force: true });
|
|
113
|
+
const worker = spawn(process.execPath, [fileURLToPath(import.meta.url), "--worker", manifestPath], {
|
|
114
|
+
detached: true,
|
|
115
|
+
windowsHide: true,
|
|
116
|
+
stdio: "ignore",
|
|
117
|
+
});
|
|
118
|
+
worker.unref();
|
|
119
|
+
const deadline = Date.now() + 15 * 60 * 1000;
|
|
120
|
+
const requestCancellation = signal => { void writeJson(value.cancellationPath, { schema: SCHEMA, transactionId: value.transactionId, signal, requestedAt: new Date().toISOString() }); };
|
|
121
|
+
const onSigint = () => requestCancellation("SIGINT");
|
|
122
|
+
const onSigterm = () => requestCancellation("SIGTERM");
|
|
123
|
+
process.on("SIGINT", onSigint);
|
|
124
|
+
process.on("SIGTERM", onSigterm);
|
|
125
|
+
try {
|
|
126
|
+
while (Date.now() < deadline && !await exists(value.resultPath)) await sleep(50);
|
|
127
|
+
} finally {
|
|
128
|
+
process.off("SIGINT", onSigint);
|
|
129
|
+
process.off("SIGTERM", onSigterm);
|
|
130
|
+
}
|
|
131
|
+
entry = await installedTargetEntry(value);
|
|
132
|
+
}
|
|
133
|
+
entry ??= resolve(value.priorReleaseRoot, "bin", "cli.js");
|
|
134
|
+
const child = spawn(process.execPath, [entry, ...arguments_], { stdio: "inherit", windowsHide: false });
|
|
135
|
+
return await new Promise(resolvePromise => {
|
|
136
|
+
child.once("error", error => { console.error(error instanceof Error ? error.message : String(error)); resolvePromise(1); });
|
|
137
|
+
child.once("close", code => resolvePromise(code ?? 1));
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function readCapsule(path) {
|
|
142
|
+
const value = JSON.parse(await readFile(path, "utf8"));
|
|
143
|
+
if (value.schema !== SCHEMA || typeof value.transactionId !== "string" || typeof value.packageName !== "string"
|
|
144
|
+
|| typeof value.packageName !== "string" || !value.packageName.includes("/") || typeof value.targetVersion !== "string" || typeof value.packageRoot !== "string" || typeof value.globalRoot !== "string"
|
|
145
|
+
|| typeof value.launcherRoot !== "string" || !Array.isArray(value.launchers) || typeof value.priorReleaseId !== "string" || typeof value.priorReleaseRoot !== "string"
|
|
146
|
+
|| !/^[a-f0-9]{64}$/.test(value.priorContentDigest) || typeof value.releaseManifestName !== "string"
|
|
147
|
+
|| typeof value.recoveryEntry !== "string" || typeof value.recoveryEntryDigest !== "string" || value.nodeExecutable !== process.execPath || typeof value.npmCli !== "string"
|
|
148
|
+
|| !Array.isArray(value.npmArguments) || typeof value.resultPath !== "string" || typeof value.cancellationPath !== "string") {
|
|
149
|
+
throw new Error("invalid A1 update recovery capsule");
|
|
150
|
+
}
|
|
151
|
+
const lexicalCapsuleRoot = dirname(resolve(path));
|
|
152
|
+
if (lexicalCapsuleRoot.split(sep).at(-1) !== value.transactionId) throw new Error("A1 update recovery transaction path is invalid");
|
|
153
|
+
const capsuleRoot = await realpath(lexicalCapsuleRoot);
|
|
154
|
+
const entry = await realpath(value.recoveryEntry);
|
|
155
|
+
assertDirectChild(capsuleRoot, entry);
|
|
156
|
+
if (!samePath(entry, resolve(capsuleRoot, "recovery.js"))) throw new Error("A1 update recovery entry path is invalid");
|
|
157
|
+
const digest = createHash("sha256").update(await readFile(entry)).digest("hex");
|
|
158
|
+
if (digest !== value.recoveryEntryDigest) throw new Error("A1 update recovery payload digest mismatch");
|
|
159
|
+
const globalRoot = await realpath(value.globalRoot);
|
|
160
|
+
const packageRoot = await realpath(value.packageRoot).catch(() => resolve(value.packageRoot));
|
|
161
|
+
const expectedPackage = resolve(globalRoot, ...value.packageName.split("/"));
|
|
162
|
+
if (!samePath(packageRoot, expectedPackage) || !containedBy(globalRoot, packageRoot)) throw new Error("A1 update recovery package root escapes npm global root");
|
|
163
|
+
const expectedNpmArguments = ["install", "--global", "--loglevel=error", "--no-fund", "--no-audit", `${value.packageName}@${value.targetVersion}`];
|
|
164
|
+
if (JSON.stringify(value.npmArguments) !== JSON.stringify(expectedNpmArguments)) throw new Error("A1 update recovery npm arguments are invalid");
|
|
165
|
+
const launcherRoot = resolve(value.launcherRoot);
|
|
166
|
+
const expectedLaunchers = process.platform === "win32"
|
|
167
|
+
? [resolve(launcherRoot, "a1"), resolve(launcherRoot, "a1.cmd"), resolve(launcherRoot, "a1.ps1")]
|
|
168
|
+
: [resolve(launcherRoot, "a1")];
|
|
169
|
+
if (JSON.stringify(value.launchers.map(path => resolve(path))) !== JSON.stringify(expectedLaunchers)) throw new Error("A1 update recovery launcher set is invalid");
|
|
170
|
+
for (const launcher of value.launchers) assertDirectChild(launcherRoot, resolve(launcher));
|
|
171
|
+
const expectedSidecars = [resolve(lexicalCapsuleRoot, "cancel.json"), resolve(lexicalCapsuleRoot, "result.json"), resolve(lexicalCapsuleRoot, "owner.json")];
|
|
172
|
+
if (![value.cancellationPath, value.resultPath, value.ownerPath].every((candidate, index) => samePath(candidate, expectedSidecars[index]))) {
|
|
173
|
+
throw new Error("A1 update recovery sidecar paths are invalid");
|
|
174
|
+
}
|
|
175
|
+
const dataDir = dirname(dirname(capsuleRoot));
|
|
176
|
+
const releasesRoot = await realpath(resolve(dataDir, "releases"));
|
|
177
|
+
const priorReleaseRoot = await realpath(value.priorReleaseRoot);
|
|
178
|
+
assertDirectChild(releasesRoot, priorReleaseRoot);
|
|
179
|
+
if (priorReleaseRoot.split(sep).at(-1) !== value.priorReleaseId) throw new Error("A1 update recovery prior release identity is invalid");
|
|
180
|
+
const priorManifest = JSON.parse(await readFile(resolve(priorReleaseRoot, value.releaseManifestName), "utf8"));
|
|
181
|
+
if (priorManifest.releaseId !== value.priorReleaseId || priorManifest.contentDigest !== value.priorContentDigest) {
|
|
182
|
+
throw new Error("A1 update recovery prior release manifest is invalid");
|
|
183
|
+
}
|
|
184
|
+
const npmMetadata = await lstat(value.npmCli);
|
|
185
|
+
if (!npmMetadata.isFile() || npmMetadata.isSymbolicLink()) throw new Error("A1 update recovery npm entry is invalid");
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function installedTargetEntry(value) {
|
|
190
|
+
try {
|
|
191
|
+
const manifest = JSON.parse(await readFile(resolve(value.packageRoot, "package.json"), "utf8"));
|
|
192
|
+
const entry = resolve(value.packageRoot, "bin", "cli.js");
|
|
193
|
+
if (manifest.name !== value.packageName || manifest.version !== value.targetVersion || !(await lstat(entry)).isFile()) return null;
|
|
194
|
+
return entry;
|
|
195
|
+
} catch { return null; }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function installedTargetIsCallable(value) {
|
|
199
|
+
if (await installedTargetEntry(value) === null) return false;
|
|
200
|
+
const token = `node_modules/${value.packageName}/bin/cli.js`;
|
|
201
|
+
for (const path of value.launchers) {
|
|
202
|
+
try {
|
|
203
|
+
const metadata = await lstat(path);
|
|
204
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) return false;
|
|
205
|
+
if (process.platform !== "win32" && (metadata.mode & 0o111) === 0) return false;
|
|
206
|
+
const normalized = (await readFile(path, "utf8")).replaceAll("\\", "/");
|
|
207
|
+
if (!normalized.includes(token)) return false;
|
|
208
|
+
} catch { return false; }
|
|
209
|
+
}
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function writeRecoveryLaunchers(value) {
|
|
214
|
+
const node = value.nodeExecutable ?? process.execPath;
|
|
215
|
+
const entry = value.recoveryEntry;
|
|
216
|
+
const manifest = manifestPath;
|
|
217
|
+
const shell = `#!/bin/sh\nexec ${shellQuote(node)} ${shellQuote(entry)} --launch ${shellQuote(manifest)} -- "$@"\n`;
|
|
218
|
+
const command = `@ECHO off\r\n"${node}" "${entry}" --launch "${manifest}" -- %*\r\n`;
|
|
219
|
+
const powershell = `& '${psQuote(node)}' '${psQuote(entry)}' --launch '${psQuote(manifest)}' -- $args\nexit $LASTEXITCODE\n`;
|
|
220
|
+
const content = process.platform === "win32" ? [shell, command, powershell] : [shell];
|
|
221
|
+
await mkdir(value.launcherRoot, { recursive: true });
|
|
222
|
+
for (let index = 0; index < value.launchers.length; index += 1) await atomicWrite(value.launchers[index], content[index], 0o755);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function recoveryLaunchersAreCallable(value) {
|
|
226
|
+
for (const path of value.launchers) {
|
|
227
|
+
try {
|
|
228
|
+
const metadata = await lstat(path);
|
|
229
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) return false;
|
|
230
|
+
if (process.platform !== "win32" && (metadata.mode & 0o111) === 0) return false;
|
|
231
|
+
if (!(await readFile(path, "utf8")).includes(value.recoveryEntry)) return false;
|
|
232
|
+
} catch { return false; }
|
|
233
|
+
}
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function atomicWrite(path, content, mode) {
|
|
238
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
239
|
+
await writeFile(temporary, content, { mode });
|
|
240
|
+
await chmod(temporary, mode);
|
|
241
|
+
await rm(path, { force: true });
|
|
242
|
+
await rename(temporary, path);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function writeJson(path, value) {
|
|
246
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
247
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
248
|
+
const file = await open(temporary, "wx", 0o600);
|
|
249
|
+
try { await file.writeFile(JSON.stringify(value, null, 2)); await file.sync(); }
|
|
250
|
+
finally { await file.close(); }
|
|
251
|
+
await rm(path, { force: true });
|
|
252
|
+
await rename(temporary, path);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function samePath(left, right) {
|
|
256
|
+
return process.platform === "win32" ? resolve(left).toLowerCase() === resolve(right).toLowerCase() : resolve(left) === resolve(right);
|
|
257
|
+
}
|
|
258
|
+
function containedBy(parent, child) {
|
|
259
|
+
const fromParent = relative(parent, child);
|
|
260
|
+
return fromParent.length > 0 && fromParent !== ".." && !fromParent.startsWith(`..${sep}`) && !isAbsolute(fromParent);
|
|
261
|
+
}
|
|
262
|
+
function assertDirectChild(parent, child) {
|
|
263
|
+
const expectedParent = resolve(parent);
|
|
264
|
+
const actualParent = dirname(resolve(child));
|
|
265
|
+
const matches = process.platform === "win32" ? actualParent.toLowerCase() === expectedParent.toLowerCase() : actualParent === expectedParent;
|
|
266
|
+
if (!matches) throw new Error(`A1 update recovery path escapes its managed root: ${child}`);
|
|
267
|
+
}
|
|
268
|
+
function shellQuote(value) { return `'${String(value).replaceAll("'", `'"'"'`)}'`; }
|
|
269
|
+
function psQuote(value) { return String(value).replaceAll("'", "''"); }
|
|
270
|
+
function bounded(value) { return value.length <= 64 * 1024 ? value : value.slice(-64 * 1024); }
|
|
271
|
+
async function exists(path) { return await lstat(path).then(() => true).catch(() => false); }
|
|
272
|
+
async function sleep(ms) { await new Promise(resolvePromise => setTimeout(resolvePromise, ms)); }
|
|
@@ -27,6 +27,8 @@ export interface BootstrapOptions {
|
|
|
27
27
|
}
|
|
28
28
|
export declare function runBootstrap(options: BootstrapOptions): Promise<number>;
|
|
29
29
|
export declare function certifyMaterializedRelease(release: MaterializedRelease, dataDir: string, verification?: VerifyMaterializedReleaseOptions): Promise<string>;
|
|
30
|
+
/** Persist current-format evidence after an authenticated parent has certified the exact release. */
|
|
31
|
+
export declare function recordParentCertifiedRelease(release: MaterializedRelease, dataDir: string): Promise<string>;
|
|
30
32
|
export interface SupervisorStartupAttempt extends SupervisorStartupAttemptIdentity {
|
|
31
33
|
readonly childOutcome: Promise<{
|
|
32
34
|
readonly exitCode: number | null;
|
|
@@ -193,6 +193,10 @@ export async function certifyMaterializedRelease(release, dataDir, verification
|
|
|
193
193
|
if (!consumeMaterializationProof(release)) {
|
|
194
194
|
await verifyMaterializedRelease(release.releaseRoot, release, resolve(dataDir, "releases"), verification);
|
|
195
195
|
}
|
|
196
|
+
return await recordParentCertifiedRelease(release, dataDir);
|
|
197
|
+
}
|
|
198
|
+
/** Persist current-format evidence after an authenticated parent has certified the exact release. */
|
|
199
|
+
export async function recordParentCertifiedRelease(release, dataDir) {
|
|
196
200
|
const path = resolve(dataDir, `certification-${release.releaseId}.json`);
|
|
197
201
|
const restartSeal = await createRestartSeal(release, dataDir);
|
|
198
202
|
await chmod(path, 0o600).catch(() => { });
|
|
@@ -37,6 +37,10 @@ export interface DependencyLayerOperationEvent {
|
|
|
37
37
|
readonly path: string;
|
|
38
38
|
readonly bytes: number;
|
|
39
39
|
}
|
|
40
|
+
export interface ReadCertifiedDependencyLayerOptions {
|
|
41
|
+
/** Accept and replace certification written by the immediately preceding updater format. */
|
|
42
|
+
readonly allowLegacyParentCertification?: boolean;
|
|
43
|
+
}
|
|
40
44
|
export interface SelectedRuntimePayload {
|
|
41
45
|
readonly paths: readonly string[];
|
|
42
46
|
readonly inventory: RuntimePayloadInventory;
|
|
@@ -71,7 +75,7 @@ export interface MaterializeDependencyLayerOptions {
|
|
|
71
75
|
/** Materialize or reuse an exact immutable dependency layer after one source-content pass. */
|
|
72
76
|
export declare function materializeDependencyLayer(packageRoot: string, dataDir: string, paths: readonly string[], options: MaterializeDependencyLayerOptions): Promise<MaterializedDependencyLayer | null>;
|
|
73
77
|
/** Read trusted layer certification and canonical metadata without rereading every payload byte. */
|
|
74
|
-
export declare function readCertifiedDependencyLayer(dataDir: string, layerId: string, expected?: Pick<DependencyLayerIdentity, "layerId" | "contentDigest"
|
|
78
|
+
export declare function readCertifiedDependencyLayer(dataDir: string, layerId: string, expected?: Pick<DependencyLayerIdentity, "layerId" | "contentDigest">, options?: ReadCertifiedDependencyLayerOptions): Promise<Omit<MaterializedDependencyLayer, "reused">>;
|
|
75
79
|
/** Fully verify a layer when certification is absent or explicit tamper evidence is required. */
|
|
76
80
|
export declare function verifyDependencyLayer(dataDir: string, reference: DependencyLayerReference, onOperation?: (event: DependencyLayerOperationEvent) => void): Promise<Omit<MaterializedDependencyLayer, "reused">>;
|
|
77
81
|
export declare function dependencyReference(layer: MaterializedDependencyLayer): DependencyLayerReference;
|
|
@@ -269,7 +269,7 @@ export async function materializeDependencyLayer(packageRoot, dataDir, paths, op
|
|
|
269
269
|
}
|
|
270
270
|
}
|
|
271
271
|
/** Read trusted layer certification and canonical metadata without rereading every payload byte. */
|
|
272
|
-
export async function readCertifiedDependencyLayer(dataDir, layerId, expected) {
|
|
272
|
+
export async function readCertifiedDependencyLayer(dataDir, layerId, expected, options = {}) {
|
|
273
273
|
const layersRoot = await realpath(resolve(dataDir, "dependency-layers"));
|
|
274
274
|
const layerRoot = await realpath(resolveWithin(layersRoot, layerId));
|
|
275
275
|
assertDirectChild(layersRoot, layerRoot);
|
|
@@ -278,14 +278,23 @@ export async function readCertifiedDependencyLayer(dataDir, layerId, expected) {
|
|
|
278
278
|
throw new Error(`dependency layer is not a managed non-link directory: ${layerId}`);
|
|
279
279
|
const manifest = JSON.parse(await readFile(resolve(layerRoot, DEPENDENCY_LAYER_MANIFEST), "utf8"));
|
|
280
280
|
validateLayerManifest(manifest);
|
|
281
|
-
const certification = JSON.parse(await readFile(certificationPath(dataDir, layerId), "utf8"));
|
|
282
|
-
if (certification.schema !== PRODUCT_IDENTITY.evidence.dependencyLayerCertificationSchema || certification.layerId !== manifest.layerId || certification.contentDigest !== manifest.contentDigest
|
|
283
|
-
|| certification.platform !== process.platform || certification.platformPolicy !== immutablePlatformPolicy()) {
|
|
284
|
-
throw new Error(`dependency layer certification differs from manifest: ${layerId}`);
|
|
285
|
-
}
|
|
286
281
|
if (expected && (expected.layerId !== manifest.layerId || expected.contentDigest !== manifest.contentDigest)) {
|
|
287
282
|
throw new Error(`dependency layer identity mismatch: ${layerId}`);
|
|
288
283
|
}
|
|
284
|
+
const certification = JSON.parse(await readFile(certificationPath(dataDir, layerId), "utf8"));
|
|
285
|
+
const identityMatches = certification.schema === PRODUCT_IDENTITY.evidence.dependencyLayerCertificationSchema
|
|
286
|
+
&& certification.layerId === manifest.layerId && certification.contentDigest === manifest.contentDigest;
|
|
287
|
+
const currentPlatformEvidence = certification.platform === process.platform && certification.platformPolicy === immutablePlatformPolicy();
|
|
288
|
+
// Compatibility: the updater that introduced layers certified these exact identities but did
|
|
289
|
+
// not record platform fields. Only an authenticated parent-started supervisor opts into this
|
|
290
|
+
// transition; durable/reuse readers remain strict and cannot treat the legacy marker as authority.
|
|
291
|
+
const legacyParentCertification = options.allowLegacyParentCertification === true
|
|
292
|
+
&& certification.platform === undefined && certification.platformPolicy === undefined;
|
|
293
|
+
if (!identityMatches || (!currentPlatformEvidence && !legacyParentCertification)) {
|
|
294
|
+
throw new Error(`dependency layer certification differs from manifest: ${layerId}`);
|
|
295
|
+
}
|
|
296
|
+
if (legacyParentCertification)
|
|
297
|
+
await writeLayerCertification(dataDir, manifest);
|
|
289
298
|
return { ...manifest, layerRoot };
|
|
290
299
|
}
|
|
291
300
|
/** Fully verify a layer when certification is absent or explicit tamper evidence is required. */
|
|
@@ -10,5 +10,6 @@ export * from "./release-store.js";
|
|
|
10
10
|
export * from "./restart-certification.js";
|
|
11
11
|
export * from "./stable-release.js";
|
|
12
12
|
export * from "./update.js";
|
|
13
|
+
export * from "./update-recovery.js";
|
|
13
14
|
export * from "./update-transaction.js";
|
|
14
15
|
export * from "./warmup.js";
|
|
@@ -10,5 +10,6 @@ export * from "./release-store.js";
|
|
|
10
10
|
export * from "./restart-certification.js";
|
|
11
11
|
export * from "./stable-release.js";
|
|
12
12
|
export * from "./update.js";
|
|
13
|
+
export * from "./update-recovery.js";
|
|
13
14
|
export * from "./update-transaction.js";
|
|
14
15
|
export * from "./warmup.js";
|
|
@@ -9,6 +9,7 @@ import { resolveProductPaths } from "../lifecycle/index.js";
|
|
|
9
9
|
import { RELEASE_MANIFEST_FILENAME } from "./release-store.js";
|
|
10
10
|
import { DEPENDENCY_LAYER_MANIFEST, dependencyLayerCertificationPath } from "./dependency-layer.js";
|
|
11
11
|
import { UpdateTransactionStore } from "./update-transaction.js";
|
|
12
|
+
import { cleanupUpdateRecoveryCapsules } from "./update-recovery.js";
|
|
12
13
|
import { PRODUCT_IDENTITY } from "../../product-identity.js";
|
|
13
14
|
import { collectCompileCaches, startupCompileCachePath } from "../startup/index.js";
|
|
14
15
|
const DEFAULT_CANDIDATE_AGE_MS = 60 * 60 * 1_000;
|
|
@@ -115,6 +116,7 @@ export async function runBoundedReleaseCleanup(dataDir, paths, options = {}) {
|
|
|
115
116
|
}
|
|
116
117
|
if (!activeTransaction && hasTime()) {
|
|
117
118
|
await collectCompileCaches(dataDir, await protectedCompileCachePaths(dataDir, await store.read())).catch(() => { });
|
|
119
|
+
await cleanupUpdateRecoveryCapsules(dataDir).catch(() => { });
|
|
118
120
|
}
|
|
119
121
|
const remaining = Object.keys((await store.read()).cleanup.pending).length;
|
|
120
122
|
return { planned: pending.length + artifacts.length, attempted, completed, remaining, durationMs: Math.max(0, now() - startedAt) };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ReleaseIdentity } from "./release.js";
|
|
2
|
-
import { type RuntimePayloadInventory } from "./dependency-layer.js";
|
|
2
|
+
import { type ReadCertifiedDependencyLayerOptions, type RuntimePayloadInventory } from "./dependency-layer.js";
|
|
3
3
|
export declare const RELEASE_MANIFEST_FILENAME: string;
|
|
4
4
|
export interface MaterializedRelease extends ReleaseIdentity {
|
|
5
5
|
readonly releaseRoot: string;
|
|
@@ -29,6 +29,8 @@ export interface CertifiedReleaseRecord {
|
|
|
29
29
|
readonly packageVersion?: string;
|
|
30
30
|
readonly contentDigest: string;
|
|
31
31
|
}
|
|
32
|
+
/** Process-authority compatibility controls for metadata-only release loading. */
|
|
33
|
+
export type ReadCertifiedReleaseManifestOptions = ReadCertifiedDependencyLayerOptions;
|
|
32
34
|
export declare function materializeRelease(packageRoot: string, dataDir: string, options?: MaterializeReleaseOptions): Promise<MaterializedRelease>;
|
|
33
35
|
/** Consume proof that this exact object was freshly materialized or fully verified in this process. */
|
|
34
36
|
export declare function consumeMaterializationProof(release: MaterializedRelease): boolean;
|
|
@@ -39,7 +41,7 @@ export declare function readMaterializedRelease(releaseRoot: string, selectedSto
|
|
|
39
41
|
* establish one of those preconditions; untrusted releases require full
|
|
40
42
|
* verification.
|
|
41
43
|
*/
|
|
42
|
-
export declare function readCertifiedReleaseManifest(record: CertifiedReleaseRecord, selectedStoreRoot: string): Promise<MaterializedRelease>;
|
|
44
|
+
export declare function readCertifiedReleaseManifest(record: CertifiedReleaseRecord, selectedStoreRoot: string, options?: ReadCertifiedReleaseManifestOptions): Promise<MaterializedRelease>;
|
|
43
45
|
export declare function verifyMaterializedRelease(releaseRoot: string, expected?: ReleaseIdentity, selectedStoreRoot?: string, options?: VerifyMaterializedReleaseOptions): Promise<MaterializedRelease>;
|
|
44
46
|
export declare function assertImmutableExecutionRoot(release: MaterializedRelease, dataDir: string): Promise<void>;
|
|
45
47
|
export declare function resolveReleaseEntryPoint(release: MaterializedRelease, entryPoint: string): Promise<string>;
|
|
@@ -106,7 +106,7 @@ export async function readMaterializedRelease(releaseRoot, selectedStoreRoot) {
|
|
|
106
106
|
* establish one of those preconditions; untrusted releases require full
|
|
107
107
|
* verification.
|
|
108
108
|
*/
|
|
109
|
-
export async function readCertifiedReleaseManifest(record, selectedStoreRoot) {
|
|
109
|
+
export async function readCertifiedReleaseManifest(record, selectedStoreRoot, options = {}) {
|
|
110
110
|
const canonical = await realpath(record.releaseRoot);
|
|
111
111
|
const canonicalStoreRoot = await realpath(selectedStoreRoot);
|
|
112
112
|
assertContained(canonicalStoreRoot, canonical, "release root is outside the selected release store");
|
|
@@ -118,7 +118,7 @@ export async function readCertifiedReleaseManifest(record, selectedStoreRoot) {
|
|
|
118
118
|
}
|
|
119
119
|
if (canonical.split(sep).at(-1) !== manifest.releaseId)
|
|
120
120
|
throw new Error(`release directory does not match identity ${manifest.releaseId}`);
|
|
121
|
-
await verifyReleaseDependencies(canonical, canonicalStoreRoot, manifest.dependencyLayers ?? [], false);
|
|
121
|
+
await verifyReleaseDependencies(canonical, canonicalStoreRoot, manifest.dependencyLayers ?? [], false, {}, options);
|
|
122
122
|
return { ...manifest, releaseRoot: canonical };
|
|
123
123
|
}
|
|
124
124
|
export async function verifyMaterializedRelease(releaseRoot, expected, selectedStoreRoot, options = {}) {
|
|
@@ -162,7 +162,7 @@ export async function resolveReleaseEntryPoint(release, entryPoint) {
|
|
|
162
162
|
assertContained(release.releaseRoot, canonical, "entry point resolves outside the selected release root");
|
|
163
163
|
return canonical;
|
|
164
164
|
}
|
|
165
|
-
async function verifyReleaseDependencies(releaseRoot, storeRoot, references, fullVerification, options = {}) {
|
|
165
|
+
async function verifyReleaseDependencies(releaseRoot, storeRoot, references, fullVerification, options = {}, certificationOptions = {}) {
|
|
166
166
|
if (references.length === 0)
|
|
167
167
|
return;
|
|
168
168
|
if (references.length !== 1)
|
|
@@ -171,7 +171,7 @@ async function verifyReleaseDependencies(releaseRoot, storeRoot, references, ful
|
|
|
171
171
|
const reference = references[0];
|
|
172
172
|
const layer = fullVerification
|
|
173
173
|
? await verifyDependencyLayer(dataDir, reference, event => options.onOperation?.({ operation: event.operation, path: event.path, bytes: event.bytes }))
|
|
174
|
-
: await readCertifiedDependencyLayer(dataDir, reference.layerId, reference);
|
|
174
|
+
: await readCertifiedDependencyLayer(dataDir, reference.layerId, reference, certificationOptions);
|
|
175
175
|
const binding = resolveWithin(releaseRoot, reference.binding);
|
|
176
176
|
const metadata = await lstat(binding);
|
|
177
177
|
if (!metadata.isSymbolicLink())
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { UpdateTransaction, UpdateRecoveryState } from "./update-transaction.js";
|
|
2
|
+
export declare const UPDATE_RECOVERY_SCHEMA: "a1-update-recovery-v1";
|
|
3
|
+
export interface UpdateRecoveryCapsule {
|
|
4
|
+
readonly schema: typeof UPDATE_RECOVERY_SCHEMA;
|
|
5
|
+
readonly transactionId: string;
|
|
6
|
+
readonly packageName: string;
|
|
7
|
+
readonly targetVersion: string;
|
|
8
|
+
readonly packageRoot: string;
|
|
9
|
+
readonly globalRoot: string;
|
|
10
|
+
readonly launcherRoot: string;
|
|
11
|
+
readonly launchers: readonly string[];
|
|
12
|
+
readonly priorReleaseId: string;
|
|
13
|
+
readonly priorReleaseRoot: string;
|
|
14
|
+
readonly priorContentDigest: string;
|
|
15
|
+
readonly releaseManifestName: string;
|
|
16
|
+
readonly recoveryEntry: string;
|
|
17
|
+
readonly recoveryEntryDigest: string;
|
|
18
|
+
readonly nodeExecutable: string;
|
|
19
|
+
readonly npmCli: string;
|
|
20
|
+
readonly npmArguments: readonly string[];
|
|
21
|
+
readonly cancellationPath: string;
|
|
22
|
+
readonly resultPath: string;
|
|
23
|
+
readonly ownerPath: string;
|
|
24
|
+
readonly createdAt: string;
|
|
25
|
+
}
|
|
26
|
+
export interface UpdateRecoveryResult {
|
|
27
|
+
readonly schema: typeof UPDATE_RECOVERY_SCHEMA;
|
|
28
|
+
readonly transactionId: string;
|
|
29
|
+
readonly outcome: "installed" | "recovery-launcher" | "failed";
|
|
30
|
+
readonly npmExitCode: number | null;
|
|
31
|
+
readonly cancelled: boolean;
|
|
32
|
+
readonly launcherDisposition: "target" | "recovery" | "unavailable";
|
|
33
|
+
readonly stdout: string;
|
|
34
|
+
readonly stderr: string;
|
|
35
|
+
readonly completedAt: string;
|
|
36
|
+
}
|
|
37
|
+
export interface ProtectedPackageReplacementOptions {
|
|
38
|
+
readonly dataDir: string;
|
|
39
|
+
readonly globalRoot: string;
|
|
40
|
+
readonly packageRoot: string;
|
|
41
|
+
readonly transaction: UpdateTransaction;
|
|
42
|
+
readonly priorRelease: {
|
|
43
|
+
readonly releaseId: string;
|
|
44
|
+
readonly releaseRoot: string;
|
|
45
|
+
readonly contentDigest: string;
|
|
46
|
+
};
|
|
47
|
+
readonly output: {
|
|
48
|
+
stderr(message: string): void;
|
|
49
|
+
};
|
|
50
|
+
readonly environment?: NodeJS.ProcessEnv;
|
|
51
|
+
readonly platform?: NodeJS.Platform;
|
|
52
|
+
readonly timeoutMs?: number;
|
|
53
|
+
readonly workerSpawner?: (entry: string, manifestPath: string, environment: NodeJS.ProcessEnv) => Promise<void>;
|
|
54
|
+
readonly onRecoveryState?: (state: UpdateRecoveryState) => Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
export interface ProtectedPackageReplacementResult extends UpdateRecoveryResult {
|
|
57
|
+
readonly recovery: UpdateRecoveryState;
|
|
58
|
+
}
|
|
59
|
+
/** Resolve the complete public launcher set npm owns for the active platform. */
|
|
60
|
+
export declare function updateLauncherPaths(globalRoot: string, platform?: NodeJS.Platform): readonly string[];
|
|
61
|
+
/** Validate transaction-scoped recovery authority without trusting npm temporary names. */
|
|
62
|
+
export declare function readUpdateRecoveryCapsule(manifestPath: string, platform?: NodeJS.Platform): Promise<UpdateRecoveryCapsule>;
|
|
63
|
+
/** Prepare durable recovery authority before npm can mutate the live launcher set. */
|
|
64
|
+
export declare function prepareUpdateRecoveryCapsule(options: ProtectedPackageReplacementOptions): Promise<{
|
|
65
|
+
capsule: UpdateRecoveryCapsule;
|
|
66
|
+
manifestPath: string;
|
|
67
|
+
}>;
|
|
68
|
+
/** Execute global replacement behind a detached owner and coordinate terminal cancellation. */
|
|
69
|
+
export declare function runProtectedPackageReplacement(options: ProtectedPackageReplacementOptions): Promise<ProtectedPackageReplacementResult>;
|
|
70
|
+
export declare function removeUpdateRecoveryCapsule(dataDir: string, transactionId: string): Promise<void>;
|
|
71
|
+
/** Classify a complete launcher set without following linked or mixed launcher files. */
|
|
72
|
+
export declare function inspectUpdateLauncherSet(capsule: UpdateRecoveryCapsule, platform?: NodeJS.Platform): Promise<"target" | "recovery" | "unavailable">;
|
|
73
|
+
/** Remove terminal recovery capsules only after no canonical launcher references them. */
|
|
74
|
+
export declare function cleanupUpdateRecoveryCapsules(dataDir: string): Promise<void>;
|