borgmcp 2.0.2 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -10
- package/dist/assimilate-cmd.d.ts +0 -2
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +51 -43
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/assimilate-deps.d.ts.map +1 -1
- package/dist/assimilate-deps.js +0 -1
- package/dist/assimilate-deps.js.map +1 -1
- package/dist/claude.d.ts +1 -0
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +5 -0
- package/dist/claude.js.map +1 -1
- package/dist/cleanup-cmd.d.ts +1 -1
- package/dist/cleanup-cmd.d.ts.map +1 -1
- package/dist/cleanup-cmd.js +3 -1
- package/dist/cleanup-cmd.js.map +1 -1
- package/dist/cli-help.d.ts +2 -0
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +14 -1
- package/dist/cli-help.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +41 -18
- package/dist/config.js.map +1 -1
- package/dist/credential-paths.d.ts +3 -0
- package/dist/credential-paths.d.ts.map +1 -0
- package/dist/credential-paths.js +7 -0
- package/dist/credential-paths.js.map +1 -0
- package/dist/cubes.d.ts +0 -2
- package/dist/cubes.d.ts.map +1 -1
- package/dist/cubes.js +0 -2
- package/dist/cubes.js.map +1 -1
- package/dist/launch-all-cmd.d.ts.map +1 -1
- package/dist/launch-all-cmd.js +13 -7
- package/dist/launch-all-cmd.js.map +1 -1
- package/dist/log-stream.d.ts +0 -2
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +3 -30
- package/dist/log-stream.js.map +1 -1
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +8 -23
- package/dist/remote-client.js.map +1 -1
- package/dist/seat-probe.d.ts +4 -4
- package/dist/seat-probe.d.ts.map +1 -1
- package/dist/seat-probe.js +2 -0
- package/dist/seat-probe.js.map +1 -1
- package/dist/seat-store.d.ts +11 -5
- package/dist/seat-store.d.ts.map +1 -1
- package/dist/seat-store.js +147 -14
- package/dist/seat-store.js.map +1 -1
- package/dist/seats.d.ts +0 -74
- package/dist/seats.d.ts.map +1 -1
- package/dist/seats.js +31 -131
- package/dist/seats.js.map +1 -1
- package/dist/server-errors.d.ts +6 -1
- package/dist/server-errors.d.ts.map +1 -1
- package/dist/server-errors.js +9 -0
- package/dist/server-errors.js.map +1 -1
- package/dist/server-facade.d.ts +50 -0
- package/dist/server-facade.d.ts.map +1 -0
- package/dist/server-facade.js +126 -0
- package/dist/server-facade.js.map +1 -0
- package/dist/server-handshake.d.ts +0 -2
- package/dist/server-handshake.d.ts.map +1 -1
- package/dist/server-handshake.js +0 -5
- package/dist/server-handshake.js.map +1 -1
- package/dist/token-store.d.ts +2 -1
- package/dist/token-store.d.ts.map +1 -1
- package/dist/token-store.js +5 -3
- package/dist/token-store.js.map +1 -1
- package/dist/unknown-subcommand.d.ts +1 -1
- package/dist/unknown-subcommand.d.ts.map +1 -1
- package/dist/unknown-subcommand.js +1 -0
- package/dist/unknown-subcommand.js.map +1 -1
- package/docs/EXTRACTION_PROVENANCE.md +6 -5
- package/docs/LOCAL_SERVER.md +86 -43
- package/docs/RELEASING.md +19 -1
- package/package.json +1 -1
- package/src/assimilate-cmd.ts +54 -51
- package/src/assimilate-deps.ts +0 -1
- package/src/claude.ts +5 -0
- package/src/cleanup-cmd.ts +5 -2
- package/src/cli-help.ts +17 -1
- package/src/config.ts +40 -18
- package/src/credential-paths.ts +8 -0
- package/src/cubes.ts +0 -4
- package/src/launch-all-cmd.ts +15 -7
- package/src/log-stream.ts +2 -30
- package/src/remote-client.ts +10 -22
- package/src/seat-probe.ts +5 -3
- package/src/seat-store.ts +159 -16
- package/src/seats.ts +35 -186
- package/src/server-errors.ts +9 -2
- package/src/server-facade.ts +192 -0
- package/src/server-handshake.ts +1 -10
- package/src/token-store.ts +9 -4
- package/src/unknown-subcommand.ts +1 -0
- package/dist/session-continuity.d.ts +0 -33
- package/dist/session-continuity.d.ts.map +0 -1
- package/dist/session-continuity.js +0 -220
- package/dist/session-continuity.js.map +0 -1
- package/src/session-continuity.ts +0 -280
package/src/assimilate-cmd.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
487
|
-
|
|
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
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
509
|
-
|
|
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
|
|
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
|
|
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
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
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,
|
package/src/assimilate-deps.ts
CHANGED
|
@@ -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
|
package/src/cleanup-cmd.ts
CHANGED
|
@@ -69,7 +69,8 @@ export type CleanupReason =
|
|
|
69
69
|
| 'SURVIVES-clobber'
|
|
70
70
|
| 'SURVIVES-unmerged'
|
|
71
71
|
| 'SURVIVES-detached'
|
|
72
|
-
| 'SURVIVES-
|
|
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-
|
|
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
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 } : {}),
|
package/src/launch-all-cmd.ts
CHANGED
|
@@ -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
|
-
`
|
|
301
|
-
`
|
|
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}
|
|
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
|
|
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
|
}
|