@getmikie/cli 1.1.86 → 1.1.87
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/mikie.mjs +156 -0
- package/lib/launcher.mjs +88 -0
- package/lib/update.mjs +2944 -0
- package/package.json +5 -5
package/lib/update.mjs
ADDED
|
@@ -0,0 +1,2944 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
cp,
|
|
5
|
+
chmod,
|
|
6
|
+
link,
|
|
7
|
+
lstat,
|
|
8
|
+
mkdir,
|
|
9
|
+
open,
|
|
10
|
+
readFile,
|
|
11
|
+
readdir,
|
|
12
|
+
rename,
|
|
13
|
+
rm,
|
|
14
|
+
stat,
|
|
15
|
+
} from "node:fs/promises";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { isDeepStrictEqual } from "node:util";
|
|
19
|
+
|
|
20
|
+
const WRAPPER_PACKAGE = "@getmikie/cli";
|
|
21
|
+
const HANDOFF_SCHEMA = "mikie.update-handoff.v1";
|
|
22
|
+
const REPAIR_SCHEMA = "mikie.claude-hook-repair.v1";
|
|
23
|
+
const RERUN_COMMAND = "npx -y @getmikie/cli@latest update";
|
|
24
|
+
const MAX_COMMAND_OUTPUT_BYTES = 1024 * 1024;
|
|
25
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
|
|
26
|
+
const TRANSACTION_SCHEMA = "mikie.npm-update-transaction.v1";
|
|
27
|
+
const LOCK_SCHEMA = "mikie.npm-update-lock.v1";
|
|
28
|
+
const MAX_TRANSACTION_TREE_ENTRIES = 16_384;
|
|
29
|
+
const MAX_TRANSACTION_TREE_BYTES = 512 * 1024 * 1024;
|
|
30
|
+
const MAX_TRANSACTION_JOURNAL_BYTES = 2 * 1024 * 1024;
|
|
31
|
+
const RECOVERY_SCHEMA = "mikie.npm-update-recovery.v1";
|
|
32
|
+
const MAX_RECOVERY_ARTIFACT_BYTES = 2 * 1024 * 1024;
|
|
33
|
+
const TRANSACTION_TOKEN_PATTERN =
|
|
34
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
35
|
+
|
|
36
|
+
const RECOVERY_LAUNCHER_SOURCE = `import { createHash } from "node:crypto";
|
|
37
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
38
|
+
import path from "node:path";
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const [transactionRoot, expectedDescriptorSha256] = process.argv.slice(1);
|
|
42
|
+
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
43
|
+
const inside = (parent, candidate) => {
|
|
44
|
+
const relative = path.relative(parent, candidate);
|
|
45
|
+
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
46
|
+
};
|
|
47
|
+
const readBoundedFile = async (filePath, maximum) => {
|
|
48
|
+
const metadata = await lstat(filePath);
|
|
49
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > maximum) {
|
|
50
|
+
throw new Error("invalid Mikie recovery artifact");
|
|
51
|
+
}
|
|
52
|
+
return readFile(filePath);
|
|
53
|
+
};
|
|
54
|
+
if (!path.isAbsolute(transactionRoot) || !/^[a-f0-9]{64}$/.test(expectedDescriptorSha256)) {
|
|
55
|
+
throw new Error("invalid Mikie recovery invocation");
|
|
56
|
+
}
|
|
57
|
+
const descriptorBytes = await readBoundedFile(path.join(transactionRoot, "recovery.json"), 2097152);
|
|
58
|
+
if (digest(descriptorBytes) !== expectedDescriptorSha256) {
|
|
59
|
+
throw new Error("tampered Mikie recovery descriptor");
|
|
60
|
+
}
|
|
61
|
+
const descriptor = JSON.parse(descriptorBytes.toString("utf8"));
|
|
62
|
+
const journalBytes = await readBoundedFile(path.join(transactionRoot, "journal.json"), 2097152);
|
|
63
|
+
const journal = JSON.parse(journalBytes.toString("utf8"));
|
|
64
|
+
if (
|
|
65
|
+
descriptor.transaction_root !== transactionRoot ||
|
|
66
|
+
descriptor.token !== journal.token ||
|
|
67
|
+
journal.recovery?.descriptor_sha256 !== expectedDescriptorSha256 ||
|
|
68
|
+
![descriptor.runtime, descriptor.launcher].every(
|
|
69
|
+
(artifactPath) => typeof artifactPath === "string" && inside(transactionRoot, artifactPath),
|
|
70
|
+
)
|
|
71
|
+
) {
|
|
72
|
+
throw new Error("invalid Mikie recovery authority");
|
|
73
|
+
}
|
|
74
|
+
const [runtimeBytes, launcherBytes] = await Promise.all([
|
|
75
|
+
readBoundedFile(descriptor.runtime, 2097152),
|
|
76
|
+
readBoundedFile(descriptor.launcher, 2097152),
|
|
77
|
+
]);
|
|
78
|
+
if (
|
|
79
|
+
digest(runtimeBytes) !== descriptor.runtime_sha256 ||
|
|
80
|
+
digest(launcherBytes) !== descriptor.launcher_sha256
|
|
81
|
+
) {
|
|
82
|
+
throw new Error("tampered Mikie recovery authority");
|
|
83
|
+
}
|
|
84
|
+
const runtime = await import("data:text/javascript;base64," + runtimeBytes.toString("base64"));
|
|
85
|
+
await runtime.recoverExternalNpmUpdate(transactionRoot, {
|
|
86
|
+
descriptorSha256: expectedDescriptorSha256,
|
|
87
|
+
launcherSha256: descriptor.launcher_sha256,
|
|
88
|
+
runtimeSha256: descriptor.runtime_sha256,
|
|
89
|
+
});
|
|
90
|
+
} catch {
|
|
91
|
+
process.stderr.write("mikie: recovery failed\\n");
|
|
92
|
+
process.exitCode = 1;
|
|
93
|
+
}
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
const RECOVERY_INLINE_EVAL = `await import('data:text/javascript;base64,${Buffer.from(
|
|
97
|
+
RECOVERY_LAUNCHER_SOURCE,
|
|
98
|
+
).toString("base64")}')`;
|
|
99
|
+
|
|
100
|
+
const CLAUDE_HOOK_EVENTS = [
|
|
101
|
+
["SessionStart", "session-start"],
|
|
102
|
+
["UserPromptSubmit", "user-prompt-submit"],
|
|
103
|
+
["PreToolUse", "pre-tool-use"],
|
|
104
|
+
["PostToolUse", "post-tool-use"],
|
|
105
|
+
["PostToolUseFailure", "post-tool-use-failure"],
|
|
106
|
+
["PostToolBatch", "post-tool-batch"],
|
|
107
|
+
["Stop", "stop"],
|
|
108
|
+
["StopFailure", "stop-failure"],
|
|
109
|
+
["SessionEnd", "session-end"],
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
function canonicalJson(value) {
|
|
113
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
114
|
+
if (value && typeof value === "object") {
|
|
115
|
+
return `{${Object.keys(value)
|
|
116
|
+
.sort()
|
|
117
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
|
118
|
+
.join(",")}}`;
|
|
119
|
+
}
|
|
120
|
+
return JSON.stringify(value);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sha256Text(value) {
|
|
124
|
+
return createHash("sha256").update(value).digest("hex");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function sha256Bytes(value) {
|
|
128
|
+
return createHash("sha256").update(value).digest("hex");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseJsonNoDuplicateKeys(input) {
|
|
132
|
+
const text = Buffer.isBuffer(input) ? input.toString("utf8") : input;
|
|
133
|
+
if (typeof text !== "string") throw new Error("invalid JSON input");
|
|
134
|
+
let index = 0;
|
|
135
|
+
const fail = () => {
|
|
136
|
+
throw new Error("invalid JSON input");
|
|
137
|
+
};
|
|
138
|
+
const skipWhitespace = () => {
|
|
139
|
+
while (index < text.length && /[\t\n\r ]/.test(text[index])) index += 1;
|
|
140
|
+
};
|
|
141
|
+
const parseString = () => {
|
|
142
|
+
if (text[index] !== '"') fail();
|
|
143
|
+
const start = index;
|
|
144
|
+
index += 1;
|
|
145
|
+
while (index < text.length) {
|
|
146
|
+
const character = text[index];
|
|
147
|
+
if (character === '"') {
|
|
148
|
+
index += 1;
|
|
149
|
+
try {
|
|
150
|
+
return JSON.parse(text.slice(start, index));
|
|
151
|
+
} catch {
|
|
152
|
+
fail();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (character === "\\") {
|
|
156
|
+
index += 1;
|
|
157
|
+
if (index >= text.length) fail();
|
|
158
|
+
if (text[index] === "u") {
|
|
159
|
+
if (!/^[0-9a-fA-F]{4}$/.test(text.slice(index + 1, index + 5))) fail();
|
|
160
|
+
index += 5;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (!'"\\/bfnrt'.includes(text[index])) fail();
|
|
164
|
+
} else if (character.charCodeAt(0) < 0x20) {
|
|
165
|
+
fail();
|
|
166
|
+
}
|
|
167
|
+
index += 1;
|
|
168
|
+
}
|
|
169
|
+
fail();
|
|
170
|
+
};
|
|
171
|
+
const parseValue = () => {
|
|
172
|
+
skipWhitespace();
|
|
173
|
+
if (text[index] === "{") {
|
|
174
|
+
index += 1;
|
|
175
|
+
skipWhitespace();
|
|
176
|
+
const keys = new Set();
|
|
177
|
+
if (text[index] === "}") {
|
|
178
|
+
index += 1;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
while (index < text.length) {
|
|
182
|
+
skipWhitespace();
|
|
183
|
+
const key = parseString();
|
|
184
|
+
if (keys.has(key)) throw new Error("duplicate JSON key");
|
|
185
|
+
keys.add(key);
|
|
186
|
+
skipWhitespace();
|
|
187
|
+
if (text[index] !== ":") fail();
|
|
188
|
+
index += 1;
|
|
189
|
+
parseValue();
|
|
190
|
+
skipWhitespace();
|
|
191
|
+
if (text[index] === "}") {
|
|
192
|
+
index += 1;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (text[index] !== ",") fail();
|
|
196
|
+
index += 1;
|
|
197
|
+
}
|
|
198
|
+
fail();
|
|
199
|
+
}
|
|
200
|
+
if (text[index] === "[") {
|
|
201
|
+
index += 1;
|
|
202
|
+
skipWhitespace();
|
|
203
|
+
if (text[index] === "]") {
|
|
204
|
+
index += 1;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
while (index < text.length) {
|
|
208
|
+
parseValue();
|
|
209
|
+
skipWhitespace();
|
|
210
|
+
if (text[index] === "]") {
|
|
211
|
+
index += 1;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (text[index] !== ",") fail();
|
|
215
|
+
index += 1;
|
|
216
|
+
}
|
|
217
|
+
fail();
|
|
218
|
+
}
|
|
219
|
+
if (text[index] === '"') {
|
|
220
|
+
parseString();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
for (const literal of ["true", "false", "null"]) {
|
|
224
|
+
if (text.startsWith(literal, index)) {
|
|
225
|
+
index += literal.length;
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const number = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(index));
|
|
230
|
+
if (!number) fail();
|
|
231
|
+
index += number[0].length;
|
|
232
|
+
};
|
|
233
|
+
parseValue();
|
|
234
|
+
skipWhitespace();
|
|
235
|
+
if (index !== text.length) fail();
|
|
236
|
+
return JSON.parse(text);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function expectedOwnership(candidateBinary) {
|
|
240
|
+
const handlers = {};
|
|
241
|
+
for (const [wireEvent, commandEvent] of CLAUDE_HOOK_EVENTS) {
|
|
242
|
+
handlers[wireEvent] = {
|
|
243
|
+
args: ["run-claude-hook", commandEvent],
|
|
244
|
+
command: candidateBinary,
|
|
245
|
+
type: "command",
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
launcher: sha256Text(candidateBinary),
|
|
250
|
+
handlers: sha256Text(canonicalJson(handlers)),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export const UpdateTopology = Object.freeze({
|
|
255
|
+
NpmGlobal: "npm_global",
|
|
256
|
+
NpmEphemeral: "npm_ephemeral",
|
|
257
|
+
LegacyNative: "legacy_native",
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
function exactPublicVersion(version) {
|
|
261
|
+
return typeof version === "string" && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function compareExactVersions(left, right) {
|
|
265
|
+
const leftParts = left.split(".").map(Number);
|
|
266
|
+
const rightParts = right.split(".").map(Number);
|
|
267
|
+
for (let index = 0; index < 3; index += 1) {
|
|
268
|
+
if (leftParts[index] !== rightParts[index]) return leftParts[index] - rightParts[index];
|
|
269
|
+
}
|
|
270
|
+
return 0;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function hasExactKeys(value, expected) {
|
|
274
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
275
|
+
return isDeepStrictEqual(Object.keys(value).sort(), [...expected].sort());
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function authenticatedRecoveryInvocation(transactionRoot, descriptorSha256) {
|
|
279
|
+
const args = [
|
|
280
|
+
"--input-type=module",
|
|
281
|
+
"--eval",
|
|
282
|
+
RECOVERY_INLINE_EVAL,
|
|
283
|
+
transactionRoot,
|
|
284
|
+
descriptorSha256,
|
|
285
|
+
];
|
|
286
|
+
return {
|
|
287
|
+
executable: process.execPath,
|
|
288
|
+
args,
|
|
289
|
+
command:
|
|
290
|
+
process.platform === "win32"
|
|
291
|
+
? `& ${[process.execPath, ...args]
|
|
292
|
+
.map((value) => `'${value.replaceAll("'", "''")}'`)
|
|
293
|
+
.join(" ")}`
|
|
294
|
+
: [process.execPath, ...args]
|
|
295
|
+
.map((value) => `'${value.replaceAll("'", `'"'"'`)}'`)
|
|
296
|
+
.join(" "),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export async function externalRecoveryInvocation(transactionRoot) {
|
|
301
|
+
if (typeof transactionRoot !== "string" || !path.isAbsolute(transactionRoot)) {
|
|
302
|
+
throw new Error("unsafe Mikie update recovery root");
|
|
303
|
+
}
|
|
304
|
+
await assertNoLinkedAncestors(transactionRoot);
|
|
305
|
+
const journalPath = path.join(transactionRoot, "journal.json");
|
|
306
|
+
const metadata = await lstat(journalPath);
|
|
307
|
+
if (
|
|
308
|
+
!metadata.isFile() ||
|
|
309
|
+
metadata.isSymbolicLink() ||
|
|
310
|
+
metadata.size > MAX_TRANSACTION_JOURNAL_BYTES
|
|
311
|
+
) {
|
|
312
|
+
throw new Error("invalid Mikie update transaction journal");
|
|
313
|
+
}
|
|
314
|
+
const journal = parseJsonNoDuplicateKeys(await readFile(journalPath));
|
|
315
|
+
const recovery = journal?.recovery;
|
|
316
|
+
if (
|
|
317
|
+
journal?.schema_version !== TRANSACTION_SCHEMA ||
|
|
318
|
+
typeof journal?.token !== "string" ||
|
|
319
|
+
!recovery ||
|
|
320
|
+
!hasExactKeys(recovery, [
|
|
321
|
+
"descriptor",
|
|
322
|
+
"descriptor_sha256",
|
|
323
|
+
"launcher",
|
|
324
|
+
"launcher_sha256",
|
|
325
|
+
"runtime",
|
|
326
|
+
"runtime_sha256",
|
|
327
|
+
"node",
|
|
328
|
+
"command",
|
|
329
|
+
]) ||
|
|
330
|
+
recovery.descriptor !== path.join(transactionRoot, "recovery.json") ||
|
|
331
|
+
!/^[a-f0-9]{64}$/.test(recovery.descriptor_sha256)
|
|
332
|
+
) {
|
|
333
|
+
throw new Error("invalid Mikie update recovery authority");
|
|
334
|
+
}
|
|
335
|
+
return authenticatedRecoveryInvocation(transactionRoot, recovery.descriptor_sha256);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function expectedHandoff(topology) {
|
|
339
|
+
if (topology === UpdateTopology.NpmGlobal) {
|
|
340
|
+
return { action: "npm_install_exact", topology };
|
|
341
|
+
}
|
|
342
|
+
if (topology === UpdateTopology.NpmEphemeral) {
|
|
343
|
+
return { action: "rerun_latest", topology };
|
|
344
|
+
}
|
|
345
|
+
throw new Error("invalid Mikie update handoff");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function validateHandoff(value, topology = value?.topology) {
|
|
349
|
+
const expected = expectedHandoff(topology);
|
|
350
|
+
if (
|
|
351
|
+
!hasExactKeys(value, [
|
|
352
|
+
"schema_version",
|
|
353
|
+
"topology",
|
|
354
|
+
"action",
|
|
355
|
+
"package",
|
|
356
|
+
"repair_command",
|
|
357
|
+
]) ||
|
|
358
|
+
value.schema_version !== HANDOFF_SCHEMA ||
|
|
359
|
+
value.topology !== expected.topology ||
|
|
360
|
+
value.action !== expected.action ||
|
|
361
|
+
value.package !== WRAPPER_PACKAGE ||
|
|
362
|
+
!isDeepStrictEqual(value.repair_command, ["repair-claude-hooks-v1", "--json"])
|
|
363
|
+
) {
|
|
364
|
+
throw new Error("invalid Mikie update handoff");
|
|
365
|
+
}
|
|
366
|
+
return value;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function parseUpdateHandoff(stdout, expectedTopology) {
|
|
370
|
+
if (typeof stdout !== "string" || stdout.trim().split(/\r?\n/).length !== 1) {
|
|
371
|
+
throw new Error("invalid Mikie update handoff");
|
|
372
|
+
}
|
|
373
|
+
let value;
|
|
374
|
+
try {
|
|
375
|
+
value = JSON.parse(stdout.trim());
|
|
376
|
+
} catch {
|
|
377
|
+
throw new Error("invalid Mikie update handoff");
|
|
378
|
+
}
|
|
379
|
+
return validateHandoff(value, expectedTopology);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function detectUpdateTopology({
|
|
383
|
+
environment = process.env,
|
|
384
|
+
executable = process.argv[1] ?? "",
|
|
385
|
+
globalNodeModules,
|
|
386
|
+
} = {}) {
|
|
387
|
+
const normalizedExecutable = executable.replaceAll("\\", "/").toLowerCase();
|
|
388
|
+
if (environment.npm_command === "exec" || normalizedExecutable.includes("/_npx/")) {
|
|
389
|
+
return UpdateTopology.NpmEphemeral;
|
|
390
|
+
}
|
|
391
|
+
const override = environment.MIKIE_NPM_UPDATE_TOPOLOGY;
|
|
392
|
+
if (override !== undefined) {
|
|
393
|
+
if (![UpdateTopology.NpmGlobal, UpdateTopology.NpmEphemeral].includes(override)) {
|
|
394
|
+
throw new Error("unknown npm update topology");
|
|
395
|
+
}
|
|
396
|
+
if (override === UpdateTopology.NpmEphemeral) return override;
|
|
397
|
+
}
|
|
398
|
+
const normalizedGlobalRoot =
|
|
399
|
+
typeof globalNodeModules === "string"
|
|
400
|
+
? globalNodeModules.replaceAll("\\", "/").replace(/\/$/, "").toLowerCase()
|
|
401
|
+
: null;
|
|
402
|
+
const isProvenGlobalPackage =
|
|
403
|
+
normalizedGlobalRoot !== null &&
|
|
404
|
+
normalizedExecutable.startsWith(`${normalizedGlobalRoot}/@getmikie/cli/`);
|
|
405
|
+
if (normalizedExecutable.includes("/node_modules/") && !isProvenGlobalPackage) {
|
|
406
|
+
throw new Error("project-local Mikie cannot update the global installation");
|
|
407
|
+
}
|
|
408
|
+
if (!isProvenGlobalPackage) {
|
|
409
|
+
throw new Error("Mikie is not proven to be the global Mikie package");
|
|
410
|
+
}
|
|
411
|
+
return UpdateTopology.NpmGlobal;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function shouldOfferUpdatePrompt({
|
|
415
|
+
stdinIsTTY = process.stdin.isTTY === true,
|
|
416
|
+
stdoutIsTTY = process.stdout.isTTY === true,
|
|
417
|
+
environment = process.env,
|
|
418
|
+
json = false,
|
|
419
|
+
hook = false,
|
|
420
|
+
mcp = false,
|
|
421
|
+
background = false,
|
|
422
|
+
} = {}) {
|
|
423
|
+
const ci = environment.CI !== undefined && !["", "0", "false"].includes(
|
|
424
|
+
String(environment.CI).toLowerCase(),
|
|
425
|
+
);
|
|
426
|
+
return stdinIsTTY && stdoutIsTTY && !ci && !json && !hook && !mcp && !background;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function sameExecutablePath(left, right) {
|
|
430
|
+
if (typeof left !== "string" || typeof right !== "string") return false;
|
|
431
|
+
const normalizeWindows = (value) => {
|
|
432
|
+
const separators = value.replaceAll("/", "\\");
|
|
433
|
+
const withoutDevicePrefix = separators.startsWith("\\\\?\\")
|
|
434
|
+
? separators.slice(4)
|
|
435
|
+
: separators;
|
|
436
|
+
return withoutDevicePrefix.toLowerCase();
|
|
437
|
+
};
|
|
438
|
+
const windowsPath = /^(?:\\\\\?\\)?[a-z]:[\\/]/i;
|
|
439
|
+
if (windowsPath.test(left) || windowsPath.test(right)) {
|
|
440
|
+
return normalizeWindows(left) === normalizeWindows(right);
|
|
441
|
+
}
|
|
442
|
+
return left === right;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function isCandidateHandler(handler, candidateBinary, commandEvent) {
|
|
446
|
+
return (
|
|
447
|
+
hasExactKeys(handler, ["type", "command", "args"]) &&
|
|
448
|
+
handler.type === "command" &&
|
|
449
|
+
handler.command === candidateBinary &&
|
|
450
|
+
isDeepStrictEqual(handler.args, ["run-claude-hook", commandEvent])
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function isLegacyOwnedHandler(handler, candidateBinary, wireEvent) {
|
|
455
|
+
return (
|
|
456
|
+
["Stop", "StopFailure"].includes(wireEvent) &&
|
|
457
|
+
hasExactKeys(handler, ["type", "command", "args"]) &&
|
|
458
|
+
handler.type === "command" &&
|
|
459
|
+
handler.command === candidateBinary &&
|
|
460
|
+
isDeepStrictEqual(handler.args, ["evidence", "ingest", "--client", "claude"])
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function foreignRegistration(registration) {
|
|
465
|
+
if (!registration || typeof registration !== "object" || Array.isArray(registration)) return null;
|
|
466
|
+
const projection = {};
|
|
467
|
+
for (const [key, value] of Object.entries(registration)) {
|
|
468
|
+
if (key !== "mcpServers") projection[key] = value;
|
|
469
|
+
}
|
|
470
|
+
const foreignServers = {};
|
|
471
|
+
const servers = registration.mcpServers;
|
|
472
|
+
if (servers && typeof servers === "object" && !Array.isArray(servers)) {
|
|
473
|
+
for (const [key, value] of Object.entries(servers)) {
|
|
474
|
+
if (key !== "mikie") foreignServers[key] = value;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
if (Object.keys(foreignServers).length > 0) projection.mcpServers = foreignServers;
|
|
478
|
+
return projection;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function foreignSettings(settings, candidateBinary, { removeOwnedV1 = false } = {}) {
|
|
482
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings)) return null;
|
|
483
|
+
const projection = {};
|
|
484
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
485
|
+
if (key !== "hooks") projection[key] = value;
|
|
486
|
+
}
|
|
487
|
+
const foreignHooks = {};
|
|
488
|
+
const hooks = settings.hooks;
|
|
489
|
+
if (hooks && typeof hooks === "object" && !Array.isArray(hooks)) {
|
|
490
|
+
for (const [wireEvent, groups] of Object.entries(hooks)) {
|
|
491
|
+
if (!Array.isArray(groups)) {
|
|
492
|
+
foreignHooks[wireEvent] = groups;
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
const commandEvent = CLAUDE_HOOK_EVENTS.find(([event]) => event === wireEvent)?.[1];
|
|
496
|
+
const retainedGroups = [];
|
|
497
|
+
for (const group of groups) {
|
|
498
|
+
if (!group || typeof group !== "object" || !Array.isArray(group.hooks)) {
|
|
499
|
+
retainedGroups.push(group);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
const retainedHandlers = commandEvent
|
|
503
|
+
? group.hooks.filter(
|
|
504
|
+
(handler) =>
|
|
505
|
+
!isCandidateHandler(handler, candidateBinary, commandEvent) &&
|
|
506
|
+
!(removeOwnedV1 && isLegacyOwnedHandler(handler, candidateBinary, wireEvent)),
|
|
507
|
+
)
|
|
508
|
+
: [...group.hooks];
|
|
509
|
+
const retainedGroup = Object.fromEntries(
|
|
510
|
+
Object.entries(group).filter(([key]) => key !== "hooks"),
|
|
511
|
+
);
|
|
512
|
+
if (retainedHandlers.length > 0) retainedGroup.hooks = retainedHandlers;
|
|
513
|
+
if (Object.keys(retainedGroup).length > 0) retainedGroups.push(retainedGroup);
|
|
514
|
+
}
|
|
515
|
+
if (retainedGroups.length > 0) foreignHooks[wireEvent] = retainedGroups;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (Object.keys(foreignHooks).length > 0) projection.hooks = foreignHooks;
|
|
519
|
+
return projection;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function reportedForeignSettings(registration, settings, candidateBinary) {
|
|
523
|
+
const reported = [];
|
|
524
|
+
for (const server of Object.keys(foreignRegistration(registration)?.mcpServers ?? {}).sort()) {
|
|
525
|
+
reported.push(`registration.mcpServers.${server}`);
|
|
526
|
+
}
|
|
527
|
+
const hooks = settings?.hooks;
|
|
528
|
+
if (hooks && typeof hooks === "object" && !Array.isArray(hooks)) {
|
|
529
|
+
for (const [wireEvent, groups] of Object.entries(hooks).sort(([left], [right]) => left.localeCompare(right))) {
|
|
530
|
+
if (!Array.isArray(groups)) continue;
|
|
531
|
+
const commandEvent = CLAUDE_HOOK_EVENTS.find(([event]) => event === wireEvent)?.[1];
|
|
532
|
+
groups.forEach((group, index) => {
|
|
533
|
+
const handlers = Array.isArray(group?.hooks) ? group.hooks : [];
|
|
534
|
+
const hasForeignGroupFields =
|
|
535
|
+
group &&
|
|
536
|
+
typeof group === "object" &&
|
|
537
|
+
!Array.isArray(group) &&
|
|
538
|
+
Object.keys(group).some((key) => key !== "hooks");
|
|
539
|
+
if (
|
|
540
|
+
hasForeignGroupFields ||
|
|
541
|
+
!commandEvent ||
|
|
542
|
+
handlers.some((handler) => !isCandidateHandler(handler, candidateBinary, commandEvent))
|
|
543
|
+
) {
|
|
544
|
+
reported.push(`settings.hooks.${wireEvent}[${index}]`);
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return reported;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export function verifyClaudeV2Contract({
|
|
553
|
+
candidateBinary,
|
|
554
|
+
registration,
|
|
555
|
+
settings,
|
|
556
|
+
ownership,
|
|
557
|
+
foreign,
|
|
558
|
+
}) {
|
|
559
|
+
const expected = expectedOwnership(candidateBinary);
|
|
560
|
+
const mikie = registration?.mcpServers?.mikie;
|
|
561
|
+
const registrationCurrent =
|
|
562
|
+
mikie &&
|
|
563
|
+
typeof mikie === "object" &&
|
|
564
|
+
!Array.isArray(mikie) &&
|
|
565
|
+
Object.keys(mikie).every((key) => ["type", "command", "args", "env"].includes(key)) &&
|
|
566
|
+
mikie.type === "stdio" &&
|
|
567
|
+
mikie.command === candidateBinary &&
|
|
568
|
+
isDeepStrictEqual(mikie.args, ["run-relay", "--adapter", "claude_code"]) &&
|
|
569
|
+
(mikie.env === undefined ||
|
|
570
|
+
(mikie.env &&
|
|
571
|
+
typeof mikie.env === "object" &&
|
|
572
|
+
!Array.isArray(mikie.env) &&
|
|
573
|
+
Object.keys(mikie.env).length === 0));
|
|
574
|
+
const hooksCurrent = CLAUDE_HOOK_EVENTS.every(([wireEvent, commandEvent]) => {
|
|
575
|
+
const groups = settings?.hooks?.[wireEvent];
|
|
576
|
+
if (!Array.isArray(groups)) return false;
|
|
577
|
+
const owned = groups.flatMap((group) => (Array.isArray(group?.hooks) ? group.hooks : []));
|
|
578
|
+
return owned.filter((handler) => isCandidateHandler(handler, candidateBinary, commandEvent)).length === 1;
|
|
579
|
+
});
|
|
580
|
+
const ownershipCurrent =
|
|
581
|
+
hasExactKeys(ownership, [
|
|
582
|
+
"version",
|
|
583
|
+
"launcher_sha256",
|
|
584
|
+
"handler_contract_sha256",
|
|
585
|
+
"mcp_owned",
|
|
586
|
+
]) &&
|
|
587
|
+
ownership?.version === 2 &&
|
|
588
|
+
ownership.launcher_sha256 === expected.launcher &&
|
|
589
|
+
ownership.handler_contract_sha256 === expected.handlers &&
|
|
590
|
+
ownership.mcp_owned === true;
|
|
591
|
+
const foreignPreserved =
|
|
592
|
+
foreign !== undefined &&
|
|
593
|
+
isDeepStrictEqual(foreignRegistration(registration), foreign.registration) &&
|
|
594
|
+
isDeepStrictEqual(foreignSettings(settings, candidateBinary), foreign.settings);
|
|
595
|
+
return {
|
|
596
|
+
complete: registrationCurrent && hooksCurrent && ownershipCurrent && foreignPreserved,
|
|
597
|
+
foreignPreserved,
|
|
598
|
+
foreignSettings: reportedForeignSettings(registration, settings, candidateBinary),
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function parseRepairReceipt(stdout) {
|
|
603
|
+
let receipt;
|
|
604
|
+
try {
|
|
605
|
+
receipt = JSON.parse(stdout.trim());
|
|
606
|
+
} catch {
|
|
607
|
+
throw new Error("candidate owned-adapter repair returned invalid JSON");
|
|
608
|
+
}
|
|
609
|
+
if (
|
|
610
|
+
!hasExactKeys(receipt, [
|
|
611
|
+
"schema_version",
|
|
612
|
+
"before",
|
|
613
|
+
"after",
|
|
614
|
+
"action",
|
|
615
|
+
"restart_required",
|
|
616
|
+
]) ||
|
|
617
|
+
receipt?.schema_version !== REPAIR_SCHEMA ||
|
|
618
|
+
typeof receipt.restart_required !== "boolean"
|
|
619
|
+
) {
|
|
620
|
+
throw new Error("candidate owned-adapter repair returned an invalid receipt");
|
|
621
|
+
}
|
|
622
|
+
const migrated =
|
|
623
|
+
receipt.before === "outdated_owned" &&
|
|
624
|
+
receipt.after === "current" &&
|
|
625
|
+
receipt.action === "migrated" &&
|
|
626
|
+
receipt.restart_required === true;
|
|
627
|
+
const current =
|
|
628
|
+
receipt.before === "current" &&
|
|
629
|
+
receipt.after === "current" &&
|
|
630
|
+
receipt.action === "already_current" &&
|
|
631
|
+
receipt.restart_required === false;
|
|
632
|
+
const skipped =
|
|
633
|
+
["foreign_conflict", "unavailable"].includes(receipt.before) &&
|
|
634
|
+
receipt.after === receipt.before &&
|
|
635
|
+
receipt.action === "refused" &&
|
|
636
|
+
receipt.restart_required === false;
|
|
637
|
+
if (!migrated && !current && !skipped) {
|
|
638
|
+
throw new Error("candidate owned-adapter repair returned an invalid receipt");
|
|
639
|
+
}
|
|
640
|
+
return { receipt, skipped };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function recoveryCommand(currentVersion) {
|
|
644
|
+
return `npm install --global ${WRAPPER_PACKAGE}@${currentVersion}`;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export async function runUpdateTransaction({
|
|
648
|
+
handoff,
|
|
649
|
+
currentVersion,
|
|
650
|
+
platformPackage,
|
|
651
|
+
nodeVersion = process.versions.node,
|
|
652
|
+
npmExecutable = "npm",
|
|
653
|
+
registry,
|
|
654
|
+
commands,
|
|
655
|
+
files,
|
|
656
|
+
output = { writeLine: (line) => console.log(line) },
|
|
657
|
+
}) {
|
|
658
|
+
validateHandoff(handoff);
|
|
659
|
+
if (handoff.topology === UpdateTopology.NpmEphemeral) {
|
|
660
|
+
output.writeLine(RERUN_COMMAND);
|
|
661
|
+
return { status: "rerun_required", command: RERUN_COMMAND };
|
|
662
|
+
}
|
|
663
|
+
if (!exactPublicVersion(currentVersion)) {
|
|
664
|
+
throw new Error("installed Mikie package version is not exact");
|
|
665
|
+
}
|
|
666
|
+
let recovery = recoveryCommand(currentVersion);
|
|
667
|
+
let lease;
|
|
668
|
+
try {
|
|
669
|
+
lease = await files.beginTransaction?.();
|
|
670
|
+
} catch (error) {
|
|
671
|
+
output.writeLine(`Recovery: ${recovery}`);
|
|
672
|
+
const reason = error instanceof Error ? error.message : "update recovery failed";
|
|
673
|
+
throw new Error(`${reason}. Recovery: ${recovery}`);
|
|
674
|
+
}
|
|
675
|
+
try {
|
|
676
|
+
const installedCurrentVersion = await files.currentInstalledVersion();
|
|
677
|
+
if (!exactPublicVersion(installedCurrentVersion)) {
|
|
678
|
+
throw new Error("installed Mikie package version is not exact");
|
|
679
|
+
}
|
|
680
|
+
recovery = recoveryCommand(installedCurrentVersion);
|
|
681
|
+
const resolved = await registry.resolveExactCompatibleVersion({
|
|
682
|
+
packageName: WRAPPER_PACKAGE,
|
|
683
|
+
currentVersion: installedCurrentVersion,
|
|
684
|
+
nodeVersion,
|
|
685
|
+
});
|
|
686
|
+
if (
|
|
687
|
+
!exactPublicVersion(resolved?.version) ||
|
|
688
|
+
resolved.public !== true ||
|
|
689
|
+
resolved.compatible !== true
|
|
690
|
+
) {
|
|
691
|
+
throw new Error("registry did not resolve one exact compatible public version");
|
|
692
|
+
}
|
|
693
|
+
const versionOrder = compareExactVersions(resolved.version, installedCurrentVersion);
|
|
694
|
+
if (versionOrder < 0) {
|
|
695
|
+
throw new Error("registry resolved an older Mikie version");
|
|
696
|
+
}
|
|
697
|
+
if (versionOrder === 0) {
|
|
698
|
+
output.writeLine(`Mikie is already current at ${installedCurrentVersion}.`);
|
|
699
|
+
return { status: "current", version: installedCurrentVersion };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const snapshotRequest = { platformPackage };
|
|
703
|
+
if (lease !== undefined) snapshotRequest.lease = lease;
|
|
704
|
+
const snapshot = await files.snapshot(snapshotRequest);
|
|
705
|
+
let providerPrepared = false;
|
|
706
|
+
let commitDecided = false;
|
|
707
|
+
let candidate;
|
|
708
|
+
let finalLauncher;
|
|
709
|
+
try {
|
|
710
|
+
await files.markInstalling(snapshot);
|
|
711
|
+
const stagingPrefix = files.stagingPrefix(snapshot);
|
|
712
|
+
const stagedInstall = await commands.run(
|
|
713
|
+
npmExecutable,
|
|
714
|
+
["install", "--global", `${WRAPPER_PACKAGE}@${resolved.version}`],
|
|
715
|
+
{
|
|
716
|
+
shell: false,
|
|
717
|
+
capture: true,
|
|
718
|
+
environment: { npm_config_prefix: stagingPrefix },
|
|
719
|
+
},
|
|
720
|
+
);
|
|
721
|
+
if (stagedInstall.code !== 0) throw new Error("exact staged npm install failed");
|
|
722
|
+
|
|
723
|
+
candidate = await files.locateCandidate({ platformPackage, stagingPrefix });
|
|
724
|
+
const [wrapperVersion, candidatePlatformVersion] = await Promise.all([
|
|
725
|
+
files.readPackageVersion(candidate.wrapperPackageJson),
|
|
726
|
+
files.readPackageVersion(candidate.platformPackageJson),
|
|
727
|
+
]);
|
|
728
|
+
if (wrapperVersion !== resolved.version || candidatePlatformVersion !== resolved.version) {
|
|
729
|
+
throw new Error("candidate package version verification failed");
|
|
730
|
+
}
|
|
731
|
+
finalLauncher = files.finalLauncher();
|
|
732
|
+
const externalRecovery = await files.recordPreparedCandidate(
|
|
733
|
+
snapshot,
|
|
734
|
+
candidate,
|
|
735
|
+
finalLauncher,
|
|
736
|
+
);
|
|
737
|
+
recovery = externalRecovery;
|
|
738
|
+
output.writeLine(`Recovery: ${externalRecovery}`);
|
|
739
|
+
await files.markProviderPreparing(snapshot);
|
|
740
|
+
providerPrepared = true;
|
|
741
|
+
|
|
742
|
+
const repair = await commands.run(candidate.binary, handoff.repair_command, {
|
|
743
|
+
shell: false,
|
|
744
|
+
capture: true,
|
|
745
|
+
environment: {
|
|
746
|
+
MIKIE_DISTRIBUTION_MODE: "npm",
|
|
747
|
+
MIKIE_NPM_UPDATE_TOPOLOGY: UpdateTopology.NpmGlobal,
|
|
748
|
+
MIKIE_NPM_UPDATE_REPAIR_PHASE: "prepare",
|
|
749
|
+
MIKIE_UPDATE_REPAIR_CANDIDATE: candidate.binary,
|
|
750
|
+
MIKIE_UPDATE_REPAIR_LAUNCHER: finalLauncher,
|
|
751
|
+
},
|
|
752
|
+
});
|
|
753
|
+
const repairResult = parseRepairReceipt(repair.stdout);
|
|
754
|
+
if (
|
|
755
|
+
(!repairResult.skipped && repair.code !== 0) ||
|
|
756
|
+
(repairResult.skipped && repair.code === 0)
|
|
757
|
+
) {
|
|
758
|
+
throw new Error("candidate owned-adapter repair failed");
|
|
759
|
+
}
|
|
760
|
+
const verification = await files.verifyClaudeV2({
|
|
761
|
+
candidate: { ...candidate, binary: finalLauncher },
|
|
762
|
+
snapshot,
|
|
763
|
+
repair: repairResult.receipt,
|
|
764
|
+
});
|
|
765
|
+
if (verification.complete !== true || verification.foreignPreserved !== true) {
|
|
766
|
+
throw new Error("candidate Claude v2 verification failed");
|
|
767
|
+
}
|
|
768
|
+
await files.prepareActivation(snapshot, candidate);
|
|
769
|
+
await files.promotePreparedCandidate(snapshot, candidate);
|
|
770
|
+
const activatedCandidate = await files.locateCandidate({ platformPackage });
|
|
771
|
+
const [activatedWrapperVersion, activatedPlatformVersion] = await Promise.all([
|
|
772
|
+
files.readPackageVersion(activatedCandidate.wrapperPackageJson),
|
|
773
|
+
files.readPackageVersion(activatedCandidate.platformPackageJson),
|
|
774
|
+
]);
|
|
775
|
+
if (
|
|
776
|
+
activatedWrapperVersion !== resolved.version ||
|
|
777
|
+
activatedPlatformVersion !== resolved.version
|
|
778
|
+
) {
|
|
779
|
+
throw new Error("activated package version verification failed");
|
|
780
|
+
}
|
|
781
|
+
await files.verifyActivatedCandidate(candidate, activatedCandidate);
|
|
782
|
+
await files.activate(activatedCandidate);
|
|
783
|
+
await files.checkpointCandidate(snapshot, { provider: true });
|
|
784
|
+
const settlementRequired = repairResult.receipt.action === "migrated";
|
|
785
|
+
await files.decideCommit(snapshot, { settlementRequired });
|
|
786
|
+
commitDecided = true;
|
|
787
|
+
if (settlementRequired) {
|
|
788
|
+
const settlement = await commands.run(candidate.binary, handoff.repair_command, {
|
|
789
|
+
shell: false,
|
|
790
|
+
capture: true,
|
|
791
|
+
environment: {
|
|
792
|
+
MIKIE_DISTRIBUTION_MODE: "npm",
|
|
793
|
+
MIKIE_NPM_UPDATE_TOPOLOGY: UpdateTopology.NpmGlobal,
|
|
794
|
+
MIKIE_NPM_UPDATE_REPAIR_PHASE: "commit",
|
|
795
|
+
MIKIE_UPDATE_REPAIR_CANDIDATE: candidate.binary,
|
|
796
|
+
MIKIE_UPDATE_REPAIR_LAUNCHER: finalLauncher,
|
|
797
|
+
},
|
|
798
|
+
});
|
|
799
|
+
if (settlement.code !== 0) {
|
|
800
|
+
throw new Error("candidate owned-adapter settlement failed");
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
await files.retire(snapshot);
|
|
804
|
+
return {
|
|
805
|
+
status: "updated",
|
|
806
|
+
version: resolved.version,
|
|
807
|
+
providerState: repairResult.receipt.after,
|
|
808
|
+
adapterAction: repairResult.skipped
|
|
809
|
+
? "skipped"
|
|
810
|
+
: repairResult.receipt.action === "already_current"
|
|
811
|
+
? "verified"
|
|
812
|
+
: "migrated",
|
|
813
|
+
foreignSettings: verification.foreignSettings ?? [],
|
|
814
|
+
};
|
|
815
|
+
} catch (error) {
|
|
816
|
+
let restoreFailure;
|
|
817
|
+
if (!commitDecided && providerPrepared) {
|
|
818
|
+
try {
|
|
819
|
+
const settlement = await commands.run(candidate.binary, handoff.repair_command, {
|
|
820
|
+
shell: false,
|
|
821
|
+
capture: true,
|
|
822
|
+
environment: {
|
|
823
|
+
MIKIE_DISTRIBUTION_MODE: "npm",
|
|
824
|
+
MIKIE_NPM_UPDATE_TOPOLOGY: UpdateTopology.NpmGlobal,
|
|
825
|
+
MIKIE_NPM_UPDATE_REPAIR_PHASE: "rollback",
|
|
826
|
+
MIKIE_UPDATE_REPAIR_CANDIDATE: candidate.binary,
|
|
827
|
+
MIKIE_UPDATE_REPAIR_LAUNCHER: finalLauncher,
|
|
828
|
+
},
|
|
829
|
+
});
|
|
830
|
+
if (settlement.code !== 0) {
|
|
831
|
+
throw new Error("candidate owned-adapter rollback failed");
|
|
832
|
+
}
|
|
833
|
+
await files.markProviderRolledBack(snapshot);
|
|
834
|
+
} catch (settlementError) {
|
|
835
|
+
restoreFailure = settlementError;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
if (!commitDecided && !restoreFailure) {
|
|
839
|
+
try {
|
|
840
|
+
await files.restore(snapshot);
|
|
841
|
+
} catch (restoreError) {
|
|
842
|
+
restoreFailure = restoreError;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
output.writeLine(`Recovery: ${recovery}`);
|
|
846
|
+
const reason = error instanceof Error ? error.message : "update transaction failed";
|
|
847
|
+
if (restoreFailure) {
|
|
848
|
+
const restoreReason =
|
|
849
|
+
restoreFailure instanceof Error ? restoreFailure.message : "snapshot restore failed";
|
|
850
|
+
throw new Error(`${reason}; rollback failed: ${restoreReason}. Recovery: ${recovery}`);
|
|
851
|
+
}
|
|
852
|
+
throw new Error(`${reason}. Recovery: ${recovery}`);
|
|
853
|
+
}
|
|
854
|
+
} finally {
|
|
855
|
+
await files.endTransaction?.(lease);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
export function createCommandAdapter({
|
|
860
|
+
spawnProcess = spawn,
|
|
861
|
+
environment = process.env,
|
|
862
|
+
platform = process.platform,
|
|
863
|
+
nodeExecutable = process.execPath,
|
|
864
|
+
npmExecPath,
|
|
865
|
+
commandTimeoutMs = DEFAULT_COMMAND_TIMEOUT_MS,
|
|
866
|
+
scheduleTimeout = setTimeout,
|
|
867
|
+
cancelTimeout = clearTimeout,
|
|
868
|
+
} = {}) {
|
|
869
|
+
if (!Number.isInteger(commandTimeoutMs) || commandTimeoutMs <= 0) {
|
|
870
|
+
throw new Error("Mikie update command timeout is invalid");
|
|
871
|
+
}
|
|
872
|
+
const environmentNpmCli = environment.npm_execpath;
|
|
873
|
+
const windowsNpmCli =
|
|
874
|
+
npmExecPath ??
|
|
875
|
+
(typeof environmentNpmCli === "string" &&
|
|
876
|
+
path.isAbsolute(environmentNpmCli) &&
|
|
877
|
+
/npm-cli\.(?:c?js|mjs)$/i.test(environmentNpmCli)
|
|
878
|
+
? environmentNpmCli
|
|
879
|
+
: path.join(path.dirname(nodeExecutable), "node_modules", "npm", "bin", "npm-cli.js"));
|
|
880
|
+
return {
|
|
881
|
+
run(executable, args, { shell = false, environment: commandEnvironment = {} } = {}) {
|
|
882
|
+
if (
|
|
883
|
+
shell !== false ||
|
|
884
|
+
!Array.isArray(args) ||
|
|
885
|
+
!commandEnvironment ||
|
|
886
|
+
typeof commandEnvironment !== "object" ||
|
|
887
|
+
Array.isArray(commandEnvironment)
|
|
888
|
+
) {
|
|
889
|
+
throw new Error("Mikie update commands require argument arrays with shell disabled");
|
|
890
|
+
}
|
|
891
|
+
return new Promise((resolve, reject) => {
|
|
892
|
+
const stdout = [];
|
|
893
|
+
const stderr = [];
|
|
894
|
+
let bytes = 0;
|
|
895
|
+
let settled = false;
|
|
896
|
+
const launchesNpm = platform === "win32" && executable === "npm";
|
|
897
|
+
const child = spawnProcess(
|
|
898
|
+
launchesNpm ? nodeExecutable : executable,
|
|
899
|
+
launchesNpm ? [windowsNpmCli, ...args] : args,
|
|
900
|
+
{
|
|
901
|
+
shell: false,
|
|
902
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
903
|
+
windowsHide: true,
|
|
904
|
+
env: { ...environment, ...commandEnvironment },
|
|
905
|
+
},
|
|
906
|
+
);
|
|
907
|
+
const deadline = scheduleTimeout(() => {
|
|
908
|
+
if (settled) return;
|
|
909
|
+
settled = true;
|
|
910
|
+
try {
|
|
911
|
+
child.kill?.("SIGKILL");
|
|
912
|
+
} catch {
|
|
913
|
+
// The child may already have exited.
|
|
914
|
+
}
|
|
915
|
+
reject(new Error("Mikie update command timed out"));
|
|
916
|
+
}, commandTimeoutMs);
|
|
917
|
+
const finish = (callback) => {
|
|
918
|
+
if (settled) return false;
|
|
919
|
+
settled = true;
|
|
920
|
+
cancelTimeout(deadline);
|
|
921
|
+
callback();
|
|
922
|
+
return true;
|
|
923
|
+
};
|
|
924
|
+
const capture = (target) => (chunk) => {
|
|
925
|
+
if (settled) return;
|
|
926
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
927
|
+
bytes += buffer.length;
|
|
928
|
+
if (bytes > MAX_COMMAND_OUTPUT_BYTES) {
|
|
929
|
+
finish(() => {
|
|
930
|
+
child.kill?.("SIGKILL");
|
|
931
|
+
reject(new Error("Mikie update command output exceeded the capture limit"));
|
|
932
|
+
});
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
target.push(buffer);
|
|
936
|
+
};
|
|
937
|
+
child.stdout.on("data", capture(stdout));
|
|
938
|
+
child.stderr.on("data", capture(stderr));
|
|
939
|
+
child.once("error", (error) => {
|
|
940
|
+
finish(() => reject(error));
|
|
941
|
+
});
|
|
942
|
+
child.once("close", (code) => {
|
|
943
|
+
finish(() =>
|
|
944
|
+
resolve({
|
|
945
|
+
code: Number.isInteger(code) ? code : 1,
|
|
946
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
947
|
+
stderr: Buffer.concat(stderr).toString("utf8"),
|
|
948
|
+
}),
|
|
949
|
+
);
|
|
950
|
+
});
|
|
951
|
+
});
|
|
952
|
+
},
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function nodeSatisfies(engine, nodeVersion) {
|
|
957
|
+
if (engine === undefined) return true;
|
|
958
|
+
if (typeof engine !== "string") return false;
|
|
959
|
+
const match = /^>=(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(engine.trim());
|
|
960
|
+
if (!match) return false;
|
|
961
|
+
const required = match.slice(1).map((part) => Number(part ?? 0));
|
|
962
|
+
const actual = nodeVersion.split(".").slice(0, 3).map(Number);
|
|
963
|
+
for (let index = 0; index < 3; index += 1) {
|
|
964
|
+
if (actual[index] > required[index]) return true;
|
|
965
|
+
if (actual[index] < required[index]) return false;
|
|
966
|
+
}
|
|
967
|
+
return true;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function compareExactVersionsDescending(left, right) {
|
|
971
|
+
return compareExactVersions(right, left);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
export function createRegistryAdapter({
|
|
975
|
+
commands,
|
|
976
|
+
npmExecutable = "npm",
|
|
977
|
+
registryUrl = "https://registry.npmjs.org",
|
|
978
|
+
now = Date.now,
|
|
979
|
+
resolutionTimeoutMs = 60_000,
|
|
980
|
+
}) {
|
|
981
|
+
if (!Number.isInteger(resolutionTimeoutMs) || resolutionTimeoutMs <= 0) {
|
|
982
|
+
throw new Error("Mikie registry resolution timeout is invalid");
|
|
983
|
+
}
|
|
984
|
+
return {
|
|
985
|
+
async resolveExactCompatibleVersion({ packageName, nodeVersion }) {
|
|
986
|
+
const deadline = now() + resolutionTimeoutMs;
|
|
987
|
+
const versionsResponse = await commands.run(
|
|
988
|
+
npmExecutable,
|
|
989
|
+
["view", packageName, "versions", "--json", "--registry", registryUrl],
|
|
990
|
+
{ shell: false, capture: true },
|
|
991
|
+
);
|
|
992
|
+
if (versionsResponse.code !== 0) return { public: false, compatible: false };
|
|
993
|
+
let published;
|
|
994
|
+
try {
|
|
995
|
+
const parsed = JSON.parse(versionsResponse.stdout);
|
|
996
|
+
published = (Array.isArray(parsed) ? parsed : [parsed])
|
|
997
|
+
.filter(exactPublicVersion)
|
|
998
|
+
.filter((version, index, versions) => versions.indexOf(version) === index)
|
|
999
|
+
.sort(compareExactVersionsDescending);
|
|
1000
|
+
} catch {
|
|
1001
|
+
return { public: false, compatible: false };
|
|
1002
|
+
}
|
|
1003
|
+
for (const version of published.slice(0, 64)) {
|
|
1004
|
+
if (now() >= deadline) break;
|
|
1005
|
+
const response = await commands.run(
|
|
1006
|
+
npmExecutable,
|
|
1007
|
+
[
|
|
1008
|
+
"view",
|
|
1009
|
+
`${packageName}@${version}`,
|
|
1010
|
+
"version",
|
|
1011
|
+
"engines",
|
|
1012
|
+
"--json",
|
|
1013
|
+
"--registry",
|
|
1014
|
+
registryUrl,
|
|
1015
|
+
],
|
|
1016
|
+
{ shell: false, capture: true },
|
|
1017
|
+
);
|
|
1018
|
+
if (response.code !== 0) continue;
|
|
1019
|
+
let metadata;
|
|
1020
|
+
try {
|
|
1021
|
+
metadata = JSON.parse(response.stdout);
|
|
1022
|
+
} catch {
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
if (metadata?.version !== version || !nodeSatisfies(metadata?.engines?.node, nodeVersion)) {
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
return { version, public: true, compatible: true };
|
|
1029
|
+
}
|
|
1030
|
+
return { public: published.length > 0, compatible: false };
|
|
1031
|
+
},
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
async function readOptionalFile(filePath) {
|
|
1036
|
+
try {
|
|
1037
|
+
return { existed: true, bytes: await readFile(filePath) };
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
if (error?.code === "ENOENT") return { existed: false, bytes: Buffer.alloc(0) };
|
|
1040
|
+
throw error;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function parseOptionalJson(snapshot) {
|
|
1045
|
+
if (!snapshot?.existed) return {};
|
|
1046
|
+
return parseJsonNoDuplicateKeys(snapshot.bytes);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function minimalRoots(paths) {
|
|
1050
|
+
const absolute = [...new Set(paths.map((entry) => path.resolve(entry)))].sort(
|
|
1051
|
+
(left, right) => left.length - right.length,
|
|
1052
|
+
);
|
|
1053
|
+
return absolute.filter(
|
|
1054
|
+
(candidate, index) =>
|
|
1055
|
+
!absolute.slice(0, index).some((parent) => {
|
|
1056
|
+
const relative = path.relative(parent, candidate);
|
|
1057
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
1058
|
+
}),
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function isPathInside(parent, candidate) {
|
|
1063
|
+
const relative = path.relative(parent, candidate);
|
|
1064
|
+
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function validateInstallationLayout(layout) {
|
|
1068
|
+
const values = [
|
|
1069
|
+
layout?.wrapperPackageJson,
|
|
1070
|
+
layout?.platformPackageJson,
|
|
1071
|
+
layout?.binary,
|
|
1072
|
+
];
|
|
1073
|
+
if (values.some((value) => typeof value !== "string" || !path.isAbsolute(value))) {
|
|
1074
|
+
throw new Error("unsafe Mikie update filesystem path");
|
|
1075
|
+
}
|
|
1076
|
+
const shims = layout.shims ?? [];
|
|
1077
|
+
if (
|
|
1078
|
+
!Array.isArray(shims) ||
|
|
1079
|
+
shims.length > 3 ||
|
|
1080
|
+
shims.some((value) => typeof value !== "string" || !path.isAbsolute(value)) ||
|
|
1081
|
+
new Set(shims.map((value) => path.resolve(value))).size !== shims.length
|
|
1082
|
+
) {
|
|
1083
|
+
throw new Error("unsafe Mikie update filesystem path");
|
|
1084
|
+
}
|
|
1085
|
+
const wrapperRoot = path.dirname(layout.wrapperPackageJson);
|
|
1086
|
+
const platformRoot = path.dirname(layout.platformPackageJson);
|
|
1087
|
+
const packageNamespace = (packageRoot) =>
|
|
1088
|
+
path.basename(path.dirname(packageRoot)).toLowerCase() === "@getmikie";
|
|
1089
|
+
if (
|
|
1090
|
+
path.basename(layout.wrapperPackageJson).toLowerCase() !== "package.json" ||
|
|
1091
|
+
path.basename(layout.platformPackageJson).toLowerCase() !== "package.json" ||
|
|
1092
|
+
path.basename(wrapperRoot).toLowerCase() !== "cli" ||
|
|
1093
|
+
!packageNamespace(wrapperRoot) ||
|
|
1094
|
+
!packageNamespace(platformRoot) ||
|
|
1095
|
+
wrapperRoot === path.parse(wrapperRoot).root ||
|
|
1096
|
+
platformRoot === path.parse(platformRoot).root ||
|
|
1097
|
+
!isPathInside(platformRoot, layout.binary)
|
|
1098
|
+
) {
|
|
1099
|
+
throw new Error("unsafe Mikie update filesystem path");
|
|
1100
|
+
}
|
|
1101
|
+
return { wrapperRoot, platformRoot, shims };
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function bytesFingerprint(existed, bytes) {
|
|
1105
|
+
return existed ? `present:${createHash("sha256").update(bytes).digest("hex")}` : "absent";
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
async function directoryFingerprint(root) {
|
|
1109
|
+
const digest = createHash("sha256");
|
|
1110
|
+
let entries = 0;
|
|
1111
|
+
let bytes = 0;
|
|
1112
|
+
const visit = async (directory, relativeBase = "") => {
|
|
1113
|
+
const children = (await readdir(directory, { withFileTypes: true })).sort((left, right) =>
|
|
1114
|
+
left.name.localeCompare(right.name),
|
|
1115
|
+
);
|
|
1116
|
+
for (const child of children) {
|
|
1117
|
+
entries += 1;
|
|
1118
|
+
if (entries > MAX_TRANSACTION_TREE_ENTRIES) {
|
|
1119
|
+
throw new Error("Mikie update package tree exceeds the entry limit");
|
|
1120
|
+
}
|
|
1121
|
+
const relative = path.posix.join(relativeBase, child.name);
|
|
1122
|
+
const childPath = path.join(directory, child.name);
|
|
1123
|
+
if (child.isSymbolicLink()) throw new Error("unsafe Mikie update package tree");
|
|
1124
|
+
if (child.isDirectory()) {
|
|
1125
|
+
digest.update(`directory\0${relative}\0`);
|
|
1126
|
+
await visit(childPath, relative);
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1129
|
+
if (!child.isFile()) throw new Error("unsafe Mikie update package tree");
|
|
1130
|
+
const content = await readFile(childPath);
|
|
1131
|
+
bytes += content.length;
|
|
1132
|
+
if (bytes > MAX_TRANSACTION_TREE_BYTES) {
|
|
1133
|
+
throw new Error("Mikie update package tree exceeds the byte limit");
|
|
1134
|
+
}
|
|
1135
|
+
digest.update(`file\0${relative}\0${content.length}\0`);
|
|
1136
|
+
digest.update(content);
|
|
1137
|
+
}
|
|
1138
|
+
};
|
|
1139
|
+
try {
|
|
1140
|
+
const metadata = await lstat(root);
|
|
1141
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
1142
|
+
throw new Error("unsafe Mikie update package tree");
|
|
1143
|
+
}
|
|
1144
|
+
} catch (error) {
|
|
1145
|
+
if (error?.code === "ENOENT") return "absent";
|
|
1146
|
+
throw error;
|
|
1147
|
+
}
|
|
1148
|
+
await visit(root);
|
|
1149
|
+
return `present:${digest.digest("hex")}`;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
async function assertNoLinkedAncestors(candidate) {
|
|
1153
|
+
const absolute = path.resolve(candidate);
|
|
1154
|
+
const parsed = path.parse(absolute);
|
|
1155
|
+
let current = parsed.root;
|
|
1156
|
+
for (const segment of absolute.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
|
|
1157
|
+
current = path.join(current, segment);
|
|
1158
|
+
try {
|
|
1159
|
+
const metadata = await lstat(current);
|
|
1160
|
+
if (metadata.isSymbolicLink()) throw new Error("unsafe Mikie update linked path");
|
|
1161
|
+
} catch (error) {
|
|
1162
|
+
if (error?.code === "ENOENT") return;
|
|
1163
|
+
throw error;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
async function atomicWritePrivate(filePath, bytes) {
|
|
1169
|
+
await assertNoLinkedAncestors(filePath);
|
|
1170
|
+
await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
1171
|
+
await assertNoLinkedAncestors(path.dirname(filePath));
|
|
1172
|
+
const temporary = path.join(
|
|
1173
|
+
path.dirname(filePath),
|
|
1174
|
+
`.${path.basename(filePath)}.${randomUUID()}.tmp`,
|
|
1175
|
+
);
|
|
1176
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
1177
|
+
try {
|
|
1178
|
+
await handle.writeFile(bytes);
|
|
1179
|
+
await handle.sync();
|
|
1180
|
+
} finally {
|
|
1181
|
+
await handle.close();
|
|
1182
|
+
}
|
|
1183
|
+
try {
|
|
1184
|
+
await rename(temporary, filePath);
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
await rm(temporary, { force: true });
|
|
1187
|
+
throw error;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
async function atomicCreatePrivate(filePath, bytes) {
|
|
1192
|
+
await assertNoLinkedAncestors(filePath);
|
|
1193
|
+
await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
1194
|
+
const temporary = path.join(
|
|
1195
|
+
path.dirname(filePath),
|
|
1196
|
+
`.${path.basename(filePath)}.${randomUUID()}.tmp`,
|
|
1197
|
+
);
|
|
1198
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
1199
|
+
try {
|
|
1200
|
+
await handle.writeFile(bytes);
|
|
1201
|
+
await handle.sync();
|
|
1202
|
+
} finally {
|
|
1203
|
+
await handle.close();
|
|
1204
|
+
}
|
|
1205
|
+
try {
|
|
1206
|
+
await link(temporary, filePath);
|
|
1207
|
+
} finally {
|
|
1208
|
+
await rm(temporary, { force: true });
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function processIsAlive(processId) {
|
|
1213
|
+
if (!Number.isInteger(processId) || processId <= 0) return false;
|
|
1214
|
+
try {
|
|
1215
|
+
process.kill(processId, 0);
|
|
1216
|
+
return true;
|
|
1217
|
+
} catch (error) {
|
|
1218
|
+
return error?.code === "EPERM";
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
export function createFilesystemAdapter({
|
|
1223
|
+
currentLayout,
|
|
1224
|
+
locateCandidate,
|
|
1225
|
+
providerPaths,
|
|
1226
|
+
transactionRoot = path.join(
|
|
1227
|
+
path.dirname(providerPaths?.registration ?? path.resolve(".")),
|
|
1228
|
+
".mikie",
|
|
1229
|
+
"npm-update-v1",
|
|
1230
|
+
),
|
|
1231
|
+
processId = process.pid,
|
|
1232
|
+
isProcessAlive = processIsAlive,
|
|
1233
|
+
settleProviderRepair,
|
|
1234
|
+
activationBoundary,
|
|
1235
|
+
}) {
|
|
1236
|
+
const currentRoots = validateInstallationLayout(currentLayout);
|
|
1237
|
+
const currentShims = currentRoots.shims;
|
|
1238
|
+
if (
|
|
1239
|
+
typeof locateCandidate !== "function" ||
|
|
1240
|
+
!providerPaths ||
|
|
1241
|
+
Object.values(providerPaths).some(
|
|
1242
|
+
(filePath) => typeof filePath !== "string" || !path.isAbsolute(filePath),
|
|
1243
|
+
) ||
|
|
1244
|
+
typeof transactionRoot !== "string" ||
|
|
1245
|
+
!path.isAbsolute(transactionRoot) ||
|
|
1246
|
+
(settleProviderRepair !== undefined && typeof settleProviderRepair !== "function") ||
|
|
1247
|
+
(activationBoundary !== undefined && typeof activationBoundary !== "function")
|
|
1248
|
+
) {
|
|
1249
|
+
throw new Error("unsafe Mikie update filesystem path");
|
|
1250
|
+
}
|
|
1251
|
+
const installationRoots = minimalRoots([currentRoots.wrapperRoot, currentRoots.platformRoot]);
|
|
1252
|
+
if (
|
|
1253
|
+
installationRoots.some(
|
|
1254
|
+
(root) =>
|
|
1255
|
+
sameExecutablePath(root, transactionRoot) ||
|
|
1256
|
+
isPathInside(root, transactionRoot) ||
|
|
1257
|
+
isPathInside(transactionRoot, root),
|
|
1258
|
+
) ||
|
|
1259
|
+
Object.values(providerPaths).some(
|
|
1260
|
+
(filePath) =>
|
|
1261
|
+
sameExecutablePath(filePath, transactionRoot) || isPathInside(transactionRoot, filePath),
|
|
1262
|
+
) ||
|
|
1263
|
+
currentShims.some(
|
|
1264
|
+
(shim) => sameExecutablePath(shim, transactionRoot) || isPathInside(transactionRoot, shim),
|
|
1265
|
+
)
|
|
1266
|
+
) {
|
|
1267
|
+
throw new Error("unsafe Mikie update filesystem path");
|
|
1268
|
+
}
|
|
1269
|
+
const lockPath = `${transactionRoot}.lock`;
|
|
1270
|
+
const journalPath = path.join(transactionRoot, "journal.json");
|
|
1271
|
+
|
|
1272
|
+
const readLock = async () => {
|
|
1273
|
+
try {
|
|
1274
|
+
const metadata = await lstat(lockPath);
|
|
1275
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > 1024) {
|
|
1276
|
+
throw new Error("invalid Mikie update transaction lock");
|
|
1277
|
+
}
|
|
1278
|
+
const value = parseJsonNoDuplicateKeys(await readFile(lockPath));
|
|
1279
|
+
if (
|
|
1280
|
+
!hasExactKeys(value, ["schema_version", "process_id", "token"]) ||
|
|
1281
|
+
value.schema_version !== LOCK_SCHEMA ||
|
|
1282
|
+
!Number.isInteger(value.process_id) ||
|
|
1283
|
+
typeof value.token !== "string" ||
|
|
1284
|
+
!TRANSACTION_TOKEN_PATTERN.test(value.token)
|
|
1285
|
+
) {
|
|
1286
|
+
throw new Error("invalid Mikie update transaction lock");
|
|
1287
|
+
}
|
|
1288
|
+
return value;
|
|
1289
|
+
} catch (error) {
|
|
1290
|
+
if (error?.code === "ENOENT") return null;
|
|
1291
|
+
throw error;
|
|
1292
|
+
}
|
|
1293
|
+
};
|
|
1294
|
+
|
|
1295
|
+
const releaseLease = async (lease) => {
|
|
1296
|
+
if (!lease) return;
|
|
1297
|
+
const current = await readLock();
|
|
1298
|
+
if (current?.token !== lease.token || current.process_id !== lease.process_id) {
|
|
1299
|
+
throw new Error("Mikie update transaction lock changed unexpectedly");
|
|
1300
|
+
}
|
|
1301
|
+
await rm(lockPath, { force: true });
|
|
1302
|
+
};
|
|
1303
|
+
|
|
1304
|
+
const writeJournal = async (snapshot) => {
|
|
1305
|
+
const value = {
|
|
1306
|
+
schema_version: TRANSACTION_SCHEMA,
|
|
1307
|
+
token: snapshot.lease.token,
|
|
1308
|
+
phase: snapshot.phase,
|
|
1309
|
+
staging_prefix: snapshot.stagingPrefix,
|
|
1310
|
+
prepared_candidate: snapshot.preparedCandidate
|
|
1311
|
+
? {
|
|
1312
|
+
wrapper_package_json: snapshot.preparedCandidate.wrapperPackageJson,
|
|
1313
|
+
platform_package_json: snapshot.preparedCandidate.platformPackageJson,
|
|
1314
|
+
binary: snapshot.preparedCandidate.binary,
|
|
1315
|
+
wrapper_fingerprint: snapshot.preparedCandidate.wrapperFingerprint,
|
|
1316
|
+
platform_fingerprint: snapshot.preparedCandidate.platformFingerprint,
|
|
1317
|
+
shims: snapshot.preparedCandidate.shimFingerprints,
|
|
1318
|
+
final_launcher: snapshot.preparedCandidate.finalLauncher,
|
|
1319
|
+
}
|
|
1320
|
+
: null,
|
|
1321
|
+
recovery: snapshot.recovery
|
|
1322
|
+
? {
|
|
1323
|
+
descriptor: snapshot.recovery.descriptor,
|
|
1324
|
+
descriptor_sha256: snapshot.recovery.descriptorSha256,
|
|
1325
|
+
launcher: snapshot.recovery.launcher,
|
|
1326
|
+
launcher_sha256: snapshot.recovery.launcherSha256,
|
|
1327
|
+
runtime: snapshot.recovery.runtime,
|
|
1328
|
+
runtime_sha256: snapshot.recovery.runtimeSha256,
|
|
1329
|
+
node: snapshot.recovery.node,
|
|
1330
|
+
command: snapshot.recovery.command,
|
|
1331
|
+
}
|
|
1332
|
+
: null,
|
|
1333
|
+
installation: snapshot.installation.map((entry) => ({
|
|
1334
|
+
root: entry.root,
|
|
1335
|
+
backup: entry.backup,
|
|
1336
|
+
source_fingerprint: entry.sourceFingerprint,
|
|
1337
|
+
expected_fingerprint: entry.expectedFingerprint ?? null,
|
|
1338
|
+
activation_staging: entry.activationStaging,
|
|
1339
|
+
prior_quarantine: entry.priorQuarantine,
|
|
1340
|
+
activation_state: entry.activationState ?? null,
|
|
1341
|
+
restore_state: entry.restoreState ?? null,
|
|
1342
|
+
})),
|
|
1343
|
+
shims: snapshot.shims.map((entry) => ({
|
|
1344
|
+
path: entry.path,
|
|
1345
|
+
backup: entry.backup,
|
|
1346
|
+
source_existed: entry.existed,
|
|
1347
|
+
source_fingerprint: entry.sourceFingerprint,
|
|
1348
|
+
source_mode: entry.mode,
|
|
1349
|
+
expected_fingerprint: entry.expectedFingerprint ?? null,
|
|
1350
|
+
activation_staging: entry.activationStaging,
|
|
1351
|
+
prior_quarantine: entry.priorQuarantine,
|
|
1352
|
+
activation_state: entry.activationState ?? null,
|
|
1353
|
+
})),
|
|
1354
|
+
provider_config: snapshot.providerConfig.map((entry) => ({
|
|
1355
|
+
name: entry.name,
|
|
1356
|
+
path: entry.path,
|
|
1357
|
+
backup: entry.backup,
|
|
1358
|
+
source_existed: entry.existed,
|
|
1359
|
+
source_fingerprint: entry.sourceFingerprint,
|
|
1360
|
+
})),
|
|
1361
|
+
};
|
|
1362
|
+
await atomicWritePrivate(journalPath, Buffer.from(`${JSON.stringify(value)}\n`));
|
|
1363
|
+
};
|
|
1364
|
+
|
|
1365
|
+
const hydrateJournal = async (lease, expectedJournalToken) => {
|
|
1366
|
+
let value;
|
|
1367
|
+
try {
|
|
1368
|
+
const metadata = await lstat(journalPath);
|
|
1369
|
+
if (
|
|
1370
|
+
!metadata.isFile() ||
|
|
1371
|
+
metadata.isSymbolicLink() ||
|
|
1372
|
+
metadata.size > MAX_TRANSACTION_JOURNAL_BYTES
|
|
1373
|
+
) {
|
|
1374
|
+
throw new Error("invalid Mikie update transaction journal");
|
|
1375
|
+
}
|
|
1376
|
+
value = parseJsonNoDuplicateKeys(await readFile(journalPath));
|
|
1377
|
+
} catch (error) {
|
|
1378
|
+
if (error?.code === "ENOENT") throw new Error("incomplete Mikie update transaction journal");
|
|
1379
|
+
throw error;
|
|
1380
|
+
}
|
|
1381
|
+
if (
|
|
1382
|
+
!hasExactKeys(value, [
|
|
1383
|
+
"schema_version",
|
|
1384
|
+
"token",
|
|
1385
|
+
"phase",
|
|
1386
|
+
"staging_prefix",
|
|
1387
|
+
"prepared_candidate",
|
|
1388
|
+
"recovery",
|
|
1389
|
+
"installation",
|
|
1390
|
+
"shims",
|
|
1391
|
+
"provider_config",
|
|
1392
|
+
]) ||
|
|
1393
|
+
value.schema_version !== TRANSACTION_SCHEMA ||
|
|
1394
|
+
typeof value.token !== "string" ||
|
|
1395
|
+
!TRANSACTION_TOKEN_PATTERN.test(value.token) ||
|
|
1396
|
+
(expectedJournalToken !== null && value.token !== expectedJournalToken) ||
|
|
1397
|
+
![
|
|
1398
|
+
"prepared",
|
|
1399
|
+
"installing",
|
|
1400
|
+
"candidate_staged",
|
|
1401
|
+
"activation_prepared",
|
|
1402
|
+
"candidate_packages",
|
|
1403
|
+
"provider_preparing",
|
|
1404
|
+
"provider_rolled_back",
|
|
1405
|
+
"commit_decided",
|
|
1406
|
+
"commit_decided_no_repair",
|
|
1407
|
+
"committed",
|
|
1408
|
+
].includes(value.phase) ||
|
|
1409
|
+
!Array.isArray(value.installation) ||
|
|
1410
|
+
!Array.isArray(value.shims) ||
|
|
1411
|
+
!Array.isArray(value.provider_config) ||
|
|
1412
|
+
typeof value.staging_prefix !== "string" ||
|
|
1413
|
+
!isPathInside(transactionRoot, value.staging_prefix)
|
|
1414
|
+
) {
|
|
1415
|
+
throw new Error("invalid Mikie update transaction journal");
|
|
1416
|
+
}
|
|
1417
|
+
const expectedRoots = new Set(installationRoots.map((root) => path.resolve(root)));
|
|
1418
|
+
const journalRoots = value.installation.map((entry) => path.resolve(entry?.root ?? ""));
|
|
1419
|
+
if (
|
|
1420
|
+
value.installation.length !== installationRoots.length ||
|
|
1421
|
+
new Set(journalRoots).size !== expectedRoots.size ||
|
|
1422
|
+
journalRoots.some((root) => !expectedRoots.has(root)) ||
|
|
1423
|
+
value.installation.some(
|
|
1424
|
+
(entry) =>
|
|
1425
|
+
!entry ||
|
|
1426
|
+
!hasExactKeys(entry, [
|
|
1427
|
+
"root",
|
|
1428
|
+
"backup",
|
|
1429
|
+
"source_fingerprint",
|
|
1430
|
+
"expected_fingerprint",
|
|
1431
|
+
"activation_staging",
|
|
1432
|
+
"prior_quarantine",
|
|
1433
|
+
"activation_state",
|
|
1434
|
+
"restore_state",
|
|
1435
|
+
]) ||
|
|
1436
|
+
!expectedRoots.has(path.resolve(entry.root ?? "")) ||
|
|
1437
|
+
typeof entry.backup !== "string" ||
|
|
1438
|
+
!isPathInside(transactionRoot, entry.backup) ||
|
|
1439
|
+
typeof entry.source_fingerprint !== "string" ||
|
|
1440
|
+
typeof entry.activation_staging !== "string" ||
|
|
1441
|
+
!sameExecutablePath(
|
|
1442
|
+
entry.activation_staging,
|
|
1443
|
+
path.join(
|
|
1444
|
+
path.dirname(entry.root ?? ""),
|
|
1445
|
+
`.${path.basename(entry.root ?? "")}.mikie-candidate-${value.token}`,
|
|
1446
|
+
),
|
|
1447
|
+
) ||
|
|
1448
|
+
typeof entry.prior_quarantine !== "string" ||
|
|
1449
|
+
!sameExecutablePath(
|
|
1450
|
+
entry.prior_quarantine,
|
|
1451
|
+
path.join(
|
|
1452
|
+
path.dirname(entry.root ?? ""),
|
|
1453
|
+
`.${path.basename(entry.root ?? "")}.mikie-prior-${value.token}`,
|
|
1454
|
+
),
|
|
1455
|
+
) ||
|
|
1456
|
+
![
|
|
1457
|
+
null,
|
|
1458
|
+
"copying",
|
|
1459
|
+
"prepared",
|
|
1460
|
+
"swapping",
|
|
1461
|
+
"source_moved",
|
|
1462
|
+
"activated",
|
|
1463
|
+
"conflict_detected",
|
|
1464
|
+
"conflict_restored",
|
|
1465
|
+
].includes(entry.activation_state ?? null) ||
|
|
1466
|
+
![null, "swapping", "restored"].includes(entry.restore_state ?? null) ||
|
|
1467
|
+
![null, "string"].includes(
|
|
1468
|
+
entry.expected_fingerprint === null ? null : typeof entry.expected_fingerprint,
|
|
1469
|
+
),
|
|
1470
|
+
)
|
|
1471
|
+
) {
|
|
1472
|
+
throw new Error("invalid Mikie update transaction journal");
|
|
1473
|
+
}
|
|
1474
|
+
const expectedProviders = new Map(Object.entries(providerPaths));
|
|
1475
|
+
const expectedShims = new Set(currentShims.map((shim) => path.resolve(shim)));
|
|
1476
|
+
const journalShims = value.shims.map((entry) => path.resolve(entry?.path ?? ""));
|
|
1477
|
+
if (
|
|
1478
|
+
value.shims.length !== expectedShims.size ||
|
|
1479
|
+
new Set(journalShims).size !== expectedShims.size ||
|
|
1480
|
+
journalShims.some((shim) => !expectedShims.has(shim)) ||
|
|
1481
|
+
value.shims.some(
|
|
1482
|
+
(entry) =>
|
|
1483
|
+
!entry ||
|
|
1484
|
+
!hasExactKeys(entry, [
|
|
1485
|
+
"path",
|
|
1486
|
+
"backup",
|
|
1487
|
+
"source_existed",
|
|
1488
|
+
"source_fingerprint",
|
|
1489
|
+
"source_mode",
|
|
1490
|
+
"expected_fingerprint",
|
|
1491
|
+
"activation_staging",
|
|
1492
|
+
"prior_quarantine",
|
|
1493
|
+
"activation_state",
|
|
1494
|
+
]) ||
|
|
1495
|
+
!expectedShims.has(path.resolve(entry.path ?? "")) ||
|
|
1496
|
+
(entry.backup !== null && !isPathInside(transactionRoot, entry.backup)) ||
|
|
1497
|
+
typeof entry.source_existed !== "boolean" ||
|
|
1498
|
+
typeof entry.source_fingerprint !== "string" ||
|
|
1499
|
+
!Number.isInteger(entry.source_mode) ||
|
|
1500
|
+
entry.source_mode < 0 ||
|
|
1501
|
+
entry.source_mode > 0o777 ||
|
|
1502
|
+
typeof entry.activation_staging !== "string" ||
|
|
1503
|
+
!sameExecutablePath(
|
|
1504
|
+
entry.activation_staging,
|
|
1505
|
+
path.join(
|
|
1506
|
+
path.dirname(entry.path ?? ""),
|
|
1507
|
+
`.${path.basename(entry.path ?? "")}.mikie-candidate-${value.token}`,
|
|
1508
|
+
),
|
|
1509
|
+
) ||
|
|
1510
|
+
typeof entry.prior_quarantine !== "string" ||
|
|
1511
|
+
!sameExecutablePath(
|
|
1512
|
+
entry.prior_quarantine,
|
|
1513
|
+
path.join(
|
|
1514
|
+
path.dirname(entry.path ?? ""),
|
|
1515
|
+
`.${path.basename(entry.path ?? "")}.mikie-prior-${value.token}`,
|
|
1516
|
+
),
|
|
1517
|
+
) ||
|
|
1518
|
+
![
|
|
1519
|
+
null,
|
|
1520
|
+
"copying",
|
|
1521
|
+
"prepared",
|
|
1522
|
+
"swapping",
|
|
1523
|
+
"source_moved",
|
|
1524
|
+
"activated",
|
|
1525
|
+
"conflict_detected",
|
|
1526
|
+
"conflict_restored",
|
|
1527
|
+
].includes(entry.activation_state ?? null) ||
|
|
1528
|
+
![null, "string"].includes(
|
|
1529
|
+
entry.expected_fingerprint === null ? null : typeof entry.expected_fingerprint,
|
|
1530
|
+
),
|
|
1531
|
+
)
|
|
1532
|
+
) {
|
|
1533
|
+
throw new Error("invalid Mikie update transaction journal");
|
|
1534
|
+
}
|
|
1535
|
+
const journalProviderNames = value.provider_config.map((entry) => entry?.name);
|
|
1536
|
+
if (
|
|
1537
|
+
value.provider_config.length !== expectedProviders.size ||
|
|
1538
|
+
new Set(journalProviderNames).size !== expectedProviders.size ||
|
|
1539
|
+
journalProviderNames.some((name) => !expectedProviders.has(name)) ||
|
|
1540
|
+
value.provider_config.some(
|
|
1541
|
+
(entry) =>
|
|
1542
|
+
!entry ||
|
|
1543
|
+
!hasExactKeys(entry, [
|
|
1544
|
+
"name",
|
|
1545
|
+
"path",
|
|
1546
|
+
"backup",
|
|
1547
|
+
"source_existed",
|
|
1548
|
+
"source_fingerprint",
|
|
1549
|
+
]) ||
|
|
1550
|
+
expectedProviders.get(entry.name) !== entry.path ||
|
|
1551
|
+
(entry.backup !== null && !isPathInside(transactionRoot, entry.backup)) ||
|
|
1552
|
+
typeof entry.source_existed !== "boolean" ||
|
|
1553
|
+
typeof entry.source_fingerprint !== "string"
|
|
1554
|
+
)
|
|
1555
|
+
) {
|
|
1556
|
+
throw new Error("invalid Mikie update transaction journal");
|
|
1557
|
+
}
|
|
1558
|
+
let preparedCandidate = null;
|
|
1559
|
+
if (value.prepared_candidate !== null) {
|
|
1560
|
+
const prepared = value.prepared_candidate;
|
|
1561
|
+
if (
|
|
1562
|
+
!hasExactKeys(prepared, [
|
|
1563
|
+
"wrapper_package_json",
|
|
1564
|
+
"platform_package_json",
|
|
1565
|
+
"binary",
|
|
1566
|
+
"wrapper_fingerprint",
|
|
1567
|
+
"platform_fingerprint",
|
|
1568
|
+
"shims",
|
|
1569
|
+
"final_launcher",
|
|
1570
|
+
]) ||
|
|
1571
|
+
![prepared.wrapper_package_json, prepared.platform_package_json, prepared.binary].every(
|
|
1572
|
+
(candidatePath) =>
|
|
1573
|
+
typeof candidatePath === "string" && isPathInside(value.staging_prefix, candidatePath),
|
|
1574
|
+
) ||
|
|
1575
|
+
typeof prepared.wrapper_fingerprint !== "string" ||
|
|
1576
|
+
typeof prepared.platform_fingerprint !== "string" ||
|
|
1577
|
+
!Array.isArray(prepared.shims) ||
|
|
1578
|
+
prepared.shims.length !== currentShims.length ||
|
|
1579
|
+
prepared.shims.some(
|
|
1580
|
+
(shim, index) =>
|
|
1581
|
+
!hasExactKeys(shim, ["path", "fingerprint"]) ||
|
|
1582
|
+
typeof shim.path !== "string" ||
|
|
1583
|
+
!isPathInside(value.staging_prefix, shim.path) ||
|
|
1584
|
+
path.basename(shim.path).toLowerCase() !==
|
|
1585
|
+
path.basename(currentShims[index]).toLowerCase() ||
|
|
1586
|
+
typeof shim.fingerprint !== "string",
|
|
1587
|
+
) ||
|
|
1588
|
+
new Set(prepared.shims.map((shim) => path.resolve(shim.path))).size !==
|
|
1589
|
+
prepared.shims.length ||
|
|
1590
|
+
!sameExecutablePath(prepared.final_launcher, currentLayout.binary)
|
|
1591
|
+
) {
|
|
1592
|
+
throw new Error("invalid Mikie update transaction journal");
|
|
1593
|
+
}
|
|
1594
|
+
preparedCandidate = {
|
|
1595
|
+
wrapperPackageJson: prepared.wrapper_package_json,
|
|
1596
|
+
platformPackageJson: prepared.platform_package_json,
|
|
1597
|
+
binary: prepared.binary,
|
|
1598
|
+
wrapperFingerprint: prepared.wrapper_fingerprint,
|
|
1599
|
+
platformFingerprint: prepared.platform_fingerprint,
|
|
1600
|
+
shims: prepared.shims.map((shim) => shim.path),
|
|
1601
|
+
shimFingerprints: prepared.shims,
|
|
1602
|
+
finalLauncher: prepared.final_launcher,
|
|
1603
|
+
};
|
|
1604
|
+
const preparedRoots = validateInstallationLayout(preparedCandidate);
|
|
1605
|
+
if (
|
|
1606
|
+
(await directoryFingerprint(preparedRoots.wrapperRoot)) !==
|
|
1607
|
+
preparedCandidate.wrapperFingerprint ||
|
|
1608
|
+
(await directoryFingerprint(preparedRoots.platformRoot)) !==
|
|
1609
|
+
preparedCandidate.platformFingerprint
|
|
1610
|
+
) {
|
|
1611
|
+
throw new Error("corrupt Mikie staged candidate");
|
|
1612
|
+
}
|
|
1613
|
+
for (const shim of preparedCandidate.shimFingerprints) {
|
|
1614
|
+
const current = await readOptionalFile(shim.path);
|
|
1615
|
+
if (bytesFingerprint(current.existed, current.bytes) !== shim.fingerprint) {
|
|
1616
|
+
throw new Error("corrupt Mikie staged candidate");
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
let recovery = null;
|
|
1621
|
+
if (value.recovery !== null) {
|
|
1622
|
+
const persisted = value.recovery;
|
|
1623
|
+
if (
|
|
1624
|
+
!hasExactKeys(persisted, [
|
|
1625
|
+
"descriptor",
|
|
1626
|
+
"descriptor_sha256",
|
|
1627
|
+
"launcher",
|
|
1628
|
+
"launcher_sha256",
|
|
1629
|
+
"runtime",
|
|
1630
|
+
"runtime_sha256",
|
|
1631
|
+
"node",
|
|
1632
|
+
"command",
|
|
1633
|
+
]) ||
|
|
1634
|
+
![persisted.descriptor, persisted.launcher, persisted.runtime].every(
|
|
1635
|
+
(artifactPath) =>
|
|
1636
|
+
typeof artifactPath === "string" && isPathInside(transactionRoot, artifactPath),
|
|
1637
|
+
) ||
|
|
1638
|
+
typeof persisted.node !== "string" ||
|
|
1639
|
+
!path.isAbsolute(persisted.node) ||
|
|
1640
|
+
typeof persisted.command !== "string" ||
|
|
1641
|
+
!/^[a-f0-9]{64}$/.test(persisted.descriptor_sha256) ||
|
|
1642
|
+
!/^[a-f0-9]{64}$/.test(persisted.launcher_sha256) ||
|
|
1643
|
+
!/^[a-f0-9]{64}$/.test(persisted.runtime_sha256)
|
|
1644
|
+
) {
|
|
1645
|
+
throw new Error("invalid Mikie update recovery authority");
|
|
1646
|
+
}
|
|
1647
|
+
const artifacts = await Promise.all(
|
|
1648
|
+
[persisted.descriptor, persisted.launcher, persisted.runtime].map(async (artifactPath) => {
|
|
1649
|
+
const metadata = await lstat(artifactPath);
|
|
1650
|
+
if (
|
|
1651
|
+
!metadata.isFile() ||
|
|
1652
|
+
metadata.isSymbolicLink() ||
|
|
1653
|
+
metadata.size > MAX_RECOVERY_ARTIFACT_BYTES
|
|
1654
|
+
) {
|
|
1655
|
+
throw new Error("invalid Mikie update recovery authority");
|
|
1656
|
+
}
|
|
1657
|
+
return readFile(artifactPath);
|
|
1658
|
+
}),
|
|
1659
|
+
);
|
|
1660
|
+
if (
|
|
1661
|
+
sha256Bytes(artifacts[0]) !== persisted.descriptor_sha256 ||
|
|
1662
|
+
sha256Bytes(artifacts[1]) !== persisted.launcher_sha256 ||
|
|
1663
|
+
sha256Bytes(artifacts[2]) !== persisted.runtime_sha256
|
|
1664
|
+
) {
|
|
1665
|
+
throw new Error("tampered Mikie update recovery authority");
|
|
1666
|
+
}
|
|
1667
|
+
recovery = {
|
|
1668
|
+
descriptor: persisted.descriptor,
|
|
1669
|
+
descriptorSha256: persisted.descriptor_sha256,
|
|
1670
|
+
launcher: persisted.launcher,
|
|
1671
|
+
launcherSha256: persisted.launcher_sha256,
|
|
1672
|
+
runtime: persisted.runtime,
|
|
1673
|
+
runtimeSha256: persisted.runtime_sha256,
|
|
1674
|
+
node: persisted.node,
|
|
1675
|
+
command: persisted.command,
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
if (
|
|
1679
|
+
[
|
|
1680
|
+
"provider_preparing",
|
|
1681
|
+
"activation_prepared",
|
|
1682
|
+
"provider_rolled_back",
|
|
1683
|
+
"candidate_packages",
|
|
1684
|
+
"commit_decided",
|
|
1685
|
+
"commit_decided_no_repair",
|
|
1686
|
+
"committed",
|
|
1687
|
+
].includes(value.phase) &&
|
|
1688
|
+
(!preparedCandidate || !recovery)
|
|
1689
|
+
) {
|
|
1690
|
+
throw new Error("incomplete Mikie update recovery authority");
|
|
1691
|
+
}
|
|
1692
|
+
const providerConfig = [];
|
|
1693
|
+
for (const entry of value.provider_config) {
|
|
1694
|
+
const bytes = entry.source_existed ? await readFile(entry.backup) : Buffer.alloc(0);
|
|
1695
|
+
if (bytesFingerprint(entry.source_existed, bytes) !== entry.source_fingerprint) {
|
|
1696
|
+
throw new Error("corrupt Mikie update transaction backup");
|
|
1697
|
+
}
|
|
1698
|
+
providerConfig.push({
|
|
1699
|
+
name: entry.name,
|
|
1700
|
+
path: entry.path,
|
|
1701
|
+
existed: entry.source_existed,
|
|
1702
|
+
bytes,
|
|
1703
|
+
backup: entry.backup,
|
|
1704
|
+
sourceFingerprint: entry.source_fingerprint,
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
const shims = [];
|
|
1708
|
+
for (const entry of value.shims) {
|
|
1709
|
+
const bytes = entry.source_existed ? await readFile(entry.backup) : Buffer.alloc(0);
|
|
1710
|
+
if (bytesFingerprint(entry.source_existed, bytes) !== entry.source_fingerprint) {
|
|
1711
|
+
throw new Error("corrupt Mikie update transaction backup");
|
|
1712
|
+
}
|
|
1713
|
+
shims.push({
|
|
1714
|
+
path: entry.path,
|
|
1715
|
+
existed: entry.source_existed,
|
|
1716
|
+
bytes,
|
|
1717
|
+
backup: entry.backup,
|
|
1718
|
+
mode: entry.source_mode,
|
|
1719
|
+
sourceFingerprint: entry.source_fingerprint,
|
|
1720
|
+
expectedFingerprint: entry.expected_fingerprint,
|
|
1721
|
+
activationStaging: entry.activation_staging,
|
|
1722
|
+
priorQuarantine: entry.prior_quarantine,
|
|
1723
|
+
activationState: entry.activation_state ?? null,
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
for (const entry of value.installation) {
|
|
1727
|
+
if ((await directoryFingerprint(entry.backup)) !== entry.source_fingerprint) {
|
|
1728
|
+
throw new Error("corrupt Mikie update transaction backup");
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
return {
|
|
1732
|
+
backupRoot: transactionRoot,
|
|
1733
|
+
lease,
|
|
1734
|
+
phase: value.phase,
|
|
1735
|
+
stagingPrefix: value.staging_prefix,
|
|
1736
|
+
preparedCandidate,
|
|
1737
|
+
recovery,
|
|
1738
|
+
installation: value.installation.map((entry) => ({
|
|
1739
|
+
root: entry.root,
|
|
1740
|
+
backup: entry.backup,
|
|
1741
|
+
sourceFingerprint: entry.source_fingerprint,
|
|
1742
|
+
expectedFingerprint: entry.expected_fingerprint,
|
|
1743
|
+
activationStaging: entry.activation_staging,
|
|
1744
|
+
priorQuarantine: entry.prior_quarantine,
|
|
1745
|
+
activationState: entry.activation_state ?? null,
|
|
1746
|
+
restoreState: entry.restore_state ?? null,
|
|
1747
|
+
})),
|
|
1748
|
+
shims,
|
|
1749
|
+
providerConfig,
|
|
1750
|
+
};
|
|
1751
|
+
};
|
|
1752
|
+
|
|
1753
|
+
const removePreparedActivation = async (entry) => {
|
|
1754
|
+
const stagedFingerprint = await directoryFingerprint(entry.activationStaging);
|
|
1755
|
+
if (stagedFingerprint === "absent") return;
|
|
1756
|
+
if (
|
|
1757
|
+
entry.activationState !== "copying" &&
|
|
1758
|
+
(!entry.expectedFingerprint || stagedFingerprint !== entry.expectedFingerprint)
|
|
1759
|
+
) {
|
|
1760
|
+
throw new Error("tampered Mikie activation staging package");
|
|
1761
|
+
}
|
|
1762
|
+
await rm(entry.activationStaging, { recursive: true, force: true });
|
|
1763
|
+
};
|
|
1764
|
+
|
|
1765
|
+
const restoreRoot = async (snapshot, entry) => {
|
|
1766
|
+
const currentFingerprint = await directoryFingerprint(entry.root);
|
|
1767
|
+
const expected = entry.expectedFingerprint;
|
|
1768
|
+
const priorFingerprint = await directoryFingerprint(entry.priorQuarantine);
|
|
1769
|
+
if (priorFingerprint !== "absent") {
|
|
1770
|
+
if (priorFingerprint !== entry.sourceFingerprint) {
|
|
1771
|
+
if (
|
|
1772
|
+
currentFingerprint !== "absent" ||
|
|
1773
|
+
!["swapping", "source_moved", "conflict_detected"].includes(entry.activationState)
|
|
1774
|
+
) {
|
|
1775
|
+
throw new Error("tampered Mikie prior package quarantine");
|
|
1776
|
+
}
|
|
1777
|
+
await rename(entry.priorQuarantine, entry.root);
|
|
1778
|
+
if ((await directoryFingerprint(entry.root)) !== priorFingerprint) {
|
|
1779
|
+
throw new Error("package changed while the Mikie update rollback was committing");
|
|
1780
|
+
}
|
|
1781
|
+
entry.activationState = "conflict_restored";
|
|
1782
|
+
await writeJournal(snapshot);
|
|
1783
|
+
await removePreparedActivation(entry);
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
if (currentFingerprint === entry.sourceFingerprint) {
|
|
1787
|
+
await rm(entry.priorQuarantine, { recursive: true, force: true });
|
|
1788
|
+
await removePreparedActivation(entry);
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
if (currentFingerprint !== "absent" && currentFingerprint !== expected) {
|
|
1792
|
+
throw new Error("package changed after the Mikie update checkpoint");
|
|
1793
|
+
}
|
|
1794
|
+
if (currentFingerprint === expected) {
|
|
1795
|
+
const stagedFingerprint = await directoryFingerprint(entry.activationStaging);
|
|
1796
|
+
if (stagedFingerprint === "absent") {
|
|
1797
|
+
await rename(entry.root, entry.activationStaging);
|
|
1798
|
+
if ((await directoryFingerprint(entry.activationStaging)) !== expected) {
|
|
1799
|
+
throw new Error("package changed while the Mikie update rollback was committing");
|
|
1800
|
+
}
|
|
1801
|
+
} else if (stagedFingerprint === expected) {
|
|
1802
|
+
await rm(entry.root, { recursive: true, force: true });
|
|
1803
|
+
} else {
|
|
1804
|
+
throw new Error("tampered Mikie activation staging package");
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
await rename(entry.priorQuarantine, entry.root);
|
|
1808
|
+
if ((await directoryFingerprint(entry.root)) !== entry.sourceFingerprint) {
|
|
1809
|
+
throw new Error("Mikie update package restore verification failed");
|
|
1810
|
+
}
|
|
1811
|
+
await removePreparedActivation(entry);
|
|
1812
|
+
entry.restoreState = "restored";
|
|
1813
|
+
await writeJournal(snapshot);
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1816
|
+
if (currentFingerprint === entry.sourceFingerprint) {
|
|
1817
|
+
await removePreparedActivation(entry);
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
if (
|
|
1821
|
+
priorFingerprint === "absent" &&
|
|
1822
|
+
["swapping", "source_moved", "conflict_detected", "conflict_restored"].includes(
|
|
1823
|
+
entry.activationState,
|
|
1824
|
+
) &&
|
|
1825
|
+
currentFingerprint !== expected
|
|
1826
|
+
) {
|
|
1827
|
+
await removePreparedActivation(entry);
|
|
1828
|
+
entry.activationState = "conflict_restored";
|
|
1829
|
+
entry.restoreState = "restored";
|
|
1830
|
+
await writeJournal(snapshot);
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
if (!expected) {
|
|
1834
|
+
throw new Error("package changed before the Mikie update checkpoint");
|
|
1835
|
+
}
|
|
1836
|
+
if (
|
|
1837
|
+
currentFingerprint !== expected &&
|
|
1838
|
+
currentFingerprint !== "absent" &&
|
|
1839
|
+
!(entry.restoreState === "swapping" && currentFingerprint === "absent")
|
|
1840
|
+
) {
|
|
1841
|
+
throw new Error("package changed after the Mikie update checkpoint");
|
|
1842
|
+
}
|
|
1843
|
+
const staging = path.join(
|
|
1844
|
+
path.dirname(entry.root),
|
|
1845
|
+
`.${path.basename(entry.root)}.mikie-restore-${snapshot.lease.token}`,
|
|
1846
|
+
);
|
|
1847
|
+
await rm(staging, { recursive: true, force: true });
|
|
1848
|
+
await cp(entry.backup, staging, { recursive: true, force: false, errorOnExist: true });
|
|
1849
|
+
if ((await directoryFingerprint(entry.backup)) !== entry.sourceFingerprint) {
|
|
1850
|
+
await rm(staging, { recursive: true, force: true });
|
|
1851
|
+
throw new Error("corrupt Mikie update transaction backup");
|
|
1852
|
+
}
|
|
1853
|
+
if ((await directoryFingerprint(staging)) !== entry.sourceFingerprint) {
|
|
1854
|
+
await rm(staging, { recursive: true, force: true });
|
|
1855
|
+
throw new Error("Mikie update package restore verification failed");
|
|
1856
|
+
}
|
|
1857
|
+
const quarantine = entry.activationStaging;
|
|
1858
|
+
const stagedFingerprint = await directoryFingerprint(quarantine);
|
|
1859
|
+
if (stagedFingerprint !== "absent" && stagedFingerprint !== expected) {
|
|
1860
|
+
throw new Error("tampered Mikie activation staging package");
|
|
1861
|
+
}
|
|
1862
|
+
if (stagedFingerprint === expected) {
|
|
1863
|
+
await rm(quarantine, { recursive: true, force: true });
|
|
1864
|
+
}
|
|
1865
|
+
entry.restoreState = "swapping";
|
|
1866
|
+
await writeJournal(snapshot);
|
|
1867
|
+
try {
|
|
1868
|
+
if (currentFingerprint !== "absent") {
|
|
1869
|
+
await rename(entry.root, quarantine);
|
|
1870
|
+
if ((await directoryFingerprint(quarantine)) !== currentFingerprint) {
|
|
1871
|
+
throw new Error("package changed while the Mikie update rollback was committing");
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
await rename(staging, entry.root);
|
|
1875
|
+
} catch (error) {
|
|
1876
|
+
await rm(staging, { recursive: true, force: true });
|
|
1877
|
+
try {
|
|
1878
|
+
if (currentFingerprint !== "absent") await rename(quarantine, entry.root);
|
|
1879
|
+
} catch {
|
|
1880
|
+
// The durable journal retains both locations for the next recovery attempt.
|
|
1881
|
+
}
|
|
1882
|
+
throw error;
|
|
1883
|
+
}
|
|
1884
|
+
await rm(quarantine, { recursive: true, force: true });
|
|
1885
|
+
entry.restoreState = "restored";
|
|
1886
|
+
await writeJournal(snapshot);
|
|
1887
|
+
};
|
|
1888
|
+
|
|
1889
|
+
const readShimState = async (filePath) => {
|
|
1890
|
+
await assertNoLinkedAncestors(filePath);
|
|
1891
|
+
try {
|
|
1892
|
+
const metadata = await lstat(filePath);
|
|
1893
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > MAX_COMMAND_OUTPUT_BYTES) {
|
|
1894
|
+
throw new Error("unsafe Mikie global shim");
|
|
1895
|
+
}
|
|
1896
|
+
const bytes = await readFile(filePath);
|
|
1897
|
+
return {
|
|
1898
|
+
existed: true,
|
|
1899
|
+
bytes,
|
|
1900
|
+
fingerprint: bytesFingerprint(true, bytes),
|
|
1901
|
+
device: metadata.dev,
|
|
1902
|
+
inode: metadata.ino,
|
|
1903
|
+
};
|
|
1904
|
+
} catch (error) {
|
|
1905
|
+
if (error?.code === "ENOENT") {
|
|
1906
|
+
return {
|
|
1907
|
+
existed: false,
|
|
1908
|
+
bytes: Buffer.alloc(0),
|
|
1909
|
+
fingerprint: "absent",
|
|
1910
|
+
device: null,
|
|
1911
|
+
inode: null,
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
throw error;
|
|
1915
|
+
}
|
|
1916
|
+
};
|
|
1917
|
+
|
|
1918
|
+
const removePreparedShim = async (entry) => {
|
|
1919
|
+
const staged = await readShimState(entry.activationStaging);
|
|
1920
|
+
if (!staged.existed) return;
|
|
1921
|
+
if (
|
|
1922
|
+
entry.activationState !== "copying" &&
|
|
1923
|
+
(!entry.expectedFingerprint || staged.fingerprint !== entry.expectedFingerprint)
|
|
1924
|
+
) {
|
|
1925
|
+
throw new Error("tampered Mikie activation staging shim");
|
|
1926
|
+
}
|
|
1927
|
+
await rm(entry.activationStaging, { force: true });
|
|
1928
|
+
};
|
|
1929
|
+
|
|
1930
|
+
const sameShimIdentity = (left, right) =>
|
|
1931
|
+
left.existed &&
|
|
1932
|
+
right.existed &&
|
|
1933
|
+
left.device === right.device &&
|
|
1934
|
+
left.inode === right.inode &&
|
|
1935
|
+
left.inode !== 0;
|
|
1936
|
+
|
|
1937
|
+
const publishShimWithoutReplacement = async (
|
|
1938
|
+
source,
|
|
1939
|
+
destination,
|
|
1940
|
+
expectedFingerprint,
|
|
1941
|
+
beforeSourceUnlink,
|
|
1942
|
+
) => {
|
|
1943
|
+
await link(source, destination);
|
|
1944
|
+
const published = await readShimState(destination);
|
|
1945
|
+
if (published.fingerprint !== expectedFingerprint) {
|
|
1946
|
+
throw new Error("global shim changed while Mikie was committing");
|
|
1947
|
+
}
|
|
1948
|
+
await beforeSourceUnlink?.();
|
|
1949
|
+
await rm(source, { force: true });
|
|
1950
|
+
};
|
|
1951
|
+
|
|
1952
|
+
const restoreShim = async (snapshot, entry) => {
|
|
1953
|
+
const current = await readShimState(entry.path);
|
|
1954
|
+
const prior = await readShimState(entry.priorQuarantine);
|
|
1955
|
+
let staged = await readShimState(entry.activationStaging);
|
|
1956
|
+
const conflictStates = ["swapping", "source_moved", "conflict_detected", "conflict_restored"];
|
|
1957
|
+
if (prior.existed) {
|
|
1958
|
+
if (prior.fingerprint !== entry.sourceFingerprint) {
|
|
1959
|
+
if (
|
|
1960
|
+
current.fingerprint === prior.fingerprint &&
|
|
1961
|
+
sameShimIdentity(current, prior) &&
|
|
1962
|
+
conflictStates.includes(entry.activationState)
|
|
1963
|
+
) {
|
|
1964
|
+
await rm(entry.priorQuarantine, { force: true });
|
|
1965
|
+
await removePreparedShim(entry);
|
|
1966
|
+
entry.activationState = "conflict_restored";
|
|
1967
|
+
await writeJournal(snapshot);
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
if (!current.existed && conflictStates.includes(entry.activationState)) {
|
|
1971
|
+
await publishShimWithoutReplacement(
|
|
1972
|
+
entry.priorQuarantine,
|
|
1973
|
+
entry.path,
|
|
1974
|
+
prior.fingerprint,
|
|
1975
|
+
);
|
|
1976
|
+
entry.activationState = "conflict_restored";
|
|
1977
|
+
await writeJournal(snapshot);
|
|
1978
|
+
await removePreparedShim(entry);
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
throw new Error("tampered Mikie prior shim quarantine");
|
|
1982
|
+
}
|
|
1983
|
+
if (current.fingerprint === entry.sourceFingerprint) {
|
|
1984
|
+
await rm(entry.priorQuarantine, { force: true });
|
|
1985
|
+
await removePreparedShim(entry);
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
if (!current.existed) {
|
|
1989
|
+
await publishShimWithoutReplacement(
|
|
1990
|
+
entry.priorQuarantine,
|
|
1991
|
+
entry.path,
|
|
1992
|
+
entry.sourceFingerprint,
|
|
1993
|
+
);
|
|
1994
|
+
await chmod(entry.path, entry.mode);
|
|
1995
|
+
await removePreparedShim(entry);
|
|
1996
|
+
return;
|
|
1997
|
+
}
|
|
1998
|
+
if (current.fingerprint === entry.expectedFingerprint) {
|
|
1999
|
+
if (staged.existed) {
|
|
2000
|
+
if (staged.fingerprint !== entry.expectedFingerprint) {
|
|
2001
|
+
throw new Error("Mikie shim rollback staging path is not empty");
|
|
2002
|
+
}
|
|
2003
|
+
await rm(entry.activationStaging, { force: true });
|
|
2004
|
+
staged = await readShimState(entry.activationStaging);
|
|
2005
|
+
}
|
|
2006
|
+
await rename(entry.path, entry.activationStaging);
|
|
2007
|
+
await activationBoundary?.(
|
|
2008
|
+
"shim_restore_candidate_moved",
|
|
2009
|
+
entry.path,
|
|
2010
|
+
entry.activationStaging,
|
|
2011
|
+
entry.priorQuarantine,
|
|
2012
|
+
);
|
|
2013
|
+
const moved = await readShimState(entry.activationStaging);
|
|
2014
|
+
if (moved.fingerprint !== entry.expectedFingerprint) {
|
|
2015
|
+
await publishShimWithoutReplacement(
|
|
2016
|
+
entry.activationStaging,
|
|
2017
|
+
entry.path,
|
|
2018
|
+
moved.fingerprint,
|
|
2019
|
+
);
|
|
2020
|
+
await rm(entry.priorQuarantine, { force: true });
|
|
2021
|
+
entry.activationState = "conflict_restored";
|
|
2022
|
+
await writeJournal(snapshot);
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
2025
|
+
await publishShimWithoutReplacement(
|
|
2026
|
+
entry.priorQuarantine,
|
|
2027
|
+
entry.path,
|
|
2028
|
+
entry.sourceFingerprint,
|
|
2029
|
+
);
|
|
2030
|
+
await chmod(entry.path, entry.mode);
|
|
2031
|
+
await removePreparedShim(entry);
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2034
|
+
if (
|
|
2035
|
+
staged.existed &&
|
|
2036
|
+
current.fingerprint === staged.fingerprint &&
|
|
2037
|
+
sameShimIdentity(current, staged) &&
|
|
2038
|
+
(conflictStates.includes(entry.activationState) || entry.activationState === "activated")
|
|
2039
|
+
) {
|
|
2040
|
+
await rm(entry.activationStaging, { force: true });
|
|
2041
|
+
await rm(entry.priorQuarantine, { force: true });
|
|
2042
|
+
entry.activationState = "conflict_restored";
|
|
2043
|
+
await writeJournal(snapshot);
|
|
2044
|
+
return;
|
|
2045
|
+
}
|
|
2046
|
+
if (conflictStates.includes(entry.activationState) || entry.activationState === "activated") {
|
|
2047
|
+
await rm(entry.priorQuarantine, { force: true });
|
|
2048
|
+
await removePreparedShim(entry);
|
|
2049
|
+
entry.activationState = "conflict_restored";
|
|
2050
|
+
await writeJournal(snapshot);
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
throw new Error("global shim changed after the Mikie update checkpoint");
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
if (current.fingerprint === entry.sourceFingerprint) {
|
|
2057
|
+
await removePreparedShim(entry);
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
if (
|
|
2061
|
+
staged.existed &&
|
|
2062
|
+
current.fingerprint === staged.fingerprint &&
|
|
2063
|
+
sameShimIdentity(current, staged)
|
|
2064
|
+
) {
|
|
2065
|
+
if (
|
|
2066
|
+
staged.fingerprint !== entry.expectedFingerprint &&
|
|
2067
|
+
!conflictStates.includes(entry.activationState)
|
|
2068
|
+
) {
|
|
2069
|
+
throw new Error("Mikie shim rollback staging path is not empty");
|
|
2070
|
+
}
|
|
2071
|
+
await rm(entry.activationStaging, { force: true });
|
|
2072
|
+
staged = await readShimState(entry.activationStaging);
|
|
2073
|
+
if (current.fingerprint !== entry.expectedFingerprint) {
|
|
2074
|
+
entry.activationState = "conflict_restored";
|
|
2075
|
+
await writeJournal(snapshot);
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
if (
|
|
2080
|
+
conflictStates.includes(entry.activationState) &&
|
|
2081
|
+
current.fingerprint !== entry.expectedFingerprint
|
|
2082
|
+
) {
|
|
2083
|
+
await removePreparedShim(entry);
|
|
2084
|
+
entry.activationState = "conflict_restored";
|
|
2085
|
+
await writeJournal(snapshot);
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
if (!entry.expectedFingerprint) {
|
|
2089
|
+
throw new Error("global shim changed before the Mikie update checkpoint");
|
|
2090
|
+
}
|
|
2091
|
+
if (current.existed && current.fingerprint !== entry.expectedFingerprint) {
|
|
2092
|
+
throw new Error("global shim changed after the Mikie update checkpoint");
|
|
2093
|
+
}
|
|
2094
|
+
if (current.existed) {
|
|
2095
|
+
if (staged.existed) throw new Error("Mikie shim rollback staging path is not empty");
|
|
2096
|
+
await rename(entry.path, entry.activationStaging);
|
|
2097
|
+
await activationBoundary?.(
|
|
2098
|
+
"shim_restore_candidate_moved",
|
|
2099
|
+
entry.path,
|
|
2100
|
+
entry.activationStaging,
|
|
2101
|
+
entry.priorQuarantine,
|
|
2102
|
+
);
|
|
2103
|
+
const moved = await readShimState(entry.activationStaging);
|
|
2104
|
+
if (moved.fingerprint !== entry.expectedFingerprint) {
|
|
2105
|
+
await publishShimWithoutReplacement(entry.activationStaging, entry.path, moved.fingerprint);
|
|
2106
|
+
entry.activationState = "conflict_restored";
|
|
2107
|
+
await writeJournal(snapshot);
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
if (entry.existed) {
|
|
2112
|
+
try {
|
|
2113
|
+
await atomicCreatePrivate(entry.path, entry.bytes);
|
|
2114
|
+
await chmod(entry.path, entry.mode);
|
|
2115
|
+
} catch (error) {
|
|
2116
|
+
const concurrent = await readShimState(entry.path);
|
|
2117
|
+
if (!concurrent.existed) throw error;
|
|
2118
|
+
await removePreparedShim(entry);
|
|
2119
|
+
entry.activationState = "conflict_restored";
|
|
2120
|
+
await writeJournal(snapshot);
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
2123
|
+
const restored = await readShimState(entry.path);
|
|
2124
|
+
if (restored.fingerprint !== entry.sourceFingerprint) {
|
|
2125
|
+
throw new Error("global shim restore verification failed");
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
await removePreparedShim(entry);
|
|
2129
|
+
};
|
|
2130
|
+
|
|
2131
|
+
const restoreSnapshot = async (snapshot) => {
|
|
2132
|
+
const failures = [];
|
|
2133
|
+
for (const entry of snapshot.installation) {
|
|
2134
|
+
try {
|
|
2135
|
+
await restoreRoot(snapshot, entry);
|
|
2136
|
+
} catch (error) {
|
|
2137
|
+
failures.push(error);
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
for (const entry of snapshot.shims) {
|
|
2141
|
+
try {
|
|
2142
|
+
await restoreShim(snapshot, entry);
|
|
2143
|
+
} catch (error) {
|
|
2144
|
+
failures.push(error);
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
if (failures.length > 0) throw new AggregateError(failures, "snapshot restore was incomplete");
|
|
2148
|
+
await rm(snapshot.backupRoot, { recursive: true, force: true });
|
|
2149
|
+
};
|
|
2150
|
+
|
|
2151
|
+
const cleanupCommittedActivation = async (snapshot) => {
|
|
2152
|
+
for (const entry of snapshot.installation) {
|
|
2153
|
+
if (
|
|
2154
|
+
!entry.expectedFingerprint ||
|
|
2155
|
+
(await directoryFingerprint(entry.root)) !== entry.expectedFingerprint
|
|
2156
|
+
) {
|
|
2157
|
+
throw new Error("committed Mikie package changed before cleanup");
|
|
2158
|
+
}
|
|
2159
|
+
const priorFingerprint = await directoryFingerprint(entry.priorQuarantine);
|
|
2160
|
+
if (priorFingerprint !== "absent" && priorFingerprint !== entry.sourceFingerprint) {
|
|
2161
|
+
throw new Error("tampered Mikie prior package quarantine");
|
|
2162
|
+
}
|
|
2163
|
+
const stagedFingerprint = await directoryFingerprint(entry.activationStaging);
|
|
2164
|
+
if (stagedFingerprint !== "absent" && stagedFingerprint !== entry.expectedFingerprint) {
|
|
2165
|
+
throw new Error("tampered Mikie activation staging package");
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
for (const entry of snapshot.shims) {
|
|
2169
|
+
if (
|
|
2170
|
+
!entry.expectedFingerprint ||
|
|
2171
|
+
(await readShimState(entry.path)).fingerprint !== entry.expectedFingerprint
|
|
2172
|
+
) {
|
|
2173
|
+
throw new Error("committed Mikie shim changed before cleanup");
|
|
2174
|
+
}
|
|
2175
|
+
const prior = await readShimState(entry.priorQuarantine);
|
|
2176
|
+
if (prior.existed && prior.fingerprint !== entry.sourceFingerprint) {
|
|
2177
|
+
throw new Error("tampered Mikie prior shim quarantine");
|
|
2178
|
+
}
|
|
2179
|
+
const staged = await readShimState(entry.activationStaging);
|
|
2180
|
+
if (staged.existed && staged.fingerprint !== entry.expectedFingerprint) {
|
|
2181
|
+
throw new Error("tampered Mikie activation staging shim");
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
for (const entry of snapshot.installation) {
|
|
2185
|
+
await rm(entry.priorQuarantine, { recursive: true, force: true });
|
|
2186
|
+
await rm(entry.activationStaging, { recursive: true, force: true });
|
|
2187
|
+
}
|
|
2188
|
+
for (const entry of snapshot.shims) {
|
|
2189
|
+
await rm(entry.priorQuarantine, { force: true });
|
|
2190
|
+
await rm(entry.activationStaging, { force: true });
|
|
2191
|
+
}
|
|
2192
|
+
};
|
|
2193
|
+
|
|
2194
|
+
const recoverInterrupted = async (lease, expectedJournalToken) => {
|
|
2195
|
+
try {
|
|
2196
|
+
const metadata = await lstat(transactionRoot);
|
|
2197
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
2198
|
+
throw new Error("unsafe Mikie update transaction root");
|
|
2199
|
+
}
|
|
2200
|
+
} catch (error) {
|
|
2201
|
+
if (error?.code === "ENOENT") return;
|
|
2202
|
+
throw error;
|
|
2203
|
+
}
|
|
2204
|
+
const snapshot = await hydrateJournal(lease, expectedJournalToken);
|
|
2205
|
+
if (snapshot.phase === "commit_decided") {
|
|
2206
|
+
if (!settleProviderRepair) {
|
|
2207
|
+
throw new Error("candidate owned-adapter settlement is unavailable");
|
|
2208
|
+
}
|
|
2209
|
+
await settleProviderRepair("commit", snapshot);
|
|
2210
|
+
snapshot.phase = "committed";
|
|
2211
|
+
await writeJournal(snapshot);
|
|
2212
|
+
await cleanupCommittedActivation(snapshot);
|
|
2213
|
+
await rm(transactionRoot, { recursive: true, force: true });
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
if (snapshot.phase === "commit_decided_no_repair") {
|
|
2217
|
+
snapshot.phase = "committed";
|
|
2218
|
+
await writeJournal(snapshot);
|
|
2219
|
+
await cleanupCommittedActivation(snapshot);
|
|
2220
|
+
await rm(transactionRoot, { recursive: true, force: true });
|
|
2221
|
+
return;
|
|
2222
|
+
}
|
|
2223
|
+
if (snapshot.phase === "committed") {
|
|
2224
|
+
await cleanupCommittedActivation(snapshot);
|
|
2225
|
+
await rm(transactionRoot, { recursive: true, force: true });
|
|
2226
|
+
return;
|
|
2227
|
+
}
|
|
2228
|
+
if (["provider_preparing", "activation_prepared", "candidate_packages"].includes(snapshot.phase)) {
|
|
2229
|
+
if (!settleProviderRepair) {
|
|
2230
|
+
throw new Error("candidate owned-adapter settlement is unavailable");
|
|
2231
|
+
}
|
|
2232
|
+
await settleProviderRepair("rollback", snapshot);
|
|
2233
|
+
snapshot.phase = "provider_rolled_back";
|
|
2234
|
+
await writeJournal(snapshot);
|
|
2235
|
+
}
|
|
2236
|
+
await restoreSnapshot(snapshot);
|
|
2237
|
+
};
|
|
2238
|
+
|
|
2239
|
+
const beginTransaction = async () => {
|
|
2240
|
+
await assertNoLinkedAncestors(path.dirname(transactionRoot));
|
|
2241
|
+
await mkdir(path.dirname(transactionRoot), { recursive: true, mode: 0o700 });
|
|
2242
|
+
await assertNoLinkedAncestors(path.dirname(transactionRoot));
|
|
2243
|
+
let recoveryToken = null;
|
|
2244
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
2245
|
+
const lease = { schema_version: LOCK_SCHEMA, process_id: processId, token: randomUUID() };
|
|
2246
|
+
try {
|
|
2247
|
+
const handle = await open(lockPath, "wx", 0o600);
|
|
2248
|
+
try {
|
|
2249
|
+
await handle.writeFile(`${JSON.stringify(lease)}\n`);
|
|
2250
|
+
await handle.sync();
|
|
2251
|
+
} finally {
|
|
2252
|
+
await handle.close();
|
|
2253
|
+
}
|
|
2254
|
+
try {
|
|
2255
|
+
await recoverInterrupted(lease, recoveryToken);
|
|
2256
|
+
return lease;
|
|
2257
|
+
} catch (error) {
|
|
2258
|
+
await releaseLease(lease);
|
|
2259
|
+
throw error;
|
|
2260
|
+
}
|
|
2261
|
+
} catch (error) {
|
|
2262
|
+
if (error?.code !== "EEXIST" || attempt > 0) throw error;
|
|
2263
|
+
const existing = await readLock();
|
|
2264
|
+
if (existing && isProcessAlive(existing.process_id)) {
|
|
2265
|
+
throw new Error("another Mikie update transaction is active");
|
|
2266
|
+
}
|
|
2267
|
+
recoveryToken = existing?.token ?? null;
|
|
2268
|
+
await rm(lockPath, { force: true });
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
throw new Error("Mikie update transaction lock is unavailable");
|
|
2272
|
+
};
|
|
2273
|
+
|
|
2274
|
+
const adapter = {
|
|
2275
|
+
beginTransaction,
|
|
2276
|
+
async endTransaction(lease) {
|
|
2277
|
+
await releaseLease(lease);
|
|
2278
|
+
},
|
|
2279
|
+
async currentInstalledVersion() {
|
|
2280
|
+
const [wrapperVersion, platformVersion] = await Promise.all([
|
|
2281
|
+
adapter.readPackageVersion(currentLayout.wrapperPackageJson),
|
|
2282
|
+
adapter.readPackageVersion(currentLayout.platformPackageJson),
|
|
2283
|
+
]);
|
|
2284
|
+
if (wrapperVersion !== platformVersion || !exactPublicVersion(wrapperVersion)) {
|
|
2285
|
+
throw new Error("installed Mikie package versions do not match");
|
|
2286
|
+
}
|
|
2287
|
+
return wrapperVersion;
|
|
2288
|
+
},
|
|
2289
|
+
async snapshot(request = {}) {
|
|
2290
|
+
const autoLease = request.lease === undefined;
|
|
2291
|
+
const lease = request.lease ?? (await beginTransaction());
|
|
2292
|
+
try {
|
|
2293
|
+
await mkdir(transactionRoot, { mode: 0o700 });
|
|
2294
|
+
const installation = [];
|
|
2295
|
+
for (const [index, root] of installationRoots.entries()) {
|
|
2296
|
+
const backup = path.join(transactionRoot, `package-${index}`);
|
|
2297
|
+
const sourceFingerprint = await directoryFingerprint(root);
|
|
2298
|
+
await cp(root, backup, { recursive: true, force: false, errorOnExist: true });
|
|
2299
|
+
if ((await directoryFingerprint(backup)) !== sourceFingerprint) {
|
|
2300
|
+
throw new Error("Mikie update package snapshot verification failed");
|
|
2301
|
+
}
|
|
2302
|
+
installation.push({
|
|
2303
|
+
root,
|
|
2304
|
+
backup,
|
|
2305
|
+
sourceFingerprint,
|
|
2306
|
+
expectedFingerprint: null,
|
|
2307
|
+
activationStaging: path.join(
|
|
2308
|
+
path.dirname(root),
|
|
2309
|
+
`.${path.basename(root)}.mikie-candidate-${lease.token}`,
|
|
2310
|
+
),
|
|
2311
|
+
priorQuarantine: path.join(
|
|
2312
|
+
path.dirname(root),
|
|
2313
|
+
`.${path.basename(root)}.mikie-prior-${lease.token}`,
|
|
2314
|
+
),
|
|
2315
|
+
activationState: null,
|
|
2316
|
+
restoreState: null,
|
|
2317
|
+
});
|
|
2318
|
+
}
|
|
2319
|
+
const shims = [];
|
|
2320
|
+
for (const [index, shimPath] of currentShims.entries()) {
|
|
2321
|
+
await assertNoLinkedAncestors(shimPath);
|
|
2322
|
+
const source = await readOptionalFile(shimPath);
|
|
2323
|
+
const metadata = source.existed ? await stat(shimPath) : null;
|
|
2324
|
+
if (metadata && (!metadata.isFile() || metadata.size > MAX_COMMAND_OUTPUT_BYTES)) {
|
|
2325
|
+
throw new Error("unsafe Mikie global shim");
|
|
2326
|
+
}
|
|
2327
|
+
const backup = source.existed ? path.join(transactionRoot, `shim-${index}`) : null;
|
|
2328
|
+
if (source.existed) await atomicWritePrivate(backup, source.bytes);
|
|
2329
|
+
shims.push({
|
|
2330
|
+
path: shimPath,
|
|
2331
|
+
...source,
|
|
2332
|
+
backup,
|
|
2333
|
+
mode: metadata ? metadata.mode & 0o777 : 0o600,
|
|
2334
|
+
sourceFingerprint: bytesFingerprint(source.existed, source.bytes),
|
|
2335
|
+
expectedFingerprint: null,
|
|
2336
|
+
activationStaging: path.join(
|
|
2337
|
+
path.dirname(shimPath),
|
|
2338
|
+
`.${path.basename(shimPath)}.mikie-candidate-${lease.token}`,
|
|
2339
|
+
),
|
|
2340
|
+
priorQuarantine: path.join(
|
|
2341
|
+
path.dirname(shimPath),
|
|
2342
|
+
`.${path.basename(shimPath)}.mikie-prior-${lease.token}`,
|
|
2343
|
+
),
|
|
2344
|
+
activationState: null,
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2347
|
+
const providerConfig = [];
|
|
2348
|
+
for (const [index, [name, filePath]] of Object.entries(providerPaths).entries()) {
|
|
2349
|
+
await assertNoLinkedAncestors(filePath);
|
|
2350
|
+
const source = await readOptionalFile(filePath);
|
|
2351
|
+
const backup = source.existed ? path.join(transactionRoot, `provider-${index}`) : null;
|
|
2352
|
+
if (source.existed) await atomicWritePrivate(backup, source.bytes);
|
|
2353
|
+
providerConfig.push({
|
|
2354
|
+
name,
|
|
2355
|
+
path: filePath,
|
|
2356
|
+
...source,
|
|
2357
|
+
backup,
|
|
2358
|
+
sourceFingerprint: bytesFingerprint(source.existed, source.bytes),
|
|
2359
|
+
});
|
|
2360
|
+
}
|
|
2361
|
+
const registration = providerConfig.find(({ name }) => name === "registration");
|
|
2362
|
+
const settings = providerConfig.find(({ name }) => name === "settings");
|
|
2363
|
+
const snapshot = {
|
|
2364
|
+
backupRoot: transactionRoot,
|
|
2365
|
+
lease,
|
|
2366
|
+
autoLease,
|
|
2367
|
+
phase: "prepared",
|
|
2368
|
+
stagingPrefix: path.join(transactionRoot, "candidate-prefix"),
|
|
2369
|
+
preparedCandidate: null,
|
|
2370
|
+
recovery: null,
|
|
2371
|
+
installation,
|
|
2372
|
+
shims,
|
|
2373
|
+
providerConfig,
|
|
2374
|
+
foreign: {
|
|
2375
|
+
registration: foreignRegistration(parseOptionalJson(registration)),
|
|
2376
|
+
settings: foreignSettings(parseOptionalJson(settings), currentLayout.binary),
|
|
2377
|
+
},
|
|
2378
|
+
foreignOwnedV1: {
|
|
2379
|
+
registration: foreignRegistration(parseOptionalJson(registration)),
|
|
2380
|
+
settings: foreignSettings(parseOptionalJson(settings), currentLayout.binary, {
|
|
2381
|
+
removeOwnedV1: true,
|
|
2382
|
+
}),
|
|
2383
|
+
},
|
|
2384
|
+
};
|
|
2385
|
+
await writeJournal(snapshot);
|
|
2386
|
+
return snapshot;
|
|
2387
|
+
} catch (error) {
|
|
2388
|
+
await rm(transactionRoot, { recursive: true, force: true });
|
|
2389
|
+
if (autoLease) await releaseLease(lease);
|
|
2390
|
+
throw error;
|
|
2391
|
+
}
|
|
2392
|
+
},
|
|
2393
|
+
async markInstalling(snapshot) {
|
|
2394
|
+
snapshot.phase = "installing";
|
|
2395
|
+
await writeJournal(snapshot);
|
|
2396
|
+
},
|
|
2397
|
+
stagingPrefix(snapshot) {
|
|
2398
|
+
if (!snapshot || !isPathInside(transactionRoot, snapshot.stagingPrefix)) {
|
|
2399
|
+
throw new Error("unsafe Mikie update staging prefix");
|
|
2400
|
+
}
|
|
2401
|
+
return snapshot.stagingPrefix;
|
|
2402
|
+
},
|
|
2403
|
+
finalLauncher() {
|
|
2404
|
+
return currentLayout.binary;
|
|
2405
|
+
},
|
|
2406
|
+
async recordPreparedCandidate(snapshot, candidate, finalLauncher) {
|
|
2407
|
+
const candidateRoots = validateInstallationLayout(candidate);
|
|
2408
|
+
if (
|
|
2409
|
+
!isPathInside(snapshot.stagingPrefix, candidateRoots.wrapperRoot) ||
|
|
2410
|
+
!isPathInside(snapshot.stagingPrefix, candidateRoots.platformRoot) ||
|
|
2411
|
+
!isPathInside(snapshot.stagingPrefix, candidate.binary) ||
|
|
2412
|
+
candidateRoots.shims.length !== currentShims.length ||
|
|
2413
|
+
candidateRoots.shims.some(
|
|
2414
|
+
(shim, index) =>
|
|
2415
|
+
!isPathInside(snapshot.stagingPrefix, shim) ||
|
|
2416
|
+
path.basename(shim).toLowerCase() !== path.basename(currentShims[index]).toLowerCase(),
|
|
2417
|
+
) ||
|
|
2418
|
+
!sameExecutablePath(finalLauncher, currentLayout.binary)
|
|
2419
|
+
) {
|
|
2420
|
+
throw new Error("unsafe Mikie staged candidate");
|
|
2421
|
+
}
|
|
2422
|
+
const [wrapperFingerprint, platformFingerprint] = await Promise.all([
|
|
2423
|
+
directoryFingerprint(candidateRoots.wrapperRoot),
|
|
2424
|
+
directoryFingerprint(candidateRoots.platformRoot),
|
|
2425
|
+
]);
|
|
2426
|
+
const shimFingerprints = await Promise.all(
|
|
2427
|
+
candidateRoots.shims.map(async (shim) => {
|
|
2428
|
+
const current = await readOptionalFile(shim);
|
|
2429
|
+
if (!current.existed || current.bytes.length > MAX_COMMAND_OUTPUT_BYTES) {
|
|
2430
|
+
throw new Error("incomplete Mikie staged candidate");
|
|
2431
|
+
}
|
|
2432
|
+
return { path: shim, fingerprint: bytesFingerprint(true, current.bytes) };
|
|
2433
|
+
}),
|
|
2434
|
+
);
|
|
2435
|
+
if ([wrapperFingerprint, platformFingerprint].includes("absent")) {
|
|
2436
|
+
throw new Error("incomplete Mikie staged candidate");
|
|
2437
|
+
}
|
|
2438
|
+
snapshot.preparedCandidate = {
|
|
2439
|
+
...candidate,
|
|
2440
|
+
wrapperFingerprint,
|
|
2441
|
+
platformFingerprint,
|
|
2442
|
+
shims: candidateRoots.shims,
|
|
2443
|
+
shimFingerprints,
|
|
2444
|
+
finalLauncher,
|
|
2445
|
+
};
|
|
2446
|
+
const runtime = path.join(transactionRoot, "recovery-runtime.mjs");
|
|
2447
|
+
const launcher = path.join(transactionRoot, "recover.mjs");
|
|
2448
|
+
const descriptor = path.join(transactionRoot, "recovery.json");
|
|
2449
|
+
const runtimeBytes = await readFile(fileURLToPath(import.meta.url));
|
|
2450
|
+
if (runtimeBytes.length > MAX_RECOVERY_ARTIFACT_BYTES) {
|
|
2451
|
+
throw new Error("Mikie update recovery runtime is too large");
|
|
2452
|
+
}
|
|
2453
|
+
const launcherBytes = Buffer.from(RECOVERY_LAUNCHER_SOURCE);
|
|
2454
|
+
await atomicWritePrivate(runtime, runtimeBytes);
|
|
2455
|
+
await atomicWritePrivate(launcher, launcherBytes);
|
|
2456
|
+
const recoveryDescriptor = {
|
|
2457
|
+
schema_version: RECOVERY_SCHEMA,
|
|
2458
|
+
token: snapshot.lease.token,
|
|
2459
|
+
transaction_root: transactionRoot,
|
|
2460
|
+
current_layout: currentLayout,
|
|
2461
|
+
provider_paths: providerPaths,
|
|
2462
|
+
runtime,
|
|
2463
|
+
runtime_sha256: sha256Bytes(runtimeBytes),
|
|
2464
|
+
launcher,
|
|
2465
|
+
launcher_sha256: sha256Bytes(launcherBytes),
|
|
2466
|
+
node: process.execPath,
|
|
2467
|
+
candidate_binary: candidate.binary,
|
|
2468
|
+
final_launcher: finalLauncher,
|
|
2469
|
+
};
|
|
2470
|
+
const descriptorBytes = Buffer.from(`${JSON.stringify(recoveryDescriptor)}\n`);
|
|
2471
|
+
await atomicWritePrivate(descriptor, descriptorBytes);
|
|
2472
|
+
snapshot.recovery = {
|
|
2473
|
+
descriptor,
|
|
2474
|
+
descriptorSha256: sha256Bytes(descriptorBytes),
|
|
2475
|
+
launcher,
|
|
2476
|
+
launcherSha256: recoveryDescriptor.launcher_sha256,
|
|
2477
|
+
runtime,
|
|
2478
|
+
runtimeSha256: recoveryDescriptor.runtime_sha256,
|
|
2479
|
+
node: process.execPath,
|
|
2480
|
+
command: authenticatedRecoveryInvocation(
|
|
2481
|
+
transactionRoot,
|
|
2482
|
+
sha256Bytes(descriptorBytes),
|
|
2483
|
+
).command,
|
|
2484
|
+
};
|
|
2485
|
+
snapshot.phase = "candidate_staged";
|
|
2486
|
+
await writeJournal(snapshot);
|
|
2487
|
+
return snapshot.recovery.command;
|
|
2488
|
+
},
|
|
2489
|
+
async checkpointCandidate(snapshot) {
|
|
2490
|
+
for (const entry of snapshot.installation) {
|
|
2491
|
+
if (
|
|
2492
|
+
!entry.expectedFingerprint ||
|
|
2493
|
+
(await directoryFingerprint(entry.root)) !== entry.expectedFingerprint
|
|
2494
|
+
) {
|
|
2495
|
+
throw new Error("package changed during Mikie activation");
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
for (const entry of snapshot.shims) {
|
|
2499
|
+
const current = await readOptionalFile(entry.path);
|
|
2500
|
+
if (
|
|
2501
|
+
!entry.expectedFingerprint ||
|
|
2502
|
+
bytesFingerprint(current.existed, current.bytes) !== entry.expectedFingerprint
|
|
2503
|
+
) {
|
|
2504
|
+
throw new Error("global shim changed during Mikie activation");
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
snapshot.phase = "candidate_packages";
|
|
2508
|
+
await writeJournal(snapshot);
|
|
2509
|
+
},
|
|
2510
|
+
async markProviderPreparing(snapshot) {
|
|
2511
|
+
snapshot.phase = "provider_preparing";
|
|
2512
|
+
await writeJournal(snapshot);
|
|
2513
|
+
},
|
|
2514
|
+
async markProviderRolledBack(snapshot) {
|
|
2515
|
+
snapshot.phase = "provider_rolled_back";
|
|
2516
|
+
await writeJournal(snapshot);
|
|
2517
|
+
},
|
|
2518
|
+
async prepareActivation(snapshot, candidate) {
|
|
2519
|
+
if (!snapshot?.preparedCandidate || snapshot.preparedCandidate.binary !== candidate?.binary) {
|
|
2520
|
+
throw new Error("staged candidate activation authority is unavailable");
|
|
2521
|
+
}
|
|
2522
|
+
const expectedPackages = new Map([
|
|
2523
|
+
[path.resolve(currentRoots.wrapperRoot), snapshot.preparedCandidate.wrapperFingerprint],
|
|
2524
|
+
[path.resolve(currentRoots.platformRoot), snapshot.preparedCandidate.platformFingerprint],
|
|
2525
|
+
]);
|
|
2526
|
+
for (const entry of snapshot.installation) {
|
|
2527
|
+
const expected = expectedPackages.get(path.resolve(entry.root));
|
|
2528
|
+
if (!expected) throw new Error("staged candidate package authority is incomplete");
|
|
2529
|
+
entry.expectedFingerprint = expected;
|
|
2530
|
+
}
|
|
2531
|
+
for (const [index, entry] of snapshot.shims.entries()) {
|
|
2532
|
+
const prepared = snapshot.preparedCandidate.shimFingerprints[index];
|
|
2533
|
+
if (
|
|
2534
|
+
!prepared ||
|
|
2535
|
+
path.basename(prepared.path).toLowerCase() !== path.basename(entry.path).toLowerCase()
|
|
2536
|
+
) {
|
|
2537
|
+
throw new Error("staged candidate shim authority is incomplete");
|
|
2538
|
+
}
|
|
2539
|
+
entry.expectedFingerprint = prepared.fingerprint;
|
|
2540
|
+
}
|
|
2541
|
+
snapshot.phase = "activation_prepared";
|
|
2542
|
+
await writeJournal(snapshot);
|
|
2543
|
+
},
|
|
2544
|
+
async promotePreparedCandidate(snapshot, candidate) {
|
|
2545
|
+
if (
|
|
2546
|
+
snapshot?.phase !== "activation_prepared" ||
|
|
2547
|
+
!snapshot.preparedCandidate ||
|
|
2548
|
+
!sameExecutablePath(snapshot.preparedCandidate.wrapperPackageJson, candidate?.wrapperPackageJson) ||
|
|
2549
|
+
!sameExecutablePath(
|
|
2550
|
+
snapshot.preparedCandidate.platformPackageJson,
|
|
2551
|
+
candidate?.platformPackageJson,
|
|
2552
|
+
) ||
|
|
2553
|
+
!sameExecutablePath(snapshot.preparedCandidate.binary, candidate?.binary) ||
|
|
2554
|
+
snapshot.preparedCandidate.shims.length !== (candidate?.shims ?? []).length ||
|
|
2555
|
+
snapshot.preparedCandidate.shims.some(
|
|
2556
|
+
(shim, index) => !sameExecutablePath(shim, candidate.shims[index]),
|
|
2557
|
+
)
|
|
2558
|
+
) {
|
|
2559
|
+
throw new Error("staged candidate activation authority is unavailable");
|
|
2560
|
+
}
|
|
2561
|
+
const candidateRoots = validateInstallationLayout(candidate);
|
|
2562
|
+
const packageSources = new Map([
|
|
2563
|
+
[path.resolve(currentRoots.wrapperRoot), candidateRoots.wrapperRoot],
|
|
2564
|
+
[path.resolve(currentRoots.platformRoot), candidateRoots.platformRoot],
|
|
2565
|
+
]);
|
|
2566
|
+
for (const entry of snapshot.installation) {
|
|
2567
|
+
const candidateRoot = packageSources.get(path.resolve(entry.root));
|
|
2568
|
+
if (!candidateRoot || !entry.expectedFingerprint) {
|
|
2569
|
+
throw new Error("staged candidate package authority is incomplete");
|
|
2570
|
+
}
|
|
2571
|
+
await assertNoLinkedAncestors(path.dirname(entry.root));
|
|
2572
|
+
if (
|
|
2573
|
+
(await directoryFingerprint(entry.activationStaging)) !== "absent" ||
|
|
2574
|
+
(await directoryFingerprint(entry.priorQuarantine)) !== "absent"
|
|
2575
|
+
) {
|
|
2576
|
+
throw new Error("Mikie activation staging path is not empty");
|
|
2577
|
+
}
|
|
2578
|
+
entry.activationState = "copying";
|
|
2579
|
+
await writeJournal(snapshot);
|
|
2580
|
+
await activationBoundary?.("copying", entry.root, entry.activationStaging);
|
|
2581
|
+
await cp(candidateRoot, entry.activationStaging, {
|
|
2582
|
+
recursive: true,
|
|
2583
|
+
force: false,
|
|
2584
|
+
errorOnExist: true,
|
|
2585
|
+
});
|
|
2586
|
+
if ((await directoryFingerprint(entry.activationStaging)) !== entry.expectedFingerprint) {
|
|
2587
|
+
throw new Error("staged candidate package copy verification failed");
|
|
2588
|
+
}
|
|
2589
|
+
entry.activationState = "prepared";
|
|
2590
|
+
await writeJournal(snapshot);
|
|
2591
|
+
}
|
|
2592
|
+
for (const [index, entry] of snapshot.shims.entries()) {
|
|
2593
|
+
const preparedShim = snapshot.preparedCandidate.shimFingerprints[index];
|
|
2594
|
+
const candidateState = await readShimState(preparedShim.path);
|
|
2595
|
+
if (
|
|
2596
|
+
!candidateState.existed ||
|
|
2597
|
+
candidateState.fingerprint !== entry.expectedFingerprint
|
|
2598
|
+
) {
|
|
2599
|
+
throw new Error("staged candidate shim authority is incomplete");
|
|
2600
|
+
}
|
|
2601
|
+
if (
|
|
2602
|
+
(await readShimState(entry.activationStaging)).existed ||
|
|
2603
|
+
(await readShimState(entry.priorQuarantine)).existed
|
|
2604
|
+
) {
|
|
2605
|
+
throw new Error("Mikie shim activation staging path is not empty");
|
|
2606
|
+
}
|
|
2607
|
+
entry.activationState = "copying";
|
|
2608
|
+
await writeJournal(snapshot);
|
|
2609
|
+
await activationBoundary?.("shim_copying", entry.path, entry.activationStaging);
|
|
2610
|
+
await atomicCreatePrivate(entry.activationStaging, candidateState.bytes);
|
|
2611
|
+
await chmod(entry.activationStaging, entry.mode);
|
|
2612
|
+
if (
|
|
2613
|
+
(await readShimState(entry.activationStaging)).fingerprint !==
|
|
2614
|
+
entry.expectedFingerprint
|
|
2615
|
+
) {
|
|
2616
|
+
throw new Error("staged candidate shim copy verification failed");
|
|
2617
|
+
}
|
|
2618
|
+
entry.activationState = "prepared";
|
|
2619
|
+
await writeJournal(snapshot);
|
|
2620
|
+
}
|
|
2621
|
+
for (const entry of snapshot.installation) {
|
|
2622
|
+
if ((await directoryFingerprint(entry.root)) !== entry.sourceFingerprint) {
|
|
2623
|
+
throw new Error("package changed before Mikie activation");
|
|
2624
|
+
}
|
|
2625
|
+
entry.activationState = "swapping";
|
|
2626
|
+
await writeJournal(snapshot);
|
|
2627
|
+
await rename(entry.root, entry.priorQuarantine);
|
|
2628
|
+
await activationBoundary?.(
|
|
2629
|
+
"source_renamed",
|
|
2630
|
+
entry.root,
|
|
2631
|
+
entry.activationStaging,
|
|
2632
|
+
entry.priorQuarantine,
|
|
2633
|
+
);
|
|
2634
|
+
const movedFingerprint = await directoryFingerprint(entry.priorQuarantine);
|
|
2635
|
+
if (movedFingerprint !== entry.sourceFingerprint) {
|
|
2636
|
+
entry.activationState = "conflict_detected";
|
|
2637
|
+
await writeJournal(snapshot);
|
|
2638
|
+
if ((await directoryFingerprint(entry.root)) === "absent") {
|
|
2639
|
+
await rename(entry.priorQuarantine, entry.root);
|
|
2640
|
+
if ((await directoryFingerprint(entry.root)) !== movedFingerprint) {
|
|
2641
|
+
throw new Error("package changed while Mikie activation was committing");
|
|
2642
|
+
}
|
|
2643
|
+
entry.activationState = "conflict_restored";
|
|
2644
|
+
await writeJournal(snapshot);
|
|
2645
|
+
}
|
|
2646
|
+
throw new Error("package changed while Mikie activation was committing");
|
|
2647
|
+
}
|
|
2648
|
+
entry.activationState = "source_moved";
|
|
2649
|
+
await writeJournal(snapshot);
|
|
2650
|
+
await activationBoundary?.("source_moved", entry.root);
|
|
2651
|
+
await rename(entry.activationStaging, entry.root);
|
|
2652
|
+
if ((await directoryFingerprint(entry.root)) !== entry.expectedFingerprint) {
|
|
2653
|
+
throw new Error("Mikie package activation verification failed");
|
|
2654
|
+
}
|
|
2655
|
+
entry.activationState = "activated";
|
|
2656
|
+
await writeJournal(snapshot);
|
|
2657
|
+
}
|
|
2658
|
+
for (const entry of snapshot.shims) {
|
|
2659
|
+
const current = await readShimState(entry.path);
|
|
2660
|
+
if (current.fingerprint !== entry.sourceFingerprint) {
|
|
2661
|
+
throw new Error("global shim changed before Mikie activation");
|
|
2662
|
+
}
|
|
2663
|
+
entry.activationState = "swapping";
|
|
2664
|
+
await writeJournal(snapshot);
|
|
2665
|
+
if (entry.existed) {
|
|
2666
|
+
await rename(entry.path, entry.priorQuarantine);
|
|
2667
|
+
await activationBoundary?.(
|
|
2668
|
+
"shim_source_renamed",
|
|
2669
|
+
entry.path,
|
|
2670
|
+
entry.activationStaging,
|
|
2671
|
+
entry.priorQuarantine,
|
|
2672
|
+
);
|
|
2673
|
+
const moved = await readShimState(entry.priorQuarantine);
|
|
2674
|
+
if (moved.fingerprint !== entry.sourceFingerprint) {
|
|
2675
|
+
entry.activationState = "conflict_detected";
|
|
2676
|
+
await writeJournal(snapshot);
|
|
2677
|
+
if (!(await readShimState(entry.path)).existed) {
|
|
2678
|
+
await publishShimWithoutReplacement(
|
|
2679
|
+
entry.priorQuarantine,
|
|
2680
|
+
entry.path,
|
|
2681
|
+
moved.fingerprint,
|
|
2682
|
+
);
|
|
2683
|
+
entry.activationState = "conflict_restored";
|
|
2684
|
+
await writeJournal(snapshot);
|
|
2685
|
+
}
|
|
2686
|
+
throw new Error("global shim changed while Mikie activation was committing");
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
entry.activationState = "source_moved";
|
|
2690
|
+
await writeJournal(snapshot);
|
|
2691
|
+
await publishShimWithoutReplacement(
|
|
2692
|
+
entry.activationStaging,
|
|
2693
|
+
entry.path,
|
|
2694
|
+
entry.expectedFingerprint,
|
|
2695
|
+
() =>
|
|
2696
|
+
activationBoundary?.(
|
|
2697
|
+
"shim_published",
|
|
2698
|
+
entry.path,
|
|
2699
|
+
entry.activationStaging,
|
|
2700
|
+
entry.priorQuarantine,
|
|
2701
|
+
),
|
|
2702
|
+
);
|
|
2703
|
+
await chmod(entry.path, entry.mode);
|
|
2704
|
+
const activated = await readShimState(entry.path);
|
|
2705
|
+
if (activated.fingerprint !== entry.expectedFingerprint) {
|
|
2706
|
+
throw new Error("global shim activation verification failed");
|
|
2707
|
+
}
|
|
2708
|
+
entry.activationState = "activated";
|
|
2709
|
+
await writeJournal(snapshot);
|
|
2710
|
+
}
|
|
2711
|
+
},
|
|
2712
|
+
async decideCommit(snapshot, { settlementRequired = true } = {}) {
|
|
2713
|
+
snapshot.phase = settlementRequired ? "commit_decided" : "commit_decided_no_repair";
|
|
2714
|
+
await writeJournal(snapshot);
|
|
2715
|
+
},
|
|
2716
|
+
async locateCandidate(request) {
|
|
2717
|
+
const candidate = await locateCandidate(request);
|
|
2718
|
+
const candidateRoots = validateInstallationLayout(candidate);
|
|
2719
|
+
if (request?.stagingPrefix !== undefined) {
|
|
2720
|
+
const expectedStagingPrefix = path.join(transactionRoot, "candidate-prefix");
|
|
2721
|
+
if (
|
|
2722
|
+
!sameExecutablePath(request.stagingPrefix, expectedStagingPrefix) ||
|
|
2723
|
+
!isPathInside(expectedStagingPrefix, candidateRoots.wrapperRoot) ||
|
|
2724
|
+
!isPathInside(expectedStagingPrefix, candidateRoots.platformRoot) ||
|
|
2725
|
+
candidateRoots.shims.some((shim) => !isPathInside(expectedStagingPrefix, shim))
|
|
2726
|
+
) {
|
|
2727
|
+
throw new Error("unsafe Mikie update filesystem path");
|
|
2728
|
+
}
|
|
2729
|
+
} else if (
|
|
2730
|
+
!sameExecutablePath(candidateRoots.wrapperRoot, currentRoots.wrapperRoot) ||
|
|
2731
|
+
!sameExecutablePath(candidateRoots.platformRoot, currentRoots.platformRoot)
|
|
2732
|
+
) {
|
|
2733
|
+
throw new Error("unsafe Mikie update filesystem path");
|
|
2734
|
+
}
|
|
2735
|
+
return candidate;
|
|
2736
|
+
},
|
|
2737
|
+
async verifyActivatedCandidate(prepared, activated) {
|
|
2738
|
+
const preparedRoots = validateInstallationLayout(prepared);
|
|
2739
|
+
const activatedRoots = validateInstallationLayout(activated);
|
|
2740
|
+
if (preparedRoots.shims.length !== activatedRoots.shims.length) {
|
|
2741
|
+
throw new Error("activated package does not match the staged candidate");
|
|
2742
|
+
}
|
|
2743
|
+
const [preparedWrapper, preparedPlatform, activatedWrapper, activatedPlatform] =
|
|
2744
|
+
await Promise.all([
|
|
2745
|
+
directoryFingerprint(preparedRoots.wrapperRoot),
|
|
2746
|
+
directoryFingerprint(preparedRoots.platformRoot),
|
|
2747
|
+
directoryFingerprint(activatedRoots.wrapperRoot),
|
|
2748
|
+
directoryFingerprint(activatedRoots.platformRoot),
|
|
2749
|
+
]);
|
|
2750
|
+
if (
|
|
2751
|
+
preparedWrapper === "absent" ||
|
|
2752
|
+
preparedPlatform === "absent" ||
|
|
2753
|
+
preparedWrapper !== activatedWrapper ||
|
|
2754
|
+
preparedPlatform !== activatedPlatform
|
|
2755
|
+
) {
|
|
2756
|
+
throw new Error("activated package does not match the staged candidate");
|
|
2757
|
+
}
|
|
2758
|
+
for (const [index, preparedShim] of preparedRoots.shims.entries()) {
|
|
2759
|
+
const [preparedState, activatedState] = await Promise.all([
|
|
2760
|
+
readOptionalFile(preparedShim),
|
|
2761
|
+
readOptionalFile(activatedRoots.shims[index]),
|
|
2762
|
+
]);
|
|
2763
|
+
if (
|
|
2764
|
+
!preparedState.existed ||
|
|
2765
|
+
!activatedState.existed ||
|
|
2766
|
+
!preparedState.bytes.equals(activatedState.bytes)
|
|
2767
|
+
) {
|
|
2768
|
+
throw new Error("activated package does not match the staged candidate");
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
},
|
|
2772
|
+
async readPackageVersion(packageJson) {
|
|
2773
|
+
const metadata = JSON.parse(await readFile(packageJson, "utf8"));
|
|
2774
|
+
return metadata.version;
|
|
2775
|
+
},
|
|
2776
|
+
async activate(candidate) {
|
|
2777
|
+
const metadata = await stat(candidate.binary);
|
|
2778
|
+
if (!metadata.isFile() || metadata.size === 0) {
|
|
2779
|
+
throw new Error("candidate executable is invalid");
|
|
2780
|
+
}
|
|
2781
|
+
},
|
|
2782
|
+
async verifyClaudeV2({ candidate, snapshot, repair }) {
|
|
2783
|
+
if (
|
|
2784
|
+
repair?.action === "refused" &&
|
|
2785
|
+
["foreign_conflict", "unavailable"].includes(repair.before)
|
|
2786
|
+
) {
|
|
2787
|
+
const after = await Promise.all(
|
|
2788
|
+
snapshot.providerConfig.map(async (entry) => ({
|
|
2789
|
+
...entry,
|
|
2790
|
+
...(await readOptionalFile(entry.path)),
|
|
2791
|
+
})),
|
|
2792
|
+
);
|
|
2793
|
+
const preserved = after.every((entry, index) => {
|
|
2794
|
+
const before = snapshot.providerConfig[index];
|
|
2795
|
+
return entry.existed === before.existed && entry.bytes.equals(before.bytes);
|
|
2796
|
+
});
|
|
2797
|
+
return {
|
|
2798
|
+
complete: preserved,
|
|
2799
|
+
foreignPreserved: preserved,
|
|
2800
|
+
foreignSettings: [`claude:${repair.before}`],
|
|
2801
|
+
};
|
|
2802
|
+
}
|
|
2803
|
+
const [registration, settings, ownership] = await Promise.all([
|
|
2804
|
+
readFile(providerPaths.registration).then(parseJsonNoDuplicateKeys),
|
|
2805
|
+
readFile(providerPaths.settings).then(parseJsonNoDuplicateKeys),
|
|
2806
|
+
readFile(providerPaths.ownership).then(parseJsonNoDuplicateKeys),
|
|
2807
|
+
]);
|
|
2808
|
+
return verifyClaudeV2Contract({
|
|
2809
|
+
candidateBinary: candidate.binary,
|
|
2810
|
+
registration,
|
|
2811
|
+
settings,
|
|
2812
|
+
ownership,
|
|
2813
|
+
foreign:
|
|
2814
|
+
repair?.before === "outdated_owned" ? snapshot.foreignOwnedV1 : snapshot.foreign,
|
|
2815
|
+
});
|
|
2816
|
+
},
|
|
2817
|
+
async restore(snapshot) {
|
|
2818
|
+
await restoreSnapshot(snapshot);
|
|
2819
|
+
if (snapshot.autoLease) await releaseLease(snapshot.lease);
|
|
2820
|
+
},
|
|
2821
|
+
async retire(snapshot) {
|
|
2822
|
+
snapshot.phase = "committed";
|
|
2823
|
+
await writeJournal(snapshot);
|
|
2824
|
+
await cleanupCommittedActivation(snapshot);
|
|
2825
|
+
await rm(snapshot.backupRoot, { recursive: true, force: true });
|
|
2826
|
+
if (snapshot.autoLease) await releaseLease(snapshot.lease);
|
|
2827
|
+
},
|
|
2828
|
+
};
|
|
2829
|
+
return adapter;
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
export async function recoverExternalNpmUpdate(transactionRoot, expectedAuthority) {
|
|
2833
|
+
if (typeof transactionRoot !== "string" || !path.isAbsolute(transactionRoot)) {
|
|
2834
|
+
throw new Error("unsafe Mikie update recovery root");
|
|
2835
|
+
}
|
|
2836
|
+
await assertNoLinkedAncestors(transactionRoot);
|
|
2837
|
+
const descriptorPath = path.join(transactionRoot, "recovery.json");
|
|
2838
|
+
const descriptorMetadata = await lstat(descriptorPath);
|
|
2839
|
+
if (
|
|
2840
|
+
!descriptorMetadata.isFile() ||
|
|
2841
|
+
descriptorMetadata.isSymbolicLink() ||
|
|
2842
|
+
descriptorMetadata.size > MAX_RECOVERY_ARTIFACT_BYTES
|
|
2843
|
+
) {
|
|
2844
|
+
throw new Error("invalid Mikie update recovery descriptor");
|
|
2845
|
+
}
|
|
2846
|
+
const descriptorBytes = await readFile(descriptorPath);
|
|
2847
|
+
const descriptor = parseJsonNoDuplicateKeys(descriptorBytes);
|
|
2848
|
+
if (
|
|
2849
|
+
!expectedAuthority ||
|
|
2850
|
+
sha256Bytes(descriptorBytes) !== expectedAuthority.descriptorSha256 ||
|
|
2851
|
+
!hasExactKeys(descriptor, [
|
|
2852
|
+
"schema_version",
|
|
2853
|
+
"token",
|
|
2854
|
+
"transaction_root",
|
|
2855
|
+
"current_layout",
|
|
2856
|
+
"provider_paths",
|
|
2857
|
+
"runtime",
|
|
2858
|
+
"runtime_sha256",
|
|
2859
|
+
"launcher",
|
|
2860
|
+
"launcher_sha256",
|
|
2861
|
+
"node",
|
|
2862
|
+
"candidate_binary",
|
|
2863
|
+
"final_launcher",
|
|
2864
|
+
]) ||
|
|
2865
|
+
descriptor.schema_version !== RECOVERY_SCHEMA ||
|
|
2866
|
+
descriptor.transaction_root !== transactionRoot ||
|
|
2867
|
+
typeof descriptor.token !== "string" ||
|
|
2868
|
+
![descriptor.runtime, descriptor.launcher, descriptor.candidate_binary].every(
|
|
2869
|
+
(artifactPath) =>
|
|
2870
|
+
typeof artifactPath === "string" && isPathInside(transactionRoot, artifactPath),
|
|
2871
|
+
) ||
|
|
2872
|
+
descriptor.final_launcher !== descriptor.current_layout?.binary ||
|
|
2873
|
+
descriptor.node !== process.execPath ||
|
|
2874
|
+
!/^[a-f0-9]{64}$/.test(descriptor.runtime_sha256) ||
|
|
2875
|
+
!/^[a-f0-9]{64}$/.test(descriptor.launcher_sha256) ||
|
|
2876
|
+
descriptor.runtime_sha256 !== expectedAuthority.runtimeSha256 ||
|
|
2877
|
+
descriptor.launcher_sha256 !== expectedAuthority.launcherSha256
|
|
2878
|
+
) {
|
|
2879
|
+
throw new Error("invalid Mikie update recovery descriptor");
|
|
2880
|
+
}
|
|
2881
|
+
const journalPath = path.join(transactionRoot, "journal.json");
|
|
2882
|
+
const journalMetadata = await lstat(journalPath);
|
|
2883
|
+
if (
|
|
2884
|
+
!journalMetadata.isFile() ||
|
|
2885
|
+
journalMetadata.isSymbolicLink() ||
|
|
2886
|
+
journalMetadata.size > MAX_TRANSACTION_JOURNAL_BYTES
|
|
2887
|
+
) {
|
|
2888
|
+
throw new Error("invalid Mikie update transaction journal");
|
|
2889
|
+
}
|
|
2890
|
+
const [runtimeBytes, launcherBytes, journalBytes] = await Promise.all([
|
|
2891
|
+
readFile(descriptor.runtime),
|
|
2892
|
+
readFile(descriptor.launcher),
|
|
2893
|
+
readFile(journalPath),
|
|
2894
|
+
]);
|
|
2895
|
+
if (
|
|
2896
|
+
runtimeBytes.length > MAX_RECOVERY_ARTIFACT_BYTES ||
|
|
2897
|
+
launcherBytes.length > MAX_RECOVERY_ARTIFACT_BYTES ||
|
|
2898
|
+
sha256Bytes(runtimeBytes) !== descriptor.runtime_sha256 ||
|
|
2899
|
+
sha256Bytes(launcherBytes) !== descriptor.launcher_sha256
|
|
2900
|
+
) {
|
|
2901
|
+
throw new Error("tampered Mikie update recovery authority");
|
|
2902
|
+
}
|
|
2903
|
+
const journal = parseJsonNoDuplicateKeys(journalBytes);
|
|
2904
|
+
if (journal?.token !== descriptor.token) {
|
|
2905
|
+
throw new Error("Mikie update recovery authority does not match the journal");
|
|
2906
|
+
}
|
|
2907
|
+
const commands = createCommandAdapter();
|
|
2908
|
+
const files = createFilesystemAdapter({
|
|
2909
|
+
currentLayout: descriptor.current_layout,
|
|
2910
|
+
locateCandidate: async () => {
|
|
2911
|
+
throw new Error("candidate lookup is unavailable during recovery");
|
|
2912
|
+
},
|
|
2913
|
+
providerPaths: descriptor.provider_paths,
|
|
2914
|
+
transactionRoot,
|
|
2915
|
+
settleProviderRepair: async (phase, snapshot) => {
|
|
2916
|
+
if (
|
|
2917
|
+
snapshot?.preparedCandidate?.binary !== descriptor.candidate_binary ||
|
|
2918
|
+
snapshot?.preparedCandidate?.finalLauncher !== descriptor.final_launcher
|
|
2919
|
+
) {
|
|
2920
|
+
throw new Error("prepared candidate recovery authority changed");
|
|
2921
|
+
}
|
|
2922
|
+
const settlement = await commands.run(
|
|
2923
|
+
descriptor.candidate_binary,
|
|
2924
|
+
["repair-claude-hooks-v1", "--json"],
|
|
2925
|
+
{
|
|
2926
|
+
shell: false,
|
|
2927
|
+
capture: true,
|
|
2928
|
+
environment: {
|
|
2929
|
+
MIKIE_DISTRIBUTION_MODE: "npm",
|
|
2930
|
+
MIKIE_NPM_UPDATE_TOPOLOGY: UpdateTopology.NpmGlobal,
|
|
2931
|
+
MIKIE_NPM_UPDATE_REPAIR_PHASE: phase,
|
|
2932
|
+
MIKIE_UPDATE_REPAIR_CANDIDATE: descriptor.candidate_binary,
|
|
2933
|
+
MIKIE_UPDATE_REPAIR_LAUNCHER: descriptor.final_launcher,
|
|
2934
|
+
},
|
|
2935
|
+
},
|
|
2936
|
+
);
|
|
2937
|
+
if (settlement.code !== 0) {
|
|
2938
|
+
throw new Error("candidate owned-adapter settlement failed");
|
|
2939
|
+
}
|
|
2940
|
+
},
|
|
2941
|
+
});
|
|
2942
|
+
const lease = await files.beginTransaction();
|
|
2943
|
+
await files.endTransaction(lease);
|
|
2944
|
+
}
|