@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.
- package/CHANGELOG.md +24 -0
- package/README.en.md +75 -29
- package/README.i18n.yaml +2 -2
- package/README.md +74 -29
- package/lib/cli.js +2349 -182
- package/lib/client.js +257 -0
- package/lib/exit-agent.js +5 -2
- package/lib/index.js +721 -38
- package/lib/invariant.js +1 -1
- package/lib/preflight-runner.js +125 -47
- package/lib/processes-BjZgJjQr.js +344 -0
- package/lib/restart-context-D6nISh28.js +1245 -0
- package/lib/restart-context-DUyExi9O.js +1245 -0
- package/lib/{state-Dhx9VG44.js → state-4f7yny39.js} +60 -13
- package/lib/state-CZMypGkB.js +323 -0
- package/lib/test-seam-DnvLWTeO.js +119 -0
- package/lib/test-seam-cli.js +24 -0
- package/lib/test-seam-dwvaKjRp.js +459 -0
- package/lib/test-seam.js +2 -0
- package/lib/types/browser-handoff.d.ts +55 -0
- package/lib/types/browser-handoff.js +489 -0
- package/lib/types/cli.d.ts +34 -4
- package/lib/types/cli.js +1421 -166
- package/lib/types/client/index.d.ts +15 -0
- package/lib/types/client/index.js +264 -0
- package/lib/types/deployment-proof.d.ts +24 -0
- package/lib/types/deployment-proof.js +314 -0
- package/lib/types/exit-agent.js +2 -0
- package/lib/types/git.d.ts +12 -3
- package/lib/types/git.js +69 -7
- package/lib/types/index.d.ts +66 -3
- package/lib/types/index.js +157 -39
- package/lib/types/launch-spec.d.ts +263 -0
- package/lib/types/launch-spec.js +823 -0
- package/lib/types/preflight-runner.d.ts +23 -12
- package/lib/types/preflight-runner.js +152 -57
- package/lib/types/processes.d.ts +38 -6
- package/lib/types/processes.js +236 -10
- package/lib/types/restart-context.d.ts +50 -0
- package/lib/types/restart-context.js +106 -0
- package/lib/types/restart-request.d.ts +32 -0
- package/lib/types/restart-request.js +128 -0
- package/lib/types/state-files.d.ts +30 -0
- package/lib/types/state-files.js +55 -0
- package/lib/types/state.d.ts +29 -2
- package/lib/types/state.js +52 -7
- package/lib/types/temp-artifact.d.ts +15 -0
- package/lib/types/temp-artifact.js +17 -0
- package/lib/types/test-seam-cli.d.ts +3 -0
- package/lib/types/test-seam-cli.js +27 -0
- package/lib/types/test-seam.d.ts +55 -0
- package/lib/types/test-seam.js +112 -0
- package/lib/types/transition.d.ts +118 -0
- package/lib/types/transition.js +717 -0
- package/package.json +30 -11
- package/scripts/dsh-watchdog.sh +1388 -80
- package/scripts/install-launchd.sh +43 -5
- package/scripts/install-systemd.sh +43 -5
- package/scripts/on-install.js +1 -1
- package/skills/dsh-self-restart-guard/SKILL.md +38 -12
- package/lib/processes-hCAmwma-.js +0 -127
- 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
|
|
78
|
-
* credential
|
|
79
|
-
*
|
|
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
|
-
...
|
|
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 {
|
|
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 {};
|