@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,459 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ //#region lib/types/processes.js
6
+ /**
7
+ * Process primitives shared by the guard CLI and the exit agent: locating the
8
+ * listener on a port and killing a process tree. One owner — the watchdog's
9
+ * bash side keeps its own copy (different runtime), but every TypeScript
10
+ * caller goes through here.
11
+ */
12
+ function executable(name, candidates) {
13
+ return candidates.find((candidate) => existsSync(candidate)) ?? name;
14
+ }
15
+ const LSOF = executable("lsof", ["/usr/sbin/lsof", "/usr/bin/lsof"]);
16
+ const PS = executable("ps", ["/bin/ps", "/usr/bin/ps"]);
17
+ const PGREP = executable("pgrep", ["/usr/bin/pgrep", "/bin/pgrep"]);
18
+ const SYSCTL = executable("sysctl", ["/usr/sbin/sysctl", "/sbin/sysctl"]);
19
+ const PYTHON = executable("python3", ["/usr/bin/python3", "/opt/homebrew/bin/python3"]);
20
+ const DARWIN_START_TOKEN = [
21
+ "import ctypes,struct,sys",
22
+ "p=int(sys.argv[1])",
23
+ "b=ctypes.create_string_buffer(136)",
24
+ "n=ctypes.CDLL(\"/usr/lib/libproc.dylib\").proc_pidinfo(p,3,0,b,136)",
25
+ "n == 136 or sys.exit(1)",
26
+ "s,u=struct.unpack_from(\"QQ\",b.raw,120)",
27
+ "print(f\"darwin:{s}:{u}\",end=\"\")"
28
+ ].join(";");
29
+ /** Every process listening on a TCP port. An empty list also covers unavailable lsof. */
30
+ function findPidsOnPort(port) {
31
+ try {
32
+ const out = execFileSync(LSOF, [
33
+ `-tiTCP:${port}`,
34
+ "-sTCP:LISTEN",
35
+ "-P"
36
+ ], {
37
+ encoding: "utf8",
38
+ stdio: "pipe"
39
+ }).trim();
40
+ return [...new Set(out.split("\n").map(Number).filter((pid) => Number.isInteger(pid) && pid > 0))];
41
+ } catch {
42
+ return [];
43
+ }
44
+ }
45
+ /** TCP listen ports owned directly by one PID, using the same absolute lsof resolution. */
46
+ function listeningPortsForPid(pid) {
47
+ try {
48
+ const out = execFileSync(LSOF, [
49
+ "-nP",
50
+ "-a",
51
+ "-p",
52
+ String(pid),
53
+ "-iTCP",
54
+ "-sTCP:LISTEN"
55
+ ], {
56
+ encoding: "utf8",
57
+ stdio: "pipe"
58
+ });
59
+ return [...new Set([...out.matchAll(/:(\d+) \(LISTEN\)/g)].map((match) => Number(match[1])).filter(Number.isInteger))];
60
+ } catch {
61
+ return [];
62
+ }
63
+ }
64
+ /** The first process listening on a TCP port, or null when none is (via lsof). */
65
+ function findPidOnPort(port) {
66
+ return findPidsOnPort(port)[0]?.toString() ?? null;
67
+ }
68
+ /**
69
+ * Current identity for a live process, or null when it cannot be proved.
70
+ * Linux exposes a boot-scoped kernel start tick. Other POSIX hosts do not,
71
+ * so bind the start instant to the boot, uid, session, and settled command.
72
+ * This materially strengthens macOS ps(1)'s second-granularity lstart; all
73
+ * guard captures occur after the watchdog/child has reached its steady argv.
74
+ */
75
+ function processIdentity(pid) {
76
+ if (!Number.isInteger(pid) || pid <= 0) return null;
77
+ try {
78
+ const status = execFileSync(PS, [
79
+ "-o",
80
+ "stat=",
81
+ "-p",
82
+ String(pid)
83
+ ], {
84
+ encoding: "utf8",
85
+ stdio: "pipe"
86
+ }).trim();
87
+ if (status === "" || status.startsWith("Z")) return null;
88
+ if (process.platform === "darwin") try {
89
+ const nativeStart = execFileSync(PYTHON, [
90
+ "-c",
91
+ DARWIN_START_TOKEN,
92
+ String(pid)
93
+ ], {
94
+ encoding: "utf8",
95
+ stdio: "pipe"
96
+ }).trim();
97
+ if (/^darwin:\d+:\d+$/.test(nativeStart)) return {
98
+ pid,
99
+ startToken: nativeStart
100
+ };
101
+ } catch {}
102
+ const procStat = `/proc/${pid}/stat`;
103
+ if (existsSync(procStat)) {
104
+ const stat = readFileSync(procStat, "utf8");
105
+ const commandEnd = stat.lastIndexOf(")");
106
+ const startTicks = (commandEnd < 0 ? [] : stat.slice(commandEnd + 1).trim().split(/\s+/))[19];
107
+ if (startTicks === void 0 || startTicks === "") return null;
108
+ let bootId = "unknown-boot";
109
+ try {
110
+ bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim() || bootId;
111
+ } catch {}
112
+ return {
113
+ pid,
114
+ startToken: `linux:${bootId}:${startTicks}`
115
+ };
116
+ }
117
+ const processEvidence = execFileSync(PS, [
118
+ "-o",
119
+ "sess=",
120
+ "-o",
121
+ "uid=",
122
+ "-o",
123
+ "lstart=",
124
+ "-o",
125
+ "command=",
126
+ "-p",
127
+ String(pid)
128
+ ], {
129
+ encoding: "utf8",
130
+ stdio: "pipe"
131
+ }).trim();
132
+ if (processEvidence === "") return null;
133
+ let bootEvidence = "unknown-boot";
134
+ try {
135
+ bootEvidence = execFileSync(SYSCTL, ["-n", "kern.boottime"], {
136
+ encoding: "utf8",
137
+ stdio: "pipe"
138
+ }).trim() || bootEvidence;
139
+ } catch {}
140
+ return {
141
+ pid,
142
+ startToken: `posix:${createHash("sha256").update(bootEvidence).update("\0").update(processEvidence).digest("hex")}`
143
+ };
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+ /** Whether the same, non-recycled process is still alive. */
149
+ function processIdentityMatches(identity) {
150
+ return processIdentity(identity.pid)?.startToken === identity.startToken;
151
+ }
152
+ function parentPid(pid) {
153
+ try {
154
+ const value = Number(execFileSync(PS, [
155
+ "-o",
156
+ "ppid=",
157
+ "-p",
158
+ String(pid)
159
+ ], {
160
+ encoding: "utf8",
161
+ stdio: "pipe"
162
+ }).trim());
163
+ return Number.isInteger(value) && value > 0 ? value : null;
164
+ } catch {
165
+ return null;
166
+ }
167
+ }
168
+ /** The live POSIX process-group id for one PID, or null when unavailable. */
169
+ function processGroupId(pid) {
170
+ if (!Number.isInteger(pid) || pid <= 0) return null;
171
+ try {
172
+ const value = Number(execFileSync(PS, [
173
+ "-o",
174
+ "pgid=",
175
+ "-p",
176
+ String(pid)
177
+ ], {
178
+ encoding: "utf8",
179
+ stdio: "pipe"
180
+ }).trim());
181
+ return Number.isInteger(value) && value > 0 ? value : null;
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+ /**
187
+ * Prove that exactly one port listener belongs to a supervisor and return the
188
+ * direct child root through which that supervisor owns it. This is captured
189
+ * before cutover; the successor must stop this identity, never an arbitrary
190
+ * process discovered later from the shared port.
191
+ */
192
+ function findOwnedListener(port, supervisorPid) {
193
+ const listeners = findPidsOnPort(port);
194
+ if (listeners.length !== 1) return null;
195
+ const listenerPid = listeners[0];
196
+ if (listenerPid === void 0) return null;
197
+ const listener = processIdentity(listenerPid);
198
+ if (listener === null) return null;
199
+ let cursor = listenerPid;
200
+ let childRoot = null;
201
+ const seen = /* @__PURE__ */ new Set();
202
+ while (cursor > 0 && !seen.has(cursor)) {
203
+ seen.add(cursor);
204
+ const parent = parentPid(cursor);
205
+ if (parent === supervisorPid) {
206
+ childRoot = cursor;
207
+ break;
208
+ }
209
+ if (parent === null) return null;
210
+ cursor = parent;
211
+ }
212
+ if (childRoot === null) return null;
213
+ const child = processIdentity(childRoot);
214
+ return child === null ? null : {
215
+ child,
216
+ listener
217
+ };
218
+ }
219
+ /** POSIX single-quote one word for a shell command line. */
220
+ function shellQuote(word) {
221
+ return `'${word.replace(/'/g, "'\\''")}'`;
222
+ }
223
+ /**
224
+ * Discover how the process on a port was launched — its exact argv from
225
+ * `ps -o command=`, its cwd from lsof, and its DSH_* environment from
226
+ * `ps eww` — rendered as a shell command. This exists because the agent's
227
+ * sandbox blocks ps entirely, so every fresh-machine agent fell into a
228
+ * process-tree archaeology loop before its first restart; the CLI (running
229
+ * unsandboxed) answers the same question mechanically and reliably. Returns
230
+ * null when the process is gone or ps/lsof are unavailable.
231
+ * @param pid - the listener's pid.
232
+ */
233
+ function discoverLaunchCommand(pid) {
234
+ let argv;
235
+ let cwd;
236
+ try {
237
+ argv = execFileSync(PS, [
238
+ "-o",
239
+ "command=",
240
+ "-p",
241
+ pid
242
+ ], {
243
+ encoding: "utf8",
244
+ stdio: "pipe"
245
+ }).trim();
246
+ if (argv === "") return null;
247
+ } catch (error) {
248
+ throw new Error(`ps unavailable: ${String(error)}`);
249
+ }
250
+ try {
251
+ const out = execFileSync(LSOF, [
252
+ "-a",
253
+ "-p",
254
+ pid,
255
+ "-d",
256
+ "cwd",
257
+ "-Fn"
258
+ ], {
259
+ encoding: "utf8",
260
+ stdio: "pipe"
261
+ });
262
+ const match = /^n(.+)$/m.exec(out);
263
+ if (match === null) return null;
264
+ cwd = match[1] ?? "";
265
+ } catch (error) {
266
+ throw new Error(`lsof cwd unavailable: ${String(error)}`);
267
+ }
268
+ const TRANSIENT = /* @__PURE__ */ new Set([
269
+ "DSH_ANKH_RESTART_DRIVER",
270
+ "DSH_SESSION_ID",
271
+ "DSH_SESSION_JSONL",
272
+ "DSH_WEB_URL",
273
+ "DSH_SHELL"
274
+ ]);
275
+ const env = {};
276
+ try {
277
+ const out = execFileSync(PS, [
278
+ "eww",
279
+ "-o",
280
+ "command",
281
+ "-p",
282
+ pid
283
+ ], {
284
+ encoding: "utf8",
285
+ stdio: "pipe"
286
+ });
287
+ for (const token of out.split(/\s+/)) {
288
+ const eq = token.indexOf("=");
289
+ if (eq > 0 && token.slice(0, eq).startsWith("DSH_") && !TRANSIENT.has(token.slice(0, eq))) env[token.slice(0, eq)] = token.slice(eq + 1);
290
+ }
291
+ } catch {}
292
+ const envPart = Object.entries(env).map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ");
293
+ return `cd ${shellQuote(cwd)} && ${envPart !== "" ? `${envPart} ` : ""}${argv}`;
294
+ }
295
+ /**
296
+ * Kill a pid AND its descendants, deepest first (best effort). The supervised
297
+ * instance may have forked children; a plain signal on the pid alone would
298
+ * orphan them (the EADDRINUSE race the watchdog's EADDRINUSE branch exists
299
+ * for). The process-group model is NOT assumed — the instance is not
300
+ * setsid'd — so the sweep walks `pgrep -P` instead. `pgrep` missing or
301
+ * returning nothing is fine: the pid itself still gets the signal.
302
+ */
303
+ function signalFrozenTree(pid, signal) {
304
+ let safe = true;
305
+ let children = [];
306
+ try {
307
+ const out = execFileSync(PGREP, ["-P", String(pid)], {
308
+ encoding: "utf8",
309
+ stdio: "pipe"
310
+ }).trim();
311
+ children = out === "" ? [] : out.split("\n");
312
+ } catch {}
313
+ for (const raw of children) {
314
+ const child = Number(raw);
315
+ if (!Number.isInteger(child) || child <= 0) continue;
316
+ try {
317
+ process.kill(child, "SIGSTOP");
318
+ } catch {
319
+ continue;
320
+ }
321
+ if (parentPid(child) !== pid) {
322
+ safe = false;
323
+ try {
324
+ process.kill(child, "SIGCONT");
325
+ } catch {}
326
+ continue;
327
+ }
328
+ safe = signalFrozenTree(child, signal) && safe;
329
+ }
330
+ try {
331
+ process.kill(pid, signal);
332
+ if (signal !== "SIGKILL") process.kill(pid, "SIGCONT");
333
+ } catch {}
334
+ return safe;
335
+ }
336
+ function killPidTree(pid, signal) {
337
+ try {
338
+ process.kill(pid, "SIGSTOP");
339
+ } catch {
340
+ return;
341
+ }
342
+ signalFrozenTree(pid, signal);
343
+ }
344
+ //#endregion
345
+ //#region lib/types/test-seam.js
346
+ /**
347
+ * Explicit test-only process/event registration seam. Production execution is
348
+ * inert unless a test runner supplies all ANKH_GUARD_TEST_* ownership values.
349
+ */
350
+ const TEST_RUN_DIR_ENV = "ANKH_GUARD_TEST_RUN_DIR";
351
+ const TEST_RUN_TOKEN_ENV = "ANKH_GUARD_TEST_RUN_TOKEN";
352
+ const TEST_PROCESS_ROLE_ENV = "ANKH_GUARD_TEST_PROCESS_ROLE";
353
+ const TEST_PROCESS_PORT_ENV = "ANKH_GUARD_TEST_PROCESS_PORT";
354
+ const TEST_PROCESS_TEMP_ROOT_ENV = "ANKH_GUARD_TEST_PROCESS_TEMP_ROOT";
355
+ const TEST_REGISTER_BIN_ENV = "ANKH_GUARD_TEST_REGISTER_BIN";
356
+ const TEST_SLEEP_SCALE_ENV = "ANKH_GUARD_TEST_SLEEP_SCALE";
357
+ function testCoordinates(env) {
358
+ const runDir = env[TEST_RUN_DIR_ENV];
359
+ const runToken = env[TEST_RUN_TOKEN_ENV];
360
+ if (runDir === void 0 || runDir === "" || runToken === void 0 || !/^[a-f0-9-]{16,}$/.test(runToken)) return null;
361
+ return {
362
+ runDir,
363
+ runToken
364
+ };
365
+ }
366
+ function safeRole(value) {
367
+ const role = value.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 80);
368
+ return role === "" ? "unknown" : role;
369
+ }
370
+ function appendTestLifecycleEventForProcess(subjectPid, roleValue, event, detail, source = "child-self", env = process.env) {
371
+ const coordinates = testCoordinates(env);
372
+ if (coordinates === null) return;
373
+ const role = safeRole(roleValue);
374
+ const identity = processIdentity(subjectPid);
375
+ const pgid = processGroupId(subjectPid);
376
+ const record = {
377
+ version: 1,
378
+ runToken: coordinates.runToken,
379
+ source,
380
+ role,
381
+ event: safeRole(event),
382
+ pid: subjectPid,
383
+ ...pgid === null ? {} : { pgid },
384
+ ...identity === null ? {} : { startToken: identity.startToken },
385
+ wallTimeMs: Date.now(),
386
+ monotonicNs: process.hrtime.bigint().toString(),
387
+ ...detail === void 0 ? {} : { detail }
388
+ };
389
+ try {
390
+ const dir = join(coordinates.runDir, "events");
391
+ mkdirSync(dir, {
392
+ recursive: true,
393
+ mode: 448
394
+ });
395
+ appendFileSync(join(dir, `${subjectPid}-${source}.jsonl`), `${JSON.stringify(record)}\n`, { mode: 384 });
396
+ } catch {}
397
+ }
398
+ /** Record one credential-free event in this process's append-only event file. */
399
+ function appendTestLifecycleEvent(event, detail, source = "child-self", env = process.env) {
400
+ appendTestLifecycleEventForProcess(process.pid, env["ANKH_GUARD_TEST_PROCESS_ROLE"] ?? "unknown", event, detail, source, env);
401
+ }
402
+ /**
403
+ * Atomically publish an immutable PID/PGID/start-identity lease. The same PID
404
+ * may be observed by its parent and then self-register; the first complete
405
+ * record wins because both describe the same kernel start identity.
406
+ */
407
+ function registerTestProcess(pid, role, options = {}) {
408
+ const env = options.env ?? process.env;
409
+ const coordinates = testCoordinates(env);
410
+ if (coordinates === null) return null;
411
+ const identity = options.identity ?? processIdentity(pid);
412
+ const pgid = processGroupId(pid);
413
+ if (identity === null || pgid === null) return null;
414
+ const portText = env[TEST_PROCESS_PORT_ENV];
415
+ const envPort = portText === void 0 ? void 0 : Number(portText);
416
+ const port = options.port ?? (Number.isInteger(envPort) && (envPort ?? 0) > 0 ? envPort : void 0);
417
+ const tempRoot = options.tempRoot ?? env["ANKH_GUARD_TEST_PROCESS_TEMP_ROOT"];
418
+ const record = {
419
+ version: 1,
420
+ runToken: coordinates.runToken,
421
+ role: safeRole(role),
422
+ pid,
423
+ pgid,
424
+ startToken: identity.startToken,
425
+ groupRoot: pgid === pid,
426
+ registeredAt: Date.now(),
427
+ source: options.source ?? (pid === process.pid ? "child-self" : "parent-observer"),
428
+ ...tempRoot === void 0 || tempRoot === "" ? {} : { tempRoot },
429
+ ...port === void 0 ? {} : { port }
430
+ };
431
+ try {
432
+ const dir = join(coordinates.runDir, "processes");
433
+ mkdirSync(dir, {
434
+ recursive: true,
435
+ mode: 448
436
+ });
437
+ const identityHash = createHash("sha256").update(identity.startToken).digest("hex").slice(0, 16);
438
+ writeFileSync(join(dir, `${pid}-${identityHash}.json`), `${JSON.stringify(record)}\n`, {
439
+ flag: "wx",
440
+ mode: 384
441
+ });
442
+ } catch (error) {
443
+ if (error.code !== "EEXIST") return null;
444
+ }
445
+ return record;
446
+ }
447
+ /** Self-register only when an explicit test role accompanies the run lease. */
448
+ function registerCurrentTestProcess(env = process.env) {
449
+ const role = env[TEST_PROCESS_ROLE_ENV];
450
+ if (role === void 0 || role === "") return null;
451
+ const record = registerTestProcess(process.pid, role, {
452
+ env,
453
+ source: "child-self"
454
+ });
455
+ if (record !== null) appendTestLifecycleEvent("process-registered", { pgid: record.pgid }, "child-self", env);
456
+ return record;
457
+ }
458
+ //#endregion
459
+ export { processIdentity as _, TEST_RUN_DIR_ENV as a, appendTestLifecycleEvent as c, registerTestProcess as d, discoverLaunchCommand as f, listeningPortsForPid as g, killPidTree as h, TEST_REGISTER_BIN_ENV as i, appendTestLifecycleEventForProcess as l, findPidOnPort as m, TEST_PROCESS_ROLE_ENV as n, TEST_RUN_TOKEN_ENV as o, findOwnedListener as p, TEST_PROCESS_TEMP_ROOT_ENV as r, TEST_SLEEP_SCALE_ENV as s, TEST_PROCESS_PORT_ENV as t, registerCurrentTestProcess as u, processIdentityMatches as v };
@@ -0,0 +1,2 @@
1
+ import { a as TEST_RUN_DIR_ENV, c as appendTestLifecycleEvent, d as registerTestProcess, i as TEST_REGISTER_BIN_ENV, l as appendTestLifecycleEventForProcess, n as TEST_PROCESS_ROLE_ENV, o as TEST_RUN_TOKEN_ENV, r as TEST_PROCESS_TEMP_ROOT_ENV, s as TEST_SLEEP_SCALE_ENV, t as TEST_PROCESS_PORT_ENV, u as registerCurrentTestProcess } from "./test-seam-dwvaKjRp.js";
2
+ export { TEST_PROCESS_PORT_ENV, TEST_PROCESS_ROLE_ENV, TEST_PROCESS_TEMP_ROOT_ENV, TEST_REGISTER_BIN_ENV, TEST_RUN_DIR_ENV, TEST_RUN_TOKEN_ENV, TEST_SLEEP_SCALE_ENV, appendTestLifecycleEvent, appendTestLifecycleEventForProcess, registerCurrentTestProcess, registerTestProcess };
@@ -0,0 +1,55 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { Context } from '@deepseek-ai/cordis';
3
+ import { type LaunchRole } from './launch-spec.ts';
4
+ import { processIdentityMatches, type ProcessIdentity } from './processes.ts';
5
+ export declare const BROWSER_HANDOFF_ROUTE = "/_ankh-guard/browser-handoff";
6
+ export type BrowserHandoffChannel = 'original-tab' | 'fallback-tab';
7
+ export type BrowserHandoffAuthentication = 'existing-cookie' | 'launch-url';
8
+ export interface BrowserHandoffRegistration {
9
+ authority: string;
10
+ capabilitySha256: string;
11
+ armedAt: number;
12
+ acknowledgedAt?: number;
13
+ }
14
+ export interface BrowserHandoffRequest {
15
+ version: 2;
16
+ cutoverId: string;
17
+ registrations: BrowserHandoffRegistration[];
18
+ }
19
+ export interface BrowserHandoffAcknowledgement {
20
+ version: 1;
21
+ cutoverId: string;
22
+ role: LaunchRole;
23
+ authority: string;
24
+ listenerPid: number;
25
+ listenerStartToken: string;
26
+ channel: BrowserHandoffChannel;
27
+ authentication: BrowserHandoffAuthentication;
28
+ acknowledgedAt: number;
29
+ }
30
+ interface ConnectionSlice {
31
+ requestRejection?(request: IncomingMessage): 401 | 403 | undefined;
32
+ authenticatedUrl?(baseUrl: string): string;
33
+ }
34
+ interface BrowserHandoffDependencies {
35
+ stateDir: string;
36
+ pid?: number;
37
+ now?: () => number;
38
+ identityMatches?: typeof processIdentityMatches;
39
+ /** Boot-id source for the generation channel; production default is the serving process itself. */
40
+ identityProvider?: (pid: number) => ProcessIdentity | null;
41
+ connection?: ConnectionSlice;
42
+ /** Resolve lazily because WebServer can activate before the optional connection service. */
43
+ connectionProvider?: () => ConnectionSlice | undefined;
44
+ /** Test seams; production defaults turn idle polling into a held request. */
45
+ longPollMs?: number;
46
+ longPollIntervalMs?: number;
47
+ }
48
+ export declare function readBrowserHandoffRequest(stateDir: string): BrowserHandoffRequest | null;
49
+ export declare function readBrowserHandoffAcknowledgement(stateDir: string): BrowserHandoffAcknowledgement | null;
50
+ /** Create the exact HTTP handler; dependencies are injectable for deterministic protocol tests. */
51
+ export declare function createBrowserHandoffHandler(dependencies: BrowserHandoffDependencies): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
52
+ /** Register the optional Web-profile bridge; headless/TUI compositions simply never activate it. */
53
+ export declare function registerBrowserHandoff(ctx: Context, stateDir: string): void;
54
+ export {};
55
+ //# sourceMappingURL=browser-handoff.d.ts.map