@cortexkit/claustrum-client 0.6.2 → 0.9.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
@@ -39,6 +39,32 @@ are exported for tenant plugins consuming the OpenCode handle manifest. The shar
39
39
  `HANDLE_FILE_CONTRACT` is pinned to `maxBytes: 262144`, `mode: 0o600`,
40
40
  `labelRe: /^[a-z0-9][a-z0-9._-]{0,63}$/`, and `handleRe: /^ckh_[A-Za-z0-9_-]{43}$/`.
41
41
 
42
+ ### Private files and Windows support
43
+
44
+ Enrollment tokens support Windows as well as Unix. Reading a handle manifest works on
45
+ Windows; writing one does not: the manifest lock's stale-lock eviction renames a lock
46
+ another process may hold open, which Windows refuses, so `withManifestLock` and
47
+ `writeHandleFileLocked` reject on Windows with code `unsupported_platform` rather than
48
+ risk two writers overlapping. The client uses `@cortexkit/store` 0.2.1 or newer: exclusive private creation
49
+ protects files before any secret bytes are written, and atomic replacement keeps
50
+ that protection. Unix files use mode 0600 and new directories use 0700. On Windows,
51
+ the store's `@cortexkit/private-files` native module creates owner-only ACLs and the
52
+ async path check verifies real security descriptors, not Node's emulated mode bits
53
+ or an AppData-location heuristic. Existing directories are checked, not silently
54
+ repaired. Unsupported filesystems or unavailable native modules refuse the operation.
55
+ Windows requires supported local, ACL-persisting drives; UNC/network paths and
56
+ reparse points are refused. Windows ACL enforcement can only be qualified in CI,
57
+ not by passing Unix tests locally.
58
+
59
+ **Changed in 0.9.0:** Unix ancestors must now be owned by the current effective user
60
+ or root. A foreign-owned ancestor is refused even at mode 0755; a non-root-owned
61
+ sticky ancestor is also refused. Put the files under a folder you own. Root-owned
62
+ sticky directories such as `/tmp` remain usable when the immediate child is your
63
+ own non-group/world-writable directory. The Debian user-private-group exception is
64
+ preserved: an owned, non-sticky group-writable directory (including a 0775
65
+ `~/.config` under umask 0002) is accepted only when local `/etc/passwd` and
66
+ `/etc/group` prove the effective group contains no other users.
67
+
42
68
  ## Testing a consumer of this client
43
69
 
44
70
  Three things on a developer box silently satisfy what a consumer's test is trying to
@@ -5,8 +5,8 @@ export type EnrollmentTokenFile = {
5
5
  /**
6
6
  * Atomically replace a consumer's enrollment credential.
7
7
  *
8
- * The file is always mode 0600. Writable canonical ancestors require sticky or a
9
- * proven user-private group. This is the same rule implemented by the
10
- * shipped `refuse_writable_ancestor` symbol in `opencode_files.rs`.
8
+ * Creation uses owner-only Unix permissions or native Windows ACLs before writing
9
+ * any bytes. @cortexkit/store checks that ancestors cannot be replaced by other
10
+ * users, and on Windows also verifies the completed parent's owner-only ACL.
11
11
  */
12
12
  export declare function writeEnrollmentTokenFile(path: string, value: EnrollmentTokenFile): Promise<void>;
@@ -1,7 +1,8 @@
1
- import { acceptsAncestor } from './ancestor-permissions.js';
1
+ import { createPrivateFile } from '@cortexkit/store';
2
+ import { checkPrivatePath, createPrivateParents, notPrivate, privateCreationCode } from './private-path.js';
2
3
  import { randomBytes } from 'node:crypto';
3
- import { chmod, mkdir, open, realpath, rename, stat, unlink } from 'node:fs/promises';
4
- import { basename, dirname, join } from 'node:path';
4
+ import { open, rename, unlink } from 'node:fs/promises';
5
+ import { basename, dirname, join, resolve } from 'node:path';
5
6
  const TOKEN_RE = /^[0-9a-f]{64}$/;
6
7
  function validateTokenFile(value) {
7
8
  if (!TOKEN_RE.test(value.token))
@@ -11,49 +12,43 @@ function validateTokenFile(value) {
11
12
  }
12
13
  }
13
14
  async function refuseWritableAncestor(parent) {
14
- let component;
15
- try {
16
- component = await realpath(parent);
17
- }
18
- catch {
19
- // The following file operation reports the precise filesystem error. A permissions
20
- // verdict about a path that could not be resolved would hide that useful failure.
21
- return;
22
- }
23
- for (;;) {
24
- const metadata = await stat(component).catch(() => undefined);
25
- // Canonicalisation already traversed this component. If a later stat races with a
26
- // rename, let the create/rename operation below report the concrete failure.
27
- if (metadata && !(await acceptsAncestor(metadata))) {
28
- throw new Error(`enrollment token ancestor ${component} is group- or world-writable without sticky bit`);
29
- }
30
- const next = dirname(component);
31
- if (next === component)
32
- return;
33
- component = next;
34
- }
15
+ const result = await checkPrivatePath(parent);
16
+ if (!result.ok)
17
+ throw new Error(notPrivate('enrollment token folder', result));
35
18
  }
36
19
  /**
37
20
  * Atomically replace a consumer's enrollment credential.
38
21
  *
39
- * The file is always mode 0600. Writable canonical ancestors require sticky or a
40
- * proven user-private group. This is the same rule implemented by the
41
- * shipped `refuse_writable_ancestor` symbol in `opencode_files.rs`.
22
+ * Creation uses owner-only Unix permissions or native Windows ACLs before writing
23
+ * any bytes. @cortexkit/store checks that ancestors cannot be replaced by other
24
+ * users, and on Windows also verifies the completed parent's owner-only ACL.
42
25
  */
43
26
  export async function writeEnrollmentTokenFile(path, value) {
44
27
  validateTokenFile(value);
28
+ path = resolve(path);
45
29
  const parent = dirname(path);
46
- await mkdir(parent, { recursive: true, mode: 0o700 });
30
+ // Native Windows creation rejects ancestors that other users can replace. A missing
31
+ // private parent can be created under a safe but non-private folder; afterwards the
32
+ // async check also requires the completed parent's protected owner-only ACL.
33
+ if (process.platform !== 'win32')
34
+ await refuseWritableAncestor(parent);
35
+ await createPrivateParents(parent);
47
36
  await refuseWritableAncestor(parent);
48
37
  const temporary = join(parent, `.${basename(path)}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`);
49
38
  let created = false;
50
39
  try {
51
- const descriptor = await open(temporary, 'wx', 0o600);
52
40
  created = true;
53
41
  try {
54
- await descriptor.writeFile(`${JSON.stringify(value)}\n`, 'utf8');
42
+ await createPrivateFile(temporary, `${JSON.stringify(value)}\n`);
43
+ }
44
+ catch (error) {
45
+ if (privateCreationCode(error) === 'EEXIST')
46
+ created = false;
47
+ throw error;
48
+ }
49
+ const descriptor = await open(temporary, 'r+');
50
+ try {
55
51
  await descriptor.sync();
56
- await chmod(temporary, 0o600);
57
52
  }
58
53
  finally {
59
54
  await descriptor.close();
package/dist/handles.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
- import { lstat as nodeLstat, open as nodeOpen, readFile, realpath, stat as nodeStat } from 'node:fs/promises';
3
+ import { lstat as nodeLstat, open as nodeOpen, readFile, stat as nodeStat } from 'node:fs/promises';
4
4
  import { userInfo } from 'node:os';
5
- import { dirname, join } from 'node:path';
6
- import { acceptsAncestor } from './ancestor-permissions.js';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { checkPrivatePath, notPrivate } from './private-path.js';
7
7
  export const HANDLE_FILE_CONTRACT = {
8
8
  maxBytes: 256 * 1024,
9
9
  mode: 0o600,
@@ -137,7 +137,7 @@ export function defaultHandleFilePath(env = process.env) {
137
137
  return join(configHome, 'cortexkit', 'opencode-handles.json');
138
138
  }
139
139
  function currentUid() {
140
- return process.getuid?.() ?? userInfo().uid;
140
+ return process.platform === 'win32' ? undefined : process.getuid?.() ?? userInfo().uid;
141
141
  }
142
142
  async function readBounded(descriptor, cap) {
143
143
  if (!descriptor.read)
@@ -153,6 +153,7 @@ async function readBounded(descriptor, cap) {
153
153
  return { buffer, bytes: total > cap ? -1 : total };
154
154
  }
155
155
  async function readHandleSnapshot(path = defaultHandleFilePath(), io = {}) {
156
+ path = resolve(path);
156
157
  const stat = io.stat ?? nodeStat;
157
158
  const lstat = io.lstat ?? nodeLstat;
158
159
  const read = io.readFile ?? readFile;
@@ -182,7 +183,7 @@ async function readHandleSnapshot(path = defaultHandleFilePath(), io = {}) {
182
183
  invalid('handle file must be a regular file');
183
184
  if ((metadata.size ?? 0) > HANDLE_FILE_CONTRACT.maxBytes)
184
185
  invalid('handle file exceeds 256 KiB');
185
- if ((metadata.mode & 0o777) !== HANDLE_FILE_CONTRACT.mode)
186
+ if (process.platform !== 'win32' && (metadata.mode & 0o777) !== HANDLE_FILE_CONTRACT.mode)
186
187
  invalid('handle file mode must be exactly 0600');
187
188
  const uid = io.currentUid ?? currentUid;
188
189
  const expectedUid = uid();
@@ -201,57 +202,18 @@ async function readHandleSnapshot(path = defaultHandleFilePath(), io = {}) {
201
202
  if (expectedUid !== undefined && parent.uid !== undefined && parent.uid !== expectedUid) {
202
203
  invalid('handle file parent is not owned by the current uid');
203
204
  }
204
- // Group-write counts as well as world-write: a directory writer can unlink and
205
- // replace a mode-0600 file regardless of its file permissions or owner. Owning
206
- // the directory does not exclude other group members. Sticky exempts both bits
207
- // because a writer may unlink only files they own. Without sticky, group-write
208
- // is safe only when local account files prove a user-private effective group;
209
- // world-write is never exempt. Keep this rule identical to Rust's accepts_ancestor
210
- // and parent check in opencode_files.rs.
211
- if (!(await acceptsAncestor(parent))) {
212
- invalid('handle file parent is group- or world-writable without sticky bit');
213
- }
214
- // EVERY ANCESTOR, NOT JUST THIS ONE, OR THE GUARANTEE DOES NOT COMPOSE. The immediate
215
- // parent being 0700 protects nothing when a directory above it is group-writable:
216
- // anyone who can create and unlink there renames it aside and substitutes their own
217
- // tree. Walk to '/' rather than $HOME -- a stopping point read from the environment is
218
- // attacker-influenceable and undefined when unset.
219
- //
220
- // realpath FIRST: an unresolved walk is defeated by a symlink component pointing
221
- // somewhere permissive, and every individual stat still passes while the loop is about
222
- // a path we never read through.
223
- //
224
- // Mirrors refuse_writable_ancestor in subc-transport 0.7.0 and in opencode_files.rs;
225
- // three implementations of one rule must not drift.
226
- let resolved;
227
- try {
228
- resolved = await realpath(dirname(path));
229
- }
230
- catch {
231
- // Unresolvable: the read that follows reports the real errno, and refusing here
232
- // would replace a precise failure with a permissions verdict about a path we could
233
- // not resolve.
234
- resolved = undefined;
235
- }
236
- if (resolved !== undefined) {
237
- let component = resolved;
238
- for (;;) {
239
- let ancestor;
240
- try {
241
- ancestor = await stat(component);
242
- }
243
- catch {
244
- ancestor = undefined;
245
- }
246
- if (ancestor && !(await acceptsAncestor(ancestor))) {
247
- invalid(`handle file ancestor ${component} is group- or world-writable without sticky bit`);
248
- }
249
- const next = dirname(component);
250
- if (next === component)
251
- break;
252
- component = next;
253
- }
254
- }
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), fixtureStat ? {
212
+ euid: expectedUid,
213
+ fs: { stat: fixtureStat, lstat: fixtureStat },
214
+ } : undefined);
215
+ if (!protection.ok)
216
+ invalid(notPrivate('handle file folder', protection));
255
217
  let source;
256
218
  try {
257
219
  if (descriptor) {
@@ -4,7 +4,7 @@ export declare const MANIFEST_LOCK: {
4
4
  renewEveryMs: number;
5
5
  ownerKeys: readonly ['tenant', 'pid', 'claimed_at_ms', 'nonce'];
6
6
  staleTargetRe: RegExp;
7
- errorCodes: readonly ['lock_busy', 'owner_invalid', 'renewal_failed'];
7
+ errorCodes: readonly ['lock_busy', 'owner_invalid', 'renewal_failed', 'unsupported_platform'];
8
8
  };
9
9
  /** Thrown by the lock. Branch on `code`; the message is diagnostic and may be reworded. */
10
10
  export type ManifestLockErrorCode = (typeof MANIFEST_LOCK.errorCodes)[number];
@@ -1,10 +1,10 @@
1
- import { acceptsAncestor } from './ancestor-permissions.js';
2
- import { constants as fsConstants } from 'node:fs';
3
- import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, unlink } from 'node:fs/promises';
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
4
  import { randomBytes, randomInt } from 'node:crypto';
5
- import { basename as pathBasename, dirname, join } from 'node:path';
5
+ import { basename as pathBasename, dirname, join, resolve } from 'node:path';
6
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'] };
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
8
  // The claim deadline bounds when the last stale-lock rename is issued, not when it lands:
9
9
  // a rename issued at deadline-1ms can complete afterwards, by tens to hundreds of ms on a
10
10
  // loaded host. The retry deadline and staleness window both read `ttl` today; if they split,
@@ -15,12 +15,13 @@ export function __setManifestLockTestOptions(options) { testOptions = options; }
15
15
  // 16 CSPRNG bytes: a collision needs both the same millisecond and the same nonce. Do not
16
16
  // simplify this to a counter, pid+timestamp, or a short token.
17
17
  const token = () => randomBytes(16).toString('base64url');
18
- const code = (error) => error?.code;
18
+ const code = privateCreationCode;
19
19
  const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));
20
20
  // A tenant classifying a failure must not string-match our prose: a copy-edit would
21
21
  // silently reclassify a retryable busy-lock as an unknown error, with nothing failing
22
22
  // loudly. The code is the contract; the message is free to change.
23
23
  const lockError = (code, message) => Object.assign(new Error(message), { code });
24
+ const unsupportedOnWindows = () => lockError('unsupported_platform', 'handle-file manifest locking is not supported on Windows');
24
25
  function parseOwner(source) {
25
26
  let value;
26
27
  try {
@@ -45,9 +46,8 @@ async function writeOwner(lock, owner) {
45
46
  const temporary = join(lock, `owner.${process.pid}.${token()}.tmp`);
46
47
  let file;
47
48
  try {
48
- file = await open(temporary, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600);
49
- await file.chmod(0o600);
50
- await file.writeFile(`${JSON.stringify(owner)}\n`);
49
+ await createPrivateFile(temporary, `${JSON.stringify(owner)}\n`);
50
+ file = await open(temporary, 'r+');
51
51
  await file.sync();
52
52
  await file.close();
53
53
  file = undefined;
@@ -104,11 +104,18 @@ export async function withManifestLock(path, tenant, fn) {
104
104
  return withLockCommit(path, tenant, async () => fn());
105
105
  }
106
106
  async function withLockCommit(path, tenant, fn) {
107
+ // Stale-lock eviction renames the lock directory while another process may hold it
108
+ // open. That is safe under POSIX rename; on Windows CI two evictors both won and a
109
+ // rename of an open lock failed with EPERM. Until the protocol is proven there,
110
+ // refuse rather than risk two writers overlapping on the shared handle file.
111
+ if (process.platform === 'win32')
112
+ throw unsupportedOnWindows();
113
+ path = resolve(path);
107
114
  const lock = `${path}.lock`, ownerPath = join(lock, 'owner'), ttl = testOptions?.ttlMs ?? MANIFEST_LOCK.ttlMs, renewEvery = testOptions?.renewEveryMs ?? MANIFEST_LOCK.renewEveryMs, retryMin = testOptions?.retryMinMs ?? 25, retryMax = testOptions?.retryMaxMs ?? 75;
108
115
  const nonce = token(), started = Date.now(), deadline = started + ttl;
109
116
  while (true) {
110
117
  try {
111
- await mkdir(lock, { mode: 0o700 });
118
+ await createPrivateDirectory(lock);
112
119
  await writeOwner(lock, { tenant, pid: process.pid, claimed_at_ms: Date.now(), nonce });
113
120
  await testOptions?.afterClaim?.();
114
121
  break;
@@ -241,8 +248,11 @@ async function readManifest(path) {
241
248
  }
242
249
  if (metadata.isSymbolicLink() || !metadata.isFile())
243
250
  throw new Error('handle file must be a regular file');
244
- if ((metadata.mode & 0o777) !== HANDLE_FILE_CONTRACT.mode)
251
+ if (process.platform !== 'win32' && (metadata.mode & 0o777) !== HANDLE_FILE_CONTRACT.mode)
245
252
  throw new Error('handle file mode must be exactly 0600');
253
+ const protection = await checkPrivatePath(path);
254
+ if (!protection.ok)
255
+ throw new Error(notPrivate('handle file folder', protection));
246
256
  const source = await readFile(path);
247
257
  if (source.byteLength > HANDLE_FILE_CONTRACT.maxBytes)
248
258
  throw new Error('handle file exceeds 256 KiB');
@@ -250,27 +260,9 @@ async function readManifest(path) {
250
260
  }
251
261
  const foreign = (file, tenant) => file.providers.filter((provider) => provider.serve !== tenant).map((provider) => JSON.stringify(provider));
252
262
  async function refuseWritableAncestors(parent) {
253
- let component;
254
- try {
255
- component = await realpath(parent);
256
- }
257
- catch (error) {
258
- if (code(error) !== 'ENOENT')
259
- throw error;
260
- const next = dirname(parent);
261
- if (next !== parent)
262
- await refuseWritableAncestors(next);
263
- return;
264
- }
265
- for (;;) {
266
- const metadata = await stat(component);
267
- if (!(await acceptsAncestor(metadata)))
268
- throw new Error('handle file ancestor is group- or world-writable without sticky bit');
269
- const next = dirname(component);
270
- if (next === component)
271
- return;
272
- component = next;
273
- }
263
+ const protection = await checkPrivatePath(parent);
264
+ if (!protection.ok)
265
+ throw new Error(notPrivate('handle file folder', protection));
274
266
  }
275
267
  async function prepareParent(path) {
276
268
  const parent = dirname(path);
@@ -281,14 +273,13 @@ async function prepareParent(path) {
281
273
  throw error; return undefined; });
282
274
  if (existing && typeof process.getuid === 'function' && existing.uid !== process.getuid())
283
275
  throw new Error('handle file parent is not owned by the current uid');
284
- await mkdir(parent, { recursive: true, mode: 0o700 });
276
+ await createPrivateParents(parent);
285
277
  const metadata = await stat(parent);
286
278
  if (!metadata.isDirectory())
287
279
  throw new Error('handle file parent must be a directory');
288
280
  if (typeof process.getuid === 'function' && metadata.uid !== process.getuid())
289
281
  throw new Error('handle file parent is not owned by the current uid');
290
- if (process.platform !== 'win32')
291
- await refuseWritableAncestors(parent);
282
+ await refuseWritableAncestors(parent);
292
283
  }
293
284
  async function writeAtomic(path, file, commit) {
294
285
  const bytes = Buffer.from(JSON.stringify(file));
@@ -303,13 +294,11 @@ async function writeAtomic(path, file, commit) {
303
294
  const temporary = join(dirname(path), `.${pathBasename(path)}.${process.pid}.${token()}.tmp`);
304
295
  let handle;
305
296
  try {
306
- handle = await open(temporary, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600);
307
- await handle.chmod(0o600);
308
- await handle.writeFile(bytes);
297
+ await createPrivateFile(temporary, bytes);
298
+ handle = await open(temporary, 'r+');
309
299
  await handle.sync();
310
300
  await handle.close();
311
301
  handle = undefined;
312
- await chmod(temporary, 0o600);
313
302
  await testOptions?.beforeManifestRename?.(`${path}.lock`);
314
303
  await commit();
315
304
  await rename(temporary, path);
@@ -320,13 +309,20 @@ async function writeAtomic(path, file, commit) {
320
309
  }
321
310
  }
322
311
  export async function writeHandleFileLocked(path, tenant, mutate) {
312
+ // Refuse before any path check, so a Windows caller gets the platform reason rather
313
+ // than a private-path refusal from the parent check that runs next.
314
+ if (process.platform === 'win32')
315
+ throw unsupportedOnWindows();
316
+ path = resolve(path);
323
317
  await prepareParent(path);
324
318
  await withLockCommit(path, tenant, async (commit) => {
325
319
  const before = await readManifest(path), working = structuredClone(before), result = await mutate(working), next = parseHandleFile(result ?? working), beforeForeign = foreign(before, tenant);
326
320
  if (JSON.stringify(foreign(next, tenant)) !== JSON.stringify(beforeForeign))
327
321
  throw new Error('manifest mutation changed another tenant block');
328
322
  await writeAtomic(path, next, commit);
329
- if (((await lstat(path)).mode & 0o777) !== HANDLE_FILE_CONTRACT.mode)
323
+ if (process.platform !== 'win32' && ((await lstat(path)).mode & 0o777) !== HANDLE_FILE_CONTRACT.mode)
324
+ throw new Error('manifest readback mode is not 0600');
325
+ if (!(await checkPrivatePath(path)).ok)
330
326
  throw new Error('manifest readback mode is not 0600');
331
327
  const readback = await readManifest(path);
332
328
  if (JSON.stringify(readback) !== JSON.stringify(next))
@@ -0,0 +1,16 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { type OwnerOnlyPathAsyncOptions, type OwnerOnlyPathResult } from '@cortexkit/store';
3
+ export declare const privatePathContext: AsyncLocalStorage<OwnerOnlyPathAsyncOptions>;
4
+ export declare const checkPrivatePath: (path: string, options?: OwnerOnlyPathAsyncOptions) => Promise<OwnerOnlyPathResult>;
5
+ /**
6
+ * The refusal text for a path the store judged not private. It names the folder and the
7
+ * store's own reason (foreign owner, world-writable, a sticky folder without an
8
+ * owner-only child, a Windows ACL problem, ...), so the operator sees the actual cause.
9
+ */
10
+ export declare function notPrivate(subject: string, result: Extract<OwnerOnlyPathResult, {
11
+ ok: false;
12
+ }>): string;
13
+ /** Native creation errors use Win32 numbers rather than Node's exclusive-create codes. */
14
+ export declare function privateCreationCode(error: unknown): string | undefined;
15
+ /** Create missing components privately without repairing permissions on existing directories. */
16
+ export declare function createPrivateParents(path: string): Promise<void>;
@@ -0,0 +1,51 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { stat } from 'node:fs/promises';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { checkOwnerOnlyPathAsync, createPrivateDirectory } from '@cortexkit/store';
5
+ // Tests run the ownership check against fixture uids and account files through this
6
+ // context, isolated per concurrent test. It is internal: the package `exports` map
7
+ // exposes only the index, so consumers cannot reach it.
8
+ export const privatePathContext = new AsyncLocalStorage();
9
+ export const checkPrivatePath = (path, options) => checkOwnerOnlyPathAsync(resolve(path), privatePathContext.getStore() ?? options);
10
+ /**
11
+ * The refusal text for a path the store judged not private. It names the folder and the
12
+ * store's own reason (foreign owner, world-writable, a sticky folder without an
13
+ * owner-only child, a Windows ACL problem, ...), so the operator sees the actual cause.
14
+ */
15
+ export function notPrivate(subject, result) {
16
+ return `${subject} ${result.path} is not private: ${result.reason}`;
17
+ }
18
+ /** Native creation errors use Win32 numbers rather than Node's exclusive-create codes. */
19
+ export function privateCreationCode(error) {
20
+ const value = error;
21
+ if (value?.code === 'ERR_PRIVATE_FILES_SECURITY' && value.reason === 'io-error') {
22
+ if (value.win32Code === 80 || value.win32Code === 183)
23
+ return 'EEXIST';
24
+ if (value.win32Code === 2 || value.win32Code === 3)
25
+ return 'ENOENT';
26
+ }
27
+ return value?.code;
28
+ }
29
+ /** Create missing components privately without repairing permissions on existing directories. */
30
+ export async function createPrivateParents(path) {
31
+ try {
32
+ await stat(path);
33
+ return;
34
+ }
35
+ catch (error) {
36
+ if (error.code !== 'ENOENT')
37
+ throw error;
38
+ }
39
+ const parent = dirname(path);
40
+ if (parent !== path)
41
+ await createPrivateParents(parent);
42
+ try {
43
+ await createPrivateDirectory(path);
44
+ }
45
+ catch (error) {
46
+ if (privateCreationCode(error) !== 'EEXIST')
47
+ throw error;
48
+ if (!(await stat(path)).isDirectory())
49
+ throw error;
50
+ }
51
+ }
package/dist/wire.d.ts CHANGED
@@ -212,6 +212,13 @@ export interface ScopedInventoryRow {
212
212
  * which is also what an unmapped credential reports.
213
213
  */
214
214
  readonly providerIds: readonly string[];
215
+ /**
216
+ * Subscription tier to price this account by; absent means unknown. An operator's
217
+ * `ck auth set-plan` value takes precedence over the tier detected from the provider;
218
+ * `planTierSource` says which one this is.
219
+ */
220
+ readonly planTier?: string;
221
+ readonly planTierSource?: 'operator' | 'detected';
215
222
  readonly authMethod?: 'apikey' | 'chatgpt' | 'antigravity' | 'oauth';
216
223
  /**
217
224
  * Which provider protocol this credential speaks. Absent for static keys.
package/dist/wire.js CHANGED
@@ -401,7 +401,16 @@ export function decodeScopedInventory(response, logUnknownClass) {
401
401
  : undefined;
402
402
  if (createdAtMs === undefined)
403
403
  throw asCredentialError(response, 'invalid_response', logUnknownClass);
404
+ // A negative end assertion refuses trailing newlines too; JavaScript's $ accepts one.
405
+ const planTier = row.plan_tier;
406
+ if (planTier !== undefined && (typeof planTier !== 'string' || !/^[a-z][a-z0-9_]{0,31}(?![\s\S])/.test(planTier))) {
407
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
408
+ }
404
409
  const authMethod = row.auth_method;
410
+ const planTierSource = row.plan_tier_source;
411
+ if (planTierSource !== undefined && planTierSource !== 'operator' && planTierSource !== 'detected') {
412
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
413
+ }
405
414
  if (authMethod !== undefined &&
406
415
  authMethod !== 'apikey' && authMethod !== 'chatgpt' &&
407
416
  authMethod !== 'antigravity' && authMethod !== 'oauth') {
@@ -424,6 +433,8 @@ export function decodeScopedInventory(response, logUnknownClass) {
424
433
  // no ids set. A present non-array is a malformed reply and still refuses.
425
434
  providerIds: row.provider_ids === undefined ? [] : strings(row.provider_ids),
426
435
  authMethod,
436
+ planTier,
437
+ planTierSource,
427
438
  state: row.state,
428
439
  refreshAdapter: row.refresh_adapter,
429
440
  recordVersion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/claustrum-client",
3
- "version": "0.6.2",
3
+ "version": "0.9.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -27,6 +27,7 @@
27
27
  "typecheck": "tsc -p tsconfig.json --noEmit"
28
28
  },
29
29
  "dependencies": {
30
+ "@cortexkit/store": "^0.2.1",
30
31
  "@cortexkit/subc-client": "^0.16.0"
31
32
  }
32
33
  }
@@ -1,20 +0,0 @@
1
- import { AsyncLocalStorage } from 'node:async_hooks';
2
- export type AncestorIdentity = {
3
- euid: number | undefined;
4
- egid: number | undefined;
5
- passwdPath: string;
6
- groupPath: string;
7
- };
8
- export declare const ancestorIdentityContext: AsyncLocalStorage<AncestorIdentity>;
9
- /**
10
- * Debian OpenSSH's user-group-modes rule: ancestors without group/world-write, or with sticky, are safe.
11
- * Without sticky, group-write requires effective uid/gid ownership and local passwd/group
12
- * files proving the group contains only this user, with no other primary-group users.
13
- * World-write never qualifies; anything the local account files cannot prove refuses.
14
- * The twin accepts_ancestor in opencode_files.rs must keep the identical permission rule.
15
- */
16
- export declare function acceptsAncestor(metadata: {
17
- mode: number;
18
- uid?: number;
19
- gid?: number;
20
- }, identity?: AncestorIdentity): Promise<boolean>;
@@ -1,44 +0,0 @@
1
- import { AsyncLocalStorage } from 'node:async_hooks';
2
- import { readFile } from 'node:fs/promises';
3
- export const ancestorIdentityContext = new AsyncLocalStorage();
4
- function records(source, count) {
5
- return source.split('\n').filter((line) => line.trim() !== '' && !line.trimStart().startsWith('#')).map((line) => {
6
- const fields = line.split(':');
7
- if (fields.length !== count || !fields[0] || /\s/.test(fields[0]) || !/^\d+$/.test(fields[2]) || !Number.isSafeInteger(Number(fields[2])) || Number(fields[2]) > 0xffffffff || (count === 7 && (!/^\d+$/.test(fields[3]) || !Number.isSafeInteger(Number(fields[3])) || Number(fields[3]) > 0xffffffff))) {
8
- throw new Error('malformed account record');
9
- }
10
- if (count === 4 && fields[3] !== '' && fields[3].split(',').some((name) => name === '' || /\s/.test(name)))
11
- throw new Error('malformed group members');
12
- return fields;
13
- });
14
- }
15
- /**
16
- * Debian OpenSSH's user-group-modes rule: ancestors without group/world-write, or with sticky, are safe.
17
- * Without sticky, group-write requires effective uid/gid ownership and local passwd/group
18
- * files proving the group contains only this user, with no other primary-group users.
19
- * World-write never qualifies; anything the local account files cannot prove refuses.
20
- * The twin accepts_ancestor in opencode_files.rs must keep the identical permission rule.
21
- */
22
- export async function acceptsAncestor(metadata, identity = ancestorIdentityContext.getStore() ?? {
23
- euid: process.geteuid?.(), egid: process.getegid?.(), passwdPath: '/etc/passwd', groupPath: '/etc/group',
24
- }) {
25
- if ((metadata.mode & 0o022) === 0 || (metadata.mode & 0o1000) !== 0)
26
- return true;
27
- if ((metadata.mode & 0o002) !== 0 || identity.euid === undefined || identity.egid === undefined || metadata.uid !== identity.euid || metadata.gid !== identity.egid)
28
- return false;
29
- try {
30
- const passwd = records(new TextDecoder('utf-8', { fatal: true }).decode(await readFile(identity.passwdPath)), 7);
31
- const groups = records(new TextDecoder('utf-8', { fatal: true }).decode(await readFile(identity.groupPath)), 4);
32
- const users = passwd.filter((entry) => Number(entry[2]) === identity.euid);
33
- const matchingGroups = groups.filter((entry) => Number(entry[2]) === identity.egid);
34
- if (users.length !== 1 || matchingGroups.length !== 1)
35
- return false;
36
- const name = users[0][0];
37
- return (matchingGroups[0][3] === '' || matchingGroups[0][3].split(',').every((member) => member === name)) &&
38
- !passwd.some((entry) => Number(entry[2]) !== identity.euid && Number(entry[3]) === identity.egid);
39
- }
40
- catch {
41
- // Local files must prove exclusivity; unavailable or malformed NSS data cannot grant an exception.
42
- return false;
43
- }
44
- }