borgmcp 2.0.1 → 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -10
- package/dist/claude.d.ts +1 -0
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +5 -0
- package/dist/claude.js.map +1 -1
- package/dist/cli-help.d.ts +2 -0
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +12 -0
- package/dist/cli-help.js.map +1 -1
- package/dist/codex-app-wake.d.ts +9 -11
- package/dist/codex-app-wake.d.ts.map +1 -1
- package/dist/codex-app-wake.js +15 -11
- package/dist/codex-app-wake.js.map +1 -1
- package/dist/codex-wake-resolve.d.ts +4 -4
- package/dist/codex-wake-resolve.js +4 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +41 -18
- package/dist/config.js.map +1 -1
- package/dist/credential-paths.d.ts +3 -0
- package/dist/credential-paths.d.ts.map +1 -0
- package/dist/credential-paths.js +7 -0
- package/dist/credential-paths.js.map +1 -0
- package/dist/log-stream.d.ts +4 -0
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +58 -2
- package/dist/log-stream.js.map +1 -1
- package/dist/remote-client.d.ts +25 -0
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +59 -4
- package/dist/remote-client.js.map +1 -1
- package/dist/seat-store.d.ts +11 -5
- package/dist/seat-store.d.ts.map +1 -1
- package/dist/seat-store.js +147 -14
- package/dist/seat-store.js.map +1 -1
- package/dist/seats.d.ts +72 -0
- package/dist/seats.d.ts.map +1 -1
- package/dist/seats.js +119 -0
- package/dist/seats.js.map +1 -1
- package/dist/server-errors.d.ts +1 -1
- package/dist/server-errors.d.ts.map +1 -1
- package/dist/server-errors.js.map +1 -1
- package/dist/server-facade.d.ts +50 -0
- package/dist/server-facade.d.ts.map +1 -0
- package/dist/server-facade.js +126 -0
- package/dist/server-facade.js.map +1 -0
- package/dist/server-handshake.d.ts.map +1 -1
- package/dist/server-handshake.js +49 -21
- package/dist/server-handshake.js.map +1 -1
- package/dist/session-continuity.d.ts +33 -0
- package/dist/session-continuity.d.ts.map +1 -0
- package/dist/session-continuity.js +220 -0
- package/dist/session-continuity.js.map +1 -0
- package/dist/token-store.d.ts +2 -1
- package/dist/token-store.d.ts.map +1 -1
- package/dist/token-store.js +5 -3
- package/dist/token-store.js.map +1 -1
- package/dist/unknown-subcommand.d.ts +1 -1
- package/dist/unknown-subcommand.d.ts.map +1 -1
- package/dist/unknown-subcommand.js +1 -0
- package/dist/unknown-subcommand.js.map +1 -1
- package/docs/EXTRACTION_PROVENANCE.md +10 -7
- package/docs/LOCAL_SERVER.md +82 -43
- package/docs/RELEASING.md +22 -4
- package/package.json +2 -2
- package/src/claude.ts +5 -0
- package/src/cli-help.ts +15 -0
- package/src/codex-app-wake.ts +23 -11
- package/src/codex-wake-resolve.ts +4 -4
- package/src/config.ts +40 -18
- package/src/credential-paths.ts +8 -0
- package/src/log-stream.ts +64 -2
- package/src/remote-client.ts +92 -14
- package/src/seat-store.ts +159 -16
- package/src/seats.ts +170 -0
- package/src/server-errors.ts +3 -1
- package/src/server-facade.ts +192 -0
- package/src/server-handshake.ts +54 -21
- package/src/session-continuity.ts +280 -0
- package/src/token-store.ts +9 -4
- package/src/unknown-subcommand.ts +1 -0
package/src/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';
|
package/src/server-errors.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/server-handshake.ts
CHANGED
|
@@ -40,7 +40,12 @@ import {
|
|
|
40
40
|
type SeatBinding,
|
|
41
41
|
type SeatOperation as ServerSessionOperation,
|
|
42
42
|
} from './seats.js';
|
|
43
|
-
import {
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
}
|