loom-agent 1.2.37 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,576 @@
1
+ // Docker sandbox — a lightweight, isolated Linux OS the agent can use ON
2
+ // DEMAND. It is NOT a routing layer: host commands (bash tool) keep running
3
+ // on the user's machine exactly as before. When the agent needs isolation
4
+ // (user asked for the sandbox, a risky command, a Linux-only tool), it calls
5
+ // the dedicated `sandbox` tool, which runs that ONE command via `docker exec`
6
+ // into a shared idle-running container.
7
+ //
8
+ // Flow (guided in the TUI via the /sandbox modal):
9
+ // 1. Step 1 checks Docker. If missing, the modal shows the download link
10
+ // (installLink) + "open download page", sets config.sandbox.pending,
11
+ // and the user can later say "docker is ready" or run /sandbox again.
12
+ // 2. Step 2 picks an OS from OS_CATALOG (alpine/debian/ubuntu — all
13
+ // node images, ~250–900 MB). Step 3 pulls it, creates the single
14
+ // GLOBAL loom-sandbox container with the PROJECT mounted at /workspace,
15
+ // installs essentials (+ headless chromium when offered, best-effort)
16
+ // and keeps the container idle-running in the background.
17
+ // 3. With sandbox.enabled, the sandbox TOOL becomes available. Each call
18
+ // revives the container if it stopped (e.g. after a Docker restart) and
19
+ // re-mounts the current project, then runs just that command. File
20
+ // tools stay on the host — the mount is the same directory, so reads
21
+ // and writes are shared while side effects of the command stay contained.
22
+ //
23
+ // Security posture: no --privileged, no host network, only the project dir
24
+ // is mounted (rw), container runs as the image's default non-root user when
25
+ // possible. It is a blast-radius reducer, not a hostile-VM boundary.
26
+ const { spawn, execFileSync } = require('child_process');
27
+ const path = require('path');
28
+ const { loadConfig, saveConfig } = require('../config/settings');
29
+
30
+ const CONTAINER = 'loom-sandbox';
31
+ const DEFAULT_IMAGE = 'node:20-alpine';
32
+ // Essentials installed on first setup: a POSIX shell with the tools the
33
+ // agent actually reaches for. node is already in the image. Chromium is a
34
+ // separate best-effort install (see setupSandbox).
35
+ const BASE_PACKAGES = 'git bash curl ripgrep coreutils';
36
+
37
+ /**
38
+ * OS catalog offered by the /sandbox modal. All are lightweight, rootless-
39
+ * friendly Linux userlands; the agent gets a full shell + headless browser
40
+ * inside whichever it picks. `pkg` selects the package manager path.
41
+ * @type {Array<{ id: string, label: string, image: string, sizeNote: string, pkg: 'apk'|'apt', browser: boolean }>}
42
+ */
43
+ const OS_CATALOG = [
44
+ { id: 'alpine', label: 'Alpine Linux', image: 'node:20-alpine', sizeNote: 'smallest & fastest (~250 MB)', pkg: 'apk', browser: true },
45
+ { id: 'debian', label: 'Debian Slim', image: 'node:20-slim', sizeNote: 'broad binary compatibility (~600 MB)', pkg: 'apt', browser: true },
46
+ { id: 'ubuntu', label: 'Ubuntu', image: 'node:20', sizeNote: 'full userland (~900 MB)', pkg: 'apt', browser: false },
47
+ ];
48
+
49
+ /** @typedef {'not_installed'|'no_daemon'|'ready'} DockerState */
50
+
51
+ /**
52
+ * Detect the Docker CLI + daemon. Injectable runner for tests.
53
+ * @param {(cmd: string, args: string[]) => string} [runner]
54
+ * @returns {{ state: DockerState, version: string }}
55
+ */
56
+ function detectDocker(runner) {
57
+ const run = runner || ((cmd, args) => execFileSync(cmd, args, {
58
+ encoding: 'utf8', windowsHide: true, timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'],
59
+ }).trim());
60
+ let version = '';
61
+ try {
62
+ version = run('docker', ['version', '--format', '{{.Server.Version}}']);
63
+ } catch {
64
+ // CLI missing entirely vs daemon down: `docker version` errors on both,
65
+ // but `docker --version` still succeeds when only the daemon is down.
66
+ try {
67
+ run('docker', ['--version']);
68
+ return { state: 'no_daemon', version: '' };
69
+ } catch {
70
+ return { state: 'not_installed', version: '' };
71
+ }
72
+ }
73
+ if (!version) return { state: 'no_daemon', version: '' };
74
+ return { state: 'ready', version };
75
+ }
76
+
77
+ /**
78
+ * Platform-appropriate Docker install guide shown before the user leaves to
79
+ * install. Mentions the restart when the installer requires one, and the
80
+ * exact phrase that resumes setup after they come back.
81
+ * @returns {string}
82
+ */
83
+ function installGuide() {
84
+ const plat = process.platform;
85
+ const link = installLink();
86
+ const steps = [];
87
+ if (plat === 'win32') {
88
+ steps.push('1. Download Docker Desktop for Windows: ' + link);
89
+ steps.push('2. Run the installer. If it asks to enable WSL 2 / restart Windows — accept and RESTART your computer when it finishes.');
90
+ steps.push('3. Start Docker Desktop and wait for it to say "running" in the tray (first start can take a minute).');
91
+ } else if (plat === 'darwin') {
92
+ steps.push('1. Download Docker Desktop for Mac: ' + link + ' (Apple chip or Intel — pick yours).');
93
+ steps.push('2. Drag it to Applications and open it. Accept the terms; if macOS asks for a restart of privileges, let it.');
94
+ steps.push('3. Wait for the whale icon to settle — Docker is ready when the dashboard shows "running".');
95
+ } else {
96
+ steps.push('1. Install Docker Engine: ' + link + ' (Ubuntu: `curl -fsSL https://get.docker.com | sh` then `sudo usermod -aG docker $USER` and log out/in).');
97
+ steps.push('2. Start it: `sudo systemctl enable --now docker`.');
98
+ }
99
+ steps.push('4. Come back to THIS session and say: docker is ready');
100
+ steps.push(' (or run /sandbox setup). Loom will build the sandbox automatically.');
101
+ return [
102
+ 'Docker is not set up yet — install it once and the sandbox becomes permanent.',
103
+ '',
104
+ ...steps,
105
+ '',
106
+ 'Your session is kept: nothing is lost while you install. Say "docker is ready" when done.',
107
+ ].join('\n');
108
+ }
109
+
110
+ /**
111
+ * Download page for the current platform (shown in the /sandbox modal).
112
+ * @returns {string}
113
+ */
114
+ function installLink() {
115
+ if (process.platform === 'win32') return 'https://docs.docker.com/desktop/setup/install/windows-install/';
116
+ if (process.platform === 'darwin') return 'https://docs.docker.com/desktop/setup/install/mac-install/';
117
+ return 'https://docs.docker.com/engine/install/';
118
+ }
119
+
120
+ /**
121
+ * Try to open the Docker download page in the user's default browser.
122
+ * Best-effort — the URL is always printed as a fallback.
123
+ * @returns {boolean} true if a browser spawn was attempted
124
+ */
125
+ function openInstallPage() {
126
+ const url = installLink();
127
+ const { spawn } = require('child_process');
128
+ try {
129
+ if (process.platform === 'win32') spawn('cmd', ['/c', 'start', '', url], { windowsHide: true, detached: true }).unref();
130
+ else if (process.platform === 'darwin') spawn('open', [url], { detached: true }).unref();
131
+ else spawn('xdg-open', [url], { detached: true }).unref();
132
+ return true;
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Current sandbox lifecycle state for display + gating.
140
+ * @returns {{ docker: DockerState, dockerVersion: string, enabled: boolean, pending: boolean, container: 'running'|'stopped'|'absent', image: string, osId: string, projectRoot: string }}
141
+ */
142
+ function sandboxStatus() {
143
+ const cfg = loadConfig();
144
+ const sandboxCfg = cfg.sandbox || {};
145
+ const { state, version } = detectDocker();
146
+ let container = /** @type {'running'|'stopped'|'absent'} */ ('absent');
147
+ if (state === 'ready') {
148
+ try {
149
+ const out = execFileSync('docker', ['inspect', '-f', '{{.State.Running}}', CONTAINER],
150
+ { encoding: 'utf8', windowsHide: true, timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
151
+ container = out === 'true' ? 'running' : 'stopped';
152
+ } catch {
153
+ container = 'absent';
154
+ }
155
+ }
156
+ return {
157
+ docker: state,
158
+ dockerVersion: version,
159
+ enabled: !!sandboxCfg.enabled && container === 'running',
160
+ pending: !!sandboxCfg.pending,
161
+ container,
162
+ image: sandboxCfg.image || DEFAULT_IMAGE,
163
+ osId: sandboxCfg.osId || 'alpine',
164
+ projectRoot: sandboxCfg.projectRoot || '',
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Map a host working directory to its path inside the container. The project
170
+ * root is mounted at /workspace; subdirectories map underneath it. Anything
171
+ * OUTSIDE the project falls back to /workspace (the container has no other
172
+ * mounts by design).
173
+ * @param {string} [hostDir]
174
+ * @param {string} [projectRoot]
175
+ * @returns {string}
176
+ */
177
+ function toSandboxPath(hostDir, projectRoot) {
178
+ const root = path.resolve(projectRoot || process.cwd());
179
+ const dir = path.resolve(hostDir || root);
180
+ const rel = path.relative(root, dir);
181
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return '/workspace';
182
+ return '/workspace/' + rel.split(path.sep).join('/');
183
+ }
184
+
185
+ /**
186
+ * Run ONE shell command inside the sandbox container (used by the sandbox
187
+ * tool — host commands stay on the bash tool). Revives the container if it
188
+ * stopped and re-mounts the current project, then streams output via
189
+ * onChunk('out'|'err'); resolves with { output, code } or { error }.
190
+ * @param {string} command
191
+ * @param {{ workdir?: string, projectRoot?: string, timeoutMs?: number, onChunk?: (text: string, stream: 'out'|'err') => void }} [opts]
192
+ * @returns {Promise<{ output: string, code: number }|{ error: string }>}
193
+ */
194
+ async function runInSandbox(command, opts) {
195
+ const o = opts || {};
196
+ // Bring the container back if the daemon restarted, then make sure it
197
+ // mounts the CURRENT project (cheap no-op when both already match).
198
+ const alive = await ensureContainerAlive();
199
+ if (!alive.ok) return { error: alive.error };
200
+ const remount = await ensureProjectMount(o.projectRoot);
201
+ if (!remount.ok) return { error: 'sandbox re-mount failed: ' + remount.error };
202
+ const wd = toSandboxPath(o.workdir, o.projectRoot);
203
+ const timeoutMs = o.timeoutMs || 120000;
204
+ return new Promise((resolve) => {
205
+ let child;
206
+ try {
207
+ // sh -lc keeps pipes/redirections working; -i is not needed because
208
+ // every exec is a fresh non-interactive shell.
209
+ child = spawn('docker', ['exec', '-w', wd, CONTAINER, 'sh', '-lc', String(command || '')],
210
+ { windowsHide: true });
211
+ } catch (e) {
212
+ resolve({ error: 'sandbox exec failed: ' + (e && e.message) });
213
+ return;
214
+ }
215
+ const timer = setTimeout(() => {
216
+ try { child.kill('SIGKILL'); } catch {}
217
+ }, timeoutMs);
218
+ let output = '';
219
+ const MAX_OUT = 10 * 1024 * 1024;
220
+ let truncated = false;
221
+ const push = (/** @type {Buffer} */ d, /** @type {'out'|'err'} */ stream) => {
222
+ const t = d.toString();
223
+ if (!truncated) {
224
+ if (output.length + t.length > MAX_OUT) { output = output.slice(0, MAX_OUT); truncated = true; }
225
+ else output += t;
226
+ }
227
+ if (o.onChunk) { try { o.onChunk(t, stream); } catch {} }
228
+ };
229
+ child.stdout.on('data', (d) => push(d, 'out'));
230
+ child.stderr.on('data', (d) => push(d, 'err'));
231
+ child.on('close', (code) => {
232
+ clearTimeout(timer);
233
+ if (truncated) output += '\n[output truncated at 10 MB]';
234
+ resolve({ output: output || 'command completed', code: code == null ? 1 : code });
235
+ });
236
+ child.on('error', (err) => {
237
+ clearTimeout(timer);
238
+ resolve({ error: 'sandbox exec failed: ' + err.message });
239
+ });
240
+ });
241
+ }
242
+
243
+ /**
244
+ * Spawn a host command, line-buffer its output, never block the event loop.
245
+ * Used by setupSandbox so the TUI keeps rendering (and the progress UI keeps
246
+ * updating) while docker pull / package installs run.
247
+ * @param {string} cmd
248
+ * @param {string[]} args
249
+ * @param {{ timeoutMs?: number, onLine?: (line: string, stream: 'out'|'err') => void }} [opts]
250
+ * @returns {Promise<{ code: number, out: string }>}
251
+ */
252
+ function spawnAsync(cmd, args, opts) {
253
+ const o = opts || {};
254
+ return new Promise((resolve) => {
255
+ let child;
256
+ try {
257
+ child = spawn(cmd, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
258
+ } catch (e) {
259
+ resolve({ code: -1, out: (e && e.message) || String(e) });
260
+ return;
261
+ }
262
+ const timer = o.timeoutMs
263
+ ? setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, o.timeoutMs)
264
+ : null;
265
+ let out = '';
266
+ const MAX_OUT = 4 * 1024 * 1024;
267
+ const feed = (/** @type {Buffer} */ d, /** @type {'out'|'err'} */ stream) => {
268
+ const t = d.toString();
269
+ if (out.length < MAX_OUT) out += t;
270
+ if (!o.onLine) return;
271
+ // Line-buffer: docker writes progress with \r on TTYs and \n otherwise;
272
+ // treat both as separators so the UI gets fresh rows, not one blob.
273
+ const lines = t.split(/\r?\n/);
274
+ for (const line of lines) {
275
+ if (!line) continue;
276
+ try { o.onLine(line.replace(/\x1b\[[0-9;]*m/g, ''), stream); } catch {}
277
+ }
278
+ };
279
+ child.stdout.on('data', (d) => feed(d, 'out'));
280
+ child.stderr.on('data', (d) => feed(d, 'err'));
281
+ child.on('error', (err) => {
282
+ if (timer) clearTimeout(timer);
283
+ resolve({ code: -1, out: (out ? out + '\n' : '') + err.message });
284
+ });
285
+ child.on('close', (code) => {
286
+ if (timer) clearTimeout(timer);
287
+ resolve({ code: code == null ? 1 : code, out });
288
+ });
289
+ });
290
+ }
291
+
292
+ // "12.34MB" / "951kB" / "1.2GB" → bytes (docker prints SI-style units).
293
+ function parseBytes(str, unit) {
294
+ const n = parseFloat(str);
295
+ if (!isFinite(n)) return 0;
296
+ const u = String(unit || '').toLowerCase();
297
+ if (u === 'gb') return n * 1e9;
298
+ if (u === 'mb') return n * 1e6;
299
+ if (u === 'kb') return n * 1e3;
300
+ return n;
301
+ }
302
+
303
+ /**
304
+ * Fold one docker-pull stdout line into the per-layer progress map. Layers
305
+ * report "Downloading [bar] X/Y", "Extracting [bar] X/Y", then "Pull complete".
306
+ * @param {string} line
307
+ * @param {Map<string, number>} layers layer-id → fraction 0..1
308
+ */
309
+ function trackPullLine(line, layers) {
310
+ const done = /^([0-9a-f]{8,64}):\s+(?:Download complete|Pull complete)\s*$/i.exec(line);
311
+ if (done) { layers.set(done[1], 1); return; }
312
+ // "Waiting" = queued layer, no bytes yet.
313
+ const waiting = /^([0-9a-f]{8,64}):\s+Waiting\s*$/i.exec(line);
314
+ if (waiting) { layers.set(waiting[1], 0); return; }
315
+ const active = /^([0-9a-f]{8,64}):\s+(?:Downloading|Extracting)\s+(?:\[[^\]]*\]\s+)?([\d.]+)([kmgt]?b)(?:\/([\d.]+)([kmgt]?b))?/i.exec(line);
316
+ if (active) {
317
+ const id = active[1];
318
+ if (active[4]) {
319
+ const total = parseBytes(active[4], active[5]);
320
+ const cur = parseBytes(active[2], active[3]);
321
+ if (total > 0) layers.set(id, Math.min(1, cur / total));
322
+ } else if (!layers.has(id)) {
323
+ layers.set(id, 0);
324
+ }
325
+ }
326
+ }
327
+
328
+ function pullPercent(layers) {
329
+ if (!layers.size) return null;
330
+ let sum = 0;
331
+ for (const frac of layers.values()) sum += frac;
332
+ return Math.min(99, Math.round((sum / layers.size) * 100));
333
+ }
334
+
335
+ /**
336
+ * Build the sandbox: pull the chosen OS image, (re)create the container with
337
+ * the project mounted, install essentials (+ headless chromium when the OS
338
+ * offers it). Fully ASYNC — every docker call streams, so the TUI never
339
+ * freezes and onProgress/onLog deliver live updates. The container is a
340
+ * single GLOBAL resource (~/.loom/config.json records it), so every session and
341
+ * every agent shares the same sandbox; only the mounted project follows the
342
+ * working directory via ensureProjectMount().
343
+ * @param {{ osId?: string, projectRoot?: string, onLog?: (line: string) => void, onProgress?: (p: { stage: string, pct: number|null, step: number, total: number }) => void }} [opts]
344
+ * @returns {Promise<{ ok: true, container: string }|{ ok: false, error: string }>}
345
+ */
346
+ async function setupSandbox(opts) {
347
+ const o = opts || {};
348
+ const root = path.resolve(o.projectRoot || process.cwd());
349
+ const log = o.onLog || (() => {});
350
+ const prog = o.onProgress || (() => {});
351
+ const { state, version } = detectDocker();
352
+ if (state === 'not_installed') return { ok: false, error: 'Docker is not installed. Run /sandbox guide for setup steps.' };
353
+ if (state === 'no_daemon') {
354
+ return { ok: false, error: 'Docker is installed but the daemon is not running. Start Docker Desktop (or `sudo systemctl start docker`), then run /sandbox setup again.' };
355
+ }
356
+ const osEntry = OS_CATALOG.find((x) => x.id === o.osId) || OS_CATALOG[0];
357
+ const image = osEntry.image;
358
+ log('Docker ' + version + ' detected — using ' + osEntry.label + ' (' + image + ').');
359
+ // Stages reported to onProgress: pull → create → start → essentials → browser → done.
360
+ const stages = ['pull', 'create', 'start', 'essentials'].concat(osEntry.browser ? ['browser'] : [], ['done']);
361
+ const stageIdx = (/** @type {string} */ s) => { const i = stages.indexOf(s); return i < 0 ? 0 : i; };
362
+ prog({ stage: 'pull', pct: 0, step: stageIdx('pull'), total: stages.length });
363
+
364
+ // ── Stage 1: pull the image (real per-layer progress, async + streamed) ──
365
+ log('Pulling ' + image + ' (first run only)…');
366
+ const layers = new Map();
367
+ let lastPct = -1;
368
+ const pull = await spawnAsync('docker', ['pull', image], {
369
+ timeoutMs: 600000,
370
+ onLine: (line) => {
371
+ trackPullLine(line, layers);
372
+ const pct = pullPercent(layers);
373
+ if (pct != null && pct !== lastPct) {
374
+ lastPct = pct;
375
+ prog({ stage: 'pull', pct, step: stageIdx('pull'), total: stages.length });
376
+ }
377
+ if (/^status:|^digest:|up to date/i.test(line)) log(line);
378
+ },
379
+ });
380
+ if (pull.code !== 0) {
381
+ return { ok: false, error: 'docker pull failed (exit ' + pull.code + '): ' + pull.out.slice(-400) };
382
+ }
383
+ prog({ stage: 'pull', pct: 100, step: stageIdx('pull'), total: stages.length });
384
+
385
+ // ── Stage 2: recreate the container from scratch (image updates + mount) ──
386
+ prog({ stage: 'create', pct: null, step: stageIdx('create'), total: stages.length });
387
+ await spawnAsync('docker', ['rm', '-f', CONTAINER], { timeoutMs: 30000 });
388
+ log('Creating container ' + CONTAINER + ' with ' + root + ' mounted at /workspace…');
389
+ const create = await spawnAsync('docker', [
390
+ 'create',
391
+ '--name', CONTAINER,
392
+ // Isolation: project dir only, no host network, no extra privileges.
393
+ '-v', root + ':/workspace',
394
+ '-w', '/workspace',
395
+ // Idle-forever main process; every agent command is a docker exec.
396
+ image, 'sleep', 'infinity',
397
+ ], { timeoutMs: 60000 });
398
+ if (create.code !== 0) {
399
+ return { ok: false, error: 'docker create failed (exit ' + create.code + '): ' + create.out.slice(-300) };
400
+ }
401
+
402
+ // ── Stage 3: start it (stays idle in the background until the sandbox
403
+ // tool is explicitly invoked) ──
404
+ prog({ stage: 'start', pct: null, step: stageIdx('start'), total: stages.length });
405
+ const start = await spawnAsync('docker', ['start', CONTAINER], { timeoutMs: 60000 });
406
+ if (start.code !== 0) {
407
+ return { ok: false, error: 'docker start failed (exit ' + start.code + '): ' + start.out.slice(-300) };
408
+ }
409
+
410
+ // ── Stage 4: install essentials (streamed so the UI shows life) ──
411
+ prog({ stage: 'essentials', pct: null, step: stageIdx('essentials'), total: stages.length });
412
+ const pkgCmd = osEntry.pkg === 'apk'
413
+ ? 'apk add --no-cache ' + BASE_PACKAGES
414
+ : 'export DEBIAN_FRONTEND=noninteractive; apt-get update -qq && apt-get install -y -qq --no-install-recommends ' + BASE_PACKAGES + ' && rm -rf /var/lib/apt/lists/*';
415
+ log('Installing essentials (' + BASE_PACKAGES + ')…');
416
+ const install = await spawnAsync('docker', ['exec', CONTAINER, 'sh', '-lc', pkgCmd], {
417
+ timeoutMs: 600000,
418
+ onLine: (line) => { if (!/^\s*$/.test(line)) log(line); },
419
+ });
420
+ if (install.code !== 0) {
421
+ return { ok: false, error: 'package install failed (exit ' + install.code + '): ' + install.out.slice(-400) };
422
+ }
423
+
424
+ // ── Stage 5: headless browser when the OS offers one — best-effort, so a
425
+ // missing chromium package never fails the whole setup. ──
426
+ if (osEntry.browser) {
427
+ prog({ stage: 'browser', pct: null, step: stageIdx('browser'), total: stages.length });
428
+ const browserCmd = osEntry.pkg === 'apk'
429
+ ? 'apk add --no-cache chromium'
430
+ : 'export DEBIAN_FRONTEND=noninteractive; apt-get update -qq && apt-get install -y -qq --no-install-recommends chromium && rm -rf /var/lib/apt/lists/*';
431
+ log('Installing a headless Chromium (best-effort)…');
432
+ const browser = await spawnAsync('docker', ['exec', CONTAINER, 'sh', '-lc', browserCmd], {
433
+ timeoutMs: 600000,
434
+ onLine: (line) => { if (!/^\s*$/.test(line)) log(line); },
435
+ });
436
+ if (browser.code !== 0) {
437
+ log('Chromium unavailable in this image — browser tooling will be limited (sandbox continues).');
438
+ }
439
+ }
440
+
441
+ prog({ stage: 'done', pct: 100, step: stageIdx('done'), total: stages.length });
442
+ const cfg = loadConfig();
443
+ cfg.sandbox = Object.assign({}, cfg.sandbox, {
444
+ enabled: true, pending: false, image, osId: osEntry.id, projectRoot: root,
445
+ });
446
+ saveConfig(cfg);
447
+ log('Sandbox ready' + (osEntry.browser ? ' — including a headless Chromium for the browser tool' : '') + '.');
448
+ log('Container ' + CONTAINER + ' idles in the background; the sandbox tool uses it only when the agent needs a Linux environment.');
449
+ return { ok: true, container: CONTAINER };
450
+ }
451
+
452
+ /**
453
+ * Make sure the shared container mounts the CURRENT project. Sessions in
454
+ * different directories share one sandbox — when the active project changes
455
+ * we just re-create the container with the new mount (image stays cached).
456
+ * Cheap no-op when the mount already matches. Fully async (never blocks the
457
+ * TUI event loop).
458
+ * @param {string} [projectRoot]
459
+ * @returns {Promise<{ ok: true }|{ ok: false, error: string }>}
460
+ */
461
+ async function ensureProjectMount(projectRoot) {
462
+ const root = path.resolve(projectRoot || process.cwd());
463
+ const cfg = loadConfig();
464
+ if (!cfg.sandbox || !cfg.sandbox.enabled) return { ok: false, error: 'sandbox disabled' };
465
+ if (cfg.sandbox.projectRoot === root) return { ok: true };
466
+ const image = cfg.sandbox.image || DEFAULT_IMAGE;
467
+ try {
468
+ await spawnAsync('docker', ['rm', '-f', CONTAINER], { timeoutMs: 30000 });
469
+ const create = await spawnAsync('docker', [
470
+ 'create', '--name', CONTAINER,
471
+ '-v', root + ':/workspace', '-w', '/workspace',
472
+ image, 'sleep', 'infinity',
473
+ ], { timeoutMs: 60000 });
474
+ if (create.code !== 0) return { ok: false, error: 're-mount create failed: ' + create.out.slice(-200) };
475
+ const start = await spawnAsync('docker', ['start', CONTAINER], { timeoutMs: 60000 });
476
+ if (start.code !== 0) return { ok: false, error: 're-mount start failed: ' + start.out.slice(-200) };
477
+ cfg.sandbox.projectRoot = root;
478
+ saveConfig(cfg);
479
+ return { ok: true };
480
+ } catch (e) {
481
+ return { ok: false, error: 're-mount failed: ' + (e && e.message ? e.message : String(e)) };
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Make sure the container exists and is RUNNING before a sandbox-tool call.
487
+ * Revives a stopped container (Docker Desktop restart, reboot) with a quick
488
+ * `docker start`; never recreates (image/mount are setup-time concerns).
489
+ * @returns {Promise<{ ok: true }|{ ok: false, error: string }>}
490
+ */
491
+ async function ensureContainerAlive() {
492
+ const cfg = loadConfig();
493
+ if (!cfg.sandbox || !cfg.sandbox.enabled) {
494
+ return { ok: false, error: 'Sandbox is not set up. Run /sandbox (or /sandbox setup) first — it only takes a minute and is needed once.' };
495
+ }
496
+ if (process.env.LOOM_SANDBOX === '0') {
497
+ return { ok: false, error: 'Sandbox disabled via LOOM_SANDBOX=0.' };
498
+ }
499
+ const inspect = await spawnAsync('docker', ['inspect', '-f', '{{.State.Running}}', CONTAINER], { timeoutMs: 15000 });
500
+ if (inspect.code !== 0 || !inspect.out.trim()) {
501
+ return { ok: false, error: 'Sandbox container is missing (deleted?). Run /sandbox setup again.' };
502
+ }
503
+ if (inspect.out.trim() === 'true') return { ok: true };
504
+ const start = await spawnAsync('docker', ['start', CONTAINER], { timeoutMs: 60000 });
505
+ if (start.code !== 0) {
506
+ return { ok: false, error: 'Sandbox container exists but could not be started: ' + start.out.slice(-200) + '. If Docker was restarted, start it and try again.' };
507
+ }
508
+ return { ok: true };
509
+ }
510
+
511
+ /**
512
+ * Is the sandbox TOOL available (set up and not kill-switched)? Used by the
513
+ * sandbox tool to decide between running and a helpful "run /sandbox first"
514
+ * error — NOT to route ordinary bash commands (those stay on the host).
515
+ * @returns {boolean}
516
+ */
517
+ function shouldUseSandbox() {
518
+ if (process.env.LOOM_SANDBOX === '0') return false;
519
+ const cfg = loadConfig();
520
+ return !!(cfg.sandbox && cfg.sandbox.enabled);
521
+ }
522
+
523
+ /**
524
+ * Toggle sandbox availability (the container idles either way).
525
+ * @param {boolean} on
526
+ */
527
+ function setSandboxEnabled(on) {
528
+ const cfg = loadConfig();
529
+ cfg.sandbox = Object.assign({}, cfg.sandbox, { enabled: !!on });
530
+ saveConfig(cfg);
531
+ }
532
+
533
+ /**
534
+ * Arm the "waiting for Docker install" flow: the TUI shows the guide and
535
+ * tells the user to come back with "docker is ready".
536
+ */
537
+ function markInstallPending() {
538
+ const cfg = loadConfig();
539
+ cfg.sandbox = Object.assign({}, cfg.sandbox, { pending: true });
540
+ saveConfig(cfg);
541
+ }
542
+
543
+ /**
544
+ * True when the user's message is the "docker is ready" resume signal while
545
+ * an install is pending (or they explicitly ask to set up the sandbox).
546
+ * @param {string} text
547
+ * @returns {boolean}
548
+ */
549
+ function isReadySignal(text) {
550
+ const t = String(text || '').trim().toLowerCase();
551
+ if (!t || t.length > 120) return false;
552
+ if (t.endsWith('?')) return false; // a question ("is docker ready yet?") is not an announcement
553
+ return /\bdocker\b/.test(t) && /\b(ready|installed|done|running|set ?up|finished)\b/.test(t);
554
+ }
555
+
556
+ module.exports = {
557
+ CONTAINER,
558
+ DEFAULT_IMAGE,
559
+ OS_CATALOG,
560
+ detectDocker,
561
+ installGuide,
562
+ installLink,
563
+ openInstallPage,
564
+ sandboxStatus,
565
+ toSandboxPath,
566
+ runInSandbox,
567
+ setupSandbox,
568
+ ensureProjectMount,
569
+ ensureContainerAlive,
570
+ shouldUseSandbox,
571
+ trackPullLine,
572
+ pullPercent,
573
+ setSandboxEnabled,
574
+ markInstallPending,
575
+ isReadySignal,
576
+ };