@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 +76 -0
- package/dist/detect.d.ts +18 -0
- package/dist/detect.js +145 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.js +63 -0
- package/dist/handles.d.ts +59 -0
- package/dist/handles.js +286 -0
- package/dist/identity.d.ts +3 -0
- package/dist/identity.js +30 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/manifest-lock.d.ts +32 -0
- package/dist/manifest-lock.js +277 -0
- package/dist/wire.d.ts +61 -0
- package/dist/wire.js +211 -0
- package/package.json +32 -0
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.
|
package/dist/detect.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -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 {};
|
package/dist/handles.js
ADDED
|
@@ -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
|
+
}
|