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
@@ -4,52 +4,92 @@ Install and initialize the server in the human operator's terminal:
4
4
 
5
5
  ```bash
6
6
  npm install -g borgmcp-server
7
- borg-mcp-server setup
8
- borg-mcp-server start
7
+ borg server setup
8
+ borg server start
9
9
  ```
10
10
 
11
- Keep the server running. Open a second operator terminal in the project checkout
12
- and run:
11
+ The client owns the `borg server` facade. It forwards commands and renders
12
+ verified server evidence. It does not infer a checkout, activate an artifact,
13
+ create a service, or claim a build identity by itself. The server owns artifact
14
+ verification, activation, data and identity preservation, runtime build
15
+ identity, rollback, and explicit Linux/macOS service adapters. The server
16
+ executable remains the direct foreground authority.
17
+
18
+ `borg server start` and `borg-mcp-server start` are foreground commands. They
19
+ must never imply that a daemon, LaunchAgent, or systemd service was installed.
20
+ Ctrl-C stops the foreground process. Managed persistence is a separate explicit
21
+ handoff.
22
+
23
+ The available lifecycle facade commands are:
24
+
25
+ ```text
26
+ Usage: borg server <command> [arguments]
27
+
28
+ Commands:
29
+ setup Prepare local server identity and data; does not start the server.
30
+ start Start the verified server in the foreground.
31
+ status Report verified runtime evidence.
32
+ update Verify and activate a local server artifact.
33
+ invite Create a single-use invitation in an interactive terminal.
34
+
35
+ Run borg server <command> --help for server command options.
36
+ ```
37
+
38
+ Local server client credentials are stored in the owner-controlled
39
+ `~/.borg/credentials` file with mode 0600. Fresh same-machine setup provisions
40
+ the first owner record there, so bare `borg assimilate` can use it without an
41
+ invitation prompt. Use `borg server invite` explicitly when another client or
42
+ device needs a single-use invitation; its output is owned by the server.
43
+
44
+ Status reports only runtime evidence supplied by the server: running/stopped
45
+ state, exact running artifact and immutable build identity when available,
46
+ endpoint, process mode, and data-identity availability. If the running build
47
+ identity is unavailable, status says it is unavailable. It never substitutes a
48
+ source checkout, package cache, or guessed version.
49
+
50
+ Update has four visible phases: verification, activation, result, and next
51
+ action. Only a verified artifact may activate. A verification failure says no
52
+ activation occurred and that the last verified runtime remains available.
53
+
54
+ Keep the foreground server running. Open a second operator terminal in the
55
+ project checkout and run:
13
56
 
14
57
  ```bash
15
- borg assimilate --host 127.0.0.1:7091 --enroll
58
+ borg assimilate
16
59
  ```
17
60
 
18
- The labeled hidden prompt awaits the owner enrollment invitation (single-use,
19
- shown once). An owner enrollment invitation comes from setup or the offline
20
- `borg-mcp-server owner-invite` recovery command. A later ordinary client uses a
21
- client enrollment invitation from `borg-mcp-server client-invite`. Never put an
22
- enrollment invitation in argv, environment variables, logs, or diagnostics.
23
- The offline replacement commands visibly prompt for the recovery credential;
24
- never put that credential in argv or environment variables either.
25
-
26
- Before sending the invitation,
27
- the client generates a 256-bit credential and UUID retry key and persists the
28
- exact tuple as `PENDING` in the local seat store on this machine — a
29
- 0600-permission file store, parity with the server's TLS keys. An ambiguous
30
- exchange retries that tuple exactly; the credential becomes active only after the
31
- versioned response is decoded and the authenticated protocol handshake succeeds.
32
- A new process resumes that pending enrollment before displaying another
33
- invitation prompt.
61
+ Setup provisions the first same-machine owner credential directly, so this flow
62
+ does not ask for an invitation. For another client or device, run `borg server
63
+ invite` in the server operator's interactive terminal. On the intended recipient,
64
+ run `borg assimilate --host <server> --enroll` and enter the single-use invitation
65
+ at the labeled hidden prompt. Never put an invitation in argv, environment
66
+ variables, logs, or diagnostics.
67
+
68
+ For explicit invitation enrollment, the client generates a 256-bit credential
69
+ and UUID retry key and persists the exact tuple as `PENDING` in the 0600
70
+ credential file before network I/O. An ambiguous exchange retries that tuple
71
+ exactly; the credential becomes active only after the versioned response is
72
+ decoded and the authenticated protocol handshake succeeds. A new process
73
+ resumes that pending enrollment before displaying another invitation prompt.
34
74
 
35
75
  `--cube-name <name>` explicitly selects the repository cube name. Without an
36
76
  explicit name, Borg uses the `origin` repository name or, when `origin` is
37
- absent, proposes the sanitized repository-directory basename before consuming
38
- an enrollment invitation. Confirm interactively or pass `--yes`; bare
39
- repositories fail closed.
77
+ absent, proposes the sanitized repository-directory basename. Confirm
78
+ interactively or pass `--yes`; bare repositories fail closed.
40
79
 
41
80
  The connection is HTTPS-only. Borg validates the server trust material, stores
42
- enrollment and session credentials in the local seat store on this machine (a
43
- 0600-permission file store), and persists only an opaque credential reference
44
- with the active cube. Local requests
81
+ parent enrollment credentials in `~/.borg/credentials` and session credentials
82
+ in the existing 0600 seat store, and persists only an opaque credential
83
+ reference with the active cube. Local requests
45
84
  use the server's `/api/cubes/*` coordination routes. They cannot use hosted OAuth
46
85
  credentials or change authority implicitly.
47
86
 
48
- The current client does not install or start `borgmcp-server`. The server must
49
- already be running and trusted. An owner enrollment carrying the persisted
50
- `create_cube` capability creates one idempotent cube per repository during
51
- normal assimilation, using the server-owned `default` role template; repeating
52
- an ambiguous request does not duplicate the cube, and distinct repositories can
87
+ The lifecycle facade invokes the separately installed `borgmcp-server`; it does
88
+ not bundle the server into the client. The server must be running and trusted
89
+ before assimilation. An owner enrollment carrying the persisted `create_cube`
90
+ capability creates one idempotent cube per repository during normal
91
+ assimilation, using the server-owned `default` role template; repeating an
92
+ ambiguous request does not duplicate the cube, and distinct repositories can
53
93
  create distinct bounded cubes. An ordinary enrolled client is denied before a
54
94
  create request is sent. Cloud-only capabilities fail explicitly rather than
55
95
  being redirected.
@@ -73,20 +113,19 @@ The default discovery endpoint is `https://127.0.0.1:7091`. Explicit `--host` va
73
113
 
74
114
  ## Recovery commands
75
115
 
76
- - No saved or rejected enrollment: rerun `borg assimilate --host <server> --enroll`
77
- from the operator's interactive terminal.
116
+ - No saved or rejected enrollment: generate a single-use invitation with
117
+ `borg server invite`, then run `borg assimilate --host <server> --enroll` from
118
+ the intended recipient's interactive terminal.
78
119
  - Rejected or expired invitation: keep the server running and mint a replacement
79
- scoped invitation with `borg-mcp-server owner-invite` for an unclaimed owner
80
- client or `borg-mcp-server client-invite` for an ordinary client, then rerun
81
- `borg assimilate --host <server> --enroll` with the replacement invitation.
120
+ invitation with `borg server invite`, then rerun `borg assimilate --host
121
+ <server> --enroll` with the replacement invitation.
82
122
  - Rejected or unloadable local seat: run `borg reset-local-seat --host <server>`
83
- to clear ONLY this worktree's saved local seat, then with the server still
84
- running ask the operator for a fresh scoped invitation and rerun
85
- `borg assimilate --host <server> --enroll`.
86
- - Unreachable server: start or restart it with `borg-mcp-server start`, then
123
+ to clear ONLY this worktree's saved local seat, then rerun `borg assimilate
124
+ --host <server>` with the server still running.
125
+ - Unreachable server: start or restart it with `borg server start`, then
87
126
  rerun `borg assimilate --host <server>`.
88
127
  - Trust mismatch after an intentional server re-initialization: verify the
89
- expected server identity, stop and restart `borg-mcp-server start`, then retry.
128
+ expected server identity, stop and restart `borg server start`, then retry.
90
129
  - Busy local seat store: wait for the other Borg process to finish, then rerun
91
130
  the same command. If the local seat store cannot be read or written, ensure its
92
131
  directory on this machine is readable and writable, then rerun.
@@ -96,7 +135,7 @@ The default discovery endpoint is `https://127.0.0.1:7091`. Explicit `--host` va
96
135
 
97
136
  ## Release status
98
137
 
99
- This self-hosted path consumes the published `borgmcp-shared@0.4.2` v2 registry
138
+ This self-hosted path consumes the published `borgmcp-shared@0.4.3` v2 registry
100
139
  release. The matching server owner-enrollment, cube-create, attach, restart, log,
101
140
  and SSE implementation must also pass the full process-level local dogfood gate.
102
141
  Until that gate opens the self-hosted path remains preview-only, and the client
package/docs/RELEASING.md CHANGED
@@ -4,15 +4,33 @@ The GitHub Actions workflow publishes one immutable, reviewed `borgmcp` version
4
4
  from a protected annotated tag. The protected publish job uses npm Trusted
5
5
  Publishing; no long-lived npm token is stored or exposed.
6
6
 
7
- ## Immutable Failed Release Evidence
7
+ ## Immutable Release Evidence
8
8
 
9
9
  The existing lightweight `v2.0.0` tag points to
10
10
  `90a078264f4d61c0140ad0a30357a4df42c34ab0`. Immutable workflow run
11
11
  `29693915689` rejected it at annotated-tag verification and failed before package
12
12
  creation or npm publication; the publish job was skipped and
13
13
  `borgmcp@2.0.0` remains absent from npm. Never delete, move, replace, reuse, or
14
- rerun that tag or run. Recovery uses the unused `v2.0.1` identity from a fresh
15
- reviewed protected-main commit and requires the complete release gate again.
14
+ rerun that tag or run.
15
+
16
+ The annotated `v2.0.1` tag object
17
+ `def12ee40af665fc6c3af4873a7d566b3f844fc1` peels to protected-main commit
18
+ `b30fc54a4d73bda98db4630864cca796c8923dd9`. Workflow run `29748931957`
19
+ successfully published that exact source as `borgmcp@2.0.1`; the registry records
20
+ integrity
21
+ `sha512-Ah8IY2izZ774gYLKthRL9lfrV+JBk2o9HSlrWUplyZgoGqwVjVboHNon0hWWF5i/fObiCGikFOMY6qZ+vaeyCw==`.
22
+ Never move, replace, reuse, or rerun that tag or workflow. The next candidate
23
+ must use a fresh unused identity and requires the complete release gate again.
24
+
25
+ The annotated `v2.0.2` tag object
26
+ `a1fc1f8f05c3f1b4d7ccbef86e244955642165b5` peels to protected-main commit
27
+ `aced84956fdf41315904c8901e50a345f628152c`. Workflow run `29840120451`
28
+ successfully published that exact source as `borgmcp@2.0.2`; the registry records
29
+ integrity
30
+ `sha512-w0O0t/2wzeJpXEqb4jQGuqZeavEkfFKdz6poRT8q1TxDKHTlOlwo3auctE+X1kWU2s5JkSDcUiUdQdyLwu84IA==`.
31
+ Never move, replace, reuse, or rerun that tag or workflow. The next candidate
32
+ uses the unused `v2.0.3` identity from a fresh reviewed protected-main commit
33
+ and requires the complete release gate again.
16
34
 
17
35
  ## Release Prerequisites
18
36
 
@@ -23,7 +41,7 @@ Before creating the release tag, independently verify all of these conditions:
23
41
  - the extraction review confirms no private backend secrets, deployment
24
42
  configuration, customer data, local state, or duplicated shared contracts
25
43
  entered the public package;
26
- - the exact audited registry dependency `borgmcp-shared@0.4.2` remains locked to
44
+ - the exact audited registry dependency `borgmcp-shared@0.4.3` remains locked to
27
45
  its canonical tarball and integrity;
28
46
  - the client and matching server pass the complete local dogfood gate;
29
47
  - the selected stable client version is unused and the exact release commit is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "borgmcp",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "Coordinate AI coding agents in shared cubes. Works with Claude Code, Codex, and OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -68,7 +68,7 @@
68
68
  },
69
69
  "dependencies": {
70
70
  "@modelcontextprotocol/sdk": "^1.0.4",
71
- "borgmcp-shared": "0.4.2",
71
+ "borgmcp-shared": "0.4.3",
72
72
  "chalk": "^5.3.0",
73
73
  "prompts": "^2.4.2",
74
74
  "which": "^4.0.0"
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/cli-help.ts CHANGED
@@ -36,12 +36,27 @@ 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
+ ` status Report verified runtime evidence.\n` +
54
+ ` update Verify and activate a local server artifact.\n` +
55
+ ` invite Create a single-use invitation in an interactive terminal.\n\n` +
56
+ `Run borg server <command> --help for server command options.\n`
57
+ );
58
+ }
59
+
45
60
  /**
46
61
  * Help text for `borg assimilate --help` — the home for the full assimilate flag
47
62
  * set. Model/provider configuration belongs to the selected agent CLI.
@@ -1,6 +1,12 @@
1
- import { getActiveCube, getCodexWakeTarget, setCodexWakeTarget } from './cubes.js';
1
+ import {
2
+ getActiveCube,
3
+ getCodexWakeTarget,
4
+ setCodexWakeTarget,
5
+ type ActiveCube,
6
+ } from './cubes.js';
2
7
  import { CodexAppServerClient } from './codex-app-server.js';
3
8
  import { checkCodexBridgeHealthy } from './codex-remote.js';
9
+ import { hasPendingWakeActivity } from './remote-client.js';
4
10
  import {
5
11
  BORG_CODEX_REMOTE_WAKE_ENV,
6
12
  resolveSessionAgentKind,
@@ -176,6 +182,10 @@ export interface CodexWakeDeps {
176
182
  // WAKE_RETRY_MAX_ATTEMPTS); small values let tests prove the loop terminates
177
183
  // under a non-advancing clock without running thousands of iterations.
178
184
  maxAttempts?: number;
185
+ // client#76: token-free, non-mutating preflight for the periodic backstop.
186
+ // Per-entry and retry-drain paths do not use this: their pending obligation is
187
+ // already established by a concrete delivered/deferred event.
188
+ hasPendingWork?: (active: ActiveCube) => Promise<boolean>;
179
189
  // gh#861 finding 2: lease-ownership gate for the heartbeat tick — a lease-LOSING
180
190
  // duplicate child must NOT tick/inject (symmetry with the per-entry path, which
181
191
  // only fires inside an SSE session that holds the stream lease). Heartbeat-only;
@@ -403,16 +413,13 @@ async function runRetryDrainLoop(deps: CodexWakeDeps): Promise<void> {
403
413
  export const CODEX_HEARTBEAT_CADENCE_MS = 20 * 60_000;
404
414
 
405
415
  /**
406
- * gh#857 WI-2: one tick of the codex /loop-equivalent heartbeat a periodic,
407
- * independent re-engagement that injects a borg_read-log (unread_only=true) DRAIN turn so an
408
- * idle codex drone re-syncs even if every per-entry wake was missed. SKIPS when a
409
- * delivery (per-entry wake, retry-drain, or a prior heartbeat) already landed
410
- * within the cadence window (shouldFireHeartbeat), so an active cube with flowing
411
- * wakes never gets a redundant injection. Unlike the per-entry path it does NOT
412
- * consult deliveredWakeKeys the cadence gate is the throttle, and the static
413
- * drain prompt is intentionally re-delivered each idle window. Best-effort: a
414
- * mid-turn thread / transient error / unresolved target just skips this tick (the
415
- * next tick retries). Never throws.
416
+ * gh#857/client#76: one tick of the codex catch-up backstop. The cadence only
417
+ * initiates a token-free unread-state preflight; a DRAIN turn is injected when
418
+ * that authoritative scan finds real work that a per-entry wake missed. Recent
419
+ * delivery still suppresses redundant preflights. Unlike the per-entry path it
420
+ * does not consult deliveredWakeKeys because the unread cursor is authoritative.
421
+ * Best-effort: a failed preflight, mid-turn thread, transient error, or unresolved
422
+ * target skips this tick and lets the next cadence retry. Never throws.
416
423
  */
417
424
  export async function fireCodexHeartbeatTick(
418
425
  deps: CodexWakeDeps = {},
@@ -434,6 +441,11 @@ export async function fireCodexHeartbeatTick(
434
441
  try {
435
442
  const active = await (deps.getActiveCube ?? getActiveCube)();
436
443
  if (!active) return;
444
+ // Elapsed time alone must never cross the model boundary. Query the
445
+ // authoritative unread state without advancing its cursor; only then touch
446
+ // the app-server socket or resolve a thread.
447
+ const hasPendingWork = deps.hasPendingWork ?? hasPendingWakeActivity;
448
+ if (!(await hasPendingWork(active))) return;
437
449
  const resolved = await resolveFreshCodexWakeTarget(active, deps);
438
450
  if (!resolved) return; // thread not loaded yet → next tick retries
439
451
  const client = makeCodexClient(resolved.socketPath, deps);
@@ -140,10 +140,10 @@ export function wakeRetryExpired(
140
140
  }
141
141
 
142
142
  /**
143
- * WI-2 double-fire avoidance: the periodic heartbeat fires only when no wake (or
144
- * prior heartbeat) delivery landed within the cadence window so an active cube
145
- * with flowing per-entry wakes doesn't get redundant heartbeat injections. A
146
- * never-delivered seat (null) always fires.
143
+ * WI-2 double-fire avoidance: the periodic catch-up preflight runs only when no
144
+ * wake delivery landed within the cadence window. A never-delivered seat (null)
145
+ * is eligible for preflight; client#76 separately requires pending unread work
146
+ * before a model turn can start.
147
147
  */
148
148
  export function shouldFireHeartbeat(
149
149
  lastDeliveredAt: number | null,
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');