@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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.14.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e796988: Add the experimental `--use-router` option, which stops handing a Docker-isolated task the operator's real subscription. With the flag, `~/.claude`, `~/.claude.json`, `~/.codex` and `~/.agents` are mounted only into a reference-counted `hive-mind-router` sidecar (pinned to `ghcr.io/link-assistant/router:0.119.0`) on an internal Docker network; each task is joined to that network and given its own `la_sk_…` token scoped to the one repository it works on, so every model request lands in that token's own redacted log. The sidecar runs while at least one task holds a lease and is stopped when the last one is released, with the Telegram bot reconciling leases against Docker so a killed bot cannot leave it running forever. At end of task the agent's session data is drained into the router's data volume for audit, and that volume is never removed by any code path.
8
+
9
+ GitHub is routed too: the sidecar terminates TLS itself with a certificate covering `api.github.com`, and the task container resolves that name to the router, so an unmodified `gh` is mediated without holding a GitHub credential of its own. `git` pushes through the router's `/git/*` proxy, which refuses remote-ref deletions and, from router `0.110.0`, non-fast-forward updates as well — it asks GitHub's compare API whether the proposed tip is ahead and fails closed when it cannot tell. A read-only `pre-push` hook in the task covers the same ground locally and for non-routed remotes. `--model formal-ai` is served through the router as a stored OpenAI-compatible provider, so it is audited like every other model.
10
+
11
+ Nothing changes without the flag. The remaining limits are documented and printed before every routed run: destructive GitHub API calls are blocked by HTTP method rather than by effect, so `PUT` branch protection, `PUT` rulesets, `POST` transfer and `PATCH` visibility/archived/default_branch are still forwarded (upstream `link-assistant/router#329`) — which puts branch protection itself within reach of a routed token; the Formal AI sidecar's own upstream calls are not routed; and the router resolves exact model ids only, not aliases, so the tier-shaped surface (`--plan`, `--escalate`, fallback chains) needs dated ids on a routed run. New docs: `docs/ROUTER.md`, `docs/COLLECTING-LOGS.md`, `docs/case-studies/issue-2164/` and `examples/collect-logs.mjs`.
12
+
3
13
  ## 2.13.5
4
14
 
5
15
  ### Patch Changes
package/README.hi.md CHANGED
@@ -88,6 +88,8 @@ Hive Mind में औसत प्रोग्रामर से अलग
88
88
 
89
89
  Hive Mind अपने प्रबंधित terminal, log, telemetry, upload और GitHub publication paths में पहचाने गए credentials को sanitize करता है। Scanner विफल होने पर publication रोक दिया जाता है, लेकिन यह किसी autonomous tool को किसी असंबंधित exfiltration path का उपयोग करने से नहीं रोकता। Masking contract, supported formats, maintenance process और incident-response guidance के लिए [Credential sanitization](./docs/CREDENTIAL-SANITIZATION.hi.md) देखें।
90
90
 
91
+ डिफ़ॉल्ट रूप से Docker में आइसोलेट किए गए कार्यों को ऑपरेटर के Claude/Codex क्रेडेंशियल मिल जाते हैं। `--use-router` उन्हें रोक देता है और मॉडल ट्रैफ़िक को `hive-mind-router` sidecar से भेजता है, जिससे हर कार्य को अपना सीमित दायरे वाला टोकन और अपना request लॉग मिलता है — देखें [राउटर आइसोलेशन](./docs/ROUTER.hi.md) (प्रयोगात्मक) और [लॉग एकत्र करना](./docs/COLLECTING-LOGS.hi.md)।
92
+
91
93
  `hive.mjs` चलाने के लिए न्यूनतम सिस्टम आवश्यकताएँ:
92
94
 
93
95
  ```
package/README.md CHANGED
@@ -88,6 +88,8 @@ We strongly recommend:
88
88
 
89
89
  Hive Mind sanitizes recognized credentials in its maintained terminal, log, telemetry, upload, and GitHub publication paths. Publication is blocked if the scanner fails, but this does not prevent an autonomous tool from using an unrelated exfiltration path. See [Credential sanitization](./docs/CREDENTIAL-SANITIZATION.md) for the masking contract, supported formats, maintenance process, and incident-response guidance.
90
90
 
91
+ Docker-isolated tasks receive the operator's Claude/Codex credentials by default. `--use-router` withholds them and routes model traffic through a `hive-mind-router` sidecar instead, giving each task its own scoped token and its own request log — see [Router isolation](./docs/ROUTER.md) (experimental) and [Collecting logs](./docs/COLLECTING-LOGS.md).
92
+
91
93
  Minimum system requirements to run `hive.mjs`:
92
94
 
93
95
  ```
package/README.ru.md CHANGED
@@ -88,6 +88,8 @@ Hive Mind обладает высоким уровнем творчества,
88
88
 
89
89
  Hive Mind скрывает распознанные учётные данные в обслуживаемых им путях вывода терминала, журналов, телеметрии, загрузок и публикаций GitHub. При сбое сканера публикация блокируется, однако это не мешает автономному инструменту использовать посторонний канал утечки. Соглашение о маскировании, поддерживаемые форматы, процесс сопровождения и рекомендации по реагированию на инциденты приведены в разделе [Санитизация учётных данных](./docs/CREDENTIAL-SANITIZATION.ru.md).
90
90
 
91
+ По умолчанию задачи, изолированные в Docker, получают учётные данные Claude/Codex оператора. `--use-router` их не передаёт и вместо этого направляет трафик модели через sidecar `hive-mind-router`, выдавая каждой задаче собственный ограниченный токен и собственный журнал запросов — см. [Изоляция через роутер](./docs/ROUTER.ru.md) (экспериментально) и [Сбор журналов](./docs/COLLECTING-LOGS.ru.md).
92
+
91
93
  Минимальные системные требования для запуска `hive.mjs`:
92
94
 
93
95
  ```
package/README.zh.md CHANGED
@@ -88,6 +88,8 @@ Hive Mind 具备与普通程序员无异的高度创造力。当需求不明确
88
88
 
89
89
  Hive Mind 会在其维护的终端、日志、遥测、上传和 GitHub 发布路径中清理可识别的凭证。如果扫描器失败,发布会被阻止;但这无法阻止自主工具通过不相关的路径泄露数据。有关掩码约定、支持的格式、维护流程和事件响应指南,请参阅[凭证清理](./docs/CREDENTIAL-SANITIZATION.zh.md)。
90
90
 
91
+ 默认情况下,Docker 隔离的任务会拿到操作者的 Claude/Codex 凭据。`--use-router` 不再传入这些凭据,而是让模型流量经由 `hive-mind-router` sidecar 转发,使每个任务获得仅限自身的令牌和自己的请求日志——参见[路由器隔离](./docs/ROUTER.zh.md)(实验性)与[收集日志](./docs/COLLECTING-LOGS.zh.md)。
92
+
91
93
  运行 `hive.mjs` 的最低系统要求:
92
94
 
93
95
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.13.5",
3
+ "version": "2.14.0",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Mechanics shared by every on-demand Docker sidecar Hive Mind runs.
3
+ *
4
+ * The Formal AI sidecar (issue #2146) and the router sidecar (issue #2164) are
5
+ * different services with different reasons to exist, but their *mechanics* are
6
+ * the same problem solved twice: one container shared by concurrent tasks, a
7
+ * lease per task, a durable JSON record that is only ever a cache of what
8
+ * Docker actually reports, and an exclusive lock so a launch and an update can
9
+ * never interleave.
10
+ *
11
+ * Those mechanics live here so the two lifecycles stay in step — a fix to lease
12
+ * reconciliation or to the network guard applies to both — and so each sidecar
13
+ * module is left holding only the part that is genuinely its own: which image,
14
+ * which mounts, which readiness check, and what the task is handed.
15
+ *
16
+ * @see https://github.com/link-assistant/hive-mind/issues/2146
17
+ * @see https://github.com/link-assistant/hive-mind/issues/2164
18
+ */
19
+
20
+ import { execFile } from 'node:child_process';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { promisify } from 'node:util';
24
+
25
+ import { resolveBotStateDir } from './session-store.lib.mjs';
26
+
27
+ const execFileAsync = promisify(execFile);
28
+
29
+ export const DEFAULT_DOCKER_TIMEOUT_MS = 120_000;
30
+ // Pulling a sidecar image is the one Docker call that legitimately takes many
31
+ // minutes, so it gets its own budget instead of the general command timeout.
32
+ export const DEFAULT_IMAGE_TIMEOUT_MS = 600_000;
33
+
34
+ export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
35
+
36
+ /** Run `docker …` and return trimmed stdout. Throws on a non-zero exit. */
37
+ export const dockerText = async (run, args, { timeoutMs = DEFAULT_DOCKER_TIMEOUT_MS } = {}) => {
38
+ const result = await run('docker', args, { encoding: 'utf8', timeout: timeoutMs });
39
+ return String(result?.stdout ?? '').trim();
40
+ };
41
+
42
+ /** Run `docker …` for its effect only, reporting success as a boolean. */
43
+ export const dockerOk = async (run, args, options) => {
44
+ try {
45
+ await dockerText(run, args, options);
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ };
51
+
52
+ /** The message a failed `docker` invocation should be reported with. */
53
+ export const dockerErrorMessage = error => error?.stderr?.toString?.().trim() || error?.message || String(error);
54
+
55
+ /**
56
+ * Inspect a container without treating "absent" as an error.
57
+ *
58
+ * @returns {Promise<{exists: boolean, running: boolean, image: string|null, imageDigest: string|null}>}
59
+ */
60
+ export const inspectDockerContainer = async (name, { run = execFileAsync, timeoutMs } = {}) => {
61
+ try {
62
+ const raw = await dockerText(run, ['inspect', name, '--format', '{{.State.Running}}|{{.Config.Image}}|{{.Image}}'], { timeoutMs });
63
+ const [running, image, imageDigest] = raw.split('|');
64
+ return { exists: true, running: running === 'true', image: image || null, imageDigest: imageDigest || null };
65
+ } catch {
66
+ return { exists: false, running: false, image: null, imageDigest: null };
67
+ }
68
+ };
69
+
70
+ /** Resolve the local content digest of an image reference, or null when it is absent. */
71
+ export const readDockerImageDigest = async (image, { run = execFileAsync, timeoutMs } = {}) => {
72
+ try {
73
+ return (await dockerText(run, ['image', 'inspect', image, '--format', '{{.Id}}'], { timeoutMs })) || null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ };
78
+
79
+ /** A container's IPv4 address on one named network, or null when it has none. */
80
+ export const readDockerContainerAddress = async (containerName, network, { run = execFileAsync, timeoutMs } = {}) => {
81
+ try {
82
+ return (await dockerText(run, ['inspect', containerName, '--format', `{{with index .NetworkSettings.Networks "${network}"}}{{.IPAddress}}{{end}}`], { timeoutMs })) || null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ };
87
+
88
+ /**
89
+ * Create the private network a sidecar and its tasks share.
90
+ *
91
+ * `--internal` is the security requirement: the sidecar endpoint must not be
92
+ * published to the host and must not be reachable from any other network. An
93
+ * existing network that is *not* internal is a stale artifact from an older
94
+ * deployment and is replaced — but only while nothing is attached to it, since
95
+ * removing a network out from under a running container would break it.
96
+ */
97
+ export const ensureInternalDockerNetwork = async ({ name, label, run = execFileAsync, timeoutMs, log = null, verbose = false, logPrefix = 'docker-sidecar' } = {}) => {
98
+ // `null` means "absent", which is different from "present but not internal".
99
+ let internal = null;
100
+ let containers = 0;
101
+ try {
102
+ const raw = await dockerText(run, ['network', 'inspect', name, '--format', '{{.Internal}}|{{len .Containers}}'], { timeoutMs });
103
+ const [internalFlag, containerCount] = raw.split('|');
104
+ internal = internalFlag === 'true';
105
+ containers = Number(containerCount) || 0;
106
+ } catch {
107
+ // Absent; fall through to creation.
108
+ }
109
+
110
+ if (internal === true) return { created: false, internal: true };
111
+
112
+ if (internal === false) {
113
+ if (containers > 0) {
114
+ if (log) await log(`⚠️ Network '${name}' is not internal but still has ${containers} attached container(s); leaving it in place`);
115
+ return { created: false, internal: false };
116
+ }
117
+ if (verbose && log) await log(`[VERBOSE] ${logPrefix}: replacing non-internal network '${name}'`);
118
+ await dockerOk(run, ['network', 'rm', name], { timeoutMs });
119
+ }
120
+
121
+ await dockerText(run, ['network', 'create', '--internal', '--label', `${label}=network`, name], { timeoutMs });
122
+ if (verbose && log) await log(`[VERBOSE] ${logPrefix}: created internal network '${name}'`);
123
+ return { created: true, internal: true };
124
+ };
125
+
126
+ /**
127
+ * Add a network to an already-created container.
128
+ *
129
+ * A single `docker run --network` *replaces* the container's default bridge, so
130
+ * an `--internal` network passed that way would also cut the container off from
131
+ * GitHub and the package registries. Attaching afterwards is additive, which is
132
+ * what both sidecars need — for the task container, and for the router sidecar
133
+ * itself, which must keep its outbound route to the vendor APIs.
134
+ */
135
+ export const attachDockerNetwork = async ({ network, container, alias = null, run = execFileAsync, timeoutMs, log = null, verbose = false, logPrefix = 'docker-sidecar' } = {}) => {
136
+ if (!container) return { attached: false, error: 'no container' };
137
+ const args = ['network', 'connect'];
138
+ if (alias) args.push('--alias', alias);
139
+ args.push(network, container);
140
+ try {
141
+ await dockerText(run, args, { timeoutMs });
142
+ if (verbose && log) await log(`[VERBOSE] ${logPrefix}: attached '${container}' to '${network}'${alias ? ` as '${alias}'` : ''}`);
143
+ return { attached: true, error: null };
144
+ } catch (error) {
145
+ const message = dockerErrorMessage(error);
146
+ // Docker reports an already-attached container as an error; that is success.
147
+ if (/already exists in network/i.test(message)) return { attached: true, error: null };
148
+ if (log) await log(`⚠️ Could not attach '${container}' to network '${network}': ${message}`);
149
+ return { attached: false, error: message };
150
+ }
151
+ };
152
+
153
+ /** Create a named volume if it is missing. Never removed by any caller: it holds the data the sidecar exists to keep. */
154
+ export const ensureDockerVolume = async ({ name, label, role = 'data', run = execFileAsync, timeoutMs, log = null, verbose = false, logPrefix = 'docker-sidecar' } = {}) => {
155
+ if (await dockerOk(run, ['volume', 'inspect', name], { timeoutMs })) return { created: false };
156
+ await dockerText(run, ['volume', 'create', '--label', `${label}=${role}`, name], { timeoutMs });
157
+ if (verbose && log) await log(`[VERBOSE] ${logPrefix}: created volume '${name}'`);
158
+ return { created: true };
159
+ };
160
+
161
+ /** Path of a sidecar's durable record inside the bot state directory. */
162
+ export const resolveSidecarStatePath = (fileName, env = process.env) => path.join(resolveBotStateDir(env), fileName);
163
+
164
+ /** Read a durable sidecar record. A missing or corrupt file is an empty record, never a throw. */
165
+ export const readSidecarState = ({ fileName, emptyState, env = process.env, fsImpl = fs } = {}) => {
166
+ try {
167
+ const parsed = JSON.parse(fsImpl.readFileSync(resolveSidecarStatePath(fileName, env), 'utf8'));
168
+ return { ...emptyState, ...parsed, leases: Array.isArray(parsed?.leases) ? parsed.leases : [] };
169
+ } catch {
170
+ return { ...emptyState, leases: [] };
171
+ }
172
+ };
173
+
174
+ /**
175
+ * Persist a sidecar record atomically so a crash mid-write cannot corrupt it.
176
+ *
177
+ * `mode` exists because the router's record holds its JWT signing secret, which
178
+ * mints subscription access: that file must not be world-readable (issue #2164).
179
+ */
180
+ export const writeSidecarState = (state, { fileName, env = process.env, fsImpl = fs, mode = 0o600 } = {}) => {
181
+ const target = resolveSidecarStatePath(fileName, env);
182
+ fsImpl.mkdirSync(path.dirname(target), { recursive: true });
183
+ const temporary = `${target}.tmp`;
184
+ fsImpl.writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf8', mode });
185
+ fsImpl.renameSync(temporary, target);
186
+ return state;
187
+ };
188
+
189
+ /**
190
+ * How long a lease whose container has never been seen running is kept.
191
+ *
192
+ * A lease is taken *before* start-command creates the task container, because
193
+ * the endpoint and the token have to be known when the task's environment is
194
+ * built. During that window the container legitimately does not exist yet — and
195
+ * creating it can take a long time when the isolation image still has to be
196
+ * pulled.
197
+ */
198
+ export const LEASE_START_GRACE_MS = 60 * 60 * 1000;
199
+
200
+ /**
201
+ * Drop leases whose task container no longer runs, so a crashed run cannot pin
202
+ * a sidecar up forever.
203
+ *
204
+ * Liveness is re-derived from Docker on every call rather than trusted from the
205
+ * store, which is what lets a restarted bot converge instead of orphaning.
206
+ *
207
+ * @param {Array<object>} leases
208
+ * @param {{onDropped?: (lease: object) => Promise<void>|void}} options
209
+ * `onDropped` lets a sidecar clean up what the lease owned — the router
210
+ * revokes the task's token there.
211
+ */
212
+ export const reconcileSidecarLeases = async (leases, { run = execFileAsync, timeoutMs, log = null, verbose = false, now = () => Date.now(), onDropped = null, logPrefix = 'docker-sidecar' } = {}) => {
213
+ const live = [];
214
+ for (const lease of leases) {
215
+ if (!lease?.sessionId) continue;
216
+ const container = await inspectDockerContainer(lease.sessionId, { run, timeoutMs });
217
+ if (container.exists && container.running) {
218
+ live.push(lease.containerSeen ? lease : { ...lease, containerSeen: true });
219
+ continue;
220
+ }
221
+ if (!lease.containerSeen) {
222
+ const age = now() - (Date.parse(lease.acquiredAt ?? '') || 0);
223
+ if (age < LEASE_START_GRACE_MS) {
224
+ if (verbose && log) await log(`[VERBOSE] ${logPrefix}: keeping lease '${lease.sessionId}' whose container has not appeared yet (${Math.round(age / 1000)}s into the ${Math.round(LEASE_START_GRACE_MS / 1000)}s launch grace)`);
225
+ live.push(lease);
226
+ continue;
227
+ }
228
+ }
229
+ if (verbose && log) await log(`[VERBOSE] ${logPrefix}: dropping stale lease '${lease.sessionId}' (container exists=${container.exists} running=${container.running})`);
230
+ if (onDropped) await onDropped(lease);
231
+ }
232
+ return live;
233
+ };
234
+
235
+ /**
236
+ * Periodic best-effort maintenance timer shared by the sidecar lifecycles.
237
+ *
238
+ * Both the Formal AI and the router sidecar need the same thing: a tick that
239
+ * runs immediately, repeats on an interval, never keeps the process alive on
240
+ * shutdown, and never rejects into the bot's event loop. Keeping one
241
+ * implementation means a fix to that shape applies to both.
242
+ *
243
+ * @returns {{stop: () => void}}
244
+ */
245
+ export const startSidecarMaintenance = ({ runTick, logPrefix = 'sidecar-maintenance', env = process.env, log = null, verbose = false, intervalMs, setIntervalImpl = setInterval, clearIntervalImpl = clearInterval } = {}) => {
246
+ const tick = () => {
247
+ runTick({ env, log, verbose }).catch(error => {
248
+ console.error(`[${logPrefix}] tick failed: ${error?.message || error}`);
249
+ });
250
+ };
251
+
252
+ const timer = setIntervalImpl(tick, intervalMs);
253
+ timer?.unref?.();
254
+ tick();
255
+ return {
256
+ stop: () => clearIntervalImpl(timer),
257
+ };
258
+ };
259
+
260
+ export default {
261
+ attachDockerNetwork,
262
+ dockerErrorMessage,
263
+ dockerOk,
264
+ dockerText,
265
+ ensureDockerVolume,
266
+ ensureInternalDockerNetwork,
267
+ inspectDockerContainer,
268
+ readDockerContainerAddress,
269
+ readDockerImageDigest,
270
+ readSidecarState,
271
+ reconcileSidecarLeases,
272
+ resolveSidecarStatePath,
273
+ sleep,
274
+ startSidecarMaintenance,
275
+ writeSidecarState,
276
+ };
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import { updateAgenticClisWhenIdle } from './agentic-cli-updater.lib.mjs';
24
+ import { startSidecarMaintenance } from './docker-sidecar.lib.mjs';
24
25
  import { reconcileFormalAiSidecar, stopFormalAiSidecar, withFormalAiSidecarLock } from './formal-ai-sidecar.lib.mjs';
25
26
  import { updateFormalAiSidecarWhenIdle } from './formal-ai-updater.lib.mjs';
26
27
 
@@ -88,19 +89,6 @@ export const runFormalAiMaintenanceTick = async ({ env = process.env, run, log =
88
89
  *
89
90
  * @returns {{stop: () => void}}
90
91
  */
91
- export const startFormalAiMaintenance = ({ env = process.env, log = null, verbose = false, intervalMs = DEFAULT_FORMAL_AI_MAINTENANCE_INTERVAL_MS, setIntervalImpl = setInterval, clearIntervalImpl = clearInterval, runTick = runFormalAiMaintenanceTick } = {}) => {
92
- const tick = () => {
93
- runTick({ env, log, verbose }).catch(error => {
94
- console.error(`[formal-ai-maintenance] tick failed: ${error?.message || error}`);
95
- });
96
- };
97
-
98
- const timer = setIntervalImpl(tick, intervalMs);
99
- timer?.unref?.();
100
- tick();
101
- return {
102
- stop: () => clearIntervalImpl(timer),
103
- };
104
- };
92
+ export const startFormalAiMaintenance = ({ env = process.env, log = null, verbose = false, intervalMs = DEFAULT_FORMAL_AI_MAINTENANCE_INTERVAL_MS, setIntervalImpl = setInterval, clearIntervalImpl = clearInterval, runTick = runFormalAiMaintenanceTick } = {}) => startSidecarMaintenance({ runTick, logPrefix: 'formal-ai-maintenance', env, log, verbose, intervalMs, setIntervalImpl, clearIntervalImpl });
105
93
 
106
94
  export default { DEFAULT_FORMAL_AI_MAINTENANCE_INTERVAL_MS, runFormalAiMaintenanceTick, startFormalAiMaintenance, stopIdleFormalAiSidecar };
@@ -43,18 +43,23 @@
43
43
 
44
44
  import { execFile } from 'node:child_process';
45
45
  import fs from 'node:fs';
46
- import path from 'node:path';
47
46
  import { promisify } from 'node:util';
48
47
 
49
48
  import { FORMAL_AI_MINIMUM_VERSION, isFormalAiVersionAtLeast } from './formal-ai-version.lib.mjs';
50
49
  import { ensureFormalAiSidecarImage, resolveFormalAiSidecarImage } from './formal-ai-image.lib.mjs';
51
50
  import { isFormalAiModel } from './formal-ai-model.lib.mjs';
52
51
  import { getModelFromArgs } from './model-args.lib.mjs';
53
- import { resolveBotStateDir } from './session-store.lib.mjs';
54
52
  import { withStateLock } from './state-lock.lib.mjs';
53
+ import { attachDockerNetwork, DEFAULT_IMAGE_TIMEOUT_MS, dockerOk, dockerText, ensureDockerVolume, ensureInternalDockerNetwork, inspectDockerContainer, readDockerContainerAddress, readDockerImageDigest, readSidecarState, reconcileSidecarLeases, resolveSidecarStatePath, sleep, writeSidecarState } from './docker-sidecar.lib.mjs';
55
54
 
56
55
  const execFileAsync = promisify(execFile);
57
56
 
57
+ // Re-exported because callers of this module have always imported them from
58
+ // here; the implementations now live in the shared sidecar module.
59
+ export { inspectDockerContainer, readDockerImageDigest };
60
+
61
+ const LOG_PREFIX = 'formal-ai-sidecar';
62
+
58
63
  /** Container, network, volume and alias names. Stable so reconciliation works across restarts. */
59
64
  export const FORMAL_AI_SIDECAR_CONTAINER_NAME = 'hive-mind-formal-ai';
60
65
  export const FORMAL_AI_SIDECAR_NETWORK_NAME = 'hive-mind-formal-ai';
@@ -92,17 +97,13 @@ export const FORMAL_AI_SIDECAR_LABEL = 'com.link-assistant.hive-mind.formal-ai';
92
97
 
93
98
  const STATE_FILE_NAME = 'formal-ai-sidecar.json';
94
99
  const SIDECAR_LOCK_NAME = 'formal-ai-sidecar';
95
- const DEFAULT_DOCKER_TIMEOUT_MS = 120_000;
96
100
  // Pulling a sidecar image is the one Docker call that legitimately takes many
97
101
  // minutes, so it gets its own budget instead of the general command timeout.
98
- const DEFAULT_IMAGE_TIMEOUT_MS = 600_000;
99
102
  const DEFAULT_HEALTH_ATTEMPTS = 60;
100
103
  const DEFAULT_HEALTH_DELAY_MS = 1000;
101
104
 
102
105
  const EMPTY_STATE = Object.freeze({ version: 1, image: null, imageDigest: null, startedAt: null, leases: [], lastUpdate: null });
103
106
 
104
- const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
105
-
106
107
  /**
107
108
  * True when a task will be driven by Formal AI.
108
109
  *
@@ -149,27 +150,13 @@ export const isFormalAiSidecarEnabled = (env = process.env) => {
149
150
  return !['0', 'false', 'no', 'off'].includes(raw);
150
151
  };
151
152
 
152
- export const resolveFormalAiSidecarStatePath = (env = process.env) => path.join(resolveBotStateDir(env), STATE_FILE_NAME);
153
+ export const resolveFormalAiSidecarStatePath = (env = process.env) => resolveSidecarStatePath(STATE_FILE_NAME, env);
153
154
 
154
155
  /** Read the durable sidecar record. A missing or corrupt file is an empty record, never a throw. */
155
- export const readFormalAiSidecarState = ({ env = process.env, fsImpl = fs } = {}) => {
156
- try {
157
- const parsed = JSON.parse(fsImpl.readFileSync(resolveFormalAiSidecarStatePath(env), 'utf8'));
158
- return { ...EMPTY_STATE, ...parsed, leases: Array.isArray(parsed?.leases) ? parsed.leases : [] };
159
- } catch {
160
- return { ...EMPTY_STATE, leases: [] };
161
- }
162
- };
156
+ export const readFormalAiSidecarState = ({ env = process.env, fsImpl = fs } = {}) => readSidecarState({ fileName: STATE_FILE_NAME, emptyState: EMPTY_STATE, env, fsImpl });
163
157
 
164
158
  /** Persist the sidecar record atomically so a crash mid-write cannot corrupt it. */
165
- export const writeFormalAiSidecarState = (state, { env = process.env, fsImpl = fs } = {}) => {
166
- const target = resolveFormalAiSidecarStatePath(env);
167
- fsImpl.mkdirSync(path.dirname(target), { recursive: true });
168
- const temporary = `${target}.tmp`;
169
- fsImpl.writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
170
- fsImpl.renameSync(temporary, target);
171
- return state;
172
- };
159
+ export const writeFormalAiSidecarState = (state, { env = process.env, fsImpl = fs } = {}) => writeSidecarState(state, { fileName: STATE_FILE_NAME, env, fsImpl });
173
160
 
174
161
  /**
175
162
  * Serialize every sidecar mutation — task launches, task releases and image
@@ -180,35 +167,6 @@ export const writeFormalAiSidecarState = (state, { env = process.env, fsImpl = f
180
167
  */
181
168
  export const withFormalAiSidecarLock = (fn, options = {}) => withStateLock(SIDECAR_LOCK_NAME, fn, options);
182
169
 
183
- const dockerText = async (run, args, { timeoutMs = DEFAULT_DOCKER_TIMEOUT_MS } = {}) => {
184
- const result = await run('docker', args, { encoding: 'utf8', timeout: timeoutMs });
185
- return String(result?.stdout ?? '').trim();
186
- };
187
-
188
- const dockerOk = async (run, args, options) => {
189
- try {
190
- await dockerText(run, args, options);
191
- return true;
192
- } catch {
193
- return false;
194
- }
195
- };
196
-
197
- /**
198
- * Inspect a container without treating "absent" as an error.
199
- *
200
- * @returns {Promise<{exists: boolean, running: boolean, image: string|null, imageDigest: string|null}>}
201
- */
202
- export const inspectDockerContainer = async (name, { run = execFileAsync, timeoutMs } = {}) => {
203
- try {
204
- const raw = await dockerText(run, ['inspect', name, '--format', '{{.State.Running}}|{{.Config.Image}}|{{.Image}}'], { timeoutMs });
205
- const [running, image, imageDigest] = raw.split('|');
206
- return { exists: true, running: running === 'true', image: image || null, imageDigest: imageDigest || null };
207
- } catch {
208
- return { exists: false, running: false, image: null, imageDigest: null };
209
- }
210
- };
211
-
212
170
  /**
213
171
  * The sidecar's IPv4 address on the internal network.
214
172
  *
@@ -218,22 +176,7 @@ export const inspectDockerContainer = async (name, { run = execFileAsync, timeou
218
176
  * post-attachment would be a needless gamble; the address cannot change during
219
177
  * a lease, because an image replacement requires zero leases.
220
178
  */
221
- export const readFormalAiSidecarAddress = async ({ containerName = FORMAL_AI_SIDECAR_CONTAINER_NAME, network = FORMAL_AI_SIDECAR_NETWORK_NAME, run = execFileAsync, timeoutMs } = {}) => {
222
- try {
223
- return (await dockerText(run, ['inspect', containerName, '--format', `{{with index .NetworkSettings.Networks "${network}"}}{{.IPAddress}}{{end}}`], { timeoutMs })) || null;
224
- } catch {
225
- return null;
226
- }
227
- };
228
-
229
- /** Resolve the local content digest of an image reference, or null when it is absent. */
230
- export const readDockerImageDigest = async (image, { run = execFileAsync, timeoutMs } = {}) => {
231
- try {
232
- return (await dockerText(run, ['image', 'inspect', image, '--format', '{{.Id}}'], { timeoutMs })) || null;
233
- } catch {
234
- return null;
235
- }
236
- };
179
+ export const readFormalAiSidecarAddress = async ({ containerName = FORMAL_AI_SIDECAR_CONTAINER_NAME, network = FORMAL_AI_SIDECAR_NETWORK_NAME, run = execFileAsync, timeoutMs } = {}) => readDockerContainerAddress(containerName, network, { run, timeoutMs });
237
180
 
238
181
  /**
239
182
  * Create the private network the sidecar and its tasks share.
@@ -244,34 +187,7 @@ export const readDockerImageDigest = async (image, { run = execFileAsync, timeou
244
187
  * artifact from the Compose deployment and is replaced when nothing is
245
188
  * attached to it.
246
189
  */
247
- export const ensureFormalAiNetwork = async ({ run = execFileAsync, timeoutMs, log = null, verbose = false } = {}) => {
248
- // `null` means "absent", which is different from "present but not internal".
249
- let internal = null;
250
- let containers = 0;
251
- try {
252
- const raw = await dockerText(run, ['network', 'inspect', FORMAL_AI_SIDECAR_NETWORK_NAME, '--format', '{{.Internal}}|{{len .Containers}}'], { timeoutMs });
253
- const [internalFlag, containerCount] = raw.split('|');
254
- internal = internalFlag === 'true';
255
- containers = Number(containerCount) || 0;
256
- } catch {
257
- // Absent; fall through to creation.
258
- }
259
-
260
- if (internal === true) return { created: false, internal: true };
261
-
262
- if (internal === false) {
263
- if (containers > 0) {
264
- if (log) await log(`⚠️ Formal AI network '${FORMAL_AI_SIDECAR_NETWORK_NAME}' is not internal but still has ${containers} attached container(s); leaving it in place`);
265
- return { created: false, internal: false };
266
- }
267
- if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: replacing non-internal network '${FORMAL_AI_SIDECAR_NETWORK_NAME}'`);
268
- await dockerOk(run, ['network', 'rm', FORMAL_AI_SIDECAR_NETWORK_NAME], { timeoutMs });
269
- }
270
-
271
- await dockerText(run, ['network', 'create', '--internal', '--label', `${FORMAL_AI_SIDECAR_LABEL}=network`, FORMAL_AI_SIDECAR_NETWORK_NAME], { timeoutMs });
272
- if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: created internal network '${FORMAL_AI_SIDECAR_NETWORK_NAME}'`);
273
- return { created: true, internal: true };
274
- };
190
+ export const ensureFormalAiNetwork = async ({ run = execFileAsync, timeoutMs, log = null, verbose = false } = {}) => ensureInternalDockerNetwork({ name: FORMAL_AI_SIDECAR_NETWORK_NAME, label: FORMAL_AI_SIDECAR_LABEL, run, timeoutMs, log, verbose, logPrefix: LOG_PREFIX });
275
191
 
276
192
  /**
277
193
  * Create the persisted-memory volume if it is missing and hand it to the
@@ -281,16 +197,12 @@ export const ensureFormalAiNetwork = async ({ run = execFileAsync, timeoutMs, lo
281
197
  * task boundaries, sidecar stops, image replacement and rollback.
282
198
  */
283
199
  export const ensureFormalAiMemoryVolume = async ({ image, run = execFileAsync, timeoutMs, log = null, verbose = false } = {}) => {
284
- if (await dockerOk(run, ['volume', 'inspect', FORMAL_AI_MEMORY_VOLUME_NAME], { timeoutMs })) {
285
- return { created: false };
286
- }
287
-
288
- await dockerText(run, ['volume', 'create', '--label', `${FORMAL_AI_SIDECAR_LABEL}=memory`, FORMAL_AI_MEMORY_VOLUME_NAME], { timeoutMs });
200
+ const result = await ensureDockerVolume({ name: FORMAL_AI_MEMORY_VOLUME_NAME, label: FORMAL_AI_SIDECAR_LABEL, role: 'memory', run, timeoutMs, log, verbose, logPrefix: LOG_PREFIX });
201
+ if (!result.created) return result;
289
202
  // A fresh named volume is root-owned; the image runs application commands as
290
203
  // `box`, so seed the ownership exactly as upstream's own upgrade fixture does.
291
204
  await dockerOk(run, ['run', '--rm', '--volume', `${FORMAL_AI_MEMORY_VOLUME_NAME}:${FORMAL_AI_MEMORY_MOUNT}`, '--entrypoint', 'chown', image, '-R', 'box:box', FORMAL_AI_MEMORY_MOUNT], { timeoutMs });
292
- if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: created memory volume '${FORMAL_AI_MEMORY_VOLUME_NAME}'`);
293
- return { created: true };
205
+ return result;
294
206
  };
295
207
 
296
208
  /** Build the `docker run` argv for the sidecar. Exported so tests can assert the contract. */
@@ -369,29 +281,7 @@ export const waitForFormalAiSidecarHealth = async ({ containerName = FORMAL_AI_S
369
281
  * `LEASE_START_GRACE_MS` elapses; afterwards, and always once the container has
370
282
  * been observed, liveness is Docker's answer alone.
371
283
  */
372
- const LEASE_START_GRACE_MS = 60 * 60 * 1000;
373
-
374
- const reconcileLeases = async (leases, { run, timeoutMs, log, verbose, now = () => Date.now() }) => {
375
- const live = [];
376
- for (const lease of leases) {
377
- if (!lease?.sessionId) continue;
378
- const container = await inspectDockerContainer(lease.sessionId, { run, timeoutMs });
379
- if (container.exists && container.running) {
380
- live.push(lease.containerSeen ? lease : { ...lease, containerSeen: true });
381
- continue;
382
- }
383
- if (!lease.containerSeen) {
384
- const age = now() - (Date.parse(lease.acquiredAt ?? '') || 0);
385
- if (age < LEASE_START_GRACE_MS) {
386
- if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: keeping lease '${lease.sessionId}' whose container has not appeared yet (${Math.round(age / 1000)}s into the ${Math.round(LEASE_START_GRACE_MS / 1000)}s launch grace)`);
387
- live.push(lease);
388
- continue;
389
- }
390
- }
391
- if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: dropping stale lease '${lease.sessionId}' (container exists=${container.exists} running=${container.running})`);
392
- }
393
- return live;
394
- };
284
+ const reconcileLeases = async (leases, options) => reconcileSidecarLeases(leases, { ...options, logPrefix: LOG_PREFIX });
395
285
 
396
286
  /**
397
287
  * Re-derive the sidecar record from Docker.
@@ -522,17 +412,7 @@ export const acquireFormalAiSidecar = async ({ sessionId, tool = null, model = n
522
412
  */
523
413
  export const attachTaskToFormalAiNetwork = async ({ sessionId, run = execFileAsync, timeoutMs, log = null, verbose = false } = {}) => {
524
414
  if (!sessionId) return { attached: false, error: 'no sessionId' };
525
- try {
526
- await dockerText(run, ['network', 'connect', FORMAL_AI_SIDECAR_NETWORK_NAME, sessionId], { timeoutMs });
527
- if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: attached task container '${sessionId}' to '${FORMAL_AI_SIDECAR_NETWORK_NAME}'`);
528
- return { attached: true, error: null };
529
- } catch (error) {
530
- const message = error?.stderr?.toString?.().trim() || error?.message || String(error);
531
- // Docker reports an already-attached container as an error; that is success.
532
- if (/already exists in network/i.test(message)) return { attached: true, error: null };
533
- if (log) await log(`⚠️ Could not attach task container '${sessionId}' to the Formal AI network: ${message}`);
534
- return { attached: false, error: message };
535
- }
415
+ return attachDockerNetwork({ network: FORMAL_AI_SIDECAR_NETWORK_NAME, container: sessionId, run, timeoutMs, log, verbose, logPrefix: LOG_PREFIX });
536
416
  };
537
417
 
538
418
  /**