@cortexkit/anthropic-auth-core 1.22.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 (48) hide show
  1. package/dist/accounts.d.ts +59 -2
  2. package/dist/accounts.js +666 -115
  3. package/dist/auth.js +3 -0
  4. package/dist/cachekeep.d.ts +18 -1
  5. package/dist/cachekeep.js +108 -82
  6. package/dist/cch.d.ts +11 -1
  7. package/dist/cch.js +48 -3
  8. package/dist/claude-code.d.ts +7 -1
  9. package/dist/claude-code.js +18 -3
  10. package/dist/claustrum-enrollment-registry.d.ts +17 -0
  11. package/dist/claustrum-enrollment-registry.js +194 -0
  12. package/dist/claustrum-enrollment.d.ts +70 -0
  13. package/dist/claustrum-enrollment.js +485 -0
  14. package/dist/claustrum-scoped-roster.d.ts +23 -0
  15. package/dist/claustrum-scoped-roster.js +194 -0
  16. package/dist/claustrum-scoped-runtime.d.ts +28 -0
  17. package/dist/claustrum-scoped-runtime.js +230 -0
  18. package/dist/claustrum-scoped.d.ts +39 -0
  19. package/dist/claustrum-scoped.js +193 -0
  20. package/dist/claustrum.d.ts +149 -6
  21. package/dist/claustrum.js +990 -48
  22. package/dist/commands/account.d.ts +40 -12
  23. package/dist/commands/account.js +138 -9
  24. package/dist/constants.d.ts +3 -2
  25. package/dist/constants.js +3 -2
  26. package/dist/custom-headers.d.ts +3 -0
  27. package/dist/custom-headers.js +102 -0
  28. package/dist/fast.js +1 -1
  29. package/dist/index.d.ts +7 -0
  30. package/dist/index.js +7 -0
  31. package/dist/logger.js +3 -1
  32. package/dist/model-remap.d.ts +27 -0
  33. package/dist/model-remap.js +72 -0
  34. package/dist/models.d.ts +19 -0
  35. package/dist/models.js +48 -12
  36. package/dist/oauth-profile.d.ts +2 -1
  37. package/dist/oauth-profile.js +6 -0
  38. package/dist/prime.d.ts +11 -3
  39. package/dist/prime.js +25 -7
  40. package/dist/quota-header-feed.d.ts +13 -0
  41. package/dist/quota-header-feed.js +60 -8
  42. package/dist/quota-manager.d.ts +15 -1
  43. package/dist/quota-manager.js +73 -22
  44. package/dist/relay.d.ts +8 -0
  45. package/dist/relay.js +72 -12
  46. package/dist/sticky-routing.d.ts +2 -0
  47. package/dist/sticky-routing.js +19 -1
  48. package/package.json +3 -2
package/dist/claustrum.js CHANGED
@@ -1,10 +1,101 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { userInfo } from 'node:os';
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';
3
6
  import { SubcCallError, SubcClient, } from '@cortexkit/subc-client';
7
+ import { CUSTODY_HANDLE_PATTERN } from './constants.js';
8
+ import { parseJsonRedacted } from './json';
4
9
  import { logger } from './logger';
5
- export function getDefaultClaustrumConnectionPath() {
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
+ }
6
57
  const uid = process.getuid?.() ?? userInfo().uid;
7
- return `/run/user/${uid}/subc-connection.json`;
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);
8
99
  }
9
100
  function isRecord(value) {
10
101
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -18,19 +109,20 @@ function isEndpoint(value) {
18
109
  value.port > 0 &&
19
110
  value.port <= 65_535);
20
111
  }
21
- export async function detectClaustrumConnection(path = getDefaultClaustrumConnectionPath()) {
112
+ export async function detectClaustrumConnection(explicitPath) {
113
+ const path = resolveClaustrumConnectionPath(explicitPath);
22
114
  let raw;
23
115
  try {
24
- raw = await readFile(path, 'utf8');
116
+ raw = await fs.readFile(path, 'utf8');
25
117
  }
26
118
  catch (error) {
27
- const code = error.code;
119
+ const code = errorCode(error);
28
120
  if (code === 'ENOENT')
29
121
  return { status: 'absent', path };
30
122
  return {
31
123
  status: 'malformed',
32
124
  path,
33
- reason: `unreadable (${code ?? 'unknown'})`,
125
+ reason: `unreadable (${code})`,
34
126
  };
35
127
  }
36
128
  let value;
@@ -72,14 +164,22 @@ export const CUSTODY_TOMBSTONE_PREFIX = 'claustrum-tombstone:v1:';
72
164
  export function custodyTombstoneKey(provider) {
73
165
  return `${CUSTODY_TOMBSTONE_PREFIX}${provider}`;
74
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
+ }
75
176
  export function isCustodyTombstoneValue(value) {
76
177
  return typeof value === 'string' && value.startsWith(CUSTODY_TOMBSTONE_PREFIX);
77
178
  }
78
179
  export function isCustodyTombstoneOAuth(auth, provider) {
79
180
  if (!isRecord(auth) || auth.type !== 'oauth')
80
181
  return false;
81
- const key = custodyTombstoneKey(provider);
82
- return auth.refresh === key && auth.access === key;
182
+ return auth.refresh === custodyTombstoneKey(provider);
83
183
  }
84
184
  export class CustodyTombstoneRefreshError extends Error {
85
185
  provider;
@@ -90,16 +190,24 @@ export class CustodyTombstoneRefreshError extends Error {
90
190
  this.name = 'CustodyTombstoneRefreshError';
91
191
  }
92
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
+ }
93
202
  export function assertNotCustodyTombstone(refreshToken, provider) {
94
203
  if (isCustodyTombstoneValue(refreshToken)) {
95
204
  throw new CustodyTombstoneRefreshError(provider);
96
205
  }
97
206
  }
98
207
  const CUSTODY_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
99
- // The suffix is base64url for 256 CSPRNG bits; its charset is not fixture-derived.
100
- const CUSTODY_HANDLE_PATTERN = /^ckh_[A-Za-z0-9_-]{43}$/;
101
208
  const RESERVED_CUSTODY_IDS = new Set(['__proto__', 'constructor', 'prototype']);
102
- function isCustodyId(value) {
209
+ const CUSTODY_CREDENTIAL_PREFIX = 'oauth:anthropic:';
210
+ export function isValidCustodyLabel(value) {
103
211
  return (typeof value === 'string' &&
104
212
  CUSTODY_ID_PATTERN.test(value) &&
105
213
  !RESERVED_CUSTODY_IDS.has(value));
@@ -107,43 +215,867 @@ function isCustodyId(value) {
107
215
  export function isValidCustodyHandle(value) {
108
216
  return typeof value === 'string' && CUSTODY_HANDLE_PATTERN.test(value);
109
217
  }
110
- export function readCustodyHandles(json, provider) {
111
- if (!isCustodyId(provider)) {
112
- throw new Error(`Invalid custody provider id ${provider}`);
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');
113
317
  }
114
- const providers = isRecord(json) && Array.isArray(json.providers) ? json.providers : [];
115
- const source = providers.find((entry) => isRecord(entry) &&
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) &&
116
322
  Object.hasOwn(entry, 'provider') &&
117
323
  entry.provider === provider);
118
- if (!source || !Array.isArray(source.accounts)) {
119
- throw new Error(`Missing custody handles for 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
+ });
120
394
  }
121
395
  return {
122
- provider: String(source.provider),
123
- serve: String(source.serve),
124
- shape: String(source.shape),
125
- accounts: source.accounts.flatMap((entry) => {
126
- if (!isRecord(entry))
127
- return [];
128
- if (!Object.hasOwn(entry, 'label') ||
129
- !Object.hasOwn(entry, 'handle') ||
130
- !Object.hasOwn(entry, 'credential_id') ||
131
- typeof entry.label !== 'string' ||
132
- typeof entry.handle !== 'string' ||
133
- !isCustodyId(entry.label) ||
134
- !isValidCustodyHandle(entry.handle) ||
135
- typeof entry.credential_id !== 'string')
136
- return [];
137
- return [
138
- {
139
- label: entry.label,
140
- handle: entry.handle,
141
- credentialId: entry.credential_id,
142
- },
143
- ];
144
- }),
396
+ version: 1,
397
+ provider,
398
+ serve,
399
+ accounts,
400
+ superseded,
401
+ corruptLabels,
145
402
  };
146
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
+ }
147
1079
  export class ClaustrumClient {
148
1080
  #client;
149
1081
  #connector;
@@ -159,7 +1091,7 @@ export class ClaustrumClient {
159
1091
  this.#handshakeTimeoutMs = handshakeTimeoutMs;
160
1092
  }
161
1093
  static async connect(options = {}) {
162
- const connectionFile = options.connectionFile ?? getDefaultClaustrumConnectionPath();
1094
+ const connectionFile = options.connectionFile ?? resolveClaustrumConnectionPath();
163
1095
  const connector = options.connector ??
164
1096
  ((connectOptions) => SubcClient.connect(connectOptions));
165
1097
  const client = await connector({
@@ -367,6 +1299,9 @@ function decodeCredentialGetResponse(response) {
367
1299
  ...(typeof result?.account_id === 'string' && {
368
1300
  accountId: result.account_id,
369
1301
  }),
1302
+ ...(typeof result?.credential_id === 'string' && {
1303
+ credentialId: result.credential_id,
1304
+ }),
370
1305
  };
371
1306
  }
372
1307
  export class ClaustrumCredentialCache {
@@ -386,7 +1321,7 @@ export class ClaustrumCredentialCache {
386
1321
  throw new RangeError('minTtlMs must be a non-negative safe integer');
387
1322
  }
388
1323
  }
389
- async get(handle, minTtlMs = this.#minTtlMs) {
1324
+ async get(handle, minTtlMs = this.#minTtlMs, options = {}) {
390
1325
  if (!Number.isSafeInteger(minTtlMs) || minTtlMs < 0) {
391
1326
  throw new RangeError('minTtlMs must be a non-negative safe integer');
392
1327
  }
@@ -405,7 +1340,7 @@ export class ClaustrumCredentialCache {
405
1340
  const pending = this.#inFlight.get(handle);
406
1341
  if (pending)
407
1342
  return pending;
408
- const load = this.#load(handle, minTtlMs);
1343
+ const load = this.#load(handle, minTtlMs, options.cacheIf);
409
1344
  this.#inFlight.set(handle, load);
410
1345
  try {
411
1346
  return await load;
@@ -418,6 +1353,9 @@ export class ClaustrumCredentialCache {
418
1353
  peek(handle) {
419
1354
  return this.#cache.get(handle);
420
1355
  }
1356
+ abandonPending(handle) {
1357
+ this.#inFlight.delete(handle);
1358
+ }
421
1359
  seedForTest(handle, credential) {
422
1360
  this.#cache.set(handle, credential);
423
1361
  }
@@ -493,7 +1431,7 @@ export class ClaustrumCredentialCache {
493
1431
  this.#inFlight.delete(handle);
494
1432
  });
495
1433
  }
496
- async #load(handle, minTtlMs) {
1434
+ async #load(handle, minTtlMs, cacheIf) {
497
1435
  let response;
498
1436
  try {
499
1437
  response = await this.#client.call(CLAUSTRUM_MODULE_ID, 'credential.get', {
@@ -512,9 +1450,13 @@ export class ClaustrumCredentialCache {
512
1450
  recordVersion: result.recordVersion,
513
1451
  ...(result.projectId !== undefined && { projectId: result.projectId }),
514
1452
  ...(result.accountId !== undefined && { accountId: result.accountId }),
1453
+ ...(result.credentialId !== undefined && {
1454
+ credentialId: result.credentialId,
1455
+ }),
515
1456
  };
516
1457
  if (credential.expiresAtMs !== null &&
517
- credential.expiresAtMs > this.#now()) {
1458
+ credential.expiresAtMs > this.#now() &&
1459
+ (cacheIf?.() ?? true)) {
518
1460
  this.#cache.set(handle, credential);
519
1461
  }
520
1462
  return credential;