@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/dist/identity.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
4
|
+
export function storageFingerprint(storagePath) {
|
|
5
|
+
const absolutePath = storagePath;
|
|
6
|
+
let canonicalPath;
|
|
7
|
+
try {
|
|
8
|
+
canonicalPath = realpathSync(absolutePath);
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
if (error.code !== 'ENOENT')
|
|
12
|
+
throw error;
|
|
13
|
+
let ancestor = absolutePath;
|
|
14
|
+
while (!existsSync(ancestor)) {
|
|
15
|
+
const parent = dirname(ancestor);
|
|
16
|
+
if (parent === ancestor)
|
|
17
|
+
break;
|
|
18
|
+
ancestor = parent;
|
|
19
|
+
}
|
|
20
|
+
canonicalPath = resolve(realpathSync(ancestor), relative(ancestor, absolutePath));
|
|
21
|
+
}
|
|
22
|
+
return createHash('sha256').update(canonicalPath).digest('hex');
|
|
23
|
+
}
|
|
24
|
+
export function storeIdentity(projectRoot, storagePath) {
|
|
25
|
+
return {
|
|
26
|
+
project_root: projectRoot,
|
|
27
|
+
harness: 'opencode',
|
|
28
|
+
session: `store-${storageFingerprint(storagePath)}`,
|
|
29
|
+
};
|
|
30
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { detectClaustrumConnection, getDefaultClaustrumConnectionPath, resolveClaustrumConnectionPath, type ClaustrumDetection, type ClaustrumEndpoint, } from './detect.js';
|
|
2
|
+
export { storeIdentity, storageFingerprint } from './identity.js';
|
|
3
|
+
export { MANIFEST_LOCK, manifestLockQuarantinePrefix, withManifestLock, writeHandleFileLocked, type ManifestHandleAccount, type ManifestHandleFile, type ManifestHandleProvider, type ManifestLockError, type ManifestLockErrorCode, } from './manifest-lock.js';
|
|
4
|
+
export { ClaustrumCredentialError, credentialErrorAction, ERROR_CLASS_WIRE_SET, type ClaustrumCredentialErrorAction, type ClaustrumCredentialErrorClass, } from './errors.js';
|
|
5
|
+
export { ClaustrumClient, type ClaustrumClientOptions, type ClaustrumConnector, type ClaustrumReporterSource, type CredentialStatus, type ServedCredential, } from './wire.js';
|
|
6
|
+
export { HANDLE_FILE_CONTRACT, HandleFileValidationError, defaultHandleFilePath, handleFileRevision, identifierIsValid, parseHandleFile, readHandleFile, type HandleAccount, type HandleFileIo, type HandleProvider, type OpenCodeHandleFileV1, } from './handles.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { detectClaustrumConnection, getDefaultClaustrumConnectionPath, resolveClaustrumConnectionPath, } from './detect.js';
|
|
2
|
+
export { storeIdentity, storageFingerprint } from './identity.js';
|
|
3
|
+
export { MANIFEST_LOCK, manifestLockQuarantinePrefix, withManifestLock, writeHandleFileLocked, } from './manifest-lock.js';
|
|
4
|
+
export { ClaustrumCredentialError, credentialErrorAction, ERROR_CLASS_WIRE_SET, } from './errors.js';
|
|
5
|
+
export { ClaustrumClient, } from './wire.js';
|
|
6
|
+
export { HANDLE_FILE_CONTRACT, HandleFileValidationError, defaultHandleFilePath, handleFileRevision, identifierIsValid, parseHandleFile, readHandleFile, } from './handles.js';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type OpenCodeHandleFileV1 } from './handles.js';
|
|
2
|
+
export declare const MANIFEST_LOCK: {
|
|
3
|
+
ttlMs: number;
|
|
4
|
+
renewEveryMs: number;
|
|
5
|
+
ownerKeys: readonly ['tenant', 'pid', 'claimed_at_ms', 'nonce'];
|
|
6
|
+
staleTargetRe: RegExp;
|
|
7
|
+
errorCodes: readonly ['lock_busy', 'owner_invalid', 'renewal_failed'];
|
|
8
|
+
};
|
|
9
|
+
/** Thrown by the lock. Branch on `code`; the message is diagnostic and may be reworded. */
|
|
10
|
+
export type ManifestLockErrorCode = (typeof MANIFEST_LOCK.errorCodes)[number];
|
|
11
|
+
export type ManifestLockError = Error & {
|
|
12
|
+
code: ManifestLockErrorCode;
|
|
13
|
+
};
|
|
14
|
+
export type ManifestHandleAccount = OpenCodeHandleFileV1['providers'][number]['accounts'][number];
|
|
15
|
+
export type ManifestHandleProvider = OpenCodeHandleFileV1['providers'][number];
|
|
16
|
+
export type ManifestHandleFile = OpenCodeHandleFileV1;
|
|
17
|
+
type TestOptions = {
|
|
18
|
+
ttlMs?: number;
|
|
19
|
+
renewEveryMs?: number;
|
|
20
|
+
retryMinMs?: number;
|
|
21
|
+
retryMaxMs?: number;
|
|
22
|
+
afterClaim?: () => Promise<void> | void;
|
|
23
|
+
beforeEvict?: () => Promise<void>;
|
|
24
|
+
afterEvictRenameAttempt?: () => Promise<void>;
|
|
25
|
+
afterEvict?: () => void;
|
|
26
|
+
beforeManifestRename?: (path: string) => Promise<void>;
|
|
27
|
+
};
|
|
28
|
+
export declare function __setManifestLockTestOptions(options?: TestOptions): void;
|
|
29
|
+
export declare function manifestLockQuarantinePrefix(path: string, basename?: (p: string) => string): string | undefined;
|
|
30
|
+
export declare function withManifestLock<T>(path: string, tenant: string, fn: () => Promise<T> | T): Promise<T>;
|
|
31
|
+
export declare function writeHandleFileLocked(path: string, tenant: string, mutate: (file: ManifestHandleFile) => void | ManifestHandleFile | Promise<void | ManifestHandleFile>): Promise<void>;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { constants as fsConstants } from 'node:fs';
|
|
2
|
+
import { chmod, lstat, mkdir, open, readFile, readdir, rename, rm, stat, unlink } from 'node:fs/promises';
|
|
3
|
+
import { randomBytes, randomInt } from 'node:crypto';
|
|
4
|
+
import { basename as pathBasename, dirname, join } from 'node:path';
|
|
5
|
+
import { HANDLE_FILE_CONTRACT, parseHandleFile } from './handles.js';
|
|
6
|
+
export const MANIFEST_LOCK = { ttlMs: 30_000, renewEveryMs: 10_000, ownerKeys: ['tenant', 'pid', 'claimed_at_ms', 'nonce'], staleTargetRe: /^\.lock\.stale-\d+-(?!\.{1,2}$)(?!.*[. ]$)[^/\\\x00-\x1f:*?"<>|]{1,128}$/, errorCodes: ['lock_busy', 'owner_invalid', 'renewal_failed'] };
|
|
7
|
+
// The claim deadline bounds when the last stale-lock rename is issued, not when it lands:
|
|
8
|
+
// a rename issued at deadline-1ms can complete afterwards, by tens to hundreds of ms on a
|
|
9
|
+
// loaded host. The retry deadline and staleness window both read `ttl` today; if they split,
|
|
10
|
+
// this bound must keep their max so reclamation still covers the longer role.
|
|
11
|
+
const MANIFEST_LOCK_QUARANTINE_RECLAIM_MARGIN_MS = 5_000;
|
|
12
|
+
let testOptions;
|
|
13
|
+
export function __setManifestLockTestOptions(options) { testOptions = options; }
|
|
14
|
+
// 16 CSPRNG bytes: a collision needs both the same millisecond and the same nonce. Do not
|
|
15
|
+
// simplify this to a counter, pid+timestamp, or a short token.
|
|
16
|
+
const token = () => randomBytes(16).toString('base64url');
|
|
17
|
+
const code = (error) => error?.code;
|
|
18
|
+
const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
19
|
+
// A tenant classifying a failure must not string-match our prose: a copy-edit would
|
|
20
|
+
// silently reclassify a retryable busy-lock as an unknown error, with nothing failing
|
|
21
|
+
// loudly. The code is the contract; the message is free to change.
|
|
22
|
+
const lockError = (code, message) => Object.assign(new Error(message), { code });
|
|
23
|
+
function parseOwner(source) {
|
|
24
|
+
let value;
|
|
25
|
+
try {
|
|
26
|
+
value = JSON.parse(source);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw lockError('owner_invalid', 'manifest lock owner invalid');
|
|
30
|
+
}
|
|
31
|
+
if (!value || typeof value !== 'object')
|
|
32
|
+
throw lockError('owner_invalid', 'manifest lock owner invalid');
|
|
33
|
+
const owner = value;
|
|
34
|
+
// Widen the nonce alphabet only after every tenant has this path-safe reader; an older
|
|
35
|
+
// allowlist reader can otherwise wedge forever on the first owner using the new alphabet.
|
|
36
|
+
const staleTarget = `.lock.stale-${owner.claimed_at_ms}-${owner.nonce}`;
|
|
37
|
+
if (MANIFEST_LOCK.ownerKeys.some((key) => !Object.hasOwn(owner, key)) || typeof owner.claimed_at_ms !== 'number' || !Number.isInteger(owner.claimed_at_ms) || owner.claimed_at_ms < 0 || typeof owner.nonce !== 'string' || !MANIFEST_LOCK.staleTargetRe.test(staleTarget))
|
|
38
|
+
throw lockError('owner_invalid', 'manifest lock owner invalid');
|
|
39
|
+
return owner;
|
|
40
|
+
}
|
|
41
|
+
const readOwner = async (path) => parseOwner(await readFile(path, 'utf8'));
|
|
42
|
+
async function writeOwner(lock, owner) {
|
|
43
|
+
const target = join(lock, 'owner');
|
|
44
|
+
const temporary = join(lock, `owner.${process.pid}.${token()}.tmp`);
|
|
45
|
+
let file;
|
|
46
|
+
try {
|
|
47
|
+
file = await open(temporary, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600);
|
|
48
|
+
await file.chmod(0o600);
|
|
49
|
+
await file.writeFile(`${JSON.stringify(owner)}\n`);
|
|
50
|
+
await file.sync();
|
|
51
|
+
await file.close();
|
|
52
|
+
file = undefined;
|
|
53
|
+
await rename(temporary, target);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
await file?.close().catch(() => { });
|
|
57
|
+
await unlink(temporary).catch(() => { });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Split out from the sweep so the platform claim is testable from a POSIX runner: this takes
|
|
61
|
+
// the basename FUNCTION, so a test can drive it with path.win32.basename without pretending
|
|
62
|
+
// a Linux host is Windows. A filesystem test cannot do that -- on Linux a '/' split and
|
|
63
|
+
// basename agree, so the defect below survives every filesystem test written against it.
|
|
64
|
+
//
|
|
65
|
+
// pathBasename, not a split on '/': on Windows the manifest path is backslash-separated, so a
|
|
66
|
+
// '/' split yields the whole path, nothing matches the prefix, and the sweep becomes a silent
|
|
67
|
+
// no-op on the one platform the quarantine bound is hardest to observe. Fails closed -- it
|
|
68
|
+
// cannot reap the wrong directory -- but the feature is inert, which is worse than loud.
|
|
69
|
+
export function manifestLockQuarantinePrefix(path, basename = pathBasename) {
|
|
70
|
+
const name = basename(path);
|
|
71
|
+
return name ? name : undefined;
|
|
72
|
+
}
|
|
73
|
+
async function reclaimStaleManifestLockQuarantines(path, ttlMs, claimDeadlineMs) {
|
|
74
|
+
const reclaimAgeMs = Math.max(ttlMs, claimDeadlineMs) + MANIFEST_LOCK_QUARANTINE_RECLAIM_MARGIN_MS;
|
|
75
|
+
let names;
|
|
76
|
+
try {
|
|
77
|
+
names = await readdir(dirname(path));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const basename = manifestLockQuarantinePrefix(path);
|
|
83
|
+
if (!basename)
|
|
84
|
+
return;
|
|
85
|
+
await Promise.all(names.map(async (name) => {
|
|
86
|
+
const staleTarget = name.startsWith(basename) ? name.slice(basename.length) : undefined;
|
|
87
|
+
if (!staleTarget || !MANIFEST_LOCK.staleTargetRe.test(staleTarget))
|
|
88
|
+
return;
|
|
89
|
+
const target = join(dirname(path), name);
|
|
90
|
+
let metadata;
|
|
91
|
+
try {
|
|
92
|
+
metadata = await lstat(target);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!metadata.isDirectory() || Date.now() - metadata.mtimeMs < reclaimAgeMs)
|
|
98
|
+
return;
|
|
99
|
+
await rm(target, { recursive: true, force: true }).catch(() => { });
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
export async function withManifestLock(path, tenant, fn) {
|
|
103
|
+
return withLockCommit(path, tenant, async () => fn());
|
|
104
|
+
}
|
|
105
|
+
async function withLockCommit(path, tenant, fn) {
|
|
106
|
+
const lock = `${path}.lock`, ownerPath = join(lock, 'owner'), ttl = testOptions?.ttlMs ?? MANIFEST_LOCK.ttlMs, renewEvery = testOptions?.renewEveryMs ?? MANIFEST_LOCK.renewEveryMs, retryMin = testOptions?.retryMinMs ?? 25, retryMax = testOptions?.retryMaxMs ?? 75;
|
|
107
|
+
const nonce = token(), started = Date.now(), deadline = started + ttl;
|
|
108
|
+
while (true) {
|
|
109
|
+
try {
|
|
110
|
+
await mkdir(lock, { mode: 0o700 });
|
|
111
|
+
await writeOwner(lock, { tenant, pid: process.pid, claimed_at_ms: Date.now(), nonce });
|
|
112
|
+
await testOptions?.afterClaim?.();
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (code(error) !== 'EEXIST') {
|
|
117
|
+
if (code(error) !== 'ENOENT')
|
|
118
|
+
await rm(lock, { recursive: true, force: true }).catch(() => { });
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
let observed, ownerReadError;
|
|
123
|
+
try {
|
|
124
|
+
observed = await readOwner(ownerPath);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
ownerReadError = error;
|
|
128
|
+
if (code(error) !== 'ENOENT' && Date.now() >= deadline)
|
|
129
|
+
throw code(error) === 'owner_invalid' ? error : lockError('lock_busy', 'manifest lock busy');
|
|
130
|
+
}
|
|
131
|
+
if (observed && Date.now() - observed.claimed_at_ms >= ttl) {
|
|
132
|
+
await testOptions?.beforeEvict?.();
|
|
133
|
+
// The owner record remains because the quarantine is the ABA guard, not an audit log;
|
|
134
|
+
// bounded reclamation below is what makes that retention finite.
|
|
135
|
+
const stale = `${lock}.stale-${observed.claimed_at_ms}-${observed.nonce}`;
|
|
136
|
+
let renameError;
|
|
137
|
+
try {
|
|
138
|
+
await rename(lock, stale);
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
renameError = error;
|
|
142
|
+
}
|
|
143
|
+
await testOptions?.afterEvictRenameAttempt?.();
|
|
144
|
+
if (renameError === undefined) {
|
|
145
|
+
const moved = await readOwner(join(stale, 'owner')).catch(() => undefined);
|
|
146
|
+
if (moved?.nonce === observed.nonce && moved.claimed_at_ms === observed.claimed_at_ms) {
|
|
147
|
+
testOptions?.afterEvict?.();
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
await rename(stale, lock).catch(() => { });
|
|
151
|
+
}
|
|
152
|
+
else if (!['ENOENT', 'EEXIST', 'ENOTEMPTY'].includes(code(renameError) ?? ''))
|
|
153
|
+
throw renameError;
|
|
154
|
+
}
|
|
155
|
+
if (Date.now() >= deadline)
|
|
156
|
+
throw code(ownerReadError) === 'owner_invalid' ? ownerReadError : lockError('lock_busy', 'manifest lock busy');
|
|
157
|
+
await sleep(Math.min(randomInt(retryMin, retryMax + 1), Math.max(1, deadline - Date.now())));
|
|
158
|
+
}
|
|
159
|
+
await reclaimStaleManifestLockQuarantines(path, ttl, deadline - started);
|
|
160
|
+
let renewal = Promise.resolve(), failed = false, stopped = false;
|
|
161
|
+
const timer = setInterval(() => { renewal = renewal.then(async () => { if (failed)
|
|
162
|
+
return; try {
|
|
163
|
+
const current = await readOwner(ownerPath);
|
|
164
|
+
if (current.nonce !== nonce || Date.now() - current.claimed_at_ms >= ttl)
|
|
165
|
+
throw new Error('lease lost');
|
|
166
|
+
await writeOwner(lock, { ...current, claimed_at_ms: Date.now() });
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
failed = true;
|
|
170
|
+
} }); }, renewEvery);
|
|
171
|
+
timer.unref?.();
|
|
172
|
+
const commit = async () => { if (!stopped) {
|
|
173
|
+
stopped = true;
|
|
174
|
+
clearInterval(timer);
|
|
175
|
+
await renewal;
|
|
176
|
+
} ; const current = await readOwner(ownerPath).catch(() => undefined); if (failed || !current || current.nonce !== nonce || Date.now() - current.claimed_at_ms >= ttl)
|
|
177
|
+
throw lockError('renewal_failed', 'manifest lock renewal failed; write aborted'); };
|
|
178
|
+
try {
|
|
179
|
+
const result = await fn(commit);
|
|
180
|
+
if (failed)
|
|
181
|
+
throw lockError('renewal_failed', 'manifest lock renewal failed; write aborted');
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
if (!stopped)
|
|
186
|
+
clearInterval(timer);
|
|
187
|
+
await renewal;
|
|
188
|
+
const current = await readOwner(ownerPath).catch(() => undefined);
|
|
189
|
+
if (!current || current.nonce !== nonce || Date.now() - current.claimed_at_ms >= ttl)
|
|
190
|
+
console.warn('manifest lock lease lost, not releasing', { path, tenant });
|
|
191
|
+
else {
|
|
192
|
+
const release = `${lock}.release-${nonce}`;
|
|
193
|
+
try {
|
|
194
|
+
await rename(lock, release);
|
|
195
|
+
const moved = await readOwner(join(release, 'owner')).catch(() => undefined);
|
|
196
|
+
if (!moved || moved.nonce !== nonce || Date.now() - moved.claimed_at_ms >= ttl) {
|
|
197
|
+
await rename(release, lock).catch(() => { });
|
|
198
|
+
console.warn('manifest lock lease lost, not releasing', { path, tenant });
|
|
199
|
+
}
|
|
200
|
+
else
|
|
201
|
+
await rm(release, { recursive: true, force: true });
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
console.warn('manifest lock lease lost, not releasing', { path, tenant });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async function readManifest(path) {
|
|
210
|
+
let metadata;
|
|
211
|
+
try {
|
|
212
|
+
metadata = await lstat(path);
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
if (code(error) === 'ENOENT')
|
|
216
|
+
return { version: 1, providers: [] };
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
if (metadata.isSymbolicLink() || !metadata.isFile())
|
|
220
|
+
throw new Error('handle file must be a regular file');
|
|
221
|
+
if ((metadata.mode & 0o777) !== HANDLE_FILE_CONTRACT.mode)
|
|
222
|
+
throw new Error('handle file mode must be exactly 0600');
|
|
223
|
+
const source = await readFile(path);
|
|
224
|
+
if (source.byteLength > HANDLE_FILE_CONTRACT.maxBytes)
|
|
225
|
+
throw new Error('handle file exceeds 256 KiB');
|
|
226
|
+
return parseHandleFile(JSON.parse(source.toString('utf8')));
|
|
227
|
+
}
|
|
228
|
+
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'); }
|
|
233
|
+
async function writeAtomic(path, file, commit) {
|
|
234
|
+
const bytes = Buffer.from(JSON.stringify(file));
|
|
235
|
+
if (bytes.byteLength > HANDLE_FILE_CONTRACT.maxBytes)
|
|
236
|
+
throw new Error('handle file exceeds 256 KiB');
|
|
237
|
+
// pathBasename for the same reason the quarantine sweep uses it, but this one fails LOUD
|
|
238
|
+
// rather than inert: a '/' split over a backslash path yields the whole path, so the temp
|
|
239
|
+
// name embeds separators and a drive-letter colon, join() nests it under its own directory,
|
|
240
|
+
// and O_CREAT|O_EXCL throws on a name Windows will not accept -- taking the manifest WRITE
|
|
241
|
+
// down, not just the reclaim. Covered by the dual-basename test, which a POSIX filesystem
|
|
242
|
+
// test cannot reach.
|
|
243
|
+
const temporary = join(dirname(path), `.${pathBasename(path)}.${process.pid}.${token()}.tmp`);
|
|
244
|
+
let handle;
|
|
245
|
+
try {
|
|
246
|
+
handle = await open(temporary, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600);
|
|
247
|
+
await handle.chmod(0o600);
|
|
248
|
+
await handle.writeFile(bytes);
|
|
249
|
+
await handle.sync();
|
|
250
|
+
await handle.close();
|
|
251
|
+
handle = undefined;
|
|
252
|
+
await chmod(temporary, 0o600);
|
|
253
|
+
await testOptions?.beforeManifestRename?.(`${path}.lock`);
|
|
254
|
+
await commit();
|
|
255
|
+
await rename(temporary, path);
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
await handle?.close().catch(() => { });
|
|
259
|
+
await unlink(temporary).catch(() => { });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
export async function writeHandleFileLocked(path, tenant, mutate) {
|
|
263
|
+
await prepareParent(path);
|
|
264
|
+
await withLockCommit(path, tenant, async (commit) => {
|
|
265
|
+
const before = await readManifest(path), working = structuredClone(before), result = await mutate(working), next = parseHandleFile(result ?? working), beforeForeign = foreign(before, tenant);
|
|
266
|
+
if (JSON.stringify(foreign(next, tenant)) !== JSON.stringify(beforeForeign))
|
|
267
|
+
throw new Error('manifest mutation changed another tenant block');
|
|
268
|
+
await writeAtomic(path, next, commit);
|
|
269
|
+
if (((await lstat(path)).mode & 0o777) !== HANDLE_FILE_CONTRACT.mode)
|
|
270
|
+
throw new Error('manifest readback mode is not 0600');
|
|
271
|
+
const readback = await readManifest(path);
|
|
272
|
+
if (JSON.stringify(readback) !== JSON.stringify(next))
|
|
273
|
+
throw new Error('manifest readback differs');
|
|
274
|
+
if (JSON.stringify(foreign(readback, tenant)) !== JSON.stringify(beforeForeign))
|
|
275
|
+
throw new Error('manifest readback changed another tenant block');
|
|
276
|
+
});
|
|
277
|
+
}
|
package/dist/wire.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { SubcClient, type BindIdentity } from '@cortexkit/subc-client';
|
|
2
|
+
export type ClaustrumConnector = (options: {
|
|
3
|
+
connectionFile: string;
|
|
4
|
+
handshakeTimeoutMs?: number;
|
|
5
|
+
}) => Promise<SubcClient>;
|
|
6
|
+
export type ClaustrumClientOptions = {
|
|
7
|
+
connectionFile?: string;
|
|
8
|
+
handshakeTimeoutMs?: number;
|
|
9
|
+
projectRoot?: string;
|
|
10
|
+
storagePath?: string;
|
|
11
|
+
identity?: BindIdentity;
|
|
12
|
+
connector?: ClaustrumConnector;
|
|
13
|
+
logger?: (errorClass: string) => void;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Credential material plus optional non-secret identity metadata. Missing wire fields remain
|
|
17
|
+
* `undefined`; a present non-string identity value rejects the response as invalid.
|
|
18
|
+
*/
|
|
19
|
+
export type ServedCredential = {
|
|
20
|
+
material: string;
|
|
21
|
+
recordVersion: number;
|
|
22
|
+
expiresAtMs: number | null;
|
|
23
|
+
/**
|
|
24
|
+
* Operator-chosen record label for verifying a caller-held binding. It is not a routing key:
|
|
25
|
+
* account-scoped routing joins `accountId` with `recordVersion`.
|
|
26
|
+
*/
|
|
27
|
+
credentialId?: string;
|
|
28
|
+
/** Non-secret Code Assist project identity, present only for antigravity credentials. */
|
|
29
|
+
projectId?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Provider account identity the served token executes under. Account-scoped routing joins this
|
|
32
|
+
* value with `recordVersion`; it is neither the operator's credential label nor the bearer handle.
|
|
33
|
+
*/
|
|
34
|
+
accountId?: string;
|
|
35
|
+
/** Non-secret account display metadata captured at login. */
|
|
36
|
+
email?: string;
|
|
37
|
+
/** Non-secret organization or workspace display metadata captured at login. */
|
|
38
|
+
orgName?: string;
|
|
39
|
+
};
|
|
40
|
+
export type CredentialStatus = {
|
|
41
|
+
ready: boolean;
|
|
42
|
+
lastErrorCode: string | null;
|
|
43
|
+
leaseHeld: boolean;
|
|
44
|
+
recordVersion: number;
|
|
45
|
+
stalePending?: boolean;
|
|
46
|
+
};
|
|
47
|
+
export type ClaustrumReporterSource = 'direct' | 'relay_status_field' | 'relay_message_parse';
|
|
48
|
+
export declare class ClaustrumClient {
|
|
49
|
+
#private;
|
|
50
|
+
private constructor();
|
|
51
|
+
static connect(options?: ClaustrumClientOptions): Promise<ClaustrumClient>;
|
|
52
|
+
getCredential(handle: string, minTtlMs?: number): Promise<ServedCredential>;
|
|
53
|
+
statusCredential(handle: string): Promise<CredentialStatus>;
|
|
54
|
+
reportAuthFailure(input: {
|
|
55
|
+
handle: string;
|
|
56
|
+
providerStatus: number;
|
|
57
|
+
recordVersion: number;
|
|
58
|
+
reporterSource: ClaustrumReporterSource;
|
|
59
|
+
}): Promise<void>;
|
|
60
|
+
close(): void;
|
|
61
|
+
}
|
package/dist/wire.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { SubcCallError, SubcClient, } from '@cortexkit/subc-client';
|
|
2
|
+
import { resolveClaustrumConnectionPath } from './detect.js';
|
|
3
|
+
import { asCredentialError, ClaustrumCredentialError, hasCredentialError, } from './errors.js';
|
|
4
|
+
import { storeIdentity } from './identity.js';
|
|
5
|
+
const CLAUSTRUM_MODULE_ID = 'claustrum';
|
|
6
|
+
const RECONNECT_BACKOFF_MS = 60_000;
|
|
7
|
+
function isRecord(value) {
|
|
8
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
function asRecordVersion(value) {
|
|
11
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
|
|
12
|
+
? value
|
|
13
|
+
: undefined;
|
|
14
|
+
}
|
|
15
|
+
function isOptionalString(value) {
|
|
16
|
+
return value === undefined || typeof value === 'string';
|
|
17
|
+
}
|
|
18
|
+
function decodeCredential(response, logUnknownClass) {
|
|
19
|
+
if (hasCredentialError(response))
|
|
20
|
+
throw asCredentialError(response, 'invalid_response', logUnknownClass);
|
|
21
|
+
const result = isRecord(response) && isRecord(response.result) ? response.result : undefined;
|
|
22
|
+
const payload = result?.payload;
|
|
23
|
+
if (!Array.isArray(payload) ||
|
|
24
|
+
payload.length === 0 ||
|
|
25
|
+
!payload.every((value) => typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 255)) {
|
|
26
|
+
throw asCredentialError(response, 'invalid_response', logUnknownClass);
|
|
27
|
+
}
|
|
28
|
+
const recordVersion = asRecordVersion(result?.record_version);
|
|
29
|
+
if (recordVersion === undefined) {
|
|
30
|
+
throw asCredentialError(response, 'invalid_record_version', logUnknownClass);
|
|
31
|
+
}
|
|
32
|
+
const rawExpiresAtMs = result?.expires_at_ms;
|
|
33
|
+
const expiresAtMs = rawExpiresAtMs === undefined || rawExpiresAtMs === null
|
|
34
|
+
? null
|
|
35
|
+
: typeof rawExpiresAtMs === 'number' && Number.isFinite(rawExpiresAtMs)
|
|
36
|
+
? rawExpiresAtMs
|
|
37
|
+
: undefined;
|
|
38
|
+
if (expiresAtMs === undefined) {
|
|
39
|
+
throw asCredentialError(response, 'invalid_expiry', logUnknownClass);
|
|
40
|
+
}
|
|
41
|
+
const credentialId = result?.credential_id;
|
|
42
|
+
const projectId = result?.project_id;
|
|
43
|
+
const accountId = result?.account_id;
|
|
44
|
+
const email = result?.email;
|
|
45
|
+
const orgName = result?.org_name;
|
|
46
|
+
if (!isOptionalString(credentialId) ||
|
|
47
|
+
!isOptionalString(projectId) ||
|
|
48
|
+
!isOptionalString(accountId) ||
|
|
49
|
+
!isOptionalString(email) ||
|
|
50
|
+
!isOptionalString(orgName)) {
|
|
51
|
+
throw asCredentialError(response, 'invalid_response', logUnknownClass);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
material: new TextDecoder().decode(Uint8Array.from(payload)),
|
|
55
|
+
recordVersion,
|
|
56
|
+
expiresAtMs,
|
|
57
|
+
credentialId,
|
|
58
|
+
projectId,
|
|
59
|
+
accountId,
|
|
60
|
+
email,
|
|
61
|
+
orgName,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function decodeStatus(response, logUnknownClass) {
|
|
65
|
+
if (hasCredentialError(response))
|
|
66
|
+
throw asCredentialError(response, 'invalid_response', logUnknownClass);
|
|
67
|
+
const result = isRecord(response) && isRecord(response.result) ? response.result : undefined;
|
|
68
|
+
const recordVersion = asRecordVersion(result?.record_version);
|
|
69
|
+
if (typeof result?.ready !== 'boolean' ||
|
|
70
|
+
(result?.last_error_code !== undefined && result?.last_error_code !== null && typeof result?.last_error_code !== 'string') ||
|
|
71
|
+
typeof result?.lease_held !== 'boolean' ||
|
|
72
|
+
recordVersion === undefined ||
|
|
73
|
+
(result?.stale_pending !== undefined && typeof result.stale_pending !== 'boolean')) {
|
|
74
|
+
throw asCredentialError(response, 'invalid_status', logUnknownClass);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
ready: result.ready,
|
|
78
|
+
lastErrorCode: result.last_error_code ?? null,
|
|
79
|
+
leaseHeld: result.lease_held,
|
|
80
|
+
recordVersion,
|
|
81
|
+
...(result.stale_pending === undefined ? {} : { stalePending: result.stale_pending }),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export class ClaustrumClient {
|
|
85
|
+
#client;
|
|
86
|
+
#connector;
|
|
87
|
+
#connectionFile;
|
|
88
|
+
#handshakeTimeoutMs;
|
|
89
|
+
#identity;
|
|
90
|
+
#logger;
|
|
91
|
+
#reconnecting = null;
|
|
92
|
+
#nextReconnectAt = 0;
|
|
93
|
+
#closed = false;
|
|
94
|
+
constructor(client, connector, connectionFile, handshakeTimeoutMs, identity, logger) {
|
|
95
|
+
this.#client = client;
|
|
96
|
+
this.#connector = connector;
|
|
97
|
+
this.#connectionFile = connectionFile;
|
|
98
|
+
this.#handshakeTimeoutMs = handshakeTimeoutMs;
|
|
99
|
+
this.#identity = identity;
|
|
100
|
+
this.#logger = logger;
|
|
101
|
+
}
|
|
102
|
+
static async connect(options = {}) {
|
|
103
|
+
const connectionFile = resolveClaustrumConnectionPath(options.connectionFile);
|
|
104
|
+
const connector = options.connector ?? ((connectOptions) => SubcClient.connect(connectOptions));
|
|
105
|
+
const identity = options.identity ?? storeIdentity(options.projectRoot ?? process.cwd(), options.storagePath ?? process.cwd());
|
|
106
|
+
const client = await connector({
|
|
107
|
+
connectionFile,
|
|
108
|
+
handshakeTimeoutMs: options.handshakeTimeoutMs,
|
|
109
|
+
});
|
|
110
|
+
return new ClaustrumClient(client, connector, connectionFile, options.handshakeTimeoutMs, identity, options.logger ?? ((errorClass) => console.warn(errorClass)));
|
|
111
|
+
}
|
|
112
|
+
async getCredential(handle, minTtlMs) {
|
|
113
|
+
const response = await this.#call('credential.get', {
|
|
114
|
+
handle,
|
|
115
|
+
min_ttl_ms: minTtlMs,
|
|
116
|
+
force_refresh: false,
|
|
117
|
+
});
|
|
118
|
+
return decodeCredential(response, this.#logger);
|
|
119
|
+
}
|
|
120
|
+
async statusCredential(handle) {
|
|
121
|
+
const response = await this.#call('credential.status', { handle });
|
|
122
|
+
return decodeStatus(response, this.#logger);
|
|
123
|
+
}
|
|
124
|
+
async reportAuthFailure(input) {
|
|
125
|
+
const response = await this.#call('credential.report_auth_failure', {
|
|
126
|
+
handle: input.handle,
|
|
127
|
+
provider_status: input.providerStatus,
|
|
128
|
+
record_version: input.recordVersion,
|
|
129
|
+
reporter_source: input.reporterSource,
|
|
130
|
+
});
|
|
131
|
+
if (hasCredentialError(response))
|
|
132
|
+
throw asCredentialError(response, 'invalid_response', this.#logger);
|
|
133
|
+
const result = isRecord(response) && isRecord(response.result) ? response.result : undefined;
|
|
134
|
+
if (result?.accepted !== true)
|
|
135
|
+
throw asCredentialError(response, 'invalid_response', this.#logger);
|
|
136
|
+
}
|
|
137
|
+
close() {
|
|
138
|
+
this.#closed = true;
|
|
139
|
+
this.#client.close();
|
|
140
|
+
}
|
|
141
|
+
async #call(method, params) {
|
|
142
|
+
try {
|
|
143
|
+
return await this.#client.call(CLAUSTRUM_MODULE_ID, method, params, {
|
|
144
|
+
identity: this.#identity,
|
|
145
|
+
consumerIdentity: null,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (this.#shouldReconnect(error)) {
|
|
150
|
+
try {
|
|
151
|
+
await this.#reconnect();
|
|
152
|
+
}
|
|
153
|
+
catch (reconnectError) {
|
|
154
|
+
throw this.#asTransportError(reconnectError);
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
return await this.#client.call(CLAUSTRUM_MODULE_ID, method, params, {
|
|
158
|
+
identity: this.#identity,
|
|
159
|
+
consumerIdentity: null,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
catch (retryError) {
|
|
163
|
+
throw this.#asTransportError(retryError);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
throw this.#asTransportError(error);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
#asTransportError(error) {
|
|
170
|
+
if (error instanceof ClaustrumCredentialError)
|
|
171
|
+
return error;
|
|
172
|
+
const code = error instanceof SubcCallError && error.code ? error.code : 'transport_error';
|
|
173
|
+
return new ClaustrumCredentialError(code, 'transient', 'retry');
|
|
174
|
+
}
|
|
175
|
+
#shouldReconnect(error) {
|
|
176
|
+
return (!this.#closed &&
|
|
177
|
+
error instanceof SubcCallError &&
|
|
178
|
+
error.kind === 'terminal' &&
|
|
179
|
+
error.code !== 'missing_identity' &&
|
|
180
|
+
error.code !== 'invalid_control_body');
|
|
181
|
+
}
|
|
182
|
+
async #reconnect() {
|
|
183
|
+
if (this.#closed)
|
|
184
|
+
throw new Error('Claustrum client is closed');
|
|
185
|
+
if (this.#reconnecting) {
|
|
186
|
+
await this.#reconnecting;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const now = Date.now();
|
|
190
|
+
if (now < this.#nextReconnectAt)
|
|
191
|
+
throw new Error('Claustrum client reconnect is backed off');
|
|
192
|
+
this.#nextReconnectAt = now + RECONNECT_BACKOFF_MS;
|
|
193
|
+
this.#reconnecting = this.#connector({
|
|
194
|
+
connectionFile: this.#connectionFile,
|
|
195
|
+
handshakeTimeoutMs: this.#handshakeTimeoutMs,
|
|
196
|
+
})
|
|
197
|
+
.then((client) => {
|
|
198
|
+
if (this.#closed) {
|
|
199
|
+
client.close();
|
|
200
|
+
throw new Error('Claustrum client is closed');
|
|
201
|
+
}
|
|
202
|
+
const previous = this.#client;
|
|
203
|
+
this.#client = client;
|
|
204
|
+
previous.close();
|
|
205
|
+
})
|
|
206
|
+
.finally(() => {
|
|
207
|
+
this.#reconnecting = null;
|
|
208
|
+
});
|
|
209
|
+
await this.#reconnecting;
|
|
210
|
+
}
|
|
211
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cortexkit/claustrum-client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/cortexkit/claustrum.git",
|
|
8
|
+
"directory": "packages/client"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"description": "Policy-free TypeScript transport client for the Claustrum credential vault.",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"import": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": ["dist", "README.md"],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
26
|
+
"prepack": "rm -rf dist && bun run build",
|
|
27
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@cortexkit/subc-client": "^0.8.1"
|
|
31
|
+
}
|
|
32
|
+
}
|