@cortexkit/opencode-openai-auth 1.2.0 → 1.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 (49) hide show
  1. package/dist/bin.js +2 -2
  2. package/dist/{codex-request-CjXETpTA.d.ts → codex-request-B9D0e97Q.d.ts} +145 -28
  3. package/dist/{index-ehjj89rx.js → index-1mbgsxsr.js} +1 -1
  4. package/dist/{index-htvcmtcx.js → index-2pgkrebq.js} +1 -1
  5. package/dist/{index-rwbrhkse.js → index-4v2fs42x.js} +1 -1
  6. package/dist/index-8x1aj7q3.js +2 -0
  7. package/dist/{index-6n4pnfkg.js → index-ex6nx3mq.js} +1 -1
  8. package/dist/{index-qxbchh2d.js → index-gn2b097f.js} +1 -1
  9. package/dist/{index-qp5hdpw8.js → index-m3ctrabv.js} +1 -1
  10. package/dist/{index-03em8hv1.js → index-psjyexj3.js} +15 -15
  11. package/dist/{index-ffg0sgg5.js → index-tddr876f.js} +1 -1
  12. package/dist/index-tn8x5tz8.js +90 -0
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.js +1 -1
  15. package/dist/{model-CQcw10oW.d.ts → model-BdITTmeb.d.ts} +1 -1
  16. package/dist/{port-file-BDJnzgHH.d.ts → port-file-DbHIVC9a.d.ts} +1 -1
  17. package/dist/{protocol-C-kagE6O.d.ts → protocol-MysYPAho.d.ts} +1 -1
  18. package/dist/rpc/port-file.d.ts +1 -1
  19. package/dist/rpc/port-file.js +1 -1
  20. package/dist/rpc/protocol.d.ts +1 -1
  21. package/dist/rpc/protocol.js +1 -1
  22. package/dist/rpc/rpc-client.d.ts +3 -3
  23. package/dist/rpc/rpc-client.js +1 -1
  24. package/dist/{sidebar-state-BfQ4mpZO.d.ts → sidebar-state-CVSok7zv.d.ts} +20 -4
  25. package/dist/sidebar-state.d.ts +1 -1
  26. package/dist/sidebar-state.js +1 -1
  27. package/dist/tui-preferences.js +1 -1
  28. package/dist/tui.d.ts +2 -2
  29. package/dist/v2/server.d.ts +2 -2
  30. package/dist/v2/server.js +1 -1
  31. package/package.json +4 -4
  32. package/src/tui-compiled/raw/shared/0332701351b3eeec-format.js +69 -1
  33. package/src/tui-compiled/raw/shared/0f77ebc3c914c9c6-custody.js +225 -16
  34. package/src/tui-compiled/raw/shared/169b2b4bf30909bc-consumer.js +130 -5
  35. package/src/tui-compiled/raw/shared/375d9cca2def037d-private-files.js +10 -2
  36. package/src/tui-compiled/raw/shared/94f53cfd572ba4bb-availability.js +106 -0
  37. package/src/tui-compiled/raw/shared/d0f0b6be200311af-insula.js +286 -21
  38. package/src/tui-compiled/raw/shared/dc9290c2a7552ba6-watch.js +253 -0
  39. package/src/tui-compiled/raw/shared/e8d3c8da776ef9cd-index.js +4 -1
  40. package/src/tui-compiled/runtime/shared/0332701351b3eeec-format.js +69 -1
  41. package/src/tui-compiled/runtime/shared/0f77ebc3c914c9c6-custody.js +225 -16
  42. package/src/tui-compiled/runtime/shared/169b2b4bf30909bc-consumer.js +130 -5
  43. package/src/tui-compiled/runtime/shared/375d9cca2def037d-private-files.js +10 -2
  44. package/src/tui-compiled/runtime/shared/94f53cfd572ba4bb-availability.js +106 -0
  45. package/src/tui-compiled/runtime/shared/d0f0b6be200311af-insula.js +286 -21
  46. package/src/tui-compiled/runtime/shared/dc9290c2a7552ba6-watch.js +253 -0
  47. package/src/tui-compiled/runtime/shared/e8d3c8da776ef9cd-index.js +4 -1
  48. package/dist/index-f21addzm.js +0 -90
  49. package/dist/index-r2s51ac1.js +0 -2
@@ -1,11 +1,14 @@
1
1
  import { ClaustrumCredentialError, } from "@cortexkit/claustrum-client";
2
2
  import { createLogger } from "./cc9e0702a752cea7-index.js";
3
+ import { ClaustrumUnreachableError, classifyVaultFailure, } from "./94f53cfd572ba4bb-availability.js";
3
4
  import { readClaustrumEnrollmentToken } from "./b9588ad4725c1d10-enrollment.js";
4
5
  import { ClaustrumConsumerError } from "./ca0fccaba1d87af4-errors.js";
5
6
  import { CUSTODY_PLACEHOLDER_PREFIX } from "./d9d2892a4c371301-host-slot.js";
6
7
  /** Only a new version of the same account may replace an in-flight 401. */
7
8
  export function isScopedCredentialRotation(served, current) {
8
9
  return (current !== undefined &&
10
+ !current.fromKeptReceipt &&
11
+ !served.fromKeptReceipt &&
9
12
  current.credentialId === served.credentialId &&
10
13
  current.accountIdentity === served.accountIdentity &&
11
14
  current.recordVersion !== served.recordVersion);
@@ -45,6 +48,8 @@ export function decideScopedRetryAfter401(site, served, current, logger = defaul
45
48
  * so it cannot expire while a long request is still using it.
46
49
  */
47
50
  export const SERVING_MARGIN_MS = 300_000;
51
+ /** A kept receipt needs a minute of headroom, not the vault's refresh demand. */
52
+ export const KEPT_RECEIPT_MARGIN_MS = 60_000;
48
53
  function invalidMaterial() {
49
54
  return new ClaustrumConsumerError('invalid-material', 'Claustrum returned invalid credential material');
50
55
  }
@@ -100,11 +105,11 @@ function grantReachOf(listed) {
100
105
  }
101
106
  /**
102
107
  * Reads this consumer's vault credentials (list, fetch, 401 report), with the
103
- * enrollment token as authorization. Used by every host of a plugin. There is
104
- * deliberately no credential cache and no single-flight of credential reads:
105
- * each physical send is authorized by the vault, so a revoked enrollment or a
106
- * changed record takes effect on the next send. The enrollment token is
107
- * re-read per operation so an operator's reissue on disk is picked up.
108
+ * enrollment token as authorization. Used by every host of a plugin. Every
109
+ * physical send asks the vault first while reachable. Only an unreachable vault permits
110
+ * reuse of the last validated receipt, in memory and bounded by its expiry.
111
+ * The enrollment token is re-read per operation so revocation and disconnect
112
+ * still take effect without a process restart.
108
113
  */
109
114
  export class ClaustrumScopedCustody {
110
115
  #client;
@@ -116,6 +121,16 @@ export class ClaustrumScopedCustody {
116
121
  #logger;
117
122
  #provenance = new WeakMap();
118
123
  #reports = new WeakMap();
124
+ #kept = new Map();
125
+ #keptAttempts = new WeakMap();
126
+ #setTimeout;
127
+ #clearTimeout;
128
+ #outage = false;
129
+ #outageError = new ClaustrumUnreachableError();
130
+ #probeTimer;
131
+ #probing = false;
132
+ #lastProbeAt = -Infinity;
133
+ #probeDelay = 2_000;
119
134
  #closed = false;
120
135
  constructor(options) {
121
136
  const tokenPath = options.tokenPath;
@@ -134,6 +149,63 @@ export class ClaustrumScopedCustody {
134
149
  this.#requireAssertion = options.requireAssertion ?? false;
135
150
  this.#now = options.now ?? Date.now;
136
151
  this.#logger = options.logger ?? defaultLogger;
152
+ this.#setTimeout = options.setTimeoutImpl ?? setTimeout;
153
+ this.#clearTimeout = options.clearTimeoutImpl ?? clearTimeout;
154
+ }
155
+ /** A watch losing its transport is the same outage as an unreachable read. */
156
+ transportLost(error) {
157
+ if (this.#closed || this.#outage)
158
+ return;
159
+ this.#outage = true;
160
+ this.#outageError =
161
+ error instanceof ClaustrumCredentialError
162
+ ? error
163
+ : new ClaustrumUnreachableError();
164
+ this.#logger.warn('serving kept vault receipt', { reason: 'unreachable' });
165
+ this.#scheduleProbe();
166
+ }
167
+ /** Any vault answer ends transport fallback, even when it refuses access. */
168
+ reachable() {
169
+ if (this.#closed || !this.#outage)
170
+ return;
171
+ this.#outage = false;
172
+ if (this.#probeTimer)
173
+ this.#clearTimeout(this.#probeTimer);
174
+ this.#probeTimer = undefined;
175
+ this.#probeDelay = 2_000;
176
+ for (const kept of this.#kept.values())
177
+ kept.since = null;
178
+ (this.#logger.info ?? defaultLogger.info)('vault receipt serving resumed', { reason: 'reachable' });
179
+ }
180
+ #scheduleProbe() {
181
+ if (this.#closed || !this.#outage || this.#probeTimer || this.#probing)
182
+ return;
183
+ this.#probeTimer = this.#setTimeout(() => {
184
+ this.#probeTimer = undefined;
185
+ this.#probe(true);
186
+ }, this.#probeDelay);
187
+ this.#probeTimer.unref?.();
188
+ }
189
+ #probe(idle = false) {
190
+ if (this.#closed || !this.#outage || this.#probing)
191
+ return;
192
+ if (this.#now() - this.#lastProbeAt < 1_000) {
193
+ this.#scheduleProbe();
194
+ return;
195
+ }
196
+ this.#lastProbeAt = this.#now();
197
+ this.#probing = true;
198
+ // Both triggers share list_scoped discovery, including its receipt-drop rules.
199
+ // Sends never await discovery or let idle backoff delay the next send probe.
200
+ void this.discover()
201
+ .catch(() => {
202
+ if (idle && this.#outage)
203
+ this.#probeDelay = Math.min(this.#probeDelay * 2, 30_000);
204
+ })
205
+ .finally(() => {
206
+ this.#probing = false;
207
+ this.#scheduleProbe();
208
+ });
137
209
  }
138
210
  #check(signal) {
139
211
  if (this.#closed)
@@ -172,17 +244,24 @@ export class ClaustrumScopedCustody {
172
244
  }
173
245
  catch (error) {
174
246
  this.#check(signal);
247
+ if (classifyVaultFailure(error) === 'unreachable')
248
+ this.transportLost(error);
249
+ else
250
+ this.reachable();
175
251
  // Keep the vault's own refusals (ClaustrumCredentialError, carrying
176
252
  // code, class and action); replace any other error, whose text may echo
177
253
  // request params that include the enrollment token.
178
254
  if (error instanceof ClaustrumCredentialError)
179
255
  throw error;
256
+ if (classifyVaultFailure(error) === 'unreachable')
257
+ throw new ClaustrumUnreachableError();
180
258
  throw new ClaustrumConsumerError('unavailable', 'Claustrum scoped operation unavailable');
181
259
  }
182
260
  finally {
183
261
  removeAbortListener?.();
184
262
  }
185
263
  this.#check(signal);
264
+ this.reachable();
186
265
  return result;
187
266
  }
188
267
  /**
@@ -192,8 +271,22 @@ export class ClaustrumScopedCustody {
192
271
  * skipped records: they simply are not this consumer's.
193
272
  */
194
273
  async discover(signal) {
195
- const token = await this.#token(signal);
196
- const inventory = await this.#call(() => this.#client.listScoped(token), signal);
274
+ let inventory;
275
+ let token;
276
+ try {
277
+ token = await this.#token(signal);
278
+ }
279
+ catch (error) {
280
+ this.#kept.clear();
281
+ throw error;
282
+ }
283
+ try {
284
+ inventory = await this.#call(() => this.#client.listScoped(token), signal);
285
+ }
286
+ catch (error) {
287
+ this.listFailed(error);
288
+ throw error;
289
+ }
197
290
  const skipped = [];
198
291
  const candidates = [];
199
292
  const counts = new Map();
@@ -241,22 +334,119 @@ export class ClaustrumScopedCustody {
241
334
  };
242
335
  }
243
336
  /**
244
- * Fetch the credential for one physical send and wrap it in a fresh receipt.
245
- * The vault's served identity (or, without one, the plugin's parse of the
246
- * token) must equal the roster's when both are present. Without
247
- * `requireAssertion`, absence on either side proves nothing and does not
248
- * refuse; the receipt records what the vault asserted separately from what
249
- * the roster expected. With it, a reply that does not itself name the
250
- * credential id and the expected account is refused.
337
+ * Apply a failed account listing: any answer from the vault (a refusal such
338
+ * as a revoked enrollment's `not_found`) drops every kept receipt, while an
339
+ * unreachable vault keeps them. A vault watch that ends with the same
340
+ * refusal calls this too, so both paths treat it alike.
341
+ */
342
+ listFailed(error) {
343
+ if (classifyVaultFailure(error) === 'unreachable')
344
+ this.transportLost(error);
345
+ else {
346
+ this.reachable();
347
+ this.#kept.clear();
348
+ }
349
+ }
350
+ /** Discard bearer material when a route is removed, declined or rebound. */
351
+ forget(credentialId) {
352
+ this.#kept.delete(credentialId);
353
+ }
354
+ /** Apply an already-held roster; this never fetches credentials or writes it. */
355
+ retain(identities) {
356
+ const current = new Map(identities.map((identity) => [identity.credentialId, identity]));
357
+ for (const [id, kept] of this.#kept) {
358
+ const identity = current.get(id);
359
+ if (!identity?.enabled ||
360
+ identity.state !== 'active' ||
361
+ identity.credentialType !== kept.attempt.credentialType ||
362
+ identity.accountIdentity !== kept.attempt.expectedAccountIdentity)
363
+ this.forget(id);
364
+ }
365
+ }
366
+ /** A frozen, non-secret summary for a plugin's status display. */
367
+ keptReceiptState() {
368
+ let since = null;
369
+ for (const [id, kept] of this.#kept) {
370
+ if (!this.#usable(kept.attempt)) {
371
+ this.forget(id);
372
+ continue;
373
+ }
374
+ if (kept.since !== null)
375
+ since = Math.min(since ?? kept.since, kept.since);
376
+ }
377
+ return Object.freeze({ active: since !== null, since });
378
+ }
379
+ #usable(attempt) {
380
+ return attempt.expiresAtMs === null
381
+ ? attempt.credentialType === 'api_key'
382
+ : attempt.expiresAtMs - this.#now() > KEPT_RECEIPT_MARGIN_MS;
383
+ }
384
+ /** Ask the vault while reachable; retain one validated receipt in memory only. */
385
+ async authorize(identity, signal, routeId = 'vault') {
386
+ this.#check(signal);
387
+ const { credentialId, credentialType, accountIdentity } = identity;
388
+ let kept = this.#kept.get(credentialId);
389
+ if (kept &&
390
+ (kept.attempt.credentialType !== credentialType ||
391
+ kept.attempt.expectedAccountIdentity !== accountIdentity)) {
392
+ this.forget(credentialId);
393
+ kept = undefined;
394
+ }
395
+ // A missing or invalid enrollment is not a transport outage.
396
+ let token;
397
+ try {
398
+ token = await this.#token(signal);
399
+ }
400
+ catch (error) {
401
+ this.#kept.clear();
402
+ throw error;
403
+ }
404
+ if (this.#outage && kept) {
405
+ this.#probe();
406
+ return this.#serveKept(credentialId, kept, token, routeId);
407
+ }
408
+ try {
409
+ const attempt = await this.#authorizeFresh({ credentialId, credentialType, accountIdentity }, token, signal);
410
+ this.#kept.set(credentialId, { attempt, routeId, since: null });
411
+ return attempt;
412
+ }
413
+ catch (error) {
414
+ this.#check(signal);
415
+ if (classifyVaultFailure(error) !== 'unreachable') {
416
+ this.forget(credentialId);
417
+ throw error;
418
+ }
419
+ return this.#serveKept(credentialId, kept, token, routeId, error);
420
+ }
421
+ }
422
+ #serveKept(credentialId, kept, token, routeId, error = this.#outageError) {
423
+ // A concurrent refusal or 401 may already have discarded this entry.
424
+ if (!kept ||
425
+ this.#kept.get(credentialId) !== kept ||
426
+ !this.#usable(kept.attempt)) {
427
+ this.forget(credentialId);
428
+ throw error;
429
+ }
430
+ if (kept.since === null)
431
+ kept.since = this.#now();
432
+ const attempt = Object.freeze(Object.defineProperty({ ...kept.attempt, fromKeptReceipt: true }, 'accessToken', { value: kept.attempt.accessToken, enumerable: false }));
433
+ this.#provenance.set(attempt, token);
434
+ this.#keptAttempts.set(attempt, routeId);
435
+ return attempt;
436
+ }
437
+ /**
438
+ * Fetch and validate a fresh receipt before retaining it. The vault assertion
439
+ * (or the plugin's token parse) must agree with the roster when both exist.
440
+ * With requireAssertion, an absent assertion also refuses rather than
441
+ * treating the roster's expectation as proof of the served account.
251
442
  */
252
- async authorize(identity, signal) {
443
+ async #authorizeFresh(identity, token, signal) {
253
444
  if (!identity.credentialId)
254
445
  throw new ClaustrumConsumerError('route-unavailable', 'Claustrum dispatch requires a credential id');
255
446
  // Capture the caller's fields before yielding so a later mutation cannot move the fence.
256
447
  const { credentialId, credentialType, accountIdentity } = identity;
257
448
  if (this.#requireAssertion && accountIdentity === undefined)
258
449
  throw new ClaustrumConsumerError('identity-unasserted', 'Claustrum dispatch requires a known account identity');
259
- const token = await this.#token(signal);
260
450
  const served = await this.#call(() => this.#client.getScoped({
261
451
  credentialId,
262
452
  enrollmentToken: token,
@@ -338,6 +528,21 @@ export class ClaustrumScopedCustody {
338
528
  const token = this.#provenance.get(attempt);
339
529
  if (!token)
340
530
  throw new ClaustrumConsumerError('no-receipt', 'Claustrum failure report requires an original dispatch receipt');
531
+ const keptRoute = this.#keptAttempts.get(attempt);
532
+ if (keptRoute !== undefined) {
533
+ this.forget(attempt.credentialId);
534
+ this.#logger.debug('scoped 401 not reported', {
535
+ routeId: keptRoute,
536
+ reason: 'kept-receipt',
537
+ });
538
+ return;
539
+ }
540
+ // The provider rejected this token, so it must not come back as a kept
541
+ // receipt during a later outage. Drop it before reporting, so a report that
542
+ // fails because the vault is unreachable still drops it.
543
+ const kept = this.#kept.get(attempt.credentialId);
544
+ if (kept && kept.attempt.recordVersion === attempt.recordVersion)
545
+ this.forget(attempt.credentialId);
341
546
  const pending = this.#reports.get(attempt);
342
547
  if (pending)
343
548
  return pending;
@@ -366,6 +571,10 @@ export class ClaustrumScopedCustody {
366
571
  if (this.#closed)
367
572
  return;
368
573
  this.#closed = true;
574
+ if (this.#probeTimer)
575
+ this.#clearTimeout(this.#probeTimer);
576
+ this.#probeTimer = undefined;
577
+ this.#kept.clear();
369
578
  this.#client.close();
370
579
  }
371
580
  }
@@ -5,6 +5,7 @@ import { readClaustrumEnrollmentToken } from "./b9588ad4725c1d10-enrollment.js";
5
5
  import { ClaustrumConsumerError } from "./ca0fccaba1d87af4-errors.js";
6
6
  import { acquireInsulaQuotaSource } from "./d0f0b6be200311af-insula.js";
7
7
  import { acceptVaultRoute, declineVaultRoute, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, vaultRoutingRows, } from "./30f5bc480f31f51c-roster.js";
8
+ import { SAFETY_POLL_MS, ScopedRosterWatch } from "./dc9290c2a7552ba6-watch.js";
8
9
  /**
9
10
  * One plugin host's view of its vault accounts. It turns vault credentials
10
11
  * into rows for `/routing`, authorizes each physical send with the vault, and
@@ -18,6 +19,8 @@ export class ClaustrumConsumer {
18
19
  #shutdown = new AbortController();
19
20
  #logger;
20
21
  #custody;
22
+ /** The custody's client, kept so `start()` can open its vault watch. */
23
+ #client;
21
24
  #connecting;
22
25
  #refreshing;
23
26
  #roster;
@@ -29,6 +32,10 @@ export class ClaustrumConsumer {
29
32
  #timer;
30
33
  #started = false;
31
34
  #insula;
35
+ #vaultWatch;
36
+ #releaseInsulaWatch;
37
+ /** When this instance last completed a discovery, by `now`. */
38
+ #discoveredAt;
32
39
  constructor(options) {
33
40
  this.#options = options;
34
41
  this.#logger = options.logger ?? createLogger('claustrum');
@@ -83,6 +90,7 @@ export class ClaustrumConsumer {
83
90
  client.close();
84
91
  this.#assertOpen();
85
92
  }
93
+ this.#client = client;
86
94
  this.#custody = new ClaustrumScopedCustody({
87
95
  client,
88
96
  family: this.#options.family,
@@ -91,6 +99,8 @@ export class ClaustrumConsumer {
91
99
  requireAssertion: this.#options.requireAssertion,
92
100
  now: this.#options.now,
93
101
  logger: this.#logger,
102
+ setTimeoutImpl: this.#options.setTimeoutImpl,
103
+ clearTimeoutImpl: this.#options.clearTimeoutImpl,
94
104
  });
95
105
  if (this.#options.insula && client.insula) {
96
106
  this.#insula = acquireInsulaQuotaSource(client.insula, this.#options.tokenPath);
@@ -122,6 +132,28 @@ export class ClaustrumConsumer {
122
132
  ? latest.reach
123
133
  : undefined;
124
134
  }
135
+ /** How this consumer currently learns about changes, without I/O. */
136
+ watchState() {
137
+ return Object.freeze({
138
+ vault: this.#vaultWatch?.mode ?? 'polling',
139
+ ...(this.#options.insula && {
140
+ insula: this.#insula?.source.watchMode() ?? 'polling',
141
+ }),
142
+ });
143
+ }
144
+ /** Close the watches with the connection they belong to. */
145
+ #stopWatch() {
146
+ this.#vaultWatch?.stop();
147
+ this.#vaultWatch = undefined;
148
+ this.#client = undefined;
149
+ this.#releaseInsulaWatch?.();
150
+ this.#releaseInsulaWatch = undefined;
151
+ }
152
+ /** Whether any account is using an in-memory outage receipt, without I/O. */
153
+ keptReceiptState() {
154
+ return (this.#custody?.keptReceiptState() ??
155
+ Object.freeze({ active: false, since: null }));
156
+ }
125
157
  /** Rows for `/routing`, from the last committed roster. */
126
158
  routingRows() {
127
159
  return vaultRoutingRows(this.#roster);
@@ -134,6 +166,7 @@ export class ClaustrumConsumer {
134
166
  if (!(await this.#active())) {
135
167
  this.#roster = undefined;
136
168
  this.#grantReach = undefined;
169
+ this.#stopWatch();
137
170
  this.#custody?.close();
138
171
  this.#custody = undefined;
139
172
  this.#insula?.release();
@@ -164,6 +197,7 @@ export class ClaustrumConsumer {
164
197
  this.#assertOpen();
165
198
  const changed = roster?.view !== this.#roster?.view;
166
199
  this.#roster = roster;
200
+ custody.retain(roster?.rows ?? []);
167
201
  // Each discovery replaces the previous value, including with unknown. A
168
202
  // refresh that served a peer's roster discovered nothing and leaves it;
169
203
  // grantReach() hides it once the roster's view no longer matches.
@@ -175,12 +209,23 @@ export class ClaustrumConsumer {
175
209
  // notification, and the poll runs every few seconds.
176
210
  if (roster && changed)
177
211
  this.#options.onRoster?.(roster);
212
+ this.#discoveredAt = (this.#options.now ?? Date.now)();
178
213
  return roster;
179
214
  })().finally(() => {
180
215
  this.#refreshing = undefined;
181
216
  });
182
217
  return this.#refreshing;
183
218
  }
219
+ /**
220
+ * Poll in the background and open the vault and insula watches. Until the
221
+ * vault watch is accepted, and whenever it is unavailable, every tick lists
222
+ * the vault exactly as before watches existed. While it is open a tick
223
+ * lists only when five minutes have passed since the last discovery, since
224
+ * the watch keeps no history; it still checks `isCustodyActive`, so a switch
225
+ * to local mode closes the connection as quickly as before. Insula is
226
+ * polled on the tick only while its own watch is not open. A host not in
227
+ * vault mode makes no request.
228
+ */
184
229
  start() {
185
230
  if (this.#started)
186
231
  return;
@@ -188,8 +233,10 @@ export class ClaustrumConsumer {
188
233
  this.#started = true;
189
234
  const tick = async () => {
190
235
  try {
191
- await this.refresh();
236
+ if (await this.#listDue())
237
+ await this.refresh();
192
238
  await this.refreshInsulaQuota();
239
+ this.#openWatch();
193
240
  }
194
241
  catch (error) {
195
242
  if (!this.#shutdown.signal.aborted)
@@ -208,6 +255,65 @@ export class ClaustrumConsumer {
208
255
  };
209
256
  void tick();
210
257
  }
258
+ /** Whether a background tick must list the vault now. */
259
+ async #listDue() {
260
+ if (this.#vaultWatch?.mode !== 'watching')
261
+ return true;
262
+ const now = (this.#options.now ?? Date.now)();
263
+ if (this.#discoveredAt === undefined ||
264
+ now - this.#discoveredAt >= SAFETY_POLL_MS)
265
+ return true;
266
+ // refresh() is what closes the connection when the host left vault mode.
267
+ return !(await this.#active());
268
+ }
269
+ /**
270
+ * Open the vault and insula watches once a vault connection exists. Only
271
+ * reached in vault mode: a host in local mode never connects.
272
+ */
273
+ #openWatch() {
274
+ const client = this.#client;
275
+ if (this.#shutdown.signal.aborted || !client || !this.#custody)
276
+ return;
277
+ if (!this.#vaultWatch) {
278
+ const watch = new ScopedRosterWatch({
279
+ client,
280
+ enrollmentToken: async () => (await readClaustrumEnrollmentToken(this.#options.tokenPath)).token,
281
+ view: () => this.#roster?.view,
282
+ // The existing discovery, so a watched change commits the roster and
283
+ // re-applies it to kept receipts exactly as a poll does. A refresh
284
+ // already in flight may have listed before the change was announced,
285
+ // so wait for it and list again.
286
+ discover: async () => {
287
+ while (this.#refreshing)
288
+ await this.#refreshing.catch(() => { });
289
+ await this.refresh();
290
+ },
291
+ onRefused: (error) => {
292
+ // As a poll that met the same refusal from list_scoped would.
293
+ this.#custody?.listFailed(error);
294
+ this.#options.onError?.(error);
295
+ },
296
+ onTransportLost: () => this.#custody?.transportLost(),
297
+ onReachable: () => this.#custody?.reachable(),
298
+ onError: (error) => {
299
+ if (!this.#shutdown.signal.aborted)
300
+ this.#options.onError?.(error);
301
+ },
302
+ logger: this.#logger,
303
+ now: this.#options.now,
304
+ setTimeoutImpl: this.#options.setTimeoutImpl,
305
+ clearTimeoutImpl: this.#options.clearTimeoutImpl,
306
+ });
307
+ this.#vaultWatch = watch;
308
+ watch.start();
309
+ }
310
+ if (this.#options.insula && this.#insula && !this.#releaseInsulaWatch)
311
+ this.#releaseInsulaWatch = this.#insula.source.watch({
312
+ provider: this.#options.insula.provider,
313
+ setTimeoutImpl: this.#options.setTimeoutImpl,
314
+ clearTimeoutImpl: this.#options.clearTimeoutImpl,
315
+ });
316
+ }
211
317
  /**
212
318
  * Fetch on a background tick, at most once per minute across projects with
213
319
  * the same daemon and enrollment token path. Admission must use the cache
@@ -282,20 +388,33 @@ export class ClaustrumConsumer {
282
388
  async authorize(routeId, signal) {
283
389
  this.#assertOpen(signal);
284
390
  const roster = this.#roster ?? (await this.#wait(this.refresh(), signal));
285
- if (!roster || !(await this.#active()))
391
+ if (!roster || !(await this.#active())) {
392
+ this.#custody?.close();
393
+ this.#custody = undefined;
394
+ this.#stopWatch();
395
+ this.#insula?.release();
396
+ this.#insula = undefined;
286
397
  throw new ClaustrumConsumerError('not-active', 'Claustrum scoped custody is not active');
398
+ }
287
399
  const current = await readVaultRoster(this.#options.rosterPath);
288
400
  this.#assertOpen(signal);
289
401
  const observed = roster.rows.find((row) => row.routeId === routeId);
290
402
  const configured = current?.rows.find((row) => row.routeId === routeId);
291
- if (configured && !configured.enabled)
403
+ if (configured && !configured.enabled) {
404
+ if (observed)
405
+ this.#custody?.forget(observed.credentialId);
292
406
  throw new ClaustrumConsumerError('route-declined', 'Claustrum route is disabled');
407
+ }
293
408
  if (!observed ||
294
409
  !configured ||
295
410
  configured.state !== 'active' ||
296
411
  configured.credentialId !== observed.credentialId ||
297
- configured.accountIdentity !== observed.accountIdentity)
412
+ configured.accountIdentity !== observed.accountIdentity ||
413
+ configured.credentialType !== observed.credentialType) {
414
+ if (observed)
415
+ this.#custody?.forget(observed.credentialId);
298
416
  throw new ClaustrumConsumerError('route-unavailable', 'Claustrum route is disabled, removed or changed');
417
+ }
299
418
  const custody = await this.#wait(this.#getCustody(), signal);
300
419
  const combined = combineAbortSignals([
301
420
  this.#shutdown.signal,
@@ -308,7 +427,7 @@ export class ClaustrumConsumer {
308
427
  ...(configured.accountIdentity !== undefined && {
309
428
  accountIdentity: configured.accountIdentity,
310
429
  }),
311
- }, combined.signal);
430
+ }, combined.signal, routeId);
312
431
  }
313
432
  finally {
314
433
  combined.dispose();
@@ -332,6 +451,10 @@ export class ClaustrumConsumer {
332
451
  const signal = options.signal;
333
452
  let served = await this.authorize(routeId, signal);
334
453
  let response = await dispatch(served, signal);
454
+ if (response.status === 401 && served.fromKeptReceipt) {
455
+ await this.reportFailure(served, 401, options.reporterSource ?? 'direct');
456
+ return response;
457
+ }
335
458
  if (response.status === 401 && !signal?.aborted) {
336
459
  let current;
337
460
  try {
@@ -366,6 +489,7 @@ export class ClaustrumConsumer {
366
489
  this.#assertOpen();
367
490
  await declineVaultRoute(this.#options.rosterPath, routeId);
368
491
  this.#roster = await readVaultRoster(this.#options.rosterPath);
492
+ this.#custody?.retain(this.#roster?.rows ?? []);
369
493
  }
370
494
  async accept(routeId) {
371
495
  this.#assertOpen();
@@ -421,6 +545,7 @@ export class ClaustrumConsumer {
421
545
  if (this.#timer)
422
546
  (this.#options.clearTimeoutImpl ?? clearTimeout)(this.#timer);
423
547
  this.#timer = undefined;
548
+ this.#stopWatch();
424
549
  this.#custody?.close();
425
550
  this.#insula?.release();
426
551
  this.#insula = undefined;
@@ -184,19 +184,25 @@ export async function replaceWindowsFile(from, to, options = {}) {
184
184
  to = resolve(to);
185
185
  const now = options.now ?? (() => performance.now());
186
186
  const start = now();
187
- const deadline = Math.min(start + 500, options.deadline ?? Infinity);
187
+ const leaseDeadline = options.deadline ?? Infinity;
188
+ let retryDeadline;
188
189
  const delays = [0, 10, 20, 40, 80, 100];
189
190
  let original;
190
191
  let attempts = 0;
191
192
  for (const delay of delays) {
193
+ const deadline = Math.min(retryDeadline ?? Infinity, leaseDeadline);
192
194
  if (now() >= deadline)
193
195
  break;
194
196
  if (delay)
195
197
  await (options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))))(Math.min(delay, Math.max(0, deadline - now())));
196
198
  if (now() >= deadline)
197
199
  break;
200
+ const fenceStart = now();
198
201
  await options.beforeRename?.();
199
- if (now() >= deadline)
202
+ // Ownership checks fence every attempt but do not spend the sharing-retry budget.
203
+ if (retryDeadline !== undefined)
204
+ retryDeadline += now() - fenceStart;
205
+ if (now() >= leaseDeadline)
200
206
  break;
201
207
  attempts++;
202
208
  try {
@@ -212,6 +218,8 @@ export async function replaceWindowsFile(from, to, options = {}) {
212
218
  !detail.reason;
213
219
  if (!candidate)
214
220
  break;
221
+ // Start the retry window only once a rename has actually failed.
222
+ retryDeadline ??= now() + 500;
215
223
  }
216
224
  }
217
225
  const error = new PrivateFileError(to, 'rename', original ??