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