@timurproko/a1 0.1.8-dev.260 → 0.1.8-dev.271
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/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/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/integrations/pi/components/owned-editor-ux.js +61 -8
- package/dist/integrations/pi/components/path-word-ranges.d.ts +2 -0
- package/dist/integrations/pi/components/path-word-ranges.js +37 -0
- 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,352 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { PRODUCT_IDENTITY, PRODUCT_TEXT } from "../../product-identity.js";
|
|
7
|
+
import { processIsAlive } from "./process-cleanup.js";
|
|
8
|
+
export const UPDATE_RECOVERY_SCHEMA = "a1-update-recovery-v1";
|
|
9
|
+
const WORKER_TIMEOUT_MS = 15 * 60 * 1_000;
|
|
10
|
+
const POLL_MS = 50;
|
|
11
|
+
/** Resolve the complete public launcher set npm owns for the active platform. */
|
|
12
|
+
export function updateLauncherPaths(globalRoot, platform = process.platform) {
|
|
13
|
+
const launcherRoot = platform === "win32" ? dirname(globalRoot) : resolve(globalRoot, "..", "..", "bin");
|
|
14
|
+
return platform === "win32"
|
|
15
|
+
? [resolve(launcherRoot, "a1"), resolve(launcherRoot, "a1.cmd"), resolve(launcherRoot, "a1.ps1")]
|
|
16
|
+
: [resolve(launcherRoot, "a1")];
|
|
17
|
+
}
|
|
18
|
+
/** Validate transaction-scoped recovery authority without trusting npm temporary names. */
|
|
19
|
+
export async function readUpdateRecoveryCapsule(manifestPath, platform = process.platform) {
|
|
20
|
+
const capsule = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
21
|
+
if (capsule.schema !== UPDATE_RECOVERY_SCHEMA || !/^[0-9a-f-]{36}$/i.test(capsule.transactionId) || typeof capsule.targetVersion !== "string"
|
|
22
|
+
|| capsule.packageName !== PRODUCT_TEXT.packageName || typeof capsule.packageRoot !== "string" || typeof capsule.globalRoot !== "string"
|
|
23
|
+
|| typeof capsule.launcherRoot !== "string" || !Array.isArray(capsule.launchers) || typeof capsule.priorReleaseId !== "string"
|
|
24
|
+
|| typeof capsule.priorReleaseRoot !== "string" || !/^[a-f0-9]{64}$/.test(capsule.priorContentDigest) || capsule.releaseManifestName !== PRODUCT_IDENTITY.manifest.releaseFilename || typeof capsule.recoveryEntry !== "string"
|
|
25
|
+
|| !/^[a-f0-9]{64}$/.test(capsule.recoveryEntryDigest) || typeof capsule.nodeExecutable !== "string" || typeof capsule.npmCli !== "string" || !Array.isArray(capsule.npmArguments)
|
|
26
|
+
|| typeof capsule.cancellationPath !== "string" || typeof capsule.resultPath !== "string" || typeof capsule.ownerPath !== "string") {
|
|
27
|
+
throw new Error("invalid A1 update recovery capsule");
|
|
28
|
+
}
|
|
29
|
+
const canonicalGlobal = await realpath(capsule.globalRoot);
|
|
30
|
+
const canonicalPackage = await realpath(capsule.packageRoot).catch(() => resolve(capsule.packageRoot));
|
|
31
|
+
const expectedPackage = resolve(canonicalGlobal, ...capsule.packageName.split("/"));
|
|
32
|
+
if (!samePath(canonicalPackage, expectedPackage) || !containedBy(canonicalGlobal, canonicalPackage))
|
|
33
|
+
throw new Error("recovery package root is outside npm global root");
|
|
34
|
+
const expectedNpmArguments = ["install", "--global", "--loglevel=error", "--no-fund", "--no-audit", `${capsule.packageName}@${capsule.targetVersion}`];
|
|
35
|
+
if (JSON.stringify(capsule.npmArguments) !== JSON.stringify(expectedNpmArguments))
|
|
36
|
+
throw new Error("recovery npm arguments differ from the selected target");
|
|
37
|
+
const expectedLaunchers = updateLauncherPaths(canonicalGlobal, platform);
|
|
38
|
+
if (JSON.stringify(capsule.launchers.map(path => resolve(path))) !== JSON.stringify(expectedLaunchers.map(path => resolve(path)))) {
|
|
39
|
+
throw new Error("recovery launcher set differs from the canonical npm launcher set");
|
|
40
|
+
}
|
|
41
|
+
for (const path of capsule.launchers)
|
|
42
|
+
assertDirectChild(resolve(capsule.launcherRoot), resolve(path));
|
|
43
|
+
const lexicalCapsuleRoot = dirname(resolve(manifestPath));
|
|
44
|
+
if (lexicalCapsuleRoot.split(sep).at(-1) !== capsule.transactionId)
|
|
45
|
+
throw new Error("recovery capsule path differs from its transaction identity");
|
|
46
|
+
const capsuleRoot = await realpath(lexicalCapsuleRoot);
|
|
47
|
+
const entry = await realpath(capsule.recoveryEntry);
|
|
48
|
+
assertDirectChild(capsuleRoot, entry);
|
|
49
|
+
if (!samePath(entry, resolve(capsuleRoot, "recovery.js")))
|
|
50
|
+
throw new Error("recovery entry path differs from the managed capsule");
|
|
51
|
+
const expectedSidecars = [resolve(lexicalCapsuleRoot, "cancel.json"), resolve(lexicalCapsuleRoot, "result.json"), resolve(lexicalCapsuleRoot, "owner.json")];
|
|
52
|
+
if (![capsule.cancellationPath, capsule.resultPath, capsule.ownerPath].every((path, index) => samePath(resolve(path), expectedSidecars[index]))) {
|
|
53
|
+
throw new Error("recovery sidecar paths differ from the managed capsule");
|
|
54
|
+
}
|
|
55
|
+
const dataDir = dirname(dirname(capsuleRoot));
|
|
56
|
+
const releasesRoot = await realpath(resolve(dataDir, "releases"));
|
|
57
|
+
const priorReleaseRoot = await realpath(capsule.priorReleaseRoot);
|
|
58
|
+
assertDirectChild(releasesRoot, priorReleaseRoot);
|
|
59
|
+
if (priorReleaseRoot.split(sep).at(-1) !== capsule.priorReleaseId)
|
|
60
|
+
throw new Error("recovery prior release identity is invalid");
|
|
61
|
+
const priorManifest = JSON.parse(await readFile(resolve(priorReleaseRoot, capsule.releaseManifestName), "utf8"));
|
|
62
|
+
if (priorManifest.releaseId !== capsule.priorReleaseId || priorManifest.contentDigest !== capsule.priorContentDigest) {
|
|
63
|
+
throw new Error("recovery prior release manifest differs from the capsule");
|
|
64
|
+
}
|
|
65
|
+
if (resolve(capsule.nodeExecutable) !== resolve(process.execPath))
|
|
66
|
+
throw new Error("recovery Node executable differs from the current runtime");
|
|
67
|
+
const npmMetadata = await lstat(capsule.npmCli);
|
|
68
|
+
if (!npmMetadata.isFile() || npmMetadata.isSymbolicLink())
|
|
69
|
+
throw new Error("recovery npm entry is invalid");
|
|
70
|
+
const digest = createHash("sha256").update(await readFile(entry)).digest("hex");
|
|
71
|
+
if (digest !== capsule.recoveryEntryDigest)
|
|
72
|
+
throw new Error("recovery entry digest differs from capsule");
|
|
73
|
+
return capsule;
|
|
74
|
+
}
|
|
75
|
+
/** Prepare durable recovery authority before npm can mutate the live launcher set. */
|
|
76
|
+
export async function prepareUpdateRecoveryCapsule(options) {
|
|
77
|
+
const root = resolve(options.dataDir, "update-recovery");
|
|
78
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
79
|
+
const finalRoot = resolve(root, options.transaction.transactionId);
|
|
80
|
+
const existingManifest = resolve(finalRoot, "capsule.json");
|
|
81
|
+
if (await lstat(existingManifest).catch(() => null))
|
|
82
|
+
return { capsule: await readUpdateRecoveryCapsule(existingManifest, options.platform), manifestPath: existingManifest };
|
|
83
|
+
const candidate = resolve(root, `.candidate-${options.transaction.transactionId}-${randomUUID()}`);
|
|
84
|
+
await mkdir(candidate, { mode: 0o700 });
|
|
85
|
+
try {
|
|
86
|
+
const sourceEntry = fileURLToPath(new URL("../../../bin/update-recovery.js", import.meta.url));
|
|
87
|
+
const entryBytes = await readFile(sourceEntry);
|
|
88
|
+
const candidateEntry = resolve(candidate, "recovery.js");
|
|
89
|
+
const recoveryEntry = resolve(finalRoot, "recovery.js");
|
|
90
|
+
await writeFile(candidateEntry, entryBytes, { flag: "wx", mode: 0o500 });
|
|
91
|
+
await chmod(candidateEntry, 0o500);
|
|
92
|
+
const canonicalGlobal = await realpath(options.globalRoot);
|
|
93
|
+
const platform = options.platform ?? process.platform;
|
|
94
|
+
const launcherRoot = platform === "win32" ? dirname(canonicalGlobal) : resolve(canonicalGlobal, "..", "..", "bin");
|
|
95
|
+
const npmCli = await resolveNpmCli(canonicalGlobal, options.environment ?? process.env);
|
|
96
|
+
const capsule = {
|
|
97
|
+
schema: UPDATE_RECOVERY_SCHEMA,
|
|
98
|
+
transactionId: options.transaction.transactionId,
|
|
99
|
+
packageName: PRODUCT_TEXT.packageName,
|
|
100
|
+
targetVersion: options.transaction.targetVersion,
|
|
101
|
+
packageRoot: resolve(options.packageRoot),
|
|
102
|
+
globalRoot: canonicalGlobal,
|
|
103
|
+
launcherRoot,
|
|
104
|
+
launchers: updateLauncherPaths(canonicalGlobal, platform),
|
|
105
|
+
priorReleaseId: options.priorRelease.releaseId,
|
|
106
|
+
priorReleaseRoot: resolve(options.priorRelease.releaseRoot),
|
|
107
|
+
priorContentDigest: options.priorRelease.contentDigest,
|
|
108
|
+
releaseManifestName: PRODUCT_IDENTITY.manifest.releaseFilename,
|
|
109
|
+
recoveryEntry,
|
|
110
|
+
recoveryEntryDigest: createHash("sha256").update(entryBytes).digest("hex"),
|
|
111
|
+
nodeExecutable: process.execPath,
|
|
112
|
+
npmCli,
|
|
113
|
+
npmArguments: ["install", "--global", "--loglevel=error", "--no-fund", "--no-audit", `${PRODUCT_TEXT.packageName}@${options.transaction.targetVersion}`],
|
|
114
|
+
cancellationPath: resolve(finalRoot, "cancel.json"),
|
|
115
|
+
resultPath: resolve(finalRoot, "result.json"),
|
|
116
|
+
ownerPath: resolve(finalRoot, "owner.json"),
|
|
117
|
+
createdAt: new Date().toISOString(),
|
|
118
|
+
};
|
|
119
|
+
await writeDurableJson(resolve(candidate, "capsule.json"), capsule);
|
|
120
|
+
try {
|
|
121
|
+
await rename(candidate, finalRoot);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (!await lstat(finalRoot).catch(() => null))
|
|
125
|
+
throw error;
|
|
126
|
+
await rm(candidate, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
const manifestPath = resolve(finalRoot, "capsule.json");
|
|
129
|
+
return { capsule: await readUpdateRecoveryCapsule(manifestPath, options.platform), manifestPath };
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
await rm(candidate, { recursive: true, force: true });
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** Execute global replacement behind a detached owner and coordinate terminal cancellation. */
|
|
137
|
+
export async function runProtectedPackageReplacement(options) {
|
|
138
|
+
const prepared = await prepareUpdateRecoveryCapsule(options);
|
|
139
|
+
const capsule = prepared.capsule;
|
|
140
|
+
await options.onRecoveryState?.({
|
|
141
|
+
capsulePath: prepared.manifestPath,
|
|
142
|
+
status: "prepared",
|
|
143
|
+
guardianPid: null,
|
|
144
|
+
guardianStartIdentity: null,
|
|
145
|
+
cancellationRequested: false,
|
|
146
|
+
launcherDisposition: "pending",
|
|
147
|
+
});
|
|
148
|
+
let owner = await readLiveRecoveryOwner(capsule.ownerPath, capsule.transactionId);
|
|
149
|
+
const ownerIsLive = owner !== null;
|
|
150
|
+
const priorResult = await readRecoveryResult(capsule.resultPath, capsule.transactionId).catch(() => null);
|
|
151
|
+
if (!ownerIsLive && priorResult?.outcome !== "installed") {
|
|
152
|
+
const startLease = resolve(dirname(prepared.manifestPath), "worker-starting.lock");
|
|
153
|
+
if (await acquireStartLease(startLease)) {
|
|
154
|
+
await Promise.all([rm(capsule.resultPath, { force: true }), rm(capsule.cancellationPath, { force: true }), rm(capsule.ownerPath, { force: true })]);
|
|
155
|
+
owner = null;
|
|
156
|
+
try {
|
|
157
|
+
await (options.workerSpawner ?? spawnRecoveryWorker)(capsule.recoveryEntry, prepared.manifestPath, options.environment ?? process.env);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
await rm(startLease, { force: true });
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
let runningRecorded = false;
|
|
166
|
+
let cancellationReported = false;
|
|
167
|
+
let cancellationWrite = null;
|
|
168
|
+
const requestCancellation = (signal) => {
|
|
169
|
+
cancellationWrite ??= writeDurableJson(capsule.cancellationPath, { schema: UPDATE_RECOVERY_SCHEMA, transactionId: capsule.transactionId, signal, requestedAt: new Date().toISOString() });
|
|
170
|
+
if (!cancellationReported) {
|
|
171
|
+
cancellationReported = true;
|
|
172
|
+
options.output.stderr(`${PRODUCT_TEXT.diagnostic("cancellation requested; restoring a callable launcher before exiting.")}\n`);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
const onSigint = () => requestCancellation("SIGINT");
|
|
176
|
+
const onSigterm = () => requestCancellation("SIGTERM");
|
|
177
|
+
process.on("SIGINT", onSigint);
|
|
178
|
+
process.on("SIGTERM", onSigterm);
|
|
179
|
+
try {
|
|
180
|
+
const deadline = Date.now() + (options.timeoutMs ?? WORKER_TIMEOUT_MS);
|
|
181
|
+
while (Date.now() < deadline) {
|
|
182
|
+
const result = await readRecoveryResult(capsule.resultPath, capsule.transactionId).catch(() => null);
|
|
183
|
+
if (result) {
|
|
184
|
+
const recovery = {
|
|
185
|
+
capsulePath: prepared.manifestPath,
|
|
186
|
+
status: result.outcome === "installed" ? "package-installed" : "recovery-launcher",
|
|
187
|
+
guardianPid: typeof owner?.pid === "number" ? owner.pid : null,
|
|
188
|
+
guardianStartIdentity: typeof owner?.startIdentity === "string" ? owner.startIdentity : null,
|
|
189
|
+
cancellationRequested: result.cancelled,
|
|
190
|
+
launcherDisposition: result.launcherDisposition,
|
|
191
|
+
};
|
|
192
|
+
if (cancellationWrite)
|
|
193
|
+
await cancellationWrite;
|
|
194
|
+
await options.onRecoveryState?.(recovery);
|
|
195
|
+
return { ...result, recovery };
|
|
196
|
+
}
|
|
197
|
+
owner = await readLiveRecoveryOwner(capsule.ownerPath, capsule.transactionId) ?? owner;
|
|
198
|
+
if (!runningRecorded && owner?.transactionId === capsule.transactionId && typeof owner.pid === "number" && typeof owner.startIdentity === "string") {
|
|
199
|
+
runningRecorded = true;
|
|
200
|
+
await rm(resolve(dirname(prepared.manifestPath), "worker-starting.lock"), { force: true });
|
|
201
|
+
await options.onRecoveryState?.({
|
|
202
|
+
capsulePath: prepared.manifestPath,
|
|
203
|
+
status: "running",
|
|
204
|
+
guardianPid: owner.pid,
|
|
205
|
+
guardianStartIdentity: owner.startIdentity,
|
|
206
|
+
cancellationRequested: false,
|
|
207
|
+
launcherDisposition: "pending",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
await new Promise(resolvePromise => setTimeout(resolvePromise, POLL_MS));
|
|
211
|
+
}
|
|
212
|
+
requestCancellation("SIGTERM");
|
|
213
|
+
if (cancellationWrite)
|
|
214
|
+
await cancellationWrite;
|
|
215
|
+
throw new Error("A1 update recovery guardian timed out before establishing a callable launcher");
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
process.off("SIGINT", onSigint);
|
|
219
|
+
process.off("SIGTERM", onSigterm);
|
|
220
|
+
try {
|
|
221
|
+
await cancellationWrite;
|
|
222
|
+
}
|
|
223
|
+
catch { }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
export async function removeUpdateRecoveryCapsule(dataDir, transactionId) {
|
|
227
|
+
if (!/^[0-9a-f-]+$/i.test(transactionId))
|
|
228
|
+
throw new Error("invalid update recovery transaction identity");
|
|
229
|
+
await rm(resolve(dataDir, "update-recovery", transactionId), { recursive: true, force: true });
|
|
230
|
+
}
|
|
231
|
+
/** Classify a complete launcher set without following linked or mixed launcher files. */
|
|
232
|
+
export async function inspectUpdateLauncherSet(capsule, platform = process.platform) {
|
|
233
|
+
let disposition = null;
|
|
234
|
+
const targetToken = `node_modules/${capsule.packageName}/bin/cli.js`;
|
|
235
|
+
for (const path of capsule.launchers) {
|
|
236
|
+
const metadata = await lstat(path).catch(() => null);
|
|
237
|
+
if (!metadata?.isFile() || metadata.isSymbolicLink() || (platform !== "win32" && (metadata.mode & 0o111) === 0))
|
|
238
|
+
return "unavailable";
|
|
239
|
+
const source = (await readFile(path, "utf8")).replaceAll("\\", "/");
|
|
240
|
+
const current = source.includes(capsule.recoveryEntry.replaceAll("\\", "/")) ? "recovery"
|
|
241
|
+
: source.includes(targetToken) ? "target" : null;
|
|
242
|
+
if (current === null || disposition !== null && disposition !== current)
|
|
243
|
+
return "unavailable";
|
|
244
|
+
disposition = current;
|
|
245
|
+
}
|
|
246
|
+
return disposition ?? "unavailable";
|
|
247
|
+
}
|
|
248
|
+
/** Remove terminal recovery capsules only after no canonical launcher references them. */
|
|
249
|
+
export async function cleanupUpdateRecoveryCapsules(dataDir) {
|
|
250
|
+
const root = resolve(dataDir, "update-recovery");
|
|
251
|
+
for (const entry of await readdir(root, { withFileTypes: true }).catch(() => [])) {
|
|
252
|
+
if (!entry.isDirectory() || entry.name.startsWith(".candidate-"))
|
|
253
|
+
continue;
|
|
254
|
+
const capsuleRoot = resolve(root, entry.name);
|
|
255
|
+
const manifestPath = resolve(capsuleRoot, "capsule.json");
|
|
256
|
+
try {
|
|
257
|
+
const capsule = await readUpdateRecoveryCapsule(manifestPath);
|
|
258
|
+
if (await inspectUpdateLauncherSet(capsule) !== "recovery")
|
|
259
|
+
await rm(capsuleRoot, { recursive: true, force: true });
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// Security: malformed recovery authority is retained for diagnostics rather than used as deletion authority.
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
async function spawnRecoveryWorker(entry, manifestPath, environment) {
|
|
267
|
+
const child = spawn(process.execPath, [entry, "--worker", manifestPath], { detached: true, stdio: "ignore", windowsHide: true, env: environment });
|
|
268
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
269
|
+
child.once("spawn", resolvePromise);
|
|
270
|
+
child.once("error", rejectPromise);
|
|
271
|
+
});
|
|
272
|
+
child.unref();
|
|
273
|
+
}
|
|
274
|
+
async function resolveNpmCli(globalRoot, environment) {
|
|
275
|
+
const candidates = [environment.npm_execpath, resolve(globalRoot, "npm", "bin", "npm-cli.js")].filter((value) => typeof value === "string" && value.length > 0);
|
|
276
|
+
for (const candidate of candidates) {
|
|
277
|
+
const canonical = await realpath(candidate).catch(() => null);
|
|
278
|
+
if (canonical && await lstat(canonical).then(metadata => metadata.isFile()).catch(() => false))
|
|
279
|
+
return canonical;
|
|
280
|
+
}
|
|
281
|
+
throw new Error("could not resolve npm's JavaScript entry for protected package replacement");
|
|
282
|
+
}
|
|
283
|
+
async function readLiveRecoveryOwner(path, transactionId) {
|
|
284
|
+
const owner = await readJson(path).catch(() => null);
|
|
285
|
+
const metadata = await lstat(path).catch(() => null);
|
|
286
|
+
if (!owner || owner.transactionId !== transactionId || typeof owner.pid !== "number" || typeof owner.startIdentity !== "string"
|
|
287
|
+
|| !metadata || Date.now() - metadata.mtimeMs > 2_000 || !processIsAlive(owner.pid))
|
|
288
|
+
return null;
|
|
289
|
+
return owner;
|
|
290
|
+
}
|
|
291
|
+
async function acquireStartLease(path) {
|
|
292
|
+
try {
|
|
293
|
+
const file = await open(path, "wx", 0o600);
|
|
294
|
+
await file.writeFile(JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }));
|
|
295
|
+
await file.close();
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
|
|
300
|
+
throw error;
|
|
301
|
+
const metadata = await lstat(path).catch(() => null);
|
|
302
|
+
if (metadata && Date.now() - metadata.mtimeMs > 5_000) {
|
|
303
|
+
await rm(path, { force: true });
|
|
304
|
+
return await acquireStartLease(path);
|
|
305
|
+
}
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
async function readRecoveryResult(path, transactionId) {
|
|
310
|
+
const value = await readJson(path).catch(error => {
|
|
311
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
312
|
+
return null;
|
|
313
|
+
throw error;
|
|
314
|
+
});
|
|
315
|
+
if (value === null)
|
|
316
|
+
return null;
|
|
317
|
+
if (value.schema !== UPDATE_RECOVERY_SCHEMA || value.transactionId !== transactionId || !["installed", "recovery-launcher", "failed"].includes(value.outcome)
|
|
318
|
+
|| !["target", "recovery", "unavailable"].includes(value.launcherDisposition) || typeof value.cancelled !== "boolean"
|
|
319
|
+
|| typeof value.stdout !== "string" || typeof value.stderr !== "string")
|
|
320
|
+
throw new Error("invalid A1 update recovery result");
|
|
321
|
+
return value;
|
|
322
|
+
}
|
|
323
|
+
async function writeDurableJson(path, value) {
|
|
324
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
325
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
326
|
+
const file = await open(temporary, "wx", 0o600);
|
|
327
|
+
try {
|
|
328
|
+
await file.writeFile(JSON.stringify(value, null, 2));
|
|
329
|
+
await file.sync();
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
await file.close();
|
|
333
|
+
}
|
|
334
|
+
await rename(temporary, path);
|
|
335
|
+
}
|
|
336
|
+
async function readJson(path) { return JSON.parse(await readFile(path, "utf8")); }
|
|
337
|
+
function samePath(left, right) {
|
|
338
|
+
return process.platform === "win32" ? resolve(left).toLowerCase() === resolve(right).toLowerCase() : resolve(left) === resolve(right);
|
|
339
|
+
}
|
|
340
|
+
function containedBy(parent, child) {
|
|
341
|
+
const fromParent = relative(parent, child);
|
|
342
|
+
return fromParent.length > 0 && fromParent !== ".." && !fromParent.startsWith(`..${sep}`) && !isAbsolute(fromParent);
|
|
343
|
+
}
|
|
344
|
+
function assertDirectChild(parent, child) {
|
|
345
|
+
const expectedParent = resolve(parent);
|
|
346
|
+
const actualParent = dirname(resolve(child));
|
|
347
|
+
const matches = process.platform === "win32"
|
|
348
|
+
? actualParent.toLowerCase() === expectedParent.toLowerCase()
|
|
349
|
+
: actualParent === expectedParent;
|
|
350
|
+
if (!matches)
|
|
351
|
+
throw new Error(`update recovery path is outside its managed root: ${child}`);
|
|
352
|
+
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
export declare const UPDATE_JOURNAL_SCHEMA: string;
|
|
2
2
|
import type { UpdateChannel } from "./update.js";
|
|
3
3
|
export type UpdateTransactionPhase = "shutdown-intent" | "ownership-released" | "package-installed" | "materialized" | "certified" | "active-reference-committed" | "supervisor-verified";
|
|
4
|
+
export interface UpdateRecoveryState {
|
|
5
|
+
readonly capsulePath: string;
|
|
6
|
+
readonly status: "prepared" | "running" | "package-installed" | "recovery-launcher";
|
|
7
|
+
readonly guardianPid: number | null;
|
|
8
|
+
readonly guardianStartIdentity: string | null;
|
|
9
|
+
readonly cancellationRequested: boolean;
|
|
10
|
+
readonly launcherDisposition: "pending" | "target" | "recovery" | "unavailable";
|
|
11
|
+
}
|
|
4
12
|
export interface UpdateTransaction {
|
|
5
13
|
readonly schema: typeof UPDATE_JOURNAL_SCHEMA;
|
|
6
14
|
readonly transactionId: string;
|
|
@@ -11,6 +19,7 @@ export interface UpdateTransaction {
|
|
|
11
19
|
readonly phase: UpdateTransactionPhase;
|
|
12
20
|
readonly status: "active" | "completed" | "rolled-back" | "failed";
|
|
13
21
|
readonly error: string | null;
|
|
22
|
+
readonly recovery?: UpdateRecoveryState;
|
|
14
23
|
readonly startedAt: string;
|
|
15
24
|
readonly updatedAt: string;
|
|
16
25
|
}
|
|
@@ -27,6 +36,7 @@ export declare class UpdateTransactionStore {
|
|
|
27
36
|
priorActiveReleaseId: string | null;
|
|
28
37
|
}): Promise<UpdateTransaction>;
|
|
29
38
|
advance(phase: UpdateTransactionPhase): Promise<UpdateTransaction>;
|
|
39
|
+
setRecovery(recovery: UpdateRecoveryState): Promise<UpdateTransaction>;
|
|
30
40
|
finish(status: "completed" | "rolled-back" | "failed", error?: string | null): Promise<UpdateTransaction>;
|
|
31
41
|
clearCompleted(): Promise<void>;
|
|
32
42
|
}
|
|
@@ -45,6 +45,10 @@ export class UpdateTransactionStore {
|
|
|
45
45
|
return current;
|
|
46
46
|
return await this.#write({ ...current, phase, updatedAt: new Date().toISOString() });
|
|
47
47
|
}
|
|
48
|
+
async setRecovery(recovery) {
|
|
49
|
+
const current = await this.#requiredActive();
|
|
50
|
+
return await this.#write({ ...current, recovery, updatedAt: new Date().toISOString() });
|
|
51
|
+
}
|
|
48
52
|
async finish(status, error = null) {
|
|
49
53
|
const current = await this.read();
|
|
50
54
|
if (!current)
|
|
@@ -84,7 +88,14 @@ function phaseOrder(phase) {
|
|
|
84
88
|
function validate(value) {
|
|
85
89
|
if (value.schema !== UPDATE_JOURNAL_SCHEMA || typeof value.transactionId !== "string" || !["stable", "next"].includes(value.channel)
|
|
86
90
|
|| typeof value.targetVersion !== "string" || typeof value.packageRoot !== "string" || typeof value.startedAt !== "string"
|
|
87
|
-
|| typeof value.updatedAt !== "string" || phaseOrder(value.phase) < 0 || !["active", "completed", "rolled-back", "failed"].includes(value.status)
|
|
91
|
+
|| typeof value.updatedAt !== "string" || phaseOrder(value.phase) < 0 || !["active", "completed", "rolled-back", "failed"].includes(value.status)
|
|
92
|
+
|| (value.recovery !== undefined && !validRecovery(value.recovery))) {
|
|
88
93
|
throw new Error(`invalid ${PRODUCT_TEXT.displayName} update transaction journal`);
|
|
89
94
|
}
|
|
90
95
|
}
|
|
96
|
+
function validRecovery(value) {
|
|
97
|
+
return typeof value.capsulePath === "string" && ["prepared", "running", "package-installed", "recovery-launcher"].includes(value.status)
|
|
98
|
+
&& (value.guardianPid === null || Number.isSafeInteger(value.guardianPid) && value.guardianPid > 0)
|
|
99
|
+
&& (value.guardianStartIdentity === null || typeof value.guardianStartIdentity === "string")
|
|
100
|
+
&& typeof value.cancellationRequested === "boolean" && ["pending", "target", "recovery", "unavailable"].includes(value.launcherDisposition);
|
|
101
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type UpdateTransaction, type UpdateTransactionPhase } from "./update-transaction.js";
|
|
1
|
+
import { type UpdateRecoveryState, type UpdateTransaction, type UpdateTransactionPhase } from "./update-transaction.js";
|
|
2
|
+
import { type ProtectedPackageReplacementResult } from "./update-recovery.js";
|
|
2
3
|
export declare const PRODUCT_PACKAGE: string;
|
|
3
4
|
export type UpdateChannel = "stable" | "next";
|
|
4
5
|
export interface ProcessRequest {
|
|
@@ -47,6 +48,8 @@ export interface SelfUpdateOptions {
|
|
|
47
48
|
runner?: UpdateProcessRunner;
|
|
48
49
|
lifecycle?: UpdateLifecycleCoordinator;
|
|
49
50
|
transactionStore?: UpdateTransactionJournal;
|
|
51
|
+
/** Test seam for protected global package replacement. */
|
|
52
|
+
packageReplacement?: (input: UpdatePackageReplacementInput) => Promise<ProtectedPackageReplacementResult>;
|
|
50
53
|
/** Test or embedding seam for post-activation release maintenance. */
|
|
51
54
|
maintenance?: () => Promise<void>;
|
|
52
55
|
progress?: boolean;
|
|
@@ -81,9 +84,24 @@ export interface UpdateTransactionJournal {
|
|
|
81
84
|
priorActiveReleaseId: string | null;
|
|
82
85
|
}): Promise<UpdateTransaction>;
|
|
83
86
|
advance(phase: UpdateTransactionPhase): Promise<UpdateTransaction>;
|
|
87
|
+
setRecovery?(recovery: UpdateRecoveryState): Promise<UpdateTransaction>;
|
|
84
88
|
finish(status: "completed" | "rolled-back" | "failed", error?: string | null): Promise<UpdateTransaction>;
|
|
85
89
|
clearCompleted(): Promise<void>;
|
|
86
90
|
}
|
|
91
|
+
export interface UpdatePackageReplacementInput {
|
|
92
|
+
readonly dataDir: string;
|
|
93
|
+
readonly globalRoot: string;
|
|
94
|
+
readonly packageRoot: string;
|
|
95
|
+
readonly transaction: UpdateTransaction;
|
|
96
|
+
readonly priorRelease: {
|
|
97
|
+
readonly releaseId: string;
|
|
98
|
+
readonly releaseRoot: string;
|
|
99
|
+
readonly contentDigest: string;
|
|
100
|
+
};
|
|
101
|
+
readonly output: UpdateOutput;
|
|
102
|
+
readonly environment: NodeJS.ProcessEnv;
|
|
103
|
+
readonly onRecoveryState: (state: UpdateRecoveryState) => Promise<void>;
|
|
104
|
+
}
|
|
87
105
|
export declare function createNpmProcessRunner(platform?: NodeJS.Platform): UpdateProcessRunner;
|
|
88
106
|
export type UpdateOwnershipAction = "clean-dead-record" | "leave-running" | "end-session";
|
|
89
107
|
/**
|
|
@@ -14,6 +14,7 @@ import { materializeRelease, readMaterializedRelease } from "./release-store.js"
|
|
|
14
14
|
import { scheduleReleaseCleanup } from "./release-gc.js";
|
|
15
15
|
import { warmMaterializedRelease } from "./warmup.js";
|
|
16
16
|
import { UpdateTransactionStore } from "./update-transaction.js";
|
|
17
|
+
import { removeUpdateRecoveryCapsule, runProtectedPackageReplacement } from "./update-recovery.js";
|
|
17
18
|
export const PRODUCT_PACKAGE = PRODUCT_TEXT.packageName;
|
|
18
19
|
const UPDATE_DIST_TAGS = { stable: "latest", next: "next" };
|
|
19
20
|
const defaultFileSystem = {
|
|
@@ -386,12 +387,24 @@ export async function runSelfUpdate(options) {
|
|
|
386
387
|
}
|
|
387
388
|
// Rationale: the bar first appears here so a no-change run never flashes it.
|
|
388
389
|
progress.set(3, 15);
|
|
389
|
-
const
|
|
390
|
+
const cohortStore = new CohortStateStore(paths.dataDir);
|
|
391
|
+
let cohortState = await cohortStore.read();
|
|
392
|
+
let priorActiveReleaseId = cohortState.references.active;
|
|
393
|
+
if (options.runner === undefined && (priorActiveReleaseId === null || cohortState.releases[priorActiveReleaseId]?.approval !== "approved")) {
|
|
394
|
+
// Invariant: protected replacement needs an immutable launch authority that npm cannot rename.
|
|
395
|
+
const recoveryRelease = await materializeRelease(packageRoot, paths.dataDir);
|
|
396
|
+
await cohortStore.recordCandidate(recoveryRelease);
|
|
397
|
+
const diagnostics = await certifyMaterializedRelease(recoveryRelease, paths.dataDir);
|
|
398
|
+
await cohortStore.approve(recoveryRelease.releaseId, diagnostics);
|
|
399
|
+
await cohortStore.activate(recoveryRelease.releaseId);
|
|
400
|
+
cohortState = await cohortStore.read();
|
|
401
|
+
priorActiveReleaseId = recoveryRelease.releaseId;
|
|
402
|
+
}
|
|
390
403
|
transaction = await transactionStore.begin({
|
|
391
404
|
channel,
|
|
392
405
|
targetVersion,
|
|
393
406
|
packageRoot,
|
|
394
|
-
priorActiveReleaseId
|
|
407
|
+
priorActiveReleaseId,
|
|
395
408
|
});
|
|
396
409
|
if (phaseBefore(transaction.phase, "ownership-released")) {
|
|
397
410
|
await measure("ownership-release", async () => {
|
|
@@ -402,15 +415,57 @@ export async function runSelfUpdate(options) {
|
|
|
402
415
|
}
|
|
403
416
|
progress.set(15, 70);
|
|
404
417
|
if (phaseBefore(transaction.phase, "package-installed")) {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
418
|
+
if (options.packageReplacement || options.runner === undefined) {
|
|
419
|
+
const priorReleaseId = transaction.priorActiveReleaseId ?? priorActiveReleaseId;
|
|
420
|
+
const recordedPrior = priorReleaseId === null ? undefined : cohortState.releases[priorReleaseId];
|
|
421
|
+
if ((!recordedPrior || recordedPrior.approval !== "approved") && !options.packageReplacement) {
|
|
422
|
+
throw new Error(PRODUCT_TEXT.diagnostic("cannot protect package replacement because no approved prior release is available"));
|
|
423
|
+
}
|
|
424
|
+
const priorRelease = recordedPrior ?? {
|
|
425
|
+
releaseId: priorReleaseId ?? "injected-prior-release",
|
|
426
|
+
releaseRoot: packageRoot,
|
|
427
|
+
contentDigest: "0".repeat(64),
|
|
428
|
+
};
|
|
429
|
+
const replacementTransaction = transaction;
|
|
430
|
+
const replacement = await measure("npm-install", async () => await (options.packageReplacement ?? runProtectedPackageReplacement)({
|
|
431
|
+
dataDir: paths.dataDir,
|
|
432
|
+
globalRoot,
|
|
433
|
+
packageRoot,
|
|
434
|
+
transaction: replacementTransaction,
|
|
435
|
+
priorRelease,
|
|
436
|
+
output,
|
|
437
|
+
environment,
|
|
438
|
+
onRecoveryState: async (recovery) => {
|
|
439
|
+
if (transactionStore.setRecovery)
|
|
440
|
+
transaction = await transactionStore.setRecovery(recovery) ?? transaction;
|
|
441
|
+
},
|
|
442
|
+
}));
|
|
443
|
+
if (replacement.stdout.trim().length > 0 && replacement.outcome !== "installed")
|
|
444
|
+
output.stderr(`${replacement.stdout.trimEnd()}\n`);
|
|
445
|
+
if (replacement.stderr.trim().length > 0)
|
|
446
|
+
output.stderr(`${replacement.stderr.trimEnd()}\n`);
|
|
447
|
+
if (replacement.outcome === "installed")
|
|
448
|
+
transaction = await transactionStore.advance("package-installed");
|
|
449
|
+
if (replacement.cancelled) {
|
|
450
|
+
progress.clear();
|
|
451
|
+
output.stderr(`${PRODUCT_TEXT.diagnostic("update cancelled safely; the A1 launcher is available and the transaction can be resumed.")}\n`);
|
|
452
|
+
return 130;
|
|
453
|
+
}
|
|
454
|
+
if (replacement.outcome !== "installed") {
|
|
455
|
+
throw new UpdateFailure(unsuccessfulCode(replacement.npmExitCode), `npm exited with status ${formatExitCode(replacement.npmExitCode)}`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
const installation = await measure("npm-install", async () => await runNpm(runner, ["install", "--global", "--loglevel=error", "--no-fund", "--no-audit", `${PRODUCT_PACKAGE}@${targetVersion}`], true, output, "start the global npm installation", false));
|
|
460
|
+
if (installation.result === null)
|
|
461
|
+
throw new UpdateFailure(installation.exitCode, "npm process failed");
|
|
462
|
+
if (installation.result.code !== 0) {
|
|
463
|
+
if (installation.result.stdout.trim().length > 0)
|
|
464
|
+
output.stderr(`${installation.result.stdout.trimEnd()}\n`);
|
|
465
|
+
throw new UpdateFailure(unsuccessfulCode(installation.result.code), `npm exited with status ${formatExitCode(installation.result.code)}`);
|
|
466
|
+
}
|
|
467
|
+
transaction = await transactionStore.advance("package-installed");
|
|
412
468
|
}
|
|
413
|
-
transaction = await transactionStore.advance("package-installed");
|
|
414
469
|
}
|
|
415
470
|
// Compatibility: npm 12 blocks install scripts unless allowScripts covers the package, so
|
|
416
471
|
// the postinstall that points the #pi-tui proxy at the tree npm just built
|
|
@@ -456,6 +511,8 @@ export async function runSelfUpdate(options) {
|
|
|
456
511
|
const transactionStartedAt = now();
|
|
457
512
|
await transactionStore.advance("supervisor-verified");
|
|
458
513
|
await transactionStore.finish("completed");
|
|
514
|
+
if (transaction.recovery?.capsulePath)
|
|
515
|
+
await removeUpdateRecoveryCapsule(paths.dataDir, transaction.transactionId);
|
|
459
516
|
// Invariant: successful output follows the durable cleanup disposition. Slow recursive
|
|
460
517
|
// removal belongs to the detached worker started by this maintenance coordinator.
|
|
461
518
|
await maintenance();
|