@cortexkit/anthropic-auth-core 1.21.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +1 -1
  2. package/dist/accounts.d.ts +100 -3
  3. package/dist/accounts.js +1099 -169
  4. package/dist/auth.js +6 -10
  5. package/dist/cachekeep.d.ts +18 -1
  6. package/dist/cachekeep.js +108 -82
  7. package/dist/cch.d.ts +23 -9
  8. package/dist/cch.js +104 -21
  9. package/dist/claude-code.d.ts +8 -3
  10. package/dist/claude-code.js +24 -7
  11. package/dist/claustrum-enrollment-registry.d.ts +17 -0
  12. package/dist/claustrum-enrollment-registry.js +194 -0
  13. package/dist/claustrum-enrollment.d.ts +70 -0
  14. package/dist/claustrum-enrollment.js +485 -0
  15. package/dist/claustrum-scoped-roster.d.ts +23 -0
  16. package/dist/claustrum-scoped-roster.js +194 -0
  17. package/dist/claustrum-scoped-runtime.d.ts +28 -0
  18. package/dist/claustrum-scoped-runtime.js +230 -0
  19. package/dist/claustrum-scoped.d.ts +39 -0
  20. package/dist/claustrum-scoped.js +193 -0
  21. package/dist/claustrum.d.ts +252 -0
  22. package/dist/claustrum.js +1471 -0
  23. package/dist/commands/account.d.ts +43 -11
  24. package/dist/commands/account.js +147 -4
  25. package/dist/constants.d.ts +5 -5
  26. package/dist/constants.js +5 -5
  27. package/dist/custom-headers.d.ts +3 -0
  28. package/dist/custom-headers.js +102 -0
  29. package/dist/dump.js +2 -1
  30. package/dist/fast.js +1 -1
  31. package/dist/index.d.ts +11 -0
  32. package/dist/index.js +11 -0
  33. package/dist/json.d.ts +1 -0
  34. package/dist/json.js +10 -0
  35. package/dist/logger.js +3 -1
  36. package/dist/mid-conversation-output-config.d.ts +13 -0
  37. package/dist/mid-conversation-output-config.js +89 -0
  38. package/dist/model-remap.d.ts +27 -0
  39. package/dist/model-remap.js +72 -0
  40. package/dist/models.d.ts +46 -1
  41. package/dist/models.js +87 -8
  42. package/dist/network-errors.d.ts +1 -0
  43. package/dist/network-errors.js +27 -0
  44. package/dist/oauth-profile.d.ts +2 -1
  45. package/dist/oauth-profile.js +6 -0
  46. package/dist/prime.d.ts +11 -3
  47. package/dist/prime.js +25 -7
  48. package/dist/quota-header-feed.d.ts +32 -7
  49. package/dist/quota-header-feed.js +93 -10
  50. package/dist/quota-headers.js +59 -6
  51. package/dist/quota-manager.d.ts +20 -2
  52. package/dist/quota-manager.js +101 -24
  53. package/dist/relay.d.ts +8 -0
  54. package/dist/relay.js +72 -12
  55. package/dist/sticky-routing.d.ts +2 -0
  56. package/dist/sticky-routing.js +19 -1
  57. package/dist/thinking-binding.d.ts +7 -0
  58. package/dist/thinking-binding.js +54 -0
  59. package/package.json +4 -1
@@ -0,0 +1,1471 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { constants as fsConstants, readdirSync, statSync, } from 'node:fs';
3
+ import * as fs from 'node:fs/promises';
4
+ import { homedir, tmpdir, userInfo } from 'node:os';
5
+ import { basename, dirname, isAbsolute, join } from 'node:path';
6
+ import { SubcCallError, SubcClient, } from '@cortexkit/subc-client';
7
+ import { CUSTODY_HANDLE_PATTERN } from './constants.js';
8
+ import { parseJsonRedacted } from './json';
9
+ import { logger } from './logger';
10
+ export const PRODUCTION_CLAUSTRUM_CONNECTION_FILE_NAME = 'subc-connection.json';
11
+ const CLAUSTRUM_TEMP_PREFIX = 'subc-';
12
+ const CLAUSTRUM_TEMP_SUFFIX = '.connection.json';
13
+ function safeIsFile(path) {
14
+ try {
15
+ return statSync(path).isFile();
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ function listSubcTempConnectionFiles(dir) {
22
+ let entries;
23
+ try {
24
+ entries = readdirSync(dir);
25
+ }
26
+ catch {
27
+ return [];
28
+ }
29
+ const matches = [];
30
+ for (const name of entries) {
31
+ if (typeof name !== 'string')
32
+ continue;
33
+ if (!name.startsWith(CLAUSTRUM_TEMP_PREFIX) ||
34
+ !name.endsWith(CLAUSTRUM_TEMP_SUFFIX)) {
35
+ continue;
36
+ }
37
+ const candidate = join(dir, name);
38
+ if (safeIsFile(candidate))
39
+ matches.push(candidate);
40
+ }
41
+ matches.sort();
42
+ return matches;
43
+ }
44
+ export function findExistingClaustrumConnectionPath(env = process.env) {
45
+ const runtime = env.XDG_RUNTIME_DIR?.trim();
46
+ if (runtime) {
47
+ const candidate = join(runtime, PRODUCTION_CLAUSTRUM_CONNECTION_FILE_NAME);
48
+ if (safeIsFile(candidate))
49
+ return candidate;
50
+ }
51
+ const home = env.HOME?.trim();
52
+ if (home) {
53
+ const candidate = join(home, '.local', 'share', 'cortexkit', 'run', PRODUCTION_CLAUSTRUM_CONNECTION_FILE_NAME);
54
+ if (safeIsFile(candidate))
55
+ return candidate;
56
+ }
57
+ const uid = process.getuid?.() ?? userInfo().uid;
58
+ const linuxRun = `/run/user/${uid}/${PRODUCTION_CLAUSTRUM_CONNECTION_FILE_NAME}`;
59
+ if (safeIsFile(linuxRun))
60
+ return linuxRun;
61
+ const tempMatches = listSubcTempConnectionFiles(tmpdir());
62
+ if (tempMatches.length === 1)
63
+ return tempMatches[0];
64
+ return undefined;
65
+ }
66
+ export function getDefaultClaustrumConnectionPath(env = process.env) {
67
+ const existing = findExistingClaustrumConnectionPath(env);
68
+ if (existing)
69
+ return existing;
70
+ const runtime = env.XDG_RUNTIME_DIR?.trim();
71
+ if (runtime && (process.platform !== 'darwin' || safeIsFile(runtime))) {
72
+ return join(runtime, PRODUCTION_CLAUSTRUM_CONNECTION_FILE_NAME);
73
+ }
74
+ if (process.platform === 'darwin') {
75
+ const home = env.HOME?.trim() || userInfo().homedir;
76
+ return join(home, '.local', 'share', 'cortexkit', 'run', PRODUCTION_CLAUSTRUM_CONNECTION_FILE_NAME);
77
+ }
78
+ const uid = process.getuid?.() ?? userInfo().uid;
79
+ return `/run/user/${uid}/${PRODUCTION_CLAUSTRUM_CONNECTION_FILE_NAME}`;
80
+ }
81
+ export function resolveClaustrumConnectionPath(explicit, env = process.env) {
82
+ return (explicit?.trim() ||
83
+ env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE?.trim() ||
84
+ env.CLAUSTRUM_SUBC_CONNECTION?.trim() ||
85
+ getDefaultClaustrumConnectionPath(env));
86
+ }
87
+ export function resolveCustodyHandlesPath(config, env) {
88
+ const configuredPath = config?.handlesFile?.trim();
89
+ if (configuredPath)
90
+ return configuredPath;
91
+ const environmentPath = env.CLAUSTRUM_OPENCODE_HANDLES;
92
+ if (environmentPath && isAbsolute(environmentPath))
93
+ return environmentPath;
94
+ const configHome = env.XDG_CONFIG_HOME?.trim() || join(env.HOME || homedir(), '.config');
95
+ return join(configHome, 'cortexkit', 'opencode-handles.json');
96
+ }
97
+ export function getDefaultClaustrumHandlesPath() {
98
+ return resolveCustodyHandlesPath(undefined, process.env);
99
+ }
100
+ function isRecord(value) {
101
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
102
+ }
103
+ function isEndpoint(value) {
104
+ return (isRecord(value) &&
105
+ typeof value.host === 'string' &&
106
+ value.host.trim().length > 0 &&
107
+ typeof value.port === 'number' &&
108
+ Number.isInteger(value.port) &&
109
+ value.port > 0 &&
110
+ value.port <= 65_535);
111
+ }
112
+ export async function detectClaustrumConnection(explicitPath) {
113
+ const path = resolveClaustrumConnectionPath(explicitPath);
114
+ let raw;
115
+ try {
116
+ raw = await fs.readFile(path, 'utf8');
117
+ }
118
+ catch (error) {
119
+ const code = errorCode(error);
120
+ if (code === 'ENOENT')
121
+ return { status: 'absent', path };
122
+ return {
123
+ status: 'malformed',
124
+ path,
125
+ reason: `unreadable (${code})`,
126
+ };
127
+ }
128
+ let value;
129
+ try {
130
+ value = JSON.parse(raw);
131
+ }
132
+ catch {
133
+ return {
134
+ status: 'malformed',
135
+ path,
136
+ reason: 'invalid JSON',
137
+ };
138
+ }
139
+ if (!isRecord(value) ||
140
+ typeof value.schema !== 'number' ||
141
+ !Number.isFinite(value.schema) ||
142
+ typeof value.wire_version !== 'number' ||
143
+ !Number.isFinite(value.wire_version) ||
144
+ !Array.isArray(value.endpoints) ||
145
+ value.endpoints.length === 0 ||
146
+ !value.endpoints.every(isEndpoint)) {
147
+ return {
148
+ status: 'malformed',
149
+ path,
150
+ reason: 'connection file has an invalid schema, wire_version, or endpoints',
151
+ };
152
+ }
153
+ return {
154
+ status: 'available',
155
+ schema: value.schema,
156
+ wireVersion: value.wire_version,
157
+ endpoints: value.endpoints.map((endpoint) => ({
158
+ host: endpoint.host,
159
+ port: endpoint.port,
160
+ })),
161
+ };
162
+ }
163
+ export const CUSTODY_TOMBSTONE_PREFIX = 'claustrum-tombstone:v1:';
164
+ export function custodyTombstoneKey(provider) {
165
+ return `${CUSTODY_TOMBSTONE_PREFIX}${provider}`;
166
+ }
167
+ export function custodyTombstoneOAuth(provider) {
168
+ // An empty access value makes Claustrum's deployed sealer reject this loader marker.
169
+ return {
170
+ type: 'oauth',
171
+ access: '',
172
+ refresh: custodyTombstoneKey(provider),
173
+ expires: 0,
174
+ };
175
+ }
176
+ export function isCustodyTombstoneValue(value) {
177
+ return typeof value === 'string' && value.startsWith(CUSTODY_TOMBSTONE_PREFIX);
178
+ }
179
+ export function isCustodyTombstoneOAuth(auth, provider) {
180
+ if (!isRecord(auth) || auth.type !== 'oauth')
181
+ return false;
182
+ return auth.refresh === custodyTombstoneKey(provider);
183
+ }
184
+ export class CustodyTombstoneRefreshError extends Error {
185
+ provider;
186
+ code = 'custody_tombstone_refresh';
187
+ constructor(provider) {
188
+ super(`${provider} main slot is vault-custodied; local refresh is forbidden — the vault-served main path is not yet implemented`);
189
+ this.provider = provider;
190
+ this.name = 'CustodyTombstoneRefreshError';
191
+ }
192
+ }
193
+ export class CustodyTombstoneLoginError extends Error {
194
+ provider;
195
+ code = 'custody_tombstone_login';
196
+ constructor(provider) {
197
+ super(`${provider} main slot is custodied; run /login to sign in locally`);
198
+ this.provider = provider;
199
+ this.name = 'CustodyTombstoneLoginError';
200
+ }
201
+ }
202
+ export function assertNotCustodyTombstone(refreshToken, provider) {
203
+ if (isCustodyTombstoneValue(refreshToken)) {
204
+ throw new CustodyTombstoneRefreshError(provider);
205
+ }
206
+ }
207
+ const CUSTODY_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
208
+ const RESERVED_CUSTODY_IDS = new Set(['__proto__', 'constructor', 'prototype']);
209
+ const CUSTODY_CREDENTIAL_PREFIX = 'oauth:anthropic:';
210
+ export function isValidCustodyLabel(value) {
211
+ return (typeof value === 'string' &&
212
+ CUSTODY_ID_PATTERN.test(value) &&
213
+ !RESERVED_CUSTODY_IDS.has(value));
214
+ }
215
+ export function isValidCustodyHandle(value) {
216
+ return typeof value === 'string' && CUSTODY_HANDLE_PATTERN.test(value);
217
+ }
218
+ export function custodyCredentialId(label) {
219
+ if (!isValidCustodyLabel(label))
220
+ throw new CustodyManifestSelectionError('invalid-label');
221
+ return `${CUSTODY_CREDENTIAL_PREFIX}${label}`;
222
+ }
223
+ // A resolved MANIFEST binding carries the credential id verbatim (the parser
224
+ // stores it as-written and the resolver returns it; round 2 dropped the
225
+ // parse-time derivation check). Anything else — legacy source, unresolved,
226
+ // or a manifest entry whose id happens to be undefined — derives from the
227
+ // label, the only id the caller can construct without a binding in hand.
228
+ // Callers building completion records (local-exit, reachability probe)
229
+ // pass this single rule instead of inlining it.
230
+ export function custodyCredentialIdFromResolution(resolution, label) {
231
+ if (resolution.status === 'resolved' && resolution.source === 'manifest') {
232
+ return resolution.credentialId ?? custodyCredentialId(label);
233
+ }
234
+ return custodyCredentialId(label);
235
+ }
236
+ function isValidCustodyCredentialId(value) {
237
+ return typeof value === 'string' && value.length > 0;
238
+ }
239
+ // The manifest is a co-tenant file: a sibling plugin (`provider: 'openai'`,
240
+ // `serve: 'openai-auth'`) writes its own block in the same file. Provider
241
+ // scoping is what stops us from binding its handles — the parser has to
242
+ // enforce it because the sibling plugin never talks to us.
243
+ //
244
+ // Scope on the SECOND colon-separated segment (the provider segment). The
245
+ // first segment is a "kind" prefix the vault owner extends (`oauth:`,
246
+ // `chatgpt:`, `antigravity:`, `apikey:`, …) and the rule deliberately does
247
+ // NOT enumerate or constrain it: enumerating the kinds would reject live
248
+ // credentials the moment a new one ships. The third-and-later segments are
249
+ // the label, which is never consulted here — the label is the lookup key
250
+ // elsewhere, not an authorization check.
251
+ function isScopedCustodyCredentialId(value, provider) {
252
+ if (typeof value !== 'string' || value.length === 0)
253
+ return false;
254
+ const segments = value.split(':');
255
+ return segments[1] === provider;
256
+ }
257
+ function legacyOrUnresolved(account, reason) {
258
+ if (account.claustrumHandle) {
259
+ return {
260
+ status: 'resolved',
261
+ source: 'legacy',
262
+ handle: account.claustrumHandle,
263
+ };
264
+ }
265
+ return { status: 'unresolved', reason };
266
+ }
267
+ /**
268
+ * | manifest state | legacy handle | result |
269
+ * | --- | --- | --- |
270
+ * | matching ready entry | any | manifest handle |
271
+ * | absent or invalid | present | legacy handle |
272
+ * | no matching ready entry | present | legacy handle |
273
+ * | foreign serve | any | unresolved |
274
+ * | superseded matching entry | any | unresolved |
275
+ */
276
+ export function resolveCustodyHandle(input) {
277
+ const { account, manifest, duplicateOAuthLabels } = input;
278
+ if (manifest && manifest.serve !== 'anthropic-auth') {
279
+ return { status: 'unresolved', reason: 'foreign-serve' };
280
+ }
281
+ if (!account.label)
282
+ return legacyOrUnresolved(account, 'missing-label');
283
+ if (!isValidCustodyLabel(account.label)) {
284
+ return legacyOrUnresolved(account, 'invalid-label');
285
+ }
286
+ if (duplicateOAuthLabels?.has(account.label)) {
287
+ return legacyOrUnresolved(account, 'duplicate-label');
288
+ }
289
+ if (!manifest)
290
+ return legacyOrUnresolved(account, 'missing-entry');
291
+ if (manifest.corruptLabels?.has(account.label)) {
292
+ return { status: 'unresolved', reason: 'corrupt-binding' };
293
+ }
294
+ // The manifest carries the credential id verbatim; the runtime fence in
295
+ // custody-mode.ts is the one that compares it against vault ground truth.
296
+ const entry = manifest.accounts.find((candidate) => candidate.label === account.label);
297
+ if (!entry)
298
+ return legacyOrUnresolved(account, 'missing-entry');
299
+ if (manifest.superseded.has(entry.handle)) {
300
+ return { status: 'unresolved', reason: 'superseded' };
301
+ }
302
+ return {
303
+ status: 'resolved',
304
+ source: 'manifest',
305
+ handle: entry.handle,
306
+ credentialId: entry.credentialId,
307
+ };
308
+ }
309
+ export function readCustodyHandles(json, provider, serve) {
310
+ if (!isValidCustodyLabel(provider)) {
311
+ throw new Error('invalid manifest provider');
312
+ }
313
+ if (!isRecord(json) ||
314
+ !Object.hasOwn(json, 'version') ||
315
+ json.version !== 1) {
316
+ throw new Error('invalid manifest version');
317
+ }
318
+ if (!Object.hasOwn(json, 'providers') || !Array.isArray(json.providers)) {
319
+ throw new Error('missing manifest providers');
320
+ }
321
+ const providerEntries = json.providers.filter((entry) => isRecord(entry) &&
322
+ Object.hasOwn(entry, 'provider') &&
323
+ entry.provider === provider);
324
+ if (providerEntries.length === 0) {
325
+ throw new CustodyManifestSelectionError('missing-provider');
326
+ }
327
+ const source = providerEntries.find((entry) => Object.hasOwn(entry, 'serve') && entry.serve === serve);
328
+ if (!source)
329
+ throw new CustodyManifestSelectionError('foreign-serve');
330
+ if (!Object.hasOwn(source, 'accounts') || !Array.isArray(source.accounts)) {
331
+ throw new Error('invalid manifest accounts');
332
+ }
333
+ // The label is the sole lookup key; two entries with the same label cannot
334
+ // tell the resolver which one to bind, so every such entry is marked corrupt
335
+ // rather than silently picking a winner.
336
+ const labelCounts = new Map();
337
+ for (const candidate of source.accounts) {
338
+ if (!isRecord(candidate) ||
339
+ !Object.hasOwn(candidate, 'label') ||
340
+ typeof candidate.label !== 'string' ||
341
+ !isValidCustodyLabel(candidate.label))
342
+ continue;
343
+ labelCounts.set(candidate.label, (labelCounts.get(candidate.label) ?? 0) + 1);
344
+ }
345
+ const duplicateLabels = new Set([...labelCounts].filter(([, count]) => count > 1).map(([label]) => label));
346
+ const superseded = new Set();
347
+ const corruptLabels = new Set();
348
+ const accounts = [];
349
+ for (const entry of source.accounts) {
350
+ if (!isRecord(entry))
351
+ throw new Error('invalid account entry');
352
+ if (!Object.hasOwn(entry, 'label') || typeof entry.label !== 'string') {
353
+ throw new Error('invalid account label');
354
+ }
355
+ if (!isValidCustodyLabel(entry.label)) {
356
+ throw new Error('invalid account label');
357
+ }
358
+ if (!Object.hasOwn(entry, 'handle') ||
359
+ !Object.hasOwn(entry, 'credential_id') ||
360
+ typeof entry.handle !== 'string' ||
361
+ !isValidCustodyHandle(entry.handle) ||
362
+ !isScopedCustodyCredentialId(entry.credential_id, provider)) {
363
+ corruptLabels.add(entry.label);
364
+ continue;
365
+ }
366
+ if (duplicateLabels.has(entry.label)) {
367
+ corruptLabels.add(entry.label);
368
+ continue;
369
+ }
370
+ if (Object.hasOwn(entry, 'superseded')) {
371
+ if (!Array.isArray(entry.superseded)) {
372
+ corruptLabels.add(entry.label);
373
+ continue;
374
+ }
375
+ let malformed = false;
376
+ for (const handle of entry.superseded) {
377
+ if (!isValidCustodyHandle(handle)) {
378
+ malformed = true;
379
+ break;
380
+ }
381
+ }
382
+ if (malformed) {
383
+ corruptLabels.add(entry.label);
384
+ continue;
385
+ }
386
+ for (const handle of entry.superseded)
387
+ superseded.add(handle);
388
+ }
389
+ accounts.push({
390
+ label: entry.label,
391
+ handle: entry.handle,
392
+ credentialId: entry.credential_id,
393
+ });
394
+ }
395
+ return {
396
+ version: 1,
397
+ provider,
398
+ serve,
399
+ accounts,
400
+ superseded,
401
+ corruptLabels,
402
+ };
403
+ }
404
+ export const CUSTODY_MANIFEST_SELECTION_ERROR_CODES = [
405
+ 'foreign-serve',
406
+ 'invalid-label',
407
+ 'missing-provider',
408
+ ];
409
+ export class CustodyManifestSelectionError extends Error {
410
+ code;
411
+ constructor(code) {
412
+ super(code);
413
+ this.code = code;
414
+ }
415
+ }
416
+ const MAX_CUSTODY_MANIFEST_BYTES = 256 * 1024;
417
+ function errorCode(error) {
418
+ const code = error.code;
419
+ return typeof code === 'string' ? code : 'unknown';
420
+ }
421
+ function validateManifestFile(stats, expectedUid) {
422
+ const mode = Number(stats.mode);
423
+ if (stats.isSymbolicLink())
424
+ return 'manifest is a symlink';
425
+ if (!stats.isFile())
426
+ return 'manifest is not a regular file';
427
+ if ((mode & 0o777) !== 0o600)
428
+ return 'manifest mode must be 0600';
429
+ if (stats.uid !== expectedUid)
430
+ return 'manifest owner does not match';
431
+ return null;
432
+ }
433
+ function validateManifestParent(stats, expectedUid) {
434
+ const mode = Number(stats.mode);
435
+ if (stats.uid !== expectedUid)
436
+ return 'manifest parent owner does not match';
437
+ if ((mode & 0o1000) !== 0)
438
+ return null;
439
+ if ((mode & 0o002) !== 0) {
440
+ return 'manifest parent is world-writable';
441
+ }
442
+ if ((mode & 0o020) !== 0)
443
+ return 'manifest parent is group-writable';
444
+ return null;
445
+ }
446
+ export class CustodyHandleManifestReader {
447
+ #cache;
448
+ #options;
449
+ constructor(options) {
450
+ this.#options = options;
451
+ }
452
+ async read() {
453
+ const expectedUid = this.#options.expectedUid ?? process.getuid?.() ?? userInfo().uid;
454
+ let pathStats;
455
+ try {
456
+ pathStats = await fs.lstat(this.#options.path);
457
+ }
458
+ catch (error) {
459
+ if (errorCode(error) === 'ENOENT')
460
+ return { status: 'absent' };
461
+ return { status: 'invalid', reason: `unreadable (${errorCode(error)})` };
462
+ }
463
+ const fileReason = validateManifestFile(pathStats, expectedUid);
464
+ if (fileReason)
465
+ return { status: 'invalid', reason: fileReason };
466
+ try {
467
+ const parentReason = validateManifestParent(await fs.lstat(dirname(this.#options.path)), expectedUid);
468
+ if (parentReason)
469
+ return { status: 'invalid', reason: parentReason };
470
+ }
471
+ catch (error) {
472
+ return { status: 'invalid', reason: `unreadable (${errorCode(error)})` };
473
+ }
474
+ let handle;
475
+ try {
476
+ handle = await fs.open(this.#options.path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
477
+ // The validated descriptor, not the path, is the authority for content and metadata after open.
478
+ const openedStats = await handle.stat();
479
+ const openedFileReason = validateManifestFile(openedStats, expectedUid);
480
+ if (openedFileReason)
481
+ return { status: 'invalid', reason: openedFileReason };
482
+ if (this.#cache?.mtimeMs === openedStats.mtimeMs &&
483
+ this.#cache.size === openedStats.size) {
484
+ return this.#cache.result;
485
+ }
486
+ const bytes = new Uint8Array(MAX_CUSTODY_MANIFEST_BYTES + 1);
487
+ const { bytesRead } = await handle.read(bytes, 0, bytes.byteLength, 0);
488
+ if (bytesRead > MAX_CUSTODY_MANIFEST_BYTES) {
489
+ return { status: 'invalid', reason: 'manifest exceeds maximum size' };
490
+ }
491
+ let json;
492
+ try {
493
+ json = parseJsonRedacted(new TextDecoder().decode(bytes.subarray(0, bytesRead)));
494
+ }
495
+ catch (error) {
496
+ return {
497
+ status: 'invalid',
498
+ reason: error instanceof Error ? error.message : 'invalid JSON',
499
+ };
500
+ }
501
+ let parsed;
502
+ try {
503
+ parsed = readCustodyHandles(json, this.#options.provider, this.#options.serve);
504
+ }
505
+ catch (error) {
506
+ if (error instanceof CustodyManifestSelectionError) {
507
+ if (error.code !== 'invalid-label') {
508
+ const result = { status: 'ignored', reason: error.code };
509
+ this.#cache = {
510
+ mtimeMs: openedStats.mtimeMs,
511
+ size: openedStats.size,
512
+ result,
513
+ };
514
+ return result;
515
+ }
516
+ }
517
+ return {
518
+ status: 'invalid',
519
+ reason: error instanceof Error ? error.message : 'invalid manifest',
520
+ };
521
+ }
522
+ const manifest = {
523
+ version: parsed.version,
524
+ provider: this.#options.provider,
525
+ serve: this.#options.serve,
526
+ accounts: parsed.accounts,
527
+ superseded: parsed.superseded,
528
+ corruptLabels: parsed.corruptLabels,
529
+ };
530
+ const result = { status: 'ready', manifest };
531
+ this.#cache = {
532
+ mtimeMs: openedStats.mtimeMs,
533
+ size: openedStats.size,
534
+ result,
535
+ };
536
+ return result;
537
+ }
538
+ catch (error) {
539
+ return { status: 'invalid', reason: `unreadable (${errorCode(error)})` };
540
+ }
541
+ finally {
542
+ await handle?.close().catch(() => { });
543
+ }
544
+ }
545
+ }
546
+ export const CUSTODY_MANIFEST_LOCK_TTL_MS = 30_000;
547
+ export const CUSTODY_MANIFEST_LOCK_RENEW_MS = 10_000;
548
+ export const CUSTODY_MANIFEST_LOCK_RETRY_MIN_MS = 50;
549
+ export const CUSTODY_MANIFEST_LOCK_RETRY_MAX_MS = 150;
550
+ const CUSTODY_MANIFEST_STALE_LOCK_REAP_AGE_MS = 24 * 60 * 60 * 1000;
551
+ const CUSTODY_MANIFEST_STALE_LOCK_REAP_LIMIT = 32;
552
+ let custodyManifestLockTestOptions;
553
+ export function __setCustodyManifestLockTestOptions(options) {
554
+ custodyManifestLockTestOptions = options;
555
+ }
556
+ export const CUSTODY_MANIFEST_LOCK_ERROR_CODES = [
557
+ 'lock_busy',
558
+ 'owner_invalid',
559
+ 'renewal_failed',
560
+ ];
561
+ export class CustodyManifestLockError extends Error {
562
+ code;
563
+ constructor(message, code) {
564
+ super(message);
565
+ this.code = code;
566
+ }
567
+ }
568
+ export class CustodyManifestLockBusyError extends CustodyManifestLockError {
569
+ constructor(message) {
570
+ super(message, 'lock_busy');
571
+ }
572
+ }
573
+ export class CustodyManifestLockOwnerInvalidError extends CustodyManifestLockError {
574
+ constructor(message) {
575
+ super(message, 'owner_invalid');
576
+ }
577
+ }
578
+ export class CustodyManifestLockLeaseLostError extends CustodyManifestLockError {
579
+ constructor(message) {
580
+ super(message, 'renewal_failed');
581
+ }
582
+ }
583
+ function isEvictableCustodyManifestLockNonce(nonce) {
584
+ // An allowlist freezes lock compatibility across independently upgrading readers;
585
+ // reject only unsafe characters so a widened nonce cannot wedge an older reader.
586
+ for (const character of nonce) {
587
+ const code = character.charCodeAt(0);
588
+ if (code < 0x20 || code === 0x7f)
589
+ return false;
590
+ }
591
+ return (nonce.length > 0 &&
592
+ nonce.length <= 128 &&
593
+ nonce !== '.' &&
594
+ nonce !== '..' &&
595
+ !/[\\/:*?"<>|]/.test(nonce) &&
596
+ !/[. ]$/.test(nonce));
597
+ }
598
+ export function __deriveCustodyManifestStaleLockPrefix(lockPath, pathBasename) {
599
+ return `${pathBasename(lockPath)}.stale-`;
600
+ }
601
+ async function reapStaleCustodyManifestLocks(lockPath) {
602
+ const parent = dirname(lockPath);
603
+ const prefix = __deriveCustodyManifestStaleLockPrefix(lockPath, basename);
604
+ let entries;
605
+ try {
606
+ entries = await fs.readdir(parent, { withFileTypes: true });
607
+ }
608
+ catch (error) {
609
+ logger.debug('claustrum', 'stale manifest lock reaper failed', {
610
+ error: errorCode(error),
611
+ });
612
+ return;
613
+ }
614
+ let reaped = 0;
615
+ for (const entry of entries) {
616
+ if (reaped >= CUSTODY_MANIFEST_STALE_LOCK_REAP_LIMIT)
617
+ return;
618
+ if (!entry.isDirectory() || !entry.name.startsWith(prefix))
619
+ continue;
620
+ const quarantinePath = join(parent, entry.name);
621
+ try {
622
+ const stat = await fs.lstat(quarantinePath);
623
+ if (!stat.isDirectory() ||
624
+ Date.now() - stat.mtimeMs < CUSTODY_MANIFEST_STALE_LOCK_REAP_AGE_MS) {
625
+ continue;
626
+ }
627
+ await fs.rm(quarantinePath, { recursive: true, force: true });
628
+ reaped += 1;
629
+ }
630
+ catch (error) {
631
+ logger.debug('claustrum', 'stale manifest lock reaper failed', {
632
+ error: errorCode(error),
633
+ });
634
+ }
635
+ }
636
+ }
637
+ export async function withCustodyManifestLock(path, fn) {
638
+ const lockPath = `${path}.lock`;
639
+ const now = custodyManifestLockTestOptions?.now ?? Date.now;
640
+ const setIntervalImpl = custodyManifestLockTestOptions?.setIntervalImpl ?? globalThis.setInterval;
641
+ const clearIntervalImpl = custodyManifestLockTestOptions?.clearIntervalImpl ??
642
+ globalThis.clearInterval;
643
+ const ttlMs = custodyManifestLockTestOptions?.ttlMs ?? CUSTODY_MANIFEST_LOCK_TTL_MS;
644
+ const retryMinMs = custodyManifestLockTestOptions?.retryMinMs ??
645
+ CUSTODY_MANIFEST_LOCK_RETRY_MIN_MS;
646
+ const retryMaxMs = custodyManifestLockTestOptions?.retryMaxMs ??
647
+ CUSTODY_MANIFEST_LOCK_RETRY_MAX_MS;
648
+ const startedAt = now();
649
+ const nonce = randomUUID();
650
+ const ownerPath = join(lockPath, 'owner');
651
+ async function writeOwner(claimedAtMs) {
652
+ const temporaryOwnerPath = join(lockPath, `owner.${process.pid}.${randomUUID()}.tmp`);
653
+ let handle;
654
+ try {
655
+ handle = await fs.open(temporaryOwnerPath, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600);
656
+ await handle.writeFile(`${JSON.stringify({
657
+ tenant: 'anthropic-auth',
658
+ pid: process.pid,
659
+ claimed_at_ms: claimedAtMs,
660
+ nonce,
661
+ })}\n`);
662
+ await handle.sync();
663
+ await handle.close();
664
+ handle = undefined;
665
+ await fs.rename(temporaryOwnerPath, ownerPath);
666
+ }
667
+ finally {
668
+ await handle?.close().catch(() => { });
669
+ await fs.unlink(temporaryOwnerPath).catch(() => { });
670
+ }
671
+ }
672
+ async function claim() {
673
+ try {
674
+ await fs.mkdir(lockPath, { mode: 0o700 });
675
+ }
676
+ catch (error) {
677
+ if (errorCode(error) !== 'EEXIST')
678
+ throw error;
679
+ return false;
680
+ }
681
+ try {
682
+ await writeOwner(now());
683
+ }
684
+ catch (error) {
685
+ await fs.rm(lockPath, { recursive: true, force: true }).catch(() => { });
686
+ throw error;
687
+ }
688
+ return true;
689
+ }
690
+ const deadline = startedAt + ttlMs;
691
+ let claimed = false;
692
+ while (!claimed) {
693
+ claimed = await claim();
694
+ if (claimed)
695
+ break;
696
+ let ownerClaimedAtMs;
697
+ let ownerNonce;
698
+ let ownerInvalid = false;
699
+ try {
700
+ const owner = JSON.parse(await fs.readFile(ownerPath, 'utf8'));
701
+ if (isRecord(owner) &&
702
+ typeof owner.claimed_at_ms === 'number' &&
703
+ Number.isFinite(owner.claimed_at_ms) &&
704
+ typeof owner.nonce === 'string' &&
705
+ isEvictableCustodyManifestLockNonce(owner.nonce)) {
706
+ ownerClaimedAtMs = owner.claimed_at_ms;
707
+ ownerNonce = owner.nonce;
708
+ await custodyManifestLockTestOptions?.afterStaleOwnerRead?.();
709
+ }
710
+ else
711
+ ownerInvalid = true;
712
+ }
713
+ catch (error) {
714
+ if (errorCode(error) !== 'ENOENT')
715
+ ownerInvalid = true;
716
+ }
717
+ if (ownerClaimedAtMs !== undefined &&
718
+ ownerNonce !== undefined &&
719
+ now() - ownerClaimedAtMs >= ttlMs) {
720
+ const claimedPath = `${lockPath}.stale-${ownerClaimedAtMs}-${ownerNonce}`;
721
+ try {
722
+ await fs.rename(lockPath, claimedPath);
723
+ }
724
+ catch (error) {
725
+ if (!['ENOENT', 'EEXIST', 'ENOTEMPTY'].includes(errorCode(error)))
726
+ throw error;
727
+ }
728
+ continue;
729
+ }
730
+ if (now() >= deadline && ownerInvalid)
731
+ throw new CustodyManifestLockOwnerInvalidError('manifest lock owner invalid');
732
+ if (now() >= deadline)
733
+ throw new CustodyManifestLockBusyError('manifest lock busy');
734
+ const retryMs = retryMinMs + Math.floor(Math.random() * (retryMaxMs - retryMinMs + 1));
735
+ await new Promise((resolve) => setTimeout(resolve, Math.min(retryMs, Math.max(1, deadline - now()))));
736
+ }
737
+ let renewalFailed = false;
738
+ let renewalInFlight;
739
+ async function ownsCurrentLease() {
740
+ if (renewalFailed)
741
+ return false;
742
+ try {
743
+ const owner = JSON.parse(await fs.readFile(ownerPath, 'utf8'));
744
+ return (isRecord(owner) &&
745
+ owner.pid === process.pid &&
746
+ owner.nonce === nonce &&
747
+ typeof owner.claimed_at_ms === 'number' &&
748
+ Number.isFinite(owner.claimed_at_ms) &&
749
+ now() - owner.claimed_at_ms < ttlMs);
750
+ }
751
+ catch {
752
+ return false;
753
+ }
754
+ }
755
+ async function assertLease() {
756
+ await renewalInFlight;
757
+ if (!(await ownsCurrentLease()))
758
+ throw new CustodyManifestLockLeaseLostError('manifest lock renewal failed; write aborted');
759
+ }
760
+ const renewal = setIntervalImpl(() => {
761
+ renewalInFlight = writeOwner(now()).catch(() => {
762
+ renewalFailed = true;
763
+ });
764
+ }, custodyManifestLockTestOptions?.renewalIntervalMs ??
765
+ Math.min(CUSTODY_MANIFEST_LOCK_RENEW_MS, Math.floor(ttlMs / 3)));
766
+ if ('unref' in renewal)
767
+ renewal.unref();
768
+ try {
769
+ return await fn(assertLease, nonce);
770
+ }
771
+ finally {
772
+ clearIntervalImpl(renewal);
773
+ if (!(await ownsCurrentLease())) {
774
+ logger.warn('claustrum', 'manifest lock lease lost, not releasing', {
775
+ id: path,
776
+ });
777
+ }
778
+ else {
779
+ await fs.rm(lockPath, { recursive: true, force: true }).catch(() => { });
780
+ }
781
+ await reapStaleCustodyManifestLocks(lockPath);
782
+ }
783
+ }
784
+ function refusal(reason, code) {
785
+ return code === undefined
786
+ ? { status: 'refused', reason }
787
+ : { status: 'refused', reason, code };
788
+ }
789
+ function isOurManifestBlock(value) {
790
+ return (isRecord(value) &&
791
+ value.provider === 'anthropic' &&
792
+ value.serve === 'anthropic-auth');
793
+ }
794
+ export async function writeCustodyHandleManifestEntry(input) {
795
+ if (!isValidCustodyLabel(input.entry.label) ||
796
+ !isValidCustodyHandle(input.entry.handle) ||
797
+ !isScopedCustodyCredentialId(input.entry.credentialId, 'anthropic')) {
798
+ return refusal('invalid entry');
799
+ }
800
+ try {
801
+ return await withCustodyManifestLock(input.path, (assertLease, nonce) => writeCustodyHandleManifestEntryLocked(input, assertLease, nonce));
802
+ }
803
+ catch (error) {
804
+ if (error instanceof CustodyManifestLockBusyError) {
805
+ return refusal('manifest lock busy', error.code);
806
+ }
807
+ if (error instanceof CustodyManifestLockOwnerInvalidError) {
808
+ return refusal('manifest lock owner invalid', error.code);
809
+ }
810
+ if (error instanceof CustodyManifestLockLeaseLostError) {
811
+ return refusal('manifest lock renewal failed; write aborted', error.code);
812
+ }
813
+ return refusal(`unreadable (${errorCode(error)})`);
814
+ }
815
+ }
816
+ export async function removeCustodyHandleManifestEntry(input) {
817
+ if (!isValidCustodyLabel(input.entry.label) ||
818
+ !isValidCustodyHandle(input.entry.handle) ||
819
+ !isValidCustodyCredentialId(input.entry.credentialId)) {
820
+ return { status: 'refused' };
821
+ }
822
+ try {
823
+ const result = await withCustodyManifestLock(input.path, async (assertLease, nonce) => {
824
+ const expectedUid = input.expectedUid ?? process.getuid?.() ?? userInfo().uid;
825
+ const parent = dirname(input.path);
826
+ try {
827
+ const parentReason = validateManifestParent(await fs.lstat(parent), expectedUid);
828
+ if (parentReason)
829
+ return 'refused';
830
+ }
831
+ catch (error) {
832
+ return errorCode(error) === 'ENOENT' ? 'missing' : 'refused';
833
+ }
834
+ let document;
835
+ let handle;
836
+ try {
837
+ const pathStats = await fs.lstat(input.path);
838
+ const pathReason = validateManifestFile(pathStats, expectedUid);
839
+ if (pathReason)
840
+ return 'refused';
841
+ handle = await fs.open(input.path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
842
+ const openedReason = validateManifestFile(await handle.stat(), expectedUid);
843
+ if (openedReason)
844
+ return 'refused';
845
+ const bytes = new Uint8Array(MAX_CUSTODY_MANIFEST_BYTES + 1);
846
+ const { bytesRead } = await handle.read(bytes, 0, bytes.byteLength, 0);
847
+ if (bytesRead > MAX_CUSTODY_MANIFEST_BYTES)
848
+ return 'refused';
849
+ const parsed = parseJsonRedacted(new TextDecoder().decode(bytes.subarray(0, bytesRead)));
850
+ if (!isRecord(parsed) ||
851
+ parsed.version !== 1 ||
852
+ !Array.isArray(parsed.providers)) {
853
+ return 'refused';
854
+ }
855
+ document = parsed;
856
+ if (parsed.providers.some(isOurManifestBlock)) {
857
+ try {
858
+ readCustodyHandles(parsed, 'anthropic', 'anthropic-auth');
859
+ }
860
+ catch {
861
+ return 'refused';
862
+ }
863
+ }
864
+ }
865
+ catch (error) {
866
+ if (errorCode(error) === 'ENOENT')
867
+ return 'missing';
868
+ return 'refused';
869
+ }
870
+ finally {
871
+ await handle?.close().catch(() => { });
872
+ }
873
+ const providers = document.providers;
874
+ if (!Array.isArray(providers))
875
+ return 'refused';
876
+ const blockIndex = providers.findIndex(isOurManifestBlock);
877
+ if (blockIndex === -1)
878
+ return 'missing';
879
+ const block = providers[blockIndex];
880
+ if (!isOurManifestBlock(block) || !Array.isArray(block.accounts)) {
881
+ return 'refused';
882
+ }
883
+ const matchesEntry = (account) => isRecord(account) &&
884
+ account.label === input.entry.label &&
885
+ account.handle === input.entry.handle &&
886
+ account.credential_id === input.entry.credentialId;
887
+ const matchIndex = block.accounts.findIndex(matchesEntry);
888
+ if (matchIndex === -1)
889
+ return 'missing';
890
+ const accounts = block.accounts.filter((account) => !matchesEntry(account));
891
+ const serialized = JSON.stringify({
892
+ ...document,
893
+ providers: providers.map((provider, index) => index === blockIndex ? { ...block, accounts } : provider),
894
+ }, null, 2);
895
+ if (new TextEncoder().encode(serialized).byteLength >
896
+ MAX_CUSTODY_MANIFEST_BYTES) {
897
+ return 'refused';
898
+ }
899
+ const temporaryPath = join(`${input.path}.lock`, `manifest.${nonce}.tmp`);
900
+ let temporaryHandle;
901
+ try {
902
+ temporaryHandle = await fs.open(temporaryPath, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600);
903
+ await temporaryHandle.writeFile(serialized);
904
+ await temporaryHandle.sync();
905
+ await temporaryHandle.close();
906
+ temporaryHandle = undefined;
907
+ await assertLease();
908
+ await custodyManifestLockTestOptions?.beforeRename?.();
909
+ try {
910
+ await fs.rename(temporaryPath, input.path);
911
+ }
912
+ catch (error) {
913
+ if (errorCode(error) === 'ENOENT')
914
+ throw new CustodyManifestLockLeaseLostError('manifest lock renewal failed; write aborted');
915
+ throw error;
916
+ }
917
+ return 'removed';
918
+ }
919
+ catch (error) {
920
+ if (error instanceof CustodyManifestLockLeaseLostError)
921
+ throw error;
922
+ return 'refused';
923
+ }
924
+ finally {
925
+ await temporaryHandle?.close().catch(() => { });
926
+ await fs.unlink(temporaryPath).catch(() => { });
927
+ }
928
+ });
929
+ return result === 'refused' ? { status: 'refused' } : result;
930
+ }
931
+ catch (error) {
932
+ if (error instanceof CustodyManifestLockError) {
933
+ return { status: 'refused', code: error.code };
934
+ }
935
+ return { status: 'refused' };
936
+ }
937
+ }
938
+ export async function writeCustodyHandleManifestEntryLocked(input, assertLease, lockNonce) {
939
+ const expectedUid = input.expectedUid ?? process.getuid?.() ?? userInfo().uid;
940
+ const parent = dirname(input.path);
941
+ try {
942
+ const parentReason = validateManifestParent(await fs.lstat(parent), expectedUid);
943
+ if (parentReason)
944
+ return refusal(parentReason);
945
+ }
946
+ catch (error) {
947
+ return refusal(`unreadable (${errorCode(error)})`);
948
+ }
949
+ let document;
950
+ let corruptLabels = new Set();
951
+ let handle;
952
+ try {
953
+ const pathStats = await fs.lstat(input.path);
954
+ const pathReason = validateManifestFile(pathStats, expectedUid);
955
+ if (pathReason)
956
+ return refusal(pathReason);
957
+ handle = await fs.open(input.path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
958
+ const openedReason = validateManifestFile(await handle.stat(), expectedUid);
959
+ if (openedReason)
960
+ return refusal(openedReason);
961
+ const bytes = new Uint8Array(MAX_CUSTODY_MANIFEST_BYTES + 1);
962
+ const { bytesRead } = await handle.read(bytes, 0, bytes.byteLength, 0);
963
+ if (bytesRead > MAX_CUSTODY_MANIFEST_BYTES)
964
+ return refusal('manifest exceeds maximum size');
965
+ const parsed = parseJsonRedacted(new TextDecoder().decode(bytes.subarray(0, bytesRead)));
966
+ if (!isRecord(parsed) ||
967
+ parsed.version !== 1 ||
968
+ !Array.isArray(parsed.providers)) {
969
+ return refusal('invalid manifest');
970
+ }
971
+ document = parsed;
972
+ if (parsed.providers.some(isOurManifestBlock)) {
973
+ try {
974
+ corruptLabels = readCustodyHandles(parsed, 'anthropic', 'anthropic-auth').corruptLabels;
975
+ }
976
+ catch {
977
+ return refusal('invalid manifest');
978
+ }
979
+ }
980
+ }
981
+ catch (error) {
982
+ if (errorCode(error) === 'ENOENT') {
983
+ document = { version: 1, providers: [] };
984
+ }
985
+ else if (error instanceof SyntaxError) {
986
+ return refusal('invalid JSON');
987
+ }
988
+ else {
989
+ return refusal(`unreadable (${errorCode(error)})`);
990
+ }
991
+ }
992
+ finally {
993
+ await handle?.close().catch(() => { });
994
+ }
995
+ const providers = document.providers;
996
+ if (!Array.isArray(providers))
997
+ return refusal('invalid manifest');
998
+ const blockIndex = providers.findIndex(isOurManifestBlock);
999
+ let nextProviders;
1000
+ if (blockIndex === -1) {
1001
+ nextProviders = [
1002
+ ...providers,
1003
+ {
1004
+ provider: 'anthropic',
1005
+ shape: 'oauth',
1006
+ serve: 'anthropic-auth',
1007
+ accounts: [
1008
+ {
1009
+ label: input.entry.label,
1010
+ handle: input.entry.handle,
1011
+ credential_id: input.entry.credentialId,
1012
+ },
1013
+ ],
1014
+ },
1015
+ ];
1016
+ }
1017
+ else {
1018
+ const block = providers[blockIndex];
1019
+ if (!isOurManifestBlock(block) || !Array.isArray(block.accounts))
1020
+ return refusal('invalid manifest');
1021
+ const matching = block.accounts.filter((account) => isRecord(account) && account.label === input.entry.label);
1022
+ if (matching.length === 1 &&
1023
+ matching[0]?.handle === input.entry.handle &&
1024
+ matching[0]?.credential_id === input.entry.credentialId &&
1025
+ block.shape === 'oauth' &&
1026
+ !corruptLabels.has(input.entry.label)) {
1027
+ return { status: 'unchanged' };
1028
+ }
1029
+ const replacement = {
1030
+ ...(corruptLabels.has(input.entry.label)
1031
+ ? {}
1032
+ : (matching.find(isRecord) ?? {})),
1033
+ label: input.entry.label,
1034
+ handle: input.entry.handle,
1035
+ credential_id: input.entry.credentialId,
1036
+ };
1037
+ const accounts = block.accounts.flatMap((account) => isRecord(account) && account.label === input.entry.label ? [] : [account]);
1038
+ const firstMatch = block.accounts.findIndex((account) => isRecord(account) && account.label === input.entry.label);
1039
+ accounts.splice(firstMatch === -1 ? accounts.length : firstMatch, 0, replacement);
1040
+ nextProviders = providers.map((provider, index) => index === blockIndex ? { ...block, shape: 'oauth', accounts } : provider);
1041
+ }
1042
+ const serialized = JSON.stringify({ ...document, providers: nextProviders }, null, 2);
1043
+ if (new TextEncoder().encode(serialized).byteLength > MAX_CUSTODY_MANIFEST_BYTES) {
1044
+ return refusal('manifest exceeds maximum size');
1045
+ }
1046
+ // Eviction renames the lock directory, so its temp follows the evicted owner
1047
+ // into quarantine instead of remaining able to overwrite a successor's manifest.
1048
+ // Quarantine reclaim deletes the evicted temp; it contains ids, never secrets.
1049
+ const temporaryPath = join(`${input.path}.lock`, `manifest.${lockNonce}.tmp`);
1050
+ let temporaryHandle;
1051
+ try {
1052
+ temporaryHandle = await fs.open(temporaryPath, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600);
1053
+ await temporaryHandle.writeFile(serialized);
1054
+ await temporaryHandle.sync();
1055
+ await temporaryHandle.close();
1056
+ temporaryHandle = undefined;
1057
+ await assertLease();
1058
+ await custodyManifestLockTestOptions?.beforeRename?.();
1059
+ try {
1060
+ await fs.rename(temporaryPath, input.path);
1061
+ }
1062
+ catch (error) {
1063
+ if (errorCode(error) === 'ENOENT')
1064
+ throw new CustodyManifestLockLeaseLostError('manifest lock renewal failed; write aborted');
1065
+ throw error;
1066
+ }
1067
+ return { status: 'written' };
1068
+ }
1069
+ catch (error) {
1070
+ if (error instanceof CustodyManifestLockLeaseLostError)
1071
+ throw error;
1072
+ return refusal(`unreadable (${errorCode(error)})`);
1073
+ }
1074
+ finally {
1075
+ await temporaryHandle?.close().catch(() => { });
1076
+ await fs.unlink(temporaryPath).catch(() => { });
1077
+ }
1078
+ }
1079
+ export class ClaustrumClient {
1080
+ #client;
1081
+ #connector;
1082
+ #connectionFile;
1083
+ #handshakeTimeoutMs;
1084
+ #reconnecting = null;
1085
+ #nextReconnectAt = 0;
1086
+ #closed = false;
1087
+ constructor(client, connector, connectionFile, handshakeTimeoutMs) {
1088
+ this.#client = client;
1089
+ this.#connector = connector;
1090
+ this.#connectionFile = connectionFile;
1091
+ this.#handshakeTimeoutMs = handshakeTimeoutMs;
1092
+ }
1093
+ static async connect(options = {}) {
1094
+ const connectionFile = options.connectionFile ?? resolveClaustrumConnectionPath();
1095
+ const connector = options.connector ??
1096
+ ((connectOptions) => SubcClient.connect(connectOptions));
1097
+ const client = await connector({
1098
+ connectionFile,
1099
+ handshakeTimeoutMs: options.handshakeTimeoutMs,
1100
+ });
1101
+ return new ClaustrumClient(client, connector, connectionFile, options.handshakeTimeoutMs);
1102
+ }
1103
+ async catalogList(moduleId) {
1104
+ return this.#client.catalogList(moduleId);
1105
+ }
1106
+ async routeOpen(target, identity, options = {}) {
1107
+ // The host process inherits aft's supervised-spawn environment; null is the
1108
+ // library contract that omits that identity instead of impersonating aft.
1109
+ return this.#client.routeOpen(target, identity, {
1110
+ ...options,
1111
+ consumerIdentity: null,
1112
+ });
1113
+ }
1114
+ async request(handle, body, options = {}) {
1115
+ return this.#client.request(handle, body, options);
1116
+ }
1117
+ async call(moduleId, method, params, options = {}) {
1118
+ try {
1119
+ return await this.#client.call(moduleId, method, params, {
1120
+ ...options,
1121
+ consumerIdentity: null,
1122
+ });
1123
+ }
1124
+ catch (error) {
1125
+ if (!this.#shouldReconnect(error))
1126
+ throw error;
1127
+ await this.#reconnect();
1128
+ return this.#client.call(moduleId, method, params, {
1129
+ ...options,
1130
+ consumerIdentity: null,
1131
+ });
1132
+ }
1133
+ }
1134
+ subscribe(handle, body, onEvent, options = {}) {
1135
+ return this.#client.subscribe(handle, body, onEvent, options);
1136
+ }
1137
+ async closeRoute(handle, options = {}) {
1138
+ return this.#client.closeRoute(handle, options);
1139
+ }
1140
+ async closeManagedRoute(target, identity, options = {}) {
1141
+ return this.#client.closeManagedRoute(target, identity, {
1142
+ ...options,
1143
+ consumerIdentity: null,
1144
+ });
1145
+ }
1146
+ close() {
1147
+ this.#closed = true;
1148
+ this.#client.close();
1149
+ }
1150
+ #shouldReconnect(error) {
1151
+ return (!this.#closed &&
1152
+ error instanceof SubcCallError &&
1153
+ error.kind === 'terminal' &&
1154
+ error.code !== 'missing_identity' &&
1155
+ error.code !== 'invalid_control_body');
1156
+ }
1157
+ async #reconnect() {
1158
+ if (this.#closed)
1159
+ throw new Error('Claustrum client is closed');
1160
+ if (this.#reconnecting) {
1161
+ await this.#reconnecting;
1162
+ return;
1163
+ }
1164
+ const now = Date.now();
1165
+ if (now < this.#nextReconnectAt) {
1166
+ throw new Error('Claustrum client reconnect is backed off');
1167
+ }
1168
+ this.#nextReconnectAt = now + CLAUSTRUM_CREDENTIAL_REFRESH_BACKOFF_MS;
1169
+ this.#reconnecting = this.#connector({
1170
+ connectionFile: this.#connectionFile,
1171
+ handshakeTimeoutMs: this.#handshakeTimeoutMs,
1172
+ })
1173
+ .then((client) => {
1174
+ if (this.#closed) {
1175
+ client.close();
1176
+ throw new Error('Claustrum client is closed');
1177
+ }
1178
+ const previous = this.#client;
1179
+ this.#client = client;
1180
+ previous.close();
1181
+ })
1182
+ .finally(() => {
1183
+ this.#reconnecting = null;
1184
+ });
1185
+ await this.#reconnecting;
1186
+ }
1187
+ }
1188
+ export function connectClaustrumClient(options = {}) {
1189
+ return ClaustrumClient.connect(options);
1190
+ }
1191
+ export const CLAUSTRUM_MODULE_ID = 'claustrum';
1192
+ export const DEFAULT_CLAUSTRUM_CREDENTIAL_MIN_TTL_MS = 120_000;
1193
+ const CLAUSTRUM_CREDENTIAL_REFRESH_BACKOFF_MS = 60_000;
1194
+ export const ERROR_CLASS_WIRE_SET = [
1195
+ 'transient',
1196
+ 'permanent',
1197
+ 'auth_required',
1198
+ 'context_overflow',
1199
+ ];
1200
+ export class ClaustrumCredentialError extends Error {
1201
+ code;
1202
+ action;
1203
+ errorClass;
1204
+ constructor(message, code, errorClass, action) {
1205
+ super(message);
1206
+ this.code = code;
1207
+ this.action = action;
1208
+ this.name = 'ClaustrumCredentialError';
1209
+ this.errorClass = errorClass;
1210
+ }
1211
+ }
1212
+ function credentialErrorAction(errorClass) {
1213
+ switch (errorClass) {
1214
+ case 'permanent':
1215
+ return 'gone';
1216
+ case 'auth_required':
1217
+ return 'reauth';
1218
+ case 'context_overflow':
1219
+ return 'reduce_and_retry';
1220
+ case 'transient':
1221
+ return 'retry';
1222
+ }
1223
+ }
1224
+ function asCredentialError(response, fallbackCode = 'invalid_response') {
1225
+ const result = isRecord(response) && isRecord(response.result)
1226
+ ? response.result
1227
+ : undefined;
1228
+ const error = result && isRecord(result.error) ? result.error : undefined;
1229
+ const rawErrorClass = error?.class;
1230
+ const errorClass = typeof rawErrorClass === 'string' &&
1231
+ ERROR_CLASS_WIRE_SET.includes(rawErrorClass)
1232
+ ? rawErrorClass
1233
+ : 'transient';
1234
+ if (typeof rawErrorClass !== 'string' ||
1235
+ !ERROR_CLASS_WIRE_SET.includes(rawErrorClass)) {
1236
+ logger.warn('claustrum', 'unrecognised credential error class', {
1237
+ errorClass: rawErrorClass ?? null,
1238
+ });
1239
+ }
1240
+ const code = error && typeof error.code === 'string' ? error.code : fallbackCode;
1241
+ return new ClaustrumCredentialError(`Claustrum credential request failed: ${code}`, code, errorClass, credentialErrorAction(errorClass));
1242
+ }
1243
+ function asCredentialCallError(error) {
1244
+ if (error instanceof ClaustrumCredentialError) {
1245
+ return error;
1246
+ }
1247
+ if (error instanceof SubcCallError && error.kind === 'terminal') {
1248
+ logger.warn('claustrum', 'terminal credential call failed', {
1249
+ code: error.code ?? null,
1250
+ message: error.message,
1251
+ });
1252
+ return new ClaustrumCredentialError(`Claustrum credential request failed: ${error.message}`, error.code ?? 'terminal_error', 'transient', 'retry');
1253
+ }
1254
+ const message = error instanceof Error ? error.message : String(error);
1255
+ const code = isRecord(error) && typeof error.code === 'string'
1256
+ ? error.code
1257
+ : 'transport_error';
1258
+ return new ClaustrumCredentialError(`Claustrum credential request failed: ${message}`, code, 'transient', 'retry');
1259
+ }
1260
+ function decodeCredentialGetResponse(response) {
1261
+ const result = isRecord(response) && isRecord(response.result)
1262
+ ? response.result
1263
+ : undefined;
1264
+ if (result && isRecord(result.error)) {
1265
+ throw asCredentialError(response);
1266
+ }
1267
+ const payload = result?.payload;
1268
+ if (!Array.isArray(payload) ||
1269
+ payload.length === 0 ||
1270
+ !payload.every((value) => typeof value === 'number' &&
1271
+ Number.isInteger(value) &&
1272
+ value >= 0 &&
1273
+ value <= 255)) {
1274
+ throw asCredentialError(response);
1275
+ }
1276
+ const recordVersion = result?.record_version;
1277
+ if (typeof recordVersion !== 'number' ||
1278
+ !Number.isSafeInteger(recordVersion) ||
1279
+ recordVersion < 0) {
1280
+ throw asCredentialError(response, 'invalid_record_version');
1281
+ }
1282
+ const rawExpiresAtMs = result?.expires_at_ms;
1283
+ const expiresAtMs = rawExpiresAtMs === null || rawExpiresAtMs === undefined
1284
+ ? null
1285
+ : typeof rawExpiresAtMs === 'number' && Number.isFinite(rawExpiresAtMs)
1286
+ ? rawExpiresAtMs
1287
+ : undefined;
1288
+ if (expiresAtMs === undefined) {
1289
+ throw asCredentialError(response, 'invalid_expiry');
1290
+ }
1291
+ const decoded = new TextDecoder().decode(Uint8Array.from(payload));
1292
+ return {
1293
+ payload: decoded,
1294
+ expiresAtMs,
1295
+ recordVersion,
1296
+ ...(typeof result?.project_id === 'string' && {
1297
+ projectId: result.project_id,
1298
+ }),
1299
+ ...(typeof result?.account_id === 'string' && {
1300
+ accountId: result.account_id,
1301
+ }),
1302
+ ...(typeof result?.credential_id === 'string' && {
1303
+ credentialId: result.credential_id,
1304
+ }),
1305
+ };
1306
+ }
1307
+ export class ClaustrumCredentialCache {
1308
+ #cache = new Map();
1309
+ #inFlight = new Map();
1310
+ #client;
1311
+ #identity;
1312
+ #now;
1313
+ #refreshBackoffUntil = new Map();
1314
+ #minTtlMs;
1315
+ constructor(client, options = {}) {
1316
+ this.#client = client;
1317
+ this.#identity = options.identity;
1318
+ this.#now = options.now ?? Date.now;
1319
+ this.#minTtlMs = options.minTtlMs ?? DEFAULT_CLAUSTRUM_CREDENTIAL_MIN_TTL_MS;
1320
+ if (!Number.isSafeInteger(this.#minTtlMs) || this.#minTtlMs < 0) {
1321
+ throw new RangeError('minTtlMs must be a non-negative safe integer');
1322
+ }
1323
+ }
1324
+ async get(handle, minTtlMs = this.#minTtlMs, options = {}) {
1325
+ if (!Number.isSafeInteger(minTtlMs) || minTtlMs < 0) {
1326
+ throw new RangeError('minTtlMs must be a non-negative safe integer');
1327
+ }
1328
+ const now = this.#now();
1329
+ const cached = this.#cache.get(handle);
1330
+ if (cached && cached.expiresAtMs !== null && cached.expiresAtMs > now) {
1331
+ if (cached.expiresAtMs - now <= minTtlMs) {
1332
+ this.#refreshIfApproachingExpiry(handle, now, minTtlMs);
1333
+ }
1334
+ return cached;
1335
+ }
1336
+ if (cached) {
1337
+ this.#cache.delete(handle);
1338
+ this.#refreshBackoffUntil.delete(handle);
1339
+ }
1340
+ const pending = this.#inFlight.get(handle);
1341
+ if (pending)
1342
+ return pending;
1343
+ const load = this.#load(handle, minTtlMs, options.cacheIf);
1344
+ this.#inFlight.set(handle, load);
1345
+ try {
1346
+ return await load;
1347
+ }
1348
+ finally {
1349
+ if (this.#inFlight.get(handle) === load)
1350
+ this.#inFlight.delete(handle);
1351
+ }
1352
+ }
1353
+ peek(handle) {
1354
+ return this.#cache.get(handle);
1355
+ }
1356
+ abandonPending(handle) {
1357
+ this.#inFlight.delete(handle);
1358
+ }
1359
+ seedForTest(handle, credential) {
1360
+ this.#cache.set(handle, credential);
1361
+ }
1362
+ reduceMinTtlMs() {
1363
+ this.#minTtlMs = Math.floor(this.#minTtlMs / 2);
1364
+ return this.#minTtlMs;
1365
+ }
1366
+ async reportAuthFailure(handle, providerStatusOrServedCredential, servedCredentialOrStatus, reporterSource) {
1367
+ const providerStatus = typeof providerStatusOrServedCredential === 'number'
1368
+ ? providerStatusOrServedCredential
1369
+ : typeof servedCredentialOrStatus === 'number'
1370
+ ? servedCredentialOrStatus
1371
+ : 401;
1372
+ const servedCredential = typeof providerStatusOrServedCredential === 'number'
1373
+ ? typeof servedCredentialOrStatus === 'object' &&
1374
+ servedCredentialOrStatus !== null
1375
+ ? servedCredentialOrStatus
1376
+ : undefined
1377
+ : providerStatusOrServedCredential;
1378
+ const recordVersion = servedCredential?.recordVersion;
1379
+ if (typeof recordVersion !== 'number' ||
1380
+ !Number.isSafeInteger(recordVersion) ||
1381
+ recordVersion < 0) {
1382
+ throw new TypeError('record_version is required from the credential served to the provider');
1383
+ }
1384
+ try {
1385
+ let response;
1386
+ try {
1387
+ response = await this.#client.call(CLAUSTRUM_MODULE_ID, 'credential.report_auth_failure', {
1388
+ handle,
1389
+ provider_status: providerStatus,
1390
+ record_version: recordVersion,
1391
+ ...(reporterSource ? { reporter_source: reporterSource } : {}),
1392
+ }, { identity: this.#identity });
1393
+ }
1394
+ catch (error) {
1395
+ throw asCredentialCallError(error);
1396
+ }
1397
+ const result = isRecord(response) && isRecord(response.result)
1398
+ ? response.result
1399
+ : undefined;
1400
+ if (result && isRecord(result.error)) {
1401
+ throw asCredentialError(response);
1402
+ }
1403
+ }
1404
+ finally {
1405
+ this.invalidate(handle, recordVersion);
1406
+ }
1407
+ }
1408
+ invalidate(handle, recordVersion) {
1409
+ const cached = this.#cache.get(handle);
1410
+ if (cached &&
1411
+ (recordVersion === undefined || cached.recordVersion === recordVersion)) {
1412
+ this.#cache.delete(handle);
1413
+ }
1414
+ }
1415
+ close() {
1416
+ this.#client.close();
1417
+ }
1418
+ #refreshIfApproachingExpiry(handle, now, minTtlMs) {
1419
+ if (this.#inFlight.has(handle))
1420
+ return;
1421
+ const retryAt = this.#refreshBackoffUntil.get(handle);
1422
+ if (retryAt !== undefined && retryAt > now)
1423
+ return;
1424
+ this.#refreshBackoffUntil.set(handle, now + CLAUSTRUM_CREDENTIAL_REFRESH_BACKOFF_MS);
1425
+ const load = this.#load(handle, minTtlMs);
1426
+ this.#inFlight.set(handle, load);
1427
+ void load
1428
+ .catch(() => { })
1429
+ .finally(() => {
1430
+ if (this.#inFlight.get(handle) === load)
1431
+ this.#inFlight.delete(handle);
1432
+ });
1433
+ }
1434
+ async #load(handle, minTtlMs, cacheIf) {
1435
+ let response;
1436
+ try {
1437
+ response = await this.#client.call(CLAUSTRUM_MODULE_ID, 'credential.get', {
1438
+ handle,
1439
+ force_refresh: false,
1440
+ min_ttl_ms: minTtlMs,
1441
+ }, { identity: this.#identity });
1442
+ }
1443
+ catch (error) {
1444
+ throw asCredentialCallError(error);
1445
+ }
1446
+ const result = decodeCredentialGetResponse(response);
1447
+ const credential = {
1448
+ payload: result.payload,
1449
+ expiresAtMs: result.expiresAtMs,
1450
+ recordVersion: result.recordVersion,
1451
+ ...(result.projectId !== undefined && { projectId: result.projectId }),
1452
+ ...(result.accountId !== undefined && { accountId: result.accountId }),
1453
+ ...(result.credentialId !== undefined && {
1454
+ credentialId: result.credentialId,
1455
+ }),
1456
+ };
1457
+ if (credential.expiresAtMs !== null &&
1458
+ credential.expiresAtMs > this.#now() &&
1459
+ (cacheIf?.() ?? true)) {
1460
+ this.#cache.set(handle, credential);
1461
+ }
1462
+ return credential;
1463
+ }
1464
+ }
1465
+ export async function connectClaustrumCredentialCache(options = {}) {
1466
+ if (options.enabled !== true)
1467
+ return null;
1468
+ const { enabled: _enabled, identity, now, minTtlMs, ...clientOptions } = options;
1469
+ const client = await connectClaustrumClient(clientOptions);
1470
+ return new ClaustrumCredentialCache(client, { identity, now, minTtlMs });
1471
+ }