@khorsheed/dsh-ankh-guard 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.en.md +75 -29
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +74 -29
  5. package/lib/cli.js +2349 -182
  6. package/lib/client.js +344 -0
  7. package/lib/exit-agent.js +5 -2
  8. package/lib/index.js +746 -40
  9. package/lib/invariant.js +1 -1
  10. package/lib/preflight-runner.js +125 -47
  11. package/lib/restart-context-DUyExi9O.js +1245 -0
  12. package/lib/restart-context-DlaDZWKq.js +1250 -0
  13. package/lib/{state-Dhx9VG44.js → state-CZMypGkB.js} +97 -13
  14. package/lib/test-seam-cli.js +24 -0
  15. package/lib/test-seam-dwvaKjRp.js +459 -0
  16. package/lib/test-seam.js +2 -0
  17. package/lib/types/browser-handoff.d.ts +55 -0
  18. package/lib/types/browser-handoff.js +489 -0
  19. package/lib/types/cli.d.ts +34 -4
  20. package/lib/types/cli.js +1421 -166
  21. package/lib/types/client/index.d.ts +15 -0
  22. package/lib/types/client/index.js +348 -0
  23. package/lib/types/deployment-proof.d.ts +24 -0
  24. package/lib/types/deployment-proof.js +314 -0
  25. package/lib/types/exit-agent.js +2 -0
  26. package/lib/types/git.d.ts +12 -3
  27. package/lib/types/git.js +69 -7
  28. package/lib/types/index.d.ts +66 -3
  29. package/lib/types/index.js +192 -41
  30. package/lib/types/launch-spec.d.ts +263 -0
  31. package/lib/types/launch-spec.js +823 -0
  32. package/lib/types/preflight-runner.d.ts +23 -12
  33. package/lib/types/preflight-runner.js +152 -57
  34. package/lib/types/processes.d.ts +38 -6
  35. package/lib/types/processes.js +236 -10
  36. package/lib/types/restart-context.d.ts +50 -0
  37. package/lib/types/restart-context.js +117 -6
  38. package/lib/types/restart-request.d.ts +32 -0
  39. package/lib/types/restart-request.js +128 -0
  40. package/lib/types/state-files.d.ts +30 -0
  41. package/lib/types/state-files.js +55 -0
  42. package/lib/types/state.d.ts +29 -2
  43. package/lib/types/state.js +52 -7
  44. package/lib/types/temp-artifact.d.ts +15 -0
  45. package/lib/types/temp-artifact.js +17 -0
  46. package/lib/types/test-seam-cli.d.ts +3 -0
  47. package/lib/types/test-seam-cli.js +27 -0
  48. package/lib/types/test-seam.d.ts +55 -0
  49. package/lib/types/test-seam.js +112 -0
  50. package/lib/types/transition.d.ts +118 -0
  51. package/lib/types/transition.js +717 -0
  52. package/package.json +30 -11
  53. package/scripts/dsh-watchdog.sh +1388 -80
  54. package/scripts/install-launchd.sh +43 -5
  55. package/scripts/install-systemd.sh +43 -5
  56. package/scripts/on-install.js +1 -1
  57. package/skills/dsh-self-restart-guard/SKILL.md +38 -12
  58. package/lib/processes-hCAmwma-.js +0 -127
  59. package/lib/restart-context-DmnQXNf-.js +0 -421
@@ -0,0 +1,128 @@
1
+ /**
2
+ * In-process guarded-restart trigger for UI-grade callers (the mode
3
+ * switcher's host half). Dispatches on supervision — no live watchdog: the
4
+ * `restart` verb's self-contained stop→start→canary; supervised:
5
+ * `reconfigure`'s transactional cutover, the only safe way to change the
6
+ * launch command under a live watchdog (its respawn would otherwise fight
7
+ * the new command). The trigger shells out to this package's own CLI, so
8
+ * the gate chain (credential → preflight → marker/lock) keeps exactly one
9
+ * behavior source and the detached driver keeps its proven lifetime rules;
10
+ * the structured verdict returns through DSH_ANKH_VERDICT_FILE, never
11
+ * scraped from the human stderr text.
12
+ *
13
+ * Never import the CLI module from the plugin graph: a runtime edge pulls
14
+ * cli.ts into a shared tsdown chunk, evicting its code from lib/cli.js so
15
+ * the direct-invocation guard never fires and the built CLI silently exits
16
+ * 0 on every call (observed 2026-09-09; type-only imports are erased and
17
+ * stay safe).
18
+ */
19
+ import { spawn } from 'node:child_process';
20
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { dirname, join, resolve, sep } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { readInstanceLaunch } from "./restart-context.js";
25
+ import { liveWatchdogPid } from "./state-files.js";
26
+ /** Bounded human detail carried alongside the structured verdict, for display. */
27
+ const DETAIL_CAP = 4096;
28
+ /**
29
+ * argv (after process.execPath) that runs this package's CLI with the given
30
+ * args — the same source/built split as the CLI's own cliInvocation, resolved
31
+ * from this module's directory (src/ and lib/ keep their entries side by
32
+ * side). The two copies must not be unified through an import: see the module
33
+ * doc for what a runtime edge into the CLI module does to the built bundle.
34
+ */
35
+ function cliEntryArgs(args) {
36
+ // Check the FILE path, not the directory: '…/src' has no trailing
37
+ // separator and never matches '/src/' — cliInvocation gets this right
38
+ // because it tests the module file itself.
39
+ const modulePath = fileURLToPath(import.meta.url);
40
+ const here = dirname(modulePath);
41
+ if (modulePath.includes(`${sep}src${sep}`)) {
42
+ const tsx = join(resolve(here, '../../../node_modules'), 'tsx', 'dist', 'esm', 'index.mjs');
43
+ if (existsSync(tsx))
44
+ return ['--import', tsx, join(here, 'cli.ts'), ...args];
45
+ return [join(here, 'cli.ts'), ...args];
46
+ }
47
+ return [join(here, 'cli.js'), ...args];
48
+ }
49
+ /** Read the caller-side verdict file, or null when the CLI never wrote one. */
50
+ function readVerdict(file) {
51
+ try {
52
+ const value = JSON.parse(readFileSync(file, 'utf8'));
53
+ return typeof value.stage === 'string' && typeof value.reason === 'string'
54
+ ? { stage: value.stage, reason: value.reason }
55
+ : null;
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ /**
62
+ * Validate and dispatch one restart request through the guard's own gate
63
+ * chain. A refusal never stops the running instance.
64
+ * @param request - the successor command, target profile, and owning session.
65
+ * @param context - the guard's resolved state dir and credential repo.
66
+ * @returns the structured verdict with bounded human detail for display.
67
+ */
68
+ export async function requestRestart(request, context) {
69
+ if (request.start.trim() === '') {
70
+ return { accepted: false, stage: 'usage', reason: 'start (the successor launch command) is required', detail: '' };
71
+ }
72
+ if (request.profile.trim() === '') {
73
+ return { accepted: false, stage: 'usage', reason: 'profile (the dsh profile to preflight) is required', detail: '' };
74
+ }
75
+ if (request.initiator.trim() === '') {
76
+ return { accepted: false, stage: 'usage', reason: 'initiator (the session id the restart report returns to) is required — never invent one', detail: '' };
77
+ }
78
+ const port = readInstanceLaunch(context.stateDir)?.port;
79
+ if (port === undefined) {
80
+ return { accepted: false, stage: 'usage', reason: "no instance launch record in the state dir — the guard does not know this instance's port", detail: '' };
81
+ }
82
+ const via = liveWatchdogPid(context.stateDir) === null ? 'restart' : 'reconfigure';
83
+ const argv = via === 'restart'
84
+ ? [
85
+ 'restart', '--port', String(port), '--start', request.start, '--profile', request.profile,
86
+ '--initiator', request.initiator, '--state-dir', context.stateDir, '--repo', context.repoDir,
87
+ ]
88
+ : [
89
+ 'reconfigure', '--start', request.start, '--profile', request.profile,
90
+ '--on-failure', 'restore-previous', '--initiator', request.initiator, '--state-dir', context.stateDir,
91
+ ];
92
+ const verdictDir = mkdtempSync(join(tmpdir(), 'ankh-restart-request-'));
93
+ const verdictFile = join(verdictDir, 'verdict.json');
94
+ // This instance's own supervision variables must not leak into the CLI's
95
+ // children: a WD_STATE_DIR would retarget every watchdog they spawn (the
96
+ // same scrub the restart verb applies to the instance it starts).
97
+ const env = { ...process.env, DSH_ANKH_VERDICT_FILE: verdictFile };
98
+ delete env.DSH_ANKH_RESTART_DRIVER;
99
+ for (const key of Object.keys(env)) {
100
+ if (key.startsWith('WD_'))
101
+ delete env[key];
102
+ }
103
+ try {
104
+ const { code, output } = await new Promise((resolvePromise, rejectPromise) => {
105
+ let captured = '';
106
+ const child = spawn(process.execPath, cliEntryArgs(argv), { stdio: ['ignore', 'pipe', 'pipe'], env });
107
+ const append = (chunk) => { if (captured.length < DETAIL_CAP)
108
+ captured += chunk.toString('utf8'); };
109
+ child.stdout.on('data', append);
110
+ child.stderr.on('data', append);
111
+ child.once('error', rejectPromise);
112
+ child.once('exit', exitCode => { resolvePromise({ code: exitCode, output: captured }); });
113
+ });
114
+ const detail = output.trim();
115
+ if (code === 0)
116
+ return { accepted: true, via, detail };
117
+ const verdict = readVerdict(verdictFile);
118
+ return {
119
+ accepted: false,
120
+ stage: verdict?.stage ?? 'unknown',
121
+ reason: verdict?.reason ?? detail.split('\n', 1)[0] ?? `guard CLI exited ${code ?? 'signal'}`,
122
+ detail,
123
+ };
124
+ }
125
+ finally {
126
+ rmSync(verdictDir, { recursive: true, force: true });
127
+ }
128
+ }
@@ -30,8 +30,26 @@ export declare const STATE_FILES: {
30
30
  readonly scheduleExitLog: "schedule-exit.log";
31
31
  /** How the current instance was launched (recorded by the plugin at apply). */
32
32
  readonly instanceLaunch: "instance-launch.json";
33
+ /** The atomically selected full launch configuration (stable or in cutover). */
34
+ readonly launchSpec: "launch-spec.json";
35
+ /** Redacted durable receipt for the latest launch-configuration cutover. */
36
+ readonly launchCutover: "launch-cutover.json";
37
+ /** Legacy combined operator-control marker, retained for rolling upgrades. */
38
+ readonly cutoverControl: "launch-cutover-control.json";
39
+ /** Operator → watchdog: abort according to the pre-approved recovery policy. */
40
+ readonly cutoverAbort: "launch-cutover-abort.json";
41
+ /** Monotonic stronger operator action: explicitly restore the previous spec. */
42
+ readonly cutoverRestorePrevious: "launch-cutover-restore-previous.json";
43
+ /** Original browser-tab registry: per-tab capability hashes only; retained through terminal recovery. */
44
+ readonly browserHandoffRequest: "browser-handoff-request.json";
45
+ /** Browser → watchdog acknowledgement for one proven final process. */
46
+ readonly browserHandoffAck: "browser-handoff-ack.json";
33
47
  /** Whether the restart-protocol skill registered at apply (and why not). */
34
48
  readonly skillRegistration: "skill-registration.json";
49
+ /** Directory: the healthy-boot snapshot of the profile composition inputs. */
50
+ readonly lastGoodComposition: "last-good-composition";
51
+ /** Directory prefix: a failing composition backed up before rollback restores over it. */
52
+ readonly compositionBackup: "composition-backup-";
35
53
  };
36
54
  /** A STATE_FILES key. */
37
55
  export type StateFileRole = keyof typeof STATE_FILES;
@@ -46,4 +64,16 @@ export declare function stateFile(stateDir: string, role: StateFileRole): string
46
64
  * @returns the stamped revision, or undefined.
47
65
  */
48
66
  export declare function lastGoodBootRevision(stateDir: string): string | undefined;
67
+ /**
68
+ * Whether the pid named by this raw pid/lock-file content is alive. Empty
69
+ * content reads as NO holder: Number('') is 0 and kill(0, 0) probes our own
70
+ * process group (always succeeds), which once read as "alive" and refused
71
+ * every restart forever — the bug that had to be fixed in two copies of this
72
+ * logic before it was consolidated here.
73
+ */
74
+ export declare function pidAlive(raw: string): boolean;
75
+ /** The live pid named by a pid/lock file (as the raw string), or null when absent/stale. */
76
+ export declare function livePidIn(file: string): string | null;
77
+ /** The live supervising watchdog's pid, or null when none is (pidfile + kill 0). */
78
+ export declare function liveWatchdogPid(stateDir: string): number | null;
49
79
  //# sourceMappingURL=state-files.d.ts.map
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { readFileSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
+ import { appendTestLifecycleEvent } from "./test-seam.js";
14
15
  /** Every state-directory file name, keyed by role. */
15
16
  export const STATE_FILES = {
16
17
  /** The guard's credential/checkpoint/audit state (state.ts). */
@@ -43,8 +44,26 @@ export const STATE_FILES = {
43
44
  scheduleExitLog: 'schedule-exit.log',
44
45
  /** How the current instance was launched (recorded by the plugin at apply). */
45
46
  instanceLaunch: 'instance-launch.json',
47
+ /** The atomically selected full launch configuration (stable or in cutover). */
48
+ launchSpec: 'launch-spec.json',
49
+ /** Redacted durable receipt for the latest launch-configuration cutover. */
50
+ launchCutover: 'launch-cutover.json',
51
+ /** Legacy combined operator-control marker, retained for rolling upgrades. */
52
+ cutoverControl: 'launch-cutover-control.json',
53
+ /** Operator → watchdog: abort according to the pre-approved recovery policy. */
54
+ cutoverAbort: 'launch-cutover-abort.json',
55
+ /** Monotonic stronger operator action: explicitly restore the previous spec. */
56
+ cutoverRestorePrevious: 'launch-cutover-restore-previous.json',
57
+ /** Original browser-tab registry: per-tab capability hashes only; retained through terminal recovery. */
58
+ browserHandoffRequest: 'browser-handoff-request.json',
59
+ /** Browser → watchdog acknowledgement for one proven final process. */
60
+ browserHandoffAck: 'browser-handoff-ack.json',
46
61
  /** Whether the restart-protocol skill registered at apply (and why not). */
47
62
  skillRegistration: 'skill-registration.json',
63
+ /** Directory: the healthy-boot snapshot of the profile composition inputs. */
64
+ lastGoodComposition: 'last-good-composition',
65
+ /** Directory prefix: a failing composition backed up before rollback restores over it. */
66
+ compositionBackup: 'composition-backup-',
48
67
  };
49
68
  /** Absolute path of a state file inside a state directory. */
50
69
  export function stateFile(stateDir, role) {
@@ -67,3 +86,39 @@ export function lastGoodBootRevision(stateDir) {
67
86
  return undefined;
68
87
  }
69
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
+ export function pidAlive(raw) {
97
+ const pid = Number(raw);
98
+ if (raw === '' || !Number.isInteger(pid) || pid <= 0)
99
+ return false;
100
+ try {
101
+ process.kill(pid, 0);
102
+ return true;
103
+ }
104
+ catch {
105
+ return false;
106
+ }
107
+ }
108
+ /** The live pid named by a pid/lock file (as the raw string), or null when absent/stale. */
109
+ export function livePidIn(file) {
110
+ try {
111
+ const raw = readFileSync(file, 'utf8').trim();
112
+ return pidAlive(raw) ? raw : null;
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
118
+ /** The live supervising watchdog's pid, or null when none is (pidfile + kill 0). */
119
+ export function liveWatchdogPid(stateDir) {
120
+ const raw = livePidIn(stateFile(stateDir, 'watchdogPid'));
121
+ const pid = raw === null ? null : Number(raw);
122
+ appendTestLifecycleEvent('watchdog-liveness-probe', { pid: pid ?? 0, live: pid !== null }, 'parent-observer');
123
+ return pid;
124
+ }
@@ -9,6 +9,29 @@ export interface GuardCredential {
9
9
  /** The exact command that produced the green state. */
10
10
  command: string;
11
11
  }
12
+ /**
13
+ * Durable proof that one exact deployment passed restart readiness + canary.
14
+ * Digests contain no command, profile content, or credential values.
15
+ */
16
+ export interface ProvenDeployment {
17
+ version: 1;
18
+ provenAt: number;
19
+ credential: {
20
+ revision: string;
21
+ recordedAt: number;
22
+ scope: string;
23
+ commandSha256: string;
24
+ };
25
+ fingerprint: {
26
+ version: 1;
27
+ credentialRevision: string;
28
+ harnessRevision: string;
29
+ launchSpecSha256: string;
30
+ profileSha256: string;
31
+ hostRuntimeSha256: string;
32
+ };
33
+ fingerprintSha256: string;
34
+ }
12
35
  /** A pre-batch snapshot commit the guard can reset back to. */
13
36
  export interface GuardCheckpoint {
14
37
  /** Commit SHA of the checkpoint. */
@@ -20,13 +43,14 @@ export interface GuardCheckpoint {
20
43
  }
21
44
  /** Append-only audit trail of state mutations (capped; verify is read-only). */
22
45
  export interface GuardAuditEntry {
23
- action: 'record' | 'clear' | 'checkpoint';
46
+ action: 'record' | 'clear' | 'checkpoint' | 'prove-deployment';
24
47
  ts: number;
25
48
  detail: string;
26
49
  }
27
50
  /** The whole persisted state file. */
28
51
  export interface GuardState {
29
52
  credential?: GuardCredential;
53
+ provenDeployment?: ProvenDeployment;
30
54
  checkpoint?: GuardCheckpoint;
31
55
  audit: readonly GuardAuditEntry[];
32
56
  }
@@ -69,6 +93,8 @@ export declare function recordCredential(stateDir: string, input: RecordInput, n
69
93
  * @returns the persisted state.
70
94
  */
71
95
  export declare function clearCredential(stateDir: string, now: number): GuardState;
96
+ /** Persist a deployment proof after the watchdog has completed its canary. */
97
+ export declare function setProvenDeployment(stateDir: string, proof: ProvenDeployment, now: number): GuardState;
72
98
  /**
73
99
  * Persist a pre-batch checkpoint commit reference.
74
100
  * @param stateDir - state directory.
@@ -87,7 +113,8 @@ export declare function setCheckpoint(stateDir: string, input: {
87
113
  * @param currentRevision - the git HEAD of the checkout, or null when unavailable.
88
114
  * @param now - epoch milliseconds (injected for deterministic tests).
89
115
  * @param maxAgeMinutes - freshness window.
116
+ * @param workingTreeClean - whether staged, unstaged, and untracked inputs are absent.
90
117
  * @returns ok plus a human reason either way.
91
118
  */
92
- export declare function verifyCredential(state: GuardState, currentRevision: string | null, now: number, maxAgeMinutes: number): VerifyResult;
119
+ export declare function verifyCredential(state: GuardState, currentRevision: string | null, now: number, maxAgeMinutes: number, workingTreeClean?: boolean): VerifyResult;
93
120
  //# sourceMappingURL=state.d.ts.map
@@ -3,10 +3,9 @@
3
3
  *
4
4
  * The guard records one "green build" credential — bound to the git HEAD it
5
5
  * was recorded on and to a freshness window — and answers `verify()` against
6
- * the CURRENT head and wall clock. A self-restart may only proceed while the
7
- * credential is valid; the binding to HEAD means any tree change after
8
- * recording invalidates it, so a stale or post-hoc credential can never
9
- * authorize a restart of unverified code. Framework-free: the cordis plugin,
6
+ * the CURRENT head and wall clock. A later fully gated boot may promote that
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,
10
9
  * the CLI, and the invariant companion all share this module.
11
10
  */
12
11
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
@@ -33,11 +32,34 @@ function isCheckpoint(value) {
33
32
  const c = value;
34
33
  return typeof c.revision === 'string' && typeof c.recordedAt === 'number' && typeof c.message === 'string';
35
34
  }
35
+ function isSha256(value) {
36
+ return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
37
+ }
38
+ function isProvenDeployment(value) {
39
+ if (typeof value !== 'object' || value === null)
40
+ return false;
41
+ const proof = value;
42
+ const credential = proof.credential;
43
+ const fingerprint = proof.fingerprint;
44
+ return proof.version === 1 && typeof proof.provenAt === 'number'
45
+ && credential !== undefined
46
+ && typeof credential.revision === 'string' && credential.revision !== ''
47
+ && typeof credential.recordedAt === 'number'
48
+ && typeof credential.scope === 'string'
49
+ && isSha256(credential.commandSha256)
50
+ && fingerprint?.version === 1
51
+ && typeof fingerprint.credentialRevision === 'string' && fingerprint.credentialRevision !== ''
52
+ && typeof fingerprint.harnessRevision === 'string' && fingerprint.harnessRevision !== ''
53
+ && isSha256(fingerprint.launchSpecSha256)
54
+ && isSha256(fingerprint.profileSha256)
55
+ && isSha256(fingerprint.hostRuntimeSha256)
56
+ && isSha256(proof.fingerprintSha256);
57
+ }
36
58
  function isAuditEntry(value) {
37
59
  if (typeof value !== 'object' || value === null)
38
60
  return false;
39
61
  const e = value;
40
- return (e.action === 'record' || e.action === 'clear' || e.action === 'checkpoint')
62
+ return (e.action === 'record' || e.action === 'clear' || e.action === 'checkpoint' || e.action === 'prove-deployment')
41
63
  && typeof e.ts === 'number' && typeof e.detail === 'string';
42
64
  }
43
65
  /**
@@ -56,6 +78,8 @@ export function loadState(stateDir) {
56
78
  const state = { audit };
57
79
  if (isCredential(parsed.credential))
58
80
  state.credential = parsed.credential;
81
+ if (isProvenDeployment(parsed.provenDeployment))
82
+ state.provenDeployment = parsed.provenDeployment;
59
83
  if (isCheckpoint(parsed.checkpoint))
60
84
  state.checkpoint = parsed.checkpoint;
61
85
  return state;
@@ -91,7 +115,10 @@ export function recordCredential(stateDir, input, now) {
91
115
  const credential = { ...input, recordedAt: now };
92
116
  const state = loadState(stateDir);
93
117
  const next = withAudit(state, { action: 'record', ts: now, detail: `${input.scope} @ ${input.revision}` });
94
- const updated = { ...next, credential };
118
+ // A new proof attempt invalidates the old deployed-runtime proof even when
119
+ // it happens to target the same HEAD: ignored build outputs may be changing.
120
+ const { provenDeployment: _oldProof, ...withoutOldProof } = next;
121
+ const updated = { ...withoutOldProof, credential };
95
122
  saveState(stateDir, updated);
96
123
  return updated;
97
124
  }
@@ -110,6 +137,17 @@ export function clearCredential(stateDir, now) {
110
137
  saveState(stateDir, updated);
111
138
  return updated;
112
139
  }
140
+ /** Persist a deployment proof after the watchdog has completed its canary. */
141
+ export function setProvenDeployment(stateDir, proof, now) {
142
+ const state = loadState(stateDir);
143
+ const next = withAudit(state, {
144
+ action: 'prove-deployment', ts: now,
145
+ detail: `${proof.credential.revision} ${proof.fingerprintSha256.slice(0, 16)}`,
146
+ });
147
+ const updated = { ...next, provenDeployment: proof };
148
+ saveState(stateDir, updated);
149
+ return updated;
150
+ }
113
151
  /**
114
152
  * Persist a pre-batch checkpoint commit reference.
115
153
  * @param stateDir - state directory.
@@ -132,15 +170,22 @@ export function setCheckpoint(stateDir, input, now) {
132
170
  * @param currentRevision - the git HEAD of the checkout, or null when unavailable.
133
171
  * @param now - epoch milliseconds (injected for deterministic tests).
134
172
  * @param maxAgeMinutes - freshness window.
173
+ * @param workingTreeClean - whether staged, unstaged, and untracked inputs are absent.
135
174
  * @returns ok plus a human reason either way.
136
175
  */
137
- export function verifyCredential(state, currentRevision, now, maxAgeMinutes) {
176
+ export function verifyCredential(state, currentRevision, now, maxAgeMinutes, workingTreeClean = true) {
138
177
  const credential = state.credential;
139
178
  if (credential === undefined)
140
179
  return { ok: false, reason: 'no green-build credential recorded' };
141
180
  if (currentRevision === null) {
142
181
  return { ok: false, reason: 'current git HEAD unavailable (not inside a git repository?)' };
143
182
  }
183
+ if (!workingTreeClean) {
184
+ return {
185
+ ok: false,
186
+ reason: 'working tree is dirty (staged, unstaged, or untracked changes exist) — commit or remove them, then rebuild and re-record',
187
+ };
188
+ }
144
189
  if (credential.revision !== currentRevision) {
145
190
  return {
146
191
  ok: false,
@@ -0,0 +1,15 @@
1
+ import { type ProcessIdentity } from './processes.ts';
2
+ /** Owner proof for a crash-left temporary artifact that may later be reaped. */
3
+ export declare const TEMP_ARTIFACT_OWNER_FILE = ".ankh-guard-owner.json";
4
+ export interface TempArtifactOwnerRecord {
5
+ version: 1;
6
+ kind: 'preflight-snapshot';
7
+ createdAt: number;
8
+ owner: ProcessIdentity;
9
+ }
10
+ /**
11
+ * Bind one private temporary artifact to the exact process identity creating
12
+ * it. A later reaper may remove the artifact only after this identity is gone.
13
+ */
14
+ export declare function writeTempArtifactOwner(root: string, kind: TempArtifactOwnerRecord['kind']): TempArtifactOwnerRecord;
15
+ //# sourceMappingURL=temp-artifact.d.ts.map
@@ -0,0 +1,17 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { processIdentity } from "./processes.js";
4
+ /** Owner proof for a crash-left temporary artifact that may later be reaped. */
5
+ export const TEMP_ARTIFACT_OWNER_FILE = '.ankh-guard-owner.json';
6
+ /**
7
+ * Bind one private temporary artifact to the exact process identity creating
8
+ * it. A later reaper may remove the artifact only after this identity is gone.
9
+ */
10
+ export function writeTempArtifactOwner(root, kind) {
11
+ const owner = processIdentity(process.pid);
12
+ if (owner === null)
13
+ throw new Error(`cannot capture temporary-artifact owner identity for pid ${process.pid}`);
14
+ const record = { version: 1, kind, createdAt: Date.now(), owner };
15
+ writeFileSync(join(root, TEMP_ARTIFACT_OWNER_FILE), `${JSON.stringify(record)}\n`, { flag: 'wx', mode: 0o600 });
16
+ return record;
17
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=test-seam-cli.d.ts.map
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ /** Internal executable used only by dsh-watchdog.sh under an explicit test run. */
3
+ import { appendTestLifecycleEventForProcess, registerCurrentTestProcess, registerTestProcess, TEST_PROCESS_ROLE_ENV, } from "./test-seam.js";
4
+ const command = process.argv[2] ?? 'register';
5
+ if (command === 'register-parent') {
6
+ const role = process.argv[3] ?? process.env[TEST_PROCESS_ROLE_ENV] ?? 'unknown';
7
+ registerTestProcess(process.ppid, role, { source: 'child-self' });
8
+ }
9
+ else if (command === 'event-parent') {
10
+ const role = process.argv[3] ?? process.env[TEST_PROCESS_ROLE_ENV] ?? 'unknown';
11
+ const event = process.argv[4] ?? 'unknown';
12
+ appendTestLifecycleEventForProcess(process.ppid, role, event);
13
+ }
14
+ else if (command === 'register-pid') {
15
+ const pid = Number(process.argv[3]);
16
+ const role = process.argv[4] ?? process.env[TEST_PROCESS_ROLE_ENV] ?? 'unknown';
17
+ registerTestProcess(pid, role, { source: 'child-self' });
18
+ }
19
+ else if (command === 'event-pid') {
20
+ const pid = Number(process.argv[3]);
21
+ const role = process.argv[4] ?? process.env[TEST_PROCESS_ROLE_ENV] ?? 'unknown';
22
+ const event = process.argv[5] ?? 'unknown';
23
+ appendTestLifecycleEventForProcess(pid, role, event);
24
+ }
25
+ else {
26
+ registerCurrentTestProcess();
27
+ }
@@ -0,0 +1,55 @@
1
+ import { type ProcessIdentity } from './processes.ts';
2
+ export declare const TEST_RUN_DIR_ENV = "ANKH_GUARD_TEST_RUN_DIR";
3
+ export declare const TEST_RUN_TOKEN_ENV = "ANKH_GUARD_TEST_RUN_TOKEN";
4
+ export declare const TEST_PROCESS_ROLE_ENV = "ANKH_GUARD_TEST_PROCESS_ROLE";
5
+ export declare const TEST_PROCESS_PORT_ENV = "ANKH_GUARD_TEST_PROCESS_PORT";
6
+ export declare const TEST_PROCESS_TEMP_ROOT_ENV = "ANKH_GUARD_TEST_PROCESS_TEMP_ROOT";
7
+ export declare const TEST_REGISTER_BIN_ENV = "ANKH_GUARD_TEST_REGISTER_BIN";
8
+ export declare const TEST_SLEEP_SCALE_ENV = "ANKH_GUARD_TEST_SLEEP_SCALE";
9
+ export type TestObservationSource = 'child-self' | 'parent-observer' | 'node-exit-callback' | 'node-close-callback' | 'ps-sampler';
10
+ export interface TestProcessLeaseRecord {
11
+ version: 1;
12
+ runToken: string;
13
+ role: string;
14
+ pid: number;
15
+ pgid: number;
16
+ startToken: string;
17
+ groupRoot: boolean;
18
+ registeredAt: number;
19
+ source: 'child-self' | 'parent-observer';
20
+ tempRoot?: string;
21
+ port?: number;
22
+ }
23
+ export interface TestLifecycleEvent {
24
+ version: 1;
25
+ runToken: string;
26
+ source: TestObservationSource;
27
+ role: string;
28
+ event: string;
29
+ pid: number;
30
+ pgid?: number;
31
+ startToken?: string;
32
+ wallTimeMs: number;
33
+ monotonicNs: string;
34
+ detail?: Record<string, string | number | boolean | null>;
35
+ }
36
+ interface RegisterOptions {
37
+ env?: NodeJS.ProcessEnv;
38
+ identity?: ProcessIdentity;
39
+ source?: 'child-self' | 'parent-observer';
40
+ tempRoot?: string;
41
+ port?: number;
42
+ }
43
+ export declare function appendTestLifecycleEventForProcess(subjectPid: number, roleValue: string, event: string, detail?: Record<string, string | number | boolean | null>, source?: TestObservationSource, env?: NodeJS.ProcessEnv): void;
44
+ /** Record one credential-free event in this process's append-only event file. */
45
+ export declare function appendTestLifecycleEvent(event: string, detail?: Record<string, string | number | boolean | null>, source?: TestObservationSource, env?: NodeJS.ProcessEnv): void;
46
+ /**
47
+ * Atomically publish an immutable PID/PGID/start-identity lease. The same PID
48
+ * may be observed by its parent and then self-register; the first complete
49
+ * record wins because both describe the same kernel start identity.
50
+ */
51
+ export declare function registerTestProcess(pid: number, role: string, options?: RegisterOptions): TestProcessLeaseRecord | null;
52
+ /** Self-register only when an explicit test role accompanies the run lease. */
53
+ export declare function registerCurrentTestProcess(env?: NodeJS.ProcessEnv): TestProcessLeaseRecord | null;
54
+ export {};
55
+ //# sourceMappingURL=test-seam.d.ts.map
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Explicit test-only process/event registration seam. Production execution is
3
+ * inert unless a test runner supplies all ANKH_GUARD_TEST_* ownership values.
4
+ */
5
+ import { createHash } from 'node:crypto';
6
+ import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { processGroupId, processIdentity } from "./processes.js";
9
+ export const TEST_RUN_DIR_ENV = 'ANKH_GUARD_TEST_RUN_DIR';
10
+ export const TEST_RUN_TOKEN_ENV = 'ANKH_GUARD_TEST_RUN_TOKEN';
11
+ export const TEST_PROCESS_ROLE_ENV = 'ANKH_GUARD_TEST_PROCESS_ROLE';
12
+ export const TEST_PROCESS_PORT_ENV = 'ANKH_GUARD_TEST_PROCESS_PORT';
13
+ export const TEST_PROCESS_TEMP_ROOT_ENV = 'ANKH_GUARD_TEST_PROCESS_TEMP_ROOT';
14
+ export const TEST_REGISTER_BIN_ENV = 'ANKH_GUARD_TEST_REGISTER_BIN';
15
+ export const TEST_SLEEP_SCALE_ENV = 'ANKH_GUARD_TEST_SLEEP_SCALE';
16
+ function testCoordinates(env) {
17
+ const runDir = env[TEST_RUN_DIR_ENV];
18
+ const runToken = env[TEST_RUN_TOKEN_ENV];
19
+ if (runDir === undefined || runDir === '' || runToken === undefined || !/^[a-f0-9-]{16,}$/.test(runToken))
20
+ return null;
21
+ return { runDir, runToken };
22
+ }
23
+ function safeRole(value) {
24
+ const role = value.trim().replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 80);
25
+ return role === '' ? 'unknown' : role;
26
+ }
27
+ export function appendTestLifecycleEventForProcess(subjectPid, roleValue, event, detail, source = 'child-self', env = process.env) {
28
+ const coordinates = testCoordinates(env);
29
+ if (coordinates === null)
30
+ return;
31
+ const role = safeRole(roleValue);
32
+ const identity = processIdentity(subjectPid);
33
+ const pgid = processGroupId(subjectPid);
34
+ const record = {
35
+ version: 1,
36
+ runToken: coordinates.runToken,
37
+ source,
38
+ role,
39
+ event: safeRole(event),
40
+ pid: subjectPid,
41
+ ...(pgid === null ? {} : { pgid }),
42
+ ...(identity === null ? {} : { startToken: identity.startToken }),
43
+ wallTimeMs: Date.now(),
44
+ monotonicNs: process.hrtime.bigint().toString(),
45
+ ...(detail === undefined ? {} : { detail }),
46
+ };
47
+ try {
48
+ const dir = join(coordinates.runDir, 'events');
49
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
50
+ appendFileSync(join(dir, `${subjectPid}-${source}.jsonl`), `${JSON.stringify(record)}\n`, { mode: 0o600 });
51
+ }
52
+ catch {
53
+ // Diagnostics must never change the lifecycle under test.
54
+ }
55
+ }
56
+ /** Record one credential-free event in this process's append-only event file. */
57
+ export function appendTestLifecycleEvent(event, detail, source = 'child-self', env = process.env) {
58
+ appendTestLifecycleEventForProcess(process.pid, env[TEST_PROCESS_ROLE_ENV] ?? 'unknown', event, detail, source, env);
59
+ }
60
+ /**
61
+ * Atomically publish an immutable PID/PGID/start-identity lease. The same PID
62
+ * may be observed by its parent and then self-register; the first complete
63
+ * record wins because both describe the same kernel start identity.
64
+ */
65
+ export function registerTestProcess(pid, role, options = {}) {
66
+ const env = options.env ?? process.env;
67
+ const coordinates = testCoordinates(env);
68
+ if (coordinates === null)
69
+ return null;
70
+ const identity = options.identity ?? processIdentity(pid);
71
+ const pgid = processGroupId(pid);
72
+ if (identity === null || pgid === null)
73
+ return null;
74
+ const portText = env[TEST_PROCESS_PORT_ENV];
75
+ const envPort = portText === undefined ? undefined : Number(portText);
76
+ const port = options.port ?? (Number.isInteger(envPort) && (envPort ?? 0) > 0 ? envPort : undefined);
77
+ const tempRoot = options.tempRoot ?? env[TEST_PROCESS_TEMP_ROOT_ENV];
78
+ const record = {
79
+ version: 1,
80
+ runToken: coordinates.runToken,
81
+ role: safeRole(role),
82
+ pid,
83
+ pgid,
84
+ startToken: identity.startToken,
85
+ groupRoot: pgid === pid,
86
+ registeredAt: Date.now(),
87
+ source: options.source ?? (pid === process.pid ? 'child-self' : 'parent-observer'),
88
+ ...(tempRoot === undefined || tempRoot === '' ? {} : { tempRoot }),
89
+ ...(port === undefined ? {} : { port }),
90
+ };
91
+ try {
92
+ const dir = join(coordinates.runDir, 'processes');
93
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
94
+ const identityHash = createHash('sha256').update(identity.startToken).digest('hex').slice(0, 16);
95
+ writeFileSync(join(dir, `${pid}-${identityHash}.json`), `${JSON.stringify(record)}\n`, { flag: 'wx', mode: 0o600 });
96
+ }
97
+ catch (error) {
98
+ if (error.code !== 'EEXIST')
99
+ return null;
100
+ }
101
+ return record;
102
+ }
103
+ /** Self-register only when an explicit test role accompanies the run lease. */
104
+ export function registerCurrentTestProcess(env = process.env) {
105
+ const role = env[TEST_PROCESS_ROLE_ENV];
106
+ if (role === undefined || role === '')
107
+ return null;
108
+ const record = registerTestProcess(process.pid, role, { env, source: 'child-self' });
109
+ if (record !== null)
110
+ appendTestLifecycleEvent('process-registered', { pgid: record.pgid }, 'child-self', env);
111
+ return record;
112
+ }