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.
- package/README.md +16 -10
- package/dist/claude.d.ts +1 -0
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +5 -0
- package/dist/claude.js.map +1 -1
- package/dist/cli-help.d.ts +2 -0
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +12 -0
- package/dist/cli-help.js.map +1 -1
- package/dist/codex-app-wake.d.ts +9 -11
- package/dist/codex-app-wake.d.ts.map +1 -1
- package/dist/codex-app-wake.js +15 -11
- package/dist/codex-app-wake.js.map +1 -1
- package/dist/codex-wake-resolve.d.ts +4 -4
- package/dist/codex-wake-resolve.js +4 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +41 -18
- package/dist/config.js.map +1 -1
- package/dist/credential-paths.d.ts +3 -0
- package/dist/credential-paths.d.ts.map +1 -0
- package/dist/credential-paths.js +7 -0
- package/dist/credential-paths.js.map +1 -0
- package/dist/log-stream.d.ts +4 -0
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +58 -2
- package/dist/log-stream.js.map +1 -1
- package/dist/remote-client.d.ts +25 -0
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +59 -4
- package/dist/remote-client.js.map +1 -1
- package/dist/seat-store.d.ts +11 -5
- package/dist/seat-store.d.ts.map +1 -1
- package/dist/seat-store.js +147 -14
- package/dist/seat-store.js.map +1 -1
- package/dist/seats.d.ts +72 -0
- package/dist/seats.d.ts.map +1 -1
- package/dist/seats.js +119 -0
- package/dist/seats.js.map +1 -1
- package/dist/server-errors.d.ts +1 -1
- package/dist/server-errors.d.ts.map +1 -1
- package/dist/server-errors.js.map +1 -1
- package/dist/server-facade.d.ts +50 -0
- package/dist/server-facade.d.ts.map +1 -0
- package/dist/server-facade.js +126 -0
- package/dist/server-facade.js.map +1 -0
- package/dist/server-handshake.d.ts.map +1 -1
- package/dist/server-handshake.js +49 -21
- package/dist/server-handshake.js.map +1 -1
- package/dist/session-continuity.d.ts +33 -0
- package/dist/session-continuity.d.ts.map +1 -0
- package/dist/session-continuity.js +220 -0
- package/dist/session-continuity.js.map +1 -0
- package/dist/token-store.d.ts +2 -1
- package/dist/token-store.d.ts.map +1 -1
- package/dist/token-store.js +5 -3
- package/dist/token-store.js.map +1 -1
- package/dist/unknown-subcommand.d.ts +1 -1
- package/dist/unknown-subcommand.d.ts.map +1 -1
- package/dist/unknown-subcommand.js +1 -0
- package/dist/unknown-subcommand.js.map +1 -1
- package/docs/EXTRACTION_PROVENANCE.md +10 -7
- package/docs/LOCAL_SERVER.md +82 -43
- package/docs/RELEASING.md +22 -4
- package/package.json +2 -2
- package/src/claude.ts +5 -0
- package/src/cli-help.ts +15 -0
- package/src/codex-app-wake.ts +23 -11
- package/src/codex-wake-resolve.ts +4 -4
- package/src/config.ts +40 -18
- package/src/credential-paths.ts +8 -0
- package/src/log-stream.ts +64 -2
- package/src/remote-client.ts +92 -14
- package/src/seat-store.ts +159 -16
- package/src/seats.ts +170 -0
- package/src/server-errors.ts +3 -1
- package/src/server-facade.ts +192 -0
- package/src/server-handshake.ts +54 -21
- package/src/session-continuity.ts +280 -0
- package/src/token-store.ts +9 -4
- 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
|
-
|
|
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,
|
package/src/remote-client.ts
CHANGED
|
@@ -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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
:
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
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 {
|
|
33
|
-
import {
|
|
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
|
|
185
|
+
let existing: Awaited<ReturnType<typeof lstat>> | null = null;
|
|
108
186
|
try {
|
|
109
|
-
existing = await
|
|
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(
|
|
132
|
-
|
|
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(
|
|
191
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|