@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,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 non-blocking connection notice.
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,348 @@
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 connection notice; transient blips never show it. */
10
+ const DISCONNECTED_NOTICE_MS = 5_000;
11
+ // Server idle polls hold for 25s; bound a stalled browser request beyond that.
12
+ const REQUEST_TIMEOUT_MS = 35_000;
13
+ /** No Cordis services are needed; this is a same-origin browser lifecycle observer. */
14
+ export const inject = [];
15
+ function encodeBase64Url(bytes) {
16
+ let binary = '';
17
+ for (const byte of bytes)
18
+ binary += String.fromCharCode(byte);
19
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '');
20
+ }
21
+ function freshCapability() {
22
+ const bytes = new Uint8Array(32);
23
+ crypto.getRandomValues(bytes);
24
+ return encodeBase64Url(bytes);
25
+ }
26
+ function capability() {
27
+ const existing = sessionStorage.getItem(CAPABILITY_KEY);
28
+ if (existing !== null && /^[A-Za-z0-9_-]{43}$/.test(existing))
29
+ return existing;
30
+ const created = freshCapability();
31
+ sessionStorage.setItem(CAPABILITY_KEY, created);
32
+ return created;
33
+ }
34
+ function readPending() {
35
+ try {
36
+ const value = JSON.parse(sessionStorage.getItem(PENDING_KEY) ?? 'null');
37
+ if (value?.version !== 1 || typeof value.cutoverId !== 'string'
38
+ || (value.channel !== 'original-tab' && value.channel !== 'fallback-tab')
39
+ || (value.authentication !== 'existing-cookie' && value.authentication !== 'launch-url'))
40
+ return null;
41
+ if (value.channel === 'original-tab' && typeof value.capability !== 'string')
42
+ return null;
43
+ if (value.returnPath !== undefined && !isSafeReturnPath(value.returnPath))
44
+ return null;
45
+ return value;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ /** Deliberately discard query and fragment because either may hold credentials. */
52
+ export function safeReturnPath(href, origin) {
53
+ try {
54
+ const url = new URL(href);
55
+ if (url.origin !== origin || url.pathname === '' || url.pathname.startsWith('//')
56
+ || url.pathname.includes('\\'))
57
+ return undefined;
58
+ return url.pathname;
59
+ }
60
+ catch {
61
+ return undefined;
62
+ }
63
+ }
64
+ function isSafeReturnPath(value) {
65
+ return typeof value === 'string' && value.startsWith('/') && !value.startsWith('//')
66
+ && !value.includes('\\') && !value.includes('?') && !value.includes('#');
67
+ }
68
+ function writePending(value) {
69
+ // Deliberately excludes launchUrl: bearer URLs never enter browser storage.
70
+ sessionStorage.setItem(PENDING_KEY, JSON.stringify(value));
71
+ }
72
+ export function fallbackCutoverId(hash) {
73
+ const params = new URLSearchParams(hash.startsWith('#') ? hash.slice(1) : hash);
74
+ const id = params.get(FALLBACK_HASH_KEY);
75
+ return id !== null && /^[A-Za-z0-9._-]{1,160}$/.test(id) ? id : null;
76
+ }
77
+ function consumeFallbackFragment() {
78
+ const cutoverId = fallbackCutoverId(location.hash);
79
+ if (cutoverId === null)
80
+ return null;
81
+ const url = new URL(location.href);
82
+ url.hash = '';
83
+ history.replaceState(history.state, '', url.href);
84
+ const pending = {
85
+ version: 1,
86
+ cutoverId,
87
+ channel: 'fallback-tab',
88
+ authentication: 'launch-url',
89
+ };
90
+ writePending(pending);
91
+ return pending;
92
+ }
93
+ function waitingOverlay(kind = 'restarting', retry) {
94
+ const existing = document.getElementById('ankh-guard-browser-handoff');
95
+ // A confirmed restart cannot be downgraded by a subsequent transport failure.
96
+ if (existing?.dataset.kind === 'restarting' && kind === 'disconnected')
97
+ return existing;
98
+ const element = existing ?? document.createElement('div');
99
+ const zh = navigator.language.toLowerCase().startsWith('zh');
100
+ element.id = 'ankh-guard-browser-handoff';
101
+ element.dataset.kind = kind;
102
+ element.setAttribute('role', 'status');
103
+ element.setAttribute('aria-live', 'polite');
104
+ element.textContent = kind === 'disconnected'
105
+ ? (zh ? '连接暂时中断,正在重试…' : 'Connection interrupted; retrying…')
106
+ : (zh ? 'dsh 正在重启;此标签页会自动恢复。' : 'dsh is restarting; this tab will recover automatically.');
107
+ element.style.cssText = kind === 'restarting' ? [
108
+ 'position:fixed', 'inset:0', 'z-index:2147483647', 'display:grid', 'place-items:center',
109
+ 'padding:24px', 'background:rgba(15,18,24,.92)', 'color:#fff',
110
+ 'font:500 16px/1.5 system-ui,sans-serif', 'text-align:center',
111
+ ].join(';') : [
112
+ 'position:fixed', 'top:calc(env(safe-area-inset-top,0px) + 12px)', 'left:12px', 'right:12px',
113
+ 'max-width:520px', 'margin:0 auto', 'box-sizing:border-box', 'z-index:2147483647',
114
+ 'display:flex', 'align-items:center', 'justify-content:space-between', 'gap:12px',
115
+ 'padding:12px 16px', 'border:1px solid var(--dsw-alias-border-l2,#8885)', 'border-radius:16px',
116
+ 'background:var(--dsw-alias-bg-base,#fff)', 'color:var(--dsw-alias-label-primary,#181a20)',
117
+ 'box-shadow:0 4px 24px #0002', 'font:400 15px/1.5 system-ui,sans-serif',
118
+ ].join(';');
119
+ if (kind === 'disconnected' && retry) {
120
+ const button = document.createElement('button');
121
+ button.textContent = zh ? '重试' : 'Retry';
122
+ button.style.cssText = 'flex:none;border:0;background:transparent;color:var(--dsw-alias-state-business-primary,#4d6bfe);font:inherit;min-height:36px;cursor:pointer';
123
+ button.onclick = retry;
124
+ element.append(button);
125
+ }
126
+ // Official theme tokens live on body, so the notice must inherit from it.
127
+ if (!existing)
128
+ (document.body ?? document.documentElement).append(element);
129
+ return element;
130
+ }
131
+ async function post(body, signal) {
132
+ return fetch(ROUTE, {
133
+ method: 'POST',
134
+ signal,
135
+ credentials: 'same-origin',
136
+ cache: 'no-store',
137
+ headers: { 'content-type': 'application/json' },
138
+ body: JSON.stringify(body),
139
+ });
140
+ }
141
+ async function acknowledge(pending, signal) {
142
+ const response = await post({
143
+ version: 1,
144
+ operation: 'ack',
145
+ cutoverId: pending.cutoverId,
146
+ channel: pending.channel,
147
+ authentication: pending.authentication,
148
+ ...(pending.capability !== undefined ? { capability: pending.capability } : {}),
149
+ }, signal);
150
+ return response.status === 204;
151
+ }
152
+ /** Install the polling/reload loop for this tab's lifetime. */
153
+ export function apply(_ctx) {
154
+ let disposed = false;
155
+ let timer;
156
+ let pending = consumeFallbackFragment() ?? readPending();
157
+ let cap;
158
+ let errorRetryMs = ERROR_RETRY_MIN_MS;
159
+ let firstFailureAt;
160
+ let request;
161
+ let lastWakeAt = -Infinity;
162
+ const foreground = () => document.visibilityState !== 'hidden' && navigator.onLine !== false;
163
+ const clearConnectionNotice = () => {
164
+ const notice = document.getElementById('ankh-guard-browser-handoff');
165
+ if (notice?.dataset.kind === 'disconnected')
166
+ notice.remove();
167
+ };
168
+ const pause = () => {
169
+ if (timer !== undefined)
170
+ clearTimeout(timer);
171
+ timer = undefined;
172
+ const previous = request;
173
+ request = undefined;
174
+ previous?.abort();
175
+ firstFailureAt = undefined;
176
+ errorRetryMs = ERROR_RETRY_MIN_MS;
177
+ clearConnectionNotice();
178
+ };
179
+ const wake = () => {
180
+ if (disposed || !foreground() || Date.now() - lastWakeAt < 500)
181
+ return;
182
+ lastWakeAt = Date.now();
183
+ pause();
184
+ schedule();
185
+ };
186
+ const visibility = () => {
187
+ if (!foreground()) {
188
+ lastWakeAt = -Infinity;
189
+ pause();
190
+ }
191
+ else
192
+ wake();
193
+ };
194
+ const page = (event) => { if (event.persisted)
195
+ wake(); };
196
+ // Only visible failures count. Ordinary outages never block the page.
197
+ const noteDisconnected = () => {
198
+ firstFailureAt ??= Date.now();
199
+ if (Date.now() - firstFailureAt >= DISCONNECTED_NOTICE_MS)
200
+ waitingOverlay('disconnected', wake);
201
+ };
202
+ const schedule = (delayMs = 0) => {
203
+ if (timer !== undefined)
204
+ clearTimeout(timer);
205
+ if (!disposed && foreground())
206
+ timer = setTimeout(() => { timer = undefined; void tick(); }, delayMs);
207
+ };
208
+ const tick = async () => {
209
+ if (disposed || !foreground() || request)
210
+ return;
211
+ const current = new AbortController();
212
+ request = current;
213
+ const stale = () => disposed || request !== current || !foreground();
214
+ const deadline = setTimeout(() => current.abort(), REQUEST_TIMEOUT_MS);
215
+ try {
216
+ if (pending !== null) {
217
+ waitingOverlay();
218
+ const acknowledged = await acknowledge(pending, current.signal);
219
+ if (stale())
220
+ return;
221
+ if (acknowledged) {
222
+ const returnPath = pending.returnPath;
223
+ const originalTab = pending.channel === 'original-tab';
224
+ sessionStorage.removeItem(PENDING_KEY);
225
+ sessionStorage.removeItem(CAPABILITY_KEY);
226
+ document.getElementById('ankh-guard-browser-handoff')?.remove();
227
+ pending = null;
228
+ if (originalTab && returnPath !== undefined
229
+ && safeReturnPath(location.href, location.origin) !== returnPath) {
230
+ location.replace(returnPath);
231
+ return;
232
+ }
233
+ }
234
+ errorRetryMs = ERROR_RETRY_MIN_MS;
235
+ firstFailureAt = undefined;
236
+ schedule(ACTIVE_RETRY_MS);
237
+ return;
238
+ }
239
+ const tabCapability = cap ??= capability();
240
+ const knownBootId = sessionStorage.getItem(BOOT_ID_KEY);
241
+ const response = await post({
242
+ version: 1, operation: 'poll', capability: tabCapability,
243
+ ...(knownBootId === null ? {} : { knownBootId }),
244
+ }, current.signal);
245
+ if (stale())
246
+ return;
247
+ if (!response.ok) {
248
+ noteDisconnected();
249
+ schedule(errorRetryMs);
250
+ errorRetryMs = Math.min(errorRetryMs * 2, ERROR_RETRY_MAX_MS);
251
+ return;
252
+ }
253
+ errorRetryMs = ERROR_RETRY_MIN_MS;
254
+ firstFailureAt = undefined;
255
+ const result = await response.json();
256
+ if (stale())
257
+ return;
258
+ // Learn the serving boot id before acting on it — storing the successor's
259
+ // id ahead of the reload is what makes the generation check one-shot.
260
+ if (typeof result.bootId === 'string')
261
+ sessionStorage.setItem(BOOT_ID_KEY, result.bootId);
262
+ if (result.state === 'idle') {
263
+ document.getElementById('ankh-guard-browser-handoff')?.remove();
264
+ // The server holds idle polls, providing event-like wakeup without a
265
+ // permanent fixed-frequency request loop.
266
+ schedule();
267
+ return;
268
+ }
269
+ if (result.state === 'waiting')
270
+ waitingOverlay();
271
+ // Boot-generation reload (no cutoverId): the process this tab knew is
272
+ // gone. No registration/ack dance — the stored id already advanced.
273
+ if (result.state === 'ready' && result.action === 'reload' && typeof result.cutoverId !== 'string') {
274
+ location.reload();
275
+ return;
276
+ }
277
+ if (result.state !== 'ready' || typeof result.cutoverId !== 'string') {
278
+ schedule(ACTIVE_RETRY_MS);
279
+ return;
280
+ }
281
+ if (result.action === 'reload' && result.authentication === 'existing-cookie') {
282
+ const returnPath = safeReturnPath(location.href, location.origin);
283
+ const nextPending = {
284
+ version: 1, cutoverId: result.cutoverId, channel: 'original-tab',
285
+ authentication: 'existing-cookie', capability: tabCapability,
286
+ ...(returnPath === undefined ? {} : { returnPath }),
287
+ };
288
+ pending = nextPending;
289
+ writePending(nextPending);
290
+ waitingOverlay();
291
+ location.reload();
292
+ return;
293
+ }
294
+ if (result.action === 'replace' && result.authentication === 'launch-url'
295
+ && typeof result.launchUrl === 'string') {
296
+ const launch = new URL(result.launchUrl);
297
+ if (launch.origin !== location.origin || launch.pathname !== '/' || launch.search === ''
298
+ || launch.username !== '' || launch.password !== '' || launch.hash !== '') {
299
+ schedule(ACTIVE_RETRY_MS);
300
+ return;
301
+ }
302
+ const returnPath = safeReturnPath(location.href, location.origin);
303
+ const nextPending = {
304
+ version: 1, cutoverId: result.cutoverId, channel: 'original-tab',
305
+ authentication: 'launch-url', capability: tabCapability,
306
+ ...(returnPath === undefined ? {} : { returnPath }),
307
+ };
308
+ pending = nextPending;
309
+ writePending(nextPending);
310
+ waitingOverlay();
311
+ // The bearer remains a local variable for the shortest possible time.
312
+ location.replace(launch.href);
313
+ return;
314
+ }
315
+ }
316
+ catch {
317
+ if (stale())
318
+ return;
319
+ // The expected restart interval rejects fetches; keep the old page and retry.
320
+ noteDisconnected();
321
+ schedule(errorRetryMs);
322
+ errorRetryMs = Math.min(errorRetryMs * 2, ERROR_RETRY_MAX_MS);
323
+ return;
324
+ }
325
+ finally {
326
+ clearTimeout(deadline);
327
+ if (request === current)
328
+ request = undefined;
329
+ }
330
+ schedule(ACTIVE_RETRY_MS);
331
+ };
332
+ document.addEventListener('visibilitychange', visibility);
333
+ window.addEventListener('online', wake);
334
+ window.addEventListener('offline', visibility);
335
+ window.addEventListener('pageshow', page);
336
+ window.addEventListener('dsh-mobile-foreground', wake);
337
+ void tick();
338
+ return () => {
339
+ disposed = true;
340
+ pause();
341
+ document.removeEventListener('visibilitychange', visibility);
342
+ window.removeEventListener('online', wake);
343
+ window.removeEventListener('offline', visibility);
344
+ window.removeEventListener('pageshow', page);
345
+ window.removeEventListener('dsh-mobile-foreground', wake);
346
+ document.getElementById('ankh-guard-browser-handoff')?.remove();
347
+ };
348
+ }
@@ -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