@khorsheed/dsh-ankh-guard 0.1.0 → 0.2.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 +28 -0
- package/README.en.md +75 -29
- package/README.i18n.yaml +2 -2
- package/README.md +74 -29
- package/lib/cli.js +2383 -209
- package/lib/client.js +257 -0
- package/lib/exit-agent.js +5 -2
- package/lib/index.js +722 -38
- package/lib/invariant.js +1 -1
- package/lib/preflight-runner.js +125 -47
- package/lib/processes-BjZgJjQr.js +344 -0
- package/lib/restart-context-D6nISh28.js +1245 -0
- package/lib/restart-context-DUyExi9O.js +1245 -0
- package/lib/{state-Dhx9VG44.js → state-4f7yny39.js} +60 -13
- package/lib/state-CZMypGkB.js +323 -0
- package/lib/test-seam-DnvLWTeO.js +119 -0
- 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 +1487 -225
- package/lib/types/client/index.d.ts +15 -0
- package/lib/types/client/index.js +264 -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 +157 -39
- 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 +106 -0
- 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 +29 -9
- 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-DUyExi9O.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
|
/**
|
|
@@ -74,17 +1093,42 @@ function sandboxGate(verb, options, io) {
|
|
|
74
1093
|
io.stderr(`${verb} refused: this environment is sandboxed (a probe write outside the workspace was denied), so a detached restart/watchdog process would be reaped when the turn ends. You cannot switch the sandbox yourself — ask the user to run /permission danger-full-access in THIS session (a yes/no "authorization" changes nothing), then verify with \`dsh-ankh-guard check-env\` and retry. Certain the probe is wrong? Re-run with --force\n`);
|
|
75
1094
|
return false;
|
|
76
1095
|
}
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
1096
|
+
/**
|
|
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).
|
|
1106
|
+
*/
|
|
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;
|
|
1111
|
+
}
|
|
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
|
+
};
|
|
1120
|
+
}
|
|
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");
|
|
88
1132
|
}
|
|
89
1133
|
/**
|
|
90
1134
|
* Cross-session restart mutual exclusion: two concurrent restarts would both
|
|
@@ -116,14 +1160,10 @@ function acquireRestartLock(stateDir, holderPid = process.pid) {
|
|
|
116
1160
|
holder: `unreadable (${String(error)})`
|
|
117
1161
|
};
|
|
118
1162
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
ok: false,
|
|
124
|
-
holder
|
|
125
|
-
};
|
|
126
|
-
} catch {}
|
|
1163
|
+
if (pidAlive(holder)) return {
|
|
1164
|
+
ok: false,
|
|
1165
|
+
holder
|
|
1166
|
+
};
|
|
127
1167
|
try {
|
|
128
1168
|
unlinkSync(file);
|
|
129
1169
|
} catch (error) {
|
|
@@ -168,29 +1208,52 @@ function cliInvocation(args) {
|
|
|
168
1208
|
const USAGE = `usage: dsh-ankh-guard <command> [args] [flags]
|
|
169
1209
|
commands:
|
|
170
1210
|
verify [--state-dir DIR] [--repo DIR] [--max-age MIN]
|
|
171
|
-
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
|
|
172
1213
|
status [--state-dir DIR]
|
|
173
1214
|
clear [--state-dir DIR]
|
|
174
|
-
checkpoint [--message MSG] [--repo DIR] [--state-dir DIR]
|
|
1215
|
+
checkpoint [--message MSG] [--include-dirty] [--repo DIR] [--state-dir DIR]
|
|
175
1216
|
reset <sha> [--repo DIR]
|
|
176
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
|
|
177
1220
|
check-env [--state-dir DIR] [--repo DIR] # sandbox / watchdog / git readiness probe
|
|
178
|
-
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]
|
|
179
1223
|
record-unexpected-exit [--state-dir DIR] # watchdog-facing: record an unplanned-exit recovery
|
|
180
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]
|
|
181
1238
|
restart --port N --start "CMD" [--pid PID] [--timeout-ms MS] [--delay-ms MS] [--stop-timeout-ms MS] [--rollback]
|
|
182
|
-
[--profile NAME] [--preflight-timeout-ms MS] [--state-dir DIR] [--repo DIR] [--max-age MIN]
|
|
183
|
-
schedule-exit --port N --delay-ms MS [--initiator ID] [--log FILE] [--profile NAME]
|
|
184
|
-
[--preflight-timeout-ms MS] [--state-dir DIR] [--repo DIR]
|
|
185
|
-
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]
|
|
186
1243
|
flags:
|
|
187
1244
|
--state-dir DIR state directory (default: $DSH_HOME/state, else <cwd>/.dsh-guard-state)
|
|
188
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
|
|
189
1249
|
--max-age MIN credential freshness window in minutes (default: 10)
|
|
190
1250
|
--port N canary/restart/supervise: TCP port that must be listening
|
|
191
|
-
--
|
|
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
|
|
192
1254
|
--message MSG checkpoint: batch description
|
|
193
|
-
--
|
|
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
|
|
194
1257
|
(optional once the plugin has booted — it records the launch
|
|
195
1258
|
command to <state-dir>/instance-launch.json)
|
|
196
1259
|
--pid PID restart: process to stop (default: the listener on --port)
|
|
@@ -201,18 +1264,37 @@ flags:
|
|
|
201
1264
|
logs can take tens of seconds to flush)
|
|
202
1265
|
--delay-ms MS restart: sleep before stopping, so the current turn can finish first
|
|
203
1266
|
(agent-driven graceful self-restart: schedule, complete, then restart);
|
|
204
|
-
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
|
|
205
1269
|
--log FILE supervise (detached only — with --foreground the external supervisor's
|
|
206
1270
|
redirection owns the log) / schedule-exit: log file (default: <state-dir>/*.log)
|
|
207
1271
|
--home DIR supervise: the dsh home the supervised instance boots with (profiles,
|
|
208
1272
|
credentials — default: $DSH_HOME; required when that is unset)
|
|
209
1273
|
--initiator ID schedule-exit: session id that requested the exit (default: $DSH_SESSION_ID);
|
|
210
|
-
recorded in last-restart.json so the restart report returns to that session
|
|
211
|
-
|
|
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:
|
|
212
1278
|
$DSH_PROFILE, else "web")
|
|
213
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
|
|
214
1284
|
--rollback restart: on failure, git reset --hard to the recorded checkpoint
|
|
215
|
-
--
|
|
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
|
|
216
1298
|
--sync restart: run the whole loop in-process (debug/tests; the default
|
|
217
1299
|
self-detaches a driver so the loop survives the caller's teardown)
|
|
218
1300
|
`;
|
|
@@ -225,16 +1307,21 @@ function parse(argv) {
|
|
|
225
1307
|
const options = {
|
|
226
1308
|
stateDir: "",
|
|
227
1309
|
repoDir: "",
|
|
1310
|
+
harnessRoot: "",
|
|
228
1311
|
home: "",
|
|
229
1312
|
maxAgeMinutes: 10,
|
|
230
1313
|
port: void 0,
|
|
231
1314
|
command: void 0,
|
|
1315
|
+
run: false,
|
|
1316
|
+
runArgv: void 0,
|
|
232
1317
|
message: void 0,
|
|
1318
|
+
detail: void 0,
|
|
233
1319
|
start: void 0,
|
|
234
1320
|
pid: void 0,
|
|
235
1321
|
timeoutMs: void 0,
|
|
236
1322
|
delayMs: void 0,
|
|
237
1323
|
stopTimeoutMs: void 0,
|
|
1324
|
+
supervisorYieldTimeoutMs: void 0,
|
|
238
1325
|
log: void 0,
|
|
239
1326
|
foreground: false,
|
|
240
1327
|
rollback: false,
|
|
@@ -242,7 +1329,19 @@ function parse(argv) {
|
|
|
242
1329
|
sync: false,
|
|
243
1330
|
initiator: void 0,
|
|
244
1331
|
profile: void 0,
|
|
245
|
-
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
|
|
246
1345
|
};
|
|
247
1346
|
const positionals = [];
|
|
248
1347
|
let i = 0;
|
|
@@ -254,6 +1353,10 @@ function parse(argv) {
|
|
|
254
1353
|
try {
|
|
255
1354
|
for (; i < argv.length; i++) {
|
|
256
1355
|
const arg = argv[i] ?? "";
|
|
1356
|
+
if (arg === "--") {
|
|
1357
|
+
options.runArgv = argv.slice(i + 1);
|
|
1358
|
+
break;
|
|
1359
|
+
}
|
|
257
1360
|
switch (arg) {
|
|
258
1361
|
case "--state-dir":
|
|
259
1362
|
options.stateDir = flagValue(arg, true) ?? "";
|
|
@@ -267,6 +1370,10 @@ function parse(argv) {
|
|
|
267
1370
|
options.repoDir = flagValue(arg, true) ?? "";
|
|
268
1371
|
i++;
|
|
269
1372
|
break;
|
|
1373
|
+
case "--harness-root":
|
|
1374
|
+
options.harnessRoot = flagValue(arg, true) ?? "";
|
|
1375
|
+
i++;
|
|
1376
|
+
break;
|
|
270
1377
|
case "--max-age": {
|
|
271
1378
|
const raw = flagValue(arg, true);
|
|
272
1379
|
const n = Number(raw);
|
|
@@ -287,10 +1394,23 @@ function parse(argv) {
|
|
|
287
1394
|
options.command = flagValue(arg, true) ?? "";
|
|
288
1395
|
i++;
|
|
289
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;
|
|
290
1406
|
case "--message":
|
|
291
1407
|
options.message = flagValue(arg, true) ?? "";
|
|
292
1408
|
i++;
|
|
293
1409
|
break;
|
|
1410
|
+
case "--detail":
|
|
1411
|
+
options.detail = flagValue(arg, true);
|
|
1412
|
+
i++;
|
|
1413
|
+
break;
|
|
294
1414
|
case "--start":
|
|
295
1415
|
options.start = flagValue(arg, true) ?? "";
|
|
296
1416
|
i++;
|
|
@@ -327,6 +1447,14 @@ function parse(argv) {
|
|
|
327
1447
|
i++;
|
|
328
1448
|
break;
|
|
329
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
|
+
}
|
|
330
1458
|
case "--foreground":
|
|
331
1459
|
options.foreground = true;
|
|
332
1460
|
break;
|
|
@@ -346,6 +1474,58 @@ function parse(argv) {
|
|
|
346
1474
|
i++;
|
|
347
1475
|
break;
|
|
348
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;
|
|
349
1529
|
case "--rollback":
|
|
350
1530
|
options.rollback = true;
|
|
351
1531
|
break;
|
|
@@ -401,6 +1581,68 @@ async function sleep(ms) {
|
|
|
401
1581
|
setTimeout(resolve, ms);
|
|
402
1582
|
});
|
|
403
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
|
+
}
|
|
404
1646
|
/**
|
|
405
1647
|
* How the spawned watchdog should invoke the guard CLI: the built form runs
|
|
406
1648
|
* `node <cli>`; the source form needs tsx with an absolute path (the watchdog
|
|
@@ -441,6 +1683,23 @@ function exitAgentInvocation() {
|
|
|
441
1683
|
const DEFAULT_PREFLIGHT_TIMEOUT_MS = 12e4;
|
|
442
1684
|
/** A pending restart marker older than this is stale — its watchdog died mid-flow. */
|
|
443
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
|
+
}
|
|
444
1703
|
/**
|
|
445
1704
|
* The restart marker's state. Every verb that can stop the instance must
|
|
446
1705
|
* consult this (and the restart lock) — a stop right invisible to the other
|
|
@@ -449,24 +1708,8 @@ const RESTART_MARKER_TTL_MS = 9e5;
|
|
|
449
1708
|
function restartMarkerState(stateDir) {
|
|
450
1709
|
const file = stateFile(stateDir, "restartRequested");
|
|
451
1710
|
if (!existsSync(file)) return "none";
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
return typeof marker.requestedAt === "number" && Date.now() - marker.requestedAt <= RESTART_MARKER_TTL_MS ? "fresh" : "stale";
|
|
455
|
-
} catch {
|
|
456
|
-
return "stale";
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
/** The restart lock's live holder pid (as a string), or null when free/stale. */
|
|
460
|
-
function liveRestartLockHolder(stateDir) {
|
|
461
|
-
try {
|
|
462
|
-
const raw = readFileSync(stateFile(stateDir, "restartLock"), "utf8").trim();
|
|
463
|
-
const pid = Number(raw);
|
|
464
|
-
if (raw !== "" && Number.isInteger(pid) && pid > 0) try {
|
|
465
|
-
process.kill(pid, 0);
|
|
466
|
-
return raw;
|
|
467
|
-
} catch {}
|
|
468
|
-
} catch {}
|
|
469
|
-
return null;
|
|
1711
|
+
const marker = readRestartRequestMarker(stateDir);
|
|
1712
|
+
return marker?.requestedAt !== void 0 && Date.now() - marker.requestedAt <= RESTART_MARKER_TTL_MS ? "fresh" : "stale";
|
|
470
1713
|
}
|
|
471
1714
|
/** Captured preflight output is diagnostics, not a log — cap it before it can grow without bound. */
|
|
472
1715
|
const PREFLIGHT_OUTPUT_CAP = 65536;
|
|
@@ -499,11 +1742,12 @@ const preflightInternals = {
|
|
|
499
1742
|
};
|
|
500
1743
|
/**
|
|
501
1744
|
* The harness checkout the live instance boots from (and the preflight
|
|
502
|
-
* runner resolves the official published packages from): the
|
|
503
|
-
* 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.
|
|
504
1748
|
*/
|
|
505
|
-
function resolveHarnessRoot(
|
|
506
|
-
if (
|
|
1749
|
+
function resolveHarnessRoot(optionHarnessRoot, env = process.env) {
|
|
1750
|
+
if (optionHarnessRoot !== void 0 && optionHarnessRoot !== "") return optionHarnessRoot;
|
|
507
1751
|
const fromEnv = env.DSH_HARNESS;
|
|
508
1752
|
return fromEnv !== void 0 && fromEnv.trim() !== "" ? fromEnv : join(homedir(), "code/deepseek-harness");
|
|
509
1753
|
}
|
|
@@ -519,7 +1763,69 @@ function resolveRunnerCommand(harnessRoot) {
|
|
|
519
1763
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
520
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;
|
|
521
1765
|
if (runner === void 0) return void 0;
|
|
522
|
-
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
|
+
};
|
|
523
1829
|
}
|
|
524
1830
|
/** POSIX single-quote one word for the shell command line. */
|
|
525
1831
|
function shellQuote(word) {
|
|
@@ -565,13 +1871,57 @@ function redactLaunchCommand(command) {
|
|
|
565
1871
|
* @param profile - the dsh profile to dry-run.
|
|
566
1872
|
* @param timeoutMs - bound on the whole subprocess run; a timeout kills it.
|
|
567
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.
|
|
568
1875
|
* @returns the classified outcome.
|
|
569
1876
|
*/
|
|
570
|
-
async function runPreflightCheck(profile, timeoutMs, harnessRoot) {
|
|
571
|
-
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;
|
|
572
1879
|
let command;
|
|
1880
|
+
let executable;
|
|
1881
|
+
let argv = [];
|
|
573
1882
|
let usingRunner = false;
|
|
574
|
-
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;
|
|
575
1925
|
else {
|
|
576
1926
|
const root = harnessRoot ?? resolveHarnessRoot(void 0);
|
|
577
1927
|
const runner = preflightInternals.resolveRunner(root);
|
|
@@ -591,13 +1941,19 @@ async function runPreflightCheck(profile, timeoutMs, harnessRoot) {
|
|
|
591
1941
|
return await new Promise((resolvePromise) => {
|
|
592
1942
|
let output = "";
|
|
593
1943
|
let timedOut = false;
|
|
594
|
-
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 ?? "", {
|
|
595
1950
|
shell: true,
|
|
596
|
-
env:
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
})
|
|
1951
|
+
env: preflightEnv
|
|
1952
|
+
}) : spawn(executable, argv, {
|
|
1953
|
+
shell: false,
|
|
1954
|
+
env: preflightEnv
|
|
1955
|
+
});
|
|
1956
|
+
registerSpawnedTestProcess(child, "composition-preflight", { ...home === void 0 ? {} : { tempRoot: home } });
|
|
601
1957
|
const append = (chunk) => {
|
|
602
1958
|
if (output.length < PREFLIGHT_OUTPUT_CAP) output += chunk.toString("utf8");
|
|
603
1959
|
};
|
|
@@ -639,6 +1995,61 @@ async function runPreflightCheck(profile, timeoutMs, harnessRoot) {
|
|
|
639
1995
|
});
|
|
640
1996
|
});
|
|
641
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
|
+
}
|
|
642
2053
|
/** The profile a gated verb dry-runs: the flag, then $DSH_PROFILE, then the deployment default. */
|
|
643
2054
|
function resolveProfileName(options) {
|
|
644
2055
|
const flag = options.profile ?? "";
|
|
@@ -658,10 +2069,73 @@ function resolveWdHome(optionHome, env = process.env) {
|
|
|
658
2069
|
const fromEnv = env.DSH_HOME;
|
|
659
2070
|
return fromEnv !== void 0 && fromEnv !== "" ? fromEnv : void 0;
|
|
660
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");
|
|
2134
|
+
}
|
|
661
2135
|
/** The first ~40 lines of captured preflight output, newline-terminated, or empty. */
|
|
662
2136
|
function summarizeOutput(output) {
|
|
663
2137
|
if (output.trim() === "") return "";
|
|
664
|
-
const lines = output.split("\n");
|
|
2138
|
+
const lines = output.replace(/([?&](?:token|grant)=)[^\s&#"']+/gi, "$1<redacted>").split("\n");
|
|
665
2139
|
return `${(lines.length > 41 ? [...lines.slice(0, 40), `… (${lines.length - 40} more lines)`] : lines).join("\n").replace(/\n+$/, "")}\n`;
|
|
666
2140
|
}
|
|
667
2141
|
/**
|
|
@@ -673,10 +2147,12 @@ function summarizeOutput(output) {
|
|
|
673
2147
|
* @param timeoutMs - bound on the preflight subprocess.
|
|
674
2148
|
* @param io - output sinks.
|
|
675
2149
|
* @param harnessRoot - harness checkout for the standalone runner.
|
|
2150
|
+
* @param home - dsh home to dry-run.
|
|
676
2151
|
* @returns whether the verb may proceed.
|
|
677
2152
|
*/
|
|
678
|
-
async function preflightGate(verb, profile, timeoutMs, io, harnessRoot) {
|
|
679
|
-
|
|
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);
|
|
680
2156
|
switch (outcome.kind) {
|
|
681
2157
|
case "pass":
|
|
682
2158
|
io.stdout(`composition preflight PASS (profile ${JSON.stringify(profile)})\n`);
|
|
@@ -692,6 +2168,54 @@ async function preflightGate(verb, profile, timeoutMs, io, harnessRoot) {
|
|
|
692
2168
|
return false;
|
|
693
2169
|
}
|
|
694
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
|
+
}
|
|
695
2219
|
/**
|
|
696
2220
|
* Wait for a pid to exit; SIGKILL (the whole descendant tree) after the
|
|
697
2221
|
* deadline. @param onEscalate - invoked right before the SIGKILL, so the
|
|
@@ -745,6 +2269,29 @@ function rollbackToKnownGood(stateDir, repoDir, io) {
|
|
|
745
2269
|
* @returns the process exit code: 0 ok, 1 gate denied / failure, 2 usage error.
|
|
746
2270
|
*/
|
|
747
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
|
+
};
|
|
748
2295
|
const parsed = parse(argv);
|
|
749
2296
|
if ("error" in parsed) {
|
|
750
2297
|
io.stderr(parsed.error);
|
|
@@ -755,7 +2302,8 @@ async function runCli(argv, io) {
|
|
|
755
2302
|
const repoDir = resolveRepoDir(options.repoDir);
|
|
756
2303
|
switch (command) {
|
|
757
2304
|
case "verify": {
|
|
758
|
-
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);
|
|
759
2307
|
io.stdout(`${result.reason}\n`);
|
|
760
2308
|
if (result.ok) {
|
|
761
2309
|
io.stdout(FULL_ACCESS_HINT);
|
|
@@ -769,17 +2317,65 @@ async function runCli(argv, io) {
|
|
|
769
2317
|
io.stderr(`record requires a <scope>\n\n${USAGE}`);
|
|
770
2318
|
return 2;
|
|
771
2319
|
}
|
|
772
|
-
|
|
773
|
-
|
|
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) {
|
|
774
2350
|
io.stderr("cannot record a credential outside a git repository\n");
|
|
775
2351
|
return 1;
|
|
776
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
|
+
}
|
|
777
2373
|
recordCredential(stateDir, {
|
|
778
2374
|
scope,
|
|
779
|
-
revision:
|
|
780
|
-
command:
|
|
2375
|
+
revision: headBefore,
|
|
2376
|
+
command: evidenceCommand
|
|
781
2377
|
}, Date.now());
|
|
782
|
-
io.stdout(`recorded green credential: ${scope} @ ${
|
|
2378
|
+
io.stdout(`recorded green credential: ${scope} @ ${headBefore}${options.trustCommand ? " (external proof trusted)" : ""}\n`);
|
|
783
2379
|
io.stdout(FULL_ACCESS_HINT);
|
|
784
2380
|
if (liveWatchdogPid(stateDir) === null) io.stderr(NO_WATCHDOG_HINT);
|
|
785
2381
|
return 0;
|
|
@@ -789,13 +2385,190 @@ async function runCli(argv, io) {
|
|
|
789
2385
|
io.stdout(`${JSON.stringify(state, null, 2)}\n`);
|
|
790
2386
|
return 0;
|
|
791
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
|
+
}
|
|
792
2563
|
case "clear":
|
|
793
2564
|
clearCredential(stateDir, Date.now());
|
|
794
2565
|
io.stdout("credential cleared\n");
|
|
795
2566
|
return 0;
|
|
796
2567
|
case "checkpoint": {
|
|
797
2568
|
const message = options.message ?? "batch snapshot";
|
|
798
|
-
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);
|
|
799
2572
|
if (!result.ok) {
|
|
800
2573
|
io.stderr(`${result.error}\n`);
|
|
801
2574
|
return 1;
|
|
@@ -804,7 +2577,7 @@ async function runCli(argv, io) {
|
|
|
804
2577
|
revision: result.sha,
|
|
805
2578
|
message
|
|
806
2579
|
}, Date.now());
|
|
807
|
-
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`);
|
|
808
2581
|
if (result.artifacts.length > 0) {
|
|
809
2582
|
io.stdout(`warning: ${result.artifacts.length} build-artifact-looking file(s) swept in (bare tsc emission? real build output belongs in lib/):\n`);
|
|
810
2583
|
for (const file of result.artifacts.slice(0, 5)) io.stdout(` ${file}\n`);
|
|
@@ -827,7 +2600,7 @@ async function runCli(argv, io) {
|
|
|
827
2600
|
return 0;
|
|
828
2601
|
}
|
|
829
2602
|
case "canary": {
|
|
830
|
-
const verdict =
|
|
2603
|
+
const verdict = verifyRepoCredential(stateDir, repoDir, options.maxAgeMinutes);
|
|
831
2604
|
io.stdout(`verify: ${verdict.ok ? "PASS" : "FAIL"} — ${verdict.reason}\n`);
|
|
832
2605
|
let ok = verdict.ok;
|
|
833
2606
|
if (options.port !== void 0) {
|
|
@@ -838,8 +2611,61 @@ async function runCli(argv, io) {
|
|
|
838
2611
|
io.stdout(ok ? "canary PASS\n" : "canary FAIL\n");
|
|
839
2612
|
return ok ? 0 : 1;
|
|
840
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
|
+
}
|
|
841
2659
|
case "preflight": {
|
|
842
|
-
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);
|
|
843
2669
|
if (outcome.kind === "unavailable") {
|
|
844
2670
|
io.stderr("preflight unavailable outside the dsh app layout\n");
|
|
845
2671
|
return 3;
|
|
@@ -859,6 +2685,11 @@ async function runCli(argv, io) {
|
|
|
859
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");
|
|
860
2686
|
return 0;
|
|
861
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
|
+
}
|
|
862
2693
|
case "check-env": {
|
|
863
2694
|
const sandboxed = envInternals.sandboxedByProbe();
|
|
864
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`);
|
|
@@ -897,6 +2728,188 @@ async function runCli(argv, io) {
|
|
|
897
2728
|
io.stdout(`git repo: ${currentHead(repoDir) !== null ? `yes (${repoDir})` : `no (${repoDir}) — git init + initial commit before record`}\n`);
|
|
898
2729
|
return sandboxed ? 1 : 0;
|
|
899
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
|
+
}
|
|
900
2913
|
case "restart": {
|
|
901
2914
|
const port = options.port ?? readInstanceLaunch(stateDir)?.port;
|
|
902
2915
|
if (port === void 0) {
|
|
@@ -905,21 +2918,22 @@ async function runCli(argv, io) {
|
|
|
905
2918
|
}
|
|
906
2919
|
const start = resolveStartCommand(options.start, stateDir, "restart", io, port);
|
|
907
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`);
|
|
908
2923
|
const isDriver = process.env.DSH_ANKH_RESTART_DRIVER === "1";
|
|
909
|
-
const gate =
|
|
910
|
-
if (!gate.ok) {
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
if (!sandboxGate("restart", options, io)) return 1;
|
|
915
|
-
if (!isDriver && !await preflightGate("restart", resolveProfileName(options), options.preflightTimeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS, io, resolveHarnessRoot(options.repoDir))) return 1;
|
|
916
|
-
if (restartMarkerState(stateDir) === "fresh") {
|
|
917
|
-
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");
|
|
918
|
-
return 1;
|
|
919
|
-
}
|
|
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");
|
|
920
2929
|
if (options.sync !== true && !isDriver) {
|
|
921
2930
|
const logPath = options.log ?? stateFile(stateDir, "restartLog");
|
|
922
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;
|
|
923
2937
|
const driver = spawn(process.execPath, cliInvocation(argv), {
|
|
924
2938
|
detached: true,
|
|
925
2939
|
stdio: [
|
|
@@ -927,23 +2941,23 @@ async function runCli(argv, io) {
|
|
|
927
2941
|
openSync(logPath, "a"),
|
|
928
2942
|
openSync(logPath, "a")
|
|
929
2943
|
],
|
|
930
|
-
env: {
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
}
|
|
2944
|
+
env: testChildEnv("restart-driver", restartDriverEnv, {
|
|
2945
|
+
port,
|
|
2946
|
+
tempRoot: stateDir
|
|
2947
|
+
})
|
|
2948
|
+
});
|
|
2949
|
+
registerSpawnedTestProcess(driver, "restart-driver", {
|
|
2950
|
+
port,
|
|
2951
|
+
tempRoot: stateDir
|
|
934
2952
|
});
|
|
935
2953
|
driver.unref();
|
|
936
|
-
if (driver.pid === void 0)
|
|
937
|
-
io.stderr("restart refused: could not detach the restart driver\n");
|
|
938
|
-
return 1;
|
|
939
|
-
}
|
|
2954
|
+
if (driver.pid === void 0) return refuse("spawn", "restart refused: could not detach the restart driver\n");
|
|
940
2955
|
const lock = acquireRestartLock(stateDir, driver.pid);
|
|
941
2956
|
if (!lock.ok) {
|
|
942
2957
|
try {
|
|
943
2958
|
process.kill(driver.pid, "SIGKILL");
|
|
944
2959
|
} catch {}
|
|
945
|
-
|
|
946
|
-
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`);
|
|
947
2961
|
}
|
|
948
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`);
|
|
949
2963
|
return 0;
|
|
@@ -952,7 +2966,7 @@ async function runCli(argv, io) {
|
|
|
952
2966
|
if (options.sync === true) {
|
|
953
2967
|
const lock = acquireRestartLock(stateDir);
|
|
954
2968
|
if (!lock.ok) {
|
|
955
|
-
|
|
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`);
|
|
956
2970
|
return 1;
|
|
957
2971
|
}
|
|
958
2972
|
restartLock = lock;
|
|
@@ -982,12 +2996,21 @@ async function runCli(argv, io) {
|
|
|
982
2996
|
const stoppedAt = Date.now();
|
|
983
2997
|
const startEnv = { ...process.env };
|
|
984
2998
|
delete startEnv.DSH_ANKH_RESTART_DRIVER;
|
|
985
|
-
|
|
2999
|
+
for (const key of Object.keys(startEnv)) if (key.startsWith("WD_")) delete startEnv[key];
|
|
3000
|
+
const child = spawn(start, {
|
|
986
3001
|
shell: true,
|
|
987
3002
|
detached: true,
|
|
988
3003
|
stdio: "ignore",
|
|
989
|
-
env: startEnv
|
|
990
|
-
|
|
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();
|
|
991
3014
|
io.stdout(`started: ${start}\n`);
|
|
992
3015
|
const timeoutMs = options.timeoutMs ?? 6e4;
|
|
993
3016
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -999,7 +3022,7 @@ async function runCli(argv, io) {
|
|
|
999
3022
|
}
|
|
1000
3023
|
await sleep(500);
|
|
1001
3024
|
}
|
|
1002
|
-
const initiator = options.initiator
|
|
3025
|
+
const initiator = resolveInitiator(options.initiator, io);
|
|
1003
3026
|
if (!listening) {
|
|
1004
3027
|
io.stderr(`new instance not listening on 127.0.0.1:${port} within ${timeoutMs}ms\n`);
|
|
1005
3028
|
writeRestartOutcome(stateDir, {
|
|
@@ -1011,7 +3034,7 @@ async function runCli(argv, io) {
|
|
|
1011
3034
|
if (options.rollback) rollbackToKnownGood(stateDir, repoDir, io);
|
|
1012
3035
|
return 1;
|
|
1013
3036
|
}
|
|
1014
|
-
const post =
|
|
3037
|
+
const post = verifyRepoCredential(stateDir, repoDir, options.maxAgeMinutes);
|
|
1015
3038
|
io.stdout(`canary verify: ${post.ok ? "PASS" : "FAIL"} — ${post.reason}\n`);
|
|
1016
3039
|
io.stdout(`canary port: PASS — listening on 127.0.0.1:${port}\n`);
|
|
1017
3040
|
if (!post.ok) {
|
|
@@ -1037,27 +3060,28 @@ async function runCli(argv, io) {
|
|
|
1037
3060
|
}
|
|
1038
3061
|
}
|
|
1039
3062
|
case "supervise": {
|
|
1040
|
-
|
|
1041
|
-
if (
|
|
1042
|
-
|
|
1043
|
-
|
|
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;
|
|
1044
3069
|
}
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
command: start,
|
|
1049
|
-
source: "supervisor",
|
|
1050
|
-
supervised: true,
|
|
1051
|
-
...port !== void 0 ? { port } : {},
|
|
1052
|
-
recordedAt: Date.now()
|
|
1053
|
-
});
|
|
1054
|
-
const wdHome = resolveWdHome(options.home);
|
|
1055
|
-
if (wdHome === void 0) {
|
|
1056
|
-
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");
|
|
1057
|
-
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;
|
|
1058
3073
|
}
|
|
1059
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
|
+
}
|
|
1060
3083
|
const pidfile = stateFile(stateDir, "watchdogPid");
|
|
3084
|
+
let waitedForWatchdog = false;
|
|
1061
3085
|
if (existsSync(pidfile)) {
|
|
1062
3086
|
const existing = readFileSync(pidfile, "utf8").trim();
|
|
1063
3087
|
const existingPid = Number(existing);
|
|
@@ -1069,24 +3093,61 @@ async function runCli(argv, io) {
|
|
|
1069
3093
|
existingAlive = false;
|
|
1070
3094
|
}
|
|
1071
3095
|
if (existingAlive) {
|
|
1072
|
-
if (options.
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
process.kill(existingPid, 0);
|
|
1077
|
-
} catch {
|
|
1078
|
-
break;
|
|
1079
|
-
}
|
|
1080
|
-
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;
|
|
1081
3100
|
}
|
|
1082
|
-
io.stdout(`watchdog ${existing} exited — taking over\n`);
|
|
1083
3101
|
} else {
|
|
1084
|
-
|
|
1085
|
-
|
|
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
|
+
}
|
|
1086
3122
|
}
|
|
1087
3123
|
}
|
|
1088
3124
|
}
|
|
1089
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
|
+
}
|
|
1090
3151
|
const watchdog = fileURLToPath(new URL("../scripts/dsh-watchdog.sh", import.meta.url));
|
|
1091
3152
|
if (!existsSync(watchdog)) {
|
|
1092
3153
|
io.stderr(`watchdog script not found at ${watchdog}\n`);
|
|
@@ -1096,23 +3157,67 @@ async function runCli(argv, io) {
|
|
|
1096
3157
|
io.stderr("supervise: --log has no effect with --foreground — output follows the external supervisor's redirection (launchd StandardOutPath / systemd StandardOutput=); drop --log\n");
|
|
1097
3158
|
return 2;
|
|
1098
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];
|
|
1099
3173
|
const env = {
|
|
1100
|
-
...
|
|
1101
|
-
WD_PORT: String(port),
|
|
1102
|
-
WD_HOME:
|
|
3174
|
+
...supervisorBaseEnv,
|
|
3175
|
+
WD_PORT: String(spec.port),
|
|
3176
|
+
WD_HOME: spec.home,
|
|
1103
3177
|
WD_STATE_DIR: stateDir,
|
|
1104
|
-
WD_REPO:
|
|
1105
|
-
|
|
3178
|
+
WD_REPO: spec.credentialRepo,
|
|
3179
|
+
WD_HARNESS_ROOT: spec.harnessRoot,
|
|
3180
|
+
WD_START: spec.command,
|
|
1106
3181
|
DSH_ANKH_SUPERVISED: "1",
|
|
1107
|
-
WD_INITIATOR: process.env.DSH_SESSION_ID ?? "",
|
|
1108
|
-
WD_ADOPTION: findPidOnPort(port) !== null ? "1" : "0",
|
|
1109
|
-
|
|
1110
|
-
|
|
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
|
+
} : {}
|
|
1111
3209
|
};
|
|
1112
3210
|
if (options.foreground) {
|
|
1113
3211
|
const child = spawn("bash", [watchdog, "--supervise"], {
|
|
1114
3212
|
stdio: "inherit",
|
|
1115
|
-
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
|
|
1116
3221
|
});
|
|
1117
3222
|
return await new Promise((resolve) => {
|
|
1118
3223
|
child.on("exit", (c) => {
|
|
@@ -1129,81 +3234,150 @@ async function runCli(argv, io) {
|
|
|
1129
3234
|
openSync(logPath, "a"),
|
|
1130
3235
|
openSync(logPath, "a")
|
|
1131
3236
|
],
|
|
1132
|
-
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;
|
|
1133
3249
|
});
|
|
1134
3250
|
child.unref();
|
|
1135
|
-
|
|
1136
|
-
|
|
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;
|
|
1137
3278
|
}
|
|
1138
3279
|
case "schedule-exit": {
|
|
1139
|
-
const port = options.port ?? readInstanceLaunch(stateDir)?.port;
|
|
1140
3280
|
const delayMs = options.delayMs;
|
|
1141
|
-
if (
|
|
1142
|
-
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}`);
|
|
1143
3295
|
return 2;
|
|
1144
3296
|
}
|
|
1145
|
-
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);
|
|
1146
3305
|
if (!gate.ok) {
|
|
1147
3306
|
io.stderr(`schedule-exit refused: ${gate.reason}\n`);
|
|
1148
3307
|
return 1;
|
|
1149
3308
|
}
|
|
3309
|
+
io.stdout(`restart evidence PASS — ${gate.reason}\n`);
|
|
1150
3310
|
if (!sandboxGate("schedule-exit", options, io)) return 1;
|
|
1151
|
-
if (!await preflightGate("schedule-exit",
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
if (markerState === "fresh") {
|
|
1156
|
-
io.stderr("schedule-exit refused: a restart is already scheduled (restart-requested.json still pending); a stale marker expires on its own after 15 minutes\n");
|
|
1157
|
-
return 1;
|
|
1158
|
-
}
|
|
1159
|
-
if (markerState === "stale" && existsSync(markerFile)) io.stderr("warning: overwriting a stale restart marker (a previous schedule never completed)\n");
|
|
1160
|
-
const inFlight = liveRestartLockHolder(stateDir);
|
|
1161
|
-
if (inFlight !== null) {
|
|
1162
|
-
io.stderr(`schedule-exit refused: a restart is in flight (pid ${inFlight}) — the exit agent would kill the instance it is starting\n`);
|
|
3311
|
+
if (!await preflightGate("schedule-exit", profile, options.preflightTimeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS, io, harnessRoot, durableSpec?.home, durableSpec?.preflight)) return 1;
|
|
3312
|
+
const lock = acquireRestartLock(stateDir);
|
|
3313
|
+
if (!lock.ok) {
|
|
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`);
|
|
1163
3315
|
return 1;
|
|
1164
3316
|
}
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
})}\n`);
|
|
1172
|
-
const resultFile = stateFile(stateDir, "lastRestart");
|
|
1173
|
-
const logPath = options.log ?? stateFile(stateDir, "scheduleExitLog");
|
|
1174
|
-
mkdirSync(dirname(logPath), { recursive: true });
|
|
1175
|
-
const child = spawn(process.execPath, exitAgentInvocation(), {
|
|
1176
|
-
detached: true,
|
|
1177
|
-
stdio: [
|
|
1178
|
-
"ignore",
|
|
1179
|
-
openSync(logPath, "a"),
|
|
1180
|
-
openSync(logPath, "a")
|
|
1181
|
-
],
|
|
1182
|
-
env: {
|
|
1183
|
-
...process.env,
|
|
1184
|
-
WD_PORT: String(port),
|
|
1185
|
-
WD_DELAY_MS: String(delayMs),
|
|
1186
|
-
WD_RESULT_FILE: resultFile,
|
|
1187
|
-
...initiator !== void 0 ? { WD_INITIATOR: initiator } : {}
|
|
3317
|
+
try {
|
|
3318
|
+
const markerFile = stateFile(stateDir, "restartRequested");
|
|
3319
|
+
const markerState = restartMarkerState(stateDir);
|
|
3320
|
+
if (markerState === "fresh") {
|
|
3321
|
+
io.stderr("schedule-exit refused: a restart is already scheduled (restart-requested.json still pending); a stale marker expires on its own after 15 minutes\n");
|
|
3322
|
+
return 1;
|
|
1188
3323
|
}
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
3324
|
+
if (markerState === "stale" && existsSync(markerFile)) io.stderr("warning: overwriting a stale restart marker (a previous schedule never completed)\n");
|
|
3325
|
+
const initiator = resolveInitiator(options.initiator, io);
|
|
3326
|
+
mkdirSync(stateDir, { recursive: true });
|
|
3327
|
+
writeFileSync(stateFile(stateDir, "restartRequested"), `${JSON.stringify({
|
|
3328
|
+
reason: "scheduled self-restart",
|
|
3329
|
+
requestedAt: Date.now(),
|
|
3330
|
+
...gate.authorization === void 0 ? {} : { authorization: gate.authorization },
|
|
3331
|
+
...initiator !== void 0 ? { initiator } : {}
|
|
3332
|
+
})}\n`);
|
|
3333
|
+
const resultFile = stateFile(stateDir, "lastRestart");
|
|
3334
|
+
const logPath = options.log ?? stateFile(stateDir, "scheduleExitLog");
|
|
3335
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
3336
|
+
const child = spawn(process.execPath, exitAgentInvocation(), {
|
|
3337
|
+
detached: true,
|
|
3338
|
+
stdio: [
|
|
3339
|
+
"ignore",
|
|
3340
|
+
openSync(logPath, "a"),
|
|
3341
|
+
openSync(logPath, "a")
|
|
3342
|
+
],
|
|
3343
|
+
env: testChildEnv("schedule-exit-agent", {
|
|
3344
|
+
...process.env,
|
|
3345
|
+
WD_PORT: String(port),
|
|
3346
|
+
WD_DELAY_MS: String(delayMs),
|
|
3347
|
+
WD_RESULT_FILE: resultFile,
|
|
3348
|
+
...initiator !== void 0 ? { WD_INITIATOR: initiator } : {}
|
|
3349
|
+
}, {
|
|
3350
|
+
port,
|
|
3351
|
+
tempRoot: stateDir
|
|
3352
|
+
})
|
|
3353
|
+
});
|
|
3354
|
+
registerSpawnedTestProcess(child, "schedule-exit-agent", {
|
|
3355
|
+
port,
|
|
3356
|
+
tempRoot: stateDir
|
|
3357
|
+
});
|
|
3358
|
+
child.unref();
|
|
3359
|
+
io.stdout(`exit scheduled in ${delayMs} ms (exit-agent pid ${child.pid ?? "unknown"}) — watchdog will respawn and run the canary\n`);
|
|
3360
|
+
return 0;
|
|
3361
|
+
} finally {
|
|
3362
|
+
lock.release();
|
|
3363
|
+
}
|
|
1193
3364
|
}
|
|
1194
3365
|
default:
|
|
1195
3366
|
io.stderr(`unknown command ${command}\n\n${USAGE}`);
|
|
1196
3367
|
return 2;
|
|
1197
3368
|
}
|
|
1198
3369
|
}
|
|
1199
|
-
if (isDirectInvocation(import.meta.url))
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
}).
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
});
|
|
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
|
+
}
|
|
1208
3382
|
//#endregion
|
|
1209
3383
|
export { envInternals, parse, preflightInternals, resolveHarnessRoot, resolvePreflightBin, resolveRunnerCommand, resolveWdHome, runCli, runPreflightCheck };
|