@cortexkit/claustrum-client 0.14.0 → 0.15.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 +28 -19
- package/dist/index.d.ts +1 -3
- package/dist/index.js +0 -2
- package/dist/wire.d.ts +21 -4
- package/dist/wire.js +33 -9
- package/package.json +1 -1
- package/dist/handles.d.ts +0 -64
- package/dist/handles.js +0 -255
- package/dist/manifest-lock.d.ts +0 -37
- package/dist/manifest-lock.js +0 -333
package/README.md
CHANGED
|
@@ -23,6 +23,9 @@ credential caching, refresh, account scheduling, and retry policy.
|
|
|
23
23
|
as a version older than every real one.
|
|
24
24
|
• Unknown server error classes are bounded to `transient`; callers must retry them
|
|
25
25
|
rather than treating a forward-compatible class as permanent.
|
|
26
|
+
• Account pools use the refresh adapter from `list_scoped` plus opaque `accountId`.
|
|
27
|
+
Use `recordVersion` to invalidate cached material, never as part of a pool key:
|
|
28
|
+
a token refresh must not split one account into a new pool.
|
|
26
29
|
• **Changed in 0.13.0:** a transport `Error` frame whose answer no retry or reconnect
|
|
27
30
|
can change is reported as `permanent` with action `gone`, and the client no longer
|
|
28
31
|
reconnects for it: `unknown_method`, `invalid_params`, `invalid_request`,
|
|
@@ -61,7 +64,11 @@ credential caching, refresh, account scheduling, and retry policy.
|
|
|
61
64
|
`invalid_params`.
|
|
62
65
|
|
|
63
66
|
The client sends `consumerIdentity: null` for every managed request so inherited
|
|
64
|
-
|
|
67
|
+
launch identity cannot impersonate a supervising host. Nonce possession, not process
|
|
68
|
+
ancestry, is the authority boundary: environment variables and an inheritable nonce
|
|
69
|
+
descriptor can reach helpers unless scrubbed or closed. Helpers using other clients
|
|
70
|
+
must explicitly omit route identity (for subc-client, `consumerIdentity: null`) when
|
|
71
|
+
they should be anonymous; process ancestry alone does not enforce isolation.
|
|
65
72
|
|
|
66
73
|
**Changed in 0.11.0:** the client depends on `@cortexkit/subc-client` ^0.22.0 (was
|
|
67
74
|
^0.16.0). A host that also uses subc-client must move to the 0.22 line, so both share one
|
|
@@ -69,6 +76,21 @@ copy. One behaviour reaches callers: while the vault is restarting or refusing r
|
|
|
69
76
|
opens as retryable, a call now keeps retrying for up to 90 s (paced, with jitter) instead
|
|
70
77
|
of 30 s. Give every vault call on a latency-sensitive path its own timeout.
|
|
71
78
|
|
|
79
|
+
## Reading credential material
|
|
80
|
+
|
|
81
|
+
The wire payload is opaque bytes. `getCredential(handle, minTtlMs?)` and
|
|
82
|
+
`getScoped({ credentialId, enrollmentToken?, minTtlMs? })` keep their text results
|
|
83
|
+
(`ServedCredential.material: string`) and require valid UTF-8. They refuse a
|
|
84
|
+
non-UTF-8 payload with `invalid_response`; this does not mean the stored credential
|
|
85
|
+
is corrupt.
|
|
86
|
+
|
|
87
|
+
Use `getCredentialBytes(handle, minTtlMs?)` or
|
|
88
|
+
`getScopedBytes({ credentialId, enrollmentToken?, minTtlMs? })` for binary cookies
|
|
89
|
+
or any material that may not be UTF-8. Both return `ServedCredentialBytes`, with
|
|
90
|
+
`material: Uint8Array` preserving every payload byte and the same metadata, grant
|
|
91
|
+
checks, and TTL semantics as their text counterparts. Neither byte method performs
|
|
92
|
+
text decoding.
|
|
93
|
+
|
|
72
94
|
## Watching for changes
|
|
73
95
|
|
|
74
96
|
Since 0.12.0, `client.watchScoped(onNotice, { enrollmentToken? })` holds an
|
|
@@ -139,20 +161,10 @@ for (;;) {
|
|
|
139
161
|
}
|
|
140
162
|
```
|
|
141
163
|
|
|
142
|
-
##
|
|
143
|
-
|
|
144
|
-
`defaultHandleFilePath`, `readHandleFile`, `handleFileRevision`, and `parseHandleFile`
|
|
145
|
-
are exported for tenant plugins consuming the OpenCode handle manifest. The shared
|
|
146
|
-
`HANDLE_FILE_CONTRACT` is pinned to `maxBytes: 262144`, `mode: 0o600`,
|
|
147
|
-
`labelRe: /^[a-z0-9][a-z0-9._-]{0,63}$/`, and `handleRe: /^ckh_[A-Za-z0-9_-]{43}$/`.
|
|
148
|
-
|
|
149
|
-
### Private files and Windows support
|
|
164
|
+
## Private files and Windows support
|
|
150
165
|
|
|
151
|
-
Enrollment tokens support Windows as well as Unix.
|
|
152
|
-
|
|
153
|
-
another process may hold open, which Windows refuses, so `withManifestLock` and
|
|
154
|
-
`writeHandleFileLocked` reject on Windows with code `unsupported_platform` rather than
|
|
155
|
-
risk two writers overlapping. The client uses `@cortexkit/store` 0.2.1 or newer: exclusive private creation
|
|
166
|
+
Enrollment tokens support Windows as well as Unix. The client uses
|
|
167
|
+
`@cortexkit/store` 0.2.1 or newer: exclusive private creation
|
|
156
168
|
protects files before any secret bytes are written, and atomic replacement keeps
|
|
157
169
|
that protection. Unix files use mode 0600 and new directories use 0700. On Windows,
|
|
158
170
|
the store's `@cortexkit/private-files` native module creates owner-only ACLs and the
|
|
@@ -174,7 +186,7 @@ preserved: an owned, non-sticky group-writable directory (including a 0775
|
|
|
174
186
|
|
|
175
187
|
## Testing a consumer of this client
|
|
176
188
|
|
|
177
|
-
|
|
189
|
+
Two things on a developer box silently satisfy what a consumer's test is trying to
|
|
178
190
|
prove. Each was found by a consumer shipping green and failing elsewhere.
|
|
179
191
|
|
|
180
192
|
• **A connection file at the default path is a host fact, not a fixture.** If the vault
|
|
@@ -182,9 +194,6 @@ prove. Each was found by a consumer shipping green and failing elsewhere.
|
|
|
182
194
|
up, so a suite can pass on the daemon's real socket and fail anywhere without one.
|
|
183
195
|
Force it absent (`CLAUSTRUM_SUBC_CONNECTION=/nonexistent/x.json`, and clear
|
|
184
196
|
`XDG_RUNTIME_DIR`) and prove the suite still passes.
|
|
185
|
-
• **Tests default into the operator's live config.** A suite that resolves
|
|
186
|
-
`~/.config/opencode` without an override writes lock files and manifests beside real
|
|
187
|
-
credentials — passing locally, and mutating state no CI runner has.
|
|
188
197
|
• **A bare `bun` run is not an oracle for module resolution.** The Bun CLI auto-installs
|
|
189
198
|
a public dependency it cannot resolve; a compiled binary does not. All readings below
|
|
190
199
|
are bun 1.3.14, same module, no `node_modules` — a toolchain behaviour with no version
|
|
@@ -215,5 +224,5 @@ prove. Each was found by a consumer shipping green and failing elsewhere.
|
|
|
215
224
|
result: a freshly linked unsigned binary is SIGKILLed, and `rc=137` with empty stderr
|
|
216
225
|
reads as a resolution failure while being a signature one.
|
|
217
226
|
|
|
218
|
-
The shape is the same in
|
|
227
|
+
The shape is the same in both: the producing machine supplies the thing under test.
|
|
219
228
|
A passing check on it is evidence only when the ambient supply is removed first.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
export { detectClaustrumConnection, getDefaultClaustrumConnectionPath, resolveClaustrumConnectionPath, type ClaustrumDetection, type ClaustrumEndpoint, } from './detect.js';
|
|
2
2
|
export { storeIdentity, storageFingerprint } from './identity.js';
|
|
3
3
|
export { writeEnrollmentTokenFile, type EnrollmentTokenFile, } from './enrollment.js';
|
|
4
|
-
export { MANIFEST_LOCK, manifestLockQuarantinePrefix, withManifestLock, writeHandleFileLocked, type ManifestHandleAccount, type ManifestHandleFile, type ManifestHandleProvider, type ManifestLockError, type ManifestLockErrorCode, } from './manifest-lock.js';
|
|
5
4
|
export { ClaustrumCredentialError, credentialErrorAction, ERROR_CLASS_WIRE_SET, type ClaustrumCredentialErrorAction, type ClaustrumCredentialErrorClass, } from './errors.js';
|
|
6
|
-
export { ClaustrumClient, type ClaustrumClientOptions, type ClaustrumConnector, type ClaustrumReporterSource, type CredentialStatus, type EnrollmentPollOutcome, type ScopedGrantTuple, type ScopedInventory, type ScopedInventoryRow, type ServedCredential, } from './wire.js';
|
|
5
|
+
export { ClaustrumClient, type ClaustrumClientOptions, type ClaustrumConnector, type ClaustrumReporterSource, type CredentialStatus, type EnrollmentPollOutcome, type ScopedGrantTuple, type ScopedInventory, type ScopedInventoryRow, type ServedCredential, type ServedCredentialBytes, } from './wire.js';
|
|
7
6
|
export { ScopedWatchError, type ScopedWatch, type ScopedWatchNotice, type ScopedWatchOptions, type ScopedWatchOutcome, } from './watch.js';
|
|
8
|
-
export { HANDLE_FILE_CONTRACT, HandleFileValidationError, credentialIdMatchesProvider, defaultHandleFilePath, handleFileRevision, identifierIsValid, parseHandleFile, readHandleFile, type HandleAccount, type HandleFileIo, type HandleProvider, type OpenCodeHandleFileV1, } from './handles.js';
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
export { detectClaustrumConnection, getDefaultClaustrumConnectionPath, resolveClaustrumConnectionPath, } from './detect.js';
|
|
2
2
|
export { storeIdentity, storageFingerprint } from './identity.js';
|
|
3
3
|
export { writeEnrollmentTokenFile, } from './enrollment.js';
|
|
4
|
-
export { MANIFEST_LOCK, manifestLockQuarantinePrefix, withManifestLock, writeHandleFileLocked, } from './manifest-lock.js';
|
|
5
4
|
export { ClaustrumCredentialError, credentialErrorAction, ERROR_CLASS_WIRE_SET, } from './errors.js';
|
|
6
5
|
export { ClaustrumClient, } from './wire.js';
|
|
7
6
|
export { ScopedWatchError, } from './watch.js';
|
|
8
|
-
export { HANDLE_FILE_CONTRACT, HandleFileValidationError, credentialIdMatchesProvider, defaultHandleFilePath, handleFileRevision, identifierIsValid, parseHandleFile, readHandleFile, } from './handles.js';
|
package/dist/wire.d.ts
CHANGED
|
@@ -26,19 +26,22 @@ export type ClaustrumClientOptions = {
|
|
|
26
26
|
* an outage caused that way is visible only to the consumer that caused it.
|
|
27
27
|
*/
|
|
28
28
|
export type ServedCredential = {
|
|
29
|
+
/** UTF-8 text; use `ServedCredentialBytes` for opaque, potentially non-UTF-8 material. */
|
|
29
30
|
material: string;
|
|
30
31
|
recordVersion: number;
|
|
31
32
|
expiresAtMs: number | null;
|
|
32
33
|
/**
|
|
33
34
|
* Operator-chosen record label for verifying a caller-held binding. It is not a routing key:
|
|
34
|
-
*
|
|
35
|
+
* Account-scoped routing joins `accountId` with `refreshAdapter` from the inventory,
|
|
36
|
+
* never `recordVersion`, which only invalidates cached material.
|
|
35
37
|
*/
|
|
36
38
|
credentialId?: string;
|
|
37
39
|
/** Non-secret Code Assist project identity, present only for antigravity credentials. */
|
|
38
40
|
projectId?: string;
|
|
39
41
|
/**
|
|
40
|
-
* Provider account identity the served token executes under. Account
|
|
41
|
-
*
|
|
42
|
+
* Provider account identity the served token executes under. Account pools join this value with
|
|
43
|
+
* the inventory's `refreshAdapter`, not the material's `recordVersion`, so refreshes do not split
|
|
44
|
+
* one account into new pools. It is neither the operator's credential label nor the bearer handle.
|
|
42
45
|
*
|
|
43
46
|
* BEST-EFFORT, NEVER GUARANTEED, including for JWT providers. The vault parses the claim live
|
|
44
47
|
* from the served access token and falls back to stored login-time identity; both legs can be
|
|
@@ -52,6 +55,10 @@ export type ServedCredential = {
|
|
|
52
55
|
/** Non-secret organization or workspace display metadata captured at login. */
|
|
53
56
|
orgName?: string;
|
|
54
57
|
};
|
|
58
|
+
/** The same credential metadata with opaque payload bytes, without text conversion. */
|
|
59
|
+
export type ServedCredentialBytes = Omit<ServedCredential, 'material'> & {
|
|
60
|
+
material: Uint8Array;
|
|
61
|
+
};
|
|
55
62
|
/**
|
|
56
63
|
* Non-secret health for one handle, read without minting anything.
|
|
57
64
|
*
|
|
@@ -93,7 +100,10 @@ export declare class ClaustrumClient {
|
|
|
93
100
|
#private;
|
|
94
101
|
private constructor();
|
|
95
102
|
static connect(options?: ClaustrumClientOptions): Promise<ClaustrumClient>;
|
|
103
|
+
/** Fetch UTF-8 text; use `getCredentialBytes` if the payload may contain arbitrary bytes. */
|
|
96
104
|
getCredential(handle: string, minTtlMs?: number): Promise<ServedCredential>;
|
|
105
|
+
/** Fetch opaque payload bytes by bearer handle without UTF-8 decoding. */
|
|
106
|
+
getCredentialBytes(handle: string, minTtlMs?: number): Promise<ServedCredentialBytes>;
|
|
97
107
|
statusCredential(handle: string): Promise<CredentialStatus>;
|
|
98
108
|
reportAuthFailure(input: {
|
|
99
109
|
handle: string;
|
|
@@ -111,7 +121,8 @@ export declare class ClaustrumClient {
|
|
|
111
121
|
/** One watch on a dedicated route. Reopen/reconnect policy belongs to the caller. */
|
|
112
122
|
watchScoped(onNotice: (notice: ScopedWatchNotice) => void, options?: ScopedWatchOptions): ScopedWatch;
|
|
113
123
|
/**
|
|
114
|
-
* Fetch by credential id, authorized by a grant rather than a bearer handle.
|
|
124
|
+
* Fetch UTF-8 text by credential id, authorized by a grant rather than a bearer handle.
|
|
125
|
+
* Use `getScopedBytes` if the payload may contain arbitrary bytes.
|
|
115
126
|
*
|
|
116
127
|
* `minTtlMs` is a DEMAND, not a hint: if a real upstream exchange still cannot satisfy
|
|
117
128
|
* it, the vault refuses with `ttl_unsatisfiable` (class `context_overflow`) rather than
|
|
@@ -123,6 +134,12 @@ export declare class ClaustrumClient {
|
|
|
123
134
|
enrollmentToken?: string;
|
|
124
135
|
minTtlMs?: number;
|
|
125
136
|
}): Promise<ServedCredential>;
|
|
137
|
+
/** Fetch opaque bytes by credential id, with the same grant and TTL rules as `getScoped`. */
|
|
138
|
+
getScopedBytes(input: {
|
|
139
|
+
credentialId: string;
|
|
140
|
+
enrollmentToken?: string;
|
|
141
|
+
minTtlMs?: number;
|
|
142
|
+
}): Promise<ServedCredentialBytes>;
|
|
126
143
|
/**
|
|
127
144
|
* Report a served credential dead, addressed by id rather than by handle.
|
|
128
145
|
*
|
package/dist/wire.js
CHANGED
|
@@ -52,6 +52,17 @@ function isOptionalString(value) {
|
|
|
52
52
|
* widen the published surface.
|
|
53
53
|
*/
|
|
54
54
|
export function decodeCredential(response, logUnknownClass) {
|
|
55
|
+
const credential = decodeCredentialBytes(response, logUnknownClass);
|
|
56
|
+
let material;
|
|
57
|
+
try {
|
|
58
|
+
material = new TextDecoder('utf-8', { fatal: true }).decode(credential.material);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw asCredentialError(response, 'invalid_response', logUnknownClass);
|
|
62
|
+
}
|
|
63
|
+
return { ...credential, material };
|
|
64
|
+
}
|
|
65
|
+
function decodeCredentialBytes(response, logUnknownClass) {
|
|
55
66
|
if (hasCredentialError(response))
|
|
56
67
|
throw asCredentialError(response, 'invalid_response', logUnknownClass);
|
|
57
68
|
const result = isRecord(response) && isRecord(response.result) ? response.result : undefined;
|
|
@@ -86,15 +97,8 @@ export function decodeCredential(response, logUnknownClass) {
|
|
|
86
97
|
!isOptionalString(orgName)) {
|
|
87
98
|
throw asCredentialError(response, 'invalid_response', logUnknownClass);
|
|
88
99
|
}
|
|
89
|
-
let material;
|
|
90
|
-
try {
|
|
91
|
-
material = new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(payload));
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
throw asCredentialError(response, 'invalid_response', logUnknownClass);
|
|
95
|
-
}
|
|
96
100
|
return {
|
|
97
|
-
material,
|
|
101
|
+
material: Uint8Array.from(payload),
|
|
98
102
|
recordVersion,
|
|
99
103
|
expiresAtMs,
|
|
100
104
|
credentialId,
|
|
@@ -160,6 +164,7 @@ export class ClaustrumClient {
|
|
|
160
164
|
});
|
|
161
165
|
return new ClaustrumClient(client, connector, connectionFile, options.handshakeTimeoutMs, identity, options.logger ?? ((errorClass) => console.warn(errorClass)));
|
|
162
166
|
}
|
|
167
|
+
/** Fetch UTF-8 text; use `getCredentialBytes` if the payload may contain arbitrary bytes. */
|
|
163
168
|
async getCredential(handle, minTtlMs) {
|
|
164
169
|
const response = await this.#call('credential.get', {
|
|
165
170
|
handle,
|
|
@@ -168,6 +173,15 @@ export class ClaustrumClient {
|
|
|
168
173
|
});
|
|
169
174
|
return decodeCredential(response, this.#logger);
|
|
170
175
|
}
|
|
176
|
+
/** Fetch opaque payload bytes by bearer handle without UTF-8 decoding. */
|
|
177
|
+
async getCredentialBytes(handle, minTtlMs) {
|
|
178
|
+
const response = await this.#call('credential.get', {
|
|
179
|
+
handle,
|
|
180
|
+
min_ttl_ms: minTtlMs,
|
|
181
|
+
force_refresh: false,
|
|
182
|
+
});
|
|
183
|
+
return decodeCredentialBytes(response, this.#logger);
|
|
184
|
+
}
|
|
171
185
|
async statusCredential(handle) {
|
|
172
186
|
const response = await this.#call('credential.status', { handle });
|
|
173
187
|
return decodeStatus(response, this.#logger);
|
|
@@ -204,7 +218,8 @@ export class ClaustrumClient {
|
|
|
204
218
|
});
|
|
205
219
|
}
|
|
206
220
|
/**
|
|
207
|
-
* Fetch by credential id, authorized by a grant rather than a bearer handle.
|
|
221
|
+
* Fetch UTF-8 text by credential id, authorized by a grant rather than a bearer handle.
|
|
222
|
+
* Use `getScopedBytes` if the payload may contain arbitrary bytes.
|
|
208
223
|
*
|
|
209
224
|
* `minTtlMs` is a DEMAND, not a hint: if a real upstream exchange still cannot satisfy
|
|
210
225
|
* it, the vault refuses with `ttl_unsatisfiable` (class `context_overflow`) rather than
|
|
@@ -219,6 +234,15 @@ export class ClaustrumClient {
|
|
|
219
234
|
});
|
|
220
235
|
return decodeCredential(response, this.#logger);
|
|
221
236
|
}
|
|
237
|
+
/** Fetch opaque bytes by credential id, with the same grant and TTL rules as `getScoped`. */
|
|
238
|
+
async getScopedBytes(input) {
|
|
239
|
+
const response = await this.#call('credential.get_scoped', {
|
|
240
|
+
credential_id: input.credentialId,
|
|
241
|
+
enrollment_token: input.enrollmentToken,
|
|
242
|
+
min_ttl_ms: input.minTtlMs,
|
|
243
|
+
});
|
|
244
|
+
return decodeCredentialBytes(response, this.#logger);
|
|
245
|
+
}
|
|
222
246
|
/**
|
|
223
247
|
* Report a served credential dead, addressed by id rather than by handle.
|
|
224
248
|
*
|
package/package.json
CHANGED
package/dist/handles.d.ts
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { checkPrivatePath } from './private-path.js';
|
|
2
|
-
export declare const HANDLE_FILE_CONTRACT: {
|
|
3
|
-
readonly maxBytes: number;
|
|
4
|
-
readonly mode: 384;
|
|
5
|
-
readonly labelRe: RegExp;
|
|
6
|
-
readonly handleRe: RegExp;
|
|
7
|
-
};
|
|
8
|
-
export declare class HandleFileValidationError extends Error {
|
|
9
|
-
name: string;
|
|
10
|
-
}
|
|
11
|
-
export type HandleAccount = {
|
|
12
|
-
label: string;
|
|
13
|
-
handle: string;
|
|
14
|
-
credential_id: string;
|
|
15
|
-
superseded?: string[];
|
|
16
|
-
minTtlMs?: number;
|
|
17
|
-
};
|
|
18
|
-
export type HandleProvider = {
|
|
19
|
-
provider: string;
|
|
20
|
-
shape: 'api' | 'oauth';
|
|
21
|
-
serve: string;
|
|
22
|
-
accounts: HandleAccount[];
|
|
23
|
-
};
|
|
24
|
-
export type OpenCodeHandleFileV1 = {
|
|
25
|
-
version: 1;
|
|
26
|
-
providers: HandleProvider[];
|
|
27
|
-
};
|
|
28
|
-
export declare function identifierIsValid(value: unknown): value is string;
|
|
29
|
-
export declare function credentialIdMatchesProvider(value: unknown, provider: string): value is string;
|
|
30
|
-
export declare function parseHandleFile(value: unknown): OpenCodeHandleFileV1;
|
|
31
|
-
type HandleFileStat = {
|
|
32
|
-
isFile(): boolean;
|
|
33
|
-
isDirectory?(): boolean;
|
|
34
|
-
isSymbolicLink?(): boolean;
|
|
35
|
-
mode: number;
|
|
36
|
-
size?: number;
|
|
37
|
-
gid?: number;
|
|
38
|
-
uid?: number;
|
|
39
|
-
mtimeMs?: number;
|
|
40
|
-
};
|
|
41
|
-
type HandleFileDescriptor = {
|
|
42
|
-
read?(buffer: Uint8Array, offset: number, length: number, position: number): Promise<{
|
|
43
|
-
bytesRead: number;
|
|
44
|
-
}> | {
|
|
45
|
-
bytesRead: number;
|
|
46
|
-
};
|
|
47
|
-
stat(): Promise<HandleFileStat>;
|
|
48
|
-
readFile(options: {
|
|
49
|
-
encoding: 'utf8';
|
|
50
|
-
}): Promise<string>;
|
|
51
|
-
close(): Promise<void>;
|
|
52
|
-
};
|
|
53
|
-
export type HandleFileIo = {
|
|
54
|
-
pathOptions?: Parameters<typeof checkPrivatePath>[1];
|
|
55
|
-
stat?: (path: string) => Promise<HandleFileStat>;
|
|
56
|
-
lstat?: (path: string) => Promise<HandleFileStat>;
|
|
57
|
-
readFile?: (path: string, encoding: 'utf8') => Promise<string>;
|
|
58
|
-
open?: (path: string) => Promise<HandleFileDescriptor>;
|
|
59
|
-
currentUid?: () => number | undefined;
|
|
60
|
-
};
|
|
61
|
-
export declare function defaultHandleFilePath(env?: NodeJS.ProcessEnv): string;
|
|
62
|
-
export declare function readHandleFile(path?: string, io?: HandleFileIo): Promise<OpenCodeHandleFileV1>;
|
|
63
|
-
export declare function handleFileRevision(path?: string, io?: HandleFileIo): Promise<string>;
|
|
64
|
-
export {};
|
package/dist/handles.js
DELETED
|
@@ -1,255 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { constants } from 'node:fs';
|
|
3
|
-
import { lstat as nodeLstat, open as nodeOpen, readFile, stat as nodeStat } from 'node:fs/promises';
|
|
4
|
-
import { userInfo } from 'node:os';
|
|
5
|
-
import { dirname, join, resolve } from 'node:path';
|
|
6
|
-
import { checkPrivatePath, notPrivate } from './private-path.js';
|
|
7
|
-
export const HANDLE_FILE_CONTRACT = {
|
|
8
|
-
maxBytes: 256 * 1024,
|
|
9
|
-
mode: 0o600,
|
|
10
|
-
// Shape only: identifier validity also rejects FORBIDDEN_IDENTIFIERS; use
|
|
11
|
-
// identifierIsValid for validation so callers do not accept parser-rejected labels.
|
|
12
|
-
labelRe: /^[a-z0-9][a-z0-9._-]{0,63}$/,
|
|
13
|
-
handleRe: /^ckh_[A-Za-z0-9_-]{43}$/,
|
|
14
|
-
};
|
|
15
|
-
const FORBIDDEN_IDENTIFIERS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
16
|
-
export class HandleFileValidationError extends Error {
|
|
17
|
-
name = 'HandleFileValidationError';
|
|
18
|
-
}
|
|
19
|
-
function isAccount(value) {
|
|
20
|
-
if (!value || typeof value !== 'object')
|
|
21
|
-
return false;
|
|
22
|
-
const account = value;
|
|
23
|
-
return typeof account.label === 'string' && typeof account.handle === 'string' &&
|
|
24
|
-
typeof account.credential_id === 'string' &&
|
|
25
|
-
(account.superseded === undefined ||
|
|
26
|
-
(Array.isArray(account.superseded) && account.superseded.every((handle) => typeof handle === 'string')));
|
|
27
|
-
}
|
|
28
|
-
function handleIsValid(handle) {
|
|
29
|
-
return typeof handle === 'string' && HANDLE_FILE_CONTRACT.handleRe.test(handle);
|
|
30
|
-
}
|
|
31
|
-
export function identifierIsValid(value) {
|
|
32
|
-
return typeof value === 'string' && HANDLE_FILE_CONTRACT.labelRe.test(value) && !FORBIDDEN_IDENTIFIERS.has(value);
|
|
33
|
-
}
|
|
34
|
-
export function credentialIdMatchesProvider(value, provider) {
|
|
35
|
-
if (typeof value !== 'string')
|
|
36
|
-
return false;
|
|
37
|
-
const segments = value.split(':');
|
|
38
|
-
return value.length > 0 && segments[1] === provider && segments.every((segment) => segment.length > 0);
|
|
39
|
-
}
|
|
40
|
-
function invalid(message) {
|
|
41
|
-
throw new HandleFileValidationError(message);
|
|
42
|
-
}
|
|
43
|
-
export function parseHandleFile(value) {
|
|
44
|
-
if (!value || typeof value !== 'object')
|
|
45
|
-
invalid('handle file must be an object');
|
|
46
|
-
const file = value;
|
|
47
|
-
if (file.version !== 1 || !Array.isArray(file.providers)) {
|
|
48
|
-
invalid('handle file must have version 1 and providers');
|
|
49
|
-
}
|
|
50
|
-
const providerIds = new Set();
|
|
51
|
-
const providers = file.providers.map((provider, index) => {
|
|
52
|
-
if (!provider || typeof provider !== 'object')
|
|
53
|
-
invalid(`provider ${index} must be an object`);
|
|
54
|
-
const item = provider;
|
|
55
|
-
if (!identifierIsValid(item.provider))
|
|
56
|
-
invalid(`provider ${index} has invalid provider`);
|
|
57
|
-
if (providerIds.has(item.provider))
|
|
58
|
-
invalid(`provider ${index} duplicates provider ${item.provider}`);
|
|
59
|
-
providerIds.add(item.provider);
|
|
60
|
-
if (item.shape !== 'api' && item.shape !== 'oauth')
|
|
61
|
-
invalid(`provider ${index} has invalid shape`);
|
|
62
|
-
if (typeof item.serve !== 'string' || !item.serve)
|
|
63
|
-
invalid(`provider ${index} requires serve`);
|
|
64
|
-
if (!Array.isArray(item.accounts) || item.accounts.length === 0 || !item.accounts.every(isAccount)) {
|
|
65
|
-
invalid(`provider ${index} has invalid accounts`);
|
|
66
|
-
}
|
|
67
|
-
const labels = new Set();
|
|
68
|
-
for (const account of item.accounts) {
|
|
69
|
-
if (!identifierIsValid(account.label))
|
|
70
|
-
invalid(`provider ${index} has an invalid account label`);
|
|
71
|
-
if (labels.has(account.label))
|
|
72
|
-
invalid(`provider ${index} duplicates account label ${account.label}`);
|
|
73
|
-
labels.add(account.label);
|
|
74
|
-
if (!handleIsValid(account.handle))
|
|
75
|
-
invalid(`provider ${index} account ${account.label} has invalid handle`);
|
|
76
|
-
if (account.minTtlMs !== undefined && !(Number.isSafeInteger(account.minTtlMs) && account.minTtlMs >= 0)) {
|
|
77
|
-
invalid(`provider ${index} account ${account.label} has invalid minTtlMs`);
|
|
78
|
-
}
|
|
79
|
-
// Segment 2 of the credential id must BE the provider block it sits in. Without
|
|
80
|
-
// this the check was non-empty-string only, so an `oauth:openai` binding parsed
|
|
81
|
-
// cleanly inside an `anthropic` block -- a cross-provider smuggle that every
|
|
82
|
-
// tenant reading this manifest would have honoured. Two peer tenants found the
|
|
83
|
-
// same hole in their own parsers independently.
|
|
84
|
-
//
|
|
85
|
-
// SCOPED TO SEGMENT 2 ONLY, deliberately. Segment 1 (the kind) is an OPEN SET --
|
|
86
|
-
// `oauth:`, `chatgpt:`, `antigravity:`, `apikey:` are all live in this vault today
|
|
87
|
-
// -- so a kind allowlist would refuse real ids. Segment 3+ (the label) is
|
|
88
|
-
// operator-chosen and may be absent: main is the 2-segment `oauth:anthropic`,
|
|
89
|
-
// fallbacks are 3-segment. Constraining either would reject the deployment this
|
|
90
|
-
// contract describes.
|
|
91
|
-
//
|
|
92
|
-
// NO SEGMENT MAY BE EMPTY. Segment 2 alone is what fences the provider, but a
|
|
93
|
-
// position-1 check ignores the rest of the string, and that left two ids passing
|
|
94
|
-
// that name credentials which cannot exist: `:anthropic:x` (empty kind) and
|
|
95
|
-
// `oauth:anthropic:` (empty label) both satisfy "segment 2 is the provider"
|
|
96
|
-
// literally. Neither is a smuggle; both defer a GUARANTEED resolve-time failure
|
|
97
|
-
// past the door, and under custody a resolve-time failure on a tombstoned account
|
|
98
|
-
// is a dark route rather than a refused row.
|
|
99
|
-
//
|
|
100
|
-
// It also removes an asymmetry nobody designed and everyone would read as a bug:
|
|
101
|
-
// `oauth::anthropic` rejected while `:anthropic:x` passed, purely because the
|
|
102
|
-
// check indexed position 1 and ignored positions 0 and 2. Agreed with the peer
|
|
103
|
-
// tenant and mirrored on their side, so this is chosen rather than defaulted --
|
|
104
|
-
// the previous behaviour was two independent defaults that happened to differ.
|
|
105
|
-
//
|
|
106
|
-
// The emptiness guard is ALSO explicit rather than a consequence: `''.split(':')`
|
|
107
|
-
// yields `['']`, whose `[1]` is `undefined` and cannot equal a provider string,
|
|
108
|
-
// so an empty id would reject anyway -- but that is a coincidence doing
|
|
109
|
-
// load-bearing work, and the check this replaced (`!account.credential_id`) was
|
|
110
|
-
// the emptiness guard. NO TEST DISTINGUISHES THAT ONE (empty rejects with or
|
|
111
|
-
// without it, verified by removal), so it is kept for a future reader who loosens
|
|
112
|
-
// the comparison, not for an arm it could never redden. The non-empty-SEGMENT
|
|
113
|
-
// rule in credentialIdMatchesProvider is different: it reddens, and is pinned.
|
|
114
|
-
if (!credentialIdMatchesProvider(account.credential_id, item.provider)) {
|
|
115
|
-
invalid(`provider ${index} account ${account.label} has invalid credential id`);
|
|
116
|
-
}
|
|
117
|
-
if (account.superseded?.some((handle) => !handleIsValid(handle))) {
|
|
118
|
-
invalid(`provider ${index} account ${account.label} has invalid superseded handle`);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return {
|
|
122
|
-
provider: item.provider,
|
|
123
|
-
shape: item.shape,
|
|
124
|
-
serve: item.serve,
|
|
125
|
-
accounts: item.accounts.map((account) => ({
|
|
126
|
-
...account,
|
|
127
|
-
...(account.superseded === undefined ? {} : { superseded: account.superseded }),
|
|
128
|
-
})),
|
|
129
|
-
};
|
|
130
|
-
});
|
|
131
|
-
return { version: 1, providers };
|
|
132
|
-
}
|
|
133
|
-
export function defaultHandleFilePath(env = process.env) {
|
|
134
|
-
if (env.CLAUSTRUM_OPENCODE_HANDLES)
|
|
135
|
-
return env.CLAUSTRUM_OPENCODE_HANDLES;
|
|
136
|
-
const configHome = env.XDG_CONFIG_HOME || (env.HOME ? join(env.HOME, '.config') : '.config');
|
|
137
|
-
return join(configHome, 'cortexkit', 'opencode-handles.json');
|
|
138
|
-
}
|
|
139
|
-
function currentUid() {
|
|
140
|
-
return process.platform === 'win32' ? undefined : process.getuid?.() ?? userInfo().uid;
|
|
141
|
-
}
|
|
142
|
-
async function readBounded(descriptor, cap) {
|
|
143
|
-
if (!descriptor.read)
|
|
144
|
-
throw new Error('readBounded requires a descriptor exposing read()');
|
|
145
|
-
const buffer = Buffer.alloc(cap + 1);
|
|
146
|
-
let total = 0;
|
|
147
|
-
while (total < cap + 1) {
|
|
148
|
-
const chunk = await descriptor.read(buffer, total, buffer.length - total, total);
|
|
149
|
-
if (chunk.bytesRead === 0)
|
|
150
|
-
break;
|
|
151
|
-
total += chunk.bytesRead;
|
|
152
|
-
}
|
|
153
|
-
return { buffer, bytes: total > cap ? -1 : total };
|
|
154
|
-
}
|
|
155
|
-
async function readHandleSnapshot(path = defaultHandleFilePath(), io = {}) {
|
|
156
|
-
path = resolve(path);
|
|
157
|
-
const stat = io.stat ?? nodeStat;
|
|
158
|
-
const lstat = io.lstat ?? nodeLstat;
|
|
159
|
-
const read = io.readFile ?? readFile;
|
|
160
|
-
const openFd = io.open ?? ((candidate) => nodeOpen(candidate, constants.O_RDONLY | constants.O_NOFOLLOW));
|
|
161
|
-
let descriptor;
|
|
162
|
-
try {
|
|
163
|
-
let metadata;
|
|
164
|
-
try {
|
|
165
|
-
if (io.lstat || io.readFile) {
|
|
166
|
-
metadata = await lstat(path);
|
|
167
|
-
}
|
|
168
|
-
else {
|
|
169
|
-
descriptor = await openFd(path);
|
|
170
|
-
metadata = await descriptor.stat();
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
catch (error) {
|
|
174
|
-
if (error.code === 'ENOENT')
|
|
175
|
-
return { file: { version: 1, providers: [] } };
|
|
176
|
-
if (error.code === 'ELOOP')
|
|
177
|
-
invalid('handle file must not be a symlink');
|
|
178
|
-
invalid(`cannot stat handle file: ${error instanceof Error ? error.message : String(error)}`);
|
|
179
|
-
}
|
|
180
|
-
if (metadata.isSymbolicLink?.())
|
|
181
|
-
invalid('handle file must not be a symlink');
|
|
182
|
-
if (!metadata.isFile())
|
|
183
|
-
invalid('handle file must be a regular file');
|
|
184
|
-
if ((metadata.size ?? 0) > HANDLE_FILE_CONTRACT.maxBytes)
|
|
185
|
-
invalid('handle file exceeds 256 KiB');
|
|
186
|
-
if (process.platform !== 'win32' && (metadata.mode & 0o777) !== HANDLE_FILE_CONTRACT.mode)
|
|
187
|
-
invalid('handle file mode must be exactly 0600');
|
|
188
|
-
const uid = io.currentUid ?? currentUid;
|
|
189
|
-
const expectedUid = uid();
|
|
190
|
-
if (expectedUid !== undefined && metadata.uid !== undefined && metadata.uid !== expectedUid) {
|
|
191
|
-
invalid('handle file is not owned by the current uid');
|
|
192
|
-
}
|
|
193
|
-
let parent;
|
|
194
|
-
try {
|
|
195
|
-
parent = await stat(dirname(path));
|
|
196
|
-
}
|
|
197
|
-
catch (error) {
|
|
198
|
-
invalid(`cannot stat handle file parent: ${error instanceof Error ? error.message : String(error)}`);
|
|
199
|
-
}
|
|
200
|
-
if (!parent.isDirectory?.())
|
|
201
|
-
invalid('handle file parent must be a directory');
|
|
202
|
-
if (expectedUid !== undefined && parent.uid !== undefined && parent.uid !== expectedUid) {
|
|
203
|
-
invalid('handle file parent is not owned by the current uid');
|
|
204
|
-
}
|
|
205
|
-
// HandleFileIo's injected stat supplies ancestor metadata as well as the immediate
|
|
206
|
-
// parent. Pass that metadata to @cortexkit/store's Unix path check.
|
|
207
|
-
const fixtureStat = io.stat && (async (candidate) => {
|
|
208
|
-
const value = await io.stat(candidate);
|
|
209
|
-
return { ...value, isDirectory: () => value.isDirectory?.() ?? false };
|
|
210
|
-
});
|
|
211
|
-
const protection = await checkPrivatePath(process.platform === 'win32' ? path : dirname(path), io.pathOptions ?? (fixtureStat ? {
|
|
212
|
-
euid: expectedUid,
|
|
213
|
-
fs: { stat: fixtureStat, lstat: fixtureStat },
|
|
214
|
-
} : undefined));
|
|
215
|
-
if (!protection.ok)
|
|
216
|
-
invalid(notPrivate('handle file folder', protection));
|
|
217
|
-
let source;
|
|
218
|
-
try {
|
|
219
|
-
if (descriptor) {
|
|
220
|
-
const { buffer, bytes } = await readBounded(descriptor, HANDLE_FILE_CONTRACT.maxBytes);
|
|
221
|
-
if (bytes === -1)
|
|
222
|
-
invalid('handle file exceeds 256 KiB');
|
|
223
|
-
source = buffer.subarray(0, bytes).toString('utf8');
|
|
224
|
-
}
|
|
225
|
-
else {
|
|
226
|
-
source = await read(path, 'utf8');
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
catch (error) {
|
|
230
|
-
if (error instanceof HandleFileValidationError)
|
|
231
|
-
throw error;
|
|
232
|
-
invalid(`cannot read handle file: ${error instanceof Error ? error.message : String(error)}`);
|
|
233
|
-
}
|
|
234
|
-
let value;
|
|
235
|
-
try {
|
|
236
|
-
value = JSON.parse(source);
|
|
237
|
-
}
|
|
238
|
-
catch {
|
|
239
|
-
invalid('handle file contains invalid JSON');
|
|
240
|
-
}
|
|
241
|
-
return { file: parseHandleFile(value), source, mtimeMs: metadata.mtimeMs };
|
|
242
|
-
}
|
|
243
|
-
finally {
|
|
244
|
-
await descriptor?.close();
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
export async function readHandleFile(path = defaultHandleFilePath(), io = {}) {
|
|
248
|
-
return (await readHandleSnapshot(path, io)).file;
|
|
249
|
-
}
|
|
250
|
-
export async function handleFileRevision(path = defaultHandleFilePath(), io = {}) {
|
|
251
|
-
const snapshot = await readHandleSnapshot(path, io);
|
|
252
|
-
if (snapshot.source === undefined)
|
|
253
|
-
invalid('cannot revise absent handle file');
|
|
254
|
-
return `${snapshot.mtimeMs ?? 0}:${createHash('sha256').update(snapshot.source).digest('hex')}`;
|
|
255
|
-
}
|
package/dist/manifest-lock.d.ts
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { checkPrivatePath } from './private-path.js';
|
|
2
|
-
import { type OpenCodeHandleFileV1 } from './handles.js';
|
|
3
|
-
export declare const MANIFEST_LOCK: {
|
|
4
|
-
ttlMs: number;
|
|
5
|
-
renewEveryMs: number;
|
|
6
|
-
ownerKeys: readonly ['tenant', 'pid', 'claimed_at_ms', 'nonce'];
|
|
7
|
-
staleTargetRe: RegExp;
|
|
8
|
-
errorCodes: readonly ['lock_busy', 'owner_invalid', 'renewal_failed', 'unsupported_platform'];
|
|
9
|
-
};
|
|
10
|
-
/** Thrown by the lock. Branch on `code`; the message is diagnostic and may be reworded. */
|
|
11
|
-
export type ManifestLockErrorCode = (typeof MANIFEST_LOCK.errorCodes)[number];
|
|
12
|
-
export type ManifestLockError = Error & {
|
|
13
|
-
code: ManifestLockErrorCode;
|
|
14
|
-
};
|
|
15
|
-
export type ManifestHandleAccount = OpenCodeHandleFileV1['providers'][number]['accounts'][number];
|
|
16
|
-
export type ManifestHandleProvider = OpenCodeHandleFileV1['providers'][number];
|
|
17
|
-
export type ManifestHandleFile = OpenCodeHandleFileV1;
|
|
18
|
-
type ManifestLockOptions = Readonly<{
|
|
19
|
-
ttlMs?: number;
|
|
20
|
-
renewEveryMs?: number;
|
|
21
|
-
retryMinMs?: number;
|
|
22
|
-
retryMaxMs?: number;
|
|
23
|
-
afterClaim?: () => Promise<void> | void;
|
|
24
|
-
beforeEvict?: () => Promise<void>;
|
|
25
|
-
afterEvictRenameAttempt?: () => Promise<void>;
|
|
26
|
-
afterEvict?: () => void;
|
|
27
|
-
beforeManifestRename?: (path: string) => Promise<void>;
|
|
28
|
-
checkPath?: typeof checkPrivatePath;
|
|
29
|
-
}>;
|
|
30
|
-
export declare function manifestLockQuarantinePrefix(path: string, basename?: (p: string) => string): string | undefined;
|
|
31
|
-
/** Each instance captures its dependencies; callers cannot reconfigure the shared writer. */
|
|
32
|
-
export declare function createManifestLock(options?: ManifestLockOptions): {
|
|
33
|
-
withManifestLock: <T>(path: string, tenant: string, fn: () => Promise<T> | T) => Promise<T>;
|
|
34
|
-
writeHandleFileLocked: (path: string, tenant: string, mutate: (file: ManifestHandleFile) => void | ManifestHandleFile | Promise<void | ManifestHandleFile>) => Promise<void>;
|
|
35
|
-
};
|
|
36
|
-
export declare const withManifestLock: <T>(path: string, tenant: string, fn: () => Promise<T> | T) => Promise<T>, writeHandleFileLocked: (path: string, tenant: string, mutate: (file: ManifestHandleFile) => void | ManifestHandleFile | Promise<void | ManifestHandleFile>) => Promise<void>;
|
|
37
|
-
export {};
|
package/dist/manifest-lock.js
DELETED
|
@@ -1,333 +0,0 @@
|
|
|
1
|
-
import { createPrivateDirectory, createPrivateFile } from '@cortexkit/store';
|
|
2
|
-
import { checkPrivatePath, createPrivateParents, notPrivate, privateCreationCode } from './private-path.js';
|
|
3
|
-
import { lstat, open, readFile, readdir, rename, rm, stat, unlink } from 'node:fs/promises';
|
|
4
|
-
import { randomBytes, randomInt } from 'node:crypto';
|
|
5
|
-
import { basename as pathBasename, dirname, join, resolve } from 'node:path';
|
|
6
|
-
import { HANDLE_FILE_CONTRACT, parseHandleFile } from './handles.js';
|
|
7
|
-
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', 'unsupported_platform'] };
|
|
8
|
-
// The claim deadline bounds when the last stale-lock rename is issued, not when it lands:
|
|
9
|
-
// a rename issued at deadline-1ms can complete afterwards, by tens to hundreds of ms on a
|
|
10
|
-
// loaded host. The retry deadline and staleness window both read `ttl` today; if they split,
|
|
11
|
-
// this bound must keep their max so reclamation still covers the longer role.
|
|
12
|
-
const MANIFEST_LOCK_QUARANTINE_RECLAIM_MARGIN_MS = 5_000;
|
|
13
|
-
// 16 CSPRNG bytes: a collision needs both the same millisecond and the same nonce. Do not
|
|
14
|
-
// simplify this to a counter, pid+timestamp, or a short token.
|
|
15
|
-
const token = () => randomBytes(16).toString('base64url');
|
|
16
|
-
const code = privateCreationCode;
|
|
17
|
-
const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
18
|
-
// A tenant classifying a failure must not string-match our prose: a copy-edit would
|
|
19
|
-
// silently reclassify a retryable busy-lock as an unknown error, with nothing failing
|
|
20
|
-
// loudly. The code is the contract; the message is free to change.
|
|
21
|
-
const lockError = (code, message) => Object.assign(new Error(message), { code });
|
|
22
|
-
const unsupportedOnWindows = () => lockError('unsupported_platform', 'handle-file manifest locking is not supported on Windows');
|
|
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
|
-
await createPrivateFile(temporary, `${JSON.stringify(owner)}\n`);
|
|
48
|
-
file = await open(temporary, 'r+');
|
|
49
|
-
await file.sync();
|
|
50
|
-
await file.close();
|
|
51
|
-
file = undefined;
|
|
52
|
-
await rename(temporary, target);
|
|
53
|
-
}
|
|
54
|
-
finally {
|
|
55
|
-
await file?.close().catch(() => { });
|
|
56
|
-
await unlink(temporary).catch(() => { });
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
// Split out from the sweep so the platform claim is testable from a POSIX runner: this takes
|
|
60
|
-
// the basename FUNCTION, so a test can drive it with path.win32.basename without pretending
|
|
61
|
-
// a Linux host is Windows. A filesystem test cannot do that -- on Linux a '/' split and
|
|
62
|
-
// basename agree, so the defect below survives every filesystem test written against it.
|
|
63
|
-
//
|
|
64
|
-
// pathBasename, not a split on '/': on Windows the manifest path is backslash-separated, so a
|
|
65
|
-
// '/' split yields the whole path, nothing matches the prefix, and the sweep becomes a silent
|
|
66
|
-
// no-op on the one platform the quarantine bound is hardest to observe. Fails closed -- it
|
|
67
|
-
// cannot reap the wrong directory -- but the feature is inert, which is worse than loud.
|
|
68
|
-
export function manifestLockQuarantinePrefix(path, basename = pathBasename) {
|
|
69
|
-
const name = basename(path);
|
|
70
|
-
return name ? name : undefined;
|
|
71
|
-
}
|
|
72
|
-
async function reclaimStaleManifestLockQuarantines(path, ttlMs, claimDeadlineMs) {
|
|
73
|
-
const reclaimAgeMs = Math.max(ttlMs, claimDeadlineMs) + MANIFEST_LOCK_QUARANTINE_RECLAIM_MARGIN_MS;
|
|
74
|
-
let names;
|
|
75
|
-
try {
|
|
76
|
-
names = await readdir(dirname(path));
|
|
77
|
-
}
|
|
78
|
-
catch {
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
const basename = manifestLockQuarantinePrefix(path);
|
|
82
|
-
if (!basename)
|
|
83
|
-
return;
|
|
84
|
-
await Promise.all(names.map(async (name) => {
|
|
85
|
-
const staleTarget = name.startsWith(basename) ? name.slice(basename.length) : undefined;
|
|
86
|
-
if (!staleTarget || !MANIFEST_LOCK.staleTargetRe.test(staleTarget))
|
|
87
|
-
return;
|
|
88
|
-
const target = join(dirname(path), name);
|
|
89
|
-
let metadata;
|
|
90
|
-
try {
|
|
91
|
-
metadata = await lstat(target);
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
if (!metadata.isDirectory() || Date.now() - metadata.mtimeMs < reclaimAgeMs)
|
|
97
|
-
return;
|
|
98
|
-
await rm(target, { recursive: true, force: true }).catch(() => { });
|
|
99
|
-
}));
|
|
100
|
-
}
|
|
101
|
-
/** Each instance captures its dependencies; callers cannot reconfigure the shared writer. */
|
|
102
|
-
export function createManifestLock(options = {}) {
|
|
103
|
-
const settings = Object.freeze({ ...options });
|
|
104
|
-
const checkPath = settings.checkPath ?? checkPrivatePath;
|
|
105
|
-
async function withManifestLock(path, tenant, fn) {
|
|
106
|
-
return withLockCommit(path, tenant, async () => fn());
|
|
107
|
-
}
|
|
108
|
-
async function withLockCommit(path, tenant, fn) {
|
|
109
|
-
// Stale-lock eviction renames the lock directory while another process may hold it
|
|
110
|
-
// open. That is safe under POSIX rename; on Windows CI two evictors both won and a
|
|
111
|
-
// rename of an open lock failed with EPERM. Until the protocol is proven there,
|
|
112
|
-
// refuse rather than risk two writers overlapping on the shared handle file.
|
|
113
|
-
if (process.platform === 'win32')
|
|
114
|
-
throw unsupportedOnWindows();
|
|
115
|
-
path = resolve(path);
|
|
116
|
-
const lock = `${path}.lock`, ownerPath = join(lock, 'owner'), ttl = settings.ttlMs ?? MANIFEST_LOCK.ttlMs, renewEvery = settings.renewEveryMs ?? MANIFEST_LOCK.renewEveryMs, retryMin = settings.retryMinMs ?? 25, retryMax = settings.retryMaxMs ?? 75;
|
|
117
|
-
const nonce = token(), started = Date.now(), deadline = started + ttl;
|
|
118
|
-
while (true) {
|
|
119
|
-
try {
|
|
120
|
-
await createPrivateDirectory(lock);
|
|
121
|
-
await writeOwner(lock, { tenant, pid: process.pid, claimed_at_ms: Date.now(), nonce });
|
|
122
|
-
await settings.afterClaim?.();
|
|
123
|
-
break;
|
|
124
|
-
}
|
|
125
|
-
catch (error) {
|
|
126
|
-
if (code(error) !== 'EEXIST') {
|
|
127
|
-
if (code(error) !== 'ENOENT')
|
|
128
|
-
await rm(lock, { recursive: true, force: true }).catch(() => { });
|
|
129
|
-
throw error;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
let observed, ownerReadError;
|
|
133
|
-
try {
|
|
134
|
-
observed = await readOwner(ownerPath);
|
|
135
|
-
}
|
|
136
|
-
catch (error) {
|
|
137
|
-
ownerReadError = error;
|
|
138
|
-
if (code(error) !== 'ENOENT' && Date.now() >= deadline)
|
|
139
|
-
throw code(error) === 'owner_invalid' ? error : lockError('lock_busy', 'manifest lock busy');
|
|
140
|
-
}
|
|
141
|
-
if (!observed && code(ownerReadError) === 'ENOENT') {
|
|
142
|
-
const metadata = await lstat(lock).catch(() => undefined);
|
|
143
|
-
if (metadata?.isDirectory() && Date.now() - metadata.mtimeMs >= ttl) {
|
|
144
|
-
await settings.beforeEvict?.();
|
|
145
|
-
const stale = `${lock}.stale-${Math.floor(metadata.mtimeMs)}-${token()}`;
|
|
146
|
-
try {
|
|
147
|
-
await rename(lock, stale);
|
|
148
|
-
// A claimant may have published its owner while the eviction was pending.
|
|
149
|
-
const moved = await lstat(stale);
|
|
150
|
-
const ownerExists = await lstat(join(stale, 'owner')).then(() => true, (error) => code(error) !== 'ENOENT');
|
|
151
|
-
if (!ownerExists && moved.mtimeMs === metadata.mtimeMs) {
|
|
152
|
-
settings.afterEvict?.();
|
|
153
|
-
continue;
|
|
154
|
-
}
|
|
155
|
-
await rename(stale, lock).catch(() => { });
|
|
156
|
-
}
|
|
157
|
-
catch (error) {
|
|
158
|
-
if (!['ENOENT', 'EEXIST', 'ENOTEMPTY'].includes(code(error) ?? ''))
|
|
159
|
-
throw error;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
if (observed && Date.now() - observed.claimed_at_ms >= ttl) {
|
|
164
|
-
await settings.beforeEvict?.();
|
|
165
|
-
// The owner record remains because the quarantine is the ABA guard, not an audit log;
|
|
166
|
-
// bounded reclamation below is what makes that retention finite.
|
|
167
|
-
const stale = `${lock}.stale-${observed.claimed_at_ms}-${observed.nonce}`;
|
|
168
|
-
let renameError;
|
|
169
|
-
try {
|
|
170
|
-
await rename(lock, stale);
|
|
171
|
-
}
|
|
172
|
-
catch (error) {
|
|
173
|
-
renameError = error;
|
|
174
|
-
}
|
|
175
|
-
await settings.afterEvictRenameAttempt?.();
|
|
176
|
-
if (renameError === undefined) {
|
|
177
|
-
const moved = await readOwner(join(stale, 'owner')).catch(() => undefined);
|
|
178
|
-
if (moved?.nonce === observed.nonce && moved.claimed_at_ms === observed.claimed_at_ms) {
|
|
179
|
-
settings.afterEvict?.();
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
await rename(stale, lock).catch(() => { });
|
|
183
|
-
}
|
|
184
|
-
else if (!['ENOENT', 'EEXIST', 'ENOTEMPTY'].includes(code(renameError) ?? ''))
|
|
185
|
-
throw renameError;
|
|
186
|
-
}
|
|
187
|
-
if (Date.now() >= deadline)
|
|
188
|
-
throw code(ownerReadError) === 'owner_invalid' ? ownerReadError : lockError('lock_busy', 'manifest lock busy');
|
|
189
|
-
await sleep(Math.min(randomInt(retryMin, retryMax + 1), Math.max(1, deadline - Date.now())));
|
|
190
|
-
}
|
|
191
|
-
await reclaimStaleManifestLockQuarantines(path, ttl, deadline - started);
|
|
192
|
-
let renewal = Promise.resolve(), failed = false, stopped = false;
|
|
193
|
-
const timer = setInterval(() => { renewal = renewal.then(async () => { if (failed)
|
|
194
|
-
return; try {
|
|
195
|
-
const current = await readOwner(ownerPath);
|
|
196
|
-
if (current.nonce !== nonce || Date.now() - current.claimed_at_ms >= ttl)
|
|
197
|
-
throw new Error('lease lost');
|
|
198
|
-
await writeOwner(lock, { ...current, claimed_at_ms: Date.now() });
|
|
199
|
-
}
|
|
200
|
-
catch {
|
|
201
|
-
failed = true;
|
|
202
|
-
} }); }, renewEvery);
|
|
203
|
-
timer.unref?.();
|
|
204
|
-
const commit = async () => { if (!stopped) {
|
|
205
|
-
stopped = true;
|
|
206
|
-
clearInterval(timer);
|
|
207
|
-
await renewal;
|
|
208
|
-
} ; const current = await readOwner(ownerPath).catch(() => undefined); if (failed || !current || current.nonce !== nonce || Date.now() - current.claimed_at_ms >= ttl)
|
|
209
|
-
throw lockError('renewal_failed', 'manifest lock renewal failed; write aborted'); };
|
|
210
|
-
try {
|
|
211
|
-
const result = await fn(commit);
|
|
212
|
-
if (failed)
|
|
213
|
-
throw lockError('renewal_failed', 'manifest lock renewal failed; write aborted');
|
|
214
|
-
return result;
|
|
215
|
-
}
|
|
216
|
-
finally {
|
|
217
|
-
if (!stopped)
|
|
218
|
-
clearInterval(timer);
|
|
219
|
-
await renewal;
|
|
220
|
-
const current = await readOwner(ownerPath).catch(() => undefined);
|
|
221
|
-
if (!current || current.nonce !== nonce || Date.now() - current.claimed_at_ms >= ttl)
|
|
222
|
-
console.warn('manifest lock lease lost, not releasing', { path, tenant });
|
|
223
|
-
else {
|
|
224
|
-
const release = `${lock}.release-${nonce}`;
|
|
225
|
-
try {
|
|
226
|
-
await rename(lock, release);
|
|
227
|
-
const moved = await readOwner(join(release, 'owner')).catch(() => undefined);
|
|
228
|
-
if (!moved || moved.nonce !== nonce || Date.now() - moved.claimed_at_ms >= ttl) {
|
|
229
|
-
await rename(release, lock).catch(() => { });
|
|
230
|
-
console.warn('manifest lock lease lost, not releasing', { path, tenant });
|
|
231
|
-
}
|
|
232
|
-
else
|
|
233
|
-
await rm(release, { recursive: true, force: true });
|
|
234
|
-
}
|
|
235
|
-
catch {
|
|
236
|
-
console.warn('manifest lock lease lost, not releasing', { path, tenant });
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
async function readManifest(path) {
|
|
242
|
-
let metadata;
|
|
243
|
-
try {
|
|
244
|
-
metadata = await lstat(path);
|
|
245
|
-
}
|
|
246
|
-
catch (error) {
|
|
247
|
-
if (code(error) === 'ENOENT')
|
|
248
|
-
return { version: 1, providers: [] };
|
|
249
|
-
throw error;
|
|
250
|
-
}
|
|
251
|
-
if (metadata.isSymbolicLink() || !metadata.isFile())
|
|
252
|
-
throw new Error('handle file must be a regular file');
|
|
253
|
-
if (process.platform !== 'win32' && (metadata.mode & 0o777) !== HANDLE_FILE_CONTRACT.mode)
|
|
254
|
-
throw new Error('handle file mode must be exactly 0600');
|
|
255
|
-
const protection = await checkPath(path);
|
|
256
|
-
if (!protection.ok)
|
|
257
|
-
throw new Error(notPrivate('handle file folder', protection));
|
|
258
|
-
const source = await readFile(path);
|
|
259
|
-
if (source.byteLength > HANDLE_FILE_CONTRACT.maxBytes)
|
|
260
|
-
throw new Error('handle file exceeds 256 KiB');
|
|
261
|
-
return parseHandleFile(JSON.parse(source.toString('utf8')));
|
|
262
|
-
}
|
|
263
|
-
const foreign = (file, tenant) => file.providers.filter((provider) => provider.serve !== tenant).map((provider) => JSON.stringify(provider));
|
|
264
|
-
async function refuseWritableAncestors(parent) {
|
|
265
|
-
const protection = await checkPath(parent);
|
|
266
|
-
if (!protection.ok)
|
|
267
|
-
throw new Error(notPrivate('handle file folder', protection));
|
|
268
|
-
}
|
|
269
|
-
async function prepareParent(path) {
|
|
270
|
-
const parent = dirname(path);
|
|
271
|
-
// Check existing ancestors before creating directories under a replaceable tree.
|
|
272
|
-
if (process.platform !== 'win32')
|
|
273
|
-
await refuseWritableAncestors(parent);
|
|
274
|
-
const existing = await stat(parent).catch((error) => { if (code(error) !== 'ENOENT')
|
|
275
|
-
throw error; return undefined; });
|
|
276
|
-
if (existing && typeof process.getuid === 'function' && existing.uid !== process.getuid())
|
|
277
|
-
throw new Error('handle file parent is not owned by the current uid');
|
|
278
|
-
await createPrivateParents(parent);
|
|
279
|
-
const metadata = await stat(parent);
|
|
280
|
-
if (!metadata.isDirectory())
|
|
281
|
-
throw new Error('handle file parent must be a directory');
|
|
282
|
-
if (typeof process.getuid === 'function' && metadata.uid !== process.getuid())
|
|
283
|
-
throw new Error('handle file parent is not owned by the current uid');
|
|
284
|
-
await refuseWritableAncestors(parent);
|
|
285
|
-
}
|
|
286
|
-
async function writeAtomic(path, file, commit) {
|
|
287
|
-
const bytes = Buffer.from(JSON.stringify(file));
|
|
288
|
-
if (bytes.byteLength > HANDLE_FILE_CONTRACT.maxBytes)
|
|
289
|
-
throw new Error('handle file exceeds 256 KiB');
|
|
290
|
-
// Use the native basename so the temporary name cannot embed path separators.
|
|
291
|
-
const temporary = join(dirname(path), `.${pathBasename(path)}.${process.pid}.${token()}.tmp`);
|
|
292
|
-
let handle;
|
|
293
|
-
try {
|
|
294
|
-
await createPrivateFile(temporary, bytes);
|
|
295
|
-
handle = await open(temporary, 'r+');
|
|
296
|
-
await handle.sync();
|
|
297
|
-
await handle.close();
|
|
298
|
-
handle = undefined;
|
|
299
|
-
await settings.beforeManifestRename?.(`${path}.lock`);
|
|
300
|
-
await commit();
|
|
301
|
-
await rename(temporary, path);
|
|
302
|
-
}
|
|
303
|
-
finally {
|
|
304
|
-
await handle?.close().catch(() => { });
|
|
305
|
-
await unlink(temporary).catch(() => { });
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
async function writeHandleFileLocked(path, tenant, mutate) {
|
|
309
|
-
// Refuse before any path check, so a Windows caller gets the platform reason rather
|
|
310
|
-
// than a private-path refusal from the parent check that runs next.
|
|
311
|
-
if (process.platform === 'win32')
|
|
312
|
-
throw unsupportedOnWindows();
|
|
313
|
-
path = resolve(path);
|
|
314
|
-
await prepareParent(path);
|
|
315
|
-
await withLockCommit(path, tenant, async (commit) => {
|
|
316
|
-
const before = await readManifest(path), working = structuredClone(before), result = await mutate(working), next = parseHandleFile(result ?? working), beforeForeign = foreign(before, tenant);
|
|
317
|
-
if (JSON.stringify(foreign(next, tenant)) !== JSON.stringify(beforeForeign))
|
|
318
|
-
throw new Error('manifest mutation changed another tenant block');
|
|
319
|
-
await writeAtomic(path, next, commit);
|
|
320
|
-
if (process.platform !== 'win32' && ((await lstat(path)).mode & 0o777) !== HANDLE_FILE_CONTRACT.mode)
|
|
321
|
-
throw new Error('manifest readback mode is not 0600');
|
|
322
|
-
if (!(await checkPath(path)).ok)
|
|
323
|
-
throw new Error('manifest readback mode is not 0600');
|
|
324
|
-
const readback = await readManifest(path);
|
|
325
|
-
if (JSON.stringify(readback) !== JSON.stringify(next))
|
|
326
|
-
throw new Error('manifest readback differs');
|
|
327
|
-
if (JSON.stringify(foreign(readback, tenant)) !== JSON.stringify(beforeForeign))
|
|
328
|
-
throw new Error('manifest readback changed another tenant block');
|
|
329
|
-
});
|
|
330
|
-
}
|
|
331
|
-
return { withManifestLock, writeHandleFileLocked };
|
|
332
|
-
}
|
|
333
|
-
export const { withManifestLock, writeHandleFileLocked } = createManifestLock();
|