@cortexkit/claustrum-client 0.14.0 → 0.16.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 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
- `SUBC_MODULE_ID` and `SUBC_LAUNCH_NONCE` cannot impersonate a supervising host.
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,26 @@ 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
+ **Changed in 0.16.0:** the dependency is now `@cortexkit/subc-client` ^0.24.0.
80
+ The SDK negotiates FIFO request/watch windows and retries only daemon-origin
81
+ `route_busy` refusals within the request deadline. Claustrum adds no busy retry;
82
+ older daemons keep the SDK's previous behavior.
83
+
84
+ ## Reading credential material
85
+
86
+ The wire payload is opaque bytes. `getCredential(handle, minTtlMs?)` and
87
+ `getScoped({ credentialId, enrollmentToken?, minTtlMs? })` keep their text results
88
+ (`ServedCredential.material: string`) and require valid UTF-8. They refuse a
89
+ non-UTF-8 payload with `invalid_response`; this does not mean the stored credential
90
+ is corrupt.
91
+
92
+ Use `getCredentialBytes(handle, minTtlMs?)` or
93
+ `getScopedBytes({ credentialId, enrollmentToken?, minTtlMs? })` for binary cookies
94
+ or any material that may not be UTF-8. Both return `ServedCredentialBytes`, with
95
+ `material: Uint8Array` preserving every payload byte and the same metadata, grant
96
+ checks, and TTL semantics as their text counterparts. Neither byte method performs
97
+ text decoding.
98
+
72
99
  ## Watching for changes
73
100
 
74
101
  Since 0.12.0, `client.watchScoped(onNotice, { enrollmentToken? })` holds an
@@ -139,20 +166,37 @@ for (;;) {
139
166
  }
140
167
  ```
141
168
 
142
- ## OpenCode handle-file exports
169
+ ## Enrollment
170
+
171
+ Use `newEnrollmentSecret()` to prepare an enrollment proposal. It generates 32
172
+ random bytes as exactly 64 lowercase hex characters, plus `requestSecretHash`:
173
+ the lowercase hex SHA-256 of the **32 decoded bytes**, not of the UTF-8 hex text.
174
+ `enrollmentSecretHash(requestSecret)` recomputes the hash of a persisted secret
175
+ and refuses anything other than exactly 64 lowercase hex characters.
176
+
177
+ ```ts
178
+ import { ClaustrumClient, newEnrollmentSecret } from '@cortexkit/claustrum-client'
179
+
180
+ const client = await ClaustrumClient.connect()
181
+ const { requestSecret, requestSecretHash } = newEnrollmentSecret()
182
+ // Persist the secret privately before proposing so a crash can recover the poll.
183
+ await persistRequestSecret(requestSecret)
184
+ const { requestId } = await client.enrollPropose({ name: 'consumer', requestSecretHash })
185
+ await persistRequestId(requestId)
186
+ // After operator approval, poll with the original secret, not its hash.
187
+ const outcome = await client.enrollPoll({ requestId, requestSecret })
188
+ ```
143
189
 
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}$/`.
190
+ The persistence functions above are caller-provided. Re-proposing the same name
191
+ with the same persisted secret/hash recovers the request id after a lost reply.
192
+ Only `pending` means poll again; an approved outcome returns the token once, so
193
+ persist it with `writeEnrollmentTokenFile` before using it. Never log the request
194
+ secret or token.
148
195
 
149
- ### Private files and Windows support
196
+ ## Private files and Windows support
150
197
 
151
- Enrollment tokens support Windows as well as Unix. Reading a handle manifest works on
152
- Windows; writing one does not: the manifest lock's stale-lock eviction renames a lock
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
198
+ Enrollment tokens support Windows as well as Unix. The client uses
199
+ `@cortexkit/store` 0.2.3 or newer: exclusive private creation
156
200
  protects files before any secret bytes are written, and atomic replacement keeps
157
201
  that protection. Unix files use mode 0600 and new directories use 0700. On Windows,
158
202
  the store's `@cortexkit/private-files` native module creates owner-only ACLs and the
@@ -174,7 +218,7 @@ preserved: an owned, non-sticky group-writable directory (including a 0775
174
218
 
175
219
  ## Testing a consumer of this client
176
220
 
177
- Three things on a developer box silently satisfy what a consumer's test is trying to
221
+ Two things on a developer box silently satisfy what a consumer's test is trying to
178
222
  prove. Each was found by a consumer shipping green and failing elsewhere.
179
223
 
180
224
  • **A connection file at the default path is a host fact, not a fixture.** If the vault
@@ -182,9 +226,6 @@ prove. Each was found by a consumer shipping green and failing elsewhere.
182
226
  up, so a suite can pass on the daemon's real socket and fail anywhere without one.
183
227
  Force it absent (`CLAUSTRUM_SUBC_CONNECTION=/nonexistent/x.json`, and clear
184
228
  `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
229
  • **A bare `bun` run is not an oracle for module resolution.** The Bun CLI auto-installs
189
230
  a public dependency it cannot resolve; a compiled binary does not. All readings below
190
231
  are bun 1.3.14, same module, no `node_modules` — a toolchain behaviour with no version
@@ -215,5 +256,5 @@ prove. Each was found by a consumer shipping green and failing elsewhere.
215
256
  result: a freshly linked unsigned binary is SIGKILLed, and `rc=137` with empty stderr
216
257
  reads as a resolution failure while being a signature one.
217
258
 
218
- The shape is the same in all three: the producing machine supplies the thing under test.
259
+ The shape is the same in both: the producing machine supplies the thing under test.
219
260
  A passing check on it is evidence only when the ambient supply is removed first.
@@ -3,5 +3,12 @@ export type EnrollmentTokenFile = {
3
3
  token: string;
4
4
  token_generation: number;
5
5
  };
6
+ /** Hash the 32 decoded bytes, not the UTF-8 text of the hex request secret. */
7
+ export declare function enrollmentSecretHash(requestSecret: string): string;
8
+ /** Mint an enrollment request secret and the hash to send with the proposal. */
9
+ export declare function newEnrollmentSecret(): {
10
+ requestSecret: string;
11
+ requestSecretHash: string;
12
+ };
6
13
  export declare function createEnrollmentWriter(checkPath?: typeof checkPrivatePath): (path: string, value: EnrollmentTokenFile) => Promise<void>;
7
14
  export declare const writeEnrollmentTokenFile: (path: string, value: EnrollmentTokenFile) => Promise<void>;
@@ -1,9 +1,21 @@
1
1
  import { createPrivateFile } from '@cortexkit/store';
2
2
  import { checkPrivatePath, createPrivateParents, notPrivate, privateCreationCode } from './private-path.js';
3
- import { randomBytes } from 'node:crypto';
3
+ import { createHash, randomBytes } from 'node:crypto';
4
4
  import { open, rename, unlink } from 'node:fs/promises';
5
5
  import { basename, dirname, join, resolve } from 'node:path';
6
6
  const TOKEN_RE = /^[0-9a-f]{64}$/;
7
+ /** Hash the 32 decoded bytes, not the UTF-8 text of the hex request secret. */
8
+ export function enrollmentSecretHash(requestSecret) {
9
+ if (typeof requestSecret !== 'string' || requestSecret.length !== 64 || !TOKEN_RE.test(requestSecret)) {
10
+ throw new Error('enrollment request secret must be exactly 64 lowercase hex characters');
11
+ }
12
+ return createHash('sha256').update(Buffer.from(requestSecret, 'hex')).digest('hex');
13
+ }
14
+ /** Mint an enrollment request secret and the hash to send with the proposal. */
15
+ export function newEnrollmentSecret() {
16
+ const requestSecret = randomBytes(32).toString('hex');
17
+ return { requestSecret, requestSecretHash: enrollmentSecretHash(requestSecret) };
18
+ }
7
19
  function validateTokenFile(value) {
8
20
  if (!TOKEN_RE.test(value.token))
9
21
  throw new Error('enrollment token must be exactly 64 lowercase hex characters');
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
- 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';
3
+ export { enrollmentSecretHash, newEnrollmentSecret, writeEnrollmentTokenFile, type EnrollmentTokenFile, } from './enrollment.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
- export { writeEnrollmentTokenFile, } from './enrollment.js';
4
- export { MANIFEST_LOCK, manifestLockQuarantinePrefix, withManifestLock, writeHandleFileLocked, } from './manifest-lock.js';
3
+ export { enrollmentSecretHash, newEnrollmentSecret, writeEnrollmentTokenFile, } from './enrollment.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
- * account-scoped routing joins `accountId` with `recordVersion`.
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-scoped routing joins this
41
- * value with `recordVersion`; it is neither the operator's credential label nor the bearer handle.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/claustrum-client",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -27,7 +27,7 @@
27
27
  "typecheck": "tsc -p tsconfig.json --noEmit"
28
28
  },
29
29
  "dependencies": {
30
- "@cortexkit/store": "^0.2.1",
31
- "@cortexkit/subc-client": "^0.22.0"
30
+ "@cortexkit/store": "^0.2.3",
31
+ "@cortexkit/subc-client": "^0.24.0"
32
32
  }
33
33
  }
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
- }
@@ -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 {};
@@ -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();