@khorsheed/dsh-ankh-guard 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.en.md +75 -29
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +74 -29
  5. package/lib/cli.js +2383 -209
  6. package/lib/client.js +257 -0
  7. package/lib/exit-agent.js +5 -2
  8. package/lib/index.js +722 -38
  9. package/lib/invariant.js +1 -1
  10. package/lib/preflight-runner.js +125 -47
  11. package/lib/processes-BjZgJjQr.js +344 -0
  12. package/lib/restart-context-D6nISh28.js +1245 -0
  13. package/lib/restart-context-DUyExi9O.js +1245 -0
  14. package/lib/{state-Dhx9VG44.js → state-4f7yny39.js} +60 -13
  15. package/lib/state-CZMypGkB.js +323 -0
  16. package/lib/test-seam-DnvLWTeO.js +119 -0
  17. package/lib/test-seam-cli.js +24 -0
  18. package/lib/test-seam-dwvaKjRp.js +459 -0
  19. package/lib/test-seam.js +2 -0
  20. package/lib/types/browser-handoff.d.ts +55 -0
  21. package/lib/types/browser-handoff.js +489 -0
  22. package/lib/types/cli.d.ts +34 -4
  23. package/lib/types/cli.js +1487 -225
  24. package/lib/types/client/index.d.ts +15 -0
  25. package/lib/types/client/index.js +264 -0
  26. package/lib/types/deployment-proof.d.ts +24 -0
  27. package/lib/types/deployment-proof.js +314 -0
  28. package/lib/types/exit-agent.js +2 -0
  29. package/lib/types/git.d.ts +12 -3
  30. package/lib/types/git.js +69 -7
  31. package/lib/types/index.d.ts +66 -3
  32. package/lib/types/index.js +157 -39
  33. package/lib/types/launch-spec.d.ts +263 -0
  34. package/lib/types/launch-spec.js +823 -0
  35. package/lib/types/preflight-runner.d.ts +23 -12
  36. package/lib/types/preflight-runner.js +152 -57
  37. package/lib/types/processes.d.ts +38 -6
  38. package/lib/types/processes.js +236 -10
  39. package/lib/types/restart-context.d.ts +50 -0
  40. package/lib/types/restart-context.js +106 -0
  41. package/lib/types/restart-request.d.ts +32 -0
  42. package/lib/types/restart-request.js +128 -0
  43. package/lib/types/state-files.d.ts +30 -0
  44. package/lib/types/state-files.js +55 -0
  45. package/lib/types/state.d.ts +29 -2
  46. package/lib/types/state.js +52 -7
  47. package/lib/types/temp-artifact.d.ts +15 -0
  48. package/lib/types/temp-artifact.js +17 -0
  49. package/lib/types/test-seam-cli.d.ts +3 -0
  50. package/lib/types/test-seam-cli.js +27 -0
  51. package/lib/types/test-seam.d.ts +55 -0
  52. package/lib/types/test-seam.js +112 -0
  53. package/lib/types/transition.d.ts +118 -0
  54. package/lib/types/transition.js +717 -0
  55. package/package.json +29 -9
  56. package/scripts/dsh-watchdog.sh +1388 -80
  57. package/scripts/install-launchd.sh +43 -5
  58. package/scripts/install-systemd.sh +43 -5
  59. package/scripts/on-install.js +1 -1
  60. package/skills/dsh-self-restart-guard/SKILL.md +38 -12
  61. package/lib/processes-hCAmwma-.js +0 -127
  62. package/lib/restart-context-DmnQXNf-.js +0 -421
@@ -0,0 +1,489 @@
1
+ /**
2
+ * Browser handoff bridge for launch cutovers.
3
+ *
4
+ * Old authenticated pages register random per-tab capabilities while the
5
+ * previous process is still the proven listener. Only SHA-256 digests are
6
+ * durable. After the successor proves one stable listener and passes canary,
7
+ * every registered tab may ask the final process for either a plain reload
8
+ * (its cookie is still valid) or that process's authenticated URL. The URL
9
+ * exists only in the response object and the browser's location.replace call:
10
+ * it never enters a state file, receipt, or log.
11
+ *
12
+ * Alongside the cutover channel, a boot-generation channel covers ordinary
13
+ * restarts (unsupervised `restart`/`schedule-exit`, watchdog crash respawns):
14
+ * the serving process's own pid + start token is its boot id, a polling tab
15
+ * carries the boot id it last saw, and a stale id with no active cutover
16
+ * means the process it knew is gone — answer ready/reload. A cutover receipt
17
+ * always wins over the generation check: during a cutover the old tab's known
18
+ * id is already stale, and the receipt protocol owns the pacing.
19
+ */
20
+ import { createHash, randomBytes } from 'node:crypto';
21
+ import { chmodSync, linkSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
22
+ import { dirname } from 'node:path';
23
+ import { activeCutover, readCutoverReceipt } from "./launch-spec.js";
24
+ import { processIdentity, processIdentityMatches } from "./processes.js";
25
+ import { stateFile } from "./state-files.js";
26
+ export const BROWSER_HANDOFF_ROUTE = '/_ankh-guard/browser-handoff';
27
+ const CAPABILITY_PATTERN = /^[A-Za-z0-9_-]{43}$/;
28
+ const MAX_BODY_BYTES = 4096;
29
+ const MAX_REGISTRATIONS = 64;
30
+ const DEFAULT_LONG_POLL_MS = 25_000;
31
+ const DEFAULT_LONG_POLL_INTERVAL_MS = 200;
32
+ function isObject(value) {
33
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
34
+ }
35
+ function isCapability(value) {
36
+ return typeof value === 'string' && CAPABILITY_PATTERN.test(value);
37
+ }
38
+ function capabilitySha256(capability) {
39
+ return createHash('sha256').update(capability).digest('hex');
40
+ }
41
+ function parseMessage(value) {
42
+ if (!isObject(value) || value.version !== 1)
43
+ return null;
44
+ if (value.operation === 'poll' && isCapability(value.capability)) {
45
+ // Opaque to the server; length-capped so a poll cannot carry bulk data.
46
+ const knownBootId = typeof value.knownBootId === 'string' && value.knownBootId.length <= 256
47
+ ? value.knownBootId
48
+ : undefined;
49
+ return { version: 1, operation: 'poll', capability: value.capability, ...(knownBootId === undefined ? {} : { knownBootId }) };
50
+ }
51
+ if (value.operation !== 'ack' || typeof value.cutoverId !== 'string' || value.cutoverId === '')
52
+ return null;
53
+ if (value.channel !== 'original-tab' && value.channel !== 'fallback-tab')
54
+ return null;
55
+ if (value.authentication !== 'existing-cookie' && value.authentication !== 'launch-url')
56
+ return null;
57
+ if (value.channel === 'original-tab' && !isCapability(value.capability))
58
+ return null;
59
+ return {
60
+ version: 1,
61
+ operation: 'ack',
62
+ cutoverId: value.cutoverId,
63
+ channel: value.channel,
64
+ authentication: value.authentication,
65
+ ...(typeof value.capability === 'string' ? { capability: value.capability } : {}),
66
+ };
67
+ }
68
+ function readJson(file) {
69
+ try {
70
+ return JSON.parse(readFileSync(file, 'utf8'));
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ function createJsonOnce(file, value) {
77
+ mkdirSync(dirname(file), { recursive: true });
78
+ const tmp = `${file}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
79
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
80
+ try {
81
+ // Publishing a complete hard link is both atomic for readers and
82
+ // first-writer-wins across competing tabs/processes.
83
+ linkSync(tmp, file);
84
+ try {
85
+ chmodSync(file, 0o600);
86
+ }
87
+ catch { /* best effort on non-POSIX filesystems */ }
88
+ return true;
89
+ }
90
+ catch (error) {
91
+ if (error.code === 'EEXIST')
92
+ return false;
93
+ throw error;
94
+ }
95
+ finally {
96
+ unlinkSync(tmp);
97
+ }
98
+ }
99
+ function writeJsonAtomic(file, value) {
100
+ mkdirSync(dirname(file), { recursive: true });
101
+ const tmp = `${file}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
102
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
103
+ try {
104
+ renameSync(tmp, file);
105
+ try {
106
+ chmodSync(file, 0o600);
107
+ }
108
+ catch { /* best effort on non-POSIX filesystems */ }
109
+ }
110
+ finally {
111
+ try {
112
+ unlinkSync(tmp);
113
+ }
114
+ catch { /* rename already consumed the temporary file */ }
115
+ }
116
+ }
117
+ function validRegistration(value) {
118
+ return isObject(value) && typeof value.authority === 'string' && value.authority !== ''
119
+ && typeof value.capabilitySha256 === 'string' && /^[a-f0-9]{64}$/.test(value.capabilitySha256)
120
+ && typeof value.armedAt === 'number' && Number.isFinite(value.armedAt)
121
+ && (value.acknowledgedAt === undefined
122
+ || (typeof value.acknowledgedAt === 'number' && Number.isFinite(value.acknowledgedAt)));
123
+ }
124
+ export function readBrowserHandoffRequest(stateDir) {
125
+ const request = readJson(stateFile(stateDir, 'browserHandoffRequest'));
126
+ if (request?.version !== 2 || typeof request.cutoverId !== 'string'
127
+ || !Array.isArray(request.registrations) || request.registrations.length === 0
128
+ || request.registrations.length > MAX_REGISTRATIONS
129
+ || !request.registrations.every(validRegistration))
130
+ return null;
131
+ return request;
132
+ }
133
+ function registerBrowserTab(stateDir, cutoverId, registration) {
134
+ const existing = readBrowserHandoffRequest(stateDir);
135
+ const registrations = existing?.cutoverId === cutoverId ? [...existing.registrations] : [];
136
+ if (!registrations.some(item => item.authority === registration.authority
137
+ && item.capabilitySha256 === registration.capabilitySha256)) {
138
+ if (registrations.length >= MAX_REGISTRATIONS)
139
+ return null;
140
+ registrations.push(registration);
141
+ }
142
+ const request = { version: 2, cutoverId, registrations };
143
+ writeJsonAtomic(stateFile(stateDir, 'browserHandoffRequest'), request);
144
+ return request;
145
+ }
146
+ function registrationMatches(request, cutoverId, authority, digest) {
147
+ return request?.cutoverId === cutoverId && request.registrations.some(item => (item.authority === authority && item.capabilitySha256 === digest));
148
+ }
149
+ function matchingRegistration(request, cutoverId, authority, digest) {
150
+ if (request?.cutoverId !== cutoverId)
151
+ return undefined;
152
+ return request.registrations.find(item => (item.authority === authority && item.capabilitySha256 === digest));
153
+ }
154
+ function acknowledgeBrowserTab(stateDir, cutoverId, authority, digest, acknowledgedAt) {
155
+ const request = readBrowserHandoffRequest(stateDir);
156
+ const registration = matchingRegistration(request, cutoverId, authority, digest);
157
+ if (request === null || registration === undefined)
158
+ return false;
159
+ registration.acknowledgedAt = acknowledgedAt;
160
+ writeJsonAtomic(stateFile(stateDir, 'browserHandoffRequest'), request);
161
+ return true;
162
+ }
163
+ export function readBrowserHandoffAcknowledgement(stateDir) {
164
+ const ack = readJson(stateFile(stateDir, 'browserHandoffAck'));
165
+ if (ack?.version !== 1 || typeof ack.cutoverId !== 'string'
166
+ || (ack.role !== 'target' && ack.role !== 'previous')
167
+ || typeof ack.authority !== 'string' || ack.authority === ''
168
+ || !Number.isInteger(ack.listenerPid) || (ack.listenerPid ?? 0) <= 0
169
+ || typeof ack.listenerStartToken !== 'string' || ack.listenerStartToken === ''
170
+ || (ack.channel !== 'original-tab' && ack.channel !== 'fallback-tab')
171
+ || (ack.authentication !== 'existing-cookie' && ack.authentication !== 'launch-url')
172
+ || typeof ack.acknowledgedAt !== 'number')
173
+ return null;
174
+ return ack;
175
+ }
176
+ function authorityOf(req) {
177
+ const host = req.headers.host;
178
+ const origin = req.headers.origin;
179
+ if (typeof host !== 'string' || typeof origin !== 'string')
180
+ return null;
181
+ try {
182
+ const parsed = new URL(origin);
183
+ if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.host !== host || parsed.origin !== origin)
184
+ return null;
185
+ return { authority: parsed.host, origin: parsed.origin };
186
+ }
187
+ catch {
188
+ return null;
189
+ }
190
+ }
191
+ function ownershipMatches(ownership, pid, identityMatches) {
192
+ return ownership !== undefined && ownership.listenerPid === pid
193
+ && identityMatches({ pid, startToken: ownership.listenerStartToken });
194
+ }
195
+ function json(res, status, body) {
196
+ res.writeHead(status, {
197
+ 'cache-control': 'no-store',
198
+ 'content-type': 'application/json; charset=utf-8',
199
+ 'referrer-policy': 'no-referrer',
200
+ 'x-content-type-options': 'nosniff',
201
+ });
202
+ res.end(`${JSON.stringify(body)}\n`);
203
+ }
204
+ async function bodyOf(req) {
205
+ let size = 0;
206
+ const chunks = [];
207
+ for await (const chunk of req) {
208
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
209
+ size += bytes.byteLength;
210
+ if (size > MAX_BODY_BYTES)
211
+ throw new Error('body too large');
212
+ chunks.push(bytes);
213
+ }
214
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
215
+ }
216
+ function currentReadyOwnership(stateDir, pid, identityMatches) {
217
+ const receipt = readCutoverReceipt(stateDir);
218
+ if (receipt === null || receipt.phase === 'awaiting-user' || receipt.readiness?.retryCount !== 0)
219
+ return null;
220
+ const role = receipt.readiness.role;
221
+ const ownership = role === 'target' ? receipt.ownership.target : receipt.ownership.restored;
222
+ const canarySettled = role === 'target'
223
+ ? receipt.targetValidation?.canary?.outcome === 'pass'
224
+ : receipt.recovery.validation?.canary !== undefined;
225
+ if (!ownershipMatches(ownership, pid, identityMatches)
226
+ || !canarySettled
227
+ || receipt.readiness.listenerPid !== ownership.listenerPid
228
+ || receipt.readiness.childPid !== ownership.childPid)
229
+ return null;
230
+ return {
231
+ cutoverId: receipt.id,
232
+ role,
233
+ ownership,
234
+ protected: receipt.authentication.launchUrlObserved === true,
235
+ };
236
+ }
237
+ function delay(ms) {
238
+ return new Promise(resolve => setTimeout(resolve, ms));
239
+ }
240
+ async function waitForPollState(ready, waitMs, intervalMs) {
241
+ if (ready() || waitMs <= 0)
242
+ return;
243
+ const deadline = Date.now() + waitMs;
244
+ while (Date.now() < deadline) {
245
+ await delay(Math.min(intervalMs, Math.max(1, deadline - Date.now())));
246
+ if (ready())
247
+ return;
248
+ }
249
+ }
250
+ function authenticationState(connection, req) {
251
+ if (connection?.requestRejection === undefined)
252
+ return 'legacy-public';
253
+ const rejection = connection.requestRejection(req);
254
+ if (rejection === undefined)
255
+ return 'authenticated';
256
+ return rejection === 403 ? 'forbidden' : 'unauthenticated';
257
+ }
258
+ /** Create the exact HTTP handler; dependencies are injectable for deterministic protocol tests. */
259
+ export function createBrowserHandoffHandler(dependencies) {
260
+ const pid = dependencies.pid ?? process.pid;
261
+ const now = dependencies.now ?? Date.now;
262
+ const identityMatches = dependencies.identityMatches ?? processIdentityMatches;
263
+ const identityProvider = dependencies.identityProvider ?? processIdentity;
264
+ const longPollMs = dependencies.longPollMs ?? DEFAULT_LONG_POLL_MS;
265
+ const longPollIntervalMs = dependencies.longPollIntervalMs ?? DEFAULT_LONG_POLL_INTERVAL_MS;
266
+ // Cached: a process's boot id never changes over its lifetime, and the
267
+ // platform lookup (ps/python3) must not run per held poll.
268
+ let bootIdCache;
269
+ const thisBootId = () => {
270
+ if (bootIdCache === undefined) {
271
+ const identity = identityProvider(pid);
272
+ bootIdCache = identity === null ? null : `${String(identity.pid)}:${identity.startToken}`;
273
+ }
274
+ return bootIdCache ?? undefined;
275
+ };
276
+ return async (req, res) => {
277
+ const connection = dependencies.connectionProvider?.() ?? dependencies.connection;
278
+ if (req.method !== 'POST' || req.headers['content-type']?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json') {
279
+ json(res, 404, { state: 'unavailable' });
280
+ return;
281
+ }
282
+ const requestAuthority = authorityOf(req);
283
+ if (requestAuthority === null) {
284
+ json(res, 403, { state: 'forbidden' });
285
+ return;
286
+ }
287
+ let message;
288
+ try {
289
+ message = parseMessage(await bodyOf(req));
290
+ }
291
+ catch {
292
+ message = null;
293
+ }
294
+ if (message === null) {
295
+ json(res, 400, { state: 'invalid' });
296
+ return;
297
+ }
298
+ if (message.operation === 'poll') {
299
+ const digest = capabilitySha256(message.capability);
300
+ // The serving process's boot id rides every poll response so a tab can
301
+ // learn it. Computed once: a process's identity never changes while it
302
+ // lives. Unavailable identity (ps blocked) disables the generation
303
+ // channel — polls degrade to the pre-generation flow, like an old client.
304
+ const bootId = thisBootId();
305
+ const respond = (status, body) => {
306
+ json(res, status, bootId === undefined ? body : { ...body, bootId });
307
+ };
308
+ // Boot-generation channel: a stale known id with NO active cutover means
309
+ // the process this tab knew is gone (ordinary restart or crash respawn).
310
+ // Short-circuits before the held-poll wait — this process's boot id
311
+ // cannot change under it. During a cutover the receipt protocol owns the
312
+ // pacing: the old tab's known id is already stale, and answering here
313
+ // would race the registration/ack dance.
314
+ if (bootId !== undefined && message.knownBootId !== undefined && message.knownBootId !== bootId
315
+ && activeCutover(dependencies.stateDir) === null) {
316
+ respond(200, { state: 'ready', action: 'reload', authentication: 'existing-cookie' });
317
+ return;
318
+ }
319
+ const pollHasState = () => {
320
+ const active = activeCutover(dependencies.stateDir);
321
+ if (active !== null) {
322
+ if (active.receipt.phase === 'awaiting-user'
323
+ || active.receipt.browserHandoff.status === 'off')
324
+ return true;
325
+ if (ownershipMatches(active.receipt.ownership.previous, pid, identityMatches)) {
326
+ return !registrationMatches(readBrowserHandoffRequest(dependencies.stateDir), active.receipt.id, requestAuthority.authority, digest);
327
+ }
328
+ return currentReadyOwnership(dependencies.stateDir, pid, identityMatches) !== null;
329
+ }
330
+ const ready = currentReadyOwnership(dependencies.stateDir, pid, identityMatches);
331
+ const registration = readBrowserHandoffRequest(dependencies.stateDir);
332
+ return ready !== null && matchingRegistration(registration, ready.cutoverId, requestAuthority.authority, digest)?.acknowledgedAt === undefined;
333
+ };
334
+ await waitForPollState(pollHasState, longPollMs, longPollIntervalMs);
335
+ const active = activeCutover(dependencies.stateDir);
336
+ const ready = currentReadyOwnership(dependencies.stateDir, pid, identityMatches);
337
+ const registration = readBrowserHandoffRequest(dependencies.stateDir);
338
+ const registered = ready === null ? undefined : matchingRegistration(registration, ready.cutoverId, requestAuthority.authority, digest);
339
+ if (registered?.acknowledgedAt !== undefined
340
+ || (active === null && (ready === null || registered === undefined))
341
+ || active?.receipt.phase === 'awaiting-user'
342
+ || active?.receipt.browserHandoff.status === 'off') {
343
+ respond(200, { state: 'idle' });
344
+ return;
345
+ }
346
+ const previous = active?.receipt.ownership.previous;
347
+ if (active !== null && ownershipMatches(previous, pid, identityMatches)) {
348
+ const oldAuthentication = authenticationState(connection, req);
349
+ if (oldAuthentication === 'unauthenticated' || oldAuthentication === 'forbidden') {
350
+ json(res, 401, { state: 'unauthorized' });
351
+ return;
352
+ }
353
+ const registration = {
354
+ authority: requestAuthority.authority,
355
+ capabilitySha256: digest,
356
+ armedAt: now(),
357
+ };
358
+ const selected = registerBrowserTab(dependencies.stateDir, active.receipt.id, registration);
359
+ respond(200, registrationMatches(selected, active.receipt.id, requestAuthority.authority, digest)
360
+ ? { state: 'waiting', cutoverId: active.receipt.id }
361
+ : { state: 'standby', cutoverId: active.receipt.id });
362
+ return;
363
+ }
364
+ if (ready === null || (active !== null && ready.cutoverId !== active.receipt.id)
365
+ || !registrationMatches(registration, ready?.cutoverId ?? '', requestAuthority.authority, digest)) {
366
+ respond(200, active === null
367
+ ? { state: 'idle' }
368
+ : { state: 'waiting', cutoverId: active.receipt.id });
369
+ return;
370
+ }
371
+ // A protected listener may expose the route before its optional
372
+ // connection service settles. Do not misclassify that startup window as
373
+ // a public/valid-cookie host and strand the original tab on a bare 401.
374
+ if (ready.protected && connection?.requestRejection === undefined) {
375
+ respond(200, { state: 'waiting', cutoverId: ready.cutoverId });
376
+ return;
377
+ }
378
+ const cookieState = authenticationState(connection, req);
379
+ if (cookieState === 'forbidden') {
380
+ json(res, 403, { state: 'forbidden' });
381
+ return;
382
+ }
383
+ if (cookieState !== 'unauthenticated') {
384
+ respond(200, {
385
+ state: 'ready', action: 'reload', cutoverId: ready.cutoverId,
386
+ role: ready.role, authentication: 'existing-cookie',
387
+ });
388
+ return;
389
+ }
390
+ if (connection?.authenticatedUrl === undefined) {
391
+ respond(200, { state: 'waiting', cutoverId: ready.cutoverId });
392
+ return;
393
+ }
394
+ let launchUrl;
395
+ try {
396
+ launchUrl = connection.authenticatedUrl(requestAuthority.origin);
397
+ }
398
+ catch {
399
+ json(res, 500, { state: 'unavailable' });
400
+ return;
401
+ }
402
+ let safe = false;
403
+ try {
404
+ const parsed = new URL(launchUrl);
405
+ safe = parsed.origin === requestAuthority.origin && parsed.pathname === '/'
406
+ && parsed.username === '' && parsed.password === '' && parsed.search !== '' && parsed.hash === '';
407
+ }
408
+ catch { /* unsafe provider output */ }
409
+ if (!safe) {
410
+ json(res, 500, { state: 'unavailable' });
411
+ return;
412
+ }
413
+ respond(200, {
414
+ state: 'ready', action: 'replace', cutoverId: ready.cutoverId,
415
+ role: ready.role, authentication: 'launch-url', launchUrl,
416
+ });
417
+ return;
418
+ }
419
+ const receipt = readCutoverReceipt(dependencies.stateDir);
420
+ if (receipt === null || receipt.id !== message.cutoverId) {
421
+ json(res, 409, { state: 'stale' });
422
+ return;
423
+ }
424
+ const ready = currentReadyOwnership(dependencies.stateDir, pid, identityMatches);
425
+ if (ready === null || ready.cutoverId !== message.cutoverId) {
426
+ json(res, 409, { state: 'waiting' });
427
+ return;
428
+ }
429
+ if (ready.protected && connection?.requestRejection === undefined) {
430
+ json(res, 409, { state: 'waiting' });
431
+ return;
432
+ }
433
+ const ackAuthentication = authenticationState(connection, req);
434
+ if (ackAuthentication === 'unauthenticated' || ackAuthentication === 'forbidden') {
435
+ json(res, ackAuthentication === 'forbidden' ? 403 : 401, {
436
+ state: ackAuthentication === 'forbidden' ? 'forbidden' : 'unauthorized',
437
+ });
438
+ return;
439
+ }
440
+ const acknowledgedAt = now();
441
+ if (message.channel === 'original-tab') {
442
+ const registration = readBrowserHandoffRequest(dependencies.stateDir);
443
+ if (message.capability === undefined || !registrationMatches(registration, message.cutoverId, requestAuthority.authority, capabilitySha256(message.capability))) {
444
+ json(res, 403, { state: 'forbidden' });
445
+ return;
446
+ }
447
+ if (!acknowledgeBrowserTab(dependencies.stateDir, message.cutoverId, requestAuthority.authority, capabilitySha256(message.capability), acknowledgedAt)) {
448
+ json(res, 409, { state: 'conflict' });
449
+ return;
450
+ }
451
+ }
452
+ const acknowledgement = {
453
+ version: 1,
454
+ cutoverId: message.cutoverId,
455
+ role: ready.role,
456
+ authority: requestAuthority.authority,
457
+ listenerPid: ready.ownership.listenerPid,
458
+ listenerStartToken: ready.ownership.listenerStartToken,
459
+ channel: message.channel,
460
+ authentication: message.authentication,
461
+ acknowledgedAt,
462
+ };
463
+ const ackFile = stateFile(dependencies.stateDir, 'browserHandoffAck');
464
+ if (!createJsonOnce(ackFile, acknowledgement)) {
465
+ const existing = readBrowserHandoffAcknowledgement(dependencies.stateDir);
466
+ if (existing?.cutoverId !== acknowledgement.cutoverId
467
+ || existing.listenerPid !== acknowledgement.listenerPid
468
+ || existing.listenerStartToken !== acknowledgement.listenerStartToken) {
469
+ json(res, 409, { state: 'conflict' });
470
+ return;
471
+ }
472
+ }
473
+ res.writeHead(204, { 'cache-control': 'no-store' });
474
+ res.end();
475
+ };
476
+ }
477
+ /** Register the optional Web-profile bridge; headless/TUI compositions simply never activate it. */
478
+ export function registerBrowserHandoff(ctx, stateDir) {
479
+ ctx.inject(['webServer'], (webCtx) => {
480
+ const webServer = webCtx.get('webServer');
481
+ if (webServer === undefined)
482
+ return;
483
+ const handler = createBrowserHandoffHandler({
484
+ stateDir,
485
+ connectionProvider: () => webCtx.get('connection'),
486
+ });
487
+ webCtx.effect(() => webServer.register({ kind: 'exact', path: BROWSER_HANDOFF_ROUTE, handler }), 'ankh-guard: browser handoff route');
488
+ });
489
+ }
@@ -1,18 +1,24 @@
1
1
  #!/usr/bin/env node
2
+ import { type BrowserHandoffPolicy, type CutoverRecoveryPolicy, type LaunchPreflightSpec, type PreflightSurface } from './launch-spec.ts';
2
3
  /** Parsed CLI options; empty stateDir/repoDir mean "use defaults". */
3
4
  interface CliOptions {
4
5
  stateDir: string;
5
6
  repoDir: string;
7
+ harnessRoot: string;
6
8
  home: string;
7
9
  maxAgeMinutes: number;
8
10
  port: number | undefined;
9
11
  command: string | undefined;
12
+ run: boolean;
13
+ runArgv: string[] | undefined;
10
14
  message: string | undefined;
15
+ detail: string | undefined;
11
16
  start: string | undefined;
12
17
  pid: string | undefined;
13
18
  timeoutMs: number | undefined;
14
19
  delayMs: number | undefined;
15
20
  stopTimeoutMs: number | undefined;
21
+ supervisorYieldTimeoutMs: number | undefined;
16
22
  log: string | undefined;
17
23
  foreground: boolean;
18
24
  rollback: boolean;
@@ -21,6 +27,18 @@ interface CliOptions {
21
27
  initiator: string | undefined;
22
28
  profile: string | undefined;
23
29
  preflightTimeoutMs: number | undefined;
30
+ preflightSurface: PreflightSurface | undefined;
31
+ preflightRunner: string | undefined;
32
+ preflightInstallAnchor: string | undefined;
33
+ candidateProbeCommand: string | undefined;
34
+ onFailure: CutoverRecoveryPolicy | undefined;
35
+ browserHandoff: BrowserHandoffPolicy;
36
+ ifAbsent: boolean;
37
+ trustCommand: boolean;
38
+ includeDirty: boolean;
39
+ takeoverFrom: number | undefined;
40
+ cutoverId: string | undefined;
41
+ transitionFile: string | undefined;
24
42
  }
25
43
  /** stdout/stderr sink (injected so tests capture output). */
26
44
  export interface CliIo {
@@ -63,10 +81,11 @@ export declare const preflightInternals: {
63
81
  };
64
82
  /**
65
83
  * The harness checkout the live instance boots from (and the preflight
66
- * runner resolves the official published packages from): the `--repo` target
67
- * when given, else `DSH_HARNESS`, else the conventional default.
84
+ * runner resolves the official published packages from): the
85
+ * `--harness-root` target when given, else `DSH_HARNESS`, else the
86
+ * conventional default. Credential repositories never enter this resolver.
68
87
  */
69
- export declare function resolveHarnessRoot(optionRepoDir: string | undefined, env?: Record<string, string | undefined>): string;
88
+ export declare function resolveHarnessRoot(optionHarnessRoot: string | undefined, env?: Record<string, string | undefined>): string;
70
89
  /**
71
90
  * The standalone preflight runner command (see preflight-runner.ts): executed
72
91
  * with the harness's own tsx so its dynamic imports resolve against the live
@@ -97,9 +116,10 @@ export interface PreflightOutcome {
97
116
  * @param profile - the dsh profile to dry-run.
98
117
  * @param timeoutMs - bound on the whole subprocess run; a timeout kills it.
99
118
  * @param harnessRoot - harness checkout for the runner (default: DSH_HARNESS / ~/code/deepseek-harness).
119
+ * @param home - dsh home the dry-run must read instead of ambient process state.
100
120
  * @returns the classified outcome.
101
121
  */
102
- export declare function runPreflightCheck(profile: string, timeoutMs: number, harnessRoot?: string): Promise<PreflightOutcome>;
122
+ export declare function runPreflightCheck(profile: string, timeoutMs: number, harnessRoot?: string, home?: string, binding?: LaunchPreflightSpec): Promise<PreflightOutcome>;
103
123
  /**
104
124
  * The home the supervised instance boots with (the watchdog exports it as
105
125
  * DSH_HOME): the explicit flag first, then the environment — the same
@@ -108,6 +128,16 @@ export declare function runPreflightCheck(profile: string, timeoutMs: number, ha
108
128
  * loud rather than boot the instance on a home guessed from the state dir.
109
129
  */
110
130
  export declare function resolveWdHome(optionHome: string, env?: Record<string, string | undefined>): string | undefined;
131
+ /**
132
+ * A machine-readable refusal verdict: which gate denied, plus a one-line
133
+ * reason. In-process callers (the selfRestartGuard.requestRestart service
134
+ * seam) read it back from the verdict file named by DSH_ANKH_VERDICT_FILE
135
+ * instead of scraping the human stderr text.
136
+ */
137
+ export interface CliRefusal {
138
+ stage: string;
139
+ reason: string;
140
+ }
111
141
  /**
112
142
  * Run one CLI invocation against the guard state.
113
143
  * @param argv - arguments after the subcommand name.