@khorsheed/dsh-ankh-guard 0.1.1 → 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 +24 -0
  2. package/README.en.md +75 -29
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +74 -29
  5. package/lib/cli.js +2349 -182
  6. package/lib/client.js +257 -0
  7. package/lib/exit-agent.js +5 -2
  8. package/lib/index.js +721 -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 +1421 -166
  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 +30 -11
  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
  /**
@@ -81,38 +87,42 @@ function sandboxGate(verb, options, io) {
81
87
  return false;
82
88
  }
83
89
  /**
84
- * Whether the pid named by this raw pid/lock-file content is alive. Empty
85
- * content reads as NO holder: Number('') is 0 and kill(0, 0) probes our own
86
- * process group (always succeeds), which once read as "alive" and refused
87
- * every restart forever — the bug that had to be fixed in two copies of this
88
- * logic before it was consolidated here.
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).
89
99
  */
90
- function pidAlive(raw) {
91
- const pid = Number(raw);
92
- if (raw === '' || !Number.isInteger(pid) || pid <= 0)
93
- return false;
94
- try {
95
- process.kill(pid, 0);
96
- return true;
97
- }
98
- catch {
99
- return false;
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`);
100
104
  }
105
+ return explicit !== undefined && explicit !== '' ? explicit : fromEnv;
101
106
  }
102
- /** The live pid named by a pid/lock file (as the raw string), or null when absent/stale. */
103
- function livePidIn(file) {
104
- try {
105
- const raw = readFileSync(file, 'utf8').trim();
106
- return pidAlive(raw) ? raw : null;
107
- }
108
- catch {
109
- return null;
110
- }
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
+ };
111
116
  }
112
- /** The live supervising watchdog's pid, or null when none is (pidfile + kill 0). */
113
- function liveWatchdogPid(stateDir) {
114
- const raw = livePidIn(stateFile(stateDir, 'watchdogPid'));
115
- return raw === null ? null : Number(raw);
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');
116
126
  }
117
127
  /**
118
128
  * Cross-session restart mutual exclusion: two concurrent restarts would both
@@ -184,29 +194,52 @@ function cliInvocation(args) {
184
194
  const USAGE = `usage: dsh-ankh-guard <command> [args] [flags]
185
195
  commands:
186
196
  verify [--state-dir DIR] [--repo DIR] [--max-age MIN]
187
- 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
188
199
  status [--state-dir DIR]
189
200
  clear [--state-dir DIR]
190
- checkpoint [--message MSG] [--repo DIR] [--state-dir DIR]
201
+ checkpoint [--message MSG] [--include-dirty] [--repo DIR] [--state-dir DIR]
191
202
  reset <sha> [--repo DIR]
192
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
193
206
  check-env [--state-dir DIR] [--repo DIR] # sandbox / watchdog / git readiness probe
194
- 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]
195
209
  record-unexpected-exit [--state-dir DIR] # watchdog-facing: record an unplanned-exit recovery
196
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]
197
224
  restart --port N --start "CMD" [--pid PID] [--timeout-ms MS] [--delay-ms MS] [--stop-timeout-ms MS] [--rollback]
198
- [--profile NAME] [--preflight-timeout-ms MS] [--state-dir DIR] [--repo DIR] [--max-age MIN]
199
- schedule-exit --port N --delay-ms MS [--initiator ID] [--log FILE] [--profile NAME]
200
- [--preflight-timeout-ms MS] [--state-dir DIR] [--repo DIR]
201
- 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]
202
229
  flags:
203
230
  --state-dir DIR state directory (default: $DSH_HOME/state, else <cwd>/.dsh-guard-state)
204
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
205
235
  --max-age MIN credential freshness window in minutes (default: 10)
206
236
  --port N canary/restart/supervise: TCP port that must be listening
207
- --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
208
240
  --message MSG checkpoint: batch description
209
- --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
210
243
  (optional once the plugin has booted — it records the launch
211
244
  command to <state-dir>/instance-launch.json)
212
245
  --pid PID restart: process to stop (default: the listener on --port)
@@ -217,18 +250,37 @@ flags:
217
250
  logs can take tens of seconds to flush)
218
251
  --delay-ms MS restart: sleep before stopping, so the current turn can finish first
219
252
  (agent-driven graceful self-restart: schedule, complete, then restart);
220
- 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
221
255
  --log FILE supervise (detached only — with --foreground the external supervisor's
222
256
  redirection owns the log) / schedule-exit: log file (default: <state-dir>/*.log)
223
257
  --home DIR supervise: the dsh home the supervised instance boots with (profiles,
224
258
  credentials — default: $DSH_HOME; required when that is unset)
225
259
  --initiator ID schedule-exit: session id that requested the exit (default: $DSH_SESSION_ID);
226
- recorded in last-restart.json so the restart report returns to that session
227
- --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:
228
264
  $DSH_PROFILE, else "web")
229
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
230
270
  --rollback restart: on failure, git reset --hard to the recorded checkpoint
231
- --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
232
284
  --sync restart: run the whole loop in-process (debug/tests; the default
233
285
  self-detaches a driver so the loop survives the caller's teardown)
234
286
  `;
@@ -239,10 +291,12 @@ flags:
239
291
  */
240
292
  export function parse(argv) {
241
293
  const options = {
242
- stateDir: '', repoDir: '', home: '', maxAgeMinutes: 10, port: undefined, command: undefined, message: undefined,
243
- 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,
244
296
  log: undefined,
245
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,
246
300
  };
247
301
  const positionals = [];
248
302
  let i = 0;
@@ -256,6 +310,10 @@ export function parse(argv) {
256
310
  try {
257
311
  for (; i < argv.length; i++) {
258
312
  const arg = argv[i] ?? '';
313
+ if (arg === '--') {
314
+ options.runArgv = argv.slice(i + 1);
315
+ break;
316
+ }
259
317
  switch (arg) {
260
318
  case '--state-dir':
261
319
  options.stateDir = flagValue(arg, true) ?? '';
@@ -269,6 +327,10 @@ export function parse(argv) {
269
327
  options.repoDir = flagValue(arg, true) ?? '';
270
328
  i++;
271
329
  break;
330
+ case '--harness-root':
331
+ options.harnessRoot = flagValue(arg, true) ?? '';
332
+ i++;
333
+ break;
272
334
  case '--max-age': {
273
335
  const raw = flagValue(arg, true);
274
336
  const n = Number(raw);
@@ -291,10 +353,23 @@ export function parse(argv) {
291
353
  options.command = flagValue(arg, true) ?? '';
292
354
  i++;
293
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;
294
365
  case '--message':
295
366
  options.message = flagValue(arg, true) ?? '';
296
367
  i++;
297
368
  break;
369
+ case '--detail':
370
+ options.detail = flagValue(arg, true);
371
+ i++;
372
+ break;
298
373
  case '--start':
299
374
  options.start = flagValue(arg, true) ?? '';
300
375
  i++;
@@ -334,6 +409,15 @@ export function parse(argv) {
334
409
  i++;
335
410
  break;
336
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
+ }
337
421
  case '--foreground':
338
422
  options.foreground = true;
339
423
  break;
@@ -354,6 +438,62 @@ export function parse(argv) {
354
438
  i++;
355
439
  break;
356
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;
357
497
  case '--rollback':
358
498
  options.rollback = true;
359
499
  break;
@@ -398,6 +538,53 @@ async function checkPort(port) {
398
538
  async function sleep(ms) {
399
539
  await new Promise((resolve) => { setTimeout(resolve, ms); });
400
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
+ }
401
588
  /**
402
589
  * How the spawned watchdog should invoke the guard CLI: the built form runs
403
590
  * `node <cli>`; the source form needs tsx with an absolute path (the watchdog
@@ -439,6 +626,29 @@ function exitAgentInvocation() {
439
626
  const DEFAULT_PREFLIGHT_TIMEOUT_MS = 120_000;
440
627
  /** A pending restart marker older than this is stale — its watchdog died mid-flow. */
441
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
+ }
442
652
  /**
443
653
  * The restart marker's state. Every verb that can stop the instance must
444
654
  * consult this (and the restart lock) — a stop right invisible to the other
@@ -448,13 +658,8 @@ function restartMarkerState(stateDir) {
448
658
  const file = stateFile(stateDir, 'restartRequested');
449
659
  if (!existsSync(file))
450
660
  return 'none';
451
- try {
452
- const marker = JSON.parse(readFileSync(file, 'utf8'));
453
- return typeof marker.requestedAt === 'number' && Date.now() - marker.requestedAt <= RESTART_MARKER_TTL_MS ? 'fresh' : 'stale';
454
- }
455
- catch {
456
- return 'stale'; // unparseable is stale by definition
457
- }
661
+ const marker = readRestartRequestMarker(stateDir);
662
+ return marker?.requestedAt !== undefined && Date.now() - marker.requestedAt <= RESTART_MARKER_TTL_MS ? 'fresh' : 'stale';
458
663
  }
459
664
  /** Captured preflight output is diagnostics, not a log — cap it before it can grow without bound. */
460
665
  const PREFLIGHT_OUTPUT_CAP = 64 * 1024;
@@ -490,12 +695,13 @@ export const preflightInternals = {
490
695
  };
491
696
  /**
492
697
  * The harness checkout the live instance boots from (and the preflight
493
- * runner resolves the official published packages from): the `--repo` target
494
- * 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.
495
701
  */
496
- export function resolveHarnessRoot(optionRepoDir, env = process.env) {
497
- if (optionRepoDir !== undefined && optionRepoDir !== '')
498
- return optionRepoDir;
702
+ export function resolveHarnessRoot(optionHarnessRoot, env = process.env) {
703
+ if (optionHarnessRoot !== undefined && optionHarnessRoot !== '')
704
+ return optionHarnessRoot;
499
705
  const fromEnv = env.DSH_HARNESS;
500
706
  return fromEnv !== undefined && fromEnv.trim() !== '' ? fromEnv : join(homedir(), 'code/deepseek-harness');
501
707
  }
@@ -517,7 +723,89 @@ export function resolveRunnerCommand(harnessRoot) {
517
723
  : undefined;
518
724
  if (runner === undefined)
519
725
  return undefined;
520
- 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
+ };
521
809
  }
522
810
  /** POSIX single-quote one word for the shell command line. */
523
811
  function shellQuote(word) {
@@ -579,13 +867,45 @@ function redactLaunchCommand(command) {
579
867
  * @param profile - the dsh profile to dry-run.
580
868
  * @param timeoutMs - bound on the whole subprocess run; a timeout kills it.
581
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.
582
871
  * @returns the classified outcome.
583
872
  */
584
- export async function runPreflightCheck(profile, timeoutMs, harnessRoot) {
585
- 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;
586
875
  let command;
876
+ let executable;
877
+ let argv = [];
587
878
  let usingRunner = false;
588
- 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 !== '') {
589
909
  command = override;
590
910
  }
591
911
  else {
@@ -608,9 +928,15 @@ export async function runPreflightCheck(profile, timeoutMs, harnessRoot) {
608
928
  let timedOut = false;
609
929
  // The runner resolves the live harness from DSH_HARNESS; pin it so the
610
930
  // subprocess agrees with the gate even when the caller's env differs.
611
- const child = usingRunner
612
- ? spawn(command, { shell: true, env: { ...process.env, DSH_HARNESS: harnessForRunner } })
613
- : 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 }) });
614
940
  const append = (chunk) => {
615
941
  if (output.length < PREFLIGHT_OUTPUT_CAP)
616
942
  output += chunk.toString('utf8');
@@ -652,6 +978,51 @@ export async function runPreflightCheck(profile, timeoutMs, harnessRoot) {
652
978
  });
653
979
  });
654
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
+ }
655
1026
  /** The profile a gated verb dry-runs: the flag, then $DSH_PROFILE, then the deployment default. */
656
1027
  function resolveProfileName(options) {
657
1028
  const flag = options.profile ?? '';
@@ -673,11 +1044,88 @@ export function resolveWdHome(optionHome, env = process.env) {
673
1044
  const fromEnv = env.DSH_HOME;
674
1045
  return fromEnv !== undefined && fromEnv !== '' ? fromEnv : undefined;
675
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
+ }
676
1121
  /** The first ~40 lines of captured preflight output, newline-terminated, or empty. */
677
1122
  function summarizeOutput(output) {
678
1123
  if (output.trim() === '')
679
1124
  return '';
680
- 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');
681
1129
  const kept = lines.length > 41 ? [...lines.slice(0, 40), `… (${lines.length - 40} more lines)`] : lines;
682
1130
  return `${kept.join('\n').replace(/\n+$/, '')}\n`;
683
1131
  }
@@ -690,10 +1138,18 @@ function summarizeOutput(output) {
690
1138
  * @param timeoutMs - bound on the preflight subprocess.
691
1139
  * @param io - output sinks.
692
1140
  * @param harnessRoot - harness checkout for the standalone runner.
1141
+ * @param home - dsh home to dry-run.
693
1142
  * @returns whether the verb may proceed.
694
1143
  */
695
- async function preflightGate(verb, profile, timeoutMs, io, harnessRoot) {
696
- 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);
697
1153
  switch (outcome.kind) {
698
1154
  case 'pass':
699
1155
  io.stdout(`composition preflight PASS (profile ${JSON.stringify(profile)})\n`);
@@ -711,6 +1167,64 @@ async function preflightGate(verb, profile, timeoutMs, io, harnessRoot) {
711
1167
  return false;
712
1168
  }
713
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
+ }
714
1228
  /**
715
1229
  * Wait for a pid to exit; SIGKILL (the whole descendant tree) after the
716
1230
  * deadline. @param onEscalate - invoked right before the SIGKILL, so the
@@ -775,6 +1289,33 @@ function rollbackToKnownGood(stateDir, repoDir, io) {
775
1289
  * @returns the process exit code: 0 ok, 1 gate denied / failure, 2 usage error.
776
1290
  */
777
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
+ };
778
1319
  const parsed = parse(argv);
779
1320
  if ('error' in parsed) {
780
1321
  io.stderr(parsed.error);
@@ -785,7 +1326,11 @@ export async function runCli(argv, io) {
785
1326
  const repoDir = resolveRepoDir(options.repoDir);
786
1327
  switch (command) {
787
1328
  case 'verify': {
788
- 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);
789
1334
  io.stdout(`${result.reason}\n`);
790
1335
  if (result.ok) {
791
1336
  io.stdout(FULL_ACCESS_HINT);
@@ -800,13 +1345,64 @@ export async function runCli(argv, io) {
800
1345
  io.stderr(`record requires a <scope>\n\n${USAGE}`);
801
1346
  return 2;
802
1347
  }
803
- const head = currentHead(repoDir);
804
- 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) {
805
1378
  io.stderr('cannot record a credential outside a git repository\n');
806
1379
  return 1;
807
1380
  }
808
- recordCredential(stateDir, { scope, revision: head, command: options.command ?? '' }, Date.now());
809
- 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`);
810
1406
  io.stdout(FULL_ACCESS_HINT);
811
1407
  if (liveWatchdogPid(stateDir) === null)
812
1408
  io.stderr(NO_WATCHDOG_HINT);
@@ -817,6 +1413,174 @@ export async function runCli(argv, io) {
817
1413
  io.stdout(`${JSON.stringify(state, null, 2)}\n`);
818
1414
  return 0;
819
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
+ }
820
1584
  case 'clear': {
821
1585
  clearCredential(stateDir, Date.now());
822
1586
  io.stdout('credential cleared\n');
@@ -824,13 +1588,19 @@ export async function runCli(argv, io) {
824
1588
  }
825
1589
  case 'checkpoint': {
826
1590
  const message = options.message ?? 'batch snapshot';
827
- 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);
828
1596
  if (!result.ok) {
829
1597
  io.stderr(`${result.error}\n`);
830
1598
  return 1;
831
1599
  }
832
1600
  setCheckpoint(stateDir, { revision: result.sha, message }, Date.now());
833
- 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`);
834
1604
  if (result.artifacts.length > 0) {
835
1605
  io.stdout(`warning: ${result.artifacts.length} build-artifact-looking file(s) swept in (bare tsc emission? real build output belongs in lib/):\n`);
836
1606
  for (const file of result.artifacts.slice(0, 5))
@@ -855,7 +1625,7 @@ export async function runCli(argv, io) {
855
1625
  return 0;
856
1626
  }
857
1627
  case 'canary': {
858
- const verdict = verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), options.maxAgeMinutes);
1628
+ const verdict = verifyRepoCredential(stateDir, repoDir, options.maxAgeMinutes);
859
1629
  io.stdout(`verify: ${verdict.ok ? 'PASS' : 'FAIL'} — ${verdict.reason}\n`);
860
1630
  let ok = verdict.ok;
861
1631
  if (options.port !== undefined) {
@@ -866,8 +1636,70 @@ export async function runCli(argv, io) {
866
1636
  io.stdout(ok ? 'canary PASS\n' : 'canary FAIL\n');
867
1637
  return ok ? 0 : 1;
868
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
+ }
869
1689
  case 'preflight': {
870
- 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);
871
1703
  if (outcome.kind === 'unavailable') {
872
1704
  io.stderr('preflight unavailable outside the dsh app layout\n');
873
1705
  return 3;
@@ -901,6 +1733,17 @@ export async function runCli(argv, io) {
901
1733
  : '[watchdog] adoption takeover — a report record is still pending, left it untouched\n');
902
1734
  return 0;
903
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
+ }
904
1747
  case 'check-env': {
905
1748
  // THE one-call readiness answer for an agent planning a restart: (1) is
906
1749
  // this instance supervised and by whom, (2) what command a restart
@@ -959,6 +1802,230 @@ export async function runCli(argv, io) {
959
1802
  : `no (${repoDir}) — git init + initial commit before record`}\n`);
960
1803
  return sandboxed ? 1 : 0;
961
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
+ }
962
2029
  case 'restart': {
963
2030
  const port = options.port ?? readInstanceLaunch(stateDir)?.port;
964
2031
  if (port === undefined) {
@@ -969,28 +2036,31 @@ export async function runCli(argv, io) {
969
2036
  if (start === undefined) {
970
2037
  return 2;
971
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
+ }
972
2043
  const isDriver = process.env.DSH_ANKH_RESTART_DRIVER === '1';
973
2044
  // THE GATE: never stop an instance on a denial.
974
- const gate = verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), options.maxAgeMinutes);
2045
+ const gate = verifyRepoCredential(stateDir, repoDir, options.maxAgeMinutes);
975
2046
  if (!gate.ok) {
976
- io.stderr(`restart refused: ${gate.reason}\n`);
977
- return 1;
2047
+ return refuse('credential', `restart refused: ${gate.reason}\n`);
978
2048
  }
979
2049
  // THE ENVIRONMENT GATE: a sandboxed turn reaps the detached restart
980
2050
  // mid-flight — refuse before anything is stopped.
981
- if (!sandboxGate('restart', options, io))
982
- 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
+ }
983
2054
  // THE COMPOSITION GATE (caller side only — the detached driver inherits
984
2055
  // a composition the caller already proved; re-running it would double a
985
2056
  // minute-long dry-run). A green build does not prove the profile boots.
986
- if (!isDriver && !(await preflightGate('restart', resolveProfileName(options), options.preflightTimeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS, io, resolveHarnessRoot(options.repoDir)))) {
987
- 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)');
988
2059
  }
989
2060
  // See every other pending stop before becoming one: a scheduled exit's
990
2061
  // agent would SIGTERM the instance this restart starts.
991
2062
  if (restartMarkerState(stateDir) === 'fresh') {
992
- 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');
993
- 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');
994
2064
  }
995
2065
  if (options.sync !== true && !isDriver) {
996
2066
  // SELF-DETACH: the stop→start→canary half must outlive the caller. A
@@ -1000,15 +2070,19 @@ export async function runCli(argv, io) {
1000
2070
  // exit agent and the watchdog, provably survives.
1001
2071
  const logPath = options.log ?? stateFile(stateDir, 'restartLog');
1002
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;
1003
2077
  const driver = spawn(process.execPath, cliInvocation(argv), {
1004
2078
  detached: true,
1005
2079
  stdio: ['ignore', openSync(logPath, 'a'), openSync(logPath, 'a')],
1006
- env: { ...process.env, DSH_ANKH_RESTART_DRIVER: '1' },
2080
+ env: testChildEnv('restart-driver', restartDriverEnv, { port, tempRoot: stateDir }),
1007
2081
  });
2082
+ registerSpawnedTestProcess(driver, 'restart-driver', { port, tempRoot: stateDir });
1008
2083
  driver.unref();
1009
2084
  if (driver.pid === undefined) {
1010
- io.stderr('restart refused: could not detach the restart driver\n');
1011
- return 1;
2085
+ return refuse('spawn', 'restart refused: could not detach the restart driver\n');
1012
2086
  }
1013
2087
  // ONE restart at a time across sessions: the lock names the DRIVER
1014
2088
  // (it outlives this caller by design); a live holder refuses.
@@ -1018,10 +2092,9 @@ export async function runCli(argv, io) {
1018
2092
  process.kill(driver.pid, 'SIGKILL');
1019
2093
  }
1020
2094
  catch { /* already gone */ }
1021
- io.stderr(/^\d+$/.test(lock.holder)
2095
+ return refuse('lock', /^\d+$/.test(lock.holder)
1022
2096
  ? `restart refused: another restart is already in flight (pid ${lock.holder})\n`
1023
2097
  : `restart refused: cannot claim the restart lock (${lock.holder}) — remove ${stateFile(stateDir, 'restartLock')} if it is stale\n`);
1024
- return 1;
1025
2098
  }
1026
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`);
1027
2100
  return 0;
@@ -1033,7 +2106,7 @@ export async function runCli(argv, io) {
1033
2106
  if (options.sync === true) {
1034
2107
  const lock = acquireRestartLock(stateDir);
1035
2108
  if (!lock.ok) {
1036
- io.stderr(/^\d+$/.test(lock.holder)
2109
+ refuse('lock', /^\d+$/.test(lock.holder)
1037
2110
  ? `restart refused: another restart is already in flight (pid ${lock.holder})\n`
1038
2111
  : `restart refused: cannot claim the restart lock (${lock.holder}) — remove ${stateFile(stateDir, 'restartLock')} if it is stale\n`);
1039
2112
  return 1;
@@ -1071,9 +2144,24 @@ export async function runCli(argv, io) {
1071
2144
  });
1072
2145
  io.stdout(`stopped ${pid}${exited ? '' : ' (forced)'}\n`);
1073
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.
1074
2154
  const startEnv = { ...process.env };
1075
2155
  delete startEnv.DSH_ANKH_RESTART_DRIVER;
1076
- 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 });
1077
2165
  child.unref();
1078
2166
  io.stdout(`started: ${start}\n`);
1079
2167
  const timeoutMs = options.timeoutMs ?? 60_000;
@@ -1089,7 +2177,7 @@ export async function runCli(argv, io) {
1089
2177
  // The restart verb must not be invisible to the report machinery:
1090
2178
  // record the outcome (the exit agent's semantics) so the next boot's
1091
2179
  // pendingRestartRecord delivers the report to its initiator.
1092
- const initiator = options.initiator ?? process.env.DSH_SESSION_ID;
2180
+ const initiator = resolveInitiator(options.initiator, io);
1093
2181
  if (!listening) {
1094
2182
  io.stderr(`new instance not listening on 127.0.0.1:${port} within ${timeoutMs}ms\n`);
1095
2183
  writeRestartOutcome(stateDir, { exitAt: stoppedAt, pid: pidNumber, error: `new instance not listening on :${port}`, ...(initiator !== undefined ? { initiator } : {}) });
@@ -1097,7 +2185,7 @@ export async function runCli(argv, io) {
1097
2185
  rollbackToKnownGood(stateDir, repoDir, io);
1098
2186
  return 1;
1099
2187
  }
1100
- const post = verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), options.maxAgeMinutes);
2188
+ const post = verifyRepoCredential(stateDir, repoDir, options.maxAgeMinutes);
1101
2189
  io.stdout(`canary verify: ${post.ok ? 'PASS' : 'FAIL'} — ${post.reason}\n`);
1102
2190
  io.stdout(`canary port: PASS — listening on 127.0.0.1:${port}\n`);
1103
2191
  if (!post.ok) {
@@ -1118,32 +2206,42 @@ export async function runCli(argv, io) {
1118
2206
  }
1119
2207
  }
1120
2208
  case 'supervise': {
1121
- const port = options.port;
1122
- if (port === undefined) {
1123
- io.stderr(`supervise requires --port N and --start "CMD"\n\n${USAGE}`);
1124
- return 2;
1125
- }
1126
- const start = resolveStartCommand(options.start, stateDir, 'supervise', io, port);
1127
- if (start === undefined) {
2209
+ let spec = resolveSuperviseSpec(options, stateDir, repoDir, io);
2210
+ if (spec === undefined)
1128
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;
1129
2220
  }
1130
- // The supervisor's record is authoritative: the FULL chain (watchdog +
1131
- // launch wrapper), not the inner argv the instance would self-record.
1132
- writeInstanceLaunchAsSupervisor(stateDir, { command: start, source: 'supervisor', supervised: true, ...(port !== undefined ? { port } : {}), recordedAt: Date.now() });
1133
- // The supervised instance boots with THIS home (the watchdog exports it
1134
- // as DSH_HOME): a home derived from the state dir would silently point
1135
- // the instance at the wrong profiles/credentials, surfacing far from
1136
- // the cause — so a missing home is a loud misconfiguration, not a guess.
1137
- const wdHome = resolveWdHome(options.home);
1138
- if (wdHome === undefined) {
1139
- 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');
1140
- 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;
1141
2224
  }
1142
2225
  // A detached watchdog spawned from a sandboxed turn is reaped with it —
1143
2226
  // refuse before claiming anything. Foreground mode is driven by the
1144
2227
  // external supervisor (launchd/systemd) and stays exempt.
1145
2228
  if (options.foreground !== true && !sandboxGate('supervise', options, io))
1146
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
+ }
1147
2245
  // One state directory owns every marker and the pidfile; the plugin,
1148
2246
  // this CLI, and the watchdog must agree on it. Deriving a home from
1149
2247
  // stateDir and re-appending 'state' breaks whenever stateDir is not
@@ -1151,6 +2249,7 @@ export async function runCli(argv, io) {
1151
2249
  // '<cwd>/.dsh-guard-state' fallback): the CLI would write '<cwd>/state'
1152
2250
  // while the plugin reads '<cwd>/.dsh-guard-state'.
1153
2251
  const pidfile = stateFile(stateDir, 'watchdogPid');
2252
+ let waitedForWatchdog = false;
1154
2253
  if (existsSync(pidfile)) {
1155
2254
  const existing = readFileSync(pidfile, 'utf8').trim();
1156
2255
  const existingPid = Number(existing);
@@ -1163,34 +2262,82 @@ export async function runCli(argv, io) {
1163
2262
  existingAlive = false; // stale pidfile — fall through and spawn
1164
2263
  }
1165
2264
  if (existingAlive) {
1166
- if (options.foreground) {
1167
- // Foreground = an external supervisor (launchd KeepAlive) runs
1168
- // THIS process. Exiting 0 here would read as an intentional stop
1169
- // under `KeepAlive SuccessfulExit: false`, so the job would go
1170
- // idle and never restart the CLI — silently leaving the OTHER
1171
- // watchdog unsupervised, i.e. a quiet regression to the
1172
- // single-point-of-failure shape. Instead, wait for it to exit
1173
- // and then take over: the chain (supervisor → this CLI →
1174
- // watchdog) stays intact the whole time.
1175
- io.stdout(`watchdog ${existing} already supervises the port — waiting for it to exit, then taking over (foreground)\n`);
1176
- while (true) {
1177
- try {
1178
- process.kill(existingPid, 0);
1179
- }
1180
- catch {
1181
- break;
1182
- }
1183
- 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;
1184
2269
  }
1185
- io.stdout(`watchdog ${existing} exited — taking over\n`);
2270
+ // Continue: the new watchdog performs an atomic pidfile replace.
1186
2271
  }
1187
2272
  else {
1188
- io.stdout(`already supervised by pid ${existing}\n`);
1189
- 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
+ }
1190
2304
  }
1191
2305
  }
1192
2306
  }
1193
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
+ }
1194
2341
  const watchdog = fileURLToPath(new URL('../scripts/dsh-watchdog.sh', import.meta.url));
1195
2342
  if (!existsSync(watchdog)) {
1196
2343
  io.stderr(`watchdog script not found at ${watchdog}\n`);
@@ -1204,13 +2351,34 @@ export async function runCli(argv, io) {
1204
2351
  io.stderr('supervise: --log has no effect with --foreground — output follows the external supervisor\'s redirection (launchd StandardOutPath / systemd StandardOutput=); drop --log\n');
1205
2352
  return 2;
1206
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
+ }
1207
2374
  const env = {
1208
- ...process.env,
1209
- WD_PORT: String(port),
1210
- WD_HOME: wdHome,
2375
+ ...supervisorBaseEnv,
2376
+ WD_PORT: String(spec.port),
2377
+ WD_HOME: spec.home,
1211
2378
  WD_STATE_DIR: stateDir,
1212
- WD_REPO: repoDir,
1213
- WD_START: start,
2379
+ WD_REPO: spec.credentialRepo,
2380
+ WD_HARNESS_ROOT: spec.harnessRoot,
2381
+ WD_START: spec.command,
1214
2382
  // Let the instance mark its own launch record as supervised (the
1215
2383
  // watchdog passes its env to the instance it spawns).
1216
2384
  DSH_ANKH_SUPERVISED: '1',
@@ -1218,15 +2386,42 @@ export async function runCli(argv, io) {
1218
2386
  // takeover reports back to it (record-adoption). Empty for
1219
2387
  // human-driven supervise runs — the record then waits for the first
1220
2388
  // root agent created.
1221
- WD_INITIATOR: process.env.DSH_SESSION_ID ?? '',
2389
+ WD_INITIATOR: options.initiator ?? process.env.DSH_SESSION_ID ?? '',
1222
2390
  // Adoption vs first-ever boot, decided HERE — race-free: by the time
1223
2391
  // a spawned watchdog would probe the port, the owner may already be
1224
2392
  // gone.
1225
- 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,
1226
2397
  // Foreground (launchd-supervised) mode: the watchdog owns the port by
1227
2398
  // adoption; the detached form waits for the current owner to exit.
1228
- WD_WAIT_OWNER: options.foreground ? '0' : '1',
2399
+ WD_WAIT_OWNER: options.takeoverFrom !== undefined || !options.foreground ? '1' : '0',
1229
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
+ } : {}),
1230
2425
  };
1231
2426
  if (options.foreground) {
1232
2427
  // Run the watchdog inline: the CLI process stays alive as the
@@ -1235,8 +2430,9 @@ export async function runCli(argv, io) {
1235
2430
  // exits with the watchdog so a dead watchdog triggers a restart.
1236
2431
  const child = spawn('bash', [watchdog, '--supervise'], {
1237
2432
  stdio: 'inherit',
1238
- env,
2433
+ env: testChildEnv('watchdog-foreground', env, { port: spec.port, tempRoot: stateDir }),
1239
2434
  });
2435
+ registerSpawnedTestProcess(child, 'watchdog-foreground', { port: spec.port, tempRoot: stateDir });
1240
2436
  const code = await new Promise((resolve) => {
1241
2437
  child.on('exit', (c) => { resolve(c ?? 1); });
1242
2438
  });
@@ -1247,38 +2443,94 @@ export async function runCli(argv, io) {
1247
2443
  const child = spawn('bash', [watchdog, '--supervise'], {
1248
2444
  detached: true,
1249
2445
  stdio: ['ignore', openSync(logPath, 'a'), openSync(logPath, 'a')],
1250
- env,
2446
+ env: testChildEnv('watchdog-detached', env, { port: spec.port, tempRoot: stateDir }),
1251
2447
  });
2448
+ registerSpawnedTestProcess(child, 'watchdog-detached', { port: spec.port, tempRoot: stateDir });
2449
+ let spawnError;
2450
+ child.once('error', (error) => { spawnError = error; });
1252
2451
  child.unref();
1253
- io.stdout(`watchdog spawned (pid ${child.pid ?? 'unknown'}) — supervises :${port}, log ${logPath}\n`);
1254
- 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;
1255
2485
  }
1256
2486
  case 'schedule-exit': {
1257
- const port = options.port ?? readInstanceLaunch(stateDir)?.port;
1258
2487
  const delayMs = options.delayMs;
1259
- if (port === undefined || delayMs === undefined) {
1260
- 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}`);
1261
2490
  return 2;
1262
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}`);
2503
+ return 2;
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
+ }
1263
2515
  // THE GATE: never schedule an exit on a denial.
1264
- 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);
1265
2522
  if (!gate.ok) {
1266
2523
  io.stderr(`schedule-exit refused: ${gate.reason}\n`);
1267
2524
  return 1;
1268
2525
  }
2526
+ io.stdout(`restart evidence PASS — ${gate.reason}\n`);
1269
2527
  // THE ENVIRONMENT GATE: the detached exit agent must outlive this turn.
1270
2528
  if (!sandboxGate('schedule-exit', options, io))
1271
2529
  return 1;
1272
2530
  // THE COMPOSITION GATE: a green build does not prove the profile boots.
1273
- 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))) {
1274
2532
  return 1;
1275
2533
  }
1276
- // Bootstrap guard: with no live watchdog the scheduled exit leaves the
1277
- // service DOWN — the classic first-install gap (the running instance
1278
- // has not loaded the plugin yet, and no supervisor exists yet).
1279
- if (liveWatchdogPid(stateDir) === null) {
1280
- io.stderr(NO_WATCHDOG_HINT);
1281
- }
1282
2534
  // One scheduled restart at a time, and never while a restart is in
1283
2535
  // flight. Both checks must hold ATOMICALLY with writing the marker and
1284
2536
  // spawning the exit agent: an earlier version checked without holding
@@ -1313,11 +2565,12 @@ export async function runCli(argv, io) {
1313
2565
  // to that session instead of racing to whichever root agent resumes
1314
2566
  // first. Everything lands in stateDir directly — the same directory the
1315
2567
  // plugin reads (see the supervise case for why no home is derived).
1316
- const initiator = options.initiator ?? process.env.DSH_SESSION_ID;
2568
+ const initiator = resolveInitiator(options.initiator, io);
1317
2569
  mkdirSync(stateDir, { recursive: true });
1318
2570
  writeFileSync(stateFile(stateDir, 'restartRequested'), `${JSON.stringify({
1319
2571
  reason: 'scheduled self-restart',
1320
2572
  requestedAt: Date.now(),
2573
+ ...(gate.authorization === undefined ? {} : { authorization: gate.authorization }),
1321
2574
  ...(initiator !== undefined ? { initiator } : {}),
1322
2575
  })}\n`);
1323
2576
  // A DETACHED exit agent (setsid via node spawn): it cannot be reaped by
@@ -1332,16 +2585,17 @@ export async function runCli(argv, io) {
1332
2585
  const child = spawn(process.execPath, exitAgentInvocation(), {
1333
2586
  detached: true,
1334
2587
  stdio: ['ignore', openSync(logPath, 'a'), openSync(logPath, 'a')],
1335
- env: {
2588
+ env: testChildEnv('schedule-exit-agent', {
1336
2589
  ...process.env,
1337
2590
  WD_PORT: String(port),
1338
2591
  WD_DELAY_MS: String(delayMs),
1339
2592
  WD_RESULT_FILE: resultFile,
1340
2593
  ...(initiator !== undefined ? { WD_INITIATOR: initiator } : {}),
1341
- },
2594
+ }, { port, tempRoot: stateDir }),
1342
2595
  });
2596
+ registerSpawnedTestProcess(child, 'schedule-exit-agent', { port, tempRoot: stateDir });
1343
2597
  child.unref();
1344
- io.stdout(`exit scheduled in ${delayMs} ms (agent pid ${child.pid ?? 'unknown'}) — watchdog will respawn and run the canary\n`);
2598
+ io.stdout(`exit scheduled in ${delayMs} ms (exit-agent pid ${child.pid ?? 'unknown'}) — watchdog will respawn and run the canary\n`);
1345
2599
  return 0;
1346
2600
  }
1347
2601
  finally {
@@ -1356,6 +2610,7 @@ export async function runCli(argv, io) {
1356
2610
  // Direct invocation (`tsx src/cli.ts ...`) vs import by tests. Symlink-proof
1357
2611
  // (isDirectInvocation): a plain URL compare silently never-fires via /tmp.
1358
2612
  if (isDirectInvocation(import.meta.url)) {
2613
+ registerCurrentTestProcess();
1359
2614
  void runCli(process.argv.slice(2), {
1360
2615
  stdout: line => process.stdout.write(line),
1361
2616
  stderr: line => process.stderr.write(line),