@cortexkit/claustrum-client 0.1.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 ADDED
@@ -0,0 +1,76 @@
1
+ # @cortexkit/claustrum-client
2
+
3
+ Policy-free TypeScript transport for the Claustrum credential vault. Consumers own
4
+ credential caching, refresh, account scheduling, and retry policy.
5
+
6
+ ## Wire contract callers depend on
7
+
8
+ • The daemon records a durable anomaly alarm after 64 fetches in 60 seconds or
9
+ 16 distinct handles on one connection. It still serves those requests. Timed-out
10
+ gets count, and the detector resets on reconnect. Use one warm attempt per
11
+ account per tick and apply per-handle backoff after transient failures.
12
+ • `credential.get` is bimodal: resident records return in microseconds, while an
13
+ expiry-skew refresh can take seconds. Request paths should peek cached state,
14
+ not call `get` speculatively.
15
+ • `permanent` with `not_found` means the handle is unknown OR revoked — uniform refusal
16
+ by design (the vault cannot enumerate which). Treat it as gone either way: re-run
17
+ `ck auth migrate-opencode` to mint a fresh handle; the prior record, if any, is kept.
18
+ • `auth_required` means the record is latched and needs reauthentication.
19
+ • Unknown server error classes are bounded to `transient`; callers must retry them
20
+ rather than treating a forward-compatible class as permanent.
21
+
22
+ The client sends `consumerIdentity: null` for every managed request so inherited
23
+ `SUBC_MODULE_ID` and `SUBC_LAUNCH_NONCE` cannot impersonate a supervising host.
24
+
25
+ ## OpenCode handle-file exports
26
+
27
+ `defaultHandleFilePath`, `readHandleFile`, `handleFileRevision`, and `parseHandleFile`
28
+ are exported for tenant plugins consuming the OpenCode handle manifest. The shared
29
+ `HANDLE_FILE_CONTRACT` is pinned to `maxBytes: 262144`, `mode: 0o600`,
30
+ `labelRe: /^[a-z0-9][a-z0-9._-]{0,63}$/`, and `handleRe: /^ckh_[A-Za-z0-9_-]{43}$/`.
31
+
32
+ ## Testing a consumer of this client
33
+
34
+ Three things on a developer box silently satisfy what a consumer's test is trying to
35
+ prove. Each was found by a consumer shipping green and failing elsewhere.
36
+
37
+ • **A connection file at the default path is a host fact, not a fixture.** If the vault
38
+ runs on the machine, `detectClaustrumConnection` finds it whether or not a test set it
39
+ up, so a suite can pass on the daemon's real socket and fail anywhere without one.
40
+ Force it absent (`CLAUSTRUM_SUBC_CONNECTION=/nonexistent/x.json`, and clear
41
+ `XDG_RUNTIME_DIR`) and prove the suite still passes.
42
+ • **Tests default into the operator's live config.** A suite that resolves
43
+ `~/.config/opencode` without an override writes lock files and manifests beside real
44
+ credentials — passing locally, and mutating state no CI runner has.
45
+ • **A bare `bun` run is not an oracle for module resolution.** The Bun CLI auto-installs
46
+ a public dependency it cannot resolve; a compiled binary does not. All readings below
47
+ are bun 1.3.14, same module, no `node_modules` — a toolchain behaviour with no version
48
+ attached reads as permanent, and this one is a moving target by construction.
49
+
50
+ ```
51
+ bun run m.mjs / bun -e
52
+ resolves, with or without a package.json in scope, from any cwd,
53
+ on a cold install cache
54
+
55
+ bun build --compile, static import
56
+ error: Could not resolve: "X". Maybe you need to "bun install"?
57
+ NO BINARY PRODUCED
58
+
59
+ bun build --compile, dynamic import of a disk module
60
+ builds; fails in the consumer's process at import:
61
+ ERR_MODULE_NOT_FOUND Cannot find package 'X'
62
+ ```
63
+
64
+ Both compiled shapes matter and they fail at opposite ends. A bare dependency the
65
+ compiler can see is refused early and loudly, before any artifact exists. One it cannot
66
+ see — a plugin or a path-imported client loaded at runtime — survives the build and
67
+ fails at import inside the host, which is exactly where a credential client cannot
68
+ afford to fail. Since this client's consumers are daemons and plugins, exercise
69
+ resolution under a compiled loader or the real host, never a bare CLI run.
70
+
71
+ On macOS, sign the compiled probe (`codesign --force --sign -`) before trusting its
72
+ result: a freshly linked unsigned binary is SIGKILLed, and `rc=137` with empty stderr
73
+ reads as a resolution failure while being a signature one.
74
+
75
+ The shape is the same in all three: the producing machine supplies the thing under test.
76
+ A passing check on it is evidence only when the ambient supply is removed first.
@@ -0,0 +1,18 @@
1
+ import { type Endpoint as SubcEndpoint } from '@cortexkit/subc-client';
2
+ export type ClaustrumEndpoint = SubcEndpoint;
3
+ export type ClaustrumDetection = {
4
+ status: 'available';
5
+ schema: number;
6
+ wireVersion: number;
7
+ endpoints: ClaustrumEndpoint[];
8
+ } | {
9
+ status: 'absent';
10
+ path: string;
11
+ } | {
12
+ status: 'malformed';
13
+ path: string;
14
+ reason: string;
15
+ };
16
+ export declare function getDefaultClaustrumConnectionPath(): string;
17
+ export declare function resolveClaustrumConnectionPath(explicit?: string): string;
18
+ export declare function detectClaustrumConnection(explicitPath?: string): Promise<ClaustrumDetection>;
package/dist/detect.js ADDED
@@ -0,0 +1,145 @@
1
+ import { readdirSync, statSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { tmpdir, userInfo } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { PROTOCOL_VERSION, readConnectionFile, } from '@cortexkit/subc-client';
6
+ const PRODUCTION_FILE_NAME = 'subc-connection.json';
7
+ const TEMP_PREFIX = 'subc-';
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.
33
+ function homeTierPath() {
34
+ const homeEnv = process.env.HOME;
35
+ return homeEnv ? join(homeEnv, '.local', 'share', 'cortexkit', 'run', PRODUCTION_FILE_NAME) : undefined;
36
+ }
37
+ function highestPriorityAbsentMarker() {
38
+ const runtime = process.env.XDG_RUNTIME_DIR?.trim();
39
+ if (runtime)
40
+ return join(runtime, PRODUCTION_FILE_NAME);
41
+ const home = homeTierPath();
42
+ if (home)
43
+ return home;
44
+ // HOME unset — the Rust tier falls through to the tempdir glob with no fixed path.
45
+ // Surface the os-reported home so `detectClaustrumConnection` returns an `absent`
46
+ // path the operator can fix; without this, the caller gets a misleading `./...` path.
47
+ return join(userInfo().homedir, '.local', 'share', 'cortexkit', 'run', PRODUCTION_FILE_NAME);
48
+ }
49
+ function findExistingConnectionPath() {
50
+ const runtime = process.env.XDG_RUNTIME_DIR?.trim();
51
+ if (runtime) {
52
+ const p = join(runtime, PRODUCTION_FILE_NAME);
53
+ if (safeIsFile(p))
54
+ return p;
55
+ }
56
+ const home = homeTierPath();
57
+ if (home && safeIsFile(home))
58
+ return home;
59
+ const matches = listSubcConnectionFiles(tmpdir());
60
+ // A single matching file IS the daemon; multiple matches mean different OS users
61
+ // happened to share the temp dir. Picking one would route credential-bearing
62
+ // requests at another user's daemon, so REFUSE both picks and the absent path.
63
+ return matches.length === 1 ? matches[0] : undefined;
64
+ }
65
+ export function getDefaultClaustrumConnectionPath() {
66
+ return findExistingConnectionPath() ?? highestPriorityAbsentMarker();
67
+ }
68
+ export function resolveClaustrumConnectionPath(explicit) {
69
+ return (explicit?.trim() ||
70
+ process.env.CLAUSTRUM_SUBC_CONNECTION?.trim() ||
71
+ getDefaultClaustrumConnectionPath());
72
+ }
73
+ function safeIsFile(path) {
74
+ try {
75
+ return statSync(path).isFile();
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ function listSubcConnectionFiles(dir) {
82
+ let entries;
83
+ try {
84
+ entries = readdirSync(dir);
85
+ }
86
+ catch {
87
+ return [];
88
+ }
89
+ const matches = [];
90
+ for (const name of entries) {
91
+ if (typeof name !== 'string')
92
+ continue;
93
+ if (!name.startsWith(TEMP_PREFIX) || !name.endsWith(TEMP_SUFFIX))
94
+ continue;
95
+ const candidate = join(dir, name);
96
+ if (safeIsFile(candidate))
97
+ matches.push(candidate);
98
+ }
99
+ matches.sort();
100
+ return matches;
101
+ }
102
+ // The transport's typed reader validates `wire_version` against PROTOCOL_VERSION but does
103
+ // not currently surface the value. Read it from the raw JSON so detection reports what the
104
+ // daemon actually advertised, with the PROTOCOL_VERSION fallback for legacy files that
105
+ // omitted the additive field.
106
+ async function readAdvertisedWireVersion(path) {
107
+ let raw;
108
+ try {
109
+ raw = await readFile(path, 'utf8');
110
+ }
111
+ catch {
112
+ return undefined;
113
+ }
114
+ try {
115
+ const parsed = JSON.parse(raw);
116
+ return typeof parsed.wire_version === 'number' ? parsed.wire_version : undefined;
117
+ }
118
+ catch {
119
+ return undefined;
120
+ }
121
+ }
122
+ export async function detectClaustrumConnection(explicitPath) {
123
+ const path = resolveClaustrumConnectionPath(explicitPath);
124
+ let value;
125
+ try {
126
+ value = await readConnectionFile(path);
127
+ }
128
+ catch (error) {
129
+ if (error.code === 'ENOENT') {
130
+ return { status: 'absent', path };
131
+ }
132
+ return {
133
+ status: 'malformed',
134
+ path,
135
+ reason: 'connection file could not be read or validated',
136
+ };
137
+ }
138
+ const advertised = await readAdvertisedWireVersion(path);
139
+ return {
140
+ status: 'available',
141
+ schema: value.schema,
142
+ wireVersion: advertised ?? PROTOCOL_VERSION,
143
+ endpoints: value.endpoints,
144
+ };
145
+ }
@@ -0,0 +1,14 @@
1
+ export declare const ERROR_CLASS_WIRE_SET: readonly ['transient', 'permanent', 'auth_required', 'context_overflow'];
2
+ export type ClaustrumCredentialErrorClass = (typeof ERROR_CLASS_WIRE_SET)[number];
3
+ export type ClaustrumCredentialErrorAction = 'gone' | 'reauth' | 'retry' | 'reduce_and_retry';
4
+ export declare class ClaustrumCredentialError extends Error {
5
+ readonly code: string;
6
+ readonly action: ClaustrumCredentialErrorAction;
7
+ readonly ['class']: ClaustrumCredentialErrorClass;
8
+ constructor(code: string, errorClass: ClaustrumCredentialErrorClass, action: ClaustrumCredentialErrorAction);
9
+ }
10
+ type UnknownClassLogger = (errorClass: string) => void;
11
+ export declare function credentialErrorAction(errorClass: ClaustrumCredentialErrorClass): ClaustrumCredentialErrorAction;
12
+ export declare function asCredentialError(response: unknown, fallbackCode?: string, logUnknownClass?: UnknownClassLogger): ClaustrumCredentialError;
13
+ export declare function hasCredentialError(response: unknown): boolean;
14
+ export {};
package/dist/errors.js ADDED
@@ -0,0 +1,63 @@
1
+ export const ERROR_CLASS_WIRE_SET = [
2
+ 'transient',
3
+ 'permanent',
4
+ 'auth_required',
5
+ 'context_overflow',
6
+ ];
7
+ export class ClaustrumCredentialError extends Error {
8
+ code;
9
+ action;
10
+ ['class'];
11
+ constructor(code, errorClass, action) {
12
+ super(`Claustrum credential request failed: ${code}`);
13
+ this.code = code;
14
+ this.action = action;
15
+ this.name = 'ClaustrumCredentialError';
16
+ this['class'] = errorClass;
17
+ }
18
+ }
19
+ function isRecord(value) {
20
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
21
+ }
22
+ export function credentialErrorAction(errorClass) {
23
+ switch (errorClass) {
24
+ case 'permanent':
25
+ return 'gone';
26
+ case 'auth_required':
27
+ return 'reauth';
28
+ case 'context_overflow':
29
+ return 'reduce_and_retry';
30
+ case 'transient':
31
+ return 'retry';
32
+ }
33
+ }
34
+ export function asCredentialError(response, fallbackCode = 'invalid_response', logUnknownClass = (errorClass) => console.warn(errorClass)) {
35
+ const result = isRecord(response) && isRecord(response.result) ? response.result : undefined;
36
+ const error = result && isRecord(result.error) ? result.error : undefined;
37
+ const rawClass = error?.class;
38
+ const rawCode = error?.code;
39
+ // The wire contract in `crates/credentials-module/src/read_surface.rs` carries BOTH
40
+ // `class` and `code` on every error envelope. A frame missing `code` is therefore
41
+ // malformed: even when `class` is itself in the wire set, the half-formed envelope
42
+ // is not a valid permanent reason — preserving `class` would let a malicious or
43
+ // broken peer drive a `gone` action with `{ error: { class: 'permanent' } }`.
44
+ // Anthropic's cut-line rule (unknown or absent class → transient) generalises to
45
+ // any malformed envelope; both fields must be present and class must be known.
46
+ const hasValidClass = typeof rawClass === 'string' && ERROR_CLASS_WIRE_SET.includes(rawClass);
47
+ const hasCode = typeof rawCode === 'string';
48
+ const envelopeValid = hasValidClass && hasCode;
49
+ const errorClass = envelopeValid
50
+ ? rawClass
51
+ : 'transient';
52
+ if (typeof rawClass !== 'string')
53
+ logUnknownClass('unknown');
54
+ else if (!hasValidClass)
55
+ logUnknownClass(rawClass);
56
+ const code = hasCode ? rawCode : fallbackCode;
57
+ return new ClaustrumCredentialError(code, errorClass, credentialErrorAction(errorClass));
58
+ }
59
+ export function hasCredentialError(response) {
60
+ return (isRecord(response) &&
61
+ isRecord(response.result) &&
62
+ isRecord(response.result.error));
63
+ }
@@ -0,0 +1,59 @@
1
+ export declare const HANDLE_FILE_CONTRACT: {
2
+ readonly maxBytes: number;
3
+ readonly mode: 384;
4
+ readonly labelRe: RegExp;
5
+ readonly handleRe: RegExp;
6
+ };
7
+ export declare class HandleFileValidationError extends Error {
8
+ name: string;
9
+ }
10
+ export type HandleAccount = {
11
+ label: string;
12
+ handle: string;
13
+ credential_id: string;
14
+ superseded?: string[];
15
+ };
16
+ export type HandleProvider = {
17
+ provider: string;
18
+ shape: 'api' | 'oauth';
19
+ serve: string;
20
+ accounts: HandleAccount[];
21
+ };
22
+ export type OpenCodeHandleFileV1 = {
23
+ version: 1;
24
+ providers: HandleProvider[];
25
+ };
26
+ export declare function identifierIsValid(value: unknown): value is string;
27
+ export declare function parseHandleFile(value: unknown): OpenCodeHandleFileV1;
28
+ type HandleFileStat = {
29
+ isFile(): boolean;
30
+ isDirectory?(): boolean;
31
+ isSymbolicLink?(): boolean;
32
+ mode: number;
33
+ size?: number;
34
+ uid?: number;
35
+ mtimeMs?: number;
36
+ };
37
+ type HandleFileDescriptor = {
38
+ read?(buffer: Uint8Array, offset: number, length: number, position: number): Promise<{
39
+ bytesRead: number;
40
+ }> | {
41
+ bytesRead: number;
42
+ };
43
+ stat(): Promise<HandleFileStat>;
44
+ readFile(options: {
45
+ encoding: 'utf8';
46
+ }): Promise<string>;
47
+ close(): Promise<void>;
48
+ };
49
+ export type HandleFileIo = {
50
+ stat?: (path: string) => Promise<HandleFileStat>;
51
+ lstat?: (path: string) => Promise<HandleFileStat>;
52
+ readFile?: (path: string, encoding: 'utf8') => Promise<string>;
53
+ open?: (path: string) => Promise<HandleFileDescriptor>;
54
+ currentUid?: () => number | undefined;
55
+ };
56
+ export declare function defaultHandleFilePath(env?: NodeJS.ProcessEnv): string;
57
+ export declare function readHandleFile(path?: string, io?: HandleFileIo): Promise<OpenCodeHandleFileV1>;
58
+ export declare function handleFileRevision(path?: string, io?: HandleFileIo): Promise<string>;
59
+ export {};
@@ -0,0 +1,286 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat as nodeLstat, open as nodeOpen, readFile, realpath, stat as nodeStat } from 'node:fs/promises';
4
+ import { userInfo } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ export const HANDLE_FILE_CONTRACT = {
7
+ maxBytes: 256 * 1024,
8
+ mode: 0o600,
9
+ // Shape only: identifier validity also rejects FORBIDDEN_IDENTIFIERS; use
10
+ // identifierIsValid for validation so callers do not accept parser-rejected labels.
11
+ labelRe: /^[a-z0-9][a-z0-9._-]{0,63}$/,
12
+ handleRe: /^ckh_[A-Za-z0-9_-]{43}$/,
13
+ };
14
+ const FORBIDDEN_IDENTIFIERS = new Set(['__proto__', 'constructor', 'prototype']);
15
+ export class HandleFileValidationError extends Error {
16
+ name = 'HandleFileValidationError';
17
+ }
18
+ function isAccount(value) {
19
+ if (!value || typeof value !== 'object')
20
+ return false;
21
+ const account = value;
22
+ return typeof account.label === 'string' && typeof account.handle === 'string' &&
23
+ typeof account.credential_id === 'string' &&
24
+ (account.superseded === undefined ||
25
+ (Array.isArray(account.superseded) && account.superseded.every((handle) => typeof handle === 'string')));
26
+ }
27
+ function handleIsValid(handle) {
28
+ return typeof handle === 'string' && HANDLE_FILE_CONTRACT.handleRe.test(handle);
29
+ }
30
+ export function identifierIsValid(value) {
31
+ return typeof value === 'string' && HANDLE_FILE_CONTRACT.labelRe.test(value) && !FORBIDDEN_IDENTIFIERS.has(value);
32
+ }
33
+ function invalid(message) {
34
+ throw new HandleFileValidationError(message);
35
+ }
36
+ export function parseHandleFile(value) {
37
+ if (!value || typeof value !== 'object')
38
+ invalid('handle file must be an object');
39
+ const file = value;
40
+ if (file.version !== 1 || !Array.isArray(file.providers)) {
41
+ invalid('handle file must have version 1 and providers');
42
+ }
43
+ const providerIds = new Set();
44
+ const providers = file.providers.map((provider, index) => {
45
+ if (!provider || typeof provider !== 'object')
46
+ invalid(`provider ${index} must be an object`);
47
+ const item = provider;
48
+ if (!identifierIsValid(item.provider))
49
+ invalid(`provider ${index} has invalid provider`);
50
+ if (providerIds.has(item.provider))
51
+ invalid(`provider ${index} duplicates provider ${item.provider}`);
52
+ providerIds.add(item.provider);
53
+ if (item.shape !== 'api' && item.shape !== 'oauth')
54
+ invalid(`provider ${index} has invalid shape`);
55
+ if (typeof item.serve !== 'string' || !item.serve)
56
+ invalid(`provider ${index} requires serve`);
57
+ if (!Array.isArray(item.accounts) || item.accounts.length === 0 || !item.accounts.every(isAccount)) {
58
+ invalid(`provider ${index} has invalid accounts`);
59
+ }
60
+ const labels = new Set();
61
+ for (const account of item.accounts) {
62
+ if (!identifierIsValid(account.label))
63
+ invalid(`provider ${index} has an invalid account label`);
64
+ if (labels.has(account.label))
65
+ invalid(`provider ${index} duplicates account label ${account.label}`);
66
+ labels.add(account.label);
67
+ if (!handleIsValid(account.handle))
68
+ invalid(`provider ${index} account ${account.label} has invalid handle`);
69
+ // Segment 2 of the credential id must BE the provider block it sits in. Without
70
+ // this the check was non-empty-string only, so an `oauth:openai` binding parsed
71
+ // cleanly inside an `anthropic` block -- a cross-provider smuggle that every
72
+ // tenant reading this manifest would have honoured. Two peer tenants found the
73
+ // same hole in their own parsers independently.
74
+ //
75
+ // SCOPED TO SEGMENT 2 ONLY, deliberately. Segment 1 (the kind) is an OPEN SET --
76
+ // `oauth:`, `chatgpt:`, `antigravity:`, `apikey:` are all live in this vault today
77
+ // -- so a kind allowlist would refuse real ids. Segment 3+ (the label) is
78
+ // operator-chosen and may be absent: main is the 2-segment `oauth:anthropic`,
79
+ // fallbacks are 3-segment. Constraining either would reject the deployment this
80
+ // contract describes.
81
+ //
82
+ // NO SEGMENT MAY BE EMPTY. Segment 2 alone is what fences the provider, but a
83
+ // position-1 check ignores the rest of the string, and that left two ids passing
84
+ // that name credentials which cannot exist: `:anthropic:x` (empty kind) and
85
+ // `oauth:anthropic:` (empty label) both satisfy "segment 2 is the provider"
86
+ // literally. Neither is a smuggle; both defer a GUARANTEED resolve-time failure
87
+ // past the door, and under custody a resolve-time failure on a tombstoned account
88
+ // is a dark route rather than a refused row.
89
+ //
90
+ // It also removes an asymmetry nobody designed and everyone would read as a bug:
91
+ // `oauth::anthropic` rejected while `:anthropic:x` passed, purely because the
92
+ // check indexed position 1 and ignored positions 0 and 2. Agreed with the peer
93
+ // tenant and mirrored on their side, so this is chosen rather than defaulted --
94
+ // the previous behaviour was two independent defaults that happened to differ.
95
+ //
96
+ // The emptiness guard is ALSO explicit rather than a consequence: `''.split(':')`
97
+ // yields `['']`, whose `[1]` is `undefined` and cannot equal a provider string,
98
+ // so an empty id would reject anyway -- but that is a coincidence doing
99
+ // load-bearing work, and the check this replaced (`!account.credential_id`) was
100
+ // the emptiness guard. NO TEST DISTINGUISHES THAT ONE (empty rejects with or
101
+ // without it, verified by removal), so it is kept for a future reader who loosens
102
+ // the comparison, not for an arm it could never redden. The non-empty-SEGMENT
103
+ // rule below is different: it reddens, and is pinned.
104
+ const segments = account.credential_id.split(':');
105
+ if (!account.credential_id || segments[1] !== item.provider || segments.some((segment) => segment.length === 0)) {
106
+ invalid(`provider ${index} account ${account.label} has invalid credential id`);
107
+ }
108
+ if (account.superseded?.some((handle) => !handleIsValid(handle))) {
109
+ invalid(`provider ${index} account ${account.label} has invalid superseded handle`);
110
+ }
111
+ }
112
+ return {
113
+ provider: item.provider,
114
+ shape: item.shape,
115
+ serve: item.serve,
116
+ accounts: item.accounts.map((account) => ({
117
+ ...account,
118
+ ...(account.superseded === undefined ? {} : { superseded: account.superseded }),
119
+ })),
120
+ };
121
+ });
122
+ return { version: 1, providers };
123
+ }
124
+ export function defaultHandleFilePath(env = process.env) {
125
+ if (env.CLAUSTRUM_OPENCODE_HANDLES)
126
+ return env.CLAUSTRUM_OPENCODE_HANDLES;
127
+ const configHome = env.XDG_CONFIG_HOME || (env.HOME ? join(env.HOME, '.config') : '.config');
128
+ return join(configHome, 'cortexkit', 'opencode-handles.json');
129
+ }
130
+ function currentUid() {
131
+ return process.getuid?.() ?? userInfo().uid;
132
+ }
133
+ async function readBounded(descriptor, cap) {
134
+ if (!descriptor.read)
135
+ throw new Error('readBounded requires a descriptor exposing read()');
136
+ const buffer = Buffer.alloc(cap + 1);
137
+ let total = 0;
138
+ while (total < cap + 1) {
139
+ const chunk = await descriptor.read(buffer, total, buffer.length - total, total);
140
+ if (chunk.bytesRead === 0)
141
+ break;
142
+ total += chunk.bytesRead;
143
+ }
144
+ return { buffer, bytes: total > cap ? -1 : total };
145
+ }
146
+ async function readHandleSnapshot(path = defaultHandleFilePath(), io = {}) {
147
+ const stat = io.stat ?? nodeStat;
148
+ const lstat = io.lstat ?? nodeLstat;
149
+ const read = io.readFile ?? readFile;
150
+ const openFd = io.open ?? ((candidate) => nodeOpen(candidate, constants.O_RDONLY | constants.O_NOFOLLOW));
151
+ let descriptor;
152
+ try {
153
+ let metadata;
154
+ try {
155
+ if (io.lstat || io.readFile) {
156
+ metadata = await lstat(path);
157
+ }
158
+ else {
159
+ descriptor = await openFd(path);
160
+ metadata = await descriptor.stat();
161
+ }
162
+ }
163
+ catch (error) {
164
+ if (error.code === 'ENOENT')
165
+ return { file: { version: 1, providers: [] } };
166
+ if (error.code === 'ELOOP')
167
+ invalid('handle file must not be a symlink');
168
+ invalid(`cannot stat handle file: ${error instanceof Error ? error.message : String(error)}`);
169
+ }
170
+ if (metadata.isSymbolicLink?.())
171
+ invalid('handle file must not be a symlink');
172
+ if (!metadata.isFile())
173
+ invalid('handle file must be a regular file');
174
+ if ((metadata.size ?? 0) > HANDLE_FILE_CONTRACT.maxBytes)
175
+ invalid('handle file exceeds 256 KiB');
176
+ if ((metadata.mode & 0o777) !== HANDLE_FILE_CONTRACT.mode)
177
+ invalid('handle file mode must be exactly 0600');
178
+ const uid = io.currentUid ?? currentUid;
179
+ const expectedUid = uid();
180
+ if (expectedUid !== undefined && metadata.uid !== undefined && metadata.uid !== expectedUid) {
181
+ invalid('handle file is not owned by the current uid');
182
+ }
183
+ let parent;
184
+ try {
185
+ parent = await stat(dirname(path));
186
+ }
187
+ catch (error) {
188
+ invalid(`cannot stat handle file parent: ${error instanceof Error ? error.message : String(error)}`);
189
+ }
190
+ if (!parent.isDirectory?.())
191
+ invalid('handle file parent must be a directory');
192
+ if (expectedUid !== undefined && parent.uid !== undefined && parent.uid !== expectedUid) {
193
+ invalid('handle file parent is not owned by the current uid');
194
+ }
195
+ // GROUP-WRITABLE COUNTS, NOT ONLY WORLD-WRITABLE. Directory write permission governs
196
+ // unlink and create, so anyone who can write the parent can replace a mode-0600 file
197
+ // wholesale however tightly the file itself is locked. The owner check above does not
198
+ // close it: a directory I own can still be 0770, and then any other uid in that group
199
+ // can swap the handle file for one of theirs. A cross-uid attacker is not conceded by
200
+ // this threat model the way a same-uid one is.
201
+ //
202
+ // Sticky exempts both bits for one reason: with it set, a writer may only unlink files
203
+ // they own. Mirrors the Rust check in opencode_files.rs; the two must not drift.
204
+ if ((parent.mode & 0o022) !== 0 && (parent.mode & 0o1000) === 0) {
205
+ invalid('handle file parent is group- or world-writable without sticky bit');
206
+ }
207
+ // EVERY ANCESTOR, NOT JUST THIS ONE, OR THE GUARANTEE DOES NOT COMPOSE. The immediate
208
+ // parent being 0700 protects nothing when a directory above it is group-writable:
209
+ // anyone who can create and unlink there renames it aside and substitutes their own
210
+ // tree. Walk to '/' rather than $HOME -- a stopping point read from the environment is
211
+ // attacker-influenceable and undefined when unset.
212
+ //
213
+ // realpath FIRST: an unresolved walk is defeated by a symlink component pointing
214
+ // somewhere permissive, and every individual stat still passes while the loop is about
215
+ // a path we never read through.
216
+ //
217
+ // Mirrors refuse_writable_ancestor in subc-transport 0.7.0 and in opencode_files.rs;
218
+ // three implementations of one rule must not drift.
219
+ let resolved;
220
+ try {
221
+ resolved = await realpath(dirname(path));
222
+ }
223
+ catch {
224
+ // Unresolvable: the read that follows reports the real errno, and refusing here
225
+ // would replace a precise failure with a permissions verdict about a path we could
226
+ // not resolve.
227
+ resolved = undefined;
228
+ }
229
+ if (resolved !== undefined) {
230
+ let component = resolved;
231
+ for (;;) {
232
+ let ancestor;
233
+ try {
234
+ ancestor = await stat(component);
235
+ }
236
+ catch {
237
+ ancestor = undefined;
238
+ }
239
+ if (ancestor && (ancestor.mode & 0o022) !== 0 && (ancestor.mode & 0o1000) === 0) {
240
+ invalid(`handle file ancestor ${component} is group- or world-writable without sticky bit`);
241
+ }
242
+ const next = dirname(component);
243
+ if (next === component)
244
+ break;
245
+ component = next;
246
+ }
247
+ }
248
+ let source;
249
+ try {
250
+ if (descriptor) {
251
+ const { buffer, bytes } = await readBounded(descriptor, HANDLE_FILE_CONTRACT.maxBytes);
252
+ if (bytes === -1)
253
+ invalid('handle file exceeds 256 KiB');
254
+ source = buffer.subarray(0, bytes).toString('utf8');
255
+ }
256
+ else {
257
+ source = await read(path, 'utf8');
258
+ }
259
+ }
260
+ catch (error) {
261
+ if (error instanceof HandleFileValidationError)
262
+ throw error;
263
+ invalid(`cannot read handle file: ${error instanceof Error ? error.message : String(error)}`);
264
+ }
265
+ let value;
266
+ try {
267
+ value = JSON.parse(source);
268
+ }
269
+ catch {
270
+ invalid('handle file contains invalid JSON');
271
+ }
272
+ return { file: parseHandleFile(value), source, mtimeMs: metadata.mtimeMs };
273
+ }
274
+ finally {
275
+ await descriptor?.close();
276
+ }
277
+ }
278
+ export async function readHandleFile(path = defaultHandleFilePath(), io = {}) {
279
+ return (await readHandleSnapshot(path, io)).file;
280
+ }
281
+ export async function handleFileRevision(path = defaultHandleFilePath(), io = {}) {
282
+ const snapshot = await readHandleSnapshot(path, io);
283
+ if (snapshot.source === undefined)
284
+ invalid('cannot revise absent handle file');
285
+ return `${snapshot.mtimeMs ?? 0}:${createHash('sha256').update(snapshot.source).digest('hex')}`;
286
+ }
@@ -0,0 +1,3 @@
1
+ import type { BindIdentity } from '@cortexkit/subc-client';
2
+ export declare function storageFingerprint(storagePath: string): string;
3
+ export declare function storeIdentity(projectRoot: string, storagePath: string): BindIdentity;