borgmcp 2.0.1 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +16 -10
  2. package/dist/claude.d.ts +1 -0
  3. package/dist/claude.d.ts.map +1 -1
  4. package/dist/claude.js +5 -0
  5. package/dist/claude.js.map +1 -1
  6. package/dist/cli-help.d.ts +2 -0
  7. package/dist/cli-help.d.ts.map +1 -1
  8. package/dist/cli-help.js +12 -0
  9. package/dist/cli-help.js.map +1 -1
  10. package/dist/codex-app-wake.d.ts +9 -11
  11. package/dist/codex-app-wake.d.ts.map +1 -1
  12. package/dist/codex-app-wake.js +15 -11
  13. package/dist/codex-app-wake.js.map +1 -1
  14. package/dist/codex-wake-resolve.d.ts +4 -4
  15. package/dist/codex-wake-resolve.js +4 -4
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +41 -18
  18. package/dist/config.js.map +1 -1
  19. package/dist/credential-paths.d.ts +3 -0
  20. package/dist/credential-paths.d.ts.map +1 -0
  21. package/dist/credential-paths.js +7 -0
  22. package/dist/credential-paths.js.map +1 -0
  23. package/dist/log-stream.d.ts +4 -0
  24. package/dist/log-stream.d.ts.map +1 -1
  25. package/dist/log-stream.js +58 -2
  26. package/dist/log-stream.js.map +1 -1
  27. package/dist/remote-client.d.ts +25 -0
  28. package/dist/remote-client.d.ts.map +1 -1
  29. package/dist/remote-client.js +59 -4
  30. package/dist/remote-client.js.map +1 -1
  31. package/dist/seat-store.d.ts +11 -5
  32. package/dist/seat-store.d.ts.map +1 -1
  33. package/dist/seat-store.js +147 -14
  34. package/dist/seat-store.js.map +1 -1
  35. package/dist/seats.d.ts +72 -0
  36. package/dist/seats.d.ts.map +1 -1
  37. package/dist/seats.js +119 -0
  38. package/dist/seats.js.map +1 -1
  39. package/dist/server-errors.d.ts +1 -1
  40. package/dist/server-errors.d.ts.map +1 -1
  41. package/dist/server-errors.js.map +1 -1
  42. package/dist/server-facade.d.ts +50 -0
  43. package/dist/server-facade.d.ts.map +1 -0
  44. package/dist/server-facade.js +126 -0
  45. package/dist/server-facade.js.map +1 -0
  46. package/dist/server-handshake.d.ts.map +1 -1
  47. package/dist/server-handshake.js +49 -21
  48. package/dist/server-handshake.js.map +1 -1
  49. package/dist/session-continuity.d.ts +33 -0
  50. package/dist/session-continuity.d.ts.map +1 -0
  51. package/dist/session-continuity.js +220 -0
  52. package/dist/session-continuity.js.map +1 -0
  53. package/dist/token-store.d.ts +2 -1
  54. package/dist/token-store.d.ts.map +1 -1
  55. package/dist/token-store.js +5 -3
  56. package/dist/token-store.js.map +1 -1
  57. package/dist/unknown-subcommand.d.ts +1 -1
  58. package/dist/unknown-subcommand.d.ts.map +1 -1
  59. package/dist/unknown-subcommand.js +1 -0
  60. package/dist/unknown-subcommand.js.map +1 -1
  61. package/docs/EXTRACTION_PROVENANCE.md +10 -7
  62. package/docs/LOCAL_SERVER.md +82 -43
  63. package/docs/RELEASING.md +22 -4
  64. package/package.json +2 -2
  65. package/src/claude.ts +5 -0
  66. package/src/cli-help.ts +15 -0
  67. package/src/codex-app-wake.ts +23 -11
  68. package/src/codex-wake-resolve.ts +4 -4
  69. package/src/config.ts +40 -18
  70. package/src/credential-paths.ts +8 -0
  71. package/src/log-stream.ts +64 -2
  72. package/src/remote-client.ts +92 -14
  73. package/src/seat-store.ts +159 -16
  74. package/src/seats.ts +170 -0
  75. package/src/server-errors.ts +3 -1
  76. package/src/server-facade.ts +192 -0
  77. package/src/server-handshake.ts +54 -21
  78. package/src/session-continuity.ts +280 -0
  79. package/src/token-store.ts +9 -4
  80. package/src/unknown-subcommand.ts +1 -0
package/src/seats.ts CHANGED
@@ -55,6 +55,9 @@ 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 };
58
61
  }
59
62
 
60
63
  interface SeatsFile {
@@ -154,6 +157,19 @@ function isValidSeatRecord(ref: string, value: unknown): value is SeatRecord {
154
157
  ) {
155
158
  return false;
156
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
+ }
157
173
  // State-consistency invariants (no inconsistent active|pending).
158
174
  if (r.state === 'active') {
159
175
  // An ACTIVE record MUST carry its full server session + worktree binding.
@@ -281,6 +297,17 @@ export async function getActiveSeatCredential(
281
297
  return record.credential;
282
298
  }
283
299
 
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
+
284
311
  // ─── PREPARE / mint (no worktree yet) ────────────────────────────────────────
285
312
 
286
313
  /**
@@ -483,6 +510,149 @@ export async function activateAndBindSeat(input: {
483
510
  });
484
511
  }
485
512
 
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
+
486
656
  // ─── CR#2: bind a PENDING record to a preserved worktree (no activation) ─────
487
657
 
488
658
  export type BindPendingSeatOutcome = 'bound' | 'missing' | 'replaced';
@@ -4,7 +4,9 @@ export type BorgServerErrorCode =
4
4
  | 'INVITATION_REJECTED'
5
5
  | 'CREATE_CUBE_DENIED'
6
6
  | 'ATTACH_CONFLICT'
7
- | 'SESSION_REJECTED';
7
+ | 'SESSION_REJECTED'
8
+ | 'SESSION_REVOKED'
9
+ | 'AUTH_EXPIRED';
8
10
 
9
11
  /** Safe, non-secret state code for deterministic authority recovery copy. */
10
12
  export class BorgServerError extends Error {
@@ -0,0 +1,192 @@
1
+ import { spawn as spawnChild, type SpawnOptions } from 'node:child_process';
2
+ import { constants } from 'node:os';
3
+ import { serverHelpText } from './cli-help.js';
4
+
5
+ export const SERVER_LIFECYCLE_COMMANDS = ['setup', 'start', 'status', 'update', 'invite'] as const;
6
+ export type ServerLifecycleCommand = typeof SERVER_LIFECYCLE_COMMANDS[number];
7
+
8
+ export type ParsedServerFacadeArgs =
9
+ | { kind: 'help' }
10
+ | { kind: 'command'; command: ServerLifecycleCommand; args: string[] }
11
+ | { kind: 'error'; reason: 'unknown-command'; command: string };
12
+
13
+ export function parseServerFacadeArgs(args: readonly string[]): ParsedServerFacadeArgs {
14
+ const [command, ...rest] = args;
15
+ if (command === undefined || command === '--help' || command === '-h') {
16
+ return { kind: 'help' };
17
+ }
18
+ if (!(SERVER_LIFECYCLE_COMMANDS as readonly string[]).includes(command)) {
19
+ return { kind: 'error', reason: 'unknown-command', command };
20
+ }
21
+ return {
22
+ kind: 'command',
23
+ command: command as ServerLifecycleCommand,
24
+ args: rest,
25
+ };
26
+ }
27
+
28
+ interface ServerFacadeChild {
29
+ once(event: 'error', listener: (error: Error) => void): this;
30
+ once(
31
+ event: 'exit',
32
+ listener: (code: number | null, signal: NodeJS.Signals | null) => void,
33
+ ): this;
34
+ kill(signal: NodeJS.Signals): boolean;
35
+ }
36
+
37
+ export interface ServerFacadeProcessDeps {
38
+ spawn(
39
+ command: string,
40
+ args: readonly string[],
41
+ options: Pick<SpawnOptions, 'shell' | 'stdio'>,
42
+ ): ServerFacadeChild;
43
+ addSignalListener(signal: NodeJS.Signals, listener: () => void): void;
44
+ removeSignalListener(signal: NodeJS.Signals, listener: () => void): void;
45
+ }
46
+
47
+ export interface ServerFacadeOutputDeps {
48
+ writeStdout(text: string): void;
49
+ writeStderr(text: string): void;
50
+ }
51
+
52
+ export type ServerFacadeProcessResult =
53
+ | { kind: 'exited'; code: number }
54
+ | { kind: 'signaled'; signal: NodeJS.Signals }
55
+ | { kind: 'spawn-error'; error: Error };
56
+
57
+ const defaultProcessDeps: ServerFacadeProcessDeps = {
58
+ spawn: (command, args, options) => spawnChild(command, [...args], options),
59
+ addSignalListener: (signal, listener) => process.on(signal, listener),
60
+ removeSignalListener: (signal, listener) => process.off(signal, listener),
61
+ };
62
+
63
+ const defaultOutputDeps: ServerFacadeOutputDeps = {
64
+ writeStdout: (text) => process.stdout.write(text),
65
+ writeStderr: (text) => process.stderr.write(text),
66
+ };
67
+
68
+ const MAX_RENDERED_COMMAND_CODE_POINTS = 80;
69
+
70
+ function inertCommand(command: string): string {
71
+ const rendered: string[] = [];
72
+ let truncated = false;
73
+ for (const codePoint of command) {
74
+ if (rendered.length === MAX_RENDERED_COMMAND_CODE_POINTS) {
75
+ truncated = true;
76
+ break;
77
+ }
78
+ rendered.push(/\p{Cc}/u.test(codePoint) ? '?' : codePoint);
79
+ }
80
+ if (truncated) {
81
+ return `${rendered.slice(0, MAX_RENDERED_COMMAND_CODE_POINTS - 3).join('')}...`;
82
+ }
83
+ return rendered.join('');
84
+ }
85
+
86
+ export function unknownServerCommandText(command: string): string {
87
+ return (
88
+ `Unknown server command: ${inertCommand(command)}.\n` +
89
+ `Available commands: setup, start, status, update, invite.\n` +
90
+ `Next: run borg server --help.\n`
91
+ );
92
+ }
93
+
94
+ export function missingServerExecutableText(command: ServerLifecycleCommand): string {
95
+ return (
96
+ `Local server command is unavailable: borg-mcp-server was not found.\n` +
97
+ `Next: install a verified borgmcp-server release, then rerun borg server ${command}.\n` +
98
+ `No checkout fallback is attempted.\n`
99
+ );
100
+ }
101
+
102
+ export function serverCommandStartupFailureText(command: ServerLifecycleCommand): string {
103
+ return (
104
+ `Local server command could not be started.\n` +
105
+ `Next: check local permissions and system resources, then rerun borg server ${inertCommand(command)}.\n` +
106
+ `No server command was started.\n`
107
+ );
108
+ }
109
+
110
+ function isMissingServerExecutable(error: Error): boolean {
111
+ return (error as NodeJS.ErrnoException).code === 'ENOENT';
112
+ }
113
+
114
+ export function runServerFacadeProcess(
115
+ input: { command: ServerLifecycleCommand; args: readonly string[] },
116
+ deps: ServerFacadeProcessDeps = defaultProcessDeps,
117
+ ): Promise<ServerFacadeProcessResult> {
118
+ const child = deps.spawn(
119
+ 'borg-mcp-server',
120
+ [input.command, ...input.args],
121
+ { shell: false, stdio: 'inherit' },
122
+ );
123
+ const signals = ['SIGINT', 'SIGTERM'] as const;
124
+
125
+ return new Promise((resolve) => {
126
+ let settled = false;
127
+ const forwarders = new Map<NodeJS.Signals, () => void>();
128
+ const cleanup = () => {
129
+ for (const [signal, listener] of forwarders) {
130
+ deps.removeSignalListener(signal, listener);
131
+ }
132
+ forwarders.clear();
133
+ };
134
+ const settle = (result: ServerFacadeProcessResult) => {
135
+ if (settled) return;
136
+ settled = true;
137
+ cleanup();
138
+ resolve(result);
139
+ };
140
+
141
+ for (const signal of signals) {
142
+ const listener = () => {
143
+ child.kill(signal);
144
+ };
145
+ forwarders.set(signal, listener);
146
+ deps.addSignalListener(signal, listener);
147
+ }
148
+
149
+ child.once('error', (error) => settle({ kind: 'spawn-error', error }));
150
+ child.once('exit', (code, signal) => {
151
+ if (signal) {
152
+ settle({ kind: 'signaled', signal });
153
+ return;
154
+ }
155
+ settle({ kind: 'exited', code: code ?? 1 });
156
+ });
157
+ });
158
+ }
159
+
160
+ function processResultExitCode(result: ServerFacadeProcessResult): number {
161
+ if (result.kind === 'exited') return result.code;
162
+ if (result.kind === 'spawn-error') return isMissingServerExecutable(result.error) ? 127 : 1;
163
+ return 128 + (constants.signals[result.signal] ?? 1);
164
+ }
165
+
166
+ /** Routes every facade outcome before client initialization or network work. */
167
+ export async function runEarlyServerFacade(
168
+ argv: readonly string[],
169
+ deps: ServerFacadeProcessDeps = defaultProcessDeps,
170
+ output: ServerFacadeOutputDeps = defaultOutputDeps,
171
+ ): Promise<number | null> {
172
+ if (argv[2] !== 'server') return null;
173
+ const parsed = parseServerFacadeArgs(argv.slice(3));
174
+ if (parsed.kind === 'help') {
175
+ output.writeStdout(serverHelpText());
176
+ return 0;
177
+ }
178
+ if (parsed.kind === 'error') {
179
+ output.writeStderr(unknownServerCommandText(parsed.command));
180
+ return 1;
181
+ }
182
+
183
+ const result = await runServerFacadeProcess(parsed, deps);
184
+ if (result.kind === 'spawn-error') {
185
+ output.writeStderr(
186
+ isMissingServerExecutable(result.error)
187
+ ? missingServerExecutableText(parsed.command)
188
+ : serverCommandStartupFailureText(parsed.command),
189
+ );
190
+ }
191
+ return processResultExitCode(result);
192
+ }
@@ -40,7 +40,12 @@ import {
40
40
  type SeatBinding,
41
41
  type SeatOperation as ServerSessionOperation,
42
42
  } from './seats.js';
43
- import { BorgServerError } from './server-errors.js';
43
+ import {
44
+ BorgServerError,
45
+ BorgServerTrustError,
46
+ BorgServerUnreachableError,
47
+ } from './server-errors.js';
48
+ import { DroneEvictedError, DRONE_EVICTED_CODE } from './drone-lifecycle.js';
44
49
  import { readBoundedResponseBody } from './server-response.js';
45
50
  import {
46
51
  loadBorgServerTrust,
@@ -255,30 +260,36 @@ export async function sendBorgServerAttach(
255
260
  const controller = new AbortController();
256
261
  const timeout = setTimeout(() => controller.abort(), HANDSHAKE_TIMEOUT_MS);
257
262
  try {
258
- const response = await (deps.fetchImpl ?? fetch)(handshakeUrl(origin, ATTACH_PATH), {
259
- method: 'POST',
260
- redirect: 'error',
261
- signal: controller.signal,
262
- headers: {
263
- Accept: 'application/json',
264
- 'Content-Type': 'application/json',
265
- Authorization: `Bearer ${parentCredential}`,
266
- },
267
- body: JSON.stringify(createAttachRequestEnvelope(randomUUID(), {
268
- cube_id: request.cubeId,
269
- role_id: request.roleId,
270
- session_credential: pending.credential,
271
- ...(request.priorDroneId === undefined
272
- ? {}
273
- : { prior_drone_id: request.priorDroneId }),
274
- })),
275
- });
276
- if (response.status === 401 || response.status === 403) {
263
+ let response: Response;
264
+ try {
265
+ response = await (deps.fetchImpl ?? fetch)(handshakeUrl(origin, ATTACH_PATH), {
266
+ method: 'POST',
267
+ redirect: 'error',
268
+ signal: controller.signal,
269
+ headers: {
270
+ Accept: 'application/json',
271
+ 'Content-Type': 'application/json',
272
+ Authorization: `Bearer ${parentCredential}`,
273
+ },
274
+ body: JSON.stringify(createAttachRequestEnvelope(randomUUID(), {
275
+ cube_id: request.cubeId,
276
+ role_id: request.roleId,
277
+ session_credential: pending.credential,
278
+ ...(request.priorDroneId === undefined
279
+ ? {}
280
+ : { prior_drone_id: request.priorDroneId }),
281
+ })),
282
+ });
283
+ } catch (error) {
284
+ if (error instanceof BorgServerTrustError) throw error;
285
+ throw new BorgServerUnreachableError('Borg server attach transport failed', { cause: error });
286
+ }
287
+ if (response.status === 401 || response.status === 403 || response.status === 410) {
277
288
  // A typed SESSION_REJECTED body means the presented bearer targets a seat
278
289
  // already bound to a different session (takeover), distinct from a rejected
279
290
  // parent enrollment credential. Decode defensively; any anomaly falls back
280
291
  // to the generic credential rejection below. Never echo the response body.
281
- if (response.status === 401) {
292
+ if (response.status === 401 || response.status === 410) {
282
293
  let rejectedCode: ErrorCode | undefined;
283
294
  try {
284
295
  rejectedCode = decodeProtocolErrorEnvelope(
@@ -293,7 +304,23 @@ export async function sendBorgServerAttach(
293
304
  'Borg server rejected the session: the seat is already bound to another session',
294
305
  );
295
306
  }
307
+ if (rejectedCode === ErrorCode.AUTH_EXPIRED) {
308
+ throw new BorgServerError(
309
+ 'AUTH_EXPIRED',
310
+ 'Borg server session expired',
311
+ );
312
+ }
313
+ if (rejectedCode === ErrorCode.SESSION_REVOKED) {
314
+ throw new BorgServerError(
315
+ 'SESSION_REVOKED',
316
+ 'Borg server session was revoked',
317
+ );
318
+ }
319
+ if (response.status === 410 && rejectedCode === DRONE_EVICTED_CODE) {
320
+ throw new DroneEvictedError();
321
+ }
296
322
  }
323
+ if (response.status === 410) throw new Error('Borg server attach failed (HTTP 410)');
297
324
  throw new BorgServerError('CREDENTIAL_REJECTED', 'Borg server enrollment was rejected');
298
325
  }
299
326
  if (response.status === 409) {
@@ -381,6 +408,12 @@ export async function sendBorgServerAttach(
381
408
  ...(binding.isHumanSeat !== undefined ? { isHumanSeat: binding.isHumanSeat } : {}),
382
409
  }),
383
410
  };
411
+ } catch (error) {
412
+ if (error instanceof BorgServerTrustError) throw error;
413
+ if (controller.signal.aborted && !(error instanceof BorgServerUnreachableError)) {
414
+ throw new BorgServerUnreachableError('Borg server attach transport timed out', { cause: error });
415
+ }
416
+ throw error;
384
417
  } finally {
385
418
  clearTimeout(timeout);
386
419
  }