@khorsheed/dsh-ankh-guard 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.en.md +75 -29
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +74 -29
  5. package/lib/cli.js +2383 -209
  6. package/lib/client.js +257 -0
  7. package/lib/exit-agent.js +5 -2
  8. package/lib/index.js +722 -38
  9. package/lib/invariant.js +1 -1
  10. package/lib/preflight-runner.js +125 -47
  11. package/lib/processes-BjZgJjQr.js +344 -0
  12. package/lib/restart-context-D6nISh28.js +1245 -0
  13. package/lib/restart-context-DUyExi9O.js +1245 -0
  14. package/lib/{state-Dhx9VG44.js → state-4f7yny39.js} +60 -13
  15. package/lib/state-CZMypGkB.js +323 -0
  16. package/lib/test-seam-DnvLWTeO.js +119 -0
  17. package/lib/test-seam-cli.js +24 -0
  18. package/lib/test-seam-dwvaKjRp.js +459 -0
  19. package/lib/test-seam.js +2 -0
  20. package/lib/types/browser-handoff.d.ts +55 -0
  21. package/lib/types/browser-handoff.js +489 -0
  22. package/lib/types/cli.d.ts +34 -4
  23. package/lib/types/cli.js +1487 -225
  24. package/lib/types/client/index.d.ts +15 -0
  25. package/lib/types/client/index.js +264 -0
  26. package/lib/types/deployment-proof.d.ts +24 -0
  27. package/lib/types/deployment-proof.js +314 -0
  28. package/lib/types/exit-agent.js +2 -0
  29. package/lib/types/git.d.ts +12 -3
  30. package/lib/types/git.js +69 -7
  31. package/lib/types/index.d.ts +66 -3
  32. package/lib/types/index.js +157 -39
  33. package/lib/types/launch-spec.d.ts +263 -0
  34. package/lib/types/launch-spec.js +823 -0
  35. package/lib/types/preflight-runner.d.ts +23 -12
  36. package/lib/types/preflight-runner.js +152 -57
  37. package/lib/types/processes.d.ts +38 -6
  38. package/lib/types/processes.js +236 -10
  39. package/lib/types/restart-context.d.ts +50 -0
  40. package/lib/types/restart-context.js +106 -0
  41. package/lib/types/restart-request.d.ts +32 -0
  42. package/lib/types/restart-request.js +128 -0
  43. package/lib/types/state-files.d.ts +30 -0
  44. package/lib/types/state-files.js +55 -0
  45. package/lib/types/state.d.ts +29 -2
  46. package/lib/types/state.js +52 -7
  47. package/lib/types/temp-artifact.d.ts +15 -0
  48. package/lib/types/temp-artifact.js +17 -0
  49. package/lib/types/test-seam-cli.d.ts +3 -0
  50. package/lib/types/test-seam-cli.js +27 -0
  51. package/lib/types/test-seam.d.ts +55 -0
  52. package/lib/types/test-seam.js +112 -0
  53. package/lib/types/transition.d.ts +118 -0
  54. package/lib/types/transition.js +717 -0
  55. package/package.json +29 -9
  56. package/scripts/dsh-watchdog.sh +1388 -80
  57. package/scripts/install-launchd.sh +43 -5
  58. package/scripts/install-systemd.sh +43 -5
  59. package/scripts/on-install.js +1 -1
  60. package/skills/dsh-self-restart-guard/SKILL.md +38 -12
  61. package/lib/processes-hCAmwma-.js +0 -127
  62. package/lib/restart-context-DmnQXNf-.js +0 -421
package/lib/types/cli.js CHANGED
@@ -10,26 +10,33 @@
10
10
  * record — record a green credential for the current HEAD
11
11
  * status — print the full state (credential, checkpoint, audit)
12
12
  * clear — drop the credential
13
- * checkpoint — commit the whole tree as a pre-batch snapshot
13
+ * checkpoint — record clean HEAD, or explicitly commit a reviewed dirty snapshot
14
14
  * reset — `git reset --hard` to a checkpoint commit (rollback)
15
15
  * canary — post-restart probe: verify (+ optional TCP port check)
16
+ * verify-restart — watchdog-facing validation of a scheduled authorization
17
+ * record-proven-deployment — watchdog-facing promotion after canary
16
18
  * restart — DETACHED restart: gate → stop → start → probe → canary.
17
19
  * Owns the whole loop in a process that outlives the restarted
18
20
  * instance, so the post-restart canary runs even though the
19
21
  * instance restart killed the session that used to own it.
20
22
  */
21
23
  import { execFileSync, spawn } from 'node:child_process';
24
+ import { createHash } from 'node:crypto';
22
25
  import { existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
23
26
  import { connect } from 'node:net';
24
27
  import { homedir } from 'node:os';
25
28
  import { dirname, join, resolve, sep } from 'node:path';
26
29
  import { fileURLToPath } from 'node:url';
27
30
  import { isDirectInvocation, resolveRepoDir, resolveStateDir, SRC_ARTIFACT_PATTERN } from "./defaults.js";
28
- import { commitCheckpoint, currentHead, resetToCheckpoint } from "./git.js";
31
+ import { commitCheckpoint, currentHead, isWorkingTreeClean, resetToCheckpoint, workingTreeChanges } from "./git.js";
29
32
  import { clearCredential, loadState, recordCredential, setCheckpoint, verifyCredential, } from "./state.js";
30
- import { lastGoodBootRevision, stateFile } from "./state-files.js";
31
- import { discoverLaunchCommand, findPidOnPort, killPidTree } from "./processes.js";
32
- import { readInstanceLaunch, readSkillRegistration, writeAdoptionRecord, writeInstanceLaunchAsSupervisor, writeRestartOutcome, writeUnexpectedExitRecord } from "./restart-context.js";
33
+ import { proveCurrentDeployment, verifyRestartAuthorization, verifyRestartEvidence, } from "./deployment-proof.js";
34
+ import { lastGoodBootRevision, livePidIn, liveWatchdogPid, pidAlive, stateFile } from "./state-files.js";
35
+ import { discoverLaunchCommand, findOwnedListener, findPidOnPort, killPidTree, processIdentity, processIdentityMatches, } from "./processes.js";
36
+ import { readInstanceLaunch, readSkillRegistration, writeAdoptionRecord, writeCompositionRecovery, writeInstanceLaunchAsSupervisor, writeRestartOutcome, writeUnexpectedExitRecord } from "./restart-context.js";
37
+ import { activeCutover, prepareLaunchCutover, readCutoverReceipt, readLaunchState, recordCutoverEvent, selectedLaunchSpec, summarizeLaunchState, writeCutoverControl, writeStableLaunchSpec, commandSha256, } from "./launch-spec.js";
38
+ import { applyTransition, createPreflightSnapshot, createTransitionPreflightSnapshot, prepareTransition, rollbackTransition, validateTransitionPlan, } from "./transition.js";
39
+ import { appendTestLifecycleEvent, appendTestLifecycleEventForProcess, registerCurrentTestProcess, registerTestProcess, TEST_PROCESS_PORT_ENV, TEST_PROCESS_ROLE_ENV, TEST_PROCESS_TEMP_ROOT_ENV, TEST_RUN_DIR_ENV, } from "./test-seam.js";
33
40
  /**
34
41
  * Printed by the commands every agent-driven restart flow calls before
35
42
  * restarting: the loop spawns detached processes and signals them, which a
@@ -38,9 +45,8 @@ import { readInstanceLaunch, readSkillRegistration, writeAdoptionRecord, writeIn
38
45
  */
39
46
  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';
40
47
  /**
41
- * Printed (by verify/record, and as a refusal-grade warning in schedule-exit)
42
- * while no watchdog supervises the instance: a bare exit now leaves the
43
- * service DOWN — the first-install bootstrap gap.
48
+ * Printed by verify/record while no watchdog supervises the instance. The
49
+ * stop-capable schedule-exit verb has its own hard refusal for this state.
44
50
  */
45
51
  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';
46
52
  /**
@@ -80,20 +86,43 @@ function sandboxGate(verb, options, io) {
80
86
  io.stderr(`${verb} refused: this environment is sandboxed (a probe write outside the workspace was denied), so a detached restart/watchdog process would be reaped when the turn ends. You cannot switch the sandbox yourself — ask the user to run /permission danger-full-access in THIS session (a yes/no "authorization" changes nothing), then verify with \`dsh-ankh-guard check-env\` and retry. Certain the probe is wrong? Re-run with --force\n`);
81
87
  return false;
82
88
  }
83
- /** The live supervising watchdog's pid, or null when none is (pidfile + kill 0). */
84
- function liveWatchdogPid(stateDir) {
85
- try {
86
- const raw = readFileSync(stateFile(stateDir, 'watchdogPid'), 'utf8').trim();
87
- const pid = Number(raw);
88
- // raw '' → 0, and kill(0, 0) always succeeds (it probes our own process
89
- // group): an empty pidfile must read as NO watchdog, never as alive.
90
- if (raw !== '' && Number.isInteger(pid) && pid > 0) {
91
- process.kill(pid, 0);
92
- return pid;
93
- }
89
+ /**
90
+ * Resolve the restart's initiating session. An explicit --initiator that
91
+ * contradicts the shell's own DSH_SESSION_ID routes the wake-up report to a
92
+ * session that is not the caller — observed 2026-08-29: an agent invented a
93
+ * branch-derived slug ('skill-styles-merge'), the report went to a session
94
+ * that does not exist, and the actual scheduler was never woken. Warn loudly;
95
+ * do not refuse — scheduling on behalf of another session is legitimate.
96
+ * @param explicit - the --initiator flag value, when given.
97
+ * @param io - CLI streams.
98
+ * @returns the initiator to record (explicit wins, else the env default).
99
+ */
100
+ function resolveInitiator(explicit, io) {
101
+ const fromEnv = process.env.DSH_SESSION_ID;
102
+ if (explicit !== undefined && explicit !== '' && fromEnv !== undefined && explicit !== fromEnv) {
103
+ 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`);
94
104
  }
95
- catch { /* no pidfile or a dead owner */ }
96
- return null;
105
+ return explicit !== undefined && explicit !== '' ? explicit : fromEnv;
106
+ }
107
+ function testChildEnv(role, env, options = {}) {
108
+ if (process.env[TEST_RUN_DIR_ENV] === undefined)
109
+ return env;
110
+ return {
111
+ ...env,
112
+ [TEST_PROCESS_ROLE_ENV]: role,
113
+ ...(options.port === undefined ? {} : { [TEST_PROCESS_PORT_ENV]: String(options.port) }),
114
+ ...(options.tempRoot === undefined ? {} : { [TEST_PROCESS_TEMP_ROOT_ENV]: options.tempRoot }),
115
+ };
116
+ }
117
+ function registerSpawnedTestProcess(child, role, options = {}) {
118
+ if (child.pid === undefined)
119
+ return;
120
+ registerTestProcess(child.pid, role, {
121
+ source: 'parent-observer',
122
+ ...(options.port === undefined ? {} : { port: options.port }),
123
+ ...(options.tempRoot === undefined ? {} : { tempRoot: options.tempRoot }),
124
+ });
125
+ appendTestLifecycleEventForProcess(child.pid, role, 'child-spawned', { childPid: child.pid, role }, 'parent-observer');
97
126
  }
98
127
  /**
99
128
  * Cross-session restart mutual exclusion: two concurrent restarts would both
@@ -125,14 +154,8 @@ function acquireRestartLock(stateDir, holderPid = process.pid) {
125
154
  catch (error) {
126
155
  return { ok: false, holder: `unreadable (${String(error)})` };
127
156
  }
128
- const pid = Number(holder);
129
- if (holder !== '' && Number.isInteger(pid) && pid > 0) {
130
- try {
131
- process.kill(pid, 0);
132
- return { ok: false, holder };
133
- }
134
- catch { /* dead holder — reclaim below */ }
135
- }
157
+ if (pidAlive(holder))
158
+ return { ok: false, holder };
136
159
  try {
137
160
  unlinkSync(file);
138
161
  }
@@ -171,29 +194,52 @@ function cliInvocation(args) {
171
194
  const USAGE = `usage: dsh-ankh-guard <command> [args] [flags]
172
195
  commands:
173
196
  verify [--state-dir DIR] [--repo DIR] [--max-age MIN]
174
- record <scope> [--command CMD] [--state-dir DIR] [--repo DIR]
197
+ record <scope> [--state-dir DIR] [--repo DIR] --run -- PROGRAM [ARG...]
198
+ record <scope> [--state-dir DIR] [--repo DIR] --trust-command --command CMD
175
199
  status [--state-dir DIR]
176
200
  clear [--state-dir DIR]
177
- checkpoint [--message MSG] [--repo DIR] [--state-dir DIR]
201
+ checkpoint [--message MSG] [--include-dirty] [--repo DIR] [--state-dir DIR]
178
202
  reset <sha> [--repo DIR]
179
203
  canary [--port N] [--state-dir DIR] [--repo DIR] [--max-age MIN]
204
+ verify-restart [--state-dir DIR] # watchdog-facing: revalidate the scheduled authorization
205
+ record-proven-deployment [--state-dir DIR] # watchdog-facing: promote/retain proof after canary
180
206
  check-env [--state-dir DIR] [--repo DIR] # sandbox / watchdog / git readiness probe
181
- preflight [--profile NAME] [--timeout-ms MS]
207
+ preflight [--profile NAME] [--harness-root DIR] [--timeout-ms MS]
208
+ [--preflight-surface source|built --preflight-install-anchor FILE] [--preflight-runner FILE]
182
209
  record-unexpected-exit [--state-dir DIR] # watchdog-facing: record an unplanned-exit recovery
183
210
  record-adoption [--initiator ID] [--state-dir DIR] # watchdog-facing: record the first (adoption) takeover
211
+ record-composition-recovery [--state-dir DIR] # watchdog-facing: record a composition-rollback recovery
212
+ configure-launch --port N --start "CMD" [--home DIR] [--repo DIR] --harness-root DIR [--profile NAME]
213
+ --preflight-surface source|built [--preflight-runner FILE] --preflight-install-anchor FILE [--if-absent]
214
+ launch-status [--state-dir DIR]
215
+ transition-apply CUTOVER_ID [--state-dir DIR] # watchdog-facing: apply the prepared transition
216
+ transition-rollback CUTOVER_ID [--state-dir DIR] # watchdog-facing: restore previous state before previous starts
217
+ abort-cutover [--state-dir DIR] # apply the recovery policy approved by reconfigure
218
+ restore-previous [--state-dir DIR] # explicit new authorization to restore the complete previous spec
219
+ reconfigure --start "CMD" --on-failure restore-previous|wait-for-user [--port N]
220
+ [--home DIR] [--repo DIR] [--harness-root DIR] [--profile NAME] [--browser-handoff required|off]
221
+ --preflight-surface source|built [--preflight-runner FILE] --preflight-install-anchor FILE
222
+ --candidate-probe-command "CMD"
223
+ [--transition-file FILE] [--delay-ms MS] [--supervisor-yield-timeout-ms MS] [--preflight-timeout-ms MS] [--state-dir DIR]
184
224
  restart --port N --start "CMD" [--pid PID] [--timeout-ms MS] [--delay-ms MS] [--stop-timeout-ms MS] [--rollback]
185
- [--profile NAME] [--preflight-timeout-ms MS] [--state-dir DIR] [--repo DIR] [--max-age MIN]
186
- schedule-exit --port N --delay-ms MS [--initiator ID] [--log FILE] [--profile NAME]
187
- [--preflight-timeout-ms MS] [--state-dir DIR] [--repo DIR]
188
- supervise --port N --start "CMD" [--foreground] [--log FILE] [--state-dir DIR] [--repo DIR] [--home DIR]
225
+ [--profile NAME] [--harness-root DIR] [--preflight-timeout-ms MS] [--state-dir DIR] [--repo DIR] [--max-age MIN]
226
+ schedule-exit [--port N] --delay-ms MS [--initiator ID] [--log FILE] [--profile NAME]
227
+ [--harness-root DIR] [--preflight-timeout-ms MS] [--state-dir DIR] [--repo DIR]
228
+ supervise --port N --start "CMD" [--foreground] [--log FILE] [--state-dir DIR] [--repo DIR] [--harness-root DIR] [--home DIR]
189
229
  flags:
190
230
  --state-dir DIR state directory (default: $DSH_HOME/state, else <cwd>/.dsh-guard-state)
191
231
  --repo DIR repository the credential binds to (default: cwd)
232
+ --harness-root DIR dsh host checkout used by preflight and exported to the
233
+ child as DSH_HARNESS; launch-state initialization requires
234
+ this flag or an existing DSH_HARNESS
192
235
  --max-age MIN credential freshness window in minutes (default: 10)
193
236
  --port N canary/restart/supervise: TCP port that must be listening
194
- --command CMD record: the command that produced the green state
237
+ --run -- PROGRAM [ARG...] record: execute this exact argv in --repo and record only on exit 0
238
+ --trust-command record: explicitly trust an external orchestrator's already-green --command
239
+ --command CMD record --trust-command: description of the externally proven command
195
240
  --message MSG checkpoint: batch description
196
- --start "CMD" restart/supervise: the shell command that starts the instance
241
+ --include-dirty checkpoint: after review, explicitly commit every staged, unstaged, and untracked change
242
+ --start "CMD" restart/supervise/reconfigure: the shell command that starts the instance
197
243
  (optional once the plugin has booted — it records the launch
198
244
  command to <state-dir>/instance-launch.json)
199
245
  --pid PID restart: process to stop (default: the listener on --port)
@@ -204,18 +250,37 @@ flags:
204
250
  logs can take tens of seconds to flush)
205
251
  --delay-ms MS restart: sleep before stopping, so the current turn can finish first
206
252
  (agent-driven graceful self-restart: schedule, complete, then restart);
207
- schedule-exit: delay before the detached exit agent kills the host
253
+ schedule-exit: delay before the detached exit agent kills the host;
254
+ reconfigure: grace after successor supervisor claim before old-child stop
208
255
  --log FILE supervise (detached only — with --foreground the external supervisor's
209
256
  redirection owns the log) / schedule-exit: log file (default: <state-dir>/*.log)
210
257
  --home DIR supervise: the dsh home the supervised instance boots with (profiles,
211
258
  credentials — default: $DSH_HOME; required when that is unset)
212
259
  --initiator ID schedule-exit: session id that requested the exit (default: $DSH_SESSION_ID);
213
- recorded in last-restart.json so the restart report returns to that session
214
- --profile NAME preflight/schedule-exit/restart: the dsh profile to dry-run (default:
260
+ recorded in last-restart.json so the restart report returns to that session.
261
+ Do NOT invent a value: a mismatched id routes the wake-up away from you
262
+ (the CLI warns when ID contradicts this shell's $DSH_SESSION_ID)
263
+ --profile NAME preflight/schedule-exit/restart/reconfigure: the dsh profile to dry-run (default:
215
264
  $DSH_PROFILE, else "web")
216
265
  --preflight-timeout-ms MS schedule-exit/restart: bound on the composition preflight (default 120000)
266
+ --preflight-surface MODE configure-launch/reconfigure: explicit successor module surface, source or built
267
+ --preflight-runner FILE runner file to bind by absolute path and SHA-256 (default: this package's matching face)
268
+ --preflight-install-anchor FILE the exact successor dsh package.json; built imports resolve from this npm toolchain
269
+ --candidate-probe-command CMD reconfigure: caller-supplied one-shot probe, durably co-bound with --start SHA-256
217
270
  --rollback restart: on failure, git reset --hard to the recorded checkpoint
218
- --force restart/schedule-exit/supervise: override the sandbox probe refusal
271
+ --on-failure POLICY reconfigure: REQUIRED pre-approved recovery policy:
272
+ restore-previous (restore the complete previous launch spec) or
273
+ wait-for-user (park without resetting a repository)
274
+ --browser-handoff MODE reconfigure: required (default) or off; when a protected
275
+ root announces a same-authority launch URL, readiness requires
276
+ 303 cookie exchange and authenticated / = 200; browser handoff
277
+ separately requires an original/fallback page acknowledgement
278
+ --transition-file FILE reconfigure: a schema-v1 reversible quarantine plan.
279
+ The guard validates and preflights it on an isolated home,
280
+ then applies it only after previous stops; recovery retains
281
+ target-created replacements before restoring previous bytes.
282
+ --if-absent configure-launch: initialize only; keep an existing selected spec
283
+ --force restart/schedule-exit/supervise/reconfigure: override the sandbox probe refusal
219
284
  --sync restart: run the whole loop in-process (debug/tests; the default
220
285
  self-detaches a driver so the loop survives the caller's teardown)
221
286
  `;
@@ -226,10 +291,12 @@ flags:
226
291
  */
227
292
  export function parse(argv) {
228
293
  const options = {
229
- stateDir: '', repoDir: '', home: '', maxAgeMinutes: 10, port: undefined, command: undefined, message: undefined,
230
- start: undefined, pid: undefined, timeoutMs: undefined, delayMs: undefined, stopTimeoutMs: undefined,
294
+ stateDir: '', repoDir: '', harnessRoot: '', home: '', maxAgeMinutes: 10, port: undefined, command: undefined, run: false, runArgv: undefined, message: undefined, detail: undefined,
295
+ start: undefined, pid: undefined, timeoutMs: undefined, delayMs: undefined, stopTimeoutMs: undefined, supervisorYieldTimeoutMs: undefined,
231
296
  log: undefined,
232
297
  foreground: false, rollback: false, force: false, sync: false, initiator: undefined, profile: undefined, preflightTimeoutMs: undefined,
298
+ preflightSurface: undefined, preflightRunner: undefined, preflightInstallAnchor: undefined, candidateProbeCommand: undefined,
299
+ onFailure: undefined, browserHandoff: 'required', ifAbsent: false, trustCommand: false, includeDirty: false, takeoverFrom: undefined, cutoverId: undefined, transitionFile: undefined,
233
300
  };
234
301
  const positionals = [];
235
302
  let i = 0;
@@ -243,6 +310,10 @@ export function parse(argv) {
243
310
  try {
244
311
  for (; i < argv.length; i++) {
245
312
  const arg = argv[i] ?? '';
313
+ if (arg === '--') {
314
+ options.runArgv = argv.slice(i + 1);
315
+ break;
316
+ }
246
317
  switch (arg) {
247
318
  case '--state-dir':
248
319
  options.stateDir = flagValue(arg, true) ?? '';
@@ -256,6 +327,10 @@ export function parse(argv) {
256
327
  options.repoDir = flagValue(arg, true) ?? '';
257
328
  i++;
258
329
  break;
330
+ case '--harness-root':
331
+ options.harnessRoot = flagValue(arg, true) ?? '';
332
+ i++;
333
+ break;
259
334
  case '--max-age': {
260
335
  const raw = flagValue(arg, true);
261
336
  const n = Number(raw);
@@ -278,10 +353,23 @@ export function parse(argv) {
278
353
  options.command = flagValue(arg, true) ?? '';
279
354
  i++;
280
355
  break;
356
+ case '--run':
357
+ options.run = true;
358
+ break;
359
+ case '--trust-command':
360
+ options.trustCommand = true;
361
+ break;
362
+ case '--include-dirty':
363
+ options.includeDirty = true;
364
+ break;
281
365
  case '--message':
282
366
  options.message = flagValue(arg, true) ?? '';
283
367
  i++;
284
368
  break;
369
+ case '--detail':
370
+ options.detail = flagValue(arg, true);
371
+ i++;
372
+ break;
285
373
  case '--start':
286
374
  options.start = flagValue(arg, true) ?? '';
287
375
  i++;
@@ -321,6 +409,15 @@ export function parse(argv) {
321
409
  i++;
322
410
  break;
323
411
  }
412
+ case '--supervisor-yield-timeout-ms': {
413
+ const raw = flagValue(arg, true);
414
+ const n = Number(raw);
415
+ if (raw === undefined || !Number.isInteger(n) || n < 100)
416
+ throw new Error('--supervisor-yield-timeout-ms must be an integer >= 100');
417
+ options.supervisorYieldTimeoutMs = n;
418
+ i++;
419
+ break;
420
+ }
324
421
  case '--foreground':
325
422
  options.foreground = true;
326
423
  break;
@@ -341,6 +438,62 @@ export function parse(argv) {
341
438
  i++;
342
439
  break;
343
440
  }
441
+ case '--preflight-surface': {
442
+ const value = flagValue(arg, true);
443
+ if (value !== 'source' && value !== 'built')
444
+ throw new Error('--preflight-surface must be source or built');
445
+ options.preflightSurface = value;
446
+ i++;
447
+ break;
448
+ }
449
+ case '--preflight-runner':
450
+ options.preflightRunner = flagValue(arg, true) ?? '';
451
+ i++;
452
+ break;
453
+ case '--preflight-install-anchor':
454
+ options.preflightInstallAnchor = flagValue(arg, true) ?? '';
455
+ i++;
456
+ break;
457
+ case '--candidate-probe-command':
458
+ options.candidateProbeCommand = flagValue(arg, true) ?? '';
459
+ i++;
460
+ break;
461
+ case '--on-failure': {
462
+ const value = flagValue(arg, true);
463
+ if (value !== 'restore-previous' && value !== 'wait-for-user')
464
+ throw new Error('--on-failure must be restore-previous or wait-for-user');
465
+ options.onFailure = value;
466
+ i++;
467
+ break;
468
+ }
469
+ case '--browser-handoff': {
470
+ const value = flagValue(arg, true);
471
+ if (value !== 'required' && value !== 'off')
472
+ throw new Error('--browser-handoff must be required or off');
473
+ options.browserHandoff = value;
474
+ i++;
475
+ break;
476
+ }
477
+ case '--takeover-from': {
478
+ const raw = flagValue(arg, true);
479
+ const value = Number(raw);
480
+ if (raw === undefined || !Number.isInteger(value) || value <= 0)
481
+ throw new Error('--takeover-from must be a positive pid');
482
+ options.takeoverFrom = value;
483
+ i++;
484
+ break;
485
+ }
486
+ case '--cutover-id':
487
+ options.cutoverId = flagValue(arg, true) ?? '';
488
+ i++;
489
+ break;
490
+ case '--transition-file':
491
+ options.transitionFile = flagValue(arg, true) ?? '';
492
+ i++;
493
+ break;
494
+ case '--if-absent':
495
+ options.ifAbsent = true;
496
+ break;
344
497
  case '--rollback':
345
498
  options.rollback = true;
346
499
  break;
@@ -385,6 +538,53 @@ async function checkPort(port) {
385
538
  async function sleep(ms) {
386
539
  await new Promise((resolve) => { setTimeout(resolve, ms); });
387
540
  }
541
+ /** Execute the exact argv used as credential evidence, streaming diagnostics. */
542
+ async function runCredentialCommand(argv, cwd, io) {
543
+ const executable = argv[0];
544
+ if (executable === undefined || executable === '')
545
+ return { ok: false, detail: 'no program was provided after --' };
546
+ return new Promise((resolvePromise) => {
547
+ let settled = false;
548
+ const settle = (result) => {
549
+ if (settled)
550
+ return;
551
+ settled = true;
552
+ resolvePromise(result);
553
+ };
554
+ let child;
555
+ try {
556
+ child = spawn(executable, argv.slice(1), {
557
+ cwd,
558
+ env: testChildEnv('credential-command', { ...process.env }, { tempRoot: cwd }),
559
+ stdio: ['ignore', 'pipe', 'pipe'],
560
+ });
561
+ registerSpawnedTestProcess(child, 'credential-command', { tempRoot: cwd });
562
+ }
563
+ catch (error) {
564
+ settle({ ok: false, detail: `could not start ${JSON.stringify(executable)}: ${String(error)}` });
565
+ return;
566
+ }
567
+ child.stdout.on('data', (chunk) => { io.stdout(chunk.toString()); });
568
+ child.stderr.on('data', (chunk) => { io.stderr(chunk.toString()); });
569
+ child.once('error', (error) => {
570
+ settle({ ok: false, detail: `could not start ${JSON.stringify(executable)}: ${String(error)}` });
571
+ });
572
+ child.once('exit', (code, signal) => {
573
+ if (code === 0)
574
+ settle({ ok: true });
575
+ else
576
+ settle({ ok: false, detail: signal === null ? `command exited ${code ?? 'without a status'}` : `command was terminated by ${signal}` });
577
+ });
578
+ });
579
+ }
580
+ /** Stable, non-shell rendering for credential audit metadata. */
581
+ function renderArgv(argv) {
582
+ return argv.map(word => JSON.stringify(word)).join(' ');
583
+ }
584
+ /** The credential gate always includes uncommitted and untracked inputs. */
585
+ function verifyRepoCredential(stateDir, repoDir, maxAgeMinutes) {
586
+ return verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), maxAgeMinutes, isWorkingTreeClean(repoDir));
587
+ }
388
588
  /**
389
589
  * How the spawned watchdog should invoke the guard CLI: the built form runs
390
590
  * `node <cli>`; the source form needs tsx with an absolute path (the watchdog
@@ -426,6 +626,29 @@ function exitAgentInvocation() {
426
626
  const DEFAULT_PREFLIGHT_TIMEOUT_MS = 120_000;
427
627
  /** A pending restart marker older than this is stale — its watchdog died mid-flow. */
428
628
  const RESTART_MARKER_TTL_MS = 15 * 60_000;
629
+ function isRestartAuthorization(value) {
630
+ if (typeof value !== 'object' || value === null)
631
+ return false;
632
+ const authorization = value;
633
+ return authorization.version === 1
634
+ && (authorization.kind === 'fresh-credential' || authorization.kind === 'proven-deployment')
635
+ && typeof authorization.revision === 'string' && authorization.revision !== ''
636
+ && typeof authorization.evidenceSha256 === 'string' && /^[a-f0-9]{64}$/.test(authorization.evidenceSha256);
637
+ }
638
+ function readRestartRequestMarker(stateDir) {
639
+ try {
640
+ const marker = JSON.parse(readFileSync(stateFile(stateDir, 'restartRequested'), 'utf8'));
641
+ if (typeof marker !== 'object' || marker === null)
642
+ return null;
643
+ return {
644
+ ...(typeof marker.requestedAt === 'number' ? { requestedAt: marker.requestedAt } : {}),
645
+ ...(isRestartAuthorization(marker.authorization) ? { authorization: marker.authorization } : {}),
646
+ };
647
+ }
648
+ catch {
649
+ return null;
650
+ }
651
+ }
429
652
  /**
430
653
  * The restart marker's state. Every verb that can stop the instance must
431
654
  * consult this (and the restart lock) — a stop right invisible to the other
@@ -435,29 +658,8 @@ function restartMarkerState(stateDir) {
435
658
  const file = stateFile(stateDir, 'restartRequested');
436
659
  if (!existsSync(file))
437
660
  return 'none';
438
- try {
439
- const marker = JSON.parse(readFileSync(file, 'utf8'));
440
- return typeof marker.requestedAt === 'number' && Date.now() - marker.requestedAt <= RESTART_MARKER_TTL_MS ? 'fresh' : 'stale';
441
- }
442
- catch {
443
- return 'stale'; // unparseable is stale by definition
444
- }
445
- }
446
- /** The restart lock's live holder pid (as a string), or null when free/stale. */
447
- function liveRestartLockHolder(stateDir) {
448
- try {
449
- const raw = readFileSync(stateFile(stateDir, 'restartLock'), 'utf8').trim();
450
- const pid = Number(raw);
451
- if (raw !== '' && Number.isInteger(pid) && pid > 0) {
452
- try {
453
- process.kill(pid, 0);
454
- return raw;
455
- }
456
- catch { /* dead holder */ }
457
- }
458
- }
459
- catch { /* no lock file */ }
460
- return null;
661
+ const marker = readRestartRequestMarker(stateDir);
662
+ return marker?.requestedAt !== undefined && Date.now() - marker.requestedAt <= RESTART_MARKER_TTL_MS ? 'fresh' : 'stale';
461
663
  }
462
664
  /** Captured preflight output is diagnostics, not a log — cap it before it can grow without bound. */
463
665
  const PREFLIGHT_OUTPUT_CAP = 64 * 1024;
@@ -493,12 +695,13 @@ export const preflightInternals = {
493
695
  };
494
696
  /**
495
697
  * The harness checkout the live instance boots from (and the preflight
496
- * runner resolves the official published packages from): the `--repo` target
497
- * when given, else `DSH_HARNESS`, else the conventional default.
698
+ * runner resolves the official published packages from): the
699
+ * `--harness-root` target when given, else `DSH_HARNESS`, else the
700
+ * conventional default. Credential repositories never enter this resolver.
498
701
  */
499
- export function resolveHarnessRoot(optionRepoDir, env = process.env) {
500
- if (optionRepoDir !== undefined && optionRepoDir !== '')
501
- return optionRepoDir;
702
+ export function resolveHarnessRoot(optionHarnessRoot, env = process.env) {
703
+ if (optionHarnessRoot !== undefined && optionHarnessRoot !== '')
704
+ return optionHarnessRoot;
502
705
  const fromEnv = env.DSH_HARNESS;
503
706
  return fromEnv !== undefined && fromEnv.trim() !== '' ? fromEnv : join(homedir(), 'code/deepseek-harness');
504
707
  }
@@ -520,7 +723,89 @@ export function resolveRunnerCommand(harnessRoot) {
520
723
  : undefined;
521
724
  if (runner === undefined)
522
725
  return undefined;
523
- return `node --import ${shellQuote(tsx)} ${shellQuote(runner)}`;
726
+ return `node --import ${shellQuote(tsx)} ${shellQuote(runner)} --host-surface source --install-anchor ${shellQuote(join(harnessRoot, 'apps', 'cli', 'package.json'))}`;
727
+ }
728
+ function fileSha256(file) {
729
+ return createHash('sha256').update(readFileSync(file)).digest('hex');
730
+ }
731
+ function killSpawnGroup(pid) {
732
+ if (pid === undefined)
733
+ return;
734
+ try {
735
+ process.kill(-pid, 'SIGKILL');
736
+ }
737
+ catch { /* already exited or platform lacks process groups */ }
738
+ }
739
+ function defaultPreflightRunner(surface) {
740
+ const here = dirname(fileURLToPath(import.meta.url));
741
+ const names = surface === 'source'
742
+ ? [
743
+ join(here, 'preflight-runner.ts'), join(here, '..', 'src', 'preflight-runner.ts'),
744
+ join(here, 'preflight-runner.js'), join(here, '..', 'lib', 'preflight-runner.js'),
745
+ ]
746
+ : [join(here, 'preflight-runner.js'), join(here, '..', 'lib', 'preflight-runner.js')];
747
+ return names.find(file => existsSync(file));
748
+ }
749
+ /** Build and validate the explicit preflight contract persisted with a launch spec. */
750
+ function resolvePreflightSpec(options, command, harnessRoot, requireCandidate) {
751
+ if (options.preflightSurface === undefined) {
752
+ throw new Error('--preflight-surface source|built is required; the guard will not infer the successor execution surface');
753
+ }
754
+ if (options.preflightInstallAnchor === undefined || options.preflightInstallAnchor === '') {
755
+ throw new Error('--preflight-install-anchor FILE is required and must name the successor dsh package.json');
756
+ }
757
+ const installAnchor = resolve(options.preflightInstallAnchor);
758
+ let manifest;
759
+ try {
760
+ manifest = JSON.parse(readFileSync(installAnchor, 'utf8'));
761
+ }
762
+ catch (error) {
763
+ throw new Error(`preflight install anchor is unreadable: ${String(error)}`);
764
+ }
765
+ if (manifest.name !== '@deepseek-ai/dsh') {
766
+ throw new Error('preflight install anchor must be @deepseek-ai/dsh/package.json');
767
+ }
768
+ if (typeof manifest.version !== 'string' || manifest.version === '') {
769
+ throw new Error('preflight install anchor must declare the dsh package version');
770
+ }
771
+ const configuredRunner = options.preflightRunner ?? defaultPreflightRunner(options.preflightSurface);
772
+ if (configuredRunner === undefined || configuredRunner === '') {
773
+ throw new Error(`no ${options.preflightSurface} preflight runner exists; build ankh-guard or pass --preflight-runner FILE`);
774
+ }
775
+ const runnerPath = resolve(configuredRunner);
776
+ if (!existsSync(runnerPath))
777
+ throw new Error(`preflight runner does not exist: ${runnerPath}`);
778
+ if (options.preflightSurface === 'built' && !runnerPath.endsWith('.js')) {
779
+ throw new Error('built preflight requires a JavaScript runner');
780
+ }
781
+ const runnerRuntimeArgs = [];
782
+ if (options.preflightSurface === 'source') {
783
+ const tsx = join(harnessRoot, 'node_modules', 'tsx', 'dist', 'esm', 'index.mjs');
784
+ if (!existsSync(tsx))
785
+ throw new Error(`source preflight requires the target checkout's tsx runtime: ${tsx}`);
786
+ runnerRuntimeArgs.push('--import', tsx);
787
+ }
788
+ const candidateProbeCommand = options.candidateProbeCommand;
789
+ if (requireCandidate && (candidateProbeCommand === undefined || candidateProbeCommand.trim() === '')) {
790
+ throw new Error('--candidate-probe-command CMD is required; the caller must derive it from the target executable/argv before the previous host stops');
791
+ }
792
+ return {
793
+ version: 1,
794
+ surface: options.preflightSurface,
795
+ runnerExecutable: process.execPath,
796
+ runnerRuntimeArgs,
797
+ runnerPath,
798
+ runnerSha256: fileSha256(runnerPath),
799
+ installAnchor,
800
+ installAnchorSha256: fileSha256(installAnchor),
801
+ hostPackageVersion: manifest.version,
802
+ targetCommandSha256: commandSha256(command),
803
+ ...(candidateProbeCommand === undefined || candidateProbeCommand.trim() === '' ? {} : {
804
+ candidateProbeCommand,
805
+ candidateProbeSha256: commandSha256(candidateProbeCommand),
806
+ candidateProbeProvenance: 'caller-supplied',
807
+ }),
808
+ };
524
809
  }
525
810
  /** POSIX single-quote one word for the shell command line. */
526
811
  function shellQuote(word) {
@@ -582,13 +867,45 @@ function redactLaunchCommand(command) {
582
867
  * @param profile - the dsh profile to dry-run.
583
868
  * @param timeoutMs - bound on the whole subprocess run; a timeout kills it.
584
869
  * @param harnessRoot - harness checkout for the runner (default: DSH_HARNESS / ~/code/deepseek-harness).
870
+ * @param home - dsh home the dry-run must read instead of ambient process state.
585
871
  * @returns the classified outcome.
586
872
  */
587
- export async function runPreflightCheck(profile, timeoutMs, harnessRoot) {
588
- const override = process.env.DSH_PREFLIGHT_COMMAND;
873
+ export async function runPreflightCheck(profile, timeoutMs, harnessRoot, home, binding) {
874
+ const override = binding === undefined ? process.env.DSH_PREFLIGHT_COMMAND : undefined;
589
875
  let command;
876
+ let executable;
877
+ let argv = [];
590
878
  let usingRunner = false;
591
- if (override !== undefined && override !== '') {
879
+ if (binding !== undefined) {
880
+ let runnerSha;
881
+ try {
882
+ runnerSha = fileSha256(binding.runnerPath);
883
+ }
884
+ catch {
885
+ return { kind: 'infra-failed', output: '', detail: `the bound preflight runner is unavailable: ${binding.runnerPath}` };
886
+ }
887
+ if (runnerSha !== binding.runnerSha256) {
888
+ return { kind: 'infra-failed', output: '', detail: 'the bound preflight runner changed after launch configuration' };
889
+ }
890
+ try {
891
+ if (fileSha256(binding.installAnchor) !== binding.installAnchorSha256) {
892
+ return { kind: 'infra-failed', output: '', detail: 'the bound dsh install anchor changed after launch configuration' };
893
+ }
894
+ }
895
+ catch {
896
+ return { kind: 'infra-failed', output: '', detail: `the bound dsh install anchor is unavailable: ${binding.installAnchor}` };
897
+ }
898
+ executable = binding.runnerExecutable;
899
+ argv = [
900
+ ...binding.runnerRuntimeArgs,
901
+ binding.runnerPath,
902
+ '--host-surface', binding.surface,
903
+ '--install-anchor', binding.installAnchor,
904
+ '--profile', profile,
905
+ ];
906
+ usingRunner = true;
907
+ }
908
+ else if (override !== undefined && override !== '') {
592
909
  command = override;
593
910
  }
594
911
  else {
@@ -611,9 +928,15 @@ export async function runPreflightCheck(profile, timeoutMs, harnessRoot) {
611
928
  let timedOut = false;
612
929
  // The runner resolves the live harness from DSH_HARNESS; pin it so the
613
930
  // subprocess agrees with the gate even when the caller's env differs.
614
- const child = usingRunner
615
- ? spawn(command, { shell: true, env: { ...process.env, DSH_HARNESS: harnessForRunner } })
616
- : spawn(command, { shell: true });
931
+ const preflightEnv = testChildEnv('composition-preflight', {
932
+ ...process.env,
933
+ DSH_HARNESS: harnessForRunner,
934
+ ...(home === undefined ? {} : { DSH_HOME: home }),
935
+ }, { ...(home === undefined ? {} : { tempRoot: home }) });
936
+ const child = executable === undefined
937
+ ? spawn(command ?? '', { shell: true, env: preflightEnv })
938
+ : spawn(executable, argv, { shell: false, env: preflightEnv });
939
+ registerSpawnedTestProcess(child, 'composition-preflight', { ...(home === undefined ? {} : { tempRoot: home }) });
617
940
  const append = (chunk) => {
618
941
  if (output.length < PREFLIGHT_OUTPUT_CAP)
619
942
  output += chunk.toString('utf8');
@@ -655,6 +978,51 @@ export async function runPreflightCheck(profile, timeoutMs, harnessRoot) {
655
978
  });
656
979
  });
657
980
  }
981
+ /** Execute the caller-supplied one-shot probe under the target home/root. */
982
+ async function runCandidateProbe(binding, targetCommand, timeoutMs, harnessRoot, home) {
983
+ if (binding.targetCommandSha256 !== commandSha256(targetCommand)) {
984
+ return { kind: 'infra-failed', output: '', detail: 'candidate probe is bound to a different target launch command' };
985
+ }
986
+ if (binding.candidateProbeCommand === undefined || binding.candidateProbeSha256 === undefined
987
+ || commandSha256(binding.candidateProbeCommand) !== binding.candidateProbeSha256) {
988
+ return { kind: 'infra-failed', output: '', detail: 'candidate probe command is missing or changed after binding' };
989
+ }
990
+ return await new Promise(resolvePromise => {
991
+ let output = '';
992
+ let timedOut = false;
993
+ const child = spawn(binding.candidateProbeCommand, {
994
+ shell: true,
995
+ detached: true,
996
+ env: testChildEnv('candidate-probe', {
997
+ ...process.env,
998
+ DSH_HARNESS: harnessRoot,
999
+ DSH_HOME: home,
1000
+ ANKH_TARGET_COMMAND_SHA256: binding.targetCommandSha256,
1001
+ }, { tempRoot: home }),
1002
+ });
1003
+ registerSpawnedTestProcess(child, 'candidate-probe', { tempRoot: home });
1004
+ const append = (chunk) => {
1005
+ if (output.length < PREFLIGHT_OUTPUT_CAP)
1006
+ output += chunk.toString('utf8');
1007
+ };
1008
+ child.stdout.on('data', append);
1009
+ child.stderr.on('data', append);
1010
+ const timer = setTimeout(() => {
1011
+ timedOut = true;
1012
+ killSpawnGroup(child.pid);
1013
+ child.kill('SIGKILL');
1014
+ }, timeoutMs);
1015
+ child.on('close', code => {
1016
+ clearTimeout(timer);
1017
+ if (timedOut)
1018
+ resolvePromise({ kind: 'infra-failed', output, detail: `candidate probe timed out after ${timeoutMs} ms` });
1019
+ else if (code === 0)
1020
+ resolvePromise({ kind: 'pass', output });
1021
+ else
1022
+ resolvePromise({ kind: 'composition-failed', output, detail: `candidate probe exited ${String(code)}` });
1023
+ });
1024
+ });
1025
+ }
658
1026
  /** The profile a gated verb dry-runs: the flag, then $DSH_PROFILE, then the deployment default. */
659
1027
  function resolveProfileName(options) {
660
1028
  const flag = options.profile ?? '';
@@ -676,11 +1044,88 @@ export function resolveWdHome(optionHome, env = process.env) {
676
1044
  const fromEnv = env.DSH_HOME;
677
1045
  return fromEnv !== undefined && fromEnv !== '' ? fromEnv : undefined;
678
1046
  }
1047
+ /** A persisted launch spec must never guess which checkout is the host. */
1048
+ function resolveLaunchHarnessRoot(optionHarnessRoot, selected, env = process.env) {
1049
+ if (optionHarnessRoot !== '')
1050
+ return optionHarnessRoot;
1051
+ if (selected !== undefined && selected !== '')
1052
+ return selected;
1053
+ const fromEnv = env.DSH_HARNESS;
1054
+ return fromEnv !== undefined && fromEnv.trim() !== '' ? fromEnv : undefined;
1055
+ }
1056
+ function launchSpec(input) {
1057
+ return {
1058
+ version: 1,
1059
+ command: input.command,
1060
+ port: input.port,
1061
+ home: resolve(input.home),
1062
+ credentialRepo: resolve(input.credentialRepo),
1063
+ harnessRoot: resolve(input.harnessRoot),
1064
+ profile: input.profile,
1065
+ ...(input.preflight === undefined ? {} : { preflight: input.preflight }),
1066
+ };
1067
+ }
1068
+ function sameLaunchSpec(left, right) {
1069
+ return left.command === right.command && left.port === right.port && left.home === right.home
1070
+ && left.credentialRepo === right.credentialRepo && left.harnessRoot === right.harnessRoot
1071
+ && left.profile === right.profile
1072
+ && JSON.stringify(left.preflight) === JSON.stringify(right.preflight);
1073
+ }
1074
+ /** Resolve supervise's complete spec; a post-wait refresh always prefers durable state. */
1075
+ function resolveSuperviseSpec(options, stateDir, repoDir, io, preferDurable = false) {
1076
+ const durable = readLaunchState(stateDir);
1077
+ const selected = durable === null ? undefined : selectedLaunchSpec(durable);
1078
+ const recorded = readInstanceLaunch(stateDir);
1079
+ const port = preferDurable && selected !== undefined ? selected.port : options.port ?? selected?.port ?? recorded?.port;
1080
+ if (port === undefined) {
1081
+ io.stderr(`supervise requires --port N and --start "CMD" on first configuration\n\n${USAGE}`);
1082
+ return undefined;
1083
+ }
1084
+ const command = !preferDurable && options.start !== undefined && options.start !== ''
1085
+ ? options.start
1086
+ : selected?.command ?? resolveStartCommand(undefined, stateDir, 'supervise', io, port);
1087
+ if (command === undefined || command === '') {
1088
+ io.stderr(`supervise requires --port N and --start "CMD" on first configuration\n\n${USAGE}`);
1089
+ return undefined;
1090
+ }
1091
+ const home = !preferDurable && options.home !== '' ? options.home : selected?.home ?? resolveWdHome('');
1092
+ if (home === undefined) {
1093
+ 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');
1094
+ return undefined;
1095
+ }
1096
+ const harnessRoot = resolveLaunchHarnessRoot(!preferDurable ? options.harnessRoot : '', selected?.harnessRoot);
1097
+ if (harnessRoot === undefined) {
1098
+ 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');
1099
+ return undefined;
1100
+ }
1101
+ return launchSpec({
1102
+ command,
1103
+ port,
1104
+ home,
1105
+ credentialRepo: !preferDurable && options.repoDir !== '' ? repoDir : selected?.credentialRepo ?? repoDir,
1106
+ harnessRoot,
1107
+ profile: !preferDurable && options.profile !== undefined && options.profile !== ''
1108
+ ? options.profile
1109
+ : selected?.profile ?? resolveProfileName(options),
1110
+ ...(selected?.preflight === undefined || selected.command !== command ? {} : { preflight: selected.preflight }),
1111
+ });
1112
+ }
1113
+ /** Existing full spec. The legacy launch record lacks both repository roles. */
1114
+ function resolvePreviousSpec(stateDir, io) {
1115
+ const state = readLaunchState(stateDir);
1116
+ if (state !== null)
1117
+ return selectedLaunchSpec(state);
1118
+ 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');
1119
+ return undefined;
1120
+ }
679
1121
  /** The first ~40 lines of captured preflight output, newline-terminated, or empty. */
680
1122
  function summarizeOutput(output) {
681
1123
  if (output.trim() === '')
682
1124
  return '';
683
- const lines = output.split('\n');
1125
+ // A failed candidate or composition can print its one-time browser launch
1126
+ // URL. Diagnostics may name the authority/path, never the bearer value.
1127
+ const redacted = output.replace(/([?&](?:token|grant)=)[^\s&#"']+/gi, '$1<redacted>');
1128
+ const lines = redacted.split('\n');
684
1129
  const kept = lines.length > 41 ? [...lines.slice(0, 40), `… (${lines.length - 40} more lines)`] : lines;
685
1130
  return `${kept.join('\n').replace(/\n+$/, '')}\n`;
686
1131
  }
@@ -693,10 +1138,18 @@ function summarizeOutput(output) {
693
1138
  * @param timeoutMs - bound on the preflight subprocess.
694
1139
  * @param io - output sinks.
695
1140
  * @param harnessRoot - harness checkout for the standalone runner.
1141
+ * @param home - dsh home to dry-run.
696
1142
  * @returns whether the verb may proceed.
697
1143
  */
698
- async function preflightGate(verb, profile, timeoutMs, io, harnessRoot) {
699
- const outcome = await runPreflightCheck(profile, timeoutMs, harnessRoot);
1144
+ async function preflightGate(verb, profile, timeoutMs, io, harnessRoot, home, binding) {
1145
+ // The runner may legitimately consume most of its timeout while cold-loading
1146
+ // a full profile. Announce the blocking stage before awaiting it so a managed
1147
+ // shell with a shorter caller deadline does not report a misleading
1148
+ // "no output" timeout. This line is deliberately free of paths and runner
1149
+ // output: launch URLs and other credential-shaped diagnostics remain inside
1150
+ // the redacted completion path below.
1151
+ io.stdout(`composition preflight START (profile ${JSON.stringify(profile)}, timeout ${timeoutMs} ms)\n`);
1152
+ const outcome = await runPreflightCheck(profile, timeoutMs, harnessRoot, home, binding);
700
1153
  switch (outcome.kind) {
701
1154
  case 'pass':
702
1155
  io.stdout(`composition preflight PASS (profile ${JSON.stringify(profile)})\n`);
@@ -714,6 +1167,64 @@ async function preflightGate(verb, profile, timeoutMs, io, harnessRoot) {
714
1167
  return false;
715
1168
  }
716
1169
  }
1170
+ async function candidateProbeGate(target, timeoutMs, io, home) {
1171
+ if (target.preflight === undefined) {
1172
+ io.stderr('reconfigure refused: target has no explicit candidate probe binding\n');
1173
+ return false;
1174
+ }
1175
+ const outcome = await runCandidateProbe(target.preflight, target.command, timeoutMs, target.harnessRoot, home);
1176
+ if (outcome.kind === 'pass') {
1177
+ io.stdout(`candidate command probe PASS (target command ${target.preflight.targetCommandSha256.slice(0, 16)})\n`);
1178
+ return true;
1179
+ }
1180
+ io.stderr(`reconfigure refused: candidate command probe ${outcome.kind === 'composition-failed' ? 'failed' : 'could not execute'}${outcome.detail === undefined ? '' : ` — ${outcome.detail}`}:\n${summarizeOutput(outcome.output)}`);
1181
+ return false;
1182
+ }
1183
+ /** Same-launch verbs follow the durable host root unless explicitly overridden. */
1184
+ function preflightHarnessRoot(options, stateDir) {
1185
+ if (options.harnessRoot !== '')
1186
+ return resolveHarnessRoot(options.harnessRoot);
1187
+ const state = readLaunchState(stateDir);
1188
+ return state === null ? resolveHarnessRoot(undefined) : selectedLaunchSpec(state).harnessRoot;
1189
+ }
1190
+ /**
1191
+ * A same-launch restart must use the exact durable supervisor configuration.
1192
+ * Explicit flags may confirm that configuration, but may not silently replace
1193
+ * one field while the live watchdog still owns a different command.
1194
+ */
1195
+ function stableScheduleSpec(options, stateDir, resolvedRepoDir, io) {
1196
+ const state = readLaunchState(stateDir);
1197
+ if (state === null)
1198
+ return null;
1199
+ if (state.mode !== 'stable') {
1200
+ io.stderr(`schedule-exit refused: launch state is still in cutover mode (${state.cutoverId}); settle its receipt before a same-launch restart\n`);
1201
+ return undefined;
1202
+ }
1203
+ const active = state.active;
1204
+ const conflicts = [];
1205
+ if (options.port !== undefined && options.port !== active.port)
1206
+ conflicts.push(`port ${options.port} != ${active.port}`);
1207
+ if (options.repoDir !== '' && resolve(resolvedRepoDir) !== resolve(active.credentialRepo)) {
1208
+ conflicts.push(`credential repo ${resolve(resolvedRepoDir)} != ${resolve(active.credentialRepo)}`);
1209
+ }
1210
+ if (options.harnessRoot !== '' && resolve(options.harnessRoot) !== resolve(active.harnessRoot)) {
1211
+ conflicts.push(`harness root ${resolve(options.harnessRoot)} != ${resolve(active.harnessRoot)}`);
1212
+ }
1213
+ if (options.profile !== undefined && options.profile !== '' && options.profile !== active.profile) {
1214
+ conflicts.push(`profile ${options.profile} != ${active.profile}`);
1215
+ }
1216
+ if (conflicts.length > 0) {
1217
+ io.stderr(`schedule-exit refused: explicit flags conflict with the durable active launch specification (${conflicts.join('; ')}). Use reconfigure for launch changes.\n`);
1218
+ return undefined;
1219
+ }
1220
+ const recorded = readInstanceLaunch(stateDir);
1221
+ if (recorded === null || recorded.source !== 'supervisor' || recorded.supervised !== true
1222
+ || recorded.command !== active.command || recorded.port !== active.port) {
1223
+ 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');
1224
+ return undefined;
1225
+ }
1226
+ return active;
1227
+ }
717
1228
  /**
718
1229
  * Wait for a pid to exit; SIGKILL (the whole descendant tree) after the
719
1230
  * deadline. @param onEscalate - invoked right before the SIGKILL, so the
@@ -778,6 +1289,33 @@ function rollbackToKnownGood(stateDir, repoDir, io) {
778
1289
  * @returns the process exit code: 0 ok, 1 gate denied / failure, 2 usage error.
779
1290
  */
780
1291
  export async function runCli(argv, io) {
1292
+ // The FIRST refusal is the verdict; later ones are fallout of the same stop.
1293
+ // The verdict file is a courtesy channel for the service seam — a write
1294
+ // failure changes nothing, the human refusal text stands either way.
1295
+ const verdictFile = process.env.DSH_ANKH_VERDICT_FILE;
1296
+ let recorded;
1297
+ const note = (stage, reason) => {
1298
+ if (recorded !== undefined)
1299
+ return;
1300
+ recorded = { stage, reason };
1301
+ if (verdictFile !== undefined) {
1302
+ try {
1303
+ writeFileSync(verdictFile, `${JSON.stringify(recorded)}\n`, { mode: 0o600 });
1304
+ }
1305
+ catch { /* courtesy channel */ }
1306
+ }
1307
+ };
1308
+ /** Record + print a one-line refusal, preserving the site's exit code. */
1309
+ const refuse = (stage, message, code = 1) => {
1310
+ note(stage, message.trim().split('\n', 1)[0] ?? message.trim());
1311
+ io.stderr(message.endsWith('\n') ? message : `${message}\n`);
1312
+ return code;
1313
+ };
1314
+ /** Record a refusal whose human text a gate already printed. */
1315
+ const refuseQuiet = (stage, reason, code = 1) => {
1316
+ note(stage, reason);
1317
+ return code;
1318
+ };
781
1319
  const parsed = parse(argv);
782
1320
  if ('error' in parsed) {
783
1321
  io.stderr(parsed.error);
@@ -788,7 +1326,11 @@ export async function runCli(argv, io) {
788
1326
  const repoDir = resolveRepoDir(options.repoDir);
789
1327
  switch (command) {
790
1328
  case 'verify': {
791
- const result = verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), options.maxAgeMinutes);
1329
+ const launch = readLaunchState(stateDir);
1330
+ const result = launch?.mode === 'stable'
1331
+ && resolve(launch.active.credentialRepo) === resolve(repoDir)
1332
+ ? verifyRestartEvidence(stateDir, launch.active, options.maxAgeMinutes)
1333
+ : verifyRepoCredential(stateDir, repoDir, options.maxAgeMinutes);
792
1334
  io.stdout(`${result.reason}\n`);
793
1335
  if (result.ok) {
794
1336
  io.stdout(FULL_ACCESS_HINT);
@@ -803,13 +1345,64 @@ export async function runCli(argv, io) {
803
1345
  io.stderr(`record requires a <scope>\n\n${USAGE}`);
804
1346
  return 2;
805
1347
  }
806
- const head = currentHead(repoDir);
807
- if (head === null) {
1348
+ if (positionals.length > 1) {
1349
+ io.stderr(`record accepts one <scope>; put the evidence command after --run --\n\n${USAGE}`);
1350
+ return 2;
1351
+ }
1352
+ if (options.run && options.trustCommand) {
1353
+ io.stderr('record requires exactly one proof mode: --run or --trust-command\n');
1354
+ return 2;
1355
+ }
1356
+ if (options.run && options.command !== undefined) {
1357
+ io.stderr('record --run derives its audit command from the exact argv after --; do not also pass --command\n');
1358
+ return 2;
1359
+ }
1360
+ if (options.runArgv !== undefined && !options.run) {
1361
+ io.stderr('record command argv after -- requires --run\n');
1362
+ return 2;
1363
+ }
1364
+ if (!options.run && !options.trustCommand) {
1365
+ 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');
1366
+ return 2;
1367
+ }
1368
+ if (options.run && (options.runArgv === undefined || options.runArgv.length === 0)) {
1369
+ io.stderr('record --run requires -- PROGRAM [ARG...]\n');
1370
+ return 2;
1371
+ }
1372
+ if (options.trustCommand && (options.command === undefined || options.command.trim() === '')) {
1373
+ io.stderr('record --trust-command requires a non-empty --command description\n');
1374
+ return 2;
1375
+ }
1376
+ const headBefore = currentHead(repoDir);
1377
+ if (headBefore === null) {
808
1378
  io.stderr('cannot record a credential outside a git repository\n');
809
1379
  return 1;
810
1380
  }
811
- recordCredential(stateDir, { scope, revision: head, command: options.command ?? '' }, Date.now());
812
- io.stdout(`recorded green credential: ${scope} @ ${head}\n`);
1381
+ if (!isWorkingTreeClean(repoDir)) {
1382
+ io.stderr('cannot record a credential while the working tree has staged, unstaged, or untracked changes\n');
1383
+ return 1;
1384
+ }
1385
+ let evidenceCommand = options.command ?? '';
1386
+ if (options.run) {
1387
+ const runArgv = options.runArgv ?? [];
1388
+ // A failed/replaced proof attempt must not leave an older credential
1389
+ // available to a subsequent restart command in another session.
1390
+ clearCredential(stateDir, Date.now());
1391
+ evidenceCommand = renderArgv(runArgv);
1392
+ io.stdout(`running credential evidence: ${evidenceCommand}\n`);
1393
+ const evidence = await runCredentialCommand(runArgv, repoDir, io);
1394
+ if (!evidence.ok) {
1395
+ io.stderr(`credential evidence failed: ${evidence.detail}; no credential recorded\n`);
1396
+ return 1;
1397
+ }
1398
+ const headAfter = currentHead(repoDir);
1399
+ if (headAfter !== headBefore || !isWorkingTreeClean(repoDir)) {
1400
+ io.stderr('credential evidence exited 0 but changed HEAD or left the working tree dirty; no credential recorded\n');
1401
+ return 1;
1402
+ }
1403
+ }
1404
+ recordCredential(stateDir, { scope, revision: headBefore, command: evidenceCommand }, Date.now());
1405
+ io.stdout(`recorded green credential: ${scope} @ ${headBefore}${options.trustCommand ? ' (external proof trusted)' : ''}\n`);
813
1406
  io.stdout(FULL_ACCESS_HINT);
814
1407
  if (liveWatchdogPid(stateDir) === null)
815
1408
  io.stderr(NO_WATCHDOG_HINT);
@@ -820,6 +1413,174 @@ export async function runCli(argv, io) {
820
1413
  io.stdout(`${JSON.stringify(state, null, 2)}\n`);
821
1414
  return 0;
822
1415
  }
1416
+ case 'configure-launch': {
1417
+ if (options.ifAbsent && readLaunchState(stateDir) !== null) {
1418
+ io.stdout('launch specification already exists — kept it unchanged (--if-absent)\n');
1419
+ return 0;
1420
+ }
1421
+ if (options.port === undefined || options.start === undefined || options.start === '') {
1422
+ io.stderr(`configure-launch requires --port N and --start "CMD"\n\n${USAGE}`);
1423
+ return 2;
1424
+ }
1425
+ const home = resolveWdHome(options.home);
1426
+ if (home === undefined) {
1427
+ io.stderr('configure-launch requires --home DIR or DSH_HOME\n');
1428
+ return 2;
1429
+ }
1430
+ const harnessRoot = resolveLaunchHarnessRoot(options.harnessRoot);
1431
+ if (harnessRoot === undefined) {
1432
+ io.stderr('configure-launch requires --harness-root DIR or DSH_HARNESS; --repo names the independent credential/rollback repository\n');
1433
+ return 2;
1434
+ }
1435
+ let preflight;
1436
+ try {
1437
+ preflight = resolvePreflightSpec(options, options.start, harnessRoot, false);
1438
+ }
1439
+ catch (error) {
1440
+ io.stderr(`configure-launch refused: ${error instanceof Error ? error.message : String(error)}\n`);
1441
+ return 2;
1442
+ }
1443
+ const spec = launchSpec({
1444
+ command: options.start,
1445
+ port: options.port,
1446
+ home,
1447
+ credentialRepo: repoDir,
1448
+ harnessRoot,
1449
+ profile: resolveProfileName(options),
1450
+ preflight,
1451
+ });
1452
+ const written = writeStableLaunchSpec(stateDir, spec, options.ifAbsent);
1453
+ if (written) {
1454
+ writeInstanceLaunchAsSupervisor(stateDir, {
1455
+ command: spec.command, source: 'supervisor', supervised: true, port: spec.port, recordedAt: Date.now(),
1456
+ });
1457
+ }
1458
+ io.stdout(written
1459
+ ? `launch specification recorded for :${spec.port} (command sha is in launch-status)\n`
1460
+ : 'launch specification already exists — kept it unchanged (--if-absent)\n');
1461
+ return 0;
1462
+ }
1463
+ case 'launch-status': {
1464
+ io.stdout(`${JSON.stringify({ launch: summarizeLaunchState(readLaunchState(stateDir)), receipt: readCutoverReceipt(stateDir) }, null, 2)}\n`);
1465
+ return 0;
1466
+ }
1467
+ case 'transition-apply':
1468
+ case 'transition-rollback': {
1469
+ const id = positionals[0];
1470
+ if (id === undefined || positionals.length !== 1) {
1471
+ io.stderr(`${command} requires one CUTOVER_ID\n`);
1472
+ return 2;
1473
+ }
1474
+ const transaction = activeCutover(stateDir);
1475
+ if (transaction === null || transaction.receipt.id !== id || transaction.state.transition === undefined) {
1476
+ io.stderr(`${command} refused: cutover ${id} has no active prepared transition\n`);
1477
+ return 1;
1478
+ }
1479
+ const reference = transaction.state.transition;
1480
+ const identityIsLive = (pid, startToken) => (pid !== undefined && startToken !== undefined && processIdentityMatches({ pid, startToken }));
1481
+ if (command === 'transition-apply') {
1482
+ if (transaction.state.selected !== 'target') {
1483
+ io.stderr('transition-apply refused: the target launch specification is not selected\n');
1484
+ return 1;
1485
+ }
1486
+ const previous = transaction.receipt.ownership.previous;
1487
+ if (identityIsLive(previous.childPid, previous.childStartToken)
1488
+ || identityIsLive(previous.listenerPid, previous.listenerStartToken)) {
1489
+ io.stderr('transition-apply refused: the proven previous process is still alive\n');
1490
+ return 1;
1491
+ }
1492
+ try {
1493
+ const result = applyTransition(reference, transaction.state.previous.home, stateDir, id);
1494
+ recordCutoverEvent(stateDir, id, 'transition', ['applied', reference.planSha256], Date.now());
1495
+ io.stdout(`transition applied (${result.changed.length} changed, ${result.unchanged.length} unchanged)\n`);
1496
+ return 0;
1497
+ }
1498
+ catch (error) {
1499
+ try {
1500
+ recordCutoverEvent(stateDir, id, 'transition', [
1501
+ 'apply-failed', reference.planSha256, error instanceof Error ? error.message : String(error),
1502
+ ], Date.now());
1503
+ }
1504
+ catch { /* the original transition failure remains authoritative */ }
1505
+ io.stderr(`transition-apply failed: ${String(error)}\n`);
1506
+ return 1;
1507
+ }
1508
+ }
1509
+ const liveTarget = transaction.receipt.attempts.some(attempt => (attempt.role === 'target' && identityIsLive(attempt.childPid, attempt.childStartToken)));
1510
+ const targetOwnership = transaction.receipt.ownership.target;
1511
+ if (liveTarget || (targetOwnership !== undefined
1512
+ && (identityIsLive(targetOwnership.childPid, targetOwnership.childStartToken)
1513
+ || identityIsLive(targetOwnership.listenerPid, targetOwnership.listenerStartToken)))) {
1514
+ io.stderr('transition-rollback refused: a proven target process is still alive\n');
1515
+ return 1;
1516
+ }
1517
+ try {
1518
+ const result = rollbackTransition(reference, transaction.state.previous.home, stateDir, id);
1519
+ recordCutoverEvent(stateDir, id, 'transition', ['rolled-back', reference.planSha256], Date.now());
1520
+ io.stdout(`transition rolled back (${result.changed.length} changed, ${result.unchanged.length} unchanged)\n`);
1521
+ return 0;
1522
+ }
1523
+ catch (error) {
1524
+ try {
1525
+ recordCutoverEvent(stateDir, id, 'transition', [
1526
+ 'rollback-failed', reference.planSha256, error instanceof Error ? error.message : String(error),
1527
+ ], Date.now());
1528
+ }
1529
+ catch { /* the original transition failure remains authoritative */ }
1530
+ io.stderr(`transition-rollback failed: ${String(error)}\n`);
1531
+ return 1;
1532
+ }
1533
+ }
1534
+ case 'abort-cutover':
1535
+ case 'restore-previous': {
1536
+ const transaction = activeCutover(stateDir);
1537
+ if (transaction === null) {
1538
+ io.stderr(`${command} refused: no nonterminal launch cutover is active\n`);
1539
+ return 1;
1540
+ }
1541
+ const watchdogPid = liveWatchdogPid(stateDir);
1542
+ if (watchdogPid === null) {
1543
+ io.stderr(`${command} refused: no live watchdog can consume the durable control request\n`);
1544
+ return 1;
1545
+ }
1546
+ const requested = command === 'restore-previous' ? 'restore-previous' : 'abort';
1547
+ try {
1548
+ const control = writeCutoverControl(stateDir, transaction.receipt.id, requested, Date.now());
1549
+ appendTestLifecycleEvent('control-marker-written', { action: control.action, watchdogPid }, 'parent-observer');
1550
+ try {
1551
+ process.kill(watchdogPid, 'SIGUSR2');
1552
+ appendTestLifecycleEvent('signal-result', { signal: 'SIGUSR2', targetPid: watchdogPid, result: 'sent' }, 'parent-observer');
1553
+ }
1554
+ catch (error) {
1555
+ appendTestLifecycleEvent('signal-result', { signal: 'SIGUSR2', targetPid: watchdogPid, result: String(error) }, 'parent-observer');
1556
+ throw error;
1557
+ }
1558
+ io.stdout(control.action === 'restore-previous'
1559
+ ? `cutover ${control.cutoverId}: explicit restore-previous requested; watchdog ${watchdogPid} will stop only the proven target identity and relaunch the complete previous spec\n`
1560
+ : `cutover ${control.cutoverId}: abort requested; watchdog ${watchdogPid} will apply the pre-approved ${transaction.receipt.recovery.policy} policy\n`);
1561
+ return 0;
1562
+ }
1563
+ catch (error) {
1564
+ io.stderr(`${command} failed: ${String(error)}\n`);
1565
+ return 1;
1566
+ }
1567
+ }
1568
+ case 'cutover-event': {
1569
+ const id = positionals[0];
1570
+ const kind = positionals[1];
1571
+ if (id === undefined || kind === undefined) {
1572
+ io.stderr('cutover-event requires <id> <kind> [values...]\n');
1573
+ return 2;
1574
+ }
1575
+ try {
1576
+ recordCutoverEvent(stateDir, id, kind, positionals.slice(2), Date.now());
1577
+ return 0;
1578
+ }
1579
+ catch (error) {
1580
+ io.stderr(`cutover-event failed: ${String(error)}\n`);
1581
+ return 1;
1582
+ }
1583
+ }
823
1584
  case 'clear': {
824
1585
  clearCredential(stateDir, Date.now());
825
1586
  io.stdout('credential cleared\n');
@@ -827,13 +1588,19 @@ export async function runCli(argv, io) {
827
1588
  }
828
1589
  case 'checkpoint': {
829
1590
  const message = options.message ?? 'batch snapshot';
830
- const result = commitCheckpoint(repoDir, `dsh-ankh-guard checkpoint: ${message}`, SRC_ARTIFACT_PATTERN);
1591
+ const changes = workingTreeChanges(repoDir);
1592
+ if (options.includeDirty && changes !== null && changes.length > 0) {
1593
+ io.stdout(`checkpoint includes ${changes.length} reviewed working-tree change(s)\n`);
1594
+ }
1595
+ const result = commitCheckpoint(repoDir, `dsh-ankh-guard checkpoint: ${message}`, SRC_ARTIFACT_PATTERN, options.includeDirty);
831
1596
  if (!result.ok) {
832
1597
  io.stderr(`${result.error}\n`);
833
1598
  return 1;
834
1599
  }
835
1600
  setCheckpoint(stateDir, { revision: result.sha, message }, Date.now());
836
- io.stdout(`checkpoint committed: ${result.sha}\n`);
1601
+ io.stdout(result.createdCommit
1602
+ ? `checkpoint committed: ${result.sha}\n`
1603
+ : `checkpoint recorded at existing clean HEAD: ${result.sha}\n`);
837
1604
  if (result.artifacts.length > 0) {
838
1605
  io.stdout(`warning: ${result.artifacts.length} build-artifact-looking file(s) swept in (bare tsc emission? real build output belongs in lib/):\n`);
839
1606
  for (const file of result.artifacts.slice(0, 5))
@@ -858,7 +1625,7 @@ export async function runCli(argv, io) {
858
1625
  return 0;
859
1626
  }
860
1627
  case 'canary': {
861
- const verdict = verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), options.maxAgeMinutes);
1628
+ const verdict = verifyRepoCredential(stateDir, repoDir, options.maxAgeMinutes);
862
1629
  io.stdout(`verify: ${verdict.ok ? 'PASS' : 'FAIL'} — ${verdict.reason}\n`);
863
1630
  let ok = verdict.ok;
864
1631
  if (options.port !== undefined) {
@@ -869,8 +1636,70 @@ export async function runCli(argv, io) {
869
1636
  io.stdout(ok ? 'canary PASS\n' : 'canary FAIL\n');
870
1637
  return ok ? 0 : 1;
871
1638
  }
1639
+ case 'verify-restart': {
1640
+ if (restartMarkerState(stateDir) !== 'fresh') {
1641
+ io.stderr('restart authorization is missing or stale\n');
1642
+ return 1;
1643
+ }
1644
+ const launch = readLaunchState(stateDir);
1645
+ if (launch === null) {
1646
+ io.stderr('restart authorization cannot be verified without durable launch state\n');
1647
+ return 1;
1648
+ }
1649
+ const spec = selectedLaunchSpec(launch);
1650
+ const marker = readRestartRequestMarker(stateDir);
1651
+ const verdict = marker?.authorization === undefined
1652
+ ? verifyRepoCredential(stateDir, spec.credentialRepo, options.maxAgeMinutes)
1653
+ : verifyRestartAuthorization(stateDir, spec, marker.authorization);
1654
+ io.stdout(`${verdict.ok ? 'restart evidence PASS' : 'restart evidence FAIL'} — ${verdict.reason}\n`);
1655
+ return verdict.ok ? 0 : 1;
1656
+ }
1657
+ case 'record-proven-deployment': {
1658
+ if (restartMarkerState(stateDir) !== 'fresh') {
1659
+ io.stderr('deployment proof refused: restart authorization is missing or stale\n');
1660
+ return 1;
1661
+ }
1662
+ const launch = readLaunchState(stateDir);
1663
+ if (launch === null || launch.mode !== 'stable') {
1664
+ io.stderr('deployment proof refused: no stable durable launch specification is selected\n');
1665
+ return 1;
1666
+ }
1667
+ const marker = readRestartRequestMarker(stateDir);
1668
+ let authorization = marker?.authorization;
1669
+ if (authorization === undefined) {
1670
+ const state = loadState(stateDir);
1671
+ const credential = state.credential;
1672
+ const fresh = verifyRepoCredential(stateDir, launch.active.credentialRepo, options.maxAgeMinutes);
1673
+ if (!fresh.ok || credential === undefined) {
1674
+ io.stderr(`deployment proof refused: ${fresh.reason}\n`);
1675
+ return 1;
1676
+ }
1677
+ authorization = {
1678
+ version: 1,
1679
+ kind: 'fresh-credential',
1680
+ revision: credential.revision,
1681
+ evidenceSha256: commandSha256(credential.command),
1682
+ };
1683
+ }
1684
+ const result = proveCurrentDeployment(stateDir, launch.active, authorization);
1685
+ const sink = result.ok ? io.stdout : io.stderr;
1686
+ sink(`${result.ok ? 'deployment proof PASS' : 'deployment proof FAIL'} — ${result.reason}\n`);
1687
+ return result.ok ? 0 : 1;
1688
+ }
872
1689
  case 'preflight': {
873
- const outcome = await runPreflightCheck(resolveProfileName(options), options.timeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS, resolveHarnessRoot(options.repoDir));
1690
+ const harnessRoot = resolveHarnessRoot(options.harnessRoot);
1691
+ let binding;
1692
+ if (options.preflightSurface !== undefined || options.preflightInstallAnchor !== undefined
1693
+ || options.preflightRunner !== undefined) {
1694
+ try {
1695
+ binding = resolvePreflightSpec(options, 'standalone-preflight', harnessRoot, false);
1696
+ }
1697
+ catch (error) {
1698
+ io.stderr(`preflight refused: ${error instanceof Error ? error.message : String(error)}\n`);
1699
+ return 2;
1700
+ }
1701
+ }
1702
+ const outcome = await runPreflightCheck(resolveProfileName(options), options.timeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS, harnessRoot, undefined, binding);
874
1703
  if (outcome.kind === 'unavailable') {
875
1704
  io.stderr('preflight unavailable outside the dsh app layout\n');
876
1705
  return 3;
@@ -904,6 +1733,17 @@ export async function runCli(argv, io) {
904
1733
  : '[watchdog] adoption takeover — a report record is still pending, left it untouched\n');
905
1734
  return 0;
906
1735
  }
1736
+ case 'record-composition-recovery': {
1737
+ // Invoked by the watchdog when it recovered repeated boot failures by
1738
+ // restoring the last healthy profile composition (a freshly installed
1739
+ // plugin that kills the real boot is the common case). The service is
1740
+ // up again minus the newest plugin change — reported, never silent.
1741
+ const written = writeCompositionRecovery(stateDir, Date.now(), options.detail);
1742
+ io.stdout(written
1743
+ ? '[watchdog] composition rollback recovery — left a report record for the next session\n'
1744
+ : '[watchdog] composition rollback recovery — a report record is still pending, left it untouched\n');
1745
+ return 0;
1746
+ }
907
1747
  case 'check-env': {
908
1748
  // THE one-call readiness answer for an agent planning a restart: (1) is
909
1749
  // this instance supervised and by whom, (2) what command a restart
@@ -962,6 +1802,230 @@ export async function runCli(argv, io) {
962
1802
  : `no (${repoDir}) — git init + initial commit before record`}\n`);
963
1803
  return sandboxed ? 1 : 0;
964
1804
  }
1805
+ case 'reconfigure': {
1806
+ if (options.start === undefined || options.start === '') {
1807
+ io.stderr(`reconfigure requires --start "CMD"\n\n${USAGE}`);
1808
+ return 2;
1809
+ }
1810
+ if (options.onFailure === undefined) {
1811
+ io.stderr('reconfigure refused: --on-failure restore-previous|wait-for-user is required so recovery is explicitly approved before the old instance stops\n');
1812
+ return 2;
1813
+ }
1814
+ const inFlightCutover = activeCutover(stateDir);
1815
+ if (inFlightCutover !== null) {
1816
+ 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`);
1817
+ }
1818
+ const previous = resolvePreviousSpec(stateDir, io);
1819
+ if (previous === undefined) {
1820
+ return refuseQuiet('previous-spec', 'reconfigure refused: could not resolve the active launch specification (see stderr)', 2);
1821
+ }
1822
+ let target = launchSpec({
1823
+ command: options.start,
1824
+ port: options.port ?? previous.port,
1825
+ home: options.home !== '' ? options.home : previous.home,
1826
+ credentialRepo: options.repoDir !== '' ? repoDir : previous.credentialRepo,
1827
+ harnessRoot: options.harnessRoot !== '' ? options.harnessRoot : previous.harnessRoot,
1828
+ profile: options.profile !== undefined && options.profile !== '' ? options.profile : previous.profile,
1829
+ });
1830
+ try {
1831
+ target = { ...target, preflight: resolvePreflightSpec(options, target.command, target.harnessRoot, true) };
1832
+ }
1833
+ catch (error) {
1834
+ io.stderr(`reconfigure refused: ${error instanceof Error ? error.message : String(error)}\n`);
1835
+ return 2;
1836
+ }
1837
+ if (target.port !== previous.port) {
1838
+ 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);
1839
+ }
1840
+ if (sameLaunchSpec(previous, target)) {
1841
+ return refuse('identical', 'reconfigure refused: target launch specification is identical to the active specification\n', 2);
1842
+ }
1843
+ let transitionPlan;
1844
+ if (options.transitionFile !== undefined) {
1845
+ let raw;
1846
+ try {
1847
+ raw = JSON.parse(readFileSync(resolve(options.transitionFile), 'utf8'));
1848
+ }
1849
+ catch (error) {
1850
+ io.stderr(`reconfigure refused: transition plan is unreadable: ${String(error)}\n`);
1851
+ return 2;
1852
+ }
1853
+ try {
1854
+ transitionPlan = validateTransitionPlan(raw, previous.home, stateDir);
1855
+ validateTransitionPlan(raw, target.home, stateDir);
1856
+ }
1857
+ catch (error) {
1858
+ io.stderr(`reconfigure refused: ${String(error)}\n`);
1859
+ return 2;
1860
+ }
1861
+ }
1862
+ const previousSupervisorPid = liveWatchdogPid(stateDir);
1863
+ if (previousSupervisorPid === null) {
1864
+ 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');
1865
+ }
1866
+ const gate = verifyRepoCredential(stateDir, target.credentialRepo, options.maxAgeMinutes);
1867
+ if (!gate.ok) {
1868
+ return refuse('credential', `reconfigure refused: ${gate.reason}\n`);
1869
+ }
1870
+ if (!sandboxGate('reconfigure', options, io)) {
1871
+ return refuseQuiet('sandbox', 'reconfigure refused: the environment is sandboxed, so the detached replacement supervisor would be reaped mid-flight');
1872
+ }
1873
+ const snapshotStartedAt = Date.now();
1874
+ let snapshot;
1875
+ try {
1876
+ snapshot = transitionPlan === undefined
1877
+ ? createPreflightSnapshot(target.home)
1878
+ : createTransitionPreflightSnapshot(transitionPlan);
1879
+ }
1880
+ catch (error) {
1881
+ return refuse('preflight-snapshot', `reconfigure refused: could not prepare an isolated${transitionPlan === undefined ? '' : ' transitioned'} home: ${String(error)}\n`);
1882
+ }
1883
+ // A large home copy eats the credential's freshness window: the post-boot
1884
+ // canary revalidates the same credential, so a slow prepare can expire it
1885
+ // mid-cutover and force a restore (observed with a 24 GB scratch tree —
1886
+ // scratch/ is now excluded; warn early when the remaining copy is slow).
1887
+ const snapshotMs = Date.now() - snapshotStartedAt;
1888
+ if (snapshotMs > options.maxAgeMinutes * 60_000 / 2) {
1889
+ io.stdout(`note: the isolated-home snapshot took ${Math.round(snapshotMs / 1000)}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`);
1890
+ }
1891
+ try {
1892
+ const timeout = options.preflightTimeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS;
1893
+ if (!(await candidateProbeGate(target, timeout, io, snapshot.home))) {
1894
+ return refuseQuiet('preflight', 'reconfigure refused: the candidate probe failed (see stderr)');
1895
+ }
1896
+ if (!(await preflightGate('reconfigure', target.profile, timeout, io, target.harnessRoot, snapshot.home, target.preflight))) {
1897
+ return refuseQuiet('preflight', 'reconfigure refused: the composition preflight failed (see stderr for the failing entries)');
1898
+ }
1899
+ io.stdout(`${transitionPlan === undefined ? 'candidate' : 'filesystem transition'} preflight PASS on an isolated copy of the live home\n`);
1900
+ }
1901
+ finally {
1902
+ snapshot.cleanup();
1903
+ }
1904
+ const lock = acquireRestartLock(stateDir);
1905
+ if (!lock.ok) {
1906
+ return refuse('lock', `reconfigure refused: a restart is in flight (pid ${lock.holder})\n`);
1907
+ }
1908
+ const cutoverId = `${Date.now()}-${process.pid}`;
1909
+ let driverPid;
1910
+ try {
1911
+ if (restartMarkerState(stateDir) === 'fresh') {
1912
+ return refuse('marker', 'reconfigure refused: a scheduled exit is already pending\n');
1913
+ }
1914
+ const initiator = resolveInitiator(options.initiator, io);
1915
+ const previousSupervisor = processIdentity(previousSupervisorPid);
1916
+ if (previousSupervisor === null) {
1917
+ throw new Error(`could not capture a start identity for watchdog ${previousSupervisorPid}`);
1918
+ }
1919
+ const previousOwned = findOwnedListener(previous.port, previousSupervisorPid);
1920
+ if (previousOwned === null) {
1921
+ throw new Error(`the listener on :${previous.port} is not uniquely owned by watchdog ${previousSupervisorPid}; refusing a port-inferred takeover`);
1922
+ }
1923
+ if (!processIdentityMatches(previousSupervisor)) {
1924
+ throw new Error(`watchdog ${previousSupervisorPid} changed while ownership was captured; refusing a recycled-PID takeover`);
1925
+ }
1926
+ const transition = transitionPlan === undefined
1927
+ ? undefined
1928
+ : prepareTransition(transitionPlan, previous.home, stateDir, cutoverId);
1929
+ prepareLaunchCutover(stateDir, {
1930
+ id: cutoverId,
1931
+ previous,
1932
+ target,
1933
+ recoveryPolicy: options.onFailure,
1934
+ browserHandoff: options.browserHandoff,
1935
+ previousSupervisorPid,
1936
+ previousSupervisorStartToken: previousSupervisor.startToken,
1937
+ previousOwnership: {
1938
+ childPid: previousOwned.child.pid,
1939
+ childStartToken: previousOwned.child.startToken,
1940
+ listenerPid: previousOwned.listener.pid,
1941
+ listenerStartToken: previousOwned.listener.startToken,
1942
+ },
1943
+ ...(transition === undefined ? {} : { transition }),
1944
+ ...(initiator !== undefined ? { initiator } : {}),
1945
+ now: Date.now(),
1946
+ });
1947
+ // A detached foreground-supervise DRIVER keeps the new watchdog as its
1948
+ // child after the old host exits. The watchdog first replaces the
1949
+ // pidfile claim; the old watchdog's existing yield rule then exits
1950
+ // without reaping its child. Only after that claim is observed do we
1951
+ // schedule the child exit below.
1952
+ const logPath = options.log ?? stateFile(stateDir, 'watchdogLog');
1953
+ mkdirSync(dirname(logPath), { recursive: true });
1954
+ const driverArgs = [
1955
+ 'supervise', '--foreground', '--state-dir', stateDir,
1956
+ '--takeover-from', String(previousSupervisorPid), '--cutover-id', cutoverId,
1957
+ '--delay-ms', String(options.delayMs ?? 5000),
1958
+ '--supervisor-yield-timeout-ms', String(options.supervisorYieldTimeoutMs ?? 15_000),
1959
+ ...(initiator !== undefined ? ['--initiator', initiator] : []),
1960
+ ];
1961
+ const cutoverDriverEnv = { ...process.env };
1962
+ // Same verdict-file hygiene as the restart driver: the caller-side
1963
+ // verdict is the caller's; this long-lived driver must not rewrite it.
1964
+ delete cutoverDriverEnv.DSH_ANKH_VERDICT_FILE;
1965
+ const driver = spawn(process.execPath, cliInvocation(driverArgs), {
1966
+ detached: true,
1967
+ stdio: ['ignore', openSync(logPath, 'a'), openSync(logPath, 'a')],
1968
+ env: testChildEnv('cutover-supervisor-driver', cutoverDriverEnv, { port: previous.port, tempRoot: stateDir }),
1969
+ });
1970
+ registerSpawnedTestProcess(driver, 'cutover-supervisor-driver', { port: previous.port, tempRoot: stateDir });
1971
+ driver.unref();
1972
+ driverPid = driver.pid;
1973
+ if (driverPid === undefined)
1974
+ throw new Error('could not detach the replacement supervisor driver');
1975
+ const takeoverDeadline = Date.now() + 15_000;
1976
+ let replacementPid;
1977
+ while (Date.now() < takeoverDeadline) {
1978
+ const receipt = readCutoverReceipt(stateDir);
1979
+ const candidate = receipt?.supervisor.targetPid;
1980
+ const candidateStartToken = receipt?.supervisor.targetStartToken;
1981
+ if (candidate !== undefined && candidateStartToken !== undefined
1982
+ && processIdentityMatches({ pid: candidate, startToken: candidateStartToken })
1983
+ && livePidIn(stateFile(stateDir, 'watchdogPid')) === String(candidate)) {
1984
+ replacementPid = candidate;
1985
+ break;
1986
+ }
1987
+ // Both proofs are required: the pidfile is the ownership commit,
1988
+ // and the receipt binds that PID to its start identity. Never fall
1989
+ // back to accepting an arbitrary new live pidfile owner.
1990
+ try {
1991
+ process.kill(driverPid, 0);
1992
+ }
1993
+ catch {
1994
+ break;
1995
+ }
1996
+ await sleep(100);
1997
+ }
1998
+ if (replacementPid === undefined)
1999
+ throw new Error('replacement watchdog did not claim supervision within 15000 ms');
2000
+ io.stdout(`launch cutover ${cutoverId} prepared: supervisor ${previousSupervisorPid} → ${replacementPid}; the replacement watchdog stops the old child in ${options.delayMs ?? 5000} ms\nreceipt: ${stateFile(stateDir, 'launchCutover')}\n`);
2001
+ return 0;
2002
+ }
2003
+ catch (error) {
2004
+ if (driverPid !== undefined) {
2005
+ try {
2006
+ process.kill(-driverPid, 'SIGTERM');
2007
+ }
2008
+ catch {
2009
+ try {
2010
+ process.kill(driverPid, 'SIGTERM');
2011
+ }
2012
+ catch { /* already gone */ }
2013
+ }
2014
+ // Let the driver's watchdog finish cleanup before writing the
2015
+ // terminal preparation failure. Two atomic read-modify-write events
2016
+ // racing here could otherwise resurrect a nonterminal receipt.
2017
+ await waitForExit(driverPid, 2_000);
2018
+ }
2019
+ try {
2020
+ recordCutoverEvent(stateDir, cutoverId, 'prepare-failed', [String(error)], Date.now());
2021
+ }
2022
+ catch { /* preparation may have failed before the receipt */ }
2023
+ return refuse('preparation', `reconfigure refused before stopping the old instance: ${String(error)}\n`);
2024
+ }
2025
+ finally {
2026
+ lock.release();
2027
+ }
2028
+ }
965
2029
  case 'restart': {
966
2030
  const port = options.port ?? readInstanceLaunch(stateDir)?.port;
967
2031
  if (port === undefined) {
@@ -972,28 +2036,31 @@ export async function runCli(argv, io) {
972
2036
  if (start === undefined) {
973
2037
  return 2;
974
2038
  }
2039
+ const restartCutover = activeCutover(stateDir);
2040
+ if (restartCutover !== null) {
2041
+ return refuse('cutover-active', `restart refused: launch cutover ${restartCutover.receipt.id} is ${restartCutover.receipt.phase}; a second stop would violate its recovery policy\n`);
2042
+ }
975
2043
  const isDriver = process.env.DSH_ANKH_RESTART_DRIVER === '1';
976
2044
  // THE GATE: never stop an instance on a denial.
977
- const gate = verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), options.maxAgeMinutes);
2045
+ const gate = verifyRepoCredential(stateDir, repoDir, options.maxAgeMinutes);
978
2046
  if (!gate.ok) {
979
- io.stderr(`restart refused: ${gate.reason}\n`);
980
- return 1;
2047
+ return refuse('credential', `restart refused: ${gate.reason}\n`);
981
2048
  }
982
2049
  // THE ENVIRONMENT GATE: a sandboxed turn reaps the detached restart
983
2050
  // mid-flight — refuse before anything is stopped.
984
- if (!sandboxGate('restart', options, io))
985
- return 1;
2051
+ if (!sandboxGate('restart', options, io)) {
2052
+ return refuseQuiet('sandbox', 'restart refused: the environment is sandboxed, so the detached restart driver would be reaped mid-flight');
2053
+ }
986
2054
  // THE COMPOSITION GATE (caller side only — the detached driver inherits
987
2055
  // a composition the caller already proved; re-running it would double a
988
2056
  // minute-long dry-run). A green build does not prove the profile boots.
989
- if (!isDriver && !(await preflightGate('restart', resolveProfileName(options), options.preflightTimeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS, io, resolveHarnessRoot(options.repoDir)))) {
990
- return 1;
2057
+ if (!isDriver && !(await preflightGate('restart', resolveProfileName(options), options.preflightTimeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS, io, preflightHarnessRoot(options, stateDir)))) {
2058
+ return refuseQuiet('preflight', 'restart refused: the composition preflight failed (see stderr for the failing entries)');
991
2059
  }
992
2060
  // See every other pending stop before becoming one: a scheduled exit's
993
2061
  // agent would SIGTERM the instance this restart starts.
994
2062
  if (restartMarkerState(stateDir) === 'fresh') {
995
- 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');
996
- return 1;
2063
+ 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');
997
2064
  }
998
2065
  if (options.sync !== true && !isDriver) {
999
2066
  // SELF-DETACH: the stop→start→canary half must outlive the caller. A
@@ -1003,15 +2070,19 @@ export async function runCli(argv, io) {
1003
2070
  // exit agent and the watchdog, provably survives.
1004
2071
  const logPath = options.log ?? stateFile(stateDir, 'restartLog');
1005
2072
  mkdirSync(dirname(logPath), { recursive: true });
2073
+ const restartDriverEnv = { ...process.env, DSH_ANKH_RESTART_DRIVER: '1' };
2074
+ // The caller-side verdict belongs to the caller: a later driver-side
2075
+ // refusal must not overwrite it after the caller already returned.
2076
+ delete restartDriverEnv.DSH_ANKH_VERDICT_FILE;
1006
2077
  const driver = spawn(process.execPath, cliInvocation(argv), {
1007
2078
  detached: true,
1008
2079
  stdio: ['ignore', openSync(logPath, 'a'), openSync(logPath, 'a')],
1009
- env: { ...process.env, DSH_ANKH_RESTART_DRIVER: '1' },
2080
+ env: testChildEnv('restart-driver', restartDriverEnv, { port, tempRoot: stateDir }),
1010
2081
  });
2082
+ registerSpawnedTestProcess(driver, 'restart-driver', { port, tempRoot: stateDir });
1011
2083
  driver.unref();
1012
2084
  if (driver.pid === undefined) {
1013
- io.stderr('restart refused: could not detach the restart driver\n');
1014
- return 1;
2085
+ return refuse('spawn', 'restart refused: could not detach the restart driver\n');
1015
2086
  }
1016
2087
  // ONE restart at a time across sessions: the lock names the DRIVER
1017
2088
  // (it outlives this caller by design); a live holder refuses.
@@ -1021,10 +2092,9 @@ export async function runCli(argv, io) {
1021
2092
  process.kill(driver.pid, 'SIGKILL');
1022
2093
  }
1023
2094
  catch { /* already gone */ }
1024
- io.stderr(/^\d+$/.test(lock.holder)
2095
+ return refuse('lock', /^\d+$/.test(lock.holder)
1025
2096
  ? `restart refused: another restart is already in flight (pid ${lock.holder})\n`
1026
2097
  : `restart refused: cannot claim the restart lock (${lock.holder}) — remove ${stateFile(stateDir, 'restartLock')} if it is stale\n`);
1027
- return 1;
1028
2098
  }
1029
2099
  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`);
1030
2100
  return 0;
@@ -1036,7 +2106,7 @@ export async function runCli(argv, io) {
1036
2106
  if (options.sync === true) {
1037
2107
  const lock = acquireRestartLock(stateDir);
1038
2108
  if (!lock.ok) {
1039
- io.stderr(/^\d+$/.test(lock.holder)
2109
+ refuse('lock', /^\d+$/.test(lock.holder)
1040
2110
  ? `restart refused: another restart is already in flight (pid ${lock.holder})\n`
1041
2111
  : `restart refused: cannot claim the restart lock (${lock.holder}) — remove ${stateFile(stateDir, 'restartLock')} if it is stale\n`);
1042
2112
  return 1;
@@ -1074,9 +2144,24 @@ export async function runCli(argv, io) {
1074
2144
  });
1075
2145
  io.stdout(`stopped ${pid}${exited ? '' : ' (forced)'}\n`);
1076
2146
  const stoppedAt = Date.now();
2147
+ // The new instance must not inherit this caller's supervision
2148
+ // variables: a restart driven from inside a supervised instance's
2149
+ // agent session carries that instance's WD_* (the watchdog spawns
2150
+ // the instance with its own environment), and forwarding them leaks
2151
+ // them into the new instance's shells — a leaked WD_STATE_DIR
2152
+ // retargets any watchdog script those shells spawn. The watchdog's
2153
+ // own launch_instance applies the same scrub.
1077
2154
  const startEnv = { ...process.env };
1078
2155
  delete startEnv.DSH_ANKH_RESTART_DRIVER;
1079
- const child = spawn(start, { shell: true, detached: true, stdio: 'ignore', env: startEnv });
2156
+ for (const key of Object.keys(startEnv)) {
2157
+ if (key.startsWith('WD_'))
2158
+ delete startEnv[key];
2159
+ }
2160
+ const child = spawn(start, {
2161
+ shell: true, detached: true, stdio: 'ignore',
2162
+ env: testChildEnv('restart-instance-root', startEnv, { port, tempRoot: stateDir }),
2163
+ });
2164
+ registerSpawnedTestProcess(child, 'restart-instance-root', { port, tempRoot: stateDir });
1080
2165
  child.unref();
1081
2166
  io.stdout(`started: ${start}\n`);
1082
2167
  const timeoutMs = options.timeoutMs ?? 60_000;
@@ -1092,7 +2177,7 @@ export async function runCli(argv, io) {
1092
2177
  // The restart verb must not be invisible to the report machinery:
1093
2178
  // record the outcome (the exit agent's semantics) so the next boot's
1094
2179
  // pendingRestartRecord delivers the report to its initiator.
1095
- const initiator = options.initiator ?? process.env.DSH_SESSION_ID;
2180
+ const initiator = resolveInitiator(options.initiator, io);
1096
2181
  if (!listening) {
1097
2182
  io.stderr(`new instance not listening on 127.0.0.1:${port} within ${timeoutMs}ms\n`);
1098
2183
  writeRestartOutcome(stateDir, { exitAt: stoppedAt, pid: pidNumber, error: `new instance not listening on :${port}`, ...(initiator !== undefined ? { initiator } : {}) });
@@ -1100,7 +2185,7 @@ export async function runCli(argv, io) {
1100
2185
  rollbackToKnownGood(stateDir, repoDir, io);
1101
2186
  return 1;
1102
2187
  }
1103
- const post = verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), options.maxAgeMinutes);
2188
+ const post = verifyRepoCredential(stateDir, repoDir, options.maxAgeMinutes);
1104
2189
  io.stdout(`canary verify: ${post.ok ? 'PASS' : 'FAIL'} — ${post.reason}\n`);
1105
2190
  io.stdout(`canary port: PASS — listening on 127.0.0.1:${port}\n`);
1106
2191
  if (!post.ok) {
@@ -1121,32 +2206,42 @@ export async function runCli(argv, io) {
1121
2206
  }
1122
2207
  }
1123
2208
  case 'supervise': {
1124
- const port = options.port;
1125
- if (port === undefined) {
1126
- io.stderr(`supervise requires --port N and --start "CMD"\n\n${USAGE}`);
1127
- return 2;
1128
- }
1129
- const start = resolveStartCommand(options.start, stateDir, 'supervise', io, port);
1130
- if (start === undefined) {
2209
+ let spec = resolveSuperviseSpec(options, stateDir, repoDir, io);
2210
+ if (spec === undefined)
1131
2211
  return 2;
2212
+ // An OS supervisor restarting after the replacement watchdog itself
2213
+ // crashes must resume the durable transaction. Treating that start as
2214
+ // ordinary would compact selected target into stable and discard the
2215
+ // pre-approved recovery contract.
2216
+ let transaction = activeCutover(stateDir);
2217
+ if (options.cutoverId !== undefined && (transaction === null || transaction.receipt.id !== options.cutoverId)) {
2218
+ io.stderr(`supervise refused: cutover ${options.cutoverId} is not the selected launch transaction\n`);
2219
+ return 1;
1132
2220
  }
1133
- // The supervisor's record is authoritative: the FULL chain (watchdog +
1134
- // launch wrapper), not the inner argv the instance would self-record.
1135
- writeInstanceLaunchAsSupervisor(stateDir, { command: start, source: 'supervisor', supervised: true, ...(port !== undefined ? { port } : {}), recordedAt: Date.now() });
1136
- // The supervised instance boots with THIS home (the watchdog exports it
1137
- // as DSH_HOME): a home derived from the state dir would silently point
1138
- // the instance at the wrong profiles/credentials, surfacing far from
1139
- // the cause — so a missing home is a loud misconfiguration, not a guess.
1140
- const wdHome = resolveWdHome(options.home);
1141
- if (wdHome === undefined) {
1142
- 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');
1143
- return 2;
2221
+ if (options.cutoverId === undefined && transaction?.receipt.phase === 'awaiting-user') {
2222
+ io.stderr(`supervise: cutover ${transaction.receipt.id} is waiting for user action; refusing to restart the rejected target automatically (receipt ${stateFile(stateDir, 'launchCutover')})\n`);
2223
+ return 0;
1144
2224
  }
1145
2225
  // A detached watchdog spawned from a sandboxed turn is reaped with it —
1146
2226
  // refuse before claiming anything. Foreground mode is driven by the
1147
2227
  // external supervisor (launchd/systemd) and stays exempt.
1148
2228
  if (options.foreground !== true && !sandboxGate('supervise', options, io))
1149
2229
  return 2;
2230
+ if (options.cutoverId !== undefined) {
2231
+ try {
2232
+ // The driver records itself before spawning the watchdog. Every
2233
+ // later receipt update then comes from this ordered process tree;
2234
+ // the reconfigure caller only observes the pidfile handoff.
2235
+ const driverIdentity = processIdentity(process.pid);
2236
+ if (driverIdentity === null)
2237
+ throw new Error(`could not capture driver ${process.pid} start identity`);
2238
+ recordCutoverEvent(stateDir, options.cutoverId, 'driver-started', [String(process.pid), driverIdentity.startToken], Date.now());
2239
+ }
2240
+ catch (error) {
2241
+ io.stderr(`supervise refused: could not persist cutover driver PID: ${String(error)}\n`);
2242
+ return 1;
2243
+ }
2244
+ }
1150
2245
  // One state directory owns every marker and the pidfile; the plugin,
1151
2246
  // this CLI, and the watchdog must agree on it. Deriving a home from
1152
2247
  // stateDir and re-appending 'state' breaks whenever stateDir is not
@@ -1154,6 +2249,7 @@ export async function runCli(argv, io) {
1154
2249
  // '<cwd>/.dsh-guard-state' fallback): the CLI would write '<cwd>/state'
1155
2250
  // while the plugin reads '<cwd>/.dsh-guard-state'.
1156
2251
  const pidfile = stateFile(stateDir, 'watchdogPid');
2252
+ let waitedForWatchdog = false;
1157
2253
  if (existsSync(pidfile)) {
1158
2254
  const existing = readFileSync(pidfile, 'utf8').trim();
1159
2255
  const existingPid = Number(existing);
@@ -1166,34 +2262,82 @@ export async function runCli(argv, io) {
1166
2262
  existingAlive = false; // stale pidfile — fall through and spawn
1167
2263
  }
1168
2264
  if (existingAlive) {
1169
- if (options.foreground) {
1170
- // Foreground = an external supervisor (launchd KeepAlive) runs
1171
- // THIS process. Exiting 0 here would read as an intentional stop
1172
- // under `KeepAlive SuccessfulExit: false`, so the job would go
1173
- // idle and never restart the CLI — silently leaving the OTHER
1174
- // watchdog unsupervised, i.e. a quiet regression to the
1175
- // single-point-of-failure shape. Instead, wait for it to exit
1176
- // and then take over: the chain (supervisor → this CLI →
1177
- // watchdog) stays intact the whole time.
1178
- io.stdout(`watchdog ${existing} already supervises the port — waiting for it to exit, then taking over (foreground)\n`);
1179
- while (true) {
1180
- try {
1181
- process.kill(existingPid, 0);
1182
- }
1183
- catch {
1184
- break;
1185
- }
1186
- await sleep(1000);
2265
+ if (options.takeoverFrom !== undefined) {
2266
+ if (existingPid !== options.takeoverFrom) {
2267
+ io.stderr(`supervise takeover refused: expected watchdog ${options.takeoverFrom}, but pidfile names live ${existingPid}\n`);
2268
+ return 1;
1187
2269
  }
1188
- io.stdout(`watchdog ${existing} exited — taking over\n`);
2270
+ // Continue: the new watchdog performs an atomic pidfile replace.
1189
2271
  }
1190
2272
  else {
1191
- io.stdout(`already supervised by pid ${existing}\n`);
1192
- return 0;
2273
+ const durable = readLaunchState(stateDir);
2274
+ if (options.start !== undefined && durable !== null && !sameLaunchSpec(spec, selectedLaunchSpec(durable))) {
2275
+ 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');
2276
+ return 1;
2277
+ }
2278
+ if (durable === null)
2279
+ writeStableLaunchSpec(stateDir, spec);
2280
+ if (options.foreground) {
2281
+ // Foreground = an external supervisor (launchd KeepAlive) runs
2282
+ // THIS process. Exiting 0 here would read as an intentional stop
2283
+ // under `KeepAlive SuccessfulExit: false`, so the job would go
2284
+ // idle and never restart the CLI — silently leaving the OTHER
2285
+ // watchdog unsupervised, i.e. a quiet regression to the
2286
+ // single-point-of-failure shape. Instead, wait for it to exit
2287
+ // and then take over: the chain (supervisor → this CLI →
2288
+ // watchdog) stays intact the whole time.
2289
+ io.stdout(`watchdog ${existing} already supervises the port — waiting for it to exit, then taking over (foreground)\n`);
2290
+ const existingIdentity = processIdentity(existingPid);
2291
+ if (existingIdentity === null) {
2292
+ io.stderr(`supervise refused: could not capture watchdog ${existingPid} start identity before waiting\n`);
2293
+ return 1;
2294
+ }
2295
+ while (processIdentityMatches(existingIdentity))
2296
+ await sleep(1000);
2297
+ waitedForWatchdog = true;
2298
+ io.stdout(`watchdog ${existing} exited — taking over\n`);
2299
+ }
2300
+ else {
2301
+ io.stdout(`already supervised by pid ${existing}\n`);
2302
+ return 0;
2303
+ }
1193
2304
  }
1194
2305
  }
1195
2306
  }
1196
2307
  }
2308
+ if (waitedForWatchdog) {
2309
+ // The successor can settle the cutover while this launchd/systemd
2310
+ // process waits. Its pre-wait target snapshot is stale at that point:
2311
+ // reread both the atomically selected spec and receipt before spawning
2312
+ // anything, and ignore installer-time flags when durable state exists.
2313
+ const refreshed = resolveSuperviseSpec(options, stateDir, repoDir, io, true);
2314
+ if (refreshed === undefined)
2315
+ return 2;
2316
+ spec = refreshed;
2317
+ transaction = activeCutover(stateDir);
2318
+ if (options.cutoverId !== undefined && (transaction === null || transaction.receipt.id !== options.cutoverId)) {
2319
+ io.stderr(`supervise refused after wait: cutover ${options.cutoverId} is no longer the selected launch transaction\n`);
2320
+ return 1;
2321
+ }
2322
+ if (options.cutoverId === undefined && transaction?.receipt.phase === 'awaiting-user') {
2323
+ 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`);
2324
+ return 0;
2325
+ }
2326
+ io.stdout('launch state refreshed after wait — using the durable selected specification\n');
2327
+ }
2328
+ const previousOwnership = transaction?.receipt.ownership?.previous;
2329
+ if (transaction !== null && (previousOwnership === undefined
2330
+ || !Number.isInteger(previousOwnership.childPid) || previousOwnership.childPid <= 0
2331
+ || !Number.isInteger(previousOwnership.listenerPid) || previousOwnership.listenerPid <= 0
2332
+ || previousOwnership.childStartToken === '' || previousOwnership.listenerStartToken === '')) {
2333
+ 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`);
2334
+ return 1;
2335
+ }
2336
+ const previousSupervisorStart = transaction?.receipt.supervisor?.previousStartToken;
2337
+ if (transaction !== null && (previousSupervisorStart === undefined || previousSupervisorStart === '')) {
2338
+ 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`);
2339
+ return 1;
2340
+ }
1197
2341
  const watchdog = fileURLToPath(new URL('../scripts/dsh-watchdog.sh', import.meta.url));
1198
2342
  if (!existsSync(watchdog)) {
1199
2343
  io.stderr(`watchdog script not found at ${watchdog}\n`);
@@ -1207,13 +2351,34 @@ export async function runCli(argv, io) {
1207
2351
  io.stderr('supervise: --log has no effect with --foreground — output follows the external supervisor\'s redirection (launchd StandardOutPath / systemd StandardOutput=); drop --log\n');
1208
2352
  return 2;
1209
2353
  }
2354
+ if (transaction === null) {
2355
+ writeStableLaunchSpec(stateDir, spec);
2356
+ // The supervisor's record is authoritative: the FULL chain (watchdog
2357
+ // + launch wrapper), not the inner argv the instance self-records.
2358
+ writeInstanceLaunchAsSupervisor(stateDir, {
2359
+ command: spec.command, source: 'supervisor', supervised: true, port: spec.port, recordedAt: Date.now(),
2360
+ });
2361
+ }
2362
+ // A caller may itself live inside (or debug) another supervisor. Only
2363
+ // the values resolved above may configure this watchdog; ambient WD_*
2364
+ // must not turn an ordinary spawn into a takeover or point it at a
2365
+ // foreign state directory.
2366
+ const supervisorBaseEnv = { ...process.env };
2367
+ // A caller-side verdict file belongs to that caller, not to the
2368
+ // long-lived watchdog this spawn becomes.
2369
+ delete supervisorBaseEnv.DSH_ANKH_VERDICT_FILE;
2370
+ for (const key of Object.keys(supervisorBaseEnv)) {
2371
+ if (key.startsWith('WD_'))
2372
+ delete supervisorBaseEnv[key];
2373
+ }
1210
2374
  const env = {
1211
- ...process.env,
1212
- WD_PORT: String(port),
1213
- WD_HOME: wdHome,
2375
+ ...supervisorBaseEnv,
2376
+ WD_PORT: String(spec.port),
2377
+ WD_HOME: spec.home,
1214
2378
  WD_STATE_DIR: stateDir,
1215
- WD_REPO: repoDir,
1216
- WD_START: start,
2379
+ WD_REPO: spec.credentialRepo,
2380
+ WD_HARNESS_ROOT: spec.harnessRoot,
2381
+ WD_START: spec.command,
1217
2382
  // Let the instance mark its own launch record as supervised (the
1218
2383
  // watchdog passes its env to the instance it spawns).
1219
2384
  DSH_ANKH_SUPERVISED: '1',
@@ -1221,15 +2386,42 @@ export async function runCli(argv, io) {
1221
2386
  // takeover reports back to it (record-adoption). Empty for
1222
2387
  // human-driven supervise runs — the record then waits for the first
1223
2388
  // root agent created.
1224
- WD_INITIATOR: process.env.DSH_SESSION_ID ?? '',
2389
+ WD_INITIATOR: options.initiator ?? process.env.DSH_SESSION_ID ?? '',
1225
2390
  // Adoption vs first-ever boot, decided HERE — race-free: by the time
1226
2391
  // a spawned watchdog would probe the port, the owner may already be
1227
2392
  // gone.
1228
- WD_ADOPTION: findPidOnPort(port) !== null ? '1' : '0',
2393
+ WD_ADOPTION: transaction === null && findPidOnPort(spec.port) !== null ? '1' : '0',
2394
+ // The profile whose composition inputs the watchdog snapshots at
2395
+ // healthy boots (and restores on out-of-repo boot failures).
2396
+ WD_PROFILE: spec.profile,
1229
2397
  // Foreground (launchd-supervised) mode: the watchdog owns the port by
1230
2398
  // adoption; the detached form waits for the current owner to exit.
1231
- WD_WAIT_OWNER: options.foreground ? '0' : '1',
2399
+ WD_WAIT_OWNER: options.takeoverFrom !== undefined || !options.foreground ? '1' : '0',
1232
2400
  WD_GUARD: guardInvocation(),
2401
+ ...(options.takeoverFrom !== undefined ? {
2402
+ WD_TAKEOVER_FROM: String(options.takeoverFrom),
2403
+ WD_TAKEOVER_FROM_START: previousSupervisorStart ?? '',
2404
+ } : {}),
2405
+ ...(transaction !== null && previousOwnership !== undefined ? {
2406
+ WD_CUTOVER_ID: transaction.receipt.id,
2407
+ WD_CUTOVER_ROLE: transaction.state.selected,
2408
+ WD_CUTOVER_POLICY: transaction.receipt.recovery.policy,
2409
+ WD_BROWSER_HANDOFF: transaction.receipt.authentication.browserHandoff === 'off' ? 'off' : 'required',
2410
+ WD_CUTOVER_DELAY_SECONDS: String((options.delayMs ?? 5000) / 1000),
2411
+ WD_SUPERVISOR_YIELD_TIMEOUT_MS: String(options.supervisorYieldTimeoutMs ?? 15_000),
2412
+ WD_PREVIOUS_START: transaction.state.previous.command,
2413
+ WD_PREVIOUS_HOME: transaction.state.previous.home,
2414
+ WD_PREVIOUS_REPO: transaction.state.previous.credentialRepo,
2415
+ WD_PREVIOUS_HARNESS_ROOT: transaction.state.previous.harnessRoot,
2416
+ WD_PREVIOUS_PROFILE: transaction.state.previous.profile,
2417
+ WD_PREVIOUS_CHILD_PID: String(previousOwnership.childPid),
2418
+ WD_PREVIOUS_CHILD_START: previousOwnership.childStartToken,
2419
+ WD_PREVIOUS_LISTENER_PID: String(previousOwnership.listenerPid),
2420
+ WD_PREVIOUS_LISTENER_START: previousOwnership.listenerStartToken,
2421
+ ...(transaction.state.transition === undefined ? {} : {
2422
+ WD_TRANSITION_PLAN_SHA256: transaction.state.transition.planSha256,
2423
+ }),
2424
+ } : {}),
1233
2425
  };
1234
2426
  if (options.foreground) {
1235
2427
  // Run the watchdog inline: the CLI process stays alive as the
@@ -1238,8 +2430,9 @@ export async function runCli(argv, io) {
1238
2430
  // exits with the watchdog so a dead watchdog triggers a restart.
1239
2431
  const child = spawn('bash', [watchdog, '--supervise'], {
1240
2432
  stdio: 'inherit',
1241
- env,
2433
+ env: testChildEnv('watchdog-foreground', env, { port: spec.port, tempRoot: stateDir }),
1242
2434
  });
2435
+ registerSpawnedTestProcess(child, 'watchdog-foreground', { port: spec.port, tempRoot: stateDir });
1243
2436
  const code = await new Promise((resolve) => {
1244
2437
  child.on('exit', (c) => { resolve(c ?? 1); });
1245
2438
  });
@@ -1250,96 +2443,164 @@ export async function runCli(argv, io) {
1250
2443
  const child = spawn('bash', [watchdog, '--supervise'], {
1251
2444
  detached: true,
1252
2445
  stdio: ['ignore', openSync(logPath, 'a'), openSync(logPath, 'a')],
1253
- env,
2446
+ env: testChildEnv('watchdog-detached', env, { port: spec.port, tempRoot: stateDir }),
1254
2447
  });
2448
+ registerSpawnedTestProcess(child, 'watchdog-detached', { port: spec.port, tempRoot: stateDir });
2449
+ let spawnError;
2450
+ child.once('error', (error) => { spawnError = error; });
1255
2451
  child.unref();
1256
- io.stdout(`watchdog spawned (pid ${child.pid ?? 'unknown'}) — supervises :${port}, log ${logPath}\n`);
1257
- return 0;
2452
+ const spawnedPid = child.pid;
2453
+ if (spawnedPid === undefined) {
2454
+ io.stderr('supervise refused: watchdog process has no pid\n');
2455
+ return 1;
2456
+ }
2457
+ // Returning before the pidfile claim creates a dangerous API race: an
2458
+ // immediate schedule-exit sees no owner. Wait until the detached child
2459
+ // has durably claimed supervision (or failed) before reporting success.
2460
+ const claimDeadline = Date.now() + 5_000;
2461
+ while (Date.now() < claimDeadline && spawnError === undefined) {
2462
+ if (liveWatchdogPid(stateDir) === spawnedPid) {
2463
+ io.stdout(`watchdog spawned and ready (pid ${spawnedPid}) — supervises :${spec.port}, log ${logPath}\n`);
2464
+ return 0;
2465
+ }
2466
+ try {
2467
+ process.kill(spawnedPid, 0);
2468
+ }
2469
+ catch {
2470
+ break;
2471
+ }
2472
+ await sleep(50);
2473
+ }
2474
+ try {
2475
+ process.kill(-spawnedPid, 'SIGTERM');
2476
+ }
2477
+ catch {
2478
+ try {
2479
+ process.kill(spawnedPid, 'SIGTERM');
2480
+ }
2481
+ catch { /* already gone */ }
2482
+ }
2483
+ io.stderr(`supervise refused: watchdog ${spawnedPid} did not claim ${pidfile} within 5000 ms${spawnError === undefined ? '' : ` (${String(spawnError)})`}; inspect ${logPath}\n`);
2484
+ return 1;
1258
2485
  }
1259
2486
  case 'schedule-exit': {
1260
- const port = options.port ?? readInstanceLaunch(stateDir)?.port;
1261
2487
  const delayMs = options.delayMs;
1262
- if (port === undefined || delayMs === undefined) {
1263
- io.stderr(`schedule-exit requires --port N and --delay-ms MS\n\n${USAGE}`);
2488
+ if (delayMs === undefined) {
2489
+ io.stderr(`schedule-exit requires --delay-ms MS (and --port N before durable launch configuration exists)\n\n${USAGE}`);
2490
+ return 2;
2491
+ }
2492
+ const scheduledCutover = activeCutover(stateDir);
2493
+ if (scheduledCutover !== null) {
2494
+ io.stderr(`schedule-exit refused: launch cutover ${scheduledCutover.receipt.id} is ${scheduledCutover.receipt.phase}; let that transaction settle before scheduling another stop\n`);
2495
+ return 1;
2496
+ }
2497
+ const durableSpec = stableScheduleSpec(options, stateDir, repoDir, io);
2498
+ if (durableSpec === undefined)
2499
+ return 1;
2500
+ const port = durableSpec?.port ?? options.port ?? readInstanceLaunch(stateDir)?.port;
2501
+ if (port === undefined) {
2502
+ io.stderr(`schedule-exit requires --port N before a durable launch specification exists\n\n${USAGE}`);
1264
2503
  return 2;
1265
2504
  }
2505
+ const credentialRepo = durableSpec?.credentialRepo ?? repoDir;
2506
+ const profile = durableSpec?.profile ?? resolveProfileName(options);
2507
+ const harnessRoot = durableSpec?.harnessRoot ?? preflightHarnessRoot(options, stateDir);
2508
+ // Killing the child without an owner that will respawn it is never a
2509
+ // degraded restart: it is a guaranteed outage. Refuse before running
2510
+ // the expensive composition gate or writing any restart marker.
2511
+ if (liveWatchdogPid(stateDir) === null) {
2512
+ 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`);
2513
+ return 1;
2514
+ }
1266
2515
  // THE GATE: never schedule an exit on a denial.
1267
- const gate = verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), options.maxAgeMinutes);
2516
+ // A same-launch restart may reuse an exact deployment proof written
2517
+ // only after a previous readiness + canary success. Fresh credentials
2518
+ // remain the first choice; all launch/profile/runtime drift fails closed.
2519
+ const gate = durableSpec === null
2520
+ ? verifyRepoCredential(stateDir, credentialRepo, options.maxAgeMinutes)
2521
+ : verifyRestartEvidence(stateDir, durableSpec, options.maxAgeMinutes);
1268
2522
  if (!gate.ok) {
1269
2523
  io.stderr(`schedule-exit refused: ${gate.reason}\n`);
1270
2524
  return 1;
1271
2525
  }
2526
+ io.stdout(`restart evidence PASS — ${gate.reason}\n`);
1272
2527
  // THE ENVIRONMENT GATE: the detached exit agent must outlive this turn.
1273
2528
  if (!sandboxGate('schedule-exit', options, io))
1274
2529
  return 1;
1275
2530
  // THE COMPOSITION GATE: a green build does not prove the profile boots.
1276
- if (!(await preflightGate('schedule-exit', resolveProfileName(options), options.preflightTimeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS, io, resolveHarnessRoot(options.repoDir)))) {
2531
+ if (!(await preflightGate('schedule-exit', profile, options.preflightTimeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS, io, harnessRoot, durableSpec?.home, durableSpec?.preflight))) {
1277
2532
  return 1;
1278
2533
  }
1279
- // Bootstrap guard: with no live watchdog the scheduled exit leaves the
1280
- // service DOWN — the classic first-install gap (the running instance
1281
- // has not loaded the plugin yet, and no supervisor exists yet).
1282
- if (liveWatchdogPid(stateDir) === null) {
1283
- io.stderr(NO_WATCHDOG_HINT);
1284
- }
1285
- // One scheduled restart at a time: the marker carries a single
1286
- // initiator, so overwriting a FRESH one would silently reassign the
1287
- // pending report. A marker past the TTL is stale (the watchdog died
1288
- // mid-flow without clearing it) — overwrite with a warning instead of
1289
- // refusing forever.
1290
- const markerFile = stateFile(stateDir, 'restartRequested');
1291
- const markerState = restartMarkerState(stateDir);
1292
- if (markerState === 'fresh') {
1293
- io.stderr('schedule-exit refused: a restart is already scheduled (restart-requested.json still pending); a stale marker expires on its own after 15 minutes\n');
2534
+ // One scheduled restart at a time, and never while a restart is in
2535
+ // flight. Both checks must hold ATOMICALLY with writing the marker and
2536
+ // spawning the exit agent: an earlier version checked without holding
2537
+ // the lock, and the narrow read→write window let a concurrent restart
2538
+ // pass its own checks in between — the scheduled exit agent then
2539
+ // SIGTERMed the instance that restart had just started. Taking the
2540
+ // restart lock here serializes the two verbs on the same primitive
2541
+ // (restart's own marker check stays: it guards against an exit agent
2542
+ // scheduled BEFORE its acquisition, already past this window).
2543
+ const lock = acquireRestartLock(stateDir);
2544
+ if (!lock.ok) {
2545
+ io.stderr(`schedule-exit refused: a restart is in flight (pid ${lock.holder}) — the exit agent would kill the instance it is starting\n`);
1294
2546
  return 1;
1295
2547
  }
1296
- if (markerState === 'stale' && existsSync(markerFile)) {
1297
- io.stderr('warning: overwriting a stale restart marker (a previous schedule never completed)\n');
2548
+ try {
2549
+ // The marker carries a single initiator, so overwriting a FRESH one
2550
+ // would silently reassign the pending report. A marker past the TTL
2551
+ // is stale (the watchdog died mid-flow without clearing it) —
2552
+ // overwrite with a warning instead of refusing forever.
2553
+ const markerFile = stateFile(stateDir, 'restartRequested');
2554
+ const markerState = restartMarkerState(stateDir);
2555
+ if (markerState === 'fresh') {
2556
+ io.stderr('schedule-exit refused: a restart is already scheduled (restart-requested.json still pending); a stale marker expires on its own after 15 minutes\n');
2557
+ return 1;
2558
+ }
2559
+ if (markerState === 'stale' && existsSync(markerFile)) {
2560
+ io.stderr('warning: overwriting a stale restart marker (a previous schedule never completed)\n');
2561
+ }
2562
+ // Intentional-restart marker: the supervising watchdog runs the canary
2563
+ // after the respawn and clears this on pass. The initiator (the session
2564
+ // that requested the exit) rides along so the restart report can return
2565
+ // to that session instead of racing to whichever root agent resumes
2566
+ // first. Everything lands in stateDir directly — the same directory the
2567
+ // plugin reads (see the supervise case for why no home is derived).
2568
+ const initiator = resolveInitiator(options.initiator, io);
2569
+ mkdirSync(stateDir, { recursive: true });
2570
+ writeFileSync(stateFile(stateDir, 'restartRequested'), `${JSON.stringify({
2571
+ reason: 'scheduled self-restart',
2572
+ requestedAt: Date.now(),
2573
+ ...(gate.authorization === undefined ? {} : { authorization: gate.authorization }),
2574
+ ...(initiator !== undefined ? { initiator } : {}),
2575
+ })}\n`);
2576
+ // A DETACHED exit agent (setsid via node spawn): it cannot be reaped by
2577
+ // the sandbox/harness process group, so the scheduled kill actually
2578
+ // lands even after the scheduling turn ends — the fix for "the kill
2579
+ // never happened" seen with `(sleep N; kill) &` from a managed shell.
2580
+ // The agent is a real shipped file (typechecked, linted, unit-tested),
2581
+ // spawned with the same source/built split as guardInvocation().
2582
+ const resultFile = stateFile(stateDir, 'lastRestart');
2583
+ const logPath = options.log ?? stateFile(stateDir, 'scheduleExitLog');
2584
+ mkdirSync(dirname(logPath), { recursive: true });
2585
+ const child = spawn(process.execPath, exitAgentInvocation(), {
2586
+ detached: true,
2587
+ stdio: ['ignore', openSync(logPath, 'a'), openSync(logPath, 'a')],
2588
+ env: testChildEnv('schedule-exit-agent', {
2589
+ ...process.env,
2590
+ WD_PORT: String(port),
2591
+ WD_DELAY_MS: String(delayMs),
2592
+ WD_RESULT_FILE: resultFile,
2593
+ ...(initiator !== undefined ? { WD_INITIATOR: initiator } : {}),
2594
+ }, { port, tempRoot: stateDir }),
2595
+ });
2596
+ registerSpawnedTestProcess(child, 'schedule-exit-agent', { port, tempRoot: stateDir });
2597
+ child.unref();
2598
+ io.stdout(`exit scheduled in ${delayMs} ms (exit-agent pid ${child.pid ?? 'unknown'}) — watchdog will respawn and run the canary\n`);
2599
+ return 0;
1298
2600
  }
1299
- // And the other direction of the same invariant: a restart in flight
1300
- // (live lock holder) means an instance is being stopped/started right
1301
- // now — scheduling an exit would SIGTERM the one it just started.
1302
- const inFlight = liveRestartLockHolder(stateDir);
1303
- if (inFlight !== null) {
1304
- io.stderr(`schedule-exit refused: a restart is in flight (pid ${inFlight}) — the exit agent would kill the instance it is starting\n`);
1305
- return 1;
2601
+ finally {
2602
+ lock.release();
1306
2603
  }
1307
- // Intentional-restart marker: the supervising watchdog runs the canary
1308
- // after the respawn and clears this on pass. The initiator (the session
1309
- // that requested the exit) rides along so the restart report can return
1310
- // to that session instead of racing to whichever root agent resumes
1311
- // first. Everything lands in stateDir directly — the same directory the
1312
- // plugin reads (see the supervise case for why no home is derived).
1313
- const initiator = options.initiator ?? process.env.DSH_SESSION_ID;
1314
- mkdirSync(stateDir, { recursive: true });
1315
- writeFileSync(stateFile(stateDir, 'restartRequested'), `${JSON.stringify({
1316
- reason: 'scheduled self-restart',
1317
- requestedAt: Date.now(),
1318
- ...(initiator !== undefined ? { initiator } : {}),
1319
- })}\n`);
1320
- // A DETACHED exit agent (setsid via node spawn): it cannot be reaped by
1321
- // the sandbox/harness process group, so the scheduled kill actually
1322
- // lands even after the scheduling turn ends — the fix for "the kill
1323
- // never happened" seen with `(sleep N; kill) &` from a managed shell.
1324
- // The agent is a real shipped file (typechecked, linted, unit-tested),
1325
- // spawned with the same source/built split as guardInvocation().
1326
- const resultFile = stateFile(stateDir, 'lastRestart');
1327
- const logPath = options.log ?? stateFile(stateDir, 'scheduleExitLog');
1328
- mkdirSync(dirname(logPath), { recursive: true });
1329
- const child = spawn(process.execPath, exitAgentInvocation(), {
1330
- detached: true,
1331
- stdio: ['ignore', openSync(logPath, 'a'), openSync(logPath, 'a')],
1332
- env: {
1333
- ...process.env,
1334
- WD_PORT: String(port),
1335
- WD_DELAY_MS: String(delayMs),
1336
- WD_RESULT_FILE: resultFile,
1337
- ...(initiator !== undefined ? { WD_INITIATOR: initiator } : {}),
1338
- },
1339
- });
1340
- child.unref();
1341
- io.stdout(`exit scheduled in ${delayMs} ms (agent pid ${child.pid ?? 'unknown'}) — watchdog will respawn and run the canary\n`);
1342
- return 0;
1343
2604
  }
1344
2605
  default:
1345
2606
  io.stderr(`unknown command ${command}\n\n${USAGE}`);
@@ -1349,6 +2610,7 @@ export async function runCli(argv, io) {
1349
2610
  // Direct invocation (`tsx src/cli.ts ...`) vs import by tests. Symlink-proof
1350
2611
  // (isDirectInvocation): a plain URL compare silently never-fires via /tmp.
1351
2612
  if (isDirectInvocation(import.meta.url)) {
2613
+ registerCurrentTestProcess();
1352
2614
  void runCli(process.argv.slice(2), {
1353
2615
  stdout: line => process.stdout.write(line),
1354
2616
  stderr: line => process.stderr.write(line),