@byokit/openclaw 0.5.1 → 0.6.2

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 (64) hide show
  1. package/CHANGELOG.md +56 -3
  2. package/README.md +205 -15
  3. package/SECURITY.md +6 -6
  4. package/dist/auth-store.d.ts +6 -0
  5. package/dist/auth-store.js +58 -15
  6. package/dist/bridge.d.ts +10 -2
  7. package/dist/bridge.js +61 -14
  8. package/dist/browser/broker.d.ts +63 -0
  9. package/dist/browser/broker.js +1003 -0
  10. package/dist/browser/host.d.ts +98 -0
  11. package/dist/browser/host.js +945 -0
  12. package/dist/browser/resume.d.ts +10 -0
  13. package/dist/browser/resume.js +23 -0
  14. package/dist/browser/store.d.ts +25 -0
  15. package/dist/browser/store.js +151 -0
  16. package/dist/browser/verify.d.ts +17 -0
  17. package/dist/browser/verify.js +38 -0
  18. package/dist/browser.d.ts +212 -0
  19. package/dist/browser.js +4 -0
  20. package/dist/config.d.ts +28 -0
  21. package/dist/config.js +130 -0
  22. package/dist/day-usage.d.ts +78 -0
  23. package/dist/day-usage.js +104 -0
  24. package/dist/device.d.ts +38 -5
  25. package/dist/device.js +224 -4
  26. package/dist/engine-patches.d.ts +36 -0
  27. package/dist/engine-patches.js +320 -0
  28. package/dist/engine-status.d.ts +13 -0
  29. package/dist/engine-status.js +31 -0
  30. package/dist/engine.d.ts +13 -1
  31. package/dist/engine.js +99 -52
  32. package/dist/index.d.ts +9 -1
  33. package/dist/index.js +2 -0
  34. package/dist/kit.d.ts +28 -3
  35. package/dist/kit.js +319 -12
  36. package/dist/link.d.ts +16 -5
  37. package/dist/link.js +333 -6
  38. package/dist/routes.d.ts +19 -8
  39. package/dist/routes.js +44 -11
  40. package/dist/routes.json +3775 -356
  41. package/dist/runs.js +8 -2
  42. package/dist/signin.d.ts +1 -1
  43. package/dist/signin.js +19 -4
  44. package/dist/testing/browser.d.ts +50 -0
  45. package/dist/testing/browser.js +166 -0
  46. package/dist/testing/contract.js +1 -2
  47. package/dist/testing/fake-gateway.d.ts +14 -7
  48. package/dist/testing/fake-gateway.js +8 -6
  49. package/dist/testing/index.d.ts +3 -2
  50. package/dist/testing/index.js +1 -0
  51. package/dist/testing/model-stub.d.ts +15 -1
  52. package/dist/testing/model-stub.js +11 -11
  53. package/dist/types.d.ts +50 -3
  54. package/dist/usage-boots.d.ts +8 -0
  55. package/dist/usage-boots.js +26 -0
  56. package/dist/usage.d.ts +53 -0
  57. package/dist/usage.js +60 -0
  58. package/dist/words.json +42 -0
  59. package/engine/OPENCLAW-LICENSE +24 -0
  60. package/engine/patches.json +59 -0
  61. package/engine/patches.schema.json +41 -0
  62. package/package.json +12 -4
  63. package/plugin/index.js +78 -5
  64. package/plugin/usage.js +125 -0
@@ -0,0 +1,945 @@
1
+ // Internal host runtime (5.17). Production handoff is deliberately unprotected until W7/O17 qualification.
2
+ import { randomBytes } from 'node:crypto';
3
+ import { BrowserStoreError, checkUrl, validOrigin } from "./store.js";
4
+ import { validateVerifiers, verifySignIn } from "./verify.js";
5
+ import { dispatchResume, newResume, recoverResume } from "./resume.js";
6
+ export class SignInRefused extends Error {
7
+ why;
8
+ constructor(why) { super(`sign-in refused: ${why}`); this.why = why; }
9
+ }
10
+ export class HandoffUnprotected extends Error {
11
+ why = 'handoff-unprotected';
12
+ constructor() { super('browser sign-in handoff unavailable'); }
13
+ }
14
+ const open = (r) => ['waiting', 'held', 'checking'].includes(r.state);
15
+ const mint = () => randomBytes(16).toString('base64url');
16
+ export function secureOrigin(origin) {
17
+ const u = new URL(validOrigin(origin));
18
+ return u.protocol === 'https:' || u.hostname === 'localhost' || u.hostname === '[::1]' || /^127\.(\d{1,3}\.){2}\d{1,3}$/.test(u.hostname);
19
+ }
20
+ export async function createBrowserHost(services) {
21
+ return startHost(services, false);
22
+ }
23
+ // TEST ONLY. Requires loopback-only synthetic brokers; never imported by kit wiring. No production enable flag.
24
+ export async function fixtureBrowserHost(services) {
25
+ for (const broker of services.brokers.values()) {
26
+ const target = broker.agentTab();
27
+ if (!target)
28
+ throw new Error('fixture browser required');
29
+ const origin = await broker.originOf(target);
30
+ if (!secureOrigin(origin) || new URL(origin).protocol !== 'http:')
31
+ throw new Error('loopback fixture required');
32
+ }
33
+ return startHost(services, true);
34
+ }
35
+ async function startHost(services, fixture) {
36
+ const host = new Host(services, fixture);
37
+ await host.restore();
38
+ return host;
39
+ }
40
+ class Host {
41
+ s;
42
+ fixture;
43
+ data;
44
+ committed;
45
+ brokers;
46
+ attaching = new Map();
47
+ bindingVersions = new Map();
48
+ recovery = new Map();
49
+ exhausted = new Set();
50
+ queue = Promise.resolve();
51
+ leases = new Map();
52
+ epochs = new Map();
53
+ streams = new Set();
54
+ unavailable = new Set();
55
+ closing = new Set();
56
+ revoked = new Set();
57
+ thumbs = new Map();
58
+ ttl;
59
+ stopped = false;
60
+ failed = false;
61
+ constructor(services, fixture) {
62
+ this.s = services;
63
+ this.fixture = fixture;
64
+ validateVerifiers(services.options.verifiers ?? []);
65
+ for (const origin of services.options.knownIdps ?? [])
66
+ validOrigin(origin);
67
+ for (const n of ['requestTtlMs', 'leaseTtlMs', 'claimMs', 'graceMs', 'checkMs']) {
68
+ const value = services.options[n];
69
+ if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0 || value > 2_147_483_647))
70
+ throw new Error('invalid browser timeout');
71
+ }
72
+ const recovery = services.options.recovery;
73
+ if (recovery && (!Number.isInteger(recovery.attempts) || recovery.attempts < 0 || recovery.attempts > 3
74
+ || recovery.backoffMs.some(n => !Number.isSafeInteger(n) || n < 0 || n > 60_000)))
75
+ throw new Error('invalid browser recovery');
76
+ this.brokers = new Map(services.brokers);
77
+ for (const member of this.brokers.keys())
78
+ this.bindingVersions.set(member, 1);
79
+ this.data = services.store.read();
80
+ this.committed = structuredClone(this.data);
81
+ }
82
+ now() { return this.s.now?.() ?? Date.now(); }
83
+ broker(member) {
84
+ const broker = this.brokers.get(member);
85
+ if (!broker || this.unavailable.has(member))
86
+ throw new SignInRefused('unsupported');
87
+ return broker;
88
+ }
89
+ currentBroker(member, broker) {
90
+ if (this.stopped || this.failed || this.unavailable.has(member) || this.brokers.get(member) !== broker)
91
+ throw new SignInRefused('stale');
92
+ }
93
+ brokerBinding(member) {
94
+ const broker = this.brokers.get(member);
95
+ if (!broker || this.stopped || this.failed || this.unavailable.has(member) || this.attaching.has(member))
96
+ return undefined;
97
+ try {
98
+ return Object.freeze({ broker, generation: this.bindingVersions.get(member) ?? 0, endpoint: Object.freeze({ ...broker.endpoint() }) });
99
+ }
100
+ catch {
101
+ return undefined;
102
+ }
103
+ }
104
+ attachBroker(member, next, previous) {
105
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(member) || (this.s.options.members !== 'all' && !this.s.options.members.includes(member))
106
+ || this.stopped || this.failed)
107
+ return Promise.reject(new SignInRefused('unsupported'));
108
+ if (this.attaching.has(member))
109
+ return Promise.reject(new SignInRefused('held-by-other'));
110
+ const old = this.brokers.get(member);
111
+ if (old !== previous)
112
+ return Promise.reject(new SignInRefused('stale'));
113
+ if (old === next)
114
+ return Promise.resolve();
115
+ this.attaching.set(member, next);
116
+ this.unavailable.add(member);
117
+ this.thumbs.delete(member);
118
+ this.bindingVersions.set(member, (this.bindingVersions.get(member) ?? 0) + 1);
119
+ this.recovery.delete(member);
120
+ this.epochs.set(member, (this.epochs.get(member) ?? 0) + 1);
121
+ const requests = this.data.requests.filter(r => r.member === member);
122
+ // No await before old capabilities are invalidated. New endpoint is not yet published to the engine.
123
+ try {
124
+ for (const r of requests) {
125
+ this.closing.add(r.id);
126
+ this.drop(r);
127
+ }
128
+ old?.bindLease(null);
129
+ }
130
+ catch {
131
+ this.failed = true;
132
+ }
133
+ for (const s of this.streams)
134
+ if (s.member === member)
135
+ s.close('browser-gone');
136
+ // ponytail: one host-wide queue keeps the single durable file atomic; split per member if latency matters.
137
+ return this.serial(async () => {
138
+ try {
139
+ if (this.brokers.get(member) !== old)
140
+ throw new SignInRefused('stale');
141
+ await next.fence(true);
142
+ if (old) {
143
+ await old.fence(true);
144
+ await old.closePrivate();
145
+ await old.close();
146
+ }
147
+ if (this.stopped || this.failed)
148
+ throw new SignInRefused('unsupported');
149
+ for (const r of this.data.requests.filter(r => r.member === member)) {
150
+ recoverResume(r);
151
+ if (r.state === 'held' || r.state === 'checking') {
152
+ r.state = 'settled';
153
+ r.settled = { state: 'failed', reason: 'browser-gone', at: this.now() };
154
+ }
155
+ else if (r.state === 'waiting' || r.state === 'parked')
156
+ r.gen++;
157
+ }
158
+ this.commit();
159
+ // Leave the replacement fenced. W3 publishes this binding, awaits config/policy ack, then releases.
160
+ if (this.stopped || this.failed || this.brokers.get(member) !== old)
161
+ throw new SignInRefused('stale');
162
+ this.brokers.set(member, next);
163
+ this.unavailable.delete(member);
164
+ this.exhausted.delete(member);
165
+ this.attaching.delete(member); // state-ping listeners must see the accepted canonical binding
166
+ for (const r of requests) {
167
+ this.closing.delete(r.id);
168
+ this.ping(r);
169
+ }
170
+ try {
171
+ this.s.ping?.(member, 'state');
172
+ }
173
+ catch { /* advisory */ }
174
+ }
175
+ catch {
176
+ this.exhausted.add(member);
177
+ for (const r of requests)
178
+ if (r.state !== 'settled') {
179
+ r.state = 'settled';
180
+ r.settled = { state: 'failed', reason: 'browser-gone', at: this.now() };
181
+ }
182
+ if (!this.failed)
183
+ this.commit();
184
+ throw new SignInRefused('unsupported');
185
+ }
186
+ }).catch(async (e) => { try {
187
+ await next.close();
188
+ }
189
+ catch { /* never expose endpoint details */ } throw e; })
190
+ .finally(() => { if (this.attaching.get(member) === next)
191
+ this.attaching.delete(member); });
192
+ }
193
+ serial(fn) {
194
+ const result = this.queue.then(async () => {
195
+ if (this.failed || this.stopped)
196
+ throw new SignInRefused('unsupported');
197
+ try {
198
+ return await fn();
199
+ }
200
+ catch (e) {
201
+ if (e instanceof SignInRefused || e instanceof BrowserStoreError || e instanceof HandoffUnprotected)
202
+ throw e;
203
+ throw new Error('browser action unavailable');
204
+ }
205
+ });
206
+ this.queue = result.catch(() => { });
207
+ return result;
208
+ }
209
+ commit() {
210
+ try {
211
+ this.s.store.write(this.data);
212
+ this.committed = structuredClone(this.data);
213
+ }
214
+ catch {
215
+ this.data = structuredClone(this.committed);
216
+ this.failed = true;
217
+ this.streams.forEach(s => s.close('browser-gone'));
218
+ throw new Error('browser state unavailable');
219
+ }
220
+ this.arm();
221
+ }
222
+ ping(r) { try {
223
+ this.s.ping?.(r.member, 'signin');
224
+ }
225
+ catch { /* invalidation is advisory */ } }
226
+ arm() {
227
+ clearTimeout(this.ttl);
228
+ const expires = this.data.requests.filter(r => r.state === 'waiting' || r.state === 'held').map(r => r.expires);
229
+ if (!expires.length)
230
+ return;
231
+ this.ttl = setTimeout(() => { void this.sweep().catch(() => { }); }, Math.max(1, Math.min(...expires) - this.now()));
232
+ this.ttl.unref?.();
233
+ }
234
+ async restore() {
235
+ for (const r of this.data.requests) {
236
+ recoverResume(r);
237
+ if (r.state === 'held' || r.state === 'checking') {
238
+ r.state = 'settled';
239
+ r.settled = { state: 'failed', reason: 'browser-gone', at: this.now() };
240
+ }
241
+ else if (r.state === 'waiting' || r.state === 'parked')
242
+ r.gen++;
243
+ if (open(r))
244
+ await this.broker(r.member).fence(true);
245
+ }
246
+ // No dispatch on restore, regardless of gateway process/cache state or submission marker.
247
+ this.commit();
248
+ }
249
+ state(member) {
250
+ if (this.attaching.has(member))
251
+ return this.brokers.has(member)
252
+ ? { member, phase: 'blocked', why: 'engine-detached' } : { member, phase: 'starting' };
253
+ if (!this.brokers.has(member))
254
+ return { member, phase: 'off', why: 'no-browser' };
255
+ const recovery = this.recovery.get(member);
256
+ if (recovery)
257
+ return { member, phase: 'recovering', recovery: { ...recovery } };
258
+ if (this.exhausted.has(member))
259
+ return { member, phase: 'blocked', why: 'recovery-exhausted' };
260
+ if (this.failed || this.stopped || this.unavailable.has(member))
261
+ return { member, phase: 'blocked', why: 'engine-detached' };
262
+ if (this.data.requests.some(r => r.member === member && open(r)))
263
+ return { member, phase: 'fenced' };
264
+ return this.fixture ? { member, phase: 'ready' } : { member, phase: 'blocked', why: 'handoff-unprotected' };
265
+ }
266
+ signIns(member) { return structuredClone(this.data.requests.filter(r => !member || r.member === member)); }
267
+ request(id, gen) {
268
+ const r = this.data.requests.find(r => r.id === id);
269
+ if (!r)
270
+ throw new SignInRefused('not-found');
271
+ if (gen !== undefined && gen !== r.gen)
272
+ throw new SignInRefused('stale');
273
+ return r;
274
+ }
275
+ access(grant, member, control) {
276
+ let allowed = false;
277
+ try {
278
+ allowed = !this.revoked.has(grant) && this.s.authorize(grant, member, control);
279
+ }
280
+ catch { /* fail closed */ }
281
+ if (!allowed)
282
+ throw new SignInRefused(control ? 'not-control' : 'not-found');
283
+ }
284
+ held(r, grant) {
285
+ this.access(grant, r.member, true);
286
+ if (r.state === 'held') {
287
+ const l = this.leases.get(r.id);
288
+ if (l?.grant !== grant)
289
+ throw new SignInRefused('not-lease-holder');
290
+ this.lease(l.value);
291
+ }
292
+ if (['waiting', 'held'].includes(r.state) && this.now() >= r.expires)
293
+ throw new SignInRefused('lease-expired');
294
+ }
295
+ lease(ref) {
296
+ if (this.failed || this.stopped)
297
+ throw new SignInRefused('stale');
298
+ const r = this.request(ref.requestId);
299
+ if (this.unavailable.has(r.member))
300
+ throw new SignInRefused('stale');
301
+ const l = this.leases.get(r.id);
302
+ if (!l || l.value.epoch !== ref.epoch || l.value.nonce !== ref.nonce || l.value.gen !== r.gen
303
+ || this.epochs.get(r.member) !== ref.epoch || !['held', 'checking'].includes(r.state))
304
+ throw new SignInRefused('stale');
305
+ this.access(l.grant, r.member, true);
306
+ if (r.state !== 'checking' && this.now() >= Math.min(l.value.expires, l.deadline, r.expires))
307
+ throw new SignInRefused('lease-expired');
308
+ return { r, l };
309
+ }
310
+ drop(r) {
311
+ const l = this.leases.get(r.id);
312
+ clearTimeout(l?.timer);
313
+ this.brokers.get(r.member)?.bindLease(null);
314
+ this.leases.delete(r.id);
315
+ this.epochs.set(r.member, (this.epochs.get(r.member) ?? 0) + 1);
316
+ for (const s of this.streams)
317
+ if (s.member === r.member)
318
+ s.close('superseded');
319
+ }
320
+ leaseTimer(r, l) {
321
+ clearTimeout(l.timer);
322
+ l.timer = setTimeout(() => { void this.sweep().catch(() => { }); }, Math.max(1, Math.min(l.deadline, l.value.expires) - this.now()));
323
+ l.timer.unref?.();
324
+ }
325
+ async raise(input) {
326
+ if (!this.fixture)
327
+ throw new HandoffUnprotected();
328
+ return this.serial(() => this.raiseLocked(input));
329
+ }
330
+ async raiseLocked(input, prev) {
331
+ const broker = this.broker(input.member);
332
+ if (this.data.requests.some(r => r.member === input.member && r.state !== 'settled'))
333
+ throw new SignInRefused('already-open');
334
+ if (!input.sessionKey)
335
+ throw new SignInRefused('unsupported');
336
+ const target = input.targetId ?? broker.agentTab();
337
+ if (!target)
338
+ throw new SignInRefused('unsupported');
339
+ const origin = validOrigin(await broker.originOf(target));
340
+ this.currentBroker(input.member, broker);
341
+ const url = checkUrl(input.checkUrl);
342
+ const site = this.s.siteOf(origin);
343
+ if (!site || /[\s/<>]/.test(site))
344
+ throw new SignInRefused('unsupported');
345
+ const r = { id: mint(), gen: 1, ...(prev ? { prev } : {}), member: input.member, sessionKey: input.sessionKey,
346
+ origin, site, secure: secureOrigin(origin), firstTime: !(Object.hasOwn(this.data.verified, input.member) && this.data.verified[input.member].includes(site)),
347
+ reasons: [...new Set(input.reasons)], hints: [...new Set(input.hints ?? [])],
348
+ choices: [...(secureOrigin(origin) ? [{ kind: 'takeover' }] : []), { kind: 'not-now' }, { kind: 'cancel' }],
349
+ state: 'waiting', at: this.now(), expires: this.now() + (this.s.options.requestTtlMs ?? 1_800_000) };
350
+ this.thumbs.delete(r.member);
351
+ this.data.requests.push(r);
352
+ this.data.host[r.id] = { checkUrl: url, confirmed: [] };
353
+ this.commit();
354
+ this.streams.forEach(s => { if (s.member === r.member)
355
+ s.close('superseded'); });
356
+ await broker.fence(true);
357
+ await this.s.park(r.member, r.sessionKey);
358
+ this.ping(r);
359
+ return structuredClone(r);
360
+ }
361
+ takeover(id, gen, by) {
362
+ if (!this.fixture)
363
+ return Promise.reject(new HandoffUnprotected());
364
+ if (this.closing.has(id))
365
+ return Promise.reject(new SignInRefused('held-by-other'));
366
+ return this.serial(async () => {
367
+ const r = this.request(id, gen);
368
+ this.access(by.grant, r.member, true);
369
+ if (r.state === 'held' || this.closing.has(r.id))
370
+ throw new SignInRefused('held-by-other');
371
+ if (r.state !== 'waiting')
372
+ throw new SignInRefused('stale');
373
+ if (this.now() >= r.expires)
374
+ throw new SignInRefused('lease-expired');
375
+ if (!r.secure)
376
+ throw new SignInRefused('insecure-remote');
377
+ if (r.firstTime && by.confirmSite !== r.site)
378
+ throw new SignInRefused('confirm-site');
379
+ const broker = this.broker(r.member);
380
+ await broker.fence(true);
381
+ const epoch = (this.epochs.get(r.member) ?? 0) + 1;
382
+ this.epochs.set(r.member, epoch);
383
+ const value = { requestId: r.id, gen: r.gen, epoch, nonce: mint(), expires: this.now() + (this.s.options.leaseTtlMs ?? 600_000),
384
+ claimMs: this.s.options.claimMs ?? 60_000, graceMs: this.s.options.graceMs ?? 30_000 };
385
+ broker.bindLease({ epoch, nonce: value.nonce, origin: r.origin, knownIdps: this.knownIdps() });
386
+ let target;
387
+ try {
388
+ target = await broker.openPrivate(this.data.host[r.id].checkUrl);
389
+ // Revocation during openPrivate cannot grant a new controller.
390
+ this.access(by.grant, r.member, true);
391
+ this.currentBroker(r.member, broker);
392
+ }
393
+ catch {
394
+ broker.bindLease(null);
395
+ try {
396
+ await broker.closePrivate();
397
+ }
398
+ catch {
399
+ this.unavailable.add(r.member);
400
+ }
401
+ throw new SignInRefused('stale');
402
+ }
403
+ const l = { value, grant: by.grant, target, attached: false, deadline: this.now() + value.claimMs };
404
+ this.leases.set(r.id, l);
405
+ r.state = 'held';
406
+ this.data.host[r.id].confirmed = [];
407
+ this.commit();
408
+ this.leaseTimer(r, l);
409
+ this.ping(r);
410
+ return structuredClone(value);
411
+ });
412
+ }
413
+ confirmOrigin(ref, origin) {
414
+ const { r } = this.lease(ref);
415
+ if (r.state !== 'held')
416
+ throw new SignInRefused('stale');
417
+ const exact = validOrigin(origin);
418
+ if (!secureOrigin(exact))
419
+ throw new SignInRefused('insecure-remote');
420
+ let confirmed = false;
421
+ try {
422
+ confirmed = this.broker(r.member).confirmOrigin({ epoch: ref.epoch, nonce: ref.nonce }, exact);
423
+ }
424
+ catch { /* fail closed */ }
425
+ if (!confirmed)
426
+ throw new SignInRefused('stale');
427
+ if (!this.data.host[r.id].confirmed.includes(exact))
428
+ this.data.host[r.id].confirmed.push(exact);
429
+ this.commit();
430
+ }
431
+ done(ref) {
432
+ return this.serial(async () => {
433
+ const { r, l } = this.lease(ref);
434
+ if (r.state !== 'held')
435
+ throw new SignInRefused('stale');
436
+ const broker = this.broker(r.member);
437
+ r.state = 'checking';
438
+ clearTimeout(l.timer);
439
+ for (const s of this.streams)
440
+ if (s.member === r.member)
441
+ s.close('superseded');
442
+ this.commit();
443
+ this.ping(r);
444
+ const privateState = broker.privateState();
445
+ const origin = privateState ? validOrigin(privateState.origin) : undefined;
446
+ broker.bindLease(null);
447
+ const allowed = !!origin && (origin === r.origin || this.knownIdps().includes(origin) || this.data.host[r.id].confirmed.includes(origin));
448
+ const result = allowed && origin && secureOrigin(origin)
449
+ ? await verifySignIn(broker, r.origin, this.s.options.verifiers ?? [], this.s.options.checkMs ?? 30_000)
450
+ : { state: 'failed', reason: 'origin-mismatch' };
451
+ // Revocation/reconnect epochs can change while verification is in flight.
452
+ this.lease(ref);
453
+ await this.settle(r, result.state, 'reason' in result ? result.reason : undefined);
454
+ if (r.settled?.state === 'verified') {
455
+ if (this.stopped || this.failed || this.unavailable.has(r.member))
456
+ throw new SignInRefused('unsupported');
457
+ r.settled.resume = newResume(r);
458
+ this.commit(); // action key/session/attempt durable BEFORE calling the gateway
459
+ r.settled.resume.state = await dispatchResume(r, this.s.resume);
460
+ this.commit();
461
+ this.ping(r);
462
+ }
463
+ return structuredClone(r);
464
+ });
465
+ }
466
+ async settle(r, state, reason) {
467
+ if (r.state === 'settled')
468
+ throw new SignInRefused('stale');
469
+ const broker = this.broker(r.member);
470
+ this.drop(r);
471
+ await this.closePrivate(r); // never lift before held targets (including probes/popups) are destroyed
472
+ r.state = 'settled';
473
+ r.settled = { state, ...(reason ? { reason } : {}), at: this.now() };
474
+ if (state === 'verified') {
475
+ const sites = Object.hasOwn(this.data.verified, r.member) ? this.data.verified[r.member] : (this.data.verified[r.member] = []);
476
+ if (!sites.includes(r.site))
477
+ sites.push(r.site);
478
+ }
479
+ this.commit();
480
+ await broker.fence(false);
481
+ await broker.navigateAgent(state === 'verified' ? this.data.host[r.id].checkUrl : 'reload');
482
+ this.ping(r);
483
+ }
484
+ cancel(id, gen, by) {
485
+ return this.serial(async () => {
486
+ const r = this.request(id, gen);
487
+ this.held(r, by.grant);
488
+ if (!['waiting', 'held', 'parked'].includes(r.state))
489
+ throw new SignInRefused('stale');
490
+ await this.settle(r, 'cancelled');
491
+ return structuredClone(r);
492
+ });
493
+ }
494
+ notNow(id, gen, by) {
495
+ return this.serial(async () => {
496
+ const r = this.request(id, gen);
497
+ this.held(r, by.grant);
498
+ if (!['waiting', 'held'].includes(r.state))
499
+ throw new SignInRefused('stale');
500
+ this.drop(r);
501
+ const broker = this.broker(r.member);
502
+ await this.closePrivate(r);
503
+ r.state = 'parked';
504
+ this.data.host[r.id].confirmed = [];
505
+ this.commit();
506
+ await broker.fence(false);
507
+ await broker.navigateAgent('reload');
508
+ this.ping(r);
509
+ return structuredClone(r);
510
+ });
511
+ }
512
+ reopen(id, gen, by) {
513
+ if (!this.fixture)
514
+ return Promise.reject(new HandoffUnprotected());
515
+ return this.serial(async () => {
516
+ const r = this.request(id, gen);
517
+ this.access(by.grant, r.member, true);
518
+ if (r.state !== 'parked')
519
+ throw new SignInRefused('stale');
520
+ r.state = 'waiting';
521
+ r.gen++;
522
+ r.expires = this.now() + (this.s.options.requestTtlMs ?? 1_800_000);
523
+ this.commit();
524
+ await this.broker(r.member).fence(true);
525
+ await this.s.park(r.member, r.sessionKey);
526
+ this.ping(r);
527
+ return structuredClone(r);
528
+ });
529
+ }
530
+ retry(id, gen, by) {
531
+ if (!this.fixture)
532
+ return Promise.reject(new HandoffUnprotected());
533
+ return this.serial(async () => {
534
+ const r = this.request(id, gen);
535
+ this.access(by.grant, r.member, true);
536
+ if (r.state !== 'settled' || r.settled?.state === 'verified')
537
+ throw new SignInRefused('stale');
538
+ return this.raiseLocked({ member: r.member, sessionKey: r.sessionKey, checkUrl: this.data.host[r.id].checkUrl,
539
+ reasons: r.reasons, hints: r.hints }, r.id);
540
+ });
541
+ }
542
+ async sweep() {
543
+ await this.serial(async () => {
544
+ for (const r of this.data.requests) {
545
+ if (['waiting', 'held'].includes(r.state) && this.now() >= r.expires) {
546
+ await this.settle(r, 'expired');
547
+ continue;
548
+ }
549
+ const l = this.leases.get(r.id);
550
+ if (l && r.state === 'held' && this.now() >= Math.min(l.deadline, l.value.expires))
551
+ await this.lapse(r);
552
+ }
553
+ });
554
+ }
555
+ async closePrivate(r) {
556
+ this.closing.add(r.id);
557
+ const broker = this.broker(r.member);
558
+ try {
559
+ await broker.closePrivate();
560
+ }
561
+ catch {
562
+ r.state = 'settled';
563
+ r.settled = { state: 'failed', reason: 'browser-gone', at: this.now() };
564
+ this.unavailable.add(r.member);
565
+ this.commit();
566
+ this.ping(r);
567
+ void this.recover(r.member);
568
+ throw new SignInRefused('unsupported');
569
+ }
570
+ this.currentBroker(r.member, broker);
571
+ this.closing.delete(r.id);
572
+ }
573
+ async lapse(r) {
574
+ this.drop(r);
575
+ await this.closePrivate(r);
576
+ r.state = 'waiting';
577
+ r.gen++;
578
+ this.data.host[r.id].confirmed = [];
579
+ this.commit();
580
+ this.ping(r); // fence stays on
581
+ }
582
+ revokeGrant(grant) {
583
+ this.revoked.add(grant);
584
+ const requests = [...this.leases.entries()].filter(([, l]) => l.grant === grant).map(([id]) => this.request(id));
585
+ // Before any await: revoke every capability, detach input and close streams. No grace on revoke.
586
+ for (const r of requests) {
587
+ this.closing.add(r.id);
588
+ this.drop(r);
589
+ }
590
+ for (const s of this.streams)
591
+ if (s.grant === grant)
592
+ s.close('revoked');
593
+ return this.serial(async () => {
594
+ for (const r of requests) {
595
+ if (r.state === 'settled')
596
+ continue;
597
+ try {
598
+ await this.broker(r.member).closePrivate();
599
+ r.state = 'waiting';
600
+ r.gen++;
601
+ this.data.host[r.id].confirmed = [];
602
+ this.commit();
603
+ this.closing.delete(r.id);
604
+ this.ping(r);
605
+ }
606
+ catch {
607
+ // A possibly-open private target cannot be reused. Fence remains held; no new lease.
608
+ r.state = 'settled';
609
+ r.settled = { state: 'failed', reason: 'browser-gone', at: this.now() };
610
+ this.unavailable.add(r.member);
611
+ this.commit();
612
+ this.ping(r);
613
+ }
614
+ }
615
+ }).then(async () => { for (const member of new Set(requests.map(r => r.member)))
616
+ if (this.unavailable.has(member))
617
+ await this.recover(member); });
618
+ }
619
+ browserGone(member, expected = this.brokers.get(member)) {
620
+ // An old endpoint's delayed onExit must not tear down its replacement.
621
+ if (!expected || this.brokers.get(member) !== expected || this.attaching.has(member))
622
+ return Promise.resolve();
623
+ this.unavailable.add(member);
624
+ this.thumbs.delete(member);
625
+ this.bindingVersions.set(member, (this.bindingVersions.get(member) ?? 0) + 1);
626
+ this.streams.forEach(s => { if (s.member === member)
627
+ s.close('browser-gone'); });
628
+ this.epochs.set(member, (this.epochs.get(member) ?? 0) + 1);
629
+ return this.serial(async () => {
630
+ if (this.brokers.get(member) !== expected || this.attaching.has(member))
631
+ return;
632
+ for (const r of this.data.requests.filter(r => r.member === member)) {
633
+ this.drop(r);
634
+ recoverResume(r);
635
+ if (r.state === 'held' || r.state === 'checking') {
636
+ r.state = 'settled';
637
+ r.settled = { state: 'failed', reason: 'browser-gone', at: this.now() };
638
+ }
639
+ else if (r.state === 'waiting' || r.state === 'parked')
640
+ r.gen++;
641
+ }
642
+ this.unavailable.add(member);
643
+ this.commit();
644
+ this.s.ping?.(member, 'state');
645
+ }).then(() => this.recover(member));
646
+ }
647
+ async recover(member) {
648
+ if (this.stopped || this.failed || this.recovery.has(member) || this.attaching.has(member))
649
+ return;
650
+ const old = this.brokers.get(member);
651
+ const epoch = this.epochs.get(member) ?? 0;
652
+ const current = () => !this.stopped && !this.failed && !this.attaching.has(member)
653
+ && this.brokers.get(member) === old && (this.epochs.get(member) ?? 0) === epoch;
654
+ const attempts = this.s.restart ? this.s.options.recovery?.attempts ?? 3 : 0;
655
+ // Close the old kit-owned endpoint before replacing its map entry; otherwise its server is orphaned.
656
+ try {
657
+ await old?.close();
658
+ }
659
+ catch {
660
+ if (current()) {
661
+ this.exhausted.add(member);
662
+ this.s.ping?.(member, 'state');
663
+ }
664
+ return;
665
+ }
666
+ if (!current())
667
+ return;
668
+ for (let attempt = 1; attempt <= attempts && current(); attempt++) {
669
+ const delay = this.s.options.recovery?.backoffMs[attempt - 1] ?? [1_000, 5_000, 15_000][attempt - 1];
670
+ this.recovery.set(member, { attempt, of: attempts, nextAt: this.now() + delay });
671
+ this.s.ping?.(member, 'state');
672
+ if (delay)
673
+ await new Promise(resolve => setTimeout(resolve, delay));
674
+ if (!current())
675
+ return;
676
+ try {
677
+ let expired = false;
678
+ let timer;
679
+ const launch = this.s.restart(member, attempt).then(async (broker) => {
680
+ if (expired || !current()) {
681
+ try {
682
+ await broker?.close();
683
+ }
684
+ catch { /* owned stale candidate only */ }
685
+ return undefined;
686
+ }
687
+ return broker;
688
+ });
689
+ let broker;
690
+ try {
691
+ broker = await Promise.race([launch, new Promise(resolve => { timer = setTimeout(() => resolve(undefined), 30_000); })]);
692
+ }
693
+ finally {
694
+ expired = true;
695
+ clearTimeout(timer);
696
+ }
697
+ if (!broker)
698
+ continue;
699
+ if (!current()) {
700
+ await broker.close();
701
+ return;
702
+ }
703
+ // Every recovered endpoint stays fenced until its canonical binding/config/policy ack. Never redispatch.
704
+ await broker.fence(true);
705
+ if (!current()) {
706
+ await broker.close();
707
+ return;
708
+ }
709
+ this.brokers.set(member, broker);
710
+ this.unavailable.delete(member);
711
+ this.recovery.delete(member);
712
+ this.bindingVersions.set(member, (this.bindingVersions.get(member) ?? 0) + 1);
713
+ this.s.ping?.(member, 'state');
714
+ return;
715
+ }
716
+ catch { /* bounded retries; details may contain endpoint credentials, never surface them */ }
717
+ }
718
+ if (current()) {
719
+ this.recovery.delete(member);
720
+ this.exhausted.add(member);
721
+ this.s.ping?.(member, 'state');
722
+ }
723
+ }
724
+ beforeRun(member, sessionKey) {
725
+ return this.serial(async () => {
726
+ const r = this.data.requests.find(r => r.member === member && r.sessionKey === sessionKey && r.state !== 'settled');
727
+ if (!r)
728
+ return true;
729
+ if (r.state !== 'parked')
730
+ return false;
731
+ await this.settle(r, 'cancelled', 'run-replaced');
732
+ return true;
733
+ });
734
+ }
735
+ thumbnail(source, by) {
736
+ this.access(by.grant, source.member, false);
737
+ if (source.kind !== 'browser')
738
+ return Promise.resolve({ state: 'unsupported' });
739
+ if (this.data.requests.some(r => r.member === source.member && open(r)))
740
+ return Promise.resolve({ state: 'private' });
741
+ if (!this.brokers.has(source.member) || this.unavailable.has(source.member) || this.stopped || this.failed)
742
+ return Promise.resolve({ state: 'off' });
743
+ const cached = this.thumbs.get(source.member);
744
+ if (cached && this.now() - cached.at < 5_000)
745
+ return Promise.resolve({ state: 'ok', frame: structuredClone(cached.frame) });
746
+ return this.captureThumbnail(source.member);
747
+ }
748
+ async captureThumbnail(member) {
749
+ const broker = this.broker(member);
750
+ let viewer;
751
+ let timer;
752
+ try {
753
+ viewer = broker.attachViewer({ maxWidth: 320 });
754
+ const result = await Promise.race([viewer.frames[Symbol.asyncIterator]().next(), new Promise(resolve => { timer = setTimeout(() => resolve(undefined), 2_000); })]);
755
+ // Privacy and endpoint identity are rechecked after awaiting a frame.
756
+ this.currentBroker(member, broker);
757
+ if (this.data.requests.some(r => r.member === member && open(r)))
758
+ return { state: 'private' };
759
+ if (!result || result.done || result.value.w > 320)
760
+ return { state: 'unsupported' };
761
+ const frame = structuredClone(result.value);
762
+ this.thumbs.set(member, { at: this.now(), frame });
763
+ return { state: 'ok', frame: structuredClone(frame) };
764
+ }
765
+ catch {
766
+ return { state: 'off' };
767
+ }
768
+ finally {
769
+ clearTimeout(timer);
770
+ viewer?.close();
771
+ }
772
+ }
773
+ live(source, o, on) {
774
+ this.access(o.grant, source.member, !!o.lease);
775
+ const mode = o.lease ? 'control' : 'observe';
776
+ let closed = false;
777
+ let viewer;
778
+ let l;
779
+ let r;
780
+ let attachDeadline;
781
+ let heartbeat;
782
+ const emit = (s) => on.state({ source, mode, ...s });
783
+ const stream = { member: source.member, grant: o.grant, control: !!o.lease, close: why => {
784
+ if (closed)
785
+ return;
786
+ closed = true;
787
+ clearInterval(heartbeat);
788
+ viewer?.close();
789
+ this.streams.delete(stream);
790
+ if (!this.stopped && !this.failed && l && r && this.leases.get(r.id) === l && r.state === 'held') {
791
+ l.attached = false;
792
+ l.deadline = this.now() + l.value.graceMs;
793
+ this.leaseTimer(r, l);
794
+ }
795
+ emit({ phase: 'ended', ...(why ? { why } : {}) });
796
+ } };
797
+ const inert = { input: (_i) => { throw new SignInRefused('not-control'); }, close: () => stream.close() };
798
+ if (source.kind !== 'browser') {
799
+ emit({ phase: 'failed', why: 'unsupported' });
800
+ return inert;
801
+ }
802
+ if (this.stopped || this.failed || this.unavailable.has(source.member)) {
803
+ emit({ phase: 'failed', why: 'browser-gone' });
804
+ return inert;
805
+ }
806
+ if (o.lease) {
807
+ const bound = this.lease(o.lease);
808
+ r = bound.r;
809
+ l = bound.l;
810
+ if (r.member !== source.member || l.grant !== o.grant)
811
+ throw new SignInRefused('not-lease-holder');
812
+ if (r.state !== 'held' || l.attached)
813
+ throw new SignInRefused('held-by-other');
814
+ attachDeadline = l.deadline;
815
+ l.attached = true;
816
+ l.deadline = l.value.expires;
817
+ this.leaseTimer(r, l);
818
+ }
819
+ else if (this.data.requests.some(r => r.member === source.member && open(r))) {
820
+ emit({ phase: 'private' });
821
+ return inert;
822
+ }
823
+ const broker = this.broker(source.member);
824
+ try {
825
+ viewer = broker.attachViewer({ ...(l ? { lease: { epoch: l.value.epoch, nonce: l.value.nonce } } : {}),
826
+ ...(o.maxWidth !== undefined ? { maxWidth: o.maxWidth } : {}) });
827
+ }
828
+ catch {
829
+ if (l && r) {
830
+ l.attached = false;
831
+ l.deadline = attachDeadline;
832
+ this.leaseTimer(r, l);
833
+ }
834
+ emit({ phase: 'failed', why: 'browser-gone' });
835
+ return inert;
836
+ }
837
+ this.streams.add(stream);
838
+ emit({ phase: 'connecting' });
839
+ if (l && r)
840
+ heartbeat = setInterval(() => {
841
+ try {
842
+ this.lease(l.value);
843
+ if (r.state !== 'held') {
844
+ stream.close('superseded');
845
+ return;
846
+ }
847
+ l.value.expires = this.now() + (this.s.options.leaseTtlMs ?? 600_000);
848
+ l.deadline = l.value.expires;
849
+ this.leaseTimer(r, l);
850
+ }
851
+ catch {
852
+ stream.close('expired');
853
+ }
854
+ }, Math.max(1, Math.min(30_000, (this.s.options.leaseTtlMs ?? 600_000) / 3)));
855
+ heartbeat?.unref?.();
856
+ // Origins and the atomic input pause come from the broker, not frame contents or device input.
857
+ if (l && r)
858
+ void (async () => {
859
+ try {
860
+ for await (const state of viewer.states) {
861
+ if (closed)
862
+ break;
863
+ this.lease(l.value);
864
+ emit({ phase: 'live', ...state, leaseExpires: l.value.expires });
865
+ }
866
+ }
867
+ catch {
868
+ stream.close('browser-gone');
869
+ }
870
+ })();
871
+ void (async () => {
872
+ try {
873
+ for await (const frame of viewer.frames) {
874
+ if (closed)
875
+ break;
876
+ if (l && r) {
877
+ this.lease(l.value);
878
+ const state = broker.privateState();
879
+ if (!state)
880
+ throw new SignInRefused('stale');
881
+ l.value.expires = this.now() + (this.s.options.leaseTtlMs ?? 600_000);
882
+ l.deadline = l.value.expires;
883
+ this.leaseTimer(r, l);
884
+ emit({ phase: 'live', ...state, leaseExpires: l.value.expires });
885
+ }
886
+ else {
887
+ if (this.data.requests.some(r => r.member === source.member && open(r))) {
888
+ stream.close('superseded');
889
+ break;
890
+ }
891
+ emit({ phase: 'live' });
892
+ }
893
+ if (!closed)
894
+ on.frame(frame);
895
+ }
896
+ stream.close();
897
+ }
898
+ catch {
899
+ stream.close('browser-gone');
900
+ }
901
+ })();
902
+ return { input: (i) => {
903
+ if (closed)
904
+ throw new SignInRefused('stale');
905
+ if (!l || !r)
906
+ throw new SignInRefused('not-control');
907
+ this.lease(l.value);
908
+ if (r.state !== 'held')
909
+ throw new SignInRefused('stale');
910
+ viewer.input(i); // Broker enforces exact bound origin, target, epoch and nonce at injection.
911
+ }, close: () => stream.close() };
912
+ }
913
+ forget(member, site, by) {
914
+ return this.serial(async () => {
915
+ this.access(by.grant, member, true);
916
+ if (this.data.requests.some(r => r.member === member && r.state !== 'settled'))
917
+ throw new SignInRefused('already-open');
918
+ if (site === 'all')
919
+ throw new SignInRefused('unsupported');
920
+ const requests = this.data.requests.filter(r => r.member === member && r.site === site);
921
+ const origins = [...new Set(requests.flatMap(r => [r.origin, ...this.data.host[r.id].confirmed]))];
922
+ if (!origins.length)
923
+ throw new SignInRefused('unsupported');
924
+ await this.broker(member).clearSite(origins);
925
+ this.data.verified[member] = (Object.hasOwn(this.data.verified, member) ? this.data.verified[member] : []).filter(s => s !== site);
926
+ this.commit();
927
+ this.s.ping?.(member, 'state');
928
+ });
929
+ }
930
+ knownIdps() {
931
+ return this.s.options.knownIdps ?? ['https://accounts.google.com', 'https://login.microsoftonline.com', 'https://login.live.com', 'https://appleid.apple.com', 'https://github.com'];
932
+ }
933
+ async close() {
934
+ // Invalidate synchronously before waiting on outstanding operations.
935
+ this.stopped = true;
936
+ clearTimeout(this.ttl);
937
+ for (const l of this.leases.values())
938
+ clearTimeout(l.timer);
939
+ this.streams.forEach(s => s.close('browser-gone'));
940
+ this.epochs.forEach((n, member) => this.epochs.set(member, n + 1));
941
+ await this.queue;
942
+ for (const broker of this.brokers.values())
943
+ await broker.close();
944
+ }
945
+ }