@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.
- package/CHANGELOG.md +28 -0
- package/README.en.md +75 -29
- package/README.i18n.yaml +2 -2
- package/README.md +74 -29
- package/lib/cli.js +2383 -209
- package/lib/client.js +257 -0
- package/lib/exit-agent.js +5 -2
- package/lib/index.js +722 -38
- package/lib/invariant.js +1 -1
- package/lib/preflight-runner.js +125 -47
- package/lib/processes-BjZgJjQr.js +344 -0
- package/lib/restart-context-D6nISh28.js +1245 -0
- package/lib/restart-context-DUyExi9O.js +1245 -0
- package/lib/{state-Dhx9VG44.js → state-4f7yny39.js} +60 -13
- package/lib/state-CZMypGkB.js +323 -0
- package/lib/test-seam-DnvLWTeO.js +119 -0
- package/lib/test-seam-cli.js +24 -0
- package/lib/test-seam-dwvaKjRp.js +459 -0
- package/lib/test-seam.js +2 -0
- package/lib/types/browser-handoff.d.ts +55 -0
- package/lib/types/browser-handoff.js +489 -0
- package/lib/types/cli.d.ts +34 -4
- package/lib/types/cli.js +1487 -225
- package/lib/types/client/index.d.ts +15 -0
- package/lib/types/client/index.js +264 -0
- package/lib/types/deployment-proof.d.ts +24 -0
- package/lib/types/deployment-proof.js +314 -0
- package/lib/types/exit-agent.js +2 -0
- package/lib/types/git.d.ts +12 -3
- package/lib/types/git.js +69 -7
- package/lib/types/index.d.ts +66 -3
- package/lib/types/index.js +157 -39
- package/lib/types/launch-spec.d.ts +263 -0
- package/lib/types/launch-spec.js +823 -0
- package/lib/types/preflight-runner.d.ts +23 -12
- package/lib/types/preflight-runner.js +152 -57
- package/lib/types/processes.d.ts +38 -6
- package/lib/types/processes.js +236 -10
- package/lib/types/restart-context.d.ts +50 -0
- package/lib/types/restart-context.js +106 -0
- package/lib/types/restart-request.d.ts +32 -0
- package/lib/types/restart-request.js +128 -0
- package/lib/types/state-files.d.ts +30 -0
- package/lib/types/state-files.js +55 -0
- package/lib/types/state.d.ts +29 -2
- package/lib/types/state.js +52 -7
- package/lib/types/temp-artifact.d.ts +15 -0
- package/lib/types/temp-artifact.js +17 -0
- package/lib/types/test-seam-cli.d.ts +3 -0
- package/lib/types/test-seam-cli.js +27 -0
- package/lib/types/test-seam.d.ts +55 -0
- package/lib/types/test-seam.js +112 -0
- package/lib/types/transition.d.ts +118 -0
- package/lib/types/transition.js +717 -0
- package/package.json +29 -9
- package/scripts/dsh-watchdog.sh +1388 -80
- package/scripts/install-launchd.sh +43 -5
- package/scripts/install-systemd.sh +43 -5
- package/scripts/on-install.js +1 -1
- package/skills/dsh-self-restart-guard/SKILL.md +38 -12
- package/lib/processes-hCAmwma-.js +0 -127
- package/lib/restart-context-DmnQXNf-.js +0 -421
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser handoff bridge for launch cutovers.
|
|
3
|
+
*
|
|
4
|
+
* Old authenticated pages register random per-tab capabilities while the
|
|
5
|
+
* previous process is still the proven listener. Only SHA-256 digests are
|
|
6
|
+
* durable. After the successor proves one stable listener and passes canary,
|
|
7
|
+
* every registered tab may ask the final process for either a plain reload
|
|
8
|
+
* (its cookie is still valid) or that process's authenticated URL. The URL
|
|
9
|
+
* exists only in the response object and the browser's location.replace call:
|
|
10
|
+
* it never enters a state file, receipt, or log.
|
|
11
|
+
*
|
|
12
|
+
* Alongside the cutover channel, a boot-generation channel covers ordinary
|
|
13
|
+
* restarts (unsupervised `restart`/`schedule-exit`, watchdog crash respawns):
|
|
14
|
+
* the serving process's own pid + start token is its boot id, a polling tab
|
|
15
|
+
* carries the boot id it last saw, and a stale id with no active cutover
|
|
16
|
+
* means the process it knew is gone — answer ready/reload. A cutover receipt
|
|
17
|
+
* always wins over the generation check: during a cutover the old tab's known
|
|
18
|
+
* id is already stale, and the receipt protocol owns the pacing.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
21
|
+
import { chmodSync, linkSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { dirname } from 'node:path';
|
|
23
|
+
import { activeCutover, readCutoverReceipt } from "./launch-spec.js";
|
|
24
|
+
import { processIdentity, processIdentityMatches } from "./processes.js";
|
|
25
|
+
import { stateFile } from "./state-files.js";
|
|
26
|
+
export const BROWSER_HANDOFF_ROUTE = '/_ankh-guard/browser-handoff';
|
|
27
|
+
const CAPABILITY_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
28
|
+
const MAX_BODY_BYTES = 4096;
|
|
29
|
+
const MAX_REGISTRATIONS = 64;
|
|
30
|
+
const DEFAULT_LONG_POLL_MS = 25_000;
|
|
31
|
+
const DEFAULT_LONG_POLL_INTERVAL_MS = 200;
|
|
32
|
+
function isObject(value) {
|
|
33
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
function isCapability(value) {
|
|
36
|
+
return typeof value === 'string' && CAPABILITY_PATTERN.test(value);
|
|
37
|
+
}
|
|
38
|
+
function capabilitySha256(capability) {
|
|
39
|
+
return createHash('sha256').update(capability).digest('hex');
|
|
40
|
+
}
|
|
41
|
+
function parseMessage(value) {
|
|
42
|
+
if (!isObject(value) || value.version !== 1)
|
|
43
|
+
return null;
|
|
44
|
+
if (value.operation === 'poll' && isCapability(value.capability)) {
|
|
45
|
+
// Opaque to the server; length-capped so a poll cannot carry bulk data.
|
|
46
|
+
const knownBootId = typeof value.knownBootId === 'string' && value.knownBootId.length <= 256
|
|
47
|
+
? value.knownBootId
|
|
48
|
+
: undefined;
|
|
49
|
+
return { version: 1, operation: 'poll', capability: value.capability, ...(knownBootId === undefined ? {} : { knownBootId }) };
|
|
50
|
+
}
|
|
51
|
+
if (value.operation !== 'ack' || typeof value.cutoverId !== 'string' || value.cutoverId === '')
|
|
52
|
+
return null;
|
|
53
|
+
if (value.channel !== 'original-tab' && value.channel !== 'fallback-tab')
|
|
54
|
+
return null;
|
|
55
|
+
if (value.authentication !== 'existing-cookie' && value.authentication !== 'launch-url')
|
|
56
|
+
return null;
|
|
57
|
+
if (value.channel === 'original-tab' && !isCapability(value.capability))
|
|
58
|
+
return null;
|
|
59
|
+
return {
|
|
60
|
+
version: 1,
|
|
61
|
+
operation: 'ack',
|
|
62
|
+
cutoverId: value.cutoverId,
|
|
63
|
+
channel: value.channel,
|
|
64
|
+
authentication: value.authentication,
|
|
65
|
+
...(typeof value.capability === 'string' ? { capability: value.capability } : {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function readJson(file) {
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function createJsonOnce(file, value) {
|
|
77
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
78
|
+
const tmp = `${file}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
|
|
79
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
80
|
+
try {
|
|
81
|
+
// Publishing a complete hard link is both atomic for readers and
|
|
82
|
+
// first-writer-wins across competing tabs/processes.
|
|
83
|
+
linkSync(tmp, file);
|
|
84
|
+
try {
|
|
85
|
+
chmodSync(file, 0o600);
|
|
86
|
+
}
|
|
87
|
+
catch { /* best effort on non-POSIX filesystems */ }
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (error.code === 'EEXIST')
|
|
92
|
+
return false;
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
unlinkSync(tmp);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function writeJsonAtomic(file, value) {
|
|
100
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
101
|
+
const tmp = `${file}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
|
|
102
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
103
|
+
try {
|
|
104
|
+
renameSync(tmp, file);
|
|
105
|
+
try {
|
|
106
|
+
chmodSync(file, 0o600);
|
|
107
|
+
}
|
|
108
|
+
catch { /* best effort on non-POSIX filesystems */ }
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
try {
|
|
112
|
+
unlinkSync(tmp);
|
|
113
|
+
}
|
|
114
|
+
catch { /* rename already consumed the temporary file */ }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function validRegistration(value) {
|
|
118
|
+
return isObject(value) && typeof value.authority === 'string' && value.authority !== ''
|
|
119
|
+
&& typeof value.capabilitySha256 === 'string' && /^[a-f0-9]{64}$/.test(value.capabilitySha256)
|
|
120
|
+
&& typeof value.armedAt === 'number' && Number.isFinite(value.armedAt)
|
|
121
|
+
&& (value.acknowledgedAt === undefined
|
|
122
|
+
|| (typeof value.acknowledgedAt === 'number' && Number.isFinite(value.acknowledgedAt)));
|
|
123
|
+
}
|
|
124
|
+
export function readBrowserHandoffRequest(stateDir) {
|
|
125
|
+
const request = readJson(stateFile(stateDir, 'browserHandoffRequest'));
|
|
126
|
+
if (request?.version !== 2 || typeof request.cutoverId !== 'string'
|
|
127
|
+
|| !Array.isArray(request.registrations) || request.registrations.length === 0
|
|
128
|
+
|| request.registrations.length > MAX_REGISTRATIONS
|
|
129
|
+
|| !request.registrations.every(validRegistration))
|
|
130
|
+
return null;
|
|
131
|
+
return request;
|
|
132
|
+
}
|
|
133
|
+
function registerBrowserTab(stateDir, cutoverId, registration) {
|
|
134
|
+
const existing = readBrowserHandoffRequest(stateDir);
|
|
135
|
+
const registrations = existing?.cutoverId === cutoverId ? [...existing.registrations] : [];
|
|
136
|
+
if (!registrations.some(item => item.authority === registration.authority
|
|
137
|
+
&& item.capabilitySha256 === registration.capabilitySha256)) {
|
|
138
|
+
if (registrations.length >= MAX_REGISTRATIONS)
|
|
139
|
+
return null;
|
|
140
|
+
registrations.push(registration);
|
|
141
|
+
}
|
|
142
|
+
const request = { version: 2, cutoverId, registrations };
|
|
143
|
+
writeJsonAtomic(stateFile(stateDir, 'browserHandoffRequest'), request);
|
|
144
|
+
return request;
|
|
145
|
+
}
|
|
146
|
+
function registrationMatches(request, cutoverId, authority, digest) {
|
|
147
|
+
return request?.cutoverId === cutoverId && request.registrations.some(item => (item.authority === authority && item.capabilitySha256 === digest));
|
|
148
|
+
}
|
|
149
|
+
function matchingRegistration(request, cutoverId, authority, digest) {
|
|
150
|
+
if (request?.cutoverId !== cutoverId)
|
|
151
|
+
return undefined;
|
|
152
|
+
return request.registrations.find(item => (item.authority === authority && item.capabilitySha256 === digest));
|
|
153
|
+
}
|
|
154
|
+
function acknowledgeBrowserTab(stateDir, cutoverId, authority, digest, acknowledgedAt) {
|
|
155
|
+
const request = readBrowserHandoffRequest(stateDir);
|
|
156
|
+
const registration = matchingRegistration(request, cutoverId, authority, digest);
|
|
157
|
+
if (request === null || registration === undefined)
|
|
158
|
+
return false;
|
|
159
|
+
registration.acknowledgedAt = acknowledgedAt;
|
|
160
|
+
writeJsonAtomic(stateFile(stateDir, 'browserHandoffRequest'), request);
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
export function readBrowserHandoffAcknowledgement(stateDir) {
|
|
164
|
+
const ack = readJson(stateFile(stateDir, 'browserHandoffAck'));
|
|
165
|
+
if (ack?.version !== 1 || typeof ack.cutoverId !== 'string'
|
|
166
|
+
|| (ack.role !== 'target' && ack.role !== 'previous')
|
|
167
|
+
|| typeof ack.authority !== 'string' || ack.authority === ''
|
|
168
|
+
|| !Number.isInteger(ack.listenerPid) || (ack.listenerPid ?? 0) <= 0
|
|
169
|
+
|| typeof ack.listenerStartToken !== 'string' || ack.listenerStartToken === ''
|
|
170
|
+
|| (ack.channel !== 'original-tab' && ack.channel !== 'fallback-tab')
|
|
171
|
+
|| (ack.authentication !== 'existing-cookie' && ack.authentication !== 'launch-url')
|
|
172
|
+
|| typeof ack.acknowledgedAt !== 'number')
|
|
173
|
+
return null;
|
|
174
|
+
return ack;
|
|
175
|
+
}
|
|
176
|
+
function authorityOf(req) {
|
|
177
|
+
const host = req.headers.host;
|
|
178
|
+
const origin = req.headers.origin;
|
|
179
|
+
if (typeof host !== 'string' || typeof origin !== 'string')
|
|
180
|
+
return null;
|
|
181
|
+
try {
|
|
182
|
+
const parsed = new URL(origin);
|
|
183
|
+
if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.host !== host || parsed.origin !== origin)
|
|
184
|
+
return null;
|
|
185
|
+
return { authority: parsed.host, origin: parsed.origin };
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function ownershipMatches(ownership, pid, identityMatches) {
|
|
192
|
+
return ownership !== undefined && ownership.listenerPid === pid
|
|
193
|
+
&& identityMatches({ pid, startToken: ownership.listenerStartToken });
|
|
194
|
+
}
|
|
195
|
+
function json(res, status, body) {
|
|
196
|
+
res.writeHead(status, {
|
|
197
|
+
'cache-control': 'no-store',
|
|
198
|
+
'content-type': 'application/json; charset=utf-8',
|
|
199
|
+
'referrer-policy': 'no-referrer',
|
|
200
|
+
'x-content-type-options': 'nosniff',
|
|
201
|
+
});
|
|
202
|
+
res.end(`${JSON.stringify(body)}\n`);
|
|
203
|
+
}
|
|
204
|
+
async function bodyOf(req) {
|
|
205
|
+
let size = 0;
|
|
206
|
+
const chunks = [];
|
|
207
|
+
for await (const chunk of req) {
|
|
208
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
209
|
+
size += bytes.byteLength;
|
|
210
|
+
if (size > MAX_BODY_BYTES)
|
|
211
|
+
throw new Error('body too large');
|
|
212
|
+
chunks.push(bytes);
|
|
213
|
+
}
|
|
214
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
215
|
+
}
|
|
216
|
+
function currentReadyOwnership(stateDir, pid, identityMatches) {
|
|
217
|
+
const receipt = readCutoverReceipt(stateDir);
|
|
218
|
+
if (receipt === null || receipt.phase === 'awaiting-user' || receipt.readiness?.retryCount !== 0)
|
|
219
|
+
return null;
|
|
220
|
+
const role = receipt.readiness.role;
|
|
221
|
+
const ownership = role === 'target' ? receipt.ownership.target : receipt.ownership.restored;
|
|
222
|
+
const canarySettled = role === 'target'
|
|
223
|
+
? receipt.targetValidation?.canary?.outcome === 'pass'
|
|
224
|
+
: receipt.recovery.validation?.canary !== undefined;
|
|
225
|
+
if (!ownershipMatches(ownership, pid, identityMatches)
|
|
226
|
+
|| !canarySettled
|
|
227
|
+
|| receipt.readiness.listenerPid !== ownership.listenerPid
|
|
228
|
+
|| receipt.readiness.childPid !== ownership.childPid)
|
|
229
|
+
return null;
|
|
230
|
+
return {
|
|
231
|
+
cutoverId: receipt.id,
|
|
232
|
+
role,
|
|
233
|
+
ownership,
|
|
234
|
+
protected: receipt.authentication.launchUrlObserved === true,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
function delay(ms) {
|
|
238
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
239
|
+
}
|
|
240
|
+
async function waitForPollState(ready, waitMs, intervalMs) {
|
|
241
|
+
if (ready() || waitMs <= 0)
|
|
242
|
+
return;
|
|
243
|
+
const deadline = Date.now() + waitMs;
|
|
244
|
+
while (Date.now() < deadline) {
|
|
245
|
+
await delay(Math.min(intervalMs, Math.max(1, deadline - Date.now())));
|
|
246
|
+
if (ready())
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function authenticationState(connection, req) {
|
|
251
|
+
if (connection?.requestRejection === undefined)
|
|
252
|
+
return 'legacy-public';
|
|
253
|
+
const rejection = connection.requestRejection(req);
|
|
254
|
+
if (rejection === undefined)
|
|
255
|
+
return 'authenticated';
|
|
256
|
+
return rejection === 403 ? 'forbidden' : 'unauthenticated';
|
|
257
|
+
}
|
|
258
|
+
/** Create the exact HTTP handler; dependencies are injectable for deterministic protocol tests. */
|
|
259
|
+
export function createBrowserHandoffHandler(dependencies) {
|
|
260
|
+
const pid = dependencies.pid ?? process.pid;
|
|
261
|
+
const now = dependencies.now ?? Date.now;
|
|
262
|
+
const identityMatches = dependencies.identityMatches ?? processIdentityMatches;
|
|
263
|
+
const identityProvider = dependencies.identityProvider ?? processIdentity;
|
|
264
|
+
const longPollMs = dependencies.longPollMs ?? DEFAULT_LONG_POLL_MS;
|
|
265
|
+
const longPollIntervalMs = dependencies.longPollIntervalMs ?? DEFAULT_LONG_POLL_INTERVAL_MS;
|
|
266
|
+
// Cached: a process's boot id never changes over its lifetime, and the
|
|
267
|
+
// platform lookup (ps/python3) must not run per held poll.
|
|
268
|
+
let bootIdCache;
|
|
269
|
+
const thisBootId = () => {
|
|
270
|
+
if (bootIdCache === undefined) {
|
|
271
|
+
const identity = identityProvider(pid);
|
|
272
|
+
bootIdCache = identity === null ? null : `${String(identity.pid)}:${identity.startToken}`;
|
|
273
|
+
}
|
|
274
|
+
return bootIdCache ?? undefined;
|
|
275
|
+
};
|
|
276
|
+
return async (req, res) => {
|
|
277
|
+
const connection = dependencies.connectionProvider?.() ?? dependencies.connection;
|
|
278
|
+
if (req.method !== 'POST' || req.headers['content-type']?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json') {
|
|
279
|
+
json(res, 404, { state: 'unavailable' });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const requestAuthority = authorityOf(req);
|
|
283
|
+
if (requestAuthority === null) {
|
|
284
|
+
json(res, 403, { state: 'forbidden' });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
let message;
|
|
288
|
+
try {
|
|
289
|
+
message = parseMessage(await bodyOf(req));
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
message = null;
|
|
293
|
+
}
|
|
294
|
+
if (message === null) {
|
|
295
|
+
json(res, 400, { state: 'invalid' });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (message.operation === 'poll') {
|
|
299
|
+
const digest = capabilitySha256(message.capability);
|
|
300
|
+
// The serving process's boot id rides every poll response so a tab can
|
|
301
|
+
// learn it. Computed once: a process's identity never changes while it
|
|
302
|
+
// lives. Unavailable identity (ps blocked) disables the generation
|
|
303
|
+
// channel — polls degrade to the pre-generation flow, like an old client.
|
|
304
|
+
const bootId = thisBootId();
|
|
305
|
+
const respond = (status, body) => {
|
|
306
|
+
json(res, status, bootId === undefined ? body : { ...body, bootId });
|
|
307
|
+
};
|
|
308
|
+
// Boot-generation channel: a stale known id with NO active cutover means
|
|
309
|
+
// the process this tab knew is gone (ordinary restart or crash respawn).
|
|
310
|
+
// Short-circuits before the held-poll wait — this process's boot id
|
|
311
|
+
// cannot change under it. During a cutover the receipt protocol owns the
|
|
312
|
+
// pacing: the old tab's known id is already stale, and answering here
|
|
313
|
+
// would race the registration/ack dance.
|
|
314
|
+
if (bootId !== undefined && message.knownBootId !== undefined && message.knownBootId !== bootId
|
|
315
|
+
&& activeCutover(dependencies.stateDir) === null) {
|
|
316
|
+
respond(200, { state: 'ready', action: 'reload', authentication: 'existing-cookie' });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const pollHasState = () => {
|
|
320
|
+
const active = activeCutover(dependencies.stateDir);
|
|
321
|
+
if (active !== null) {
|
|
322
|
+
if (active.receipt.phase === 'awaiting-user'
|
|
323
|
+
|| active.receipt.browserHandoff.status === 'off')
|
|
324
|
+
return true;
|
|
325
|
+
if (ownershipMatches(active.receipt.ownership.previous, pid, identityMatches)) {
|
|
326
|
+
return !registrationMatches(readBrowserHandoffRequest(dependencies.stateDir), active.receipt.id, requestAuthority.authority, digest);
|
|
327
|
+
}
|
|
328
|
+
return currentReadyOwnership(dependencies.stateDir, pid, identityMatches) !== null;
|
|
329
|
+
}
|
|
330
|
+
const ready = currentReadyOwnership(dependencies.stateDir, pid, identityMatches);
|
|
331
|
+
const registration = readBrowserHandoffRequest(dependencies.stateDir);
|
|
332
|
+
return ready !== null && matchingRegistration(registration, ready.cutoverId, requestAuthority.authority, digest)?.acknowledgedAt === undefined;
|
|
333
|
+
};
|
|
334
|
+
await waitForPollState(pollHasState, longPollMs, longPollIntervalMs);
|
|
335
|
+
const active = activeCutover(dependencies.stateDir);
|
|
336
|
+
const ready = currentReadyOwnership(dependencies.stateDir, pid, identityMatches);
|
|
337
|
+
const registration = readBrowserHandoffRequest(dependencies.stateDir);
|
|
338
|
+
const registered = ready === null ? undefined : matchingRegistration(registration, ready.cutoverId, requestAuthority.authority, digest);
|
|
339
|
+
if (registered?.acknowledgedAt !== undefined
|
|
340
|
+
|| (active === null && (ready === null || registered === undefined))
|
|
341
|
+
|| active?.receipt.phase === 'awaiting-user'
|
|
342
|
+
|| active?.receipt.browserHandoff.status === 'off') {
|
|
343
|
+
respond(200, { state: 'idle' });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const previous = active?.receipt.ownership.previous;
|
|
347
|
+
if (active !== null && ownershipMatches(previous, pid, identityMatches)) {
|
|
348
|
+
const oldAuthentication = authenticationState(connection, req);
|
|
349
|
+
if (oldAuthentication === 'unauthenticated' || oldAuthentication === 'forbidden') {
|
|
350
|
+
json(res, 401, { state: 'unauthorized' });
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const registration = {
|
|
354
|
+
authority: requestAuthority.authority,
|
|
355
|
+
capabilitySha256: digest,
|
|
356
|
+
armedAt: now(),
|
|
357
|
+
};
|
|
358
|
+
const selected = registerBrowserTab(dependencies.stateDir, active.receipt.id, registration);
|
|
359
|
+
respond(200, registrationMatches(selected, active.receipt.id, requestAuthority.authority, digest)
|
|
360
|
+
? { state: 'waiting', cutoverId: active.receipt.id }
|
|
361
|
+
: { state: 'standby', cutoverId: active.receipt.id });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (ready === null || (active !== null && ready.cutoverId !== active.receipt.id)
|
|
365
|
+
|| !registrationMatches(registration, ready?.cutoverId ?? '', requestAuthority.authority, digest)) {
|
|
366
|
+
respond(200, active === null
|
|
367
|
+
? { state: 'idle' }
|
|
368
|
+
: { state: 'waiting', cutoverId: active.receipt.id });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
// A protected listener may expose the route before its optional
|
|
372
|
+
// connection service settles. Do not misclassify that startup window as
|
|
373
|
+
// a public/valid-cookie host and strand the original tab on a bare 401.
|
|
374
|
+
if (ready.protected && connection?.requestRejection === undefined) {
|
|
375
|
+
respond(200, { state: 'waiting', cutoverId: ready.cutoverId });
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const cookieState = authenticationState(connection, req);
|
|
379
|
+
if (cookieState === 'forbidden') {
|
|
380
|
+
json(res, 403, { state: 'forbidden' });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (cookieState !== 'unauthenticated') {
|
|
384
|
+
respond(200, {
|
|
385
|
+
state: 'ready', action: 'reload', cutoverId: ready.cutoverId,
|
|
386
|
+
role: ready.role, authentication: 'existing-cookie',
|
|
387
|
+
});
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (connection?.authenticatedUrl === undefined) {
|
|
391
|
+
respond(200, { state: 'waiting', cutoverId: ready.cutoverId });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
let launchUrl;
|
|
395
|
+
try {
|
|
396
|
+
launchUrl = connection.authenticatedUrl(requestAuthority.origin);
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
json(res, 500, { state: 'unavailable' });
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
let safe = false;
|
|
403
|
+
try {
|
|
404
|
+
const parsed = new URL(launchUrl);
|
|
405
|
+
safe = parsed.origin === requestAuthority.origin && parsed.pathname === '/'
|
|
406
|
+
&& parsed.username === '' && parsed.password === '' && parsed.search !== '' && parsed.hash === '';
|
|
407
|
+
}
|
|
408
|
+
catch { /* unsafe provider output */ }
|
|
409
|
+
if (!safe) {
|
|
410
|
+
json(res, 500, { state: 'unavailable' });
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
respond(200, {
|
|
414
|
+
state: 'ready', action: 'replace', cutoverId: ready.cutoverId,
|
|
415
|
+
role: ready.role, authentication: 'launch-url', launchUrl,
|
|
416
|
+
});
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const receipt = readCutoverReceipt(dependencies.stateDir);
|
|
420
|
+
if (receipt === null || receipt.id !== message.cutoverId) {
|
|
421
|
+
json(res, 409, { state: 'stale' });
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const ready = currentReadyOwnership(dependencies.stateDir, pid, identityMatches);
|
|
425
|
+
if (ready === null || ready.cutoverId !== message.cutoverId) {
|
|
426
|
+
json(res, 409, { state: 'waiting' });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (ready.protected && connection?.requestRejection === undefined) {
|
|
430
|
+
json(res, 409, { state: 'waiting' });
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const ackAuthentication = authenticationState(connection, req);
|
|
434
|
+
if (ackAuthentication === 'unauthenticated' || ackAuthentication === 'forbidden') {
|
|
435
|
+
json(res, ackAuthentication === 'forbidden' ? 403 : 401, {
|
|
436
|
+
state: ackAuthentication === 'forbidden' ? 'forbidden' : 'unauthorized',
|
|
437
|
+
});
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const acknowledgedAt = now();
|
|
441
|
+
if (message.channel === 'original-tab') {
|
|
442
|
+
const registration = readBrowserHandoffRequest(dependencies.stateDir);
|
|
443
|
+
if (message.capability === undefined || !registrationMatches(registration, message.cutoverId, requestAuthority.authority, capabilitySha256(message.capability))) {
|
|
444
|
+
json(res, 403, { state: 'forbidden' });
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (!acknowledgeBrowserTab(dependencies.stateDir, message.cutoverId, requestAuthority.authority, capabilitySha256(message.capability), acknowledgedAt)) {
|
|
448
|
+
json(res, 409, { state: 'conflict' });
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const acknowledgement = {
|
|
453
|
+
version: 1,
|
|
454
|
+
cutoverId: message.cutoverId,
|
|
455
|
+
role: ready.role,
|
|
456
|
+
authority: requestAuthority.authority,
|
|
457
|
+
listenerPid: ready.ownership.listenerPid,
|
|
458
|
+
listenerStartToken: ready.ownership.listenerStartToken,
|
|
459
|
+
channel: message.channel,
|
|
460
|
+
authentication: message.authentication,
|
|
461
|
+
acknowledgedAt,
|
|
462
|
+
};
|
|
463
|
+
const ackFile = stateFile(dependencies.stateDir, 'browserHandoffAck');
|
|
464
|
+
if (!createJsonOnce(ackFile, acknowledgement)) {
|
|
465
|
+
const existing = readBrowserHandoffAcknowledgement(dependencies.stateDir);
|
|
466
|
+
if (existing?.cutoverId !== acknowledgement.cutoverId
|
|
467
|
+
|| existing.listenerPid !== acknowledgement.listenerPid
|
|
468
|
+
|| existing.listenerStartToken !== acknowledgement.listenerStartToken) {
|
|
469
|
+
json(res, 409, { state: 'conflict' });
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
res.writeHead(204, { 'cache-control': 'no-store' });
|
|
474
|
+
res.end();
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
/** Register the optional Web-profile bridge; headless/TUI compositions simply never activate it. */
|
|
478
|
+
export function registerBrowserHandoff(ctx, stateDir) {
|
|
479
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
480
|
+
const webServer = webCtx.get('webServer');
|
|
481
|
+
if (webServer === undefined)
|
|
482
|
+
return;
|
|
483
|
+
const handler = createBrowserHandoffHandler({
|
|
484
|
+
stateDir,
|
|
485
|
+
connectionProvider: () => webCtx.get('connection'),
|
|
486
|
+
});
|
|
487
|
+
webCtx.effect(() => webServer.register({ kind: 'exact', path: BROWSER_HANDOFF_ROUTE, handler }), 'ankh-guard: browser handoff route');
|
|
488
|
+
});
|
|
489
|
+
}
|
package/lib/types/cli.d.ts
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { type BrowserHandoffPolicy, type CutoverRecoveryPolicy, type LaunchPreflightSpec, type PreflightSurface } from './launch-spec.ts';
|
|
2
3
|
/** Parsed CLI options; empty stateDir/repoDir mean "use defaults". */
|
|
3
4
|
interface CliOptions {
|
|
4
5
|
stateDir: string;
|
|
5
6
|
repoDir: string;
|
|
7
|
+
harnessRoot: string;
|
|
6
8
|
home: string;
|
|
7
9
|
maxAgeMinutes: number;
|
|
8
10
|
port: number | undefined;
|
|
9
11
|
command: string | undefined;
|
|
12
|
+
run: boolean;
|
|
13
|
+
runArgv: string[] | undefined;
|
|
10
14
|
message: string | undefined;
|
|
15
|
+
detail: string | undefined;
|
|
11
16
|
start: string | undefined;
|
|
12
17
|
pid: string | undefined;
|
|
13
18
|
timeoutMs: number | undefined;
|
|
14
19
|
delayMs: number | undefined;
|
|
15
20
|
stopTimeoutMs: number | undefined;
|
|
21
|
+
supervisorYieldTimeoutMs: number | undefined;
|
|
16
22
|
log: string | undefined;
|
|
17
23
|
foreground: boolean;
|
|
18
24
|
rollback: boolean;
|
|
@@ -21,6 +27,18 @@ interface CliOptions {
|
|
|
21
27
|
initiator: string | undefined;
|
|
22
28
|
profile: string | undefined;
|
|
23
29
|
preflightTimeoutMs: number | undefined;
|
|
30
|
+
preflightSurface: PreflightSurface | undefined;
|
|
31
|
+
preflightRunner: string | undefined;
|
|
32
|
+
preflightInstallAnchor: string | undefined;
|
|
33
|
+
candidateProbeCommand: string | undefined;
|
|
34
|
+
onFailure: CutoverRecoveryPolicy | undefined;
|
|
35
|
+
browserHandoff: BrowserHandoffPolicy;
|
|
36
|
+
ifAbsent: boolean;
|
|
37
|
+
trustCommand: boolean;
|
|
38
|
+
includeDirty: boolean;
|
|
39
|
+
takeoverFrom: number | undefined;
|
|
40
|
+
cutoverId: string | undefined;
|
|
41
|
+
transitionFile: string | undefined;
|
|
24
42
|
}
|
|
25
43
|
/** stdout/stderr sink (injected so tests capture output). */
|
|
26
44
|
export interface CliIo {
|
|
@@ -63,10 +81,11 @@ export declare const preflightInternals: {
|
|
|
63
81
|
};
|
|
64
82
|
/**
|
|
65
83
|
* The harness checkout the live instance boots from (and the preflight
|
|
66
|
-
* runner resolves the official published packages from): the
|
|
67
|
-
* when given, else `DSH_HARNESS`, else the
|
|
84
|
+
* runner resolves the official published packages from): the
|
|
85
|
+
* `--harness-root` target when given, else `DSH_HARNESS`, else the
|
|
86
|
+
* conventional default. Credential repositories never enter this resolver.
|
|
68
87
|
*/
|
|
69
|
-
export declare function resolveHarnessRoot(
|
|
88
|
+
export declare function resolveHarnessRoot(optionHarnessRoot: string | undefined, env?: Record<string, string | undefined>): string;
|
|
70
89
|
/**
|
|
71
90
|
* The standalone preflight runner command (see preflight-runner.ts): executed
|
|
72
91
|
* with the harness's own tsx so its dynamic imports resolve against the live
|
|
@@ -97,9 +116,10 @@ export interface PreflightOutcome {
|
|
|
97
116
|
* @param profile - the dsh profile to dry-run.
|
|
98
117
|
* @param timeoutMs - bound on the whole subprocess run; a timeout kills it.
|
|
99
118
|
* @param harnessRoot - harness checkout for the runner (default: DSH_HARNESS / ~/code/deepseek-harness).
|
|
119
|
+
* @param home - dsh home the dry-run must read instead of ambient process state.
|
|
100
120
|
* @returns the classified outcome.
|
|
101
121
|
*/
|
|
102
|
-
export declare function runPreflightCheck(profile: string, timeoutMs: number, harnessRoot?: string): Promise<PreflightOutcome>;
|
|
122
|
+
export declare function runPreflightCheck(profile: string, timeoutMs: number, harnessRoot?: string, home?: string, binding?: LaunchPreflightSpec): Promise<PreflightOutcome>;
|
|
103
123
|
/**
|
|
104
124
|
* The home the supervised instance boots with (the watchdog exports it as
|
|
105
125
|
* DSH_HOME): the explicit flag first, then the environment — the same
|
|
@@ -108,6 +128,16 @@ export declare function runPreflightCheck(profile: string, timeoutMs: number, ha
|
|
|
108
128
|
* loud rather than boot the instance on a home guessed from the state dir.
|
|
109
129
|
*/
|
|
110
130
|
export declare function resolveWdHome(optionHome: string, env?: Record<string, string | undefined>): string | undefined;
|
|
131
|
+
/**
|
|
132
|
+
* A machine-readable refusal verdict: which gate denied, plus a one-line
|
|
133
|
+
* reason. In-process callers (the selfRestartGuard.requestRestart service
|
|
134
|
+
* seam) read it back from the verdict file named by DSH_ANKH_VERDICT_FILE
|
|
135
|
+
* instead of scraping the human stderr text.
|
|
136
|
+
*/
|
|
137
|
+
export interface CliRefusal {
|
|
138
|
+
stage: string;
|
|
139
|
+
reason: string;
|
|
140
|
+
}
|
|
111
141
|
/**
|
|
112
142
|
* Run one CLI invocation against the guard state.
|
|
113
143
|
* @param argv - arguments after the subcommand name.
|