@khorsheed/dsh-ankh-guard 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +33 -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 +2349 -182
  6. package/lib/client.js +344 -0
  7. package/lib/exit-agent.js +5 -2
  8. package/lib/index.js +746 -40
  9. package/lib/invariant.js +1 -1
  10. package/lib/preflight-runner.js +125 -47
  11. package/lib/restart-context-DUyExi9O.js +1245 -0
  12. package/lib/restart-context-DlaDZWKq.js +1250 -0
  13. package/lib/{state-Dhx9VG44.js → state-CZMypGkB.js} +97 -13
  14. package/lib/test-seam-cli.js +24 -0
  15. package/lib/test-seam-dwvaKjRp.js +459 -0
  16. package/lib/test-seam.js +2 -0
  17. package/lib/types/browser-handoff.d.ts +55 -0
  18. package/lib/types/browser-handoff.js +489 -0
  19. package/lib/types/cli.d.ts +34 -4
  20. package/lib/types/cli.js +1421 -166
  21. package/lib/types/client/index.d.ts +15 -0
  22. package/lib/types/client/index.js +348 -0
  23. package/lib/types/deployment-proof.d.ts +24 -0
  24. package/lib/types/deployment-proof.js +314 -0
  25. package/lib/types/exit-agent.js +2 -0
  26. package/lib/types/git.d.ts +12 -3
  27. package/lib/types/git.js +69 -7
  28. package/lib/types/index.d.ts +66 -3
  29. package/lib/types/index.js +192 -41
  30. package/lib/types/launch-spec.d.ts +263 -0
  31. package/lib/types/launch-spec.js +823 -0
  32. package/lib/types/preflight-runner.d.ts +23 -12
  33. package/lib/types/preflight-runner.js +152 -57
  34. package/lib/types/processes.d.ts +38 -6
  35. package/lib/types/processes.js +236 -10
  36. package/lib/types/restart-context.d.ts +50 -0
  37. package/lib/types/restart-context.js +117 -6
  38. package/lib/types/restart-request.d.ts +32 -0
  39. package/lib/types/restart-request.js +128 -0
  40. package/lib/types/state-files.d.ts +30 -0
  41. package/lib/types/state-files.js +55 -0
  42. package/lib/types/state.d.ts +29 -2
  43. package/lib/types/state.js +52 -7
  44. package/lib/types/temp-artifact.d.ts +15 -0
  45. package/lib/types/temp-artifact.js +17 -0
  46. package/lib/types/test-seam-cli.d.ts +3 -0
  47. package/lib/types/test-seam-cli.js +27 -0
  48. package/lib/types/test-seam.d.ts +55 -0
  49. package/lib/types/test-seam.js +112 -0
  50. package/lib/types/transition.d.ts +118 -0
  51. package/lib/types/transition.js +717 -0
  52. package/package.json +30 -11
  53. package/scripts/dsh-watchdog.sh +1388 -80
  54. package/scripts/install-launchd.sh +43 -5
  55. package/scripts/install-systemd.sh +43 -5
  56. package/scripts/on-install.js +1 -1
  57. package/skills/dsh-self-restart-guard/SKILL.md +38 -12
  58. package/lib/processes-hCAmwma-.js +0 -127
  59. 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-DlaDZWKq.js";
4
+ import { _ as processIdentity, c as appendTestLifecycleEvent, d as registerTestProcess, f as discoverLaunchCommand, h as killPidTree, l as appendTestLifecycleEventForProcess, m as findPidOnPort, n as TEST_PROCESS_ROLE_ENV, p as findOwnedListener, r as TEST_PROCESS_TEMP_ROOT_ENV, t as TEST_PROCESS_PORT_ENV, u as registerCurrentTestProcess, v as processIdentityMatches } from "./test-seam-dwvaKjRp.js";
5
+ import { a as setProvenDeployment, c as lastGoodBootRevision, d as pidAlive, f as stateFile, i as setCheckpoint, l as livePidIn, n as loadState, r as recordCredential, s as verifyCredential, t as clearCredential, u as liveWatchdogPid } from "./state-CZMypGkB.js";
6
6
  import { execFileSync, spawn } from "node:child_process";
7
- import { 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
  /**
@@ -75,35 +1094,41 @@ function sandboxGate(verb, options, io) {
75
1094
  return false;
76
1095
  }
77
1096
  /**
78
- * Whether the pid named by this raw pid/lock-file content is alive. Empty
79
- * content reads as NO holder: Number('') is 0 and kill(0, 0) probes our own
80
- * process group (always succeeds), which once read as "alive" and refused
81
- * every restart forever — the bug that had to be fixed in two copies of this
82
- * logic before it was consolidated here.
1097
+ * Resolve the restart's initiating session. An explicit --initiator that
1098
+ * contradicts the shell's own DSH_SESSION_ID routes the wake-up report to a
1099
+ * session that is not the caller — observed 2026-08-29: an agent invented a
1100
+ * branch-derived slug ('skill-styles-merge'), the report went to a session
1101
+ * that does not exist, and the actual scheduler was never woken. Warn loudly;
1102
+ * do not refuse — scheduling on behalf of another session is legitimate.
1103
+ * @param explicit - the --initiator flag value, when given.
1104
+ * @param io - CLI streams.
1105
+ * @returns the initiator to record (explicit wins, else the env default).
83
1106
  */
84
- function pidAlive(raw) {
85
- const pid = Number(raw);
86
- if (raw === "" || !Number.isInteger(pid) || pid <= 0) return false;
87
- try {
88
- process.kill(pid, 0);
89
- return true;
90
- } catch {
91
- return false;
92
- }
1107
+ function resolveInitiator(explicit, io) {
1108
+ const fromEnv = process.env.DSH_SESSION_ID;
1109
+ if (explicit !== void 0 && explicit !== "" && fromEnv !== void 0 && explicit !== fromEnv) io.stderr(`warning: --initiator ${JSON.stringify(explicit)} does not match this session's DSH_SESSION_ID ${JSON.stringify(fromEnv)} — the restart report will be routed to ${JSON.stringify(explicit)} and THIS session will not be woken. Omit --initiator to route it to the current session.\n`);
1110
+ return explicit !== void 0 && explicit !== "" ? explicit : fromEnv;
93
1111
  }
94
- /** The live pid named by a pid/lock file (as the raw string), or null when absent/stale. */
95
- function livePidIn(file) {
96
- try {
97
- const raw = readFileSync(file, "utf8").trim();
98
- return pidAlive(raw) ? raw : null;
99
- } catch {
100
- return null;
101
- }
1112
+ function testChildEnv(role, env, options = {}) {
1113
+ if (process.env["ANKH_GUARD_TEST_RUN_DIR"] === void 0) return env;
1114
+ return {
1115
+ ...env,
1116
+ [TEST_PROCESS_ROLE_ENV]: role,
1117
+ ...options.port === void 0 ? {} : { [TEST_PROCESS_PORT_ENV]: String(options.port) },
1118
+ ...options.tempRoot === void 0 ? {} : { [TEST_PROCESS_TEMP_ROOT_ENV]: options.tempRoot }
1119
+ };
102
1120
  }
103
- /** The live supervising watchdog's pid, or null when none is (pidfile + kill 0). */
104
- function liveWatchdogPid(stateDir) {
105
- const raw = livePidIn(stateFile(stateDir, "watchdogPid"));
106
- return raw === null ? null : Number(raw);
1121
+ function registerSpawnedTestProcess(child, role, options = {}) {
1122
+ if (child.pid === void 0) return;
1123
+ registerTestProcess(child.pid, role, {
1124
+ source: "parent-observer",
1125
+ ...options.port === void 0 ? {} : { port: options.port },
1126
+ ...options.tempRoot === void 0 ? {} : { tempRoot: options.tempRoot }
1127
+ });
1128
+ appendTestLifecycleEventForProcess(child.pid, role, "child-spawned", {
1129
+ childPid: child.pid,
1130
+ role
1131
+ }, "parent-observer");
107
1132
  }
108
1133
  /**
109
1134
  * Cross-session restart mutual exclusion: two concurrent restarts would both
@@ -183,29 +1208,52 @@ function cliInvocation(args) {
183
1208
  const USAGE = `usage: dsh-ankh-guard <command> [args] [flags]
184
1209
  commands:
185
1210
  verify [--state-dir DIR] [--repo DIR] [--max-age MIN]
186
- record <scope> [--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
187
1213
  status [--state-dir DIR]
188
1214
  clear [--state-dir DIR]
189
- checkpoint [--message MSG] [--repo DIR] [--state-dir DIR]
1215
+ checkpoint [--message MSG] [--include-dirty] [--repo DIR] [--state-dir DIR]
190
1216
  reset <sha> [--repo DIR]
191
1217
  canary [--port N] [--state-dir DIR] [--repo DIR] [--max-age MIN]
1218
+ verify-restart [--state-dir DIR] # watchdog-facing: revalidate the scheduled authorization
1219
+ record-proven-deployment [--state-dir DIR] # watchdog-facing: promote/retain proof after canary
192
1220
  check-env [--state-dir DIR] [--repo DIR] # sandbox / watchdog / git readiness probe
193
- preflight [--profile NAME] [--timeout-ms MS]
1221
+ preflight [--profile NAME] [--harness-root DIR] [--timeout-ms MS]
1222
+ [--preflight-surface source|built --preflight-install-anchor FILE] [--preflight-runner FILE]
194
1223
  record-unexpected-exit [--state-dir DIR] # watchdog-facing: record an unplanned-exit recovery
195
1224
  record-adoption [--initiator ID] [--state-dir DIR] # watchdog-facing: record the first (adoption) takeover
1225
+ record-composition-recovery [--state-dir DIR] # watchdog-facing: record a composition-rollback recovery
1226
+ configure-launch --port N --start "CMD" [--home DIR] [--repo DIR] --harness-root DIR [--profile NAME]
1227
+ --preflight-surface source|built [--preflight-runner FILE] --preflight-install-anchor FILE [--if-absent]
1228
+ launch-status [--state-dir DIR]
1229
+ transition-apply CUTOVER_ID [--state-dir DIR] # watchdog-facing: apply the prepared transition
1230
+ transition-rollback CUTOVER_ID [--state-dir DIR] # watchdog-facing: restore previous state before previous starts
1231
+ abort-cutover [--state-dir DIR] # apply the recovery policy approved by reconfigure
1232
+ restore-previous [--state-dir DIR] # explicit new authorization to restore the complete previous spec
1233
+ reconfigure --start "CMD" --on-failure restore-previous|wait-for-user [--port N]
1234
+ [--home DIR] [--repo DIR] [--harness-root DIR] [--profile NAME] [--browser-handoff required|off]
1235
+ --preflight-surface source|built [--preflight-runner FILE] --preflight-install-anchor FILE
1236
+ --candidate-probe-command "CMD"
1237
+ [--transition-file FILE] [--delay-ms MS] [--supervisor-yield-timeout-ms MS] [--preflight-timeout-ms MS] [--state-dir DIR]
196
1238
  restart --port N --start "CMD" [--pid PID] [--timeout-ms MS] [--delay-ms MS] [--stop-timeout-ms MS] [--rollback]
197
- [--profile NAME] [--preflight-timeout-ms MS] [--state-dir DIR] [--repo DIR] [--max-age MIN]
198
- schedule-exit --port N --delay-ms MS [--initiator ID] [--log FILE] [--profile NAME]
199
- [--preflight-timeout-ms MS] [--state-dir DIR] [--repo DIR]
200
- supervise --port N --start "CMD" [--foreground] [--log FILE] [--state-dir DIR] [--repo DIR] [--home DIR]
1239
+ [--profile NAME] [--harness-root DIR] [--preflight-timeout-ms MS] [--state-dir DIR] [--repo DIR] [--max-age MIN]
1240
+ schedule-exit [--port N] --delay-ms MS [--initiator ID] [--log FILE] [--profile NAME]
1241
+ [--harness-root DIR] [--preflight-timeout-ms MS] [--state-dir DIR] [--repo DIR]
1242
+ supervise --port N --start "CMD" [--foreground] [--log FILE] [--state-dir DIR] [--repo DIR] [--harness-root DIR] [--home DIR]
201
1243
  flags:
202
1244
  --state-dir DIR state directory (default: $DSH_HOME/state, else <cwd>/.dsh-guard-state)
203
1245
  --repo DIR repository the credential binds to (default: cwd)
1246
+ --harness-root DIR dsh host checkout used by preflight and exported to the
1247
+ child as DSH_HARNESS; launch-state initialization requires
1248
+ this flag or an existing DSH_HARNESS
204
1249
  --max-age MIN credential freshness window in minutes (default: 10)
205
1250
  --port N canary/restart/supervise: TCP port that must be listening
206
- --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
207
1254
  --message MSG checkpoint: batch description
208
- --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
209
1257
  (optional once the plugin has booted — it records the launch
210
1258
  command to <state-dir>/instance-launch.json)
211
1259
  --pid PID restart: process to stop (default: the listener on --port)
@@ -216,18 +1264,37 @@ flags:
216
1264
  logs can take tens of seconds to flush)
217
1265
  --delay-ms MS restart: sleep before stopping, so the current turn can finish first
218
1266
  (agent-driven graceful self-restart: schedule, complete, then restart);
219
- schedule-exit: delay before the detached exit agent kills the host
1267
+ schedule-exit: delay before the detached exit agent kills the host;
1268
+ reconfigure: grace after successor supervisor claim before old-child stop
220
1269
  --log FILE supervise (detached only — with --foreground the external supervisor's
221
1270
  redirection owns the log) / schedule-exit: log file (default: <state-dir>/*.log)
222
1271
  --home DIR supervise: the dsh home the supervised instance boots with (profiles,
223
1272
  credentials — default: $DSH_HOME; required when that is unset)
224
1273
  --initiator ID schedule-exit: session id that requested the exit (default: $DSH_SESSION_ID);
225
- recorded in last-restart.json so the restart report returns to that session
226
- --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:
227
1278
  $DSH_PROFILE, else "web")
228
1279
  --preflight-timeout-ms MS schedule-exit/restart: bound on the composition preflight (default 120000)
1280
+ --preflight-surface MODE configure-launch/reconfigure: explicit successor module surface, source or built
1281
+ --preflight-runner FILE runner file to bind by absolute path and SHA-256 (default: this package's matching face)
1282
+ --preflight-install-anchor FILE the exact successor dsh package.json; built imports resolve from this npm toolchain
1283
+ --candidate-probe-command CMD reconfigure: caller-supplied one-shot probe, durably co-bound with --start SHA-256
229
1284
  --rollback restart: on failure, git reset --hard to the recorded checkpoint
230
- --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
231
1298
  --sync restart: run the whole loop in-process (debug/tests; the default
232
1299
  self-detaches a driver so the loop survives the caller's teardown)
233
1300
  `;
@@ -240,16 +1307,21 @@ function parse(argv) {
240
1307
  const options = {
241
1308
  stateDir: "",
242
1309
  repoDir: "",
1310
+ harnessRoot: "",
243
1311
  home: "",
244
1312
  maxAgeMinutes: 10,
245
1313
  port: void 0,
246
1314
  command: void 0,
1315
+ run: false,
1316
+ runArgv: void 0,
247
1317
  message: void 0,
1318
+ detail: void 0,
248
1319
  start: void 0,
249
1320
  pid: void 0,
250
1321
  timeoutMs: void 0,
251
1322
  delayMs: void 0,
252
1323
  stopTimeoutMs: void 0,
1324
+ supervisorYieldTimeoutMs: void 0,
253
1325
  log: void 0,
254
1326
  foreground: false,
255
1327
  rollback: false,
@@ -257,7 +1329,19 @@ function parse(argv) {
257
1329
  sync: false,
258
1330
  initiator: void 0,
259
1331
  profile: void 0,
260
- preflightTimeoutMs: void 0
1332
+ preflightTimeoutMs: void 0,
1333
+ preflightSurface: void 0,
1334
+ preflightRunner: void 0,
1335
+ preflightInstallAnchor: void 0,
1336
+ candidateProbeCommand: void 0,
1337
+ onFailure: void 0,
1338
+ browserHandoff: "required",
1339
+ ifAbsent: false,
1340
+ trustCommand: false,
1341
+ includeDirty: false,
1342
+ takeoverFrom: void 0,
1343
+ cutoverId: void 0,
1344
+ transitionFile: void 0
261
1345
  };
262
1346
  const positionals = [];
263
1347
  let i = 0;
@@ -269,6 +1353,10 @@ function parse(argv) {
269
1353
  try {
270
1354
  for (; i < argv.length; i++) {
271
1355
  const arg = argv[i] ?? "";
1356
+ if (arg === "--") {
1357
+ options.runArgv = argv.slice(i + 1);
1358
+ break;
1359
+ }
272
1360
  switch (arg) {
273
1361
  case "--state-dir":
274
1362
  options.stateDir = flagValue(arg, true) ?? "";
@@ -282,6 +1370,10 @@ function parse(argv) {
282
1370
  options.repoDir = flagValue(arg, true) ?? "";
283
1371
  i++;
284
1372
  break;
1373
+ case "--harness-root":
1374
+ options.harnessRoot = flagValue(arg, true) ?? "";
1375
+ i++;
1376
+ break;
285
1377
  case "--max-age": {
286
1378
  const raw = flagValue(arg, true);
287
1379
  const n = Number(raw);
@@ -302,10 +1394,23 @@ function parse(argv) {
302
1394
  options.command = flagValue(arg, true) ?? "";
303
1395
  i++;
304
1396
  break;
1397
+ case "--run":
1398
+ options.run = true;
1399
+ break;
1400
+ case "--trust-command":
1401
+ options.trustCommand = true;
1402
+ break;
1403
+ case "--include-dirty":
1404
+ options.includeDirty = true;
1405
+ break;
305
1406
  case "--message":
306
1407
  options.message = flagValue(arg, true) ?? "";
307
1408
  i++;
308
1409
  break;
1410
+ case "--detail":
1411
+ options.detail = flagValue(arg, true);
1412
+ i++;
1413
+ break;
309
1414
  case "--start":
310
1415
  options.start = flagValue(arg, true) ?? "";
311
1416
  i++;
@@ -342,6 +1447,14 @@ function parse(argv) {
342
1447
  i++;
343
1448
  break;
344
1449
  }
1450
+ case "--supervisor-yield-timeout-ms": {
1451
+ const raw = flagValue(arg, true);
1452
+ const n = Number(raw);
1453
+ if (raw === void 0 || !Number.isInteger(n) || n < 100) throw new Error("--supervisor-yield-timeout-ms must be an integer >= 100");
1454
+ options.supervisorYieldTimeoutMs = n;
1455
+ i++;
1456
+ break;
1457
+ }
345
1458
  case "--foreground":
346
1459
  options.foreground = true;
347
1460
  break;
@@ -361,6 +1474,58 @@ function parse(argv) {
361
1474
  i++;
362
1475
  break;
363
1476
  }
1477
+ case "--preflight-surface": {
1478
+ const value = flagValue(arg, true);
1479
+ if (value !== "source" && value !== "built") throw new Error("--preflight-surface must be source or built");
1480
+ options.preflightSurface = value;
1481
+ i++;
1482
+ break;
1483
+ }
1484
+ case "--preflight-runner":
1485
+ options.preflightRunner = flagValue(arg, true) ?? "";
1486
+ i++;
1487
+ break;
1488
+ case "--preflight-install-anchor":
1489
+ options.preflightInstallAnchor = flagValue(arg, true) ?? "";
1490
+ i++;
1491
+ break;
1492
+ case "--candidate-probe-command":
1493
+ options.candidateProbeCommand = flagValue(arg, true) ?? "";
1494
+ i++;
1495
+ break;
1496
+ case "--on-failure": {
1497
+ const value = flagValue(arg, true);
1498
+ if (value !== "restore-previous" && value !== "wait-for-user") throw new Error("--on-failure must be restore-previous or wait-for-user");
1499
+ options.onFailure = value;
1500
+ i++;
1501
+ break;
1502
+ }
1503
+ case "--browser-handoff": {
1504
+ const value = flagValue(arg, true);
1505
+ if (value !== "required" && value !== "off") throw new Error("--browser-handoff must be required or off");
1506
+ options.browserHandoff = value;
1507
+ i++;
1508
+ break;
1509
+ }
1510
+ case "--takeover-from": {
1511
+ const raw = flagValue(arg, true);
1512
+ const value = Number(raw);
1513
+ if (raw === void 0 || !Number.isInteger(value) || value <= 0) throw new Error("--takeover-from must be a positive pid");
1514
+ options.takeoverFrom = value;
1515
+ i++;
1516
+ break;
1517
+ }
1518
+ case "--cutover-id":
1519
+ options.cutoverId = flagValue(arg, true) ?? "";
1520
+ i++;
1521
+ break;
1522
+ case "--transition-file":
1523
+ options.transitionFile = flagValue(arg, true) ?? "";
1524
+ i++;
1525
+ break;
1526
+ case "--if-absent":
1527
+ options.ifAbsent = true;
1528
+ break;
364
1529
  case "--rollback":
365
1530
  options.rollback = true;
366
1531
  break;
@@ -416,6 +1581,68 @@ async function sleep(ms) {
416
1581
  setTimeout(resolve, ms);
417
1582
  });
418
1583
  }
1584
+ /** Execute the exact argv used as credential evidence, streaming diagnostics. */
1585
+ async function runCredentialCommand(argv, cwd, io) {
1586
+ const executable = argv[0];
1587
+ if (executable === void 0 || executable === "") return {
1588
+ ok: false,
1589
+ detail: "no program was provided after --"
1590
+ };
1591
+ return new Promise((resolvePromise) => {
1592
+ let settled = false;
1593
+ const settle = (result) => {
1594
+ if (settled) return;
1595
+ settled = true;
1596
+ resolvePromise(result);
1597
+ };
1598
+ let child;
1599
+ try {
1600
+ child = spawn(executable, argv.slice(1), {
1601
+ cwd,
1602
+ env: testChildEnv("credential-command", { ...process.env }, { tempRoot: cwd }),
1603
+ stdio: [
1604
+ "ignore",
1605
+ "pipe",
1606
+ "pipe"
1607
+ ]
1608
+ });
1609
+ registerSpawnedTestProcess(child, "credential-command", { tempRoot: cwd });
1610
+ } catch (error) {
1611
+ settle({
1612
+ ok: false,
1613
+ detail: `could not start ${JSON.stringify(executable)}: ${String(error)}`
1614
+ });
1615
+ return;
1616
+ }
1617
+ child.stdout.on("data", (chunk) => {
1618
+ io.stdout(chunk.toString());
1619
+ });
1620
+ child.stderr.on("data", (chunk) => {
1621
+ io.stderr(chunk.toString());
1622
+ });
1623
+ child.once("error", (error) => {
1624
+ settle({
1625
+ ok: false,
1626
+ detail: `could not start ${JSON.stringify(executable)}: ${String(error)}`
1627
+ });
1628
+ });
1629
+ child.once("exit", (code, signal) => {
1630
+ if (code === 0) settle({ ok: true });
1631
+ else settle({
1632
+ ok: false,
1633
+ detail: signal === null ? `command exited ${code ?? "without a status"}` : `command was terminated by ${signal}`
1634
+ });
1635
+ });
1636
+ });
1637
+ }
1638
+ /** Stable, non-shell rendering for credential audit metadata. */
1639
+ function renderArgv(argv) {
1640
+ return argv.map((word) => JSON.stringify(word)).join(" ");
1641
+ }
1642
+ /** The credential gate always includes uncommitted and untracked inputs. */
1643
+ function verifyRepoCredential(stateDir, repoDir, maxAgeMinutes) {
1644
+ return verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), maxAgeMinutes, isWorkingTreeClean(repoDir));
1645
+ }
419
1646
  /**
420
1647
  * How the spawned watchdog should invoke the guard CLI: the built form runs
421
1648
  * `node <cli>`; the source form needs tsx with an absolute path (the watchdog
@@ -456,6 +1683,23 @@ function exitAgentInvocation() {
456
1683
  const DEFAULT_PREFLIGHT_TIMEOUT_MS = 12e4;
457
1684
  /** A pending restart marker older than this is stale — its watchdog died mid-flow. */
458
1685
  const RESTART_MARKER_TTL_MS = 9e5;
1686
+ function isRestartAuthorization(value) {
1687
+ if (typeof value !== "object" || value === null) return false;
1688
+ const authorization = value;
1689
+ return authorization.version === 1 && (authorization.kind === "fresh-credential" || authorization.kind === "proven-deployment") && typeof authorization.revision === "string" && authorization.revision !== "" && typeof authorization.evidenceSha256 === "string" && /^[a-f0-9]{64}$/.test(authorization.evidenceSha256);
1690
+ }
1691
+ function readRestartRequestMarker(stateDir) {
1692
+ try {
1693
+ const marker = JSON.parse(readFileSync(stateFile(stateDir, "restartRequested"), "utf8"));
1694
+ if (typeof marker !== "object" || marker === null) return null;
1695
+ return {
1696
+ ...typeof marker.requestedAt === "number" ? { requestedAt: marker.requestedAt } : {},
1697
+ ...isRestartAuthorization(marker.authorization) ? { authorization: marker.authorization } : {}
1698
+ };
1699
+ } catch {
1700
+ return null;
1701
+ }
1702
+ }
459
1703
  /**
460
1704
  * The restart marker's state. Every verb that can stop the instance must
461
1705
  * consult this (and the restart lock) — a stop right invisible to the other
@@ -464,12 +1708,8 @@ const RESTART_MARKER_TTL_MS = 9e5;
464
1708
  function restartMarkerState(stateDir) {
465
1709
  const file = stateFile(stateDir, "restartRequested");
466
1710
  if (!existsSync(file)) return "none";
467
- try {
468
- const marker = JSON.parse(readFileSync(file, "utf8"));
469
- return typeof marker.requestedAt === "number" && Date.now() - marker.requestedAt <= RESTART_MARKER_TTL_MS ? "fresh" : "stale";
470
- } catch {
471
- return "stale";
472
- }
1711
+ const marker = readRestartRequestMarker(stateDir);
1712
+ return marker?.requestedAt !== void 0 && Date.now() - marker.requestedAt <= RESTART_MARKER_TTL_MS ? "fresh" : "stale";
473
1713
  }
474
1714
  /** Captured preflight output is diagnostics, not a log — cap it before it can grow without bound. */
475
1715
  const PREFLIGHT_OUTPUT_CAP = 65536;
@@ -502,11 +1742,12 @@ const preflightInternals = {
502
1742
  };
503
1743
  /**
504
1744
  * The harness checkout the live instance boots from (and the preflight
505
- * runner resolves the official published packages from): the `--repo` target
506
- * 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.
507
1748
  */
508
- function resolveHarnessRoot(optionRepoDir, env = process.env) {
509
- if (optionRepoDir !== void 0 && optionRepoDir !== "") return optionRepoDir;
1749
+ function resolveHarnessRoot(optionHarnessRoot, env = process.env) {
1750
+ if (optionHarnessRoot !== void 0 && optionHarnessRoot !== "") return optionHarnessRoot;
510
1751
  const fromEnv = env.DSH_HARNESS;
511
1752
  return fromEnv !== void 0 && fromEnv.trim() !== "" ? fromEnv : join(homedir(), "code/deepseek-harness");
512
1753
  }
@@ -522,7 +1763,69 @@ function resolveRunnerCommand(harnessRoot) {
522
1763
  const here = dirname(fileURLToPath(import.meta.url));
523
1764
  const runner = existsSync(join(here, "preflight-runner.ts")) ? join(here, "preflight-runner.ts") : existsSync(join(here, "preflight-runner.js")) ? join(here, "preflight-runner.js") : void 0;
524
1765
  if (runner === void 0) return void 0;
525
- return `node --import ${shellQuote(tsx)} ${shellQuote(runner)}`;
1766
+ return `node --import ${shellQuote(tsx)} ${shellQuote(runner)} --host-surface source --install-anchor ${shellQuote(join(harnessRoot, "apps", "cli", "package.json"))}`;
1767
+ }
1768
+ function fileSha256(file) {
1769
+ return createHash("sha256").update(readFileSync(file)).digest("hex");
1770
+ }
1771
+ function killSpawnGroup(pid) {
1772
+ if (pid === void 0) return;
1773
+ try {
1774
+ process.kill(-pid, "SIGKILL");
1775
+ } catch {}
1776
+ }
1777
+ function defaultPreflightRunner(surface) {
1778
+ const here = dirname(fileURLToPath(import.meta.url));
1779
+ return (surface === "source" ? [
1780
+ join(here, "preflight-runner.ts"),
1781
+ join(here, "..", "src", "preflight-runner.ts"),
1782
+ join(here, "preflight-runner.js"),
1783
+ join(here, "..", "lib", "preflight-runner.js")
1784
+ ] : [join(here, "preflight-runner.js"), join(here, "..", "lib", "preflight-runner.js")]).find((file) => existsSync(file));
1785
+ }
1786
+ /** Build and validate the explicit preflight contract persisted with a launch spec. */
1787
+ function resolvePreflightSpec(options, command, harnessRoot, requireCandidate) {
1788
+ if (options.preflightSurface === void 0) throw new Error("--preflight-surface source|built is required; the guard will not infer the successor execution surface");
1789
+ if (options.preflightInstallAnchor === void 0 || options.preflightInstallAnchor === "") throw new Error("--preflight-install-anchor FILE is required and must name the successor dsh package.json");
1790
+ const installAnchor = resolve(options.preflightInstallAnchor);
1791
+ let manifest;
1792
+ try {
1793
+ manifest = JSON.parse(readFileSync(installAnchor, "utf8"));
1794
+ } catch (error) {
1795
+ throw new Error(`preflight install anchor is unreadable: ${String(error)}`);
1796
+ }
1797
+ if (manifest.name !== "@deepseek-ai/dsh") throw new Error("preflight install anchor must be @deepseek-ai/dsh/package.json");
1798
+ if (typeof manifest.version !== "string" || manifest.version === "") throw new Error("preflight install anchor must declare the dsh package version");
1799
+ const configuredRunner = options.preflightRunner ?? defaultPreflightRunner(options.preflightSurface);
1800
+ if (configuredRunner === void 0 || configuredRunner === "") throw new Error(`no ${options.preflightSurface} preflight runner exists; build ankh-guard or pass --preflight-runner FILE`);
1801
+ const runnerPath = resolve(configuredRunner);
1802
+ if (!existsSync(runnerPath)) throw new Error(`preflight runner does not exist: ${runnerPath}`);
1803
+ if (options.preflightSurface === "built" && !runnerPath.endsWith(".js")) throw new Error("built preflight requires a JavaScript runner");
1804
+ const runnerRuntimeArgs = [];
1805
+ if (options.preflightSurface === "source") {
1806
+ const tsx = join(harnessRoot, "node_modules", "tsx", "dist", "esm", "index.mjs");
1807
+ if (!existsSync(tsx)) throw new Error(`source preflight requires the target checkout's tsx runtime: ${tsx}`);
1808
+ runnerRuntimeArgs.push("--import", tsx);
1809
+ }
1810
+ const candidateProbeCommand = options.candidateProbeCommand;
1811
+ if (requireCandidate && (candidateProbeCommand === void 0 || candidateProbeCommand.trim() === "")) throw new Error("--candidate-probe-command CMD is required; the caller must derive it from the target executable/argv before the previous host stops");
1812
+ return {
1813
+ version: 1,
1814
+ surface: options.preflightSurface,
1815
+ runnerExecutable: process.execPath,
1816
+ runnerRuntimeArgs,
1817
+ runnerPath,
1818
+ runnerSha256: fileSha256(runnerPath),
1819
+ installAnchor,
1820
+ installAnchorSha256: fileSha256(installAnchor),
1821
+ hostPackageVersion: manifest.version,
1822
+ targetCommandSha256: commandSha256(command),
1823
+ ...candidateProbeCommand === void 0 || candidateProbeCommand.trim() === "" ? {} : {
1824
+ candidateProbeCommand,
1825
+ candidateProbeSha256: commandSha256(candidateProbeCommand),
1826
+ candidateProbeProvenance: "caller-supplied"
1827
+ }
1828
+ };
526
1829
  }
527
1830
  /** POSIX single-quote one word for the shell command line. */
528
1831
  function shellQuote(word) {
@@ -568,13 +1871,57 @@ function redactLaunchCommand(command) {
568
1871
  * @param profile - the dsh profile to dry-run.
569
1872
  * @param timeoutMs - bound on the whole subprocess run; a timeout kills it.
570
1873
  * @param harnessRoot - harness checkout for the runner (default: DSH_HARNESS / ~/code/deepseek-harness).
1874
+ * @param home - dsh home the dry-run must read instead of ambient process state.
571
1875
  * @returns the classified outcome.
572
1876
  */
573
- async function runPreflightCheck(profile, timeoutMs, harnessRoot) {
574
- const override = process.env.DSH_PREFLIGHT_COMMAND;
1877
+ async function runPreflightCheck(profile, timeoutMs, harnessRoot, home, binding) {
1878
+ const override = binding === void 0 ? process.env.DSH_PREFLIGHT_COMMAND : void 0;
575
1879
  let command;
1880
+ let executable;
1881
+ let argv = [];
576
1882
  let usingRunner = false;
577
- if (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;
578
1925
  else {
579
1926
  const root = harnessRoot ?? resolveHarnessRoot(void 0);
580
1927
  const runner = preflightInternals.resolveRunner(root);
@@ -594,13 +1941,19 @@ async function runPreflightCheck(profile, timeoutMs, harnessRoot) {
594
1941
  return await new Promise((resolvePromise) => {
595
1942
  let output = "";
596
1943
  let timedOut = false;
597
- const 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 ?? "", {
598
1950
  shell: true,
599
- env: {
600
- ...process.env,
601
- DSH_HARNESS: harnessForRunner
602
- }
603
- }) : 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 } });
604
1957
  const append = (chunk) => {
605
1958
  if (output.length < PREFLIGHT_OUTPUT_CAP) output += chunk.toString("utf8");
606
1959
  };
@@ -642,6 +1995,61 @@ async function runPreflightCheck(profile, timeoutMs, harnessRoot) {
642
1995
  });
643
1996
  });
644
1997
  }
1998
+ /** Execute the caller-supplied one-shot probe under the target home/root. */
1999
+ async function runCandidateProbe(binding, targetCommand, timeoutMs, harnessRoot, home) {
2000
+ if (binding.targetCommandSha256 !== commandSha256(targetCommand)) return {
2001
+ kind: "infra-failed",
2002
+ output: "",
2003
+ detail: "candidate probe is bound to a different target launch command"
2004
+ };
2005
+ if (binding.candidateProbeCommand === void 0 || binding.candidateProbeSha256 === void 0 || commandSha256(binding.candidateProbeCommand) !== binding.candidateProbeSha256) return {
2006
+ kind: "infra-failed",
2007
+ output: "",
2008
+ detail: "candidate probe command is missing or changed after binding"
2009
+ };
2010
+ return await new Promise((resolvePromise) => {
2011
+ let output = "";
2012
+ let timedOut = false;
2013
+ const child = spawn(binding.candidateProbeCommand, {
2014
+ shell: true,
2015
+ detached: true,
2016
+ env: testChildEnv("candidate-probe", {
2017
+ ...process.env,
2018
+ DSH_HARNESS: harnessRoot,
2019
+ DSH_HOME: home,
2020
+ ANKH_TARGET_COMMAND_SHA256: binding.targetCommandSha256
2021
+ }, { tempRoot: home })
2022
+ });
2023
+ registerSpawnedTestProcess(child, "candidate-probe", { tempRoot: home });
2024
+ const append = (chunk) => {
2025
+ if (output.length < PREFLIGHT_OUTPUT_CAP) output += chunk.toString("utf8");
2026
+ };
2027
+ child.stdout.on("data", append);
2028
+ child.stderr.on("data", append);
2029
+ const timer = setTimeout(() => {
2030
+ timedOut = true;
2031
+ killSpawnGroup(child.pid);
2032
+ child.kill("SIGKILL");
2033
+ }, timeoutMs);
2034
+ child.on("close", (code) => {
2035
+ clearTimeout(timer);
2036
+ if (timedOut) resolvePromise({
2037
+ kind: "infra-failed",
2038
+ output,
2039
+ detail: `candidate probe timed out after ${timeoutMs} ms`
2040
+ });
2041
+ else if (code === 0) resolvePromise({
2042
+ kind: "pass",
2043
+ output
2044
+ });
2045
+ else resolvePromise({
2046
+ kind: "composition-failed",
2047
+ output,
2048
+ detail: `candidate probe exited ${String(code)}`
2049
+ });
2050
+ });
2051
+ });
2052
+ }
645
2053
  /** The profile a gated verb dry-runs: the flag, then $DSH_PROFILE, then the deployment default. */
646
2054
  function resolveProfileName(options) {
647
2055
  const flag = options.profile ?? "";
@@ -649,22 +2057,85 @@ function resolveProfileName(options) {
649
2057
  const env = process.env.DSH_PROFILE ?? "";
650
2058
  return env !== "" ? env : "web";
651
2059
  }
652
- /**
653
- * The home the supervised instance boots with (the watchdog exports it as
654
- * DSH_HOME): the explicit flag first, then the environment — the same
655
- * flag-over-env order as every other resolver in this CLI (and as the
656
- * installers' own --home). Undefined when neither names one: supervise fails
657
- * loud rather than boot the instance on a home guessed from the state dir.
658
- */
659
- function resolveWdHome(optionHome, env = process.env) {
660
- if (optionHome !== "") return optionHome;
661
- const fromEnv = env.DSH_HOME;
662
- return fromEnv !== void 0 && fromEnv !== "" ? fromEnv : void 0;
2060
+ /**
2061
+ * The home the supervised instance boots with (the watchdog exports it as
2062
+ * DSH_HOME): the explicit flag first, then the environment — the same
2063
+ * flag-over-env order as every other resolver in this CLI (and as the
2064
+ * installers' own --home). Undefined when neither names one: supervise fails
2065
+ * loud rather than boot the instance on a home guessed from the state dir.
2066
+ */
2067
+ function resolveWdHome(optionHome, env = process.env) {
2068
+ if (optionHome !== "") return optionHome;
2069
+ const fromEnv = env.DSH_HOME;
2070
+ return fromEnv !== void 0 && fromEnv !== "" ? fromEnv : void 0;
2071
+ }
2072
+ /** A persisted launch spec must never guess which checkout is the host. */
2073
+ function resolveLaunchHarnessRoot(optionHarnessRoot, selected, env = process.env) {
2074
+ if (optionHarnessRoot !== "") return optionHarnessRoot;
2075
+ if (selected !== void 0 && selected !== "") return selected;
2076
+ const fromEnv = env.DSH_HARNESS;
2077
+ return fromEnv !== void 0 && fromEnv.trim() !== "" ? fromEnv : void 0;
2078
+ }
2079
+ function launchSpec(input) {
2080
+ return {
2081
+ version: 1,
2082
+ command: input.command,
2083
+ port: input.port,
2084
+ home: resolve(input.home),
2085
+ credentialRepo: resolve(input.credentialRepo),
2086
+ harnessRoot: resolve(input.harnessRoot),
2087
+ profile: input.profile,
2088
+ ...input.preflight === void 0 ? {} : { preflight: input.preflight }
2089
+ };
2090
+ }
2091
+ function sameLaunchSpec(left, right) {
2092
+ return left.command === right.command && left.port === right.port && left.home === right.home && left.credentialRepo === right.credentialRepo && left.harnessRoot === right.harnessRoot && left.profile === right.profile && JSON.stringify(left.preflight) === JSON.stringify(right.preflight);
2093
+ }
2094
+ /** Resolve supervise's complete spec; a post-wait refresh always prefers durable state. */
2095
+ function resolveSuperviseSpec(options, stateDir, repoDir, io, preferDurable = false) {
2096
+ const durable = readLaunchState(stateDir);
2097
+ const selected = durable === null ? void 0 : selectedLaunchSpec(durable);
2098
+ const recorded = readInstanceLaunch(stateDir);
2099
+ const port = preferDurable && selected !== void 0 ? selected.port : options.port ?? selected?.port ?? recorded?.port;
2100
+ if (port === void 0) {
2101
+ io.stderr(`supervise requires --port N and --start "CMD" on first configuration\n\n${USAGE}`);
2102
+ return;
2103
+ }
2104
+ const command = !preferDurable && options.start !== void 0 && options.start !== "" ? options.start : selected?.command ?? resolveStartCommand(void 0, stateDir, "supervise", io, port);
2105
+ if (command === void 0 || command === "") {
2106
+ io.stderr(`supervise requires --port N and --start "CMD" on first configuration\n\n${USAGE}`);
2107
+ return;
2108
+ }
2109
+ const home = !preferDurable && options.home !== "" ? options.home : selected?.home ?? resolveWdHome("");
2110
+ if (home === void 0) {
2111
+ io.stderr("supervise needs the dsh home: pass --home DIR or set DSH_HOME — the supervised instance reads its profiles/credentials from there, and deriving one from --state-dir would guess wrong\n");
2112
+ return;
2113
+ }
2114
+ const harnessRoot = resolveLaunchHarnessRoot(!preferDurable ? options.harnessRoot : "", selected?.harnessRoot);
2115
+ if (harnessRoot === void 0) {
2116
+ io.stderr("supervise needs the dsh host checkout: pass --harness-root DIR or set DSH_HARNESS. The credential --repo is a separate role and is never used as the host root.\n");
2117
+ return;
2118
+ }
2119
+ return launchSpec({
2120
+ command,
2121
+ port,
2122
+ home,
2123
+ credentialRepo: !preferDurable && options.repoDir !== "" ? repoDir : selected?.credentialRepo ?? repoDir,
2124
+ harnessRoot,
2125
+ profile: !preferDurable && options.profile !== void 0 && options.profile !== "" ? options.profile : selected?.profile ?? resolveProfileName(options),
2126
+ ...selected?.preflight === void 0 || selected.command !== command ? {} : { preflight: selected.preflight }
2127
+ });
2128
+ }
2129
+ /** Existing full spec. The legacy launch record lacks both repository roles. */
2130
+ function resolvePreviousSpec(stateDir, io) {
2131
+ const state = readLaunchState(stateDir);
2132
+ if (state !== null) return selectedLaunchSpec(state);
2133
+ io.stderr("reconfigure refused: no complete durable previous launch specification is available. The legacy instance-launch record does not identify credential repo, host root, home, and profile independently. Run `configure-launch --port N --start \"CURRENT CMD\" --home DIR --repo CREDENTIAL_REPO --harness-root HOST_ROOT --profile NAME` first.\n");
663
2134
  }
664
2135
  /** The first ~40 lines of captured preflight output, newline-terminated, or empty. */
665
2136
  function summarizeOutput(output) {
666
2137
  if (output.trim() === "") return "";
667
- const lines = output.split("\n");
2138
+ const lines = output.replace(/([?&](?:token|grant)=)[^\s&#"']+/gi, "$1<redacted>").split("\n");
668
2139
  return `${(lines.length > 41 ? [...lines.slice(0, 40), `… (${lines.length - 40} more lines)`] : lines).join("\n").replace(/\n+$/, "")}\n`;
669
2140
  }
670
2141
  /**
@@ -676,10 +2147,12 @@ function summarizeOutput(output) {
676
2147
  * @param timeoutMs - bound on the preflight subprocess.
677
2148
  * @param io - output sinks.
678
2149
  * @param harnessRoot - harness checkout for the standalone runner.
2150
+ * @param home - dsh home to dry-run.
679
2151
  * @returns whether the verb may proceed.
680
2152
  */
681
- async function preflightGate(verb, profile, timeoutMs, io, harnessRoot) {
682
- 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);
683
2156
  switch (outcome.kind) {
684
2157
  case "pass":
685
2158
  io.stdout(`composition preflight PASS (profile ${JSON.stringify(profile)})\n`);
@@ -695,6 +2168,54 @@ async function preflightGate(verb, profile, timeoutMs, io, harnessRoot) {
695
2168
  return false;
696
2169
  }
697
2170
  }
2171
+ async function candidateProbeGate(target, timeoutMs, io, home) {
2172
+ if (target.preflight === void 0) {
2173
+ io.stderr("reconfigure refused: target has no explicit candidate probe binding\n");
2174
+ return false;
2175
+ }
2176
+ const outcome = await runCandidateProbe(target.preflight, target.command, timeoutMs, target.harnessRoot, home);
2177
+ if (outcome.kind === "pass") {
2178
+ io.stdout(`candidate command probe PASS (target command ${target.preflight.targetCommandSha256.slice(0, 16)})\n`);
2179
+ return true;
2180
+ }
2181
+ io.stderr(`reconfigure refused: candidate command probe ${outcome.kind === "composition-failed" ? "failed" : "could not execute"}${outcome.detail === void 0 ? "" : ` — ${outcome.detail}`}:\n${summarizeOutput(outcome.output)}`);
2182
+ return false;
2183
+ }
2184
+ /** Same-launch verbs follow the durable host root unless explicitly overridden. */
2185
+ function preflightHarnessRoot(options, stateDir) {
2186
+ if (options.harnessRoot !== "") return resolveHarnessRoot(options.harnessRoot);
2187
+ const state = readLaunchState(stateDir);
2188
+ return state === null ? resolveHarnessRoot(void 0) : selectedLaunchSpec(state).harnessRoot;
2189
+ }
2190
+ /**
2191
+ * A same-launch restart must use the exact durable supervisor configuration.
2192
+ * Explicit flags may confirm that configuration, but may not silently replace
2193
+ * one field while the live watchdog still owns a different command.
2194
+ */
2195
+ function stableScheduleSpec(options, stateDir, resolvedRepoDir, io) {
2196
+ const state = readLaunchState(stateDir);
2197
+ if (state === null) return null;
2198
+ if (state.mode !== "stable") {
2199
+ io.stderr(`schedule-exit refused: launch state is still in cutover mode (${state.cutoverId}); settle its receipt before a same-launch restart\n`);
2200
+ return;
2201
+ }
2202
+ const active = state.active;
2203
+ const conflicts = [];
2204
+ if (options.port !== void 0 && options.port !== active.port) conflicts.push(`port ${options.port} != ${active.port}`);
2205
+ if (options.repoDir !== "" && resolve(resolvedRepoDir) !== resolve(active.credentialRepo)) conflicts.push(`credential repo ${resolve(resolvedRepoDir)} != ${resolve(active.credentialRepo)}`);
2206
+ if (options.harnessRoot !== "" && resolve(options.harnessRoot) !== resolve(active.harnessRoot)) conflicts.push(`harness root ${resolve(options.harnessRoot)} != ${resolve(active.harnessRoot)}`);
2207
+ if (options.profile !== void 0 && options.profile !== "" && options.profile !== active.profile) conflicts.push(`profile ${options.profile} != ${active.profile}`);
2208
+ if (conflicts.length > 0) {
2209
+ io.stderr(`schedule-exit refused: explicit flags conflict with the durable active launch specification (${conflicts.join("; ")}). Use reconfigure for launch changes.\n`);
2210
+ return;
2211
+ }
2212
+ const recorded = readInstanceLaunch(stateDir);
2213
+ if (recorded === null || recorded.source !== "supervisor" || recorded.supervised !== true || recorded.command !== active.command || recorded.port !== active.port) {
2214
+ io.stderr("schedule-exit refused: the live instance launch record does not prove that its supervisor owns the durable active launch specification. Re-establish supervision or use reconfigure; do not stop the host on an inferred command.\n");
2215
+ return;
2216
+ }
2217
+ return active;
2218
+ }
698
2219
  /**
699
2220
  * Wait for a pid to exit; SIGKILL (the whole descendant tree) after the
700
2221
  * deadline. @param onEscalate - invoked right before the SIGKILL, so the
@@ -748,6 +2269,29 @@ function rollbackToKnownGood(stateDir, repoDir, io) {
748
2269
  * @returns the process exit code: 0 ok, 1 gate denied / failure, 2 usage error.
749
2270
  */
750
2271
  async function runCli(argv, io) {
2272
+ const verdictFile = process.env.DSH_ANKH_VERDICT_FILE;
2273
+ let recorded;
2274
+ const note = (stage, reason) => {
2275
+ if (recorded !== void 0) return;
2276
+ recorded = {
2277
+ stage,
2278
+ reason
2279
+ };
2280
+ if (verdictFile !== void 0) try {
2281
+ writeFileSync(verdictFile, `${JSON.stringify(recorded)}\n`, { mode: 384 });
2282
+ } catch {}
2283
+ };
2284
+ /** Record + print a one-line refusal, preserving the site's exit code. */
2285
+ const refuse = (stage, message, code = 1) => {
2286
+ note(stage, message.trim().split("\n", 1)[0] ?? message.trim());
2287
+ io.stderr(message.endsWith("\n") ? message : `${message}\n`);
2288
+ return code;
2289
+ };
2290
+ /** Record a refusal whose human text a gate already printed. */
2291
+ const refuseQuiet = (stage, reason, code = 1) => {
2292
+ note(stage, reason);
2293
+ return code;
2294
+ };
751
2295
  const parsed = parse(argv);
752
2296
  if ("error" in parsed) {
753
2297
  io.stderr(parsed.error);
@@ -758,7 +2302,8 @@ async function runCli(argv, io) {
758
2302
  const repoDir = resolveRepoDir(options.repoDir);
759
2303
  switch (command) {
760
2304
  case "verify": {
761
- const 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);
762
2307
  io.stdout(`${result.reason}\n`);
763
2308
  if (result.ok) {
764
2309
  io.stdout(FULL_ACCESS_HINT);
@@ -772,17 +2317,65 @@ async function runCli(argv, io) {
772
2317
  io.stderr(`record requires a <scope>\n\n${USAGE}`);
773
2318
  return 2;
774
2319
  }
775
- const head = currentHead(repoDir);
776
- 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) {
777
2350
  io.stderr("cannot record a credential outside a git repository\n");
778
2351
  return 1;
779
2352
  }
2353
+ if (!isWorkingTreeClean(repoDir)) {
2354
+ io.stderr("cannot record a credential while the working tree has staged, unstaged, or untracked changes\n");
2355
+ return 1;
2356
+ }
2357
+ let evidenceCommand = options.command ?? "";
2358
+ if (options.run) {
2359
+ const runArgv = options.runArgv ?? [];
2360
+ clearCredential(stateDir, Date.now());
2361
+ evidenceCommand = renderArgv(runArgv);
2362
+ io.stdout(`running credential evidence: ${evidenceCommand}\n`);
2363
+ const evidence = await runCredentialCommand(runArgv, repoDir, io);
2364
+ if (!evidence.ok) {
2365
+ io.stderr(`credential evidence failed: ${evidence.detail}; no credential recorded\n`);
2366
+ return 1;
2367
+ }
2368
+ if (currentHead(repoDir) !== headBefore || !isWorkingTreeClean(repoDir)) {
2369
+ io.stderr("credential evidence exited 0 but changed HEAD or left the working tree dirty; no credential recorded\n");
2370
+ return 1;
2371
+ }
2372
+ }
780
2373
  recordCredential(stateDir, {
781
2374
  scope,
782
- revision: head,
783
- command: options.command ?? ""
2375
+ revision: headBefore,
2376
+ command: evidenceCommand
784
2377
  }, Date.now());
785
- io.stdout(`recorded green credential: ${scope} @ ${head}\n`);
2378
+ io.stdout(`recorded green credential: ${scope} @ ${headBefore}${options.trustCommand ? " (external proof trusted)" : ""}\n`);
786
2379
  io.stdout(FULL_ACCESS_HINT);
787
2380
  if (liveWatchdogPid(stateDir) === null) io.stderr(NO_WATCHDOG_HINT);
788
2381
  return 0;
@@ -792,13 +2385,190 @@ async function runCli(argv, io) {
792
2385
  io.stdout(`${JSON.stringify(state, null, 2)}\n`);
793
2386
  return 0;
794
2387
  }
2388
+ case "configure-launch": {
2389
+ if (options.ifAbsent && readLaunchState(stateDir) !== null) {
2390
+ io.stdout("launch specification already exists — kept it unchanged (--if-absent)\n");
2391
+ return 0;
2392
+ }
2393
+ if (options.port === void 0 || options.start === void 0 || options.start === "") {
2394
+ io.stderr(`configure-launch requires --port N and --start "CMD"\n\n${USAGE}`);
2395
+ return 2;
2396
+ }
2397
+ const home = resolveWdHome(options.home);
2398
+ if (home === void 0) {
2399
+ io.stderr("configure-launch requires --home DIR or DSH_HOME\n");
2400
+ return 2;
2401
+ }
2402
+ const harnessRoot = resolveLaunchHarnessRoot(options.harnessRoot);
2403
+ if (harnessRoot === void 0) {
2404
+ io.stderr("configure-launch requires --harness-root DIR or DSH_HARNESS; --repo names the independent credential/rollback repository\n");
2405
+ return 2;
2406
+ }
2407
+ let preflight;
2408
+ try {
2409
+ preflight = resolvePreflightSpec(options, options.start, harnessRoot, false);
2410
+ } catch (error) {
2411
+ io.stderr(`configure-launch refused: ${error instanceof Error ? error.message : String(error)}\n`);
2412
+ return 2;
2413
+ }
2414
+ const spec = launchSpec({
2415
+ command: options.start,
2416
+ port: options.port,
2417
+ home,
2418
+ credentialRepo: repoDir,
2419
+ harnessRoot,
2420
+ profile: resolveProfileName(options),
2421
+ preflight
2422
+ });
2423
+ const written = writeStableLaunchSpec(stateDir, spec, options.ifAbsent);
2424
+ if (written) writeInstanceLaunchAsSupervisor(stateDir, {
2425
+ command: spec.command,
2426
+ source: "supervisor",
2427
+ supervised: true,
2428
+ port: spec.port,
2429
+ recordedAt: Date.now()
2430
+ });
2431
+ io.stdout(written ? `launch specification recorded for :${spec.port} (command sha is in launch-status)\n` : "launch specification already exists — kept it unchanged (--if-absent)\n");
2432
+ return 0;
2433
+ }
2434
+ case "launch-status":
2435
+ io.stdout(`${JSON.stringify({
2436
+ launch: summarizeLaunchState(readLaunchState(stateDir)),
2437
+ receipt: readCutoverReceipt(stateDir)
2438
+ }, null, 2)}\n`);
2439
+ return 0;
2440
+ case "transition-apply":
2441
+ case "transition-rollback": {
2442
+ const id = positionals[0];
2443
+ if (id === void 0 || positionals.length !== 1) {
2444
+ io.stderr(`${command} requires one CUTOVER_ID\n`);
2445
+ return 2;
2446
+ }
2447
+ const transaction = activeCutover(stateDir);
2448
+ if (transaction === null || transaction.receipt.id !== id || transaction.state.transition === void 0) {
2449
+ io.stderr(`${command} refused: cutover ${id} has no active prepared transition\n`);
2450
+ return 1;
2451
+ }
2452
+ const reference = transaction.state.transition;
2453
+ const identityIsLive = (pid, startToken) => pid !== void 0 && startToken !== void 0 && processIdentityMatches({
2454
+ pid,
2455
+ startToken
2456
+ });
2457
+ if (command === "transition-apply") {
2458
+ if (transaction.state.selected !== "target") {
2459
+ io.stderr("transition-apply refused: the target launch specification is not selected\n");
2460
+ return 1;
2461
+ }
2462
+ const previous = transaction.receipt.ownership.previous;
2463
+ if (identityIsLive(previous.childPid, previous.childStartToken) || identityIsLive(previous.listenerPid, previous.listenerStartToken)) {
2464
+ io.stderr("transition-apply refused: the proven previous process is still alive\n");
2465
+ return 1;
2466
+ }
2467
+ try {
2468
+ const result = applyTransition(reference, transaction.state.previous.home, stateDir, id);
2469
+ recordCutoverEvent(stateDir, id, "transition", ["applied", reference.planSha256], Date.now());
2470
+ io.stdout(`transition applied (${result.changed.length} changed, ${result.unchanged.length} unchanged)\n`);
2471
+ return 0;
2472
+ } catch (error) {
2473
+ try {
2474
+ recordCutoverEvent(stateDir, id, "transition", [
2475
+ "apply-failed",
2476
+ reference.planSha256,
2477
+ error instanceof Error ? error.message : String(error)
2478
+ ], Date.now());
2479
+ } catch {}
2480
+ io.stderr(`transition-apply failed: ${String(error)}\n`);
2481
+ return 1;
2482
+ }
2483
+ }
2484
+ const liveTarget = transaction.receipt.attempts.some((attempt) => attempt.role === "target" && identityIsLive(attempt.childPid, attempt.childStartToken));
2485
+ const targetOwnership = transaction.receipt.ownership.target;
2486
+ if (liveTarget || targetOwnership !== void 0 && (identityIsLive(targetOwnership.childPid, targetOwnership.childStartToken) || identityIsLive(targetOwnership.listenerPid, targetOwnership.listenerStartToken))) {
2487
+ io.stderr("transition-rollback refused: a proven target process is still alive\n");
2488
+ return 1;
2489
+ }
2490
+ try {
2491
+ const result = rollbackTransition(reference, transaction.state.previous.home, stateDir, id);
2492
+ recordCutoverEvent(stateDir, id, "transition", ["rolled-back", reference.planSha256], Date.now());
2493
+ io.stdout(`transition rolled back (${result.changed.length} changed, ${result.unchanged.length} unchanged)\n`);
2494
+ return 0;
2495
+ } catch (error) {
2496
+ try {
2497
+ recordCutoverEvent(stateDir, id, "transition", [
2498
+ "rollback-failed",
2499
+ reference.planSha256,
2500
+ error instanceof Error ? error.message : String(error)
2501
+ ], Date.now());
2502
+ } catch {}
2503
+ io.stderr(`transition-rollback failed: ${String(error)}\n`);
2504
+ return 1;
2505
+ }
2506
+ }
2507
+ case "abort-cutover":
2508
+ case "restore-previous": {
2509
+ const transaction = activeCutover(stateDir);
2510
+ if (transaction === null) {
2511
+ io.stderr(`${command} refused: no nonterminal launch cutover is active\n`);
2512
+ return 1;
2513
+ }
2514
+ const watchdogPid = liveWatchdogPid(stateDir);
2515
+ if (watchdogPid === null) {
2516
+ io.stderr(`${command} refused: no live watchdog can consume the durable control request\n`);
2517
+ return 1;
2518
+ }
2519
+ const requested = command === "restore-previous" ? "restore-previous" : "abort";
2520
+ try {
2521
+ const control = writeCutoverControl(stateDir, transaction.receipt.id, requested, Date.now());
2522
+ appendTestLifecycleEvent("control-marker-written", {
2523
+ action: control.action,
2524
+ watchdogPid
2525
+ }, "parent-observer");
2526
+ try {
2527
+ process.kill(watchdogPid, "SIGUSR2");
2528
+ appendTestLifecycleEvent("signal-result", {
2529
+ signal: "SIGUSR2",
2530
+ targetPid: watchdogPid,
2531
+ result: "sent"
2532
+ }, "parent-observer");
2533
+ } catch (error) {
2534
+ appendTestLifecycleEvent("signal-result", {
2535
+ signal: "SIGUSR2",
2536
+ targetPid: watchdogPid,
2537
+ result: String(error)
2538
+ }, "parent-observer");
2539
+ throw error;
2540
+ }
2541
+ io.stdout(control.action === "restore-previous" ? `cutover ${control.cutoverId}: explicit restore-previous requested; watchdog ${watchdogPid} will stop only the proven target identity and relaunch the complete previous spec\n` : `cutover ${control.cutoverId}: abort requested; watchdog ${watchdogPid} will apply the pre-approved ${transaction.receipt.recovery.policy} policy\n`);
2542
+ return 0;
2543
+ } catch (error) {
2544
+ io.stderr(`${command} failed: ${String(error)}\n`);
2545
+ return 1;
2546
+ }
2547
+ }
2548
+ case "cutover-event": {
2549
+ const id = positionals[0];
2550
+ const kind = positionals[1];
2551
+ if (id === void 0 || kind === void 0) {
2552
+ io.stderr("cutover-event requires <id> <kind> [values...]\n");
2553
+ return 2;
2554
+ }
2555
+ try {
2556
+ recordCutoverEvent(stateDir, id, kind, positionals.slice(2), Date.now());
2557
+ return 0;
2558
+ } catch (error) {
2559
+ io.stderr(`cutover-event failed: ${String(error)}\n`);
2560
+ return 1;
2561
+ }
2562
+ }
795
2563
  case "clear":
796
2564
  clearCredential(stateDir, Date.now());
797
2565
  io.stdout("credential cleared\n");
798
2566
  return 0;
799
2567
  case "checkpoint": {
800
2568
  const message = options.message ?? "batch snapshot";
801
- const 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);
802
2572
  if (!result.ok) {
803
2573
  io.stderr(`${result.error}\n`);
804
2574
  return 1;
@@ -807,7 +2577,7 @@ async function runCli(argv, io) {
807
2577
  revision: result.sha,
808
2578
  message
809
2579
  }, Date.now());
810
- io.stdout(`checkpoint committed: ${result.sha}\n`);
2580
+ io.stdout(result.createdCommit ? `checkpoint committed: ${result.sha}\n` : `checkpoint recorded at existing clean HEAD: ${result.sha}\n`);
811
2581
  if (result.artifacts.length > 0) {
812
2582
  io.stdout(`warning: ${result.artifacts.length} build-artifact-looking file(s) swept in (bare tsc emission? real build output belongs in lib/):\n`);
813
2583
  for (const file of result.artifacts.slice(0, 5)) io.stdout(` ${file}\n`);
@@ -830,7 +2600,7 @@ async function runCli(argv, io) {
830
2600
  return 0;
831
2601
  }
832
2602
  case "canary": {
833
- const verdict = verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), options.maxAgeMinutes);
2603
+ const verdict = verifyRepoCredential(stateDir, repoDir, options.maxAgeMinutes);
834
2604
  io.stdout(`verify: ${verdict.ok ? "PASS" : "FAIL"} — ${verdict.reason}\n`);
835
2605
  let ok = verdict.ok;
836
2606
  if (options.port !== void 0) {
@@ -841,8 +2611,61 @@ async function runCli(argv, io) {
841
2611
  io.stdout(ok ? "canary PASS\n" : "canary FAIL\n");
842
2612
  return ok ? 0 : 1;
843
2613
  }
2614
+ case "verify-restart": {
2615
+ if (restartMarkerState(stateDir) !== "fresh") {
2616
+ io.stderr("restart authorization is missing or stale\n");
2617
+ return 1;
2618
+ }
2619
+ const launch = readLaunchState(stateDir);
2620
+ if (launch === null) {
2621
+ io.stderr("restart authorization cannot be verified without durable launch state\n");
2622
+ return 1;
2623
+ }
2624
+ const spec = selectedLaunchSpec(launch);
2625
+ const marker = readRestartRequestMarker(stateDir);
2626
+ const verdict = marker?.authorization === void 0 ? verifyRepoCredential(stateDir, spec.credentialRepo, options.maxAgeMinutes) : verifyRestartAuthorization(stateDir, spec, marker.authorization);
2627
+ io.stdout(`${verdict.ok ? "restart evidence PASS" : "restart evidence FAIL"} — ${verdict.reason}\n`);
2628
+ return verdict.ok ? 0 : 1;
2629
+ }
2630
+ case "record-proven-deployment": {
2631
+ if (restartMarkerState(stateDir) !== "fresh") {
2632
+ io.stderr("deployment proof refused: restart authorization is missing or stale\n");
2633
+ return 1;
2634
+ }
2635
+ const launch = readLaunchState(stateDir);
2636
+ if (launch === null || launch.mode !== "stable") {
2637
+ io.stderr("deployment proof refused: no stable durable launch specification is selected\n");
2638
+ return 1;
2639
+ }
2640
+ let authorization = readRestartRequestMarker(stateDir)?.authorization;
2641
+ if (authorization === void 0) {
2642
+ const credential = loadState(stateDir).credential;
2643
+ const fresh = verifyRepoCredential(stateDir, launch.active.credentialRepo, options.maxAgeMinutes);
2644
+ if (!fresh.ok || credential === void 0) {
2645
+ io.stderr(`deployment proof refused: ${fresh.reason}\n`);
2646
+ return 1;
2647
+ }
2648
+ authorization = {
2649
+ version: 1,
2650
+ kind: "fresh-credential",
2651
+ revision: credential.revision,
2652
+ evidenceSha256: commandSha256(credential.command)
2653
+ };
2654
+ }
2655
+ const result = proveCurrentDeployment(stateDir, launch.active, authorization);
2656
+ (result.ok ? io.stdout : io.stderr)(`${result.ok ? "deployment proof PASS" : "deployment proof FAIL"} — ${result.reason}\n`);
2657
+ return result.ok ? 0 : 1;
2658
+ }
844
2659
  case "preflight": {
845
- const 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);
846
2669
  if (outcome.kind === "unavailable") {
847
2670
  io.stderr("preflight unavailable outside the dsh app layout\n");
848
2671
  return 3;
@@ -862,6 +2685,11 @@ async function runCli(argv, io) {
862
2685
  io.stdout(written ? "[watchdog] adoption takeover — left a report record for the supervising session\n" : "[watchdog] adoption takeover — a report record is still pending, left it untouched\n");
863
2686
  return 0;
864
2687
  }
2688
+ case "record-composition-recovery": {
2689
+ const written = writeCompositionRecovery(stateDir, Date.now(), options.detail);
2690
+ io.stdout(written ? "[watchdog] composition rollback recovery — left a report record for the next session\n" : "[watchdog] composition rollback recovery — a report record is still pending, left it untouched\n");
2691
+ return 0;
2692
+ }
865
2693
  case "check-env": {
866
2694
  const sandboxed = envInternals.sandboxedByProbe();
867
2695
  io.stdout(`sandbox: ${sandboxed ? "SANDBOXED — detached processes are reaped when the turn ends; ask the user for /permission danger-full-access in THIS session" : "unsandboxed (full access)"}\n`);
@@ -900,6 +2728,188 @@ async function runCli(argv, io) {
900
2728
  io.stdout(`git repo: ${currentHead(repoDir) !== null ? `yes (${repoDir})` : `no (${repoDir}) — git init + initial commit before record`}\n`);
901
2729
  return sandboxed ? 1 : 0;
902
2730
  }
2731
+ case "reconfigure": {
2732
+ if (options.start === void 0 || options.start === "") {
2733
+ io.stderr(`reconfigure requires --start "CMD"\n\n${USAGE}`);
2734
+ return 2;
2735
+ }
2736
+ if (options.onFailure === void 0) {
2737
+ io.stderr("reconfigure refused: --on-failure restore-previous|wait-for-user is required so recovery is explicitly approved before the old instance stops\n");
2738
+ return 2;
2739
+ }
2740
+ const inFlightCutover = activeCutover(stateDir);
2741
+ if (inFlightCutover !== null) return refuse("cutover-active", `reconfigure refused: launch cutover ${inFlightCutover.receipt.id} is still ${inFlightCutover.receipt.phase}; inspect it with \`launch-status\` and settle/retry that transaction first\n`);
2742
+ const previous = resolvePreviousSpec(stateDir, io);
2743
+ if (previous === void 0) return refuseQuiet("previous-spec", "reconfigure refused: could not resolve the active launch specification (see stderr)", 2);
2744
+ let target = launchSpec({
2745
+ command: options.start,
2746
+ port: options.port ?? previous.port,
2747
+ home: options.home !== "" ? options.home : previous.home,
2748
+ credentialRepo: options.repoDir !== "" ? repoDir : previous.credentialRepo,
2749
+ harnessRoot: options.harnessRoot !== "" ? options.harnessRoot : previous.harnessRoot,
2750
+ profile: options.profile !== void 0 && options.profile !== "" ? options.profile : previous.profile
2751
+ });
2752
+ try {
2753
+ target = {
2754
+ ...target,
2755
+ preflight: resolvePreflightSpec(options, target.command, target.harnessRoot, true)
2756
+ };
2757
+ } catch (error) {
2758
+ io.stderr(`reconfigure refused: ${error instanceof Error ? error.message : String(error)}\n`);
2759
+ return 2;
2760
+ }
2761
+ if (target.port !== previous.port) return refuse("port-mismatch", `reconfigure refused: online supervisor handoff keeps one authority and port (${previous.port}); target requested ${target.port}. Move ports as a separately supervised deployment, then cut traffic over.\n`, 2);
2762
+ if (sameLaunchSpec(previous, target)) return refuse("identical", "reconfigure refused: target launch specification is identical to the active specification\n", 2);
2763
+ let transitionPlan;
2764
+ if (options.transitionFile !== void 0) {
2765
+ let raw;
2766
+ try {
2767
+ raw = JSON.parse(readFileSync(resolve(options.transitionFile), "utf8"));
2768
+ } catch (error) {
2769
+ io.stderr(`reconfigure refused: transition plan is unreadable: ${String(error)}\n`);
2770
+ return 2;
2771
+ }
2772
+ try {
2773
+ transitionPlan = validateTransitionPlan(raw, previous.home, stateDir);
2774
+ validateTransitionPlan(raw, target.home, stateDir);
2775
+ } catch (error) {
2776
+ io.stderr(`reconfigure refused: ${String(error)}\n`);
2777
+ return 2;
2778
+ }
2779
+ }
2780
+ const previousSupervisorPid = liveWatchdogPid(stateDir);
2781
+ if (previousSupervisorPid === null) return refuse("unsupervised", "reconfigure refused: no live watchdog owns the old instance. Establish supervision first; an online handoff cannot promise continuity without an old supervisor.\n");
2782
+ const gate = verifyRepoCredential(stateDir, target.credentialRepo, options.maxAgeMinutes);
2783
+ if (!gate.ok) return refuse("credential", `reconfigure refused: ${gate.reason}\n`);
2784
+ if (!sandboxGate("reconfigure", options, io)) return refuseQuiet("sandbox", "reconfigure refused: the environment is sandboxed, so the detached replacement supervisor would be reaped mid-flight");
2785
+ const snapshotStartedAt = Date.now();
2786
+ let snapshot;
2787
+ try {
2788
+ snapshot = transitionPlan === void 0 ? createPreflightSnapshot(target.home) : createTransitionPreflightSnapshot(transitionPlan);
2789
+ } catch (error) {
2790
+ return refuse("preflight-snapshot", `reconfigure refused: could not prepare an isolated${transitionPlan === void 0 ? "" : " transitioned"} home: ${String(error)}\n`);
2791
+ }
2792
+ const snapshotMs = Date.now() - snapshotStartedAt;
2793
+ if (snapshotMs > options.maxAgeMinutes * 6e4 / 2) io.stdout(`note: the isolated-home snapshot took ${Math.round(snapshotMs / 1e3)}s — over half the ${options.maxAgeMinutes}min credential window; re-record the credential immediately before reconfigure, and keep the home slim (top-level scratch/ is excluded from the copy)\n`);
2794
+ try {
2795
+ const timeout = options.preflightTimeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS;
2796
+ if (!await candidateProbeGate(target, timeout, io, snapshot.home)) return refuseQuiet("preflight", "reconfigure refused: the candidate probe failed (see stderr)");
2797
+ if (!await preflightGate("reconfigure", target.profile, timeout, io, target.harnessRoot, snapshot.home, target.preflight)) return refuseQuiet("preflight", "reconfigure refused: the composition preflight failed (see stderr for the failing entries)");
2798
+ io.stdout(`${transitionPlan === void 0 ? "candidate" : "filesystem transition"} preflight PASS on an isolated copy of the live home\n`);
2799
+ } finally {
2800
+ snapshot.cleanup();
2801
+ }
2802
+ const lock = acquireRestartLock(stateDir);
2803
+ if (!lock.ok) return refuse("lock", `reconfigure refused: a restart is in flight (pid ${lock.holder})\n`);
2804
+ const cutoverId = `${Date.now()}-${process.pid}`;
2805
+ let driverPid;
2806
+ try {
2807
+ if (restartMarkerState(stateDir) === "fresh") return refuse("marker", "reconfigure refused: a scheduled exit is already pending\n");
2808
+ const initiator = resolveInitiator(options.initiator, io);
2809
+ const previousSupervisor = processIdentity(previousSupervisorPid);
2810
+ if (previousSupervisor === null) throw new Error(`could not capture a start identity for watchdog ${previousSupervisorPid}`);
2811
+ const previousOwned = findOwnedListener(previous.port, previousSupervisorPid);
2812
+ if (previousOwned === null) throw new Error(`the listener on :${previous.port} is not uniquely owned by watchdog ${previousSupervisorPid}; refusing a port-inferred takeover`);
2813
+ if (!processIdentityMatches(previousSupervisor)) throw new Error(`watchdog ${previousSupervisorPid} changed while ownership was captured; refusing a recycled-PID takeover`);
2814
+ const transition = transitionPlan === void 0 ? void 0 : prepareTransition(transitionPlan, previous.home, stateDir, cutoverId);
2815
+ prepareLaunchCutover(stateDir, {
2816
+ id: cutoverId,
2817
+ previous,
2818
+ target,
2819
+ recoveryPolicy: options.onFailure,
2820
+ browserHandoff: options.browserHandoff,
2821
+ previousSupervisorPid,
2822
+ previousSupervisorStartToken: previousSupervisor.startToken,
2823
+ previousOwnership: {
2824
+ childPid: previousOwned.child.pid,
2825
+ childStartToken: previousOwned.child.startToken,
2826
+ listenerPid: previousOwned.listener.pid,
2827
+ listenerStartToken: previousOwned.listener.startToken
2828
+ },
2829
+ ...transition === void 0 ? {} : { transition },
2830
+ ...initiator !== void 0 ? { initiator } : {},
2831
+ now: Date.now()
2832
+ });
2833
+ const logPath = options.log ?? stateFile(stateDir, "watchdogLog");
2834
+ mkdirSync(dirname(logPath), { recursive: true });
2835
+ const driverArgs = [
2836
+ "supervise",
2837
+ "--foreground",
2838
+ "--state-dir",
2839
+ stateDir,
2840
+ "--takeover-from",
2841
+ String(previousSupervisorPid),
2842
+ "--cutover-id",
2843
+ cutoverId,
2844
+ "--delay-ms",
2845
+ String(options.delayMs ?? 5e3),
2846
+ "--supervisor-yield-timeout-ms",
2847
+ String(options.supervisorYieldTimeoutMs ?? 15e3),
2848
+ ...initiator !== void 0 ? ["--initiator", initiator] : []
2849
+ ];
2850
+ const cutoverDriverEnv = { ...process.env };
2851
+ delete cutoverDriverEnv.DSH_ANKH_VERDICT_FILE;
2852
+ const driver = spawn(process.execPath, cliInvocation(driverArgs), {
2853
+ detached: true,
2854
+ stdio: [
2855
+ "ignore",
2856
+ openSync(logPath, "a"),
2857
+ openSync(logPath, "a")
2858
+ ],
2859
+ env: testChildEnv("cutover-supervisor-driver", cutoverDriverEnv, {
2860
+ port: previous.port,
2861
+ tempRoot: stateDir
2862
+ })
2863
+ });
2864
+ registerSpawnedTestProcess(driver, "cutover-supervisor-driver", {
2865
+ port: previous.port,
2866
+ tempRoot: stateDir
2867
+ });
2868
+ driver.unref();
2869
+ driverPid = driver.pid;
2870
+ if (driverPid === void 0) throw new Error("could not detach the replacement supervisor driver");
2871
+ const takeoverDeadline = Date.now() + 15e3;
2872
+ let replacementPid;
2873
+ while (Date.now() < takeoverDeadline) {
2874
+ const receipt = readCutoverReceipt(stateDir);
2875
+ const candidate = receipt?.supervisor.targetPid;
2876
+ const candidateStartToken = receipt?.supervisor.targetStartToken;
2877
+ if (candidate !== void 0 && candidateStartToken !== void 0 && processIdentityMatches({
2878
+ pid: candidate,
2879
+ startToken: candidateStartToken
2880
+ }) && livePidIn(stateFile(stateDir, "watchdogPid")) === String(candidate)) {
2881
+ replacementPid = candidate;
2882
+ break;
2883
+ }
2884
+ try {
2885
+ process.kill(driverPid, 0);
2886
+ } catch {
2887
+ break;
2888
+ }
2889
+ await sleep(100);
2890
+ }
2891
+ if (replacementPid === void 0) throw new Error("replacement watchdog did not claim supervision within 15000 ms");
2892
+ io.stdout(`launch cutover ${cutoverId} prepared: supervisor ${previousSupervisorPid} → ${replacementPid}; the replacement watchdog stops the old child in ${options.delayMs ?? 5e3} ms\nreceipt: ${stateFile(stateDir, "launchCutover")}\n`);
2893
+ return 0;
2894
+ } catch (error) {
2895
+ if (driverPid !== void 0) {
2896
+ try {
2897
+ process.kill(-driverPid, "SIGTERM");
2898
+ } catch {
2899
+ try {
2900
+ process.kill(driverPid, "SIGTERM");
2901
+ } catch {}
2902
+ }
2903
+ await waitForExit(driverPid, 2e3);
2904
+ }
2905
+ try {
2906
+ recordCutoverEvent(stateDir, cutoverId, "prepare-failed", [String(error)], Date.now());
2907
+ } catch {}
2908
+ return refuse("preparation", `reconfigure refused before stopping the old instance: ${String(error)}\n`);
2909
+ } finally {
2910
+ lock.release();
2911
+ }
2912
+ }
903
2913
  case "restart": {
904
2914
  const port = options.port ?? readInstanceLaunch(stateDir)?.port;
905
2915
  if (port === void 0) {
@@ -908,21 +2918,22 @@ async function runCli(argv, io) {
908
2918
  }
909
2919
  const start = resolveStartCommand(options.start, stateDir, "restart", io, port);
910
2920
  if (start === void 0) return 2;
2921
+ const restartCutover = activeCutover(stateDir);
2922
+ if (restartCutover !== null) return refuse("cutover-active", `restart refused: launch cutover ${restartCutover.receipt.id} is ${restartCutover.receipt.phase}; a second stop would violate its recovery policy\n`);
911
2923
  const isDriver = process.env.DSH_ANKH_RESTART_DRIVER === "1";
912
- const gate = verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), options.maxAgeMinutes);
913
- if (!gate.ok) {
914
- io.stderr(`restart refused: ${gate.reason}\n`);
915
- return 1;
916
- }
917
- if (!sandboxGate("restart", options, io)) return 1;
918
- if (!isDriver && !await preflightGate("restart", resolveProfileName(options), options.preflightTimeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS, io, resolveHarnessRoot(options.repoDir))) return 1;
919
- if (restartMarkerState(stateDir) === "fresh") {
920
- io.stderr("restart refused: a scheduled exit is still pending (restart-requested.json) — its exit agent would kill the instance this restart starts; wait for it or remove the stale marker\n");
921
- return 1;
922
- }
2924
+ const gate = verifyRepoCredential(stateDir, repoDir, options.maxAgeMinutes);
2925
+ if (!gate.ok) return refuse("credential", `restart refused: ${gate.reason}\n`);
2926
+ if (!sandboxGate("restart", options, io)) return refuseQuiet("sandbox", "restart refused: the environment is sandboxed, so the detached restart driver would be reaped mid-flight");
2927
+ if (!isDriver && !await preflightGate("restart", resolveProfileName(options), options.preflightTimeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS, io, preflightHarnessRoot(options, stateDir))) return refuseQuiet("preflight", "restart refused: the composition preflight failed (see stderr for the failing entries)");
2928
+ if (restartMarkerState(stateDir) === "fresh") return refuse("marker", "restart refused: a scheduled exit is still pending (restart-requested.json) — its exit agent would kill the instance this restart starts; wait for it or remove the stale marker\n");
923
2929
  if (options.sync !== true && !isDriver) {
924
2930
  const logPath = options.log ?? stateFile(stateDir, "restartLog");
925
2931
  mkdirSync(dirname(logPath), { recursive: true });
2932
+ const restartDriverEnv = {
2933
+ ...process.env,
2934
+ DSH_ANKH_RESTART_DRIVER: "1"
2935
+ };
2936
+ delete restartDriverEnv.DSH_ANKH_VERDICT_FILE;
926
2937
  const driver = spawn(process.execPath, cliInvocation(argv), {
927
2938
  detached: true,
928
2939
  stdio: [
@@ -930,23 +2941,23 @@ async function runCli(argv, io) {
930
2941
  openSync(logPath, "a"),
931
2942
  openSync(logPath, "a")
932
2943
  ],
933
- env: {
934
- ...process.env,
935
- DSH_ANKH_RESTART_DRIVER: "1"
936
- }
2944
+ env: testChildEnv("restart-driver", restartDriverEnv, {
2945
+ port,
2946
+ tempRoot: stateDir
2947
+ })
2948
+ });
2949
+ registerSpawnedTestProcess(driver, "restart-driver", {
2950
+ port,
2951
+ tempRoot: stateDir
937
2952
  });
938
2953
  driver.unref();
939
- if (driver.pid === void 0) {
940
- io.stderr("restart refused: could not detach the restart driver\n");
941
- return 1;
942
- }
2954
+ if (driver.pid === void 0) return refuse("spawn", "restart refused: could not detach the restart driver\n");
943
2955
  const lock = acquireRestartLock(stateDir, driver.pid);
944
2956
  if (!lock.ok) {
945
2957
  try {
946
2958
  process.kill(driver.pid, "SIGKILL");
947
2959
  } catch {}
948
- 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`);
949
- return 1;
2960
+ return refuse("lock", /^\d+$/.test(lock.holder) ? `restart refused: another restart is already in flight (pid ${lock.holder})\n` : `restart refused: cannot claim the restart lock (${lock.holder}) — remove ${stateFile(stateDir, "restartLock")} if it is stale\n`);
950
2961
  }
951
2962
  io.stdout(`restart driver detached (pid ${driver.pid}) — log ${logPath}\nthe instance stops in ${options.delayMs ?? 0} ms and comes back on its own; check the log or \`status\` afterwards\n`);
952
2963
  return 0;
@@ -955,7 +2966,7 @@ async function runCli(argv, io) {
955
2966
  if (options.sync === true) {
956
2967
  const lock = acquireRestartLock(stateDir);
957
2968
  if (!lock.ok) {
958
- 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`);
959
2970
  return 1;
960
2971
  }
961
2972
  restartLock = lock;
@@ -985,12 +2996,21 @@ async function runCli(argv, io) {
985
2996
  const stoppedAt = Date.now();
986
2997
  const startEnv = { ...process.env };
987
2998
  delete startEnv.DSH_ANKH_RESTART_DRIVER;
988
- spawn(start, {
2999
+ for (const key of Object.keys(startEnv)) if (key.startsWith("WD_")) delete startEnv[key];
3000
+ const child = spawn(start, {
989
3001
  shell: true,
990
3002
  detached: true,
991
3003
  stdio: "ignore",
992
- env: startEnv
993
- }).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();
994
3014
  io.stdout(`started: ${start}\n`);
995
3015
  const timeoutMs = options.timeoutMs ?? 6e4;
996
3016
  const deadline = Date.now() + timeoutMs;
@@ -1002,7 +3022,7 @@ async function runCli(argv, io) {
1002
3022
  }
1003
3023
  await sleep(500);
1004
3024
  }
1005
- const initiator = options.initiator ?? process.env.DSH_SESSION_ID;
3025
+ const initiator = resolveInitiator(options.initiator, io);
1006
3026
  if (!listening) {
1007
3027
  io.stderr(`new instance not listening on 127.0.0.1:${port} within ${timeoutMs}ms\n`);
1008
3028
  writeRestartOutcome(stateDir, {
@@ -1014,7 +3034,7 @@ async function runCli(argv, io) {
1014
3034
  if (options.rollback) rollbackToKnownGood(stateDir, repoDir, io);
1015
3035
  return 1;
1016
3036
  }
1017
- const post = verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), options.maxAgeMinutes);
3037
+ const post = verifyRepoCredential(stateDir, repoDir, options.maxAgeMinutes);
1018
3038
  io.stdout(`canary verify: ${post.ok ? "PASS" : "FAIL"} — ${post.reason}\n`);
1019
3039
  io.stdout(`canary port: PASS — listening on 127.0.0.1:${port}\n`);
1020
3040
  if (!post.ok) {
@@ -1040,27 +3060,28 @@ async function runCli(argv, io) {
1040
3060
  }
1041
3061
  }
1042
3062
  case "supervise": {
1043
- const port = options.port;
1044
- if (port === void 0) {
1045
- io.stderr(`supervise requires --port N and --start "CMD"\n\n${USAGE}`);
1046
- 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;
1047
3069
  }
1048
- const start = resolveStartCommand(options.start, stateDir, "supervise", io, port);
1049
- if (start === void 0) return 2;
1050
- writeInstanceLaunchAsSupervisor(stateDir, {
1051
- command: start,
1052
- source: "supervisor",
1053
- supervised: true,
1054
- ...port !== void 0 ? { port } : {},
1055
- recordedAt: Date.now()
1056
- });
1057
- const wdHome = resolveWdHome(options.home);
1058
- if (wdHome === void 0) {
1059
- io.stderr("supervise needs the dsh home: pass --home DIR or set DSH_HOME — the supervised instance reads its profiles/credentials from there, and deriving one from --state-dir would guess wrong\n");
1060
- return 2;
3070
+ if (options.cutoverId === void 0 && transaction?.receipt.phase === "awaiting-user") {
3071
+ io.stderr(`supervise: cutover ${transaction.receipt.id} is waiting for user action; refusing to restart the rejected target automatically (receipt ${stateFile(stateDir, "launchCutover")})\n`);
3072
+ return 0;
1061
3073
  }
1062
3074
  if (options.foreground !== true && !sandboxGate("supervise", options, io)) return 2;
3075
+ if (options.cutoverId !== void 0) try {
3076
+ const driverIdentity = processIdentity(process.pid);
3077
+ if (driverIdentity === null) throw new Error(`could not capture driver ${process.pid} start identity`);
3078
+ recordCutoverEvent(stateDir, options.cutoverId, "driver-started", [String(process.pid), driverIdentity.startToken], Date.now());
3079
+ } catch (error) {
3080
+ io.stderr(`supervise refused: could not persist cutover driver PID: ${String(error)}\n`);
3081
+ return 1;
3082
+ }
1063
3083
  const pidfile = stateFile(stateDir, "watchdogPid");
3084
+ let waitedForWatchdog = false;
1064
3085
  if (existsSync(pidfile)) {
1065
3086
  const existing = readFileSync(pidfile, "utf8").trim();
1066
3087
  const existingPid = Number(existing);
@@ -1072,24 +3093,61 @@ async function runCli(argv, io) {
1072
3093
  existingAlive = false;
1073
3094
  }
1074
3095
  if (existingAlive) {
1075
- if (options.foreground) {
1076
- io.stdout(`watchdog ${existing} already supervises the port — waiting for it to exit, then taking over (foreground)\n`);
1077
- while (true) {
1078
- try {
1079
- process.kill(existingPid, 0);
1080
- } catch {
1081
- break;
1082
- }
1083
- await sleep(1e3);
3096
+ if (options.takeoverFrom !== void 0) {
3097
+ if (existingPid !== options.takeoverFrom) {
3098
+ io.stderr(`supervise takeover refused: expected watchdog ${options.takeoverFrom}, but pidfile names live ${existingPid}\n`);
3099
+ return 1;
1084
3100
  }
1085
- io.stdout(`watchdog ${existing} exited — taking over\n`);
1086
3101
  } else {
1087
- io.stdout(`already supervised by pid ${existing}\n`);
1088
- 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
+ }
1089
3122
  }
1090
3123
  }
1091
3124
  }
1092
3125
  }
3126
+ if (waitedForWatchdog) {
3127
+ const refreshed = resolveSuperviseSpec(options, stateDir, repoDir, io, true);
3128
+ if (refreshed === void 0) return 2;
3129
+ spec = refreshed;
3130
+ transaction = activeCutover(stateDir);
3131
+ if (options.cutoverId !== void 0 && (transaction === null || transaction.receipt.id !== options.cutoverId)) {
3132
+ io.stderr(`supervise refused after wait: cutover ${options.cutoverId} is no longer the selected launch transaction\n`);
3133
+ return 1;
3134
+ }
3135
+ if (options.cutoverId === void 0 && transaction?.receipt.phase === "awaiting-user") {
3136
+ io.stderr(`supervise: cutover ${transaction.receipt.id} settled awaiting-user while this supervisor waited; refusing to restart the rejected target (receipt ${stateFile(stateDir, "launchCutover")})\n`);
3137
+ return 0;
3138
+ }
3139
+ io.stdout("launch state refreshed after wait — using the durable selected specification\n");
3140
+ }
3141
+ const previousOwnership = transaction?.receipt.ownership?.previous;
3142
+ if (transaction !== null && (previousOwnership === void 0 || !Number.isInteger(previousOwnership.childPid) || previousOwnership.childPid <= 0 || !Number.isInteger(previousOwnership.listenerPid) || previousOwnership.listenerPid <= 0 || previousOwnership.childStartToken === "" || previousOwnership.listenerStartToken === "")) {
3143
+ io.stderr(`supervise refused: active cutover ${transaction.receipt.id} predates authoritative child/listener ownership evidence; refusing to infer or kill a process by port. Keep the existing host untouched and settle the transaction explicitly.\n`);
3144
+ return 1;
3145
+ }
3146
+ const previousSupervisorStart = transaction?.receipt.supervisor?.previousStartToken;
3147
+ if (transaction !== null && (previousSupervisorStart === void 0 || previousSupervisorStart === "")) {
3148
+ io.stderr(`supervise refused: active cutover ${transaction.receipt.id} predates supervisor start identity evidence; refusing a PID-only takeover. Keep the existing host untouched and settle the transaction explicitly.\n`);
3149
+ return 1;
3150
+ }
1093
3151
  const watchdog = fileURLToPath(new URL("../scripts/dsh-watchdog.sh", import.meta.url));
1094
3152
  if (!existsSync(watchdog)) {
1095
3153
  io.stderr(`watchdog script not found at ${watchdog}\n`);
@@ -1099,23 +3157,67 @@ async function runCli(argv, io) {
1099
3157
  io.stderr("supervise: --log has no effect with --foreground — output follows the external supervisor's redirection (launchd StandardOutPath / systemd StandardOutput=); drop --log\n");
1100
3158
  return 2;
1101
3159
  }
3160
+ if (transaction === null) {
3161
+ writeStableLaunchSpec(stateDir, spec);
3162
+ writeInstanceLaunchAsSupervisor(stateDir, {
3163
+ command: spec.command,
3164
+ source: "supervisor",
3165
+ supervised: true,
3166
+ port: spec.port,
3167
+ recordedAt: Date.now()
3168
+ });
3169
+ }
3170
+ const supervisorBaseEnv = { ...process.env };
3171
+ delete supervisorBaseEnv.DSH_ANKH_VERDICT_FILE;
3172
+ for (const key of Object.keys(supervisorBaseEnv)) if (key.startsWith("WD_")) delete supervisorBaseEnv[key];
1102
3173
  const env = {
1103
- ...process.env,
1104
- WD_PORT: String(port),
1105
- WD_HOME: wdHome,
3174
+ ...supervisorBaseEnv,
3175
+ WD_PORT: String(spec.port),
3176
+ WD_HOME: spec.home,
1106
3177
  WD_STATE_DIR: stateDir,
1107
- WD_REPO: repoDir,
1108
- WD_START: start,
3178
+ WD_REPO: spec.credentialRepo,
3179
+ WD_HARNESS_ROOT: spec.harnessRoot,
3180
+ WD_START: spec.command,
1109
3181
  DSH_ANKH_SUPERVISED: "1",
1110
- WD_INITIATOR: process.env.DSH_SESSION_ID ?? "",
1111
- WD_ADOPTION: findPidOnPort(port) !== null ? "1" : "0",
1112
- WD_WAIT_OWNER: options.foreground ? "0" : "1",
1113
- 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
+ } : {}
1114
3209
  };
1115
3210
  if (options.foreground) {
1116
3211
  const child = spawn("bash", [watchdog, "--supervise"], {
1117
3212
  stdio: "inherit",
1118
- env
3213
+ env: testChildEnv("watchdog-foreground", env, {
3214
+ port: spec.port,
3215
+ tempRoot: stateDir
3216
+ })
3217
+ });
3218
+ registerSpawnedTestProcess(child, "watchdog-foreground", {
3219
+ port: spec.port,
3220
+ tempRoot: stateDir
1119
3221
  });
1120
3222
  return await new Promise((resolve) => {
1121
3223
  child.on("exit", (c) => {
@@ -1132,27 +3234,81 @@ async function runCli(argv, io) {
1132
3234
  openSync(logPath, "a"),
1133
3235
  openSync(logPath, "a")
1134
3236
  ],
1135
- env
3237
+ env: testChildEnv("watchdog-detached", env, {
3238
+ port: spec.port,
3239
+ tempRoot: stateDir
3240
+ })
3241
+ });
3242
+ registerSpawnedTestProcess(child, "watchdog-detached", {
3243
+ port: spec.port,
3244
+ tempRoot: stateDir
3245
+ });
3246
+ let spawnError;
3247
+ child.once("error", (error) => {
3248
+ spawnError = error;
1136
3249
  });
1137
3250
  child.unref();
1138
- io.stdout(`watchdog spawned (pid ${child.pid ?? "unknown"}) — supervises :${port}, log ${logPath}\n`);
1139
- 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;
1140
3278
  }
1141
3279
  case "schedule-exit": {
1142
- const port = options.port ?? readInstanceLaunch(stateDir)?.port;
1143
3280
  const delayMs = options.delayMs;
1144
- if (port === void 0 || delayMs === void 0) {
1145
- 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}`);
1146
3295
  return 2;
1147
3296
  }
1148
- 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);
1149
3305
  if (!gate.ok) {
1150
3306
  io.stderr(`schedule-exit refused: ${gate.reason}\n`);
1151
3307
  return 1;
1152
3308
  }
3309
+ io.stdout(`restart evidence PASS — ${gate.reason}\n`);
1153
3310
  if (!sandboxGate("schedule-exit", options, io)) return 1;
1154
- if (!await preflightGate("schedule-exit", resolveProfileName(options), options.preflightTimeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS, io, resolveHarnessRoot(options.repoDir))) return 1;
1155
- if (liveWatchdogPid(stateDir) === null) io.stderr(NO_WATCHDOG_HINT);
3311
+ if (!await preflightGate("schedule-exit", profile, options.preflightTimeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS, io, harnessRoot, durableSpec?.home, durableSpec?.preflight)) return 1;
1156
3312
  const lock = acquireRestartLock(stateDir);
1157
3313
  if (!lock.ok) {
1158
3314
  io.stderr(`schedule-exit refused: a restart is in flight (pid ${lock.holder}) — the exit agent would kill the instance it is starting\n`);
@@ -1166,11 +3322,12 @@ async function runCli(argv, io) {
1166
3322
  return 1;
1167
3323
  }
1168
3324
  if (markerState === "stale" && existsSync(markerFile)) io.stderr("warning: overwriting a stale restart marker (a previous schedule never completed)\n");
1169
- const initiator = options.initiator ?? process.env.DSH_SESSION_ID;
3325
+ const initiator = resolveInitiator(options.initiator, io);
1170
3326
  mkdirSync(stateDir, { recursive: true });
1171
3327
  writeFileSync(stateFile(stateDir, "restartRequested"), `${JSON.stringify({
1172
3328
  reason: "scheduled self-restart",
1173
3329
  requestedAt: Date.now(),
3330
+ ...gate.authorization === void 0 ? {} : { authorization: gate.authorization },
1174
3331
  ...initiator !== void 0 ? { initiator } : {}
1175
3332
  })}\n`);
1176
3333
  const resultFile = stateFile(stateDir, "lastRestart");
@@ -1183,16 +3340,23 @@ async function runCli(argv, io) {
1183
3340
  openSync(logPath, "a"),
1184
3341
  openSync(logPath, "a")
1185
3342
  ],
1186
- env: {
3343
+ env: testChildEnv("schedule-exit-agent", {
1187
3344
  ...process.env,
1188
3345
  WD_PORT: String(port),
1189
3346
  WD_DELAY_MS: String(delayMs),
1190
3347
  WD_RESULT_FILE: resultFile,
1191
3348
  ...initiator !== void 0 ? { WD_INITIATOR: initiator } : {}
1192
- }
3349
+ }, {
3350
+ port,
3351
+ tempRoot: stateDir
3352
+ })
3353
+ });
3354
+ registerSpawnedTestProcess(child, "schedule-exit-agent", {
3355
+ port,
3356
+ tempRoot: stateDir
1193
3357
  });
1194
3358
  child.unref();
1195
- io.stdout(`exit scheduled in ${delayMs} ms (agent pid ${child.pid ?? "unknown"}) — watchdog will respawn and run the canary\n`);
3359
+ io.stdout(`exit scheduled in ${delayMs} ms (exit-agent pid ${child.pid ?? "unknown"}) — watchdog will respawn and run the canary\n`);
1196
3360
  return 0;
1197
3361
  } finally {
1198
3362
  lock.release();
@@ -1203,14 +3367,17 @@ async function runCli(argv, io) {
1203
3367
  return 2;
1204
3368
  }
1205
3369
  }
1206
- if (isDirectInvocation(import.meta.url)) runCli(process.argv.slice(2), {
1207
- stdout: (line) => process.stdout.write(line),
1208
- stderr: (line) => process.stderr.write(line)
1209
- }).then((code) => {
1210
- process.exitCode = code;
1211
- }).catch((error) => {
1212
- process.stderr.write(`ankh-guard: ${String(error)}\n`);
1213
- process.exitCode = 1;
1214
- });
3370
+ if (isDirectInvocation(import.meta.url)) {
3371
+ registerCurrentTestProcess();
3372
+ runCli(process.argv.slice(2), {
3373
+ stdout: (line) => process.stdout.write(line),
3374
+ stderr: (line) => process.stderr.write(line)
3375
+ }).then((code) => {
3376
+ process.exitCode = code;
3377
+ }).catch((error) => {
3378
+ process.stderr.write(`ankh-guard: ${String(error)}\n`);
3379
+ process.exitCode = 1;
3380
+ });
3381
+ }
1215
3382
  //#endregion
1216
3383
  export { envInternals, parse, preflightInternals, resolveHarnessRoot, resolvePreflightBin, resolveRunnerCommand, resolveWdHome, runCli, runPreflightCheck };