@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.
- package/CHANGELOG.md +10 -0
- package/README.hi.md +2 -0
- package/README.md +2 -0
- package/README.ru.md +2 -0
- package/README.zh.md +2 -0
- package/package.json +1 -1
- package/src/docker-sidecar.lib.mjs +276 -0
- package/src/formal-ai-maintenance.lib.mjs +2 -14
- package/src/formal-ai-sidecar.lib.mjs +17 -137
- package/src/git-push-guard.lib.mjs +230 -0
- package/src/isolation-runner.lib.mjs +90 -10
- package/src/router-isolation.lib.mjs +496 -0
- package/src/router-logs.lib.mjs +143 -0
- package/src/router-maintenance.lib.mjs +77 -0
- package/src/router-session-drain.lib.mjs +153 -0
- package/src/router-sidecar.lib.mjs +516 -0
- package/src/router-task-isolation.lib.mjs +121 -0
- package/src/solve.config.lib.mjs +11 -0
- package/src/task.config.lib.mjs +5 -0
- package/src/task.mjs +1 -0
- package/src/telegram-bot.mjs +18 -0
|
@@ -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
|
-
|
|
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
|
|
209
|
-
startArgs.push('
|
|
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
|