@link-assistant/hive-mind 2.13.4 → 2.14.0

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 (47) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.hi.md +2 -0
  3. package/README.md +2 -0
  4. package/README.ru.md +2 -0
  5. package/README.zh.md +2 -0
  6. package/package.json +1 -1
  7. package/src/agent.lib.mjs +5 -1
  8. package/src/claude.lib.mjs +5 -158
  9. package/src/claude.session-tokens.lib.mjs +180 -0
  10. package/src/codex.diagnostics.lib.mjs +135 -0
  11. package/src/codex.lib.mjs +8 -121
  12. package/src/config.lib.mjs +9 -0
  13. package/src/docker-sidecar.lib.mjs +276 -0
  14. package/src/formal-ai-maintenance.lib.mjs +2 -14
  15. package/src/formal-ai-sidecar.lib.mjs +17 -137
  16. package/src/gemini.lib.mjs +5 -1
  17. package/src/git-push-guard.lib.mjs +230 -0
  18. package/src/git-retry.lib.mjs +97 -0
  19. package/src/github-pr-idempotency.lib.mjs +83 -0
  20. package/src/github-rate-limit.lib.mjs +44 -41
  21. package/src/hive.mjs +8 -150
  22. package/src/hive.repository-fallback.lib.mjs +125 -0
  23. package/src/hive.startup-checks.lib.mjs +57 -0
  24. package/src/isolation-runner.lib.mjs +94 -287
  25. package/src/isolation-runner.parsers.lib.mjs +292 -0
  26. package/src/lib.mjs +79 -18
  27. package/src/opencode.lib.mjs +5 -1
  28. package/src/qwen.lib.mjs +5 -1
  29. package/src/router-isolation.lib.mjs +496 -0
  30. package/src/router-logs.lib.mjs +143 -0
  31. package/src/router-maintenance.lib.mjs +77 -0
  32. package/src/router-session-drain.lib.mjs +153 -0
  33. package/src/router-sidecar.lib.mjs +516 -0
  34. package/src/router-task-isolation.lib.mjs +121 -0
  35. package/src/session-monitor.lib.mjs +12 -272
  36. package/src/session-monitor.queries.lib.mjs +304 -0
  37. package/src/solve.auto-pr-push-sync.lib.mjs +176 -0
  38. package/src/solve.auto-pr.lib.mjs +40 -154
  39. package/src/solve.config.lib.mjs +11 -0
  40. package/src/solve.mjs +8 -158
  41. package/src/solve.mode.lib.mjs +191 -0
  42. package/src/task.config.lib.mjs +5 -0
  43. package/src/task.mjs +1 -0
  44. package/src/telegram-bot.mjs +18 -0
  45. package/src/telegram-solve-queue.lib.mjs +19 -272
  46. package/src/telegram-solve-queue.throttling.lib.mjs +323 -0
  47. package/src/transient-errors.lib.mjs +238 -0
@@ -0,0 +1,496 @@
1
+ /**
2
+ * Router isolation policy (issue #2164, EXPERIMENTAL).
3
+ *
4
+ * By default a Docker-isolated task receives the operator's real subscription:
5
+ * `~/.claude`, `~/.claude.json`, `~/.codex`, `~/.agents` and `~/.config/gh` are
6
+ * bind-mounted into the container by `getDockerIsolationAuthMounts()`. The agent
7
+ * inside therefore holds the raw vendor OAuth credential and the raw GitHub
8
+ * token, can spend the subscription without limit, and leaves no record beyond
9
+ * whatever it chose to write itself.
10
+ *
11
+ * With `--use-router` those mounts are withheld. The credentials stay in one
12
+ * `hive-mind-router` sidecar, each task gets its own short-lived `la_sk_…`
13
+ * token, and every request lands in that token's own redacted JSONL log inside
14
+ * a preserved data volume.
15
+ *
16
+ * Three decisions here are worth stating, because they are what makes the
17
+ * isolation hold rather than merely look tidy (all three were measured first —
18
+ * see `experiments/issue-2164/`):
19
+ *
20
+ * 1. **The router serves TLS on 443.** Router 0.119.0 terminates TLS itself
21
+ * (`TLS_SELF_SIGNED=1`) and prints its CA with `router tls ca`. Plain HTTP
22
+ * would rule out `gh` entirely, which refuses non-HTTPS hosts.
23
+ * 2. **GitHub is intercepted by name, not by reconfiguring `gh`.** The
24
+ * certificate carries `api.github.com` as a SAN and the *task* container gets
25
+ * `<router-ip> api.github.com` in its `/etc/hosts`. Every form an agent might
26
+ * use — `gh api`, `gh pr view <url>`, a bare `curl` — lands on the router
27
+ * without the agent being asked to cooperate. The alias is deliberately NOT
28
+ * added to the router's own network attachment: the router has to resolve
29
+ * `api.github.com` to the real GitHub, and an alias would make it resolve to
30
+ * itself (measured: 502 on every proxied call).
31
+ * 3. **`github.com` itself stays untouched**, so git is pointed at the router's
32
+ * smart-HTTP proxy explicitly with `url.<router>/git/.insteadOf`.
33
+ *
34
+ * This module is deliberately pure: it decides *what a routed task should see*
35
+ * and nothing else, so the policy is testable without Docker. Container
36
+ * lifecycle lives in `router-sidecar.lib.mjs`.
37
+ *
38
+ * @see https://github.com/link-assistant/hive-mind/issues/2164
39
+ */
40
+
41
+ export const ROUTER_SIDECAR_CONTAINER_NAME = 'hive-mind-router';
42
+ export const ROUTER_SIDECAR_NETWORK_NAME = 'hive-mind-router';
43
+ // Tasks reach the sidecar by alias, so the endpoint stays stable across restarts.
44
+ export const ROUTER_SIDECAR_NETWORK_ALIAS = 'link-assistant-router';
45
+ // 443, because `gh` builds every endpoint as `https://<host>/…` with no port and
46
+ // no plaintext option. Serving the default HTTPS port is what lets the same
47
+ // listener answer both the agent CLIs and an unmodified `gh`.
48
+ export const ROUTER_SIDECAR_PORT = 443;
49
+ export const ROUTER_SIDECAR_LABEL = 'com.link-assistant.hive-mind.router';
50
+ // Pinned: an experimental feature that depends on `router tls ca`, the git proxy
51
+ // and per-token request logs must not silently change underneath a running fleet.
52
+ // 0.110.0 is the floor: it carries the compare-based force-push mediation
53
+ // (upstream router#273), without which a routed task can still rewrite history.
54
+ // Override with HIVE_MIND_ROUTER_IMAGE.
55
+ export const ROUTER_SIDECAR_IMAGE = 'ghcr.io/link-assistant/router:0.119.0';
56
+
57
+ /** The one GitHub name the router impersonates; see the module header. */
58
+ export const ROUTER_GITHUB_API_HOST = 'api.github.com';
59
+ /** SANs the sidecar's self-signed certificate must carry. */
60
+ export const ROUTER_TLS_DNS_NAMES = `${ROUTER_SIDECAR_NETWORK_ALIAS},${ROUTER_GITHUB_API_HOST}`;
61
+
62
+ // Where the task container is given the router's CA. Two files, because clients
63
+ // disagree about what the variable means: NODE_EXTRA_CA_CERTS *adds* to the
64
+ // system store, while SSL_CERT_FILE *replaces* it — so the latter must be handed
65
+ // a bundle that still contains the public roots, or the task loses the ability
66
+ // to verify every other site on the internet.
67
+ export const ROUTER_CA_CONTAINER_PATH = '/etc/hive-mind-router-ca.pem';
68
+ export const ROUTER_CA_BUNDLE_CONTAINER_PATH = '/etc/hive-mind-router-ca-bundle.pem';
69
+ export const CONTAINER_SYSTEM_CA_BUNDLE = '/etc/ssl/certs/ca-certificates.crt';
70
+
71
+ // Named volume, never removed by any Hive Mind code path: it holds the audit
72
+ // trail the whole feature exists to produce (issue #2164, R8).
73
+ export const ROUTER_DATA_VOLUME_NAME = 'hive-mind-router-data';
74
+ export const ROUTER_DATA_MOUNT = '/data/router';
75
+
76
+ // Vendor credential homes inside the sidecar. The router reads each from its
77
+ // matching `*_HOME` variable; mounting them here is what makes the sidecar the
78
+ // only point of contact with the subscription (R3). `~/.config/gh` is mounted
79
+ // read-only: the router only ever reads the token out of `hosts.yml`, and a
80
+ // writable mount would let a proxied call rewrite the operator's own gh state.
81
+ export const ROUTER_CREDENTIAL_MOUNTS = Object.freeze([Object.freeze({ home: '.claude', target: '/data/claude', envVar: 'CLAUDE_CODE_HOME' }), Object.freeze({ home: '.codex', target: '/data/codex', envVar: 'CODEX_HOME' }), Object.freeze({ home: '.gemini', target: '/data/gemini', envVar: 'GEMINI_HOME' }), Object.freeze({ home: '.qwen', target: '/data/qwen', envVar: 'QWEN_HOME' })]);
82
+
83
+ /** The gh credential the router presents upstream, mounted read-only (R12). */
84
+ export const ROUTER_GH_CONFIG_MOUNT = Object.freeze({ home: '.config/gh', target: '/data/gh', envVar: 'GH_CONFIG_DIR', readOnly: true });
85
+
86
+ /**
87
+ * Tools whose CLI speaks the Anthropic Messages API. The router serves
88
+ * `/v1/messages` at its root, so `ANTHROPIC_BASE_URL` needs no path suffix.
89
+ */
90
+ const ANTHROPIC_TOOLS = new Set(['claude', 'agent']);
91
+
92
+ /** Provider id written into a routed task's `config.toml` (codex). */
93
+ const CODEX_PROVIDER_ID = 'hive-mind-router';
94
+
95
+ const normalizeTool = tool => String(tool || 'claude').toLowerCase();
96
+
97
+ const isFalsey = value =>
98
+ ['0', 'false', 'no'].includes(
99
+ String(value || '')
100
+ .trim()
101
+ .toLowerCase()
102
+ );
103
+
104
+ /**
105
+ * Is router isolation requested for this run?
106
+ *
107
+ * The flag is the primary switch; `HIVE_MIND_USE_ROUTER` exists so the Telegram
108
+ * bot and nested `solve` invocations inherit the decision without every layer
109
+ * having to thread an argument through.
110
+ */
111
+ export function isRouterEnabled({ useRouter = false, env = process.env } = {}) {
112
+ if (useRouter === true) return true;
113
+ const fromEnv = String(env?.HIVE_MIND_USE_ROUTER || '')
114
+ .trim()
115
+ .toLowerCase();
116
+ return fromEnv === '1' || fromEnv === 'true' || fromEnv === 'yes';
117
+ }
118
+
119
+ /**
120
+ * Read `--use-router` out of a raw argument vector.
121
+ *
122
+ * The host has to know before it launches anything, because the sidecar must be
123
+ * running and the token minted by the time the task container is created — but
124
+ * the flag is also a real `solve`/`hive`/`task` option, so it is read from the
125
+ * args rather than being stripped out of them the way `--isolation` is.
126
+ *
127
+ * @param {string[]} args
128
+ * @returns {boolean}
129
+ */
130
+ export function hasUseRouterFlag(args) {
131
+ const list = Array.isArray(args) ? args : [];
132
+ return list.some(arg => {
133
+ const value = String(arg ?? '');
134
+ return value === '--use-router' || value === '--use-router=true';
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Validate a router endpoint.
140
+ *
141
+ * Mirrors `normalizeFormalAiBaseUrl`: an origin only. A path, query, fragment
142
+ * or embedded credential is rejected rather than silently dropped, because a
143
+ * base URL that is *almost* right produces 404s that look like router bugs.
144
+ *
145
+ * @returns {string|null} normalized `scheme://host[:port]`, or null when invalid
146
+ */
147
+ export function normalizeRouterBaseUrl(value) {
148
+ const raw = String(value || '').trim();
149
+ if (!raw) return null;
150
+ let parsed;
151
+ try {
152
+ parsed = new URL(raw);
153
+ } catch {
154
+ return null;
155
+ }
156
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
157
+ if (parsed.username || parsed.password) return null;
158
+ if (parsed.search || parsed.hash) return null;
159
+ if (parsed.pathname && parsed.pathname !== '/') return null;
160
+ if (!parsed.hostname) return null;
161
+ return `${parsed.protocol}//${parsed.host}`;
162
+ }
163
+
164
+ /**
165
+ * Endpoint of the sidecar Hive Mind starts itself, reachable only on the
166
+ * internal network. The port is omitted when it is 443 so the authority matches
167
+ * the certificate the way every client expects.
168
+ */
169
+ export function getInternalRouterBaseUrl() {
170
+ const port = ROUTER_SIDECAR_PORT === 443 ? '' : `:${ROUTER_SIDECAR_PORT}`;
171
+ return `https://${ROUTER_SIDECAR_NETWORK_ALIAS}${port}`;
172
+ }
173
+
174
+ /**
175
+ * Resolve which router a task should talk to.
176
+ *
177
+ * An operator who already runs a router elsewhere sets `HIVE_MIND_ROUTER_URL`
178
+ * and Hive Mind skips starting its own sidecar; this mirrors the router's own
179
+ * `LINK_ASSISTANT_ROUTER_URL` resolution order.
180
+ *
181
+ * @returns {{baseUrl: string, external: boolean, error: string|null}}
182
+ */
183
+ export function resolveRouterBaseUrl({ env = process.env } = {}) {
184
+ const explicit = String(env?.HIVE_MIND_ROUTER_URL || '').trim();
185
+ if (!explicit) {
186
+ return { baseUrl: getInternalRouterBaseUrl(), external: false, error: null };
187
+ }
188
+ const normalized = normalizeRouterBaseUrl(explicit);
189
+ if (!normalized) {
190
+ return {
191
+ baseUrl: null,
192
+ external: true,
193
+ error: `HIVE_MIND_ROUTER_URL is not a bare http(s) origin: ${explicit}`,
194
+ };
195
+ }
196
+ return { baseUrl: normalized, external: true, error: null };
197
+ }
198
+
199
+ /**
200
+ * Resolve an explicit host for `gh`, for the external-router case only.
201
+ *
202
+ * The sidecar Hive Mind starts needs none of this: it answers to
203
+ * `api.github.com` directly (see the module header). An operator-run router,
204
+ * though, is on someone else's network with a certificate Hive Mind cannot
205
+ * inspect, so GitHub routing there has to be declared — and `gh` builds a custom
206
+ * host's REST base as `https://<host>/api/v3/` with no plaintext option, so the
207
+ * value must be HTTPS.
208
+ *
209
+ * @returns {string|null} bare hostname (no scheme, no path), or null when unset
210
+ */
211
+ export function resolveRouterGhHost({ env = process.env } = {}) {
212
+ const raw = String(env?.HIVE_MIND_ROUTER_GH_HOST || '').trim();
213
+ if (!raw) return null;
214
+ const candidate = raw.includes('://') ? raw : `https://${raw}`;
215
+ let parsed;
216
+ try {
217
+ parsed = new URL(candidate);
218
+ } catch {
219
+ return null;
220
+ }
221
+ // Plain HTTP would silently fail inside gh, so refuse it here where we can explain why.
222
+ if (parsed.protocol !== 'https:') return null;
223
+ if (!parsed.hostname) return null;
224
+ return parsed.host;
225
+ }
226
+
227
+ /**
228
+ * How GitHub traffic reaches the router for this task.
229
+ *
230
+ * - `transparent`: our own sidecar answers to `api.github.com` (the default).
231
+ * - `host`: an operator-supplied HTTPS endpoint, wired through `GH_HOST`.
232
+ * - `off`: not routed — the task keeps its own gh credential and the caller
233
+ * warns about it.
234
+ *
235
+ * @returns {{mode: 'transparent'|'host'|'off', ghHost: string|null}}
236
+ */
237
+ export function resolveRouterGitHubRouting({ env = process.env, external = false } = {}) {
238
+ if (isFalsey(env?.HIVE_MIND_ROUTER_GITHUB)) return { mode: 'off', ghHost: null };
239
+ const explicit = resolveRouterGhHost({ env });
240
+ if (explicit) return { mode: 'host', ghHost: explicit };
241
+ // An external router is not on a network we control, so there is no container
242
+ // whose /etc/hosts we could point at it.
243
+ if (external) return { mode: 'off', ghHost: null };
244
+ return { mode: 'transparent', ghHost: null };
245
+ }
246
+
247
+ /**
248
+ * Git configuration a routed task needs, as `key=value` pairs.
249
+ *
250
+ * `github.com` is not intercepted — only `api.github.com` is — so git is sent to
251
+ * the router's smart-HTTP proxy by rewriting the URL. The token rides in a
252
+ * scoped `http.<url>.extraHeader` rather than in the URL itself, so it never
253
+ * lands in a remote URL, a reflog or an error message.
254
+ *
255
+ * `credential.helper` is reset to empty on purpose: the operator's `~/.gitconfig`
256
+ * is mounted into every task and may name a helper holding a real GitHub token.
257
+ * An empty value clears the inherited list, so the only credential the task can
258
+ * present is the router's.
259
+ *
260
+ * @returns {Array<[string, string]>}
261
+ */
262
+ export function buildRouterGitConfigEntries({ baseUrl, token, githubMode = 'transparent' } = {}) {
263
+ if (!baseUrl || !token || githubMode === 'off') return [];
264
+ const routerUrl = `${String(baseUrl).replace(/\/+$/, '')}/`;
265
+ return [
266
+ ['credential.helper', ''],
267
+ ['url.' + `${routerUrl}git/` + '.insteadOf', 'https://github.com/'],
268
+ [`http.${routerUrl}.sslCAInfo`, ROUTER_CA_CONTAINER_PATH],
269
+ [`http.${routerUrl}.extraHeader`, `Authorization: Bearer ${token}`],
270
+ ];
271
+ }
272
+
273
+ /**
274
+ * Environment a routed task needs so its AI CLI, `gh` and `git` reach the router
275
+ * instead of the vendor directly.
276
+ *
277
+ * `ANTHROPIC_BASE_URL` is the important one: Claude Code sends *every* request
278
+ * through it, including agentic sub-loops, so there is no path that quietly
279
+ * escapes the proxy. The router accepts the task token as either
280
+ * `Authorization: Bearer` or `x-api-key`, so both variables are set and the CLI
281
+ * may use whichever it prefers.
282
+ *
283
+ * The CA variables are not interchangeable and each client honours a different
284
+ * one (measured in `experiments/issue-2164/probe-clients-tls.sh`): Node and
285
+ * Claude Code read `NODE_EXTRA_CA_CERTS`, Rust/`gh`/`codex` read `SSL_CERT_FILE`,
286
+ * curl reads `CURL_CA_BUNDLE`.
287
+ *
288
+ * @returns {Record<string,string>}
289
+ */
290
+ export function buildRouterTaskEnv({ tool = 'claude', baseUrl, token, githubMode = 'transparent', ghHost = null, homeDir = '/home/box' } = {}) {
291
+ if (!baseUrl || !token) return {};
292
+ const normalizedTool = normalizeTool(tool);
293
+ const taskEnv = {
294
+ HIVE_MIND_USE_ROUTER: '1',
295
+ HIVE_MIND_ROUTER_URL: baseUrl,
296
+ HIVE_MIND_ROUTER_TOKEN: token,
297
+ // Trust the router's CA without losing the public roots.
298
+ NODE_EXTRA_CA_CERTS: ROUTER_CA_CONTAINER_PATH,
299
+ SSL_CERT_FILE: ROUTER_CA_BUNDLE_CONTAINER_PATH,
300
+ CURL_CA_BUNDLE: ROUTER_CA_BUNDLE_CONTAINER_PATH,
301
+ REQUESTS_CA_BUNDLE: ROUTER_CA_BUNDLE_CONTAINER_PATH,
302
+ // A routed task holds no interactive credential; prompting would hang it.
303
+ GIT_TERMINAL_PROMPT: '0',
304
+ };
305
+ if (ANTHROPIC_TOOLS.has(normalizedTool)) {
306
+ taskEnv.ANTHROPIC_BASE_URL = baseUrl;
307
+ taskEnv.ANTHROPIC_AUTH_TOKEN = token;
308
+ taskEnv.ANTHROPIC_API_KEY = token;
309
+ } else {
310
+ // codex, opencode, gemini and qwen all speak the OpenAI-compatible surface,
311
+ // which the router serves under /v1. Codex additionally ignores
312
+ // OPENAI_BASE_URL and needs the generated provider entry written by
313
+ // buildRouterTaskWiringScript().
314
+ taskEnv.OPENAI_BASE_URL = `${baseUrl}/v1`;
315
+ taskEnv.OPENAI_API_KEY = token;
316
+ if (normalizedTool === 'codex') taskEnv.CODEX_HOME = `${homeDir}/.codex`;
317
+ }
318
+ if (githubMode === 'transparent') {
319
+ // The host stays github.com: gh resolves api.github.com to the router
320
+ // through /etc/hosts, so no gh reconfiguration is needed and every command
321
+ // form — including `gh pr view <url>` — is covered.
322
+ taskEnv.GH_TOKEN = token;
323
+ taskEnv.GITHUB_TOKEN = token;
324
+ } else if (githubMode === 'host' && ghHost) {
325
+ taskEnv.GH_HOST = ghHost;
326
+ taskEnv.GH_ENTERPRISE_TOKEN = token;
327
+ }
328
+ return taskEnv;
329
+ }
330
+
331
+ /**
332
+ * The codex provider entry that points it at the router.
333
+ *
334
+ * Codex 0.147 ignores `OPENAI_BASE_URL` (measured: it kept calling
335
+ * api.openai.com and returned 401), so the endpoint has to be declared as a
336
+ * provider in `CODEX_HOME/config.toml`. `wire_api = "responses"` matches the
337
+ * router's `/v1/responses`.
338
+ */
339
+ export function buildRouterCodexConfig({ baseUrl } = {}) {
340
+ return `model_provider = "${CODEX_PROVIDER_ID}"\n\n[model_providers.${CODEX_PROVIDER_ID}]\nname = "Hive Mind Router"\nbase_url = "${baseUrl}/v1"\nenv_key = "OPENAI_API_KEY"\nwire_api = "responses"\n`;
341
+ }
342
+
343
+ /**
344
+ * Name the Formal AI sidecar is stored under in the router's provider store, and
345
+ * the model id it advertises (R11).
346
+ */
347
+ export const ROUTER_FORMAL_AI_PROVIDER_NAME = 'hive-mind-formal-ai';
348
+ export const ROUTER_FORMAL_AI_MODEL = 'formal-ai';
349
+
350
+ /**
351
+ * `router providers add` argv for an OpenAI-compatible upstream.
352
+ *
353
+ * The router keeps these in `<DATA_DIR>/providers.lenv` with the key encrypted
354
+ * from `TOKEN_SECRET`, so a provider registered once survives a restart of the
355
+ * sidecar and is never written to a task's environment.
356
+ *
357
+ * @returns {string[]|null} argv after the `router` binary, or null when a
358
+ * required field is missing.
359
+ */
360
+ export function buildRouterProviderArgs({ name, baseUrl, model, models = null, apiKey = null } = {}) {
361
+ if (!name || !baseUrl || !model) return null;
362
+ const advertised = models && models.length ? models : [model];
363
+ const args = ['providers', 'add', '--name', name, '--base-url', baseUrl, '--model', model, '--models', advertised.join(',')];
364
+ // The Formal AI sidecar authenticates nothing, but the store requires a key;
365
+ // an explicit placeholder is clearer than an empty string in providers.lenv.
366
+ args.push('--api-key', apiKey || 'unused');
367
+ return args;
368
+ }
369
+
370
+ /**
371
+ * Register the Formal AI sidecar as a router provider (R11), so a task that asks
372
+ * for `--model formal-ai` reaches it *through* the router rather than around it,
373
+ * and the exchange lands in the same per-token request log and audit trail as
374
+ * every model call.
375
+ *
376
+ * Measured in experiments/issue-2164/probe-formal-ai-provider.sh against router
377
+ * 0.109.0: with the default `UPSTREAM_PROVIDER=auto` the router picks a stored
378
+ * provider by the model id in the request, so adding this one does not pin the
379
+ * router — Claude tasks sharing the same sidecar keep reaching Anthropic. After
380
+ * the call, GET /v1/models advertises `{"id":"formal-ai","owned_by":
381
+ * "hive-mind-formal-ai"}` and a chat completion for that id is answered by the
382
+ * sidecar (HTTP 200), with `"provider":"openai-compatible","model":"formal-ai"`
383
+ * recorded in /data/router/audit.jsonl. The pin has since moved to 0.119.0; the
384
+ * `providers add` surface and the automatic-routing behaviour this relies on
385
+ * (upstream router#260) are unchanged there, but the probe has not been re-run
386
+ * against it — re-run it before treating the measurement as current.
387
+ *
388
+ * @param {string} baseUrl origin of the Formal AI sidecar, e.g.
389
+ * `http://link-assistant-formal-ai:8080`
390
+ */
391
+ export function buildRouterFormalAiProviderArgs({ baseUrl } = {}) {
392
+ const origin = String(baseUrl || '')
393
+ .trim()
394
+ .replace(/\/+$/, '');
395
+ if (!origin) return null;
396
+ // The router calls `<base-url>/chat/completions`, so the version segment
397
+ // belongs in the stored base URL.
398
+ const versioned = /\/v1$/.test(origin) ? origin : `${origin}/v1`;
399
+ return buildRouterProviderArgs({ name: ROUTER_FORMAL_AI_PROVIDER_NAME, baseUrl: versioned, model: ROUTER_FORMAL_AI_MODEL });
400
+ }
401
+
402
+ /**
403
+ * The one-shot script that finishes wiring a task container from the host.
404
+ *
405
+ * It runs as root through `docker exec` while the start gate still holds the
406
+ * task command, because start-command's Docker backend forwards only
407
+ * `--privileged`, `-e`, `-v`, `--mount`, `--network` and `--network-alias` —
408
+ * there is no `--add-host` to pass through, and the CA is not known until the
409
+ * router has generated it.
410
+ *
411
+ * Every step is idempotent: an acquire that reuses a running sidecar re-runs
412
+ * this without duplicating a hosts entry or a certificate.
413
+ *
414
+ * @returns {string} a `sh -c` script
415
+ */
416
+ export function buildRouterTaskWiringScript({ routerIp = null, caCertificate = null, homeDir = '/home/box', tool = 'claude', baseUrl = getInternalRouterBaseUrl(), githubMode = 'transparent' } = {}) {
417
+ const lines = ['set -e'];
418
+ if (caCertificate) {
419
+ lines.push(`cat > ${ROUTER_CA_CONTAINER_PATH} <<'HIVE_MIND_ROUTER_CA_PEM'`, String(caCertificate).trim(), 'HIVE_MIND_ROUTER_CA_PEM', `chmod 0644 ${ROUTER_CA_CONTAINER_PATH}`);
420
+ // SSL_CERT_FILE replaces the system store rather than adding to it, so the
421
+ // bundle has to carry the public roots too. A missing system bundle is not
422
+ // fatal: the task still trusts the router, which is what it cannot do without.
423
+ lines.push(`: > ${ROUTER_CA_BUNDLE_CONTAINER_PATH}`, `if [ -f ${CONTAINER_SYSTEM_CA_BUNDLE} ]; then cat ${CONTAINER_SYSTEM_CA_BUNDLE} >> ${ROUTER_CA_BUNDLE_CONTAINER_PATH}; fi`, `cat ${ROUTER_CA_CONTAINER_PATH} >> ${ROUTER_CA_BUNDLE_CONTAINER_PATH}`, `chmod 0644 ${ROUTER_CA_BUNDLE_CONTAINER_PATH}`);
424
+ }
425
+ if (routerIp && githubMode === 'transparent') {
426
+ lines.push(`if ! grep -q ' ${ROUTER_GITHUB_API_HOST}$' /etc/hosts; then printf '%s %s\\n' '${routerIp}' '${ROUTER_GITHUB_API_HOST}' >> /etc/hosts; fi`);
427
+ }
428
+ if (normalizeTool(tool) === 'codex') {
429
+ const codexHome = `${homeDir}/.codex`;
430
+ lines.push(`mkdir -p ${codexHome}`, `cat > ${codexHome}/config.toml <<'HIVE_MIND_ROUTER_CODEX_TOML'`, buildRouterCodexConfig({ baseUrl }).trimEnd(), 'HIVE_MIND_ROUTER_CODEX_TOML');
431
+ // The exec runs as root; the task does not, and codex rewrites its own
432
+ // config. Hand the directory to whoever owns the home directory.
433
+ lines.push(`owner=$(stat -c '%u:%g' ${homeDir} 2>/dev/null || echo '')`, `if [ -n "$owner" ]; then chown -R "$owner" ${codexHome}; fi`);
434
+ }
435
+ return lines.join('\n');
436
+ }
437
+
438
+ /**
439
+ * Vendor credential paths that must NOT be mounted into a routed task.
440
+ *
441
+ * Exported so the suppression can be asserted directly in tests: the security
442
+ * property of this feature is a negative one, and a negative is only trustworthy
443
+ * when it is checked explicitly rather than inferred from a mount list.
444
+ */
445
+ export function getRouterSuppressedCredentialPaths({ tool = 'claude', ghRouted = false } = {}) {
446
+ const normalizedTool = normalizeTool(tool);
447
+ const suppressed = [];
448
+ if (normalizedTool === 'codex') {
449
+ suppressed.push('.codex', '.agents');
450
+ } else if (normalizedTool === 'claude') {
451
+ suppressed.push('.claude', '.claude.json');
452
+ }
453
+ // gh config is only withheld when gh actually has somewhere else to go;
454
+ // otherwise the task would lose GitHub access entirely (see resolveRouterGitHubRouting).
455
+ if (ghRouted) suppressed.push('.config/gh');
456
+ return suppressed;
457
+ }
458
+
459
+ /**
460
+ * Human-readable warnings for the parts of issue #2164 that are not yet covered,
461
+ * so an experimental run states its own limits instead of implying full coverage.
462
+ */
463
+ export function describeRouterCoverageGaps({ model = null, tool = 'claude', githubMode = 'transparent' } = {}) {
464
+ const gaps = [];
465
+ if (githubMode === 'off') {
466
+ gaps.push('GitHub traffic is NOT routed: the task keeps its own gh credential, and destructive API calls are not mediated. Unset HIVE_MIND_ROUTER_GITHUB, or set HIVE_MIND_ROUTER_GH_HOST for an external router.');
467
+ }
468
+ // Measured in experiments/issue-2164/probe-git-transport.sh against router
469
+ // 0.109.0: the router refused `git push :ref` with 403, but a non-fast-forward
470
+ // push succeeded, because git never announces the `force-ref-updates`
471
+ // capability the router looked for. Reported as router#272 and fixed upstream
472
+ // in router#273: from 0.110.0 the router asks GitHub's compare API whether the
473
+ // proposed tip is ahead of the current one and forwards the packfile only if it
474
+ // is, failing closed on any answer it cannot read. The pin is now 0.119.0, so
475
+ // that layer is live and this is no longer warned about.
476
+ //
477
+ // What remains uncovered is the other half of R13. The router's built-in
478
+ // GitHub policy keys on the HTTP method — any DELETE, a forced REST ref
479
+ // update, a destructive GraphQL mutation — so destructive operations spelled
480
+ // as PUT/PATCH/POST are still forwarded. Reported as router#329.
481
+ gaps.push('Destructive GitHub API calls are blocked by method, not by effect: DELETE, forced ref updates and destructive GraphQL are refused, but `PUT /repos/{o}/{r}/branches/{b}/protection`, `PUT .../rulesets/{id}`, `POST .../transfer` and `PATCH /repos/{o}/{r}` with visibility/archived/default_branch are not (upstream link-assistant/router#329). Branch protection is reachable this way, so it is not a control the task cannot touch — keep the repository owner outside the token scope for anything that must not change.');
482
+ if (!ANTHROPIC_TOOLS.has(normalizeTool(tool))) {
483
+ gaps.push(`Routing for '${normalizeTool(tool)}' is less exercised than Claude Code: it is wired through the router's OpenAI-compatible surface and a generated provider entry, and only Claude Code has an end-to-end proof in experiments/issue-2164/.`);
484
+ }
485
+ const requested = String(model || '').trim();
486
+ if (requested === 'formal-ai') {
487
+ gaps.push("Formal AI is registered on the router as an OpenAI-compatible provider, so `--model formal-ai` is served through it and appears in the audit log. The Formal AI sidecar's own upstream calls are not routed yet: when it is run in agent mode against a vendor API, that leg still leaves the sidecar directly.");
488
+ } else if (requested && !/\d/.test(requested)) {
489
+ // The router ships no alias table by design (upstream router#192), and
490
+ // declined to add tier resolution when it was raised (router#323). From
491
+ // 0.115.0 the refusal at least names the ids the deployment does advertise,
492
+ // so a wrong name is one run away from the right one instead of a dead end.
493
+ gaps.push(`The router resolves exact model ids only, as advertised by GET /v1/models — an alias like '${requested}' is rejected, and the refusal lists the ids it would have accepted. Use one of those (for example claude-sonnet-4-5-20250929).`);
494
+ }
495
+ return gaps;
496
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Getting the logs back out (issue #2164, R8/R14/R15).
3
+ *
4
+ * The router only earns its keep if what it records can be read afterwards, and
5
+ * "read afterwards" is harder than it sounds: the request logs live in a named
6
+ * Docker volume that outlives every container that wrote to it, so at the
7
+ * moment an auditor wants them there may well be no router running to ask.
8
+ *
9
+ * Two paths are therefore supported — copy out of the live container when there
10
+ * is one, and mount the volume into a throwaway container when there is not.
11
+ * The second is the one that matters, because it still works after the sidecar
12
+ * has been stopped by the idle reconciler.
13
+ *
14
+ * `describeSystemLogLocations()` is the other half: Hive Mind writes logs in
15
+ * five different places, and an operator collecting evidence needs the whole
16
+ * list, not just the router's part of it. The list is code rather than prose so
17
+ * `docs/COLLECTING-LOGS.md` and `examples/collect-logs.mjs` cannot drift from
18
+ * each other.
19
+ *
20
+ * @see https://github.com/link-assistant/hive-mind/issues/2164
21
+ */
22
+
23
+ import os from 'node:os';
24
+ import path from 'node:path';
25
+ import { promisify } from 'node:util';
26
+ import { execFile } from 'node:child_process';
27
+
28
+ import { dockerOk, inspectDockerContainer } from './docker-sidecar.lib.mjs';
29
+ import { ROUTER_DATA_MOUNT, ROUTER_DATA_VOLUME_NAME, ROUTER_SIDECAR_CONTAINER_NAME, ROUTER_SIDECAR_IMAGE } from './router-isolation.lib.mjs';
30
+ import { resolveBotLogDir } from './bot-logger.lib.mjs';
31
+ import { resolveBotStateDir } from './session-store.lib.mjs';
32
+ import { TASK_SESSION_ARCHIVE_DIR } from './router-session-drain.lib.mjs';
33
+
34
+ const execFileAsync = promisify(execFile);
35
+
36
+ /** Where `$` (start-command) keeps the console log of an isolated session. */
37
+ export const START_COMMAND_LOG_ROOT = '/tmp/start-command/logs';
38
+
39
+ /**
40
+ * Path of a task session's own console log.
41
+ *
42
+ * Mirrors `resolveLogPath()` in telegram-log-command.lib.mjs, which is what the
43
+ * `/log` Telegram command uses; kept in one shape here so a collector and the
44
+ * bot never disagree about where a session's log is.
45
+ */
46
+ export const resolveSessionConsoleLogPath = ({ sessionId, backend = 'docker' }) => (backend ? path.join(START_COMMAND_LOG_ROOT, 'isolation', backend, `${sessionId}.log`) : path.join(START_COMMAND_LOG_ROOT, 'direct', `${sessionId}.log`));
47
+
48
+ /**
49
+ * Every place Hive Mind writes something an audit might need.
50
+ *
51
+ * @returns {Array<{key: string, path: string, kind: string, description: string}>}
52
+ */
53
+ export const describeSystemLogLocations = ({ env = process.env } = {}) => [
54
+ {
55
+ key: 'run-logs',
56
+ path: String(env.HIVE_MIND_LOG_DIR || '').trim() || process.cwd(),
57
+ kind: 'directory',
58
+ description: 'Per-run `solve-*.log` / `hive-*.log`, renamed to `<sessionId>.log` once the AI tool reports its session id. Written to the working directory unless --log-dir says otherwise.',
59
+ },
60
+ {
61
+ key: 'bot-logs',
62
+ path: resolveBotLogDir(env),
63
+ kind: 'directory',
64
+ description: 'Rotated Telegram bot log (`telegram-bot.log` plus timestamped backups): every command, launch, and lifecycle event.',
65
+ },
66
+ {
67
+ key: 'bot-state',
68
+ path: resolveBotStateDir(env),
69
+ kind: 'directory',
70
+ description: 'Tracked sessions and sidecar state, including `router-sidecar.json` — which task held which token, and when. Contains the router signing secret, so it is mode 0600 and must not be copied into a shared archive.',
71
+ },
72
+ {
73
+ key: 'session-console',
74
+ path: path.join(START_COMMAND_LOG_ROOT, 'isolation'),
75
+ kind: 'directory',
76
+ description: 'Console output of each isolated session, one `<sessionId>.log` per backend. This is what the Telegram `/log <uuid>` command serves.',
77
+ },
78
+ {
79
+ key: 'container-logs',
80
+ path: 'docker logs <sessionId>',
81
+ kind: 'command',
82
+ description: "Docker's own capture of a task container's stdout/stderr, available until the container is removed.",
83
+ },
84
+ {
85
+ key: 'router-requests',
86
+ path: `${ROUTER_DATA_VOLUME_NAME}:${ROUTER_DATA_MOUNT}/requests/<token-hash>/requests.jsonl`,
87
+ kind: 'volume',
88
+ description: 'One redacted JSONL request log per issued token — that is, per task (R6). Retained after the token is revoked and after the sidecar is stopped.',
89
+ },
90
+ {
91
+ key: 'router-audit',
92
+ path: `${ROUTER_DATA_VOLUME_NAME}:${ROUTER_DATA_MOUNT}/audit.jsonl`,
93
+ kind: 'volume',
94
+ description: 'Router audit log: one line per authorised request — time, token id, session label, provider, surface, path and model.',
95
+ },
96
+ {
97
+ key: 'task-sessions',
98
+ path: `${ROUTER_DATA_VOLUME_NAME}:${TASK_SESSION_ARCHIVE_DIR}/<sessionId>/`,
99
+ kind: 'volume',
100
+ description: 'Agent session data drained out of each routed task before its container was reclaimed (R7): the transcripts of what the agent actually did.',
101
+ },
102
+ ];
103
+
104
+ /**
105
+ * Arguments for reading the router volume without a running router.
106
+ *
107
+ * The router image is used because it is already on the host; its entrypoint is
108
+ * overridden because we want `cp`, not a server. The volume is mounted read-only
109
+ * so a collection can never damage the evidence it is collecting, and the copy
110
+ * runs as the calling user so the exported files are readable without root.
111
+ */
112
+ export const buildRouterVolumeExportArgs = ({ destination, image = ROUTER_SIDECAR_IMAGE, volume = ROUTER_DATA_VOLUME_NAME, uid = null, gid = null }) => {
113
+ const args = ['run', '--rm', '--entrypoint', 'cp', '--volume', `${volume}:${ROUTER_DATA_MOUNT}:ro`, '--volume', `${destination}:/export`];
114
+ if (uid !== null && gid !== null) args.push('--user', `${uid}:${gid}`);
115
+ args.push(image, '-a', `${ROUTER_DATA_MOUNT}/.`, '/export/');
116
+ return args;
117
+ };
118
+
119
+ /**
120
+ * Copy the router's data volume — request logs, audit log, drained task
121
+ * sessions — into a host directory.
122
+ *
123
+ * @returns {Promise<{collected: boolean, via: string|null, destination: string, error: string|null}>}
124
+ */
125
+ export const collectRouterLogs = async ({ destination, run = execFileAsync, timeoutMs, image = ROUTER_SIDECAR_IMAGE, containerName = ROUTER_SIDECAR_CONTAINER_NAME, uid = typeof process.getuid === 'function' ? process.getuid() : null, gid = typeof process.getgid === 'function' ? process.getgid() : null, log = null } = {}) => {
126
+ const target = path.resolve(destination || path.join(os.tmpdir(), 'hive-mind-router-logs'));
127
+ const container = await inspectDockerContainer(containerName, { run, timeoutMs });
128
+ if (container.running) {
129
+ if (await dockerOk(run, ['cp', `${containerName}:${ROUTER_DATA_MOUNT}/.`, target], { timeoutMs })) {
130
+ if (log) await log(`📥 Copied router logs from the running sidecar into ${target}`);
131
+ return { collected: true, via: 'container', destination: target, error: null };
132
+ }
133
+ }
134
+ // The usual case once the idle reconciler has done its job: the volume is
135
+ // still there, the container is not.
136
+ if (await dockerOk(run, buildRouterVolumeExportArgs({ destination: target, image, uid, gid }), { timeoutMs })) {
137
+ if (log) await log(`📥 Copied router logs from volume '${ROUTER_DATA_VOLUME_NAME}' into ${target}`);
138
+ return { collected: true, via: 'volume', destination: target, error: null };
139
+ }
140
+ return { collected: false, via: null, destination: target, error: `could not read '${ROUTER_DATA_VOLUME_NAME}': is Docker running, and has the router ever been started?` };
141
+ };
142
+
143
+ export default { buildRouterVolumeExportArgs, collectRouterLogs, describeSystemLogLocations, resolveSessionConsoleLogPath, START_COMMAND_LOG_ROOT };