@cortexkit/claustrum-client 0.4.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/README.md CHANGED
@@ -16,6 +16,11 @@ credential caching, refresh, account scheduling, and retry policy.
16
16
  by design (the vault cannot enumerate which). Treat it as gone either way: re-run
17
17
  `ck auth migrate-opencode` to mint a fresh handle; the prior record, if any, is kept.
18
18
  • `auth_required` means the record is latched and needs reauthentication.
19
+ • `statusCredential` on an unknown or revoked handle RESOLVES, it does not throw: it
20
+ returns `ready: false, lastErrorCode: 'not_found'` with `recordVersion`, `credentialId`
21
+ and `stalePending` absent. Since 0.5.0 `CredentialStatus.recordVersion` is optional for
22
+ that reason; handle `undefined` rather than defaulting it to a number, which would read
23
+ as a version older than every real one.
19
24
  • Unknown server error classes are bounded to `transient`; callers must retry them
20
25
  rather than treating a forward-compatible class as permanent.
21
26
 
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
@@ -15,6 +15,14 @@ export type ClaustrumClientOptions = {
15
15
  /**
16
16
  * Credential material plus optional non-secret identity metadata. Missing wire fields remain
17
17
  * `undefined`; a present non-string identity value rejects the response as invalid.
18
+ *
19
+ * An absent identity field means the vault had nothing to assert — NOT that identity failed.
20
+ * Consumers must not collapse absence into mismatch: assert on presence, refuse on a present
21
+ * difference, and serve (log, if it matters) when the field is missing. Absence proves neither
22
+ * the right identity nor the wrong one, so treating it as a failure converts a vault that could
23
+ * not answer into a consumer that cannot serve. The vault records no per-read success (at most
24
+ * the first scoped read per principal), and nothing at all when a consumer declines to serve, so
25
+ * an outage caused that way is visible only to the consumer that caused it.
18
26
  */
19
27
  export type ServedCredential = {
20
28
  material: string;
@@ -30,6 +38,12 @@ export type ServedCredential = {
30
38
  /**
31
39
  * Provider account identity the served token executes under. Account-scoped routing joins this
32
40
  * value with `recordVersion`; it is neither the operator's credential label nor the bearer handle.
41
+ *
42
+ * BEST-EFFORT, NEVER GUARANTEED, including for JWT providers. The vault parses the claim live
43
+ * from the served access token and falls back to stored login-time identity; both legs can be
44
+ * empty, and the serve path yields nothing rather than fabricating an id. It is absent when the
45
+ * provider has no known claim, the token does not carry one, or no identity was captured at
46
+ * login. Do not gate serving on its presence.
33
47
  */
34
48
  accountId?: string;
35
49
  /** Non-secret account display metadata captured at login. */
@@ -37,14 +51,43 @@ export type ServedCredential = {
37
51
  /** Non-secret organization or workspace display metadata captured at login. */
38
52
  orgName?: string;
39
53
  };
54
+ /**
55
+ * Non-secret health for one handle, read without minting anything.
56
+ *
57
+ * An unresolved handle (unknown or revoked) is an ordinary answer, not an error: it decodes
58
+ * as `ready: false, lastErrorCode: 'not_found'` with `credentialId`, `recordVersion` and
59
+ * `stalePending` all absent. The vault omits them on purpose so a probe learns nothing about
60
+ * what exists and no placeholder value is asserted about a record it never saw.
61
+ */
40
62
  export type CredentialStatus = {
41
63
  ready: boolean;
42
64
  lastErrorCode: string | null;
43
65
  leaseHeld: boolean;
44
- recordVersion: number;
66
+ /**
67
+ * Operator-chosen record label the handle resolved to, for verifying a binding the caller
68
+ * already holds. Not a routing key. Absent when the handle did not resolve.
69
+ */
70
+ credentialId?: string;
71
+ /**
72
+ * The record's change cursor, present only when the handle resolved to a record.
73
+ *
74
+ * Optional, and OMITTED from the object rather than defaulted, because there is no honest
75
+ * default: 0 or -1 would compare as older than every real version, so a poller would read a
76
+ * revoked handle as a change it had yet to see, forever. With the key absent, TypeScript
77
+ * refuses arithmetic or ordering on it until the caller has handled `undefined`, and
78
+ * `'recordVersion' in status` is false. Never replace absence with a number (`?? 0`).
79
+ */
80
+ recordVersion?: number;
45
81
  stalePending?: boolean;
46
82
  };
47
83
  export type ClaustrumReporterSource = 'direct' | 'relay_status_field' | 'relay_message_parse';
84
+ /**
85
+ * Exported for the golden-reply test only; `index.ts` re-exports by name, so this does not
86
+ * widen the published surface.
87
+ */
88
+ export declare function decodeCredential(response: unknown, logUnknownClass: (errorClass: string) => void): ServedCredential;
89
+ /** Exported for the golden-reply test only, like `decodeCredential`. */
90
+ export declare function decodeStatus(response: unknown, logUnknownClass: (errorClass: string) => void): CredentialStatus;
48
91
  export declare class ClaustrumClient {
49
92
  #private;
50
93
  private constructor();
@@ -143,26 +186,29 @@ export declare class ClaustrumClient {
143
186
  /**
144
187
  * A row from the caller's own grant-covered inventory.
145
188
  *
146
- * 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
147
190
  * to do with the token:
148
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
+ *
149
196
  * - `refreshAdapter` — whose PROTOCOL the credential speaks. Use it when the token
150
197
  * goes to a provider's own endpoints. Anything else is invalid there.
151
198
  * - `serves` — which model VENDORS are reachable through it. Use it when choosing
152
199
  * where a model can be served from, and the protocol is not yours to care about.
153
200
  *
154
- * Never the id spelling, in either case: on a live vault 8 rows serve Anthropic models
155
- * and 3 of them contain no "anthropic" anywhere in their id.
156
- *
157
- * An earlier version of this comment called `serves` "the routing axis" full stop. That
158
- * was written before `refreshAdapter` existed and is wrong for the protocol-native case:
159
- * 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.
160
203
  */
161
204
  export interface ScopedInventoryRow {
162
205
  readonly id: string;
163
206
  readonly categories: readonly string[];
164
207
  readonly credentialType: string;
165
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';
166
212
  /**
167
213
  * Which provider protocol this credential speaks. Absent for static keys.
168
214
  *
package/dist/wire.js CHANGED
@@ -15,7 +15,11 @@ function asRecordVersion(value) {
15
15
  function isOptionalString(value) {
16
16
  return value === undefined || typeof value === 'string';
17
17
  }
18
- function decodeCredential(response, logUnknownClass) {
18
+ /**
19
+ * Exported for the golden-reply test only; `index.ts` re-exports by name, so this does not
20
+ * widen the published surface.
21
+ */
22
+ export function decodeCredential(response, logUnknownClass) {
19
23
  if (hasCredentialError(response))
20
24
  throw asCredentialError(response, 'invalid_response', logUnknownClass);
21
25
  const result = isRecord(response) && isRecord(response.result) ? response.result : undefined;
@@ -50,8 +54,15 @@ function decodeCredential(response, logUnknownClass) {
50
54
  !isOptionalString(orgName)) {
51
55
  throw asCredentialError(response, 'invalid_response', logUnknownClass);
52
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
+ }
53
64
  return {
54
- material: new TextDecoder().decode(Uint8Array.from(payload)),
65
+ material,
55
66
  recordVersion,
56
67
  expiresAtMs,
57
68
  credentialId,
@@ -61,15 +72,22 @@ function decodeCredential(response, logUnknownClass) {
61
72
  orgName,
62
73
  };
63
74
  }
64
- function decodeStatus(response, logUnknownClass) {
75
+ /** Exported for the golden-reply test only, like `decodeCredential`. */
76
+ export function decodeStatus(response, logUnknownClass) {
65
77
  if (hasCredentialError(response))
66
78
  throw asCredentialError(response, 'invalid_response', logUnknownClass);
67
79
  const result = isRecord(response) && isRecord(response.result) ? response.result : undefined;
68
- const recordVersion = asRecordVersion(result?.record_version);
80
+ // Absent is a valid reply (the handle did not resolve); a PRESENT value that is not a
81
+ // non-negative integer is malformed. The producer omits the key rather than nulling it,
82
+ // so an explicit null is refused too.
83
+ const rawRecordVersion = result?.record_version;
84
+ const recordVersion = rawRecordVersion === undefined ? undefined : asRecordVersion(rawRecordVersion);
85
+ const credentialId = result?.credential_id;
69
86
  if (typeof result?.ready !== 'boolean' ||
70
87
  (result?.last_error_code !== undefined && result?.last_error_code !== null && typeof result?.last_error_code !== 'string') ||
71
88
  typeof result?.lease_held !== 'boolean' ||
72
- recordVersion === undefined ||
89
+ (rawRecordVersion !== undefined && recordVersion === undefined) ||
90
+ !isOptionalString(credentialId) ||
73
91
  (result?.stale_pending !== undefined && typeof result.stale_pending !== 'boolean')) {
74
92
  throw asCredentialError(response, 'invalid_status', logUnknownClass);
75
93
  }
@@ -77,7 +95,8 @@ function decodeStatus(response, logUnknownClass) {
77
95
  ready: result.ready,
78
96
  lastErrorCode: result.last_error_code ?? null,
79
97
  leaseHeld: result.lease_held,
80
- recordVersion,
98
+ ...(credentialId === undefined ? {} : { credentialId }),
99
+ ...(recordVersion === undefined ? {} : { recordVersion }),
81
100
  ...(result.stale_pending === undefined ? {} : { stalePending: result.stale_pending }),
82
101
  };
83
102
  }
@@ -238,7 +257,7 @@ export class ClaustrumClient {
238
257
  const result = decodeEnrollmentResult(response, this.#logger);
239
258
  const token = result.token;
240
259
  const generation = result.token_generation;
241
- 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) {
242
261
  throw asCredentialError(response, 'invalid_response', this.#logger);
243
262
  }
244
263
  return { token, tokenGeneration: generation };
@@ -248,8 +267,9 @@ export class ClaustrumClient {
248
267
  this.#client.close();
249
268
  }
250
269
  async #call(method, params) {
270
+ const sentClient = this.#client;
251
271
  try {
252
- return await this.#client.call(CLAUSTRUM_MODULE_ID, method, params, {
272
+ return await sentClient.call(CLAUSTRUM_MODULE_ID, method, params, {
253
273
  identity: this.#identity,
254
274
  consumerIdentity: null,
255
275
  });
@@ -257,7 +277,8 @@ export class ClaustrumClient {
257
277
  catch (error) {
258
278
  if (this.#shouldReconnect(error)) {
259
279
  try {
260
- await this.#reconnect();
280
+ if (this.#client === sentClient)
281
+ await this.#reconnect();
261
282
  }
262
283
  catch (reconnectError) {
263
284
  throw this.#asTransportError(reconnectError);
@@ -380,6 +401,12 @@ export function decodeScopedInventory(response, logUnknownClass) {
380
401
  : undefined;
381
402
  if (createdAtMs === undefined)
382
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
+ }
383
410
  if (!isOptionalString(row.refresh_adapter)) {
384
411
  throw asCredentialError(response, 'invalid_response', logUnknownClass);
385
412
  }
@@ -391,6 +418,8 @@ export function decodeScopedInventory(response, logUnknownClass) {
391
418
  categories: strings(row.categories),
392
419
  credentialType: row.type,
393
420
  serves: strings(row.serves),
421
+ providerIds: strings(row.provider_ids),
422
+ authMethod,
394
423
  state: row.state,
395
424
  refreshAdapter: row.refresh_adapter,
396
425
  recordVersion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/claustrum-client",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",