borgmcp 2.0.3 → 2.0.5

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 (76) hide show
  1. package/README.md +2 -2
  2. package/dist/assimilate-cmd.d.ts +1 -2
  3. package/dist/assimilate-cmd.d.ts.map +1 -1
  4. package/dist/assimilate-cmd.js +59 -44
  5. package/dist/assimilate-cmd.js.map +1 -1
  6. package/dist/assimilate-deps.d.ts.map +1 -1
  7. package/dist/assimilate-deps.js +2 -1
  8. package/dist/assimilate-deps.js.map +1 -1
  9. package/dist/cleanup-cmd.d.ts +1 -1
  10. package/dist/cleanup-cmd.d.ts.map +1 -1
  11. package/dist/cleanup-cmd.js +3 -1
  12. package/dist/cleanup-cmd.js.map +1 -1
  13. package/dist/cli-help.d.ts.map +1 -1
  14. package/dist/cli-help.js +2 -1
  15. package/dist/cli-help.js.map +1 -1
  16. package/dist/cubes.d.ts +0 -2
  17. package/dist/cubes.d.ts.map +1 -1
  18. package/dist/cubes.js +0 -2
  19. package/dist/cubes.js.map +1 -1
  20. package/dist/launch-all-cmd.d.ts.map +1 -1
  21. package/dist/launch-all-cmd.js +13 -7
  22. package/dist/launch-all-cmd.js.map +1 -1
  23. package/dist/log-stream.d.ts +0 -2
  24. package/dist/log-stream.d.ts.map +1 -1
  25. package/dist/log-stream.js +3 -30
  26. package/dist/log-stream.js.map +1 -1
  27. package/dist/private-root.d.ts +4 -0
  28. package/dist/private-root.d.ts.map +1 -0
  29. package/dist/private-root.js +39 -0
  30. package/dist/private-root.js.map +1 -0
  31. package/dist/remote-client.d.ts.map +1 -1
  32. package/dist/remote-client.js +8 -23
  33. package/dist/remote-client.js.map +1 -1
  34. package/dist/seat-probe.d.ts +4 -4
  35. package/dist/seat-probe.d.ts.map +1 -1
  36. package/dist/seat-probe.js +2 -0
  37. package/dist/seat-probe.js.map +1 -1
  38. package/dist/seats.d.ts +0 -74
  39. package/dist/seats.d.ts.map +1 -1
  40. package/dist/seats.js +31 -131
  41. package/dist/seats.js.map +1 -1
  42. package/dist/server-errors.d.ts +6 -1
  43. package/dist/server-errors.d.ts.map +1 -1
  44. package/dist/server-errors.js +9 -0
  45. package/dist/server-errors.js.map +1 -1
  46. package/dist/server-facade.d.ts +1 -1
  47. package/dist/server-facade.d.ts.map +1 -1
  48. package/dist/server-facade.js +2 -2
  49. package/dist/server-facade.js.map +1 -1
  50. package/dist/server-handshake.d.ts +1 -3
  51. package/dist/server-handshake.d.ts.map +1 -1
  52. package/dist/server-handshake.js +0 -5
  53. package/dist/server-handshake.js.map +1 -1
  54. package/docs/EXTRACTION_PROVENANCE.md +7 -7
  55. package/docs/LOCAL_SERVER.md +8 -4
  56. package/docs/RELEASING.md +19 -2
  57. package/package.json +2 -2
  58. package/src/assimilate-cmd.ts +65 -52
  59. package/src/assimilate-deps.ts +2 -1
  60. package/src/cleanup-cmd.ts +5 -2
  61. package/src/cli-help.ts +2 -1
  62. package/src/cubes.ts +0 -4
  63. package/src/launch-all-cmd.ts +15 -7
  64. package/src/log-stream.ts +2 -30
  65. package/src/private-root.ts +42 -0
  66. package/src/remote-client.ts +10 -22
  67. package/src/seat-probe.ts +5 -3
  68. package/src/seats.ts +35 -186
  69. package/src/server-errors.ts +9 -2
  70. package/src/server-facade.ts +2 -2
  71. package/src/server-handshake.ts +2 -11
  72. package/dist/session-continuity.d.ts +0 -33
  73. package/dist/session-continuity.d.ts.map +0 -1
  74. package/dist/session-continuity.js +0 -220
  75. package/dist/session-continuity.js.map +0 -1
  76. package/src/session-continuity.ts +0 -280
package/src/log-stream.ts CHANGED
@@ -54,8 +54,7 @@ import {
54
54
  wakeCodexViaAppServer,
55
55
  } from './codex-app-wake.js';
56
56
  import { readBoundedResponseBody } from './server-response.js';
57
- import { BorgServerError, BorgServerUnreachableError } from './server-errors.js';
58
- import { recoverExpiredLocalSession } from './session-continuity.js';
57
+ import { BorgServerError } from './server-errors.js';
59
58
  import {
60
59
  acquireStreamLease,
61
60
  readOwnershipSnapshot,
@@ -428,7 +427,6 @@ export interface RunLoopTestDeps {
428
427
  getActiveCube?: typeof getActiveCube;
429
428
  acquireStreamLease?: typeof acquireStreamLease;
430
429
  streamOnce?: typeof streamOnce;
431
- recoverExpiredSession?: typeof recoverExpiredLocalSession;
432
430
  sleep?: (ms: number) => Promise<void>;
433
431
  maxIterations?: number;
434
432
  }
@@ -437,7 +435,6 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
437
435
  const _getActiveCube = testDeps.getActiveCube ?? getActiveCube;
438
436
  const _acquireStreamLease = testDeps.acquireStreamLease ?? acquireStreamLease;
439
437
  const _streamOnce = testDeps.streamOnce ?? streamOnce;
440
- const _recoverExpiredSession = testDeps.recoverExpiredSession ?? recoverExpiredLocalSession;
441
438
  const _sleep = testDeps.sleep ?? sleep;
442
439
  const _maxIterations = testDeps.maxIterations ?? Infinity;
443
440
  let _iterations = 0;
@@ -447,7 +444,6 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
447
444
  let currentCubeId: string | null = null;
448
445
  let lease: StreamLease | null = null;
449
446
  let leaseKey: string | null = null;
450
- let lastRecoveredToken: string | null = null;
451
447
 
452
448
  try {
453
449
  while (_iterations < _maxIterations) {
@@ -548,7 +544,6 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
548
544
  }
549
545
  // Clean disconnect (e.g. server-side rollout). Reset backoff.
550
546
  attempt = 0;
551
- lastRecoveredToken = null;
552
547
  streamState.reconnectAttempts = 0;
553
548
  } catch (err: any) {
554
549
  if (ownerLost) {
@@ -576,27 +571,7 @@ async function runLoop(testDeps: RunLoopTestDeps = {}): Promise<void> {
576
571
  );
577
572
  throw new TerminalStreamError();
578
573
  }
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
- }
599
- }
574
+ if (err instanceof BorgServerError) throw new TerminalStreamError();
600
575
  streamState.connected = false;
601
576
  const delay =
602
577
  Math.min(RECONNECT_MIN_MS * 2 ** attempt, RECONNECT_MAX_MS) +
@@ -907,9 +882,6 @@ export async function streamOnce(
907
882
  } catch {
908
883
  code = undefined;
909
884
  }
910
- if (code === ErrorCode.AUTH_EXPIRED) {
911
- throw new BorgServerError('AUTH_EXPIRED', 'Borg server session expired');
912
- }
913
885
  if (code === ErrorCode.SESSION_REJECTED) {
914
886
  throw new BorgServerError('SESSION_REJECTED', 'Borg server session was rejected');
915
887
  }
@@ -0,0 +1,42 @@
1
+ import { chmod, lstat, mkdir } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, join, resolve } from 'node:path';
4
+
5
+ export const borgConfigRoot = (): string => join(homedir(), '.config', 'borgmcp');
6
+
7
+ /** Ensure Borg's local state root exists with owner-only directory permissions. */
8
+ export async function ensurePrivateBorgConfigRoot(root = borgConfigRoot()): Promise<void> {
9
+ if (!isAbsolute(root) || resolve(root) !== root) {
10
+ throw new Error('Borg private-state directory path is not canonical');
11
+ }
12
+
13
+ let metadata: Awaited<ReturnType<typeof lstat>>;
14
+ try {
15
+ metadata = await lstat(root);
16
+ } catch (error) {
17
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
18
+ await mkdir(root, { recursive: true, mode: 0o700 });
19
+ metadata = await lstat(root);
20
+ }
21
+
22
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
23
+ throw new Error('Borg private-state directory must be a real directory');
24
+ }
25
+ const uid = typeof process.getuid === 'function' ? process.getuid() : null;
26
+ if (uid !== null && metadata.uid !== uid) {
27
+ throw new Error('Borg private-state directory is not owned by the current user');
28
+ }
29
+
30
+ const mode = metadata.mode & 0o777;
31
+ if ((mode & 0o022) !== 0) {
32
+ throw new Error('Borg private-state directory is writable by other users');
33
+ }
34
+ if (mode !== 0o700) {
35
+ await chmod(root, 0o700);
36
+ }
37
+
38
+ const final = await lstat(root);
39
+ if (!final.isDirectory() || (final.mode & 0o777) !== 0o700) {
40
+ throw new Error('Borg private-state directory is not private');
41
+ }
42
+ }
@@ -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
- let current = active.localSessionCredentialRef && active.localSessionExpiresAt
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: candidate.sessionToken,
239
- apiUrl: candidate.apiUrl,
240
- serverTrustIdentity: candidate.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.AUTH_EXPIRED) {
612
+ if (droneSession !== undefined && rejectedCode === ErrorCode.SESSION_REJECTED) {
625
613
  throw new BorgServerError(
626
- 'AUTH_EXPIRED',
627
- 'the selected Borg server session expired',
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.SESSION_REJECTED) {
618
+ if (droneSession !== undefined && rejectedCode === ErrorCode.SESSION_REVOKED) {
631
619
  throw new BorgServerError(
632
- 'SESSION_REJECTED',
633
- 'the selected Borg server rejected this worktree session (revoked or taken over)',
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
- * rejected ← pin-matched drone-SESSION 401 carrying the EXACT typed
23
- * SESSION_REJECTED code (revoked / taken over; recover via
24
- * the offline `borg reset-local-seat` — never here)
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/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 fields
126
- * (session id / expiry). A single invalid entry ⇒ the whole store is rejected
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 || r.expiresAt !== undefined) return false;
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';
@@ -5,8 +5,7 @@ export type BorgServerErrorCode =
5
5
  | 'CREATE_CUBE_DENIED'
6
6
  | 'ATTACH_CONFLICT'
7
7
  | 'SESSION_REJECTED'
8
- | 'SESSION_REVOKED'
9
- | 'AUTH_EXPIRED';
8
+ | 'SESSION_REVOKED';
10
9
 
11
10
  /** Safe, non-secret state code for deterministic authority recovery copy. */
12
11
  export class BorgServerError extends Error {
@@ -92,4 +91,12 @@ export class BorgServerUnreachableError extends Error {
92
91
  this.name = 'BorgServerUnreachableError';
93
92
  }
94
93
  }
94
+
95
+ /** Exact retired TTL-replacement state: two saved bearers and no safe implicit winner. */
96
+ export class LegacySessionCredentialCollisionError extends Error {
97
+ constructor(public readonly origin: string) {
98
+ super('Local session credential collision detected');
99
+ this.name = 'LegacySessionCredentialCollisionError';
100
+ }
101
+ }
95
102
  import type { ErrorCode } from 'borgmcp-shared/protocol';
@@ -2,7 +2,7 @@ import { spawn as spawnChild, type SpawnOptions } from 'node:child_process';
2
2
  import { constants } from 'node:os';
3
3
  import { serverHelpText } from './cli-help.js';
4
4
 
5
- export const SERVER_LIFECYCLE_COMMANDS = ['setup', 'start', 'status', 'update', 'invite'] as const;
5
+ export const SERVER_LIFECYCLE_COMMANDS = ['setup', 'start', 'stop', 'status', 'update', 'invite'] as const;
6
6
  export type ServerLifecycleCommand = typeof SERVER_LIFECYCLE_COMMANDS[number];
7
7
 
8
8
  export type ParsedServerFacadeArgs =
@@ -86,7 +86,7 @@ function inertCommand(command: string): string {
86
86
  export function unknownServerCommandText(command: string): string {
87
87
  return (
88
88
  `Unknown server command: ${inertCommand(command)}.\n` +
89
- `Available commands: setup, start, status, update, invite.\n` +
89
+ `Available commands: setup, start, stop, status, update, invite.\n` +
90
90
  `Next: run borg server --help.\n`
91
91
  );
92
92
  }
@@ -175,13 +175,12 @@ export interface ServerAttachResult {
175
175
  session: {
176
176
  credentialRef: string;
177
177
  sessionId: string;
178
- expiresAt: string;
179
178
  };
180
179
  result: 'created' | 'reused';
181
180
  }
182
181
 
183
182
  /**
184
- * Attach an enrolled client principal to one granted cube/role over protocol v2.
183
+ * Attach an enrolled client principal to one granted cube/role over protocol v3.
185
184
  * The client CSPRNG-generates the session bearer and persists it PENDING in the
186
185
  * OS keychain (keyed by the stable per-seat identity) BEFORE this request, so an
187
186
  * interrupted/lost response is recovered by re-sending the exact same bearer —
@@ -202,7 +201,7 @@ export interface PreparedServerAttach {
202
201
  cube: ServerAttachResult['cube'];
203
202
  role: ServerAttachResult['role'];
204
203
  drone: ServerAttachResult['drone'];
205
- session: { sessionId: string; expiresAt: string };
204
+ session: { sessionId: string };
206
205
  result: 'created' | 'reused';
207
206
  credentialRef: string;
208
207
  pendingBearerDigest: string;
@@ -304,12 +303,6 @@ export async function sendBorgServerAttach(
304
303
  'Borg server rejected the session: the seat is already bound to another session',
305
304
  );
306
305
  }
307
- if (rejectedCode === ErrorCode.AUTH_EXPIRED) {
308
- throw new BorgServerError(
309
- 'AUTH_EXPIRED',
310
- 'Borg server session expired',
311
- );
312
- }
313
306
  if (rejectedCode === ErrorCode.SESSION_REVOKED) {
314
307
  throw new BorgServerError(
315
308
  'SESSION_REVOKED',
@@ -358,7 +351,6 @@ export async function sendBorgServerAttach(
358
351
  drone: decoded.drone,
359
352
  session: {
360
353
  sessionId: decoded.session.id,
361
- expiresAt: decoded.session.expires_at,
362
354
  },
363
355
  result: decoded.result,
364
356
  credentialRef,
@@ -374,7 +366,6 @@ export async function sendBorgServerAttach(
374
366
  ...seatInput,
375
367
  droneId: decoded.drone.id,
376
368
  sessionId: decoded.session.id,
377
- expiresAt: decoded.session.expires_at,
378
369
  expectedPendingDigest: pendingBearerDigest,
379
370
  worktree: binding.worktree,
380
371
  name: binding.name,
@@ -1,33 +0,0 @@
1
- import { type ActiveCube } from './cubes.js';
2
- import { prepareSeatReplacement, promoteSeatReplacement, refreshActiveSeatSession, scrubSeatReplacement, type SeatRecord } from './seats.js';
3
- export declare const SESSION_RENEWAL_LEAD_MS: number;
4
- interface AttachOutcome {
5
- result: 'created' | 'reused';
6
- droneId: string;
7
- sessionId: string;
8
- expiresAt: string;
9
- }
10
- interface SessionContinuityDeps {
11
- now(): number;
12
- randomCredential(): string;
13
- getSeat(ref: string, binding: {
14
- origin: string;
15
- trustIdentity: string;
16
- cubeId: string;
17
- }): Promise<SeatRecord | null>;
18
- getActive(): Promise<ActiveCube | null>;
19
- getParentCredential(origin: string, trustIdentity: string): Promise<string | null>;
20
- sendAttach(record: SeatRecord, bearer: string, parentCredential: string): Promise<AttachOutcome>;
21
- prepareReplacement: typeof prepareSeatReplacement;
22
- refreshSession: typeof refreshActiveSeatSession;
23
- promoteReplacement: typeof promoteSeatReplacement;
24
- scrubReplacement: typeof scrubSeatReplacement;
25
- }
26
- export declare function ensureLocalSessionFresh(active: ActiveCube): Promise<ActiveCube>;
27
- export declare function recoverExpiredLocalSession(active: ActiveCube): Promise<ActiveCube>;
28
- /** @internal */
29
- export declare function __renewLocalSessionForTest(active: ActiveCube, mode: 'proactive' | 'expired', deps: SessionContinuityDeps): Promise<ActiveCube>;
30
- /** @internal */
31
- export declare function __resetSessionContinuityForTest(): void;
32
- export {};
33
- //# sourceMappingURL=session-continuity.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"session-continuity.d.ts","sourceRoot":"","sources":["../src/session-continuity.ts"],"names":[],"mappings":"AAEA,OAAO,EAAiB,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAK5D,OAAO,EAEL,sBAAsB,EACtB,sBAAsB,EACtB,wBAAwB,EACxB,oBAAoB,EAEpB,KAAK,UAAU,EAChB,MAAM,YAAY,CAAC;AAEpB,eAAO,MAAM,uBAAuB,QAAa,CAAC;AAElD,UAAU,aAAa;IACrB,MAAM,EAAE,SAAS,GAAG,QAAQ,CAAC;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,UAAU,qBAAqB;IAC7B,GAAG,IAAI,MAAM,CAAC;IACd,gBAAgB,IAAI,MAAM,CAAC;IAC3B,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC;IACrH,SAAS,IAAI,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC;IACxC,mBAAmB,CAAC,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACnF,UAAU,CAAC,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IACjG,kBAAkB,EAAE,OAAO,sBAAsB,CAAC;IAClD,cAAc,EAAE,OAAO,wBAAwB,CAAC;IAChD,kBAAkB,EAAE,OAAO,sBAAsB,CAAC;IAClD,gBAAgB,EAAE,OAAO,oBAAoB,CAAC;CAC/C;AA8ND,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CAE/E;AAED,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CAElF;AAED,gBAAgB;AAChB,wBAAgB,0BAA0B,CACxC,MAAM,EAAE,UAAU,EAClB,IAAI,EAAE,WAAW,GAAG,SAAS,EAC7B,IAAI,EAAE,qBAAqB,GAC1B,OAAO,CAAC,UAAU,CAAC,CAErB;AAED,gBAAgB;AAChB,wBAAgB,+BAA+B,IAAI,IAAI,CAEtD"}