@link-assistant/hive-mind 1.74.2 → 1.74.4

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,17 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.74.4
4
+
5
+ ### Patch Changes
6
+
7
+ - 9b88700: Fix Telegram Docker isolation to use Hive Mind images with scoped GitHub, Claude, and Codex auth mounts.
8
+
9
+ ## 1.74.3
10
+
11
+ ### Patch Changes
12
+
13
+ - 741752e: Bump the Docker-in-Docker base image to `konard/box-dind:2.1.4` so `docker exec` sessions default to the `box` user with `/home/box` while dockerd still starts correctly.
14
+
3
15
  ## 1.74.2
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.74.2",
3
+ "version": "1.74.4",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -13,6 +13,10 @@
13
13
  */
14
14
 
15
15
  import crypto from 'crypto';
16
+ import { spawn } from 'node:child_process';
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
16
20
 
17
21
  if (typeof use === 'undefined') {
18
22
  globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
@@ -24,6 +28,11 @@ const { $ } = await use('command-stream');
24
28
  const VALID_ISOLATION_BACKENDS = ['screen', 'tmux', 'docker'];
25
29
  const RUNNING_SESSION_STATUSES = new Set(['executing', 'running']);
26
30
  const TERMINAL_SESSION_STATUSES = new Set(['executed', 'completed', 'failed', 'cancelled', 'canceled', 'error']);
31
+ const DEFAULT_HIVE_MIND_IMAGE = 'konard/hive-mind:latest';
32
+ const DEFAULT_HIVE_MIND_DIND_IMAGE = 'konard/hive-mind-dind:latest';
33
+ const DOCKER_ISOLATION_TRACKING_BACKEND = 'screen';
34
+ const DOCKER_CONTAINER_HOME = '/home/box';
35
+ const DOCKER_CONTAINER_PREFIX = 'hive-mind-isolation';
27
36
 
28
37
  function normalizeProcessIds(value) {
29
38
  if (!value || typeof value !== 'object') return {};
@@ -35,6 +44,149 @@ function normalizeProcessIds(value) {
35
44
  return out;
36
45
  }
37
46
 
47
+ function normalizeTool(tool) {
48
+ return String(tool || 'claude')
49
+ .trim()
50
+ .toLowerCase();
51
+ }
52
+
53
+ function shellQuote(value) {
54
+ const stringValue = String(value);
55
+ if (stringValue === '') return "''";
56
+ return `'${stringValue.replaceAll("'", "'\\''")}'`;
57
+ }
58
+
59
+ function shellDoubleQuote(value) {
60
+ return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('$', '\\$').replaceAll('`', '\\`')}"`;
61
+ }
62
+
63
+ function buildShellCommand(command, args = []) {
64
+ return [command, ...args].map(shellQuote).join(' ');
65
+ }
66
+
67
+ function makeDockerContainerName(sessionId) {
68
+ const normalizedSession = String(sessionId || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.-]/g, '-');
69
+ return `${DOCKER_CONTAINER_PREFIX}-${normalizedSession}`;
70
+ }
71
+
72
+ function shouldRunPrivilegedDockerIsolation(image, env = process.env) {
73
+ return String(env.HIVE_MIND_IMAGE_VARIANT || '').toLowerCase() === 'dind' || String(image || '').includes('hive-mind-dind');
74
+ }
75
+
76
+ function maybeAddMount(mounts, source, target, existsSync) {
77
+ if (!source) return;
78
+ if (!existsSync(source)) return;
79
+ mounts.push({ source, target });
80
+ }
81
+
82
+ /**
83
+ * Pick the Docker image used for `--isolation docker`.
84
+ *
85
+ * start-command defaults its Docker backend to a base OS image. Hive Mind needs
86
+ * an image with the same CLI/tooling baseline as the parent process instead.
87
+ */
88
+ export function getDockerIsolationImage({ env = process.env } = {}) {
89
+ if (env.HIVE_MIND_DOCKER_ISOLATION_IMAGE) return env.HIVE_MIND_DOCKER_ISOLATION_IMAGE;
90
+ return String(env.HIVE_MIND_IMAGE_VARIANT || '').toLowerCase() === 'dind' ? DEFAULT_HIVE_MIND_DIND_IMAGE : DEFAULT_HIVE_MIND_IMAGE;
91
+ }
92
+
93
+ /**
94
+ * Build host auth mounts for a Docker-isolated task.
95
+ *
96
+ * GitHub auth is mounted for every task because solve/hive/task need gh. Tool
97
+ * credentials are deliberately scoped: Codex sessions do not receive Claude
98
+ * files and Claude sessions do not receive Codex files.
99
+ */
100
+ export function getDockerIsolationAuthMounts({ tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync } = {}) {
101
+ const mounts = [];
102
+ const normalizedTool = normalizeTool(tool);
103
+
104
+ maybeAddMount(mounts, env.GH_CONFIG_DIR || path.join(homeDir, '.config', 'gh'), path.join(DOCKER_CONTAINER_HOME, '.config', 'gh'), existsSync);
105
+
106
+ if (normalizedTool === 'codex') {
107
+ maybeAddMount(mounts, path.join(homeDir, '.codex'), path.join(DOCKER_CONTAINER_HOME, '.codex'), existsSync);
108
+ } else if (normalizedTool === 'claude') {
109
+ maybeAddMount(mounts, path.join(homeDir, '.claude'), path.join(DOCKER_CONTAINER_HOME, '.claude'), existsSync);
110
+ maybeAddMount(mounts, path.join(homeDir, '.claude.json'), path.join(DOCKER_CONTAINER_HOME, '.claude.json'), existsSync);
111
+ }
112
+
113
+ return mounts;
114
+ }
115
+
116
+ /**
117
+ * Build the shell command executed inside a start-command wrapper session for
118
+ * Docker isolation. The wrapper remains a start-command session so Telegram can
119
+ * keep using the same status/log lifecycle while Hive Mind controls image and
120
+ * auth mounts directly.
121
+ */
122
+ export function buildDockerIsolationCommand(command, args = [], options = {}) {
123
+ const { sessionId, tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync } = options;
124
+ const image = getDockerIsolationImage({ env });
125
+ const innerCommand = buildShellCommand(command, args);
126
+ const dockerArgs = ['docker', 'run', '--rm', '--name', makeDockerContainerName(sessionId), '--workdir', DOCKER_CONTAINER_HOME, '-e', `HOME=${DOCKER_CONTAINER_HOME}`, '-e', `HIVE_MIND_PARENT_SESSION_ID=${sessionId || ''}`];
127
+
128
+ if (shouldRunPrivilegedDockerIsolation(image, env)) {
129
+ dockerArgs.push('--privileged');
130
+ }
131
+
132
+ const imageVariant = image.includes('hive-mind-dind') ? 'dind' : env.HIVE_MIND_IMAGE_VARIANT || 'regular';
133
+ dockerArgs.push('-e', `HIVE_MIND_IMAGE_VARIANT=${imageVariant}`);
134
+
135
+ for (const mount of getDockerIsolationAuthMounts({ tool, env, homeDir, existsSync })) {
136
+ dockerArgs.push('--volume', `${mount.source}:${mount.target}`);
137
+ }
138
+
139
+ dockerArgs.push(image, 'bash', '-lc');
140
+
141
+ return [...dockerArgs.map(shellQuote), shellDoubleQuote(innerCommand)].join(' ');
142
+ }
143
+
144
+ export function buildStartCommandArgs(command, args = [], options = {}) {
145
+ const { backend, sessionId } = options;
146
+ if (backend === 'docker') {
147
+ return ['--isolated', DOCKER_ISOLATION_TRACKING_BACKEND, '--detached', '--session', sessionId, '--', buildDockerIsolationCommand(command, args, options)];
148
+ }
149
+ return ['--isolated', backend, '--detached', '--session', sessionId, '--', buildShellCommand(command, args)];
150
+ }
151
+
152
+ async function runStartCommand(binPath, startCommandArgs) {
153
+ return await new Promise(resolve => {
154
+ const child = spawn(binPath, startCommandArgs, {
155
+ stdio: ['ignore', 'pipe', 'pipe'],
156
+ env: process.env,
157
+ });
158
+
159
+ let stdout = '';
160
+ let stderr = '';
161
+
162
+ child.stdout.on('data', data => {
163
+ stdout += data.toString();
164
+ });
165
+ child.stderr.on('data', data => {
166
+ stderr += data.toString();
167
+ });
168
+ child.on('error', error => {
169
+ resolve({
170
+ success: false,
171
+ output: (stdout + stderr).trim(),
172
+ error: error.message,
173
+ });
174
+ });
175
+ child.on('close', code => {
176
+ const output = (stdout + (stderr ? `\n${stderr}` : '')).trim();
177
+ if (code === 0) {
178
+ resolve({ success: true, output, error: null });
179
+ } else {
180
+ resolve({
181
+ success: false,
182
+ output,
183
+ error: stderr.trim() || `start-command exited with code ${code}`,
184
+ });
185
+ }
186
+ });
187
+ });
188
+ }
189
+
38
190
  /**
39
191
  * Generate a UUID v4 for unique session identification
40
192
  * @returns {string} UUID v4 string
@@ -169,6 +321,7 @@ async function findStartCommandBinary() {
169
321
  * @param {Object} options - Isolation options
170
322
  * @param {string} options.backend - Isolation backend: 'screen', 'tmux', or 'docker'
171
323
  * @param {string} [options.sessionId] - UUID for session tracking (auto-generated if not provided)
324
+ * @param {string} [options.tool] - AI tool selected for the task; used to scope Docker auth mounts
172
325
  * @param {boolean} [options.verbose] - Enable verbose logging
173
326
  * @returns {Promise<{success: boolean, sessionId: string, output: string, error?: string, warning?: string}>}
174
327
  */
@@ -201,47 +354,40 @@ export async function executeWithIsolation(command, args, options = {}) {
201
354
  console.log(`[VERBOSE] isolation-runner: Backend: ${backend}, Session ID: ${sessionId}`);
202
355
  }
203
356
 
204
- // Build arguments as array for the $ CLI:
205
- // $ --isolated <backend> --detached --session <sessionId> -- <command> <args...>
206
- const argsStr = args.join(' ');
357
+ const startCommandArgs = buildStartCommandArgs(command, args, { ...options, sessionId });
207
358
 
208
359
  if (verbose) {
209
- console.log(`[VERBOSE] isolation-runner: $ --isolated ${backend} --detached --session ${sessionId} -- ${command} ${argsStr}`);
360
+ console.log(`[VERBOSE] isolation-runner: ${[binPath, ...startCommandArgs].map(shellQuote).join(' ')}`);
361
+ if (backend === 'docker') {
362
+ const image = getDockerIsolationImage({ env: options.env || process.env });
363
+ const mounts = getDockerIsolationAuthMounts({ tool: options.tool, env: options.env || process.env, homeDir: options.homeDir || os.homedir(), existsSync: options.existsSync || fs.existsSync });
364
+ console.log(`[VERBOSE] isolation-runner: Docker isolation image: ${image}`);
365
+ console.log(`[VERBOSE] isolation-runner: Docker isolation mounts: ${mounts.map(m => m.target).join(', ') || '(none)'}`);
366
+ }
210
367
  }
211
368
 
212
- try {
213
- const result = await $({ mirror: false })`${binPath} --isolated ${backend} --detached --session ${sessionId} -- ${command} ${argsStr}`;
214
-
215
- const stdout = result.stdout?.toString() || '';
216
- const stderr = result.stderr?.toString() || '';
217
- const output = stdout + (stderr ? '\n' + stderr : '');
369
+ const result = await runStartCommand(binPath, startCommandArgs);
218
370
 
219
- if (verbose) {
220
- console.log(`[VERBOSE] isolation-runner: Output: ${output.substring(0, 500)}`);
221
- }
371
+ if (verbose) {
372
+ const stream = result.success ? console.log : console.error;
373
+ stream(`[VERBOSE] isolation-runner: Output: ${result.output.substring(0, 500)}`);
374
+ if (result.error) stream(`[VERBOSE] isolation-runner: Error: ${result.error}`);
375
+ }
222
376
 
377
+ if (result.success) {
223
378
  return {
224
379
  success: true,
225
380
  sessionId,
226
- output: output.trim(),
227
- };
228
- } catch (error) {
229
- const stdout = error.stdout?.toString() || '';
230
- const stderr = error.stderr?.toString() || '';
231
- const output = stdout + stderr;
232
-
233
- if (verbose) {
234
- console.error(`[VERBOSE] isolation-runner: Error: ${error.message}`);
235
- console.error(`[VERBOSE] isolation-runner: Output: ${output.substring(0, 500)}`);
236
- }
237
-
238
- return {
239
- success: false,
240
- sessionId,
241
- output: output.trim(),
242
- error: error.message,
381
+ output: result.output,
243
382
  };
244
383
  }
384
+
385
+ return {
386
+ success: false,
387
+ sessionId,
388
+ output: result.output,
389
+ error: result.error,
390
+ };
245
391
  }
246
392
 
247
393
  /**
@@ -378,12 +524,14 @@ export async function isSessionRunning(sessionId, options = {}) {
378
524
  }
379
525
  }
380
526
 
381
- // Fallback: for screen backend, check screen -ls directly.
527
+ // Fallback: for screen-backed sessions, check screen -ls directly.
528
+ // Docker isolation is also tracked through a screen wrapper so Hive Mind can
529
+ // control image selection and credential mounts while preserving logs/status.
382
530
  // Only use this when $ --status has no usable record. This works around
383
531
  // older start-command bugs where:
384
532
  // 1. $ --status can't find session by --session name (only by internal UUID)
385
533
  // See: https://github.com/link-assistant/hive-mind/issues/1545
386
- if (backend === 'screen' && shouldFallbackToScreenStatus(result)) {
534
+ if ((backend === 'screen' || backend === 'docker') && shouldFallbackToScreenStatus(result)) {
387
535
  const screenRunning = await checkScreenSessionRunning(sessionId, verbose);
388
536
  if (screenRunning && verbose) {
389
537
  console.log(`[VERBOSE] isolation-runner: $ --status says not running, but screen -ls confirms session '${sessionId}' is still active`);
@@ -115,7 +115,7 @@ export function buildExecuteAndUpdateMessage(deps) {
115
115
  if (iso) {
116
116
  session = iso.runner.generateSessionId();
117
117
  VERBOSE && console.log(`[VERBOSE] Using isolation (${iso.backend}), session: ${session}`);
118
- result = await iso.runner.executeWithIsolation(commandName, args, { backend: iso.backend, sessionId: session, verbose: VERBOSE });
118
+ result = await iso.runner.executeWithIsolation(commandName, args, { backend: iso.backend, sessionId: session, tool, verbose: VERBOSE });
119
119
  if (result.success) {
120
120
  sessionInfo = { ...baseSessionInfo, isolationBackend: iso.backend, sessionId: session };
121
121
  trackSession(session, sessionInfo, VERBOSE);
@@ -79,7 +79,8 @@ export function createIsolationAwareQueueCallback(botIsolationBackend, botIsolat
79
79
  const iso = await resolveIsolation(item.perCommandIsolation, botIsolationBackend, botIsolationRunner, verbose);
80
80
  if (iso) {
81
81
  const sid = iso.runner.generateSessionId();
82
- const r = await iso.runner.executeWithIsolation(item.command || 'solve', item.args, { backend: iso.backend, sessionId: sid, verbose });
82
+ const tool = item.tool || 'claude';
83
+ const r = await iso.runner.executeWithIsolation(item.command || 'solve', item.args, { backend: iso.backend, sessionId: sid, tool, verbose });
83
84
  if (r.success)
84
85
  trackSession(
85
86
  sid,
@@ -91,7 +92,7 @@ export function createIsolationAwareQueueCallback(botIsolationBackend, botIsolat
91
92
  command: item.command || 'solve',
92
93
  isolationBackend: iso.backend,
93
94
  sessionId: sid,
94
- tool: item.tool || 'claude',
95
+ tool,
95
96
  infoBlock: item.infoBlock,
96
97
  // Issue #1688: propagate URL context + requester through the queue so the
97
98
  // completion notification can append a 'Pull request:' line and skip
@@ -109,8 +109,9 @@ export function resolveLogPath({ statusResult, isolationBackend }) {
109
109
  if (statusResult?.logPath) return statusResult.logPath;
110
110
  const uuid = statusResult?.uuid;
111
111
  if (!uuid) return null;
112
- if (isolationBackend && ISOLATION_BACKENDS.has(isolationBackend)) {
113
- return path.join('/tmp/start-command/logs/isolation', isolationBackend, `${uuid}.log`);
112
+ const logBackend = statusResult?.isolation || isolationBackend;
113
+ if (logBackend && ISOLATION_BACKENDS.has(logBackend)) {
114
+ return path.join('/tmp/start-command/logs/isolation', logBackend, `${uuid}.log`);
114
115
  }
115
116
  return path.join('/tmp/start-command/logs/direct', `${uuid}.log`);
116
117
  }