@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.
- package/dist/accounts.d.ts +59 -2
- package/dist/accounts.js +666 -115
- package/dist/auth.js +3 -0
- package/dist/cachekeep.d.ts +18 -1
- package/dist/cachekeep.js +108 -82
- package/dist/cch.d.ts +11 -1
- package/dist/cch.js +48 -3
- package/dist/claude-code.d.ts +7 -1
- package/dist/claude-code.js +18 -3
- package/dist/claustrum-enrollment-registry.d.ts +17 -0
- package/dist/claustrum-enrollment-registry.js +194 -0
- package/dist/claustrum-enrollment.d.ts +70 -0
- package/dist/claustrum-enrollment.js +485 -0
- package/dist/claustrum-scoped-roster.d.ts +23 -0
- package/dist/claustrum-scoped-roster.js +194 -0
- package/dist/claustrum-scoped-runtime.d.ts +28 -0
- package/dist/claustrum-scoped-runtime.js +230 -0
- package/dist/claustrum-scoped.d.ts +39 -0
- package/dist/claustrum-scoped.js +193 -0
- package/dist/claustrum.d.ts +149 -6
- package/dist/claustrum.js +990 -48
- package/dist/commands/account.d.ts +40 -12
- package/dist/commands/account.js +138 -9
- package/dist/constants.d.ts +3 -2
- package/dist/constants.js +3 -2
- package/dist/custom-headers.d.ts +3 -0
- package/dist/custom-headers.js +102 -0
- package/dist/fast.js +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/logger.js +3 -1
- package/dist/model-remap.d.ts +27 -0
- package/dist/model-remap.js +72 -0
- package/dist/models.d.ts +19 -0
- package/dist/models.js +48 -12
- package/dist/oauth-profile.d.ts +2 -1
- package/dist/oauth-profile.js +6 -0
- package/dist/prime.d.ts +11 -3
- package/dist/prime.js +25 -7
- package/dist/quota-header-feed.d.ts +13 -0
- package/dist/quota-header-feed.js +60 -8
- package/dist/quota-manager.d.ts +15 -1
- package/dist/quota-manager.js +73 -22
- package/dist/relay.d.ts +8 -0
- package/dist/relay.js +72 -12
- package/dist/sticky-routing.d.ts +2 -0
- package/dist/sticky-routing.js +19 -1
- package/package.json +3 -2
package/dist/claustrum.js
CHANGED
|
@@ -1,10 +1,101 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
111
|
-
if (!
|
|
112
|
-
throw new
|
|
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
|
-
|
|
115
|
-
|
|
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 (
|
|
119
|
-
throw new
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
accounts
|
|
126
|
-
|
|
127
|
-
|
|
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 ??
|
|
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;
|