@cortexkit/claustrum-client 0.5.0 → 0.6.1
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/ancestor-permissions.d.ts +20 -0
- package/dist/ancestor-permissions.js +44 -0
- package/dist/detect.d.ts +1 -0
- package/dist/detect.js +21 -26
- package/dist/enrollment.d.ts +2 -2
- package/dist/enrollment.js +4 -3
- package/dist/handles.d.ts +1 -0
- package/dist/handles.js +10 -11
- package/dist/manifest-lock.js +65 -5
- package/dist/wire.d.ts +10 -7
- package/dist/wire.js +21 -4
- package/package.json +1 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
export type AncestorIdentity = {
|
|
3
|
+
euid: number | undefined;
|
|
4
|
+
egid: number | undefined;
|
|
5
|
+
passwdPath: string;
|
|
6
|
+
groupPath: string;
|
|
7
|
+
};
|
|
8
|
+
export declare const ancestorIdentityContext: AsyncLocalStorage<AncestorIdentity>;
|
|
9
|
+
/**
|
|
10
|
+
* Debian OpenSSH's user-group-modes rule: ancestors without group/world-write, or with sticky, are safe.
|
|
11
|
+
* Without sticky, group-write requires effective uid/gid ownership and local passwd/group
|
|
12
|
+
* files proving the group contains only this user, with no other primary-group users.
|
|
13
|
+
* World-write never qualifies; anything the local account files cannot prove refuses.
|
|
14
|
+
* The twin accepts_ancestor in opencode_files.rs must keep the identical permission rule.
|
|
15
|
+
*/
|
|
16
|
+
export declare function acceptsAncestor(metadata: {
|
|
17
|
+
mode: number;
|
|
18
|
+
uid?: number;
|
|
19
|
+
gid?: number;
|
|
20
|
+
}, identity?: AncestorIdentity): Promise<boolean>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
export const ancestorIdentityContext = new AsyncLocalStorage();
|
|
4
|
+
function records(source, count) {
|
|
5
|
+
return source.split('\n').filter((line) => line.trim() !== '' && !line.trimStart().startsWith('#')).map((line) => {
|
|
6
|
+
const fields = line.split(':');
|
|
7
|
+
if (fields.length !== count || !fields[0] || /\s/.test(fields[0]) || !/^\d+$/.test(fields[2]) || !Number.isSafeInteger(Number(fields[2])) || Number(fields[2]) > 0xffffffff || (count === 7 && (!/^\d+$/.test(fields[3]) || !Number.isSafeInteger(Number(fields[3])) || Number(fields[3]) > 0xffffffff))) {
|
|
8
|
+
throw new Error('malformed account record');
|
|
9
|
+
}
|
|
10
|
+
if (count === 4 && fields[3] !== '' && fields[3].split(',').some((name) => name === '' || /\s/.test(name)))
|
|
11
|
+
throw new Error('malformed group members');
|
|
12
|
+
return fields;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Debian OpenSSH's user-group-modes rule: ancestors without group/world-write, or with sticky, are safe.
|
|
17
|
+
* Without sticky, group-write requires effective uid/gid ownership and local passwd/group
|
|
18
|
+
* files proving the group contains only this user, with no other primary-group users.
|
|
19
|
+
* World-write never qualifies; anything the local account files cannot prove refuses.
|
|
20
|
+
* The twin accepts_ancestor in opencode_files.rs must keep the identical permission rule.
|
|
21
|
+
*/
|
|
22
|
+
export async function acceptsAncestor(metadata, identity = ancestorIdentityContext.getStore() ?? {
|
|
23
|
+
euid: process.geteuid?.(), egid: process.getegid?.(), passwdPath: '/etc/passwd', groupPath: '/etc/group',
|
|
24
|
+
}) {
|
|
25
|
+
if ((metadata.mode & 0o022) === 0 || (metadata.mode & 0o1000) !== 0)
|
|
26
|
+
return true;
|
|
27
|
+
if ((metadata.mode & 0o002) !== 0 || identity.euid === undefined || identity.egid === undefined || metadata.uid !== identity.euid || metadata.gid !== identity.egid)
|
|
28
|
+
return false;
|
|
29
|
+
try {
|
|
30
|
+
const passwd = records(new TextDecoder('utf-8', { fatal: true }).decode(await readFile(identity.passwdPath)), 7);
|
|
31
|
+
const groups = records(new TextDecoder('utf-8', { fatal: true }).decode(await readFile(identity.groupPath)), 4);
|
|
32
|
+
const users = passwd.filter((entry) => Number(entry[2]) === identity.euid);
|
|
33
|
+
const matchingGroups = groups.filter((entry) => Number(entry[2]) === identity.egid);
|
|
34
|
+
if (users.length !== 1 || matchingGroups.length !== 1)
|
|
35
|
+
return false;
|
|
36
|
+
const name = users[0][0];
|
|
37
|
+
return (matchingGroups[0][3] === '' || matchingGroups[0][3].split(',').every((member) => member === name)) &&
|
|
38
|
+
!passwd.some((entry) => Number(entry[2]) !== identity.euid && Number(entry[3]) === identity.egid);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Local files must prove exclusivity; unavailable or malformed NSS data cannot grant an exception.
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
package/dist/detect.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export type ClaustrumDetection = {
|
|
|
13
13
|
path: string;
|
|
14
14
|
reason: string;
|
|
15
15
|
};
|
|
16
|
+
export declare function userConnectionToken(getuid?: (() => number) | null, env?: NodeJS.ProcessEnv): string;
|
|
16
17
|
export declare function getDefaultClaustrumConnectionPath(): string;
|
|
17
18
|
export declare function resolveClaustrumConnectionPath(explicit?: string): string;
|
|
18
19
|
export declare function detectClaustrumConnection(explicitPath?: string): Promise<ClaustrumDetection>;
|
package/dist/detect.js
CHANGED
|
@@ -6,36 +6,15 @@ import { PROTOCOL_VERSION, readConnectionFile, } from '@cortexkit/subc-client';
|
|
|
6
6
|
const PRODUCTION_FILE_NAME = 'subc-connection.json';
|
|
7
7
|
const TEMP_PREFIX = 'subc-';
|
|
8
8
|
const TEMP_SUFFIX = '.connection.json';
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// crates/credentials-module/src/bin/credentials_cli.rs :: discover_subc_connection_file
|
|
14
|
-
// -> XDG_RUNTIME_DIR/subc-connection.json, else
|
|
15
|
-
// ~/.local/share/cortexkit/run/subc-connection.json, else
|
|
16
|
-
// <tempdir>/subc-<token>.connection.json (glob; ambiguity REFUSES)
|
|
17
|
-
//
|
|
18
|
-
// crates/subc-core/src/bootstrap.rs :: connection_file_path_with_source
|
|
19
|
-
// -> XDG_RUNTIME_DIR/subc-connection.json, else
|
|
20
|
-
// <tempdir>/subc-<user_connection_token()>.connection.json
|
|
21
|
-
// where user_connection_token() is the UID on unix (via a probe-file UID read),
|
|
22
|
-
// else a sanitized USER/USERNAME/HOME/USERPROFILE value, else "unknown".
|
|
23
|
-
//
|
|
24
|
-
// The OLD client derived `${uid}` itself via `process.getuid()`, but the daemon may
|
|
25
|
-
// produce a different token (sanitized user on macOS, the literal "unknown" if all of
|
|
26
|
-
// the lookups miss). The glob is the only way to mirror the daemon without re-deriving
|
|
27
|
-
// the token (which is filesystem-side-effecting per the Rust side comment).
|
|
28
|
-
// The home tier resolves from `$HOME` byte-for-byte, mirroring `non_empty_env("HOME")`
|
|
29
|
-
// in `discover_subc_connection_file`: only the empty string is absent, and whitespace
|
|
30
|
-
// is not trimmed. The Rust code does NOT fall back to a `getpwuid`-style lookup; the
|
|
31
|
-
// client must not either, or an operator who points HOME at a per-test fixture loses
|
|
32
|
-
// the daemon they started there.
|
|
9
|
+
// Keep discovery in the CLI's order: exclusive SUBC_CONNECTION_FILE, runtime,
|
|
10
|
+
// HOME, exact per-user temp name, then an unambiguous temp glob. The glob supports
|
|
11
|
+
// daemons whose token derivation differs without guessing between other users.
|
|
33
12
|
function homeTierPath() {
|
|
34
13
|
const homeEnv = process.env.HOME;
|
|
35
14
|
return homeEnv ? join(homeEnv, '.local', 'share', 'cortexkit', 'run', PRODUCTION_FILE_NAME) : undefined;
|
|
36
15
|
}
|
|
37
16
|
function highestPriorityAbsentMarker() {
|
|
38
|
-
const runtime = process.env.XDG_RUNTIME_DIR
|
|
17
|
+
const runtime = process.env.XDG_RUNTIME_DIR;
|
|
39
18
|
if (runtime)
|
|
40
19
|
return join(runtime, PRODUCTION_FILE_NAME);
|
|
41
20
|
const home = homeTierPath();
|
|
@@ -47,7 +26,9 @@ function highestPriorityAbsentMarker() {
|
|
|
47
26
|
return join(userInfo().homedir, '.local', 'share', 'cortexkit', 'run', PRODUCTION_FILE_NAME);
|
|
48
27
|
}
|
|
49
28
|
function findExistingConnectionPath() {
|
|
50
|
-
|
|
29
|
+
if (process.env.SUBC_CONNECTION_FILE)
|
|
30
|
+
return process.env.SUBC_CONNECTION_FILE;
|
|
31
|
+
const runtime = process.env.XDG_RUNTIME_DIR;
|
|
51
32
|
if (runtime) {
|
|
52
33
|
const p = join(runtime, PRODUCTION_FILE_NAME);
|
|
53
34
|
if (safeIsFile(p))
|
|
@@ -56,17 +37,31 @@ function findExistingConnectionPath() {
|
|
|
56
37
|
const home = homeTierPath();
|
|
57
38
|
if (home && safeIsFile(home))
|
|
58
39
|
return home;
|
|
40
|
+
const exact = join(tmpdir(), `${TEMP_PREFIX}${userConnectionToken()}${TEMP_SUFFIX}`);
|
|
41
|
+
if (safeIsFile(exact))
|
|
42
|
+
return exact;
|
|
59
43
|
const matches = listSubcConnectionFiles(tmpdir());
|
|
60
44
|
// A single matching file IS the daemon; multiple matches mean different OS users
|
|
61
45
|
// happened to share the temp dir. Picking one would route credential-bearing
|
|
62
46
|
// requests at another user's daemon, so REFUSE both picks and the absent path.
|
|
63
47
|
return matches.length === 1 ? matches[0] : undefined;
|
|
64
48
|
}
|
|
49
|
+
export function userConnectionToken(getuid = process.getuid ?? null, env = process.env) {
|
|
50
|
+
if (getuid)
|
|
51
|
+
return String(getuid());
|
|
52
|
+
for (const key of ['USER', 'USERNAME', 'HOME', 'USERPROFILE']) {
|
|
53
|
+
const value = env[key];
|
|
54
|
+
if (value)
|
|
55
|
+
return Array.from(value, (character) => /^[a-zA-Z0-9_-]$/.test(character) ? character : '_').join('');
|
|
56
|
+
}
|
|
57
|
+
return 'unknown';
|
|
58
|
+
}
|
|
65
59
|
export function getDefaultClaustrumConnectionPath() {
|
|
66
60
|
return findExistingConnectionPath() ?? highestPriorityAbsentMarker();
|
|
67
61
|
}
|
|
68
62
|
export function resolveClaustrumConnectionPath(explicit) {
|
|
69
63
|
return (explicit?.trim() ||
|
|
64
|
+
process.env.SUBC_CONNECTION_FILE ||
|
|
70
65
|
process.env.CLAUSTRUM_SUBC_CONNECTION?.trim() ||
|
|
71
66
|
getDefaultClaustrumConnectionPath());
|
|
72
67
|
}
|
package/dist/enrollment.d.ts
CHANGED
|
@@ -5,8 +5,8 @@ export type EnrollmentTokenFile = {
|
|
|
5
5
|
/**
|
|
6
6
|
* Atomically replace a consumer's enrollment credential.
|
|
7
7
|
*
|
|
8
|
-
* The file is always mode 0600
|
|
9
|
-
*
|
|
8
|
+
* The file is always mode 0600. Writable canonical ancestors require sticky or a
|
|
9
|
+
* proven user-private group. This is the same rule implemented by the
|
|
10
10
|
* shipped `refuse_writable_ancestor` symbol in `opencode_files.rs`.
|
|
11
11
|
*/
|
|
12
12
|
export declare function writeEnrollmentTokenFile(path: string, value: EnrollmentTokenFile): Promise<void>;
|
package/dist/enrollment.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { acceptsAncestor } from './ancestor-permissions.js';
|
|
1
2
|
import { randomBytes } from 'node:crypto';
|
|
2
3
|
import { chmod, mkdir, open, realpath, rename, stat, unlink } from 'node:fs/promises';
|
|
3
4
|
import { basename, dirname, join } from 'node:path';
|
|
@@ -23,7 +24,7 @@ async function refuseWritableAncestor(parent) {
|
|
|
23
24
|
const metadata = await stat(component).catch(() => undefined);
|
|
24
25
|
// Canonicalisation already traversed this component. If a later stat races with a
|
|
25
26
|
// rename, let the create/rename operation below report the concrete failure.
|
|
26
|
-
if (metadata && (
|
|
27
|
+
if (metadata && !(await acceptsAncestor(metadata))) {
|
|
27
28
|
throw new Error(`enrollment token ancestor ${component} is group- or world-writable without sticky bit`);
|
|
28
29
|
}
|
|
29
30
|
const next = dirname(component);
|
|
@@ -35,8 +36,8 @@ async function refuseWritableAncestor(parent) {
|
|
|
35
36
|
/**
|
|
36
37
|
* Atomically replace a consumer's enrollment credential.
|
|
37
38
|
*
|
|
38
|
-
* The file is always mode 0600
|
|
39
|
-
*
|
|
39
|
+
* The file is always mode 0600. Writable canonical ancestors require sticky or a
|
|
40
|
+
* proven user-private group. This is the same rule implemented by the
|
|
40
41
|
* shipped `refuse_writable_ancestor` symbol in `opencode_files.rs`.
|
|
41
42
|
*/
|
|
42
43
|
export async function writeEnrollmentTokenFile(path, value) {
|
package/dist/handles.d.ts
CHANGED
package/dist/handles.js
CHANGED
|
@@ -3,6 +3,7 @@ import { constants } from 'node:fs';
|
|
|
3
3
|
import { lstat as nodeLstat, open as nodeOpen, readFile, realpath, stat as nodeStat } from 'node:fs/promises';
|
|
4
4
|
import { userInfo } from 'node:os';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
|
+
import { acceptsAncestor } from './ancestor-permissions.js';
|
|
6
7
|
export const HANDLE_FILE_CONTRACT = {
|
|
7
8
|
maxBytes: 256 * 1024,
|
|
8
9
|
mode: 0o600,
|
|
@@ -200,16 +201,14 @@ async function readHandleSnapshot(path = defaultHandleFilePath(), io = {}) {
|
|
|
200
201
|
if (expectedUid !== undefined && parent.uid !== undefined && parent.uid !== expectedUid) {
|
|
201
202
|
invalid('handle file parent is not owned by the current uid');
|
|
202
203
|
}
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
// they own. Mirrors the Rust check in opencode_files.rs; the two must not drift.
|
|
212
|
-
if ((parent.mode & 0o022) !== 0 && (parent.mode & 0o1000) === 0) {
|
|
204
|
+
// Group-write counts as well as world-write: a directory writer can unlink and
|
|
205
|
+
// replace a mode-0600 file regardless of its file permissions or owner. Owning
|
|
206
|
+
// the directory does not exclude other group members. Sticky exempts both bits
|
|
207
|
+
// because a writer may unlink only files they own. Without sticky, group-write
|
|
208
|
+
// is safe only when local account files prove a user-private effective group;
|
|
209
|
+
// world-write is never exempt. Keep this rule identical to Rust's accepts_ancestor
|
|
210
|
+
// and parent check in opencode_files.rs.
|
|
211
|
+
if (!(await acceptsAncestor(parent))) {
|
|
213
212
|
invalid('handle file parent is group- or world-writable without sticky bit');
|
|
214
213
|
}
|
|
215
214
|
// EVERY ANCESTOR, NOT JUST THIS ONE, OR THE GUARANTEE DOES NOT COMPOSE. The immediate
|
|
@@ -244,7 +243,7 @@ async function readHandleSnapshot(path = defaultHandleFilePath(), io = {}) {
|
|
|
244
243
|
catch {
|
|
245
244
|
ancestor = undefined;
|
|
246
245
|
}
|
|
247
|
-
if (ancestor && (
|
|
246
|
+
if (ancestor && !(await acceptsAncestor(ancestor))) {
|
|
248
247
|
invalid(`handle file ancestor ${component} is group- or world-writable without sticky bit`);
|
|
249
248
|
}
|
|
250
249
|
const next = dirname(component);
|
package/dist/manifest-lock.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { acceptsAncestor } from './ancestor-permissions.js';
|
|
1
2
|
import { constants as fsConstants } from 'node:fs';
|
|
2
|
-
import { chmod, lstat, mkdir, open, readFile, readdir, rename, rm, stat, unlink } from 'node:fs/promises';
|
|
3
|
+
import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, unlink } from 'node:fs/promises';
|
|
3
4
|
import { randomBytes, randomInt } from 'node:crypto';
|
|
4
5
|
import { basename as pathBasename, dirname, join } from 'node:path';
|
|
5
6
|
import { HANDLE_FILE_CONTRACT, parseHandleFile } from './handles.js';
|
|
@@ -128,6 +129,28 @@ async function withLockCommit(path, tenant, fn) {
|
|
|
128
129
|
if (code(error) !== 'ENOENT' && Date.now() >= deadline)
|
|
129
130
|
throw code(error) === 'owner_invalid' ? error : lockError('lock_busy', 'manifest lock busy');
|
|
130
131
|
}
|
|
132
|
+
if (!observed && code(ownerReadError) === 'ENOENT') {
|
|
133
|
+
const metadata = await lstat(lock).catch(() => undefined);
|
|
134
|
+
if (metadata?.isDirectory() && Date.now() - metadata.mtimeMs >= ttl) {
|
|
135
|
+
await testOptions?.beforeEvict?.();
|
|
136
|
+
const stale = `${lock}.stale-${Math.floor(metadata.mtimeMs)}-${token()}`;
|
|
137
|
+
try {
|
|
138
|
+
await rename(lock, stale);
|
|
139
|
+
// A claimant may have published its owner while the eviction was pending.
|
|
140
|
+
const moved = await lstat(stale);
|
|
141
|
+
const ownerExists = await lstat(join(stale, 'owner')).then(() => true, (error) => code(error) !== 'ENOENT');
|
|
142
|
+
if (!ownerExists && moved.mtimeMs === metadata.mtimeMs) {
|
|
143
|
+
testOptions?.afterEvict?.();
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
await rename(stale, lock).catch(() => { });
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (!['ENOENT', 'EEXIST', 'ENOTEMPTY'].includes(code(error) ?? ''))
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
131
154
|
if (observed && Date.now() - observed.claimed_at_ms >= ttl) {
|
|
132
155
|
await testOptions?.beforeEvict?.();
|
|
133
156
|
// The owner record remains because the quarantine is the ABA guard, not an audit log;
|
|
@@ -226,10 +249,47 @@ async function readManifest(path) {
|
|
|
226
249
|
return parseHandleFile(JSON.parse(source.toString('utf8')));
|
|
227
250
|
}
|
|
228
251
|
const foreign = (file, tenant) => file.providers.filter((provider) => provider.serve !== tenant).map((provider) => JSON.stringify(provider));
|
|
229
|
-
async function
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
252
|
+
async function refuseWritableAncestors(parent) {
|
|
253
|
+
let component;
|
|
254
|
+
try {
|
|
255
|
+
component = await realpath(parent);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
if (code(error) !== 'ENOENT')
|
|
259
|
+
throw error;
|
|
260
|
+
const next = dirname(parent);
|
|
261
|
+
if (next !== parent)
|
|
262
|
+
await refuseWritableAncestors(next);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
for (;;) {
|
|
266
|
+
const metadata = await stat(component);
|
|
267
|
+
if (!(await acceptsAncestor(metadata)))
|
|
268
|
+
throw new Error('handle file ancestor is group- or world-writable without sticky bit');
|
|
269
|
+
const next = dirname(component);
|
|
270
|
+
if (next === component)
|
|
271
|
+
return;
|
|
272
|
+
component = next;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async function prepareParent(path) {
|
|
276
|
+
const parent = dirname(path);
|
|
277
|
+
// Check existing ancestors before creating directories under a replaceable tree.
|
|
278
|
+
if (process.platform !== 'win32')
|
|
279
|
+
await refuseWritableAncestors(parent);
|
|
280
|
+
const existing = await stat(parent).catch((error) => { if (code(error) !== 'ENOENT')
|
|
281
|
+
throw error; return undefined; });
|
|
282
|
+
if (existing && typeof process.getuid === 'function' && existing.uid !== process.getuid())
|
|
283
|
+
throw new Error('handle file parent is not owned by the current uid');
|
|
284
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
285
|
+
const metadata = await stat(parent);
|
|
286
|
+
if (!metadata.isDirectory())
|
|
287
|
+
throw new Error('handle file parent must be a directory');
|
|
288
|
+
if (typeof process.getuid === 'function' && metadata.uid !== process.getuid())
|
|
289
|
+
throw new Error('handle file parent is not owned by the current uid');
|
|
290
|
+
if (process.platform !== 'win32')
|
|
291
|
+
await refuseWritableAncestors(parent);
|
|
292
|
+
}
|
|
233
293
|
async function writeAtomic(path, file, commit) {
|
|
234
294
|
const bytes = Buffer.from(JSON.stringify(file));
|
|
235
295
|
if (bytes.byteLength > HANDLE_FILE_CONTRACT.maxBytes)
|
package/dist/wire.d.ts
CHANGED
|
@@ -186,26 +186,29 @@ export declare class ClaustrumClient {
|
|
|
186
186
|
/**
|
|
187
187
|
* A row from the caller's own grant-covered inventory.
|
|
188
188
|
*
|
|
189
|
-
*
|
|
189
|
+
* Selection fields answer different questions. Pick by what you are about
|
|
190
190
|
* to do with the token:
|
|
191
191
|
*
|
|
192
|
+
* - `providerIds` — operator-assigned catalog provider ids; match exactly, with no fallback.
|
|
193
|
+
* - `authMethod` — the closed authentication method derived from the unsealed record,
|
|
194
|
+
* not the id spelling. Absent on sign/open-only rows or when no method is defined.
|
|
195
|
+
*
|
|
192
196
|
* - `refreshAdapter` — whose PROTOCOL the credential speaks. Use it when the token
|
|
193
197
|
* goes to a provider's own endpoints. Anything else is invalid there.
|
|
194
198
|
* - `serves` — which model VENDORS are reachable through it. Use it when choosing
|
|
195
199
|
* where a model can be served from, and the protocol is not yours to care about.
|
|
196
200
|
*
|
|
197
|
-
* Never
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
* An earlier version of this comment called `serves` "the routing axis" full stop. That
|
|
201
|
-
* was written before `refreshAdapter` existed and is wrong for the protocol-native case:
|
|
202
|
-
* it would send a Claude OAuth request to `apikey:openrouter`.
|
|
201
|
+
* Never infer these fields from the id spelling. A credential reaching Anthropic models
|
|
202
|
+
* through OpenRouter still speaks OpenRouter's protocol, not Anthropic's.
|
|
203
203
|
*/
|
|
204
204
|
export interface ScopedInventoryRow {
|
|
205
205
|
readonly id: string;
|
|
206
206
|
readonly categories: readonly string[];
|
|
207
207
|
readonly credentialType: string;
|
|
208
208
|
readonly serves: readonly string[];
|
|
209
|
+
/** Operator-assigned catalog ids, always present, including an empty set. */
|
|
210
|
+
readonly providerIds: readonly string[];
|
|
211
|
+
readonly authMethod?: 'apikey' | 'chatgpt' | 'antigravity' | 'oauth';
|
|
209
212
|
/**
|
|
210
213
|
* Which provider protocol this credential speaks. Absent for static keys.
|
|
211
214
|
*
|
package/dist/wire.js
CHANGED
|
@@ -54,8 +54,15 @@ export function decodeCredential(response, logUnknownClass) {
|
|
|
54
54
|
!isOptionalString(orgName)) {
|
|
55
55
|
throw asCredentialError(response, 'invalid_response', logUnknownClass);
|
|
56
56
|
}
|
|
57
|
+
let material;
|
|
58
|
+
try {
|
|
59
|
+
material = new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(payload));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw asCredentialError(response, 'invalid_response', logUnknownClass);
|
|
63
|
+
}
|
|
57
64
|
return {
|
|
58
|
-
material
|
|
65
|
+
material,
|
|
59
66
|
recordVersion,
|
|
60
67
|
expiresAtMs,
|
|
61
68
|
credentialId,
|
|
@@ -250,7 +257,7 @@ export class ClaustrumClient {
|
|
|
250
257
|
const result = decodeEnrollmentResult(response, this.#logger);
|
|
251
258
|
const token = result.token;
|
|
252
259
|
const generation = result.token_generation;
|
|
253
|
-
if (typeof token !== 'string' || token.length === 0 || typeof generation !== 'number') {
|
|
260
|
+
if (typeof token !== 'string' || token.length === 0 || typeof generation !== 'number' || !Number.isInteger(generation) || generation < 0) {
|
|
254
261
|
throw asCredentialError(response, 'invalid_response', this.#logger);
|
|
255
262
|
}
|
|
256
263
|
return { token, tokenGeneration: generation };
|
|
@@ -260,8 +267,9 @@ export class ClaustrumClient {
|
|
|
260
267
|
this.#client.close();
|
|
261
268
|
}
|
|
262
269
|
async #call(method, params) {
|
|
270
|
+
const sentClient = this.#client;
|
|
263
271
|
try {
|
|
264
|
-
return await
|
|
272
|
+
return await sentClient.call(CLAUSTRUM_MODULE_ID, method, params, {
|
|
265
273
|
identity: this.#identity,
|
|
266
274
|
consumerIdentity: null,
|
|
267
275
|
});
|
|
@@ -269,7 +277,8 @@ export class ClaustrumClient {
|
|
|
269
277
|
catch (error) {
|
|
270
278
|
if (this.#shouldReconnect(error)) {
|
|
271
279
|
try {
|
|
272
|
-
|
|
280
|
+
if (this.#client === sentClient)
|
|
281
|
+
await this.#reconnect();
|
|
273
282
|
}
|
|
274
283
|
catch (reconnectError) {
|
|
275
284
|
throw this.#asTransportError(reconnectError);
|
|
@@ -392,6 +401,12 @@ export function decodeScopedInventory(response, logUnknownClass) {
|
|
|
392
401
|
: undefined;
|
|
393
402
|
if (createdAtMs === undefined)
|
|
394
403
|
throw asCredentialError(response, 'invalid_response', logUnknownClass);
|
|
404
|
+
const authMethod = row.auth_method;
|
|
405
|
+
if (authMethod !== undefined &&
|
|
406
|
+
authMethod !== 'apikey' && authMethod !== 'chatgpt' &&
|
|
407
|
+
authMethod !== 'antigravity' && authMethod !== 'oauth') {
|
|
408
|
+
throw asCredentialError(response, 'invalid_response', logUnknownClass);
|
|
409
|
+
}
|
|
395
410
|
if (!isOptionalString(row.refresh_adapter)) {
|
|
396
411
|
throw asCredentialError(response, 'invalid_response', logUnknownClass);
|
|
397
412
|
}
|
|
@@ -403,6 +418,8 @@ export function decodeScopedInventory(response, logUnknownClass) {
|
|
|
403
418
|
categories: strings(row.categories),
|
|
404
419
|
credentialType: row.type,
|
|
405
420
|
serves: strings(row.serves),
|
|
421
|
+
providerIds: strings(row.provider_ids),
|
|
422
|
+
authMethod,
|
|
406
423
|
state: row.state,
|
|
407
424
|
refreshAdapter: row.refresh_adapter,
|
|
408
425
|
recordVersion,
|