@khorsheed/dsh-ankh-guard 0.4.2 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # 变更记录
2
2
 
3
+ ## 0.4.3(2026-10-22)
4
+
5
+ - **启动环境 scrub 扩展到 NODE_PATH / NODE_OPTIONS**:看门狗 `launch_instance`、CLI 的 bare-restart spawn 与 supervisor spawn 三处在清 `WD_*` 之外同步清 `NODE_PATH` / `NODE_OPTIONS`。从 tsx 等工具包裹的 shell 拉起看门狗时,tsx 会把 `NODE_PATH` 钉到自己的 pnpm 仓;不清掉的话它遗传给每一代实例,bundle 解析被劫持到那个检出的 workspace——组合默默地服务那个检出的内容而不是 profile 里装的版本(3080,2026-10-12:presets 被解析到主检出的 workspace 副本,新部署的 preset 行整天不可见,逐字节排查复现 9 vs 10 才定位)
6
+ - **已结束的切换不再引发快速请求循环**:启动切换完成后,新标签页对该 cutoverId 没有匹配的注册记录,可选链把「记录不存在」误判为「尚未确认」,长轮询立即结束、客户端立刻重询;且每次所有权校验在 macOS 上同步起进程探针,阻塞其它 HTTP 响应。现在仅注册记录存在且尚未确认时才唤醒轮询;处理器在启动代际通道与回执通道之间复用自身进程身份缓存(回执、注册、认证、canary 检查仍实时执行,身份不可用时拒绝授权);浏览器收到 `idle` 后至少等 250ms 再发起下一次 poll
7
+ - 适配宿主 0.2.1 线:`@deepseek-ai/dsh-*` peer 区间加宽覆盖 0.2.1,声明 `engines.dsh`;退役 load 期 invariant 伴生探针(guard 状态文件损坏仍在 gate 读取时 fail-loud,契约不变)
8
+ - 能力目录的 skill 卡片 provider 从裸词 `ankh-guard` 归一到全名 `@khorsheed/dsh-ankh-guard`,与其它卡片一致
9
+
3
10
  ## 0.4.2(2026-09-30)
4
11
 
5
12
  - **修复重启浮层卡死**:标签页带着 pending handoff 时,若 cutover 已不经由本标签页收口(操作员 restore、handoff off、监督链楔死),服务端对该 cutoverId 永远回 409,「正在重启」浮层会无限重试不下台。现在每 8 次失败 ACK 花一次 poll——`idle` 即证明没有在途事务,丢弃 pending 并收浮层。sessionStorage 按标签页存活,所以旧实例上已卡住的标签页不受影响此修复——关掉重开即可(3080 实测案例)
package/README.en.md CHANGED
@@ -203,7 +203,7 @@ None.
203
203
 
204
204
  - npm release line (`@deepseek-ai/dsh@0.1.5-rc.1`): supported — 0.1.5-rc.1 full-line boot-verified (42 packages including capture, 2026-09-25) — with two designed degrades. The composition-preflight gate runs through the standalone `preflight-runner` (composing through the published `@deepseek-ai/dsh-app-boot` primitives with a drift tripwire, since 0.1.5-rc.1 still does not export `composeProfile`) wherever a dsh app layout resolves — `--harness-root`, the durable launch spec, `DSH_HARNESS`, or the default checkout. On a pure npm deployment with no harness checkout the gate reports a notice and proceeds instead. The original-tab bridge feature-probes the optional WebServer/connection authentication seams; hosts without token auth naturally take the existing-cookie path. Cold reads (the parked probe and the preset derivation) ride the 0.1.5 handle-based sessionPersistence (`open(id, 'read')` → `read` → `close`; the one-shot `inspect` is gone). Every other capability is intact on the npm line. minHost stays 0.1.5-rc.1 — older hosts stay on the previous release line.
205
205
  - Historical verification: a live npm-host 0.1.1-rc.2 → 0.1.2-alpha.4 isolated cutover passed (transition preflight removed a v3 whole-unit projection cache with an old-schema record from a home copy, live apply quarantined the old file, and target completed Token URL → 303 → cookie 200, the ownership stability window, and canary at zero retries; the old file remained byte-exact in the cutover directory. An untransitioned control over the same home failed on the missing Alpha.4 record fields, demonstrating that acceptance covered the real schema break).
206
- - source line (deepseek-harness master, fork or upstream): ✅ (verifiedHost: 0.1.7-rc.2) — the gate runs through the standalone `preflight-runner` (resolves the published `@deepseek-ai/dsh-app-boot` etc. from the live checkout), so no fork patch is required.
206
+ - source line (deepseek-harness master, fork or upstream): ✅ (verifiedHost: 0.2.1-rc.1) — host-021 wave: full build+test green against the 0.2.1-rc.1 tree (including the 3080 production deployment acceptance); no other surface changes. The gate runs through the standalone `preflight-runner` (resolves the published `@deepseek-ai/dsh-app-boot` etc. from the live checkout), so no fork patch is required. History: the 3080 production instance's 0.2.0-rc.2 line — watchdog-supervised gated restarts passed from 2026-10-09 on (readiness + ownership stability window + canary, deployment proofs recorded on the 0.2.0-rc.2 release head `639ed01539`).
207
207
  - Dual-line evidence: the 0.1.5 boot passes end-to-end through three compat layers — the [preset-registry dual-name probe](../../.agents/notes/implemented/bug-fix/2026-09-25-preset-registry-dual-name-probe.md), [dual-shape typert codecs](../../.agents/notes/implemented/bug-fix/2026-09-25-typert-codec-dual-shape.md), and [typert faces carrying zod@4](../../.agents/notes/implemented/bug-fix/2026-09-25-typert-faces-carry-zod-v4.md).
208
208
 
209
209
  **Version line mapping**: the first release after 0.2.0 supports host `0.1.5-rc.1` and later; hosts on `0.1.2-rc.1` stay on `0.2.0`, hosts on `0.1.0-rc.6` ~ `0.1.1-rc.2` stay on the 0.1.x release line (last release `0.1.1`).
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # last confirmed-consistent state. Both languages carry equal authority; after
3
3
  # editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/ankh-guard/README.en.md
5
- packages/ankh-guard/README.en.md: fb474172d57154e58e44661bbefb6824c78425e0
6
- packages/ankh-guard/README.md: e58a1358dcfae6d426b3ced160120de361709e24
5
+ packages/ankh-guard/README.en.md: c2163a3e673790cc46fe34b044eb85e57a0a9305
6
+ packages/ankh-guard/README.md: db9d7b6f3c75e7b80226cffa9946cef973e7be4c
package/README.md CHANGED
@@ -202,7 +202,7 @@ dsh-ankh-guard restart \
202
202
 
203
203
  - npm 发布线(`@deepseek-ai/dsh@0.1.5-rc.1`):支持——0.1.5-rc.1 全量 boot 实证通过(42 包含 capture,2026-09-25)——带两处设计内降级。composition-preflight 门禁通过独立的 `preflight-runner` 运行(0.1.5-rc.1 仍未导出 `composeProfile`,runner 改经已发布的 `@deepseek-ai/dsh-app-boot` 原语组装,带漂移绊线测试),只要能解析到 dsh app 布局——`--harness-root`、耐久 launch spec、`DSH_HARNESS` 或默认检出路径——就完整运行。没有 harness 检出的纯 npm 部署下门禁退化为提示后放行。原标签页桥会探测可选 WebServer/connection 认证 seam,不使用 token 认证的宿主自然走现有 Cookie 路径;冷读(停靠探测与 preset 推导)走 0.1.5 的 handle 制 sessionPersistence(`open(id, 'read')` → `read` → `close`,一次性 `inspect` 已移除)。其余能力在 npm 线上完整。minHost 保持 0.1.5-rc.1,旧宿主请停留在旧发布线。
204
204
  - 历史验证:npm host 的 0.1.1-rc.2 → 0.1.2-alpha.4 隔离切换已通过(transition preflight 在 home 副本上移开带旧 schema record 的 v3 whole-unit projection cache,live apply 隔离旧文件,target 以零重试完成 Token URL → 303 → Cookie 200、ownership 稳定窗口与 canary;旧文件逐字节保留在 cutover 目录。相同 home 的无 transition 对照因缺少 Alpha.4 record 字段而拒绝,证明验收覆盖了真实 schema 断裂面)。
205
- - 源码线(deepseek-harness master,fork 或上游):✅(verifiedHost: 0.1.7-rc.2)——门禁通过独立的 `preflight-runner` 运行(从在线 checkout 解析已发布的 `@deepseek-ai/dsh-app-boot` 等),不再需要 fork 补丁。
205
+ - 源码线(deepseek-harness master,fork 或上游):✅(verifiedHost: 0.2.1-rc.1)——host-021 波对 0.2.1-rc.1 树全量 build+test 绿(含 3080 生产部署验收),无其它表面变化。门禁通过独立的 `preflight-runner` 运行(从在线 checkout 解析已发布的 `@deepseek-ai/dsh-app-boot` 等),不再需要 fork 补丁。历史:3080 生产实例的 0.2.0-rc.2 线——watchdog 值守的 gated restart 自 2026-10-09 起连续 PASS(readiness + ownership 稳定窗口 + canary,deployment proof 记于 0.2.0-rc.2 release 头 `639ed01539`)。
206
206
  - 双线证据:0.1.5 的 boot 经三层兼容修复端到端通过——[preset-registry 双名探测](../../.agents/notes/implemented/bug-fix/2026-09-25-preset-registry-dual-name-probe.md)、[typert codec 双形状](../../.agents/notes/implemented/bug-fix/2026-09-25-typert-codec-dual-shape.md)、[face 自带 zod@4](../../.agents/notes/implemented/bug-fix/2026-09-25-typert-faces-carry-zod-v4.md)。
207
207
 
208
208
  **版本线对照**:0.2.0 之后的首个发布起支持宿主 `0.1.5-rc.1` 及以后;宿主 `0.1.2-rc.1` 请停留在 `0.2.0`,宿主 `0.1.0-rc.6` ~ `0.1.1-rc.2` 请停留在 0.1.x 发布线(末版 `0.1.1`)。
package/lib/cli.js CHANGED
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { i as resolveStateDir, n as isDirectInvocation, r as resolveRepoDir, t as SRC_ARTIFACT_PATTERN } from "./defaults-CduvOcGq.js";
3
- import { A as writeStableLaunchSpec, C as prepareLaunchCutover, D as selectedLaunchSpec, E as recordCutoverEvent, F as workingTreeChanges, M as currentHead, N as isWorkingTreeClean, O as summarizeLaunchState, P as resetToCheckpoint, T as readLaunchState, _ as writeRestartOutcome, b as activeCutover, c as readInstanceLaunch, f as writeAdoptionRecord, h as writeInstanceLaunchAsSupervisor, j as commitCheckpoint, k as writeCutoverControl, p as writeCompositionRecovery, u as readSkillRegistration, w as readCutoverReceipt, x as commandSha256, y as writeUnexpectedExitRecord } from "./restart-context-DlaDZWKq.js";
2
+ import { i as resolveStateDir, n as isDirectInvocation, r as resolveRepoDir, t as SRC_ARTIFACT_PATTERN } from "./defaults-DX6GVMvK.js";
3
+ import { A as writeStableLaunchSpec, B as pidAlive, C as prepareLaunchCutover, D as selectedLaunchSpec, E as recordCutoverEvent, F as setProvenDeployment, G as resetToCheckpoint, H as commitCheckpoint, I as verifyCredential, K as workingTreeChanges, L as lastGoodBootRevision, M as loadState, N as recordCredential, O as summarizeLaunchState, P as setCheckpoint, R as livePidIn, T as readLaunchState, U as currentHead, V as stateFile, W as isWorkingTreeClean, _ as writeRestartOutcome, b as activeCutover, c as readInstanceLaunch, f as writeAdoptionRecord, h as writeInstanceLaunchAsSupervisor, j as clearCredential, k as writeCutoverControl, p as writeCompositionRecovery, u as readSkillRegistration, w as readCutoverReceipt, x as commandSha256, y as writeUnexpectedExitRecord, z as liveWatchdogPid } from "./restart-context-BYN4zJsu.js";
4
4
  import { _ as processIdentity, c as appendTestLifecycleEvent, d as registerTestProcess, f as discoverLaunchCommand, h as killPidTree, l as appendTestLifecycleEventForProcess, m as findPidOnPort, n as TEST_PROCESS_ROLE_ENV, p as findOwnedListener, r as TEST_PROCESS_TEMP_ROOT_ENV, t as TEST_PROCESS_PORT_ENV, u as registerCurrentTestProcess, v as processIdentityMatches } from "./test-seam-dwvaKjRp.js";
5
- import { a as setProvenDeployment, c as lastGoodBootRevision, d as pidAlive, f as stateFile, i as setCheckpoint, l as livePidIn, n as loadState, r as recordCredential, s as verifyCredential, t as clearCredential, u as liveWatchdogPid } from "./state-CZMypGkB.js";
6
5
  import { execFileSync, spawn } from "node:child_process";
7
6
  import { createHash, randomBytes } from "node:crypto";
8
7
  import { chmodSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
@@ -3071,6 +3070,8 @@ async function runCli(argv, io) {
3071
3070
  const startEnv = { ...process.env };
3072
3071
  delete startEnv.DSH_ANKH_RESTART_DRIVER;
3073
3072
  for (const key of Object.keys(startEnv)) if (key.startsWith("WD_")) delete startEnv[key];
3073
+ delete startEnv.NODE_PATH;
3074
+ delete startEnv.NODE_OPTIONS;
3074
3075
  const child = spawn(start, {
3075
3076
  shell: true,
3076
3077
  detached: true,
@@ -3255,6 +3256,8 @@ async function runCli(argv, io) {
3255
3256
  const supervisorBaseEnv = { ...process.env };
3256
3257
  delete supervisorBaseEnv.DSH_ANKH_VERDICT_FILE;
3257
3258
  for (const key of Object.keys(supervisorBaseEnv)) if (key.startsWith("WD_")) delete supervisorBaseEnv[key];
3259
+ delete supervisorBaseEnv.NODE_PATH;
3260
+ delete supervisorBaseEnv.NODE_OPTIONS;
3258
3261
  const env = {
3259
3262
  ...supervisorBaseEnv,
3260
3263
  WD_PORT: String(spec.port),
package/lib/client.js CHANGED
@@ -272,7 +272,7 @@ window.__ModuleLoader__.load({
272
272
  if (typeof result.bootId === "string") sessionStorage.setItem(BOOT_ID_KEY, result.bootId);
273
273
  if (result.state === "idle") {
274
274
  document.getElementById("ankh-guard-browser-handoff")?.remove();
275
- schedule();
275
+ schedule(ACTIVE_RETRY_MS);
276
276
  return;
277
277
  }
278
278
  if (result.state === "waiting") waitingOverlay();
@@ -3,8 +3,8 @@ import { join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  //#region lib/types/defaults.js
5
5
  /**
6
- * Default state/repo directory resolution, shared by the cordis plugin, the
7
- * CLI, and the invariant companion so all three agree on where a credential
6
+ * Default state/repo directory resolution, shared by the cordis plugin and
7
+ * the CLI so both agree on where a credential
8
8
  * lives. The state file must survive a restart, so it anchors to DSH_HOME (or
9
9
  * the checkout) rather than any process-lifetime directory.
10
10
  */
package/lib/exit-agent.js CHANGED
@@ -1,4 +1,4 @@
1
- import { n as isDirectInvocation } from "./defaults-CduvOcGq.js";
1
+ import { n as isDirectInvocation } from "./defaults-DX6GVMvK.js";
2
2
  import { m as findPidOnPort, u as registerCurrentTestProcess } from "./test-seam-dwvaKjRp.js";
3
3
  import { writeFileSync } from "node:fs";
4
4
  //#region lib/types/exit-agent.js
package/lib/index.js CHANGED
@@ -1,7 +1,6 @@
1
- import { i as resolveStateDir, r as resolveRepoDir, t as SRC_ARTIFACT_PATTERN } from "./defaults-CduvOcGq.js";
2
- import { M as currentHead, N as isWorkingTreeClean, P as resetToCheckpoint, S as cutoverBlocksWake, a as interruptedSnapshotFile, b as activeCutover, c as readInstanceLaunch, d as restartContextText, g as writeInterruptedSnapshot, i as continueInterruptedText, j as commitCheckpoint, l as readInterruptedSnapshot, m as writeInstanceLaunch, n as buildLaunchCommand, o as isParkedOnUserInput, r as continueAndReportText, s as pendingRestartRecord, t as acknowledgeRestartRecord, v as writeSkillRegistration, w as readCutoverReceipt } from "./restart-context-DlaDZWKq.js";
3
- import { _ as processIdentity, g as listeningPortsForPid, v as processIdentityMatches } from "./test-seam-dwvaKjRp.js";
4
- import { f as stateFile, i as setCheckpoint, n as loadState, r as recordCredential, s as verifyCredential, t as clearCredential, u as liveWatchdogPid } from "./state-CZMypGkB.js";
1
+ import { i as resolveStateDir, r as resolveRepoDir, t as SRC_ARTIFACT_PATTERN } from "./defaults-DX6GVMvK.js";
2
+ import { G as resetToCheckpoint, H as commitCheckpoint, I as verifyCredential, M as loadState, N as recordCredential, P as setCheckpoint, S as cutoverBlocksWake, U as currentHead, V as stateFile, W as isWorkingTreeClean, a as interruptedSnapshotFile, b as activeCutover, c as readInstanceLaunch, d as restartContextText, g as writeInterruptedSnapshot, i as continueInterruptedText, j as clearCredential, l as readInterruptedSnapshot, m as writeInstanceLaunch, n as buildLaunchCommand, o as isParkedOnUserInput, r as continueAndReportText, s as pendingRestartRecord, t as acknowledgeRestartRecord, v as writeSkillRegistration, w as readCutoverReceipt, z as liveWatchdogPid } from "./restart-context-BYN4zJsu.js";
3
+ import { _ as processIdentity, g as listeningPortsForPid } from "./test-seam-dwvaKjRp.js";
5
4
  import { spawn } from "node:child_process";
6
5
  import { createHash, randomBytes } from "node:crypto";
7
6
  import { chmodSync, existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
@@ -242,17 +241,21 @@ function authenticationState(connection, req) {
242
241
  function createBrowserHandoffHandler(dependencies) {
243
242
  const pid = dependencies.pid ?? process.pid;
244
243
  const now = dependencies.now ?? Date.now;
245
- const identityMatches = dependencies.identityMatches ?? processIdentityMatches;
246
244
  const identityProvider = dependencies.identityProvider ?? processIdentity;
247
245
  const longPollMs = dependencies.longPollMs ?? DEFAULT_LONG_POLL_MS;
248
246
  const longPollIntervalMs = dependencies.longPollIntervalMs ?? DEFAULT_LONG_POLL_INTERVAL_MS;
249
- let bootIdCache;
247
+ let selfIdentity;
248
+ const identity = () => {
249
+ if (selfIdentity === void 0) selfIdentity = identityProvider(pid);
250
+ return selfIdentity;
251
+ };
252
+ const identityMatches = dependencies.identityMatches ?? ((expected) => {
253
+ const own = identity();
254
+ return own !== null && expected.pid === pid && own.pid === pid && own.startToken === expected.startToken;
255
+ });
250
256
  const thisBootId = () => {
251
- if (bootIdCache === void 0) {
252
- const identity = identityProvider(pid);
253
- bootIdCache = identity === null ? null : `${String(identity.pid)}:${identity.startToken}`;
254
- }
255
- return bootIdCache ?? void 0;
257
+ const own = identity();
258
+ return own === null ? void 0 : `${String(own.pid)}:${own.startToken}`;
256
259
  };
257
260
  return async (req, res) => {
258
261
  const connection = dependencies.connectionProvider?.() ?? dependencies.connection;
@@ -305,7 +308,8 @@ function createBrowserHandoffHandler(dependencies) {
305
308
  }
306
309
  const ready = currentReadyOwnership(dependencies.stateDir, pid, identityMatches);
307
310
  const registration = readBrowserHandoffRequest(dependencies.stateDir);
308
- return ready !== null && matchingRegistration(registration, ready.cutoverId, requestAuthority.authority, digest)?.acknowledgedAt === void 0;
311
+ const registered = ready === null ? void 0 : matchingRegistration(registration, ready.cutoverId, requestAuthority.authority, digest);
312
+ return registered !== void 0 && registered.acknowledgedAt === void 0;
309
313
  };
310
314
  await waitForPollState(pollHasState, longPollMs, longPollIntervalMs);
311
315
  const active = activeCutover(dependencies.stateDir);
@@ -787,7 +791,7 @@ function registerRestartSkill(ctx, stateDir) {
787
791
  description,
788
792
  content,
789
793
  source: "runtime",
790
- provider: "ankh-guard"
794
+ provider: "@khorsheed/dsh-ankh-guard"
791
795
  }));
792
796
  writeSkillRegistration(stateDir, {
793
797
  registered: true,
@@ -1,4 +1,4 @@
1
- import { n as isDirectInvocation } from "./defaults-CduvOcGq.js";
1
+ import { n as isDirectInvocation } from "./defaults-DX6GVMvK.js";
2
2
  import { createRequire } from "node:module";
3
3
  import { existsSync, mkdtempSync, statSync, writeFileSync } from "node:fs";
4
4
  import { homedir, tmpdir } from "node:os";
@@ -1,4 +1,4 @@
1
- import { f as stateFile } from "./state-CZMypGkB.js";
1
+ import { c as appendTestLifecycleEvent } from "./test-seam-dwvaKjRp.js";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { createHash } from "node:crypto";
4
4
  import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
@@ -248,6 +248,325 @@ function resetToCheckpoint(repoDir, sha) {
248
248
  }
249
249
  }
250
250
  //#endregion
251
+ //#region lib/types/state-files.js
252
+ /**
253
+ * The guard's state-directory protocol: every file that lives in the state
254
+ * dir, named exactly once. Five writers in three languages (this package's
255
+ * TS, the watchdog's bash, the schedule-exit exit agent's inline JS, and the
256
+ * two supervisor installers) read and write these — a literal drifting in
257
+ * any one of them splits the protocol silently (three review rounds of path
258
+ * bugs came from exactly that). The bash sides are pinned by
259
+ * tests/state-files.spec.ts, which asserts every state-dir literal in the
260
+ * watchdog and both installers appears here, and that no script re-derives
261
+ * the directory from the home.
262
+ */
263
+ /** Every state-directory file name, keyed by role. */
264
+ const STATE_FILES = {
265
+ /** The guard's credential/checkpoint/audit state (state.ts). */
266
+ guard: "self-restart-guard.json",
267
+ /** Watchdog-stamped last deployment-proven revision. */
268
+ lastGoodBoot: "last-good-boot.json",
269
+ /** schedule-exit → watchdog: an intentional restart, run the canary. */
270
+ restartRequested: "restart-requested.json",
271
+ /** The exit agent's restart outcome record (the report's source). */
272
+ lastRestart: "last-restart.json",
273
+ /** The SIGTERM snapshot of interrupted sessions. */
274
+ interruptedSessions: "interrupted-sessions.json",
275
+ /** The supervising watchdog's pidfile. */
276
+ watchdogPid: "watchdog.pid",
277
+ /** Cross-session mutual exclusion for the restart verb. */
278
+ restartLock: "restart.lock",
279
+ /** The detached restart driver's log. */
280
+ restartLog: "restart.log",
281
+ /** Marker: exit the watchdog without respawn. */
282
+ watchdogStop: "watchdog-stop",
283
+ /** Marker: the watchdog gave up; a crash page holds the port. */
284
+ watchdogGaveUp: "watchdog-gave-up",
285
+ /** The current boot attempt's captured output. */
286
+ bootAttemptLog: "boot-attempt.log",
287
+ /** The watchdog's own log. */
288
+ watchdogLog: "watchdog.log",
289
+ /** The watchdog's stderr, captured separately by the supervisor installers. */
290
+ watchdogStderrLog: "watchdog.stderr.log",
291
+ /** The exit agent's log. */
292
+ scheduleExitLog: "schedule-exit.log",
293
+ /** How the current instance was launched (recorded by the plugin at apply). */
294
+ instanceLaunch: "instance-launch.json",
295
+ /** The atomically selected full launch configuration (stable or in cutover). */
296
+ launchSpec: "launch-spec.json",
297
+ /** Redacted durable receipt for the latest launch-configuration cutover. */
298
+ launchCutover: "launch-cutover.json",
299
+ /** Legacy combined operator-control marker, retained for rolling upgrades. */
300
+ cutoverControl: "launch-cutover-control.json",
301
+ /** Operator → watchdog: abort according to the pre-approved recovery policy. */
302
+ cutoverAbort: "launch-cutover-abort.json",
303
+ /** Monotonic stronger operator action: explicitly restore the previous spec. */
304
+ cutoverRestorePrevious: "launch-cutover-restore-previous.json",
305
+ /** Original browser-tab registry: per-tab capability hashes only; retained through terminal recovery. */
306
+ browserHandoffRequest: "browser-handoff-request.json",
307
+ /** Browser → watchdog acknowledgement for one proven final process. */
308
+ browserHandoffAck: "browser-handoff-ack.json",
309
+ /** Whether the restart-protocol skill registered at apply (and why not). */
310
+ skillRegistration: "skill-registration.json",
311
+ /** Directory: the healthy-boot snapshot of the profile composition inputs. */
312
+ lastGoodComposition: "last-good-composition",
313
+ /** Directory prefix: a failing composition backed up before rollback restores over it. */
314
+ compositionBackup: "composition-backup-"
315
+ };
316
+ /** Absolute path of a state file inside a state directory. */
317
+ function stateFile(stateDir, role) {
318
+ return join(stateDir, STATE_FILES[role]);
319
+ }
320
+ /**
321
+ * The last deployment-proven revision, stamped by the watchdog on every
322
+ * healthy boot, or undefined. Lives here rather than in the credential core:
323
+ * the stamp is written by the watchdog and proves the deployment composed and
324
+ * came up — a green credential only ever proves build+test passed.
325
+ * @param stateDir - state directory.
326
+ * @returns the stamped revision, or undefined.
327
+ */
328
+ function lastGoodBootRevision(stateDir) {
329
+ try {
330
+ const stamp = JSON.parse(readFileSync(stateFile(stateDir, "lastGoodBoot"), "utf8"));
331
+ return typeof stamp.revision === "string" && stamp.revision !== "" ? stamp.revision : void 0;
332
+ } catch {
333
+ return;
334
+ }
335
+ }
336
+ /**
337
+ * Whether the pid named by this raw pid/lock-file content is alive. Empty
338
+ * content reads as NO holder: Number('') is 0 and kill(0, 0) probes our own
339
+ * process group (always succeeds), which once read as "alive" and refused
340
+ * every restart forever — the bug that had to be fixed in two copies of this
341
+ * logic before it was consolidated here.
342
+ */
343
+ function pidAlive(raw) {
344
+ const pid = Number(raw);
345
+ if (raw === "" || !Number.isInteger(pid) || pid <= 0) return false;
346
+ try {
347
+ process.kill(pid, 0);
348
+ return true;
349
+ } catch {
350
+ return false;
351
+ }
352
+ }
353
+ /** The live pid named by a pid/lock file (as the raw string), or null when absent/stale. */
354
+ function livePidIn(file) {
355
+ try {
356
+ const raw = readFileSync(file, "utf8").trim();
357
+ return pidAlive(raw) ? raw : null;
358
+ } catch {
359
+ return null;
360
+ }
361
+ }
362
+ /** The live supervising watchdog's pid, or null when none is (pidfile + kill 0). */
363
+ function liveWatchdogPid(stateDir) {
364
+ const raw = livePidIn(stateFile(stateDir, "watchdogPid"));
365
+ const pid = raw === null ? null : Number(raw);
366
+ appendTestLifecycleEvent("watchdog-liveness-probe", {
367
+ pid: pid ?? 0,
368
+ live: pid !== null
369
+ }, "parent-observer");
370
+ return pid;
371
+ }
372
+ //#endregion
373
+ //#region lib/types/state.js
374
+ /**
375
+ * Pure credential/checkpoint state core for the self-restart guard.
376
+ *
377
+ * The guard records one "green build" credential — bound to the git HEAD it
378
+ * was recorded on and to a freshness window — and answers `verify()` against
379
+ * the CURRENT head and wall clock. A later fully gated boot may promote that
380
+ * credential into a deployment proof whose exact runtime fingerprint can be
381
+ * reused by the same-launch restart path. Framework-free: the cordis plugin
382
+ * and the CLI share this module.
383
+ */
384
+ /** Absolute path of the state file inside a state directory. */
385
+ function stateFilePath(stateDir) {
386
+ return stateFile(stateDir, "guard");
387
+ }
388
+ /** A fresh, credential-less state. */
389
+ function emptyState() {
390
+ return { audit: [] };
391
+ }
392
+ function isCredential(value) {
393
+ if (typeof value !== "object" || value === null) return false;
394
+ const c = value;
395
+ return typeof c.scope === "string" && typeof c.revision === "string" && typeof c.recordedAt === "number" && typeof c.command === "string";
396
+ }
397
+ function isCheckpoint(value) {
398
+ if (typeof value !== "object" || value === null) return false;
399
+ const c = value;
400
+ return typeof c.revision === "string" && typeof c.recordedAt === "number" && typeof c.message === "string";
401
+ }
402
+ function isSha256(value) {
403
+ return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
404
+ }
405
+ function isProvenDeployment(value) {
406
+ if (typeof value !== "object" || value === null) return false;
407
+ const proof = value;
408
+ const credential = proof.credential;
409
+ const fingerprint = proof.fingerprint;
410
+ 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);
411
+ }
412
+ function isAuditEntry(value) {
413
+ if (typeof value !== "object" || value === null) return false;
414
+ const e = value;
415
+ return (e.action === "record" || e.action === "clear" || e.action === "checkpoint" || e.action === "prove-deployment") && typeof e.ts === "number" && typeof e.detail === "string";
416
+ }
417
+ /**
418
+ * Read the state file; an absent file is an empty state, a malformed one is a
419
+ * loud misconfiguration error (never silently ignored).
420
+ * @param stateDir - directory holding the state file.
421
+ * @returns the parsed state.
422
+ */
423
+ function loadState(stateDir) {
424
+ try {
425
+ const raw = readFileSync(stateFilePath(stateDir), "utf8");
426
+ const parsed = JSON.parse(raw);
427
+ if (parsed === null || typeof parsed !== "object") return emptyState();
428
+ const state = { audit: Array.isArray(parsed.audit) ? parsed.audit.filter(isAuditEntry) : [] };
429
+ if (isCredential(parsed.credential)) state.credential = parsed.credential;
430
+ if (isProvenDeployment(parsed.provenDeployment)) state.provenDeployment = parsed.provenDeployment;
431
+ if (isCheckpoint(parsed.checkpoint)) state.checkpoint = parsed.checkpoint;
432
+ return state;
433
+ } catch (error) {
434
+ if (error?.code === "ENOENT") return emptyState();
435
+ throw new Error(`ankh-guard: unreadable state file ${stateFilePath(stateDir)}: ${String(error)}`);
436
+ }
437
+ }
438
+ /** Persist a state (creates the directory as needed). */
439
+ function saveState(stateDir, state) {
440
+ mkdirSync(stateDir, { recursive: true });
441
+ const file = stateFilePath(stateDir);
442
+ const tmp = `${file}.${process.pid}.tmp`;
443
+ writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
444
+ renameSync(tmp, file);
445
+ }
446
+ function withAudit(state, entry) {
447
+ return {
448
+ ...state,
449
+ audit: [...state.audit, entry].slice(-50)
450
+ };
451
+ }
452
+ /**
453
+ * Record a green-build credential bound to {@link RecordInput.revision}.
454
+ * @param stateDir - state directory.
455
+ * @param input - scope, the git HEAD, and the command that went green.
456
+ * @param now - epoch milliseconds (injected for deterministic tests).
457
+ * @returns the persisted state.
458
+ */
459
+ function recordCredential(stateDir, input, now) {
460
+ const credential = {
461
+ ...input,
462
+ recordedAt: now
463
+ };
464
+ const { provenDeployment: _oldProof, ...withoutOldProof } = withAudit(loadState(stateDir), {
465
+ action: "record",
466
+ ts: now,
467
+ detail: `${input.scope} @ ${input.revision}`
468
+ });
469
+ const updated = {
470
+ ...withoutOldProof,
471
+ credential
472
+ };
473
+ saveState(stateDir, updated);
474
+ return updated;
475
+ }
476
+ /**
477
+ * Drop the credential (checkpoint and audit survive).
478
+ * @param stateDir - state directory.
479
+ * @param now - epoch milliseconds (injected for deterministic tests).
480
+ * @returns the persisted state.
481
+ */
482
+ function clearCredential(stateDir, now) {
483
+ const audited = withAudit(loadState(stateDir), {
484
+ action: "clear",
485
+ ts: now,
486
+ detail: "credential cleared"
487
+ });
488
+ const updated = { audit: audited.audit };
489
+ if (audited.checkpoint !== void 0) updated.checkpoint = audited.checkpoint;
490
+ saveState(stateDir, updated);
491
+ return updated;
492
+ }
493
+ /** Persist a deployment proof after the watchdog has completed its canary. */
494
+ function setProvenDeployment(stateDir, proof, now) {
495
+ const updated = {
496
+ ...withAudit(loadState(stateDir), {
497
+ action: "prove-deployment",
498
+ ts: now,
499
+ detail: `${proof.credential.revision} ${proof.fingerprintSha256.slice(0, 16)}`
500
+ }),
501
+ provenDeployment: proof
502
+ };
503
+ saveState(stateDir, updated);
504
+ return updated;
505
+ }
506
+ /**
507
+ * Persist a pre-batch checkpoint commit reference.
508
+ * @param stateDir - state directory.
509
+ * @param input - the checkpoint commit SHA and its message.
510
+ * @param now - epoch milliseconds (injected for deterministic tests).
511
+ * @returns the persisted state.
512
+ */
513
+ function setCheckpoint(stateDir, input, now) {
514
+ const checkpoint = {
515
+ ...input,
516
+ recordedAt: now
517
+ };
518
+ const updated = {
519
+ ...withAudit(loadState(stateDir), {
520
+ action: "checkpoint",
521
+ ts: now,
522
+ detail: `${input.revision} ${input.message}`
523
+ }),
524
+ checkpoint
525
+ };
526
+ saveState(stateDir, updated);
527
+ return updated;
528
+ }
529
+ /**
530
+ * Answer the gate: a credential is valid only when present, recorded on the
531
+ * CURRENT revision, and younger than {@link maxAgeMinutes}.
532
+ * @param state - the loaded state.
533
+ * @param currentRevision - the git HEAD of the checkout, or null when unavailable.
534
+ * @param now - epoch milliseconds (injected for deterministic tests).
535
+ * @param maxAgeMinutes - freshness window.
536
+ * @param workingTreeClean - whether staged, unstaged, and untracked inputs are absent.
537
+ * @returns ok plus a human reason either way.
538
+ */
539
+ function verifyCredential(state, currentRevision, now, maxAgeMinutes, workingTreeClean = true) {
540
+ const credential = state.credential;
541
+ if (credential === void 0) return {
542
+ ok: false,
543
+ reason: "no green-build credential recorded"
544
+ };
545
+ if (currentRevision === null) return {
546
+ ok: false,
547
+ reason: "current git HEAD unavailable (not inside a git repository?)"
548
+ };
549
+ if (!workingTreeClean) return {
550
+ ok: false,
551
+ reason: "working tree is dirty (staged, unstaged, or untracked changes exist) — commit or remove them, then rebuild and re-record"
552
+ };
553
+ if (credential.revision !== currentRevision) return {
554
+ ok: false,
555
+ 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`
556
+ };
557
+ const ageMs = now - credential.recordedAt;
558
+ const maxMs = maxAgeMinutes * 6e4;
559
+ if (ageMs > maxMs) return {
560
+ ok: false,
561
+ reason: `green credential is stale (${Math.round(ageMs / 6e4)} min old, limit ${maxAgeMinutes} min) — rebuild and re-record`
562
+ };
563
+ const leftMs = Math.max(0, maxMs - ageMs);
564
+ return {
565
+ ok: true,
566
+ reason: `green credential valid (${credential.scope} @ ${credential.revision}, ${Math.round(leftMs / 6e4)} min left)`
567
+ };
568
+ }
569
+ //#endregion
251
570
  //#region lib/types/launch-spec.js
252
571
  /**
253
572
  * Durable launch-configuration cutovers.
@@ -1247,4 +1566,4 @@ function isParkedOnUserInput(events) {
1247
1566
  return lastToolName === USER_INPUT_TOOL || pendingApproval;
1248
1567
  }
1249
1568
  //#endregion
1250
- export { writeStableLaunchSpec as A, prepareLaunchCutover as C, selectedLaunchSpec as D, recordCutoverEvent as E, workingTreeChanges as F, currentHead as M, isWorkingTreeClean as N, summarizeLaunchState as O, resetToCheckpoint as P, cutoverBlocksWake as S, readLaunchState as T, writeRestartOutcome as _, interruptedSnapshotFile as a, activeCutover as b, readInstanceLaunch as c, restartContextText as d, writeAdoptionRecord as f, writeInterruptedSnapshot as g, writeInstanceLaunchAsSupervisor as h, continueInterruptedText as i, commitCheckpoint as j, writeCutoverControl as k, readInterruptedSnapshot as l, writeInstanceLaunch as m, buildLaunchCommand as n, isParkedOnUserInput as o, writeCompositionRecovery as p, continueAndReportText as r, pendingRestartRecord as s, acknowledgeRestartRecord as t, readSkillRegistration as u, writeSkillRegistration as v, readCutoverReceipt as w, commandSha256 as x, writeUnexpectedExitRecord as y };
1569
+ export { writeStableLaunchSpec as A, pidAlive as B, prepareLaunchCutover as C, selectedLaunchSpec as D, recordCutoverEvent as E, setProvenDeployment as F, resetToCheckpoint as G, commitCheckpoint as H, verifyCredential as I, workingTreeChanges as K, lastGoodBootRevision as L, loadState as M, recordCredential as N, summarizeLaunchState as O, setCheckpoint as P, livePidIn as R, cutoverBlocksWake as S, readLaunchState as T, currentHead as U, stateFile as V, isWorkingTreeClean as W, writeRestartOutcome as _, interruptedSnapshotFile as a, activeCutover as b, readInstanceLaunch as c, restartContextText as d, writeAdoptionRecord as f, writeInterruptedSnapshot as g, writeInstanceLaunchAsSupervisor as h, continueInterruptedText as i, clearCredential as j, writeCutoverControl as k, readInterruptedSnapshot as l, writeInstanceLaunch as m, buildLaunchCommand as n, isParkedOnUserInput as o, writeCompositionRecovery as p, continueAndReportText as r, pendingRestartRecord as s, acknowledgeRestartRecord as t, readSkillRegistration as u, writeSkillRegistration as v, readCutoverReceipt as w, commandSha256 as x, writeUnexpectedExitRecord as y, liveWatchdogPid as z };
File without changes
@@ -1,7 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import type { Context } from '@deepseek-ai/cordis';
3
3
  import { type LaunchRole } from './launch-spec.ts';
4
- import { processIdentityMatches, type ProcessIdentity } from './processes.ts';
4
+ import { type processIdentityMatches, type ProcessIdentity } from './processes.ts';
5
5
  export declare const BROWSER_HANDOFF_ROUTE = "/_ankh-guard/browser-handoff";
6
6
  export type BrowserHandoffChannel = 'original-tab' | 'fallback-tab';
7
7
  export type BrowserHandoffAuthentication = 'existing-cookie' | 'launch-url';
@@ -29,7 +29,7 @@ import { createHash, randomBytes } from 'node:crypto';
29
29
  import { chmodSync, linkSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
30
30
  import { dirname } from 'node:path';
31
31
  import { activeCutover, readCutoverReceipt } from "./launch-spec.js";
32
- import { processIdentity, processIdentityMatches } from "./processes.js";
32
+ import { processIdentity } from "./processes.js";
33
33
  import { stateFile } from "./state-files.js";
34
34
  export const BROWSER_HANDOFF_ROUTE = '/_ankh-guard/browser-handoff';
35
35
  const CAPABILITY_PATTERN = /^[A-Za-z0-9_-]{43}$/;
@@ -267,19 +267,25 @@ function authenticationState(connection, req) {
267
267
  export function createBrowserHandoffHandler(dependencies) {
268
268
  const pid = dependencies.pid ?? process.pid;
269
269
  const now = dependencies.now ?? Date.now;
270
- const identityMatches = dependencies.identityMatches ?? processIdentityMatches;
271
270
  const identityProvider = dependencies.identityProvider ?? processIdentity;
272
271
  const longPollMs = dependencies.longPollMs ?? DEFAULT_LONG_POLL_MS;
273
272
  const longPollIntervalMs = dependencies.longPollIntervalMs ?? DEFAULT_LONG_POLL_INTERVAL_MS;
274
- // Cached: a process's boot id never changes over its lifetime, and the
275
- // platform lookup (ps/python3) must not run per held poll.
276
- let bootIdCache;
273
+ // The handler only proves its own listener identity. That identity cannot
274
+ // change while this process is serving: a recycled PID has a new handler.
275
+ // Share one lookup with the boot channel; never spawn ps/python per poll.
276
+ let selfIdentity;
277
+ const identity = () => {
278
+ if (selfIdentity === undefined)
279
+ selfIdentity = identityProvider(pid);
280
+ return selfIdentity;
281
+ };
282
+ const identityMatches = dependencies.identityMatches ?? ((expected) => {
283
+ const own = identity();
284
+ return own !== null && expected.pid === pid && own.pid === pid && own.startToken === expected.startToken;
285
+ });
277
286
  const thisBootId = () => {
278
- if (bootIdCache === undefined) {
279
- const identity = identityProvider(pid);
280
- bootIdCache = identity === null ? null : `${String(identity.pid)}:${identity.startToken}`;
281
- }
282
- return bootIdCache ?? undefined;
287
+ const own = identity();
288
+ return own === null ? undefined : `${String(own.pid)}:${own.startToken}`;
283
289
  };
284
290
  return async (req, res) => {
285
291
  const connection = dependencies.connectionProvider?.() ?? dependencies.connection;
@@ -344,7 +350,8 @@ export function createBrowserHandoffHandler(dependencies) {
344
350
  }
345
351
  const ready = currentReadyOwnership(dependencies.stateDir, pid, identityMatches);
346
352
  const registration = readBrowserHandoffRequest(dependencies.stateDir);
347
- return ready !== null && matchingRegistration(registration, ready.cutoverId, requestAuthority.authority, digest)?.acknowledgedAt === undefined;
353
+ const registered = ready === null ? undefined : matchingRegistration(registration, ready.cutoverId, requestAuthority.authority, digest);
354
+ return registered !== undefined && registered.acknowledgedAt === undefined;
348
355
  };
349
356
  await waitForPollState(pollHasState, longPollMs, longPollIntervalMs);
350
357
  const active = activeCutover(dependencies.stateDir);
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),
@@ -286,9 +286,9 @@ export function apply(_ctx) {
286
286
  sessionStorage.setItem(BOOT_ID_KEY, result.bootId);
287
287
  if (result.state === 'idle') {
288
288
  document.getElementById('ankh-guard-browser-handoff')?.remove();
289
- // The server holds idle polls, providing event-like wakeup without a
290
- // permanent fixed-frequency request loop.
291
- 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);
292
292
  return;
293
293
  }
294
294
  if (result.state === 'waiting')
@@ -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.2",
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 */