borgmcp 2.0.2 → 2.0.3

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.
Files changed (44) hide show
  1. package/README.md +16 -10
  2. package/dist/claude.d.ts +1 -0
  3. package/dist/claude.d.ts.map +1 -1
  4. package/dist/claude.js +5 -0
  5. package/dist/claude.js.map +1 -1
  6. package/dist/cli-help.d.ts +2 -0
  7. package/dist/cli-help.d.ts.map +1 -1
  8. package/dist/cli-help.js +12 -0
  9. package/dist/cli-help.js.map +1 -1
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/config.js +41 -18
  12. package/dist/config.js.map +1 -1
  13. package/dist/credential-paths.d.ts +3 -0
  14. package/dist/credential-paths.d.ts.map +1 -0
  15. package/dist/credential-paths.js +7 -0
  16. package/dist/credential-paths.js.map +1 -0
  17. package/dist/seat-store.d.ts +11 -5
  18. package/dist/seat-store.d.ts.map +1 -1
  19. package/dist/seat-store.js +147 -14
  20. package/dist/seat-store.js.map +1 -1
  21. package/dist/server-facade.d.ts +50 -0
  22. package/dist/server-facade.d.ts.map +1 -0
  23. package/dist/server-facade.js +126 -0
  24. package/dist/server-facade.js.map +1 -0
  25. package/dist/token-store.d.ts +2 -1
  26. package/dist/token-store.d.ts.map +1 -1
  27. package/dist/token-store.js +5 -3
  28. package/dist/token-store.js.map +1 -1
  29. package/dist/unknown-subcommand.d.ts +1 -1
  30. package/dist/unknown-subcommand.d.ts.map +1 -1
  31. package/dist/unknown-subcommand.js +1 -0
  32. package/dist/unknown-subcommand.js.map +1 -1
  33. package/docs/EXTRACTION_PROVENANCE.md +6 -5
  34. package/docs/LOCAL_SERVER.md +81 -42
  35. package/docs/RELEASING.md +10 -1
  36. package/package.json +1 -1
  37. package/src/claude.ts +5 -0
  38. package/src/cli-help.ts +15 -0
  39. package/src/config.ts +40 -18
  40. package/src/credential-paths.ts +8 -0
  41. package/src/seat-store.ts +159 -16
  42. package/src/server-facade.ts +192 -0
  43. package/src/token-store.ts +9 -4
  44. package/src/unknown-subcommand.ts +1 -0
package/src/seat-store.ts CHANGED
@@ -13,7 +13,9 @@
13
13
  * 2. ATOMIC RENAME — temp in the SAME dir/fs, created 0600, fsync THEN rename;
14
14
  * a crash never leaves a torn/readable partial, and the temp is cleaned up
15
15
  * on any write failure.
16
- * 3. PARENT DIR 0700 created with mode 0700.
16
+ * 3. PARENT DIR — private stores require 0700. The canonical ~/.borg parent
17
+ * may explicitly use the owner-controlled policy (0755 accepted, 0022
18
+ * forbidden) without rewriting its established mode.
17
19
  * 4. flock DISCIPLINE — a single advisory lock (O_EXCL lockfile), RULED option
18
20
  * (b) (Coordinator cca6957a): NO automatic reclaim, EVER. Acquire is an atomic
19
21
  * `open(lockPath,'wx',0o600)`; the whole read-compare-write runs inside ONE
@@ -29,8 +31,9 @@
29
31
  * callers keeps the raw bearer from leaving the store owner.
30
32
  */
31
33
 
32
- import { open, link, mkdir, readFile, rename, stat, unlink } from 'node:fs/promises';
33
- import { dirname } from 'node:path';
34
+ import { constants } from 'node:fs';
35
+ import { open, link, lstat, mkdir, readFile, realpath, rename, stat, unlink } from 'node:fs/promises';
36
+ import { dirname, isAbsolute, resolve } from 'node:path';
34
37
  import { randomBytes } from 'node:crypto';
35
38
 
36
39
  const LOCK_WAIT_MS = 10;
@@ -42,6 +45,78 @@ interface LockPayload {
42
45
  startTime: string;
43
46
  }
44
47
 
48
+ export interface SecureStoreOptions {
49
+ secureRoot?: string;
50
+ rootMode?: 'private' | 'owner-controlled';
51
+ }
52
+
53
+ function expectedUid(): number | null {
54
+ return typeof process.getuid === 'function' ? process.getuid() : null;
55
+ }
56
+
57
+ async function assertSecureRoot(
58
+ root: string,
59
+ rootMode: SecureStoreOptions['rootMode'] = 'private',
60
+ ): Promise<void> {
61
+ if (!isAbsolute(root) || resolve(root) !== root) {
62
+ throw new Error(`Borg credential store path ${root} is not canonical`);
63
+ }
64
+ let metadata: Awaited<ReturnType<typeof lstat>>;
65
+ try {
66
+ metadata = await lstat(root);
67
+ } catch (error) {
68
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
69
+ await mkdir(root, { recursive: true, mode: 0o700 });
70
+ metadata = await lstat(root);
71
+ }
72
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
73
+ throw new Error(`Borg credential store root ${root} must be a real directory, not a symlink`);
74
+ }
75
+ const mode = metadata.mode & 0o777;
76
+ if (rootMode === 'private' ? mode !== 0o700 : (mode & 0o022) !== 0) {
77
+ throw new Error(
78
+ `Borg credential store root ${root} has insecure permissions; ` +
79
+ (rootMode === 'private' ? 'expected 0700' : 'group/world write access is forbidden'),
80
+ );
81
+ }
82
+ const uid = expectedUid();
83
+ if (uid !== null && metadata.uid !== uid) {
84
+ throw new Error(`Borg credential store root ${root} is not owned by the current user`);
85
+ }
86
+ if (await realpath(root) !== root) {
87
+ throw new Error(`Borg credential store root ${root} is not canonical or contains a symlink`);
88
+ }
89
+ }
90
+
91
+ async function assertSecureFile(filePath: string): Promise<Awaited<ReturnType<typeof lstat>> | null> {
92
+ let metadata: Awaited<ReturnType<typeof lstat>>;
93
+ try {
94
+ metadata = await lstat(filePath);
95
+ } catch (error) {
96
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
97
+ throw error;
98
+ }
99
+ if (metadata.isSymbolicLink() || !metadata.isFile()) {
100
+ throw new Error(`Borg credential store file ${filePath} must be a regular file, not a symlink`);
101
+ }
102
+ if ((metadata.mode & 0o777) !== 0o600) {
103
+ throw new Error(`Borg credential store file ${filePath} has insecure permissions; expected 0600`);
104
+ }
105
+ const uid = expectedUid();
106
+ if (uid !== null && metadata.uid !== uid) {
107
+ throw new Error(`Borg credential store file ${filePath} is not owned by the current user`);
108
+ }
109
+ return metadata;
110
+ }
111
+
112
+ async function assertSecurePath(filePath: string, options: SecureStoreOptions): Promise<void> {
113
+ const secureRoot = options.secureRoot!;
114
+ if (!isAbsolute(filePath) || resolve(filePath) !== filePath || dirname(filePath) !== secureRoot) {
115
+ throw new Error(`Borg credential store file path ${filePath} is not canonical`);
116
+ }
117
+ await assertSecureRoot(secureRoot, options.rootMode);
118
+ }
119
+
45
120
  /**
46
121
  * Liveness check for the alive/dead branch (RULED option b). `process.kill(pid, 0)`
47
122
  * sends no signal but validates the target: ESRCH ⇒ no such process (DEAD → fail
@@ -103,20 +178,30 @@ function staleLockError(lockPath: string, held: LockPayload | null): Error {
103
178
  * beneath it.
104
179
  */
105
180
  async function ensureParentDir(filePath: string): Promise<void> {
181
+ if (!isAbsolute(filePath) || resolve(filePath) !== filePath) {
182
+ throw new Error(`Borg store file path ${filePath} is not canonical`);
183
+ }
106
184
  const dir = dirname(filePath);
107
- let existing: Awaited<ReturnType<typeof stat>> | null = null;
185
+ let existing: Awaited<ReturnType<typeof lstat>> | null = null;
108
186
  try {
109
- existing = await stat(dir);
187
+ existing = await lstat(dir);
110
188
  } catch (err) {
111
189
  if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
112
190
  }
113
191
  if (existing) {
192
+ if (existing.isSymbolicLink() || !existing.isDirectory()) {
193
+ throw new Error(`Borg store directory ${dir} must be a real directory, not a symlink`);
194
+ }
114
195
  if ((existing.mode & 0o777) !== 0o700) {
115
196
  throw new Error(
116
197
  `Borg store directory ${dir} has insecure permissions ` +
117
198
  `(0${(existing.mode & 0o777).toString(8)}, expected 0700); refusing to write a credential under it`,
118
199
  );
119
200
  }
201
+ const uid = expectedUid();
202
+ if (uid !== null && existing.uid !== uid) {
203
+ throw new Error(`Borg store directory ${dir} is not owned by the current user`);
204
+ }
120
205
  return;
121
206
  }
122
207
  await mkdir(dir, { recursive: true, mode: 0o700 });
@@ -128,8 +213,14 @@ async function ensureParentDir(filePath: string): Promise<void> {
128
213
  * rename over the target (atomic; the mode carries across). A failed write
129
214
  * cleans up the temp so no leftover file ever holds the secret.
130
215
  */
131
- export async function atomicWrite0600(filePath: string, data: string): Promise<void> {
132
- await ensureParentDir(filePath);
216
+ export async function atomicWrite0600(
217
+ filePath: string,
218
+ data: string,
219
+ options: SecureStoreOptions = {},
220
+ ): Promise<void> {
221
+ if (options.secureRoot) await assertSecurePath(filePath, options);
222
+ else await ensureParentDir(filePath);
223
+ if (options.secureRoot) await assertSecureFile(filePath);
133
224
  const tmp = `${filePath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
134
225
  const handle = await open(tmp, 'wx', 0o600);
135
226
  try {
@@ -142,6 +233,10 @@ export async function atomicWrite0600(filePath: string, data: string): Promise<v
142
233
  }
143
234
  await handle.close();
144
235
  try {
236
+ if (options.secureRoot) {
237
+ await assertSecurePath(filePath, options);
238
+ await assertSecureFile(filePath);
239
+ }
145
240
  await rename(tmp, filePath);
146
241
  } catch (err) {
147
242
  await unlink(tmp).catch(() => {});
@@ -187,14 +282,53 @@ async function assertSecureStorePerms(
187
282
  * perms are enforced BEFORE the bytes are read (CR#2) — a loosely-permissioned
188
283
  * secret fails closed and is never read.
189
284
  */
190
- export async function readStoreFile(filePath: string): Promise<string | null> {
191
- let fileStat: Awaited<ReturnType<typeof stat>>;
285
+ export async function readStoreFile(
286
+ filePath: string,
287
+ options: SecureStoreOptions = {},
288
+ ): Promise<string | null> {
289
+ if (!isAbsolute(filePath) || resolve(filePath) !== filePath) {
290
+ throw new Error(`Borg store file path ${filePath} is not canonical`);
291
+ }
292
+ if (options.secureRoot) await assertSecurePath(filePath, options);
293
+ if (options.secureRoot) {
294
+ const before = await assertSecureFile(filePath);
295
+ if (!before) return null;
296
+ let handle;
297
+ try {
298
+ handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
299
+ } catch (error) {
300
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
301
+ throw error;
302
+ }
303
+ try {
304
+ const opened = await handle.stat();
305
+ if (opened.dev !== before.dev || opened.ino !== before.ino) {
306
+ throw new Error('Borg credential store file changed while it was being opened');
307
+ }
308
+ const raw = await handle.readFile('utf8');
309
+ const after = await handle.stat();
310
+ if (after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size) {
311
+ throw new Error('Borg credential store file changed while it was being read');
312
+ }
313
+ return raw;
314
+ } finally {
315
+ await handle.close();
316
+ }
317
+ }
318
+ let fileStat: Awaited<ReturnType<typeof lstat>>;
192
319
  try {
193
- fileStat = await stat(filePath);
320
+ fileStat = await lstat(filePath);
194
321
  } catch (err) {
195
322
  if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
196
323
  throw err;
197
324
  }
325
+ if (fileStat.isSymbolicLink() || !fileStat.isFile()) {
326
+ throw new Error(`Borg store file ${filePath} must be a regular file, not a symlink`);
327
+ }
328
+ const uid = expectedUid();
329
+ if (uid !== null && fileStat.uid !== uid) {
330
+ throw new Error(`Borg store file ${filePath} is not owned by the current user`);
331
+ }
198
332
  await assertSecureStorePerms(filePath, fileStat.mode);
199
333
  try {
200
334
  return await readFile(filePath, 'utf8');
@@ -220,11 +354,12 @@ export async function readStoreFile(filePath: string): Promise<string | null> {
220
354
  export async function withStoreLock<T>(
221
355
  lockPath: string,
222
356
  op: () => Promise<T>,
223
- opts: { attempts?: number; waitMs?: number } = {},
357
+ opts: { attempts?: number; waitMs?: number } & SecureStoreOptions = {},
224
358
  ): Promise<T> {
225
359
  const attempts = opts.attempts ?? LOCK_ATTEMPTS;
226
360
  const waitMs = opts.waitMs ?? LOCK_WAIT_MS;
227
- await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
361
+ if (opts.secureRoot) await assertSecurePath(lockPath, opts);
362
+ else await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
228
363
  const myPayload = JSON.stringify({ pid: process.pid, startTime: processStartTime() });
229
364
  // Stage the FULLY-WRITTEN payload in a same-dir temp, then acquire by atomically
230
365
  // hard-linking it into place. `link` is atomic (EEXIST when the lock is held), and
@@ -248,7 +383,14 @@ export async function withStoreLock<T>(
248
383
  // The lock is held. Inspect the holder — but NEVER reclaim/steal it.
249
384
  let raw: string;
250
385
  try {
251
- raw = await readFile(lockPath, 'utf8');
386
+ const stored = await readStoreFile(
387
+ lockPath,
388
+ opts.secureRoot
389
+ ? { secureRoot: opts.secureRoot, rootMode: opts.rootMode }
390
+ : {},
391
+ );
392
+ if (stored === null) continue;
393
+ raw = stored;
252
394
  } catch (readErr) {
253
395
  if ((readErr as NodeJS.ErrnoException).code === 'ENOENT') continue; // released — retry
254
396
  throw readErr;
@@ -298,9 +440,10 @@ export async function withStore<S, T>(
298
440
  emptyState: () => S,
299
441
  parse: (raw: string) => S | null,
300
442
  op: (txn: StoreTxn<S>) => Promise<T>,
443
+ options: SecureStoreOptions = {},
301
444
  ): Promise<T> {
302
445
  return withStoreLock(`${storePath}.lock`, async () => {
303
- const raw = await readStoreFile(storePath);
446
+ const raw = await readStoreFile(storePath, options);
304
447
  // CR4 fail-closed: ONLY a missing file (ENOENT → readStoreFile returns null)
305
448
  // may initialize an empty state. A present-but-malformed / wrong-version /
306
449
  // schema-invalid store must NEVER be silently mapped to empty and then
@@ -326,8 +469,8 @@ export async function withStore<S, T>(
326
469
  }
327
470
  const txn: StoreTxn<S> = {
328
471
  data,
329
- commit: () => atomicWrite0600(storePath, JSON.stringify(data, null, 2) + '\n'),
472
+ commit: () => atomicWrite0600(storePath, JSON.stringify(data, null, 2) + '\n', options),
330
473
  };
331
474
  return op(txn);
332
- });
475
+ }, options);
333
476
  }
@@ -0,0 +1,192 @@
1
+ import { spawn as spawnChild, type SpawnOptions } from 'node:child_process';
2
+ import { constants } from 'node:os';
3
+ import { serverHelpText } from './cli-help.js';
4
+
5
+ export const SERVER_LIFECYCLE_COMMANDS = ['setup', 'start', 'status', 'update', 'invite'] as const;
6
+ export type ServerLifecycleCommand = typeof SERVER_LIFECYCLE_COMMANDS[number];
7
+
8
+ export type ParsedServerFacadeArgs =
9
+ | { kind: 'help' }
10
+ | { kind: 'command'; command: ServerLifecycleCommand; args: string[] }
11
+ | { kind: 'error'; reason: 'unknown-command'; command: string };
12
+
13
+ export function parseServerFacadeArgs(args: readonly string[]): ParsedServerFacadeArgs {
14
+ const [command, ...rest] = args;
15
+ if (command === undefined || command === '--help' || command === '-h') {
16
+ return { kind: 'help' };
17
+ }
18
+ if (!(SERVER_LIFECYCLE_COMMANDS as readonly string[]).includes(command)) {
19
+ return { kind: 'error', reason: 'unknown-command', command };
20
+ }
21
+ return {
22
+ kind: 'command',
23
+ command: command as ServerLifecycleCommand,
24
+ args: rest,
25
+ };
26
+ }
27
+
28
+ interface ServerFacadeChild {
29
+ once(event: 'error', listener: (error: Error) => void): this;
30
+ once(
31
+ event: 'exit',
32
+ listener: (code: number | null, signal: NodeJS.Signals | null) => void,
33
+ ): this;
34
+ kill(signal: NodeJS.Signals): boolean;
35
+ }
36
+
37
+ export interface ServerFacadeProcessDeps {
38
+ spawn(
39
+ command: string,
40
+ args: readonly string[],
41
+ options: Pick<SpawnOptions, 'shell' | 'stdio'>,
42
+ ): ServerFacadeChild;
43
+ addSignalListener(signal: NodeJS.Signals, listener: () => void): void;
44
+ removeSignalListener(signal: NodeJS.Signals, listener: () => void): void;
45
+ }
46
+
47
+ export interface ServerFacadeOutputDeps {
48
+ writeStdout(text: string): void;
49
+ writeStderr(text: string): void;
50
+ }
51
+
52
+ export type ServerFacadeProcessResult =
53
+ | { kind: 'exited'; code: number }
54
+ | { kind: 'signaled'; signal: NodeJS.Signals }
55
+ | { kind: 'spawn-error'; error: Error };
56
+
57
+ const defaultProcessDeps: ServerFacadeProcessDeps = {
58
+ spawn: (command, args, options) => spawnChild(command, [...args], options),
59
+ addSignalListener: (signal, listener) => process.on(signal, listener),
60
+ removeSignalListener: (signal, listener) => process.off(signal, listener),
61
+ };
62
+
63
+ const defaultOutputDeps: ServerFacadeOutputDeps = {
64
+ writeStdout: (text) => process.stdout.write(text),
65
+ writeStderr: (text) => process.stderr.write(text),
66
+ };
67
+
68
+ const MAX_RENDERED_COMMAND_CODE_POINTS = 80;
69
+
70
+ function inertCommand(command: string): string {
71
+ const rendered: string[] = [];
72
+ let truncated = false;
73
+ for (const codePoint of command) {
74
+ if (rendered.length === MAX_RENDERED_COMMAND_CODE_POINTS) {
75
+ truncated = true;
76
+ break;
77
+ }
78
+ rendered.push(/\p{Cc}/u.test(codePoint) ? '?' : codePoint);
79
+ }
80
+ if (truncated) {
81
+ return `${rendered.slice(0, MAX_RENDERED_COMMAND_CODE_POINTS - 3).join('')}...`;
82
+ }
83
+ return rendered.join('');
84
+ }
85
+
86
+ export function unknownServerCommandText(command: string): string {
87
+ return (
88
+ `Unknown server command: ${inertCommand(command)}.\n` +
89
+ `Available commands: setup, start, status, update, invite.\n` +
90
+ `Next: run borg server --help.\n`
91
+ );
92
+ }
93
+
94
+ export function missingServerExecutableText(command: ServerLifecycleCommand): string {
95
+ return (
96
+ `Local server command is unavailable: borg-mcp-server was not found.\n` +
97
+ `Next: install a verified borgmcp-server release, then rerun borg server ${command}.\n` +
98
+ `No checkout fallback is attempted.\n`
99
+ );
100
+ }
101
+
102
+ export function serverCommandStartupFailureText(command: ServerLifecycleCommand): string {
103
+ return (
104
+ `Local server command could not be started.\n` +
105
+ `Next: check local permissions and system resources, then rerun borg server ${inertCommand(command)}.\n` +
106
+ `No server command was started.\n`
107
+ );
108
+ }
109
+
110
+ function isMissingServerExecutable(error: Error): boolean {
111
+ return (error as NodeJS.ErrnoException).code === 'ENOENT';
112
+ }
113
+
114
+ export function runServerFacadeProcess(
115
+ input: { command: ServerLifecycleCommand; args: readonly string[] },
116
+ deps: ServerFacadeProcessDeps = defaultProcessDeps,
117
+ ): Promise<ServerFacadeProcessResult> {
118
+ const child = deps.spawn(
119
+ 'borg-mcp-server',
120
+ [input.command, ...input.args],
121
+ { shell: false, stdio: 'inherit' },
122
+ );
123
+ const signals = ['SIGINT', 'SIGTERM'] as const;
124
+
125
+ return new Promise((resolve) => {
126
+ let settled = false;
127
+ const forwarders = new Map<NodeJS.Signals, () => void>();
128
+ const cleanup = () => {
129
+ for (const [signal, listener] of forwarders) {
130
+ deps.removeSignalListener(signal, listener);
131
+ }
132
+ forwarders.clear();
133
+ };
134
+ const settle = (result: ServerFacadeProcessResult) => {
135
+ if (settled) return;
136
+ settled = true;
137
+ cleanup();
138
+ resolve(result);
139
+ };
140
+
141
+ for (const signal of signals) {
142
+ const listener = () => {
143
+ child.kill(signal);
144
+ };
145
+ forwarders.set(signal, listener);
146
+ deps.addSignalListener(signal, listener);
147
+ }
148
+
149
+ child.once('error', (error) => settle({ kind: 'spawn-error', error }));
150
+ child.once('exit', (code, signal) => {
151
+ if (signal) {
152
+ settle({ kind: 'signaled', signal });
153
+ return;
154
+ }
155
+ settle({ kind: 'exited', code: code ?? 1 });
156
+ });
157
+ });
158
+ }
159
+
160
+ function processResultExitCode(result: ServerFacadeProcessResult): number {
161
+ if (result.kind === 'exited') return result.code;
162
+ if (result.kind === 'spawn-error') return isMissingServerExecutable(result.error) ? 127 : 1;
163
+ return 128 + (constants.signals[result.signal] ?? 1);
164
+ }
165
+
166
+ /** Routes every facade outcome before client initialization or network work. */
167
+ export async function runEarlyServerFacade(
168
+ argv: readonly string[],
169
+ deps: ServerFacadeProcessDeps = defaultProcessDeps,
170
+ output: ServerFacadeOutputDeps = defaultOutputDeps,
171
+ ): Promise<number | null> {
172
+ if (argv[2] !== 'server') return null;
173
+ const parsed = parseServerFacadeArgs(argv.slice(3));
174
+ if (parsed.kind === 'help') {
175
+ output.writeStdout(serverHelpText());
176
+ return 0;
177
+ }
178
+ if (parsed.kind === 'error') {
179
+ output.writeStderr(unknownServerCommandText(parsed.command));
180
+ return 1;
181
+ }
182
+
183
+ const result = await runServerFacadeProcess(parsed, deps);
184
+ if (result.kind === 'spawn-error') {
185
+ output.writeStderr(
186
+ isMissingServerExecutable(result.error)
187
+ ? missingServerExecutableText(parsed.command)
188
+ : serverCommandStartupFailureText(parsed.command),
189
+ );
190
+ }
191
+ return processResultExitCode(result);
192
+ }
@@ -7,7 +7,8 @@
7
7
  * private keys; there is no keychain and no obfuscation-grade fallback.
8
8
  */
9
9
 
10
- import { atomicWrite0600, readStoreFile } from './seat-store.js';
10
+ import { atomicWrite0600, readStoreFile, type SecureStoreOptions } from './seat-store.js';
11
+ import { dirname } from 'node:path';
11
12
 
12
13
  // The OS-keychain backend was retired with the Queen rescope; the only backend
13
14
  // name is the 0600 file store.
@@ -37,9 +38,13 @@ export interface TokenBackend {
37
38
  * lockfile. Pure reads (get) are safe lock-free because atomicWrite0600's rename
38
39
  * guarantees a reader only ever sees a complete file.
39
40
  */
40
- export function makeFileBackend(filePath: string): TokenBackend {
41
+ export function makeFileBackend(
42
+ filePath: string,
43
+ storeOptions: SecureStoreOptions = {},
44
+ ): TokenBackend {
45
+ const options = { secureRoot: dirname(filePath), ...storeOptions };
41
46
  const load = async (): Promise<Record<string, string>> => {
42
- const raw = await readStoreFile(filePath);
47
+ const raw = await readStoreFile(filePath, options);
43
48
  // CR4 fail-closed: ONLY a missing file initializes empty. A present-but-
44
49
  // malformed / wrong-version / schema-invalid credential store MUST NOT read as
45
50
  // empty — a subsequent set/delete would OVERWRITE it and erase every stored
@@ -79,7 +84,7 @@ export function makeFileBackend(filePath: string): TokenBackend {
79
84
  return { ...(accounts as Record<string, string>) };
80
85
  };
81
86
  const save = (accounts: Record<string, string>): Promise<void> =>
82
- atomicWrite0600(filePath, JSON.stringify({ version: 1, accounts }, null, 2) + '\n');
87
+ atomicWrite0600(filePath, JSON.stringify({ version: 1, accounts }, null, 2) + '\n', options);
83
88
  return {
84
89
  name: 'file',
85
90
  async get(account) {
@@ -20,6 +20,7 @@ export const KNOWN_SUBCOMMANDS = [
20
20
  'sync',
21
21
  'cleanup',
22
22
  'launch-all',
23
+ 'server',
23
24
  ] as const;
24
25
 
25
26
  /**