@khorsheed/dsh-ankh-guard 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.en.md +75 -29
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +74 -29
  5. package/lib/cli.js +2349 -182
  6. package/lib/client.js +257 -0
  7. package/lib/exit-agent.js +5 -2
  8. package/lib/index.js +721 -38
  9. package/lib/invariant.js +1 -1
  10. package/lib/preflight-runner.js +125 -47
  11. package/lib/processes-BjZgJjQr.js +344 -0
  12. package/lib/restart-context-D6nISh28.js +1245 -0
  13. package/lib/restart-context-DUyExi9O.js +1245 -0
  14. package/lib/{state-Dhx9VG44.js → state-4f7yny39.js} +60 -13
  15. package/lib/state-CZMypGkB.js +323 -0
  16. package/lib/test-seam-DnvLWTeO.js +119 -0
  17. package/lib/test-seam-cli.js +24 -0
  18. package/lib/test-seam-dwvaKjRp.js +459 -0
  19. package/lib/test-seam.js +2 -0
  20. package/lib/types/browser-handoff.d.ts +55 -0
  21. package/lib/types/browser-handoff.js +489 -0
  22. package/lib/types/cli.d.ts +34 -4
  23. package/lib/types/cli.js +1421 -166
  24. package/lib/types/client/index.d.ts +15 -0
  25. package/lib/types/client/index.js +264 -0
  26. package/lib/types/deployment-proof.d.ts +24 -0
  27. package/lib/types/deployment-proof.js +314 -0
  28. package/lib/types/exit-agent.js +2 -0
  29. package/lib/types/git.d.ts +12 -3
  30. package/lib/types/git.js +69 -7
  31. package/lib/types/index.d.ts +66 -3
  32. package/lib/types/index.js +157 -39
  33. package/lib/types/launch-spec.d.ts +263 -0
  34. package/lib/types/launch-spec.js +823 -0
  35. package/lib/types/preflight-runner.d.ts +23 -12
  36. package/lib/types/preflight-runner.js +152 -57
  37. package/lib/types/processes.d.ts +38 -6
  38. package/lib/types/processes.js +236 -10
  39. package/lib/types/restart-context.d.ts +50 -0
  40. package/lib/types/restart-context.js +106 -0
  41. package/lib/types/restart-request.d.ts +32 -0
  42. package/lib/types/restart-request.js +128 -0
  43. package/lib/types/state-files.d.ts +30 -0
  44. package/lib/types/state-files.js +55 -0
  45. package/lib/types/state.d.ts +29 -2
  46. package/lib/types/state.js +52 -7
  47. package/lib/types/temp-artifact.d.ts +15 -0
  48. package/lib/types/temp-artifact.js +17 -0
  49. package/lib/types/test-seam-cli.d.ts +3 -0
  50. package/lib/types/test-seam-cli.js +27 -0
  51. package/lib/types/test-seam.d.ts +55 -0
  52. package/lib/types/test-seam.js +112 -0
  53. package/lib/types/transition.d.ts +118 -0
  54. package/lib/types/transition.js +717 -0
  55. package/package.json +30 -11
  56. package/scripts/dsh-watchdog.sh +1388 -80
  57. package/scripts/install-launchd.sh +43 -5
  58. package/scripts/install-systemd.sh +43 -5
  59. package/scripts/on-install.js +1 -1
  60. package/skills/dsh-self-restart-guard/SKILL.md +38 -12
  61. package/lib/processes-hCAmwma-.js +0 -127
  62. package/lib/restart-context-DmnQXNf-.js +0 -421
@@ -44,8 +44,26 @@ const STATE_FILES = {
44
44
  scheduleExitLog: "schedule-exit.log",
45
45
  /** How the current instance was launched (recorded by the plugin at apply). */
46
46
  instanceLaunch: "instance-launch.json",
47
+ /** The atomically selected full launch configuration (stable or in cutover). */
48
+ launchSpec: "launch-spec.json",
49
+ /** Redacted durable receipt for the latest launch-configuration cutover. */
50
+ launchCutover: "launch-cutover.json",
51
+ /** Legacy combined operator-control marker, retained for rolling upgrades. */
52
+ cutoverControl: "launch-cutover-control.json",
53
+ /** Operator → watchdog: abort according to the pre-approved recovery policy. */
54
+ cutoverAbort: "launch-cutover-abort.json",
55
+ /** Monotonic stronger operator action: explicitly restore the previous spec. */
56
+ cutoverRestorePrevious: "launch-cutover-restore-previous.json",
57
+ /** Original browser-tab registry: per-tab capability hashes only; retained through terminal recovery. */
58
+ browserHandoffRequest: "browser-handoff-request.json",
59
+ /** Browser → watchdog acknowledgement for one proven final process. */
60
+ browserHandoffAck: "browser-handoff-ack.json",
47
61
  /** Whether the restart-protocol skill registered at apply (and why not). */
48
- skillRegistration: "skill-registration.json"
62
+ skillRegistration: "skill-registration.json",
63
+ /** Directory: the healthy-boot snapshot of the profile composition inputs. */
64
+ lastGoodComposition: "last-good-composition",
65
+ /** Directory prefix: a failing composition backed up before rollback restores over it. */
66
+ compositionBackup: "composition-backup-"
49
67
  };
50
68
  /** Absolute path of a state file inside a state directory. */
51
69
  function stateFile(stateDir, role) {
@@ -74,10 +92,9 @@ function lastGoodBootRevision(stateDir) {
74
92
  *
75
93
  * The guard records one "green build" credential — bound to the git HEAD it
76
94
  * 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,
95
+ * the CURRENT head and wall clock. A later fully gated boot may promote that
96
+ * credential into a deployment proof whose exact runtime fingerprint can be
97
+ * reused by the same-launch restart path. Framework-free: the cordis plugin,
81
98
  * the CLI, and the invariant companion all share this module.
82
99
  */
83
100
  /** Absolute path of the state file inside a state directory. */
@@ -98,10 +115,20 @@ function isCheckpoint(value) {
98
115
  const c = value;
99
116
  return typeof c.revision === "string" && typeof c.recordedAt === "number" && typeof c.message === "string";
100
117
  }
118
+ function isSha256(value) {
119
+ return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
120
+ }
121
+ function isProvenDeployment(value) {
122
+ if (typeof value !== "object" || value === null) return false;
123
+ const proof = value;
124
+ const credential = proof.credential;
125
+ const fingerprint = proof.fingerprint;
126
+ 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);
127
+ }
101
128
  function isAuditEntry(value) {
102
129
  if (typeof value !== "object" || value === null) return false;
103
130
  const e = value;
104
- return (e.action === "record" || e.action === "clear" || e.action === "checkpoint") && typeof e.ts === "number" && typeof e.detail === "string";
131
+ return (e.action === "record" || e.action === "clear" || e.action === "checkpoint" || e.action === "prove-deployment") && typeof e.ts === "number" && typeof e.detail === "string";
105
132
  }
106
133
  /**
107
134
  * Read the state file; an absent file is an empty state, a malformed one is a
@@ -116,6 +143,7 @@ function loadState(stateDir) {
116
143
  if (parsed === null || typeof parsed !== "object") return emptyState();
117
144
  const state = { audit: Array.isArray(parsed.audit) ? parsed.audit.filter(isAuditEntry) : [] };
118
145
  if (isCredential(parsed.credential)) state.credential = parsed.credential;
146
+ if (isProvenDeployment(parsed.provenDeployment)) state.provenDeployment = parsed.provenDeployment;
119
147
  if (isCheckpoint(parsed.checkpoint)) state.checkpoint = parsed.checkpoint;
120
148
  return state;
121
149
  } catch (error) {
@@ -149,12 +177,13 @@ function recordCredential(stateDir, input, now) {
149
177
  ...input,
150
178
  recordedAt: now
151
179
  };
180
+ const { provenDeployment: _oldProof, ...withoutOldProof } = withAudit(loadState(stateDir), {
181
+ action: "record",
182
+ ts: now,
183
+ detail: `${input.scope} @ ${input.revision}`
184
+ });
152
185
  const updated = {
153
- ...withAudit(loadState(stateDir), {
154
- action: "record",
155
- ts: now,
156
- detail: `${input.scope} @ ${input.revision}`
157
- }),
186
+ ...withoutOldProof,
158
187
  credential
159
188
  };
160
189
  saveState(stateDir, updated);
@@ -177,6 +206,19 @@ function clearCredential(stateDir, now) {
177
206
  saveState(stateDir, updated);
178
207
  return updated;
179
208
  }
209
+ /** Persist a deployment proof after the watchdog has completed its canary. */
210
+ function setProvenDeployment(stateDir, proof, now) {
211
+ const updated = {
212
+ ...withAudit(loadState(stateDir), {
213
+ action: "prove-deployment",
214
+ ts: now,
215
+ detail: `${proof.credential.revision} ${proof.fingerprintSha256.slice(0, 16)}`
216
+ }),
217
+ provenDeployment: proof
218
+ };
219
+ saveState(stateDir, updated);
220
+ return updated;
221
+ }
180
222
  /**
181
223
  * Persist a pre-batch checkpoint commit reference.
182
224
  * @param stateDir - state directory.
@@ -207,9 +249,10 @@ function setCheckpoint(stateDir, input, now) {
207
249
  * @param currentRevision - the git HEAD of the checkout, or null when unavailable.
208
250
  * @param now - epoch milliseconds (injected for deterministic tests).
209
251
  * @param maxAgeMinutes - freshness window.
252
+ * @param workingTreeClean - whether staged, unstaged, and untracked inputs are absent.
210
253
  * @returns ok plus a human reason either way.
211
254
  */
212
- function verifyCredential(state, currentRevision, now, maxAgeMinutes) {
255
+ function verifyCredential(state, currentRevision, now, maxAgeMinutes, workingTreeClean = true) {
213
256
  const credential = state.credential;
214
257
  if (credential === void 0) return {
215
258
  ok: false,
@@ -219,6 +262,10 @@ function verifyCredential(state, currentRevision, now, maxAgeMinutes) {
219
262
  ok: false,
220
263
  reason: "current git HEAD unavailable (not inside a git repository?)"
221
264
  };
265
+ if (!workingTreeClean) return {
266
+ ok: false,
267
+ reason: "working tree is dirty (staged, unstaged, or untracked changes exist) — commit or remove them, then rebuild and re-record"
268
+ };
222
269
  if (credential.revision !== currentRevision) return {
223
270
  ok: false,
224
271
  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 +283,4 @@ function verifyCredential(state, currentRevision, now, maxAgeMinutes) {
236
283
  };
237
284
  }
238
285
  //#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 };
286
+ export { setProvenDeployment as a, lastGoodBootRevision as c, setCheckpoint as i, stateFile as l, loadState as n, stateFilePath as o, recordCredential as r, verifyCredential as s, clearCredential as t };
@@ -0,0 +1,323 @@
1
+ import { c as appendTestLifecycleEvent } from "./test-seam-dwvaKjRp.js";
2
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ //#region lib/types/state-files.js
5
+ /**
6
+ * The guard's state-directory protocol: every file that lives in the state
7
+ * dir, named exactly once. Five writers in three languages (this package's
8
+ * TS, the watchdog's bash, the schedule-exit exit agent's inline JS, and the
9
+ * two supervisor installers) read and write these — a literal drifting in
10
+ * any one of them splits the protocol silently (three review rounds of path
11
+ * bugs came from exactly that). The bash sides are pinned by
12
+ * tests/state-files.spec.ts, which asserts every state-dir literal in the
13
+ * watchdog and both installers appears here, and that no script re-derives
14
+ * the directory from the home.
15
+ */
16
+ /** Every state-directory file name, keyed by role. */
17
+ const STATE_FILES = {
18
+ /** The guard's credential/checkpoint/audit state (state.ts). */
19
+ guard: "self-restart-guard.json",
20
+ /** Watchdog-stamped last deployment-proven revision. */
21
+ lastGoodBoot: "last-good-boot.json",
22
+ /** schedule-exit → watchdog: an intentional restart, run the canary. */
23
+ restartRequested: "restart-requested.json",
24
+ /** The exit agent's restart outcome record (the report's source). */
25
+ lastRestart: "last-restart.json",
26
+ /** The SIGTERM snapshot of interrupted sessions. */
27
+ interruptedSessions: "interrupted-sessions.json",
28
+ /** The supervising watchdog's pidfile. */
29
+ watchdogPid: "watchdog.pid",
30
+ /** Cross-session mutual exclusion for the restart verb. */
31
+ restartLock: "restart.lock",
32
+ /** The detached restart driver's log. */
33
+ restartLog: "restart.log",
34
+ /** Marker: exit the watchdog without respawn. */
35
+ watchdogStop: "watchdog-stop",
36
+ /** Marker: the watchdog gave up; a crash page holds the port. */
37
+ watchdogGaveUp: "watchdog-gave-up",
38
+ /** The current boot attempt's captured output. */
39
+ bootAttemptLog: "boot-attempt.log",
40
+ /** The watchdog's own log. */
41
+ watchdogLog: "watchdog.log",
42
+ /** The watchdog's stderr, captured separately by the supervisor installers. */
43
+ watchdogStderrLog: "watchdog.stderr.log",
44
+ /** The exit agent's log. */
45
+ scheduleExitLog: "schedule-exit.log",
46
+ /** How the current instance was launched (recorded by the plugin at apply). */
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",
62
+ /** Whether the restart-protocol skill registered at apply (and why not). */
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-"
68
+ };
69
+ /** Absolute path of a state file inside a state directory. */
70
+ function stateFile(stateDir, role) {
71
+ return join(stateDir, STATE_FILES[role]);
72
+ }
73
+ /**
74
+ * The last deployment-proven revision, stamped by the watchdog on every
75
+ * healthy boot, or undefined. Lives here rather than in the credential core:
76
+ * the stamp is written by the watchdog and proves the deployment composed and
77
+ * came up — a green credential only ever proves build+test passed.
78
+ * @param stateDir - state directory.
79
+ * @returns the stamped revision, or undefined.
80
+ */
81
+ function lastGoodBootRevision(stateDir) {
82
+ try {
83
+ const stamp = JSON.parse(readFileSync(stateFile(stateDir, "lastGoodBoot"), "utf8"));
84
+ return typeof stamp.revision === "string" && stamp.revision !== "" ? stamp.revision : void 0;
85
+ } catch {
86
+ return;
87
+ }
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
+ }
125
+ //#endregion
126
+ //#region lib/types/state.js
127
+ /**
128
+ * Pure credential/checkpoint state core for the self-restart guard.
129
+ *
130
+ * The guard records one "green build" credential — bound to the git HEAD it
131
+ * was recorded on and to a freshness window — and answers `verify()` against
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,
135
+ * the CLI, and the invariant companion all share this module.
136
+ */
137
+ /** Absolute path of the state file inside a state directory. */
138
+ function stateFilePath(stateDir) {
139
+ return stateFile(stateDir, "guard");
140
+ }
141
+ /** A fresh, credential-less state. */
142
+ function emptyState() {
143
+ return { audit: [] };
144
+ }
145
+ function isCredential(value) {
146
+ if (typeof value !== "object" || value === null) return false;
147
+ const c = value;
148
+ return typeof c.scope === "string" && typeof c.revision === "string" && typeof c.recordedAt === "number" && typeof c.command === "string";
149
+ }
150
+ function isCheckpoint(value) {
151
+ if (typeof value !== "object" || value === null) return false;
152
+ const c = value;
153
+ return typeof c.revision === "string" && typeof c.recordedAt === "number" && typeof c.message === "string";
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
+ }
165
+ function isAuditEntry(value) {
166
+ if (typeof value !== "object" || value === null) return false;
167
+ const e = value;
168
+ return (e.action === "record" || e.action === "clear" || e.action === "checkpoint" || e.action === "prove-deployment") && typeof e.ts === "number" && typeof e.detail === "string";
169
+ }
170
+ /**
171
+ * Read the state file; an absent file is an empty state, a malformed one is a
172
+ * loud misconfiguration error (never silently ignored).
173
+ * @param stateDir - directory holding the state file.
174
+ * @returns the parsed state.
175
+ */
176
+ function loadState(stateDir) {
177
+ try {
178
+ const raw = readFileSync(stateFilePath(stateDir), "utf8");
179
+ const parsed = JSON.parse(raw);
180
+ if (parsed === null || typeof parsed !== "object") return emptyState();
181
+ const state = { audit: Array.isArray(parsed.audit) ? parsed.audit.filter(isAuditEntry) : [] };
182
+ if (isCredential(parsed.credential)) state.credential = parsed.credential;
183
+ if (isProvenDeployment(parsed.provenDeployment)) state.provenDeployment = parsed.provenDeployment;
184
+ if (isCheckpoint(parsed.checkpoint)) state.checkpoint = parsed.checkpoint;
185
+ return state;
186
+ } catch (error) {
187
+ if (error?.code === "ENOENT") return emptyState();
188
+ throw new Error(`ankh-guard: unreadable state file ${stateFilePath(stateDir)}: ${String(error)}`);
189
+ }
190
+ }
191
+ /** Persist a state (creates the directory as needed). */
192
+ function saveState(stateDir, state) {
193
+ mkdirSync(stateDir, { recursive: true });
194
+ const file = stateFilePath(stateDir);
195
+ const tmp = `${file}.${process.pid}.tmp`;
196
+ writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
197
+ renameSync(tmp, file);
198
+ }
199
+ function withAudit(state, entry) {
200
+ return {
201
+ ...state,
202
+ audit: [...state.audit, entry].slice(-50)
203
+ };
204
+ }
205
+ /**
206
+ * Record a green-build credential bound to {@link RecordInput.revision}.
207
+ * @param stateDir - state directory.
208
+ * @param input - scope, the git HEAD, and the command that went green.
209
+ * @param now - epoch milliseconds (injected for deterministic tests).
210
+ * @returns the persisted state.
211
+ */
212
+ function recordCredential(stateDir, input, now) {
213
+ const credential = {
214
+ ...input,
215
+ recordedAt: now
216
+ };
217
+ const { provenDeployment: _oldProof, ...withoutOldProof } = withAudit(loadState(stateDir), {
218
+ action: "record",
219
+ ts: now,
220
+ detail: `${input.scope} @ ${input.revision}`
221
+ });
222
+ const updated = {
223
+ ...withoutOldProof,
224
+ credential
225
+ };
226
+ saveState(stateDir, updated);
227
+ return updated;
228
+ }
229
+ /**
230
+ * Drop the credential (checkpoint and audit survive).
231
+ * @param stateDir - state directory.
232
+ * @param now - epoch milliseconds (injected for deterministic tests).
233
+ * @returns the persisted state.
234
+ */
235
+ function clearCredential(stateDir, now) {
236
+ const audited = withAudit(loadState(stateDir), {
237
+ action: "clear",
238
+ ts: now,
239
+ detail: "credential cleared"
240
+ });
241
+ const updated = { audit: audited.audit };
242
+ if (audited.checkpoint !== void 0) updated.checkpoint = audited.checkpoint;
243
+ saveState(stateDir, updated);
244
+ return updated;
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
+ }
259
+ /**
260
+ * Persist a pre-batch checkpoint commit reference.
261
+ * @param stateDir - state directory.
262
+ * @param input - the checkpoint commit SHA and its message.
263
+ * @param now - epoch milliseconds (injected for deterministic tests).
264
+ * @returns the persisted state.
265
+ */
266
+ function setCheckpoint(stateDir, input, now) {
267
+ const checkpoint = {
268
+ ...input,
269
+ recordedAt: now
270
+ };
271
+ const updated = {
272
+ ...withAudit(loadState(stateDir), {
273
+ action: "checkpoint",
274
+ ts: now,
275
+ detail: `${input.revision} ${input.message}`
276
+ }),
277
+ checkpoint
278
+ };
279
+ saveState(stateDir, updated);
280
+ return updated;
281
+ }
282
+ /**
283
+ * Answer the gate: a credential is valid only when present, recorded on the
284
+ * CURRENT revision, and younger than {@link maxAgeMinutes}.
285
+ * @param state - the loaded state.
286
+ * @param currentRevision - the git HEAD of the checkout, or null when unavailable.
287
+ * @param now - epoch milliseconds (injected for deterministic tests).
288
+ * @param maxAgeMinutes - freshness window.
289
+ * @param workingTreeClean - whether staged, unstaged, and untracked inputs are absent.
290
+ * @returns ok plus a human reason either way.
291
+ */
292
+ function verifyCredential(state, currentRevision, now, maxAgeMinutes, workingTreeClean = true) {
293
+ const credential = state.credential;
294
+ if (credential === void 0) return {
295
+ ok: false,
296
+ reason: "no green-build credential recorded"
297
+ };
298
+ if (currentRevision === null) return {
299
+ ok: false,
300
+ reason: "current git HEAD unavailable (not inside a git repository?)"
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
+ };
306
+ if (credential.revision !== currentRevision) return {
307
+ ok: false,
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`
309
+ };
310
+ const ageMs = now - credential.recordedAt;
311
+ const maxMs = maxAgeMinutes * 6e4;
312
+ if (ageMs > maxMs) return {
313
+ ok: false,
314
+ reason: `green credential is stale (${Math.round(ageMs / 6e4)} min old, limit ${maxAgeMinutes} min) — rebuild and re-record`
315
+ };
316
+ const leftMs = Math.max(0, maxMs - ageMs);
317
+ return {
318
+ ok: true,
319
+ reason: `green credential valid (${credential.scope} @ ${credential.revision}, ${Math.round(leftMs / 6e4)} min left)`
320
+ };
321
+ }
322
+ //#endregion
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,119 @@
1
+ import { o as processGroupId, s as processIdentity } from "./processes-BjZgJjQr.js";
2
+ import { createHash } from "node:crypto";
3
+ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ //#region lib/types/test-seam.js
6
+ /**
7
+ * Explicit test-only process/event registration seam. Production execution is
8
+ * inert unless a test runner supplies all ANKH_GUARD_TEST_* ownership values.
9
+ */
10
+ const TEST_RUN_DIR_ENV = "ANKH_GUARD_TEST_RUN_DIR";
11
+ const TEST_RUN_TOKEN_ENV = "ANKH_GUARD_TEST_RUN_TOKEN";
12
+ const TEST_PROCESS_ROLE_ENV = "ANKH_GUARD_TEST_PROCESS_ROLE";
13
+ const TEST_PROCESS_PORT_ENV = "ANKH_GUARD_TEST_PROCESS_PORT";
14
+ const TEST_PROCESS_TEMP_ROOT_ENV = "ANKH_GUARD_TEST_PROCESS_TEMP_ROOT";
15
+ const TEST_REGISTER_BIN_ENV = "ANKH_GUARD_TEST_REGISTER_BIN";
16
+ const TEST_SLEEP_SCALE_ENV = "ANKH_GUARD_TEST_SLEEP_SCALE";
17
+ function testCoordinates(env) {
18
+ const runDir = env[TEST_RUN_DIR_ENV];
19
+ const runToken = env[TEST_RUN_TOKEN_ENV];
20
+ if (runDir === void 0 || runDir === "" || runToken === void 0 || !/^[a-f0-9-]{16,}$/.test(runToken)) return null;
21
+ return {
22
+ runDir,
23
+ runToken
24
+ };
25
+ }
26
+ function safeRole(value) {
27
+ const role = value.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 80);
28
+ return role === "" ? "unknown" : role;
29
+ }
30
+ function appendTestLifecycleEventForProcess(subjectPid, roleValue, event, detail, source = "child-self", env = process.env) {
31
+ const coordinates = testCoordinates(env);
32
+ if (coordinates === null) return;
33
+ const role = safeRole(roleValue);
34
+ const identity = processIdentity(subjectPid);
35
+ const pgid = processGroupId(subjectPid);
36
+ const record = {
37
+ version: 1,
38
+ runToken: coordinates.runToken,
39
+ source,
40
+ role,
41
+ event: safeRole(event),
42
+ pid: subjectPid,
43
+ ...pgid === null ? {} : { pgid },
44
+ ...identity === null ? {} : { startToken: identity.startToken },
45
+ wallTimeMs: Date.now(),
46
+ monotonicNs: process.hrtime.bigint().toString(),
47
+ ...detail === void 0 ? {} : { detail }
48
+ };
49
+ try {
50
+ const dir = join(coordinates.runDir, "events");
51
+ mkdirSync(dir, {
52
+ recursive: true,
53
+ mode: 448
54
+ });
55
+ appendFileSync(join(dir, `${subjectPid}-${source}.jsonl`), `${JSON.stringify(record)}\n`, { mode: 384 });
56
+ } catch {}
57
+ }
58
+ /** Record one credential-free event in this process's append-only event file. */
59
+ function appendTestLifecycleEvent(event, detail, source = "child-self", env = process.env) {
60
+ appendTestLifecycleEventForProcess(process.pid, env["ANKH_GUARD_TEST_PROCESS_ROLE"] ?? "unknown", event, detail, source, env);
61
+ }
62
+ /**
63
+ * Atomically publish an immutable PID/PGID/start-identity lease. The same PID
64
+ * may be observed by its parent and then self-register; the first complete
65
+ * record wins because both describe the same kernel start identity.
66
+ */
67
+ function registerTestProcess(pid, role, options = {}) {
68
+ const env = options.env ?? process.env;
69
+ const coordinates = testCoordinates(env);
70
+ if (coordinates === null) return null;
71
+ const identity = options.identity ?? processIdentity(pid);
72
+ const pgid = processGroupId(pid);
73
+ if (identity === null || pgid === null) return null;
74
+ const portText = env[TEST_PROCESS_PORT_ENV];
75
+ const envPort = portText === void 0 ? void 0 : Number(portText);
76
+ const port = options.port ?? (Number.isInteger(envPort) && (envPort ?? 0) > 0 ? envPort : void 0);
77
+ const tempRoot = options.tempRoot ?? env["ANKH_GUARD_TEST_PROCESS_TEMP_ROOT"];
78
+ const record = {
79
+ version: 1,
80
+ runToken: coordinates.runToken,
81
+ role: safeRole(role),
82
+ pid,
83
+ pgid,
84
+ startToken: identity.startToken,
85
+ groupRoot: pgid === pid,
86
+ registeredAt: Date.now(),
87
+ source: options.source ?? (pid === process.pid ? "child-self" : "parent-observer"),
88
+ ...tempRoot === void 0 || tempRoot === "" ? {} : { tempRoot },
89
+ ...port === void 0 ? {} : { port }
90
+ };
91
+ try {
92
+ const dir = join(coordinates.runDir, "processes");
93
+ mkdirSync(dir, {
94
+ recursive: true,
95
+ mode: 448
96
+ });
97
+ const identityHash = createHash("sha256").update(identity.startToken).digest("hex").slice(0, 16);
98
+ writeFileSync(join(dir, `${pid}-${identityHash}.json`), `${JSON.stringify(record)}\n`, {
99
+ flag: "wx",
100
+ mode: 384
101
+ });
102
+ } catch (error) {
103
+ if (error.code !== "EEXIST") return null;
104
+ }
105
+ return record;
106
+ }
107
+ /** Self-register only when an explicit test role accompanies the run lease. */
108
+ function registerCurrentTestProcess(env = process.env) {
109
+ const role = env[TEST_PROCESS_ROLE_ENV];
110
+ if (role === void 0 || role === "") return null;
111
+ const record = registerTestProcess(process.pid, role, {
112
+ env,
113
+ source: "child-self"
114
+ });
115
+ if (record !== null) appendTestLifecycleEvent("process-registered", { pgid: record.pgid }, "child-self", env);
116
+ return record;
117
+ }
118
+ //#endregion
119
+ export { TEST_RUN_DIR_ENV as a, appendTestLifecycleEvent as c, registerTestProcess as d, TEST_REGISTER_BIN_ENV as i, appendTestLifecycleEventForProcess as l, TEST_PROCESS_ROLE_ENV as n, TEST_RUN_TOKEN_ENV as o, TEST_PROCESS_TEMP_ROOT_ENV as r, TEST_SLEEP_SCALE_ENV as s, TEST_PROCESS_PORT_ENV as t, registerCurrentTestProcess 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 {};