@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 +1 -0
- package/dist/detect.js +21 -26
- package/dist/manifest-lock.js +64 -5
- package/dist/wire.d.ts +10 -7
- package/dist/wire.js +21 -4
- package/package.json +1 -1
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/manifest-lock.js
CHANGED
|
@@ -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
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
*
|
|
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,
|