@evomap/evolver-proxy 2.0.2 → 2.0.8
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/dist/bin/evolver-proxy.d.ts +12 -0
- package/dist/bin/evolver-proxy.js +192 -59
- package/dist/daemon/proxyDaemon.js +10 -0
- package/dist/daemon/systemdNotifier.d.ts +2 -0
- package/dist/daemon/systemdNotifier.js +11 -1
- package/dist/lifecycle/claimNudge.js +1 -1
- package/dist/lifecycle/deployGuard.js +1 -1
- package/dist/lifecycle/legacyNodeId.js +1 -1
- package/dist/lifecycle/manager.js +1 -1
- package/dist/llm/bodyCapture.js +1 -1
- package/dist/llm/index.js +1 -1
- package/dist/llm/server.js +1 -1
- package/dist/llm/traceBackfill.js +1 -1
- package/dist/llm/traceConfig.js +1 -1
- package/dist/llm/traceControl.js +1 -1
- package/dist/llm/traceEnvelope.js +1 -1
- package/dist/llm/traceSink.js +1 -1
- package/dist/llm/traceUploadPayload.js +1 -1
- package/dist/llm/upstream.js +1 -1
- package/dist/router/cachePassthrough.js +1 -1
- package/dist/router/features.js +1 -1
- package/dist/router/index.js +1 -1
- package/dist/router/messagesRoute.js +1 -1
- package/dist/router/modelRouter.js +1 -1
- package/dist/router/providerRoutes.js +1 -1
- package/dist/router/sseScan.js +1 -1
- package/dist/selfUpdate/bootstrap.d.ts +106 -13
- package/dist/selfUpdate/bootstrap.js +3418 -176
- package/dist/selfUpdate/bootstrapReadiness.d.ts +9 -0
- package/dist/selfUpdate/bootstrapReadiness.js +153 -0
- package/dist/selfUpdate/controllerLifecycleAuthority.d.ts +45 -0
- package/dist/selfUpdate/controllerLifecycleAuthority.js +61 -0
- package/dist/selfUpdate/executor.d.ts +17 -6
- package/dist/selfUpdate/executor.js +158 -58
- package/dist/selfUpdate/index.d.ts +2 -1
- package/dist/selfUpdate/index.js +2 -1
- package/dist/selfUpdate/migration.d.ts +80 -15
- package/dist/selfUpdate/migration.js +2513 -156
- package/dist/selfUpdate/policy.js +2 -8
- package/dist/selfUpdate/recoveryChildStartGate.d.ts +29 -0
- package/dist/selfUpdate/recoveryChildStartGate.js +319 -0
- package/dist/selfUpdate/releaseBinary.d.ts +3 -0
- package/dist/selfUpdate/releaseBinary.js +46 -3
- package/dist/selfUpdate/transaction.d.ts +8 -0
- package/dist/selfUpdate/transaction.js +166 -18
- package/dist/selfUpdate/unixController.d.ts +8 -0
- package/dist/selfUpdate/unixController.js +364 -38
- package/dist/selfUpdate/windowsController.d.ts +14 -2
- package/dist/selfUpdate/windowsController.js +482 -103
- package/dist/selfUpdate/windowsUpdater.d.ts +25 -0
- package/dist/selfUpdate/windowsUpdater.js +174 -7
- package/dist/sync/engine.js +1 -1
- package/package.json +3 -3
|
@@ -1,43 +1,97 @@
|
|
|
1
1
|
// First-run supervision bootstrap for the DEFAULT self-update policy.
|
|
2
2
|
//
|
|
3
3
|
// Default auto self-update degrades to 'off' without a durable supervisor attestation (policy.ts).
|
|
4
|
-
//
|
|
4
|
+
// For a standalone release binary, an unsupervised foreground startup may once
|
|
5
5
|
// register its own user-level durable launcher (`evolver lifecycle bootstrap`) and hand over to it:
|
|
6
6
|
// the generated launcher carries the EVOLVER_SELF_UPDATE_SUPERVISOR attestation, so the next
|
|
7
7
|
// supervised startup runs auto self-update with the unchanged signature/health-check/rollback gates.
|
|
8
8
|
//
|
|
9
9
|
// Bootstrap is a convenience, never an escalation: it is skipped for attested runs, explicit
|
|
10
10
|
// non-auto policies, the EVOLVER_SELF_BOOTSTRAP kill switch, CI, containers, and within a
|
|
11
|
-
// cooldown window after a failed attempt. The npm/JS install shape
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
import { spawn } from 'node:child_process';
|
|
11
|
+
// cooldown window after a safe failed attempt. The npm/JS install shape first attempts the
|
|
12
|
+
// signed standalone migration from migration.ts. Foreground startup continues only when no
|
|
13
|
+
// registration child was created or a clean rollback and process-tree termination are proven;
|
|
14
|
+
// all ambiguous child ownership fails closed.
|
|
15
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
16
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
16
17
|
import { createRequire } from 'node:module';
|
|
17
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { closeSync, constants, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, renameSync, rmSync, writeFileSync, writeSync, } from 'node:fs';
|
|
18
19
|
import { homedir } from 'node:os';
|
|
19
|
-
import { basename, dirname, join, resolve as resolvePath } from 'node:path';
|
|
20
|
+
import { basename, dirname, join, parse as parsePath, posix, resolve as resolvePath, win32, } from 'node:path';
|
|
20
21
|
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { bootstrap as coreBootstrap, util } from '@evomap/evolver-core';
|
|
21
23
|
import { isSelfUpdateExplicit, resolveSelfUpdatePolicy, selfUpdateSupervisorAttested, } from './policy.js';
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import { migrateToStandaloneBinary } from './migration.js';
|
|
24
|
+
import { isStandaloneReleaseBinaryName, resolveSelfUpdateTarget, } from './releaseBinary.js';
|
|
25
|
+
import { expandHomePath, parseEnvFile } from '../bin/envFile.js';
|
|
26
|
+
import { migrateToStandaloneBinary, } from './migration.js';
|
|
25
27
|
const requireFromHere = createRequire(import.meta.url);
|
|
26
|
-
export const BOOTSTRAP_SUCCESS_FILE = 'bootstrap.json';
|
|
27
28
|
const BOOTSTRAP_ATTEMPT_FILE = 'bootstrap-attempt.json';
|
|
29
|
+
const BOOTSTRAP_REGISTRATION_INTENT_FILE = coreBootstrap.LIFECYCLE_BOOTSTRAP_REGISTRATION_INTENT_FILE;
|
|
30
|
+
const BOOTSTRAP_REGISTRATION_INTENT_TERMINAL_FILE = coreBootstrap.LIFECYCLE_BOOTSTRAP_REGISTRATION_INTENT_TERMINAL_FILE;
|
|
31
|
+
const BOOTSTRAP_REGISTRATION_INTENT_CLEARING_FILE = coreBootstrap.LIFECYCLE_BOOTSTRAP_REGISTRATION_INTENT_CLEARING_FILE;
|
|
32
|
+
const BOOTSTRAP_REGISTRATION_INTENT_PUBLISHING_FILE = 'bootstrap-registration.intent.publishing';
|
|
33
|
+
const BOOTSTRAP_REGISTRATION_INTENT_PUBLISHER_PREFIX = 'bootstrap-registration.intent.publisher.';
|
|
34
|
+
const BOOTSTRAP_REGISTRATION_INTENT_SCHEMA = coreBootstrap.LIFECYCLE_BOOTSTRAP_REGISTRATION_INTENT_SCHEMA;
|
|
28
35
|
const BOOTSTRAP_FAILURE_COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
|
29
|
-
// The
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
const
|
|
36
|
+
// The child owns a shorter absolute transaction deadline. The parent waits beyond that deadline
|
|
37
|
+
// so the child can finish its durable rollback, and only then force-terminates the whole process
|
|
38
|
+
// tree if the child failed to honor the contract.
|
|
39
|
+
const BOOTSTRAP_TRANSACTION_BUDGET_MS = 180_000;
|
|
40
|
+
const BOOTSTRAP_PARENT_EXIT_GRACE_MS = 30_000;
|
|
41
|
+
const BOOTSTRAP_TIMEOUT_MS = BOOTSTRAP_TRANSACTION_BUDGET_MS + BOOTSTRAP_PARENT_EXIT_GRACE_MS;
|
|
42
|
+
const BOOTSTRAP_TREE_TERMINATION_GRACE_MS = 5_000;
|
|
43
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
33
44
|
const BOOTSTRAP_ENV_FILE_HANDOFF = 'EVOLVER_INTERNAL_BOOTSTRAP_ENV_FILE';
|
|
45
|
+
export const RECOVERY_CONTROLLER_LIFECYCLE_OWNER_CAPABILITY_ENV = 'EVOLVER_INTERNAL_RECOVERY_CONTROLLER_LIFECYCLE_OWNER';
|
|
46
|
+
const MAX_RECOVERY_CONTROLLER_OWNER_CAPABILITY_BYTES = 1024;
|
|
47
|
+
const MAX_BOOTSTRAP_OUTPUT_BYTES = 64 * 1024;
|
|
48
|
+
const MAX_BOOTSTRAP_STATE_BYTES = 128 * 1024;
|
|
49
|
+
const MAX_BOOTSTRAP_ATTEMPT_BYTES = 4 * 1024;
|
|
50
|
+
const MAX_BOOTSTRAP_REGISTRATION_INTENT_BYTES = coreBootstrap.MAX_LIFECYCLE_BOOTSTRAP_REGISTRATION_INTENT_BYTES;
|
|
51
|
+
const MAX_BOOTSTRAP_DETAIL_LENGTH = 512;
|
|
52
|
+
const HOST_WINDOWS_SYSTEM_ROOT = process.env['SystemRoot']?.trim() || 'C:\\Windows';
|
|
34
53
|
/** Lifecycle state dir mirror of evolver-cli lifecyclePaths (kept dependency-free across packages). */
|
|
35
54
|
export function resolveBootstrapStateDir(env) {
|
|
36
55
|
const explicit = env['EVOLVER_LIFECYCLE_STATE_DIR']?.trim();
|
|
37
56
|
const home = env['EVOLVER_HOME'] ?? env['EVOMAP_HOME'] ?? join(homedir(), '.evomap');
|
|
38
57
|
return resolvePath(explicit || join(home, 'lifecycle'));
|
|
39
58
|
}
|
|
40
|
-
function
|
|
59
|
+
export function withRecoveryControllerLifecycleOwnerCapability(env, owner) {
|
|
60
|
+
return prepareRecoveryControllerLifecycleOwnerCapability(env, owner).env;
|
|
61
|
+
}
|
|
62
|
+
export function prepareRecoveryControllerLifecycleOwnerCapability(env, owner) {
|
|
63
|
+
const startupAckToken = randomUUID();
|
|
64
|
+
return {
|
|
65
|
+
env: {
|
|
66
|
+
...env,
|
|
67
|
+
[RECOVERY_CONTROLLER_LIFECYCLE_OWNER_CAPABILITY_ENV]: JSON.stringify({
|
|
68
|
+
v: 1,
|
|
69
|
+
pid: owner.pid,
|
|
70
|
+
token: owner.token,
|
|
71
|
+
processStartIdentity: owner.processStartIdentity,
|
|
72
|
+
startupAckToken,
|
|
73
|
+
}),
|
|
74
|
+
},
|
|
75
|
+
startupAckToken,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export function clearRecoveryControllerLifecycleOwnerCapability(env) {
|
|
79
|
+
delete env[RECOVERY_CONTROLLER_LIFECYCLE_OWNER_CAPABILITY_ENV];
|
|
80
|
+
}
|
|
81
|
+
export function publishRecoveryControllerLifecycleStartupAttestation(env, descriptor = 3) {
|
|
82
|
+
if (env[RECOVERY_CONTROLLER_LIFECYCLE_OWNER_CAPABILITY_ENV] === undefined)
|
|
83
|
+
return false;
|
|
84
|
+
const capability = recoveryControllerLifecycleOwnerCapability(env);
|
|
85
|
+
if (!capability) {
|
|
86
|
+
throw new Error('self_update_recovery_controller_lifecycle_capability_invalid');
|
|
87
|
+
}
|
|
88
|
+
assertRecoveryControllerLifecycleOwnerLease(env, capability.owner);
|
|
89
|
+
writeSync(descriptor, `${capability.startupAckToken}\n`, undefined, 'utf8');
|
|
90
|
+
closeSync(descriptor);
|
|
91
|
+
clearRecoveryControllerLifecycleOwnerCapability(env);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
function bootstrapChildEnv(env, transactionDeadlineMs) {
|
|
41
95
|
const childEnv = { ...env };
|
|
42
96
|
const envFile = env['EVOLVER_ENV_FILE']?.trim();
|
|
43
97
|
delete childEnv['EVOLVER_ENV_FILE'];
|
|
@@ -47,178 +101,3195 @@ function bootstrapChildEnv(env) {
|
|
|
47
101
|
// Resolve while the foreground proxy still owns cwd, then carry that identity through the
|
|
48
102
|
// bootstrap child and generated service launcher. Service managers do not share one cwd.
|
|
49
103
|
childEnv['EVOLVER_LIFECYCLE_STATE_DIR'] = resolveBootstrapStateDir(env);
|
|
104
|
+
childEnv[coreBootstrap.LIFECYCLE_BOOTSTRAP_DEADLINE_ENV] = String(transactionDeadlineMs);
|
|
50
105
|
return childEnv;
|
|
51
106
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
107
|
+
export function lifecycleBootstrapStatePresent(env, exists = bootstrapStateEntryPresent) {
|
|
108
|
+
const stateDir = resolveBootstrapStateDir(env);
|
|
109
|
+
return [
|
|
110
|
+
coreBootstrap.LIFECYCLE_BOOTSTRAP_SUCCESS_FILE,
|
|
111
|
+
coreBootstrap.LIFECYCLE_BOOTSTRAP_JOURNAL_FILE,
|
|
112
|
+
coreBootstrap.LIFECYCLE_BOOTSTRAP_OWNER_LOCK_FILE,
|
|
113
|
+
coreBootstrap.LIFECYCLE_BOOTSTRAP_READINESS_FILE,
|
|
114
|
+
coreBootstrap.LIFECYCLE_BOOTSTRAP_READINESS_LOCK_FILE,
|
|
115
|
+
coreBootstrap.LIFECYCLE_BOOTSTRAP_MANUAL_TRANSITION_FILE,
|
|
116
|
+
].some((name) => exists(join(stateDir, name)));
|
|
117
|
+
}
|
|
118
|
+
function bootstrapStateEntryPresent(path) {
|
|
119
|
+
try {
|
|
120
|
+
lstatSync(path);
|
|
55
121
|
return true;
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
return !isErrno(error, 'ENOENT');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const defaultReadTextFile = (path) => readFileSync(path, 'utf8');
|
|
128
|
+
const BOOTSTRAP_JOURNAL_SCHEMA = 'evolver.lifecycle-bootstrap-transaction.v1';
|
|
129
|
+
const BOOTSTRAP_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
130
|
+
const BOOTSTRAP_JOURNAL_STAGES = new Set([
|
|
131
|
+
'prepared', 'installing', 'installed', 'activating', 'activated', 'committing',
|
|
132
|
+
'committed', 'rollback_pending', 'rolled_back',
|
|
133
|
+
]);
|
|
134
|
+
function boundedBootstrapDetail(value) {
|
|
135
|
+
let normalized = '';
|
|
136
|
+
let previousWasSpace = false;
|
|
137
|
+
for (const character of value) {
|
|
138
|
+
const next = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(character) ? ' ' : character;
|
|
139
|
+
if (next === ' ' && previousWasSpace)
|
|
140
|
+
continue;
|
|
141
|
+
normalized += next;
|
|
142
|
+
previousWasSpace = next === ' ';
|
|
143
|
+
if (normalized.length >= MAX_BOOTSTRAP_DETAIL_LENGTH)
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
return normalized.slice(0, MAX_BOOTSTRAP_DETAIL_LENGTH);
|
|
147
|
+
}
|
|
148
|
+
function readBoundedBootstrapStateFile(path, options, maxBytes = MAX_BOOTSTRAP_STATE_BYTES) {
|
|
149
|
+
const injected = options.exists !== undefined || options.readFile !== undefined;
|
|
150
|
+
try {
|
|
151
|
+
if (injected) {
|
|
152
|
+
if (!(options.exists ?? existsSync)(path))
|
|
153
|
+
return { status: 'absent' };
|
|
154
|
+
const raw = (options.readFile ?? defaultReadTextFile)(path);
|
|
155
|
+
if (Buffer.byteLength(raw, 'utf8') > maxBytes) {
|
|
156
|
+
return { status: 'invalid', detail: `oversized durable state: ${basename(path)}` };
|
|
157
|
+
}
|
|
158
|
+
return { status: 'present', raw };
|
|
159
|
+
}
|
|
160
|
+
const before = lstatSync(path, { bigint: true });
|
|
161
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n
|
|
162
|
+
|| before.size > BigInt(maxBytes)
|
|
163
|
+
|| before.dev <= 0n || before.ino <= 0n) {
|
|
164
|
+
return { status: 'invalid', detail: `unsafe durable state: ${basename(path)}` };
|
|
165
|
+
}
|
|
166
|
+
(options.assertIntentFileTrust ?? assertNativeBootstrapIntentFileTrust)(path);
|
|
167
|
+
const descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
168
|
+
try {
|
|
169
|
+
const opened = fstatSync(descriptor, { bigint: true });
|
|
170
|
+
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino
|
|
171
|
+
|| opened.size !== before.size) {
|
|
172
|
+
return { status: 'invalid', detail: `changed durable state: ${basename(path)}` };
|
|
173
|
+
}
|
|
174
|
+
const bytes = Buffer.alloc(maxBytes + 1);
|
|
175
|
+
let offset = 0;
|
|
176
|
+
while (offset < bytes.length) {
|
|
177
|
+
const count = readSync(descriptor, bytes, offset, bytes.length - offset, null);
|
|
178
|
+
if (count === 0)
|
|
179
|
+
break;
|
|
180
|
+
offset += count;
|
|
181
|
+
}
|
|
182
|
+
if (offset > maxBytes) {
|
|
183
|
+
return { status: 'invalid', detail: `oversized durable state: ${basename(path)}` };
|
|
184
|
+
}
|
|
185
|
+
const raw = bytes.subarray(0, offset).toString('utf8');
|
|
186
|
+
const after = fstatSync(descriptor, { bigint: true });
|
|
187
|
+
if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size
|
|
188
|
+
|| after.mtimeNs !== opened.mtimeNs || after.ctimeNs !== opened.ctimeNs) {
|
|
189
|
+
return { status: 'invalid', detail: `changed durable state: ${basename(path)}` };
|
|
190
|
+
}
|
|
191
|
+
(options.assertIntentFileTrust ?? assertNativeBootstrapIntentFileTrust)(path);
|
|
192
|
+
const settled = lstatSync(path, { bigint: true });
|
|
193
|
+
if (!settled.isFile() || settled.isSymbolicLink() || settled.nlink !== 1n
|
|
194
|
+
|| settled.dev !== opened.dev || settled.ino !== opened.ino || settled.size !== opened.size
|
|
195
|
+
|| settled.mtimeNs !== opened.mtimeNs || settled.ctimeNs !== opened.ctimeNs) {
|
|
196
|
+
return { status: 'invalid', detail: `changed durable state: ${basename(path)}` };
|
|
197
|
+
}
|
|
198
|
+
return { status: 'present', raw };
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
closeSync(descriptor);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
if (isErrno(error, 'ENOENT'))
|
|
206
|
+
return { status: 'absent' };
|
|
207
|
+
return {
|
|
208
|
+
status: 'invalid',
|
|
209
|
+
detail: boundedBootstrapDetail(`unreadable durable state ${basename(path)}: ${error instanceof Error ? error.message : String(error)}`),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function sameBootstrapStateFileSnapshot(left, right) {
|
|
214
|
+
if (left.status !== right.status)
|
|
215
|
+
return false;
|
|
216
|
+
if (left.status === 'present' && right.status === 'present') {
|
|
217
|
+
return left.raw === right.raw;
|
|
218
|
+
}
|
|
219
|
+
return left.status === 'absent' && right.status === 'absent';
|
|
220
|
+
}
|
|
221
|
+
function legacyBootstrapPathKey(path, target) {
|
|
222
|
+
return target === 'windows'
|
|
223
|
+
? win32.normalize(path).toLowerCase()
|
|
224
|
+
: posix.normalize(path);
|
|
225
|
+
}
|
|
226
|
+
function assertNativeBootstrapLegacyProofFileTrust(path) {
|
|
227
|
+
const info = lstatSync(path);
|
|
228
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink < 1) {
|
|
229
|
+
throw new Error('legacy bootstrap proof file is unsafe');
|
|
230
|
+
}
|
|
231
|
+
if (process.platform === 'win32') {
|
|
232
|
+
assertWindowsBootstrapIntentAclTrusted([{ path, parentOnly: false }]);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
|
236
|
+
if (uid === undefined
|
|
237
|
+
|| (info.uid !== uid && info.uid !== 0)
|
|
238
|
+
|| (info.mode & 0o022) !== 0) {
|
|
239
|
+
throw new Error('legacy bootstrap proof file is not trusted');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function readLegacyBootstrapProofFile(receipt, options) {
|
|
243
|
+
if (receipt.size < 1 || receipt.size > MAX_BOOTSTRAP_STATE_BYTES) {
|
|
244
|
+
throw new Error('legacy bootstrap proof receipt has an invalid size');
|
|
245
|
+
}
|
|
246
|
+
const injected = options.exists !== undefined || options.readFile !== undefined;
|
|
247
|
+
const exists = options.exists ?? existsSync;
|
|
248
|
+
const readFile = options.readFile ?? defaultReadTextFile;
|
|
249
|
+
if (injected) {
|
|
250
|
+
if (!exists(receipt.path))
|
|
251
|
+
throw new Error('legacy bootstrap proof file is missing');
|
|
252
|
+
const first = readFile(receipt.path);
|
|
253
|
+
options.afterLegacyProofRead?.(receipt.path);
|
|
254
|
+
const second = readFile(receipt.path);
|
|
255
|
+
const bytes = Buffer.from(first, 'utf8');
|
|
256
|
+
if (first !== second
|
|
257
|
+
|| bytes.length !== receipt.size
|
|
258
|
+
|| createHash('sha256').update(bytes).digest('hex') !== receipt.sha256) {
|
|
259
|
+
throw new Error('legacy bootstrap proof file changed after adoption');
|
|
260
|
+
}
|
|
261
|
+
return first;
|
|
262
|
+
}
|
|
263
|
+
const assertDirectoryTrust = options.assertLegacyProofDirectoryTrust
|
|
264
|
+
?? ((directory) => assertNativeBootstrapIntentDirectoryTrust(directory, false));
|
|
265
|
+
const assertFileTrust = options.assertLegacyProofFileTrust
|
|
266
|
+
?? assertNativeBootstrapLegacyProofFileTrust;
|
|
267
|
+
const directory = dirname(receipt.path);
|
|
268
|
+
assertDirectoryTrust(directory);
|
|
269
|
+
assertFileTrust(receipt.path);
|
|
270
|
+
const before = lstatSync(receipt.path, { bigint: true });
|
|
271
|
+
if (!before.isFile() || before.isSymbolicLink()
|
|
272
|
+
|| before.dev <= 0n || before.ino <= 0n
|
|
273
|
+
|| before.size !== BigInt(receipt.size)
|
|
274
|
+
|| before.dev !== BigInt(receipt.device ?? '0')
|
|
275
|
+
|| before.ino !== BigInt(receipt.inode ?? '0')) {
|
|
276
|
+
throw new Error('legacy bootstrap proof identity changed after adoption');
|
|
277
|
+
}
|
|
278
|
+
const descriptor = openSync(receipt.path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
279
|
+
try {
|
|
280
|
+
const opened = fstatSync(descriptor, { bigint: true });
|
|
281
|
+
if (!opened.isFile()
|
|
282
|
+
|| opened.dev !== before.dev || opened.ino !== before.ino
|
|
283
|
+
|| opened.size !== before.size
|
|
284
|
+
|| opened.mtimeNs !== before.mtimeNs || opened.ctimeNs !== before.ctimeNs) {
|
|
285
|
+
throw new Error('legacy bootstrap proof changed while opening');
|
|
286
|
+
}
|
|
287
|
+
const readPass = () => {
|
|
288
|
+
const bytes = Buffer.alloc(receipt.size + 1);
|
|
289
|
+
let offset = 0;
|
|
290
|
+
while (offset < bytes.length) {
|
|
291
|
+
const count = readSync(descriptor, bytes, offset, bytes.length - offset, offset);
|
|
292
|
+
if (count === 0)
|
|
293
|
+
break;
|
|
294
|
+
offset += count;
|
|
295
|
+
}
|
|
296
|
+
return bytes.subarray(0, offset);
|
|
297
|
+
};
|
|
298
|
+
const first = readPass();
|
|
299
|
+
options.afterLegacyProofRead?.(receipt.path);
|
|
300
|
+
const second = readPass();
|
|
301
|
+
const after = fstatSync(descriptor, { bigint: true });
|
|
302
|
+
if (!first.equals(second)
|
|
303
|
+
|| first.length !== receipt.size
|
|
304
|
+
|| createHash('sha256').update(first).digest('hex') !== receipt.sha256
|
|
305
|
+
|| after.dev !== opened.dev || after.ino !== opened.ino
|
|
306
|
+
|| after.size !== opened.size
|
|
307
|
+
|| after.mtimeNs !== opened.mtimeNs || after.ctimeNs !== opened.ctimeNs) {
|
|
308
|
+
throw new Error('legacy bootstrap proof changed while reading');
|
|
309
|
+
}
|
|
310
|
+
assertDirectoryTrust(directory);
|
|
311
|
+
assertFileTrust(receipt.path);
|
|
312
|
+
const settled = lstatSync(receipt.path, { bigint: true });
|
|
313
|
+
if (!settled.isFile() || settled.isSymbolicLink()
|
|
314
|
+
|| settled.dev !== opened.dev || settled.ino !== opened.ino
|
|
315
|
+
|| settled.size !== opened.size
|
|
316
|
+
|| settled.mtimeNs !== opened.mtimeNs || settled.ctimeNs !== opened.ctimeNs) {
|
|
317
|
+
throw new Error('legacy bootstrap proof path changed while reading');
|
|
318
|
+
}
|
|
319
|
+
return first.toString('utf8');
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
closeSync(descriptor);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function validateLegacyBootstrapStateRootProof(marker, stateDir, env, options) {
|
|
326
|
+
const proof = marker.legacyStateRootProof;
|
|
327
|
+
if (!proof)
|
|
328
|
+
return undefined;
|
|
329
|
+
const key = (path) => legacyBootstrapPathKey(path, marker.target);
|
|
330
|
+
if (key(proof.stateDir) !== key(stateDir)
|
|
331
|
+
|| key(env['EVOLVER_LIFECYCLE_STATE_DIR']?.trim() ?? '') !== key(proof.stateDir)
|
|
332
|
+
|| key(env['EVOLVER_ENV_FILE']?.trim() ?? '') !== key(proof.envFilePath)) {
|
|
333
|
+
return 'legacy bootstrap state-root binding no longer matches the supervised environment';
|
|
334
|
+
}
|
|
335
|
+
const receipt = marker.preservedArtifacts?.find((artifact) => key(artifact.path) === key(proof.envFilePath));
|
|
336
|
+
if (!receipt)
|
|
337
|
+
return 'legacy bootstrap state-root proof has no preserved receipt';
|
|
338
|
+
try {
|
|
339
|
+
const raw = readLegacyBootstrapProofFile(receipt, options);
|
|
340
|
+
const configured = parseEnvFile(raw)['EVOLVER_LIFECYCLE_STATE_DIR'];
|
|
341
|
+
if (!configured || key(configured) !== key(proof.stateDir)) {
|
|
342
|
+
return 'legacy bootstrap env file no longer pins the adopted state root';
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
return 'legacy bootstrap state-root proof could not be revalidated';
|
|
347
|
+
}
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
function parseBootstrapLockOwnerJson(raw) {
|
|
351
|
+
try {
|
|
352
|
+
const parsed = JSON.parse(raw);
|
|
353
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
354
|
+
return undefined;
|
|
355
|
+
const record = parsed;
|
|
356
|
+
const keys = Object.keys(record).sort();
|
|
357
|
+
if (keys.length !== 4
|
|
358
|
+
|| keys[0] !== 'pid'
|
|
359
|
+
|| keys[1] !== 'processStart'
|
|
360
|
+
|| keys[2] !== 'token'
|
|
361
|
+
|| keys[3] !== 'v'
|
|
362
|
+
|| record['v'] !== 2
|
|
363
|
+
|| !Number.isSafeInteger(record['pid']) || record['pid'] <= 0
|
|
364
|
+
|| typeof record['token'] !== 'string' || !BOOTSTRAP_UUID_RE.test(record['token'])) {
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
const processStartIdentity = util.parseFileLockProcessStartIdentity(record['processStart']);
|
|
368
|
+
if (!processStartIdentity)
|
|
369
|
+
return undefined;
|
|
370
|
+
return {
|
|
371
|
+
pid: record['pid'],
|
|
372
|
+
token: record['token'],
|
|
373
|
+
processStartIdentity,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function parseBootstrapJournalOwner(value) {
|
|
381
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
382
|
+
return undefined;
|
|
383
|
+
const record = value;
|
|
384
|
+
const processStartIdentity = util.parseFileLockProcessStartIdentity(record['processStartIdentity']);
|
|
385
|
+
if (!Number.isSafeInteger(record['pid']) || record['pid'] <= 0
|
|
386
|
+
|| typeof record['token'] !== 'string' || !BOOTSTRAP_UUID_RE.test(record['token'])
|
|
387
|
+
|| !processStartIdentity
|
|
388
|
+
|| typeof record['acquiredAt'] !== 'string'
|
|
389
|
+
|| Number.isNaN(Date.parse(record['acquiredAt']))) {
|
|
390
|
+
return undefined;
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
pid: record['pid'],
|
|
394
|
+
token: record['token'],
|
|
395
|
+
processStartIdentity,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
function recoveryControllerLifecycleOwnerCapability(env, parentPid = process.ppid) {
|
|
399
|
+
const raw = env[RECOVERY_CONTROLLER_LIFECYCLE_OWNER_CAPABILITY_ENV];
|
|
400
|
+
if (!raw || Buffer.byteLength(raw, 'utf8') > MAX_RECOVERY_CONTROLLER_OWNER_CAPABILITY_BYTES) {
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
const parsed = JSON.parse(raw);
|
|
405
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
406
|
+
return undefined;
|
|
407
|
+
const record = parsed;
|
|
408
|
+
const keys = Object.keys(record).sort();
|
|
409
|
+
if (keys.length !== 5
|
|
410
|
+
|| keys[0] !== 'pid'
|
|
411
|
+
|| keys[1] !== 'processStartIdentity'
|
|
412
|
+
|| keys[2] !== 'startupAckToken'
|
|
413
|
+
|| keys[3] !== 'token'
|
|
414
|
+
|| keys[4] !== 'v'
|
|
415
|
+
|| record['v'] !== 1
|
|
416
|
+
|| !Number.isSafeInteger(record['pid'])
|
|
417
|
+
|| record['pid'] !== parentPid
|
|
418
|
+
|| typeof record['token'] !== 'string'
|
|
419
|
+
|| !BOOTSTRAP_UUID_RE.test(record['token'])
|
|
420
|
+
|| typeof record['startupAckToken'] !== 'string'
|
|
421
|
+
|| !BOOTSTRAP_UUID_RE.test(record['startupAckToken'])) {
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
const processStartIdentity = util.parseFileLockProcessStartIdentity(record['processStartIdentity']);
|
|
425
|
+
if (!processStartIdentity)
|
|
426
|
+
return undefined;
|
|
427
|
+
const owner = {
|
|
428
|
+
pid: record['pid'],
|
|
429
|
+
token: record['token'],
|
|
430
|
+
processStartIdentity,
|
|
431
|
+
};
|
|
432
|
+
if (util.inspectFileLockOwnerProcess(owner) !== 'current')
|
|
433
|
+
return undefined;
|
|
434
|
+
return { owner, startupAckToken: record['startupAckToken'] };
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
function assertRecoveryControllerLifecycleOwnerLease(env, owner, options = {}) {
|
|
441
|
+
const durable = inspectLifecycleBootstrapDurableState(env, {
|
|
442
|
+
...options,
|
|
443
|
+
expectedRecoveryOwner: owner,
|
|
444
|
+
});
|
|
445
|
+
if (durable.status === 'invalid') {
|
|
446
|
+
throw new Error('self_update_recovery_controller_lifecycle_capability_invalid:'
|
|
447
|
+
+ boundedBootstrapDetail(durable.detail));
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function sameBootstrapLockOwner(left, right) {
|
|
451
|
+
return left.pid === right.pid
|
|
452
|
+
&& left.token === right.token
|
|
453
|
+
&& util.sameFileLockProcessStartIdentity(left.processStartIdentity, right.processStartIdentity);
|
|
454
|
+
}
|
|
455
|
+
function bootstrapLockProcessStatus(owner, options) {
|
|
456
|
+
try {
|
|
457
|
+
return (options.registrationOwnerProcessStatus
|
|
458
|
+
?? util.inspectFileLockOwnerProcess)(owner);
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
return 'unverifiable';
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
function inspectBootstrapRecoveryLock(owner, options) {
|
|
465
|
+
if (!owner)
|
|
466
|
+
return { recoveryStatus: 'active_or_unverifiable' };
|
|
467
|
+
const processStatus = bootstrapLockProcessStatus(owner, options);
|
|
468
|
+
return {
|
|
469
|
+
owner,
|
|
470
|
+
processStatus,
|
|
471
|
+
recoveryStatus: processStatus === 'dead' || processStatus === 'pid_reused'
|
|
472
|
+
? 'stale'
|
|
473
|
+
: 'active_or_unverifiable',
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
function normalBootstrapActivationContractValid(record, owner, target, deadlineMs) {
|
|
477
|
+
const managerBinding = record['managerBinding'];
|
|
478
|
+
return owner !== undefined
|
|
479
|
+
&& target !== undefined
|
|
480
|
+
&& deadlineMs !== undefined
|
|
481
|
+
&& record['operation'] === undefined
|
|
482
|
+
&& record['terminalAction'] === undefined
|
|
483
|
+
&& typeof record['service'] === 'string'
|
|
484
|
+
&& record['service'].length > 0
|
|
485
|
+
&& record['service'].length <= 128
|
|
486
|
+
&& record['managerBefore'] === 'absent'
|
|
487
|
+
&& managerBinding !== null
|
|
488
|
+
&& typeof managerBinding === 'object'
|
|
489
|
+
&& !Array.isArray(managerBinding)
|
|
490
|
+
&& managerBinding['kind'] === 'transaction'
|
|
491
|
+
&& typeof managerBinding['artifactPath'] === 'string'
|
|
492
|
+
&& managerBinding['artifactPath'].length > 0
|
|
493
|
+
&& managerBinding['artifactPath'].length <= 4_096
|
|
494
|
+
&& Array.isArray(record['artifacts'])
|
|
495
|
+
&& record['artifacts'].length > 0
|
|
496
|
+
&& record['artifacts'].length <= 32
|
|
497
|
+
&& typeof record['updatedAt'] === 'string'
|
|
498
|
+
&& !Number.isNaN(Date.parse(record['updatedAt']));
|
|
499
|
+
}
|
|
500
|
+
function parseBootstrapJournalReceipt(raw) {
|
|
501
|
+
try {
|
|
502
|
+
const parsed = JSON.parse(raw);
|
|
503
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
504
|
+
return undefined;
|
|
505
|
+
const record = parsed;
|
|
506
|
+
if (record['schema'] !== BOOTSTRAP_JOURNAL_SCHEMA
|
|
507
|
+
|| typeof record['transactionId'] !== 'string' || !BOOTSTRAP_UUID_RE.test(record['transactionId'])
|
|
508
|
+
|| typeof record['stage'] !== 'string' || !BOOTSTRAP_JOURNAL_STAGES.has(record['stage'])) {
|
|
509
|
+
return undefined;
|
|
510
|
+
}
|
|
511
|
+
if (record['terminalAction'] === undefined) {
|
|
512
|
+
if (record['operation'] !== undefined
|
|
513
|
+
|| (record['target'] !== undefined
|
|
514
|
+
&& (typeof record['target'] !== 'string'
|
|
515
|
+
|| !['launchd', 'systemd', 'windows'].includes(record['target'])))
|
|
516
|
+
|| (record['owner'] !== undefined && !parseBootstrapJournalOwner(record['owner']))
|
|
517
|
+
|| (record['deadlineMs'] !== undefined
|
|
518
|
+
&& (!Number.isSafeInteger(record['deadlineMs'])
|
|
519
|
+
|| record['deadlineMs'] <= 0))
|
|
520
|
+
|| (record['activationStarted'] !== undefined
|
|
521
|
+
&& typeof record['activationStarted'] !== 'boolean')) {
|
|
522
|
+
return undefined;
|
|
523
|
+
}
|
|
524
|
+
const target = record['target'];
|
|
525
|
+
const owner = record['owner'] === undefined
|
|
526
|
+
? undefined
|
|
527
|
+
: parseBootstrapJournalOwner(record['owner']);
|
|
528
|
+
const deadlineMs = record['deadlineMs'];
|
|
529
|
+
return {
|
|
530
|
+
transactionId: record['transactionId'],
|
|
531
|
+
stage: record['stage'],
|
|
532
|
+
...(target ? { target } : {}),
|
|
533
|
+
...(owner ? { owner } : {}),
|
|
534
|
+
...(deadlineMs !== undefined ? { deadlineMs } : {}),
|
|
535
|
+
...(record['activationStarted'] !== undefined
|
|
536
|
+
? { activationStarted: record['activationStarted'] }
|
|
537
|
+
: {}),
|
|
538
|
+
...(normalBootstrapActivationContractValid(record, owner, target, deadlineMs)
|
|
539
|
+
? { activationContractValid: true }
|
|
540
|
+
: {}),
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
if (record['operation'] === 'legacy-v907-remove'
|
|
544
|
+
&& record['terminalAction'] === 'remove_committed'
|
|
545
|
+
&& ['prepared', 'rollback_pending', 'rolled_back'].includes(record['stage'])
|
|
546
|
+
&& typeof record['target'] === 'string'
|
|
547
|
+
&& ['launchd', 'systemd', 'windows'].includes(record['target'])) {
|
|
548
|
+
return {
|
|
549
|
+
transactionId: record['transactionId'],
|
|
550
|
+
stage: record['stage'],
|
|
551
|
+
target: record['target'],
|
|
552
|
+
terminalAction: 'remove_committed',
|
|
553
|
+
operation: 'legacy-v907-remove',
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
if (record['terminalAction'] !== 'remove_committed'
|
|
557
|
+
|| !['rollback_pending', 'rolled_back'].includes(record['stage'])
|
|
558
|
+
|| typeof record['target'] !== 'string'
|
|
559
|
+
|| !['launchd', 'systemd', 'windows'].includes(record['target'])) {
|
|
560
|
+
return undefined;
|
|
561
|
+
}
|
|
562
|
+
return {
|
|
563
|
+
transactionId: record['transactionId'],
|
|
564
|
+
stage: record['stage'],
|
|
565
|
+
target: record['target'],
|
|
566
|
+
terminalAction: 'remove_committed',
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
return undefined;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
function inspectLifecycleBootstrapDurableState(env, options) {
|
|
574
|
+
const stateDir = resolveBootstrapStateDir(env);
|
|
575
|
+
if (options.exists === undefined && options.readFile === undefined) {
|
|
576
|
+
try {
|
|
577
|
+
const state = lstatSync(stateDir);
|
|
578
|
+
if (!state.isDirectory() || state.isSymbolicLink()) {
|
|
579
|
+
return { status: 'invalid', detail: 'lifecycle state directory is not trusted' };
|
|
580
|
+
}
|
|
581
|
+
if (process.platform !== 'win32') {
|
|
582
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
|
583
|
+
if ((uid !== undefined && state.uid !== uid) || (state.mode & 0o077) !== 0) {
|
|
584
|
+
return { status: 'invalid', detail: 'lifecycle state directory is not owner-only' };
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
catch (error) {
|
|
589
|
+
if (isErrno(error, 'ENOENT'))
|
|
590
|
+
return { status: 'clean' };
|
|
591
|
+
return { status: 'invalid', detail: 'lifecycle state directory is unreadable' };
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
const markerFile = readBoundedBootstrapStateFile(join(stateDir, coreBootstrap.LIFECYCLE_BOOTSTRAP_SUCCESS_FILE), options);
|
|
595
|
+
const journalFile = readBoundedBootstrapStateFile(join(stateDir, coreBootstrap.LIFECYCLE_BOOTSTRAP_JOURNAL_FILE), options);
|
|
596
|
+
const ownerLockFile = readBoundedBootstrapStateFile(join(stateDir, coreBootstrap.LIFECYCLE_BOOTSTRAP_OWNER_LOCK_FILE), options, util.MAX_LOCK_OWNER_BYTES);
|
|
597
|
+
const readinessFile = readBoundedBootstrapStateFile(join(stateDir, coreBootstrap.LIFECYCLE_BOOTSTRAP_READINESS_FILE), options);
|
|
598
|
+
const readinessLockFile = readBoundedBootstrapStateFile(join(stateDir, coreBootstrap.LIFECYCLE_BOOTSTRAP_READINESS_LOCK_FILE), options, util.MAX_LOCK_OWNER_BYTES);
|
|
599
|
+
const manualTransitionFile = readBoundedBootstrapStateFile(join(stateDir, coreBootstrap.LIFECYCLE_BOOTSTRAP_MANUAL_TRANSITION_FILE), options, coreBootstrap.MAX_LIFECYCLE_BOOTSTRAP_MANUAL_TRANSITION_BYTES);
|
|
600
|
+
for (const state of [
|
|
601
|
+
markerFile,
|
|
602
|
+
journalFile,
|
|
603
|
+
ownerLockFile,
|
|
604
|
+
readinessFile,
|
|
605
|
+
readinessLockFile,
|
|
606
|
+
manualTransitionFile,
|
|
607
|
+
]) {
|
|
608
|
+
if (state.status === 'invalid')
|
|
609
|
+
return { status: 'invalid', detail: state.detail };
|
|
610
|
+
}
|
|
611
|
+
const marker = markerFile.status === 'present'
|
|
612
|
+
? coreBootstrap.parseLifecycleBootstrapMarkerJson(markerFile.raw)
|
|
613
|
+
: undefined;
|
|
614
|
+
const legacyMarker = markerFile.status === 'present' && !marker
|
|
615
|
+
? coreBootstrap.parseLegacyLifecycleBootstrapMarkerJson(markerFile.raw)
|
|
616
|
+
: undefined;
|
|
617
|
+
if (markerFile.status === 'present' && !marker && !legacyMarker) {
|
|
618
|
+
return { status: 'invalid', detail: 'bootstrap success marker is corrupt' };
|
|
619
|
+
}
|
|
620
|
+
const readiness = readinessFile.status === 'present'
|
|
621
|
+
? coreBootstrap.parseLifecycleBootstrapReadinessJson(readinessFile.raw)
|
|
622
|
+
: undefined;
|
|
623
|
+
if (readinessFile.status === 'present' && !readiness) {
|
|
624
|
+
return { status: 'invalid', detail: 'bootstrap readiness receipt is corrupt' };
|
|
625
|
+
}
|
|
626
|
+
const journal = journalFile.status === 'present'
|
|
627
|
+
? parseBootstrapJournalReceipt(journalFile.raw)
|
|
628
|
+
: undefined;
|
|
629
|
+
if (journalFile.status === 'present' && !journal) {
|
|
630
|
+
return { status: 'invalid', detail: 'bootstrap recovery journal is corrupt' };
|
|
631
|
+
}
|
|
632
|
+
const manualTransition = manualTransitionFile.status === 'present'
|
|
633
|
+
? coreBootstrap.parseLifecycleBootstrapManualTransitionJson(manualTransitionFile.raw)
|
|
634
|
+
: undefined;
|
|
635
|
+
if (manualTransitionFile.status === 'present' && !manualTransition) {
|
|
636
|
+
return { status: 'invalid', detail: 'bootstrap manual-transition tombstone is corrupt' };
|
|
637
|
+
}
|
|
638
|
+
const ownerLockOwner = ownerLockFile.status === 'present'
|
|
639
|
+
? parseBootstrapLockOwnerJson(ownerLockFile.raw)
|
|
640
|
+
: undefined;
|
|
641
|
+
const expectedRecoveryOwner = options.expectedRecoveryOwner;
|
|
642
|
+
if (expectedRecoveryOwner !== undefined
|
|
643
|
+
&& (ownerLockFile.status !== 'present'
|
|
644
|
+
|| ownerLockOwner === undefined
|
|
645
|
+
|| !sameBootstrapLockOwner(ownerLockOwner, expectedRecoveryOwner))) {
|
|
646
|
+
return {
|
|
647
|
+
status: 'invalid',
|
|
648
|
+
detail: 'lifecycle bootstrap owner lease is no longer held by the expected owner',
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
const ownerLockIgnored = ownerLockOwner !== undefined
|
|
652
|
+
&& expectedRecoveryOwner !== undefined
|
|
653
|
+
&& sameBootstrapLockOwner(ownerLockOwner, expectedRecoveryOwner);
|
|
654
|
+
const ownerLockPresent = ownerLockFile.status === 'present' && !ownerLockIgnored;
|
|
655
|
+
const readinessLockPresent = readinessLockFile.status === 'present';
|
|
656
|
+
const ownerLock = ownerLockPresent
|
|
657
|
+
? inspectBootstrapRecoveryLock(ownerLockOwner, options)
|
|
658
|
+
: undefined;
|
|
659
|
+
const readinessLockOwner = readinessLockFile.status === 'present'
|
|
660
|
+
? parseBootstrapLockOwnerJson(readinessLockFile.raw)
|
|
661
|
+
: undefined;
|
|
662
|
+
const readinessLock = readinessLockPresent
|
|
663
|
+
? inspectBootstrapRecoveryLock(readinessLockOwner, options)
|
|
664
|
+
: undefined;
|
|
665
|
+
const changedAfterLockProbe = (prior, path, maxBytes) => {
|
|
666
|
+
const current = readBoundedBootstrapStateFile(path, options, maxBytes);
|
|
667
|
+
if (current.status === 'invalid')
|
|
668
|
+
return current.detail;
|
|
669
|
+
return sameBootstrapStateFileSnapshot(prior, current)
|
|
670
|
+
? undefined
|
|
671
|
+
: `durable lifecycle state changed during lock owner inspection: ${basename(path)}`;
|
|
672
|
+
};
|
|
673
|
+
const snapshotFailure = changedAfterLockProbe(ownerLockFile, join(stateDir, coreBootstrap.LIFECYCLE_BOOTSTRAP_OWNER_LOCK_FILE), util.MAX_LOCK_OWNER_BYTES) ?? changedAfterLockProbe(readinessLockFile, join(stateDir, coreBootstrap.LIFECYCLE_BOOTSTRAP_READINESS_LOCK_FILE), util.MAX_LOCK_OWNER_BYTES) ?? changedAfterLockProbe(journalFile, join(stateDir, coreBootstrap.LIFECYCLE_BOOTSTRAP_JOURNAL_FILE), MAX_BOOTSTRAP_STATE_BYTES);
|
|
674
|
+
if (snapshotFailure)
|
|
675
|
+
return { status: 'invalid', detail: snapshotFailure };
|
|
676
|
+
const recoveryLocks = {
|
|
677
|
+
...(ownerLock ? { owner: ownerLock.recoveryStatus } : {}),
|
|
678
|
+
...(readinessLock ? { readiness: readinessLock.recoveryStatus } : {}),
|
|
679
|
+
};
|
|
680
|
+
const recoveryLockState = Object.keys(recoveryLocks).length > 0
|
|
681
|
+
? recoveryLocks
|
|
682
|
+
: undefined;
|
|
683
|
+
const activeRecoveryLock = Object.values(recoveryLocks)
|
|
684
|
+
.some((status) => status === 'active_or_unverifiable');
|
|
685
|
+
const withRecoveryLocks = recoveryLockState ? { recoveryLocks: recoveryLockState } : {};
|
|
686
|
+
if (journal && readiness && journal.transactionId !== readiness.transactionId) {
|
|
687
|
+
return { status: 'invalid', detail: 'bootstrap journal and readiness owners differ' };
|
|
688
|
+
}
|
|
689
|
+
if (journal?.target && marker && journal.target !== marker.target) {
|
|
690
|
+
return { status: 'invalid', detail: 'bootstrap journal and marker targets differ' };
|
|
691
|
+
}
|
|
692
|
+
if (legacyMarker) {
|
|
693
|
+
if (readiness) {
|
|
694
|
+
return {
|
|
695
|
+
status: 'invalid',
|
|
696
|
+
detail: 'legacy bootstrap marker conflicts with transaction durable state',
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
if (journal) {
|
|
700
|
+
if (manualTransition && (journal.terminalAction !== 'remove_committed'
|
|
701
|
+
|| journal.transactionId !== manualTransition.removedTransactionId
|
|
702
|
+
|| journal.target !== manualTransition.target)) {
|
|
703
|
+
return {
|
|
704
|
+
status: 'invalid',
|
|
705
|
+
detail: 'legacy bootstrap removal journal conflicts with manual-transition state',
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
return {
|
|
709
|
+
status: 'pending',
|
|
710
|
+
detail: 'legacy bootstrap recovery journal requires CLI recovery',
|
|
711
|
+
...withRecoveryLocks,
|
|
712
|
+
...(manualTransition ? {
|
|
713
|
+
manualTransition: {
|
|
714
|
+
transitionId: manualTransition.transitionId,
|
|
715
|
+
target: manualTransition.target,
|
|
716
|
+
},
|
|
717
|
+
} : {}),
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
if (manualTransition) {
|
|
721
|
+
return {
|
|
722
|
+
status: 'invalid',
|
|
723
|
+
detail: 'legacy bootstrap marker conflicts with manual-transition state',
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
return {
|
|
727
|
+
status: 'pending',
|
|
728
|
+
detail: 'legacy bootstrap marker requires exact CLI adoption',
|
|
729
|
+
...withRecoveryLocks,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
if (manualTransition) {
|
|
733
|
+
if (journal) {
|
|
734
|
+
if (journal.terminalAction !== 'remove_committed'
|
|
735
|
+
|| journal.transactionId !== manualTransition.removedTransactionId
|
|
736
|
+
|| journal.target !== manualTransition.target) {
|
|
737
|
+
return {
|
|
738
|
+
status: 'invalid',
|
|
739
|
+
detail: 'bootstrap manual-transition tombstone conflicts with recovery journal',
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
if ((marker && (marker.transactionId !== manualTransition.removedTransactionId
|
|
743
|
+
|| marker.target !== manualTransition.target))
|
|
744
|
+
|| (readiness && readiness.transactionId !== manualTransition.removedTransactionId)) {
|
|
745
|
+
return {
|
|
746
|
+
status: 'invalid',
|
|
747
|
+
detail: 'bootstrap manual-transition tombstone conflicts with committed state',
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
status: 'pending',
|
|
752
|
+
detail: 'committed service removal recovery is pending',
|
|
753
|
+
...withRecoveryLocks,
|
|
754
|
+
manualTransition: {
|
|
755
|
+
transitionId: manualTransition.transitionId,
|
|
756
|
+
target: manualTransition.target,
|
|
757
|
+
},
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
if ((marker && (marker.transactionId !== manualTransition.removedTransactionId
|
|
761
|
+
|| marker.target !== manualTransition.target))
|
|
762
|
+
|| (readiness && (!marker
|
|
763
|
+
|| readiness.transactionId !== manualTransition.removedTransactionId))) {
|
|
764
|
+
return {
|
|
765
|
+
status: 'invalid',
|
|
766
|
+
detail: 'bootstrap manual-transition tombstone conflicts with committed state',
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
if (marker) {
|
|
770
|
+
return {
|
|
771
|
+
status: 'pending',
|
|
772
|
+
detail: 'manual service removal has not retired the committed marker; rerun remove-service',
|
|
773
|
+
...withRecoveryLocks,
|
|
774
|
+
manualTransition: {
|
|
775
|
+
transitionId: manualTransition.transitionId,
|
|
776
|
+
target: manualTransition.target,
|
|
777
|
+
},
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
if (activeRecoveryLock) {
|
|
781
|
+
return {
|
|
782
|
+
status: 'pending',
|
|
783
|
+
detail: 'manual service transition lifecycle lock ownership is active or unverifiable',
|
|
784
|
+
...withRecoveryLocks,
|
|
785
|
+
manualTransition: {
|
|
786
|
+
transitionId: manualTransition.transitionId,
|
|
787
|
+
target: manualTransition.target,
|
|
788
|
+
},
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
return {
|
|
792
|
+
status: 'manual_transition',
|
|
793
|
+
transitionId: manualTransition.transitionId,
|
|
794
|
+
target: manualTransition.target,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
if (!marker && !journal && !ownerLockPresent && !readiness && !readinessLockPresent) {
|
|
798
|
+
return { status: 'clean' };
|
|
799
|
+
}
|
|
800
|
+
if (marker?.managerBindingKind === coreBootstrap.LIFECYCLE_BOOTSTRAP_LEGACY_BINDING) {
|
|
801
|
+
const proofFailure = validateLegacyBootstrapStateRootProof(marker, stateDir, env, options);
|
|
802
|
+
if (proofFailure) {
|
|
803
|
+
return { status: 'invalid', detail: proofFailure };
|
|
804
|
+
}
|
|
805
|
+
if (readiness) {
|
|
806
|
+
return {
|
|
807
|
+
status: 'invalid',
|
|
808
|
+
detail: 'legacy bootstrap binding cannot own a transaction readiness receipt',
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
if (journal) {
|
|
812
|
+
return {
|
|
813
|
+
status: 'pending',
|
|
814
|
+
detail: 'legacy bootstrap adoption journal requires CLI recovery',
|
|
815
|
+
...withRecoveryLocks,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
if (activeRecoveryLock) {
|
|
819
|
+
return {
|
|
820
|
+
status: 'pending',
|
|
821
|
+
detail: 'legacy bootstrap adoption owner lock remains active or readiness lock is unverifiable',
|
|
822
|
+
...withRecoveryLocks,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
return {
|
|
826
|
+
status: 'committed',
|
|
827
|
+
transactionId: marker.transactionId,
|
|
828
|
+
target: marker.target,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
if (marker && readiness) {
|
|
832
|
+
if (marker.transactionId !== readiness.transactionId) {
|
|
833
|
+
return { status: 'invalid', detail: 'bootstrap marker and readiness owners differ' };
|
|
834
|
+
}
|
|
835
|
+
if (journal && (journal.transactionId !== marker.transactionId
|
|
836
|
+
|| !['committing', 'committed'].includes(journal.stage))) {
|
|
837
|
+
return { status: 'invalid', detail: 'bootstrap journal conflicts with committed receipt' };
|
|
838
|
+
}
|
|
839
|
+
if (activeRecoveryLock) {
|
|
840
|
+
return {
|
|
841
|
+
status: 'pending',
|
|
842
|
+
detail: 'committed lifecycle state still has active or unverifiable lock ownership',
|
|
843
|
+
...withRecoveryLocks,
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
return {
|
|
847
|
+
status: 'committed',
|
|
848
|
+
transactionId: marker.transactionId,
|
|
849
|
+
target: marker.target,
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
const now = options.now ?? Date.now();
|
|
853
|
+
const supervisedActivation = !marker
|
|
854
|
+
&& journal?.stage === 'activating'
|
|
855
|
+
&& journal.activationStarted === true
|
|
856
|
+
&& journal.activationContractValid === true
|
|
857
|
+
&& journal.target !== undefined
|
|
858
|
+
&& journal.owner !== undefined
|
|
859
|
+
&& journal.deadlineMs !== undefined
|
|
860
|
+
&& Number.isSafeInteger(now)
|
|
861
|
+
&& now >= 0
|
|
862
|
+
&& journal.deadlineMs > now
|
|
863
|
+
&& ownerLock?.processStatus === 'current'
|
|
864
|
+
&& ownerLock.owner !== undefined
|
|
865
|
+
&& sameBootstrapLockOwner(ownerLock.owner, journal.owner)
|
|
866
|
+
&& readinessLock?.recoveryStatus !== 'active_or_unverifiable'
|
|
867
|
+
&& (!readiness || readiness.transactionId === journal.transactionId)
|
|
868
|
+
? {
|
|
869
|
+
transactionId: journal.transactionId,
|
|
870
|
+
target: journal.target,
|
|
871
|
+
}
|
|
872
|
+
: undefined;
|
|
873
|
+
const present = [
|
|
874
|
+
marker ? 'marker' : undefined,
|
|
875
|
+
journal ? 'journal' : undefined,
|
|
876
|
+
ownerLockPresent ? 'owner lock' : undefined,
|
|
877
|
+
readiness ? 'readiness' : undefined,
|
|
878
|
+
readinessLockPresent ? 'readiness lock' : undefined,
|
|
879
|
+
].filter((value) => value !== undefined);
|
|
880
|
+
return {
|
|
881
|
+
status: 'pending',
|
|
882
|
+
detail: `partial durable bootstrap state: ${present.join(',')}`,
|
|
883
|
+
...withRecoveryLocks,
|
|
884
|
+
...(supervisedActivation ? { supervisedActivation } : {}),
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
export function assertSupervisedLifecycleBootstrapState(env, options = {}) {
|
|
888
|
+
const capabilityRaw = env[RECOVERY_CONTROLLER_LIFECYCLE_OWNER_CAPABILITY_ENV];
|
|
889
|
+
const controllerCapability = capabilityRaw === undefined
|
|
890
|
+
? undefined
|
|
891
|
+
: recoveryControllerLifecycleOwnerCapability(env, options.recoveryControllerParentPid ?? process.ppid);
|
|
892
|
+
if (capabilityRaw !== undefined && !controllerCapability) {
|
|
893
|
+
throw new Error('self_update_recovery_controller_lifecycle_capability_invalid');
|
|
894
|
+
}
|
|
895
|
+
if (!selfUpdateSupervisorAttested(env)) {
|
|
896
|
+
if (controllerCapability) {
|
|
897
|
+
assertRecoveryControllerLifecycleOwnerLease(env, controllerCapability.owner, options);
|
|
898
|
+
}
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
const supervisor = env['EVOLVER_SELF_UPDATE_SUPERVISOR']?.trim();
|
|
902
|
+
const supervisorTarget = supervisor === 'systemd'
|
|
903
|
+
? 'systemd'
|
|
904
|
+
: supervisor === 'launchd'
|
|
905
|
+
? 'launchd'
|
|
906
|
+
: supervisor === 'windows-scheduled-task'
|
|
907
|
+
? 'windows'
|
|
908
|
+
: undefined;
|
|
909
|
+
const transactionValue = env[coreBootstrap.LIFECYCLE_BOOTSTRAP_TRANSACTION_ENV];
|
|
910
|
+
const transactionId = transactionValue?.trim();
|
|
911
|
+
if (!lifecycleBootstrapStatePresent(env, options.exists ?? bootstrapStateEntryPresent)) {
|
|
912
|
+
if (transactionValue !== undefined || options.requireLifecycleState) {
|
|
913
|
+
throw new Error('self_update_supervisor_bootstrap_state_invalid:'
|
|
914
|
+
+ (transactionValue !== undefined
|
|
915
|
+
? 'transaction-bound supervisor has no durable lifecycle state'
|
|
916
|
+
: 'unpinned legacy supervisor has no durable lifecycle state'));
|
|
917
|
+
}
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
if (controllerCapability && options.expectedRecoveryOwner
|
|
921
|
+
&& !sameBootstrapLockOwner(controllerCapability.owner, options.expectedRecoveryOwner)) {
|
|
922
|
+
throw new Error('self_update_recovery_controller_lifecycle_capability_invalid:expected owner differs');
|
|
923
|
+
}
|
|
924
|
+
const durable = inspectLifecycleBootstrapDurableState(env, {
|
|
925
|
+
...options,
|
|
926
|
+
expectedRecoveryOwner: options.expectedRecoveryOwner ?? controllerCapability?.owner,
|
|
927
|
+
});
|
|
928
|
+
if (durable.status === 'committed') {
|
|
929
|
+
if (durable.target !== supervisorTarget) {
|
|
930
|
+
throw new Error('self_update_supervisor_bootstrap_state_invalid:'
|
|
931
|
+
+ 'committed lifecycle target does not match supervisor attestation');
|
|
932
|
+
}
|
|
933
|
+
if (transactionValue !== undefined
|
|
934
|
+
&& (!transactionId
|
|
935
|
+
|| !BOOTSTRAP_UUID_RE.test(transactionId)
|
|
936
|
+
|| transactionId !== durable.transactionId)) {
|
|
937
|
+
throw new Error('self_update_supervisor_bootstrap_state_invalid:'
|
|
938
|
+
+ 'committed lifecycle transaction does not match supervisor attestation');
|
|
939
|
+
}
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
if (durable.status === 'pending' && durable.supervisedActivation) {
|
|
943
|
+
if (!transactionId
|
|
944
|
+
|| !BOOTSTRAP_UUID_RE.test(transactionId)
|
|
945
|
+
|| transactionId !== durable.supervisedActivation.transactionId
|
|
946
|
+
|| durable.supervisedActivation.target !== supervisorTarget) {
|
|
947
|
+
throw new Error('self_update_supervisor_bootstrap_state_invalid:'
|
|
948
|
+
+ 'activating lifecycle transaction does not match supervisor attestation');
|
|
949
|
+
}
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
const detail = durable.status === 'pending' || durable.status === 'invalid'
|
|
953
|
+
? durable.detail
|
|
954
|
+
: 'supervised lifecycle state is not committed';
|
|
955
|
+
throw new Error('self_update_supervisor_bootstrap_state_invalid:' + boundedBootstrapDetail(detail));
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Revalidate the narrow parent-owned activation window used by a newly launched recovery
|
|
959
|
+
* controller. This deliberately rejects committed supervision: callers use it only as delegated
|
|
960
|
+
* authority while the lifecycle parent still owns the mutation lock and is waiting for readiness.
|
|
961
|
+
*/
|
|
962
|
+
export function assertActiveSupervisedLifecycleBootstrapDelegation(env, options = {}) {
|
|
963
|
+
const supervisor = env['EVOLVER_SELF_UPDATE_SUPERVISOR']?.trim();
|
|
964
|
+
const supervisorTarget = supervisor === 'systemd'
|
|
965
|
+
? 'systemd'
|
|
966
|
+
: supervisor === 'launchd'
|
|
967
|
+
? 'launchd'
|
|
968
|
+
: supervisor === 'windows-scheduled-task'
|
|
969
|
+
? 'windows'
|
|
970
|
+
: undefined;
|
|
971
|
+
const transactionId = env[coreBootstrap.LIFECYCLE_BOOTSTRAP_TRANSACTION_ENV]?.trim();
|
|
972
|
+
if (!selfUpdateSupervisorAttested(env)
|
|
973
|
+
|| !transactionId
|
|
974
|
+
|| !BOOTSTRAP_UUID_RE.test(transactionId)
|
|
975
|
+
|| !lifecycleBootstrapStatePresent(env, options.exists ?? bootstrapStateEntryPresent)) {
|
|
976
|
+
throw new Error('self_update_supervisor_bootstrap_delegation_invalid:'
|
|
977
|
+
+ 'controller has no valid activating lifecycle attestation');
|
|
978
|
+
}
|
|
979
|
+
const durable = inspectLifecycleBootstrapDurableState(env, options);
|
|
980
|
+
if (durable.status === 'pending'
|
|
981
|
+
&& durable.supervisedActivation?.transactionId === transactionId
|
|
982
|
+
&& durable.supervisedActivation.target === supervisorTarget) {
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
const detail = durable.status === 'pending' || durable.status === 'invalid'
|
|
986
|
+
? durable.detail
|
|
987
|
+
: 'lifecycle state is not an active delegated activation';
|
|
988
|
+
throw new Error('self_update_supervisor_bootstrap_delegation_invalid:'
|
|
989
|
+
+ boundedBootstrapDetail(detail));
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Revalidate a transaction-bound launcher before any self-update operation. Unlike the startup
|
|
993
|
+
* assertion above, this never accepts the narrow activating window used to publish readiness.
|
|
994
|
+
*/
|
|
995
|
+
export function assertCommittedLifecycleBootstrapState(env, options = {}) {
|
|
996
|
+
const transactionValue = env[coreBootstrap.LIFECYCLE_BOOTSTRAP_TRANSACTION_ENV];
|
|
997
|
+
if (transactionValue === undefined)
|
|
998
|
+
return;
|
|
999
|
+
const transactionId = transactionValue.trim();
|
|
1000
|
+
const supervisor = env['EVOLVER_SELF_UPDATE_SUPERVISOR']?.trim();
|
|
1001
|
+
const supervisorTarget = supervisor === 'systemd'
|
|
1002
|
+
? 'systemd'
|
|
1003
|
+
: supervisor === 'launchd'
|
|
1004
|
+
? 'launchd'
|
|
1005
|
+
: supervisor === 'windows-scheduled-task'
|
|
1006
|
+
? 'windows'
|
|
1007
|
+
: undefined;
|
|
1008
|
+
if (!selfUpdateSupervisorAttested(env)
|
|
1009
|
+
|| !transactionId
|
|
1010
|
+
|| !BOOTSTRAP_UUID_RE.test(transactionId)
|
|
1011
|
+
|| !lifecycleBootstrapStatePresent(env, options.exists ?? bootstrapStateEntryPresent)) {
|
|
1012
|
+
throw new Error('self_update_supervisor_bootstrap_state_invalid:'
|
|
1013
|
+
+ 'transaction-bound self-update has no valid committed lifecycle attestation');
|
|
1014
|
+
}
|
|
1015
|
+
const durable = inspectLifecycleBootstrapDurableState(env, options);
|
|
1016
|
+
if (durable.status === 'committed'
|
|
1017
|
+
&& durable.transactionId === transactionId
|
|
1018
|
+
&& durable.target === supervisorTarget) {
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
const detail = durable.status === 'pending' || durable.status === 'invalid'
|
|
1022
|
+
? durable.detail
|
|
1023
|
+
: 'transaction-bound self-update lifecycle state is not committed';
|
|
1024
|
+
throw new Error('self_update_supervisor_bootstrap_state_invalid:' + boundedBootstrapDetail(detail));
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Serialize self-update with every lifecycle bootstrap, recovery, and manual-transition writer.
|
|
1028
|
+
* Acquisition is deliberately fail-fast: a heartbeat must not block the daemon while another
|
|
1029
|
+
* lifecycle owner is active. Transaction-bound launchers revalidate the committed receipt under
|
|
1030
|
+
* the exact acquired generation; legacy launchers still hold and recheck the shared owner lock.
|
|
1031
|
+
*/
|
|
1032
|
+
export function acquireLifecycleBootstrapOwnerLease(env, lockOptions = { maxTries: 2, waitMs: 0 }) {
|
|
1033
|
+
const transactionBound = env[coreBootstrap.LIFECYCLE_BOOTSTRAP_TRANSACTION_ENV] !== undefined;
|
|
1034
|
+
if (transactionBound)
|
|
1035
|
+
assertCommittedLifecycleBootstrapState(env);
|
|
1036
|
+
const stateDir = resolveBootstrapStateDir(env);
|
|
1037
|
+
assertNativeBootstrapIntentDirectoryTrust(stateDir, true);
|
|
1038
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
1039
|
+
assertNativeBootstrapIntentDirectoryTrust(stateDir, true);
|
|
1040
|
+
const path = join(stateDir, coreBootstrap.LIFECYCLE_BOOTSTRAP_OWNER_LOCK_FILE);
|
|
1041
|
+
const owner = util.acquireLock(path, lockOptions);
|
|
1042
|
+
let state = 'active';
|
|
1043
|
+
let guardian;
|
|
1044
|
+
const assertOwned = () => {
|
|
1045
|
+
if (state !== 'active') {
|
|
1046
|
+
throw new Error('self_update_lifecycle_owner_lease_not_active');
|
|
1047
|
+
}
|
|
1048
|
+
const lock = readBoundedBootstrapStateFile(path, { assertIntentFileTrust: () => undefined }, util.MAX_LOCK_OWNER_BYTES);
|
|
1049
|
+
const current = lock.status === 'present'
|
|
1050
|
+
? parseBootstrapLockOwnerJson(lock.raw)
|
|
1051
|
+
: undefined;
|
|
1052
|
+
if (!current || !sameBootstrapLockOwner(current, owner)) {
|
|
1053
|
+
throw new Error('self_update_lifecycle_owner_lease_ownership_changed');
|
|
1054
|
+
}
|
|
1055
|
+
};
|
|
1056
|
+
const release = () => {
|
|
1057
|
+
if (state === 'transferred')
|
|
1058
|
+
return;
|
|
1059
|
+
if (state === 'released')
|
|
1060
|
+
return;
|
|
1061
|
+
if (state === 'release_failed') {
|
|
1062
|
+
throw new Error('self_update_lifecycle_owner_lease_release_failed');
|
|
1063
|
+
}
|
|
1064
|
+
const result = util.releaseLock(path);
|
|
1065
|
+
if (result.released
|
|
1066
|
+
&& (result.reason === 'released' || result.reason === 'released_with_cleanup_error')) {
|
|
1067
|
+
state = 'released';
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
state = 'release_failed';
|
|
1071
|
+
throw new Error(`self_update_lifecycle_owner_lease_release_failed:${result.reason}`);
|
|
1072
|
+
};
|
|
1073
|
+
const armProcess = (pid) => {
|
|
1074
|
+
assertOwned();
|
|
1075
|
+
if (guardian !== undefined) {
|
|
1076
|
+
throw new Error('self_update_lifecycle_owner_lease_guardian_already_armed');
|
|
1077
|
+
}
|
|
1078
|
+
const result = util.attachLockGuardianToProcess(path, owner, pid);
|
|
1079
|
+
if (!result.attached) {
|
|
1080
|
+
throw new Error(`self_update_lifecycle_owner_lease_guardian_arm_failed:${result.reason}`);
|
|
1081
|
+
}
|
|
1082
|
+
guardian = result.guardian;
|
|
1083
|
+
return guardian;
|
|
1084
|
+
};
|
|
1085
|
+
const disarmProcess = () => {
|
|
1086
|
+
assertOwned();
|
|
1087
|
+
if (guardian === undefined)
|
|
1088
|
+
return;
|
|
1089
|
+
const result = util.clearLockGuardianForProcess(path, owner, guardian);
|
|
1090
|
+
if (!result.cleared) {
|
|
1091
|
+
throw new Error(`self_update_lifecycle_owner_lease_guardian_disarm_failed:${result.reason}`);
|
|
1092
|
+
}
|
|
1093
|
+
guardian = undefined;
|
|
1094
|
+
};
|
|
1095
|
+
const retainProcess = () => {
|
|
1096
|
+
assertOwned();
|
|
1097
|
+
if (guardian === undefined) {
|
|
1098
|
+
throw new Error('self_update_lifecycle_owner_lease_guardian_not_armed');
|
|
1099
|
+
}
|
|
1100
|
+
const result = util.retainLockGuardianForProcess(path, owner, guardian);
|
|
1101
|
+
if (!result.retained) {
|
|
1102
|
+
throw new Error(`self_update_lifecycle_owner_lease_guardian_retain_failed:${result.reason}`);
|
|
1103
|
+
}
|
|
1104
|
+
state = 'transferred';
|
|
1105
|
+
};
|
|
1106
|
+
const transferToProcess = (pid) => {
|
|
1107
|
+
const attached = armProcess(pid);
|
|
1108
|
+
retainProcess();
|
|
1109
|
+
return attached;
|
|
1110
|
+
};
|
|
1111
|
+
try {
|
|
1112
|
+
assertOwned();
|
|
1113
|
+
const options = { expectedRecoveryOwner: owner };
|
|
1114
|
+
if (transactionBound) {
|
|
1115
|
+
assertCommittedLifecycleBootstrapState(env, options);
|
|
1116
|
+
}
|
|
1117
|
+
else {
|
|
1118
|
+
const durable = inspectLifecycleBootstrapDurableState(env, options);
|
|
1119
|
+
if (durable.status === 'invalid') {
|
|
1120
|
+
throw new Error('self_update_lifecycle_owner_lease_invalid:'
|
|
1121
|
+
+ boundedBootstrapDetail(durable.detail));
|
|
1122
|
+
}
|
|
1123
|
+
if (durable.status === 'pending' || durable.status === 'manual_transition') {
|
|
1124
|
+
const detail = durable.status === 'pending'
|
|
1125
|
+
? durable.detail
|
|
1126
|
+
: 'manual_transition';
|
|
1127
|
+
throw new Error('self_update_lifecycle_owner_lease_state_blocked:'
|
|
1128
|
+
+ boundedBootstrapDetail(detail));
|
|
1129
|
+
}
|
|
1130
|
+
const supervisor = env['EVOLVER_SELF_UPDATE_SUPERVISOR']?.trim();
|
|
1131
|
+
const supervisorTarget = supervisor === 'systemd'
|
|
1132
|
+
? 'systemd'
|
|
1133
|
+
: supervisor === 'launchd'
|
|
1134
|
+
? 'launchd'
|
|
1135
|
+
: supervisor === 'windows-scheduled-task'
|
|
1136
|
+
? 'windows'
|
|
1137
|
+
: undefined;
|
|
1138
|
+
if (durable.status === 'committed'
|
|
1139
|
+
&& supervisorTarget !== undefined
|
|
1140
|
+
&& durable.target !== supervisorTarget) {
|
|
1141
|
+
throw new Error('self_update_lifecycle_owner_lease_state_blocked:'
|
|
1142
|
+
+ 'committed lifecycle target does not match supervisor attestation');
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
catch (error) {
|
|
1147
|
+
try {
|
|
1148
|
+
release();
|
|
1149
|
+
}
|
|
1150
|
+
catch (releaseError) {
|
|
1151
|
+
throw new AggregateError([error, releaseError], 'self_update_lifecycle_owner_lease_validation_and_release_failed');
|
|
1152
|
+
}
|
|
1153
|
+
throw error;
|
|
1154
|
+
}
|
|
1155
|
+
return {
|
|
1156
|
+
path,
|
|
1157
|
+
owner,
|
|
1158
|
+
assertOwned,
|
|
1159
|
+
armProcess,
|
|
1160
|
+
disarmProcess,
|
|
1161
|
+
retainProcess,
|
|
1162
|
+
transferToProcess,
|
|
1163
|
+
release,
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
export function looksLikeContainer(exists, readFile) {
|
|
1167
|
+
if (exists('/.dockerenv'))
|
|
1168
|
+
return true;
|
|
1169
|
+
try {
|
|
1170
|
+
return /docker|containerd|kubepods|podman|lxc/.test(readFile('/proc/1/cgroup'));
|
|
1171
|
+
}
|
|
1172
|
+
catch {
|
|
1173
|
+
return false;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
const BOOTSTRAP_ATTEMPT_OUTCOMES = new Set([
|
|
1177
|
+
'already_supervised',
|
|
1178
|
+
'already_bootstrapped',
|
|
1179
|
+
'unsupported_install_shape',
|
|
1180
|
+
'policy_not_auto',
|
|
1181
|
+
'bootstrap_disabled',
|
|
1182
|
+
'ci_environment',
|
|
1183
|
+
'container_environment',
|
|
1184
|
+
'recent_failure',
|
|
1185
|
+
'failed',
|
|
1186
|
+
'cli_not_found',
|
|
1187
|
+
'rolled_back',
|
|
1188
|
+
'bootstrapped',
|
|
1189
|
+
'bootstrapped_lock_release_unconfirmed',
|
|
1190
|
+
'blocked',
|
|
1191
|
+
'ambiguous',
|
|
1192
|
+
'termination_unconfirmed',
|
|
1193
|
+
'timeout',
|
|
1194
|
+
'signal',
|
|
1195
|
+
'migrated',
|
|
1196
|
+
'migration_failed',
|
|
1197
|
+
'migration_timeout',
|
|
1198
|
+
'migration_ambiguous',
|
|
1199
|
+
]);
|
|
1200
|
+
const BOOTSTRAP_ATTEMPT_PENDING_OUTCOMES = new Set([
|
|
1201
|
+
'blocked',
|
|
1202
|
+
'ambiguous',
|
|
1203
|
+
'termination_unconfirmed',
|
|
1204
|
+
'timeout',
|
|
1205
|
+
'signal',
|
|
1206
|
+
'migration_ambiguous',
|
|
1207
|
+
]);
|
|
1208
|
+
function readBootstrapAttempt(env, options) {
|
|
1209
|
+
try {
|
|
1210
|
+
const state = readBoundedBootstrapStateFile(join(resolveBootstrapStateDir(env), BOOTSTRAP_ATTEMPT_FILE), options, MAX_BOOTSTRAP_ATTEMPT_BYTES);
|
|
1211
|
+
if (state.status === 'absent')
|
|
1212
|
+
return { status: 'absent' };
|
|
1213
|
+
if (state.status === 'invalid')
|
|
1214
|
+
return { status: 'invalid' };
|
|
1215
|
+
const parsed = JSON.parse(state.raw);
|
|
1216
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1217
|
+
return { status: 'invalid' };
|
|
1218
|
+
}
|
|
1219
|
+
const record = parsed;
|
|
1220
|
+
if (typeof record['outcome'] !== 'string'
|
|
1221
|
+
|| !BOOTSTRAP_ATTEMPT_OUTCOMES.has(record['outcome'])
|
|
1222
|
+
|| typeof record['attemptedAt'] !== 'string') {
|
|
1223
|
+
return { status: 'invalid' };
|
|
1224
|
+
}
|
|
1225
|
+
const attemptedAt = Date.parse(record['attemptedAt']);
|
|
1226
|
+
if (!Number.isFinite(attemptedAt)
|
|
1227
|
+
|| new Date(attemptedAt).toISOString() !== record['attemptedAt']
|
|
1228
|
+
|| (record['detail'] !== undefined
|
|
1229
|
+
&& typeof record['detail'] !== 'string')) {
|
|
1230
|
+
return { status: 'invalid' };
|
|
1231
|
+
}
|
|
1232
|
+
return BOOTSTRAP_ATTEMPT_PENDING_OUTCOMES.has(record['outcome'])
|
|
1233
|
+
? { status: 'ambiguous', attemptedAt }
|
|
1234
|
+
: { status: 'safe_non_ambiguous', attemptedAt, outcome: record['outcome'] };
|
|
1235
|
+
}
|
|
1236
|
+
catch {
|
|
1237
|
+
return { status: 'invalid' };
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
function bootstrapRegistrationIntentPresent(env) {
|
|
1241
|
+
const stateDir = resolveBootstrapStateDir(env);
|
|
1242
|
+
return bootstrapStateEntryPresent(join(stateDir, BOOTSTRAP_REGISTRATION_INTENT_FILE))
|
|
1243
|
+
|| bootstrapStateEntryPresent(join(stateDir, BOOTSTRAP_REGISTRATION_INTENT_PUBLISHING_FILE))
|
|
1244
|
+
|| bootstrapStateEntryPresent(join(stateDir, BOOTSTRAP_REGISTRATION_INTENT_TERMINAL_FILE))
|
|
1245
|
+
|| bootstrapStateEntryPresent(join(stateDir, BOOTSTRAP_REGISTRATION_INTENT_CLEARING_FILE))
|
|
1246
|
+
|| bootstrapRegistrationPublisherEntryPresent(stateDir);
|
|
1247
|
+
}
|
|
1248
|
+
function bootstrapRegistrationPublisherEntryPresent(stateDir) {
|
|
1249
|
+
try {
|
|
1250
|
+
return readdirSync(stateDir).some((name) => name.startsWith(BOOTSTRAP_REGISTRATION_INTENT_PUBLISHER_PREFIX));
|
|
1251
|
+
}
|
|
1252
|
+
catch (error) {
|
|
1253
|
+
return !isErrno(error, 'ENOENT');
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
// Only outcomes that prove no competing child remains may suppress a retry. Ambiguous bootstrap
|
|
1257
|
+
// outcomes deliberately stay out of this set so recovery is never bypassed by the cooldown.
|
|
1258
|
+
const BOOTSTRAP_COOLDOWN_OUTCOMES = new Set([
|
|
1259
|
+
'failed',
|
|
1260
|
+
'cli_not_found',
|
|
1261
|
+
'rolled_back',
|
|
1262
|
+
'migration_failed',
|
|
1263
|
+
'migration_timeout',
|
|
1264
|
+
]);
|
|
1265
|
+
/** True when a recent bootstrap/migration attempt failed within the cooldown window. */
|
|
1266
|
+
export function recentBootstrapFailure(env, optionsOrReadFile, now) {
|
|
1267
|
+
const options = typeof optionsOrReadFile === 'function'
|
|
1268
|
+
? { readFile: optionsOrReadFile }
|
|
1269
|
+
: optionsOrReadFile;
|
|
1270
|
+
const attempt = readBootstrapAttempt(env, options);
|
|
1271
|
+
if (attempt.status !== 'safe_non_ambiguous'
|
|
1272
|
+
|| !BOOTSTRAP_COOLDOWN_OUTCOMES.has(attempt.outcome))
|
|
1273
|
+
return false;
|
|
1274
|
+
const ageMs = now - attempt.attemptedAt;
|
|
1275
|
+
return ageMs >= 0 && ageMs < BOOTSTRAP_FAILURE_COOLDOWN_MS;
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Decide whether an unsupervised (degraded) startup should attempt first-run bootstrap.
|
|
1279
|
+
* Pure — filesystem access is injectable for tests.
|
|
1280
|
+
*/
|
|
1281
|
+
export function shouldBootstrap(env, platform = process.platform, options = {}) {
|
|
1282
|
+
if (selfUpdateSupervisorAttested(env)) {
|
|
1283
|
+
try {
|
|
1284
|
+
resolveSelfUpdateTarget({ env, processExecPath: options.execPath });
|
|
1285
|
+
}
|
|
1286
|
+
catch {
|
|
1287
|
+
return { proceed: false, reason: 'unsupported_install_shape' };
|
|
1288
|
+
}
|
|
1289
|
+
return { proceed: false, reason: 'already_supervised' };
|
|
1290
|
+
}
|
|
1291
|
+
const exists = options.exists ?? existsSync;
|
|
1292
|
+
// The no-replace intent predates child creation and therefore outranks every lifecycle
|
|
1293
|
+
// state fragment. Recovery must classify it without creating another child.
|
|
1294
|
+
if (bootstrapRegistrationIntentPresent(env)) {
|
|
1295
|
+
return { proceed: false, reason: 'bootstrap_intent_pending' };
|
|
1296
|
+
}
|
|
1297
|
+
if (lifecycleBootstrapStatePresent(env, options.exists ?? bootstrapStateEntryPresent)) {
|
|
1298
|
+
try {
|
|
1299
|
+
resolveSelfUpdateTarget({ env, processExecPath: options.execPath });
|
|
1300
|
+
return { proceed: true };
|
|
1301
|
+
}
|
|
1302
|
+
catch {
|
|
1303
|
+
// Durable lifecycle state must be reconciled, but an npm/JS foreground cannot safely
|
|
1304
|
+
// perform that reconciliation through ordinary lifecycle bootstrap: it would bind the
|
|
1305
|
+
// service manager back to the same unreplaceable Node launcher. Route recovery through
|
|
1306
|
+
// the signed standalone migration and keep every non-migrated outcome fail-closed.
|
|
1307
|
+
return { proceed: false, reason: 'bootstrap_attempt_pending' };
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
const attempt = readBootstrapAttempt(env, options);
|
|
1311
|
+
if (attempt.status === 'invalid') {
|
|
1312
|
+
return { proceed: false, reason: 'bootstrap_attempt_invalid' };
|
|
1313
|
+
}
|
|
1314
|
+
if (attempt.status === 'ambiguous') {
|
|
1315
|
+
try {
|
|
1316
|
+
resolveSelfUpdateTarget({ env, processExecPath: options.execPath });
|
|
1317
|
+
return { proceed: true, reason: 'bootstrap_attempt_pending' };
|
|
1318
|
+
}
|
|
1319
|
+
catch {
|
|
1320
|
+
// An npm/JS foreground cannot safely run ordinary lifecycle bootstrap. Retry the
|
|
1321
|
+
// signed standalone migration, whose reconciler keeps this ambiguity fail-closed.
|
|
1322
|
+
return { proceed: false, reason: 'bootstrap_attempt_pending' };
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
const bootstrapSwitch = env['EVOLVER_SELF_BOOTSTRAP']?.trim();
|
|
1326
|
+
if (bootstrapSwitch === '0' || bootstrapSwitch === 'off')
|
|
1327
|
+
return { proceed: false, reason: 'bootstrap_disabled' };
|
|
1328
|
+
if (isSelfUpdateExplicit(env) && resolveSelfUpdatePolicy(env) !== 'auto') {
|
|
1329
|
+
return { proceed: false, reason: 'policy_not_auto' };
|
|
1330
|
+
}
|
|
1331
|
+
// The npm/JS install shape has no replaceable standalone binary target, so the launcher
|
|
1332
|
+
// bootstrap would register a supervised instance that crashes at self-update target
|
|
1333
|
+
// resolution on every startup (crash-loop under the service manager). Skip it; an explicit
|
|
1334
|
+
// EVOLVER_SELF_UPDATE_TARGET_PATH keeps the target bindable and bypasses this guard.
|
|
1335
|
+
try {
|
|
1336
|
+
resolveSelfUpdateTarget({ env, processExecPath: options.execPath });
|
|
1337
|
+
}
|
|
1338
|
+
catch {
|
|
1339
|
+
return { proceed: false, reason: 'unsupported_install_shape' };
|
|
1340
|
+
}
|
|
1341
|
+
const ci = env['CI']?.trim();
|
|
1342
|
+
if (ci && ci.toLowerCase() !== 'false' && ci !== '0')
|
|
1343
|
+
return { proceed: false, reason: 'ci_environment' };
|
|
1344
|
+
if (platform === 'linux' && looksLikeContainer(exists, options.readFile ?? defaultReadTextFile)) {
|
|
1345
|
+
return { proceed: false, reason: 'container_environment' };
|
|
1346
|
+
}
|
|
1347
|
+
if (recentBootstrapFailure(env, options, options.now ?? Date.now())) {
|
|
1348
|
+
return { proceed: false, reason: 'recent_failure' };
|
|
1349
|
+
}
|
|
1350
|
+
return { proceed: true };
|
|
1351
|
+
}
|
|
1352
|
+
/**
|
|
1353
|
+
* Resolve the `lifecycle bootstrap` invocation for the current install shape: standalone binary,
|
|
1354
|
+
* CLI entry through node, or the npm-installed @evomap/evolver-cli sibling. Returns undefined when
|
|
1355
|
+
* no CLI can be located. The caller may continue only after confirming durable state is clean.
|
|
1356
|
+
*/
|
|
1357
|
+
export function resolveBootstrapCliInvocation(options = {}) {
|
|
1358
|
+
const execPath = options.execPath ?? process.execPath;
|
|
1359
|
+
const argv1 = options.argv1 ?? process.argv[1];
|
|
1360
|
+
const exists = options.exists ?? existsSync;
|
|
1361
|
+
const executableName = basename(execPath).toLowerCase();
|
|
1362
|
+
if (isStandaloneReleaseBinaryName(executableName)) {
|
|
1363
|
+
return { command: execPath, args: ['lifecycle', 'bootstrap'] };
|
|
1364
|
+
}
|
|
1365
|
+
if (argv1 && basename(argv1).toLowerCase() === 'cli.js') {
|
|
1366
|
+
return { command: execPath, args: [argv1, 'lifecycle', 'bootstrap'] };
|
|
1367
|
+
}
|
|
1368
|
+
try {
|
|
1369
|
+
const entry = requireFromHere.resolve('@evomap/evolver-cli');
|
|
1370
|
+
const cliPath = join(dirname(entry), 'cli.js');
|
|
1371
|
+
if (exists(cliPath))
|
|
1372
|
+
return { command: execPath, args: [cliPath, 'lifecycle', 'bootstrap'] };
|
|
1373
|
+
}
|
|
1374
|
+
catch {
|
|
1375
|
+
// Not resolvable from the installed proxy package — fall through to the monorepo layout.
|
|
1376
|
+
}
|
|
1377
|
+
const local = fileURLToPath(new URL('../../../evolver-cli/dist/cli.js', import.meta.url));
|
|
1378
|
+
if (exists(local))
|
|
1379
|
+
return { command: execPath, args: [local, 'lifecycle', 'bootstrap'] };
|
|
1380
|
+
return undefined;
|
|
1381
|
+
}
|
|
1382
|
+
/** Best-effort attempt marker; never throws — bootstrap bookkeeping must not break startup. */
|
|
1383
|
+
export function recordBootstrapAttempt(env, outcome, options = {}) {
|
|
1384
|
+
const path = join(resolveBootstrapStateDir(env), BOOTSTRAP_ATTEMPT_FILE);
|
|
1385
|
+
const record = {
|
|
1386
|
+
attemptedAt: new Date(options.now ?? Date.now()).toISOString(),
|
|
1387
|
+
outcome: outcome.reason,
|
|
1388
|
+
...(outcome.detail ? { detail: boundedBootstrapDetail(outcome.detail) } : {}),
|
|
1389
|
+
};
|
|
1390
|
+
try {
|
|
1391
|
+
const writeFile = options.writeFile ?? ((target, content) => {
|
|
1392
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
1393
|
+
const temporary = join(dirname(target), `.${basename(target)}.${process.pid}.${randomUUID()}.tmp`);
|
|
1394
|
+
let descriptor;
|
|
1395
|
+
try {
|
|
1396
|
+
descriptor = openSync(temporary, 'wx', 0o600);
|
|
1397
|
+
writeFileSync(descriptor, content, { encoding: 'utf8' });
|
|
1398
|
+
fsyncSync(descriptor);
|
|
1399
|
+
closeSync(descriptor);
|
|
1400
|
+
descriptor = undefined;
|
|
1401
|
+
renameSync(temporary, target);
|
|
1402
|
+
}
|
|
1403
|
+
finally {
|
|
1404
|
+
if (descriptor !== undefined)
|
|
1405
|
+
closeSync(descriptor);
|
|
1406
|
+
rmSync(temporary, { force: true });
|
|
1407
|
+
}
|
|
1408
|
+
});
|
|
1409
|
+
writeFile(path, `${JSON.stringify(record)}\n`);
|
|
1410
|
+
}
|
|
1411
|
+
catch {
|
|
1412
|
+
// Marker is advisory; startup continues regardless.
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
function captureBootstrapOutput(stream) {
|
|
1416
|
+
const chunks = [];
|
|
1417
|
+
let capturedBytes = 0;
|
|
1418
|
+
let truncated = false;
|
|
1419
|
+
stream?.on('data', (chunk) => {
|
|
1420
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1421
|
+
const available = Math.max(0, MAX_BOOTSTRAP_OUTPUT_BYTES - capturedBytes);
|
|
1422
|
+
if (bytes.byteLength > available)
|
|
1423
|
+
truncated = true;
|
|
1424
|
+
if (available > 0) {
|
|
1425
|
+
const captured = bytes.subarray(0, available);
|
|
1426
|
+
chunks.push(captured);
|
|
1427
|
+
capturedBytes += captured.byteLength;
|
|
1428
|
+
}
|
|
1429
|
+
});
|
|
1430
|
+
return {
|
|
1431
|
+
get truncated() { return truncated; },
|
|
1432
|
+
text: () => Buffer.concat(chunks, capturedBytes).toString('utf8'),
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
function parseBootstrapCliResult(output) {
|
|
1436
|
+
if (output.truncated)
|
|
1437
|
+
return undefined;
|
|
1438
|
+
const raw = output.text().trim();
|
|
1439
|
+
if (!raw)
|
|
1440
|
+
return undefined;
|
|
1441
|
+
try {
|
|
1442
|
+
const parsed = JSON.parse(raw);
|
|
1443
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
1444
|
+
return undefined;
|
|
1445
|
+
const record = parsed;
|
|
1446
|
+
if (record['status'] === 'bootstrap-committed-lock-release-unconfirmed') {
|
|
1447
|
+
const result = record['result'];
|
|
1448
|
+
const resultRecord = result && typeof result === 'object' && !Array.isArray(result)
|
|
1449
|
+
? result
|
|
1450
|
+
: undefined;
|
|
1451
|
+
const transactionId = resultRecord?.['transactionId'];
|
|
1452
|
+
if (record['outcome'] !== 'committed'
|
|
1453
|
+
|| !resultRecord
|
|
1454
|
+
|| !['bootstrapped', 'already-bootstrapped']
|
|
1455
|
+
.includes(resultRecord['status'])
|
|
1456
|
+
|| typeof transactionId !== 'string'
|
|
1457
|
+
|| !BOOTSTRAP_UUID_RE.test(transactionId)
|
|
1458
|
+
|| record['transactionId'] !== transactionId) {
|
|
1459
|
+
return undefined;
|
|
1460
|
+
}
|
|
1461
|
+
const detail = typeof record['detail'] === 'string' && record['detail'].trim()
|
|
1462
|
+
? boundedBootstrapDetail(record['detail'])
|
|
1463
|
+
: undefined;
|
|
1464
|
+
if (!detail)
|
|
1465
|
+
return undefined;
|
|
1466
|
+
return { outcome: 'committed_lock_release_unconfirmed', transactionId, detail };
|
|
1467
|
+
}
|
|
1468
|
+
if (record['status'] === 'bootstrapped' || record['status'] === 'already-bootstrapped') {
|
|
1469
|
+
const transactionId = record['transactionId'];
|
|
1470
|
+
if (typeof transactionId !== 'string' || !BOOTSTRAP_UUID_RE.test(transactionId)) {
|
|
1471
|
+
return undefined;
|
|
1472
|
+
}
|
|
1473
|
+
return { outcome: 'bootstrapped', transactionId };
|
|
1474
|
+
}
|
|
1475
|
+
if (record['status'] !== 'bootstrap-failed')
|
|
1476
|
+
return undefined;
|
|
1477
|
+
const detail = typeof record['detail'] === 'string'
|
|
1478
|
+
? boundedBootstrapDetail(record['detail'])
|
|
1479
|
+
: undefined;
|
|
1480
|
+
if (record['outcome'] === 'rolled_back') {
|
|
1481
|
+
return { outcome: 'rolled_back', ...(detail ? { detail } : {}) };
|
|
1482
|
+
}
|
|
1483
|
+
if (record['outcome'] === 'blocked') {
|
|
1484
|
+
return { outcome: 'blocked', ...(detail ? { detail } : {}) };
|
|
1485
|
+
}
|
|
1486
|
+
return undefined;
|
|
1487
|
+
}
|
|
1488
|
+
catch {
|
|
1489
|
+
return undefined;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
function observationDetail(observation, stdout, stderr) {
|
|
1493
|
+
const parts = [];
|
|
1494
|
+
if ('detail' in observation && observation.detail)
|
|
1495
|
+
parts.push(observation.detail);
|
|
1496
|
+
if (stdout.truncated)
|
|
1497
|
+
parts.push('stdout_truncated');
|
|
1498
|
+
if (stderr.truncated)
|
|
1499
|
+
parts.push('stderr_truncated');
|
|
1500
|
+
const stderrText = boundedBootstrapDetail(stderr.text().trim());
|
|
1501
|
+
if (stderrText)
|
|
1502
|
+
parts.push(stderrText);
|
|
1503
|
+
return parts.length > 0 ? boundedBootstrapDetail(parts.join('; ')) : undefined;
|
|
1504
|
+
}
|
|
1505
|
+
function reconcileBootstrapOutcome(observation, state, stdout, stderr) {
|
|
1506
|
+
const detail = observationDetail(observation, stdout, stderr);
|
|
1507
|
+
const failClosed = (reason, failureDetail) => ({
|
|
1508
|
+
ok: false,
|
|
1509
|
+
reason,
|
|
1510
|
+
...(failureDetail ? { detail: boundedBootstrapDetail(failureDetail) } : {}),
|
|
1511
|
+
requiresForegroundExit: true,
|
|
1512
|
+
});
|
|
1513
|
+
const stateDetail = state.status === 'pending' || state.status === 'invalid' ? state.detail : undefined;
|
|
1514
|
+
const combinedDetail = [detail, stateDetail].filter((value) => Boolean(value)).join('; ');
|
|
1515
|
+
if (observation.kind === 'termination_unconfirmed') {
|
|
1516
|
+
return failClosed('termination_unconfirmed', combinedDetail || observation.detail);
|
|
1517
|
+
}
|
|
1518
|
+
if (observation.kind === 'timeout')
|
|
1519
|
+
return failClosed('timeout', combinedDetail || undefined);
|
|
1520
|
+
if (observation.kind === 'signal') {
|
|
1521
|
+
return failClosed('signal', combinedDetail || `signal ${observation.signal}`);
|
|
1522
|
+
}
|
|
1523
|
+
if (observation.kind === 'child_ambiguous') {
|
|
1524
|
+
return failClosed('ambiguous', combinedDetail || observation.detail);
|
|
1525
|
+
}
|
|
1526
|
+
if (state.status === 'invalid')
|
|
1527
|
+
return failClosed('ambiguous', combinedDetail);
|
|
1528
|
+
if (state.status === 'pending')
|
|
1529
|
+
return failClosed('blocked', combinedDetail);
|
|
1530
|
+
if (state.status === 'committed') {
|
|
1531
|
+
if (observation.kind === 'exit' && observation.code === 0
|
|
1532
|
+
&& observation.cli?.outcome === 'bootstrapped'
|
|
1533
|
+
&& observation.cli.transactionId === state.transactionId
|
|
1534
|
+
&& !stdout.truncated) {
|
|
1535
|
+
return { ok: true, reason: 'bootstrapped' };
|
|
1536
|
+
}
|
|
1537
|
+
if (observation.kind === 'exit' && observation.code === 1
|
|
1538
|
+
&& observation.cli?.outcome === 'committed_lock_release_unconfirmed'
|
|
1539
|
+
&& observation.cli.transactionId === state.transactionId
|
|
1540
|
+
&& !stdout.truncated) {
|
|
1541
|
+
return {
|
|
1542
|
+
ok: true,
|
|
1543
|
+
reason: 'bootstrapped_lock_release_unconfirmed',
|
|
1544
|
+
...(observation.cli.detail ? { detail: observation.cli.detail } : {}),
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
return failClosed('ambiguous', combinedDetail || 'durable bootstrap committed without a matching transaction-bound CLI success result');
|
|
1548
|
+
}
|
|
1549
|
+
if (observation.kind === 'no_child') {
|
|
1550
|
+
return {
|
|
1551
|
+
ok: false,
|
|
1552
|
+
reason: observation.reason,
|
|
1553
|
+
...(detail ? { detail } : {}),
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
if (observation.cli?.outcome === 'rolled_back' && observation.code !== 0 && !stdout.truncated) {
|
|
1557
|
+
return {
|
|
1558
|
+
ok: false,
|
|
1559
|
+
reason: 'rolled_back',
|
|
1560
|
+
...(observation.cli.detail ? { detail: observation.cli.detail } : {}),
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
if (observation.cli?.outcome === 'blocked') {
|
|
1564
|
+
return failClosed('blocked', combinedDetail || observation.cli.detail);
|
|
1565
|
+
}
|
|
1566
|
+
return failClosed('ambiguous', combinedDetail || `CLI result did not prove commit or rollback (exit ${observation.code ?? 'null'})`);
|
|
1567
|
+
}
|
|
1568
|
+
function isErrno(error, code) {
|
|
1569
|
+
return typeof error === 'object' && error !== null && error.code === code;
|
|
1570
|
+
}
|
|
1571
|
+
function trustedWindowsSystemExecutable(name) {
|
|
1572
|
+
if (!win32.isAbsolute(HOST_WINDOWS_SYSTEM_ROOT) || /[\r\n\0]/.test(HOST_WINDOWS_SYSTEM_ROOT)) {
|
|
1573
|
+
throw new Error('Windows SystemRoot is not an absolute trusted path');
|
|
1574
|
+
}
|
|
1575
|
+
return win32.join(HOST_WINDOWS_SYSTEM_ROOT, 'System32', name);
|
|
1576
|
+
}
|
|
1577
|
+
function remainingMs(deadlineMs) {
|
|
1578
|
+
return Math.max(0, deadlineMs - Date.now());
|
|
1579
|
+
}
|
|
1580
|
+
async function waitForCondition(predicate, deadlineMs) {
|
|
1581
|
+
while (!predicate()) {
|
|
1582
|
+
const remaining = remainingMs(deadlineMs);
|
|
1583
|
+
if (remaining === 0)
|
|
1584
|
+
return false;
|
|
1585
|
+
await new Promise((resolvePromise) => {
|
|
1586
|
+
setTimeout(resolvePromise, Math.min(10, remaining));
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
return true;
|
|
1590
|
+
}
|
|
1591
|
+
function requestWindowsProcessTreeTermination(pid, spawnFn, deadlineMs) {
|
|
1592
|
+
return new Promise((resolvePromise) => {
|
|
1593
|
+
let settled = false;
|
|
1594
|
+
let killer;
|
|
1595
|
+
let timer;
|
|
1596
|
+
const settle = (accepted) => {
|
|
1597
|
+
if (settled)
|
|
1598
|
+
return;
|
|
1599
|
+
settled = true;
|
|
1600
|
+
if (timer)
|
|
1601
|
+
clearTimeout(timer);
|
|
1602
|
+
resolvePromise(accepted);
|
|
1603
|
+
};
|
|
1604
|
+
try {
|
|
1605
|
+
killer = spawnFn(trustedWindowsSystemExecutable('taskkill.exe'), ['/PID', String(pid), '/T', '/F'], {
|
|
1606
|
+
shell: false,
|
|
1607
|
+
windowsHide: true,
|
|
1608
|
+
stdio: 'ignore',
|
|
1609
|
+
});
|
|
1610
|
+
killer.once('error', () => settle(false));
|
|
1611
|
+
killer.once('close', (code) => settle(code === 0));
|
|
1612
|
+
const remaining = remainingMs(deadlineMs);
|
|
1613
|
+
if (remaining === 0) {
|
|
1614
|
+
try {
|
|
1615
|
+
killer.kill('SIGKILL');
|
|
1616
|
+
}
|
|
1617
|
+
catch { /* best-effort watchdog cleanup */ }
|
|
1618
|
+
settle(false);
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
timer = setTimeout(() => {
|
|
1622
|
+
try {
|
|
1623
|
+
killer?.kill('SIGKILL');
|
|
1624
|
+
}
|
|
1625
|
+
catch { /* best-effort watchdog cleanup */ }
|
|
1626
|
+
settle(false);
|
|
1627
|
+
}, remaining);
|
|
1628
|
+
}
|
|
1629
|
+
catch {
|
|
1630
|
+
settle(false);
|
|
1631
|
+
}
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
async function terminateBootstrapProcessTree(child, platform, childClosed, deadlineMs, processKill, treeKillSpawnFn) {
|
|
1635
|
+
const pid = child.pid;
|
|
1636
|
+
if (!Number.isSafeInteger(pid) || typeof pid !== 'number' || pid <= 0) {
|
|
1637
|
+
try {
|
|
1638
|
+
child.kill('SIGKILL');
|
|
1639
|
+
}
|
|
1640
|
+
catch { /* direct-child fallback only */ }
|
|
1641
|
+
await waitForCondition(childClosed, deadlineMs);
|
|
1642
|
+
return { confirmed: false, detail: 'process_tree_termination_unconfirmed' };
|
|
1643
|
+
}
|
|
1644
|
+
if (platform === 'win32') {
|
|
1645
|
+
const treeKillAccepted = await requestWindowsProcessTreeTermination(pid, treeKillSpawnFn, deadlineMs);
|
|
1646
|
+
if (!treeKillAccepted) {
|
|
1647
|
+
try {
|
|
1648
|
+
child.kill('SIGKILL');
|
|
1649
|
+
}
|
|
1650
|
+
catch { /* direct-child fallback only */ }
|
|
1651
|
+
}
|
|
1652
|
+
const directChildClosed = await waitForCondition(childClosed, deadlineMs);
|
|
1653
|
+
return treeKillAccepted && directChildClosed
|
|
1654
|
+
? { confirmed: true }
|
|
1655
|
+
: { confirmed: false, detail: 'process_tree_termination_unconfirmed' };
|
|
1656
|
+
}
|
|
1657
|
+
let groupKillAccepted = false;
|
|
1658
|
+
try {
|
|
1659
|
+
processKill(-pid, 'SIGKILL');
|
|
1660
|
+
groupKillAccepted = true;
|
|
1661
|
+
}
|
|
1662
|
+
catch (error) {
|
|
1663
|
+
groupKillAccepted = isErrno(error, 'ESRCH');
|
|
1664
|
+
if (!groupKillAccepted) {
|
|
1665
|
+
try {
|
|
1666
|
+
child.kill('SIGKILL');
|
|
1667
|
+
}
|
|
1668
|
+
catch { /* direct-child fallback only */ }
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
const processGroupGone = () => {
|
|
1672
|
+
try {
|
|
1673
|
+
processKill(-pid, 0);
|
|
1674
|
+
return false;
|
|
1675
|
+
}
|
|
1676
|
+
catch (error) {
|
|
1677
|
+
return isErrno(error, 'ESRCH');
|
|
1678
|
+
}
|
|
1679
|
+
};
|
|
1680
|
+
const confirmed = groupKillAccepted
|
|
1681
|
+
&& await waitForCondition(() => childClosed() && processGroupGone(), deadlineMs);
|
|
1682
|
+
return confirmed
|
|
1683
|
+
? { confirmed: true }
|
|
1684
|
+
: { confirmed: false, detail: 'process_tree_termination_unconfirmed' };
|
|
1685
|
+
}
|
|
1686
|
+
/** Spawn `evolver lifecycle bootstrap` and await its result within a bounded timeout. */
|
|
1687
|
+
export async function runBootstrap(options) {
|
|
1688
|
+
const emptyOutput = () => captureBootstrapOutput(undefined);
|
|
1689
|
+
const reconcileNoChild = (reason, detail) => reconcileBootstrapOutcome({ kind: 'no_child', reason, ...(detail ? { detail } : {}) }, inspectLifecycleBootstrapDurableState(options.env, options), emptyOutput(), emptyOutput());
|
|
1690
|
+
const invocation = resolveBootstrapCliInvocation(options);
|
|
1691
|
+
if (!invocation)
|
|
1692
|
+
return reconcileNoChild('cli_not_found');
|
|
1693
|
+
const spawnFn = options.spawnFn ?? spawn;
|
|
1694
|
+
const timeoutMs = options.timeoutMs ?? BOOTSTRAP_TIMEOUT_MS;
|
|
1695
|
+
const transactionBudgetMs = options.transactionBudgetMs ?? BOOTSTRAP_TRANSACTION_BUDGET_MS;
|
|
1696
|
+
const terminationGraceMs = options.terminationGraceMs ?? BOOTSTRAP_TREE_TERMINATION_GRACE_MS;
|
|
1697
|
+
const startedAt = options.now ?? Date.now();
|
|
1698
|
+
if (!Number.isSafeInteger(timeoutMs)
|
|
1699
|
+
|| !Number.isSafeInteger(transactionBudgetMs)
|
|
1700
|
+
|| !Number.isSafeInteger(terminationGraceMs)
|
|
1701
|
+
|| !Number.isSafeInteger(startedAt)
|
|
1702
|
+
|| !Number.isSafeInteger(startedAt + transactionBudgetMs)
|
|
1703
|
+
|| timeoutMs <= transactionBudgetMs
|
|
1704
|
+
|| timeoutMs > MAX_TIMER_DELAY_MS
|
|
1705
|
+
|| transactionBudgetMs <= 0
|
|
1706
|
+
|| terminationGraceMs <= 0
|
|
1707
|
+
|| terminationGraceMs > MAX_TIMER_DELAY_MS
|
|
1708
|
+
|| startedAt < 0) {
|
|
1709
|
+
return reconcileNoChild('failed', 'invalid_bootstrap_timeout_contract');
|
|
1710
|
+
}
|
|
1711
|
+
const platform = options.platform ?? process.platform;
|
|
1712
|
+
return new Promise((resolvePromise) => {
|
|
1713
|
+
let child;
|
|
1714
|
+
try {
|
|
1715
|
+
child = spawnFn(invocation.command, invocation.args, {
|
|
1716
|
+
detached: platform !== 'win32',
|
|
1717
|
+
shell: false,
|
|
1718
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1719
|
+
windowsHide: true,
|
|
1720
|
+
env: bootstrapChildEnv(options.env, startedAt + transactionBudgetMs),
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1723
|
+
catch (error) {
|
|
1724
|
+
resolvePromise(reconcileNoChild('failed', error instanceof Error ? error.message : String(error)));
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
const stdout = captureBootstrapOutput(child.stdout);
|
|
1728
|
+
const stderr = captureBootstrapOutput(child.stderr);
|
|
1729
|
+
let settled = false;
|
|
1730
|
+
let containmentStarted = false;
|
|
1731
|
+
let childClosed = false;
|
|
1732
|
+
let childStarted = Number.isSafeInteger(child.pid) && typeof child.pid === 'number' && child.pid > 0;
|
|
1733
|
+
const timerRef = {};
|
|
1734
|
+
const settle = (observation) => {
|
|
1735
|
+
if (settled)
|
|
1736
|
+
return;
|
|
1737
|
+
settled = true;
|
|
1738
|
+
if (timerRef.value)
|
|
1739
|
+
clearTimeout(timerRef.value);
|
|
1740
|
+
resolvePromise(reconcileBootstrapOutcome(observation, inspectLifecycleBootstrapDurableState(options.env, options), stdout, stderr));
|
|
1741
|
+
};
|
|
1742
|
+
const containAndSettle = (confirmedObservation) => {
|
|
1743
|
+
if (settled || containmentStarted)
|
|
1744
|
+
return;
|
|
1745
|
+
containmentStarted = true;
|
|
1746
|
+
const confirmationDeadlineMs = Date.now() + terminationGraceMs;
|
|
1747
|
+
void terminateBootstrapProcessTree(child, platform, () => childClosed, confirmationDeadlineMs, options.processKill ?? process.kill.bind(process), options.treeKillSpawnFn ?? spawn).then((termination) => {
|
|
1748
|
+
if (!termination.confirmed) {
|
|
1749
|
+
settle({ kind: 'termination_unconfirmed', detail: termination.detail ?? 'process_tree_termination_unconfirmed' });
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
settle(confirmedObservation);
|
|
1753
|
+
}).catch(() => {
|
|
1754
|
+
settle({ kind: 'termination_unconfirmed', detail: 'process_tree_termination_unconfirmed' });
|
|
1755
|
+
});
|
|
1756
|
+
};
|
|
1757
|
+
child.once('spawn', () => {
|
|
1758
|
+
childStarted = true;
|
|
1759
|
+
});
|
|
1760
|
+
child.once('close', (code, signal) => {
|
|
1761
|
+
childClosed = true;
|
|
1762
|
+
if (settled || containmentStarted)
|
|
1763
|
+
return;
|
|
1764
|
+
if (signal) {
|
|
1765
|
+
containAndSettle({ kind: 'signal', signal });
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
const cli = parseBootstrapCliResult(stdout);
|
|
1769
|
+
settle({
|
|
1770
|
+
kind: 'exit',
|
|
1771
|
+
code,
|
|
1772
|
+
cli,
|
|
1773
|
+
...((stdout.truncated || !cli)
|
|
1774
|
+
? { detail: stdout.truncated ? 'bootstrap_cli_stdout_truncated' : 'bootstrap_cli_result_invalid' }
|
|
1775
|
+
: {}),
|
|
1776
|
+
});
|
|
1777
|
+
});
|
|
1778
|
+
timerRef.value = setTimeout(() => {
|
|
1779
|
+
containAndSettle({ kind: 'timeout' });
|
|
1780
|
+
}, timeoutMs);
|
|
1781
|
+
child.once('error', (error) => {
|
|
1782
|
+
if (settled || containmentStarted)
|
|
1783
|
+
return;
|
|
1784
|
+
if (!childStarted && (!Number.isSafeInteger(child.pid) || !child.pid || child.pid <= 0)) {
|
|
1785
|
+
settle({ kind: 'no_child', reason: 'failed', detail: error.message });
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
containAndSettle({ kind: 'child_ambiguous', detail: error.message });
|
|
1789
|
+
});
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
function windowsBootstrapIntentAclScript(checks) {
|
|
1793
|
+
const encodedChecks = Buffer.from(JSON.stringify(checks), 'utf8').toString('base64');
|
|
1794
|
+
const d = String.fromCharCode(36);
|
|
1795
|
+
return [
|
|
1796
|
+
`${d}ErrorActionPreference = 'Stop'`,
|
|
1797
|
+
'try {',
|
|
1798
|
+
` ${d}json = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedChecks}'))`,
|
|
1799
|
+
` ${d}checks = ${d}json | ConvertFrom-Json`,
|
|
1800
|
+
` ${d}userSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value`,
|
|
1801
|
+
` ${d}trustedInstaller = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'`,
|
|
1802
|
+
` ${d}trustedOwners = @(${d}userSid, 'S-1-5-18', 'S-1-5-32-544', ${d}trustedInstaller)`,
|
|
1803
|
+
` ${d}trustedWriters = @(${d}userSid, 'S-1-5-18', 'S-1-5-32-544', ${d}trustedInstaller, 'S-1-3-0', 'S-1-3-4')`,
|
|
1804
|
+
` ${d}parentDanger = [System.Security.AccessControl.FileSystemRights]::Delete -bor [System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor [System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor [System.Security.AccessControl.FileSystemRights]::TakeOwnership`,
|
|
1805
|
+
` ${d}contentDanger = [System.Security.AccessControl.FileSystemRights]::WriteData -bor [System.Security.AccessControl.FileSystemRights]::AppendData -bor [System.Security.AccessControl.FileSystemRights]::CreateFiles -bor [System.Security.AccessControl.FileSystemRights]::CreateDirectories`,
|
|
1806
|
+
` foreach (${d}check in @(${d}checks)) {`,
|
|
1807
|
+
` ${d}acl = Get-Acl -LiteralPath ${d}check.path`,
|
|
1808
|
+
` ${d}owner = ${d}acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value`,
|
|
1809
|
+
` if (${d}trustedOwners -notcontains ${d}owner) { exit 21 }`,
|
|
1810
|
+
` ${d}danger = if (${d}check.parentOnly) { ${d}parentDanger } else { ${d}parentDanger -bor ${d}contentDanger }`,
|
|
1811
|
+
` foreach (${d}rule in @(${d}acl.Access)) {`,
|
|
1812
|
+
` if (${d}rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { continue }`,
|
|
1813
|
+
` if ((${d}rule.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly) -ne 0) { continue }`,
|
|
1814
|
+
` if ((${d}rule.FileSystemRights -band ${d}danger) -eq 0) { continue }`,
|
|
1815
|
+
` try { ${d}sid = ${d}rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value } catch { exit 22 }`,
|
|
1816
|
+
` if (${d}trustedWriters -notcontains ${d}sid) { exit 23 }`,
|
|
1817
|
+
' }',
|
|
1818
|
+
' }',
|
|
1819
|
+
'} catch { exit 24 }',
|
|
1820
|
+
].join('; ');
|
|
1821
|
+
}
|
|
1822
|
+
function assertWindowsBootstrapIntentAclTrusted(checks) {
|
|
1823
|
+
if (process.platform !== 'win32' || checks.length === 0)
|
|
1824
|
+
return;
|
|
1825
|
+
try {
|
|
1826
|
+
execFileSync(trustedWindowsSystemExecutable('WindowsPowerShell\\v1.0\\powershell.exe'), ['-NoProfile', '-NonInteractive', '-Command', windowsBootstrapIntentAclScript(checks)], { stdio: 'ignore', timeout: 10_000, windowsHide: true });
|
|
1827
|
+
}
|
|
1828
|
+
catch (error) {
|
|
1829
|
+
throw new Error('bootstrap registration intent Windows ACL chain is not trusted', { cause: error });
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
function bootstrapPathKey(path) {
|
|
1833
|
+
const resolved = resolvePath(path);
|
|
1834
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
1835
|
+
}
|
|
1836
|
+
function assertNativeBootstrapIntentDirectoryTrust(directory, requireOwnerOnlyLeaf) {
|
|
1837
|
+
const absoluteDirectory = resolvePath(directory);
|
|
1838
|
+
let current = absoluteDirectory;
|
|
1839
|
+
const root = parsePath(current).root;
|
|
1840
|
+
const windowsChecks = [];
|
|
1841
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
|
1842
|
+
let nearestExisting = true;
|
|
1843
|
+
let privateUserAnchor = false;
|
|
1844
|
+
let directoryExists = false;
|
|
1845
|
+
for (;;) {
|
|
1846
|
+
let info;
|
|
1847
|
+
try {
|
|
1848
|
+
info = lstatSync(current);
|
|
1849
|
+
}
|
|
1850
|
+
catch (error) {
|
|
1851
|
+
if (!isErrno(error, 'ENOENT'))
|
|
1852
|
+
throw error;
|
|
1853
|
+
if (current === root)
|
|
1854
|
+
break;
|
|
1855
|
+
current = dirname(current);
|
|
1856
|
+
continue;
|
|
1857
|
+
}
|
|
1858
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
1859
|
+
throw new Error('bootstrap registration intent directory chain is unsafe');
|
|
1860
|
+
}
|
|
1861
|
+
if (bootstrapPathKey(current) === bootstrapPathKey(absoluteDirectory))
|
|
1862
|
+
directoryExists = true;
|
|
1863
|
+
if (process.platform === 'win32') {
|
|
1864
|
+
windowsChecks.push({ path: current, parentOnly: bootstrapPathKey(current) !== bootstrapPathKey(absoluteDirectory) });
|
|
1865
|
+
}
|
|
1866
|
+
else {
|
|
1867
|
+
if (uid !== undefined && info.uid !== uid && (nearestExisting || info.uid !== 0)) {
|
|
1868
|
+
throw new Error('bootstrap registration intent directory owner is untrusted');
|
|
1869
|
+
}
|
|
1870
|
+
if (requireOwnerOnlyLeaf && bootstrapPathKey(current) === bootstrapPathKey(absoluteDirectory)
|
|
1871
|
+
&& (info.mode & 0o077) !== 0) {
|
|
1872
|
+
throw new Error('bootstrap registration intent directory is not owner-only');
|
|
1873
|
+
}
|
|
1874
|
+
const writableByOthers = (info.mode & 0o022) !== 0;
|
|
1875
|
+
const trustedStickyAncestor = !nearestExisting
|
|
1876
|
+
&& privateUserAnchor
|
|
1877
|
+
&& info.uid === 0
|
|
1878
|
+
&& (info.mode & 0o1000) !== 0;
|
|
1879
|
+
if (writableByOthers && !trustedStickyAncestor) {
|
|
1880
|
+
throw new Error('bootstrap registration intent directory chain is writable by another principal');
|
|
1881
|
+
}
|
|
1882
|
+
if (info.uid === uid && (info.mode & 0o077) === 0)
|
|
1883
|
+
privateUserAnchor = true;
|
|
1884
|
+
}
|
|
1885
|
+
nearestExisting = false;
|
|
1886
|
+
if (current === root)
|
|
1887
|
+
break;
|
|
1888
|
+
current = dirname(current);
|
|
1889
|
+
}
|
|
1890
|
+
if (directoryExists
|
|
1891
|
+
&& bootstrapPathKey(realpathSync(absoluteDirectory)) !== bootstrapPathKey(absoluteDirectory)) {
|
|
1892
|
+
throw new Error('bootstrap registration intent directory is not canonical');
|
|
1893
|
+
}
|
|
1894
|
+
assertWindowsBootstrapIntentAclTrusted(windowsChecks);
|
|
1895
|
+
}
|
|
1896
|
+
function assertNativeBootstrapIntentFileTrust(path, expectedLinkCount = 1) {
|
|
1897
|
+
const info = lstatSync(path);
|
|
1898
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== expectedLinkCount) {
|
|
1899
|
+
throw new Error('bootstrap registration intent file is unsafe');
|
|
1900
|
+
}
|
|
1901
|
+
if (process.platform === 'win32') {
|
|
1902
|
+
assertWindowsBootstrapIntentAclTrusted([{ path, parentOnly: false }]);
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
|
1906
|
+
if ((uid !== undefined && info.uid !== uid) || (info.mode & 0o077) !== 0) {
|
|
1907
|
+
throw new Error('bootstrap registration intent file is not owner-only');
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
function assertBootstrapIntentDirectoryTrust(options, directory, requireOwnerOnlyLeaf) {
|
|
1911
|
+
if (options.assertIntentDirectoryTrust) {
|
|
1912
|
+
options.assertIntentDirectoryTrust(directory);
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
assertNativeBootstrapIntentDirectoryTrust(directory, requireOwnerOnlyLeaf);
|
|
1916
|
+
}
|
|
1917
|
+
function assertBootstrapIntentFileTrust(options, path, expectedLinkCount = 1) {
|
|
1918
|
+
if (options.assertIntentFileTrust) {
|
|
1919
|
+
options.assertIntentFileTrust(path);
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
assertNativeBootstrapIntentFileTrust(path, expectedLinkCount);
|
|
1923
|
+
}
|
|
1924
|
+
function readBootstrapRegistrationProcessStartIdentity(options, pid) {
|
|
1925
|
+
return (options.readRegistrationProcessStartIdentity
|
|
1926
|
+
?? util.readFileLockProcessStartIdentity)(pid);
|
|
1927
|
+
}
|
|
1928
|
+
function bootstrapRegistrationProcessIdentityDigest(identity) {
|
|
1929
|
+
const canonical = identity.source === 'linux-proc'
|
|
1930
|
+
? ['linux-proc', identity.bootId, identity.startTicks]
|
|
1931
|
+
: identity.source === 'windows-powershell'
|
|
1932
|
+
? ['windows-powershell', identity.startTimeTicks]
|
|
1933
|
+
: ['darwin-ps', identity.startTime];
|
|
1934
|
+
return createHash('sha256').update(JSON.stringify(canonical), 'utf8').digest('hex');
|
|
1935
|
+
}
|
|
1936
|
+
function bootstrapRegistrationPublisherProcessStatus(options, publisher) {
|
|
1937
|
+
if (options.registrationPublisherProcessStatus) {
|
|
1938
|
+
return options.registrationPublisherProcessStatus({
|
|
1939
|
+
pid: publisher.pid,
|
|
1940
|
+
token: publisher.token,
|
|
1941
|
+
processIdentityDigest: publisher.processIdentityDigest,
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
try {
|
|
1945
|
+
process.kill(publisher.pid, 0);
|
|
1946
|
+
}
|
|
1947
|
+
catch (error) {
|
|
1948
|
+
if (isErrno(error, 'ESRCH'))
|
|
1949
|
+
return 'dead';
|
|
1950
|
+
if (!isErrno(error, 'EPERM'))
|
|
1951
|
+
return 'unverifiable';
|
|
1952
|
+
}
|
|
1953
|
+
const current = readBootstrapRegistrationProcessStartIdentity(options, publisher.pid);
|
|
1954
|
+
if (current === null)
|
|
1955
|
+
return 'unverifiable';
|
|
1956
|
+
return bootstrapRegistrationProcessIdentityDigest(current) === publisher.processIdentityDigest
|
|
1957
|
+
? 'current'
|
|
1958
|
+
: 'pid_reused';
|
|
1959
|
+
}
|
|
1960
|
+
function assertBootstrapRegistrationOwnerCurrent(options, owner) {
|
|
1961
|
+
const current = readBootstrapRegistrationProcessStartIdentity(options, owner.pid);
|
|
1962
|
+
if (current === null
|
|
1963
|
+
|| !util.sameFileLockProcessStartIdentity(owner.processStartIdentity, current)) {
|
|
1964
|
+
throw new Error('bootstrap registration parent process identity is no longer current');
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
function bootstrapRegistrationOwnerProcessStatus(options, owner) {
|
|
1968
|
+
return (options.registrationOwnerProcessStatus
|
|
1969
|
+
?? util.inspectFileLockOwnerProcess)(owner);
|
|
1970
|
+
}
|
|
1971
|
+
function acquireBootstrapRegistrationRecoveryLock(options) {
|
|
1972
|
+
const stateDir = resolveBootstrapStateDir(options.env);
|
|
1973
|
+
assertBootstrapIntentDirectoryTrust(options, stateDir, true);
|
|
1974
|
+
const path = join(stateDir, coreBootstrap.LIFECYCLE_BOOTSTRAP_OWNER_LOCK_FILE);
|
|
1975
|
+
const owner = util.acquireLock(path, { maxTries: 2, waitMs: 0 });
|
|
1976
|
+
let released = false;
|
|
1977
|
+
return {
|
|
1978
|
+
path,
|
|
1979
|
+
owner,
|
|
1980
|
+
release: () => {
|
|
1981
|
+
if (released)
|
|
1982
|
+
return;
|
|
1983
|
+
const result = util.releaseLock(path);
|
|
1984
|
+
if (!result.released
|
|
1985
|
+
|| (result.reason !== 'released' && result.reason !== 'released_with_cleanup_error')) {
|
|
1986
|
+
throw new Error(`bootstrap registration recovery lock release failed: ${result.reason}`);
|
|
1987
|
+
}
|
|
1988
|
+
released = true;
|
|
1989
|
+
},
|
|
1990
|
+
};
|
|
1991
|
+
}
|
|
1992
|
+
function parseBootstrapRegistrationIntent(raw) {
|
|
1993
|
+
return coreBootstrap.parseLifecycleBootstrapRegistrationIntentJson(raw);
|
|
1994
|
+
}
|
|
1995
|
+
function syncBootstrapDirectory(path) {
|
|
1996
|
+
let descriptor;
|
|
1997
|
+
try {
|
|
1998
|
+
descriptor = openSync(path, constants.O_RDONLY);
|
|
1999
|
+
fsyncSync(descriptor);
|
|
2000
|
+
}
|
|
2001
|
+
catch (error) {
|
|
2002
|
+
if (isErrno(error, 'EINVAL'))
|
|
2003
|
+
return;
|
|
2004
|
+
if (process.platform === 'win32' && (isErrno(error, 'EPERM') || isErrno(error, 'EACCES')))
|
|
2005
|
+
return;
|
|
2006
|
+
throw error;
|
|
2007
|
+
}
|
|
2008
|
+
finally {
|
|
2009
|
+
if (descriptor !== undefined)
|
|
2010
|
+
closeSync(descriptor);
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
function readBootstrapRegistrationIntentFile(options, path, registrationPath, terminalPath, clearingPath, expectedLinkCount = 1n) {
|
|
2014
|
+
try {
|
|
2015
|
+
assertBootstrapIntentFileTrust(options, path, Number(expectedLinkCount));
|
|
2016
|
+
const before = lstatSync(path, { bigint: true });
|
|
2017
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== expectedLinkCount
|
|
2018
|
+
|| before.dev <= 0n || before.ino <= 0n
|
|
2019
|
+
|| before.size > BigInt(MAX_BOOTSTRAP_REGISTRATION_INTENT_BYTES)
|
|
2020
|
+
|| (process.platform !== 'win32' && (before.mode & 63n) !== 0n)) {
|
|
2021
|
+
return { status: 'invalid', detail: 'registration intent file is unsafe' };
|
|
2022
|
+
}
|
|
2023
|
+
const trustedFile = {
|
|
2024
|
+
path,
|
|
2025
|
+
device: before.dev,
|
|
2026
|
+
inode: before.ino,
|
|
2027
|
+
linkCount: before.nlink,
|
|
2028
|
+
size: before.size,
|
|
2029
|
+
mtimeNs: before.mtimeNs,
|
|
2030
|
+
ctimeNs: before.ctimeNs,
|
|
2031
|
+
};
|
|
2032
|
+
const descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
2033
|
+
let raw;
|
|
2034
|
+
try {
|
|
2035
|
+
const opened = fstatSync(descriptor, { bigint: true });
|
|
2036
|
+
if (!opened.isFile() || opened.nlink !== expectedLinkCount
|
|
2037
|
+
|| opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== before.size) {
|
|
2038
|
+
return { status: 'invalid', detail: 'registration intent changed before read' };
|
|
2039
|
+
}
|
|
2040
|
+
const bytes = Buffer.alloc(MAX_BOOTSTRAP_REGISTRATION_INTENT_BYTES + 1);
|
|
2041
|
+
let offset = 0;
|
|
2042
|
+
while (offset < bytes.length) {
|
|
2043
|
+
const count = readSync(descriptor, bytes, offset, bytes.length - offset, null);
|
|
2044
|
+
if (count === 0)
|
|
2045
|
+
break;
|
|
2046
|
+
offset += count;
|
|
2047
|
+
}
|
|
2048
|
+
if (offset > MAX_BOOTSTRAP_REGISTRATION_INTENT_BYTES) {
|
|
2049
|
+
return { status: 'invalid', detail: 'registration intent is oversized', trustedFile };
|
|
2050
|
+
}
|
|
2051
|
+
raw = bytes.subarray(0, offset).toString('utf8');
|
|
2052
|
+
const settled = fstatSync(descriptor, { bigint: true });
|
|
2053
|
+
if (settled.dev !== opened.dev || settled.ino !== opened.ino
|
|
2054
|
+
|| settled.size !== opened.size || settled.mtimeNs !== opened.mtimeNs
|
|
2055
|
+
|| settled.ctimeNs !== opened.ctimeNs) {
|
|
2056
|
+
return { status: 'invalid', detail: 'registration intent changed during read' };
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
finally {
|
|
2060
|
+
closeSync(descriptor);
|
|
2061
|
+
}
|
|
2062
|
+
const after = lstatSync(path, { bigint: true });
|
|
2063
|
+
if (!after.isFile() || after.isSymbolicLink() || after.nlink !== expectedLinkCount
|
|
2064
|
+
|| after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size
|
|
2065
|
+
|| after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) {
|
|
2066
|
+
return { status: 'invalid', detail: 'registration intent changed after read' };
|
|
2067
|
+
}
|
|
2068
|
+
assertBootstrapIntentFileTrust(options, path, Number(expectedLinkCount));
|
|
2069
|
+
const record = parseBootstrapRegistrationIntent(raw);
|
|
2070
|
+
if (!record) {
|
|
2071
|
+
return { status: 'invalid', detail: 'registration intent payload is invalid', trustedFile };
|
|
2072
|
+
}
|
|
2073
|
+
return {
|
|
2074
|
+
status: 'present',
|
|
2075
|
+
lease: {
|
|
2076
|
+
path: registrationPath,
|
|
2077
|
+
terminalPath,
|
|
2078
|
+
clearingPath,
|
|
2079
|
+
currentPath: path,
|
|
2080
|
+
device: before.dev,
|
|
2081
|
+
inode: before.ino,
|
|
2082
|
+
size: before.size,
|
|
2083
|
+
mtimeNs: before.mtimeNs,
|
|
2084
|
+
ctimeNs: before.ctimeNs,
|
|
2085
|
+
record,
|
|
2086
|
+
},
|
|
2087
|
+
};
|
|
2088
|
+
}
|
|
2089
|
+
catch (error) {
|
|
2090
|
+
return {
|
|
2091
|
+
status: 'invalid',
|
|
2092
|
+
detail: boundedBootstrapDetail(`registration intent is unreadable: ${error instanceof Error ? error.message : String(error)}`),
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
function sameBootstrapRegistrationIntentOwner(left, right) {
|
|
2097
|
+
return left.createdAt === right.createdAt
|
|
2098
|
+
&& left.owner.pid === right.owner.pid
|
|
2099
|
+
&& left.owner.token === right.owner.token
|
|
2100
|
+
&& util.sameFileLockProcessStartIdentity(left.owner.processStartIdentity, right.owner.processStartIdentity);
|
|
2101
|
+
}
|
|
2102
|
+
function bootstrapRegistrationIntentIdentity(lease, linkCount) {
|
|
2103
|
+
return {
|
|
2104
|
+
path: lease.currentPath,
|
|
2105
|
+
device: lease.device,
|
|
2106
|
+
inode: lease.inode,
|
|
2107
|
+
linkCount,
|
|
2108
|
+
size: lease.size,
|
|
2109
|
+
mtimeNs: lease.mtimeNs,
|
|
2110
|
+
ctimeNs: lease.ctimeNs,
|
|
2111
|
+
};
|
|
2112
|
+
}
|
|
2113
|
+
function sameBootstrapRegistrationIntentFileIdentity(left, right) {
|
|
2114
|
+
return left.path === right.path
|
|
2115
|
+
&& left.device === right.device
|
|
2116
|
+
&& left.inode === right.inode
|
|
2117
|
+
&& left.linkCount === right.linkCount
|
|
2118
|
+
&& left.size === right.size
|
|
2119
|
+
&& left.mtimeNs === right.mtimeNs
|
|
2120
|
+
&& left.ctimeNs === right.ctimeNs;
|
|
2121
|
+
}
|
|
2122
|
+
const BOOTSTRAP_REGISTRATION_PUBLISHER_RE = /^bootstrap-registration\.intent\.publisher\.([1-9][0-9]{0,15})\.([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.([0-9a-f]{64})$/;
|
|
2123
|
+
function readBootstrapRegistrationPublisherClaim(options, stateDir) {
|
|
2124
|
+
let names;
|
|
2125
|
+
try {
|
|
2126
|
+
names = readdirSync(stateDir).filter((name) => name.startsWith(BOOTSTRAP_REGISTRATION_INTENT_PUBLISHER_PREFIX));
|
|
2127
|
+
}
|
|
2128
|
+
catch (error) {
|
|
2129
|
+
if (isErrno(error, 'ENOENT'))
|
|
2130
|
+
return { status: 'absent' };
|
|
2131
|
+
return {
|
|
2132
|
+
status: 'invalid',
|
|
2133
|
+
detail: boundedBootstrapDetail(`registration publisher claim directory is unreadable: ${error instanceof Error ? error.message : String(error)}`),
|
|
2134
|
+
};
|
|
2135
|
+
}
|
|
2136
|
+
if (names.length === 0)
|
|
2137
|
+
return { status: 'absent' };
|
|
2138
|
+
if (names.length !== 1) {
|
|
2139
|
+
return { status: 'invalid', detail: 'registration publisher claim count is invalid' };
|
|
2140
|
+
}
|
|
2141
|
+
const name = names[0];
|
|
2142
|
+
const match = BOOTSTRAP_REGISTRATION_PUBLISHER_RE.exec(name);
|
|
2143
|
+
if (!match)
|
|
2144
|
+
return { status: 'invalid', detail: 'registration publisher claim name is invalid' };
|
|
2145
|
+
const pid = Number(match[1]);
|
|
2146
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
|
2147
|
+
return { status: 'invalid', detail: 'registration publisher claim pid is invalid' };
|
|
2148
|
+
}
|
|
2149
|
+
const path = join(stateDir, name);
|
|
2150
|
+
try {
|
|
2151
|
+
assertBootstrapIntentFileTrust(options, path);
|
|
2152
|
+
const before = lstatSync(path, { bigint: true });
|
|
2153
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n
|
|
2154
|
+
|| before.size !== 0n || before.dev <= 0n || before.ino <= 0n) {
|
|
2155
|
+
return { status: 'invalid', detail: 'registration publisher claim is unsafe' };
|
|
2156
|
+
}
|
|
2157
|
+
let descriptor;
|
|
2158
|
+
try {
|
|
2159
|
+
descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
2160
|
+
const opened = fstatSync(descriptor, { bigint: true });
|
|
2161
|
+
if (!opened.isFile() || opened.nlink !== 1n || opened.size !== 0n
|
|
2162
|
+
|| opened.dev !== before.dev || opened.ino !== before.ino
|
|
2163
|
+
|| opened.mtimeNs !== before.mtimeNs || opened.ctimeNs !== before.ctimeNs) {
|
|
2164
|
+
return { status: 'invalid', detail: 'registration publisher claim changed before read' };
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
finally {
|
|
2168
|
+
if (descriptor !== undefined)
|
|
2169
|
+
closeSync(descriptor);
|
|
2170
|
+
}
|
|
2171
|
+
const after = lstatSync(path, { bigint: true });
|
|
2172
|
+
if (!after.isFile() || after.isSymbolicLink() || after.nlink !== 1n || after.size !== 0n
|
|
2173
|
+
|| after.dev !== before.dev || after.ino !== before.ino
|
|
2174
|
+
|| after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) {
|
|
2175
|
+
return { status: 'invalid', detail: 'registration publisher claim changed after read' };
|
|
2176
|
+
}
|
|
2177
|
+
assertBootstrapIntentFileTrust(options, path);
|
|
2178
|
+
return {
|
|
2179
|
+
status: 'present',
|
|
2180
|
+
claim: {
|
|
2181
|
+
path,
|
|
2182
|
+
pid,
|
|
2183
|
+
token: match[2],
|
|
2184
|
+
processIdentityDigest: match[3],
|
|
2185
|
+
identity: {
|
|
2186
|
+
path,
|
|
2187
|
+
device: before.dev,
|
|
2188
|
+
inode: before.ino,
|
|
2189
|
+
linkCount: 1n,
|
|
2190
|
+
size: 0n,
|
|
2191
|
+
mtimeNs: before.mtimeNs,
|
|
2192
|
+
ctimeNs: before.ctimeNs,
|
|
2193
|
+
},
|
|
2194
|
+
},
|
|
2195
|
+
};
|
|
2196
|
+
}
|
|
2197
|
+
catch (error) {
|
|
2198
|
+
return {
|
|
2199
|
+
status: 'invalid',
|
|
2200
|
+
detail: boundedBootstrapDetail(`registration publisher claim is unreadable: ${error instanceof Error ? error.message : String(error)}`),
|
|
2201
|
+
};
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
function bootstrapRegistrationPublisherOwnsIntent(publisher, record) {
|
|
2205
|
+
return record.owner.pid === publisher.pid
|
|
2206
|
+
&& record.owner.token === publisher.token
|
|
2207
|
+
&& bootstrapRegistrationProcessIdentityDigest(record.owner.processStartIdentity)
|
|
2208
|
+
=== publisher.processIdentityDigest;
|
|
2209
|
+
}
|
|
2210
|
+
function readBootstrapRegistrationIntent(options) {
|
|
2211
|
+
const stateDir = resolveBootstrapStateDir(options.env);
|
|
2212
|
+
const path = join(stateDir, BOOTSTRAP_REGISTRATION_INTENT_FILE);
|
|
2213
|
+
const publishingPath = join(stateDir, BOOTSTRAP_REGISTRATION_INTENT_PUBLISHING_FILE);
|
|
2214
|
+
const terminalPath = join(stateDir, BOOTSTRAP_REGISTRATION_INTENT_TERMINAL_FILE);
|
|
2215
|
+
const clearingPath = join(stateDir, BOOTSTRAP_REGISTRATION_INTENT_CLEARING_FILE);
|
|
2216
|
+
try {
|
|
2217
|
+
const pathPresent = bootstrapStateEntryPresent(path);
|
|
2218
|
+
const publishingPresent = bootstrapStateEntryPresent(publishingPath);
|
|
2219
|
+
const terminalPresent = bootstrapStateEntryPresent(terminalPath);
|
|
2220
|
+
const clearingPresent = bootstrapStateEntryPresent(clearingPath);
|
|
2221
|
+
const publisherEntryPresent = bootstrapRegistrationPublisherEntryPresent(stateDir);
|
|
2222
|
+
if (!pathPresent && !publishingPresent && !terminalPresent && !clearingPresent
|
|
2223
|
+
&& !publisherEntryPresent) {
|
|
2224
|
+
return { status: 'absent' };
|
|
2225
|
+
}
|
|
2226
|
+
assertBootstrapIntentDirectoryTrust(options, stateDir, true);
|
|
2227
|
+
const publisher = readBootstrapRegistrationPublisherClaim(options, stateDir);
|
|
2228
|
+
if (publisher.status === 'invalid') {
|
|
2229
|
+
return { status: 'invalid', detail: publisher.detail };
|
|
2230
|
+
}
|
|
2231
|
+
if (publishingPresent || publisher.status === 'present') {
|
|
2232
|
+
if (terminalPresent || clearingPresent) {
|
|
2233
|
+
return {
|
|
2234
|
+
status: 'invalid',
|
|
2235
|
+
detail: 'registration intent publication conflicts with a terminal transition',
|
|
2236
|
+
};
|
|
2237
|
+
}
|
|
2238
|
+
if (publisher.status !== 'present') {
|
|
2239
|
+
return {
|
|
2240
|
+
status: 'invalid',
|
|
2241
|
+
detail: 'registration intent publication has no authenticated publisher claim',
|
|
2242
|
+
};
|
|
2243
|
+
}
|
|
2244
|
+
if (pathPresent && publishingPresent) {
|
|
2245
|
+
const registration = readBootstrapRegistrationIntentFile(options, path, path, terminalPath, clearingPath, 2n);
|
|
2246
|
+
const publishing = readBootstrapRegistrationIntentFile(options, publishingPath, path, terminalPath, clearingPath, 2n);
|
|
2247
|
+
if (registration.status !== 'present'
|
|
2248
|
+
|| publishing.status !== 'present'
|
|
2249
|
+
|| registration.lease.record.state !== 'registering'
|
|
2250
|
+
|| publishing.lease.record.state !== 'registering'
|
|
2251
|
+
|| !bootstrapRegistrationPublisherOwnsIntent(publisher.claim, registration.lease.record)
|
|
2252
|
+
|| !bootstrapRegistrationPublisherOwnsIntent(publisher.claim, publishing.lease.record)
|
|
2253
|
+
|| !sameBootstrapRegistrationIntentOwner(registration.lease.record, publishing.lease.record)) {
|
|
2254
|
+
return {
|
|
2255
|
+
status: 'invalid',
|
|
2256
|
+
detail: 'registration intent publication identity is invalid',
|
|
2257
|
+
};
|
|
2258
|
+
}
|
|
2259
|
+
const registrationIdentity = bootstrapRegistrationIntentIdentity(registration.lease, 2n);
|
|
2260
|
+
const publishingIdentity = bootstrapRegistrationIntentIdentity(publishing.lease, 2n);
|
|
2261
|
+
if (registrationIdentity.device !== publishingIdentity.device
|
|
2262
|
+
|| registrationIdentity.inode !== publishingIdentity.inode
|
|
2263
|
+
|| registrationIdentity.size !== publishingIdentity.size
|
|
2264
|
+
|| registrationIdentity.mtimeNs !== publishingIdentity.mtimeNs
|
|
2265
|
+
|| registrationIdentity.ctimeNs !== publishingIdentity.ctimeNs) {
|
|
2266
|
+
return {
|
|
2267
|
+
status: 'invalid',
|
|
2268
|
+
detail: 'registration intent publication does not own the canonical claim',
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2271
|
+
return {
|
|
2272
|
+
status: 'incomplete_publication',
|
|
2273
|
+
publication: {
|
|
2274
|
+
publisher: publisher.claim,
|
|
2275
|
+
publishing: publishingIdentity,
|
|
2276
|
+
registration: {
|
|
2277
|
+
lease: registration.lease,
|
|
2278
|
+
identity: registrationIdentity,
|
|
2279
|
+
},
|
|
2280
|
+
},
|
|
2281
|
+
};
|
|
2282
|
+
}
|
|
2283
|
+
if (pathPresent) {
|
|
2284
|
+
const registration = readBootstrapRegistrationIntentFile(options, path, path, terminalPath, clearingPath);
|
|
2285
|
+
if (registration.status !== 'present'
|
|
2286
|
+
|| registration.lease.record.state !== 'registering'
|
|
2287
|
+
|| !bootstrapRegistrationPublisherOwnsIntent(publisher.claim, registration.lease.record)) {
|
|
2288
|
+
return {
|
|
2289
|
+
status: 'invalid',
|
|
2290
|
+
detail: 'registration intent canonical publisher claim is invalid',
|
|
2291
|
+
};
|
|
2292
|
+
}
|
|
2293
|
+
return {
|
|
2294
|
+
status: 'incomplete_publication',
|
|
2295
|
+
publication: {
|
|
2296
|
+
publisher: publisher.claim,
|
|
2297
|
+
registration: {
|
|
2298
|
+
lease: registration.lease,
|
|
2299
|
+
identity: bootstrapRegistrationIntentIdentity(registration.lease, 1n),
|
|
2300
|
+
},
|
|
2301
|
+
},
|
|
2302
|
+
};
|
|
2303
|
+
}
|
|
2304
|
+
if (publishingPresent) {
|
|
2305
|
+
const publishing = readBootstrapRegistrationIntentFile(options, publishingPath, path, terminalPath, clearingPath);
|
|
2306
|
+
if (publishing.status === 'present') {
|
|
2307
|
+
if (publishing.lease.record.state !== 'registering'
|
|
2308
|
+
|| !bootstrapRegistrationPublisherOwnsIntent(publisher.claim, publishing.lease.record)) {
|
|
2309
|
+
return {
|
|
2310
|
+
status: 'invalid',
|
|
2311
|
+
detail: 'registration intent publication payload is invalid',
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
return {
|
|
2315
|
+
status: 'incomplete_publication',
|
|
2316
|
+
publication: {
|
|
2317
|
+
publisher: publisher.claim,
|
|
2318
|
+
publishing: bootstrapRegistrationIntentIdentity(publishing.lease, 1n),
|
|
2319
|
+
},
|
|
2320
|
+
};
|
|
2321
|
+
}
|
|
2322
|
+
if (publishing.detail === 'registration intent payload is invalid'
|
|
2323
|
+
&& publishing.trustedFile !== undefined) {
|
|
2324
|
+
return {
|
|
2325
|
+
status: 'incomplete_publication',
|
|
2326
|
+
publication: {
|
|
2327
|
+
publisher: publisher.claim,
|
|
2328
|
+
publishing: publishing.trustedFile,
|
|
2329
|
+
},
|
|
2330
|
+
};
|
|
2331
|
+
}
|
|
2332
|
+
return { status: 'invalid', detail: publishing.detail };
|
|
2333
|
+
}
|
|
2334
|
+
return {
|
|
2335
|
+
status: 'incomplete_publication',
|
|
2336
|
+
publication: { publisher: publisher.claim },
|
|
2337
|
+
};
|
|
2338
|
+
}
|
|
2339
|
+
if (clearingPresent && (pathPresent || terminalPresent)) {
|
|
2340
|
+
return { status: 'invalid', detail: 'registration intent clearing conflicts with another state' };
|
|
2341
|
+
}
|
|
2342
|
+
if (clearingPresent) {
|
|
2343
|
+
const clearing = readBootstrapRegistrationIntentFile(options, clearingPath, path, terminalPath, clearingPath);
|
|
2344
|
+
if (clearing.status !== 'present')
|
|
2345
|
+
return { status: 'invalid', detail: clearing.detail };
|
|
2346
|
+
if (clearing.lease.record.state !== 'terminal') {
|
|
2347
|
+
return { status: 'invalid', detail: 'registration clearing receipt is invalid' };
|
|
2348
|
+
}
|
|
2349
|
+
return { status: 'present', lease: clearing.lease };
|
|
2350
|
+
}
|
|
2351
|
+
const registration = pathPresent
|
|
2352
|
+
? readBootstrapRegistrationIntentFile(options, path, path, terminalPath, clearingPath)
|
|
2353
|
+
: undefined;
|
|
2354
|
+
const terminal = terminalPresent
|
|
2355
|
+
? readBootstrapRegistrationIntentFile(options, terminalPath, path, terminalPath, clearingPath)
|
|
2356
|
+
: undefined;
|
|
2357
|
+
if (registration && terminal) {
|
|
2358
|
+
if (registration.status !== 'present' || registration.lease.record.state !== 'registering') {
|
|
2359
|
+
return { status: 'invalid', detail: 'registration intent source is invalid during terminal publication' };
|
|
2360
|
+
}
|
|
2361
|
+
if (terminal.status === 'invalid') {
|
|
2362
|
+
if (!terminal.trustedFile)
|
|
2363
|
+
return { status: 'invalid', detail: terminal.detail };
|
|
2364
|
+
return {
|
|
2365
|
+
status: 'incomplete_terminal',
|
|
2366
|
+
registering: registration.lease,
|
|
2367
|
+
terminalFile: terminal.trustedFile,
|
|
2368
|
+
};
|
|
2369
|
+
}
|
|
2370
|
+
if (terminal.lease.record.state !== 'terminal'
|
|
2371
|
+
|| !sameBootstrapRegistrationIntentOwner(registration.lease.record, terminal.lease.record)) {
|
|
2372
|
+
return { status: 'invalid', detail: 'registration terminal receipt has a different owner' };
|
|
2373
|
+
}
|
|
2374
|
+
return {
|
|
2375
|
+
status: 'present',
|
|
2376
|
+
lease: {
|
|
2377
|
+
...terminal.lease,
|
|
2378
|
+
staleRegistering: {
|
|
2379
|
+
path,
|
|
2380
|
+
device: registration.lease.device,
|
|
2381
|
+
inode: registration.lease.inode,
|
|
2382
|
+
size: registration.lease.size,
|
|
2383
|
+
mtimeNs: registration.lease.mtimeNs,
|
|
2384
|
+
ctimeNs: registration.lease.ctimeNs,
|
|
2385
|
+
},
|
|
2386
|
+
},
|
|
2387
|
+
};
|
|
2388
|
+
}
|
|
2389
|
+
if (registration) {
|
|
2390
|
+
if (registration.status !== 'present')
|
|
2391
|
+
return { status: 'invalid', detail: registration.detail };
|
|
2392
|
+
if (registration.lease.record.state !== 'registering') {
|
|
2393
|
+
return { status: 'invalid', detail: 'registration intent payload is invalid' };
|
|
2394
|
+
}
|
|
2395
|
+
return { status: 'present', lease: registration.lease };
|
|
2396
|
+
}
|
|
2397
|
+
if (!terminal)
|
|
2398
|
+
return { status: 'invalid', detail: 'registration terminal receipt is absent' };
|
|
2399
|
+
if (terminal.status !== 'present')
|
|
2400
|
+
return { status: 'invalid', detail: terminal.detail };
|
|
2401
|
+
if (terminal.lease.record.state !== 'terminal') {
|
|
2402
|
+
return { status: 'invalid', detail: 'registration terminal receipt is invalid' };
|
|
2403
|
+
}
|
|
2404
|
+
return { status: 'present', lease: terminal.lease };
|
|
2405
|
+
}
|
|
2406
|
+
catch (error) {
|
|
2407
|
+
return {
|
|
2408
|
+
status: 'invalid',
|
|
2409
|
+
detail: boundedBootstrapDetail(`registration intent is unreadable: ${error instanceof Error ? error.message : String(error)}`),
|
|
2410
|
+
};
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
function writeBootstrapRegistrationIntentBytes(descriptor, content, start, end) {
|
|
2414
|
+
let offset = start;
|
|
2415
|
+
while (offset < end) {
|
|
2416
|
+
const written = writeSync(descriptor, content, offset, end - offset, null);
|
|
2417
|
+
if (written <= 0)
|
|
2418
|
+
throw new Error('bootstrap registration intent write made no progress');
|
|
2419
|
+
offset += written;
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
function createBootstrapRegistrationPublisherClaim(options, stateDir, owner) {
|
|
2423
|
+
const processIdentityDigest = bootstrapRegistrationProcessIdentityDigest(owner.processStartIdentity);
|
|
2424
|
+
const path = join(stateDir, `${BOOTSTRAP_REGISTRATION_INTENT_PUBLISHER_PREFIX}${owner.pid}.${owner.token}.${processIdentityDigest}`);
|
|
2425
|
+
let descriptor;
|
|
2426
|
+
let opened;
|
|
2427
|
+
try {
|
|
2428
|
+
descriptor = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
2429
|
+
fsyncSync(descriptor);
|
|
2430
|
+
opened = fstatSync(descriptor, { bigint: true });
|
|
2431
|
+
if (!opened.isFile() || opened.nlink !== 1n || opened.size !== 0n
|
|
2432
|
+
|| opened.dev <= 0n || opened.ino <= 0n
|
|
2433
|
+
|| (process.platform !== 'win32' && (opened.mode & 63n) !== 0n)) {
|
|
2434
|
+
throw new Error('unsafe bootstrap registration publisher claim');
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
finally {
|
|
2438
|
+
if (descriptor !== undefined)
|
|
2439
|
+
closeSync(descriptor);
|
|
2440
|
+
}
|
|
2441
|
+
if (!opened)
|
|
2442
|
+
throw new Error('bootstrap registration publisher claim failed');
|
|
2443
|
+
syncBootstrapDirectory(stateDir);
|
|
2444
|
+
assertBootstrapIntentFileTrust(options, path);
|
|
2445
|
+
const settled = lstatSync(path, { bigint: true });
|
|
2446
|
+
if (!settled.isFile() || settled.isSymbolicLink() || settled.nlink !== 1n
|
|
2447
|
+
|| settled.size !== 0n || settled.dev !== opened.dev || settled.ino !== opened.ino
|
|
2448
|
+
|| settled.mtimeNs !== opened.mtimeNs || settled.ctimeNs !== opened.ctimeNs) {
|
|
2449
|
+
throw new Error('bootstrap registration publisher claim is unconfirmed');
|
|
2450
|
+
}
|
|
2451
|
+
return {
|
|
2452
|
+
path,
|
|
2453
|
+
pid: owner.pid,
|
|
2454
|
+
token: owner.token,
|
|
2455
|
+
processIdentityDigest,
|
|
2456
|
+
identity: {
|
|
2457
|
+
path,
|
|
2458
|
+
device: settled.dev,
|
|
2459
|
+
inode: settled.ino,
|
|
2460
|
+
linkCount: 1n,
|
|
2461
|
+
size: 0n,
|
|
2462
|
+
mtimeNs: settled.mtimeNs,
|
|
2463
|
+
ctimeNs: settled.ctimeNs,
|
|
2464
|
+
},
|
|
2465
|
+
};
|
|
2466
|
+
}
|
|
2467
|
+
function retireBootstrapRegistrationPublisherClaim(options, publisher) {
|
|
2468
|
+
const stateDir = dirname(publisher.path);
|
|
2469
|
+
assertBootstrapRegistrationIntentFileIdentity(options, publisher.identity);
|
|
2470
|
+
rmSync(publisher.path);
|
|
2471
|
+
syncBootstrapDirectory(stateDir);
|
|
2472
|
+
const remaining = readBootstrapRegistrationPublisherClaim(options, stateDir);
|
|
2473
|
+
if (remaining.status !== 'absent') {
|
|
2474
|
+
throw new Error('bootstrap registration publisher claim retirement is unconfirmed');
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
function publishBootstrapRegistrationIntent(options) {
|
|
2478
|
+
const stateDir = resolveBootstrapStateDir(options.env);
|
|
2479
|
+
assertBootstrapIntentDirectoryTrust(options, stateDir, false);
|
|
2480
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
2481
|
+
assertBootstrapIntentDirectoryTrust(options, stateDir, true);
|
|
2482
|
+
const path = join(stateDir, BOOTSTRAP_REGISTRATION_INTENT_FILE);
|
|
2483
|
+
const publishingPath = join(stateDir, BOOTSTRAP_REGISTRATION_INTENT_PUBLISHING_FILE);
|
|
2484
|
+
const terminalPath = join(stateDir, BOOTSTRAP_REGISTRATION_INTENT_TERMINAL_FILE);
|
|
2485
|
+
const clearingPath = join(stateDir, BOOTSTRAP_REGISTRATION_INTENT_CLEARING_FILE);
|
|
2486
|
+
options.beforeIntentPublish?.();
|
|
2487
|
+
const publicationLock = acquireBootstrapRegistrationRecoveryLock(options);
|
|
2488
|
+
try {
|
|
2489
|
+
for (const unavailablePath of [path, publishingPath, terminalPath, clearingPath]) {
|
|
2490
|
+
try {
|
|
2491
|
+
lstatSync(unavailablePath);
|
|
2492
|
+
throw new Error('bootstrap registration intent transition is incomplete');
|
|
2493
|
+
}
|
|
2494
|
+
catch (error) {
|
|
2495
|
+
if (!isErrno(error, 'ENOENT'))
|
|
2496
|
+
throw error;
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
if (readBootstrapRegistrationPublisherClaim(options, stateDir).status !== 'absent') {
|
|
2500
|
+
throw new Error('bootstrap registration publisher claim already exists');
|
|
2501
|
+
}
|
|
2502
|
+
const token = randomUUID();
|
|
2503
|
+
const createdAtMs = options.now ?? Date.now();
|
|
2504
|
+
if (!Number.isSafeInteger(createdAtMs) || createdAtMs < 0) {
|
|
2505
|
+
throw new Error('invalid bootstrap registration intent clock');
|
|
2506
|
+
}
|
|
2507
|
+
const processStartIdentity = readBootstrapRegistrationProcessStartIdentity(options, process.pid);
|
|
2508
|
+
if (processStartIdentity === null) {
|
|
2509
|
+
throw new Error('bootstrap registration parent process identity is unavailable');
|
|
2510
|
+
}
|
|
2511
|
+
const record = {
|
|
2512
|
+
schema: BOOTSTRAP_REGISTRATION_INTENT_SCHEMA,
|
|
2513
|
+
state: 'registering',
|
|
2514
|
+
owner: { pid: process.pid, token, processStartIdentity },
|
|
2515
|
+
createdAt: new Date(createdAtMs).toISOString(),
|
|
2516
|
+
};
|
|
2517
|
+
const content = Buffer.from(`${JSON.stringify(record)}\n`, 'utf8');
|
|
2518
|
+
if (content.byteLength > MAX_BOOTSTRAP_REGISTRATION_INTENT_BYTES) {
|
|
2519
|
+
throw new Error('bootstrap registration intent publication is oversized');
|
|
2520
|
+
}
|
|
2521
|
+
const publisher = createBootstrapRegistrationPublisherClaim(options, stateDir, record.owner);
|
|
2522
|
+
let descriptor;
|
|
2523
|
+
let opened;
|
|
2524
|
+
try {
|
|
2525
|
+
descriptor = openSync(publishingPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
2526
|
+
options.afterIntentPublicationStep?.('create', publishingPath);
|
|
2527
|
+
const split = Math.max(1, Math.floor(content.byteLength / 2));
|
|
2528
|
+
writeBootstrapRegistrationIntentBytes(descriptor, content, 0, split);
|
|
2529
|
+
options.afterIntentPublicationStep?.('partial_write', publishingPath);
|
|
2530
|
+
writeBootstrapRegistrationIntentBytes(descriptor, content, split, content.byteLength);
|
|
2531
|
+
fsyncSync(descriptor);
|
|
2532
|
+
options.afterIntentPublicationStep?.('file_fsync', publishingPath);
|
|
2533
|
+
opened = fstatSync(descriptor, { bigint: true });
|
|
2534
|
+
if (!opened.isFile() || opened.nlink !== 1n || opened.dev <= 0n || opened.ino <= 0n
|
|
2535
|
+
|| opened.size !== BigInt(content.byteLength)
|
|
2536
|
+
|| (process.platform !== 'win32' && (opened.mode & 63n) !== 0n)) {
|
|
2537
|
+
throw new Error('unsafe bootstrap registration intent publication');
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
finally {
|
|
2541
|
+
if (descriptor !== undefined)
|
|
2542
|
+
closeSync(descriptor);
|
|
2543
|
+
}
|
|
2544
|
+
if (!opened)
|
|
2545
|
+
throw new Error('bootstrap registration intent publication failed');
|
|
2546
|
+
assertBootstrapIntentFileTrust(options, publishingPath);
|
|
2547
|
+
const prepared = lstatSync(publishingPath, { bigint: true });
|
|
2548
|
+
if (!prepared.isFile() || prepared.isSymbolicLink() || prepared.nlink !== 1n
|
|
2549
|
+
|| prepared.dev !== opened.dev || prepared.ino !== opened.ino
|
|
2550
|
+
|| prepared.size !== opened.size || prepared.mtimeNs !== opened.mtimeNs
|
|
2551
|
+
|| prepared.ctimeNs !== opened.ctimeNs) {
|
|
2552
|
+
throw new Error('bootstrap registration intent temp changed before publication');
|
|
2553
|
+
}
|
|
2554
|
+
linkSync(publishingPath, path);
|
|
2555
|
+
options.afterIntentPublicationStep?.('link', path);
|
|
2556
|
+
syncBootstrapDirectory(stateDir);
|
|
2557
|
+
options.afterIntentPublicationStep?.('directory_fsync', stateDir);
|
|
2558
|
+
assertBootstrapIntentFileTrust(options, publishingPath, 2);
|
|
2559
|
+
assertBootstrapIntentFileTrust(options, path, 2);
|
|
2560
|
+
const linkedTemp = lstatSync(publishingPath, { bigint: true });
|
|
2561
|
+
const linkedCanonical = lstatSync(path, { bigint: true });
|
|
2562
|
+
if (!linkedTemp.isFile() || linkedTemp.isSymbolicLink() || linkedTemp.nlink !== 2n
|
|
2563
|
+
|| !linkedCanonical.isFile() || linkedCanonical.isSymbolicLink()
|
|
2564
|
+
|| linkedCanonical.nlink !== 2n
|
|
2565
|
+
|| linkedTemp.dev !== opened.dev || linkedTemp.ino !== opened.ino
|
|
2566
|
+
|| linkedCanonical.dev !== opened.dev || linkedCanonical.ino !== opened.ino
|
|
2567
|
+
|| linkedTemp.size !== opened.size || linkedCanonical.size !== opened.size
|
|
2568
|
+
|| linkedTemp.mtimeNs !== opened.mtimeNs || linkedCanonical.mtimeNs !== opened.mtimeNs) {
|
|
2569
|
+
throw new Error('bootstrap registration intent canonical claim is unconfirmed');
|
|
2570
|
+
}
|
|
2571
|
+
rmSync(publishingPath);
|
|
2572
|
+
syncBootstrapDirectory(stateDir);
|
|
2573
|
+
if (bootstrapStateEntryPresent(publishingPath)) {
|
|
2574
|
+
throw new Error('bootstrap registration intent temp retirement is unconfirmed');
|
|
2575
|
+
}
|
|
2576
|
+
assertBootstrapIntentFileTrust(options, path);
|
|
2577
|
+
const published = lstatSync(path, { bigint: true });
|
|
2578
|
+
if (!published.isFile() || published.isSymbolicLink() || published.nlink !== 1n
|
|
2579
|
+
|| published.dev !== opened.dev || published.ino !== opened.ino
|
|
2580
|
+
|| published.size !== opened.size || published.mtimeNs !== opened.mtimeNs) {
|
|
2581
|
+
throw new Error('bootstrap registration intent changed during publication');
|
|
2582
|
+
}
|
|
2583
|
+
retireBootstrapRegistrationPublisherClaim(options, publisher);
|
|
2584
|
+
return {
|
|
2585
|
+
path,
|
|
2586
|
+
terminalPath,
|
|
2587
|
+
clearingPath,
|
|
2588
|
+
currentPath: path,
|
|
2589
|
+
device: published.dev,
|
|
2590
|
+
inode: published.ino,
|
|
2591
|
+
size: published.size,
|
|
2592
|
+
mtimeNs: published.mtimeNs,
|
|
2593
|
+
ctimeNs: published.ctimeNs,
|
|
2594
|
+
record,
|
|
2595
|
+
};
|
|
2596
|
+
}
|
|
2597
|
+
finally {
|
|
2598
|
+
publicationLock.release();
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
function terminalizeBootstrapRegistrationIntent(options, lease, outcome, transactionId) {
|
|
2602
|
+
if (outcome === 'committed' && (!transactionId || !BOOTSTRAP_UUID_RE.test(transactionId))) {
|
|
2603
|
+
throw new Error('bootstrap registration intent commit identity is invalid');
|
|
2604
|
+
}
|
|
2605
|
+
if (outcome !== 'committed' && transactionId !== undefined) {
|
|
2606
|
+
throw new Error('bootstrap registration intent terminal identity is unexpected');
|
|
2607
|
+
}
|
|
2608
|
+
if (lease.record.state !== 'registering' || lease.currentPath !== lease.path) {
|
|
2609
|
+
throw new Error('bootstrap registration intent is not an active registration');
|
|
2610
|
+
}
|
|
2611
|
+
assertBootstrapIntentDirectoryTrust(options, dirname(lease.currentPath), true);
|
|
2612
|
+
assertBootstrapIntentFileTrust(options, lease.currentPath);
|
|
2613
|
+
const current = lstatSync(lease.currentPath, { bigint: true });
|
|
2614
|
+
if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1n
|
|
2615
|
+
|| current.dev !== lease.device || current.ino !== lease.inode || current.size !== lease.size
|
|
2616
|
+
|| current.mtimeNs !== lease.mtimeNs || current.ctimeNs !== lease.ctimeNs) {
|
|
2617
|
+
throw new Error('bootstrap registration intent ownership changed');
|
|
2618
|
+
}
|
|
2619
|
+
const terminalAtMs = options.now ?? Date.now();
|
|
2620
|
+
if (!Number.isSafeInteger(terminalAtMs) || terminalAtMs < 0) {
|
|
2621
|
+
throw new Error('invalid bootstrap registration intent terminal clock');
|
|
2622
|
+
}
|
|
2623
|
+
const record = {
|
|
2624
|
+
schema: BOOTSTRAP_REGISTRATION_INTENT_SCHEMA,
|
|
2625
|
+
state: 'terminal',
|
|
2626
|
+
owner: lease.record.owner,
|
|
2627
|
+
createdAt: lease.record.createdAt,
|
|
2628
|
+
outcome,
|
|
2629
|
+
terminalAt: new Date(terminalAtMs).toISOString(),
|
|
2630
|
+
...(transactionId !== undefined ? { transactionId } : {}),
|
|
2631
|
+
};
|
|
2632
|
+
const content = `${JSON.stringify(record)}\n`;
|
|
2633
|
+
if (Buffer.byteLength(content, 'utf8') > MAX_BOOTSTRAP_REGISTRATION_INTENT_BYTES) {
|
|
2634
|
+
throw new Error('bootstrap registration intent terminal receipt is oversized');
|
|
2635
|
+
}
|
|
2636
|
+
for (const unavailablePath of [lease.terminalPath, lease.clearingPath]) {
|
|
2637
|
+
try {
|
|
2638
|
+
lstatSync(unavailablePath);
|
|
2639
|
+
throw new Error('bootstrap registration intent terminal path exists');
|
|
2640
|
+
}
|
|
2641
|
+
catch (error) {
|
|
2642
|
+
if (!isErrno(error, 'ENOENT'))
|
|
2643
|
+
throw error;
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
let descriptor;
|
|
2647
|
+
let settled;
|
|
56
2648
|
try {
|
|
57
|
-
|
|
2649
|
+
descriptor = openSync(lease.terminalPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
2650
|
+
writeFileSync(descriptor, content, { encoding: 'utf8' });
|
|
2651
|
+
options.beforeIntentTerminalFsync?.(lease.terminalPath);
|
|
2652
|
+
fsyncSync(descriptor);
|
|
2653
|
+
settled = fstatSync(descriptor, { bigint: true });
|
|
2654
|
+
if (!settled.isFile() || settled.nlink !== 1n
|
|
2655
|
+
|| settled.dev <= 0n || settled.ino <= 0n
|
|
2656
|
+
|| settled.size !== BigInt(Buffer.byteLength(content, 'utf8'))) {
|
|
2657
|
+
throw new Error('bootstrap registration intent terminalization is unsafe');
|
|
2658
|
+
}
|
|
58
2659
|
}
|
|
59
|
-
|
|
60
|
-
|
|
2660
|
+
finally {
|
|
2661
|
+
if (descriptor !== undefined)
|
|
2662
|
+
closeSync(descriptor);
|
|
2663
|
+
}
|
|
2664
|
+
if (!settled)
|
|
2665
|
+
throw new Error('bootstrap registration intent terminal publication failed');
|
|
2666
|
+
assertBootstrapIntentFileTrust(options, lease.terminalPath);
|
|
2667
|
+
const after = lstatSync(lease.terminalPath, { bigint: true });
|
|
2668
|
+
if (!after.isFile() || after.isSymbolicLink() || after.nlink !== 1n
|
|
2669
|
+
|| after.dev !== settled.dev || after.ino !== settled.ino
|
|
2670
|
+
|| after.size !== settled.size || after.mtimeNs !== settled.mtimeNs
|
|
2671
|
+
|| after.ctimeNs !== settled.ctimeNs) {
|
|
2672
|
+
throw new Error('bootstrap registration intent changed after terminalization');
|
|
2673
|
+
}
|
|
2674
|
+
const stateDir = dirname(lease.currentPath);
|
|
2675
|
+
syncBootstrapDirectory(stateDir);
|
|
2676
|
+
options.afterIntentTerminalPublish?.();
|
|
2677
|
+
const active = lstatSync(lease.path, { bigint: true });
|
|
2678
|
+
if (!active.isFile() || active.isSymbolicLink() || active.nlink !== 1n
|
|
2679
|
+
|| active.dev !== lease.device || active.ino !== lease.inode || active.size !== lease.size
|
|
2680
|
+
|| active.mtimeNs !== lease.mtimeNs || active.ctimeNs !== lease.ctimeNs) {
|
|
2681
|
+
throw new Error('bootstrap registration intent source changed before retirement');
|
|
2682
|
+
}
|
|
2683
|
+
rmSync(lease.path);
|
|
2684
|
+
syncBootstrapDirectory(stateDir);
|
|
2685
|
+
if (bootstrapStateEntryPresent(lease.path)) {
|
|
2686
|
+
throw new Error('bootstrap registration intent source retirement is unconfirmed');
|
|
61
2687
|
}
|
|
2688
|
+
return {
|
|
2689
|
+
...lease,
|
|
2690
|
+
currentPath: lease.terminalPath,
|
|
2691
|
+
device: settled.dev,
|
|
2692
|
+
inode: settled.ino,
|
|
2693
|
+
size: settled.size,
|
|
2694
|
+
mtimeNs: settled.mtimeNs,
|
|
2695
|
+
ctimeNs: settled.ctimeNs,
|
|
2696
|
+
record,
|
|
2697
|
+
};
|
|
62
2698
|
}
|
|
63
|
-
function
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
67
|
-
return undefined;
|
|
68
|
-
return parsed;
|
|
2699
|
+
function clearBootstrapRegistrationIntent(options, lease) {
|
|
2700
|
+
if (lease.record.state !== 'terminal') {
|
|
2701
|
+
throw new Error('bootstrap registration intent is not terminal');
|
|
69
2702
|
}
|
|
70
|
-
|
|
71
|
-
|
|
2703
|
+
const stateDir = dirname(lease.currentPath);
|
|
2704
|
+
assertBootstrapIntentDirectoryTrust(options, stateDir, true);
|
|
2705
|
+
if (lease.staleRegistering) {
|
|
2706
|
+
assertBootstrapIntentFileTrust(options, lease.staleRegistering.path);
|
|
2707
|
+
const stale = lstatSync(lease.staleRegistering.path, { bigint: true });
|
|
2708
|
+
if (!stale.isFile() || stale.isSymbolicLink() || stale.nlink !== 1n
|
|
2709
|
+
|| stale.dev !== lease.staleRegistering.device || stale.ino !== lease.staleRegistering.inode
|
|
2710
|
+
|| stale.size !== lease.staleRegistering.size
|
|
2711
|
+
|| stale.mtimeNs !== lease.staleRegistering.mtimeNs
|
|
2712
|
+
|| stale.ctimeNs !== lease.staleRegistering.ctimeNs) {
|
|
2713
|
+
throw new Error('bootstrap registration intent source changed before recovery retirement');
|
|
2714
|
+
}
|
|
2715
|
+
rmSync(lease.staleRegistering.path);
|
|
2716
|
+
syncBootstrapDirectory(stateDir);
|
|
2717
|
+
if (bootstrapStateEntryPresent(lease.staleRegistering.path)) {
|
|
2718
|
+
throw new Error('bootstrap registration intent source recovery retirement is unconfirmed');
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
assertBootstrapIntentFileTrust(options, lease.currentPath);
|
|
2722
|
+
const current = lstatSync(lease.currentPath, { bigint: true });
|
|
2723
|
+
if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1n
|
|
2724
|
+
|| current.dev !== lease.device || current.ino !== lease.inode || current.size !== lease.size
|
|
2725
|
+
|| current.mtimeNs !== lease.mtimeNs || current.ctimeNs !== lease.ctimeNs) {
|
|
2726
|
+
throw new Error('bootstrap registration intent ownership changed');
|
|
2727
|
+
}
|
|
2728
|
+
let clearingLease = lease;
|
|
2729
|
+
if (lease.currentPath === lease.terminalPath) {
|
|
2730
|
+
try {
|
|
2731
|
+
lstatSync(lease.clearingPath);
|
|
2732
|
+
throw new Error('bootstrap registration intent clearing path exists');
|
|
2733
|
+
}
|
|
2734
|
+
catch (error) {
|
|
2735
|
+
if (!isErrno(error, 'ENOENT'))
|
|
2736
|
+
throw error;
|
|
2737
|
+
}
|
|
2738
|
+
renameSync(lease.terminalPath, lease.clearingPath);
|
|
2739
|
+
syncBootstrapDirectory(stateDir);
|
|
2740
|
+
const renamed = lstatSync(lease.clearingPath, { bigint: true });
|
|
2741
|
+
if (!renamed.isFile() || renamed.isSymbolicLink() || renamed.nlink !== 1n
|
|
2742
|
+
|| renamed.dev !== lease.device || renamed.ino !== lease.inode
|
|
2743
|
+
|| renamed.size !== lease.size) {
|
|
2744
|
+
throw new Error('bootstrap registration intent clear rename changed ownership');
|
|
2745
|
+
}
|
|
2746
|
+
clearingLease = {
|
|
2747
|
+
...lease,
|
|
2748
|
+
currentPath: lease.clearingPath,
|
|
2749
|
+
size: renamed.size,
|
|
2750
|
+
mtimeNs: renamed.mtimeNs,
|
|
2751
|
+
ctimeNs: renamed.ctimeNs,
|
|
2752
|
+
};
|
|
2753
|
+
options.afterIntentClearRename?.();
|
|
2754
|
+
}
|
|
2755
|
+
else if (lease.currentPath !== lease.clearingPath) {
|
|
2756
|
+
throw new Error('bootstrap registration intent path is unexpected');
|
|
2757
|
+
}
|
|
2758
|
+
assertBootstrapIntentFileTrust(options, clearingLease.currentPath);
|
|
2759
|
+
const moved = lstatSync(clearingLease.currentPath, { bigint: true });
|
|
2760
|
+
if (!moved.isFile() || moved.isSymbolicLink() || moved.nlink !== 1n
|
|
2761
|
+
|| moved.dev !== clearingLease.device || moved.ino !== clearingLease.inode
|
|
2762
|
+
|| moved.size !== clearingLease.size || moved.mtimeNs !== clearingLease.mtimeNs
|
|
2763
|
+
|| moved.ctimeNs !== clearingLease.ctimeNs) {
|
|
2764
|
+
throw new Error('bootstrap registration intent clear changed ownership');
|
|
2765
|
+
}
|
|
2766
|
+
rmSync(clearingLease.currentPath);
|
|
2767
|
+
syncBootstrapDirectory(stateDir);
|
|
2768
|
+
if (bootstrapStateEntryPresent(lease.path)
|
|
2769
|
+
|| bootstrapStateEntryPresent(lease.terminalPath)
|
|
2770
|
+
|| bootstrapStateEntryPresent(lease.clearingPath)) {
|
|
2771
|
+
throw new Error('bootstrap registration intent clear is unconfirmed');
|
|
72
2772
|
}
|
|
73
2773
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
/** True when a recent bootstrap/migration attempt failed within the cooldown window. */
|
|
80
|
-
export function recentBootstrapFailure(env, readFile, now) {
|
|
81
|
-
const attempt = readBootstrapAttempt(env, readFile);
|
|
82
|
-
if (!attempt)
|
|
2774
|
+
function sameBootstrapIncompletePublication(left, right) {
|
|
2775
|
+
if (left.publisher.pid !== right.publisher.pid
|
|
2776
|
+
|| left.publisher.token !== right.publisher.token
|
|
2777
|
+
|| left.publisher.processIdentityDigest !== right.publisher.processIdentityDigest
|
|
2778
|
+
|| !sameBootstrapRegistrationIntentFileIdentity(left.publisher.identity, right.publisher.identity)) {
|
|
83
2779
|
return false;
|
|
84
|
-
|
|
2780
|
+
}
|
|
2781
|
+
if ((left.publishing === undefined) !== (right.publishing === undefined))
|
|
85
2782
|
return false;
|
|
86
|
-
if (
|
|
2783
|
+
if (left.publishing && right.publishing
|
|
2784
|
+
&& !sameBootstrapRegistrationIntentFileIdentity(left.publishing, right.publishing)) {
|
|
87
2785
|
return false;
|
|
88
|
-
|
|
89
|
-
if (
|
|
2786
|
+
}
|
|
2787
|
+
if ((left.registration === undefined) !== (right.registration === undefined))
|
|
90
2788
|
return false;
|
|
91
|
-
|
|
2789
|
+
if (!left.registration || !right.registration)
|
|
2790
|
+
return true;
|
|
2791
|
+
return sameBootstrapRegistrationIntentFileIdentity(left.registration.identity, right.registration.identity) && sameBootstrapRegistrationIntentOwner(left.registration.lease.record, right.registration.lease.record);
|
|
92
2792
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (bootstrapSwitch === '0' || bootstrapSwitch === 'off')
|
|
102
|
-
return { proceed: false, reason: 'bootstrap_disabled' };
|
|
103
|
-
if (isSelfUpdateExplicit(env) && resolveSelfUpdatePolicy(env) !== 'auto') {
|
|
104
|
-
return { proceed: false, reason: 'policy_not_auto' };
|
|
2793
|
+
function assertBootstrapRegistrationIntentFileIdentity(options, expected) {
|
|
2794
|
+
assertBootstrapIntentFileTrust(options, expected.path, Number(expected.linkCount));
|
|
2795
|
+
const current = lstatSync(expected.path, { bigint: true });
|
|
2796
|
+
if (!current.isFile() || current.isSymbolicLink() || current.nlink !== expected.linkCount
|
|
2797
|
+
|| current.dev !== expected.device || current.ino !== expected.inode
|
|
2798
|
+
|| current.size !== expected.size || current.mtimeNs !== expected.mtimeNs
|
|
2799
|
+
|| current.ctimeNs !== expected.ctimeNs) {
|
|
2800
|
+
throw new Error('incomplete registration publication identity changed');
|
|
105
2801
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
// EVOLVER_SELF_UPDATE_TARGET_PATH keeps the target bindable and bypasses this guard.
|
|
114
|
-
try {
|
|
115
|
-
resolveSelfUpdateTarget({ env, processExecPath: options.execPath });
|
|
2802
|
+
}
|
|
2803
|
+
function clearBootstrapIncompletePublication(options, publication) {
|
|
2804
|
+
const stateDir = resolveBootstrapStateDir(options.env);
|
|
2805
|
+
assertBootstrapIntentDirectoryTrust(options, stateDir, true);
|
|
2806
|
+
assertBootstrapRegistrationIntentFileIdentity(options, publication.publisher.identity);
|
|
2807
|
+
if (publication.publishing) {
|
|
2808
|
+
assertBootstrapRegistrationIntentFileIdentity(options, publication.publishing);
|
|
116
2809
|
}
|
|
117
|
-
|
|
118
|
-
|
|
2810
|
+
if (publication.registration) {
|
|
2811
|
+
assertBootstrapRegistrationIntentFileIdentity(options, publication.registration.identity);
|
|
2812
|
+
rmSync(publication.registration.identity.path);
|
|
2813
|
+
syncBootstrapDirectory(stateDir);
|
|
2814
|
+
if (bootstrapStateEntryPresent(publication.registration.identity.path)) {
|
|
2815
|
+
throw new Error('incomplete canonical registration claim retirement is unconfirmed');
|
|
2816
|
+
}
|
|
2817
|
+
if (publication.publishing
|
|
2818
|
+
&& publication.registration.identity.device === publication.publishing.device
|
|
2819
|
+
&& publication.registration.identity.inode === publication.publishing.inode) {
|
|
2820
|
+
assertBootstrapIntentFileTrust(options, publication.publishing.path);
|
|
2821
|
+
const remaining = lstatSync(publication.publishing.path, { bigint: true });
|
|
2822
|
+
if (!remaining.isFile() || remaining.isSymbolicLink() || remaining.nlink !== 1n
|
|
2823
|
+
|| remaining.dev !== publication.publishing.device
|
|
2824
|
+
|| remaining.ino !== publication.publishing.inode
|
|
2825
|
+
|| remaining.size !== publication.publishing.size
|
|
2826
|
+
|| remaining.mtimeNs !== publication.publishing.mtimeNs) {
|
|
2827
|
+
throw new Error('incomplete registration publication changed after claim retirement');
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
119
2830
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
2831
|
+
if (publication.publishing) {
|
|
2832
|
+
if (!publication.registration
|
|
2833
|
+
|| publication.registration.identity.device !== publication.publishing.device
|
|
2834
|
+
|| publication.registration.identity.inode !== publication.publishing.inode) {
|
|
2835
|
+
assertBootstrapRegistrationIntentFileIdentity(options, publication.publishing);
|
|
2836
|
+
}
|
|
2837
|
+
rmSync(publication.publishing.path);
|
|
2838
|
+
syncBootstrapDirectory(stateDir);
|
|
2839
|
+
if (bootstrapStateEntryPresent(publication.publishing.path)) {
|
|
2840
|
+
throw new Error('incomplete registration publication retirement is unconfirmed');
|
|
2841
|
+
}
|
|
125
2842
|
}
|
|
126
|
-
|
|
127
|
-
|
|
2843
|
+
retireBootstrapRegistrationPublisherClaim(options, publication.publisher);
|
|
2844
|
+
if (bootstrapStateEntryPresent(join(stateDir, BOOTSTRAP_REGISTRATION_INTENT_FILE))
|
|
2845
|
+
|| bootstrapStateEntryPresent(join(stateDir, BOOTSTRAP_REGISTRATION_INTENT_PUBLISHING_FILE))
|
|
2846
|
+
|| bootstrapRegistrationPublisherEntryPresent(stateDir)) {
|
|
2847
|
+
throw new Error('incomplete registration publication retirement is unconfirmed');
|
|
128
2848
|
}
|
|
129
|
-
return { proceed: true };
|
|
130
2849
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (/^evolver(?:\.exe|-(?:darwin-(?:arm64|x64)|linux-(?:arm64|x64)|windows-x64\.exe))?$/.test(executableName)) {
|
|
142
|
-
return { command: execPath, args: ['lifecycle', 'bootstrap'] };
|
|
143
|
-
}
|
|
144
|
-
if (argv1 && basename(argv1).toLowerCase() === 'cli.js') {
|
|
145
|
-
return { command: execPath, args: [argv1, 'lifecycle', 'bootstrap'] };
|
|
2850
|
+
function bootstrapRegistrationOwnerIsStale(status) {
|
|
2851
|
+
return status === 'dead' || status === 'pid_reused';
|
|
2852
|
+
}
|
|
2853
|
+
function recoverBootstrapRegistrationIntent(options) {
|
|
2854
|
+
const intent = readBootstrapRegistrationIntent(options);
|
|
2855
|
+
if (intent.status === 'absent' || intent.status === 'invalid') {
|
|
2856
|
+
return {
|
|
2857
|
+
status: 'blocked',
|
|
2858
|
+
detail: intent.status === 'invalid' ? intent.detail : 'registration intent disappeared during recovery',
|
|
2859
|
+
};
|
|
146
2860
|
}
|
|
147
2861
|
try {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
2862
|
+
if (intent.status === 'incomplete_publication') {
|
|
2863
|
+
const observedPublisherStatus = bootstrapRegistrationPublisherProcessStatus(options, intent.publication.publisher);
|
|
2864
|
+
if (!bootstrapRegistrationOwnerIsStale(observedPublisherStatus)) {
|
|
2865
|
+
return {
|
|
2866
|
+
status: 'blocked',
|
|
2867
|
+
detail: `registration publisher owner is ${observedPublisherStatus}`,
|
|
2868
|
+
};
|
|
2869
|
+
}
|
|
2870
|
+
let recoveryLock;
|
|
2871
|
+
try {
|
|
2872
|
+
recoveryLock = acquireBootstrapRegistrationRecoveryLock(options);
|
|
2873
|
+
}
|
|
2874
|
+
catch {
|
|
2875
|
+
return {
|
|
2876
|
+
status: 'blocked',
|
|
2877
|
+
detail: 'registration publisher or another recovery owns the bootstrap transaction lock',
|
|
2878
|
+
};
|
|
2879
|
+
}
|
|
2880
|
+
try {
|
|
2881
|
+
const lockedIntent = readBootstrapRegistrationIntent(options);
|
|
2882
|
+
if (lockedIntent.status !== 'incomplete_publication'
|
|
2883
|
+
|| !sameBootstrapIncompletePublication(intent.publication, lockedIntent.publication)) {
|
|
2884
|
+
return {
|
|
2885
|
+
status: 'blocked',
|
|
2886
|
+
detail: 'registration publication changed while acquiring recovery ownership',
|
|
2887
|
+
};
|
|
2888
|
+
}
|
|
2889
|
+
const lockedPublisherStatus = bootstrapRegistrationPublisherProcessStatus(options, lockedIntent.publication.publisher);
|
|
2890
|
+
if (!bootstrapRegistrationOwnerIsStale(lockedPublisherStatus)) {
|
|
2891
|
+
return {
|
|
2892
|
+
status: 'blocked',
|
|
2893
|
+
detail: `registration publisher owner became ${lockedPublisherStatus}`,
|
|
2894
|
+
};
|
|
2895
|
+
}
|
|
2896
|
+
const lockedDurable = inspectLifecycleBootstrapDurableState(options.env, { ...options, expectedRecoveryOwner: recoveryLock.owner });
|
|
2897
|
+
if (lockedDurable.status !== 'clean'
|
|
2898
|
+
&& lockedDurable.status !== 'committed'
|
|
2899
|
+
&& lockedDurable.status !== 'manual_transition') {
|
|
2900
|
+
return {
|
|
2901
|
+
status: 'blocked',
|
|
2902
|
+
detail: lockedDurable.status === 'invalid' || lockedDurable.status === 'pending'
|
|
2903
|
+
? lockedDurable.detail
|
|
2904
|
+
: 'incomplete registration publication durable state is unresolved',
|
|
2905
|
+
};
|
|
2906
|
+
}
|
|
2907
|
+
clearBootstrapIncompletePublication(options, lockedIntent.publication);
|
|
2908
|
+
return lockedDurable.status === 'committed'
|
|
2909
|
+
? { status: 'committed', transactionId: lockedDurable.transactionId }
|
|
2910
|
+
: {
|
|
2911
|
+
status: 'safe_terminal',
|
|
2912
|
+
outcome: lockedDurable.status === 'manual_transition'
|
|
2913
|
+
? 'rolled_back'
|
|
2914
|
+
: 'cancelled',
|
|
2915
|
+
};
|
|
2916
|
+
}
|
|
2917
|
+
finally {
|
|
2918
|
+
recoveryLock.release();
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
if (intent.status === 'present' && intent.lease.record.state === 'registering') {
|
|
2922
|
+
const observedStatus = bootstrapRegistrationOwnerProcessStatus(options, intent.lease.record.owner);
|
|
2923
|
+
if (!bootstrapRegistrationOwnerIsStale(observedStatus)) {
|
|
2924
|
+
return {
|
|
2925
|
+
status: 'blocked',
|
|
2926
|
+
detail: `registration intent owner is ${observedStatus}`,
|
|
2927
|
+
};
|
|
2928
|
+
}
|
|
2929
|
+
let recoveryLock;
|
|
2930
|
+
try {
|
|
2931
|
+
recoveryLock = acquireBootstrapRegistrationRecoveryLock(options);
|
|
2932
|
+
}
|
|
2933
|
+
catch {
|
|
2934
|
+
return {
|
|
2935
|
+
status: 'blocked',
|
|
2936
|
+
detail: 'registration child or another recovery owns the bootstrap transaction lock',
|
|
2937
|
+
};
|
|
2938
|
+
}
|
|
2939
|
+
try {
|
|
2940
|
+
const lockedIntent = readBootstrapRegistrationIntent(options);
|
|
2941
|
+
if (lockedIntent.status !== 'present'
|
|
2942
|
+
|| lockedIntent.lease.record.state !== 'registering'
|
|
2943
|
+
|| lockedIntent.lease.currentPath !== intent.lease.currentPath
|
|
2944
|
+
|| lockedIntent.lease.device !== intent.lease.device
|
|
2945
|
+
|| lockedIntent.lease.inode !== intent.lease.inode
|
|
2946
|
+
|| lockedIntent.lease.size !== intent.lease.size
|
|
2947
|
+
|| lockedIntent.lease.mtimeNs !== intent.lease.mtimeNs
|
|
2948
|
+
|| lockedIntent.lease.ctimeNs !== intent.lease.ctimeNs
|
|
2949
|
+
|| !sameBootstrapRegistrationIntentOwner(lockedIntent.lease.record, intent.lease.record)) {
|
|
2950
|
+
return {
|
|
2951
|
+
status: 'blocked',
|
|
2952
|
+
detail: 'registration intent changed while acquiring recovery ownership',
|
|
2953
|
+
};
|
|
2954
|
+
}
|
|
2955
|
+
const lockedOwnerStatus = bootstrapRegistrationOwnerProcessStatus(options, lockedIntent.lease.record.owner);
|
|
2956
|
+
if (!bootstrapRegistrationOwnerIsStale(lockedOwnerStatus)) {
|
|
2957
|
+
return {
|
|
2958
|
+
status: 'blocked',
|
|
2959
|
+
detail: `registration intent owner became ${lockedOwnerStatus}`,
|
|
2960
|
+
};
|
|
2961
|
+
}
|
|
2962
|
+
const lockedDurable = inspectLifecycleBootstrapDurableState(options.env, { ...options, expectedRecoveryOwner: recoveryLock.owner });
|
|
2963
|
+
if (lockedDurable.status !== 'clean'
|
|
2964
|
+
&& lockedDurable.status !== 'committed'
|
|
2965
|
+
&& lockedDurable.status !== 'manual_transition') {
|
|
2966
|
+
return {
|
|
2967
|
+
status: 'blocked',
|
|
2968
|
+
detail: lockedDurable.status === 'invalid' || lockedDurable.status === 'pending'
|
|
2969
|
+
? lockedDurable.detail
|
|
2970
|
+
: 'registration intent durable state is unresolved',
|
|
2971
|
+
};
|
|
2972
|
+
}
|
|
2973
|
+
const terminal = terminalizeBootstrapRegistrationIntent(options, lockedIntent.lease, lockedDurable.status === 'committed'
|
|
2974
|
+
? 'committed'
|
|
2975
|
+
: lockedDurable.status === 'manual_transition'
|
|
2976
|
+
? 'rolled_back'
|
|
2977
|
+
: 'cancelled', lockedDurable.status === 'committed' ? lockedDurable.transactionId : undefined);
|
|
2978
|
+
clearBootstrapRegistrationIntent(options, terminal);
|
|
2979
|
+
return lockedDurable.status === 'committed'
|
|
2980
|
+
? { status: 'committed', transactionId: lockedDurable.transactionId }
|
|
2981
|
+
: {
|
|
2982
|
+
status: 'safe_terminal',
|
|
2983
|
+
outcome: lockedDurable.status === 'manual_transition'
|
|
2984
|
+
? 'rolled_back'
|
|
2985
|
+
: 'cancelled',
|
|
2986
|
+
};
|
|
2987
|
+
}
|
|
2988
|
+
finally {
|
|
2989
|
+
recoveryLock.release();
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
const durable = inspectLifecycleBootstrapDurableState(options.env, options);
|
|
2993
|
+
let lease;
|
|
2994
|
+
if (intent.status === 'incomplete_terminal') {
|
|
2995
|
+
if (durable.status !== 'clean'
|
|
2996
|
+
&& durable.status !== 'committed'
|
|
2997
|
+
&& durable.status !== 'manual_transition') {
|
|
2998
|
+
return {
|
|
2999
|
+
status: 'blocked',
|
|
3000
|
+
detail: durable.status === 'invalid' || durable.status === 'pending'
|
|
3001
|
+
? durable.detail
|
|
3002
|
+
: 'incomplete terminal receipt has no safe durable proof',
|
|
3003
|
+
};
|
|
3004
|
+
}
|
|
3005
|
+
assertBootstrapIntentFileTrust(options, intent.terminalFile.path);
|
|
3006
|
+
const partial = lstatSync(intent.terminalFile.path, { bigint: true });
|
|
3007
|
+
if (!partial.isFile() || partial.isSymbolicLink() || partial.nlink !== 1n
|
|
3008
|
+
|| partial.dev !== intent.terminalFile.device || partial.ino !== intent.terminalFile.inode) {
|
|
3009
|
+
throw new Error('incomplete terminal receipt ownership changed');
|
|
3010
|
+
}
|
|
3011
|
+
rmSync(intent.terminalFile.path);
|
|
3012
|
+
syncBootstrapDirectory(dirname(intent.terminalFile.path));
|
|
3013
|
+
if (bootstrapStateEntryPresent(intent.terminalFile.path)) {
|
|
3014
|
+
throw new Error('incomplete terminal receipt retirement is unconfirmed');
|
|
3015
|
+
}
|
|
3016
|
+
lease = terminalizeBootstrapRegistrationIntent(options, intent.registering, durable.status === 'committed'
|
|
3017
|
+
? 'committed'
|
|
3018
|
+
: durable.status === 'manual_transition'
|
|
3019
|
+
? 'rolled_back'
|
|
3020
|
+
: 'no_child', durable.status === 'committed' ? durable.transactionId : undefined);
|
|
3021
|
+
}
|
|
3022
|
+
else {
|
|
3023
|
+
lease = intent.lease;
|
|
3024
|
+
}
|
|
3025
|
+
if (lease.record.state === 'registering') {
|
|
3026
|
+
if (durable.status === 'manual_transition') {
|
|
3027
|
+
lease = terminalizeBootstrapRegistrationIntent(options, lease, 'rolled_back');
|
|
3028
|
+
}
|
|
3029
|
+
else if (durable.status !== 'committed') {
|
|
3030
|
+
const detail = durable.status === 'clean'
|
|
3031
|
+
? 'registration intent has no durable child completion proof'
|
|
3032
|
+
: durable.detail;
|
|
3033
|
+
return { status: 'blocked', detail };
|
|
3034
|
+
}
|
|
3035
|
+
else {
|
|
3036
|
+
lease = terminalizeBootstrapRegistrationIntent(options, lease, 'committed', durable.transactionId);
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
const terminal = lease.record;
|
|
3040
|
+
if (terminal.state !== 'terminal') {
|
|
3041
|
+
return { status: 'blocked', detail: 'registration intent did not reach a terminal state' };
|
|
3042
|
+
}
|
|
3043
|
+
if (terminal.outcome === 'committed') {
|
|
3044
|
+
if (durable.status !== 'committed'
|
|
3045
|
+
|| durable.transactionId !== terminal.transactionId) {
|
|
3046
|
+
return { status: 'blocked', detail: 'registration intent commit receipt conflicts with durable state' };
|
|
3047
|
+
}
|
|
3048
|
+
clearBootstrapRegistrationIntent(options, lease);
|
|
3049
|
+
return { status: 'committed', transactionId: durable.transactionId };
|
|
3050
|
+
}
|
|
3051
|
+
const safeRolledBackTransition = terminal.outcome === 'rolled_back'
|
|
3052
|
+
&& durable.status === 'manual_transition';
|
|
3053
|
+
if (durable.status !== 'clean' && !safeRolledBackTransition) {
|
|
3054
|
+
return {
|
|
3055
|
+
status: 'blocked',
|
|
3056
|
+
detail: durable.status === 'invalid' || durable.status === 'pending'
|
|
3057
|
+
? durable.detail
|
|
3058
|
+
: 'registration intent terminal receipt conflicts with committed state',
|
|
3059
|
+
};
|
|
3060
|
+
}
|
|
3061
|
+
const outcome = terminal.outcome;
|
|
3062
|
+
clearBootstrapRegistrationIntent(options, lease);
|
|
3063
|
+
return { status: 'safe_terminal', outcome };
|
|
152
3064
|
}
|
|
153
|
-
catch {
|
|
154
|
-
|
|
3065
|
+
catch (error) {
|
|
3066
|
+
return {
|
|
3067
|
+
status: 'blocked',
|
|
3068
|
+
detail: boundedBootstrapDetail(`registration intent recovery failed: ${error instanceof Error ? error.message : String(error)}`),
|
|
3069
|
+
};
|
|
155
3070
|
}
|
|
156
|
-
const local = fileURLToPath(new URL('../../../evolver-cli/dist/cli.js', import.meta.url));
|
|
157
|
-
if (exists(local))
|
|
158
|
-
return { command: execPath, args: [local, 'lifecycle', 'bootstrap'] };
|
|
159
|
-
return undefined;
|
|
160
3071
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const path = join(resolveBootstrapStateDir(env), BOOTSTRAP_ATTEMPT_FILE);
|
|
164
|
-
const record = {
|
|
165
|
-
attemptedAt: new Date(options.now ?? Date.now()).toISOString(),
|
|
166
|
-
outcome: outcome.reason,
|
|
167
|
-
...(outcome.detail ? { detail: outcome.detail } : {}),
|
|
168
|
-
};
|
|
3072
|
+
async function runBootstrapWithRegistrationIntent(options, prepareRun) {
|
|
3073
|
+
let lease;
|
|
169
3074
|
try {
|
|
170
|
-
|
|
171
|
-
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
172
|
-
writeFileSync(target, content, { encoding: 'utf8', mode: 0o600 });
|
|
173
|
-
});
|
|
174
|
-
writeFile(path, `${JSON.stringify(record)}\n`);
|
|
3075
|
+
lease = publishBootstrapRegistrationIntent(options);
|
|
175
3076
|
}
|
|
176
3077
|
catch {
|
|
177
|
-
|
|
3078
|
+
return {
|
|
3079
|
+
ok: false,
|
|
3080
|
+
reason: 'blocked',
|
|
3081
|
+
detail: 'bootstrap registration intent publication is unavailable',
|
|
3082
|
+
requiresForegroundExit: true,
|
|
3083
|
+
};
|
|
178
3084
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (!invocation)
|
|
184
|
-
return { ok: false, reason: 'cli_not_found' };
|
|
185
|
-
const spawnFn = options.spawnFn ?? spawn;
|
|
186
|
-
const timeoutMs = options.timeoutMs ?? BOOTSTRAP_TIMEOUT_MS;
|
|
187
|
-
return new Promise((resolvePromise) => {
|
|
188
|
-
let child;
|
|
3085
|
+
let runOptions = options;
|
|
3086
|
+
let outcome;
|
|
3087
|
+
let parentCurrentAtFinalClock = false;
|
|
3088
|
+
if (prepareRun) {
|
|
189
3089
|
try {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
3090
|
+
runOptions = {
|
|
3091
|
+
...options,
|
|
3092
|
+
...await prepareRun(() => {
|
|
3093
|
+
assertBootstrapRegistrationOwnerCurrent(options, lease.record.owner);
|
|
3094
|
+
parentCurrentAtFinalClock = true;
|
|
3095
|
+
}),
|
|
3096
|
+
};
|
|
3097
|
+
if (!parentCurrentAtFinalClock) {
|
|
3098
|
+
throw new Error('bootstrap registration preparation omitted parent identity revalidation');
|
|
3099
|
+
}
|
|
195
3100
|
}
|
|
196
3101
|
catch (error) {
|
|
197
|
-
|
|
198
|
-
|
|
3102
|
+
// The preparation hook runs before child creation, so a strict terminal no-child receipt
|
|
3103
|
+
// can retire this bootstrap intent while the migration transaction handles its own state.
|
|
3104
|
+
outcome = {
|
|
3105
|
+
ok: false,
|
|
3106
|
+
reason: 'failed',
|
|
3107
|
+
detail: boundedBootstrapDetail(error instanceof Error ? error.message : String(error)),
|
|
3108
|
+
};
|
|
199
3109
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
3110
|
+
}
|
|
3111
|
+
if (!outcome) {
|
|
3112
|
+
try {
|
|
3113
|
+
// Re-read the parent process identity after every asynchronous sealed check, then bind the
|
|
3114
|
+
// exact unique intent token into only the child environment. runBootstrap reaches spawn
|
|
3115
|
+
// synchronously, so no await or mutation window exists between this check and child creation.
|
|
3116
|
+
if (!parentCurrentAtFinalClock) {
|
|
3117
|
+
assertBootstrapRegistrationOwnerCurrent(runOptions, lease.record.owner);
|
|
3118
|
+
}
|
|
3119
|
+
runOptions = {
|
|
3120
|
+
...runOptions,
|
|
3121
|
+
env: {
|
|
3122
|
+
...runOptions.env,
|
|
3123
|
+
[coreBootstrap.LIFECYCLE_BOOTSTRAP_REGISTRATION_TOKEN_ENV]: lease.record.owner.token,
|
|
3124
|
+
},
|
|
3125
|
+
};
|
|
3126
|
+
}
|
|
3127
|
+
catch (error) {
|
|
3128
|
+
outcome = {
|
|
3129
|
+
ok: false,
|
|
3130
|
+
reason: 'failed',
|
|
3131
|
+
detail: boundedBootstrapDetail(error instanceof Error ? error.message : String(error)),
|
|
3132
|
+
};
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
if (!outcome) {
|
|
3136
|
+
try {
|
|
3137
|
+
outcome = await runBootstrap(runOptions);
|
|
3138
|
+
}
|
|
3139
|
+
catch {
|
|
3140
|
+
// The no-replace intent remains authoritative until an operator or durable recovery clears it.
|
|
3141
|
+
return {
|
|
3142
|
+
ok: false,
|
|
3143
|
+
reason: 'ambiguous',
|
|
3144
|
+
detail: 'bootstrap registration runner failed unexpectedly',
|
|
3145
|
+
requiresForegroundExit: true,
|
|
3146
|
+
};
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
if (outcome.requiresForegroundExit === true)
|
|
3150
|
+
return outcome;
|
|
3151
|
+
try {
|
|
3152
|
+
const durable = inspectLifecycleBootstrapDurableState(runOptions.env, runOptions);
|
|
3153
|
+
if (outcome.ok) {
|
|
3154
|
+
if (durable.status !== 'committed') {
|
|
3155
|
+
throw new Error('bootstrap success lacks a durable committed transaction');
|
|
3156
|
+
}
|
|
3157
|
+
lease = terminalizeBootstrapRegistrationIntent(runOptions, lease, 'committed', durable.transactionId);
|
|
3158
|
+
}
|
|
3159
|
+
else if (outcome.reason === 'rolled_back') {
|
|
3160
|
+
if (durable.status !== 'clean' && durable.status !== 'manual_transition') {
|
|
3161
|
+
throw new Error('bootstrap rollback did not restore a clean durable state');
|
|
3162
|
+
}
|
|
3163
|
+
lease = terminalizeBootstrapRegistrationIntent(runOptions, lease, 'rolled_back');
|
|
3164
|
+
}
|
|
3165
|
+
else if (['failed', 'cli_not_found'].includes(outcome.reason)) {
|
|
3166
|
+
if (durable.status !== 'clean') {
|
|
3167
|
+
throw new Error('bootstrap no-child failure left durable state');
|
|
3168
|
+
}
|
|
3169
|
+
lease = terminalizeBootstrapRegistrationIntent(runOptions, lease, 'no_child');
|
|
3170
|
+
}
|
|
3171
|
+
else {
|
|
3172
|
+
throw new Error('bootstrap result does not prove a safe terminal state');
|
|
3173
|
+
}
|
|
3174
|
+
clearBootstrapRegistrationIntent(runOptions, lease);
|
|
3175
|
+
}
|
|
3176
|
+
catch {
|
|
3177
|
+
return {
|
|
3178
|
+
ok: false,
|
|
3179
|
+
reason: 'blocked',
|
|
3180
|
+
detail: 'bootstrap registration intent clear is unconfirmed',
|
|
3181
|
+
requiresForegroundExit: true,
|
|
207
3182
|
};
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
settle({ ok: true, reason: 'bootstrapped' });
|
|
218
|
-
else
|
|
219
|
-
settle({ ok: false, reason: 'failed', detail: `exit ${code ?? 'null'}` });
|
|
220
|
-
});
|
|
3183
|
+
}
|
|
3184
|
+
return outcome;
|
|
3185
|
+
}
|
|
3186
|
+
function defaultMigrationRegistrationRunner(options) {
|
|
3187
|
+
const clock = options.migration?.clock ?? Date.now;
|
|
3188
|
+
const failed = (detail) => ({
|
|
3189
|
+
ok: false,
|
|
3190
|
+
reason: 'failed',
|
|
3191
|
+
detail: boundedBootstrapDetail(detail),
|
|
221
3192
|
});
|
|
3193
|
+
const readClock = () => {
|
|
3194
|
+
try {
|
|
3195
|
+
const value = clock();
|
|
3196
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
3197
|
+
}
|
|
3198
|
+
catch {
|
|
3199
|
+
return undefined;
|
|
3200
|
+
}
|
|
3201
|
+
};
|
|
3202
|
+
return async (request) => {
|
|
3203
|
+
if (request.execPath !== request.targetIdentity.path
|
|
3204
|
+
|| request.env['EVOLVER_SELF_UPDATE_TARGET_PATH'] !== request.execPath
|
|
3205
|
+
|| !Number.isSafeInteger(request.startedAtMs)
|
|
3206
|
+
|| request.startedAtMs < 0
|
|
3207
|
+
|| !Number.isSafeInteger(request.transactionBudgetMs)
|
|
3208
|
+
|| request.transactionBudgetMs <= 0
|
|
3209
|
+
|| !Number.isSafeInteger(request.timeoutMs)
|
|
3210
|
+
|| request.timeoutMs > MAX_TIMER_DELAY_MS
|
|
3211
|
+
|| !Number.isSafeInteger(request.transactionDeadlineMs)
|
|
3212
|
+
|| !Number.isSafeInteger(request.parentDeadlineMs)
|
|
3213
|
+
|| request.transactionDeadlineMs - request.startedAtMs !== request.transactionBudgetMs
|
|
3214
|
+
|| request.parentDeadlineMs - request.startedAtMs !== request.timeoutMs
|
|
3215
|
+
|| request.timeoutMs <= request.transactionBudgetMs) {
|
|
3216
|
+
return failed('invalid migration registration binding');
|
|
3217
|
+
}
|
|
3218
|
+
return runBootstrapWithRegistrationIntent({
|
|
3219
|
+
env: request.env,
|
|
3220
|
+
platform: request.platform,
|
|
3221
|
+
execPath: request.execPath,
|
|
3222
|
+
...(request.exists !== undefined ? { exists: request.exists } : {}),
|
|
3223
|
+
...(request.readFile !== undefined ? { readFile: request.readFile } : {}),
|
|
3224
|
+
...(request.writeFile !== undefined ? { writeFile: request.writeFile } : {}),
|
|
3225
|
+
...(request.spawnFn !== undefined ? { spawnFn: request.spawnFn } : {}),
|
|
3226
|
+
...(options.treeKillSpawnFn !== undefined ? { treeKillSpawnFn: options.treeKillSpawnFn } : {}),
|
|
3227
|
+
...(options.processKill !== undefined ? { processKill: options.processKill } : {}),
|
|
3228
|
+
...(options.terminationGraceMs !== undefined ? { terminationGraceMs: options.terminationGraceMs } : {}),
|
|
3229
|
+
...(options.beforeIntentPublish !== undefined ? { beforeIntentPublish: options.beforeIntentPublish } : {}),
|
|
3230
|
+
...(options.afterIntentPublicationStep !== undefined
|
|
3231
|
+
? { afterIntentPublicationStep: options.afterIntentPublicationStep }
|
|
3232
|
+
: {}),
|
|
3233
|
+
...(options.afterIntentTerminalPublish !== undefined
|
|
3234
|
+
? { afterIntentTerminalPublish: options.afterIntentTerminalPublish }
|
|
3235
|
+
: {}),
|
|
3236
|
+
...(options.beforeIntentTerminalFsync !== undefined
|
|
3237
|
+
? { beforeIntentTerminalFsync: options.beforeIntentTerminalFsync }
|
|
3238
|
+
: {}),
|
|
3239
|
+
...(options.afterIntentClearRename !== undefined
|
|
3240
|
+
? { afterIntentClearRename: options.afterIntentClearRename }
|
|
3241
|
+
: {}),
|
|
3242
|
+
...(options.assertIntentDirectoryTrust !== undefined
|
|
3243
|
+
? { assertIntentDirectoryTrust: options.assertIntentDirectoryTrust }
|
|
3244
|
+
: {}),
|
|
3245
|
+
...(options.assertIntentFileTrust !== undefined
|
|
3246
|
+
? { assertIntentFileTrust: options.assertIntentFileTrust }
|
|
3247
|
+
: {}),
|
|
3248
|
+
...(options.readRegistrationProcessStartIdentity !== undefined
|
|
3249
|
+
? { readRegistrationProcessStartIdentity: options.readRegistrationProcessStartIdentity }
|
|
3250
|
+
: {}),
|
|
3251
|
+
...(options.registrationOwnerProcessStatus !== undefined
|
|
3252
|
+
? { registrationOwnerProcessStatus: options.registrationOwnerProcessStatus }
|
|
3253
|
+
: {}),
|
|
3254
|
+
...(options.registrationPublisherProcessStatus !== undefined
|
|
3255
|
+
? { registrationPublisherProcessStatus: options.registrationPublisherProcessStatus }
|
|
3256
|
+
: {}),
|
|
3257
|
+
}, async (assertParentCurrent) => {
|
|
3258
|
+
// The bootstrap intent is durable before these sealed checks. No await occurs between
|
|
3259
|
+
// successful target revalidation, remaining-budget calculation, and runBootstrap's spawn.
|
|
3260
|
+
const beforeRevalidation = readClock();
|
|
3261
|
+
if (beforeRevalidation === undefined
|
|
3262
|
+
|| beforeRevalidation < request.startedAtMs
|
|
3263
|
+
|| beforeRevalidation >= request.transactionDeadlineMs
|
|
3264
|
+
|| beforeRevalidation >= request.parentDeadlineMs) {
|
|
3265
|
+
throw new Error('migration registration deadline expired before target revalidation');
|
|
3266
|
+
}
|
|
3267
|
+
try {
|
|
3268
|
+
await request.assertRegistrationIntentCurrent();
|
|
3269
|
+
await request.revalidateTarget();
|
|
3270
|
+
}
|
|
3271
|
+
catch {
|
|
3272
|
+
throw new Error('migration registration intent or target revalidation failed');
|
|
3273
|
+
}
|
|
3274
|
+
// This native observation can block. Run it before the final absolute-deadline clock read
|
|
3275
|
+
// so its elapsed time is subtracted instead of silently extending either remaining budget.
|
|
3276
|
+
assertParentCurrent();
|
|
3277
|
+
const current = readClock();
|
|
3278
|
+
if (current === undefined || current < beforeRevalidation) {
|
|
3279
|
+
throw new Error('migration registration clock regressed');
|
|
3280
|
+
}
|
|
3281
|
+
const transactionBudgetMs = request.transactionDeadlineMs - current;
|
|
3282
|
+
const timeoutMs = request.parentDeadlineMs - current;
|
|
3283
|
+
if (!Number.isSafeInteger(transactionBudgetMs)
|
|
3284
|
+
|| !Number.isSafeInteger(timeoutMs)
|
|
3285
|
+
|| transactionBudgetMs <= 0
|
|
3286
|
+
|| timeoutMs <= transactionBudgetMs
|
|
3287
|
+
|| timeoutMs > MAX_TIMER_DELAY_MS) {
|
|
3288
|
+
throw new Error('migration registration deadline expired before child creation');
|
|
3289
|
+
}
|
|
3290
|
+
return { now: current, transactionBudgetMs, timeoutMs };
|
|
3291
|
+
});
|
|
3292
|
+
};
|
|
222
3293
|
}
|
|
223
3294
|
/**
|
|
224
3295
|
* Orchestrate bootstrap for a degraded (default-auto, unsupervised) startup: decide, attempt,
|
|
@@ -229,52 +3300,223 @@ export async function bootstrapDegradedSelfUpdateStartup(env, platform = process
|
|
|
229
3300
|
...env,
|
|
230
3301
|
EVOLVER_LIFECYCLE_STATE_DIR: resolveBootstrapStateDir(env),
|
|
231
3302
|
};
|
|
3303
|
+
const migrateUnbindableInstall = async (reason) => {
|
|
3304
|
+
if (reason === 'bootstrap_attempt_pending') {
|
|
3305
|
+
const durable = inspectLifecycleBootstrapDurableState(bootstrapEnv, options);
|
|
3306
|
+
if (durable.status === 'manual_transition') {
|
|
3307
|
+
return {
|
|
3308
|
+
disposition: 'continue',
|
|
3309
|
+
handedOver: false,
|
|
3310
|
+
message: '[evolver-proxy] self-update: automatic bootstrap remains disabled by a durable '
|
|
3311
|
+
+ 'manual service transition; continuing degraded without downloading, replacing, or '
|
|
3312
|
+
+ 'registering a standalone binary. Run evolver lifecycle install-service --target='
|
|
3313
|
+
+ durable.target
|
|
3314
|
+
+ ' explicitly to resume.',
|
|
3315
|
+
};
|
|
3316
|
+
}
|
|
3317
|
+
if (durable.status === 'pending' && durable.manualTransition) {
|
|
3318
|
+
return {
|
|
3319
|
+
disposition: 'fail_closed',
|
|
3320
|
+
handedOver: false,
|
|
3321
|
+
exitCode: 1,
|
|
3322
|
+
message: '[evolver-proxy] self-update: manual service transition recovery is incomplete; '
|
|
3323
|
+
+ 'exiting without downloading, replacing, or registering a standalone binary. '
|
|
3324
|
+
+ `(${boundedBootstrapDetail(durable.detail)}) Run evolver lifecycle remove-service --target=`
|
|
3325
|
+
+ durable.manualTransition.target
|
|
3326
|
+
+ ' to finish the interrupted removal before explicitly reinstalling the service.',
|
|
3327
|
+
};
|
|
3328
|
+
}
|
|
3329
|
+
if (durable.status === 'pending'
|
|
3330
|
+
&& Object.values(durable.recoveryLocks ?? {})
|
|
3331
|
+
.some((status) => status === 'active_or_unverifiable')) {
|
|
3332
|
+
return {
|
|
3333
|
+
disposition: 'fail_closed',
|
|
3334
|
+
handedOver: false,
|
|
3335
|
+
exitCode: 1,
|
|
3336
|
+
message: '[evolver-proxy] self-update: lifecycle recovery lock ownership is active or '
|
|
3337
|
+
+ 'unverifiable; exiting before migration download, target mutation, or child creation. ('
|
|
3338
|
+
+ boundedBootstrapDetail(durable.detail)
|
|
3339
|
+
+ ')',
|
|
3340
|
+
};
|
|
3341
|
+
}
|
|
3342
|
+
if (durable.status === 'invalid') {
|
|
3343
|
+
return {
|
|
3344
|
+
disposition: 'fail_closed',
|
|
3345
|
+
handedOver: false,
|
|
3346
|
+
exitCode: 1,
|
|
3347
|
+
message: '[evolver-proxy] self-update: lifecycle recovery state is invalid; exiting without '
|
|
3348
|
+
+ 'migration, target mutation, or child creation. ('
|
|
3349
|
+
+ boundedBootstrapDetail(durable.detail)
|
|
3350
|
+
+ ')',
|
|
3351
|
+
};
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
3354
|
+
// The npm/JS install shape cannot safely run ordinary lifecycle bootstrap because it would
|
|
3355
|
+
// bind supervision back to an unreplaceable Node launcher. Only the signed standalone
|
|
3356
|
+
// migration may register recovery, and pending ownership stays fail-closed on every skip.
|
|
3357
|
+
const migration = await migrateToStandaloneBinary(bootstrapEnv, platform, {
|
|
3358
|
+
...options.migration,
|
|
3359
|
+
registrationRunner: options.migration?.registrationRunner
|
|
3360
|
+
?? defaultMigrationRegistrationRunner(options),
|
|
3361
|
+
...(options.execPath !== undefined ? { execPath: options.execPath } : {}),
|
|
3362
|
+
...(options.exists !== undefined ? { exists: options.exists } : {}),
|
|
3363
|
+
...(options.readFile !== undefined ? { readFile: options.readFile } : {}),
|
|
3364
|
+
...(options.writeFile !== undefined ? { writeFile: options.writeFile } : {}),
|
|
3365
|
+
...(options.spawnFn !== undefined ? { spawnFn: options.spawnFn } : {}),
|
|
3366
|
+
...(options.now !== undefined ? { now: options.now } : {}),
|
|
3367
|
+
});
|
|
3368
|
+
const recoveringPendingOwnership = reason === 'migration_ambiguous'
|
|
3369
|
+
|| reason === 'bootstrap_attempt_pending';
|
|
3370
|
+
if (migration.requiresForegroundExit === true
|
|
3371
|
+
|| (recoveringPendingOwnership && migration.outcome !== 'migrated')) {
|
|
3372
|
+
return {
|
|
3373
|
+
disposition: 'fail_closed',
|
|
3374
|
+
handedOver: false,
|
|
3375
|
+
exitCode: 1,
|
|
3376
|
+
message: '[evolver-proxy] self-update: standalone migration registration did not prove a clean rollback; '
|
|
3377
|
+
+ 'exiting the foreground proxy to avoid concurrent IPC ownership. '
|
|
3378
|
+
+ 'Inspect the lifecycle bootstrap transaction state before retrying.',
|
|
3379
|
+
};
|
|
3380
|
+
}
|
|
3381
|
+
if (migration.outcome === 'migrated') {
|
|
3382
|
+
return {
|
|
3383
|
+
disposition: 'handoff',
|
|
3384
|
+
handedOver: true,
|
|
3385
|
+
exitCode: 0,
|
|
3386
|
+
message: migration.message,
|
|
3387
|
+
};
|
|
3388
|
+
}
|
|
3389
|
+
if (migration.outcome === 'skipped' && migration.reason !== 'cooldown') {
|
|
3390
|
+
recordBootstrapAttempt(bootstrapEnv, { ok: false, reason }, options);
|
|
3391
|
+
}
|
|
3392
|
+
return {
|
|
3393
|
+
disposition: 'continue',
|
|
3394
|
+
handedOver: false,
|
|
3395
|
+
message: '[evolver-proxy] self-update: running from the npm/JS install shape, which has no standalone '
|
|
3396
|
+
+ 'binary target for self-update; bootstrap skipped, continuing with self-update off. '
|
|
3397
|
+
+ 'Install the standalone binary from GitHub Releases and start it to enable self-update. ('
|
|
3398
|
+
+ migration.message
|
|
3399
|
+
+ ')',
|
|
3400
|
+
};
|
|
3401
|
+
};
|
|
232
3402
|
const decision = shouldBootstrap(bootstrapEnv, platform, options);
|
|
233
3403
|
if (!decision.proceed) {
|
|
234
3404
|
const reason = decision.reason ?? 'skipped';
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
3405
|
+
if (reason === 'bootstrap_attempt_invalid') {
|
|
3406
|
+
return {
|
|
3407
|
+
disposition: 'fail_closed',
|
|
3408
|
+
handedOver: false,
|
|
3409
|
+
exitCode: 1,
|
|
3410
|
+
message: '[evolver-proxy] self-update: bootstrap attempt recovery state is invalid; '
|
|
3411
|
+
+ 'exiting without replacing it because prior process ownership cannot be classified safely. '
|
|
3412
|
+
+ 'Inspect the lifecycle state before retrying.',
|
|
3413
|
+
};
|
|
3414
|
+
}
|
|
3415
|
+
if (reason === 'bootstrap_intent_pending') {
|
|
3416
|
+
const recovery = recoverBootstrapRegistrationIntent({
|
|
3417
|
+
env: bootstrapEnv,
|
|
3418
|
+
platform,
|
|
3419
|
+
...options,
|
|
249
3420
|
});
|
|
250
|
-
if (
|
|
251
|
-
|
|
3421
|
+
if (recovery.status === 'committed') {
|
|
3422
|
+
try {
|
|
3423
|
+
resolveSelfUpdateTarget({ env: bootstrapEnv, processExecPath: options.execPath });
|
|
3424
|
+
return {
|
|
3425
|
+
disposition: 'handoff',
|
|
3426
|
+
handedOver: true,
|
|
3427
|
+
exitCode: 0,
|
|
3428
|
+
message: '[evolver-proxy] self-update: recovered a committed lifecycle bootstrap registration; '
|
|
3429
|
+
+ 'handing over to the service manager without creating another child.',
|
|
3430
|
+
};
|
|
3431
|
+
}
|
|
3432
|
+
catch {
|
|
3433
|
+
// Exact intent recovery retired the old no-replace authority. An npm/JS foreground
|
|
3434
|
+
// still cannot prove that the committed launcher binds a standalone executable.
|
|
3435
|
+
return migrateUnbindableInstall('bootstrap_attempt_pending');
|
|
3436
|
+
}
|
|
3437
|
+
}
|
|
3438
|
+
if (recovery.status === 'safe_terminal') {
|
|
3439
|
+
return {
|
|
3440
|
+
disposition: 'continue',
|
|
3441
|
+
handedOver: false,
|
|
3442
|
+
message: '[evolver-proxy] self-update: recovered and cleared a bootstrap registration '
|
|
3443
|
+
+ `${recovery.outcome} receipt without creating another child; continuing degraded.`,
|
|
3444
|
+
};
|
|
252
3445
|
}
|
|
253
3446
|
return {
|
|
3447
|
+
disposition: 'fail_closed',
|
|
254
3448
|
handedOver: false,
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
+ '
|
|
258
|
-
+ `(${
|
|
3449
|
+
exitCode: 1,
|
|
3450
|
+
message: '[evolver-proxy] self-update: prior bootstrap registration ownership remains unresolved; '
|
|
3451
|
+
+ 'exiting without migration, target mutation, or child creation. '
|
|
3452
|
+
+ `(${boundedBootstrapDetail(recovery.detail)})`,
|
|
259
3453
|
};
|
|
260
3454
|
}
|
|
3455
|
+
if (reason === 'unsupported_install_shape'
|
|
3456
|
+
|| reason === 'migration_ambiguous'
|
|
3457
|
+
|| reason === 'bootstrap_attempt_pending') {
|
|
3458
|
+
// Do not suggest `evolver lifecycle bootstrap` here: under the npm/JS install shape it
|
|
3459
|
+
// would register a supervised service that crashes at self-update target resolution on
|
|
3460
|
+
// every startup (crash-loop). Only a standalone release binary can host self-update —
|
|
3461
|
+
// so attempt the one-time migration to it; any skip/failure keeps the degraded startup.
|
|
3462
|
+
return migrateUnbindableInstall(reason);
|
|
3463
|
+
}
|
|
3464
|
+
if (reason !== 'recent_failure') {
|
|
3465
|
+
recordBootstrapAttempt(bootstrapEnv, { ok: false, reason }, options);
|
|
3466
|
+
}
|
|
261
3467
|
return {
|
|
3468
|
+
disposition: 'continue',
|
|
262
3469
|
handedOver: false,
|
|
263
3470
|
message: '[evolver-proxy] self-update: default auto requires a durable supervisor attestation; '
|
|
264
3471
|
+ `running with self-update off (bootstrap skipped: ${reason}). `
|
|
265
3472
|
+ 'Run `evolver lifecycle bootstrap` or `evolver lifecycle install-service` to enable.',
|
|
266
3473
|
};
|
|
267
3474
|
}
|
|
268
|
-
const outcome = await
|
|
269
|
-
|
|
3475
|
+
const outcome = await runBootstrapWithRegistrationIntent({ env: bootstrapEnv, platform, ...options });
|
|
3476
|
+
const recoveringPendingOwnership = decision.reason === 'migration_ambiguous'
|
|
3477
|
+
|| decision.reason === 'bootstrap_attempt_pending';
|
|
3478
|
+
if (!recoveringPendingOwnership || outcome.ok) {
|
|
3479
|
+
recordBootstrapAttempt(bootstrapEnv, outcome, options);
|
|
3480
|
+
}
|
|
270
3481
|
if (outcome.ok) {
|
|
3482
|
+
const lockReleaseWarning = outcome.reason === 'bootstrapped_lock_release_unconfirmed'
|
|
3483
|
+
? ' The lifecycle commit is durable, but lock release could not be confirmed'
|
|
3484
|
+
+ ` (${boundedBootstrapDetail(outcome.detail ?? 'bootstrap lifecycle lock release is unconfirmed')}).`
|
|
3485
|
+
+ ' Inspect the lifecycle state before the next lifecycle mutation.'
|
|
3486
|
+
: '';
|
|
271
3487
|
return {
|
|
3488
|
+
disposition: 'handoff',
|
|
272
3489
|
handedOver: true,
|
|
3490
|
+
exitCode: 0,
|
|
273
3491
|
message: '[evolver-proxy] self-update: registered durable service supervision via `evolver lifecycle bootstrap`; '
|
|
274
|
-
+ 'handing over to the service manager and exiting so it can take the IPC port.'
|
|
3492
|
+
+ 'handing over to the service manager and exiting so it can take the IPC port.'
|
|
3493
|
+
+ lockReleaseWarning,
|
|
3494
|
+
};
|
|
3495
|
+
}
|
|
3496
|
+
if (outcome.requiresForegroundExit) {
|
|
3497
|
+
const termination = outcome.reason === 'termination_unconfirmed'
|
|
3498
|
+
? 'process-tree termination could not be confirmed'
|
|
3499
|
+
: `the child result was ${outcome.reason} and did not prove a clean rollback`;
|
|
3500
|
+
return {
|
|
3501
|
+
disposition: 'fail_closed',
|
|
3502
|
+
handedOver: false,
|
|
3503
|
+
exitCode: 1,
|
|
3504
|
+
message: `[evolver-proxy] self-update: bootstrap ${termination}; exiting the foreground proxy to avoid concurrent IPC ownership. `
|
|
3505
|
+
+ 'Inspect the lifecycle bootstrap transaction state before retrying.',
|
|
3506
|
+
};
|
|
3507
|
+
}
|
|
3508
|
+
if (recoveringPendingOwnership) {
|
|
3509
|
+
return {
|
|
3510
|
+
disposition: 'fail_closed',
|
|
3511
|
+
handedOver: false,
|
|
3512
|
+
exitCode: 1,
|
|
3513
|
+
message: '[evolver-proxy] self-update: prior standalone migration ownership remains unresolved; '
|
|
3514
|
+
+ 'exiting the foreground proxy without replacing the ambiguity marker. '
|
|
3515
|
+
+ 'Inspect the lifecycle bootstrap transaction state before retrying.',
|
|
275
3516
|
};
|
|
276
3517
|
}
|
|
277
3518
|
return {
|
|
3519
|
+
disposition: 'continue',
|
|
278
3520
|
handedOver: false,
|
|
279
3521
|
message: `[evolver-proxy] self-update: first-run bootstrap failed (${outcome.reason}); `
|
|
280
3522
|
+ 'running with self-update off. Run `evolver lifecycle install-service` manually to enable.',
|