@khorsheed/dsh-ankh-guard 0.1.1 → 0.3.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 (59) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.en.md +75 -29
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +74 -29
  5. package/lib/cli.js +2349 -182
  6. package/lib/client.js +344 -0
  7. package/lib/exit-agent.js +5 -2
  8. package/lib/index.js +746 -40
  9. package/lib/invariant.js +1 -1
  10. package/lib/preflight-runner.js +125 -47
  11. package/lib/restart-context-DUyExi9O.js +1245 -0
  12. package/lib/restart-context-DlaDZWKq.js +1250 -0
  13. package/lib/{state-Dhx9VG44.js → state-CZMypGkB.js} +97 -13
  14. package/lib/test-seam-cli.js +24 -0
  15. package/lib/test-seam-dwvaKjRp.js +459 -0
  16. package/lib/test-seam.js +2 -0
  17. package/lib/types/browser-handoff.d.ts +55 -0
  18. package/lib/types/browser-handoff.js +489 -0
  19. package/lib/types/cli.d.ts +34 -4
  20. package/lib/types/cli.js +1421 -166
  21. package/lib/types/client/index.d.ts +15 -0
  22. package/lib/types/client/index.js +348 -0
  23. package/lib/types/deployment-proof.d.ts +24 -0
  24. package/lib/types/deployment-proof.js +314 -0
  25. package/lib/types/exit-agent.js +2 -0
  26. package/lib/types/git.d.ts +12 -3
  27. package/lib/types/git.js +69 -7
  28. package/lib/types/index.d.ts +66 -3
  29. package/lib/types/index.js +192 -41
  30. package/lib/types/launch-spec.d.ts +263 -0
  31. package/lib/types/launch-spec.js +823 -0
  32. package/lib/types/preflight-runner.d.ts +23 -12
  33. package/lib/types/preflight-runner.js +152 -57
  34. package/lib/types/processes.d.ts +38 -6
  35. package/lib/types/processes.js +236 -10
  36. package/lib/types/restart-context.d.ts +50 -0
  37. package/lib/types/restart-context.js +117 -6
  38. package/lib/types/restart-request.d.ts +32 -0
  39. package/lib/types/restart-request.js +128 -0
  40. package/lib/types/state-files.d.ts +30 -0
  41. package/lib/types/state-files.js +55 -0
  42. package/lib/types/state.d.ts +29 -2
  43. package/lib/types/state.js +52 -7
  44. package/lib/types/temp-artifact.d.ts +15 -0
  45. package/lib/types/temp-artifact.js +17 -0
  46. package/lib/types/test-seam-cli.d.ts +3 -0
  47. package/lib/types/test-seam-cli.js +27 -0
  48. package/lib/types/test-seam.d.ts +55 -0
  49. package/lib/types/test-seam.js +112 -0
  50. package/lib/types/transition.d.ts +118 -0
  51. package/lib/types/transition.js +717 -0
  52. package/package.json +30 -11
  53. package/scripts/dsh-watchdog.sh +1388 -80
  54. package/scripts/install-launchd.sh +43 -5
  55. package/scripts/install-systemd.sh +43 -5
  56. package/scripts/on-install.js +1 -1
  57. package/skills/dsh-self-restart-guard/SKILL.md +38 -12
  58. package/lib/processes-hCAmwma-.js +0 -127
  59. package/lib/restart-context-DmnQXNf-.js +0 -421
@@ -1,3 +1,4 @@
1
+ import { c as appendTestLifecycleEvent } from "./test-seam-dwvaKjRp.js";
1
2
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  //#region lib/types/state-files.js
@@ -44,8 +45,26 @@ const STATE_FILES = {
44
45
  scheduleExitLog: "schedule-exit.log",
45
46
  /** How the current instance was launched (recorded by the plugin at apply). */
46
47
  instanceLaunch: "instance-launch.json",
48
+ /** The atomically selected full launch configuration (stable or in cutover). */
49
+ launchSpec: "launch-spec.json",
50
+ /** Redacted durable receipt for the latest launch-configuration cutover. */
51
+ launchCutover: "launch-cutover.json",
52
+ /** Legacy combined operator-control marker, retained for rolling upgrades. */
53
+ cutoverControl: "launch-cutover-control.json",
54
+ /** Operator → watchdog: abort according to the pre-approved recovery policy. */
55
+ cutoverAbort: "launch-cutover-abort.json",
56
+ /** Monotonic stronger operator action: explicitly restore the previous spec. */
57
+ cutoverRestorePrevious: "launch-cutover-restore-previous.json",
58
+ /** Original browser-tab registry: per-tab capability hashes only; retained through terminal recovery. */
59
+ browserHandoffRequest: "browser-handoff-request.json",
60
+ /** Browser → watchdog acknowledgement for one proven final process. */
61
+ browserHandoffAck: "browser-handoff-ack.json",
47
62
  /** Whether the restart-protocol skill registered at apply (and why not). */
48
- skillRegistration: "skill-registration.json"
63
+ skillRegistration: "skill-registration.json",
64
+ /** Directory: the healthy-boot snapshot of the profile composition inputs. */
65
+ lastGoodComposition: "last-good-composition",
66
+ /** Directory prefix: a failing composition backed up before rollback restores over it. */
67
+ compositionBackup: "composition-backup-"
49
68
  };
50
69
  /** Absolute path of a state file inside a state directory. */
51
70
  function stateFile(stateDir, role) {
@@ -67,6 +86,42 @@ function lastGoodBootRevision(stateDir) {
67
86
  return;
68
87
  }
69
88
  }
89
+ /**
90
+ * Whether the pid named by this raw pid/lock-file content is alive. Empty
91
+ * content reads as NO holder: Number('') is 0 and kill(0, 0) probes our own
92
+ * process group (always succeeds), which once read as "alive" and refused
93
+ * every restart forever — the bug that had to be fixed in two copies of this
94
+ * logic before it was consolidated here.
95
+ */
96
+ function pidAlive(raw) {
97
+ const pid = Number(raw);
98
+ if (raw === "" || !Number.isInteger(pid) || pid <= 0) return false;
99
+ try {
100
+ process.kill(pid, 0);
101
+ return true;
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+ /** The live pid named by a pid/lock file (as the raw string), or null when absent/stale. */
107
+ function livePidIn(file) {
108
+ try {
109
+ const raw = readFileSync(file, "utf8").trim();
110
+ return pidAlive(raw) ? raw : null;
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+ /** The live supervising watchdog's pid, or null when none is (pidfile + kill 0). */
116
+ function liveWatchdogPid(stateDir) {
117
+ const raw = livePidIn(stateFile(stateDir, "watchdogPid"));
118
+ const pid = raw === null ? null : Number(raw);
119
+ appendTestLifecycleEvent("watchdog-liveness-probe", {
120
+ pid: pid ?? 0,
121
+ live: pid !== null
122
+ }, "parent-observer");
123
+ return pid;
124
+ }
70
125
  //#endregion
71
126
  //#region lib/types/state.js
72
127
  /**
@@ -74,10 +129,9 @@ function lastGoodBootRevision(stateDir) {
74
129
  *
75
130
  * The guard records one "green build" credential — bound to the git HEAD it
76
131
  * was recorded on and to a freshness window — and answers `verify()` against
77
- * the CURRENT head and wall clock. A self-restart may only proceed while the
78
- * credential is valid; the binding to HEAD means any tree change after
79
- * recording invalidates it, so a stale or post-hoc credential can never
80
- * authorize a restart of unverified code. Framework-free: the cordis plugin,
132
+ * the CURRENT head and wall clock. A later fully gated boot may promote that
133
+ * credential into a deployment proof whose exact runtime fingerprint can be
134
+ * reused by the same-launch restart path. Framework-free: the cordis plugin,
81
135
  * the CLI, and the invariant companion all share this module.
82
136
  */
83
137
  /** Absolute path of the state file inside a state directory. */
@@ -98,10 +152,20 @@ function isCheckpoint(value) {
98
152
  const c = value;
99
153
  return typeof c.revision === "string" && typeof c.recordedAt === "number" && typeof c.message === "string";
100
154
  }
155
+ function isSha256(value) {
156
+ return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
157
+ }
158
+ function isProvenDeployment(value) {
159
+ if (typeof value !== "object" || value === null) return false;
160
+ const proof = value;
161
+ const credential = proof.credential;
162
+ const fingerprint = proof.fingerprint;
163
+ return proof.version === 1 && typeof proof.provenAt === "number" && credential !== void 0 && typeof credential.revision === "string" && credential.revision !== "" && typeof credential.recordedAt === "number" && typeof credential.scope === "string" && isSha256(credential.commandSha256) && fingerprint?.version === 1 && typeof fingerprint.credentialRevision === "string" && fingerprint.credentialRevision !== "" && typeof fingerprint.harnessRevision === "string" && fingerprint.harnessRevision !== "" && isSha256(fingerprint.launchSpecSha256) && isSha256(fingerprint.profileSha256) && isSha256(fingerprint.hostRuntimeSha256) && isSha256(proof.fingerprintSha256);
164
+ }
101
165
  function isAuditEntry(value) {
102
166
  if (typeof value !== "object" || value === null) return false;
103
167
  const e = value;
104
- return (e.action === "record" || e.action === "clear" || e.action === "checkpoint") && typeof e.ts === "number" && typeof e.detail === "string";
168
+ return (e.action === "record" || e.action === "clear" || e.action === "checkpoint" || e.action === "prove-deployment") && typeof e.ts === "number" && typeof e.detail === "string";
105
169
  }
106
170
  /**
107
171
  * Read the state file; an absent file is an empty state, a malformed one is a
@@ -116,6 +180,7 @@ function loadState(stateDir) {
116
180
  if (parsed === null || typeof parsed !== "object") return emptyState();
117
181
  const state = { audit: Array.isArray(parsed.audit) ? parsed.audit.filter(isAuditEntry) : [] };
118
182
  if (isCredential(parsed.credential)) state.credential = parsed.credential;
183
+ if (isProvenDeployment(parsed.provenDeployment)) state.provenDeployment = parsed.provenDeployment;
119
184
  if (isCheckpoint(parsed.checkpoint)) state.checkpoint = parsed.checkpoint;
120
185
  return state;
121
186
  } catch (error) {
@@ -149,12 +214,13 @@ function recordCredential(stateDir, input, now) {
149
214
  ...input,
150
215
  recordedAt: now
151
216
  };
217
+ const { provenDeployment: _oldProof, ...withoutOldProof } = withAudit(loadState(stateDir), {
218
+ action: "record",
219
+ ts: now,
220
+ detail: `${input.scope} @ ${input.revision}`
221
+ });
152
222
  const updated = {
153
- ...withAudit(loadState(stateDir), {
154
- action: "record",
155
- ts: now,
156
- detail: `${input.scope} @ ${input.revision}`
157
- }),
223
+ ...withoutOldProof,
158
224
  credential
159
225
  };
160
226
  saveState(stateDir, updated);
@@ -177,6 +243,19 @@ function clearCredential(stateDir, now) {
177
243
  saveState(stateDir, updated);
178
244
  return updated;
179
245
  }
246
+ /** Persist a deployment proof after the watchdog has completed its canary. */
247
+ function setProvenDeployment(stateDir, proof, now) {
248
+ const updated = {
249
+ ...withAudit(loadState(stateDir), {
250
+ action: "prove-deployment",
251
+ ts: now,
252
+ detail: `${proof.credential.revision} ${proof.fingerprintSha256.slice(0, 16)}`
253
+ }),
254
+ provenDeployment: proof
255
+ };
256
+ saveState(stateDir, updated);
257
+ return updated;
258
+ }
180
259
  /**
181
260
  * Persist a pre-batch checkpoint commit reference.
182
261
  * @param stateDir - state directory.
@@ -207,9 +286,10 @@ function setCheckpoint(stateDir, input, now) {
207
286
  * @param currentRevision - the git HEAD of the checkout, or null when unavailable.
208
287
  * @param now - epoch milliseconds (injected for deterministic tests).
209
288
  * @param maxAgeMinutes - freshness window.
289
+ * @param workingTreeClean - whether staged, unstaged, and untracked inputs are absent.
210
290
  * @returns ok plus a human reason either way.
211
291
  */
212
- function verifyCredential(state, currentRevision, now, maxAgeMinutes) {
292
+ function verifyCredential(state, currentRevision, now, maxAgeMinutes, workingTreeClean = true) {
213
293
  const credential = state.credential;
214
294
  if (credential === void 0) return {
215
295
  ok: false,
@@ -219,6 +299,10 @@ function verifyCredential(state, currentRevision, now, maxAgeMinutes) {
219
299
  ok: false,
220
300
  reason: "current git HEAD unavailable (not inside a git repository?)"
221
301
  };
302
+ if (!workingTreeClean) return {
303
+ ok: false,
304
+ reason: "working tree is dirty (staged, unstaged, or untracked changes exist) — commit or remove them, then rebuild and re-record"
305
+ };
222
306
  if (credential.revision !== currentRevision) return {
223
307
  ok: false,
224
308
  reason: `green credential is bound to revision ${credential.revision}, but current HEAD is ${currentRevision} — the tree changed since it was recorded; rebuild and re-record`
@@ -236,4 +320,4 @@ function verifyCredential(state, currentRevision, now, maxAgeMinutes) {
236
320
  };
237
321
  }
238
322
  //#endregion
239
- export { stateFilePath as a, stateFile as c, setCheckpoint as i, loadState as n, verifyCredential as o, recordCredential as r, lastGoodBootRevision as s, clearCredential as t };
323
+ export { setProvenDeployment as a, lastGoodBootRevision as c, pidAlive as d, stateFile as f, setCheckpoint as i, livePidIn as l, loadState as n, stateFilePath as o, recordCredential as r, verifyCredential as s, clearCredential as t, liveWatchdogPid as u };
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import { d as registerTestProcess, l as appendTestLifecycleEventForProcess, u as registerCurrentTestProcess } from "./test-seam-dwvaKjRp.js";
3
+ //#region lib/types/test-seam-cli.js
4
+ /** Internal executable used only by dsh-watchdog.sh under an explicit test run. */
5
+ const command = process.argv[2] ?? "register";
6
+ if (command === "register-parent") {
7
+ const role = process.argv[3] ?? process.env["ANKH_GUARD_TEST_PROCESS_ROLE"] ?? "unknown";
8
+ registerTestProcess(process.ppid, role, { source: "child-self" });
9
+ } else if (command === "event-parent") {
10
+ const role = process.argv[3] ?? process.env["ANKH_GUARD_TEST_PROCESS_ROLE"] ?? "unknown";
11
+ const event = process.argv[4] ?? "unknown";
12
+ appendTestLifecycleEventForProcess(process.ppid, role, event);
13
+ } else if (command === "register-pid") {
14
+ const pid = Number(process.argv[3]);
15
+ const role = process.argv[4] ?? process.env["ANKH_GUARD_TEST_PROCESS_ROLE"] ?? "unknown";
16
+ registerTestProcess(pid, role, { source: "child-self" });
17
+ } else if (command === "event-pid") {
18
+ const pid = Number(process.argv[3]);
19
+ const role = process.argv[4] ?? process.env["ANKH_GUARD_TEST_PROCESS_ROLE"] ?? "unknown";
20
+ const event = process.argv[5] ?? "unknown";
21
+ appendTestLifecycleEventForProcess(pid, role, event);
22
+ } else registerCurrentTestProcess();
23
+ //#endregion
24
+ export {};
@@ -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 };