@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.
- package/CHANGELOG.md +29 -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/agent.lib.mjs +5 -1
- package/src/claude.lib.mjs +5 -158
- package/src/claude.session-tokens.lib.mjs +180 -0
- package/src/codex.diagnostics.lib.mjs +135 -0
- package/src/codex.lib.mjs +8 -121
- package/src/config.lib.mjs +9 -0
- 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/gemini.lib.mjs +5 -1
- package/src/git-push-guard.lib.mjs +230 -0
- package/src/git-retry.lib.mjs +97 -0
- package/src/github-pr-idempotency.lib.mjs +83 -0
- package/src/github-rate-limit.lib.mjs +44 -41
- package/src/hive.mjs +8 -150
- package/src/hive.repository-fallback.lib.mjs +125 -0
- package/src/hive.startup-checks.lib.mjs +57 -0
- package/src/isolation-runner.lib.mjs +94 -287
- package/src/isolation-runner.parsers.lib.mjs +292 -0
- package/src/lib.mjs +79 -18
- package/src/opencode.lib.mjs +5 -1
- package/src/qwen.lib.mjs +5 -1
- 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/session-monitor.lib.mjs +12 -272
- package/src/session-monitor.queries.lib.mjs +304 -0
- package/src/solve.auto-pr-push-sync.lib.mjs +176 -0
- package/src/solve.auto-pr.lib.mjs +40 -154
- package/src/solve.config.lib.mjs +11 -0
- package/src/solve.mjs +8 -158
- package/src/solve.mode.lib.mjs +191 -0
- package/src/task.config.lib.mjs +5 -0
- package/src/task.mjs +1 -0
- package/src/telegram-bot.mjs +18 -0
- package/src/telegram-solve-queue.lib.mjs +19 -272
- package/src/telegram-solve-queue.throttling.lib.mjs +323 -0
- package/src/transient-errors.lib.mjs +238 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifecycle of the `hive-mind-router` sidecar (issue #2164, EXPERIMENTAL).
|
|
3
|
+
*
|
|
4
|
+
* `router-isolation.lib.mjs` decides what a routed task should see. This module
|
|
5
|
+
* makes that true: it starts one Link.Assistant Router container, mounts the
|
|
6
|
+
* operator's vendor credentials into it and nowhere else, mints one scoped
|
|
7
|
+
* `la_sk_…` token per task, and stops the container once the last task that
|
|
8
|
+
* needed it has finished.
|
|
9
|
+
*
|
|
10
|
+
* Two properties drive the design and are worth stating outright:
|
|
11
|
+
*
|
|
12
|
+
* 1. **The sidecar keeps its default bridge.** Unlike the Formal AI sidecar,
|
|
13
|
+
* which only ever talks to tasks, this container must reach api.anthropic.com
|
|
14
|
+
* and api.github.com. A single `docker run --network hive-mind-router` would
|
|
15
|
+
* *replace* the bridge with an `--internal` network and leave the router with
|
|
16
|
+
* no upstream, so the internal network is attached afterwards instead.
|
|
17
|
+
* 2. **`TOKEN_SECRET` never leaves this process.** It signs every token, so
|
|
18
|
+
* anyone holding it can mint subscription access — upstream states it plainly:
|
|
19
|
+
* "Keep it out of the environment of the tasks themselves." It is generated
|
|
20
|
+
* once, persisted in a mode-0600 state file, and passed only to the router.
|
|
21
|
+
*
|
|
22
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2164
|
|
23
|
+
* @see https://github.com/link-assistant/router
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { execFile } from 'node:child_process';
|
|
27
|
+
import crypto from 'node:crypto';
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import os from 'node:os';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import { promisify } from 'node:util';
|
|
32
|
+
|
|
33
|
+
import { attachDockerNetwork, DEFAULT_IMAGE_TIMEOUT_MS, dockerOk, dockerText, ensureDockerVolume, ensureInternalDockerNetwork, inspectDockerContainer, readDockerImageDigest, readSidecarState, reconcileSidecarLeases, resolveSidecarStatePath, sleep, writeSidecarState } from './docker-sidecar.lib.mjs';
|
|
34
|
+
import { drainTaskSessionData } from './router-session-drain.lib.mjs';
|
|
35
|
+
import { buildRouterTaskWiringScript, getInternalRouterBaseUrl, ROUTER_CREDENTIAL_MOUNTS, ROUTER_DATA_MOUNT, ROUTER_DATA_VOLUME_NAME, ROUTER_GH_CONFIG_MOUNT, ROUTER_SIDECAR_CONTAINER_NAME, ROUTER_SIDECAR_IMAGE, ROUTER_SIDECAR_LABEL, ROUTER_SIDECAR_NETWORK_ALIAS, ROUTER_SIDECAR_NETWORK_NAME, ROUTER_SIDECAR_PORT, ROUTER_TLS_DNS_NAMES, resolveRouterBaseUrl } from './router-isolation.lib.mjs';
|
|
36
|
+
import { withStateLock } from './state-lock.lib.mjs';
|
|
37
|
+
|
|
38
|
+
const execFileAsync = promisify(execFile);
|
|
39
|
+
|
|
40
|
+
const LOG_PREFIX = 'router-sidecar';
|
|
41
|
+
const STATE_FILE_NAME = 'router-sidecar.json';
|
|
42
|
+
const SIDECAR_LOCK_NAME = 'router-sidecar';
|
|
43
|
+
const DEFAULT_HEALTH_ATTEMPTS = 60;
|
|
44
|
+
/** HOME inside the isolation image; the wiring script writes under it. */
|
|
45
|
+
const TASK_CONTAINER_HOME = '/home/box';
|
|
46
|
+
const DEFAULT_HEALTH_DELAY_MS = 1000;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Default limits stamped onto every task token.
|
|
50
|
+
*
|
|
51
|
+
* A TTL is the crash backstop: if Hive Mind dies before it can revoke, the token
|
|
52
|
+
* stops working on its own. The request cap is deliberately generous — it exists
|
|
53
|
+
* to bound a runaway loop, not to interrupt legitimate work.
|
|
54
|
+
*/
|
|
55
|
+
export const ROUTER_TOKEN_TTL_HOURS = 24;
|
|
56
|
+
export const ROUTER_TOKEN_MAX_REQUESTS = 5000;
|
|
57
|
+
|
|
58
|
+
const EMPTY_STATE = Object.freeze({ version: 1, image: null, imageDigest: null, startedAt: null, leases: [], tokenSecret: null, lastUpdate: null });
|
|
59
|
+
|
|
60
|
+
/** Is Hive Mind allowed to manage the router container itself? */
|
|
61
|
+
export const isRouterSidecarEnabled = (env = process.env) => {
|
|
62
|
+
const raw = String(env?.HIVE_MIND_ROUTER_SIDECAR || '')
|
|
63
|
+
.trim()
|
|
64
|
+
.toLowerCase();
|
|
65
|
+
return raw !== '0' && raw !== 'false' && raw !== 'no';
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Image the sidecar runs, overridable for pinning or a local build. */
|
|
69
|
+
export const resolveRouterSidecarImage = (env = process.env) => String(env?.HIVE_MIND_ROUTER_IMAGE || '').trim() || ROUTER_SIDECAR_IMAGE;
|
|
70
|
+
|
|
71
|
+
export const resolveRouterSidecarStatePath = (env = process.env) => resolveSidecarStatePath(STATE_FILE_NAME, env);
|
|
72
|
+
|
|
73
|
+
export const readRouterSidecarState = ({ env = process.env, fsImpl = fs } = {}) => readSidecarState({ fileName: STATE_FILE_NAME, emptyState: EMPTY_STATE, env, fsImpl });
|
|
74
|
+
|
|
75
|
+
/** Persisted with mode 0600: this record holds the token-signing secret. */
|
|
76
|
+
export const writeRouterSidecarState = (state, { env = process.env, fsImpl = fs } = {}) => writeSidecarState(state, { fileName: STATE_FILE_NAME, env, fsImpl, mode: 0o600 });
|
|
77
|
+
|
|
78
|
+
export const withRouterSidecarLock = (fn, options = {}) => withStateLock(SIDECAR_LOCK_NAME, fn, options);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The signing secret, generated once and reused.
|
|
82
|
+
*
|
|
83
|
+
* It must survive a sidecar restart: tokens already handed to running tasks were
|
|
84
|
+
* signed with it, and a fresh secret would invalidate every one of them mid-run.
|
|
85
|
+
* `HIVE_MIND_ROUTER_TOKEN_SECRET` lets an operator supply their own.
|
|
86
|
+
*/
|
|
87
|
+
export const resolveRouterTokenSecret = ({ state, env = process.env, generate = () => crypto.randomBytes(32).toString('hex') } = {}) => {
|
|
88
|
+
const fromEnv = String(env?.HIVE_MIND_ROUTER_TOKEN_SECRET || '').trim();
|
|
89
|
+
if (fromEnv) return { secret: fromEnv, generated: false };
|
|
90
|
+
if (state?.tokenSecret) return { secret: state.tokenSecret, generated: false };
|
|
91
|
+
return { secret: generate(), generated: true };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Credential directories to mount into the sidecar, with the env var the router
|
|
96
|
+
* reads each from.
|
|
97
|
+
*
|
|
98
|
+
* `~/.config/gh` is included: the router presents the operator's GitHub token
|
|
99
|
+
* upstream so a routed task never holds one (R12), and it finds that token the
|
|
100
|
+
* same way gh does, through `GH_CONFIG_DIR`.
|
|
101
|
+
*/
|
|
102
|
+
export const getRouterCredentialMounts = ({ homeDir = os.homedir(), existsSync = fs.existsSync } = {}) => [...ROUTER_CREDENTIAL_MOUNTS, ROUTER_GH_CONFIG_MOUNT].map(mount => ({ ...mount, source: path.join(homeDir, ...mount.home.split('/')) })).filter(mount => existsSync(mount.source));
|
|
103
|
+
|
|
104
|
+
/** Does the sidecar hold a vendor credential, as opposed to only a GitHub one? */
|
|
105
|
+
const hasVendorCredential = mounts => mounts.some(mount => mount.envVar !== ROUTER_GH_CONFIG_MOUNT.envVar);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Build the `docker run` argv for the sidecar.
|
|
109
|
+
*
|
|
110
|
+
* The credential mounts are intentionally **not** `:ro`. Vendor OAuth
|
|
111
|
+
* credentials are refresh tokens: the CLI rewrites them when they expire, and a
|
|
112
|
+
* read-only mount would silently discard every rotation, leaving the operator
|
|
113
|
+
* with credentials that stop working the moment the current access token lapses.
|
|
114
|
+
*/
|
|
115
|
+
export const buildRouterSidecarRunArgs = ({ image, tokenSecret, credentialMounts = [], containerName = ROUTER_SIDECAR_CONTAINER_NAME, env = process.env } = {}) => {
|
|
116
|
+
const args = ['run', '--detach', '--name', containerName, '--label', `${ROUTER_SIDECAR_LABEL}=sidecar`, '--restart', 'no'];
|
|
117
|
+
|
|
118
|
+
// No `--network` here: see the module header. The internal network is attached
|
|
119
|
+
// after creation so the default bridge, and with it the route to the vendor
|
|
120
|
+
// APIs the router exists to reach, survives.
|
|
121
|
+
|
|
122
|
+
args.push('--env', `ROUTER_PORT=${ROUTER_SIDECAR_PORT}`, '--env', `TOKEN_SECRET=${tokenSecret}`, '--env', `DATA_DIR=${ROUTER_DATA_MOUNT}`, '--env', `AUDIT_LOG=${ROUTER_DATA_MOUNT}/audit.jsonl`);
|
|
123
|
+
|
|
124
|
+
// TLS is not optional here: `gh` refuses a plaintext host, so an HTTP router
|
|
125
|
+
// could never mediate GitHub traffic. The certificate names api.github.com as
|
|
126
|
+
// well as the alias, which is what lets an unmodified `gh` inside a task be
|
|
127
|
+
// redirected by /etc/hosts and still verify the connection.
|
|
128
|
+
args.push('--env', 'TLS_SELF_SIGNED=1', '--env', `TLS_SELF_SIGNED_DNS=${ROUTER_TLS_DNS_NAMES}`);
|
|
129
|
+
|
|
130
|
+
// R8: one named volume holds every request log and the token store, so the
|
|
131
|
+
// audit trail outlives the container it was produced by.
|
|
132
|
+
args.push('--volume', `${ROUTER_DATA_VOLUME_NAME}:${ROUTER_DATA_MOUNT}`);
|
|
133
|
+
|
|
134
|
+
for (const mount of credentialMounts) {
|
|
135
|
+
args.push('--env', `${mount.envVar}=${mount.target}`, '--volume', `${mount.source}:${mount.target}${mount.readOnly ? ':ro' : ''}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const extraArgs = String(env?.HIVE_MIND_ROUTER_EXTRA_ARGS || '').trim();
|
|
139
|
+
if (extraArgs) args.push(...extraArgs.split(/\s+/));
|
|
140
|
+
|
|
141
|
+
args.push(image, 'serve', '--host', '0.0.0.0', '--port', String(ROUTER_SIDECAR_PORT));
|
|
142
|
+
// No `-p`: the endpoint is reachable only from the internal network.
|
|
143
|
+
return args;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Probe the router's `/health`.
|
|
148
|
+
*
|
|
149
|
+
* The Formal AI sidecar shells out to `curl`; the router's runtime image is
|
|
150
|
+
* `debian:trixie-slim` plus `ca-certificates` and has no curl. It does ship
|
|
151
|
+
* `bun`, which is used here as the HTTP client instead of adding a dependency to
|
|
152
|
+
* an image Hive Mind does not own.
|
|
153
|
+
*/
|
|
154
|
+
export const checkRouterSidecarHealth = async ({ containerName = ROUTER_SIDECAR_CONTAINER_NAME, run = execFileAsync, timeoutMs = 30_000 } = {}) => {
|
|
155
|
+
const probe = `fetch("https://127.0.0.1:${ROUTER_SIDECAR_PORT}/health").then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))`;
|
|
156
|
+
// The certificate names the alias, not 127.0.0.1, and this probe is a
|
|
157
|
+
// liveness check on a loopback socket inside the container — there is no
|
|
158
|
+
// network for anyone to sit in the middle of. Verification is disabled for
|
|
159
|
+
// this one call rather than shipping the CA back into the router's own image.
|
|
160
|
+
return dockerOk(run, ['exec', '--env', 'NODE_TLS_REJECT_UNAUTHORIZED=0', containerName, 'bun', '-e', probe], { timeoutMs });
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export const waitForRouterSidecarHealth = async ({ containerName = ROUTER_SIDECAR_CONTAINER_NAME, run = execFileAsync, attempts = DEFAULT_HEALTH_ATTEMPTS, delayMs = DEFAULT_HEALTH_DELAY_MS, sleepImpl = sleep, log = null, verbose = false } = {}) => {
|
|
164
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
165
|
+
if (await checkRouterSidecarHealth({ containerName, run })) {
|
|
166
|
+
if (verbose && log) await log(`[VERBOSE] ${LOG_PREFIX}: healthy after ${attempt} attempt(s)`);
|
|
167
|
+
return { healthy: true, attempts: attempt };
|
|
168
|
+
}
|
|
169
|
+
if (attempt < attempts) await sleepImpl(delayMs);
|
|
170
|
+
}
|
|
171
|
+
if (log) await log(`⚠️ Router sidecar did not become healthy after ${attempts} attempt(s)`);
|
|
172
|
+
return { healthy: false, attempts };
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Recover a token's id from the token itself.
|
|
177
|
+
*
|
|
178
|
+
* The router mints `la_sk_<jwt>` whose `sub` claim *is* the token id, and the
|
|
179
|
+
* payload is plain base64url — readable without the signing secret. Reading it
|
|
180
|
+
* here avoids parsing the fixed-width `router tokens list` table, whose column
|
|
181
|
+
* layout is a display detail rather than an interface.
|
|
182
|
+
*
|
|
183
|
+
* @returns {string|null}
|
|
184
|
+
*/
|
|
185
|
+
export const decodeRouterTokenId = token => {
|
|
186
|
+
const raw = String(token || '').trim();
|
|
187
|
+
if (!raw.startsWith('la_sk_')) return null;
|
|
188
|
+
const segments = raw.slice('la_sk_'.length).split('.');
|
|
189
|
+
if (segments.length < 2) return null;
|
|
190
|
+
try {
|
|
191
|
+
const claims = JSON.parse(Buffer.from(segments[1], 'base64url').toString('utf8'));
|
|
192
|
+
return typeof claims?.sub === 'string' && claims.sub ? claims.sub : null;
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Mint one token for one task.
|
|
200
|
+
*
|
|
201
|
+
* R6: every task gets its own, so each has its own request log and its own
|
|
202
|
+
* budget. Upstream is explicit that sharing breaks attribution: "Never share one
|
|
203
|
+
* token between two tasks."
|
|
204
|
+
*
|
|
205
|
+
* @returns {Promise<{token: string|null, tokenId: string|null, error: string|null}>}
|
|
206
|
+
*/
|
|
207
|
+
export const issueRouterTaskToken = async ({ sessionId, containerName = ROUTER_SIDECAR_CONTAINER_NAME, ttlHours = ROUTER_TOKEN_TTL_HOURS, maxRequests = ROUTER_TOKEN_MAX_REQUESTS, githubRepo = null, run = execFileAsync, timeoutMs, log = null, verbose = false } = {}) => {
|
|
208
|
+
if (!sessionId) return { token: null, tokenId: null, error: 'no sessionId' };
|
|
209
|
+
const issueArgs = ['exec', containerName, 'router', 'tokens', 'issue', '--label', `hive-mind:${sessionId}`, '--ttl-hours', String(ttlHours)];
|
|
210
|
+
if (maxRequests) issueArgs.push('--max-requests', String(maxRequests));
|
|
211
|
+
// Confining the token to one repository is enforced by the router itself: a
|
|
212
|
+
// call about any other repository is refused with "outside this token's
|
|
213
|
+
// repositories", for the git transport as well as the REST surface.
|
|
214
|
+
if (githubRepo) issueArgs.push('--github-repo', githubRepo);
|
|
215
|
+
try {
|
|
216
|
+
// `tokens issue` prints the token and nothing else on stdout.
|
|
217
|
+
const token = await dockerText(run, issueArgs, { timeoutMs });
|
|
218
|
+
if (!token.startsWith('la_sk_')) return { token: null, tokenId: null, error: `unexpected token format from router: ${token.slice(0, 24)}…` };
|
|
219
|
+
const tokenId = decodeRouterTokenId(token);
|
|
220
|
+
if (verbose && log) await log(`[VERBOSE] ${LOG_PREFIX}: issued token ${tokenId || '(id unknown)'} for '${sessionId}'`);
|
|
221
|
+
return { token, tokenId, error: null };
|
|
222
|
+
} catch (error) {
|
|
223
|
+
return { token: null, tokenId: null, error: error?.stderr?.toString?.().trim() || error?.message || String(error) };
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Revoke a task's token.
|
|
229
|
+
*
|
|
230
|
+
* Best-effort by design: the token's TTL is the guarantee, this is the prompt
|
|
231
|
+
* cleanup. A failure is logged and never propagated, because a task must still
|
|
232
|
+
* be able to finish when the router has already gone away.
|
|
233
|
+
*/
|
|
234
|
+
export const revokeRouterTaskToken = async ({ tokenId, containerName = ROUTER_SIDECAR_CONTAINER_NAME, run = execFileAsync, timeoutMs, log = null, verbose = false } = {}) => {
|
|
235
|
+
if (!tokenId) return { revoked: false };
|
|
236
|
+
const revoked = await dockerOk(run, ['exec', containerName, 'router', 'tokens', 'revoke', tokenId], { timeoutMs });
|
|
237
|
+
if (verbose && log) await log(`[VERBOSE] ${LOG_PREFIX}: revoke ${tokenId} → ${revoked ? 'ok' : 'failed (token still expires on its own)'}`);
|
|
238
|
+
return { revoked };
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Close out a lease whose task has ended.
|
|
243
|
+
*
|
|
244
|
+
* Order matters: the session data is copied out of the task container while the
|
|
245
|
+
* router is still up to receive it, and only then is the token revoked. Both
|
|
246
|
+
* steps are best-effort and neither can keep a dead lease alive.
|
|
247
|
+
*/
|
|
248
|
+
export const finalizeEndedLease = async ({ lease, env = process.env, run = execFileAsync, timeoutMs, log = null, verbose = false, drain = drainTaskSessionData } = {}) => {
|
|
249
|
+
const drained = await drain({ sessionId: lease?.sessionId, env, run, timeoutMs, log, verbose });
|
|
250
|
+
if (drained?.error && log) await log(`⚠️ Could not archive session data for '${lease?.sessionId}': ${drained.error}`);
|
|
251
|
+
const revoked = await revokeRouterTaskToken({ tokenId: lease?.tokenId, run, timeoutMs, log, verbose });
|
|
252
|
+
return { drained, ...revoked };
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/** Re-derive the sidecar record from Docker, revoking the tokens of leases that died. */
|
|
256
|
+
export const reconcileRouterSidecar = async ({ env = process.env, fsImpl = fs, run = execFileAsync, timeoutMs, log = null, verbose = false } = {}) => {
|
|
257
|
+
const state = readRouterSidecarState({ env, fsImpl });
|
|
258
|
+
const leases = await reconcileSidecarLeases(state.leases, {
|
|
259
|
+
run,
|
|
260
|
+
timeoutMs,
|
|
261
|
+
log,
|
|
262
|
+
verbose,
|
|
263
|
+
logPrefix: LOG_PREFIX,
|
|
264
|
+
onDropped: lease => finalizeEndedLease({ lease, env, run, timeoutMs, log, verbose }),
|
|
265
|
+
});
|
|
266
|
+
const container = await inspectDockerContainer(ROUTER_SIDECAR_CONTAINER_NAME, { run, timeoutMs });
|
|
267
|
+
const next = {
|
|
268
|
+
...state,
|
|
269
|
+
leases,
|
|
270
|
+
image: container.exists ? container.image : state.image,
|
|
271
|
+
imageDigest: container.exists ? container.imageDigest : state.imageDigest,
|
|
272
|
+
startedAt: container.running ? state.startedAt : null,
|
|
273
|
+
};
|
|
274
|
+
writeRouterSidecarState(next, { env, fsImpl });
|
|
275
|
+
return { state: next, container, leaseCount: leases.length };
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Stop and remove the sidecar and its network.
|
|
280
|
+
*
|
|
281
|
+
* The data volume is deliberately left in place: it is the audit trail (R8), and
|
|
282
|
+
* the point of the feature is that it outlives the tasks it recorded.
|
|
283
|
+
*/
|
|
284
|
+
export const stopRouterSidecar = async ({ env = process.env, fsImpl = fs, run = execFileAsync, timeoutMs, log = null, verbose = false, reason = 'idle' } = {}) => {
|
|
285
|
+
const container = await inspectDockerContainer(ROUTER_SIDECAR_CONTAINER_NAME, { run, timeoutMs });
|
|
286
|
+
if (container.exists) {
|
|
287
|
+
await dockerOk(run, ['stop', ROUTER_SIDECAR_CONTAINER_NAME], { timeoutMs });
|
|
288
|
+
await dockerOk(run, ['rm', '--force', ROUTER_SIDECAR_CONTAINER_NAME], { timeoutMs });
|
|
289
|
+
}
|
|
290
|
+
await dockerOk(run, ['network', 'rm', ROUTER_SIDECAR_NETWORK_NAME], { timeoutMs });
|
|
291
|
+
|
|
292
|
+
const state = readRouterSidecarState({ env, fsImpl });
|
|
293
|
+
writeRouterSidecarState({ ...state, startedAt: null, leases: [] }, { env, fsImpl });
|
|
294
|
+
if (log) await log(`🛑 Router sidecar stopped (${reason}); data volume '${ROUTER_DATA_VOLUME_NAME}' preserved for audit`);
|
|
295
|
+
if (verbose && log) await log(`[VERBOSE] ${LOG_PREFIX}: removed container=${container.exists} network='${ROUTER_SIDECAR_NETWORK_NAME}'`);
|
|
296
|
+
return { stopped: container.exists };
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Ensure a healthy sidecar exists, then mint this task's token.
|
|
301
|
+
*
|
|
302
|
+
* Must run *before* the task container's command is released by the start gate,
|
|
303
|
+
* because the token and endpoint are part of the environment that container is
|
|
304
|
+
* created with.
|
|
305
|
+
*
|
|
306
|
+
* @returns {Promise<{baseUrl: string|null, token: string|null, tokenId: string|null, leaseCount: number, external: boolean, error: string|null}>}
|
|
307
|
+
*/
|
|
308
|
+
export const acquireRouterSidecar = async ({ sessionId, githubRepo = null, env = process.env, fsImpl = fs, run = execFileAsync, timeoutMs, imageTimeoutMs = DEFAULT_IMAGE_TIMEOUT_MS, homeDir = os.homedir(), existsSync = fs.existsSync, log = null, verbose = false, now = () => new Date(), healthAttempts, healthDelayMs, sleepImpl = sleep, lockOptions = {} } = {}) => {
|
|
309
|
+
if (!sessionId) throw new Error('acquireRouterSidecar requires a sessionId');
|
|
310
|
+
|
|
311
|
+
const endpoint = resolveRouterBaseUrl({ env });
|
|
312
|
+
if (endpoint.error) return { baseUrl: null, token: null, tokenId: null, leaseCount: 0, external: true, error: endpoint.error };
|
|
313
|
+
if (endpoint.external) {
|
|
314
|
+
// An operator-run router is not ours to start, and we hold no admin
|
|
315
|
+
// credential for it, so the token has to be supplied alongside the URL.
|
|
316
|
+
const token = String(env?.HIVE_MIND_ROUTER_TOKEN || '').trim();
|
|
317
|
+
if (!token) return { baseUrl: null, token: null, tokenId: null, leaseCount: 0, external: true, error: 'HIVE_MIND_ROUTER_URL is set but HIVE_MIND_ROUTER_TOKEN is empty; an external router must be given a token to use' };
|
|
318
|
+
if (log) await log('⚠️ Using an external router: the token is shared by every task, so per-task attribution (issue #2164, R6) does not apply');
|
|
319
|
+
return { baseUrl: endpoint.baseUrl, token, tokenId: decodeRouterTokenId(token), leaseCount: 0, external: true, error: null };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (!isRouterSidecarEnabled(env)) {
|
|
323
|
+
return { baseUrl: null, token: null, tokenId: null, leaseCount: 0, external: false, error: 'HIVE_MIND_ROUTER_SIDECAR is disabled but no HIVE_MIND_ROUTER_URL was provided' };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return withRouterSidecarLock(async () => {
|
|
327
|
+
const image = resolveRouterSidecarImage(env);
|
|
328
|
+
|
|
329
|
+
await ensureInternalDockerNetwork({ name: ROUTER_SIDECAR_NETWORK_NAME, label: ROUTER_SIDECAR_LABEL, run, timeoutMs, log, verbose, logPrefix: LOG_PREFIX });
|
|
330
|
+
await ensureDockerVolume({ name: ROUTER_DATA_VOLUME_NAME, label: ROUTER_SIDECAR_LABEL, role: 'data', run, timeoutMs, log, verbose, logPrefix: LOG_PREFIX });
|
|
331
|
+
|
|
332
|
+
const reconciled = await reconcileRouterSidecar({ env, fsImpl, run, timeoutMs, log, verbose });
|
|
333
|
+
const state = reconciled.state;
|
|
334
|
+
const { secret: tokenSecret } = resolveRouterTokenSecret({ state, env });
|
|
335
|
+
|
|
336
|
+
let container = reconciled.container;
|
|
337
|
+
let startedAt = state.startedAt;
|
|
338
|
+
|
|
339
|
+
if (!container.running) {
|
|
340
|
+
if (container.exists) await dockerOk(run, ['rm', '--force', ROUTER_SIDECAR_CONTAINER_NAME], { timeoutMs });
|
|
341
|
+
if (!(await readDockerImageDigest(image, { run, timeoutMs }))) {
|
|
342
|
+
if (log) await log(`📦 Pulling router image ${image}…`);
|
|
343
|
+
if (!(await dockerOk(run, ['pull', image], { timeoutMs: imageTimeoutMs }))) {
|
|
344
|
+
return { baseUrl: null, token: null, tokenId: null, leaseCount: state.leases.length, external: false, error: `could not pull router image ${image}` };
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const credentialMounts = getRouterCredentialMounts({ homeDir, existsSync });
|
|
349
|
+
// A GitHub credential alone would leave the router with nothing to answer
|
|
350
|
+
// a model request with, so it does not count towards this check.
|
|
351
|
+
if (!hasVendorCredential(credentialMounts)) {
|
|
352
|
+
return { baseUrl: null, token: null, tokenId: null, leaseCount: state.leases.length, external: false, error: 'no vendor credential directory found to mount into the router (looked for ~/.claude, ~/.codex, ~/.gemini, ~/.qwen)' };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
await dockerText(run, buildRouterSidecarRunArgs({ image, tokenSecret, credentialMounts, env }), { timeoutMs });
|
|
357
|
+
} catch (error) {
|
|
358
|
+
return { baseUrl: null, token: null, tokenId: null, leaseCount: state.leases.length, external: false, error: error?.stderr?.toString?.().trim() || error?.message || String(error) };
|
|
359
|
+
}
|
|
360
|
+
startedAt = now().toISOString();
|
|
361
|
+
if (log) await log(`🔀 Router sidecar started with ${credentialMounts.length} credential mount(s); tasks will not receive vendor credentials directly`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Additive, and a no-op when already attached — so it is safe on every
|
|
365
|
+
// acquire, including the ones that reuse a running container.
|
|
366
|
+
await attachDockerNetwork({ network: ROUTER_SIDECAR_NETWORK_NAME, container: ROUTER_SIDECAR_CONTAINER_NAME, alias: ROUTER_SIDECAR_NETWORK_ALIAS, run, timeoutMs, log, verbose, logPrefix: LOG_PREFIX });
|
|
367
|
+
|
|
368
|
+
const health = await waitForRouterSidecarHealth({ run, attempts: healthAttempts, delayMs: healthDelayMs, sleepImpl, log, verbose });
|
|
369
|
+
if (!health.healthy) {
|
|
370
|
+
return { baseUrl: null, token: null, tokenId: null, leaseCount: state.leases.length, external: false, error: 'router sidecar did not become healthy' };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const issued = await issueRouterTaskToken({ sessionId, githubRepo, run, timeoutMs, log, verbose });
|
|
374
|
+
if (issued.error || !issued.token) {
|
|
375
|
+
return { baseUrl: null, token: null, tokenId: null, leaseCount: state.leases.length, external: false, error: issued.error || 'router issued no token' };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
container = await inspectDockerContainer(ROUTER_SIDECAR_CONTAINER_NAME, { run, timeoutMs });
|
|
379
|
+
const leases = [...state.leases.filter(lease => lease.sessionId !== sessionId), { sessionId, tokenId: issued.tokenId, acquiredAt: now().toISOString(), containerSeen: false }];
|
|
380
|
+
writeRouterSidecarState({ ...state, leases, tokenSecret, startedAt, image: container.image, imageDigest: container.imageDigest, lastUpdate: now().toISOString() }, { env, fsImpl });
|
|
381
|
+
|
|
382
|
+
return { baseUrl: getInternalRouterBaseUrl(), token: issued.token, tokenId: issued.tokenId, leaseCount: leases.length, external: false, error: null };
|
|
383
|
+
}, lockOptions);
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The router's self-signed CA, as PEM.
|
|
388
|
+
*
|
|
389
|
+
* Generated on first start and kept in the data volume, so it survives a
|
|
390
|
+
* restart and every task in a fleet trusts the same authority.
|
|
391
|
+
*
|
|
392
|
+
* @returns {Promise<string|null>} null when the router has no TLS CA to print
|
|
393
|
+
*/
|
|
394
|
+
export const readRouterCaCertificate = async ({ containerName = ROUTER_SIDECAR_CONTAINER_NAME, run = execFileAsync, timeoutMs } = {}) => {
|
|
395
|
+
try {
|
|
396
|
+
const pem = await dockerText(run, ['exec', containerName, 'router', 'tls', 'ca'], { timeoutMs });
|
|
397
|
+
return pem.includes('BEGIN CERTIFICATE') ? pem : null;
|
|
398
|
+
} catch {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The sidecar's address on the internal network.
|
|
405
|
+
*
|
|
406
|
+
* Needed because the interception is by name: the task's `/etc/hosts` has to
|
|
407
|
+
* name an address, and the alias it would otherwise resolve through is exactly
|
|
408
|
+
* what must not be attached to the router (see router-isolation.lib.mjs).
|
|
409
|
+
*
|
|
410
|
+
* @returns {Promise<string|null>}
|
|
411
|
+
*/
|
|
412
|
+
export const readRouterNetworkIp = async ({ containerName = ROUTER_SIDECAR_CONTAINER_NAME, network = ROUTER_SIDECAR_NETWORK_NAME, run = execFileAsync, timeoutMs } = {}) => {
|
|
413
|
+
try {
|
|
414
|
+
const address = await dockerText(run, ['inspect', '--format', `{{ with index .NetworkSettings.Networks "${network}" }}{{ .IPAddress }}{{ end }}`, containerName], { timeoutMs });
|
|
415
|
+
return /^\d{1,3}(\.\d{1,3}){3}$/.test(address) ? address : null;
|
|
416
|
+
} catch {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Store an upstream provider in the router (R11).
|
|
423
|
+
*
|
|
424
|
+
* Idempotent in practice: re-adding the same name overwrites the entry, so an
|
|
425
|
+
* acquire that reuses a running sidecar can call this unconditionally.
|
|
426
|
+
*
|
|
427
|
+
* @returns {Promise<{registered: boolean, error: string|null}>}
|
|
428
|
+
*/
|
|
429
|
+
export const registerRouterProvider = async ({ providerArgs, containerName = ROUTER_SIDECAR_CONTAINER_NAME, run = execFileAsync, timeoutMs, log = null, verbose = false } = {}) => {
|
|
430
|
+
if (!providerArgs?.length) return { registered: false, error: 'no provider arguments' };
|
|
431
|
+
try {
|
|
432
|
+
// `docker exec` bypasses the image entrypoint, so the binary is named again.
|
|
433
|
+
await dockerText(run, ['exec', containerName, 'router', ...providerArgs], { timeoutMs });
|
|
434
|
+
} catch (error) {
|
|
435
|
+
return { registered: false, error: error?.stderr?.toString?.().trim() || error?.message || String(error) };
|
|
436
|
+
}
|
|
437
|
+
if (verbose && log) await log(`[VERBOSE] ${LOG_PREFIX}: registered provider '${providerArgs[providerArgs.indexOf('--name') + 1]}'`);
|
|
438
|
+
return { registered: true, error: null };
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Put the router itself on another sidecar's internal network.
|
|
443
|
+
*
|
|
444
|
+
* Needed for R11: the router has to be able to resolve
|
|
445
|
+
* `link-assistant-formal-ai` before it can forward anything to it. No alias is
|
|
446
|
+
* requested — nothing on that network calls the router by name.
|
|
447
|
+
*/
|
|
448
|
+
export const attachRouterToNetwork = async ({ network, containerName = ROUTER_SIDECAR_CONTAINER_NAME, run = execFileAsync, timeoutMs, log = null, verbose = false } = {}) => {
|
|
449
|
+
if (!network) return { attached: false, error: 'no network' };
|
|
450
|
+
return attachDockerNetwork({ network, container: containerName, run, timeoutMs, log, verbose, logPrefix: LOG_PREFIX });
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
/** Attach a task container to the router network so it can resolve the alias. */
|
|
454
|
+
export const attachTaskToRouterNetwork = async ({ sessionId, run = execFileAsync, timeoutMs, log = null, verbose = false } = {}) => {
|
|
455
|
+
if (!sessionId) return { attached: false, error: 'no sessionId' };
|
|
456
|
+
return attachDockerNetwork({ network: ROUTER_SIDECAR_NETWORK_NAME, container: sessionId, run, timeoutMs, log, verbose, logPrefix: LOG_PREFIX });
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Finish wiring a task container: trust the router's CA, resolve
|
|
461
|
+
* `api.github.com` to it, and (for codex) write the provider entry that points
|
|
462
|
+
* the CLI at it.
|
|
463
|
+
*
|
|
464
|
+
* This runs as root through `docker exec` while the start gate still holds the
|
|
465
|
+
* task command, because none of it can be expressed as a `docker run` flag
|
|
466
|
+
* start-command forwards, and the CA does not exist until the router has
|
|
467
|
+
* started. Failure is reported rather than swallowed: a task that does not
|
|
468
|
+
* trust the CA cannot reach any model, so the caller stops it (fail closed).
|
|
469
|
+
*
|
|
470
|
+
* @returns {Promise<{wired: boolean, error: string|null}>}
|
|
471
|
+
*/
|
|
472
|
+
export const wireRouterTaskContainer = async ({ sessionId, tool = 'claude', baseUrl = getInternalRouterBaseUrl(), githubMode = 'transparent', homeDir = TASK_CONTAINER_HOME, containerName = ROUTER_SIDECAR_CONTAINER_NAME, run = execFileAsync, timeoutMs, log = null, verbose = false } = {}) => {
|
|
473
|
+
if (!sessionId) return { wired: false, error: 'no sessionId' };
|
|
474
|
+
const caCertificate = await readRouterCaCertificate({ containerName, run, timeoutMs });
|
|
475
|
+
if (!caCertificate) return { wired: false, error: 'the router printed no TLS CA (`router tls ca`), so the task could not be given anything to trust' };
|
|
476
|
+
const routerIp = githubMode === 'transparent' ? await readRouterNetworkIp({ containerName, run, timeoutMs }) : null;
|
|
477
|
+
if (githubMode === 'transparent' && !routerIp) return { wired: false, error: `the router has no address on '${ROUTER_SIDECAR_NETWORK_NAME}', so api.github.com could not be pointed at it` };
|
|
478
|
+
const script = buildRouterTaskWiringScript({ routerIp, caCertificate, homeDir, tool, baseUrl, githubMode });
|
|
479
|
+
try {
|
|
480
|
+
await dockerText(run, ['exec', '--user', '0', sessionId, 'sh', '-c', script], { timeoutMs });
|
|
481
|
+
} catch (error) {
|
|
482
|
+
return { wired: false, error: error?.stderr?.toString?.().trim() || error?.message || String(error) };
|
|
483
|
+
}
|
|
484
|
+
if (verbose && log) await log(`[VERBOSE] ${LOG_PREFIX}: wired '${sessionId}' (CA installed, github=${githubMode}${routerIp ? ` via ${routerIp}` : ''})`);
|
|
485
|
+
return { wired: true, error: null };
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Revoke this task's token, drop its lease, and stop the sidecar when it was the
|
|
490
|
+
* last one (R5).
|
|
491
|
+
*
|
|
492
|
+
* @returns {Promise<{leaseCount: number, stopped: boolean}>}
|
|
493
|
+
*/
|
|
494
|
+
export const releaseRouterSidecar = async ({ sessionId, env = process.env, fsImpl = fs, run = execFileAsync, timeoutMs, log = null, verbose = false, lockOptions = {} } = {}) => {
|
|
495
|
+
if (!isRouterSidecarEnabled(env) || resolveRouterBaseUrl({ env }).external) return { leaseCount: 0, stopped: false };
|
|
496
|
+
|
|
497
|
+
return withRouterSidecarLock(async () => {
|
|
498
|
+
const state = readRouterSidecarState({ env, fsImpl });
|
|
499
|
+
const released = state.leases.find(lease => lease.sessionId === sessionId);
|
|
500
|
+
if (released) await finalizeEndedLease({ lease: released, env, run, timeoutMs, log, verbose });
|
|
501
|
+
|
|
502
|
+
const remaining = await reconcileSidecarLeases(
|
|
503
|
+
state.leases.filter(lease => lease.sessionId !== sessionId),
|
|
504
|
+
{ run, timeoutMs, log, verbose, logPrefix: LOG_PREFIX, onDropped: lease => finalizeEndedLease({ lease, env, run, timeoutMs, log, verbose }) }
|
|
505
|
+
);
|
|
506
|
+
writeRouterSidecarState({ ...state, leases: remaining }, { env, fsImpl });
|
|
507
|
+
|
|
508
|
+
if (remaining.length > 0) {
|
|
509
|
+
if (verbose && log) await log(`[VERBOSE] ${LOG_PREFIX}: '${sessionId}' released; ${remaining.length} lease(s) still hold the sidecar up`);
|
|
510
|
+
return { leaseCount: remaining.length, stopped: false };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const { stopped } = await stopRouterSidecar({ env, fsImpl, run, timeoutMs, log, verbose, reason: 'last task finished' });
|
|
514
|
+
return { leaseCount: 0, stopped };
|
|
515
|
+
}, lockOptions);
|
|
516
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launch-time policy for router isolation (issue #2164, EXPERIMENTAL).
|
|
3
|
+
*
|
|
4
|
+
* The lifecycle lives in `./router-sidecar.lib.mjs` and the mount/endpoint
|
|
5
|
+
* policy in `./router-isolation.lib.mjs`; this module decides *when* they apply
|
|
6
|
+
* to a launch, and is kept separate so the runner stays readable and the policy
|
|
7
|
+
* is testable without Docker.
|
|
8
|
+
*
|
|
9
|
+
* The governing rule is that router isolation **fails closed**. `--use-router`
|
|
10
|
+
* is a request to withhold the operator's subscription from the task; if the
|
|
11
|
+
* router cannot be reached, launching anyway would either hand the credentials
|
|
12
|
+
* over after all — exactly what was asked against — or start an agent with no
|
|
13
|
+
* model at all. Neither is a useful outcome, so the launch is refused with the
|
|
14
|
+
* reason on the record.
|
|
15
|
+
*
|
|
16
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2164
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { FORMAL_AI_SIDECAR_NETWORK_NAME, resolveFormalAiSidecarBaseUrl } from './formal-ai-sidecar.lib.mjs';
|
|
20
|
+
import { buildRouterFormalAiProviderArgs, describeRouterCoverageGaps, isRouterEnabled, resolveRouterBaseUrl, resolveRouterGitHubRouting, ROUTER_FORMAL_AI_PROVIDER_NAME } from './router-isolation.lib.mjs';
|
|
21
|
+
import { acquireRouterSidecar, attachRouterToNetwork, attachTaskToRouterNetwork, registerRouterProvider, releaseRouterSidecar, wireRouterTaskContainer } from './router-sidecar.lib.mjs';
|
|
22
|
+
|
|
23
|
+
const logToConsole = message => console.log(message);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Mint this task's token and make sure the router is up.
|
|
27
|
+
*
|
|
28
|
+
* @returns {Promise<{router: object|null, error: string|null}>} `router` is null
|
|
29
|
+
* with `error` null when routing was not requested; a non-null `error` means
|
|
30
|
+
* the caller must abort the launch.
|
|
31
|
+
*/
|
|
32
|
+
export const acquireRouterForTask = async ({ backend, useRouter = false, model = null, tool = 'claude', githubRepo = null, sessionId, env = process.env, verbose = false, log = logToConsole, acquire = acquireRouterSidecar } = {}) => {
|
|
33
|
+
if (backend !== 'docker' || !isRouterEnabled({ useRouter, env })) return { router: null, error: null };
|
|
34
|
+
|
|
35
|
+
let acquired;
|
|
36
|
+
try {
|
|
37
|
+
acquired = await acquire({ sessionId, githubRepo, env, verbose, log });
|
|
38
|
+
} catch (error) {
|
|
39
|
+
acquired = { error: error?.message || String(error) };
|
|
40
|
+
}
|
|
41
|
+
if (acquired?.error || !acquired?.token) {
|
|
42
|
+
const message = `Router isolation was requested but the router is unavailable, so the task was not launched rather than being given direct access to the subscription (issue #2164): ${acquired?.error || 'no token was issued'}`;
|
|
43
|
+
console.error(`[router-isolation] Session ${sessionId}: ${message}`);
|
|
44
|
+
return { router: null, error: message };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { mode: githubMode } = resolveRouterGitHubRouting({ env, external: Boolean(acquired.external) });
|
|
48
|
+
if (log) {
|
|
49
|
+
await log(`🔀 [EXPERIMENTAL] Task '${sessionId}' routed through the router; vendor credentials stay in the sidecar (issue #2164)`);
|
|
50
|
+
for (const gap of describeRouterCoverageGaps({ model, tool, githubMode })) {
|
|
51
|
+
await log(`⚠️ ${gap}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// The mode and tool travel with the lease so the attach step, which runs
|
|
55
|
+
// later and elsewhere, does not have to re-derive them from the environment.
|
|
56
|
+
return { router: { ...acquired, githubMode, tool }, error: null };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Teach the router about the Formal AI sidecar so `--model formal-ai` is served
|
|
61
|
+
* through the router as well (R11).
|
|
62
|
+
*
|
|
63
|
+
* Two steps, both needed and both cheap to repeat: the router joins the Formal
|
|
64
|
+
* AI network (it is otherwise on its own network and the default bridge, and
|
|
65
|
+
* cannot resolve the alias), and the sidecar is stored as an OpenAI-compatible
|
|
66
|
+
* provider. The stored entry does not pin the router — with the default
|
|
67
|
+
* `UPSTREAM_PROVIDER=auto` it dispatches on the model id in the request, so a
|
|
68
|
+
* Claude task sharing the same sidecar is unaffected. Measured end to end in
|
|
69
|
+
* experiments/issue-2164/probe-formal-ai-provider.sh.
|
|
70
|
+
*
|
|
71
|
+
* Fails closed like the rest of router isolation: a Formal AI task that cannot
|
|
72
|
+
* reach Formal AI through the router would either fall back to an unmediated
|
|
73
|
+
* path or have no model at all.
|
|
74
|
+
*
|
|
75
|
+
* @returns {Promise<string|null>} An error message, or null when there is
|
|
76
|
+
* nothing to do or the registration succeeded.
|
|
77
|
+
*/
|
|
78
|
+
export const registerFormalAiWithRouter = async ({ router, sidecar, verbose = false, log = logToConsole, attach = attachRouterToNetwork, register = registerRouterProvider } = {}) => {
|
|
79
|
+
if (!router || router.external || !sidecar) return null;
|
|
80
|
+
const attached = await attach({ network: FORMAL_AI_SIDECAR_NETWORK_NAME, verbose, log });
|
|
81
|
+
if (!attached?.attached) return `Router isolation was requested for a Formal AI task, but the router could not join the '${FORMAL_AI_SIDECAR_NETWORK_NAME}' network, so it could not reach Formal AI (issue #2164): ${attached?.error || 'unknown error'}`;
|
|
82
|
+
const providerArgs = buildRouterFormalAiProviderArgs({ baseUrl: sidecar.dnsBaseUrl || sidecar.baseUrl || resolveFormalAiSidecarBaseUrl() });
|
|
83
|
+
const registered = await register({ providerArgs, verbose, log });
|
|
84
|
+
if (!registered?.registered) return `Router isolation was requested for a Formal AI task, but the router refused to store it as a provider (issue #2164): ${registered?.error || 'unknown error'}`;
|
|
85
|
+
if (log) await log(`🧮 [EXPERIMENTAL] Formal AI is registered on the router as '${ROUTER_FORMAL_AI_PROVIDER_NAME}', so '--model formal-ai' is mediated and logged like every other model (issue #2164)`);
|
|
86
|
+
return null;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Put the freshly-created task container on the router's internal network and
|
|
91
|
+
* finish wiring it up.
|
|
92
|
+
*
|
|
93
|
+
* Both halves happen here because both are only possible in the same window:
|
|
94
|
+
* after the container exists and before the start gate releases its command.
|
|
95
|
+
* An external router is reached over the default bridge and has no network of
|
|
96
|
+
* ours to join, so there is nothing to attach — and no container of ours to
|
|
97
|
+
* write an /etc/hosts entry into either.
|
|
98
|
+
*
|
|
99
|
+
* @returns {Promise<string|null>} An error message, or null when there is
|
|
100
|
+
* nothing to do or the attach succeeded.
|
|
101
|
+
*/
|
|
102
|
+
export const attachRouterTaskContainer = async ({ router, sessionId, env = process.env, verbose = false, log = logToConsole, attach = attachTaskToRouterNetwork, wire = wireRouterTaskContainer } = {}) => {
|
|
103
|
+
if (!router || router.external) return null;
|
|
104
|
+
const result = await attach({ sessionId, verbose, log });
|
|
105
|
+
if (!result?.attached) return result?.error || 'unknown error';
|
|
106
|
+
const wired = await wire({ sessionId, tool: router.tool ?? 'claude', baseUrl: router.baseUrl || resolveRouterBaseUrl({ env }).baseUrl, githubMode: router.githubMode ?? 'transparent', verbose, log });
|
|
107
|
+
return wired?.wired ? null : wired?.error || 'unknown error';
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** Revoke the task's token and release its lease. Never throws: a failed release must not mask a launch error. */
|
|
111
|
+
export const releaseRouterForTask = async ({ router, sessionId, env = process.env, verbose = false, log = logToConsole, release = releaseRouterSidecar } = {}) => {
|
|
112
|
+
if (!router || router.external) return null;
|
|
113
|
+
try {
|
|
114
|
+
return await release({ sessionId, env, verbose, log });
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.error(`[router-isolation] Could not release the router lease for '${sessionId}': ${error?.message || error}`);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export default { acquireRouterForTask, attachRouterTaskContainer, registerFormalAiWithRouter, releaseRouterForTask };
|