@khorsheed/dsh-ankh-guard 0.4.1 → 0.4.3

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/lib/types/cli.js CHANGED
@@ -2203,6 +2203,12 @@ export async function runCli(argv, io) {
2203
2203
  if (key.startsWith('WD_'))
2204
2204
  delete startEnv[key];
2205
2205
  }
2206
+ // NODE_PATH/NODE_OPTIONS go too: from a tool-wrapped caller (tsx pins
2207
+ // NODE_PATH to its own pnpm store) they hijack the instance's module
2208
+ // resolution onto that checkout's workspace — the watchdog's
2209
+ // launch_instance scrubs them for the same reason (3080, 2026-10-12).
2210
+ delete startEnv.NODE_PATH;
2211
+ delete startEnv.NODE_OPTIONS;
2206
2212
  const child = spawn(start, {
2207
2213
  shell: true, detached: true, stdio: 'ignore',
2208
2214
  env: testChildEnv('restart-instance-root', startEnv, { port, tempRoot: stateDir }),
@@ -2453,6 +2459,12 @@ export async function runCli(argv, io) {
2453
2459
  if (key.startsWith('WD_'))
2454
2460
  delete supervisorBaseEnv[key];
2455
2461
  }
2462
+ // NODE_PATH/NODE_OPTIONS must not reach the watchdog either: it forwards
2463
+ // its environment to every instance it launches, and a tool-wrapped
2464
+ // caller (tsx pins NODE_PATH to its own pnpm store) would otherwise
2465
+ // hijack instance module resolution onto that checkout (3080, 2026-10-12).
2466
+ delete supervisorBaseEnv.NODE_PATH;
2467
+ delete supervisorBaseEnv.NODE_OPTIONS;
2456
2468
  const env = {
2457
2469
  ...supervisorBaseEnv,
2458
2470
  WD_PORT: String(spec.port),
@@ -155,6 +155,8 @@ export function apply(_ctx) {
155
155
  let timer;
156
156
  let pending = consumeFallbackFragment() ?? readPending();
157
157
  let cap;
158
+ /** Consecutive failed ACKs for the current pending handoff. */
159
+ let ackFailures = 0;
158
160
  let errorRetryMs = ERROR_RETRY_MIN_MS;
159
161
  let firstFailureAt;
160
162
  let request;
@@ -231,6 +233,29 @@ export function apply(_ctx) {
231
233
  return;
232
234
  }
233
235
  }
236
+ else {
237
+ // A failed ACK can be permanent: the cutover settled without this tab
238
+ // (operator restore, handoff off, a wedged supervisor) and the server
239
+ // keeps answering 409 'waiting'/'stale' for a dead cutoverId — the
240
+ // veil would otherwise stay up forever (observed 2026-09-30). Every
241
+ // few failures, spend one poll: 'idle' proves nothing is in flight,
242
+ // so the pending handoff is dead — drop it and let the ordinary
243
+ // poll loop remove the veil on the next tick.
244
+ ackFailures += 1;
245
+ if (ackFailures >= 8) {
246
+ ackFailures = 0;
247
+ const probe = await post({
248
+ version: 1, operation: 'poll', capability: cap ??= capability(),
249
+ }, current.signal);
250
+ if (stale())
251
+ return;
252
+ if (probe.ok && (await probe.json()).state === 'idle') {
253
+ sessionStorage.removeItem(PENDING_KEY);
254
+ sessionStorage.removeItem(CAPABILITY_KEY);
255
+ pending = null;
256
+ }
257
+ }
258
+ }
234
259
  errorRetryMs = ERROR_RETRY_MIN_MS;
235
260
  firstFailureAt = undefined;
236
261
  schedule(ACTIVE_RETRY_MS);
@@ -261,9 +286,9 @@ export function apply(_ctx) {
261
286
  sessionStorage.setItem(BOOT_ID_KEY, result.bootId);
262
287
  if (result.state === 'idle') {
263
288
  document.getElementById('ankh-guard-browser-handoff')?.remove();
264
- // The server holds idle polls, providing event-like wakeup without a
265
- // permanent fixed-frequency request loop.
266
- schedule();
289
+ // Healthy servers hold idle polls. Keep a floor for older servers or
290
+ // terminal states that answer immediately, so they cannot create a spin.
291
+ schedule(ACTIVE_RETRY_MS);
267
292
  return;
268
293
  }
269
294
  if (result.state === 'waiting')
@@ -287,6 +312,7 @@ export function apply(_ctx) {
287
312
  };
288
313
  pending = nextPending;
289
314
  writePending(nextPending);
315
+ ackFailures = 0;
290
316
  waitingOverlay();
291
317
  location.reload();
292
318
  return;
@@ -307,6 +333,7 @@ export function apply(_ctx) {
307
333
  };
308
334
  pending = nextPending;
309
335
  writePending(nextPending);
336
+ ackFailures = 0;
310
337
  waitingOverlay();
311
338
  // The bearer remains a local variable for the shortest possible time.
312
339
  location.replace(launch.href);
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Default state/repo directory resolution, shared by the cordis plugin, the
3
- * CLI, and the invariant companion so all three agree on where a credential
2
+ * Default state/repo directory resolution, shared by the cordis plugin and
3
+ * the CLI so both agree on where a credential
4
4
  * lives. The state file must survive a restart, so it anchors to DSH_HOME (or
5
5
  * the checkout) rather than any process-lifetime directory.
6
6
  */
@@ -128,7 +128,7 @@ function registerRestartSkill(ctx, stateDir) {
128
128
  description,
129
129
  content,
130
130
  source: 'runtime',
131
- provider: 'ankh-guard',
131
+ provider: '@khorsheed/dsh-ankh-guard',
132
132
  }));
133
133
  writeSkillRegistration(stateDir, { registered: true, at: Date.now() });
134
134
  }
@@ -5,8 +5,8 @@
5
5
  * was recorded on and to a freshness window — and answers `verify()` against
6
6
  * the CURRENT head and wall clock. A later fully gated boot may promote that
7
7
  * credential into a deployment proof whose exact runtime fingerprint can be
8
- * reused by the same-launch restart path. Framework-free: the cordis plugin,
9
- * the CLI, and the invariant companion all share this module.
8
+ * reused by the same-launch restart path. Framework-free: the cordis plugin
9
+ * and the CLI share this module.
10
10
  */
11
11
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
12
12
  import { stateFile } from "./state-files.js";
@@ -94,8 +94,8 @@ export function loadState(stateDir) {
94
94
  export function saveState(stateDir, state) {
95
95
  mkdirSync(stateDir, { recursive: true });
96
96
  // Atomic via tmp + rename: a crash mid-write must never leave a truncated
97
- // state file — a malformed one throws in loadState and fails the host's
98
- // boot invariant, i.e. the guard's own state would take the host down.
97
+ // state file — a malformed one throws in loadState the next time the gate
98
+ // reads it, i.e. the guard's own state would fail every later verify.
99
99
  const file = stateFilePath(stateDir);
100
100
  const tmp = `${file}.${process.pid}.tmp`;
101
101
  writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@khorsheed/dsh-ankh-guard",
3
3
  "description": "Hard gate for self-modification restarts: a green-build credential bound to the git HEAD, checked before any restart of the running instance",
4
- "version": "0.4.1",
4
+ "version": "0.4.3",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -13,10 +13,6 @@
13
13
  "types": "./lib/types/index.d.ts",
14
14
  "default": "./lib/index.js"
15
15
  },
16
- "./invariant": {
17
- "types": "./lib/types/invariant.d.ts",
18
- "default": "./lib/invariant.js"
19
- },
20
16
  "./cli": {
21
17
  "types": "./lib/types/cli.d.ts",
22
18
  "default": "./lib/cli.js"
@@ -42,16 +38,18 @@
42
38
  "CHANGELOG.md"
43
39
  ],
44
40
  "license": "MIT",
41
+ "engines": {
42
+ "dsh": ">=0.1.5-rc.1 <0.3.0-0"
43
+ },
45
44
  "peerDependencies": {
46
45
  "@deepseek-ai/cordis": "^4.0.1",
47
- "@deepseek-ai/dsh-agent": "^0.1.0-rc.6 || ^0.2.0-rc.1",
48
- "@deepseek-ai/dsh-agent-preset-registry": "^0.1.0-rc.6 || ^0.2.0-rc.1",
49
- "@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.6 || ^0.2.0-rc.1",
50
- "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.6 || ^0.2.0-rc.1",
51
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6 || ^0.2.0-rc.1",
52
- "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6 || ^0.2.0-rc.1",
53
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ^0.2.0-rc.1",
54
- "@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6 || ^0.2.0-rc.1",
46
+ "@deepseek-ai/dsh-agent": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
47
+ "@deepseek-ai/dsh-agent-preset-registry": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
48
+ "@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
49
+ "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
50
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
51
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
52
+ "@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
55
53
  "@deepseek-ai/schemastery": "^3.18.1"
56
54
  },
57
55
  "peerDependenciesMeta": {
@@ -90,8 +88,8 @@
90
88
  },
91
89
  "compat": {
92
90
  "minHost": "0.1.5-rc.1",
93
- "notes": "0.1.5-rc.1 full-line boot-verified 2026-09-25 (42 packages including capture) through the three compat layers: preset-registry dual-name probe, dual-shape typert codecs, and typert faces carrying zod@4; 0.1.7-rc.2 is the 3080 production-verified line; composition-preflight runs via the standalone preflight-runner and degrades to a notice when no live harness checkout resolves; original-tab browser handoff feature-probes optional WebServer/connection auth seams; reversible state quarantine was exercised in a live 0.1.1-rc.2 to 0.1.2-alpha.4 cutover; since 0.4.0 the preflight verdict also fails on any agent preset the registry marks broken — preset rows mount on standing scopes beside the profile tree, so a clean-booting profile whose presets cannot serve sessions (picker 加载失败, resume never-started) no longer passes the gate",
94
- "verifiedHost": "0.1.7-rc.2"
91
+ "notes": "0.1.5-rc.1 full-line boot-verified 2026-09-25 (42 packages including capture) through the three compat layers: preset-registry dual-name probe, dual-shape typert codecs, and typert faces carrying zod@4; 0.2.0-rc.2 is the 3080 production-verified line (watchdog-supervised gated restarts PASS — readiness, ownership window, canary, deployment proof on the 0.2.0-rc.2 release head 639ed01539 — since 2026-10-09); composition-preflight runs via the standalone preflight-runner and degrades to a notice when no live harness checkout resolves; original-tab browser handoff feature-probes optional WebServer/connection auth seams; reversible state quarantine was exercised in a live 0.1.1-rc.2 to 0.1.2-alpha.4 cutover; since 0.4.0 the preflight verdict also fails on any agent preset the registry marks broken — preset rows mount on standing scopes beside the profile tree, so a clean-booting profile whose presets cannot serve sessions (picker 加载失败, resume never-started) no longer passes the gate",
92
+ "verifiedHost": "0.2.1-rc.1"
95
93
  }
96
94
  }
97
95
  }
@@ -182,6 +182,14 @@ launch_instance() {
182
182
  if [ -n "$launch_harness_root" ]; then export DSH_HARNESS="$launch_harness_root"; fi
183
183
  cd "$launch_home/home" 2>/dev/null || cd /tmp || exit 1
184
184
  for v in $(env | sed -n 's/^\(WD_[^=]*\)=.*/\1/p'); do unset "$v"; done
185
+ # NODE_PATH/NODE_OPTIONS get the same scrub: launched from a tool-wrapped
186
+ # shell (tsx sets NODE_PATH to its own pnpm store), the instance's module
187
+ # resolution would be hijacked onto that checkout's workspace — bundle
188
+ # patches then resolve to the REPO's packages instead of the profile's
189
+ # installed ones, and the composition silently serves whatever the repo
190
+ # checkout happens to hold (3080, 2026-10-12: presets resolved to the main
191
+ # checkout's workspace copy, hiding the newly deployed preset rows).
192
+ unset NODE_PATH NODE_OPTIONS
185
193
  sh -c "$start_cmd"
186
194
  )
187
195
  }
File without changes
File without changes
package/lib/invariant.js DELETED
@@ -1,40 +0,0 @@
1
- import { i as resolveStateDir } from "./defaults-CduvOcGq.js";
2
- import { n as loadState, o as stateFilePath } from "./state-CZMypGkB.js";
3
- import { existsSync } from "node:fs";
4
- //#region lib/types/invariant.js
5
- /**
6
- * Package-owned invariant companion for `@khorsheed/dsh-ankh-guard`.
7
- * @module @khorsheed/dsh-ankh-guard/invariant
8
- */
9
- const PACKAGE_NAME = "@khorsheed/dsh-ankh-guard";
10
- /** Cordis companion plugin name. */
11
- const name = "ankh-guard-invariant";
12
- /** Service required before the companion can reserve package ownership. */
13
- const inject = ["invariants"];
14
- /**
15
- * Owned-data check: the package's state file, when present at the default
16
- * location, must parse as a well-formed guard state. A malformed file would
17
- * make the gate unreadable — the credential's revision/recordedAt shape is
18
- * this package's own contract, so corruption fails loud at load instead of at
19
- * the moment a restart needs the gate.
20
- * @param _ctx - host context (unused; the check reads only the state file).
21
- * @param fail - invariant failure reporter.
22
- */
23
- const install = (_ctx, fail) => {
24
- const stateDir = resolveStateDir(void 0);
25
- const file = stateFilePath(stateDir);
26
- if (!existsSync(file)) return;
27
- try {
28
- loadState(stateDir);
29
- } catch (error) {
30
- fail(`state file ${file} is malformed: ${String(error)}`);
31
- }
32
- };
33
- /**
34
- * Register this package's invariant companion.
35
- * @param ctx - Cordis context carrying the invariant service.
36
- * @returns The installed registration's disposer after setup succeeds.
37
- */
38
- const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
39
- //#endregion
40
- export { apply, inject, install, name };
@@ -1,323 +0,0 @@
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 };
@@ -1,27 +0,0 @@
1
- /**
2
- * Package-owned invariant companion for `@khorsheed/dsh-ankh-guard`.
3
- * @module @khorsheed/dsh-ankh-guard/invariant
4
- */
5
- import type { Context } from '@deepseek-ai/cordis';
6
- import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants';
7
- /** Cordis companion plugin name. */
8
- export declare const name = "ankh-guard-invariant";
9
- /** Service required before the companion can reserve package ownership. */
10
- export declare const inject: string[];
11
- /**
12
- * Owned-data check: the package's state file, when present at the default
13
- * location, must parse as a well-formed guard state. A malformed file would
14
- * make the gate unreadable — the credential's revision/recordedAt shape is
15
- * this package's own contract, so corruption fails loud at load instead of at
16
- * the moment a restart needs the gate.
17
- * @param _ctx - host context (unused; the check reads only the state file).
18
- * @param fail - invariant failure reporter.
19
- */
20
- export declare const install: InvariantInstaller;
21
- /**
22
- * Register this package's invariant companion.
23
- * @param ctx - Cordis context carrying the invariant service.
24
- * @returns The installed registration's disposer after setup succeeds.
25
- */
26
- export declare const apply: (ctx: Context) => Promise<() => void>;
27
- //# sourceMappingURL=invariant.d.ts.map
@@ -1,41 +0,0 @@
1
- /**
2
- * Package-owned invariant companion for `@khorsheed/dsh-ankh-guard`.
3
- * @module @khorsheed/dsh-ankh-guard/invariant
4
- */
5
- /* jscpd:ignore-start */
6
- import { existsSync } from 'node:fs';
7
- import { resolveStateDir } from "./defaults.js";
8
- import { loadState, stateFilePath } from "./state.js";
9
- const PACKAGE_NAME = '@khorsheed/dsh-ankh-guard';
10
- /** Cordis companion plugin name. */
11
- export const name = 'ankh-guard-invariant';
12
- /** Service required before the companion can reserve package ownership. */
13
- export const inject = ['invariants'];
14
- /**
15
- * Owned-data check: the package's state file, when present at the default
16
- * location, must parse as a well-formed guard state. A malformed file would
17
- * make the gate unreadable — the credential's revision/recordedAt shape is
18
- * this package's own contract, so corruption fails loud at load instead of at
19
- * the moment a restart needs the gate.
20
- * @param _ctx - host context (unused; the check reads only the state file).
21
- * @param fail - invariant failure reporter.
22
- */
23
- export const install = (_ctx, fail) => {
24
- const stateDir = resolveStateDir(undefined);
25
- const file = stateFilePath(stateDir);
26
- if (!existsSync(file))
27
- return;
28
- try {
29
- loadState(stateDir);
30
- }
31
- catch (error) {
32
- fail(`state file ${file} is malformed: ${String(error)}`);
33
- }
34
- };
35
- /**
36
- * Register this package's invariant companion.
37
- * @param ctx - Cordis context carrying the invariant service.
38
- * @returns The installed registration's disposer after setup succeeds.
39
- */
40
- export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
41
- /* jscpd:ignore-end */