@khorsheed/dsh-ankh-guard 0.2.0 → 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.
@@ -1,286 +0,0 @@
1
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- //#region lib/types/state-files.js
4
- /**
5
- * The guard's state-directory protocol: every file that lives in the state
6
- * dir, named exactly once. Five writers in three languages (this package's
7
- * TS, the watchdog's bash, the schedule-exit exit agent's inline JS, and the
8
- * two supervisor installers) read and write these — a literal drifting in
9
- * any one of them splits the protocol silently (three review rounds of path
10
- * bugs came from exactly that). The bash sides are pinned by
11
- * tests/state-files.spec.ts, which asserts every state-dir literal in the
12
- * watchdog and both installers appears here, and that no script re-derives
13
- * the directory from the home.
14
- */
15
- /** Every state-directory file name, keyed by role. */
16
- const STATE_FILES = {
17
- /** The guard's credential/checkpoint/audit state (state.ts). */
18
- guard: "self-restart-guard.json",
19
- /** Watchdog-stamped last deployment-proven revision. */
20
- lastGoodBoot: "last-good-boot.json",
21
- /** schedule-exit → watchdog: an intentional restart, run the canary. */
22
- restartRequested: "restart-requested.json",
23
- /** The exit agent's restart outcome record (the report's source). */
24
- lastRestart: "last-restart.json",
25
- /** The SIGTERM snapshot of interrupted sessions. */
26
- interruptedSessions: "interrupted-sessions.json",
27
- /** The supervising watchdog's pidfile. */
28
- watchdogPid: "watchdog.pid",
29
- /** Cross-session mutual exclusion for the restart verb. */
30
- restartLock: "restart.lock",
31
- /** The detached restart driver's log. */
32
- restartLog: "restart.log",
33
- /** Marker: exit the watchdog without respawn. */
34
- watchdogStop: "watchdog-stop",
35
- /** Marker: the watchdog gave up; a crash page holds the port. */
36
- watchdogGaveUp: "watchdog-gave-up",
37
- /** The current boot attempt's captured output. */
38
- bootAttemptLog: "boot-attempt.log",
39
- /** The watchdog's own log. */
40
- watchdogLog: "watchdog.log",
41
- /** The watchdog's stderr, captured separately by the supervisor installers. */
42
- watchdogStderrLog: "watchdog.stderr.log",
43
- /** The exit agent's log. */
44
- scheduleExitLog: "schedule-exit.log",
45
- /** How the current instance was launched (recorded by the plugin at apply). */
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",
61
- /** Whether the restart-protocol skill registered at apply (and why not). */
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-"
67
- };
68
- /** Absolute path of a state file inside a state directory. */
69
- function stateFile(stateDir, role) {
70
- return join(stateDir, STATE_FILES[role]);
71
- }
72
- /**
73
- * The last deployment-proven revision, stamped by the watchdog on every
74
- * healthy boot, or undefined. Lives here rather than in the credential core:
75
- * the stamp is written by the watchdog and proves the deployment composed and
76
- * came up — a green credential only ever proves build+test passed.
77
- * @param stateDir - state directory.
78
- * @returns the stamped revision, or undefined.
79
- */
80
- function lastGoodBootRevision(stateDir) {
81
- try {
82
- const stamp = JSON.parse(readFileSync(stateFile(stateDir, "lastGoodBoot"), "utf8"));
83
- return typeof stamp.revision === "string" && stamp.revision !== "" ? stamp.revision : void 0;
84
- } catch {
85
- return;
86
- }
87
- }
88
- //#endregion
89
- //#region lib/types/state.js
90
- /**
91
- * Pure credential/checkpoint state core for the self-restart guard.
92
- *
93
- * The guard records one "green build" credential — bound to the git HEAD it
94
- * was recorded on and to a freshness window — and answers `verify()` against
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,
98
- * the CLI, and the invariant companion all share this module.
99
- */
100
- /** Absolute path of the state file inside a state directory. */
101
- function stateFilePath(stateDir) {
102
- return stateFile(stateDir, "guard");
103
- }
104
- /** A fresh, credential-less state. */
105
- function emptyState() {
106
- return { audit: [] };
107
- }
108
- function isCredential(value) {
109
- if (typeof value !== "object" || value === null) return false;
110
- const c = value;
111
- return typeof c.scope === "string" && typeof c.revision === "string" && typeof c.recordedAt === "number" && typeof c.command === "string";
112
- }
113
- function isCheckpoint(value) {
114
- if (typeof value !== "object" || value === null) return false;
115
- const c = value;
116
- return typeof c.revision === "string" && typeof c.recordedAt === "number" && typeof c.message === "string";
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
- }
128
- function isAuditEntry(value) {
129
- if (typeof value !== "object" || value === null) return false;
130
- const e = value;
131
- return (e.action === "record" || e.action === "clear" || e.action === "checkpoint" || e.action === "prove-deployment") && typeof e.ts === "number" && typeof e.detail === "string";
132
- }
133
- /**
134
- * Read the state file; an absent file is an empty state, a malformed one is a
135
- * loud misconfiguration error (never silently ignored).
136
- * @param stateDir - directory holding the state file.
137
- * @returns the parsed state.
138
- */
139
- function loadState(stateDir) {
140
- try {
141
- const raw = readFileSync(stateFilePath(stateDir), "utf8");
142
- const parsed = JSON.parse(raw);
143
- if (parsed === null || typeof parsed !== "object") return emptyState();
144
- const state = { audit: Array.isArray(parsed.audit) ? parsed.audit.filter(isAuditEntry) : [] };
145
- if (isCredential(parsed.credential)) state.credential = parsed.credential;
146
- if (isProvenDeployment(parsed.provenDeployment)) state.provenDeployment = parsed.provenDeployment;
147
- if (isCheckpoint(parsed.checkpoint)) state.checkpoint = parsed.checkpoint;
148
- return state;
149
- } catch (error) {
150
- if (error?.code === "ENOENT") return emptyState();
151
- throw new Error(`ankh-guard: unreadable state file ${stateFilePath(stateDir)}: ${String(error)}`);
152
- }
153
- }
154
- /** Persist a state (creates the directory as needed). */
155
- function saveState(stateDir, state) {
156
- mkdirSync(stateDir, { recursive: true });
157
- const file = stateFilePath(stateDir);
158
- const tmp = `${file}.${process.pid}.tmp`;
159
- writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
160
- renameSync(tmp, file);
161
- }
162
- function withAudit(state, entry) {
163
- return {
164
- ...state,
165
- audit: [...state.audit, entry].slice(-50)
166
- };
167
- }
168
- /**
169
- * Record a green-build credential bound to {@link RecordInput.revision}.
170
- * @param stateDir - state directory.
171
- * @param input - scope, the git HEAD, and the command that went green.
172
- * @param now - epoch milliseconds (injected for deterministic tests).
173
- * @returns the persisted state.
174
- */
175
- function recordCredential(stateDir, input, now) {
176
- const credential = {
177
- ...input,
178
- recordedAt: now
179
- };
180
- const { provenDeployment: _oldProof, ...withoutOldProof } = withAudit(loadState(stateDir), {
181
- action: "record",
182
- ts: now,
183
- detail: `${input.scope} @ ${input.revision}`
184
- });
185
- const updated = {
186
- ...withoutOldProof,
187
- credential
188
- };
189
- saveState(stateDir, updated);
190
- return updated;
191
- }
192
- /**
193
- * Drop the credential (checkpoint and audit survive).
194
- * @param stateDir - state directory.
195
- * @param now - epoch milliseconds (injected for deterministic tests).
196
- * @returns the persisted state.
197
- */
198
- function clearCredential(stateDir, now) {
199
- const audited = withAudit(loadState(stateDir), {
200
- action: "clear",
201
- ts: now,
202
- detail: "credential cleared"
203
- });
204
- const updated = { audit: audited.audit };
205
- if (audited.checkpoint !== void 0) updated.checkpoint = audited.checkpoint;
206
- saveState(stateDir, updated);
207
- return updated;
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
- }
222
- /**
223
- * Persist a pre-batch checkpoint commit reference.
224
- * @param stateDir - state directory.
225
- * @param input - the checkpoint commit SHA and its message.
226
- * @param now - epoch milliseconds (injected for deterministic tests).
227
- * @returns the persisted state.
228
- */
229
- function setCheckpoint(stateDir, input, now) {
230
- const checkpoint = {
231
- ...input,
232
- recordedAt: now
233
- };
234
- const updated = {
235
- ...withAudit(loadState(stateDir), {
236
- action: "checkpoint",
237
- ts: now,
238
- detail: `${input.revision} ${input.message}`
239
- }),
240
- checkpoint
241
- };
242
- saveState(stateDir, updated);
243
- return updated;
244
- }
245
- /**
246
- * Answer the gate: a credential is valid only when present, recorded on the
247
- * CURRENT revision, and younger than {@link maxAgeMinutes}.
248
- * @param state - the loaded state.
249
- * @param currentRevision - the git HEAD of the checkout, or null when unavailable.
250
- * @param now - epoch milliseconds (injected for deterministic tests).
251
- * @param maxAgeMinutes - freshness window.
252
- * @param workingTreeClean - whether staged, unstaged, and untracked inputs are absent.
253
- * @returns ok plus a human reason either way.
254
- */
255
- function verifyCredential(state, currentRevision, now, maxAgeMinutes, workingTreeClean = true) {
256
- const credential = state.credential;
257
- if (credential === void 0) return {
258
- ok: false,
259
- reason: "no green-build credential recorded"
260
- };
261
- if (currentRevision === null) return {
262
- ok: false,
263
- reason: "current git HEAD unavailable (not inside a git repository?)"
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
- };
269
- if (credential.revision !== currentRevision) return {
270
- ok: false,
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`
272
- };
273
- const ageMs = now - credential.recordedAt;
274
- const maxMs = maxAgeMinutes * 6e4;
275
- if (ageMs > maxMs) return {
276
- ok: false,
277
- reason: `green credential is stale (${Math.round(ageMs / 6e4)} min old, limit ${maxAgeMinutes} min) — rebuild and re-record`
278
- };
279
- const leftMs = Math.max(0, maxMs - ageMs);
280
- return {
281
- ok: true,
282
- reason: `green credential valid (${credential.scope} @ ${credential.revision}, ${Math.round(leftMs / 6e4)} min left)`
283
- };
284
- }
285
- //#endregion
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 };
@@ -1,119 +0,0 @@
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 };