@khorsheed/dsh-ankh-guard 0.1.1 → 0.3.0
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/CHANGELOG.md +33 -0
- package/README.en.md +75 -29
- package/README.i18n.yaml +2 -2
- package/README.md +74 -29
- package/lib/cli.js +2349 -182
- package/lib/client.js +344 -0
- package/lib/exit-agent.js +5 -2
- package/lib/index.js +746 -40
- package/lib/invariant.js +1 -1
- package/lib/preflight-runner.js +125 -47
- package/lib/restart-context-DUyExi9O.js +1245 -0
- package/lib/restart-context-DlaDZWKq.js +1250 -0
- package/lib/{state-Dhx9VG44.js → state-CZMypGkB.js} +97 -13
- package/lib/test-seam-cli.js +24 -0
- package/lib/test-seam-dwvaKjRp.js +459 -0
- package/lib/test-seam.js +2 -0
- package/lib/types/browser-handoff.d.ts +55 -0
- package/lib/types/browser-handoff.js +489 -0
- package/lib/types/cli.d.ts +34 -4
- package/lib/types/cli.js +1421 -166
- package/lib/types/client/index.d.ts +15 -0
- package/lib/types/client/index.js +348 -0
- package/lib/types/deployment-proof.d.ts +24 -0
- package/lib/types/deployment-proof.js +314 -0
- package/lib/types/exit-agent.js +2 -0
- package/lib/types/git.d.ts +12 -3
- package/lib/types/git.js +69 -7
- package/lib/types/index.d.ts +66 -3
- package/lib/types/index.js +192 -41
- package/lib/types/launch-spec.d.ts +263 -0
- package/lib/types/launch-spec.js +823 -0
- package/lib/types/preflight-runner.d.ts +23 -12
- package/lib/types/preflight-runner.js +152 -57
- package/lib/types/processes.d.ts +38 -6
- package/lib/types/processes.js +236 -10
- package/lib/types/restart-context.d.ts +50 -0
- package/lib/types/restart-context.js +117 -6
- package/lib/types/restart-request.d.ts +32 -0
- package/lib/types/restart-request.js +128 -0
- package/lib/types/state-files.d.ts +30 -0
- package/lib/types/state-files.js +55 -0
- package/lib/types/state.d.ts +29 -2
- package/lib/types/state.js +52 -7
- package/lib/types/temp-artifact.d.ts +15 -0
- package/lib/types/temp-artifact.js +17 -0
- package/lib/types/test-seam-cli.d.ts +3 -0
- package/lib/types/test-seam-cli.js +27 -0
- package/lib/types/test-seam.d.ts +55 -0
- package/lib/types/test-seam.js +112 -0
- package/lib/types/transition.d.ts +118 -0
- package/lib/types/transition.js +717 -0
- package/package.json +30 -11
- package/scripts/dsh-watchdog.sh +1388 -80
- package/scripts/install-launchd.sh +43 -5
- package/scripts/install-systemd.sh +43 -5
- package/scripts/on-install.js +1 -1
- package/skills/dsh-self-restart-guard/SKILL.md +38 -12
- package/lib/processes-hCAmwma-.js +0 -127
- package/lib/restart-context-DmnQXNf-.js +0 -421
package/lib/cli.js
CHANGED
|
@@ -1,14 +1,1032 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { i as resolveStateDir, n as isDirectInvocation, r as resolveRepoDir, t as SRC_ARTIFACT_PATTERN } from "./defaults-CduvOcGq.js";
|
|
3
|
-
import { _ as
|
|
4
|
-
import { c as
|
|
5
|
-
import { n as
|
|
3
|
+
import { A as writeStableLaunchSpec, C as prepareLaunchCutover, D as selectedLaunchSpec, E as recordCutoverEvent, F as workingTreeChanges, M as currentHead, N as isWorkingTreeClean, O as summarizeLaunchState, P as resetToCheckpoint, T as readLaunchState, _ as writeRestartOutcome, b as activeCutover, c as readInstanceLaunch, f as writeAdoptionRecord, h as writeInstanceLaunchAsSupervisor, j as commitCheckpoint, k as writeCutoverControl, p as writeCompositionRecovery, u as readSkillRegistration, w as readCutoverReceipt, x as commandSha256, y as writeUnexpectedExitRecord } from "./restart-context-DlaDZWKq.js";
|
|
4
|
+
import { _ as processIdentity, c as appendTestLifecycleEvent, d as registerTestProcess, f as discoverLaunchCommand, h as killPidTree, l as appendTestLifecycleEventForProcess, m as findPidOnPort, n as TEST_PROCESS_ROLE_ENV, p as findOwnedListener, r as TEST_PROCESS_TEMP_ROOT_ENV, t as TEST_PROCESS_PORT_ENV, u as registerCurrentTestProcess, v as processIdentityMatches } from "./test-seam-dwvaKjRp.js";
|
|
5
|
+
import { a as setProvenDeployment, c as lastGoodBootRevision, d as pidAlive, f as stateFile, i as setCheckpoint, l as livePidIn, n as loadState, r as recordCredential, s as verifyCredential, t as clearCredential, u as liveWatchdogPid } from "./state-CZMypGkB.js";
|
|
6
6
|
import { execFileSync, spawn } from "node:child_process";
|
|
7
|
-
import {
|
|
7
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
8
|
+
import { chmodSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
8
9
|
import { connect } from "node:net";
|
|
9
|
-
import { homedir } from "node:os";
|
|
10
|
-
import { dirname, join, resolve, sep } from "node:path";
|
|
10
|
+
import { homedir, tmpdir } from "node:os";
|
|
11
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
13
|
+
//#region lib/types/deployment-proof.js
|
|
14
|
+
/**
|
|
15
|
+
* Reusable evidence for a pure, same-launch restart.
|
|
16
|
+
*
|
|
17
|
+
* A green credential is deliberately short lived: it authorizes one stop
|
|
18
|
+
* close to the observed build/test run. Once that stop has completed
|
|
19
|
+
* readiness and canary, however, the exact deployed inputs are known-good.
|
|
20
|
+
* This module fingerprints those inputs so a later pure restart can reuse the
|
|
21
|
+
* expensive proof without turning "same git HEAD" into a weak proxy for
|
|
22
|
+
* "same deployment".
|
|
23
|
+
*/
|
|
24
|
+
const PROFILE_FILES = [
|
|
25
|
+
"cordis.patch.yml",
|
|
26
|
+
"cordis.yml",
|
|
27
|
+
"package.json",
|
|
28
|
+
"pnpm-lock.yaml",
|
|
29
|
+
"pnpm-workspace.yaml"
|
|
30
|
+
];
|
|
31
|
+
const HOST_RUNTIME_FILES = [
|
|
32
|
+
"package.json",
|
|
33
|
+
"pnpm-lock.yaml",
|
|
34
|
+
"pnpm-workspace.yaml",
|
|
35
|
+
"node_modules/.modules.yaml",
|
|
36
|
+
"node_modules/.pnpm-workspace-state-v1.json"
|
|
37
|
+
];
|
|
38
|
+
const SKIPPED_PACKAGE_DIRS = /* @__PURE__ */ new Set([
|
|
39
|
+
".git",
|
|
40
|
+
"coverage",
|
|
41
|
+
"node_modules"
|
|
42
|
+
]);
|
|
43
|
+
function hashChunk(hash, kind, logicalPath, value = "") {
|
|
44
|
+
hash.update(kind);
|
|
45
|
+
hash.update("\0");
|
|
46
|
+
hash.update(logicalPath);
|
|
47
|
+
hash.update("\0");
|
|
48
|
+
hash.update(value);
|
|
49
|
+
hash.update("\0");
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Hash a runtime tree, following links so a linked package cannot drift
|
|
53
|
+
* outside the fingerprint. Real-directory de-duplication permits legal link
|
|
54
|
+
* cycles without recursion loops. Special/dangling/unreadable nodes fail
|
|
55
|
+
* closed.
|
|
56
|
+
*/
|
|
57
|
+
function hashRuntimePath(hash, absolutePath, logicalPath, seenDirectories, packageRoot = false) {
|
|
58
|
+
const info = lstatSync(absolutePath);
|
|
59
|
+
if (info.isSymbolicLink()) {
|
|
60
|
+
hashChunk(hash, "link", logicalPath, readlinkSync(absolutePath));
|
|
61
|
+
let target;
|
|
62
|
+
try {
|
|
63
|
+
target = realpathSync(absolutePath);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw new Error(`deployment fingerprint refused dangling link ${logicalPath}: ${String(error)}`);
|
|
66
|
+
}
|
|
67
|
+
hashRuntimePath(hash, target, `${logicalPath}/@target`, seenDirectories, packageRoot);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (info.isFile()) {
|
|
71
|
+
hashChunk(hash, "file", logicalPath, readFileSync(absolutePath));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (!info.isDirectory()) throw new Error(`deployment fingerprint refused special file ${logicalPath}`);
|
|
75
|
+
const realDirectory = realpathSync(absolutePath);
|
|
76
|
+
if (seenDirectories.has(realDirectory)) {
|
|
77
|
+
hashChunk(hash, "cycle", logicalPath);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
seenDirectories.add(realDirectory);
|
|
81
|
+
hashChunk(hash, "dir", logicalPath);
|
|
82
|
+
for (const name of readdirSync(absolutePath).sort((left, right) => Buffer.from(left).compare(Buffer.from(right)))) {
|
|
83
|
+
if (packageRoot && SKIPPED_PACKAGE_DIRS.has(name)) {
|
|
84
|
+
hashChunk(hash, "excluded-dir", `${logicalPath}/${name}`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
hashRuntimePath(hash, join(absolutePath, name), `${logicalPath}/${name}`, seenDirectories, packageRoot);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function hashOptionalPath(hash, root, relativePath) {
|
|
91
|
+
const absolutePath = join(root, relativePath);
|
|
92
|
+
if (!existsSync(absolutePath)) {
|
|
93
|
+
hashChunk(hash, "absent", relativePath);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
hashRuntimePath(hash, absolutePath, relativePath, /* @__PURE__ */ new Set());
|
|
97
|
+
}
|
|
98
|
+
function dependencyInstallPath(profileDir, dependency) {
|
|
99
|
+
return join(profileDir, "node_modules", ...dependency.split("/"));
|
|
100
|
+
}
|
|
101
|
+
function fileDependencyPath(profileDir, specifier) {
|
|
102
|
+
if (!specifier.startsWith("file:")) return void 0;
|
|
103
|
+
const pathname = specifier.slice(5);
|
|
104
|
+
return isAbsolute(pathname) ? pathname : resolve(profileDir, pathname);
|
|
105
|
+
}
|
|
106
|
+
/** Hash profile inputs, installed direct packages, and file: source archives. */
|
|
107
|
+
function profileRuntimeSha256(spec) {
|
|
108
|
+
const profileDir = join(spec.home, "profiles", spec.profile);
|
|
109
|
+
const manifestPath = join(profileDir, "package.json");
|
|
110
|
+
if (!existsSync(manifestPath)) throw new Error(`deployment fingerprint requires ${manifestPath}`);
|
|
111
|
+
let manifest;
|
|
112
|
+
try {
|
|
113
|
+
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
114
|
+
} catch (error) {
|
|
115
|
+
throw new Error(`deployment fingerprint could not parse profile package.json: ${String(error)}`);
|
|
116
|
+
}
|
|
117
|
+
const hash = createHash("sha256");
|
|
118
|
+
for (const file of PROFILE_FILES) hashOptionalPath(hash, profileDir, file);
|
|
119
|
+
const dependencies = Object.entries(manifest.dependencies ?? {}).sort(([left], [right]) => Buffer.from(left).compare(Buffer.from(right)));
|
|
120
|
+
for (const [name, source] of dependencies) {
|
|
121
|
+
const installed = dependencyInstallPath(profileDir, name);
|
|
122
|
+
if (!existsSync(installed)) throw new Error(`deployment fingerprint requires installed profile dependency ${name}`);
|
|
123
|
+
hashRuntimePath(hash, installed, `installed/${name}`, /* @__PURE__ */ new Set(), true);
|
|
124
|
+
const archive = fileDependencyPath(profileDir, source);
|
|
125
|
+
if (archive !== void 0) {
|
|
126
|
+
if (!existsSync(archive)) throw new Error(`deployment fingerprint requires file dependency archive for ${name}`);
|
|
127
|
+
hashRuntimePath(hash, archive, `source-archive/${name}`, /* @__PURE__ */ new Set());
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return hash.digest("hex");
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Hash the host install metadata and any explicitly bound built execution
|
|
134
|
+
* surface. Source surfaces are content-bound by the clean harness git tree.
|
|
135
|
+
*/
|
|
136
|
+
function hostRuntimeSha256(spec) {
|
|
137
|
+
const hash = createHash("sha256");
|
|
138
|
+
for (const file of HOST_RUNTIME_FILES) hashOptionalPath(hash, spec.harnessRoot, file);
|
|
139
|
+
if (spec.preflight !== void 0) {
|
|
140
|
+
hashRuntimePath(hash, spec.preflight.runnerPath, "preflight/runner", /* @__PURE__ */ new Set());
|
|
141
|
+
hashRuntimePath(hash, spec.preflight.installAnchor, "preflight/install-anchor", /* @__PURE__ */ new Set());
|
|
142
|
+
if (spec.preflight.surface === "built") hashRuntimePath(hash, dirname(spec.preflight.installAnchor), "preflight/built-host-package", /* @__PURE__ */ new Set(), true);
|
|
143
|
+
}
|
|
144
|
+
return hash.digest("hex");
|
|
145
|
+
}
|
|
146
|
+
function cleanRevision(root, role) {
|
|
147
|
+
const revision = currentHead(root);
|
|
148
|
+
if (revision === null) throw new Error(`deployment fingerprint requires ${role} to be a git repository`);
|
|
149
|
+
if (!isWorkingTreeClean(root)) throw new Error(`deployment fingerprint requires a clean ${role}`);
|
|
150
|
+
return revision;
|
|
151
|
+
}
|
|
152
|
+
function captureDeploymentFingerprint(spec) {
|
|
153
|
+
if (spec.preflight === void 0) throw new Error("deployment fingerprint requires an explicit source/built preflight execution binding");
|
|
154
|
+
if (spec.preflight.targetCommandSha256 !== commandSha256(spec.command)) throw new Error("deployment fingerprint refused a preflight binding for a different launch command");
|
|
155
|
+
if (fileSha256$1(spec.preflight.runnerPath) !== spec.preflight.runnerSha256) throw new Error("deployment fingerprint refused a changed preflight runner");
|
|
156
|
+
if (fileSha256$1(spec.preflight.installAnchor) !== spec.preflight.installAnchorSha256) throw new Error("deployment fingerprint refused a changed preflight install anchor");
|
|
157
|
+
return {
|
|
158
|
+
version: 1,
|
|
159
|
+
credentialRevision: cleanRevision(spec.credentialRepo, "credential repository"),
|
|
160
|
+
harnessRevision: cleanRevision(spec.harnessRoot, "harness root"),
|
|
161
|
+
launchSpecSha256: commandSha256(JSON.stringify(spec)),
|
|
162
|
+
profileSha256: profileRuntimeSha256(spec),
|
|
163
|
+
hostRuntimeSha256: hostRuntimeSha256(spec)
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function deploymentFingerprintSha256(fingerprint) {
|
|
167
|
+
return commandSha256(JSON.stringify(fingerprint));
|
|
168
|
+
}
|
|
169
|
+
function credentialCommandSha256(credential) {
|
|
170
|
+
return commandSha256(credential.command);
|
|
171
|
+
}
|
|
172
|
+
function fileSha256$1(path) {
|
|
173
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
174
|
+
}
|
|
175
|
+
function proofCredentialMatches(proof, credential) {
|
|
176
|
+
return credential !== void 0 && credential.revision === proof.credential.revision && credential.recordedAt === proof.credential.recordedAt && credential.scope === proof.credential.scope && credentialCommandSha256(credential) === proof.credential.commandSha256;
|
|
177
|
+
}
|
|
178
|
+
function verifyProvenDeployment(stateDir, spec) {
|
|
179
|
+
const state = loadState(stateDir);
|
|
180
|
+
const proof = state.provenDeployment;
|
|
181
|
+
if (proof === void 0) return {
|
|
182
|
+
ok: false,
|
|
183
|
+
reason: "no proven deployment recorded by a completed restart canary"
|
|
184
|
+
};
|
|
185
|
+
if (!proofCredentialMatches(proof, state.credential)) return {
|
|
186
|
+
ok: false,
|
|
187
|
+
reason: "proven deployment no longer matches the recorded build/test credential"
|
|
188
|
+
};
|
|
189
|
+
let current;
|
|
190
|
+
try {
|
|
191
|
+
current = captureDeploymentFingerprint(spec);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const currentSha256 = deploymentFingerprintSha256(current);
|
|
199
|
+
if (currentSha256 !== proof.fingerprintSha256 || JSON.stringify(current) !== JSON.stringify(proof.fingerprint)) return {
|
|
200
|
+
ok: false,
|
|
201
|
+
reason: `proven deployment fingerprint changed (${proof.fingerprintSha256.slice(0, 16)} -> ${currentSha256.slice(0, 16)}) — rebuild, test, and record again`
|
|
202
|
+
};
|
|
203
|
+
return {
|
|
204
|
+
ok: true,
|
|
205
|
+
reason: `proven deployment valid (${proof.credential.scope} @ ${proof.credential.revision}, fingerprint ${proof.fingerprintSha256.slice(0, 16)})`,
|
|
206
|
+
authorization: {
|
|
207
|
+
version: 1,
|
|
208
|
+
kind: "proven-deployment",
|
|
209
|
+
revision: proof.credential.revision,
|
|
210
|
+
evidenceSha256: proof.fingerprintSha256
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
/** Fresh evidence wins; an exact prior deployment is the same-launch fallback. */
|
|
215
|
+
function verifyRestartEvidence(stateDir, spec, maxAgeMinutes, now = Date.now()) {
|
|
216
|
+
const state = loadState(stateDir);
|
|
217
|
+
const credential = state.credential;
|
|
218
|
+
const fresh = verifyCredential(state, currentHead(spec.credentialRepo), now, maxAgeMinutes, isWorkingTreeClean(spec.credentialRepo));
|
|
219
|
+
if (fresh.ok && credential !== void 0) return {
|
|
220
|
+
...fresh,
|
|
221
|
+
authorization: {
|
|
222
|
+
version: 1,
|
|
223
|
+
kind: "fresh-credential",
|
|
224
|
+
revision: credential.revision,
|
|
225
|
+
evidenceSha256: credentialCommandSha256(credential)
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
const proven = verifyProvenDeployment(stateDir, spec);
|
|
229
|
+
if (proven.ok) return proven;
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
reason: `${fresh.reason}; pure-restart reuse unavailable: ${proven.reason}`
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/** Revalidate the exact evidence selected before the scheduled stop. */
|
|
236
|
+
function verifyRestartAuthorization(stateDir, spec, authorization) {
|
|
237
|
+
const state = loadState(stateDir);
|
|
238
|
+
if (authorization.kind === "proven-deployment") {
|
|
239
|
+
const proven = verifyProvenDeployment(stateDir, spec);
|
|
240
|
+
if (!proven.ok || proven.authorization?.evidenceSha256 !== authorization.evidenceSha256) return {
|
|
241
|
+
ok: false,
|
|
242
|
+
reason: `scheduled proven-deployment authorization changed: ${proven.reason}`
|
|
243
|
+
};
|
|
244
|
+
return proven;
|
|
245
|
+
}
|
|
246
|
+
const credential = state.credential;
|
|
247
|
+
if (credential === void 0 || credential.revision !== authorization.revision || credentialCommandSha256(credential) !== authorization.evidenceSha256) return {
|
|
248
|
+
ok: false,
|
|
249
|
+
reason: "scheduled credential authorization no longer matches durable state"
|
|
250
|
+
};
|
|
251
|
+
if (currentHead(spec.credentialRepo) !== credential.revision || !isWorkingTreeClean(spec.credentialRepo)) return {
|
|
252
|
+
ok: false,
|
|
253
|
+
reason: "scheduled credential authorization no longer matches a clean repository HEAD"
|
|
254
|
+
};
|
|
255
|
+
return {
|
|
256
|
+
ok: true,
|
|
257
|
+
reason: `scheduled fresh credential still matches ${credential.revision}`,
|
|
258
|
+
authorization
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Promote a fresh credential after the boot's readiness/canary boundary. A
|
|
263
|
+
* proof-authorized pure restart retains the existing proof unchanged.
|
|
264
|
+
*/
|
|
265
|
+
function proveCurrentDeployment(stateDir, spec, authorization, now = Date.now()) {
|
|
266
|
+
const authorized = verifyRestartAuthorization(stateDir, spec, authorization);
|
|
267
|
+
if (!authorized.ok) return authorized;
|
|
268
|
+
if (authorization.kind === "proven-deployment") return {
|
|
269
|
+
...authorized,
|
|
270
|
+
reason: `${authorized.reason}; existing deployment proof retained`
|
|
271
|
+
};
|
|
272
|
+
const credential = loadState(stateDir).credential;
|
|
273
|
+
if (credential === void 0) return {
|
|
274
|
+
ok: false,
|
|
275
|
+
reason: "credential disappeared before deployment proof was recorded"
|
|
276
|
+
};
|
|
277
|
+
let fingerprint;
|
|
278
|
+
try {
|
|
279
|
+
fingerprint = captureDeploymentFingerprint(spec);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
return {
|
|
282
|
+
ok: false,
|
|
283
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
if (fingerprint.credentialRevision !== credential.revision) return {
|
|
287
|
+
ok: false,
|
|
288
|
+
reason: "deployment fingerprint revision differs from the credential revision"
|
|
289
|
+
};
|
|
290
|
+
const fingerprintSha256 = deploymentFingerprintSha256(fingerprint);
|
|
291
|
+
const proof = {
|
|
292
|
+
version: 1,
|
|
293
|
+
provenAt: now,
|
|
294
|
+
credential: {
|
|
295
|
+
revision: credential.revision,
|
|
296
|
+
recordedAt: credential.recordedAt,
|
|
297
|
+
scope: credential.scope,
|
|
298
|
+
commandSha256: credentialCommandSha256(credential)
|
|
299
|
+
},
|
|
300
|
+
fingerprint,
|
|
301
|
+
fingerprintSha256
|
|
302
|
+
};
|
|
303
|
+
setProvenDeployment(stateDir, proof, now);
|
|
304
|
+
return {
|
|
305
|
+
ok: true,
|
|
306
|
+
reason: `recorded proven deployment ${fingerprintSha256.slice(0, 16)} for ${credential.revision}`,
|
|
307
|
+
authorization: {
|
|
308
|
+
version: 1,
|
|
309
|
+
kind: "proven-deployment",
|
|
310
|
+
revision: credential.revision,
|
|
311
|
+
evidenceSha256: fingerprintSha256
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
//#endregion
|
|
316
|
+
//#region lib/types/temp-artifact.js
|
|
317
|
+
/** Owner proof for a crash-left temporary artifact that may later be reaped. */
|
|
318
|
+
const TEMP_ARTIFACT_OWNER_FILE = ".ankh-guard-owner.json";
|
|
319
|
+
/**
|
|
320
|
+
* Bind one private temporary artifact to the exact process identity creating
|
|
321
|
+
* it. A later reaper may remove the artifact only after this identity is gone.
|
|
322
|
+
*/
|
|
323
|
+
function writeTempArtifactOwner(root, kind) {
|
|
324
|
+
const owner = processIdentity(process.pid);
|
|
325
|
+
if (owner === null) throw new Error(`cannot capture temporary-artifact owner identity for pid ${process.pid}`);
|
|
326
|
+
const record = {
|
|
327
|
+
version: 1,
|
|
328
|
+
kind,
|
|
329
|
+
createdAt: Date.now(),
|
|
330
|
+
owner
|
|
331
|
+
};
|
|
332
|
+
writeFileSync(join(root, TEMP_ARTIFACT_OWNER_FILE), `${JSON.stringify(record)}\n`, {
|
|
333
|
+
flag: "wx",
|
|
334
|
+
mode: 384
|
|
335
|
+
});
|
|
336
|
+
return record;
|
|
337
|
+
}
|
|
338
|
+
//#endregion
|
|
339
|
+
//#region lib/types/transition.js
|
|
340
|
+
/**
|
|
341
|
+
* Reversible filesystem transitions for launch cutovers.
|
|
342
|
+
*
|
|
343
|
+
* A transition plan names home-relative paths that the next host cannot read.
|
|
344
|
+
* The guard moves those paths into a cutover-scoped quarantine only after the
|
|
345
|
+
* previous process has stopped. If the target is rejected, target-created
|
|
346
|
+
* replacements are retained separately before the previous bytes are restored.
|
|
347
|
+
*/
|
|
348
|
+
const MAX_OPERATIONS = 64;
|
|
349
|
+
const CUTOVER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
|
|
350
|
+
function isObject(value) {
|
|
351
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
352
|
+
}
|
|
353
|
+
function exactKeys(value, expected, label) {
|
|
354
|
+
const actual = Object.keys(value).sort();
|
|
355
|
+
const wanted = [...expected].sort();
|
|
356
|
+
if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) throw new Error(`${label} requires exactly: ${wanted.join(", ")}`);
|
|
357
|
+
}
|
|
358
|
+
function canonicalDirectory(path, label) {
|
|
359
|
+
if (!isAbsolute(path)) throw new Error(`${label} must be absolute`);
|
|
360
|
+
let canonical;
|
|
361
|
+
try {
|
|
362
|
+
canonical = realpathSync(path);
|
|
363
|
+
} catch {
|
|
364
|
+
throw new Error(`${label} does not exist: ${path}`);
|
|
365
|
+
}
|
|
366
|
+
if (!statSync(canonical).isDirectory()) throw new Error(`${label} must be a directory: ${path}`);
|
|
367
|
+
return canonical;
|
|
368
|
+
}
|
|
369
|
+
function pathExists(path) {
|
|
370
|
+
try {
|
|
371
|
+
lstatSync(path);
|
|
372
|
+
return true;
|
|
373
|
+
} catch (error) {
|
|
374
|
+
if (error.code === "ENOENT") return false;
|
|
375
|
+
throw error;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
function normalizeRelativePath(path) {
|
|
379
|
+
if (typeof path !== "string" || path === "" || isAbsolute(path) || path.includes("\\") || path.includes("\0")) throw new Error("transition operation path must be a non-empty home-relative POSIX path");
|
|
380
|
+
const parts = path.split("/");
|
|
381
|
+
if (parts.some((part) => part === "" || part === "." || part === "..")) throw new Error(`transition operation path contains an empty or traversal segment: ${path}`);
|
|
382
|
+
return parts.join(sep);
|
|
383
|
+
}
|
|
384
|
+
function overlaps(left, right) {
|
|
385
|
+
return left === right || left.startsWith(`${right}${sep}`) || right.startsWith(`${left}${sep}`);
|
|
386
|
+
}
|
|
387
|
+
function assertPathHasNoSymlink(home, path) {
|
|
388
|
+
let current = home;
|
|
389
|
+
for (const part of path.split(sep)) {
|
|
390
|
+
current = join(current, part);
|
|
391
|
+
if (!pathExists(current)) return;
|
|
392
|
+
const stat = lstatSync(current);
|
|
393
|
+
if (stat.isSymbolicLink()) throw new Error(`transition path crosses a symbolic link: ${path}`);
|
|
394
|
+
if (current !== join(home, path) && !stat.isDirectory()) throw new Error(`transition path has a non-directory ancestor: ${path}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
function assertOperationScope(plan, stateDir) {
|
|
398
|
+
const paths = plan.operations.map((operation) => operation.path);
|
|
399
|
+
for (let index = 0; index < paths.length; index++) {
|
|
400
|
+
const path = paths[index];
|
|
401
|
+
assertPathHasNoSymlink(plan.home, path);
|
|
402
|
+
for (const other of paths.slice(index + 1)) if (overlaps(path, other)) throw new Error(`transition paths may not overlap: ${path} and ${other}`);
|
|
403
|
+
}
|
|
404
|
+
if (stateDir === void 0) return;
|
|
405
|
+
const canonicalState = canonicalDirectory(stateDir, "transition state directory");
|
|
406
|
+
const stateRelative = relative(plan.home, canonicalState);
|
|
407
|
+
if (stateRelative === "") throw new Error("transition state directory must not equal the transition home");
|
|
408
|
+
if (!stateRelative.startsWith(`..${sep}`) && stateRelative !== ".." && !isAbsolute(stateRelative)) {
|
|
409
|
+
for (const path of paths) if (overlaps(path, stateRelative)) throw new Error(`transition path overlaps the guard state directory: ${path}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Validate and normalize an untrusted transition plan.
|
|
414
|
+
* @param raw - Parsed JSON plan.
|
|
415
|
+
* @param expectedHome - Home shared by the previous and target launch specs.
|
|
416
|
+
* @param stateDir - Guard state directory, which no operation may move.
|
|
417
|
+
* @returns A canonical plan safe for durable preparation.
|
|
418
|
+
*/
|
|
419
|
+
function validateTransitionPlan(raw, expectedHome, stateDir) {
|
|
420
|
+
if (!isObject(raw)) throw new Error("transition plan must be an object");
|
|
421
|
+
exactKeys(raw, [
|
|
422
|
+
"schemaVersion",
|
|
423
|
+
"home",
|
|
424
|
+
"operations"
|
|
425
|
+
], "transition plan");
|
|
426
|
+
if (raw.schemaVersion !== 1) throw new Error("transition plan requires schemaVersion: 1");
|
|
427
|
+
if (typeof raw.home !== "string") throw new Error("transition plan home must be a string");
|
|
428
|
+
const home = canonicalDirectory(raw.home, "transition plan home");
|
|
429
|
+
if (home !== canonicalDirectory(expectedHome, "cutover home")) throw new Error("transition plan home must equal the cutover home");
|
|
430
|
+
if (!Array.isArray(raw.operations) || raw.operations.length === 0 || raw.operations.length > MAX_OPERATIONS) throw new Error(`transition plan requires 1-${MAX_OPERATIONS} operations`);
|
|
431
|
+
const seen = /* @__PURE__ */ new Set();
|
|
432
|
+
const plan = {
|
|
433
|
+
schemaVersion: 1,
|
|
434
|
+
home,
|
|
435
|
+
operations: raw.operations.map((operation, index) => {
|
|
436
|
+
if (!isObject(operation)) throw new Error(`transition operation ${index} must be an object`);
|
|
437
|
+
exactKeys(operation, [
|
|
438
|
+
"expect",
|
|
439
|
+
"kind",
|
|
440
|
+
"path"
|
|
441
|
+
], `transition operation ${index}`);
|
|
442
|
+
if (operation.kind !== "quarantine") throw new Error(`transition operation ${index} has unsupported kind`);
|
|
443
|
+
if (operation.expect !== "present" && operation.expect !== "absent") throw new Error(`transition operation ${index} requires expect: present or absent`);
|
|
444
|
+
const path = normalizeRelativePath(operation.path);
|
|
445
|
+
if (seen.has(path)) throw new Error(`transition operation path is duplicated: ${path}`);
|
|
446
|
+
seen.add(path);
|
|
447
|
+
return {
|
|
448
|
+
kind: "quarantine",
|
|
449
|
+
path,
|
|
450
|
+
expect: operation.expect
|
|
451
|
+
};
|
|
452
|
+
})
|
|
453
|
+
};
|
|
454
|
+
assertOperationScope(plan, stateDir);
|
|
455
|
+
return plan;
|
|
456
|
+
}
|
|
457
|
+
function jsonBytes(value) {
|
|
458
|
+
return Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
|
|
459
|
+
}
|
|
460
|
+
function sha256(value) {
|
|
461
|
+
return createHash("sha256").update(value).digest("hex");
|
|
462
|
+
}
|
|
463
|
+
function atomicWrite(file, bytes) {
|
|
464
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
465
|
+
const temporary = `${file}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
466
|
+
writeFileSync(temporary, bytes, {
|
|
467
|
+
flag: "wx",
|
|
468
|
+
mode: 384
|
|
469
|
+
});
|
|
470
|
+
try {
|
|
471
|
+
renameSync(temporary, file);
|
|
472
|
+
try {
|
|
473
|
+
chmodSync(file, 384);
|
|
474
|
+
} catch {}
|
|
475
|
+
} finally {
|
|
476
|
+
rmSync(temporary, { force: true });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
function atomicWriteJson(file, value) {
|
|
480
|
+
atomicWrite(file, jsonBytes(value));
|
|
481
|
+
}
|
|
482
|
+
function transitionRoot(stateDir, cutoverId) {
|
|
483
|
+
if (!CUTOVER_ID_PATTERN.test(cutoverId)) throw new Error("transition cutover id is invalid");
|
|
484
|
+
return join(canonicalDirectory(stateDir, "transition state directory"), "launch-transitions", cutoverId);
|
|
485
|
+
}
|
|
486
|
+
function expectedPlanPath(stateDir, cutoverId) {
|
|
487
|
+
return join(transitionRoot(stateDir, cutoverId), "plan.json");
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Persist the immutable plan and an empty crash-recovery journal before the previous host stops.
|
|
491
|
+
* @param raw - Validated or untrusted plan JSON.
|
|
492
|
+
* @param expectedHome - Shared launch home.
|
|
493
|
+
* @param stateDir - Guard state directory.
|
|
494
|
+
* @param cutoverId - Unique launch cutover identifier.
|
|
495
|
+
* @returns Reference stored in the launch state and redacted receipt.
|
|
496
|
+
*/
|
|
497
|
+
function prepareTransition(raw, expectedHome, stateDir, cutoverId) {
|
|
498
|
+
const plan = validateTransitionPlan(raw, expectedHome, stateDir);
|
|
499
|
+
const root = transitionRoot(stateDir, cutoverId);
|
|
500
|
+
if (pathExists(root)) throw new Error(`transition directory already exists for cutover ${cutoverId}`);
|
|
501
|
+
if (statSync(plan.home).dev !== statSync(canonicalDirectory(stateDir, "transition state directory")).dev) throw new Error("transition home and state directory must be on the same filesystem");
|
|
502
|
+
for (const operation of plan.operations) {
|
|
503
|
+
const source = join(plan.home, operation.path);
|
|
504
|
+
if (pathExists(source) !== (operation.expect === "present")) throw new Error(`transition source ${operation.path} is ${pathExists(source) ? "present" : "absent"}, expected ${operation.expect}`);
|
|
505
|
+
if (pathExists(source) && lstatSync(source).dev !== statSync(canonicalDirectory(stateDir, "transition state directory")).dev) throw new Error(`transition source is on a different filesystem: ${operation.path}`);
|
|
506
|
+
}
|
|
507
|
+
mkdirSync(root, {
|
|
508
|
+
recursive: true,
|
|
509
|
+
mode: 448
|
|
510
|
+
});
|
|
511
|
+
const planPath = join(root, "plan.json");
|
|
512
|
+
const bytes = jsonBytes(plan);
|
|
513
|
+
atomicWrite(planPath, bytes);
|
|
514
|
+
const planSha256 = sha256(bytes);
|
|
515
|
+
const record = {
|
|
516
|
+
version: 1,
|
|
517
|
+
cutoverId,
|
|
518
|
+
planSha256,
|
|
519
|
+
phase: "prepared",
|
|
520
|
+
entries: plan.operations.map((operation) => ({
|
|
521
|
+
path: operation.path,
|
|
522
|
+
original: operation.expect,
|
|
523
|
+
apply: "pending",
|
|
524
|
+
rollback: "pending"
|
|
525
|
+
})),
|
|
526
|
+
updatedAt: Date.now()
|
|
527
|
+
};
|
|
528
|
+
atomicWriteJson(join(root, "state.json"), record);
|
|
529
|
+
return {
|
|
530
|
+
version: 1,
|
|
531
|
+
planPath,
|
|
532
|
+
planSha256,
|
|
533
|
+
operationCount: plan.operations.length
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
function parseRecord(raw, cutoverId, reference, plan) {
|
|
537
|
+
if (!isObject(raw) || raw.version !== 1 || raw.cutoverId !== cutoverId || raw.planSha256 !== reference.planSha256 || typeof raw.updatedAt !== "number" || ![
|
|
538
|
+
"prepared",
|
|
539
|
+
"applying",
|
|
540
|
+
"applied",
|
|
541
|
+
"rolling-back",
|
|
542
|
+
"rolled-back",
|
|
543
|
+
"failed"
|
|
544
|
+
].includes(String(raw.phase)) || !Array.isArray(raw.entries) || raw.entries.length !== plan.operations.length) throw new Error("transition state is malformed or does not match its plan");
|
|
545
|
+
const entries = raw.entries;
|
|
546
|
+
for (let index = 0; index < entries.length; index++) {
|
|
547
|
+
const entry = entries[index];
|
|
548
|
+
if (!isObject(entry) || entry.path !== plan.operations[index]?.path || !["present", "absent"].includes(String(entry.original)) || ![
|
|
549
|
+
"pending",
|
|
550
|
+
"moving",
|
|
551
|
+
"absent",
|
|
552
|
+
"quarantined"
|
|
553
|
+
].includes(String(entry.apply)) || ![
|
|
554
|
+
"pending",
|
|
555
|
+
"moving-target",
|
|
556
|
+
"target-retained",
|
|
557
|
+
"target-absent",
|
|
558
|
+
"restoring",
|
|
559
|
+
"restored"
|
|
560
|
+
].includes(String(entry.rollback))) throw new Error(`transition state entry ${index} is malformed`);
|
|
561
|
+
}
|
|
562
|
+
return raw;
|
|
563
|
+
}
|
|
564
|
+
function loadTransition(reference, expectedHome, stateDir, cutoverId) {
|
|
565
|
+
if (reference.version !== 1 || !/^[a-f0-9]{64}$/.test(reference.planSha256) || !Number.isInteger(reference.operationCount) || reference.operationCount < 1) throw new Error("transition reference is malformed");
|
|
566
|
+
const planPath = expectedPlanPath(stateDir, cutoverId);
|
|
567
|
+
if (resolve(reference.planPath) !== planPath) throw new Error("transition plan path is outside its cutover directory");
|
|
568
|
+
const bytes = readFileSync(planPath);
|
|
569
|
+
if (sha256(bytes) !== reference.planSha256) throw new Error("transition plan changed after preparation");
|
|
570
|
+
let rawPlan;
|
|
571
|
+
try {
|
|
572
|
+
rawPlan = JSON.parse(bytes.toString("utf8"));
|
|
573
|
+
} catch {
|
|
574
|
+
throw new Error("transition plan is not valid JSON");
|
|
575
|
+
}
|
|
576
|
+
const plan = validateTransitionPlan(rawPlan, expectedHome, stateDir);
|
|
577
|
+
if (plan.operations.length !== reference.operationCount) throw new Error("transition operation count changed after preparation");
|
|
578
|
+
const root = dirname(planPath);
|
|
579
|
+
const stateFile = join(root, "state.json");
|
|
580
|
+
let rawRecord;
|
|
581
|
+
try {
|
|
582
|
+
rawRecord = JSON.parse(readFileSync(stateFile, "utf8"));
|
|
583
|
+
} catch {
|
|
584
|
+
throw new Error("transition state is unreadable");
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
plan,
|
|
588
|
+
record: parseRecord(rawRecord, cutoverId, reference, plan),
|
|
589
|
+
root,
|
|
590
|
+
stateFile
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
function writeRecord(loaded) {
|
|
594
|
+
loaded.record.updatedAt = Date.now();
|
|
595
|
+
atomicWriteJson(loaded.stateFile, loaded.record);
|
|
596
|
+
}
|
|
597
|
+
function previousPath(root, path) {
|
|
598
|
+
return join(root, "previous", path);
|
|
599
|
+
}
|
|
600
|
+
function rejectedTargetPath(root, path) {
|
|
601
|
+
return join(root, "rejected-target", path);
|
|
602
|
+
}
|
|
603
|
+
function markFailure(loaded, operation, error) {
|
|
604
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
605
|
+
loaded.record.phase = "failed";
|
|
606
|
+
loaded.record.failure = {
|
|
607
|
+
operation,
|
|
608
|
+
detail
|
|
609
|
+
};
|
|
610
|
+
try {
|
|
611
|
+
writeRecord(loaded);
|
|
612
|
+
} catch {}
|
|
613
|
+
throw new Error(`transition ${operation} failed: ${detail}`);
|
|
614
|
+
}
|
|
615
|
+
function reconcileMovingApply(loaded, entry) {
|
|
616
|
+
const source = join(loaded.plan.home, entry.path);
|
|
617
|
+
const retained = previousPath(loaded.root, entry.path);
|
|
618
|
+
const sourceExists = pathExists(source);
|
|
619
|
+
const retainedExists = pathExists(retained);
|
|
620
|
+
if (!sourceExists && retainedExists) {
|
|
621
|
+
entry.original = "present";
|
|
622
|
+
entry.apply = "quarantined";
|
|
623
|
+
writeRecord(loaded);
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
if (sourceExists && !retainedExists) {
|
|
627
|
+
entry.apply = "pending";
|
|
628
|
+
writeRecord(loaded);
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
throw new Error(`cannot reconcile interrupted quarantine for ${entry.path}`);
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Apply a prepared transition after the previous host has stopped.
|
|
635
|
+
* @param reference - Immutable reference from the launch state.
|
|
636
|
+
* @param expectedHome - Shared launch home.
|
|
637
|
+
* @param stateDir - Guard state directory.
|
|
638
|
+
* @param cutoverId - Active cutover identifier.
|
|
639
|
+
* @returns Idempotent apply result.
|
|
640
|
+
*/
|
|
641
|
+
function applyTransition(reference, expectedHome, stateDir, cutoverId) {
|
|
642
|
+
const loaded = loadTransition(reference, expectedHome, stateDir, cutoverId);
|
|
643
|
+
if (loaded.record.phase === "rolled-back") throw new Error("a rolled-back transition cannot be applied again");
|
|
644
|
+
if (loaded.record.phase === "applied") return {
|
|
645
|
+
phase: "applied",
|
|
646
|
+
changed: [],
|
|
647
|
+
unchanged: loaded.record.entries.map((entry) => entry.path)
|
|
648
|
+
};
|
|
649
|
+
const changed = [];
|
|
650
|
+
const unchanged = [];
|
|
651
|
+
loaded.record.phase = "applying";
|
|
652
|
+
delete loaded.record.failure;
|
|
653
|
+
writeRecord(loaded);
|
|
654
|
+
try {
|
|
655
|
+
for (const entry of loaded.record.entries) {
|
|
656
|
+
assertPathHasNoSymlink(loaded.plan.home, entry.path);
|
|
657
|
+
if (entry.apply === "quarantined" || entry.apply === "absent") {
|
|
658
|
+
unchanged.push(entry.path);
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
if (entry.apply === "moving" && reconcileMovingApply(loaded, entry)) {
|
|
662
|
+
unchanged.push(entry.path);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
const source = join(loaded.plan.home, entry.path);
|
|
666
|
+
const retained = previousPath(loaded.root, entry.path);
|
|
667
|
+
const sourceExists = pathExists(source);
|
|
668
|
+
if (sourceExists !== (entry.original === "present")) throw new Error(`transition source changed after preparation: ${entry.path}`);
|
|
669
|
+
if (!sourceExists) {
|
|
670
|
+
if (pathExists(retained)) throw new Error(`unexpected retained source for absent path ${entry.path}`);
|
|
671
|
+
entry.apply = "absent";
|
|
672
|
+
writeRecord(loaded);
|
|
673
|
+
unchanged.push(entry.path);
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
if (lstatSync(source).isSymbolicLink()) throw new Error(`transition source is a symbolic link: ${entry.path}`);
|
|
677
|
+
if (pathExists(retained)) throw new Error(`transition quarantine destination already exists: ${entry.path}`);
|
|
678
|
+
entry.apply = "moving";
|
|
679
|
+
writeRecord(loaded);
|
|
680
|
+
mkdirSync(dirname(retained), {
|
|
681
|
+
recursive: true,
|
|
682
|
+
mode: 448
|
|
683
|
+
});
|
|
684
|
+
renameSync(source, retained);
|
|
685
|
+
entry.apply = "quarantined";
|
|
686
|
+
writeRecord(loaded);
|
|
687
|
+
changed.push(entry.path);
|
|
688
|
+
}
|
|
689
|
+
loaded.record.phase = "applied";
|
|
690
|
+
writeRecord(loaded);
|
|
691
|
+
return {
|
|
692
|
+
phase: "applied",
|
|
693
|
+
changed,
|
|
694
|
+
unchanged
|
|
695
|
+
};
|
|
696
|
+
} catch (error) {
|
|
697
|
+
return markFailure(loaded, "apply", error);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
function finishTargetRetention(loaded, entry) {
|
|
701
|
+
const source = join(loaded.plan.home, entry.path);
|
|
702
|
+
const target = rejectedTargetPath(loaded.root, entry.path);
|
|
703
|
+
const sourceExists = pathExists(source);
|
|
704
|
+
const targetExists = pathExists(target);
|
|
705
|
+
if (entry.rollback === "moving-target") {
|
|
706
|
+
if (!sourceExists && targetExists) {
|
|
707
|
+
entry.rollback = "target-retained";
|
|
708
|
+
writeRecord(loaded);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (!sourceExists || targetExists) throw new Error(`cannot reconcile rejected target output for ${entry.path}`);
|
|
712
|
+
}
|
|
713
|
+
if (sourceExists) {
|
|
714
|
+
if (targetExists) throw new Error(`rejected target quarantine already exists for ${entry.path}`);
|
|
715
|
+
entry.rollback = "moving-target";
|
|
716
|
+
writeRecord(loaded);
|
|
717
|
+
mkdirSync(dirname(target), {
|
|
718
|
+
recursive: true,
|
|
719
|
+
mode: 448
|
|
720
|
+
});
|
|
721
|
+
renameSync(source, target);
|
|
722
|
+
entry.rollback = "target-retained";
|
|
723
|
+
} else entry.rollback = targetExists ? "target-retained" : "target-absent";
|
|
724
|
+
writeRecord(loaded);
|
|
725
|
+
}
|
|
726
|
+
function finishPreviousRestore(loaded, entry) {
|
|
727
|
+
if (entry.original === "absent") {
|
|
728
|
+
entry.rollback = "restored";
|
|
729
|
+
writeRecord(loaded);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (entry.original !== "present") throw new Error(`transition original state is unknown for ${entry.path}`);
|
|
733
|
+
const source = join(loaded.plan.home, entry.path);
|
|
734
|
+
const retained = previousPath(loaded.root, entry.path);
|
|
735
|
+
const sourceExists = pathExists(source);
|
|
736
|
+
const retainedExists = pathExists(retained);
|
|
737
|
+
if (entry.rollback === "restoring") {
|
|
738
|
+
if (sourceExists && !retainedExists) {
|
|
739
|
+
entry.rollback = "restored";
|
|
740
|
+
writeRecord(loaded);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
if (sourceExists || !retainedExists) throw new Error(`cannot reconcile previous-state restore for ${entry.path}`);
|
|
744
|
+
}
|
|
745
|
+
if (sourceExists || !retainedExists) throw new Error(`previous-state quarantine is incomplete for ${entry.path}`);
|
|
746
|
+
entry.rollback = "restoring";
|
|
747
|
+
writeRecord(loaded);
|
|
748
|
+
mkdirSync(dirname(source), { recursive: true });
|
|
749
|
+
renameSync(retained, source);
|
|
750
|
+
entry.rollback = "restored";
|
|
751
|
+
writeRecord(loaded);
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Roll back an applied or partially applied transition before previous starts.
|
|
755
|
+
* Target-created replacements are retained for diagnosis rather than deleted.
|
|
756
|
+
* @param reference - Immutable reference from the launch state.
|
|
757
|
+
* @param expectedHome - Shared launch home.
|
|
758
|
+
* @param stateDir - Guard state directory.
|
|
759
|
+
* @param cutoverId - Active cutover identifier.
|
|
760
|
+
* @returns Idempotent rollback result.
|
|
761
|
+
*/
|
|
762
|
+
function rollbackTransition(reference, expectedHome, stateDir, cutoverId) {
|
|
763
|
+
const loaded = loadTransition(reference, expectedHome, stateDir, cutoverId);
|
|
764
|
+
if (loaded.record.phase === "rolled-back") return {
|
|
765
|
+
phase: "rolled-back",
|
|
766
|
+
changed: [],
|
|
767
|
+
unchanged: loaded.record.entries.map((entry) => entry.path)
|
|
768
|
+
};
|
|
769
|
+
const changed = [];
|
|
770
|
+
const unchanged = [];
|
|
771
|
+
loaded.record.phase = "rolling-back";
|
|
772
|
+
delete loaded.record.failure;
|
|
773
|
+
writeRecord(loaded);
|
|
774
|
+
try {
|
|
775
|
+
for (const entry of [...loaded.record.entries].reverse()) {
|
|
776
|
+
if (entry.rollback === "restored") {
|
|
777
|
+
unchanged.push(entry.path);
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
if (entry.apply === "moving") reconcileMovingApply(loaded, entry);
|
|
781
|
+
if (entry.apply === "pending") {
|
|
782
|
+
entry.rollback = "restored";
|
|
783
|
+
writeRecord(loaded);
|
|
784
|
+
unchanged.push(entry.path);
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
assertPathHasNoSymlink(loaded.plan.home, entry.path);
|
|
788
|
+
if (entry.rollback !== "restoring") finishTargetRetention(loaded, entry);
|
|
789
|
+
finishPreviousRestore(loaded, entry);
|
|
790
|
+
changed.push(entry.path);
|
|
791
|
+
}
|
|
792
|
+
loaded.record.phase = "rolled-back";
|
|
793
|
+
writeRecord(loaded);
|
|
794
|
+
return {
|
|
795
|
+
phase: "rolled-back",
|
|
796
|
+
changed,
|
|
797
|
+
unchanged
|
|
798
|
+
};
|
|
799
|
+
} catch (error) {
|
|
800
|
+
return markFailure(loaded, "rollback", error);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
function snapshotCopyError(source, error) {
|
|
804
|
+
if (error.code === "ELOOP") return /* @__PURE__ */ new Error(`preflight snapshot refused an unresolvable symbolic-link cycle at ${source}`);
|
|
805
|
+
return /* @__PURE__ */ new Error(`preflight snapshot could not safely copy ${source}: ${String(error)}`);
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Top-level home entries that never join a preflight snapshot: scratch is
|
|
809
|
+
* ephemeral by definition, and copying it can push prepare+canary past the
|
|
810
|
+
* credential's freshness window (observed: a 24 GB scratch expired the
|
|
811
|
+
* credential mid-cutover, the target canary then failed and restored).
|
|
812
|
+
*/
|
|
813
|
+
const SNAPSHOT_SKIPPED_TOP_LEVEL = /* @__PURE__ */ new Set(["scratch"]);
|
|
814
|
+
function canonicalSnapshotSource(source) {
|
|
815
|
+
try {
|
|
816
|
+
return realpathSync(source);
|
|
817
|
+
} catch (error) {
|
|
818
|
+
throw snapshotCopyError(source, error);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
function createContainedSnapshotLink(destination, target, directory) {
|
|
822
|
+
const targetFromLink = relative(dirname(destination), target) || ".";
|
|
823
|
+
symlinkSync(targetFromLink, destination, directory ? "dir" : "file");
|
|
824
|
+
}
|
|
825
|
+
/** Copy physical entries once; source symlinks become deferred graph edges. */
|
|
826
|
+
function copySnapshotNode(source, destination, context) {
|
|
827
|
+
let linkMetadata;
|
|
828
|
+
try {
|
|
829
|
+
linkMetadata = lstatSync(source);
|
|
830
|
+
} catch (error) {
|
|
831
|
+
throw snapshotCopyError(source, error);
|
|
832
|
+
}
|
|
833
|
+
if (linkMetadata.isSymbolicLink()) {
|
|
834
|
+
context.pendingLinks.push({
|
|
835
|
+
source,
|
|
836
|
+
destination
|
|
837
|
+
});
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
if (linkMetadata.isSocket() || linkMetadata.isFIFO()) {
|
|
841
|
+
context.skippedRuntimeEntries++;
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
if (!linkMetadata.isDirectory() && !linkMetadata.isFile()) throw new Error(`preflight snapshot refused a special filesystem entry at ${source}`);
|
|
845
|
+
const canonical = canonicalSnapshotSource(source);
|
|
846
|
+
const existing = context.destinations.get(canonical);
|
|
847
|
+
if (existing !== void 0) {
|
|
848
|
+
createContainedSnapshotLink(destination, existing, linkMetadata.isDirectory());
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
context.destinations.set(canonical, destination);
|
|
852
|
+
if (linkMetadata.isDirectory()) {
|
|
853
|
+
mkdirSync(destination, { mode: 448 });
|
|
854
|
+
context.directories.push({
|
|
855
|
+
destination,
|
|
856
|
+
mode: linkMetadata.mode & 4095,
|
|
857
|
+
atime: linkMetadata.atime,
|
|
858
|
+
mtime: linkMetadata.mtime
|
|
859
|
+
});
|
|
860
|
+
try {
|
|
861
|
+
for (const name of readdirSync(canonical)) {
|
|
862
|
+
if (canonical === context.rootCanonical && SNAPSHOT_SKIPPED_TOP_LEVEL.has(name)) continue;
|
|
863
|
+
copySnapshotNode(join(canonical, name), join(destination, name), context);
|
|
864
|
+
}
|
|
865
|
+
} catch (error) {
|
|
866
|
+
if (error instanceof Error && error.message.startsWith("preflight snapshot ")) throw error;
|
|
867
|
+
throw snapshotCopyError(source, error);
|
|
868
|
+
}
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
try {
|
|
872
|
+
copyFileSync(canonical, destination, constants.COPYFILE_FICLONE);
|
|
873
|
+
chmodSync(destination, linkMetadata.mode & 4095);
|
|
874
|
+
utimesSync(destination, linkMetadata.atime, linkMetadata.mtime);
|
|
875
|
+
} catch (error) {
|
|
876
|
+
throw snapshotCopyError(source, error);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Preserve Node's ancestor node_modules lookup for an external package while
|
|
881
|
+
* avoiding one copy per package link. Other external targets are materialized
|
|
882
|
+
* individually and still deduplicated by canonical path.
|
|
883
|
+
*/
|
|
884
|
+
function externalMaterializationAnchor(target) {
|
|
885
|
+
const parsed = resolve(target).split(sep);
|
|
886
|
+
const nodeModulesIndex = parsed.indexOf("node_modules");
|
|
887
|
+
if (nodeModulesIndex >= 0) return {
|
|
888
|
+
source: parsed.slice(0, nodeModulesIndex + 1).join(sep) || sep,
|
|
889
|
+
destination: "node_modules"
|
|
890
|
+
};
|
|
891
|
+
return {
|
|
892
|
+
source: target,
|
|
893
|
+
destination: basename(target) || "target"
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
function resolveSnapshotLinks(context) {
|
|
897
|
+
for (let index = 0; index < context.pendingLinks.length; index++) {
|
|
898
|
+
const link = context.pendingLinks[index];
|
|
899
|
+
const target = canonicalSnapshotSource(link.source);
|
|
900
|
+
let metadata;
|
|
901
|
+
try {
|
|
902
|
+
metadata = statSync(target);
|
|
903
|
+
} catch (error) {
|
|
904
|
+
throw snapshotCopyError(link.source, error);
|
|
905
|
+
}
|
|
906
|
+
if (metadata.isSocket() || metadata.isFIFO()) {
|
|
907
|
+
context.skippedRuntimeEntries++;
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
if (!metadata.isDirectory() && !metadata.isFile()) throw new Error(`preflight snapshot refused a link to a special filesystem entry at ${link.source}`);
|
|
911
|
+
let mapped = context.destinations.get(target);
|
|
912
|
+
if (mapped === void 0) {
|
|
913
|
+
const anchor = externalMaterializationAnchor(target);
|
|
914
|
+
const canonicalAnchor = canonicalSnapshotSource(anchor.source);
|
|
915
|
+
mapped = context.destinations.get(target);
|
|
916
|
+
if (mapped === void 0) {
|
|
917
|
+
mkdirSync(context.externalRoot, {
|
|
918
|
+
recursive: true,
|
|
919
|
+
mode: 448
|
|
920
|
+
});
|
|
921
|
+
const anchorDestination = join(context.externalRoot, sha256(Buffer.from(canonicalAnchor)), anchor.destination);
|
|
922
|
+
mkdirSync(dirname(anchorDestination), {
|
|
923
|
+
recursive: true,
|
|
924
|
+
mode: 448
|
|
925
|
+
});
|
|
926
|
+
copySnapshotNode(canonicalAnchor, anchorDestination, context);
|
|
927
|
+
mapped = context.destinations.get(target);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
if (mapped === void 0) throw new Error(`preflight snapshot could not map symbolic-link target for ${link.source}`);
|
|
931
|
+
createContainedSnapshotLink(link.destination, mapped, metadata.isDirectory());
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
function pathIsWithin(root, path) {
|
|
935
|
+
const offset = relative(root, path);
|
|
936
|
+
return offset === "" || !isAbsolute(offset) && offset !== ".." && !offset.startsWith(`..${sep}`);
|
|
937
|
+
}
|
|
938
|
+
/** Prove every retained link resolves to a writable target inside the snapshot. */
|
|
939
|
+
function assertSnapshotLinksContained(path, canonicalRoot) {
|
|
940
|
+
const metadata = lstatSync(path);
|
|
941
|
+
if (metadata.isSymbolicLink()) {
|
|
942
|
+
const target = canonicalSnapshotSource(path);
|
|
943
|
+
if (!pathIsWithin(canonicalRoot, target)) throw new Error(`preflight snapshot retained a writable link escape at ${path}`);
|
|
944
|
+
const targetMetadata = statSync(target);
|
|
945
|
+
if (!targetMetadata.isDirectory() && !targetMetadata.isFile()) throw new Error(`preflight snapshot retained a link to a special filesystem entry at ${path}`);
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
if (metadata.isDirectory()) {
|
|
949
|
+
for (const name of readdirSync(path)) assertSnapshotLinksContained(join(path, name), canonicalRoot);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
if (!metadata.isFile()) throw new Error(`preflight snapshot retained a special filesystem entry at ${path}`);
|
|
953
|
+
}
|
|
954
|
+
function finalizeSnapshotDirectories(context) {
|
|
955
|
+
for (const directory of [...context.directories].reverse()) {
|
|
956
|
+
chmodSync(directory.destination, directory.mode);
|
|
957
|
+
utimesSync(directory.destination, directory.atime, directory.mtime);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Clone a live home while retaining a contained package-link graph. Internal
|
|
962
|
+
* links are rebuilt against copied nodes; external targets are deduplicated in
|
|
963
|
+
* a snapshot-owned materialization area. No retained link resolves outside the
|
|
964
|
+
* snapshot root, so writes through pnpm/Cordis links cannot reach live bytes.
|
|
965
|
+
* Runtime entries without copyable content (sockets, FIFOs — and links to
|
|
966
|
+
* them) are skipped and counted, never copied; the top-level scratch/ tree is
|
|
967
|
+
* excluded for size. Device nodes still fail closed.
|
|
968
|
+
* @param sourceHome - Live dsh home to read.
|
|
969
|
+
* @returns Isolated home, an idempotent cleanup callback, and the count of skipped runtime entries.
|
|
970
|
+
*/
|
|
971
|
+
function createPreflightSnapshot(sourceHome) {
|
|
972
|
+
const root = mkdtempSync(join(tmpdir(), "ankh-transition-preflight-"));
|
|
973
|
+
const home = join(root, "home");
|
|
974
|
+
try {
|
|
975
|
+
writeTempArtifactOwner(root, "preflight-snapshot");
|
|
976
|
+
const source = canonicalDirectory(sourceHome, "preflight source home");
|
|
977
|
+
const context = {
|
|
978
|
+
externalRoot: join(root, "materialized"),
|
|
979
|
+
rootCanonical: source,
|
|
980
|
+
destinations: /* @__PURE__ */ new Map(),
|
|
981
|
+
pendingLinks: [],
|
|
982
|
+
directories: [],
|
|
983
|
+
skippedRuntimeEntries: 0
|
|
984
|
+
};
|
|
985
|
+
copySnapshotNode(source, home, context);
|
|
986
|
+
resolveSnapshotLinks(context);
|
|
987
|
+
assertSnapshotLinksContained(root, realpathSync(root));
|
|
988
|
+
finalizeSnapshotDirectories(context);
|
|
989
|
+
return {
|
|
990
|
+
home,
|
|
991
|
+
root,
|
|
992
|
+
skippedRuntimeEntries: context.skippedRuntimeEntries,
|
|
993
|
+
cleanup: () => {
|
|
994
|
+
rmSync(root, {
|
|
995
|
+
recursive: true,
|
|
996
|
+
force: true
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
};
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
rmSync(root, {
|
|
1002
|
+
recursive: true,
|
|
1003
|
+
force: true
|
|
1004
|
+
});
|
|
1005
|
+
throw error;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
function createTransitionPreflightSnapshot(plan) {
|
|
1009
|
+
const snapshot = createPreflightSnapshot(plan.home);
|
|
1010
|
+
try {
|
|
1011
|
+
const stateDir = join(snapshot.root, "guard-state");
|
|
1012
|
+
mkdirSync(stateDir, {
|
|
1013
|
+
recursive: true,
|
|
1014
|
+
mode: 448
|
|
1015
|
+
});
|
|
1016
|
+
applyTransition(prepareTransition({
|
|
1017
|
+
...plan,
|
|
1018
|
+
home: snapshot.home
|
|
1019
|
+
}, snapshot.home, stateDir, "preflight"), snapshot.home, stateDir, "preflight");
|
|
1020
|
+
return {
|
|
1021
|
+
home: snapshot.home,
|
|
1022
|
+
cleanup: snapshot.cleanup
|
|
1023
|
+
};
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
snapshot.cleanup();
|
|
1026
|
+
throw error;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
//#endregion
|
|
12
1030
|
//#region lib/types/cli.js
|
|
13
1031
|
/**
|
|
14
1032
|
* CLI for the self-restart guard — the interface the agent and the launcher
|
|
@@ -21,9 +1039,11 @@ import { fileURLToPath } from "node:url";
|
|
|
21
1039
|
* record — record a green credential for the current HEAD
|
|
22
1040
|
* status — print the full state (credential, checkpoint, audit)
|
|
23
1041
|
* clear — drop the credential
|
|
24
|
-
* checkpoint —
|
|
1042
|
+
* checkpoint — record clean HEAD, or explicitly commit a reviewed dirty snapshot
|
|
25
1043
|
* reset — `git reset --hard` to a checkpoint commit (rollback)
|
|
26
1044
|
* canary — post-restart probe: verify (+ optional TCP port check)
|
|
1045
|
+
* verify-restart — watchdog-facing validation of a scheduled authorization
|
|
1046
|
+
* record-proven-deployment — watchdog-facing promotion after canary
|
|
27
1047
|
* restart — DETACHED restart: gate → stop → start → probe → canary.
|
|
28
1048
|
* Owns the whole loop in a process that outlives the restarted
|
|
29
1049
|
* instance, so the post-restart canary runs even though the
|
|
@@ -37,9 +1057,8 @@ import { fileURLToPath } from "node:url";
|
|
|
37
1057
|
*/
|
|
38
1058
|
const FULL_ACCESS_HINT = "hint: the restart loop spawns detached processes and signals them — a sandboxed session (not full-access) will fail with EPERM. You CANNOT switch the sandbox yourself (that is the point of it): ask the user to run /permission danger-full-access in THIS session (the settings page only affects NEW sessions; an open persistent terminal fences the switch)\n";
|
|
39
1059
|
/**
|
|
40
|
-
* Printed
|
|
41
|
-
*
|
|
42
|
-
* service DOWN — the first-install bootstrap gap.
|
|
1060
|
+
* Printed by verify/record while no watchdog supervises the instance. The
|
|
1061
|
+
* stop-capable schedule-exit verb has its own hard refusal for this state.
|
|
43
1062
|
*/
|
|
44
1063
|
const NO_WATCHDOG_HINT = "warning: no live watchdog supervises the instance — a bare exit now leaves the service DOWN. Before the first restart, run `supervise --port N --start \"CMD\"` (it adopts the running instance and respawns ANY exit), or drive the restart with `restart` yourself\n";
|
|
45
1064
|
/**
|
|
@@ -75,35 +1094,41 @@ function sandboxGate(verb, options, io) {
|
|
|
75
1094
|
return false;
|
|
76
1095
|
}
|
|
77
1096
|
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
1097
|
+
* Resolve the restart's initiating session. An explicit --initiator that
|
|
1098
|
+
* contradicts the shell's own DSH_SESSION_ID routes the wake-up report to a
|
|
1099
|
+
* session that is not the caller — observed 2026-08-29: an agent invented a
|
|
1100
|
+
* branch-derived slug ('skill-styles-merge'), the report went to a session
|
|
1101
|
+
* that does not exist, and the actual scheduler was never woken. Warn loudly;
|
|
1102
|
+
* do not refuse — scheduling on behalf of another session is legitimate.
|
|
1103
|
+
* @param explicit - the --initiator flag value, when given.
|
|
1104
|
+
* @param io - CLI streams.
|
|
1105
|
+
* @returns the initiator to record (explicit wins, else the env default).
|
|
83
1106
|
*/
|
|
84
|
-
function
|
|
85
|
-
const
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
process.kill(pid, 0);
|
|
89
|
-
return true;
|
|
90
|
-
} catch {
|
|
91
|
-
return false;
|
|
92
|
-
}
|
|
1107
|
+
function resolveInitiator(explicit, io) {
|
|
1108
|
+
const fromEnv = process.env.DSH_SESSION_ID;
|
|
1109
|
+
if (explicit !== void 0 && explicit !== "" && fromEnv !== void 0 && explicit !== fromEnv) io.stderr(`warning: --initiator ${JSON.stringify(explicit)} does not match this session's DSH_SESSION_ID ${JSON.stringify(fromEnv)} — the restart report will be routed to ${JSON.stringify(explicit)} and THIS session will not be woken. Omit --initiator to route it to the current session.\n`);
|
|
1110
|
+
return explicit !== void 0 && explicit !== "" ? explicit : fromEnv;
|
|
93
1111
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
1112
|
+
function testChildEnv(role, env, options = {}) {
|
|
1113
|
+
if (process.env["ANKH_GUARD_TEST_RUN_DIR"] === void 0) return env;
|
|
1114
|
+
return {
|
|
1115
|
+
...env,
|
|
1116
|
+
[TEST_PROCESS_ROLE_ENV]: role,
|
|
1117
|
+
...options.port === void 0 ? {} : { [TEST_PROCESS_PORT_ENV]: String(options.port) },
|
|
1118
|
+
...options.tempRoot === void 0 ? {} : { [TEST_PROCESS_TEMP_ROOT_ENV]: options.tempRoot }
|
|
1119
|
+
};
|
|
102
1120
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
1121
|
+
function registerSpawnedTestProcess(child, role, options = {}) {
|
|
1122
|
+
if (child.pid === void 0) return;
|
|
1123
|
+
registerTestProcess(child.pid, role, {
|
|
1124
|
+
source: "parent-observer",
|
|
1125
|
+
...options.port === void 0 ? {} : { port: options.port },
|
|
1126
|
+
...options.tempRoot === void 0 ? {} : { tempRoot: options.tempRoot }
|
|
1127
|
+
});
|
|
1128
|
+
appendTestLifecycleEventForProcess(child.pid, role, "child-spawned", {
|
|
1129
|
+
childPid: child.pid,
|
|
1130
|
+
role
|
|
1131
|
+
}, "parent-observer");
|
|
107
1132
|
}
|
|
108
1133
|
/**
|
|
109
1134
|
* Cross-session restart mutual exclusion: two concurrent restarts would both
|
|
@@ -183,29 +1208,52 @@ function cliInvocation(args) {
|
|
|
183
1208
|
const USAGE = `usage: dsh-ankh-guard <command> [args] [flags]
|
|
184
1209
|
commands:
|
|
185
1210
|
verify [--state-dir DIR] [--repo DIR] [--max-age MIN]
|
|
186
|
-
record <scope> [--
|
|
1211
|
+
record <scope> [--state-dir DIR] [--repo DIR] --run -- PROGRAM [ARG...]
|
|
1212
|
+
record <scope> [--state-dir DIR] [--repo DIR] --trust-command --command CMD
|
|
187
1213
|
status [--state-dir DIR]
|
|
188
1214
|
clear [--state-dir DIR]
|
|
189
|
-
checkpoint [--message MSG] [--repo DIR] [--state-dir DIR]
|
|
1215
|
+
checkpoint [--message MSG] [--include-dirty] [--repo DIR] [--state-dir DIR]
|
|
190
1216
|
reset <sha> [--repo DIR]
|
|
191
1217
|
canary [--port N] [--state-dir DIR] [--repo DIR] [--max-age MIN]
|
|
1218
|
+
verify-restart [--state-dir DIR] # watchdog-facing: revalidate the scheduled authorization
|
|
1219
|
+
record-proven-deployment [--state-dir DIR] # watchdog-facing: promote/retain proof after canary
|
|
192
1220
|
check-env [--state-dir DIR] [--repo DIR] # sandbox / watchdog / git readiness probe
|
|
193
|
-
preflight [--profile NAME] [--timeout-ms MS]
|
|
1221
|
+
preflight [--profile NAME] [--harness-root DIR] [--timeout-ms MS]
|
|
1222
|
+
[--preflight-surface source|built --preflight-install-anchor FILE] [--preflight-runner FILE]
|
|
194
1223
|
record-unexpected-exit [--state-dir DIR] # watchdog-facing: record an unplanned-exit recovery
|
|
195
1224
|
record-adoption [--initiator ID] [--state-dir DIR] # watchdog-facing: record the first (adoption) takeover
|
|
1225
|
+
record-composition-recovery [--state-dir DIR] # watchdog-facing: record a composition-rollback recovery
|
|
1226
|
+
configure-launch --port N --start "CMD" [--home DIR] [--repo DIR] --harness-root DIR [--profile NAME]
|
|
1227
|
+
--preflight-surface source|built [--preflight-runner FILE] --preflight-install-anchor FILE [--if-absent]
|
|
1228
|
+
launch-status [--state-dir DIR]
|
|
1229
|
+
transition-apply CUTOVER_ID [--state-dir DIR] # watchdog-facing: apply the prepared transition
|
|
1230
|
+
transition-rollback CUTOVER_ID [--state-dir DIR] # watchdog-facing: restore previous state before previous starts
|
|
1231
|
+
abort-cutover [--state-dir DIR] # apply the recovery policy approved by reconfigure
|
|
1232
|
+
restore-previous [--state-dir DIR] # explicit new authorization to restore the complete previous spec
|
|
1233
|
+
reconfigure --start "CMD" --on-failure restore-previous|wait-for-user [--port N]
|
|
1234
|
+
[--home DIR] [--repo DIR] [--harness-root DIR] [--profile NAME] [--browser-handoff required|off]
|
|
1235
|
+
--preflight-surface source|built [--preflight-runner FILE] --preflight-install-anchor FILE
|
|
1236
|
+
--candidate-probe-command "CMD"
|
|
1237
|
+
[--transition-file FILE] [--delay-ms MS] [--supervisor-yield-timeout-ms MS] [--preflight-timeout-ms MS] [--state-dir DIR]
|
|
196
1238
|
restart --port N --start "CMD" [--pid PID] [--timeout-ms MS] [--delay-ms MS] [--stop-timeout-ms MS] [--rollback]
|
|
197
|
-
[--profile NAME] [--preflight-timeout-ms MS] [--state-dir DIR] [--repo DIR] [--max-age MIN]
|
|
198
|
-
schedule-exit --port N --delay-ms MS [--initiator ID] [--log FILE] [--profile NAME]
|
|
199
|
-
[--preflight-timeout-ms MS] [--state-dir DIR] [--repo DIR]
|
|
200
|
-
supervise --port N --start "CMD" [--foreground] [--log FILE] [--state-dir DIR] [--repo DIR] [--home DIR]
|
|
1239
|
+
[--profile NAME] [--harness-root DIR] [--preflight-timeout-ms MS] [--state-dir DIR] [--repo DIR] [--max-age MIN]
|
|
1240
|
+
schedule-exit [--port N] --delay-ms MS [--initiator ID] [--log FILE] [--profile NAME]
|
|
1241
|
+
[--harness-root DIR] [--preflight-timeout-ms MS] [--state-dir DIR] [--repo DIR]
|
|
1242
|
+
supervise --port N --start "CMD" [--foreground] [--log FILE] [--state-dir DIR] [--repo DIR] [--harness-root DIR] [--home DIR]
|
|
201
1243
|
flags:
|
|
202
1244
|
--state-dir DIR state directory (default: $DSH_HOME/state, else <cwd>/.dsh-guard-state)
|
|
203
1245
|
--repo DIR repository the credential binds to (default: cwd)
|
|
1246
|
+
--harness-root DIR dsh host checkout used by preflight and exported to the
|
|
1247
|
+
child as DSH_HARNESS; launch-state initialization requires
|
|
1248
|
+
this flag or an existing DSH_HARNESS
|
|
204
1249
|
--max-age MIN credential freshness window in minutes (default: 10)
|
|
205
1250
|
--port N canary/restart/supervise: TCP port that must be listening
|
|
206
|
-
--
|
|
1251
|
+
--run -- PROGRAM [ARG...] record: execute this exact argv in --repo and record only on exit 0
|
|
1252
|
+
--trust-command record: explicitly trust an external orchestrator's already-green --command
|
|
1253
|
+
--command CMD record --trust-command: description of the externally proven command
|
|
207
1254
|
--message MSG checkpoint: batch description
|
|
208
|
-
--
|
|
1255
|
+
--include-dirty checkpoint: after review, explicitly commit every staged, unstaged, and untracked change
|
|
1256
|
+
--start "CMD" restart/supervise/reconfigure: the shell command that starts the instance
|
|
209
1257
|
(optional once the plugin has booted — it records the launch
|
|
210
1258
|
command to <state-dir>/instance-launch.json)
|
|
211
1259
|
--pid PID restart: process to stop (default: the listener on --port)
|
|
@@ -216,18 +1264,37 @@ flags:
|
|
|
216
1264
|
logs can take tens of seconds to flush)
|
|
217
1265
|
--delay-ms MS restart: sleep before stopping, so the current turn can finish first
|
|
218
1266
|
(agent-driven graceful self-restart: schedule, complete, then restart);
|
|
219
|
-
schedule-exit: delay before the detached exit agent kills the host
|
|
1267
|
+
schedule-exit: delay before the detached exit agent kills the host;
|
|
1268
|
+
reconfigure: grace after successor supervisor claim before old-child stop
|
|
220
1269
|
--log FILE supervise (detached only — with --foreground the external supervisor's
|
|
221
1270
|
redirection owns the log) / schedule-exit: log file (default: <state-dir>/*.log)
|
|
222
1271
|
--home DIR supervise: the dsh home the supervised instance boots with (profiles,
|
|
223
1272
|
credentials — default: $DSH_HOME; required when that is unset)
|
|
224
1273
|
--initiator ID schedule-exit: session id that requested the exit (default: $DSH_SESSION_ID);
|
|
225
|
-
recorded in last-restart.json so the restart report returns to that session
|
|
226
|
-
|
|
1274
|
+
recorded in last-restart.json so the restart report returns to that session.
|
|
1275
|
+
Do NOT invent a value: a mismatched id routes the wake-up away from you
|
|
1276
|
+
(the CLI warns when ID contradicts this shell's $DSH_SESSION_ID)
|
|
1277
|
+
--profile NAME preflight/schedule-exit/restart/reconfigure: the dsh profile to dry-run (default:
|
|
227
1278
|
$DSH_PROFILE, else "web")
|
|
228
1279
|
--preflight-timeout-ms MS schedule-exit/restart: bound on the composition preflight (default 120000)
|
|
1280
|
+
--preflight-surface MODE configure-launch/reconfigure: explicit successor module surface, source or built
|
|
1281
|
+
--preflight-runner FILE runner file to bind by absolute path and SHA-256 (default: this package's matching face)
|
|
1282
|
+
--preflight-install-anchor FILE the exact successor dsh package.json; built imports resolve from this npm toolchain
|
|
1283
|
+
--candidate-probe-command CMD reconfigure: caller-supplied one-shot probe, durably co-bound with --start SHA-256
|
|
229
1284
|
--rollback restart: on failure, git reset --hard to the recorded checkpoint
|
|
230
|
-
--
|
|
1285
|
+
--on-failure POLICY reconfigure: REQUIRED pre-approved recovery policy:
|
|
1286
|
+
restore-previous (restore the complete previous launch spec) or
|
|
1287
|
+
wait-for-user (park without resetting a repository)
|
|
1288
|
+
--browser-handoff MODE reconfigure: required (default) or off; when a protected
|
|
1289
|
+
root announces a same-authority launch URL, readiness requires
|
|
1290
|
+
303 cookie exchange and authenticated / = 200; browser handoff
|
|
1291
|
+
separately requires an original/fallback page acknowledgement
|
|
1292
|
+
--transition-file FILE reconfigure: a schema-v1 reversible quarantine plan.
|
|
1293
|
+
The guard validates and preflights it on an isolated home,
|
|
1294
|
+
then applies it only after previous stops; recovery retains
|
|
1295
|
+
target-created replacements before restoring previous bytes.
|
|
1296
|
+
--if-absent configure-launch: initialize only; keep an existing selected spec
|
|
1297
|
+
--force restart/schedule-exit/supervise/reconfigure: override the sandbox probe refusal
|
|
231
1298
|
--sync restart: run the whole loop in-process (debug/tests; the default
|
|
232
1299
|
self-detaches a driver so the loop survives the caller's teardown)
|
|
233
1300
|
`;
|
|
@@ -240,16 +1307,21 @@ function parse(argv) {
|
|
|
240
1307
|
const options = {
|
|
241
1308
|
stateDir: "",
|
|
242
1309
|
repoDir: "",
|
|
1310
|
+
harnessRoot: "",
|
|
243
1311
|
home: "",
|
|
244
1312
|
maxAgeMinutes: 10,
|
|
245
1313
|
port: void 0,
|
|
246
1314
|
command: void 0,
|
|
1315
|
+
run: false,
|
|
1316
|
+
runArgv: void 0,
|
|
247
1317
|
message: void 0,
|
|
1318
|
+
detail: void 0,
|
|
248
1319
|
start: void 0,
|
|
249
1320
|
pid: void 0,
|
|
250
1321
|
timeoutMs: void 0,
|
|
251
1322
|
delayMs: void 0,
|
|
252
1323
|
stopTimeoutMs: void 0,
|
|
1324
|
+
supervisorYieldTimeoutMs: void 0,
|
|
253
1325
|
log: void 0,
|
|
254
1326
|
foreground: false,
|
|
255
1327
|
rollback: false,
|
|
@@ -257,7 +1329,19 @@ function parse(argv) {
|
|
|
257
1329
|
sync: false,
|
|
258
1330
|
initiator: void 0,
|
|
259
1331
|
profile: void 0,
|
|
260
|
-
preflightTimeoutMs: void 0
|
|
1332
|
+
preflightTimeoutMs: void 0,
|
|
1333
|
+
preflightSurface: void 0,
|
|
1334
|
+
preflightRunner: void 0,
|
|
1335
|
+
preflightInstallAnchor: void 0,
|
|
1336
|
+
candidateProbeCommand: void 0,
|
|
1337
|
+
onFailure: void 0,
|
|
1338
|
+
browserHandoff: "required",
|
|
1339
|
+
ifAbsent: false,
|
|
1340
|
+
trustCommand: false,
|
|
1341
|
+
includeDirty: false,
|
|
1342
|
+
takeoverFrom: void 0,
|
|
1343
|
+
cutoverId: void 0,
|
|
1344
|
+
transitionFile: void 0
|
|
261
1345
|
};
|
|
262
1346
|
const positionals = [];
|
|
263
1347
|
let i = 0;
|
|
@@ -269,6 +1353,10 @@ function parse(argv) {
|
|
|
269
1353
|
try {
|
|
270
1354
|
for (; i < argv.length; i++) {
|
|
271
1355
|
const arg = argv[i] ?? "";
|
|
1356
|
+
if (arg === "--") {
|
|
1357
|
+
options.runArgv = argv.slice(i + 1);
|
|
1358
|
+
break;
|
|
1359
|
+
}
|
|
272
1360
|
switch (arg) {
|
|
273
1361
|
case "--state-dir":
|
|
274
1362
|
options.stateDir = flagValue(arg, true) ?? "";
|
|
@@ -282,6 +1370,10 @@ function parse(argv) {
|
|
|
282
1370
|
options.repoDir = flagValue(arg, true) ?? "";
|
|
283
1371
|
i++;
|
|
284
1372
|
break;
|
|
1373
|
+
case "--harness-root":
|
|
1374
|
+
options.harnessRoot = flagValue(arg, true) ?? "";
|
|
1375
|
+
i++;
|
|
1376
|
+
break;
|
|
285
1377
|
case "--max-age": {
|
|
286
1378
|
const raw = flagValue(arg, true);
|
|
287
1379
|
const n = Number(raw);
|
|
@@ -302,10 +1394,23 @@ function parse(argv) {
|
|
|
302
1394
|
options.command = flagValue(arg, true) ?? "";
|
|
303
1395
|
i++;
|
|
304
1396
|
break;
|
|
1397
|
+
case "--run":
|
|
1398
|
+
options.run = true;
|
|
1399
|
+
break;
|
|
1400
|
+
case "--trust-command":
|
|
1401
|
+
options.trustCommand = true;
|
|
1402
|
+
break;
|
|
1403
|
+
case "--include-dirty":
|
|
1404
|
+
options.includeDirty = true;
|
|
1405
|
+
break;
|
|
305
1406
|
case "--message":
|
|
306
1407
|
options.message = flagValue(arg, true) ?? "";
|
|
307
1408
|
i++;
|
|
308
1409
|
break;
|
|
1410
|
+
case "--detail":
|
|
1411
|
+
options.detail = flagValue(arg, true);
|
|
1412
|
+
i++;
|
|
1413
|
+
break;
|
|
309
1414
|
case "--start":
|
|
310
1415
|
options.start = flagValue(arg, true) ?? "";
|
|
311
1416
|
i++;
|
|
@@ -342,6 +1447,14 @@ function parse(argv) {
|
|
|
342
1447
|
i++;
|
|
343
1448
|
break;
|
|
344
1449
|
}
|
|
1450
|
+
case "--supervisor-yield-timeout-ms": {
|
|
1451
|
+
const raw = flagValue(arg, true);
|
|
1452
|
+
const n = Number(raw);
|
|
1453
|
+
if (raw === void 0 || !Number.isInteger(n) || n < 100) throw new Error("--supervisor-yield-timeout-ms must be an integer >= 100");
|
|
1454
|
+
options.supervisorYieldTimeoutMs = n;
|
|
1455
|
+
i++;
|
|
1456
|
+
break;
|
|
1457
|
+
}
|
|
345
1458
|
case "--foreground":
|
|
346
1459
|
options.foreground = true;
|
|
347
1460
|
break;
|
|
@@ -361,6 +1474,58 @@ function parse(argv) {
|
|
|
361
1474
|
i++;
|
|
362
1475
|
break;
|
|
363
1476
|
}
|
|
1477
|
+
case "--preflight-surface": {
|
|
1478
|
+
const value = flagValue(arg, true);
|
|
1479
|
+
if (value !== "source" && value !== "built") throw new Error("--preflight-surface must be source or built");
|
|
1480
|
+
options.preflightSurface = value;
|
|
1481
|
+
i++;
|
|
1482
|
+
break;
|
|
1483
|
+
}
|
|
1484
|
+
case "--preflight-runner":
|
|
1485
|
+
options.preflightRunner = flagValue(arg, true) ?? "";
|
|
1486
|
+
i++;
|
|
1487
|
+
break;
|
|
1488
|
+
case "--preflight-install-anchor":
|
|
1489
|
+
options.preflightInstallAnchor = flagValue(arg, true) ?? "";
|
|
1490
|
+
i++;
|
|
1491
|
+
break;
|
|
1492
|
+
case "--candidate-probe-command":
|
|
1493
|
+
options.candidateProbeCommand = flagValue(arg, true) ?? "";
|
|
1494
|
+
i++;
|
|
1495
|
+
break;
|
|
1496
|
+
case "--on-failure": {
|
|
1497
|
+
const value = flagValue(arg, true);
|
|
1498
|
+
if (value !== "restore-previous" && value !== "wait-for-user") throw new Error("--on-failure must be restore-previous or wait-for-user");
|
|
1499
|
+
options.onFailure = value;
|
|
1500
|
+
i++;
|
|
1501
|
+
break;
|
|
1502
|
+
}
|
|
1503
|
+
case "--browser-handoff": {
|
|
1504
|
+
const value = flagValue(arg, true);
|
|
1505
|
+
if (value !== "required" && value !== "off") throw new Error("--browser-handoff must be required or off");
|
|
1506
|
+
options.browserHandoff = value;
|
|
1507
|
+
i++;
|
|
1508
|
+
break;
|
|
1509
|
+
}
|
|
1510
|
+
case "--takeover-from": {
|
|
1511
|
+
const raw = flagValue(arg, true);
|
|
1512
|
+
const value = Number(raw);
|
|
1513
|
+
if (raw === void 0 || !Number.isInteger(value) || value <= 0) throw new Error("--takeover-from must be a positive pid");
|
|
1514
|
+
options.takeoverFrom = value;
|
|
1515
|
+
i++;
|
|
1516
|
+
break;
|
|
1517
|
+
}
|
|
1518
|
+
case "--cutover-id":
|
|
1519
|
+
options.cutoverId = flagValue(arg, true) ?? "";
|
|
1520
|
+
i++;
|
|
1521
|
+
break;
|
|
1522
|
+
case "--transition-file":
|
|
1523
|
+
options.transitionFile = flagValue(arg, true) ?? "";
|
|
1524
|
+
i++;
|
|
1525
|
+
break;
|
|
1526
|
+
case "--if-absent":
|
|
1527
|
+
options.ifAbsent = true;
|
|
1528
|
+
break;
|
|
364
1529
|
case "--rollback":
|
|
365
1530
|
options.rollback = true;
|
|
366
1531
|
break;
|
|
@@ -416,6 +1581,68 @@ async function sleep(ms) {
|
|
|
416
1581
|
setTimeout(resolve, ms);
|
|
417
1582
|
});
|
|
418
1583
|
}
|
|
1584
|
+
/** Execute the exact argv used as credential evidence, streaming diagnostics. */
|
|
1585
|
+
async function runCredentialCommand(argv, cwd, io) {
|
|
1586
|
+
const executable = argv[0];
|
|
1587
|
+
if (executable === void 0 || executable === "") return {
|
|
1588
|
+
ok: false,
|
|
1589
|
+
detail: "no program was provided after --"
|
|
1590
|
+
};
|
|
1591
|
+
return new Promise((resolvePromise) => {
|
|
1592
|
+
let settled = false;
|
|
1593
|
+
const settle = (result) => {
|
|
1594
|
+
if (settled) return;
|
|
1595
|
+
settled = true;
|
|
1596
|
+
resolvePromise(result);
|
|
1597
|
+
};
|
|
1598
|
+
let child;
|
|
1599
|
+
try {
|
|
1600
|
+
child = spawn(executable, argv.slice(1), {
|
|
1601
|
+
cwd,
|
|
1602
|
+
env: testChildEnv("credential-command", { ...process.env }, { tempRoot: cwd }),
|
|
1603
|
+
stdio: [
|
|
1604
|
+
"ignore",
|
|
1605
|
+
"pipe",
|
|
1606
|
+
"pipe"
|
|
1607
|
+
]
|
|
1608
|
+
});
|
|
1609
|
+
registerSpawnedTestProcess(child, "credential-command", { tempRoot: cwd });
|
|
1610
|
+
} catch (error) {
|
|
1611
|
+
settle({
|
|
1612
|
+
ok: false,
|
|
1613
|
+
detail: `could not start ${JSON.stringify(executable)}: ${String(error)}`
|
|
1614
|
+
});
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
child.stdout.on("data", (chunk) => {
|
|
1618
|
+
io.stdout(chunk.toString());
|
|
1619
|
+
});
|
|
1620
|
+
child.stderr.on("data", (chunk) => {
|
|
1621
|
+
io.stderr(chunk.toString());
|
|
1622
|
+
});
|
|
1623
|
+
child.once("error", (error) => {
|
|
1624
|
+
settle({
|
|
1625
|
+
ok: false,
|
|
1626
|
+
detail: `could not start ${JSON.stringify(executable)}: ${String(error)}`
|
|
1627
|
+
});
|
|
1628
|
+
});
|
|
1629
|
+
child.once("exit", (code, signal) => {
|
|
1630
|
+
if (code === 0) settle({ ok: true });
|
|
1631
|
+
else settle({
|
|
1632
|
+
ok: false,
|
|
1633
|
+
detail: signal === null ? `command exited ${code ?? "without a status"}` : `command was terminated by ${signal}`
|
|
1634
|
+
});
|
|
1635
|
+
});
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
/** Stable, non-shell rendering for credential audit metadata. */
|
|
1639
|
+
function renderArgv(argv) {
|
|
1640
|
+
return argv.map((word) => JSON.stringify(word)).join(" ");
|
|
1641
|
+
}
|
|
1642
|
+
/** The credential gate always includes uncommitted and untracked inputs. */
|
|
1643
|
+
function verifyRepoCredential(stateDir, repoDir, maxAgeMinutes) {
|
|
1644
|
+
return verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), maxAgeMinutes, isWorkingTreeClean(repoDir));
|
|
1645
|
+
}
|
|
419
1646
|
/**
|
|
420
1647
|
* How the spawned watchdog should invoke the guard CLI: the built form runs
|
|
421
1648
|
* `node <cli>`; the source form needs tsx with an absolute path (the watchdog
|
|
@@ -456,6 +1683,23 @@ function exitAgentInvocation() {
|
|
|
456
1683
|
const DEFAULT_PREFLIGHT_TIMEOUT_MS = 12e4;
|
|
457
1684
|
/** A pending restart marker older than this is stale — its watchdog died mid-flow. */
|
|
458
1685
|
const RESTART_MARKER_TTL_MS = 9e5;
|
|
1686
|
+
function isRestartAuthorization(value) {
|
|
1687
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1688
|
+
const authorization = value;
|
|
1689
|
+
return authorization.version === 1 && (authorization.kind === "fresh-credential" || authorization.kind === "proven-deployment") && typeof authorization.revision === "string" && authorization.revision !== "" && typeof authorization.evidenceSha256 === "string" && /^[a-f0-9]{64}$/.test(authorization.evidenceSha256);
|
|
1690
|
+
}
|
|
1691
|
+
function readRestartRequestMarker(stateDir) {
|
|
1692
|
+
try {
|
|
1693
|
+
const marker = JSON.parse(readFileSync(stateFile(stateDir, "restartRequested"), "utf8"));
|
|
1694
|
+
if (typeof marker !== "object" || marker === null) return null;
|
|
1695
|
+
return {
|
|
1696
|
+
...typeof marker.requestedAt === "number" ? { requestedAt: marker.requestedAt } : {},
|
|
1697
|
+
...isRestartAuthorization(marker.authorization) ? { authorization: marker.authorization } : {}
|
|
1698
|
+
};
|
|
1699
|
+
} catch {
|
|
1700
|
+
return null;
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
459
1703
|
/**
|
|
460
1704
|
* The restart marker's state. Every verb that can stop the instance must
|
|
461
1705
|
* consult this (and the restart lock) — a stop right invisible to the other
|
|
@@ -464,12 +1708,8 @@ const RESTART_MARKER_TTL_MS = 9e5;
|
|
|
464
1708
|
function restartMarkerState(stateDir) {
|
|
465
1709
|
const file = stateFile(stateDir, "restartRequested");
|
|
466
1710
|
if (!existsSync(file)) return "none";
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
return typeof marker.requestedAt === "number" && Date.now() - marker.requestedAt <= RESTART_MARKER_TTL_MS ? "fresh" : "stale";
|
|
470
|
-
} catch {
|
|
471
|
-
return "stale";
|
|
472
|
-
}
|
|
1711
|
+
const marker = readRestartRequestMarker(stateDir);
|
|
1712
|
+
return marker?.requestedAt !== void 0 && Date.now() - marker.requestedAt <= RESTART_MARKER_TTL_MS ? "fresh" : "stale";
|
|
473
1713
|
}
|
|
474
1714
|
/** Captured preflight output is diagnostics, not a log — cap it before it can grow without bound. */
|
|
475
1715
|
const PREFLIGHT_OUTPUT_CAP = 65536;
|
|
@@ -502,11 +1742,12 @@ const preflightInternals = {
|
|
|
502
1742
|
};
|
|
503
1743
|
/**
|
|
504
1744
|
* The harness checkout the live instance boots from (and the preflight
|
|
505
|
-
* runner resolves the official published packages from): the
|
|
506
|
-
* when given, else `DSH_HARNESS`, else the
|
|
1745
|
+
* runner resolves the official published packages from): the
|
|
1746
|
+
* `--harness-root` target when given, else `DSH_HARNESS`, else the
|
|
1747
|
+
* conventional default. Credential repositories never enter this resolver.
|
|
507
1748
|
*/
|
|
508
|
-
function resolveHarnessRoot(
|
|
509
|
-
if (
|
|
1749
|
+
function resolveHarnessRoot(optionHarnessRoot, env = process.env) {
|
|
1750
|
+
if (optionHarnessRoot !== void 0 && optionHarnessRoot !== "") return optionHarnessRoot;
|
|
510
1751
|
const fromEnv = env.DSH_HARNESS;
|
|
511
1752
|
return fromEnv !== void 0 && fromEnv.trim() !== "" ? fromEnv : join(homedir(), "code/deepseek-harness");
|
|
512
1753
|
}
|
|
@@ -522,7 +1763,69 @@ function resolveRunnerCommand(harnessRoot) {
|
|
|
522
1763
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
523
1764
|
const runner = existsSync(join(here, "preflight-runner.ts")) ? join(here, "preflight-runner.ts") : existsSync(join(here, "preflight-runner.js")) ? join(here, "preflight-runner.js") : void 0;
|
|
524
1765
|
if (runner === void 0) return void 0;
|
|
525
|
-
return `node --import ${shellQuote(tsx)} ${shellQuote(runner)}`;
|
|
1766
|
+
return `node --import ${shellQuote(tsx)} ${shellQuote(runner)} --host-surface source --install-anchor ${shellQuote(join(harnessRoot, "apps", "cli", "package.json"))}`;
|
|
1767
|
+
}
|
|
1768
|
+
function fileSha256(file) {
|
|
1769
|
+
return createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
1770
|
+
}
|
|
1771
|
+
function killSpawnGroup(pid) {
|
|
1772
|
+
if (pid === void 0) return;
|
|
1773
|
+
try {
|
|
1774
|
+
process.kill(-pid, "SIGKILL");
|
|
1775
|
+
} catch {}
|
|
1776
|
+
}
|
|
1777
|
+
function defaultPreflightRunner(surface) {
|
|
1778
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
1779
|
+
return (surface === "source" ? [
|
|
1780
|
+
join(here, "preflight-runner.ts"),
|
|
1781
|
+
join(here, "..", "src", "preflight-runner.ts"),
|
|
1782
|
+
join(here, "preflight-runner.js"),
|
|
1783
|
+
join(here, "..", "lib", "preflight-runner.js")
|
|
1784
|
+
] : [join(here, "preflight-runner.js"), join(here, "..", "lib", "preflight-runner.js")]).find((file) => existsSync(file));
|
|
1785
|
+
}
|
|
1786
|
+
/** Build and validate the explicit preflight contract persisted with a launch spec. */
|
|
1787
|
+
function resolvePreflightSpec(options, command, harnessRoot, requireCandidate) {
|
|
1788
|
+
if (options.preflightSurface === void 0) throw new Error("--preflight-surface source|built is required; the guard will not infer the successor execution surface");
|
|
1789
|
+
if (options.preflightInstallAnchor === void 0 || options.preflightInstallAnchor === "") throw new Error("--preflight-install-anchor FILE is required and must name the successor dsh package.json");
|
|
1790
|
+
const installAnchor = resolve(options.preflightInstallAnchor);
|
|
1791
|
+
let manifest;
|
|
1792
|
+
try {
|
|
1793
|
+
manifest = JSON.parse(readFileSync(installAnchor, "utf8"));
|
|
1794
|
+
} catch (error) {
|
|
1795
|
+
throw new Error(`preflight install anchor is unreadable: ${String(error)}`);
|
|
1796
|
+
}
|
|
1797
|
+
if (manifest.name !== "@deepseek-ai/dsh") throw new Error("preflight install anchor must be @deepseek-ai/dsh/package.json");
|
|
1798
|
+
if (typeof manifest.version !== "string" || manifest.version === "") throw new Error("preflight install anchor must declare the dsh package version");
|
|
1799
|
+
const configuredRunner = options.preflightRunner ?? defaultPreflightRunner(options.preflightSurface);
|
|
1800
|
+
if (configuredRunner === void 0 || configuredRunner === "") throw new Error(`no ${options.preflightSurface} preflight runner exists; build ankh-guard or pass --preflight-runner FILE`);
|
|
1801
|
+
const runnerPath = resolve(configuredRunner);
|
|
1802
|
+
if (!existsSync(runnerPath)) throw new Error(`preflight runner does not exist: ${runnerPath}`);
|
|
1803
|
+
if (options.preflightSurface === "built" && !runnerPath.endsWith(".js")) throw new Error("built preflight requires a JavaScript runner");
|
|
1804
|
+
const runnerRuntimeArgs = [];
|
|
1805
|
+
if (options.preflightSurface === "source") {
|
|
1806
|
+
const tsx = join(harnessRoot, "node_modules", "tsx", "dist", "esm", "index.mjs");
|
|
1807
|
+
if (!existsSync(tsx)) throw new Error(`source preflight requires the target checkout's tsx runtime: ${tsx}`);
|
|
1808
|
+
runnerRuntimeArgs.push("--import", tsx);
|
|
1809
|
+
}
|
|
1810
|
+
const candidateProbeCommand = options.candidateProbeCommand;
|
|
1811
|
+
if (requireCandidate && (candidateProbeCommand === void 0 || candidateProbeCommand.trim() === "")) throw new Error("--candidate-probe-command CMD is required; the caller must derive it from the target executable/argv before the previous host stops");
|
|
1812
|
+
return {
|
|
1813
|
+
version: 1,
|
|
1814
|
+
surface: options.preflightSurface,
|
|
1815
|
+
runnerExecutable: process.execPath,
|
|
1816
|
+
runnerRuntimeArgs,
|
|
1817
|
+
runnerPath,
|
|
1818
|
+
runnerSha256: fileSha256(runnerPath),
|
|
1819
|
+
installAnchor,
|
|
1820
|
+
installAnchorSha256: fileSha256(installAnchor),
|
|
1821
|
+
hostPackageVersion: manifest.version,
|
|
1822
|
+
targetCommandSha256: commandSha256(command),
|
|
1823
|
+
...candidateProbeCommand === void 0 || candidateProbeCommand.trim() === "" ? {} : {
|
|
1824
|
+
candidateProbeCommand,
|
|
1825
|
+
candidateProbeSha256: commandSha256(candidateProbeCommand),
|
|
1826
|
+
candidateProbeProvenance: "caller-supplied"
|
|
1827
|
+
}
|
|
1828
|
+
};
|
|
526
1829
|
}
|
|
527
1830
|
/** POSIX single-quote one word for the shell command line. */
|
|
528
1831
|
function shellQuote(word) {
|
|
@@ -568,13 +1871,57 @@ function redactLaunchCommand(command) {
|
|
|
568
1871
|
* @param profile - the dsh profile to dry-run.
|
|
569
1872
|
* @param timeoutMs - bound on the whole subprocess run; a timeout kills it.
|
|
570
1873
|
* @param harnessRoot - harness checkout for the runner (default: DSH_HARNESS / ~/code/deepseek-harness).
|
|
1874
|
+
* @param home - dsh home the dry-run must read instead of ambient process state.
|
|
571
1875
|
* @returns the classified outcome.
|
|
572
1876
|
*/
|
|
573
|
-
async function runPreflightCheck(profile, timeoutMs, harnessRoot) {
|
|
574
|
-
const override = process.env.DSH_PREFLIGHT_COMMAND;
|
|
1877
|
+
async function runPreflightCheck(profile, timeoutMs, harnessRoot, home, binding) {
|
|
1878
|
+
const override = binding === void 0 ? process.env.DSH_PREFLIGHT_COMMAND : void 0;
|
|
575
1879
|
let command;
|
|
1880
|
+
let executable;
|
|
1881
|
+
let argv = [];
|
|
576
1882
|
let usingRunner = false;
|
|
577
|
-
if (
|
|
1883
|
+
if (binding !== void 0) {
|
|
1884
|
+
let runnerSha;
|
|
1885
|
+
try {
|
|
1886
|
+
runnerSha = fileSha256(binding.runnerPath);
|
|
1887
|
+
} catch {
|
|
1888
|
+
return {
|
|
1889
|
+
kind: "infra-failed",
|
|
1890
|
+
output: "",
|
|
1891
|
+
detail: `the bound preflight runner is unavailable: ${binding.runnerPath}`
|
|
1892
|
+
};
|
|
1893
|
+
}
|
|
1894
|
+
if (runnerSha !== binding.runnerSha256) return {
|
|
1895
|
+
kind: "infra-failed",
|
|
1896
|
+
output: "",
|
|
1897
|
+
detail: "the bound preflight runner changed after launch configuration"
|
|
1898
|
+
};
|
|
1899
|
+
try {
|
|
1900
|
+
if (fileSha256(binding.installAnchor) !== binding.installAnchorSha256) return {
|
|
1901
|
+
kind: "infra-failed",
|
|
1902
|
+
output: "",
|
|
1903
|
+
detail: "the bound dsh install anchor changed after launch configuration"
|
|
1904
|
+
};
|
|
1905
|
+
} catch {
|
|
1906
|
+
return {
|
|
1907
|
+
kind: "infra-failed",
|
|
1908
|
+
output: "",
|
|
1909
|
+
detail: `the bound dsh install anchor is unavailable: ${binding.installAnchor}`
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
executable = binding.runnerExecutable;
|
|
1913
|
+
argv = [
|
|
1914
|
+
...binding.runnerRuntimeArgs,
|
|
1915
|
+
binding.runnerPath,
|
|
1916
|
+
"--host-surface",
|
|
1917
|
+
binding.surface,
|
|
1918
|
+
"--install-anchor",
|
|
1919
|
+
binding.installAnchor,
|
|
1920
|
+
"--profile",
|
|
1921
|
+
profile
|
|
1922
|
+
];
|
|
1923
|
+
usingRunner = true;
|
|
1924
|
+
} else if (override !== void 0 && override !== "") command = override;
|
|
578
1925
|
else {
|
|
579
1926
|
const root = harnessRoot ?? resolveHarnessRoot(void 0);
|
|
580
1927
|
const runner = preflightInternals.resolveRunner(root);
|
|
@@ -594,13 +1941,19 @@ async function runPreflightCheck(profile, timeoutMs, harnessRoot) {
|
|
|
594
1941
|
return await new Promise((resolvePromise) => {
|
|
595
1942
|
let output = "";
|
|
596
1943
|
let timedOut = false;
|
|
597
|
-
const
|
|
1944
|
+
const preflightEnv = testChildEnv("composition-preflight", {
|
|
1945
|
+
...process.env,
|
|
1946
|
+
DSH_HARNESS: harnessForRunner,
|
|
1947
|
+
...home === void 0 ? {} : { DSH_HOME: home }
|
|
1948
|
+
}, { ...home === void 0 ? {} : { tempRoot: home } });
|
|
1949
|
+
const child = executable === void 0 ? spawn(command ?? "", {
|
|
598
1950
|
shell: true,
|
|
599
|
-
env:
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
})
|
|
1951
|
+
env: preflightEnv
|
|
1952
|
+
}) : spawn(executable, argv, {
|
|
1953
|
+
shell: false,
|
|
1954
|
+
env: preflightEnv
|
|
1955
|
+
});
|
|
1956
|
+
registerSpawnedTestProcess(child, "composition-preflight", { ...home === void 0 ? {} : { tempRoot: home } });
|
|
604
1957
|
const append = (chunk) => {
|
|
605
1958
|
if (output.length < PREFLIGHT_OUTPUT_CAP) output += chunk.toString("utf8");
|
|
606
1959
|
};
|
|
@@ -642,6 +1995,61 @@ async function runPreflightCheck(profile, timeoutMs, harnessRoot) {
|
|
|
642
1995
|
});
|
|
643
1996
|
});
|
|
644
1997
|
}
|
|
1998
|
+
/** Execute the caller-supplied one-shot probe under the target home/root. */
|
|
1999
|
+
async function runCandidateProbe(binding, targetCommand, timeoutMs, harnessRoot, home) {
|
|
2000
|
+
if (binding.targetCommandSha256 !== commandSha256(targetCommand)) return {
|
|
2001
|
+
kind: "infra-failed",
|
|
2002
|
+
output: "",
|
|
2003
|
+
detail: "candidate probe is bound to a different target launch command"
|
|
2004
|
+
};
|
|
2005
|
+
if (binding.candidateProbeCommand === void 0 || binding.candidateProbeSha256 === void 0 || commandSha256(binding.candidateProbeCommand) !== binding.candidateProbeSha256) return {
|
|
2006
|
+
kind: "infra-failed",
|
|
2007
|
+
output: "",
|
|
2008
|
+
detail: "candidate probe command is missing or changed after binding"
|
|
2009
|
+
};
|
|
2010
|
+
return await new Promise((resolvePromise) => {
|
|
2011
|
+
let output = "";
|
|
2012
|
+
let timedOut = false;
|
|
2013
|
+
const child = spawn(binding.candidateProbeCommand, {
|
|
2014
|
+
shell: true,
|
|
2015
|
+
detached: true,
|
|
2016
|
+
env: testChildEnv("candidate-probe", {
|
|
2017
|
+
...process.env,
|
|
2018
|
+
DSH_HARNESS: harnessRoot,
|
|
2019
|
+
DSH_HOME: home,
|
|
2020
|
+
ANKH_TARGET_COMMAND_SHA256: binding.targetCommandSha256
|
|
2021
|
+
}, { tempRoot: home })
|
|
2022
|
+
});
|
|
2023
|
+
registerSpawnedTestProcess(child, "candidate-probe", { tempRoot: home });
|
|
2024
|
+
const append = (chunk) => {
|
|
2025
|
+
if (output.length < PREFLIGHT_OUTPUT_CAP) output += chunk.toString("utf8");
|
|
2026
|
+
};
|
|
2027
|
+
child.stdout.on("data", append);
|
|
2028
|
+
child.stderr.on("data", append);
|
|
2029
|
+
const timer = setTimeout(() => {
|
|
2030
|
+
timedOut = true;
|
|
2031
|
+
killSpawnGroup(child.pid);
|
|
2032
|
+
child.kill("SIGKILL");
|
|
2033
|
+
}, timeoutMs);
|
|
2034
|
+
child.on("close", (code) => {
|
|
2035
|
+
clearTimeout(timer);
|
|
2036
|
+
if (timedOut) resolvePromise({
|
|
2037
|
+
kind: "infra-failed",
|
|
2038
|
+
output,
|
|
2039
|
+
detail: `candidate probe timed out after ${timeoutMs} ms`
|
|
2040
|
+
});
|
|
2041
|
+
else if (code === 0) resolvePromise({
|
|
2042
|
+
kind: "pass",
|
|
2043
|
+
output
|
|
2044
|
+
});
|
|
2045
|
+
else resolvePromise({
|
|
2046
|
+
kind: "composition-failed",
|
|
2047
|
+
output,
|
|
2048
|
+
detail: `candidate probe exited ${String(code)}`
|
|
2049
|
+
});
|
|
2050
|
+
});
|
|
2051
|
+
});
|
|
2052
|
+
}
|
|
645
2053
|
/** The profile a gated verb dry-runs: the flag, then $DSH_PROFILE, then the deployment default. */
|
|
646
2054
|
function resolveProfileName(options) {
|
|
647
2055
|
const flag = options.profile ?? "";
|
|
@@ -649,22 +2057,85 @@ function resolveProfileName(options) {
|
|
|
649
2057
|
const env = process.env.DSH_PROFILE ?? "";
|
|
650
2058
|
return env !== "" ? env : "web";
|
|
651
2059
|
}
|
|
652
|
-
/**
|
|
653
|
-
* The home the supervised instance boots with (the watchdog exports it as
|
|
654
|
-
* DSH_HOME): the explicit flag first, then the environment — the same
|
|
655
|
-
* flag-over-env order as every other resolver in this CLI (and as the
|
|
656
|
-
* installers' own --home). Undefined when neither names one: supervise fails
|
|
657
|
-
* loud rather than boot the instance on a home guessed from the state dir.
|
|
658
|
-
*/
|
|
659
|
-
function resolveWdHome(optionHome, env = process.env) {
|
|
660
|
-
if (optionHome !== "") return optionHome;
|
|
661
|
-
const fromEnv = env.DSH_HOME;
|
|
662
|
-
return fromEnv !== void 0 && fromEnv !== "" ? fromEnv : void 0;
|
|
2060
|
+
/**
|
|
2061
|
+
* The home the supervised instance boots with (the watchdog exports it as
|
|
2062
|
+
* DSH_HOME): the explicit flag first, then the environment — the same
|
|
2063
|
+
* flag-over-env order as every other resolver in this CLI (and as the
|
|
2064
|
+
* installers' own --home). Undefined when neither names one: supervise fails
|
|
2065
|
+
* loud rather than boot the instance on a home guessed from the state dir.
|
|
2066
|
+
*/
|
|
2067
|
+
function resolveWdHome(optionHome, env = process.env) {
|
|
2068
|
+
if (optionHome !== "") return optionHome;
|
|
2069
|
+
const fromEnv = env.DSH_HOME;
|
|
2070
|
+
return fromEnv !== void 0 && fromEnv !== "" ? fromEnv : void 0;
|
|
2071
|
+
}
|
|
2072
|
+
/** A persisted launch spec must never guess which checkout is the host. */
|
|
2073
|
+
function resolveLaunchHarnessRoot(optionHarnessRoot, selected, env = process.env) {
|
|
2074
|
+
if (optionHarnessRoot !== "") return optionHarnessRoot;
|
|
2075
|
+
if (selected !== void 0 && selected !== "") return selected;
|
|
2076
|
+
const fromEnv = env.DSH_HARNESS;
|
|
2077
|
+
return fromEnv !== void 0 && fromEnv.trim() !== "" ? fromEnv : void 0;
|
|
2078
|
+
}
|
|
2079
|
+
function launchSpec(input) {
|
|
2080
|
+
return {
|
|
2081
|
+
version: 1,
|
|
2082
|
+
command: input.command,
|
|
2083
|
+
port: input.port,
|
|
2084
|
+
home: resolve(input.home),
|
|
2085
|
+
credentialRepo: resolve(input.credentialRepo),
|
|
2086
|
+
harnessRoot: resolve(input.harnessRoot),
|
|
2087
|
+
profile: input.profile,
|
|
2088
|
+
...input.preflight === void 0 ? {} : { preflight: input.preflight }
|
|
2089
|
+
};
|
|
2090
|
+
}
|
|
2091
|
+
function sameLaunchSpec(left, right) {
|
|
2092
|
+
return left.command === right.command && left.port === right.port && left.home === right.home && left.credentialRepo === right.credentialRepo && left.harnessRoot === right.harnessRoot && left.profile === right.profile && JSON.stringify(left.preflight) === JSON.stringify(right.preflight);
|
|
2093
|
+
}
|
|
2094
|
+
/** Resolve supervise's complete spec; a post-wait refresh always prefers durable state. */
|
|
2095
|
+
function resolveSuperviseSpec(options, stateDir, repoDir, io, preferDurable = false) {
|
|
2096
|
+
const durable = readLaunchState(stateDir);
|
|
2097
|
+
const selected = durable === null ? void 0 : selectedLaunchSpec(durable);
|
|
2098
|
+
const recorded = readInstanceLaunch(stateDir);
|
|
2099
|
+
const port = preferDurable && selected !== void 0 ? selected.port : options.port ?? selected?.port ?? recorded?.port;
|
|
2100
|
+
if (port === void 0) {
|
|
2101
|
+
io.stderr(`supervise requires --port N and --start "CMD" on first configuration\n\n${USAGE}`);
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
const command = !preferDurable && options.start !== void 0 && options.start !== "" ? options.start : selected?.command ?? resolveStartCommand(void 0, stateDir, "supervise", io, port);
|
|
2105
|
+
if (command === void 0 || command === "") {
|
|
2106
|
+
io.stderr(`supervise requires --port N and --start "CMD" on first configuration\n\n${USAGE}`);
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
const home = !preferDurable && options.home !== "" ? options.home : selected?.home ?? resolveWdHome("");
|
|
2110
|
+
if (home === void 0) {
|
|
2111
|
+
io.stderr("supervise needs the dsh home: pass --home DIR or set DSH_HOME — the supervised instance reads its profiles/credentials from there, and deriving one from --state-dir would guess wrong\n");
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
const harnessRoot = resolveLaunchHarnessRoot(!preferDurable ? options.harnessRoot : "", selected?.harnessRoot);
|
|
2115
|
+
if (harnessRoot === void 0) {
|
|
2116
|
+
io.stderr("supervise needs the dsh host checkout: pass --harness-root DIR or set DSH_HARNESS. The credential --repo is a separate role and is never used as the host root.\n");
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
return launchSpec({
|
|
2120
|
+
command,
|
|
2121
|
+
port,
|
|
2122
|
+
home,
|
|
2123
|
+
credentialRepo: !preferDurable && options.repoDir !== "" ? repoDir : selected?.credentialRepo ?? repoDir,
|
|
2124
|
+
harnessRoot,
|
|
2125
|
+
profile: !preferDurable && options.profile !== void 0 && options.profile !== "" ? options.profile : selected?.profile ?? resolveProfileName(options),
|
|
2126
|
+
...selected?.preflight === void 0 || selected.command !== command ? {} : { preflight: selected.preflight }
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
2129
|
+
/** Existing full spec. The legacy launch record lacks both repository roles. */
|
|
2130
|
+
function resolvePreviousSpec(stateDir, io) {
|
|
2131
|
+
const state = readLaunchState(stateDir);
|
|
2132
|
+
if (state !== null) return selectedLaunchSpec(state);
|
|
2133
|
+
io.stderr("reconfigure refused: no complete durable previous launch specification is available. The legacy instance-launch record does not identify credential repo, host root, home, and profile independently. Run `configure-launch --port N --start \"CURRENT CMD\" --home DIR --repo CREDENTIAL_REPO --harness-root HOST_ROOT --profile NAME` first.\n");
|
|
663
2134
|
}
|
|
664
2135
|
/** The first ~40 lines of captured preflight output, newline-terminated, or empty. */
|
|
665
2136
|
function summarizeOutput(output) {
|
|
666
2137
|
if (output.trim() === "") return "";
|
|
667
|
-
const lines = output.split("\n");
|
|
2138
|
+
const lines = output.replace(/([?&](?:token|grant)=)[^\s&#"']+/gi, "$1<redacted>").split("\n");
|
|
668
2139
|
return `${(lines.length > 41 ? [...lines.slice(0, 40), `… (${lines.length - 40} more lines)`] : lines).join("\n").replace(/\n+$/, "")}\n`;
|
|
669
2140
|
}
|
|
670
2141
|
/**
|
|
@@ -676,10 +2147,12 @@ function summarizeOutput(output) {
|
|
|
676
2147
|
* @param timeoutMs - bound on the preflight subprocess.
|
|
677
2148
|
* @param io - output sinks.
|
|
678
2149
|
* @param harnessRoot - harness checkout for the standalone runner.
|
|
2150
|
+
* @param home - dsh home to dry-run.
|
|
679
2151
|
* @returns whether the verb may proceed.
|
|
680
2152
|
*/
|
|
681
|
-
async function preflightGate(verb, profile, timeoutMs, io, harnessRoot) {
|
|
682
|
-
|
|
2153
|
+
async function preflightGate(verb, profile, timeoutMs, io, harnessRoot, home, binding) {
|
|
2154
|
+
io.stdout(`composition preflight START (profile ${JSON.stringify(profile)}, timeout ${timeoutMs} ms)\n`);
|
|
2155
|
+
const outcome = await runPreflightCheck(profile, timeoutMs, harnessRoot, home, binding);
|
|
683
2156
|
switch (outcome.kind) {
|
|
684
2157
|
case "pass":
|
|
685
2158
|
io.stdout(`composition preflight PASS (profile ${JSON.stringify(profile)})\n`);
|
|
@@ -695,6 +2168,54 @@ async function preflightGate(verb, profile, timeoutMs, io, harnessRoot) {
|
|
|
695
2168
|
return false;
|
|
696
2169
|
}
|
|
697
2170
|
}
|
|
2171
|
+
async function candidateProbeGate(target, timeoutMs, io, home) {
|
|
2172
|
+
if (target.preflight === void 0) {
|
|
2173
|
+
io.stderr("reconfigure refused: target has no explicit candidate probe binding\n");
|
|
2174
|
+
return false;
|
|
2175
|
+
}
|
|
2176
|
+
const outcome = await runCandidateProbe(target.preflight, target.command, timeoutMs, target.harnessRoot, home);
|
|
2177
|
+
if (outcome.kind === "pass") {
|
|
2178
|
+
io.stdout(`candidate command probe PASS (target command ${target.preflight.targetCommandSha256.slice(0, 16)})\n`);
|
|
2179
|
+
return true;
|
|
2180
|
+
}
|
|
2181
|
+
io.stderr(`reconfigure refused: candidate command probe ${outcome.kind === "composition-failed" ? "failed" : "could not execute"}${outcome.detail === void 0 ? "" : ` — ${outcome.detail}`}:\n${summarizeOutput(outcome.output)}`);
|
|
2182
|
+
return false;
|
|
2183
|
+
}
|
|
2184
|
+
/** Same-launch verbs follow the durable host root unless explicitly overridden. */
|
|
2185
|
+
function preflightHarnessRoot(options, stateDir) {
|
|
2186
|
+
if (options.harnessRoot !== "") return resolveHarnessRoot(options.harnessRoot);
|
|
2187
|
+
const state = readLaunchState(stateDir);
|
|
2188
|
+
return state === null ? resolveHarnessRoot(void 0) : selectedLaunchSpec(state).harnessRoot;
|
|
2189
|
+
}
|
|
2190
|
+
/**
|
|
2191
|
+
* A same-launch restart must use the exact durable supervisor configuration.
|
|
2192
|
+
* Explicit flags may confirm that configuration, but may not silently replace
|
|
2193
|
+
* one field while the live watchdog still owns a different command.
|
|
2194
|
+
*/
|
|
2195
|
+
function stableScheduleSpec(options, stateDir, resolvedRepoDir, io) {
|
|
2196
|
+
const state = readLaunchState(stateDir);
|
|
2197
|
+
if (state === null) return null;
|
|
2198
|
+
if (state.mode !== "stable") {
|
|
2199
|
+
io.stderr(`schedule-exit refused: launch state is still in cutover mode (${state.cutoverId}); settle its receipt before a same-launch restart\n`);
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2202
|
+
const active = state.active;
|
|
2203
|
+
const conflicts = [];
|
|
2204
|
+
if (options.port !== void 0 && options.port !== active.port) conflicts.push(`port ${options.port} != ${active.port}`);
|
|
2205
|
+
if (options.repoDir !== "" && resolve(resolvedRepoDir) !== resolve(active.credentialRepo)) conflicts.push(`credential repo ${resolve(resolvedRepoDir)} != ${resolve(active.credentialRepo)}`);
|
|
2206
|
+
if (options.harnessRoot !== "" && resolve(options.harnessRoot) !== resolve(active.harnessRoot)) conflicts.push(`harness root ${resolve(options.harnessRoot)} != ${resolve(active.harnessRoot)}`);
|
|
2207
|
+
if (options.profile !== void 0 && options.profile !== "" && options.profile !== active.profile) conflicts.push(`profile ${options.profile} != ${active.profile}`);
|
|
2208
|
+
if (conflicts.length > 0) {
|
|
2209
|
+
io.stderr(`schedule-exit refused: explicit flags conflict with the durable active launch specification (${conflicts.join("; ")}). Use reconfigure for launch changes.\n`);
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
const recorded = readInstanceLaunch(stateDir);
|
|
2213
|
+
if (recorded === null || recorded.source !== "supervisor" || recorded.supervised !== true || recorded.command !== active.command || recorded.port !== active.port) {
|
|
2214
|
+
io.stderr("schedule-exit refused: the live instance launch record does not prove that its supervisor owns the durable active launch specification. Re-establish supervision or use reconfigure; do not stop the host on an inferred command.\n");
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2217
|
+
return active;
|
|
2218
|
+
}
|
|
698
2219
|
/**
|
|
699
2220
|
* Wait for a pid to exit; SIGKILL (the whole descendant tree) after the
|
|
700
2221
|
* deadline. @param onEscalate - invoked right before the SIGKILL, so the
|
|
@@ -748,6 +2269,29 @@ function rollbackToKnownGood(stateDir, repoDir, io) {
|
|
|
748
2269
|
* @returns the process exit code: 0 ok, 1 gate denied / failure, 2 usage error.
|
|
749
2270
|
*/
|
|
750
2271
|
async function runCli(argv, io) {
|
|
2272
|
+
const verdictFile = process.env.DSH_ANKH_VERDICT_FILE;
|
|
2273
|
+
let recorded;
|
|
2274
|
+
const note = (stage, reason) => {
|
|
2275
|
+
if (recorded !== void 0) return;
|
|
2276
|
+
recorded = {
|
|
2277
|
+
stage,
|
|
2278
|
+
reason
|
|
2279
|
+
};
|
|
2280
|
+
if (verdictFile !== void 0) try {
|
|
2281
|
+
writeFileSync(verdictFile, `${JSON.stringify(recorded)}\n`, { mode: 384 });
|
|
2282
|
+
} catch {}
|
|
2283
|
+
};
|
|
2284
|
+
/** Record + print a one-line refusal, preserving the site's exit code. */
|
|
2285
|
+
const refuse = (stage, message, code = 1) => {
|
|
2286
|
+
note(stage, message.trim().split("\n", 1)[0] ?? message.trim());
|
|
2287
|
+
io.stderr(message.endsWith("\n") ? message : `${message}\n`);
|
|
2288
|
+
return code;
|
|
2289
|
+
};
|
|
2290
|
+
/** Record a refusal whose human text a gate already printed. */
|
|
2291
|
+
const refuseQuiet = (stage, reason, code = 1) => {
|
|
2292
|
+
note(stage, reason);
|
|
2293
|
+
return code;
|
|
2294
|
+
};
|
|
751
2295
|
const parsed = parse(argv);
|
|
752
2296
|
if ("error" in parsed) {
|
|
753
2297
|
io.stderr(parsed.error);
|
|
@@ -758,7 +2302,8 @@ async function runCli(argv, io) {
|
|
|
758
2302
|
const repoDir = resolveRepoDir(options.repoDir);
|
|
759
2303
|
switch (command) {
|
|
760
2304
|
case "verify": {
|
|
761
|
-
const
|
|
2305
|
+
const launch = readLaunchState(stateDir);
|
|
2306
|
+
const result = launch?.mode === "stable" && resolve(launch.active.credentialRepo) === resolve(repoDir) ? verifyRestartEvidence(stateDir, launch.active, options.maxAgeMinutes) : verifyRepoCredential(stateDir, repoDir, options.maxAgeMinutes);
|
|
762
2307
|
io.stdout(`${result.reason}\n`);
|
|
763
2308
|
if (result.ok) {
|
|
764
2309
|
io.stdout(FULL_ACCESS_HINT);
|
|
@@ -772,17 +2317,65 @@ async function runCli(argv, io) {
|
|
|
772
2317
|
io.stderr(`record requires a <scope>\n\n${USAGE}`);
|
|
773
2318
|
return 2;
|
|
774
2319
|
}
|
|
775
|
-
|
|
776
|
-
|
|
2320
|
+
if (positionals.length > 1) {
|
|
2321
|
+
io.stderr(`record accepts one <scope>; put the evidence command after --run --\n\n${USAGE}`);
|
|
2322
|
+
return 2;
|
|
2323
|
+
}
|
|
2324
|
+
if (options.run && options.trustCommand) {
|
|
2325
|
+
io.stderr("record requires exactly one proof mode: --run or --trust-command\n");
|
|
2326
|
+
return 2;
|
|
2327
|
+
}
|
|
2328
|
+
if (options.run && options.command !== void 0) {
|
|
2329
|
+
io.stderr("record --run derives its audit command from the exact argv after --; do not also pass --command\n");
|
|
2330
|
+
return 2;
|
|
2331
|
+
}
|
|
2332
|
+
if (options.runArgv !== void 0 && !options.run) {
|
|
2333
|
+
io.stderr("record command argv after -- requires --run\n");
|
|
2334
|
+
return 2;
|
|
2335
|
+
}
|
|
2336
|
+
if (!options.run && !options.trustCommand) {
|
|
2337
|
+
io.stderr("record refuses self-attestation: use --run -- PROGRAM [ARG...] so the guard observes exit 0, or --trust-command --command CMD only from an external orchestrator that already observed the command\n");
|
|
2338
|
+
return 2;
|
|
2339
|
+
}
|
|
2340
|
+
if (options.run && (options.runArgv === void 0 || options.runArgv.length === 0)) {
|
|
2341
|
+
io.stderr("record --run requires -- PROGRAM [ARG...]\n");
|
|
2342
|
+
return 2;
|
|
2343
|
+
}
|
|
2344
|
+
if (options.trustCommand && (options.command === void 0 || options.command.trim() === "")) {
|
|
2345
|
+
io.stderr("record --trust-command requires a non-empty --command description\n");
|
|
2346
|
+
return 2;
|
|
2347
|
+
}
|
|
2348
|
+
const headBefore = currentHead(repoDir);
|
|
2349
|
+
if (headBefore === null) {
|
|
777
2350
|
io.stderr("cannot record a credential outside a git repository\n");
|
|
778
2351
|
return 1;
|
|
779
2352
|
}
|
|
2353
|
+
if (!isWorkingTreeClean(repoDir)) {
|
|
2354
|
+
io.stderr("cannot record a credential while the working tree has staged, unstaged, or untracked changes\n");
|
|
2355
|
+
return 1;
|
|
2356
|
+
}
|
|
2357
|
+
let evidenceCommand = options.command ?? "";
|
|
2358
|
+
if (options.run) {
|
|
2359
|
+
const runArgv = options.runArgv ?? [];
|
|
2360
|
+
clearCredential(stateDir, Date.now());
|
|
2361
|
+
evidenceCommand = renderArgv(runArgv);
|
|
2362
|
+
io.stdout(`running credential evidence: ${evidenceCommand}\n`);
|
|
2363
|
+
const evidence = await runCredentialCommand(runArgv, repoDir, io);
|
|
2364
|
+
if (!evidence.ok) {
|
|
2365
|
+
io.stderr(`credential evidence failed: ${evidence.detail}; no credential recorded\n`);
|
|
2366
|
+
return 1;
|
|
2367
|
+
}
|
|
2368
|
+
if (currentHead(repoDir) !== headBefore || !isWorkingTreeClean(repoDir)) {
|
|
2369
|
+
io.stderr("credential evidence exited 0 but changed HEAD or left the working tree dirty; no credential recorded\n");
|
|
2370
|
+
return 1;
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
780
2373
|
recordCredential(stateDir, {
|
|
781
2374
|
scope,
|
|
782
|
-
revision:
|
|
783
|
-
command:
|
|
2375
|
+
revision: headBefore,
|
|
2376
|
+
command: evidenceCommand
|
|
784
2377
|
}, Date.now());
|
|
785
|
-
io.stdout(`recorded green credential: ${scope} @ ${
|
|
2378
|
+
io.stdout(`recorded green credential: ${scope} @ ${headBefore}${options.trustCommand ? " (external proof trusted)" : ""}\n`);
|
|
786
2379
|
io.stdout(FULL_ACCESS_HINT);
|
|
787
2380
|
if (liveWatchdogPid(stateDir) === null) io.stderr(NO_WATCHDOG_HINT);
|
|
788
2381
|
return 0;
|
|
@@ -792,13 +2385,190 @@ async function runCli(argv, io) {
|
|
|
792
2385
|
io.stdout(`${JSON.stringify(state, null, 2)}\n`);
|
|
793
2386
|
return 0;
|
|
794
2387
|
}
|
|
2388
|
+
case "configure-launch": {
|
|
2389
|
+
if (options.ifAbsent && readLaunchState(stateDir) !== null) {
|
|
2390
|
+
io.stdout("launch specification already exists — kept it unchanged (--if-absent)\n");
|
|
2391
|
+
return 0;
|
|
2392
|
+
}
|
|
2393
|
+
if (options.port === void 0 || options.start === void 0 || options.start === "") {
|
|
2394
|
+
io.stderr(`configure-launch requires --port N and --start "CMD"\n\n${USAGE}`);
|
|
2395
|
+
return 2;
|
|
2396
|
+
}
|
|
2397
|
+
const home = resolveWdHome(options.home);
|
|
2398
|
+
if (home === void 0) {
|
|
2399
|
+
io.stderr("configure-launch requires --home DIR or DSH_HOME\n");
|
|
2400
|
+
return 2;
|
|
2401
|
+
}
|
|
2402
|
+
const harnessRoot = resolveLaunchHarnessRoot(options.harnessRoot);
|
|
2403
|
+
if (harnessRoot === void 0) {
|
|
2404
|
+
io.stderr("configure-launch requires --harness-root DIR or DSH_HARNESS; --repo names the independent credential/rollback repository\n");
|
|
2405
|
+
return 2;
|
|
2406
|
+
}
|
|
2407
|
+
let preflight;
|
|
2408
|
+
try {
|
|
2409
|
+
preflight = resolvePreflightSpec(options, options.start, harnessRoot, false);
|
|
2410
|
+
} catch (error) {
|
|
2411
|
+
io.stderr(`configure-launch refused: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
2412
|
+
return 2;
|
|
2413
|
+
}
|
|
2414
|
+
const spec = launchSpec({
|
|
2415
|
+
command: options.start,
|
|
2416
|
+
port: options.port,
|
|
2417
|
+
home,
|
|
2418
|
+
credentialRepo: repoDir,
|
|
2419
|
+
harnessRoot,
|
|
2420
|
+
profile: resolveProfileName(options),
|
|
2421
|
+
preflight
|
|
2422
|
+
});
|
|
2423
|
+
const written = writeStableLaunchSpec(stateDir, spec, options.ifAbsent);
|
|
2424
|
+
if (written) writeInstanceLaunchAsSupervisor(stateDir, {
|
|
2425
|
+
command: spec.command,
|
|
2426
|
+
source: "supervisor",
|
|
2427
|
+
supervised: true,
|
|
2428
|
+
port: spec.port,
|
|
2429
|
+
recordedAt: Date.now()
|
|
2430
|
+
});
|
|
2431
|
+
io.stdout(written ? `launch specification recorded for :${spec.port} (command sha is in launch-status)\n` : "launch specification already exists — kept it unchanged (--if-absent)\n");
|
|
2432
|
+
return 0;
|
|
2433
|
+
}
|
|
2434
|
+
case "launch-status":
|
|
2435
|
+
io.stdout(`${JSON.stringify({
|
|
2436
|
+
launch: summarizeLaunchState(readLaunchState(stateDir)),
|
|
2437
|
+
receipt: readCutoverReceipt(stateDir)
|
|
2438
|
+
}, null, 2)}\n`);
|
|
2439
|
+
return 0;
|
|
2440
|
+
case "transition-apply":
|
|
2441
|
+
case "transition-rollback": {
|
|
2442
|
+
const id = positionals[0];
|
|
2443
|
+
if (id === void 0 || positionals.length !== 1) {
|
|
2444
|
+
io.stderr(`${command} requires one CUTOVER_ID\n`);
|
|
2445
|
+
return 2;
|
|
2446
|
+
}
|
|
2447
|
+
const transaction = activeCutover(stateDir);
|
|
2448
|
+
if (transaction === null || transaction.receipt.id !== id || transaction.state.transition === void 0) {
|
|
2449
|
+
io.stderr(`${command} refused: cutover ${id} has no active prepared transition\n`);
|
|
2450
|
+
return 1;
|
|
2451
|
+
}
|
|
2452
|
+
const reference = transaction.state.transition;
|
|
2453
|
+
const identityIsLive = (pid, startToken) => pid !== void 0 && startToken !== void 0 && processIdentityMatches({
|
|
2454
|
+
pid,
|
|
2455
|
+
startToken
|
|
2456
|
+
});
|
|
2457
|
+
if (command === "transition-apply") {
|
|
2458
|
+
if (transaction.state.selected !== "target") {
|
|
2459
|
+
io.stderr("transition-apply refused: the target launch specification is not selected\n");
|
|
2460
|
+
return 1;
|
|
2461
|
+
}
|
|
2462
|
+
const previous = transaction.receipt.ownership.previous;
|
|
2463
|
+
if (identityIsLive(previous.childPid, previous.childStartToken) || identityIsLive(previous.listenerPid, previous.listenerStartToken)) {
|
|
2464
|
+
io.stderr("transition-apply refused: the proven previous process is still alive\n");
|
|
2465
|
+
return 1;
|
|
2466
|
+
}
|
|
2467
|
+
try {
|
|
2468
|
+
const result = applyTransition(reference, transaction.state.previous.home, stateDir, id);
|
|
2469
|
+
recordCutoverEvent(stateDir, id, "transition", ["applied", reference.planSha256], Date.now());
|
|
2470
|
+
io.stdout(`transition applied (${result.changed.length} changed, ${result.unchanged.length} unchanged)\n`);
|
|
2471
|
+
return 0;
|
|
2472
|
+
} catch (error) {
|
|
2473
|
+
try {
|
|
2474
|
+
recordCutoverEvent(stateDir, id, "transition", [
|
|
2475
|
+
"apply-failed",
|
|
2476
|
+
reference.planSha256,
|
|
2477
|
+
error instanceof Error ? error.message : String(error)
|
|
2478
|
+
], Date.now());
|
|
2479
|
+
} catch {}
|
|
2480
|
+
io.stderr(`transition-apply failed: ${String(error)}\n`);
|
|
2481
|
+
return 1;
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
const liveTarget = transaction.receipt.attempts.some((attempt) => attempt.role === "target" && identityIsLive(attempt.childPid, attempt.childStartToken));
|
|
2485
|
+
const targetOwnership = transaction.receipt.ownership.target;
|
|
2486
|
+
if (liveTarget || targetOwnership !== void 0 && (identityIsLive(targetOwnership.childPid, targetOwnership.childStartToken) || identityIsLive(targetOwnership.listenerPid, targetOwnership.listenerStartToken))) {
|
|
2487
|
+
io.stderr("transition-rollback refused: a proven target process is still alive\n");
|
|
2488
|
+
return 1;
|
|
2489
|
+
}
|
|
2490
|
+
try {
|
|
2491
|
+
const result = rollbackTransition(reference, transaction.state.previous.home, stateDir, id);
|
|
2492
|
+
recordCutoverEvent(stateDir, id, "transition", ["rolled-back", reference.planSha256], Date.now());
|
|
2493
|
+
io.stdout(`transition rolled back (${result.changed.length} changed, ${result.unchanged.length} unchanged)\n`);
|
|
2494
|
+
return 0;
|
|
2495
|
+
} catch (error) {
|
|
2496
|
+
try {
|
|
2497
|
+
recordCutoverEvent(stateDir, id, "transition", [
|
|
2498
|
+
"rollback-failed",
|
|
2499
|
+
reference.planSha256,
|
|
2500
|
+
error instanceof Error ? error.message : String(error)
|
|
2501
|
+
], Date.now());
|
|
2502
|
+
} catch {}
|
|
2503
|
+
io.stderr(`transition-rollback failed: ${String(error)}\n`);
|
|
2504
|
+
return 1;
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
case "abort-cutover":
|
|
2508
|
+
case "restore-previous": {
|
|
2509
|
+
const transaction = activeCutover(stateDir);
|
|
2510
|
+
if (transaction === null) {
|
|
2511
|
+
io.stderr(`${command} refused: no nonterminal launch cutover is active\n`);
|
|
2512
|
+
return 1;
|
|
2513
|
+
}
|
|
2514
|
+
const watchdogPid = liveWatchdogPid(stateDir);
|
|
2515
|
+
if (watchdogPid === null) {
|
|
2516
|
+
io.stderr(`${command} refused: no live watchdog can consume the durable control request\n`);
|
|
2517
|
+
return 1;
|
|
2518
|
+
}
|
|
2519
|
+
const requested = command === "restore-previous" ? "restore-previous" : "abort";
|
|
2520
|
+
try {
|
|
2521
|
+
const control = writeCutoverControl(stateDir, transaction.receipt.id, requested, Date.now());
|
|
2522
|
+
appendTestLifecycleEvent("control-marker-written", {
|
|
2523
|
+
action: control.action,
|
|
2524
|
+
watchdogPid
|
|
2525
|
+
}, "parent-observer");
|
|
2526
|
+
try {
|
|
2527
|
+
process.kill(watchdogPid, "SIGUSR2");
|
|
2528
|
+
appendTestLifecycleEvent("signal-result", {
|
|
2529
|
+
signal: "SIGUSR2",
|
|
2530
|
+
targetPid: watchdogPid,
|
|
2531
|
+
result: "sent"
|
|
2532
|
+
}, "parent-observer");
|
|
2533
|
+
} catch (error) {
|
|
2534
|
+
appendTestLifecycleEvent("signal-result", {
|
|
2535
|
+
signal: "SIGUSR2",
|
|
2536
|
+
targetPid: watchdogPid,
|
|
2537
|
+
result: String(error)
|
|
2538
|
+
}, "parent-observer");
|
|
2539
|
+
throw error;
|
|
2540
|
+
}
|
|
2541
|
+
io.stdout(control.action === "restore-previous" ? `cutover ${control.cutoverId}: explicit restore-previous requested; watchdog ${watchdogPid} will stop only the proven target identity and relaunch the complete previous spec\n` : `cutover ${control.cutoverId}: abort requested; watchdog ${watchdogPid} will apply the pre-approved ${transaction.receipt.recovery.policy} policy\n`);
|
|
2542
|
+
return 0;
|
|
2543
|
+
} catch (error) {
|
|
2544
|
+
io.stderr(`${command} failed: ${String(error)}\n`);
|
|
2545
|
+
return 1;
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
case "cutover-event": {
|
|
2549
|
+
const id = positionals[0];
|
|
2550
|
+
const kind = positionals[1];
|
|
2551
|
+
if (id === void 0 || kind === void 0) {
|
|
2552
|
+
io.stderr("cutover-event requires <id> <kind> [values...]\n");
|
|
2553
|
+
return 2;
|
|
2554
|
+
}
|
|
2555
|
+
try {
|
|
2556
|
+
recordCutoverEvent(stateDir, id, kind, positionals.slice(2), Date.now());
|
|
2557
|
+
return 0;
|
|
2558
|
+
} catch (error) {
|
|
2559
|
+
io.stderr(`cutover-event failed: ${String(error)}\n`);
|
|
2560
|
+
return 1;
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
795
2563
|
case "clear":
|
|
796
2564
|
clearCredential(stateDir, Date.now());
|
|
797
2565
|
io.stdout("credential cleared\n");
|
|
798
2566
|
return 0;
|
|
799
2567
|
case "checkpoint": {
|
|
800
2568
|
const message = options.message ?? "batch snapshot";
|
|
801
|
-
const
|
|
2569
|
+
const changes = workingTreeChanges(repoDir);
|
|
2570
|
+
if (options.includeDirty && changes !== null && changes.length > 0) io.stdout(`checkpoint includes ${changes.length} reviewed working-tree change(s)\n`);
|
|
2571
|
+
const result = commitCheckpoint(repoDir, `dsh-ankh-guard checkpoint: ${message}`, SRC_ARTIFACT_PATTERN, options.includeDirty);
|
|
802
2572
|
if (!result.ok) {
|
|
803
2573
|
io.stderr(`${result.error}\n`);
|
|
804
2574
|
return 1;
|
|
@@ -807,7 +2577,7 @@ async function runCli(argv, io) {
|
|
|
807
2577
|
revision: result.sha,
|
|
808
2578
|
message
|
|
809
2579
|
}, Date.now());
|
|
810
|
-
io.stdout(`checkpoint committed: ${result.sha}\n`);
|
|
2580
|
+
io.stdout(result.createdCommit ? `checkpoint committed: ${result.sha}\n` : `checkpoint recorded at existing clean HEAD: ${result.sha}\n`);
|
|
811
2581
|
if (result.artifacts.length > 0) {
|
|
812
2582
|
io.stdout(`warning: ${result.artifacts.length} build-artifact-looking file(s) swept in (bare tsc emission? real build output belongs in lib/):\n`);
|
|
813
2583
|
for (const file of result.artifacts.slice(0, 5)) io.stdout(` ${file}\n`);
|
|
@@ -830,7 +2600,7 @@ async function runCli(argv, io) {
|
|
|
830
2600
|
return 0;
|
|
831
2601
|
}
|
|
832
2602
|
case "canary": {
|
|
833
|
-
const verdict =
|
|
2603
|
+
const verdict = verifyRepoCredential(stateDir, repoDir, options.maxAgeMinutes);
|
|
834
2604
|
io.stdout(`verify: ${verdict.ok ? "PASS" : "FAIL"} — ${verdict.reason}\n`);
|
|
835
2605
|
let ok = verdict.ok;
|
|
836
2606
|
if (options.port !== void 0) {
|
|
@@ -841,8 +2611,61 @@ async function runCli(argv, io) {
|
|
|
841
2611
|
io.stdout(ok ? "canary PASS\n" : "canary FAIL\n");
|
|
842
2612
|
return ok ? 0 : 1;
|
|
843
2613
|
}
|
|
2614
|
+
case "verify-restart": {
|
|
2615
|
+
if (restartMarkerState(stateDir) !== "fresh") {
|
|
2616
|
+
io.stderr("restart authorization is missing or stale\n");
|
|
2617
|
+
return 1;
|
|
2618
|
+
}
|
|
2619
|
+
const launch = readLaunchState(stateDir);
|
|
2620
|
+
if (launch === null) {
|
|
2621
|
+
io.stderr("restart authorization cannot be verified without durable launch state\n");
|
|
2622
|
+
return 1;
|
|
2623
|
+
}
|
|
2624
|
+
const spec = selectedLaunchSpec(launch);
|
|
2625
|
+
const marker = readRestartRequestMarker(stateDir);
|
|
2626
|
+
const verdict = marker?.authorization === void 0 ? verifyRepoCredential(stateDir, spec.credentialRepo, options.maxAgeMinutes) : verifyRestartAuthorization(stateDir, spec, marker.authorization);
|
|
2627
|
+
io.stdout(`${verdict.ok ? "restart evidence PASS" : "restart evidence FAIL"} — ${verdict.reason}\n`);
|
|
2628
|
+
return verdict.ok ? 0 : 1;
|
|
2629
|
+
}
|
|
2630
|
+
case "record-proven-deployment": {
|
|
2631
|
+
if (restartMarkerState(stateDir) !== "fresh") {
|
|
2632
|
+
io.stderr("deployment proof refused: restart authorization is missing or stale\n");
|
|
2633
|
+
return 1;
|
|
2634
|
+
}
|
|
2635
|
+
const launch = readLaunchState(stateDir);
|
|
2636
|
+
if (launch === null || launch.mode !== "stable") {
|
|
2637
|
+
io.stderr("deployment proof refused: no stable durable launch specification is selected\n");
|
|
2638
|
+
return 1;
|
|
2639
|
+
}
|
|
2640
|
+
let authorization = readRestartRequestMarker(stateDir)?.authorization;
|
|
2641
|
+
if (authorization === void 0) {
|
|
2642
|
+
const credential = loadState(stateDir).credential;
|
|
2643
|
+
const fresh = verifyRepoCredential(stateDir, launch.active.credentialRepo, options.maxAgeMinutes);
|
|
2644
|
+
if (!fresh.ok || credential === void 0) {
|
|
2645
|
+
io.stderr(`deployment proof refused: ${fresh.reason}\n`);
|
|
2646
|
+
return 1;
|
|
2647
|
+
}
|
|
2648
|
+
authorization = {
|
|
2649
|
+
version: 1,
|
|
2650
|
+
kind: "fresh-credential",
|
|
2651
|
+
revision: credential.revision,
|
|
2652
|
+
evidenceSha256: commandSha256(credential.command)
|
|
2653
|
+
};
|
|
2654
|
+
}
|
|
2655
|
+
const result = proveCurrentDeployment(stateDir, launch.active, authorization);
|
|
2656
|
+
(result.ok ? io.stdout : io.stderr)(`${result.ok ? "deployment proof PASS" : "deployment proof FAIL"} — ${result.reason}\n`);
|
|
2657
|
+
return result.ok ? 0 : 1;
|
|
2658
|
+
}
|
|
844
2659
|
case "preflight": {
|
|
845
|
-
const
|
|
2660
|
+
const harnessRoot = resolveHarnessRoot(options.harnessRoot);
|
|
2661
|
+
let binding;
|
|
2662
|
+
if (options.preflightSurface !== void 0 || options.preflightInstallAnchor !== void 0 || options.preflightRunner !== void 0) try {
|
|
2663
|
+
binding = resolvePreflightSpec(options, "standalone-preflight", harnessRoot, false);
|
|
2664
|
+
} catch (error) {
|
|
2665
|
+
io.stderr(`preflight refused: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
2666
|
+
return 2;
|
|
2667
|
+
}
|
|
2668
|
+
const outcome = await runPreflightCheck(resolveProfileName(options), options.timeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS, harnessRoot, void 0, binding);
|
|
846
2669
|
if (outcome.kind === "unavailable") {
|
|
847
2670
|
io.stderr("preflight unavailable outside the dsh app layout\n");
|
|
848
2671
|
return 3;
|
|
@@ -862,6 +2685,11 @@ async function runCli(argv, io) {
|
|
|
862
2685
|
io.stdout(written ? "[watchdog] adoption takeover — left a report record for the supervising session\n" : "[watchdog] adoption takeover — a report record is still pending, left it untouched\n");
|
|
863
2686
|
return 0;
|
|
864
2687
|
}
|
|
2688
|
+
case "record-composition-recovery": {
|
|
2689
|
+
const written = writeCompositionRecovery(stateDir, Date.now(), options.detail);
|
|
2690
|
+
io.stdout(written ? "[watchdog] composition rollback recovery — left a report record for the next session\n" : "[watchdog] composition rollback recovery — a report record is still pending, left it untouched\n");
|
|
2691
|
+
return 0;
|
|
2692
|
+
}
|
|
865
2693
|
case "check-env": {
|
|
866
2694
|
const sandboxed = envInternals.sandboxedByProbe();
|
|
867
2695
|
io.stdout(`sandbox: ${sandboxed ? "SANDBOXED — detached processes are reaped when the turn ends; ask the user for /permission danger-full-access in THIS session" : "unsandboxed (full access)"}\n`);
|
|
@@ -900,6 +2728,188 @@ async function runCli(argv, io) {
|
|
|
900
2728
|
io.stdout(`git repo: ${currentHead(repoDir) !== null ? `yes (${repoDir})` : `no (${repoDir}) — git init + initial commit before record`}\n`);
|
|
901
2729
|
return sandboxed ? 1 : 0;
|
|
902
2730
|
}
|
|
2731
|
+
case "reconfigure": {
|
|
2732
|
+
if (options.start === void 0 || options.start === "") {
|
|
2733
|
+
io.stderr(`reconfigure requires --start "CMD"\n\n${USAGE}`);
|
|
2734
|
+
return 2;
|
|
2735
|
+
}
|
|
2736
|
+
if (options.onFailure === void 0) {
|
|
2737
|
+
io.stderr("reconfigure refused: --on-failure restore-previous|wait-for-user is required so recovery is explicitly approved before the old instance stops\n");
|
|
2738
|
+
return 2;
|
|
2739
|
+
}
|
|
2740
|
+
const inFlightCutover = activeCutover(stateDir);
|
|
2741
|
+
if (inFlightCutover !== null) return refuse("cutover-active", `reconfigure refused: launch cutover ${inFlightCutover.receipt.id} is still ${inFlightCutover.receipt.phase}; inspect it with \`launch-status\` and settle/retry that transaction first\n`);
|
|
2742
|
+
const previous = resolvePreviousSpec(stateDir, io);
|
|
2743
|
+
if (previous === void 0) return refuseQuiet("previous-spec", "reconfigure refused: could not resolve the active launch specification (see stderr)", 2);
|
|
2744
|
+
let target = launchSpec({
|
|
2745
|
+
command: options.start,
|
|
2746
|
+
port: options.port ?? previous.port,
|
|
2747
|
+
home: options.home !== "" ? options.home : previous.home,
|
|
2748
|
+
credentialRepo: options.repoDir !== "" ? repoDir : previous.credentialRepo,
|
|
2749
|
+
harnessRoot: options.harnessRoot !== "" ? options.harnessRoot : previous.harnessRoot,
|
|
2750
|
+
profile: options.profile !== void 0 && options.profile !== "" ? options.profile : previous.profile
|
|
2751
|
+
});
|
|
2752
|
+
try {
|
|
2753
|
+
target = {
|
|
2754
|
+
...target,
|
|
2755
|
+
preflight: resolvePreflightSpec(options, target.command, target.harnessRoot, true)
|
|
2756
|
+
};
|
|
2757
|
+
} catch (error) {
|
|
2758
|
+
io.stderr(`reconfigure refused: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
2759
|
+
return 2;
|
|
2760
|
+
}
|
|
2761
|
+
if (target.port !== previous.port) return refuse("port-mismatch", `reconfigure refused: online supervisor handoff keeps one authority and port (${previous.port}); target requested ${target.port}. Move ports as a separately supervised deployment, then cut traffic over.\n`, 2);
|
|
2762
|
+
if (sameLaunchSpec(previous, target)) return refuse("identical", "reconfigure refused: target launch specification is identical to the active specification\n", 2);
|
|
2763
|
+
let transitionPlan;
|
|
2764
|
+
if (options.transitionFile !== void 0) {
|
|
2765
|
+
let raw;
|
|
2766
|
+
try {
|
|
2767
|
+
raw = JSON.parse(readFileSync(resolve(options.transitionFile), "utf8"));
|
|
2768
|
+
} catch (error) {
|
|
2769
|
+
io.stderr(`reconfigure refused: transition plan is unreadable: ${String(error)}\n`);
|
|
2770
|
+
return 2;
|
|
2771
|
+
}
|
|
2772
|
+
try {
|
|
2773
|
+
transitionPlan = validateTransitionPlan(raw, previous.home, stateDir);
|
|
2774
|
+
validateTransitionPlan(raw, target.home, stateDir);
|
|
2775
|
+
} catch (error) {
|
|
2776
|
+
io.stderr(`reconfigure refused: ${String(error)}\n`);
|
|
2777
|
+
return 2;
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
const previousSupervisorPid = liveWatchdogPid(stateDir);
|
|
2781
|
+
if (previousSupervisorPid === null) return refuse("unsupervised", "reconfigure refused: no live watchdog owns the old instance. Establish supervision first; an online handoff cannot promise continuity without an old supervisor.\n");
|
|
2782
|
+
const gate = verifyRepoCredential(stateDir, target.credentialRepo, options.maxAgeMinutes);
|
|
2783
|
+
if (!gate.ok) return refuse("credential", `reconfigure refused: ${gate.reason}\n`);
|
|
2784
|
+
if (!sandboxGate("reconfigure", options, io)) return refuseQuiet("sandbox", "reconfigure refused: the environment is sandboxed, so the detached replacement supervisor would be reaped mid-flight");
|
|
2785
|
+
const snapshotStartedAt = Date.now();
|
|
2786
|
+
let snapshot;
|
|
2787
|
+
try {
|
|
2788
|
+
snapshot = transitionPlan === void 0 ? createPreflightSnapshot(target.home) : createTransitionPreflightSnapshot(transitionPlan);
|
|
2789
|
+
} catch (error) {
|
|
2790
|
+
return refuse("preflight-snapshot", `reconfigure refused: could not prepare an isolated${transitionPlan === void 0 ? "" : " transitioned"} home: ${String(error)}\n`);
|
|
2791
|
+
}
|
|
2792
|
+
const snapshotMs = Date.now() - snapshotStartedAt;
|
|
2793
|
+
if (snapshotMs > options.maxAgeMinutes * 6e4 / 2) io.stdout(`note: the isolated-home snapshot took ${Math.round(snapshotMs / 1e3)}s — over half the ${options.maxAgeMinutes}min credential window; re-record the credential immediately before reconfigure, and keep the home slim (top-level scratch/ is excluded from the copy)\n`);
|
|
2794
|
+
try {
|
|
2795
|
+
const timeout = options.preflightTimeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS;
|
|
2796
|
+
if (!await candidateProbeGate(target, timeout, io, snapshot.home)) return refuseQuiet("preflight", "reconfigure refused: the candidate probe failed (see stderr)");
|
|
2797
|
+
if (!await preflightGate("reconfigure", target.profile, timeout, io, target.harnessRoot, snapshot.home, target.preflight)) return refuseQuiet("preflight", "reconfigure refused: the composition preflight failed (see stderr for the failing entries)");
|
|
2798
|
+
io.stdout(`${transitionPlan === void 0 ? "candidate" : "filesystem transition"} preflight PASS on an isolated copy of the live home\n`);
|
|
2799
|
+
} finally {
|
|
2800
|
+
snapshot.cleanup();
|
|
2801
|
+
}
|
|
2802
|
+
const lock = acquireRestartLock(stateDir);
|
|
2803
|
+
if (!lock.ok) return refuse("lock", `reconfigure refused: a restart is in flight (pid ${lock.holder})\n`);
|
|
2804
|
+
const cutoverId = `${Date.now()}-${process.pid}`;
|
|
2805
|
+
let driverPid;
|
|
2806
|
+
try {
|
|
2807
|
+
if (restartMarkerState(stateDir) === "fresh") return refuse("marker", "reconfigure refused: a scheduled exit is already pending\n");
|
|
2808
|
+
const initiator = resolveInitiator(options.initiator, io);
|
|
2809
|
+
const previousSupervisor = processIdentity(previousSupervisorPid);
|
|
2810
|
+
if (previousSupervisor === null) throw new Error(`could not capture a start identity for watchdog ${previousSupervisorPid}`);
|
|
2811
|
+
const previousOwned = findOwnedListener(previous.port, previousSupervisorPid);
|
|
2812
|
+
if (previousOwned === null) throw new Error(`the listener on :${previous.port} is not uniquely owned by watchdog ${previousSupervisorPid}; refusing a port-inferred takeover`);
|
|
2813
|
+
if (!processIdentityMatches(previousSupervisor)) throw new Error(`watchdog ${previousSupervisorPid} changed while ownership was captured; refusing a recycled-PID takeover`);
|
|
2814
|
+
const transition = transitionPlan === void 0 ? void 0 : prepareTransition(transitionPlan, previous.home, stateDir, cutoverId);
|
|
2815
|
+
prepareLaunchCutover(stateDir, {
|
|
2816
|
+
id: cutoverId,
|
|
2817
|
+
previous,
|
|
2818
|
+
target,
|
|
2819
|
+
recoveryPolicy: options.onFailure,
|
|
2820
|
+
browserHandoff: options.browserHandoff,
|
|
2821
|
+
previousSupervisorPid,
|
|
2822
|
+
previousSupervisorStartToken: previousSupervisor.startToken,
|
|
2823
|
+
previousOwnership: {
|
|
2824
|
+
childPid: previousOwned.child.pid,
|
|
2825
|
+
childStartToken: previousOwned.child.startToken,
|
|
2826
|
+
listenerPid: previousOwned.listener.pid,
|
|
2827
|
+
listenerStartToken: previousOwned.listener.startToken
|
|
2828
|
+
},
|
|
2829
|
+
...transition === void 0 ? {} : { transition },
|
|
2830
|
+
...initiator !== void 0 ? { initiator } : {},
|
|
2831
|
+
now: Date.now()
|
|
2832
|
+
});
|
|
2833
|
+
const logPath = options.log ?? stateFile(stateDir, "watchdogLog");
|
|
2834
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
2835
|
+
const driverArgs = [
|
|
2836
|
+
"supervise",
|
|
2837
|
+
"--foreground",
|
|
2838
|
+
"--state-dir",
|
|
2839
|
+
stateDir,
|
|
2840
|
+
"--takeover-from",
|
|
2841
|
+
String(previousSupervisorPid),
|
|
2842
|
+
"--cutover-id",
|
|
2843
|
+
cutoverId,
|
|
2844
|
+
"--delay-ms",
|
|
2845
|
+
String(options.delayMs ?? 5e3),
|
|
2846
|
+
"--supervisor-yield-timeout-ms",
|
|
2847
|
+
String(options.supervisorYieldTimeoutMs ?? 15e3),
|
|
2848
|
+
...initiator !== void 0 ? ["--initiator", initiator] : []
|
|
2849
|
+
];
|
|
2850
|
+
const cutoverDriverEnv = { ...process.env };
|
|
2851
|
+
delete cutoverDriverEnv.DSH_ANKH_VERDICT_FILE;
|
|
2852
|
+
const driver = spawn(process.execPath, cliInvocation(driverArgs), {
|
|
2853
|
+
detached: true,
|
|
2854
|
+
stdio: [
|
|
2855
|
+
"ignore",
|
|
2856
|
+
openSync(logPath, "a"),
|
|
2857
|
+
openSync(logPath, "a")
|
|
2858
|
+
],
|
|
2859
|
+
env: testChildEnv("cutover-supervisor-driver", cutoverDriverEnv, {
|
|
2860
|
+
port: previous.port,
|
|
2861
|
+
tempRoot: stateDir
|
|
2862
|
+
})
|
|
2863
|
+
});
|
|
2864
|
+
registerSpawnedTestProcess(driver, "cutover-supervisor-driver", {
|
|
2865
|
+
port: previous.port,
|
|
2866
|
+
tempRoot: stateDir
|
|
2867
|
+
});
|
|
2868
|
+
driver.unref();
|
|
2869
|
+
driverPid = driver.pid;
|
|
2870
|
+
if (driverPid === void 0) throw new Error("could not detach the replacement supervisor driver");
|
|
2871
|
+
const takeoverDeadline = Date.now() + 15e3;
|
|
2872
|
+
let replacementPid;
|
|
2873
|
+
while (Date.now() < takeoverDeadline) {
|
|
2874
|
+
const receipt = readCutoverReceipt(stateDir);
|
|
2875
|
+
const candidate = receipt?.supervisor.targetPid;
|
|
2876
|
+
const candidateStartToken = receipt?.supervisor.targetStartToken;
|
|
2877
|
+
if (candidate !== void 0 && candidateStartToken !== void 0 && processIdentityMatches({
|
|
2878
|
+
pid: candidate,
|
|
2879
|
+
startToken: candidateStartToken
|
|
2880
|
+
}) && livePidIn(stateFile(stateDir, "watchdogPid")) === String(candidate)) {
|
|
2881
|
+
replacementPid = candidate;
|
|
2882
|
+
break;
|
|
2883
|
+
}
|
|
2884
|
+
try {
|
|
2885
|
+
process.kill(driverPid, 0);
|
|
2886
|
+
} catch {
|
|
2887
|
+
break;
|
|
2888
|
+
}
|
|
2889
|
+
await sleep(100);
|
|
2890
|
+
}
|
|
2891
|
+
if (replacementPid === void 0) throw new Error("replacement watchdog did not claim supervision within 15000 ms");
|
|
2892
|
+
io.stdout(`launch cutover ${cutoverId} prepared: supervisor ${previousSupervisorPid} → ${replacementPid}; the replacement watchdog stops the old child in ${options.delayMs ?? 5e3} ms\nreceipt: ${stateFile(stateDir, "launchCutover")}\n`);
|
|
2893
|
+
return 0;
|
|
2894
|
+
} catch (error) {
|
|
2895
|
+
if (driverPid !== void 0) {
|
|
2896
|
+
try {
|
|
2897
|
+
process.kill(-driverPid, "SIGTERM");
|
|
2898
|
+
} catch {
|
|
2899
|
+
try {
|
|
2900
|
+
process.kill(driverPid, "SIGTERM");
|
|
2901
|
+
} catch {}
|
|
2902
|
+
}
|
|
2903
|
+
await waitForExit(driverPid, 2e3);
|
|
2904
|
+
}
|
|
2905
|
+
try {
|
|
2906
|
+
recordCutoverEvent(stateDir, cutoverId, "prepare-failed", [String(error)], Date.now());
|
|
2907
|
+
} catch {}
|
|
2908
|
+
return refuse("preparation", `reconfigure refused before stopping the old instance: ${String(error)}\n`);
|
|
2909
|
+
} finally {
|
|
2910
|
+
lock.release();
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
903
2913
|
case "restart": {
|
|
904
2914
|
const port = options.port ?? readInstanceLaunch(stateDir)?.port;
|
|
905
2915
|
if (port === void 0) {
|
|
@@ -908,21 +2918,22 @@ async function runCli(argv, io) {
|
|
|
908
2918
|
}
|
|
909
2919
|
const start = resolveStartCommand(options.start, stateDir, "restart", io, port);
|
|
910
2920
|
if (start === void 0) return 2;
|
|
2921
|
+
const restartCutover = activeCutover(stateDir);
|
|
2922
|
+
if (restartCutover !== null) return refuse("cutover-active", `restart refused: launch cutover ${restartCutover.receipt.id} is ${restartCutover.receipt.phase}; a second stop would violate its recovery policy\n`);
|
|
911
2923
|
const isDriver = process.env.DSH_ANKH_RESTART_DRIVER === "1";
|
|
912
|
-
const gate =
|
|
913
|
-
if (!gate.ok) {
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
if (!sandboxGate("restart", options, io)) return 1;
|
|
918
|
-
if (!isDriver && !await preflightGate("restart", resolveProfileName(options), options.preflightTimeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS, io, resolveHarnessRoot(options.repoDir))) return 1;
|
|
919
|
-
if (restartMarkerState(stateDir) === "fresh") {
|
|
920
|
-
io.stderr("restart refused: a scheduled exit is still pending (restart-requested.json) — its exit agent would kill the instance this restart starts; wait for it or remove the stale marker\n");
|
|
921
|
-
return 1;
|
|
922
|
-
}
|
|
2924
|
+
const gate = verifyRepoCredential(stateDir, repoDir, options.maxAgeMinutes);
|
|
2925
|
+
if (!gate.ok) return refuse("credential", `restart refused: ${gate.reason}\n`);
|
|
2926
|
+
if (!sandboxGate("restart", options, io)) return refuseQuiet("sandbox", "restart refused: the environment is sandboxed, so the detached restart driver would be reaped mid-flight");
|
|
2927
|
+
if (!isDriver && !await preflightGate("restart", resolveProfileName(options), options.preflightTimeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS, io, preflightHarnessRoot(options, stateDir))) return refuseQuiet("preflight", "restart refused: the composition preflight failed (see stderr for the failing entries)");
|
|
2928
|
+
if (restartMarkerState(stateDir) === "fresh") return refuse("marker", "restart refused: a scheduled exit is still pending (restart-requested.json) — its exit agent would kill the instance this restart starts; wait for it or remove the stale marker\n");
|
|
923
2929
|
if (options.sync !== true && !isDriver) {
|
|
924
2930
|
const logPath = options.log ?? stateFile(stateDir, "restartLog");
|
|
925
2931
|
mkdirSync(dirname(logPath), { recursive: true });
|
|
2932
|
+
const restartDriverEnv = {
|
|
2933
|
+
...process.env,
|
|
2934
|
+
DSH_ANKH_RESTART_DRIVER: "1"
|
|
2935
|
+
};
|
|
2936
|
+
delete restartDriverEnv.DSH_ANKH_VERDICT_FILE;
|
|
926
2937
|
const driver = spawn(process.execPath, cliInvocation(argv), {
|
|
927
2938
|
detached: true,
|
|
928
2939
|
stdio: [
|
|
@@ -930,23 +2941,23 @@ async function runCli(argv, io) {
|
|
|
930
2941
|
openSync(logPath, "a"),
|
|
931
2942
|
openSync(logPath, "a")
|
|
932
2943
|
],
|
|
933
|
-
env: {
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
}
|
|
2944
|
+
env: testChildEnv("restart-driver", restartDriverEnv, {
|
|
2945
|
+
port,
|
|
2946
|
+
tempRoot: stateDir
|
|
2947
|
+
})
|
|
2948
|
+
});
|
|
2949
|
+
registerSpawnedTestProcess(driver, "restart-driver", {
|
|
2950
|
+
port,
|
|
2951
|
+
tempRoot: stateDir
|
|
937
2952
|
});
|
|
938
2953
|
driver.unref();
|
|
939
|
-
if (driver.pid === void 0)
|
|
940
|
-
io.stderr("restart refused: could not detach the restart driver\n");
|
|
941
|
-
return 1;
|
|
942
|
-
}
|
|
2954
|
+
if (driver.pid === void 0) return refuse("spawn", "restart refused: could not detach the restart driver\n");
|
|
943
2955
|
const lock = acquireRestartLock(stateDir, driver.pid);
|
|
944
2956
|
if (!lock.ok) {
|
|
945
2957
|
try {
|
|
946
2958
|
process.kill(driver.pid, "SIGKILL");
|
|
947
2959
|
} catch {}
|
|
948
|
-
|
|
949
|
-
return 1;
|
|
2960
|
+
return refuse("lock", /^\d+$/.test(lock.holder) ? `restart refused: another restart is already in flight (pid ${lock.holder})\n` : `restart refused: cannot claim the restart lock (${lock.holder}) — remove ${stateFile(stateDir, "restartLock")} if it is stale\n`);
|
|
950
2961
|
}
|
|
951
2962
|
io.stdout(`restart driver detached (pid ${driver.pid}) — log ${logPath}\nthe instance stops in ${options.delayMs ?? 0} ms and comes back on its own; check the log or \`status\` afterwards\n`);
|
|
952
2963
|
return 0;
|
|
@@ -955,7 +2966,7 @@ async function runCli(argv, io) {
|
|
|
955
2966
|
if (options.sync === true) {
|
|
956
2967
|
const lock = acquireRestartLock(stateDir);
|
|
957
2968
|
if (!lock.ok) {
|
|
958
|
-
|
|
2969
|
+
refuse("lock", /^\d+$/.test(lock.holder) ? `restart refused: another restart is already in flight (pid ${lock.holder})\n` : `restart refused: cannot claim the restart lock (${lock.holder}) — remove ${stateFile(stateDir, "restartLock")} if it is stale\n`);
|
|
959
2970
|
return 1;
|
|
960
2971
|
}
|
|
961
2972
|
restartLock = lock;
|
|
@@ -985,12 +2996,21 @@ async function runCli(argv, io) {
|
|
|
985
2996
|
const stoppedAt = Date.now();
|
|
986
2997
|
const startEnv = { ...process.env };
|
|
987
2998
|
delete startEnv.DSH_ANKH_RESTART_DRIVER;
|
|
988
|
-
|
|
2999
|
+
for (const key of Object.keys(startEnv)) if (key.startsWith("WD_")) delete startEnv[key];
|
|
3000
|
+
const child = spawn(start, {
|
|
989
3001
|
shell: true,
|
|
990
3002
|
detached: true,
|
|
991
3003
|
stdio: "ignore",
|
|
992
|
-
env: startEnv
|
|
993
|
-
|
|
3004
|
+
env: testChildEnv("restart-instance-root", startEnv, {
|
|
3005
|
+
port,
|
|
3006
|
+
tempRoot: stateDir
|
|
3007
|
+
})
|
|
3008
|
+
});
|
|
3009
|
+
registerSpawnedTestProcess(child, "restart-instance-root", {
|
|
3010
|
+
port,
|
|
3011
|
+
tempRoot: stateDir
|
|
3012
|
+
});
|
|
3013
|
+
child.unref();
|
|
994
3014
|
io.stdout(`started: ${start}\n`);
|
|
995
3015
|
const timeoutMs = options.timeoutMs ?? 6e4;
|
|
996
3016
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -1002,7 +3022,7 @@ async function runCli(argv, io) {
|
|
|
1002
3022
|
}
|
|
1003
3023
|
await sleep(500);
|
|
1004
3024
|
}
|
|
1005
|
-
const initiator = options.initiator
|
|
3025
|
+
const initiator = resolveInitiator(options.initiator, io);
|
|
1006
3026
|
if (!listening) {
|
|
1007
3027
|
io.stderr(`new instance not listening on 127.0.0.1:${port} within ${timeoutMs}ms\n`);
|
|
1008
3028
|
writeRestartOutcome(stateDir, {
|
|
@@ -1014,7 +3034,7 @@ async function runCli(argv, io) {
|
|
|
1014
3034
|
if (options.rollback) rollbackToKnownGood(stateDir, repoDir, io);
|
|
1015
3035
|
return 1;
|
|
1016
3036
|
}
|
|
1017
|
-
const post =
|
|
3037
|
+
const post = verifyRepoCredential(stateDir, repoDir, options.maxAgeMinutes);
|
|
1018
3038
|
io.stdout(`canary verify: ${post.ok ? "PASS" : "FAIL"} — ${post.reason}\n`);
|
|
1019
3039
|
io.stdout(`canary port: PASS — listening on 127.0.0.1:${port}\n`);
|
|
1020
3040
|
if (!post.ok) {
|
|
@@ -1040,27 +3060,28 @@ async function runCli(argv, io) {
|
|
|
1040
3060
|
}
|
|
1041
3061
|
}
|
|
1042
3062
|
case "supervise": {
|
|
1043
|
-
|
|
1044
|
-
if (
|
|
1045
|
-
|
|
1046
|
-
|
|
3063
|
+
let spec = resolveSuperviseSpec(options, stateDir, repoDir, io);
|
|
3064
|
+
if (spec === void 0) return 2;
|
|
3065
|
+
let transaction = activeCutover(stateDir);
|
|
3066
|
+
if (options.cutoverId !== void 0 && (transaction === null || transaction.receipt.id !== options.cutoverId)) {
|
|
3067
|
+
io.stderr(`supervise refused: cutover ${options.cutoverId} is not the selected launch transaction\n`);
|
|
3068
|
+
return 1;
|
|
1047
3069
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
command: start,
|
|
1052
|
-
source: "supervisor",
|
|
1053
|
-
supervised: true,
|
|
1054
|
-
...port !== void 0 ? { port } : {},
|
|
1055
|
-
recordedAt: Date.now()
|
|
1056
|
-
});
|
|
1057
|
-
const wdHome = resolveWdHome(options.home);
|
|
1058
|
-
if (wdHome === void 0) {
|
|
1059
|
-
io.stderr("supervise needs the dsh home: pass --home DIR or set DSH_HOME — the supervised instance reads its profiles/credentials from there, and deriving one from --state-dir would guess wrong\n");
|
|
1060
|
-
return 2;
|
|
3070
|
+
if (options.cutoverId === void 0 && transaction?.receipt.phase === "awaiting-user") {
|
|
3071
|
+
io.stderr(`supervise: cutover ${transaction.receipt.id} is waiting for user action; refusing to restart the rejected target automatically (receipt ${stateFile(stateDir, "launchCutover")})\n`);
|
|
3072
|
+
return 0;
|
|
1061
3073
|
}
|
|
1062
3074
|
if (options.foreground !== true && !sandboxGate("supervise", options, io)) return 2;
|
|
3075
|
+
if (options.cutoverId !== void 0) try {
|
|
3076
|
+
const driverIdentity = processIdentity(process.pid);
|
|
3077
|
+
if (driverIdentity === null) throw new Error(`could not capture driver ${process.pid} start identity`);
|
|
3078
|
+
recordCutoverEvent(stateDir, options.cutoverId, "driver-started", [String(process.pid), driverIdentity.startToken], Date.now());
|
|
3079
|
+
} catch (error) {
|
|
3080
|
+
io.stderr(`supervise refused: could not persist cutover driver PID: ${String(error)}\n`);
|
|
3081
|
+
return 1;
|
|
3082
|
+
}
|
|
1063
3083
|
const pidfile = stateFile(stateDir, "watchdogPid");
|
|
3084
|
+
let waitedForWatchdog = false;
|
|
1064
3085
|
if (existsSync(pidfile)) {
|
|
1065
3086
|
const existing = readFileSync(pidfile, "utf8").trim();
|
|
1066
3087
|
const existingPid = Number(existing);
|
|
@@ -1072,24 +3093,61 @@ async function runCli(argv, io) {
|
|
|
1072
3093
|
existingAlive = false;
|
|
1073
3094
|
}
|
|
1074
3095
|
if (existingAlive) {
|
|
1075
|
-
if (options.
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
process.kill(existingPid, 0);
|
|
1080
|
-
} catch {
|
|
1081
|
-
break;
|
|
1082
|
-
}
|
|
1083
|
-
await sleep(1e3);
|
|
3096
|
+
if (options.takeoverFrom !== void 0) {
|
|
3097
|
+
if (existingPid !== options.takeoverFrom) {
|
|
3098
|
+
io.stderr(`supervise takeover refused: expected watchdog ${options.takeoverFrom}, but pidfile names live ${existingPid}\n`);
|
|
3099
|
+
return 1;
|
|
1084
3100
|
}
|
|
1085
|
-
io.stdout(`watchdog ${existing} exited — taking over\n`);
|
|
1086
3101
|
} else {
|
|
1087
|
-
|
|
1088
|
-
|
|
3102
|
+
const durable = readLaunchState(stateDir);
|
|
3103
|
+
if (options.start !== void 0 && durable !== null && !sameLaunchSpec(spec, selectedLaunchSpec(durable))) {
|
|
3104
|
+
io.stderr("supervise refused: a live watchdog owns a different launch specification; use `reconfigure --on-failure ...` so the supervisor and full config move transactionally\n");
|
|
3105
|
+
return 1;
|
|
3106
|
+
}
|
|
3107
|
+
if (durable === null) writeStableLaunchSpec(stateDir, spec);
|
|
3108
|
+
if (options.foreground) {
|
|
3109
|
+
io.stdout(`watchdog ${existing} already supervises the port — waiting for it to exit, then taking over (foreground)\n`);
|
|
3110
|
+
const existingIdentity = processIdentity(existingPid);
|
|
3111
|
+
if (existingIdentity === null) {
|
|
3112
|
+
io.stderr(`supervise refused: could not capture watchdog ${existingPid} start identity before waiting\n`);
|
|
3113
|
+
return 1;
|
|
3114
|
+
}
|
|
3115
|
+
while (processIdentityMatches(existingIdentity)) await sleep(1e3);
|
|
3116
|
+
waitedForWatchdog = true;
|
|
3117
|
+
io.stdout(`watchdog ${existing} exited — taking over\n`);
|
|
3118
|
+
} else {
|
|
3119
|
+
io.stdout(`already supervised by pid ${existing}\n`);
|
|
3120
|
+
return 0;
|
|
3121
|
+
}
|
|
1089
3122
|
}
|
|
1090
3123
|
}
|
|
1091
3124
|
}
|
|
1092
3125
|
}
|
|
3126
|
+
if (waitedForWatchdog) {
|
|
3127
|
+
const refreshed = resolveSuperviseSpec(options, stateDir, repoDir, io, true);
|
|
3128
|
+
if (refreshed === void 0) return 2;
|
|
3129
|
+
spec = refreshed;
|
|
3130
|
+
transaction = activeCutover(stateDir);
|
|
3131
|
+
if (options.cutoverId !== void 0 && (transaction === null || transaction.receipt.id !== options.cutoverId)) {
|
|
3132
|
+
io.stderr(`supervise refused after wait: cutover ${options.cutoverId} is no longer the selected launch transaction\n`);
|
|
3133
|
+
return 1;
|
|
3134
|
+
}
|
|
3135
|
+
if (options.cutoverId === void 0 && transaction?.receipt.phase === "awaiting-user") {
|
|
3136
|
+
io.stderr(`supervise: cutover ${transaction.receipt.id} settled awaiting-user while this supervisor waited; refusing to restart the rejected target (receipt ${stateFile(stateDir, "launchCutover")})\n`);
|
|
3137
|
+
return 0;
|
|
3138
|
+
}
|
|
3139
|
+
io.stdout("launch state refreshed after wait — using the durable selected specification\n");
|
|
3140
|
+
}
|
|
3141
|
+
const previousOwnership = transaction?.receipt.ownership?.previous;
|
|
3142
|
+
if (transaction !== null && (previousOwnership === void 0 || !Number.isInteger(previousOwnership.childPid) || previousOwnership.childPid <= 0 || !Number.isInteger(previousOwnership.listenerPid) || previousOwnership.listenerPid <= 0 || previousOwnership.childStartToken === "" || previousOwnership.listenerStartToken === "")) {
|
|
3143
|
+
io.stderr(`supervise refused: active cutover ${transaction.receipt.id} predates authoritative child/listener ownership evidence; refusing to infer or kill a process by port. Keep the existing host untouched and settle the transaction explicitly.\n`);
|
|
3144
|
+
return 1;
|
|
3145
|
+
}
|
|
3146
|
+
const previousSupervisorStart = transaction?.receipt.supervisor?.previousStartToken;
|
|
3147
|
+
if (transaction !== null && (previousSupervisorStart === void 0 || previousSupervisorStart === "")) {
|
|
3148
|
+
io.stderr(`supervise refused: active cutover ${transaction.receipt.id} predates supervisor start identity evidence; refusing a PID-only takeover. Keep the existing host untouched and settle the transaction explicitly.\n`);
|
|
3149
|
+
return 1;
|
|
3150
|
+
}
|
|
1093
3151
|
const watchdog = fileURLToPath(new URL("../scripts/dsh-watchdog.sh", import.meta.url));
|
|
1094
3152
|
if (!existsSync(watchdog)) {
|
|
1095
3153
|
io.stderr(`watchdog script not found at ${watchdog}\n`);
|
|
@@ -1099,23 +3157,67 @@ async function runCli(argv, io) {
|
|
|
1099
3157
|
io.stderr("supervise: --log has no effect with --foreground — output follows the external supervisor's redirection (launchd StandardOutPath / systemd StandardOutput=); drop --log\n");
|
|
1100
3158
|
return 2;
|
|
1101
3159
|
}
|
|
3160
|
+
if (transaction === null) {
|
|
3161
|
+
writeStableLaunchSpec(stateDir, spec);
|
|
3162
|
+
writeInstanceLaunchAsSupervisor(stateDir, {
|
|
3163
|
+
command: spec.command,
|
|
3164
|
+
source: "supervisor",
|
|
3165
|
+
supervised: true,
|
|
3166
|
+
port: spec.port,
|
|
3167
|
+
recordedAt: Date.now()
|
|
3168
|
+
});
|
|
3169
|
+
}
|
|
3170
|
+
const supervisorBaseEnv = { ...process.env };
|
|
3171
|
+
delete supervisorBaseEnv.DSH_ANKH_VERDICT_FILE;
|
|
3172
|
+
for (const key of Object.keys(supervisorBaseEnv)) if (key.startsWith("WD_")) delete supervisorBaseEnv[key];
|
|
1102
3173
|
const env = {
|
|
1103
|
-
...
|
|
1104
|
-
WD_PORT: String(port),
|
|
1105
|
-
WD_HOME:
|
|
3174
|
+
...supervisorBaseEnv,
|
|
3175
|
+
WD_PORT: String(spec.port),
|
|
3176
|
+
WD_HOME: spec.home,
|
|
1106
3177
|
WD_STATE_DIR: stateDir,
|
|
1107
|
-
WD_REPO:
|
|
1108
|
-
|
|
3178
|
+
WD_REPO: spec.credentialRepo,
|
|
3179
|
+
WD_HARNESS_ROOT: spec.harnessRoot,
|
|
3180
|
+
WD_START: spec.command,
|
|
1109
3181
|
DSH_ANKH_SUPERVISED: "1",
|
|
1110
|
-
WD_INITIATOR: process.env.DSH_SESSION_ID ?? "",
|
|
1111
|
-
WD_ADOPTION: findPidOnPort(port) !== null ? "1" : "0",
|
|
1112
|
-
|
|
1113
|
-
|
|
3182
|
+
WD_INITIATOR: options.initiator ?? process.env.DSH_SESSION_ID ?? "",
|
|
3183
|
+
WD_ADOPTION: transaction === null && findPidOnPort(spec.port) !== null ? "1" : "0",
|
|
3184
|
+
WD_PROFILE: spec.profile,
|
|
3185
|
+
WD_WAIT_OWNER: options.takeoverFrom !== void 0 || !options.foreground ? "1" : "0",
|
|
3186
|
+
WD_GUARD: guardInvocation(),
|
|
3187
|
+
...options.takeoverFrom !== void 0 ? {
|
|
3188
|
+
WD_TAKEOVER_FROM: String(options.takeoverFrom),
|
|
3189
|
+
WD_TAKEOVER_FROM_START: previousSupervisorStart ?? ""
|
|
3190
|
+
} : {},
|
|
3191
|
+
...transaction !== null && previousOwnership !== void 0 ? {
|
|
3192
|
+
WD_CUTOVER_ID: transaction.receipt.id,
|
|
3193
|
+
WD_CUTOVER_ROLE: transaction.state.selected,
|
|
3194
|
+
WD_CUTOVER_POLICY: transaction.receipt.recovery.policy,
|
|
3195
|
+
WD_BROWSER_HANDOFF: transaction.receipt.authentication.browserHandoff === "off" ? "off" : "required",
|
|
3196
|
+
WD_CUTOVER_DELAY_SECONDS: String((options.delayMs ?? 5e3) / 1e3),
|
|
3197
|
+
WD_SUPERVISOR_YIELD_TIMEOUT_MS: String(options.supervisorYieldTimeoutMs ?? 15e3),
|
|
3198
|
+
WD_PREVIOUS_START: transaction.state.previous.command,
|
|
3199
|
+
WD_PREVIOUS_HOME: transaction.state.previous.home,
|
|
3200
|
+
WD_PREVIOUS_REPO: transaction.state.previous.credentialRepo,
|
|
3201
|
+
WD_PREVIOUS_HARNESS_ROOT: transaction.state.previous.harnessRoot,
|
|
3202
|
+
WD_PREVIOUS_PROFILE: transaction.state.previous.profile,
|
|
3203
|
+
WD_PREVIOUS_CHILD_PID: String(previousOwnership.childPid),
|
|
3204
|
+
WD_PREVIOUS_CHILD_START: previousOwnership.childStartToken,
|
|
3205
|
+
WD_PREVIOUS_LISTENER_PID: String(previousOwnership.listenerPid),
|
|
3206
|
+
WD_PREVIOUS_LISTENER_START: previousOwnership.listenerStartToken,
|
|
3207
|
+
...transaction.state.transition === void 0 ? {} : { WD_TRANSITION_PLAN_SHA256: transaction.state.transition.planSha256 }
|
|
3208
|
+
} : {}
|
|
1114
3209
|
};
|
|
1115
3210
|
if (options.foreground) {
|
|
1116
3211
|
const child = spawn("bash", [watchdog, "--supervise"], {
|
|
1117
3212
|
stdio: "inherit",
|
|
1118
|
-
env
|
|
3213
|
+
env: testChildEnv("watchdog-foreground", env, {
|
|
3214
|
+
port: spec.port,
|
|
3215
|
+
tempRoot: stateDir
|
|
3216
|
+
})
|
|
3217
|
+
});
|
|
3218
|
+
registerSpawnedTestProcess(child, "watchdog-foreground", {
|
|
3219
|
+
port: spec.port,
|
|
3220
|
+
tempRoot: stateDir
|
|
1119
3221
|
});
|
|
1120
3222
|
return await new Promise((resolve) => {
|
|
1121
3223
|
child.on("exit", (c) => {
|
|
@@ -1132,27 +3234,81 @@ async function runCli(argv, io) {
|
|
|
1132
3234
|
openSync(logPath, "a"),
|
|
1133
3235
|
openSync(logPath, "a")
|
|
1134
3236
|
],
|
|
1135
|
-
env
|
|
3237
|
+
env: testChildEnv("watchdog-detached", env, {
|
|
3238
|
+
port: spec.port,
|
|
3239
|
+
tempRoot: stateDir
|
|
3240
|
+
})
|
|
3241
|
+
});
|
|
3242
|
+
registerSpawnedTestProcess(child, "watchdog-detached", {
|
|
3243
|
+
port: spec.port,
|
|
3244
|
+
tempRoot: stateDir
|
|
3245
|
+
});
|
|
3246
|
+
let spawnError;
|
|
3247
|
+
child.once("error", (error) => {
|
|
3248
|
+
spawnError = error;
|
|
1136
3249
|
});
|
|
1137
3250
|
child.unref();
|
|
1138
|
-
|
|
1139
|
-
|
|
3251
|
+
const spawnedPid = child.pid;
|
|
3252
|
+
if (spawnedPid === void 0) {
|
|
3253
|
+
io.stderr("supervise refused: watchdog process has no pid\n");
|
|
3254
|
+
return 1;
|
|
3255
|
+
}
|
|
3256
|
+
const claimDeadline = Date.now() + 5e3;
|
|
3257
|
+
while (Date.now() < claimDeadline && spawnError === void 0) {
|
|
3258
|
+
if (liveWatchdogPid(stateDir) === spawnedPid) {
|
|
3259
|
+
io.stdout(`watchdog spawned and ready (pid ${spawnedPid}) — supervises :${spec.port}, log ${logPath}\n`);
|
|
3260
|
+
return 0;
|
|
3261
|
+
}
|
|
3262
|
+
try {
|
|
3263
|
+
process.kill(spawnedPid, 0);
|
|
3264
|
+
} catch {
|
|
3265
|
+
break;
|
|
3266
|
+
}
|
|
3267
|
+
await sleep(50);
|
|
3268
|
+
}
|
|
3269
|
+
try {
|
|
3270
|
+
process.kill(-spawnedPid, "SIGTERM");
|
|
3271
|
+
} catch {
|
|
3272
|
+
try {
|
|
3273
|
+
process.kill(spawnedPid, "SIGTERM");
|
|
3274
|
+
} catch {}
|
|
3275
|
+
}
|
|
3276
|
+
io.stderr(`supervise refused: watchdog ${spawnedPid} did not claim ${pidfile} within 5000 ms${spawnError === void 0 ? "" : ` (${String(spawnError)})`}; inspect ${logPath}\n`);
|
|
3277
|
+
return 1;
|
|
1140
3278
|
}
|
|
1141
3279
|
case "schedule-exit": {
|
|
1142
|
-
const port = options.port ?? readInstanceLaunch(stateDir)?.port;
|
|
1143
3280
|
const delayMs = options.delayMs;
|
|
1144
|
-
if (
|
|
1145
|
-
io.stderr(`schedule-exit requires --
|
|
3281
|
+
if (delayMs === void 0) {
|
|
3282
|
+
io.stderr(`schedule-exit requires --delay-ms MS (and --port N before durable launch configuration exists)\n\n${USAGE}`);
|
|
3283
|
+
return 2;
|
|
3284
|
+
}
|
|
3285
|
+
const scheduledCutover = activeCutover(stateDir);
|
|
3286
|
+
if (scheduledCutover !== null) {
|
|
3287
|
+
io.stderr(`schedule-exit refused: launch cutover ${scheduledCutover.receipt.id} is ${scheduledCutover.receipt.phase}; let that transaction settle before scheduling another stop\n`);
|
|
3288
|
+
return 1;
|
|
3289
|
+
}
|
|
3290
|
+
const durableSpec = stableScheduleSpec(options, stateDir, repoDir, io);
|
|
3291
|
+
if (durableSpec === void 0) return 1;
|
|
3292
|
+
const port = durableSpec?.port ?? options.port ?? readInstanceLaunch(stateDir)?.port;
|
|
3293
|
+
if (port === void 0) {
|
|
3294
|
+
io.stderr(`schedule-exit requires --port N before a durable launch specification exists\n\n${USAGE}`);
|
|
1146
3295
|
return 2;
|
|
1147
3296
|
}
|
|
1148
|
-
const
|
|
3297
|
+
const credentialRepo = durableSpec?.credentialRepo ?? repoDir;
|
|
3298
|
+
const profile = durableSpec?.profile ?? resolveProfileName(options);
|
|
3299
|
+
const harnessRoot = durableSpec?.harnessRoot ?? preflightHarnessRoot(options, stateDir);
|
|
3300
|
+
if (liveWatchdogPid(stateDir) === null) {
|
|
3301
|
+
io.stderr(`schedule-exit refused: no live watchdog owns the instance on :${port}; establish supervision first. A scheduled exit here would leave the service down.\n`);
|
|
3302
|
+
return 1;
|
|
3303
|
+
}
|
|
3304
|
+
const gate = durableSpec === null ? verifyRepoCredential(stateDir, credentialRepo, options.maxAgeMinutes) : verifyRestartEvidence(stateDir, durableSpec, options.maxAgeMinutes);
|
|
1149
3305
|
if (!gate.ok) {
|
|
1150
3306
|
io.stderr(`schedule-exit refused: ${gate.reason}\n`);
|
|
1151
3307
|
return 1;
|
|
1152
3308
|
}
|
|
3309
|
+
io.stdout(`restart evidence PASS — ${gate.reason}\n`);
|
|
1153
3310
|
if (!sandboxGate("schedule-exit", options, io)) return 1;
|
|
1154
|
-
if (!await preflightGate("schedule-exit",
|
|
1155
|
-
if (liveWatchdogPid(stateDir) === null) io.stderr(NO_WATCHDOG_HINT);
|
|
3311
|
+
if (!await preflightGate("schedule-exit", profile, options.preflightTimeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS, io, harnessRoot, durableSpec?.home, durableSpec?.preflight)) return 1;
|
|
1156
3312
|
const lock = acquireRestartLock(stateDir);
|
|
1157
3313
|
if (!lock.ok) {
|
|
1158
3314
|
io.stderr(`schedule-exit refused: a restart is in flight (pid ${lock.holder}) — the exit agent would kill the instance it is starting\n`);
|
|
@@ -1166,11 +3322,12 @@ async function runCli(argv, io) {
|
|
|
1166
3322
|
return 1;
|
|
1167
3323
|
}
|
|
1168
3324
|
if (markerState === "stale" && existsSync(markerFile)) io.stderr("warning: overwriting a stale restart marker (a previous schedule never completed)\n");
|
|
1169
|
-
const initiator = options.initiator
|
|
3325
|
+
const initiator = resolveInitiator(options.initiator, io);
|
|
1170
3326
|
mkdirSync(stateDir, { recursive: true });
|
|
1171
3327
|
writeFileSync(stateFile(stateDir, "restartRequested"), `${JSON.stringify({
|
|
1172
3328
|
reason: "scheduled self-restart",
|
|
1173
3329
|
requestedAt: Date.now(),
|
|
3330
|
+
...gate.authorization === void 0 ? {} : { authorization: gate.authorization },
|
|
1174
3331
|
...initiator !== void 0 ? { initiator } : {}
|
|
1175
3332
|
})}\n`);
|
|
1176
3333
|
const resultFile = stateFile(stateDir, "lastRestart");
|
|
@@ -1183,16 +3340,23 @@ async function runCli(argv, io) {
|
|
|
1183
3340
|
openSync(logPath, "a"),
|
|
1184
3341
|
openSync(logPath, "a")
|
|
1185
3342
|
],
|
|
1186
|
-
env: {
|
|
3343
|
+
env: testChildEnv("schedule-exit-agent", {
|
|
1187
3344
|
...process.env,
|
|
1188
3345
|
WD_PORT: String(port),
|
|
1189
3346
|
WD_DELAY_MS: String(delayMs),
|
|
1190
3347
|
WD_RESULT_FILE: resultFile,
|
|
1191
3348
|
...initiator !== void 0 ? { WD_INITIATOR: initiator } : {}
|
|
1192
|
-
}
|
|
3349
|
+
}, {
|
|
3350
|
+
port,
|
|
3351
|
+
tempRoot: stateDir
|
|
3352
|
+
})
|
|
3353
|
+
});
|
|
3354
|
+
registerSpawnedTestProcess(child, "schedule-exit-agent", {
|
|
3355
|
+
port,
|
|
3356
|
+
tempRoot: stateDir
|
|
1193
3357
|
});
|
|
1194
3358
|
child.unref();
|
|
1195
|
-
io.stdout(`exit scheduled in ${delayMs} ms (agent pid ${child.pid ?? "unknown"}) — watchdog will respawn and run the canary\n`);
|
|
3359
|
+
io.stdout(`exit scheduled in ${delayMs} ms (exit-agent pid ${child.pid ?? "unknown"}) — watchdog will respawn and run the canary\n`);
|
|
1196
3360
|
return 0;
|
|
1197
3361
|
} finally {
|
|
1198
3362
|
lock.release();
|
|
@@ -1203,14 +3367,17 @@ async function runCli(argv, io) {
|
|
|
1203
3367
|
return 2;
|
|
1204
3368
|
}
|
|
1205
3369
|
}
|
|
1206
|
-
if (isDirectInvocation(import.meta.url))
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
}).
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
});
|
|
3370
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
3371
|
+
registerCurrentTestProcess();
|
|
3372
|
+
runCli(process.argv.slice(2), {
|
|
3373
|
+
stdout: (line) => process.stdout.write(line),
|
|
3374
|
+
stderr: (line) => process.stderr.write(line)
|
|
3375
|
+
}).then((code) => {
|
|
3376
|
+
process.exitCode = code;
|
|
3377
|
+
}).catch((error) => {
|
|
3378
|
+
process.stderr.write(`ankh-guard: ${String(error)}\n`);
|
|
3379
|
+
process.exitCode = 1;
|
|
3380
|
+
});
|
|
3381
|
+
}
|
|
1215
3382
|
//#endregion
|
|
1216
3383
|
export { envInternals, parse, preflightInternals, resolveHarnessRoot, resolvePreflightBin, resolveRunnerCommand, resolveWdHome, runCli, runPreflightCheck };
|