@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.
@@ -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
- // The default discovery order MUST mirror `ck` / the daemon, because a client that picks a
10
- // different file ends up talking to the wrong daemon. The Rust source of truth (sibling
11
- // pin, NOT regenerated here) is:
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?.trim();
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
- const runtime = process.env.XDG_RUNTIME_DIR?.trim();
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
  }
@@ -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 and every canonical ancestor is rejected when it is
9
- * group- or world-writable without sticky. This is the same rule implemented by the
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>;
@@ -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 && (metadata.mode & 0o022) !== 0 && (metadata.mode & 0o1000) === 0) {
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 and every canonical ancestor is rejected when it is
39
- * group- or world-writable without sticky. This is the same rule implemented by the
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
@@ -33,6 +33,7 @@ type HandleFileStat = {
33
33
  isSymbolicLink?(): boolean;
34
34
  mode: number;
35
35
  size?: number;
36
+ gid?: number;
36
37
  uid?: number;
37
38
  mtimeMs?: number;
38
39
  };
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
- // GROUP-WRITABLE COUNTS, NOT ONLY WORLD-WRITABLE. Directory write permission governs
204
- // unlink and create, so anyone who can write the parent can replace a mode-0600 file
205
- // wholesale however tightly the file itself is locked. The owner check above does not
206
- // close it: a directory I own can still be 0770, and then any other uid in that group
207
- // can swap the handle file for one of theirs. A cross-uid attacker is not conceded by
208
- // this threat model the way a same-uid one is.
209
- //
210
- // Sticky exempts both bits for one reason: with it set, a writer may only unlink files
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 && (ancestor.mode & 0o022) !== 0 && (ancestor.mode & 0o1000) === 0) {
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);
@@ -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 prepareParent(path) { const parent = dirname(path); await mkdir(parent, { recursive: true, mode: 0o700 }); const metadata = await stat(parent); if (!metadata.isDirectory())
230
- throw new Error('handle file parent must be a directory'); if ((metadata.mode & 0o002) !== 0 && (metadata.mode & 0o1000) === 0)
231
- throw new Error('handle file parent is world-writable without sticky bit'); if ((metadata.mode & 0o022) !== 0)
232
- throw new Error('handle file parent must not be group- or other-writable'); }
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
- * TWO SELECTION AXES, AND THEY ANSWER DIFFERENT QUESTIONS. Pick by what you are about
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 the id spelling, in either case: on a live vault 8 rows serve Anthropic models
198
- * and 3 of them contain no "anthropic" anywhere in their id.
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: new TextDecoder().decode(Uint8Array.from(payload)),
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 this.#client.call(CLAUSTRUM_MODULE_ID, method, params, {
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
- await this.#reconnect();
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/claustrum-client",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",