@khorsheed/dsh-ankh-guard 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +28 -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 +2383 -209
  6. package/lib/client.js +257 -0
  7. package/lib/exit-agent.js +5 -2
  8. package/lib/index.js +722 -38
  9. package/lib/invariant.js +1 -1
  10. package/lib/preflight-runner.js +125 -47
  11. package/lib/processes-BjZgJjQr.js +344 -0
  12. package/lib/restart-context-D6nISh28.js +1245 -0
  13. package/lib/restart-context-DUyExi9O.js +1245 -0
  14. package/lib/{state-Dhx9VG44.js → state-4f7yny39.js} +60 -13
  15. package/lib/state-CZMypGkB.js +323 -0
  16. package/lib/test-seam-DnvLWTeO.js +119 -0
  17. package/lib/test-seam-cli.js +24 -0
  18. package/lib/test-seam-dwvaKjRp.js +459 -0
  19. package/lib/test-seam.js +2 -0
  20. package/lib/types/browser-handoff.d.ts +55 -0
  21. package/lib/types/browser-handoff.js +489 -0
  22. package/lib/types/cli.d.ts +34 -4
  23. package/lib/types/cli.js +1487 -225
  24. package/lib/types/client/index.d.ts +15 -0
  25. package/lib/types/client/index.js +264 -0
  26. package/lib/types/deployment-proof.d.ts +24 -0
  27. package/lib/types/deployment-proof.js +314 -0
  28. package/lib/types/exit-agent.js +2 -0
  29. package/lib/types/git.d.ts +12 -3
  30. package/lib/types/git.js +69 -7
  31. package/lib/types/index.d.ts +66 -3
  32. package/lib/types/index.js +157 -39
  33. package/lib/types/launch-spec.d.ts +263 -0
  34. package/lib/types/launch-spec.js +823 -0
  35. package/lib/types/preflight-runner.d.ts +23 -12
  36. package/lib/types/preflight-runner.js +152 -57
  37. package/lib/types/processes.d.ts +38 -6
  38. package/lib/types/processes.js +236 -10
  39. package/lib/types/restart-context.d.ts +50 -0
  40. package/lib/types/restart-context.js +106 -0
  41. package/lib/types/restart-request.d.ts +32 -0
  42. package/lib/types/restart-request.js +128 -0
  43. package/lib/types/state-files.d.ts +30 -0
  44. package/lib/types/state-files.js +55 -0
  45. package/lib/types/state.d.ts +29 -2
  46. package/lib/types/state.js +52 -7
  47. package/lib/types/temp-artifact.d.ts +15 -0
  48. package/lib/types/temp-artifact.js +17 -0
  49. package/lib/types/test-seam-cli.d.ts +3 -0
  50. package/lib/types/test-seam-cli.js +27 -0
  51. package/lib/types/test-seam.d.ts +55 -0
  52. package/lib/types/test-seam.js +112 -0
  53. package/lib/types/transition.d.ts +118 -0
  54. package/lib/types/transition.js +717 -0
  55. package/package.json +29 -9
  56. package/scripts/dsh-watchdog.sh +1388 -80
  57. package/scripts/install-launchd.sh +43 -5
  58. package/scripts/install-systemd.sh +43 -5
  59. package/scripts/on-install.js +1 -1
  60. package/skills/dsh-self-restart-guard/SKILL.md +38 -12
  61. package/lib/processes-hCAmwma-.js +0 -127
  62. package/lib/restart-context-DmnQXNf-.js +0 -421
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Browser half of the original-tab launch-cutover handoff, plus the
3
+ * boot-generation reload loop: polls carry the last-seen boot id, a stale id
4
+ * with no cutover in flight reloads the tab once, and a sustained disconnection
5
+ * (not a transient blip) raises a neutral overlay.
6
+ */
7
+ import type { Context as ClientContext } from '@deepseek-ai/cordis';
8
+ /** No Cordis services are needed; this is a same-origin browser lifecycle observer. */
9
+ export declare const inject: readonly string[];
10
+ /** Deliberately discard query and fragment because either may hold credentials. */
11
+ export declare function safeReturnPath(href: string, origin: string): string | undefined;
12
+ export declare function fallbackCutoverId(hash: string): string | null;
13
+ /** Install the polling/reload loop for this tab's lifetime. */
14
+ export declare function apply(_ctx: ClientContext): () => void;
15
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,264 @@
1
+ const ROUTE = '/_ankh-guard/browser-handoff';
2
+ const CAPABILITY_KEY = 'ankh-guard.browser-handoff-capability.v1';
3
+ const PENDING_KEY = 'ankh-guard.browser-handoff-pending.v1';
4
+ const BOOT_ID_KEY = 'ankh-guard.browser-handoff-boot-id.v1';
5
+ const FALLBACK_HASH_KEY = 'ankh-guard-handoff';
6
+ const ACTIVE_RETRY_MS = 250;
7
+ const ERROR_RETRY_MIN_MS = 1_000;
8
+ const ERROR_RETRY_MAX_MS = 30_000;
9
+ /** Sustained-failure threshold for the disconnected overlay; transient blips never show it. */
10
+ const DISCONNECTED_OVERLAY_MS = 5_000;
11
+ /** No Cordis services are needed; this is a same-origin browser lifecycle observer. */
12
+ export const inject = [];
13
+ function encodeBase64Url(bytes) {
14
+ let binary = '';
15
+ for (const byte of bytes)
16
+ binary += String.fromCharCode(byte);
17
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '');
18
+ }
19
+ function freshCapability() {
20
+ const bytes = new Uint8Array(32);
21
+ crypto.getRandomValues(bytes);
22
+ return encodeBase64Url(bytes);
23
+ }
24
+ function capability() {
25
+ const existing = sessionStorage.getItem(CAPABILITY_KEY);
26
+ if (existing !== null && /^[A-Za-z0-9_-]{43}$/.test(existing))
27
+ return existing;
28
+ const created = freshCapability();
29
+ sessionStorage.setItem(CAPABILITY_KEY, created);
30
+ return created;
31
+ }
32
+ function readPending() {
33
+ try {
34
+ const value = JSON.parse(sessionStorage.getItem(PENDING_KEY) ?? 'null');
35
+ if (value?.version !== 1 || typeof value.cutoverId !== 'string'
36
+ || (value.channel !== 'original-tab' && value.channel !== 'fallback-tab')
37
+ || (value.authentication !== 'existing-cookie' && value.authentication !== 'launch-url'))
38
+ return null;
39
+ if (value.channel === 'original-tab' && typeof value.capability !== 'string')
40
+ return null;
41
+ if (value.returnPath !== undefined && !isSafeReturnPath(value.returnPath))
42
+ return null;
43
+ return value;
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ /** Deliberately discard query and fragment because either may hold credentials. */
50
+ export function safeReturnPath(href, origin) {
51
+ try {
52
+ const url = new URL(href);
53
+ if (url.origin !== origin || url.pathname === '' || url.pathname.startsWith('//')
54
+ || url.pathname.includes('\\'))
55
+ return undefined;
56
+ return url.pathname;
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ }
62
+ function isSafeReturnPath(value) {
63
+ return typeof value === 'string' && value.startsWith('/') && !value.startsWith('//')
64
+ && !value.includes('\\') && !value.includes('?') && !value.includes('#');
65
+ }
66
+ function writePending(value) {
67
+ // Deliberately excludes launchUrl: bearer URLs never enter browser storage.
68
+ sessionStorage.setItem(PENDING_KEY, JSON.stringify(value));
69
+ }
70
+ export function fallbackCutoverId(hash) {
71
+ const params = new URLSearchParams(hash.startsWith('#') ? hash.slice(1) : hash);
72
+ const id = params.get(FALLBACK_HASH_KEY);
73
+ return id !== null && /^[A-Za-z0-9._-]{1,160}$/.test(id) ? id : null;
74
+ }
75
+ function consumeFallbackFragment() {
76
+ const cutoverId = fallbackCutoverId(location.hash);
77
+ if (cutoverId === null)
78
+ return null;
79
+ const url = new URL(location.href);
80
+ url.hash = '';
81
+ history.replaceState(history.state, '', url.href);
82
+ const pending = {
83
+ version: 1,
84
+ cutoverId,
85
+ channel: 'fallback-tab',
86
+ authentication: 'launch-url',
87
+ };
88
+ writePending(pending);
89
+ return pending;
90
+ }
91
+ function waitingOverlay(kind = 'restarting') {
92
+ const existing = document.getElementById('ankh-guard-browser-handoff');
93
+ if (existing !== null)
94
+ return existing;
95
+ const zh = navigator.language.toLowerCase().startsWith('zh');
96
+ const element = document.createElement('div');
97
+ element.id = 'ankh-guard-browser-handoff';
98
+ element.setAttribute('role', 'status');
99
+ element.setAttribute('aria-live', 'polite');
100
+ // 'disconnected' covers failures without a known restart in flight (a bare
101
+ // exit may never come back) — neutral copy, no auto-recovery promise.
102
+ element.textContent = kind === 'disconnected'
103
+ ? (zh ? '连接已断开,等待服务恢复。' : 'Connection lost; waiting for the service to recover.')
104
+ : (zh ? 'dsh 正在重启;此标签页会自动恢复。' : 'dsh is restarting; this tab will recover automatically.');
105
+ element.style.cssText = [
106
+ 'position:fixed', 'inset:0', 'z-index:2147483647', 'display:grid', 'place-items:center',
107
+ 'padding:24px', 'background:rgba(15,18,24,.92)', 'color:#fff',
108
+ 'font:500 16px/1.5 system-ui,sans-serif', 'text-align:center',
109
+ ].join(';');
110
+ document.documentElement.append(element);
111
+ return element;
112
+ }
113
+ async function post(body) {
114
+ return fetch(ROUTE, {
115
+ method: 'POST',
116
+ credentials: 'same-origin',
117
+ cache: 'no-store',
118
+ headers: { 'content-type': 'application/json' },
119
+ body: JSON.stringify(body),
120
+ });
121
+ }
122
+ async function acknowledge(pending) {
123
+ const response = await post({
124
+ version: 1,
125
+ operation: 'ack',
126
+ cutoverId: pending.cutoverId,
127
+ channel: pending.channel,
128
+ authentication: pending.authentication,
129
+ ...(pending.capability !== undefined ? { capability: pending.capability } : {}),
130
+ });
131
+ return response.status === 204;
132
+ }
133
+ /** Install the polling/reload loop for this tab's lifetime. */
134
+ export function apply(_ctx) {
135
+ let disposed = false;
136
+ let timer;
137
+ let pending = consumeFallbackFragment() ?? readPending();
138
+ let cap;
139
+ let errorRetryMs = ERROR_RETRY_MIN_MS;
140
+ let firstFailureAt;
141
+ // Fullscreen notice only for SUSTAINED disconnection (restart window, bare
142
+ // exit): transient network blips and the backoff cadence never trip it.
143
+ const noteDisconnected = () => {
144
+ firstFailureAt ??= Date.now();
145
+ if (Date.now() - firstFailureAt >= DISCONNECTED_OVERLAY_MS)
146
+ waitingOverlay('disconnected');
147
+ };
148
+ const schedule = (delayMs = 0) => {
149
+ if (!disposed)
150
+ timer = setTimeout(() => { void tick(); }, delayMs);
151
+ };
152
+ const tick = async () => {
153
+ if (disposed)
154
+ return;
155
+ try {
156
+ if (pending !== null) {
157
+ waitingOverlay();
158
+ if (await acknowledge(pending)) {
159
+ const returnPath = pending.returnPath;
160
+ const originalTab = pending.channel === 'original-tab';
161
+ sessionStorage.removeItem(PENDING_KEY);
162
+ sessionStorage.removeItem(CAPABILITY_KEY);
163
+ document.getElementById('ankh-guard-browser-handoff')?.remove();
164
+ pending = null;
165
+ if (originalTab && returnPath !== undefined
166
+ && safeReturnPath(location.href, location.origin) !== returnPath) {
167
+ location.replace(returnPath);
168
+ return;
169
+ }
170
+ }
171
+ errorRetryMs = ERROR_RETRY_MIN_MS;
172
+ firstFailureAt = undefined;
173
+ schedule(ACTIVE_RETRY_MS);
174
+ return;
175
+ }
176
+ const tabCapability = cap ??= capability();
177
+ const knownBootId = sessionStorage.getItem(BOOT_ID_KEY);
178
+ const response = await post({
179
+ version: 1, operation: 'poll', capability: tabCapability,
180
+ ...(knownBootId === null ? {} : { knownBootId }),
181
+ });
182
+ if (!response.ok) {
183
+ noteDisconnected();
184
+ schedule(errorRetryMs);
185
+ errorRetryMs = Math.min(errorRetryMs * 2, ERROR_RETRY_MAX_MS);
186
+ return;
187
+ }
188
+ errorRetryMs = ERROR_RETRY_MIN_MS;
189
+ firstFailureAt = undefined;
190
+ const result = await response.json();
191
+ // Learn the serving boot id before acting on it — storing the successor's
192
+ // id ahead of the reload is what makes the generation check one-shot.
193
+ if (typeof result.bootId === 'string')
194
+ sessionStorage.setItem(BOOT_ID_KEY, result.bootId);
195
+ if (result.state === 'idle') {
196
+ document.getElementById('ankh-guard-browser-handoff')?.remove();
197
+ // The server holds idle polls, providing event-like wakeup without a
198
+ // permanent fixed-frequency request loop.
199
+ schedule();
200
+ return;
201
+ }
202
+ if (result.state === 'waiting')
203
+ waitingOverlay();
204
+ // Boot-generation reload (no cutoverId): the process this tab knew is
205
+ // gone. No registration/ack dance — the stored id already advanced.
206
+ if (result.state === 'ready' && result.action === 'reload' && typeof result.cutoverId !== 'string') {
207
+ location.reload();
208
+ return;
209
+ }
210
+ if (result.state !== 'ready' || typeof result.cutoverId !== 'string') {
211
+ schedule(ACTIVE_RETRY_MS);
212
+ return;
213
+ }
214
+ if (result.action === 'reload' && result.authentication === 'existing-cookie') {
215
+ const returnPath = safeReturnPath(location.href, location.origin);
216
+ const nextPending = {
217
+ version: 1, cutoverId: result.cutoverId, channel: 'original-tab',
218
+ authentication: 'existing-cookie', capability: tabCapability,
219
+ ...(returnPath === undefined ? {} : { returnPath }),
220
+ };
221
+ pending = nextPending;
222
+ writePending(nextPending);
223
+ waitingOverlay();
224
+ location.reload();
225
+ return;
226
+ }
227
+ if (result.action === 'replace' && result.authentication === 'launch-url'
228
+ && typeof result.launchUrl === 'string') {
229
+ const launch = new URL(result.launchUrl);
230
+ if (launch.origin !== location.origin || launch.pathname !== '/' || launch.search === ''
231
+ || launch.username !== '' || launch.password !== '' || launch.hash !== '') {
232
+ schedule(ACTIVE_RETRY_MS);
233
+ return;
234
+ }
235
+ const returnPath = safeReturnPath(location.href, location.origin);
236
+ const nextPending = {
237
+ version: 1, cutoverId: result.cutoverId, channel: 'original-tab',
238
+ authentication: 'launch-url', capability: tabCapability,
239
+ ...(returnPath === undefined ? {} : { returnPath }),
240
+ };
241
+ pending = nextPending;
242
+ writePending(nextPending);
243
+ waitingOverlay();
244
+ // The bearer remains a local variable for the shortest possible time.
245
+ location.replace(launch.href);
246
+ return;
247
+ }
248
+ }
249
+ catch {
250
+ // The expected restart interval rejects fetches; keep the old page and retry.
251
+ noteDisconnected();
252
+ schedule(errorRetryMs);
253
+ errorRetryMs = Math.min(errorRetryMs * 2, ERROR_RETRY_MAX_MS);
254
+ return;
255
+ }
256
+ schedule(ACTIVE_RETRY_MS);
257
+ };
258
+ void tick();
259
+ return () => {
260
+ disposed = true;
261
+ if (timer !== undefined)
262
+ clearTimeout(timer);
263
+ };
264
+ }
@@ -0,0 +1,24 @@
1
+ import { type LaunchSpec } from './launch-spec.ts';
2
+ import { type ProvenDeployment, type VerifyResult } from './state.ts';
3
+ export interface RestartAuthorization {
4
+ version: 1;
5
+ kind: 'fresh-credential' | 'proven-deployment';
6
+ revision: string;
7
+ evidenceSha256: string;
8
+ }
9
+ export interface RestartEvidenceResult extends VerifyResult {
10
+ authorization?: RestartAuthorization;
11
+ }
12
+ export declare function captureDeploymentFingerprint(spec: LaunchSpec): ProvenDeployment['fingerprint'];
13
+ export declare function deploymentFingerprintSha256(fingerprint: ProvenDeployment['fingerprint']): string;
14
+ export declare function verifyProvenDeployment(stateDir: string, spec: LaunchSpec): RestartEvidenceResult;
15
+ /** Fresh evidence wins; an exact prior deployment is the same-launch fallback. */
16
+ export declare function verifyRestartEvidence(stateDir: string, spec: LaunchSpec, maxAgeMinutes: number, now?: number): RestartEvidenceResult;
17
+ /** Revalidate the exact evidence selected before the scheduled stop. */
18
+ export declare function verifyRestartAuthorization(stateDir: string, spec: LaunchSpec, authorization: RestartAuthorization): RestartEvidenceResult;
19
+ /**
20
+ * Promote a fresh credential after the boot's readiness/canary boundary. A
21
+ * proof-authorized pure restart retains the existing proof unchanged.
22
+ */
23
+ export declare function proveCurrentDeployment(stateDir: string, spec: LaunchSpec, authorization: RestartAuthorization, now?: number): RestartEvidenceResult;
24
+ //# sourceMappingURL=deployment-proof.d.ts.map
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Reusable evidence for a pure, same-launch restart.
3
+ *
4
+ * A green credential is deliberately short lived: it authorizes one stop
5
+ * close to the observed build/test run. Once that stop has completed
6
+ * readiness and canary, however, the exact deployed inputs are known-good.
7
+ * This module fingerprints those inputs so a later pure restart can reuse the
8
+ * expensive proof without turning "same git HEAD" into a weak proxy for
9
+ * "same deployment".
10
+ */
11
+ import { createHash } from 'node:crypto';
12
+ import { existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync, } from 'node:fs';
13
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
14
+ import { currentHead, isWorkingTreeClean } from "./git.js";
15
+ import { commandSha256 } from "./launch-spec.js";
16
+ import { loadState, setProvenDeployment, verifyCredential, } from "./state.js";
17
+ const PROFILE_FILES = [
18
+ 'cordis.patch.yml', 'cordis.yml', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml',
19
+ ];
20
+ const HOST_RUNTIME_FILES = [
21
+ 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml',
22
+ 'node_modules/.modules.yaml', 'node_modules/.pnpm-workspace-state-v1.json',
23
+ ];
24
+ const SKIPPED_PACKAGE_DIRS = new Set(['.git', 'coverage', 'node_modules']);
25
+ function hashChunk(hash, kind, logicalPath, value = '') {
26
+ hash.update(kind);
27
+ hash.update('\0');
28
+ hash.update(logicalPath);
29
+ hash.update('\0');
30
+ hash.update(value);
31
+ hash.update('\0');
32
+ }
33
+ /**
34
+ * Hash a runtime tree, following links so a linked package cannot drift
35
+ * outside the fingerprint. Real-directory de-duplication permits legal link
36
+ * cycles without recursion loops. Special/dangling/unreadable nodes fail
37
+ * closed.
38
+ */
39
+ function hashRuntimePath(hash, absolutePath, logicalPath, seenDirectories, packageRoot = false) {
40
+ const info = lstatSync(absolutePath);
41
+ if (info.isSymbolicLink()) {
42
+ const targetText = readlinkSync(absolutePath);
43
+ hashChunk(hash, 'link', logicalPath, targetText);
44
+ let target;
45
+ try {
46
+ target = realpathSync(absolutePath);
47
+ }
48
+ catch (error) {
49
+ throw new Error(`deployment fingerprint refused dangling link ${logicalPath}: ${String(error)}`);
50
+ }
51
+ hashRuntimePath(hash, target, `${logicalPath}/@target`, seenDirectories, packageRoot);
52
+ return;
53
+ }
54
+ if (info.isFile()) {
55
+ hashChunk(hash, 'file', logicalPath, readFileSync(absolutePath));
56
+ return;
57
+ }
58
+ if (!info.isDirectory()) {
59
+ throw new Error(`deployment fingerprint refused special file ${logicalPath}`);
60
+ }
61
+ const realDirectory = realpathSync(absolutePath);
62
+ if (seenDirectories.has(realDirectory)) {
63
+ hashChunk(hash, 'cycle', logicalPath);
64
+ return;
65
+ }
66
+ seenDirectories.add(realDirectory);
67
+ hashChunk(hash, 'dir', logicalPath);
68
+ for (const name of readdirSync(absolutePath).sort((left, right) => Buffer.from(left).compare(Buffer.from(right)))) {
69
+ if (packageRoot && SKIPPED_PACKAGE_DIRS.has(name)) {
70
+ hashChunk(hash, 'excluded-dir', `${logicalPath}/${name}`);
71
+ continue;
72
+ }
73
+ hashRuntimePath(hash, join(absolutePath, name), `${logicalPath}/${name}`, seenDirectories, packageRoot);
74
+ }
75
+ }
76
+ function hashOptionalPath(hash, root, relativePath) {
77
+ const absolutePath = join(root, relativePath);
78
+ if (!existsSync(absolutePath)) {
79
+ hashChunk(hash, 'absent', relativePath);
80
+ return;
81
+ }
82
+ hashRuntimePath(hash, absolutePath, relativePath, new Set());
83
+ }
84
+ function dependencyInstallPath(profileDir, dependency) {
85
+ return join(profileDir, 'node_modules', ...dependency.split('/'));
86
+ }
87
+ function fileDependencyPath(profileDir, specifier) {
88
+ if (!specifier.startsWith('file:'))
89
+ return undefined;
90
+ const pathname = specifier.slice('file:'.length);
91
+ return isAbsolute(pathname) ? pathname : resolve(profileDir, pathname);
92
+ }
93
+ /** Hash profile inputs, installed direct packages, and file: source archives. */
94
+ function profileRuntimeSha256(spec) {
95
+ const profileDir = join(spec.home, 'profiles', spec.profile);
96
+ const manifestPath = join(profileDir, 'package.json');
97
+ if (!existsSync(manifestPath))
98
+ throw new Error(`deployment fingerprint requires ${manifestPath}`);
99
+ let manifest;
100
+ try {
101
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
102
+ }
103
+ catch (error) {
104
+ throw new Error(`deployment fingerprint could not parse profile package.json: ${String(error)}`);
105
+ }
106
+ const hash = createHash('sha256');
107
+ for (const file of PROFILE_FILES)
108
+ hashOptionalPath(hash, profileDir, file);
109
+ const dependencies = Object.entries(manifest.dependencies ?? {})
110
+ .sort(([left], [right]) => Buffer.from(left).compare(Buffer.from(right)));
111
+ for (const [name, source] of dependencies) {
112
+ const installed = dependencyInstallPath(profileDir, name);
113
+ if (!existsSync(installed))
114
+ throw new Error(`deployment fingerprint requires installed profile dependency ${name}`);
115
+ hashRuntimePath(hash, installed, `installed/${name}`, new Set(), true);
116
+ const archive = fileDependencyPath(profileDir, source);
117
+ if (archive !== undefined) {
118
+ if (!existsSync(archive))
119
+ throw new Error(`deployment fingerprint requires file dependency archive for ${name}`);
120
+ hashRuntimePath(hash, archive, `source-archive/${name}`, new Set());
121
+ }
122
+ }
123
+ return hash.digest('hex');
124
+ }
125
+ /**
126
+ * Hash the host install metadata and any explicitly bound built execution
127
+ * surface. Source surfaces are content-bound by the clean harness git tree.
128
+ */
129
+ function hostRuntimeSha256(spec) {
130
+ const hash = createHash('sha256');
131
+ for (const file of HOST_RUNTIME_FILES)
132
+ hashOptionalPath(hash, spec.harnessRoot, file);
133
+ if (spec.preflight !== undefined) {
134
+ hashRuntimePath(hash, spec.preflight.runnerPath, 'preflight/runner', new Set());
135
+ hashRuntimePath(hash, spec.preflight.installAnchor, 'preflight/install-anchor', new Set());
136
+ if (spec.preflight.surface === 'built') {
137
+ hashRuntimePath(hash, dirname(spec.preflight.installAnchor), 'preflight/built-host-package', new Set(), true);
138
+ }
139
+ }
140
+ return hash.digest('hex');
141
+ }
142
+ function cleanRevision(root, role) {
143
+ const revision = currentHead(root);
144
+ if (revision === null)
145
+ throw new Error(`deployment fingerprint requires ${role} to be a git repository`);
146
+ if (!isWorkingTreeClean(root))
147
+ throw new Error(`deployment fingerprint requires a clean ${role}`);
148
+ return revision;
149
+ }
150
+ export function captureDeploymentFingerprint(spec) {
151
+ if (spec.preflight === undefined) {
152
+ throw new Error('deployment fingerprint requires an explicit source/built preflight execution binding');
153
+ }
154
+ if (spec.preflight.targetCommandSha256 !== commandSha256(spec.command)) {
155
+ throw new Error('deployment fingerprint refused a preflight binding for a different launch command');
156
+ }
157
+ if (fileSha256(spec.preflight.runnerPath) !== spec.preflight.runnerSha256) {
158
+ throw new Error('deployment fingerprint refused a changed preflight runner');
159
+ }
160
+ if (fileSha256(spec.preflight.installAnchor) !== spec.preflight.installAnchorSha256) {
161
+ throw new Error('deployment fingerprint refused a changed preflight install anchor');
162
+ }
163
+ const credentialRevision = cleanRevision(spec.credentialRepo, 'credential repository');
164
+ const harnessRevision = cleanRevision(spec.harnessRoot, 'harness root');
165
+ return {
166
+ version: 1,
167
+ credentialRevision,
168
+ harnessRevision,
169
+ launchSpecSha256: commandSha256(JSON.stringify(spec)),
170
+ profileSha256: profileRuntimeSha256(spec),
171
+ hostRuntimeSha256: hostRuntimeSha256(spec),
172
+ };
173
+ }
174
+ export function deploymentFingerprintSha256(fingerprint) {
175
+ return commandSha256(JSON.stringify(fingerprint));
176
+ }
177
+ function credentialCommandSha256(credential) {
178
+ return commandSha256(credential.command);
179
+ }
180
+ function fileSha256(path) {
181
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
182
+ }
183
+ function proofCredentialMatches(proof, credential) {
184
+ return credential !== undefined
185
+ && credential.revision === proof.credential.revision
186
+ && credential.recordedAt === proof.credential.recordedAt
187
+ && credential.scope === proof.credential.scope
188
+ && credentialCommandSha256(credential) === proof.credential.commandSha256;
189
+ }
190
+ export function verifyProvenDeployment(stateDir, spec) {
191
+ const state = loadState(stateDir);
192
+ const proof = state.provenDeployment;
193
+ if (proof === undefined)
194
+ return { ok: false, reason: 'no proven deployment recorded by a completed restart canary' };
195
+ if (!proofCredentialMatches(proof, state.credential)) {
196
+ return { ok: false, reason: 'proven deployment no longer matches the recorded build/test credential' };
197
+ }
198
+ let current;
199
+ try {
200
+ current = captureDeploymentFingerprint(spec);
201
+ }
202
+ catch (error) {
203
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
204
+ }
205
+ const currentSha256 = deploymentFingerprintSha256(current);
206
+ if (currentSha256 !== proof.fingerprintSha256 || JSON.stringify(current) !== JSON.stringify(proof.fingerprint)) {
207
+ return {
208
+ ok: false,
209
+ reason: `proven deployment fingerprint changed (${proof.fingerprintSha256.slice(0, 16)} -> ${currentSha256.slice(0, 16)}) — rebuild, test, and record again`,
210
+ };
211
+ }
212
+ return {
213
+ ok: true,
214
+ reason: `proven deployment valid (${proof.credential.scope} @ ${proof.credential.revision}, fingerprint ${proof.fingerprintSha256.slice(0, 16)})`,
215
+ authorization: {
216
+ version: 1,
217
+ kind: 'proven-deployment',
218
+ revision: proof.credential.revision,
219
+ evidenceSha256: proof.fingerprintSha256,
220
+ },
221
+ };
222
+ }
223
+ /** Fresh evidence wins; an exact prior deployment is the same-launch fallback. */
224
+ export function verifyRestartEvidence(stateDir, spec, maxAgeMinutes, now = Date.now()) {
225
+ const state = loadState(stateDir);
226
+ const credential = state.credential;
227
+ const fresh = verifyCredential(state, currentHead(spec.credentialRepo), now, maxAgeMinutes, isWorkingTreeClean(spec.credentialRepo));
228
+ if (fresh.ok && credential !== undefined) {
229
+ return {
230
+ ...fresh,
231
+ authorization: {
232
+ version: 1,
233
+ kind: 'fresh-credential',
234
+ revision: credential.revision,
235
+ evidenceSha256: credentialCommandSha256(credential),
236
+ },
237
+ };
238
+ }
239
+ const proven = verifyProvenDeployment(stateDir, spec);
240
+ if (proven.ok)
241
+ return proven;
242
+ return { ok: false, reason: `${fresh.reason}; pure-restart reuse unavailable: ${proven.reason}` };
243
+ }
244
+ /** Revalidate the exact evidence selected before the scheduled stop. */
245
+ export function verifyRestartAuthorization(stateDir, spec, authorization) {
246
+ const state = loadState(stateDir);
247
+ if (authorization.kind === 'proven-deployment') {
248
+ const proven = verifyProvenDeployment(stateDir, spec);
249
+ if (!proven.ok || proven.authorization?.evidenceSha256 !== authorization.evidenceSha256) {
250
+ return { ok: false, reason: `scheduled proven-deployment authorization changed: ${proven.reason}` };
251
+ }
252
+ return proven;
253
+ }
254
+ const credential = state.credential;
255
+ if (credential === undefined
256
+ || credential.revision !== authorization.revision
257
+ || credentialCommandSha256(credential) !== authorization.evidenceSha256) {
258
+ return { ok: false, reason: 'scheduled credential authorization no longer matches durable state' };
259
+ }
260
+ if (currentHead(spec.credentialRepo) !== credential.revision || !isWorkingTreeClean(spec.credentialRepo)) {
261
+ return { ok: false, reason: 'scheduled credential authorization no longer matches a clean repository HEAD' };
262
+ }
263
+ return { ok: true, reason: `scheduled fresh credential still matches ${credential.revision}`, authorization };
264
+ }
265
+ /**
266
+ * Promote a fresh credential after the boot's readiness/canary boundary. A
267
+ * proof-authorized pure restart retains the existing proof unchanged.
268
+ */
269
+ export function proveCurrentDeployment(stateDir, spec, authorization, now = Date.now()) {
270
+ const authorized = verifyRestartAuthorization(stateDir, spec, authorization);
271
+ if (!authorized.ok)
272
+ return authorized;
273
+ if (authorization.kind === 'proven-deployment') {
274
+ return { ...authorized, reason: `${authorized.reason}; existing deployment proof retained` };
275
+ }
276
+ const state = loadState(stateDir);
277
+ const credential = state.credential;
278
+ if (credential === undefined)
279
+ return { ok: false, reason: 'credential disappeared before deployment proof was recorded' };
280
+ let fingerprint;
281
+ try {
282
+ fingerprint = captureDeploymentFingerprint(spec);
283
+ }
284
+ catch (error) {
285
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
286
+ }
287
+ if (fingerprint.credentialRevision !== credential.revision) {
288
+ return { ok: false, reason: 'deployment fingerprint revision differs from the credential revision' };
289
+ }
290
+ const fingerprintSha256 = deploymentFingerprintSha256(fingerprint);
291
+ const proof = {
292
+ version: 1,
293
+ provenAt: now,
294
+ credential: {
295
+ revision: credential.revision,
296
+ recordedAt: credential.recordedAt,
297
+ scope: credential.scope,
298
+ commandSha256: credentialCommandSha256(credential),
299
+ },
300
+ fingerprint,
301
+ fingerprintSha256,
302
+ };
303
+ setProvenDeployment(stateDir, proof, now);
304
+ return {
305
+ ok: true,
306
+ reason: `recorded proven deployment ${fingerprintSha256.slice(0, 16)} for ${credential.revision}`,
307
+ authorization: {
308
+ version: 1,
309
+ kind: 'proven-deployment',
310
+ revision: credential.revision,
311
+ evidenceSha256: fingerprintSha256,
312
+ },
313
+ };
314
+ }
@@ -15,6 +15,7 @@
15
15
  import { writeFileSync } from 'node:fs';
16
16
  import { isDirectInvocation } from "./defaults.js";
17
17
  import { findPidOnPort } from "./processes.js";
18
+ import { registerCurrentTestProcess } from "./test-seam.js";
18
19
  /**
19
20
  * One exit attempt: SIGTERM the listener on {@link port} and record the
20
21
  * outcome. Extracted from the timer so tests can drive it directly.
@@ -49,5 +50,6 @@ export function exitAgentMain(env = process.env) {
49
50
  }
50
51
  // Direct invocation only: imported (tests, bundling) it must never fire.
51
52
  if (isDirectInvocation(import.meta.url)) {
53
+ registerCurrentTestProcess();
52
54
  exitAgentMain();
53
55
  }
@@ -5,18 +5,27 @@
5
5
  * @returns the full HEAD sha, or null.
6
6
  */
7
7
  export declare function currentHead(repoDir: string): string | null;
8
+ /**
9
+ * Porcelain entries for every tracked or untracked working-tree change.
10
+ * `null` means git could not inspect the checkout; callers must fail closed.
11
+ */
12
+ export declare function workingTreeChanges(repoDir: string): string[] | null;
13
+ /** Whether the checkout has no staged, unstaged, or untracked changes. */
14
+ export declare function isWorkingTreeClean(repoDir: string): boolean;
8
15
  /** Result of a checkpoint commit. */
9
16
  export type CheckpointCommitResult = {
10
17
  ok: true;
11
18
  sha: string;
12
19
  artifacts: string[];
20
+ createdCommit: boolean;
13
21
  } | {
14
22
  ok: false;
15
23
  error: string;
16
24
  };
17
25
  /**
18
- * Commit the whole working tree as a checkpoint snapshot (empty commits
19
- * allowed — a clean tree still records a rollback point).
26
+ * Record a clean HEAD directly, or commit the whole dirty working tree as an
27
+ * explicitly approved checkpoint snapshot. Dirty commits use a temporary git
28
+ * index: a hook/commit failure leaves the caller's real index untouched.
20
29
  * @param repoDir - repository directory.
21
30
  * @param message - checkpoint commit message.
22
31
  * @param artifactPattern - staged paths matching this are reported as
@@ -24,7 +33,7 @@ export type CheckpointCommitResult = {
24
33
  * SRC_ARTIFACT_PATTERN in defaults.ts; omit for none).
25
34
  * @returns the new HEAD sha, or a failure reason.
26
35
  */
27
- export declare function commitCheckpoint(repoDir: string, message: string, artifactPattern?: RegExp): CheckpointCommitResult;
36
+ export declare function commitCheckpoint(repoDir: string, message: string, artifactPattern?: RegExp, includeDirty?: boolean): CheckpointCommitResult;
28
37
  /**
29
38
  * Roll the checkout back to a checkpoint commit WITHOUT losing work: the
30
39
  * discarded HEAD becomes a `guard-backup-*` branch, and uncommitted tracked