@cortexkit/claustrum-client 0.5.0 → 0.6.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/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
  }
@@ -1,5 +1,5 @@
1
1
  import { constants as fsConstants } from 'node:fs';
2
- import { chmod, lstat, mkdir, open, readFile, readdir, rename, rm, stat, unlink } from 'node:fs/promises';
2
+ import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, unlink } from 'node:fs/promises';
3
3
  import { randomBytes, randomInt } from 'node:crypto';
4
4
  import { basename as pathBasename, dirname, join } from 'node:path';
5
5
  import { HANDLE_FILE_CONTRACT, parseHandleFile } from './handles.js';
@@ -128,6 +128,28 @@ async function withLockCommit(path, tenant, fn) {
128
128
  if (code(error) !== 'ENOENT' && Date.now() >= deadline)
129
129
  throw code(error) === 'owner_invalid' ? error : lockError('lock_busy', 'manifest lock busy');
130
130
  }
131
+ if (!observed && code(ownerReadError) === 'ENOENT') {
132
+ const metadata = await lstat(lock).catch(() => undefined);
133
+ if (metadata?.isDirectory() && Date.now() - metadata.mtimeMs >= ttl) {
134
+ await testOptions?.beforeEvict?.();
135
+ const stale = `${lock}.stale-${Math.floor(metadata.mtimeMs)}-${token()}`;
136
+ try {
137
+ await rename(lock, stale);
138
+ // A claimant may have published its owner while the eviction was pending.
139
+ const moved = await lstat(stale);
140
+ const ownerExists = await lstat(join(stale, 'owner')).then(() => true, (error) => code(error) !== 'ENOENT');
141
+ if (!ownerExists && moved.mtimeMs === metadata.mtimeMs) {
142
+ testOptions?.afterEvict?.();
143
+ continue;
144
+ }
145
+ await rename(stale, lock).catch(() => { });
146
+ }
147
+ catch (error) {
148
+ if (!['ENOENT', 'EEXIST', 'ENOTEMPTY'].includes(code(error) ?? ''))
149
+ throw error;
150
+ }
151
+ }
152
+ }
131
153
  if (observed && Date.now() - observed.claimed_at_ms >= ttl) {
132
154
  await testOptions?.beforeEvict?.();
133
155
  // The owner record remains because the quarantine is the ABA guard, not an audit log;
@@ -226,10 +248,47 @@ async function readManifest(path) {
226
248
  return parseHandleFile(JSON.parse(source.toString('utf8')));
227
249
  }
228
250
  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'); }
251
+ async function refuseWritableAncestors(parent) {
252
+ let component;
253
+ try {
254
+ component = await realpath(parent);
255
+ }
256
+ catch (error) {
257
+ if (code(error) !== 'ENOENT')
258
+ throw error;
259
+ const next = dirname(parent);
260
+ if (next !== parent)
261
+ await refuseWritableAncestors(next);
262
+ return;
263
+ }
264
+ for (;;) {
265
+ const metadata = await stat(component);
266
+ if ((metadata.mode & 0o022) !== 0 && (metadata.mode & 0o1000) === 0)
267
+ throw new Error('handle file ancestor is group- or world-writable without sticky bit');
268
+ const next = dirname(component);
269
+ if (next === component)
270
+ return;
271
+ component = next;
272
+ }
273
+ }
274
+ async function prepareParent(path) {
275
+ const parent = dirname(path);
276
+ // Check existing ancestors before creating directories under a replaceable tree.
277
+ if (process.platform !== 'win32')
278
+ await refuseWritableAncestors(parent);
279
+ const existing = await stat(parent).catch((error) => { if (code(error) !== 'ENOENT')
280
+ throw error; return undefined; });
281
+ if (existing && typeof process.getuid === 'function' && existing.uid !== process.getuid())
282
+ throw new Error('handle file parent is not owned by the current uid');
283
+ await mkdir(parent, { recursive: true, mode: 0o700 });
284
+ const metadata = await stat(parent);
285
+ if (!metadata.isDirectory())
286
+ throw new Error('handle file parent must be a directory');
287
+ if (typeof process.getuid === 'function' && metadata.uid !== process.getuid())
288
+ throw new Error('handle file parent is not owned by the current uid');
289
+ if (process.platform !== 'win32')
290
+ await refuseWritableAncestors(parent);
291
+ }
233
292
  async function writeAtomic(path, file, commit) {
234
293
  const bytes = Buffer.from(JSON.stringify(file));
235
294
  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.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",