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.
Files changed (101) hide show
  1. package/README.md +16 -10
  2. package/dist/assimilate-cmd.d.ts +0 -2
  3. package/dist/assimilate-cmd.d.ts.map +1 -1
  4. package/dist/assimilate-cmd.js +51 -43
  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 +0 -1
  8. package/dist/assimilate-deps.js.map +1 -1
  9. package/dist/claude.d.ts +1 -0
  10. package/dist/claude.d.ts.map +1 -1
  11. package/dist/claude.js +5 -0
  12. package/dist/claude.js.map +1 -1
  13. package/dist/cleanup-cmd.d.ts +1 -1
  14. package/dist/cleanup-cmd.d.ts.map +1 -1
  15. package/dist/cleanup-cmd.js +3 -1
  16. package/dist/cleanup-cmd.js.map +1 -1
  17. package/dist/cli-help.d.ts +2 -0
  18. package/dist/cli-help.d.ts.map +1 -1
  19. package/dist/cli-help.js +14 -1
  20. package/dist/cli-help.js.map +1 -1
  21. package/dist/config.d.ts.map +1 -1
  22. package/dist/config.js +41 -18
  23. package/dist/config.js.map +1 -1
  24. package/dist/credential-paths.d.ts +3 -0
  25. package/dist/credential-paths.d.ts.map +1 -0
  26. package/dist/credential-paths.js +7 -0
  27. package/dist/credential-paths.js.map +1 -0
  28. package/dist/cubes.d.ts +0 -2
  29. package/dist/cubes.d.ts.map +1 -1
  30. package/dist/cubes.js +0 -2
  31. package/dist/cubes.js.map +1 -1
  32. package/dist/launch-all-cmd.d.ts.map +1 -1
  33. package/dist/launch-all-cmd.js +13 -7
  34. package/dist/launch-all-cmd.js.map +1 -1
  35. package/dist/log-stream.d.ts +0 -2
  36. package/dist/log-stream.d.ts.map +1 -1
  37. package/dist/log-stream.js +3 -30
  38. package/dist/log-stream.js.map +1 -1
  39. package/dist/remote-client.d.ts.map +1 -1
  40. package/dist/remote-client.js +8 -23
  41. package/dist/remote-client.js.map +1 -1
  42. package/dist/seat-probe.d.ts +4 -4
  43. package/dist/seat-probe.d.ts.map +1 -1
  44. package/dist/seat-probe.js +2 -0
  45. package/dist/seat-probe.js.map +1 -1
  46. package/dist/seat-store.d.ts +11 -5
  47. package/dist/seat-store.d.ts.map +1 -1
  48. package/dist/seat-store.js +147 -14
  49. package/dist/seat-store.js.map +1 -1
  50. package/dist/seats.d.ts +0 -74
  51. package/dist/seats.d.ts.map +1 -1
  52. package/dist/seats.js +31 -131
  53. package/dist/seats.js.map +1 -1
  54. package/dist/server-errors.d.ts +6 -1
  55. package/dist/server-errors.d.ts.map +1 -1
  56. package/dist/server-errors.js +9 -0
  57. package/dist/server-errors.js.map +1 -1
  58. package/dist/server-facade.d.ts +50 -0
  59. package/dist/server-facade.d.ts.map +1 -0
  60. package/dist/server-facade.js +126 -0
  61. package/dist/server-facade.js.map +1 -0
  62. package/dist/server-handshake.d.ts +0 -2
  63. package/dist/server-handshake.d.ts.map +1 -1
  64. package/dist/server-handshake.js +0 -5
  65. package/dist/server-handshake.js.map +1 -1
  66. package/dist/token-store.d.ts +2 -1
  67. package/dist/token-store.d.ts.map +1 -1
  68. package/dist/token-store.js +5 -3
  69. package/dist/token-store.js.map +1 -1
  70. package/dist/unknown-subcommand.d.ts +1 -1
  71. package/dist/unknown-subcommand.d.ts.map +1 -1
  72. package/dist/unknown-subcommand.js +1 -0
  73. package/dist/unknown-subcommand.js.map +1 -1
  74. package/docs/EXTRACTION_PROVENANCE.md +6 -5
  75. package/docs/LOCAL_SERVER.md +86 -43
  76. package/docs/RELEASING.md +19 -1
  77. package/package.json +1 -1
  78. package/src/assimilate-cmd.ts +54 -51
  79. package/src/assimilate-deps.ts +0 -1
  80. package/src/claude.ts +5 -0
  81. package/src/cleanup-cmd.ts +5 -2
  82. package/src/cli-help.ts +17 -1
  83. package/src/config.ts +40 -18
  84. package/src/credential-paths.ts +8 -0
  85. package/src/cubes.ts +0 -4
  86. package/src/launch-all-cmd.ts +15 -7
  87. package/src/log-stream.ts +2 -30
  88. package/src/remote-client.ts +10 -22
  89. package/src/seat-probe.ts +5 -3
  90. package/src/seat-store.ts +159 -16
  91. package/src/seats.ts +35 -186
  92. package/src/server-errors.ts +9 -2
  93. package/src/server-facade.ts +192 -0
  94. package/src/server-handshake.ts +1 -10
  95. package/src/token-store.ts +9 -4
  96. package/src/unknown-subcommand.ts +1 -0
  97. package/dist/session-continuity.d.ts +0 -33
  98. package/dist/session-continuity.d.ts.map +0 -1
  99. package/dist/session-continuity.js +0 -220
  100. package/dist/session-continuity.js.map +0 -1
  101. package/src/session-continuity.ts +0 -280
@@ -42,7 +42,7 @@ import { installBorgPlugin } from './opencode-plugin.js';
42
42
  import { computeOpenCodePort, connectOpenCodeDrone, createOpenCodeLaunchKickoff, injectInitialKickoff } from './opencode-drone.js';
43
43
  import { ensureCliMcpConfigured } from './ensure-mcp-config.js';
44
44
  import { normalizeServerEndpoint } from './server-endpoint.js';
45
- import { BorgServerError } from './server-errors.js';
45
+ import { BorgServerError, LegacySessionCredentialCollisionError } from './server-errors.js';
46
46
  import type { SeatStatus } from './seat-probe.js';
47
47
  import type { ServerSessionOperation } from './config.js';
48
48
  import type { ExpectedBinding, FinalizeServerSeatOutcome, PersistedLocalSeat } from './cubes.js';
@@ -92,7 +92,6 @@ export interface AssimilateResult {
92
92
  role_id: string;
93
93
  local_session?: {
94
94
  credential_ref: string;
95
- expires_at: string | null;
96
95
  };
97
96
  // Idempotent-reattach discriminant: 'reused' when the server resolved the
98
97
  // client bearer to an existing seat, 'created' on a first/fresh attach.
@@ -126,7 +125,6 @@ export interface ActiveCube {
126
125
  /** Verified local-server CA identity; absent until a local server is selected. */
127
126
  serverTrustIdentity?: string;
128
127
  localSessionCredentialRef?: string;
129
- localSessionExpiresAt?: string | null;
130
128
  // gh#899: assimilated role, persisted for connect-time tool-surface scoping
131
129
  // (mirrors cubes.ts ActiveCube; optional → backward-compatible).
132
130
  roleName?: string;
@@ -388,25 +386,25 @@ function reportServerFailure(
388
386
  );
389
387
  return 1;
390
388
  }
389
+ if (error instanceof LegacySessionCredentialCollisionError) {
390
+ deps.stderr(
391
+ `Local session credential collision detected.\n` +
392
+ `No local credentials were changed.\n` +
393
+ `Next: run borg assimilate --host ${error.origin} --enroll.\n`,
394
+ );
395
+ return 1;
396
+ }
391
397
  // A pin-matched typed 401: the server verified its own identity but rejected
392
- // THIS worktree's session bearer (revoked, or taken over by another session).
398
+ // THIS worktree's session bearer with an explicit terminal outcome.
393
399
  // Distinct from a protocol/version mismatch and from a rejected enrollment:
394
400
  // only this worktree's saved local seat is affected, and recovery is scoped to
395
401
  // this worktree — no server/trust-anchor/cube/other-worktree reset, no restart
396
402
  // or version-alignment advice (#1082).
403
+ if (error instanceof BorgServerError && error.code === 'SESSION_REVOKED') {
404
+ return diagnoseSessionTermination(deps, apiUrl, 'revoked');
405
+ }
397
406
  if (error instanceof BorgServerError && error.code === 'SESSION_REJECTED') {
398
- // Attach is PURE DIAGNOSIS on a rejection — it mutates nothing. This copy
399
- // claims NO mutation of its own; it points at the dedicated OFFLINE
400
- // `borg reset-local-seat` command (the only writer that clears a seat).
401
- deps.stderr(
402
- `This worktree's saved local seat on ${apiUrl} is no longer accepted — it was ` +
403
- 'revoked or taken over by another session. No Borg state changed. Run ' +
404
- `${resetLocalSeatCommand(apiUrl)} to clear ONLY this worktree's saved seat ` +
405
- '(offline; add `--yes` when non-interactive), then ask the server operator for a new ' +
406
- 'invitation — the server can stay running — and re-enroll with ' +
407
- `${localAssimilateCommand(apiUrl, true)}.\n`,
408
- );
409
- return 1;
407
+ return diagnoseSessionTermination(deps, apiUrl, 'superseded');
410
408
  }
411
409
  if (error instanceof BorgServerError && error.code === 'INVITATION_REJECTED') {
412
410
  deps.stderr(
@@ -482,34 +480,19 @@ function resetLocalSeatCommand(apiUrl: string): string {
482
480
  return `\`borg reset-local-seat --host ${apiUrl}\``;
483
481
  }
484
482
 
485
- /**
486
- * Pin-matched SESSION_REJECTED diagnosis (#1082). PURE DIAGNOSIS attach makes
487
- * ZERO local mutation on a rejected seat (ratified client-seat-reset-state-model
488
- * clause 1). The local server verified its OWN pinned identity (this is reached
489
- * only after a successful pinned-TLS attach — a pin MISMATCH throws a distinct
490
- * trust error and never lands here), then rejected THIS worktree's saved session
491
- * bearer (revoked, or taken over by another session). We stop and recommend the
492
- * dedicated OFFLINE `borg reset-local-seat` command, which is the ONLY writer
493
- * that clears a worktree's saved seat. Nothing here contacts the server further,
494
- * emits unselected egress, or touches local state. The recovery is
495
- * live-safe: the operator is never told to stop/restart the server.
496
- */
497
- function diagnoseSessionRejected(
483
+ // Pin-matched terminal session diagnosis. This is intentionally output-only:
484
+ // only the explicit offline reset command may clear the saved local seat.
485
+ function diagnoseSessionTermination(
498
486
  deps: AssimilateDeps,
499
487
  apiUrl: string,
488
+ outcome: 'revoked' | 'superseded',
500
489
  ): number {
501
- const worktree = deps.findProjectRoot(deps.cwd());
502
- deps.stderr(
503
- `This worktree's saved local seat on ${apiUrl} is no longer accepted — it was ` +
504
- 'revoked or taken over by another session. No Borg state was changed: your pinned ' +
505
- 'server trust, this and every other worktree, and their cubes are all untouched.\n',
506
- );
490
+ const message = outcome === 'revoked'
491
+ ? 'Local session was revoked.'
492
+ : 'Local session was superseded by a newer enrollment.';
507
493
  deps.stderr(
508
- `To clear ONLY this worktree's saved local seat (worktree ${worktree}), run ` +
509
- `${resetLocalSeatCommand(apiUrl)} (add \`--yes\` when non-interactive). That command is ` +
510
- 'offline — it revokes nothing server-side. Then ask the server operator for a new ' +
511
- 'invitation (the server can stay running) and re-enroll with ' +
512
- `${localAssimilateCommand(apiUrl, true)}.\n`,
494
+ `${message}\n` +
495
+ `Next: run borg reset-local-seat, then borg assimilate --host ${apiUrl} --enroll.\n`,
513
496
  );
514
497
  return 1;
515
498
  }
@@ -534,9 +517,28 @@ export async function runAssimilate(
534
517
  }
535
518
  }
536
519
 
520
+ // Read local seat state before authority discovery, which may probe the local
521
+ // server. A retired replacement collision must not send either saved bearer or
522
+ // perform any other network request.
523
+ let existing: ActiveCube | null = null;
524
+ let hasPersistedIdentity = false;
525
+ let localSeatReadError: unknown;
526
+ try {
527
+ existing = await deps.getActiveCube();
528
+ hasPersistedIdentity = existing !== null || await deps.hasPersistedActiveCube();
529
+ } catch (error) {
530
+ if (error instanceof LegacySessionCredentialCollisionError) {
531
+ return reportServerFailure(deps, error.origin, error);
532
+ }
533
+ localSeatReadError = error;
534
+ }
535
+
537
536
  // ----- Step 1: Select and authenticate the local server -----
538
537
  const authority = await selectAssimilationAuthority(args.flags, deps);
539
538
  if (!authority) return 1;
539
+ if (localSeatReadError !== undefined) {
540
+ return reportServerFailure(deps, authority.apiUrl, localSeatReadError);
541
+ }
540
542
 
541
543
  // ----- Repository + cube-name preflight -----
542
544
  // Resolve and, where necessary, confirm local presentation data before an
@@ -766,8 +768,6 @@ export async function runAssimilate(
766
768
  // retain its original role so the attach request reuses the exact durable
767
769
  // retry binding instead of selecting another unoccupied role and minting a
768
770
  // duplicate seat.
769
- const existing = await deps.getActiveCube();
770
- const hasPersistedIdentity = existing !== null || await deps.hasPersistedActiveCube();
771
771
  const wantSibling =
772
772
  args.flags.worktree !== undefined || (existing !== null && !args.flags.here);
773
773
  // `let`: the bound-pending resume path (CR#2) OVERRIDES this from the stored
@@ -943,8 +943,11 @@ export async function runAssimilate(
943
943
  // rejection; it points at the offline `borg reset-local-seat` command.
944
944
  // Distinct from unreachable/404/5xx/trust-mismatch, which stay
945
945
  // indeterminate below.
946
+ if (status === 'revoked') {
947
+ return diagnoseSessionTermination(deps, auth.apiUrl, 'revoked');
948
+ }
946
949
  if (status === 'rejected') {
947
- return diagnoseSessionRejected(deps, auth.apiUrl);
950
+ return diagnoseSessionTermination(deps, auth.apiUrl, 'superseded');
948
951
  }
949
952
  // CR #6: distinct causes get cause-accurate, non-destructive recovery —
950
953
  // never the generic "restart the server" advice.
@@ -1192,17 +1195,18 @@ export async function runAssimilate(
1192
1195
  );
1193
1196
  return 1;
1194
1197
  }
1195
- // Pin-matched SESSION_REJECTED (revoked / taken-over seat): PURE DIAGNOSIS.
1198
+ // Pin-matched terminal session outcomes are pure diagnosis.
1196
1199
  // Reached only after a successful pinned-TLS attach, so it is pin-matched by
1197
1200
  // construction — a pin mismatch throws a distinct trust error and never
1198
1201
  // enters this branch. Attach mutates NOTHING; it recommends the offline
1199
1202
  // `borg reset-local-seat` command.
1200
- if (
1201
- err instanceof BorgServerError &&
1202
- err.code === 'SESSION_REJECTED' &&
1203
- reattachPriorId != null
1204
- ) {
1205
- return diagnoseSessionRejected(deps, authority.apiUrl);
1203
+ if (err instanceof BorgServerError && reattachPriorId != null) {
1204
+ if (err.code === 'SESSION_REVOKED') {
1205
+ return diagnoseSessionTermination(deps, authority.apiUrl, 'revoked');
1206
+ }
1207
+ if (err.code === 'SESSION_REJECTED') {
1208
+ return diagnoseSessionTermination(deps, authority.apiUrl, 'superseded');
1209
+ }
1206
1210
  }
1207
1211
  if (authority.kind === 'server') {
1208
1212
  return reportServerFailure(deps, authority.apiUrl, err);
@@ -1389,7 +1393,6 @@ export async function runAssimilate(
1389
1393
  apiUrl: auth.apiUrl,
1390
1394
  serverTrustIdentity: auth.serverTrustIdentity,
1391
1395
  localSessionCredentialRef: result.local_session!.credential_ref,
1392
- localSessionExpiresAt: result.local_session!.expires_at,
1393
1396
  // gh#899: persist the assimilated role so the connect-time ListTools
1394
1397
  // handler can role-scope the native tool surface.
1395
1398
  roleName: assignedRole.name,
@@ -352,7 +352,6 @@ export function buildDefaultAssimilateDeps(): AssimilateDeps {
352
352
  result: prepared.result,
353
353
  local_session: {
354
354
  credential_ref: prepared.credentialRef,
355
- expires_at: prepared.session.expiresAt,
356
355
  },
357
356
  // Handles for the cube-lock-held FINALIZE (assimilate-cmd Step 8).
358
357
  finalize: {
package/src/claude.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  * fresh drone inside it (see spawn.ts)
15
15
  * borg sync → Advance the current worktree across the 5
16
16
  * lifecycle states (see sync.ts, gh#33)
17
+ * borg server <cmd> → Forward a lifecycle command to borg-mcp-server
17
18
  */
18
19
 
19
20
  import { spawn } from 'child_process';
@@ -78,8 +79,12 @@ import { ensureCliMcpConfigured } from './ensure-mcp-config.js';
78
79
  import { installBorgPlugin } from './opencode-plugin.js';
79
80
  import { connectOpenCodeDrone, computeOpenCodePort, createOpenCodeLaunchKickoff, injectInitialKickoff } from './opencode-drone.js';
80
81
  import { buildOpenCodeLaunchArgs, defaultApprovalIo, resolveLaunchBorgApprovals } from './cli-tool-approval.js';
82
+ import { runEarlyServerFacade } from './server-facade.js';
81
83
 
82
84
  async function main() {
85
+ const serverExitCode = await runEarlyServerFacade(process.argv);
86
+ if (serverExitCode !== null) process.exit(serverExitCode);
87
+
83
88
  // `--debug` / BORG_DEBUG: enable HTTP request/response logging to stderr
84
89
  // (observability for failures like the cross-account assimilate 404).
85
90
  // Done first so debug covers everything below; strips `--debug` from argv
@@ -69,7 +69,8 @@ export type CleanupReason =
69
69
  | 'SURVIVES-clobber'
70
70
  | 'SURVIVES-unmerged'
71
71
  | 'SURVIVES-detached'
72
- | 'SURVIVES-rejected'
72
+ | 'SURVIVES-revoked'
73
+ | 'SURVIVES-superseded'
73
74
  | 'SURVIVES-live'
74
75
  | 'SURVIVES-self'
75
76
  | 'UNKNOWN-indeterminate'
@@ -345,7 +346,9 @@ async function classifyWorktree(
345
346
  case 'evicted':
346
347
  return { reason: 'PRUNABLE', detail: '410 DRONE_EVICTED (clean + merged)' };
347
348
  case 'rejected':
348
- return { reason: 'SURVIVES-rejected', detail: 'pin-matched 401 (revoked/taken over) — recoverable via re-enroll, never delete' };
349
+ return { reason: 'SURVIVES-superseded', detail: 'session superseded by a newer enrollment — recoverable via re-enroll, never delete' };
350
+ case 'revoked':
351
+ return { reason: 'SURVIVES-revoked', detail: 'session revoked — recoverable via re-enroll, never delete' };
349
352
  case 'live':
350
353
  return { reason: 'SURVIVES-live', detail: 'seat resolves (drone alive)' };
351
354
  case 'indeterminate':
package/src/cli-help.ts CHANGED
@@ -36,12 +36,28 @@ export function topLevelHelpText(version: string): string {
36
36
  ` borg launch-all [cube] Launch all drone worktrees of a cube (default: active cube)\n` +
37
37
  ` borg launch-all [cube] --cli claude|codex|opencode\n` +
38
38
  ` Launch all drone worktrees with that agent CLI\n` +
39
+ ` borg server <command> [arguments]\n` +
39
40
  ` borg --cli claude|codex|opencode Launch that agent CLI directly\n` +
40
41
  ` borg --version Show installed version\n\n` +
41
42
  `All other arguments are passed through to the selected agent CLI.\n`
42
43
  );
43
44
  }
44
45
 
46
+ /** Product Design-approved client-owned copy for `borg server --help`. */
47
+ export function serverHelpText(): string {
48
+ return (
49
+ `Usage: borg server <command> [arguments]\n\n` +
50
+ `Commands:\n` +
51
+ ` setup Prepare local server identity and data; does not start the server.\n` +
52
+ ` start Start the verified server in the foreground.\n` +
53
+ ` stop Stop the managed local server.\n` +
54
+ ` status Report verified runtime evidence.\n` +
55
+ ` update Verify and activate a local server artifact.\n` +
56
+ ` invite Create a single-use invitation in an interactive terminal.\n\n` +
57
+ `Run borg server <command> --help for server command options.\n`
58
+ );
59
+ }
60
+
45
61
  /**
46
62
  * Help text for `borg assimilate --help` — the home for the full assimilate flag
47
63
  * set. Model/provider configuration belongs to the selected agent CLI.
@@ -87,7 +103,7 @@ export function resetLocalSeatHelpText(version: string): string {
87
103
  `just this worktree's saved local seat — its credential and cube binding together — from the\n` +
88
104
  `local seat store on this machine. Server, trust anchor, cube, and every sibling worktree are\n` +
89
105
  `left untouched.\n\n` +
90
- `Use it after \`borg assimilate\` reports this worktree's seat was revoked or taken over\n` +
106
+ `Use it after \`borg assimilate\` reports this worktree's session was revoked or superseded\n` +
91
107
  `(a pin-matched rejection), then ask the operator for a new invitation and re-enroll.\n\n` +
92
108
  `Usage:\n` +
93
109
  ` borg reset-local-seat Reset this worktree's saved seat (TTY confirms [y/N])\n` +
package/src/config.ts CHANGED
@@ -1,14 +1,11 @@
1
1
  /**
2
2
  * Secure local-server credential storage.
3
3
  *
4
- * The self-hosted-server credential group (enrollment credentials, pending
5
- * enrollment/cube-creation records, and the per-seat drone-session bearers)
6
- * rests ONLY in the 0600 credential file store (Queen rescope) parity with the
7
- * server's own TLS private keys. The OS keychain is gone; the raw secret never
8
- * leaves the 0600 file, and a single store flock serializes every read-compare-write.
4
+ * Parent enrollment credentials and pending enrollment/cube-creation records
5
+ * rest ONLY in the canonical 0600 credential file. Per-seat session credentials
6
+ * remain in the separate seat store. A single flock serializes every parent-store
7
+ * read-compare-write shared by client enrollment and same-machine server setup.
9
8
  */
10
- import os from 'os';
11
- import path from 'path';
12
9
  import { createHash, randomBytes, randomUUID } from 'crypto';
13
10
  import type { ServerCapability } from 'borgmcp-shared/protocol';
14
11
  import { withStoreLock } from './seat-store.js';
@@ -16,14 +13,15 @@ import {
16
13
  makeFileBackend,
17
14
  type TokenBackend,
18
15
  } from './token-store.js';
16
+ import { BORG_USER_ROOT, SERVER_CREDENTIALS_FILE } from './credential-paths.js';
19
17
 
20
18
  const SERVER_CREDENTIAL_RECORD_VERSION = 2 as const;
21
19
  const SERVER_PENDING_ENROLLMENT_RECORD_VERSION = 1 as const;
22
20
  const SERVER_CUBE_RETRY_RECORD_VERSION = 1 as const;
23
21
  // The 0600 credential store (Queen rescope: replaces the OS keychain). A single
24
- // file holds every server credential/session/enrollment record; a single flock
22
+ // file holds every parent credential/enrollment record; a single flock
25
23
  // serializes every mutator + observer that must (SR-seven #4).
26
- const CREDENTIALS_FILE = path.join(os.homedir(), '.config', 'borgmcp', 'credentials.json');
24
+ const CREDENTIALS_FILE = SERVER_CREDENTIALS_FILE;
27
25
  const CREDENTIALS_LOCK = `${CREDENTIALS_FILE}.lock`;
28
26
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
29
27
 
@@ -199,15 +197,39 @@ function serverCubeRetryAccount(
199
197
  // parity with the server's TLS keys; no OS keychain, no obfuscation-grade
200
198
  // fallback). The single store lock (CREDENTIALS_LOCK) serializes the RCW.
201
199
  let serverCredentialBackendPromise: Promise<TokenBackend> | null = null;
200
+ let testBackendInjected = false;
201
+ let testLockTail: Promise<void> = Promise.resolve();
202
+ async function withCredentialStoreLock<T>(operation: () => Promise<T>): Promise<T> {
203
+ if (!testBackendInjected) {
204
+ return withStoreLock(CREDENTIALS_LOCK, operation, {
205
+ secureRoot: BORG_USER_ROOT,
206
+ rootMode: 'owner-controlled',
207
+ });
208
+ }
209
+ const prior = testLockTail;
210
+ let release!: () => void;
211
+ testLockTail = new Promise<void>((resolveLock) => { release = resolveLock; });
212
+ await prior;
213
+ try {
214
+ return await operation();
215
+ } finally {
216
+ release();
217
+ }
218
+ }
202
219
  async function getServerCredentialBackend(): Promise<TokenBackend> {
203
220
  if (!serverCredentialBackendPromise) {
204
- serverCredentialBackendPromise = Promise.resolve(makeFileBackend(CREDENTIALS_FILE));
221
+ serverCredentialBackendPromise = Promise.resolve(makeFileBackend(CREDENTIALS_FILE, {
222
+ secureRoot: BORG_USER_ROOT,
223
+ rootMode: 'owner-controlled',
224
+ }));
205
225
  }
206
226
  return serverCredentialBackendPromise;
207
227
  }
208
228
 
209
229
  /** Test-only credential-store backend injection. */
210
230
  export function __setServerCredentialBackendForTest(backend: TokenBackend | null): void {
231
+ testBackendInjected = backend !== null;
232
+ testLockTail = Promise.resolve();
211
233
  serverCredentialBackendPromise = backend ? Promise.resolve(backend) : null;
212
234
  }
213
235
 
@@ -252,7 +274,7 @@ async function writeServerCredentialRecord(
252
274
  */
253
275
  export async function storeServerCredential(record: ServerCredentialRecord): Promise<void> {
254
276
  const backend = await getServerCredentialBackend();
255
- await withStoreLock(CREDENTIALS_LOCK, () => writeServerCredentialRecord(backend, record));
277
+ await withCredentialStoreLock(() => writeServerCredentialRecord(backend, record));
256
278
  }
257
279
 
258
280
  /** Read an authority-bound active client record, failing closed on corruption. */
@@ -346,7 +368,7 @@ export async function getPendingServerEnrollment(
346
368
  validateServerCredentialBinding(origin, trustIdentity);
347
369
  const backend = await getServerCredentialBackend();
348
370
  const account = serverPendingEnrollmentAccount(origin, trustIdentity);
349
- return withStoreLock(CREDENTIALS_LOCK, async () => {
371
+ return withCredentialStoreLock(async () => {
350
372
  const stored = await backend.get(account);
351
373
  if (!stored) return null;
352
374
  try {
@@ -375,7 +397,7 @@ export async function getOrCreatePendingServerEnrollment(
375
397
  validateClientName(input.clientName);
376
398
  const backend = await getServerCredentialBackend();
377
399
  const account = serverPendingEnrollmentAccount(input.origin, input.trustIdentity);
378
- return withStoreLock(CREDENTIALS_LOCK, async () => {
400
+ return withCredentialStoreLock(async () => {
379
401
  const stored = await backend.get(account);
380
402
  if (stored) {
381
403
  try {
@@ -432,7 +454,7 @@ export async function activatePendingServerEnrollment(
432
454
  const serverCapabilities = validateServerCapabilities(input.serverCapabilities);
433
455
  const backend = await getServerCredentialBackend();
434
456
  const pendingAccount = serverPendingEnrollmentAccount(input.origin, input.trustIdentity);
435
- await withStoreLock(CREDENTIALS_LOCK, async () => {
457
+ await withCredentialStoreLock(async () => {
436
458
  const stored = await backend.get(pendingAccount);
437
459
  if (!stored) throw new Error('pending Borg server enrollment is missing');
438
460
  try {
@@ -469,7 +491,7 @@ export async function clearPendingServerEnrollment(
469
491
  validateUuid(retryKey, 'enrollment retry key');
470
492
  const backend = await getServerCredentialBackend();
471
493
  const account = serverPendingEnrollmentAccount(origin, trustIdentity);
472
- await withStoreLock(CREDENTIALS_LOCK, async () => {
494
+ await withCredentialStoreLock(async () => {
473
495
  const stored = await backend.get(account);
474
496
  if (!stored) return;
475
497
  try {
@@ -510,7 +532,7 @@ export async function getOrCreatePendingServerCubeCreation(
510
532
  input.clientId,
511
533
  repositoryBinding,
512
534
  );
513
- return withStoreLock(CREDENTIALS_LOCK, async () => {
535
+ return withCredentialStoreLock(async () => {
514
536
  const stored = await backend.get(account);
515
537
  if (stored) {
516
538
  try {
@@ -572,7 +594,7 @@ export async function clearPendingServerCubeCreation(
572
594
  record.clientId,
573
595
  record.repositoryBinding,
574
596
  );
575
- await withStoreLock(CREDENTIALS_LOCK, async () => {
597
+ await withCredentialStoreLock(async () => {
576
598
  const stored = await backend.get(account);
577
599
  if (!stored) return;
578
600
  try {
@@ -588,7 +610,7 @@ export async function clearPendingServerCubeCreation(
588
610
  export async function clearServerCredential(origin: string, trustIdentity: string): Promise<void> {
589
611
  const backend = await getServerCredentialBackend();
590
612
  const pendingAccount = serverPendingEnrollmentAccount(origin, trustIdentity);
591
- await withStoreLock(CREDENTIALS_LOCK, async () => {
613
+ await withCredentialStoreLock(async () => {
592
614
  await backend.delete(serverCredentialAccount(origin, trustIdentity));
593
615
  await backend.delete(pendingAccount);
594
616
  });
@@ -0,0 +1,8 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ const canonicalHome = realpathSync(homedir());
6
+
7
+ export const BORG_USER_ROOT = join(canonicalHome, '.borg');
8
+ export const SERVER_CREDENTIALS_FILE = join(BORG_USER_ROOT, 'credentials');
package/src/cubes.ts CHANGED
@@ -62,7 +62,6 @@ export interface ActiveCube {
62
62
  serverTrustIdentity?: string;
63
63
  /** Opaque local-session keychain reference; never a bearer. */
64
64
  localSessionCredentialRef?: string;
65
- localSessionExpiresAt?: string | null;
66
65
  // gh#899: the assimilated role, persisted so the connect-time ListTools
67
66
  // handler can role-scope the NATIVE tool surface (UX/context only — never an
68
67
  // auth boundary). Absent on pre-gh#899 cubes.json entries → the filter
@@ -280,7 +279,6 @@ async function hydrateActiveCube(record: SeatRecord): Promise<ActiveCube | null>
280
279
  apiUrl: record.origin,
281
280
  serverTrustIdentity: record.trustIdentity,
282
281
  localSessionCredentialRef: ref,
283
- ...(record.expiresAt !== undefined ? { localSessionExpiresAt: record.expiresAt } : {}),
284
282
  ...(record.roleName !== undefined ? { roleName: record.roleName } : {}),
285
283
  ...(record.roleClass !== undefined ? { roleClass: record.roleClass } : {}),
286
284
  ...(record.isHumanSeat !== undefined ? { isHumanSeat: record.isHumanSeat } : {}),
@@ -392,7 +390,6 @@ export interface PersistedLocalSeat {
392
390
  apiUrl: string;
393
391
  serverTrustIdentity: string;
394
392
  localSessionCredentialRef: string;
395
- localSessionExpiresAt?: string | null;
396
393
  roleName?: string;
397
394
  roleClass?: 'queen' | 'worker';
398
395
  isHumanSeat?: boolean;
@@ -432,7 +429,6 @@ export async function readPersistedLocalSeat(): Promise<PersistedLocalSeat | nul
432
429
  localSessionCredentialRef: seatRef(record),
433
430
  operation: record.operation,
434
431
  state: record.state,
435
- ...(record.expiresAt !== undefined ? { localSessionExpiresAt: record.expiresAt } : {}),
436
432
  ...(record.roleName !== undefined ? { roleName: record.roleName } : {}),
437
433
  ...(record.roleClass !== undefined ? { roleClass: record.roleClass } : {}),
438
434
  ...(record.isHumanSeat !== undefined ? { isHumanSeat: record.isHumanSeat } : {}),
@@ -276,6 +276,7 @@ export async function runLaunchAll(
276
276
  // LOCK-live (seemingly-running) session.
277
277
  const launchable: DroneCandidate[] = [];
278
278
  let evictedCount = 0;
279
+ let revokedCount = 0;
279
280
  let rejectedCount = 0;
280
281
  let trustMismatchCount = 0;
281
282
  let credentialRejectedCount = 0;
@@ -294,15 +295,19 @@ export async function runLaunchAll(
294
295
  );
295
296
  continue;
296
297
  }
298
+ if (status === 'revoked') {
299
+ revokedCount += 1;
300
+ deps.stderr(
301
+ `Local session was revoked.\n` +
302
+ `Next: run borg reset-local-seat, then borg assimilate --host ${c.apiUrl} --enroll.\n`
303
+ );
304
+ continue;
305
+ }
297
306
  if (status === 'rejected') {
298
307
  rejectedCount += 1;
299
308
  deps.stderr(
300
- `skipping ${c.droneLabel} (${c.worktreeDir}): saved seat no longer accepted (revoked or ` +
301
- `taken over). Recover from that worktree: ` +
302
- `1) \`borg reset-local-seat --host ${c.apiUrl}\` (add --yes non-interactively) to clear ` +
303
- `ONLY that worktree's saved seat; ` +
304
- `2) ask the server operator to mint a fresh scoped invitation (the server stays running); ` +
305
- `3) \`borg assimilate --host ${c.apiUrl} --enroll\`.\n`
309
+ `Local session was superseded by a newer enrollment.\n` +
310
+ `Next: run borg reset-local-seat, then borg assimilate --host ${c.apiUrl} --enroll.\n`
306
311
  );
307
312
  continue;
308
313
  }
@@ -355,8 +360,11 @@ export async function runLaunchAll(
355
360
  if (evictedCount > 0) {
356
361
  causes.push(`${evictedCount} evicted`);
357
362
  }
363
+ if (revokedCount > 0) {
364
+ causes.push(`${revokedCount} revoked`);
365
+ }
358
366
  if (rejectedCount > 0) {
359
- causes.push(`${rejectedCount} with a saved seat no longer accepted (revoked/taken over)`);
367
+ causes.push(`${rejectedCount} superseded`);
360
368
  }
361
369
  if (trustMismatchCount > 0) {
362
370
  causes.push(`${trustMismatchCount} with a changed (terminal) server identity`);
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
  }