@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.en.md +75 -29
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +74 -29
  5. package/lib/cli.js +2383 -209
  6. package/lib/client.js +257 -0
  7. package/lib/exit-agent.js +5 -2
  8. package/lib/index.js +722 -38
  9. package/lib/invariant.js +1 -1
  10. package/lib/preflight-runner.js +125 -47
  11. package/lib/processes-BjZgJjQr.js +344 -0
  12. package/lib/restart-context-D6nISh28.js +1245 -0
  13. package/lib/restart-context-DUyExi9O.js +1245 -0
  14. package/lib/{state-Dhx9VG44.js → state-4f7yny39.js} +60 -13
  15. package/lib/state-CZMypGkB.js +323 -0
  16. package/lib/test-seam-DnvLWTeO.js +119 -0
  17. package/lib/test-seam-cli.js +24 -0
  18. package/lib/test-seam-dwvaKjRp.js +459 -0
  19. package/lib/test-seam.js +2 -0
  20. package/lib/types/browser-handoff.d.ts +55 -0
  21. package/lib/types/browser-handoff.js +489 -0
  22. package/lib/types/cli.d.ts +34 -4
  23. package/lib/types/cli.js +1487 -225
  24. package/lib/types/client/index.d.ts +15 -0
  25. package/lib/types/client/index.js +264 -0
  26. package/lib/types/deployment-proof.d.ts +24 -0
  27. package/lib/types/deployment-proof.js +314 -0
  28. package/lib/types/exit-agent.js +2 -0
  29. package/lib/types/git.d.ts +12 -3
  30. package/lib/types/git.js +69 -7
  31. package/lib/types/index.d.ts +66 -3
  32. package/lib/types/index.js +157 -39
  33. package/lib/types/launch-spec.d.ts +263 -0
  34. package/lib/types/launch-spec.js +823 -0
  35. package/lib/types/preflight-runner.d.ts +23 -12
  36. package/lib/types/preflight-runner.js +152 -57
  37. package/lib/types/processes.d.ts +38 -6
  38. package/lib/types/processes.js +236 -10
  39. package/lib/types/restart-context.d.ts +50 -0
  40. package/lib/types/restart-context.js +106 -0
  41. package/lib/types/restart-request.d.ts +32 -0
  42. package/lib/types/restart-request.js +128 -0
  43. package/lib/types/state-files.d.ts +30 -0
  44. package/lib/types/state-files.js +55 -0
  45. package/lib/types/state.d.ts +29 -2
  46. package/lib/types/state.js +52 -7
  47. package/lib/types/temp-artifact.d.ts +15 -0
  48. package/lib/types/temp-artifact.js +17 -0
  49. package/lib/types/test-seam-cli.d.ts +3 -0
  50. package/lib/types/test-seam-cli.js +27 -0
  51. package/lib/types/test-seam.d.ts +55 -0
  52. package/lib/types/test-seam.js +112 -0
  53. package/lib/types/transition.d.ts +118 -0
  54. package/lib/types/transition.js +717 -0
  55. package/package.json +29 -9
  56. package/scripts/dsh-watchdog.sh +1388 -80
  57. package/scripts/install-launchd.sh +43 -5
  58. package/scripts/install-systemd.sh +43 -5
  59. package/scripts/on-install.js +1 -1
  60. package/skills/dsh-self-restart-guard/SKILL.md +38 -12
  61. package/lib/processes-hCAmwma-.js +0 -127
  62. 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 writeUnexpectedExitRecord, b as resetToCheckpoint, d as writeAdoptionRecord, h as writeRestartOutcome, l as readSkillRegistration, p as writeInstanceLaunchAsSupervisor, s as readInstanceLaunch, v as commitCheckpoint, y as currentHead } from "./restart-context-DmnQXNf-.js";
4
- import { c as stateFile, i as setCheckpoint, n as loadState, o as verifyCredential, r as recordCredential, s as lastGoodBootRevision, t as clearCredential } from "./state-Dhx9VG44.js";
5
- import { n as findPidOnPort, r as killPidTree, t as discoverLaunchCommand } from "./processes-hCAmwma-.js";
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 { existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
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 — commit the whole tree as a pre-batch snapshot
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 (by verify/record, and as a refusal-grade warning in schedule-exit)
41
- * while no watchdog supervises the instance: a bare exit now leaves the
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
- /** The live supervising watchdog's pid, or null when none is (pidfile + kill 0). */
78
- function liveWatchdogPid(stateDir) {
79
- try {
80
- const raw = readFileSync(stateFile(stateDir, "watchdogPid"), "utf8").trim();
81
- const pid = Number(raw);
82
- if (raw !== "" && Number.isInteger(pid) && pid > 0) {
83
- process.kill(pid, 0);
84
- return pid;
85
- }
86
- } catch {}
87
- return null;
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
- const pid = Number(holder);
120
- if (holder !== "" && Number.isInteger(pid) && pid > 0) try {
121
- process.kill(pid, 0);
122
- return {
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> [--command CMD] [--state-dir DIR] [--repo DIR]
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
- --command CMD record: the command that produced the green state
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
- --start "CMD" restart/supervise: the shell command that starts the instance
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
- --profile NAME preflight/schedule-exit/restart: the dsh profile to dry-run (default:
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
- --force restart/schedule-exit/supervise: override the sandbox probe refusal
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
- try {
453
- const marker = JSON.parse(readFileSync(file, "utf8"));
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 `--repo` target
503
- * when given, else `DSH_HARNESS`, else the conventional default.
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(optionRepoDir, env = process.env) {
506
- if (optionRepoDir !== void 0 && optionRepoDir !== "") return optionRepoDir;
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 (override !== void 0 && override !== "") command = override;
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 child = usingRunner ? spawn(command, {
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
- ...process.env,
598
- DSH_HARNESS: harnessForRunner
599
- }
600
- }) : spawn(command, { shell: true });
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
- const outcome = await runPreflightCheck(profile, timeoutMs, harnessRoot);
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 result = verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), options.maxAgeMinutes);
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
- const head = currentHead(repoDir);
773
- if (head === null) {
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: head,
780
- command: options.command ?? ""
2375
+ revision: headBefore,
2376
+ command: evidenceCommand
781
2377
  }, Date.now());
782
- io.stdout(`recorded green credential: ${scope} @ ${head}\n`);
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 result = commitCheckpoint(repoDir, `dsh-ankh-guard checkpoint: ${message}`, SRC_ARTIFACT_PATTERN);
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 = verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), options.maxAgeMinutes);
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 outcome = await runPreflightCheck(resolveProfileName(options), options.timeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS, resolveHarnessRoot(options.repoDir));
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 = verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), options.maxAgeMinutes);
910
- if (!gate.ok) {
911
- io.stderr(`restart refused: ${gate.reason}\n`);
912
- return 1;
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
- ...process.env,
932
- DSH_ANKH_RESTART_DRIVER: "1"
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
- io.stderr(/^\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`);
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
- io.stderr(/^\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`);
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
- spawn(start, {
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
- }).unref();
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 ?? process.env.DSH_SESSION_ID;
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 = verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), options.maxAgeMinutes);
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
- const port = options.port;
1041
- if (port === void 0) {
1042
- io.stderr(`supervise requires --port N and --start "CMD"\n\n${USAGE}`);
1043
- return 2;
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
- const start = resolveStartCommand(options.start, stateDir, "supervise", io, port);
1046
- if (start === void 0) return 2;
1047
- writeInstanceLaunchAsSupervisor(stateDir, {
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.foreground) {
1073
- io.stdout(`watchdog ${existing} already supervises the port — waiting for it to exit, then taking over (foreground)\n`);
1074
- while (true) {
1075
- try {
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
- io.stdout(`already supervised by pid ${existing}\n`);
1085
- return 0;
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
- ...process.env,
1101
- WD_PORT: String(port),
1102
- WD_HOME: wdHome,
3174
+ ...supervisorBaseEnv,
3175
+ WD_PORT: String(spec.port),
3176
+ WD_HOME: spec.home,
1103
3177
  WD_STATE_DIR: stateDir,
1104
- WD_REPO: repoDir,
1105
- WD_START: start,
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
- WD_WAIT_OWNER: options.foreground ? "0" : "1",
1110
- WD_GUARD: guardInvocation()
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
- io.stdout(`watchdog spawned (pid ${child.pid ?? "unknown"}) — supervises :${port}, log ${logPath}\n`);
1136
- return 0;
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 (port === void 0 || delayMs === void 0) {
1142
- io.stderr(`schedule-exit requires --port N and --delay-ms MS\n\n${USAGE}`);
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 gate = verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), options.maxAgeMinutes);
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", resolveProfileName(options), options.preflightTimeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS, io, resolveHarnessRoot(options.repoDir))) return 1;
1152
- if (liveWatchdogPid(stateDir) === null) io.stderr(NO_WATCHDOG_HINT);
1153
- const markerFile = stateFile(stateDir, "restartRequested");
1154
- const markerState = restartMarkerState(stateDir);
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
- const initiator = options.initiator ?? process.env.DSH_SESSION_ID;
1166
- mkdirSync(stateDir, { recursive: true });
1167
- writeFileSync(stateFile(stateDir, "restartRequested"), `${JSON.stringify({
1168
- reason: "scheduled self-restart",
1169
- requestedAt: Date.now(),
1170
- ...initiator !== void 0 ? { initiator } : {}
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
- child.unref();
1191
- io.stdout(`exit scheduled in ${delayMs} ms (agent pid ${child.pid ?? "unknown"}) — watchdog will respawn and run the canary\n`);
1192
- return 0;
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)) runCli(process.argv.slice(2), {
1200
- stdout: (line) => process.stdout.write(line),
1201
- stderr: (line) => process.stderr.write(line)
1202
- }).then((code) => {
1203
- process.exitCode = code;
1204
- }).catch((error) => {
1205
- process.stderr.write(`ankh-guard: ${String(error)}\n`);
1206
- process.exitCode = 1;
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 };