borgmcp 2.0.2 → 2.0.4
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/assimilate-cmd.d.ts +0 -2
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +51 -43
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/assimilate-deps.d.ts.map +1 -1
- package/dist/assimilate-deps.js +0 -1
- package/dist/assimilate-deps.js.map +1 -1
- 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/cleanup-cmd.d.ts +1 -1
- package/dist/cleanup-cmd.d.ts.map +1 -1
- package/dist/cleanup-cmd.js +3 -1
- package/dist/cleanup-cmd.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 +14 -1
- package/dist/cli-help.js.map +1 -1
- 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/cubes.d.ts +0 -2
- package/dist/cubes.d.ts.map +1 -1
- package/dist/cubes.js +0 -2
- package/dist/cubes.js.map +1 -1
- package/dist/launch-all-cmd.d.ts.map +1 -1
- package/dist/launch-all-cmd.js +13 -7
- package/dist/launch-all-cmd.js.map +1 -1
- package/dist/log-stream.d.ts +0 -2
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +3 -30
- package/dist/log-stream.js.map +1 -1
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +8 -23
- package/dist/remote-client.js.map +1 -1
- package/dist/seat-probe.d.ts +4 -4
- package/dist/seat-probe.d.ts.map +1 -1
- package/dist/seat-probe.js +2 -0
- package/dist/seat-probe.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 +0 -74
- package/dist/seats.d.ts.map +1 -1
- package/dist/seats.js +31 -131
- package/dist/seats.js.map +1 -1
- package/dist/server-errors.d.ts +6 -1
- package/dist/server-errors.d.ts.map +1 -1
- package/dist/server-errors.js +9 -0
- 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 +0 -2
- package/dist/server-handshake.d.ts.map +1 -1
- package/dist/server-handshake.js +0 -5
- package/dist/server-handshake.js.map +1 -1
- 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 +6 -5
- package/docs/LOCAL_SERVER.md +86 -43
- package/docs/RELEASING.md +19 -1
- package/package.json +1 -1
- package/src/assimilate-cmd.ts +54 -51
- package/src/assimilate-deps.ts +0 -1
- package/src/claude.ts +5 -0
- package/src/cleanup-cmd.ts +5 -2
- package/src/cli-help.ts +17 -1
- package/src/config.ts +40 -18
- package/src/credential-paths.ts +8 -0
- package/src/cubes.ts +0 -4
- package/src/launch-all-cmd.ts +15 -7
- package/src/log-stream.ts +2 -30
- package/src/remote-client.ts +10 -22
- package/src/seat-probe.ts +5 -3
- package/src/seat-store.ts +159 -16
- package/src/seats.ts +35 -186
- package/src/server-errors.ts +9 -2
- package/src/server-facade.ts +192 -0
- package/src/server-handshake.ts +1 -10
- package/src/token-store.ts +9 -4
- package/src/unknown-subcommand.ts +1 -0
- package/dist/session-continuity.d.ts +0 -33
- package/dist/session-continuity.d.ts.map +0 -1
- package/dist/session-continuity.js +0 -220
- package/dist/session-continuity.js.map +0 -1
- package/src/session-continuity.ts +0 -280
package/src/remote-client.ts
CHANGED
|
@@ -47,7 +47,6 @@ 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';
|
|
51
50
|
|
|
52
51
|
export interface RemoteConnection {
|
|
53
52
|
apiUrl: string;
|
|
@@ -229,15 +228,12 @@ async function localServerRequest<T>(
|
|
|
229
228
|
method: 'GET' | 'POST' | 'PUT' | 'PATCH',
|
|
230
229
|
payload?: Record<string, unknown>,
|
|
231
230
|
): Promise<T | null> {
|
|
232
|
-
|
|
233
|
-
? await ensureLocalSessionFresh(active)
|
|
234
|
-
: active;
|
|
235
|
-
const request = (candidate: ActiveCube) => decodeLocalProtocolResponse<T>((signal) => authedFetch(path, {
|
|
231
|
+
return decodeLocalProtocolResponse<T>((signal) => authedFetch(path, {
|
|
236
232
|
method,
|
|
237
233
|
signal,
|
|
238
|
-
droneSession:
|
|
239
|
-
apiUrl:
|
|
240
|
-
serverTrustIdentity:
|
|
234
|
+
droneSession: active.sessionToken,
|
|
235
|
+
apiUrl: active.apiUrl,
|
|
236
|
+
serverTrustIdentity: active.serverTrustIdentity,
|
|
241
237
|
redirect: 'error',
|
|
242
238
|
...(payload === undefined
|
|
243
239
|
? { headers: { Accept: 'application/json' } }
|
|
@@ -246,14 +242,6 @@ async function localServerRequest<T>(
|
|
|
246
242
|
body: JSON.stringify(createProtocolEnvelope(randomUUID(), payload)),
|
|
247
243
|
}),
|
|
248
244
|
}), 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
|
-
}
|
|
257
245
|
}
|
|
258
246
|
|
|
259
247
|
export interface LocalManageOperation {
|
|
@@ -621,16 +609,16 @@ async function authedFetch(
|
|
|
621
609
|
} catch {
|
|
622
610
|
rejectedCode = undefined;
|
|
623
611
|
}
|
|
624
|
-
if (droneSession !== undefined && rejectedCode === ErrorCode.
|
|
612
|
+
if (droneSession !== undefined && rejectedCode === ErrorCode.SESSION_REJECTED) {
|
|
625
613
|
throw new BorgServerError(
|
|
626
|
-
'
|
|
627
|
-
'the selected Borg server session
|
|
614
|
+
'SESSION_REJECTED',
|
|
615
|
+
'the selected Borg server superseded this worktree session with a newer enrollment',
|
|
628
616
|
);
|
|
629
617
|
}
|
|
630
|
-
if (droneSession !== undefined && rejectedCode === ErrorCode.
|
|
618
|
+
if (droneSession !== undefined && rejectedCode === ErrorCode.SESSION_REVOKED) {
|
|
631
619
|
throw new BorgServerError(
|
|
632
|
-
'
|
|
633
|
-
'the selected Borg server
|
|
620
|
+
'SESSION_REVOKED',
|
|
621
|
+
'the selected Borg server revoked this worktree session',
|
|
634
622
|
);
|
|
635
623
|
}
|
|
636
624
|
throw new BorgServerError(
|
package/src/seat-probe.ts
CHANGED
|
@@ -19,9 +19,9 @@ import {
|
|
|
19
19
|
* preserved (CR #6 / CR5 — the probe must NOT collapse them). Each verdict is
|
|
20
20
|
* derived from the actual error TYPE/CODE, never from a mutable error-text regex:
|
|
21
21
|
* evicted ← 410 DRONE_EVICTED (terminal; the SOLE delete authority — gh#882 S1)
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* revoked ← pin-matched drone-SESSION 401 carrying SESSION_REVOKED
|
|
23
|
+
* rejected ← pin-matched drone-SESSION 401 carrying SESSION_REJECTED
|
|
24
|
+
* (superseded by a newer enrollment)
|
|
25
25
|
* credential-rejected← any OTHER 401 on the drone session (bare/untyped or a
|
|
26
26
|
* non-SESSION typed code): the saved credential is no longer
|
|
27
27
|
* accepted, but this is NON-DESTRUCTIVE — re-enroll, NEVER a
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
*/
|
|
41
41
|
export type SeatStatus =
|
|
42
42
|
| 'evicted'
|
|
43
|
+
| 'revoked'
|
|
43
44
|
| 'rejected'
|
|
44
45
|
| 'live'
|
|
45
46
|
| 'credential-rejected'
|
|
@@ -99,6 +100,7 @@ export async function defaultProbeSeat(
|
|
|
99
100
|
// the error TYPE, never from message text (the security boundary).
|
|
100
101
|
if (err instanceof BorgServerTrustError) return 'trust-mismatch';
|
|
101
102
|
if (err instanceof BorgServerError) {
|
|
103
|
+
if (err.code === 'SESSION_REVOKED') return 'revoked';
|
|
102
104
|
if (err.code === 'SESSION_REJECTED') return 'rejected';
|
|
103
105
|
// Every non-SESSION 401 (bare/untyped or a different typed code) is a
|
|
104
106
|
// credential rejection — non-destructive, distinct from a takeover.
|
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
|
}
|
package/src/seats.ts
CHANGED
|
@@ -21,6 +21,7 @@ import os from 'os';
|
|
|
21
21
|
import path from 'path';
|
|
22
22
|
import { createHash } from 'node:crypto';
|
|
23
23
|
import { readStoreFile, withStore } from './seat-store.js';
|
|
24
|
+
import { LegacySessionCredentialCollisionError } from './server-errors.js';
|
|
24
25
|
|
|
25
26
|
export const SEATS_FILE = path.join(os.homedir(), '.config', 'borgmcp', 'seats.json');
|
|
26
27
|
const SEATS_VERSION = 1 as const;
|
|
@@ -47,7 +48,6 @@ export interface SeatRecord {
|
|
|
47
48
|
// server metadata (active only)
|
|
48
49
|
droneId?: string;
|
|
49
50
|
sessionId?: string;
|
|
50
|
-
expiresAt?: string;
|
|
51
51
|
// binding + display (set atomically at FINALIZE; absent while pending)
|
|
52
52
|
worktree?: string;
|
|
53
53
|
name?: string;
|
|
@@ -55,9 +55,6 @@ export interface SeatRecord {
|
|
|
55
55
|
roleName?: string;
|
|
56
56
|
roleClass?: 'queen' | 'worker';
|
|
57
57
|
isHumanSeat?: boolean;
|
|
58
|
-
// One crash-safe replacement may coexist with an ACTIVE session until the
|
|
59
|
-
// server confirms the same-seat restore. It is never hydratable or bound.
|
|
60
|
-
replacement?: { credential: string };
|
|
61
58
|
}
|
|
62
59
|
|
|
63
60
|
interface SeatsFile {
|
|
@@ -122,8 +119,8 @@ function isValidOperation(value: unknown): value is SeatOperation {
|
|
|
122
119
|
* checked — the ref is well-formed and self-consistent (the map key equals the
|
|
123
120
|
* record's derived ref), state ∈ {pending,active}, the credential is a non-empty
|
|
124
121
|
* string, the operation is well-shaped, an ACTIVE record carries ALL its required
|
|
125
|
-
* server + binding fields, and a PENDING record carries NO active-only
|
|
126
|
-
*
|
|
122
|
+
* server + binding fields, and a PENDING record carries NO active-only session
|
|
123
|
+
* id. A single invalid entry ⇒ the whole store is rejected
|
|
127
124
|
* (fail closed at the caller, bytes preserved) — never a silent cast.
|
|
128
125
|
*/
|
|
129
126
|
function isValidSeatRecord(ref: string, value: unknown): value is SeatRecord {
|
|
@@ -151,32 +148,12 @@ function isValidSeatRecord(ref: string, value: unknown): value is SeatRecord {
|
|
|
151
148
|
if (r.worktree !== undefined && typeof r.worktree !== 'string') return false;
|
|
152
149
|
if (r.droneId !== undefined && (typeof r.droneId !== 'string' || !UUID_RE.test(r.droneId))) return false;
|
|
153
150
|
if (r.sessionId !== undefined && (typeof r.sessionId !== 'string' || !UUID_RE.test(r.sessionId))) return false;
|
|
154
|
-
if (
|
|
155
|
-
r.expiresAt !== undefined &&
|
|
156
|
-
(typeof r.expiresAt !== 'string' || !Number.isFinite(Date.parse(r.expiresAt)))
|
|
157
|
-
) {
|
|
158
|
-
return false;
|
|
159
|
-
}
|
|
160
|
-
if (r.replacement !== undefined) {
|
|
161
|
-
if (
|
|
162
|
-
r.state !== 'active' ||
|
|
163
|
-
r.replacement === null ||
|
|
164
|
-
typeof r.replacement !== 'object' ||
|
|
165
|
-
Array.isArray(r.replacement) ||
|
|
166
|
-
Object.keys(r.replacement as object).length !== 1 ||
|
|
167
|
-
typeof (r.replacement as { credential?: unknown }).credential !== 'string' ||
|
|
168
|
-
!/^[A-Za-z0-9_-]{43}$/.test((r.replacement as { credential: string }).credential)
|
|
169
|
-
) {
|
|
170
|
-
return false;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
151
|
// State-consistency invariants (no inconsistent active|pending).
|
|
174
152
|
if (r.state === 'active') {
|
|
175
153
|
// An ACTIVE record MUST carry its full server session + worktree binding.
|
|
176
154
|
if (
|
|
177
155
|
typeof r.droneId !== 'string' ||
|
|
178
156
|
typeof r.sessionId !== 'string' ||
|
|
179
|
-
typeof r.expiresAt !== 'string' ||
|
|
180
157
|
typeof r.worktree !== 'string' ||
|
|
181
158
|
typeof r.name !== 'string' ||
|
|
182
159
|
typeof r.droneLabel !== 'string'
|
|
@@ -185,7 +162,7 @@ function isValidSeatRecord(ref: string, value: unknown): value is SeatRecord {
|
|
|
185
162
|
}
|
|
186
163
|
} else {
|
|
187
164
|
// A PENDING record must NOT carry active-only server session fields.
|
|
188
|
-
if (r.sessionId !== undefined
|
|
165
|
+
if (r.sessionId !== undefined) return false;
|
|
189
166
|
}
|
|
190
167
|
// The map key must equal the record's derived ref (no cross-key aliasing).
|
|
191
168
|
return seatRef(value as SeatRecord) === ref;
|
|
@@ -214,6 +191,37 @@ function parseStore(raw: string): SeatsFile | null {
|
|
|
214
191
|
) {
|
|
215
192
|
const seats = candidate.seats as Record<string, unknown>;
|
|
216
193
|
for (const [ref, record] of Object.entries(seats)) {
|
|
194
|
+
if (
|
|
195
|
+
record !== null &&
|
|
196
|
+
typeof record === 'object' &&
|
|
197
|
+
!Array.isArray(record) &&
|
|
198
|
+
Object.prototype.hasOwnProperty.call(record, 'replacement')
|
|
199
|
+
) {
|
|
200
|
+
const { replacement, ...withoutReplacement } = record as Record<string, unknown>;
|
|
201
|
+
const exactReplacement =
|
|
202
|
+
replacement !== null &&
|
|
203
|
+
typeof replacement === 'object' &&
|
|
204
|
+
!Array.isArray(replacement) &&
|
|
205
|
+
Object.keys(replacement).length === 1 &&
|
|
206
|
+
typeof (replacement as { credential?: unknown }).credential === 'string' &&
|
|
207
|
+
/^[A-Za-z0-9_-]{43}$/.test((replacement as { credential: string }).credential);
|
|
208
|
+
let canonicalOrigin = false;
|
|
209
|
+
try {
|
|
210
|
+
const origin = new URL(String(withoutReplacement.origin));
|
|
211
|
+
canonicalOrigin = origin.protocol === 'https:' && origin.origin === withoutReplacement.origin;
|
|
212
|
+
} catch {
|
|
213
|
+
canonicalOrigin = false;
|
|
214
|
+
}
|
|
215
|
+
if (
|
|
216
|
+
withoutReplacement.state === 'active' &&
|
|
217
|
+
exactReplacement &&
|
|
218
|
+
canonicalOrigin &&
|
|
219
|
+
isValidSeatRecord(ref, withoutReplacement)
|
|
220
|
+
) {
|
|
221
|
+
throw new LegacySessionCredentialCollisionError(withoutReplacement.origin as string);
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
217
225
|
if (!isValidSeatRecord(ref, record)) return null;
|
|
218
226
|
}
|
|
219
227
|
return { version: SEATS_VERSION, seats: seats as Record<string, SeatRecord> };
|
|
@@ -297,17 +305,6 @@ export async function getActiveSeatCredential(
|
|
|
297
305
|
return record.credential;
|
|
298
306
|
}
|
|
299
307
|
|
|
300
|
-
/** Exact ACTIVE record for the internal continuity coordinator. */
|
|
301
|
-
export async function getActiveSeat(
|
|
302
|
-
ref: string,
|
|
303
|
-
binding: { origin: string; trustIdentity: string; cubeId: string },
|
|
304
|
-
): Promise<SeatRecord | null> {
|
|
305
|
-
if (!REF_RE.test(ref)) return null;
|
|
306
|
-
const store = await readStore();
|
|
307
|
-
const record = store.seats[ref];
|
|
308
|
-
return recordMatches(record, ref, binding) && record.state === 'active' ? record : null;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
308
|
// ─── PREPARE / mint (no worktree yet) ────────────────────────────────────────
|
|
312
309
|
|
|
313
310
|
/**
|
|
@@ -471,7 +468,6 @@ export async function activateAndBindSeat(input: {
|
|
|
471
468
|
operation: SeatOperation;
|
|
472
469
|
droneId: string;
|
|
473
470
|
sessionId: string;
|
|
474
|
-
expiresAt: string;
|
|
475
471
|
expectedPendingDigest: string;
|
|
476
472
|
worktree: string;
|
|
477
473
|
name: string;
|
|
@@ -483,9 +479,6 @@ export async function activateAndBindSeat(input: {
|
|
|
483
479
|
if (!UUID_RE.test(input.droneId) || !UUID_RE.test(input.sessionId)) {
|
|
484
480
|
throw new Error('invalid Borg server session identity');
|
|
485
481
|
}
|
|
486
|
-
if (typeof input.expiresAt !== 'string' || !Number.isFinite(Date.parse(input.expiresAt))) {
|
|
487
|
-
throw new Error('invalid Borg server session expiry');
|
|
488
|
-
}
|
|
489
482
|
const ref = seatRef(input);
|
|
490
483
|
const binding = { origin: input.origin, trustIdentity: input.trustIdentity, cubeId: input.cubeId };
|
|
491
484
|
return withStore<SeatsFile, ActivateSeatOutcome>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
|
|
@@ -497,7 +490,6 @@ export async function activateAndBindSeat(input: {
|
|
|
497
490
|
state: 'active',
|
|
498
491
|
droneId: input.droneId,
|
|
499
492
|
sessionId: input.sessionId,
|
|
500
|
-
expiresAt: input.expiresAt,
|
|
501
493
|
worktree: input.worktree,
|
|
502
494
|
name: input.name,
|
|
503
495
|
droneLabel: input.droneLabel,
|
|
@@ -510,149 +502,6 @@ export async function activateAndBindSeat(input: {
|
|
|
510
502
|
});
|
|
511
503
|
}
|
|
512
504
|
|
|
513
|
-
export type PrepareSeatReplacementOutcome =
|
|
514
|
-
| { ok: true; credential: string; digest: string }
|
|
515
|
-
| { ok: false; reason: 'expectation-mismatch' };
|
|
516
|
-
|
|
517
|
-
/**
|
|
518
|
-
* Preserve the exact ACTIVE seat while preparing one durable replacement bearer.
|
|
519
|
-
* A lost response or process restart reuses the stored replacement; a changed
|
|
520
|
-
* authority, drone, or active bearer fails before any mutation.
|
|
521
|
-
*/
|
|
522
|
-
export async function prepareSeatReplacement(input: {
|
|
523
|
-
ref: string;
|
|
524
|
-
binding: { origin: string; trustIdentity: string; cubeId: string };
|
|
525
|
-
expectedDroneId: string;
|
|
526
|
-
expectedActiveDigest: string;
|
|
527
|
-
replacementCredential: string;
|
|
528
|
-
}): Promise<PrepareSeatReplacementOutcome> {
|
|
529
|
-
if (!REF_RE.test(input.ref) || !/^[A-Za-z0-9_-]{43}$/.test(input.replacementCredential)) {
|
|
530
|
-
return { ok: false, reason: 'expectation-mismatch' };
|
|
531
|
-
}
|
|
532
|
-
return withStore<SeatsFile, PrepareSeatReplacementOutcome>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
|
|
533
|
-
const record = txn.data.seats[input.ref];
|
|
534
|
-
if (
|
|
535
|
-
!recordMatches(record, input.ref, input.binding) ||
|
|
536
|
-
record.state !== 'active' ||
|
|
537
|
-
record.droneId !== input.expectedDroneId ||
|
|
538
|
-
digestOf(record.credential) !== input.expectedActiveDigest
|
|
539
|
-
) {
|
|
540
|
-
return { ok: false as const, reason: 'expectation-mismatch' as const };
|
|
541
|
-
}
|
|
542
|
-
if (record.replacement) {
|
|
543
|
-
return {
|
|
544
|
-
ok: true as const,
|
|
545
|
-
credential: record.replacement.credential,
|
|
546
|
-
digest: digestOf(record.replacement.credential),
|
|
547
|
-
};
|
|
548
|
-
}
|
|
549
|
-
record.replacement = { credential: input.replacementCredential };
|
|
550
|
-
await txn.commit();
|
|
551
|
-
return {
|
|
552
|
-
ok: true as const,
|
|
553
|
-
credential: input.replacementCredential,
|
|
554
|
-
digest: digestOf(input.replacementCredential),
|
|
555
|
-
};
|
|
556
|
-
});
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
export type PromoteSeatReplacementOutcome = 'promoted' | 'missing' | 'replaced';
|
|
560
|
-
|
|
561
|
-
/** Update only the committed expiry for an exact same-bearer session renewal. */
|
|
562
|
-
export async function refreshActiveSeatSession(input: {
|
|
563
|
-
ref: string;
|
|
564
|
-
binding: { origin: string; trustIdentity: string; cubeId: string };
|
|
565
|
-
expectedDroneId: string;
|
|
566
|
-
expectedActiveDigest: string;
|
|
567
|
-
expectedSessionId: string;
|
|
568
|
-
expiresAt: string;
|
|
569
|
-
}): Promise<boolean> {
|
|
570
|
-
if (!UUID_RE.test(input.expectedDroneId) || !UUID_RE.test(input.expectedSessionId)) {
|
|
571
|
-
throw new Error('invalid Borg server session identity');
|
|
572
|
-
}
|
|
573
|
-
if (!Number.isFinite(Date.parse(input.expiresAt))) {
|
|
574
|
-
throw new Error('invalid Borg server session expiry');
|
|
575
|
-
}
|
|
576
|
-
return withStore<SeatsFile, boolean>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
|
|
577
|
-
const record = txn.data.seats[input.ref];
|
|
578
|
-
if (
|
|
579
|
-
!recordMatches(record, input.ref, input.binding) ||
|
|
580
|
-
record.state !== 'active' ||
|
|
581
|
-
record.droneId !== input.expectedDroneId ||
|
|
582
|
-
record.sessionId !== input.expectedSessionId ||
|
|
583
|
-
digestOf(record.credential) !== input.expectedActiveDigest
|
|
584
|
-
) {
|
|
585
|
-
return false;
|
|
586
|
-
}
|
|
587
|
-
record.expiresAt = input.expiresAt;
|
|
588
|
-
await txn.commit();
|
|
589
|
-
return true;
|
|
590
|
-
});
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
/** Atomically promote only the response-bound replacement onto the same ACTIVE seat. */
|
|
594
|
-
export async function promoteSeatReplacement(input: {
|
|
595
|
-
ref: string;
|
|
596
|
-
binding: { origin: string; trustIdentity: string; cubeId: string };
|
|
597
|
-
expectedDroneId: string;
|
|
598
|
-
expectedActiveDigest: string;
|
|
599
|
-
expectedReplacementDigest: string;
|
|
600
|
-
sessionId: string;
|
|
601
|
-
expiresAt: string;
|
|
602
|
-
}): Promise<PromoteSeatReplacementOutcome> {
|
|
603
|
-
if (!UUID_RE.test(input.expectedDroneId) || !UUID_RE.test(input.sessionId)) {
|
|
604
|
-
throw new Error('invalid Borg server session identity');
|
|
605
|
-
}
|
|
606
|
-
if (!Number.isFinite(Date.parse(input.expiresAt))) {
|
|
607
|
-
throw new Error('invalid Borg server session expiry');
|
|
608
|
-
}
|
|
609
|
-
return withStore<SeatsFile, PromoteSeatReplacementOutcome>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
|
|
610
|
-
const record = txn.data.seats[input.ref];
|
|
611
|
-
if (!recordMatches(record, input.ref, input.binding) || record.state !== 'active') return 'missing';
|
|
612
|
-
if (
|
|
613
|
-
record.droneId !== input.expectedDroneId ||
|
|
614
|
-
digestOf(record.credential) !== input.expectedActiveDigest ||
|
|
615
|
-
!record.replacement ||
|
|
616
|
-
digestOf(record.replacement.credential) !== input.expectedReplacementDigest
|
|
617
|
-
) {
|
|
618
|
-
return 'replaced';
|
|
619
|
-
}
|
|
620
|
-
const { replacement, ...active } = record;
|
|
621
|
-
txn.data.seats[input.ref] = {
|
|
622
|
-
...active,
|
|
623
|
-
credential: replacement.credential,
|
|
624
|
-
sessionId: input.sessionId,
|
|
625
|
-
expiresAt: input.expiresAt,
|
|
626
|
-
};
|
|
627
|
-
await txn.commit();
|
|
628
|
-
return 'promoted';
|
|
629
|
-
});
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
/** Remove only the caller's exact pending replacement, never the ACTIVE seat. */
|
|
633
|
-
export async function scrubSeatReplacement(input: {
|
|
634
|
-
ref: string;
|
|
635
|
-
binding: { origin: string; trustIdentity: string; cubeId: string };
|
|
636
|
-
expectedActiveDigest: string;
|
|
637
|
-
expectedReplacementDigest: string;
|
|
638
|
-
}): Promise<boolean> {
|
|
639
|
-
return withStore<SeatsFile, boolean>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
|
|
640
|
-
const record = txn.data.seats[input.ref];
|
|
641
|
-
if (
|
|
642
|
-
!recordMatches(record, input.ref, input.binding) ||
|
|
643
|
-
record.state !== 'active' ||
|
|
644
|
-
digestOf(record.credential) !== input.expectedActiveDigest ||
|
|
645
|
-
!record.replacement ||
|
|
646
|
-
digestOf(record.replacement.credential) !== input.expectedReplacementDigest
|
|
647
|
-
) {
|
|
648
|
-
return false;
|
|
649
|
-
}
|
|
650
|
-
delete record.replacement;
|
|
651
|
-
await txn.commit();
|
|
652
|
-
return true;
|
|
653
|
-
});
|
|
654
|
-
}
|
|
655
|
-
|
|
656
505
|
// ─── CR#2: bind a PENDING record to a preserved worktree (no activation) ─────
|
|
657
506
|
|
|
658
507
|
export type BindPendingSeatOutcome = 'bound' | 'missing' | 'replaced';
|