@link-assistant/hive-mind 2.13.5 → 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.
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Blocking destructive git pushes from inside a routed task (issue #2164, R13).
3
+ *
4
+ * The issue asks that agents lose the *physical ability* to destroy data:
5
+ * "immediately apply block of all delete operations or history changes like git
6
+ * reset and so on detected up on git push". A `git reset` is harmless on its
7
+ * own — nothing is lost until the rewritten history reaches the remote — so the
8
+ * point where the damage becomes real, and therefore the point worth guarding,
9
+ * is the push.
10
+ *
11
+ * Three layers were planned for R13. This module is layer 2:
12
+ *
13
+ * 1. Branch protection on the remote (`src/protect-branch.mjs`) — server-side
14
+ * and unbypassable, but only covers branches somebody protected.
15
+ * 2. This `pre-push` hook — covers every branch and every remote in the task
16
+ * container, costs nothing, and is defeated by `git push --no-verify`.
17
+ * 3. The router's git transport (`/git/…`), which routed tasks now push
18
+ * through. It is unbypassable for the same reason the model traffic is —
19
+ * the task holds no other credential — and it refuses branch deletions
20
+ * outright (measured: `git push origin :refs/heads/x` → HTTP 403).
21
+ *
22
+ * Layer 3 originally covered deletions but not force pushes: the router decided
23
+ * by looking for a `force-ref-updates` capability that git never sends, so a
24
+ * non-fast-forward push was relayed unchanged (measured in
25
+ * `experiments/issue-2164/probe-git-transport.sh`, reported upstream as
26
+ * link-assistant/router#272). That was fixed in link-assistant/router#273 and
27
+ * ships from 0.110.0, which is at or below the pin — the router now asks
28
+ * GitHub's compare API whether the proposed tip is ahead of the current one and
29
+ * fails closed when it cannot tell.
30
+ *
31
+ * So this hook is no longer the only thing standing between an agent and a
32
+ * rewritten branch. It is still worth keeping: it is the layer that applies to
33
+ * every remote rather than only the routed one, it costs nothing, and it stops
34
+ * the push locally instead of after a round trip. It remains defeated by
35
+ * `--no-verify`, so it is a speed bump and the docs say so.
36
+ *
37
+ * The hook is delivered by mounting a host directory read-only into the task
38
+ * container and pointing git at it with `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_0`
39
+ * (git >= 2.31). Env vars rather than `git config --global` because the task's
40
+ * `~/.gitconfig` is bind-mounted from the *host*: writing to it would reconfigure
41
+ * the operator's own machine.
42
+ *
43
+ * @see https://github.com/link-assistant/hive-mind/issues/2164
44
+ */
45
+
46
+ import fs from 'node:fs';
47
+ import os from 'node:os';
48
+ import path from 'node:path';
49
+
50
+ /** Only `pre-push` is provided; git silently skips hook names that do not exist. */
51
+ export const GIT_PUSH_GUARD_HOOK_NAME = 'pre-push';
52
+
53
+ /** Operator escape hatch, propagated from an explicit `solve` opt-in (see `hasForcePushOptIn`). */
54
+ export const GIT_PUSH_GUARD_ESCAPE_ENV = 'HIVE_MIND_ALLOW_DESTRUCTIVE_PUSH';
55
+
56
+ /** Where the hook directory is mounted inside a Docker-isolated task. */
57
+ export const GIT_PUSH_GUARD_CONTAINER_DIR = '/home/box/.hive-mind/git-hooks';
58
+
59
+ /**
60
+ * The hook itself.
61
+ *
62
+ * git feeds `<local ref> <local sha> <remote ref> <remote sha>` on stdin, one
63
+ * line per ref being updated, and a non-zero exit aborts the whole push. Two
64
+ * things are refused:
65
+ *
66
+ * - an all-zero *local* sha, which is how `git push --delete`, `git push
67
+ * :branch` and `--mirror`/`--prune` deletions present themselves;
68
+ * - an update where the remote's current commit is not an ancestor of what we
69
+ * are about to send, i.e. a force push discarding commits that exist only on
70
+ * the remote — the shape a `git reset --hard` + `push --force` takes.
71
+ *
72
+ * A remote sha we do not have locally is refused too: it cannot be proven to be
73
+ * an ancestor, and the honest answer to "would this destroy something?" is
74
+ * "unknown". Everything else — ordinary fast-forward pushes, new branches, new
75
+ * tags — passes untouched.
76
+ *
77
+ * POSIX sh, no bashisms: the isolation image's /bin/sh is dash.
78
+ */
79
+ export const PRE_PUSH_GUARD_SCRIPT = `#!/bin/sh
80
+ # Hive Mind push guard (issue #2164). Refuses branch/tag deletions and
81
+ # history-rewriting (non-fast-forward) pushes from inside an isolated task.
82
+ # Generated file - edit src/git-push-guard.lib.mjs instead.
83
+ set -u
84
+
85
+ remote_name="\${1:-}"
86
+ remote_url="\${2:-}"
87
+ allow="\${${GIT_PUSH_GUARD_ESCAPE_ENV}:-}"
88
+ status=0
89
+
90
+ is_zero_sha() {
91
+ [ -n "$1" ] || return 0
92
+ case "$1" in
93
+ *[!0]*) return 1 ;;
94
+ *) return 0 ;;
95
+ esac
96
+ }
97
+
98
+ refuse() {
99
+ status=1
100
+ echo "🛑 Hive Mind push guard: refused to $1" >&2
101
+ echo " remote: \${remote_name} \${remote_url}" >&2
102
+ echo " ref: $2" >&2
103
+ }
104
+
105
+ while read -r local_ref local_sha remote_ref remote_sha; do
106
+ [ -n "\${remote_ref:-}" ] || continue
107
+ if is_zero_sha "\${local_sha:-}"; then
108
+ refuse "delete a remote ref" "\${remote_ref}"
109
+ continue
110
+ fi
111
+ is_zero_sha "\${remote_sha:-}" && continue
112
+ if ! git cat-file -e "\${remote_sha}^{commit}" 2>/dev/null; then
113
+ refuse "overwrite a remote commit this clone does not have (\${remote_sha})" "\${remote_ref}"
114
+ continue
115
+ fi
116
+ if ! git merge-base --is-ancestor "\${remote_sha}" "\${local_sha}" 2>/dev/null; then
117
+ refuse "rewrite history (the remote's \${remote_sha} is not an ancestor of \${local_sha})" "\${remote_ref}"
118
+ fi
119
+ done
120
+
121
+ if [ "\${status}" -ne 0 ]; then
122
+ case "\${allow}" in
123
+ 1 | true | TRUE | yes | YES)
124
+ echo "⚠️ ${GIT_PUSH_GUARD_ESCAPE_ENV} is set, so the push is allowed anyway." >&2
125
+ exit 0
126
+ ;;
127
+ esac
128
+ echo "" >&2
129
+ echo "Destructive pushes are blocked for routed tasks (--use-router, issue #2164)." >&2
130
+ echo "Nothing was sent. Push a new commit instead, or ask a human operator to run it." >&2
131
+ fi
132
+
133
+ exit "\${status}"
134
+ `;
135
+
136
+ /**
137
+ * Host directory holding the generated hook.
138
+ *
139
+ * Deliberately NOT the bot state directory: that holds the router's signing
140
+ * secret, and this directory is mounted into every routed task.
141
+ */
142
+ export function resolveGitPushGuardHostDir({ env = process.env, homeDir = os.homedir() } = {}) {
143
+ const explicit = String(env.HIVE_MIND_GIT_HOOKS_DIR || '').trim();
144
+ return explicit || path.join(homeDir, '.hive-mind', 'git-hooks');
145
+ }
146
+
147
+ /**
148
+ * Write the hook to the host, ready to be mounted.
149
+ *
150
+ * Rewritten on every launch so an upgrade cannot leave a stale hook behind.
151
+ * Never throws: a task that cannot get its guard is still a task worth running
152
+ * (the caller warns), because the remaining layers — branch protection — are
153
+ * the ones that were never bypassable anyway.
154
+ *
155
+ * @returns {{installed: boolean, dir: string, hookPath: string, error: string|null}}
156
+ */
157
+ export function installGitPushGuard({ env = process.env, homeDir = os.homedir(), fsImpl = fs } = {}) {
158
+ const dir = resolveGitPushGuardHostDir({ env, homeDir });
159
+ const hookPath = path.join(dir, GIT_PUSH_GUARD_HOOK_NAME);
160
+ try {
161
+ fsImpl.mkdirSync(dir, { recursive: true });
162
+ fsImpl.writeFileSync(hookPath, PRE_PUSH_GUARD_SCRIPT, { mode: 0o755 });
163
+ // writeFileSync only applies `mode` when it creates the file, so an existing
164
+ // hook keeps whatever permissions it had — including non-executable ones,
165
+ // which git ignores silently.
166
+ fsImpl.chmodSync(hookPath, 0o755);
167
+ return { installed: true, dir, hookPath, error: null };
168
+ } catch (error) {
169
+ return { installed: false, dir, hookPath, error: error?.message || String(error) };
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Turn `[key, value]` pairs into git's `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`
175
+ * environment form (git >= 2.31).
176
+ *
177
+ * A routed task needs several such settings — the hook path here, plus the URL
178
+ * rewrite and CA that send git through the router (issue #2164) — and they share
179
+ * one counter, so building them separately would have each overwrite the other.
180
+ * The order is preserved because git applies these last-to-win, which is what
181
+ * lets `credential.helper=` clear an inherited helper list.
182
+ *
183
+ * @param {Array<[string, string]>} entries
184
+ * @returns {Record<string,string>}
185
+ */
186
+ export function buildGitConfigEnv(entries = []) {
187
+ const usable = entries.filter(entry => Array.isArray(entry) && entry[0]);
188
+ if (usable.length === 0) return {};
189
+ const taskEnv = { GIT_CONFIG_COUNT: String(usable.length) };
190
+ usable.forEach(([key, value], index) => {
191
+ taskEnv[`GIT_CONFIG_KEY_${index}`] = key;
192
+ taskEnv[`GIT_CONFIG_VALUE_${index}`] = value ?? '';
193
+ });
194
+ return taskEnv;
195
+ }
196
+
197
+ /**
198
+ * Environment that points git at the mounted hook for every repository in the
199
+ * container, without writing to the bind-mounted `~/.gitconfig`.
200
+ *
201
+ * `extraConfig` carries any other settings the same task needs, so all of them
202
+ * end up under one `GIT_CONFIG_COUNT`.
203
+ */
204
+ export function buildGitPushGuardEnv({ hooksPath = GIT_PUSH_GUARD_CONTAINER_DIR, allowDestructive = false, extraConfig = [] } = {}) {
205
+ const entries = [...(hooksPath ? [['core.hooksPath', hooksPath]] : []), ...extraConfig];
206
+ const taskEnv = buildGitConfigEnv(entries);
207
+ if (Object.keys(taskEnv).length === 0) return {};
208
+ if (allowDestructive) taskEnv[GIT_PUSH_GUARD_ESCAPE_ENV] = '1';
209
+ return taskEnv;
210
+ }
211
+
212
+ /**
213
+ * Read the existing fork-divergence opt-in out of a raw argument vector.
214
+ *
215
+ * `--allow-fork-divergence-resolution-using-force-push-with-lease` already means
216
+ * "this operator accepts a force push", and Hive Mind performs one itself in
217
+ * `solve.fork-sync.lib.mjs`. Blocking that would break a documented workflow, so
218
+ * the opt-in is propagated into the container rather than overridden.
219
+ *
220
+ * @param {string[]} args
221
+ */
222
+ export function hasForcePushOptIn(args) {
223
+ const list = Array.isArray(args) ? args : [];
224
+ return list.some(arg => {
225
+ const value = String(arg ?? '');
226
+ return value === '--allow-fork-divergence-resolution-using-force-push-with-lease' || value === '--allow-fork-divergence-resolution-using-force-push-with-lease=true';
227
+ });
228
+ }
229
+
230
+ export default { buildGitConfigEnv, buildGitPushGuardEnv, hasForcePushOptIn, installGitPushGuard, resolveGitPushGuardHostDir, GIT_PUSH_GUARD_CONTAINER_DIR, PRE_PUSH_GUARD_SCRIPT };
@@ -26,6 +26,9 @@ import { acquireFormalAiSidecarForTask, attachFormalAiTaskContainer, releaseForm
26
26
  // importing this runner and creating a cycle. Re-exported here because callers
27
27
  // and tests have always reached them through the isolation runner. See #2154.
28
28
  import { getDockerIsolationImage } from './hive-mind-image.lib.mjs';
29
+ import { buildRouterGitConfigEntries, buildRouterTaskEnv, getRouterSuppressedCredentialPaths, hasUseRouterFlag, isRouterEnabled, resolveRouterBaseUrl, resolveRouterGitHubRouting } from './router-isolation.lib.mjs';
30
+ import { acquireRouterForTask, attachRouterTaskContainer, registerFormalAiWithRouter, releaseRouterForTask } from './router-task-isolation.lib.mjs';
31
+ import { buildGitConfigEnv, GIT_PUSH_GUARD_CONTAINER_DIR, GIT_PUSH_GUARD_ESCAPE_ENV, hasForcePushOptIn, installGitPushGuard } from './git-push-guard.lib.mjs';
29
32
  export { getDockerIsolationImage, resolveDockerIsolationImageTag } from './hive-mind-image.lib.mjs';
30
33
  let commandStreamDollarPromise = null;
31
34
  async function getCommandStreamDollar() {
@@ -114,21 +117,31 @@ export function resolveHostDockerSock({ env = process.env } = {}) {
114
117
  * commit. See issue #1939. Tool credentials are deliberately scoped: Codex
115
118
  * sessions do not receive Claude files and Claude sessions do not receive Codex
116
119
  * files.
120
+ *
121
+ * Issue #2164 (EXPERIMENTAL): with `useRouter` the vendor credential mounts are
122
+ * withheld entirely, so the task never holds the subscription — it reaches the
123
+ * `hive-mind-router` sidecar with its own scoped token instead. Git identity is
124
+ * still mounted, because it carries no secret and `solve` aborts without it
125
+ * (issue #1939). The gh config is only withheld when `ghRouted` says gh has
126
+ * somewhere else to go; otherwise the task would lose GitHub access entirely.
117
127
  */
118
- export function getDockerIsolationAuthMounts({ tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync } = {}) {
128
+ export function getDockerIsolationAuthMounts({ tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync, useRouter = false, ghRouted = false } = {}) {
119
129
  const mounts = [];
120
130
  const normalizedTool = normalizeTool(tool);
121
- maybeAddMount(mounts, env.GH_CONFIG_DIR || path.join(homeDir, '.config', 'gh'), path.join(DOCKER_CONTAINER_HOME, '.config', 'gh'), existsSync);
131
+ const suppressed = useRouter ? new Set(getRouterSuppressedCredentialPaths({ tool: normalizedTool, ghRouted })) : new Set();
132
+ if (!suppressed.has('.config/gh')) {
133
+ maybeAddMount(mounts, env.GH_CONFIG_DIR || path.join(homeDir, '.config', 'gh'), path.join(DOCKER_CONTAINER_HOME, '.config', 'gh'), existsSync);
134
+ }
122
135
  // Git identity (tool-agnostic, required for commits). Honor the same env vars git itself reads for an alternate global config location (GIT_CONFIG_GLOBAL) and the XDG base dir, falling back to the conventional `~/.gitconfig` and `~/.config/git`. Missing host paths are skipped, so a container image that already bakes a git identity is left untouched. See issue #1939.
123
136
  maybeAddMount(mounts, env.GIT_CONFIG_GLOBAL || path.join(homeDir, '.gitconfig'), path.join(DOCKER_CONTAINER_HOME, '.gitconfig'), existsSync);
124
137
  maybeAddMount(mounts, env.XDG_CONFIG_HOME ? path.join(env.XDG_CONFIG_HOME, 'git') : path.join(homeDir, '.config', 'git'), path.join(DOCKER_CONTAINER_HOME, '.config', 'git'), existsSync);
125
138
  if (normalizedTool === 'codex') {
126
- maybeAddMount(mounts, path.join(homeDir, '.codex'), path.join(DOCKER_CONTAINER_HOME, '.codex'), existsSync);
139
+ if (!suppressed.has('.codex')) maybeAddMount(mounts, path.join(homeDir, '.codex'), path.join(DOCKER_CONTAINER_HOME, '.codex'), existsSync);
127
140
  // Issue #2074: Codex also discovers persistent user Agent Skills from ~/.agents/skills. Propagate that standard location alongside .codex so direct and Docker-isolated solver sessions expose the same capabilities.
128
- maybeAddMount(mounts, path.join(homeDir, '.agents'), path.join(DOCKER_CONTAINER_HOME, '.agents'), existsSync);
141
+ if (!suppressed.has('.agents')) maybeAddMount(mounts, path.join(homeDir, '.agents'), path.join(DOCKER_CONTAINER_HOME, '.agents'), existsSync);
129
142
  } else if (normalizedTool === 'claude') {
130
- maybeAddMount(mounts, path.join(homeDir, '.claude'), path.join(DOCKER_CONTAINER_HOME, '.claude'), existsSync);
131
- maybeAddMount(mounts, path.join(homeDir, '.claude.json'), path.join(DOCKER_CONTAINER_HOME, '.claude.json'), existsSync);
143
+ if (!suppressed.has('.claude')) maybeAddMount(mounts, path.join(homeDir, '.claude'), path.join(DOCKER_CONTAINER_HOME, '.claude'), existsSync);
144
+ if (!suppressed.has('.claude.json')) maybeAddMount(mounts, path.join(homeDir, '.claude.json'), path.join(DOCKER_CONTAINER_HOME, '.claude.json'), existsSync);
132
145
  }
133
146
  return mounts;
134
147
  }
@@ -191,7 +204,18 @@ export async function resolveFormalAiIsolationEnv(env = process.env, { lookup =
191
204
  * reused instead of re-downloaded — no `--pull` plumbing required (issue #1879).
192
205
  */
193
206
  export function buildDockerIsolationStartArgs(command, args = [], options = {}) {
194
- const { sessionId, tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync } = options;
207
+ const { sessionId, tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync, useRouter = false, routerToken = null, installGuard = installGitPushGuard } = options;
208
+ // Issue #2164 (EXPERIMENTAL): router isolation replaces the credential mounts
209
+ // with a scoped token pointing at the `hive-mind-router` sidecar. It only
210
+ // engages when a token was actually issued; without one the task would have
211
+ // neither credentials nor a route, so we fail open to the default mounts
212
+ // rather than launching an agent that cannot reach any model.
213
+ const routerActive = isRouterEnabled({ useRouter, env }) && Boolean(routerToken);
214
+ const routerEndpoint = routerActive ? resolveRouterBaseUrl({ env }) : { baseUrl: null, external: false };
215
+ const routerBaseUrl = routerEndpoint.baseUrl;
216
+ const routerGitHub = routerActive ? resolveRouterGitHubRouting({ env, external: Boolean(routerEndpoint.external) }) : { mode: 'off', ghHost: null };
217
+ const routerEnv = routerActive && routerBaseUrl ? buildRouterTaskEnv({ tool, baseUrl: routerBaseUrl, token: routerToken, githubMode: routerGitHub.mode, ghHost: routerGitHub.ghHost, homeDir: DOCKER_CONTAINER_HOME }) : {};
218
+ const routerWired = Object.keys(routerEnv).length > 0;
195
219
  const image = getDockerIsolationImage({ env });
196
220
  const startArgs = ['--isolated', 'docker', '--image', image];
197
221
  if (shouldRunPrivilegedDockerIsolation(image, env)) {
@@ -205,8 +229,33 @@ export function buildDockerIsolationStartArgs(command, args = [], options = {})
205
229
  if (env.HIVE_MIND_FORMAL_AI_BASE_URL) {
206
230
  startArgs.push('-e', `HIVE_MIND_FORMAL_AI_BASE_URL=${env.HIVE_MIND_FORMAL_AI_BASE_URL}`);
207
231
  }
208
- for (const mount of getDockerIsolationAuthMounts({ tool, env, homeDir, existsSync })) {
209
- startArgs.push('--volume', `${mount.source}:${mount.target}`);
232
+ for (const [name, value] of Object.entries(routerEnv)) {
233
+ startArgs.push('-e', `${name}=${value}`);
234
+ }
235
+ const mounts = getDockerIsolationAuthMounts({ tool, env, homeDir, existsSync, useRouter: routerWired, ghRouted: routerWired && routerGitHub.mode !== 'off' });
236
+ // Issue #2164 (R13): a routed task also loses the ability to destroy remote
237
+ // history by accident. The hook lives on the host and is mounted read-only, so
238
+ // the task cannot edit the rule it is being held to; git is pointed at it with
239
+ // GIT_CONFIG_* rather than `git config --global`, because the container's
240
+ // ~/.gitconfig is the operator's own file. This is one layer of three (see
241
+ // git-push-guard.lib.mjs) and `--no-verify` still gets past it.
242
+ if (routerWired) {
243
+ // One `GIT_CONFIG_COUNT` covers both the hook and the router's git
244
+ // transport: git shares the counter across all of them, so they have to be
245
+ // built together or the second would silently replace the first.
246
+ const gitConfigEntries = buildRouterGitConfigEntries({ baseUrl: routerBaseUrl, token: routerToken, githubMode: routerGitHub.mode });
247
+ const guard = installGuard({ env, homeDir });
248
+ if (guard.installed) {
249
+ mounts.push({ source: guard.dir, target: GIT_PUSH_GUARD_CONTAINER_DIR, readOnly: true });
250
+ gitConfigEntries.unshift(['core.hooksPath', GIT_PUSH_GUARD_CONTAINER_DIR]);
251
+ if (hasForcePushOptIn(args)) startArgs.push('-e', `${GIT_PUSH_GUARD_ESCAPE_ENV}=1`);
252
+ }
253
+ for (const [name, value] of Object.entries(buildGitConfigEnv(gitConfigEntries))) {
254
+ startArgs.push('-e', `${name}=${value}`);
255
+ }
256
+ }
257
+ for (const mount of mounts) {
258
+ startArgs.push('--volume', `${mount.source}:${mount.target}${mount.readOnly ? ':ro' : ''}`);
210
259
  }
211
260
  const taskCommand = buildShellCommand(command, args);
212
261
  startArgs.push('--detached', '--session', sessionId, '--', buildDockerStartGatedCommand(taskCommand, sessionId));
@@ -354,6 +403,25 @@ export async function executeWithIsolation(command, args, options = {}) {
354
403
  const hostEnv = options.env || process.env;
355
404
  const { sidecar, error: sidecarError } = await acquireFormalAiSidecarForTask({ backend, args, model: options.model ?? null, tool: options.tool ?? null, sessionId, env: hostEnv, verbose });
356
405
  if (sidecarError) return failLaunch(sidecarError);
406
+ // Issue #2164 (EXPERIMENTAL): --use-router replaces the task's credential
407
+ // mounts with a token scoped to it alone. Like the Formal AI lease this is
408
+ // taken before the container exists, because the token is part of the
409
+ // environment the container is created with — and like it, it fails closed.
410
+ const { router, error: routerError } = await acquireRouterForTask({ backend, useRouter: options.useRouter === true || hasUseRouterFlag(args), model: options.model ?? null, tool: options.tool ?? 'claude', githubRepo: options.githubRepo ?? null, sessionId, env: hostEnv, verbose });
411
+ if (routerError) {
412
+ await releaseFormalAiSidecarForTask({ sidecar, sessionId, env: hostEnv, verbose });
413
+ return failLaunch(routerError);
414
+ }
415
+ // R11: when both sidecars are up, the router is taught to serve `formal-ai`
416
+ // itself, so that model is mediated and audited like every other one. Done
417
+ // before the container is created because the provider has to exist by the
418
+ // time the task issues its first request.
419
+ const formalAiRoutingError = await registerFormalAiWithRouter({ router, sidecar, verbose });
420
+ if (formalAiRoutingError) {
421
+ await releaseRouterForTask({ router, sessionId, env: hostEnv, verbose });
422
+ await releaseFormalAiSidecarForTask({ sidecar, sessionId, env: hostEnv, verbose });
423
+ return failLaunch(formalAiRoutingError);
424
+ }
357
425
  const taskEnv = sidecar ? { ...hostEnv, HIVE_MIND_FORMAL_AI_BASE_URL: sidecar.baseUrl } : hostEnv;
358
426
  const effectiveOptions =
359
427
  backend === 'docker'
@@ -362,7 +430,7 @@ export async function executeWithIsolation(command, args, options = {}) {
362
430
  env: await resolveFormalAiIsolationEnv(taskEnv),
363
431
  }
364
432
  : options;
365
- const startCommandArgs = buildStartCommandArgs(command, args, { ...effectiveOptions, sessionId });
433
+ const startCommandArgs = buildStartCommandArgs(command, args, { ...effectiveOptions, sessionId, useRouter: Boolean(router), routerToken: router?.token ?? null });
366
434
  if (verbose) {
367
435
  console.log(`[VERBOSE] isolation-runner: ${[binPath, ...startCommandArgs].map(shellQuote).join(' ')}`);
368
436
  if (backend === 'docker') {
@@ -386,6 +454,7 @@ export async function executeWithIsolation(command, args, options = {}) {
386
454
  }
387
455
  let containerFilesystemStartBytes = null;
388
456
  let formalAiAttachError = null;
457
+ let routerAttachError = null;
389
458
  if (result.success && backend === 'docker') {
390
459
  try {
391
460
  containerFilesystemStartBytes = await getDockerContainerWritableLayerSize(sessionId, verbose);
@@ -398,10 +467,17 @@ export async function executeWithIsolation(command, args, options = {}) {
398
467
  // start sequence, and doing it here keeps the attach fail-closed on any
399
468
  // installed version instead of silently one-network on older parsers.
400
469
  formalAiAttachError = await attachFormalAiTaskContainer({ sidecar, sessionId, verbose });
470
+ routerAttachError = await attachRouterTaskContainer({ router, sessionId, env: hostEnv, verbose });
401
471
  } finally {
402
472
  await releaseDockerContainerStartGate(sessionId, verbose);
403
473
  }
404
474
  }
475
+ if (router && (!result.success || formalAiAttachError || routerAttachError)) {
476
+ // Fail closed for the same reason the acquire does: a task that cannot
477
+ // reach the router must not be left running with no route to a model.
478
+ if (routerAttachError) await removeDockerContainer(sessionId, verbose);
479
+ await releaseRouterForTask({ router, sessionId, env: hostEnv, verbose });
480
+ }
405
481
  if (sidecar && (!result.success || formalAiAttachError)) {
406
482
  // Fail closed: without the internal network the task cannot reach Formal
407
483
  // AI, and issue #2146 forbids falling back to another model.
@@ -411,6 +487,10 @@ export async function executeWithIsolation(command, args, options = {}) {
411
487
  return failLaunch(`Formal AI task container could not be attached to the internal Formal AI network, so the task was stopped instead of falling back to another model (issue #2146): ${formalAiAttachError}`, { output: result.output });
412
488
  }
413
489
  }
490
+ if (routerAttachError) {
491
+ if (sidecar) await releaseFormalAiSidecarForTask({ sidecar, sessionId, env: hostEnv, verbose });
492
+ return failLaunch(`The task container could not be joined to the router (internal network, CA trust or api.github.com interception), so it was stopped rather than run without a route to any model (issue #2164): ${routerAttachError}`, { output: result.output });
493
+ }
414
494
  // Issue #1939: capture the freshly-launched docker session's reported status
415
495
  // and the live container state together, so the next iteration has the data to
416
496
  // diagnose a premature "executed/-1" status (problem #1) or a surprise image