borgmcp 2.0.1 → 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 (80) 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/codex-app-wake.d.ts +9 -11
  11. package/dist/codex-app-wake.d.ts.map +1 -1
  12. package/dist/codex-app-wake.js +15 -11
  13. package/dist/codex-app-wake.js.map +1 -1
  14. package/dist/codex-wake-resolve.d.ts +4 -4
  15. package/dist/codex-wake-resolve.js +4 -4
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +41 -18
  18. package/dist/config.js.map +1 -1
  19. package/dist/credential-paths.d.ts +3 -0
  20. package/dist/credential-paths.d.ts.map +1 -0
  21. package/dist/credential-paths.js +7 -0
  22. package/dist/credential-paths.js.map +1 -0
  23. package/dist/log-stream.d.ts +4 -0
  24. package/dist/log-stream.d.ts.map +1 -1
  25. package/dist/log-stream.js +58 -2
  26. package/dist/log-stream.js.map +1 -1
  27. package/dist/remote-client.d.ts +25 -0
  28. package/dist/remote-client.d.ts.map +1 -1
  29. package/dist/remote-client.js +59 -4
  30. package/dist/remote-client.js.map +1 -1
  31. package/dist/seat-store.d.ts +11 -5
  32. package/dist/seat-store.d.ts.map +1 -1
  33. package/dist/seat-store.js +147 -14
  34. package/dist/seat-store.js.map +1 -1
  35. package/dist/seats.d.ts +72 -0
  36. package/dist/seats.d.ts.map +1 -1
  37. package/dist/seats.js +119 -0
  38. package/dist/seats.js.map +1 -1
  39. package/dist/server-errors.d.ts +1 -1
  40. package/dist/server-errors.d.ts.map +1 -1
  41. package/dist/server-errors.js.map +1 -1
  42. package/dist/server-facade.d.ts +50 -0
  43. package/dist/server-facade.d.ts.map +1 -0
  44. package/dist/server-facade.js +126 -0
  45. package/dist/server-facade.js.map +1 -0
  46. package/dist/server-handshake.d.ts.map +1 -1
  47. package/dist/server-handshake.js +49 -21
  48. package/dist/server-handshake.js.map +1 -1
  49. package/dist/session-continuity.d.ts +33 -0
  50. package/dist/session-continuity.d.ts.map +1 -0
  51. package/dist/session-continuity.js +220 -0
  52. package/dist/session-continuity.js.map +1 -0
  53. package/dist/token-store.d.ts +2 -1
  54. package/dist/token-store.d.ts.map +1 -1
  55. package/dist/token-store.js +5 -3
  56. package/dist/token-store.js.map +1 -1
  57. package/dist/unknown-subcommand.d.ts +1 -1
  58. package/dist/unknown-subcommand.d.ts.map +1 -1
  59. package/dist/unknown-subcommand.js +1 -0
  60. package/dist/unknown-subcommand.js.map +1 -1
  61. package/docs/EXTRACTION_PROVENANCE.md +10 -7
  62. package/docs/LOCAL_SERVER.md +82 -43
  63. package/docs/RELEASING.md +22 -4
  64. package/package.json +2 -2
  65. package/src/claude.ts +5 -0
  66. package/src/cli-help.ts +15 -0
  67. package/src/codex-app-wake.ts +23 -11
  68. package/src/codex-wake-resolve.ts +4 -4
  69. package/src/config.ts +40 -18
  70. package/src/credential-paths.ts +8 -0
  71. package/src/log-stream.ts +64 -2
  72. package/src/remote-client.ts +92 -14
  73. package/src/seat-store.ts +159 -16
  74. package/src/seats.ts +170 -0
  75. package/src/server-errors.ts +3 -1
  76. package/src/server-facade.ts +192 -0
  77. package/src/server-handshake.ts +54 -21
  78. package/src/session-continuity.ts +280 -0
  79. package/src/token-store.ts +9 -4
  80. package/src/unknown-subcommand.ts +1 -0
package/src/log-stream.ts CHANGED
@@ -28,6 +28,7 @@ import { Buffer } from 'node:buffer';
28
28
  import { promises as fs } from 'node:fs';
29
29
  import path from 'node:path';
30
30
  import { compareBroadcastHwm, type BroadcastHwm } from 'borgmcp-shared/log-stream-hwm';
31
+ import { decodeProtocolErrorEnvelope, ErrorCode } from 'borgmcp-shared/protocol';
31
32
  import { getActiveCube, inboxPathForDrone } from './cubes.js';
32
33
  import { assertUuidShape } from './evict-drone.js';
33
34
  import { loadBorgServerTrust } from './server-trust.js';
@@ -53,6 +54,8 @@ import {
53
54
  wakeCodexViaAppServer,
54
55
  } from './codex-app-wake.js';
55
56
  import { readBoundedResponseBody } from './server-response.js';
57
+ import { BorgServerError, BorgServerUnreachableError } from './server-errors.js';
58
+ import { recoverExpiredLocalSession } from './session-continuity.js';
56
59
  import {
57
60
  acquireStreamLease,
58
61
  readOwnershipSnapshot,
@@ -97,6 +100,13 @@ export class StreamCursorExpiredError extends Error {
97
100
  }
98
101
  }
99
102
 
103
+ class TerminalStreamError extends Error {
104
+ constructor() {
105
+ super('terminal local session state');
106
+ this.name = 'TerminalStreamError';
107
+ }
108
+ }
109
+
100
110
  function isProcessAlive(pid: number): boolean {
101
111
  try {
102
112
  process.kill(pid, 0);
@@ -322,6 +332,7 @@ function runStreamLoopForever(): void {
322
332
  await runLoop();
323
333
  process.stderr.write('[borg-mcp log stream] runLoop returned unexpectedly; restarting in 5s\n');
324
334
  } catch (err: any) {
335
+ if (err instanceof TerminalStreamError) return;
325
336
  process.stderr.write(`[borg-mcp log stream] runLoop threw: ${err?.message ?? err}; restarting in 5s\n`);
326
337
  }
327
338
  streamState.runLoopRestartCount += 1;
@@ -417,6 +428,7 @@ export interface RunLoopTestDeps {
417
428
  getActiveCube?: typeof getActiveCube;
418
429
  acquireStreamLease?: typeof acquireStreamLease;
419
430
  streamOnce?: typeof streamOnce;
431
+ recoverExpiredSession?: typeof recoverExpiredLocalSession;
420
432
  sleep?: (ms: number) => Promise<void>;
421
433
  maxIterations?: number;
422
434
  }
@@ -425,6 +437,7 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
425
437
  const _getActiveCube = testDeps.getActiveCube ?? getActiveCube;
426
438
  const _acquireStreamLease = testDeps.acquireStreamLease ?? acquireStreamLease;
427
439
  const _streamOnce = testDeps.streamOnce ?? streamOnce;
440
+ const _recoverExpiredSession = testDeps.recoverExpiredSession ?? recoverExpiredLocalSession;
428
441
  const _sleep = testDeps.sleep ?? sleep;
429
442
  const _maxIterations = testDeps.maxIterations ?? Infinity;
430
443
  let _iterations = 0;
@@ -434,6 +447,7 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
434
447
  let currentCubeId: string | null = null;
435
448
  let lease: StreamLease | null = null;
436
449
  let leaseKey: string | null = null;
450
+ let lastRecoveredToken: string | null = null;
437
451
 
438
452
  try {
439
453
  while (_iterations < _maxIterations) {
@@ -534,6 +548,7 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
534
548
  }
535
549
  // Clean disconnect (e.g. server-side rollout). Reset backoff.
536
550
  attempt = 0;
551
+ lastRecoveredToken = null;
537
552
  streamState.reconnectAttempts = 0;
538
553
  } catch (err: any) {
539
554
  if (ownerLost) {
@@ -559,7 +574,28 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
559
574
  process.stderr.write(
560
575
  `[borg-mcp log stream] drone evicted — stream terminated (no reconnect).\n`
561
576
  );
562
- return;
577
+ throw new TerminalStreamError();
578
+ }
579
+ if (err instanceof BorgServerError) {
580
+ if (err.code !== 'AUTH_EXPIRED' || lastRecoveredToken === active.sessionToken) {
581
+ throw new TerminalStreamError();
582
+ }
583
+ try {
584
+ const renewed = await _recoverExpiredSession(active);
585
+ lastRecoveredToken = renewed.sessionToken;
586
+ attempt = 0;
587
+ streamState.reconnectAttempts = 0;
588
+ continue;
589
+ } catch (recoveryError) {
590
+ if (!(recoveryError instanceof BorgServerUnreachableError)) {
591
+ throw new TerminalStreamError();
592
+ }
593
+ // The replacement bearer is durable and intentionally survives an
594
+ // ambiguous transport failure. Fall through to the ordinary bounded
595
+ // reconnect backoff; the next AUTH_EXPIRED response resumes that exact
596
+ // replacement instead of permanently silencing the stream.
597
+ err = recoveryError;
598
+ }
563
599
  }
564
600
  streamState.connected = false;
565
601
  const delay =
@@ -603,6 +639,8 @@ export interface ActiveCube {
603
639
  sessionToken: string;
604
640
  apiUrl: string;
605
641
  serverTrustIdentity?: string;
642
+ localSessionCredentialRef?: string;
643
+ localSessionExpiresAt?: string | null;
606
644
  }
607
645
 
608
646
  export async function streamOnce(
@@ -762,7 +800,7 @@ export async function streamOnce(
762
800
  // Set + FIFO array for O(1) membership + bounded memory.
763
801
  const recentIds = new Set<string>();
764
802
  const recentIdsOrder: string[] = [];
765
- let isCatchingUp = lastEventId !== null;
803
+ let isCatchingUp = lastEventId !== null || cursor !== null;
766
804
 
767
805
  // gh#29 quality-stream (#5): shared inbox-write + cursor-advance helpers,
768
806
  // extracted from the previously-duplicated ack / regular-log branches in the
@@ -856,6 +894,30 @@ export async function streamOnce(
856
894
  // off forever against a dead seat. Keyed on the structured code, not the
857
895
  // bare status (SEC R2). Any other status falls through to the generic throw
858
896
  // so the reconnect loop keeps retrying transient failures.
897
+ if (response.status === 401) {
898
+ const body = await readBoundedResponseBody(
899
+ response,
900
+ LOCAL_SERVER_SSE_FRAME_LIMIT_BYTES,
901
+ 'Local Borg server SSE response exceeded the response limit',
902
+ ac.signal,
903
+ ).catch(() => '');
904
+ let code: ErrorCode | undefined;
905
+ try {
906
+ code = decodeProtocolErrorEnvelope(JSON.parse(body)).error.code;
907
+ } catch {
908
+ code = undefined;
909
+ }
910
+ if (code === ErrorCode.AUTH_EXPIRED) {
911
+ throw new BorgServerError('AUTH_EXPIRED', 'Borg server session expired');
912
+ }
913
+ if (code === ErrorCode.SESSION_REJECTED) {
914
+ throw new BorgServerError('SESSION_REJECTED', 'Borg server session was rejected');
915
+ }
916
+ if (code === ErrorCode.SESSION_REVOKED) {
917
+ throw new BorgServerError('SESSION_REVOKED', 'Borg server session was revoked');
918
+ }
919
+ throw new BorgServerError('CREDENTIAL_REJECTED', 'Borg server rejected the stream credential');
920
+ }
859
921
  if (response.status === 410) {
860
922
  const body = await readBoundedResponseBody(
861
923
  response,
@@ -47,6 +47,7 @@ import {
47
47
  } from './local-server-cursor.js';
48
48
  import { readBoundedResponseBody } from './server-response.js';
49
49
  import { resolveLocalLogRecipients } from './local-log-routing.js';
50
+ import { ensureLocalSessionFresh, recoverExpiredLocalSession } from './session-continuity.js';
50
51
 
51
52
  export interface RemoteConnection {
52
53
  apiUrl: string;
@@ -228,20 +229,31 @@ async function localServerRequest<T>(
228
229
  method: 'GET' | 'POST' | 'PUT' | 'PATCH',
229
230
  payload?: Record<string, unknown>,
230
231
  ): Promise<T | null> {
231
- return decodeLocalProtocolResponse<T>((signal) => authedFetch(path, {
232
- method,
233
- signal,
234
- droneSession: active.sessionToken,
235
- apiUrl: active.apiUrl,
236
- serverTrustIdentity: active.serverTrustIdentity,
237
- redirect: 'error',
238
- ...(payload === undefined
239
- ? { headers: { Accept: 'application/json' } }
240
- : {
241
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
242
- body: JSON.stringify(createProtocolEnvelope(randomUUID(), payload)),
243
- }),
244
- }), true);
232
+ let current = active.localSessionCredentialRef && active.localSessionExpiresAt
233
+ ? await ensureLocalSessionFresh(active)
234
+ : active;
235
+ const request = (candidate: ActiveCube) => decodeLocalProtocolResponse<T>((signal) => authedFetch(path, {
236
+ method,
237
+ signal,
238
+ droneSession: candidate.sessionToken,
239
+ apiUrl: candidate.apiUrl,
240
+ serverTrustIdentity: candidate.serverTrustIdentity,
241
+ redirect: 'error',
242
+ ...(payload === undefined
243
+ ? { headers: { Accept: 'application/json' } }
244
+ : {
245
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
246
+ body: JSON.stringify(createProtocolEnvelope(randomUUID(), payload)),
247
+ }),
248
+ }), true);
249
+ try {
250
+ return await request(current);
251
+ } catch (error) {
252
+ if (!(error instanceof BorgServerError) || error.code !== 'AUTH_EXPIRED') throw error;
253
+ if (current.sessionToken !== active.sessionToken) throw error;
254
+ current = await recoverExpiredLocalSession(current);
255
+ return request(current);
256
+ }
245
257
  }
246
258
 
247
259
  export interface LocalManageOperation {
@@ -404,6 +416,66 @@ async function localReadLogPage(
404
416
  return payload;
405
417
  }
406
418
 
419
+ interface PendingWakePage {
420
+ entries: Array<{
421
+ drone_id?: unknown;
422
+ message?: unknown;
423
+ visibility?: unknown;
424
+ recipient_drone_ids?: unknown;
425
+ }>;
426
+ cursor: LocalServerCursor | null;
427
+ has_more: boolean;
428
+ }
429
+
430
+ function isPendingWakeEntry(
431
+ entry: PendingWakePage['entries'][number],
432
+ droneId: string,
433
+ ): boolean {
434
+ if (entry.visibility === 'direct') {
435
+ const recipients = Array.isArray(entry.recipient_drone_ids)
436
+ ? entry.recipient_drone_ids.filter((recipient): recipient is string => typeof recipient === 'string')
437
+ : [];
438
+ if (!recipients.includes(droneId)) return false;
439
+ }
440
+
441
+ const isHeartbeatPing =
442
+ typeof entry.message === 'string' && entry.message.startsWith('[HEARTBEAT-PING]');
443
+ return entry.drone_id !== droneId || isHeartbeatPing;
444
+ }
445
+
446
+ /**
447
+ * client#76: inspect authoritative unread log state without advancing the
448
+ * agent-owned unread cursor. The scan mirrors the SSE wake filters: unaddressed
449
+ * direct entries and ordinary own posts are not work for this seat. A full
450
+ * paginated scan prevents a run of skipped entries from hiding later real work.
451
+ */
452
+ export async function hasPendingWakeActivity(
453
+ active: ActiveCube,
454
+ deps: {
455
+ getCursor?: typeof getLocalServerCursor;
456
+ readPage?: (active: ActiveCube, opts: { cursor: LocalServerCursor | null; limit: number }) => Promise<PendingWakePage>;
457
+ } = {},
458
+ ): Promise<boolean> {
459
+ if (!active.serverTrustIdentity) {
460
+ throw new Error('Selected Borg server authority state is missing or unreadable');
461
+ }
462
+
463
+ const getCursor = deps.getCursor ?? getLocalServerCursor;
464
+ const readPage = deps.readPage ?? localReadLogPage;
465
+ let cursor = await getCursor(localCursorBinding(active));
466
+
467
+ for (;;) {
468
+ const page: PendingWakePage = await readPage(active, { cursor, limit: 500 });
469
+ if (page.entries.some((entry) => isPendingWakeEntry(entry, active.droneId))) return true;
470
+ if (!page.has_more) return false;
471
+ if (!page.cursor ||
472
+ (cursor && page.cursor.id === cursor.id && page.cursor.created_at === cursor.created_at)) {
473
+ throw new Error('Local Borg server returned a non-advancing log cursor');
474
+ }
475
+ cursor = page.cursor;
476
+ }
477
+ }
478
+
407
479
  async function resolveLocalLogCursor(
408
480
  active: ActiveCube,
409
481
  since: string,
@@ -549,6 +621,12 @@ async function authedFetch(
549
621
  } catch {
550
622
  rejectedCode = undefined;
551
623
  }
624
+ if (droneSession !== undefined && rejectedCode === ErrorCode.AUTH_EXPIRED) {
625
+ throw new BorgServerError(
626
+ 'AUTH_EXPIRED',
627
+ 'the selected Borg server session expired',
628
+ );
629
+ }
552
630
  if (droneSession !== undefined && rejectedCode === ErrorCode.SESSION_REJECTED) {
553
631
  throw new BorgServerError(
554
632
  'SESSION_REJECTED',
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
  }