loom-agent 1.2.37 → 1.3.2

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,400 @@
1
+ // Docker sandbox — a lightweight, isolated OS for the agent's shell commands.
2
+ //
3
+ // Flow (guided in the TUI via the /sandbox modal):
4
+ // 1. Step 1 checks Docker. If missing, the modal shows the download link
5
+ // (installLink) + "open download page", sets config.sandbox.pending,
6
+ // and the user can later say "docker is ready" or run /sandbox again.
7
+ // 2. Step 2 picks an OS from OS_CATALOG (alpine/debian/ubuntu — all
8
+ // node images, ~250–900 MB). Step 3 pulls it, creates the single
9
+ // GLOBAL loom-sandbox container with the PROJECT mounted at /workspace,
10
+ // installs essentials (+ headless chromium when offered, best-effort)
11
+ // and keeps the container idle-running.
12
+ // 3. With sandbox.enabled, the bash tool routes every command through
13
+ // `docker exec` into that container instead of the host shell. File
14
+ // tools stay on the host — the mount is the same directory, so reads
15
+ // and writes are shared while side effects of commands stay contained.
16
+ // ensureProjectMount() re-creates the container when the active project
17
+ // changes, so every session/agent shares one sandbox.
18
+ //
19
+ // Security posture: no --privileged, no host network, only the project dir
20
+ // is mounted (rw), container runs as the image's default non-root user when
21
+ // possible. It is a blast-radius reducer, not a hostile-VM boundary.
22
+ const { spawn, execFileSync } = require('child_process');
23
+ const path = require('path');
24
+ const { loadConfig, saveConfig } = require('../config/settings');
25
+
26
+ const CONTAINER = 'loom-sandbox';
27
+ const DEFAULT_IMAGE = 'node:20-alpine';
28
+ // Essentials installed on first setup: a POSIX shell with the tools the
29
+ // agent actually reaches for. node is already in the image. Chromium is a
30
+ // separate best-effort install (see setupSandbox).
31
+ const BASE_PACKAGES = 'git bash curl ripgrep coreutils';
32
+
33
+ /**
34
+ * OS catalog offered by the /sandbox modal. All are lightweight, rootless-
35
+ * friendly Linux userlands; the agent gets a full shell + headless browser
36
+ * inside whichever it picks. `pkg` selects the package manager path.
37
+ * @type {Array<{ id: string, label: string, image: string, sizeNote: string, pkg: 'apk'|'apt', browser: boolean }>}
38
+ */
39
+ const OS_CATALOG = [
40
+ { id: 'alpine', label: 'Alpine Linux', image: 'node:20-alpine', sizeNote: 'smallest & fastest (~250 MB)', pkg: 'apk', browser: true },
41
+ { id: 'debian', label: 'Debian Slim', image: 'node:20-slim', sizeNote: 'broad binary compatibility (~600 MB)', pkg: 'apt', browser: true },
42
+ { id: 'ubuntu', label: 'Ubuntu', image: 'node:20', sizeNote: 'full userland (~900 MB)', pkg: 'apt', browser: false },
43
+ ];
44
+
45
+ /** @typedef {'not_installed'|'no_daemon'|'ready'} DockerState */
46
+
47
+ /**
48
+ * Detect the Docker CLI + daemon. Injectable runner for tests.
49
+ * @param {(cmd: string, args: string[]) => string} [runner]
50
+ * @returns {{ state: DockerState, version: string }}
51
+ */
52
+ function detectDocker(runner) {
53
+ const run = runner || ((cmd, args) => execFileSync(cmd, args, {
54
+ encoding: 'utf8', windowsHide: true, timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'],
55
+ }).trim());
56
+ let version = '';
57
+ try {
58
+ version = run('docker', ['version', '--format', '{{.Server.Version}}']);
59
+ } catch {
60
+ // CLI missing entirely vs daemon down: `docker version` errors on both,
61
+ // but `docker --version` still succeeds when only the daemon is down.
62
+ try {
63
+ run('docker', ['--version']);
64
+ return { state: 'no_daemon', version: '' };
65
+ } catch {
66
+ return { state: 'not_installed', version: '' };
67
+ }
68
+ }
69
+ if (!version) return { state: 'no_daemon', version: '' };
70
+ return { state: 'ready', version };
71
+ }
72
+
73
+ /**
74
+ * Platform-appropriate Docker install guide shown before the user leaves to
75
+ * install. Mentions the restart when the installer requires one, and the
76
+ * exact phrase that resumes setup after they come back.
77
+ * @returns {string}
78
+ */
79
+ function installGuide() {
80
+ const plat = process.platform;
81
+ const link = installLink();
82
+ const steps = [];
83
+ if (plat === 'win32') {
84
+ steps.push('1. Download Docker Desktop for Windows: ' + link);
85
+ steps.push('2. Run the installer. If it asks to enable WSL 2 / restart Windows — accept and RESTART your computer when it finishes.');
86
+ steps.push('3. Start Docker Desktop and wait for it to say "running" in the tray (first start can take a minute).');
87
+ } else if (plat === 'darwin') {
88
+ steps.push('1. Download Docker Desktop for Mac: ' + link + ' (Apple chip or Intel — pick yours).');
89
+ steps.push('2. Drag it to Applications and open it. Accept the terms; if macOS asks for a restart of privileges, let it.');
90
+ steps.push('3. Wait for the whale icon to settle — Docker is ready when the dashboard shows "running".');
91
+ } else {
92
+ 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).');
93
+ steps.push('2. Start it: `sudo systemctl enable --now docker`.');
94
+ }
95
+ steps.push('4. Come back to THIS session and say: docker is ready');
96
+ steps.push(' (or run /sandbox setup). Loom will build the sandbox automatically.');
97
+ return [
98
+ 'Docker is not set up yet — install it once and the sandbox becomes permanent.',
99
+ '',
100
+ ...steps,
101
+ '',
102
+ 'Your session is kept: nothing is lost while you install. Say "docker is ready" when done.',
103
+ ].join('\n');
104
+ }
105
+
106
+ /**
107
+ * Download page for the current platform (shown in the /sandbox modal).
108
+ * @returns {string}
109
+ */
110
+ function installLink() {
111
+ if (process.platform === 'win32') return 'https://docs.docker.com/desktop/setup/install/windows-install/';
112
+ if (process.platform === 'darwin') return 'https://docs.docker.com/desktop/setup/install/mac-install/';
113
+ return 'https://docs.docker.com/engine/install/';
114
+ }
115
+
116
+ /**
117
+ * Try to open the Docker download page in the user's default browser.
118
+ * Best-effort — the URL is always printed as a fallback.
119
+ * @returns {boolean} true if a browser spawn was attempted
120
+ */
121
+ function openInstallPage() {
122
+ const url = installLink();
123
+ const { spawn } = require('child_process');
124
+ try {
125
+ if (process.platform === 'win32') spawn('cmd', ['/c', 'start', '', url], { windowsHide: true, detached: true }).unref();
126
+ else if (process.platform === 'darwin') spawn('open', [url], { detached: true }).unref();
127
+ else spawn('xdg-open', [url], { detached: true }).unref();
128
+ return true;
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Current sandbox lifecycle state for display + gating.
136
+ * @returns {{ docker: DockerState, dockerVersion: string, enabled: boolean, pending: boolean, container: 'running'|'stopped'|'absent', image: string, osId: string, projectRoot: string }}
137
+ */
138
+ function sandboxStatus() {
139
+ const cfg = loadConfig();
140
+ const sandboxCfg = cfg.sandbox || {};
141
+ const { state, version } = detectDocker();
142
+ let container = /** @type {'running'|'stopped'|'absent'} */ ('absent');
143
+ if (state === 'ready') {
144
+ try {
145
+ const out = execFileSync('docker', ['inspect', '-f', '{{.State.Running}}', CONTAINER],
146
+ { encoding: 'utf8', windowsHide: true, timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
147
+ container = out === 'true' ? 'running' : 'stopped';
148
+ } catch {
149
+ container = 'absent';
150
+ }
151
+ }
152
+ return {
153
+ docker: state,
154
+ dockerVersion: version,
155
+ enabled: !!sandboxCfg.enabled && container === 'running',
156
+ pending: !!sandboxCfg.pending,
157
+ container,
158
+ image: sandboxCfg.image || DEFAULT_IMAGE,
159
+ osId: sandboxCfg.osId || 'alpine',
160
+ projectRoot: sandboxCfg.projectRoot || '',
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Map a host working directory to its path inside the container. The project
166
+ * root is mounted at /workspace; subdirectories map underneath it. Anything
167
+ * OUTSIDE the project falls back to /workspace (the container has no other
168
+ * mounts by design).
169
+ * @param {string} [hostDir]
170
+ * @param {string} [projectRoot]
171
+ * @returns {string}
172
+ */
173
+ function toSandboxPath(hostDir, projectRoot) {
174
+ const root = path.resolve(projectRoot || process.cwd());
175
+ const dir = path.resolve(hostDir || root);
176
+ const rel = path.relative(root, dir);
177
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return '/workspace';
178
+ return '/workspace/' + rel.split(path.sep).join('/');
179
+ }
180
+
181
+ /**
182
+ * Run a shell command inside the sandbox container. Streams output via
183
+ * onChunk('out'|'err') while running; resolves with { output, code } or
184
+ * { error }.
185
+ * @param {string} command
186
+ * @param {{ workdir?: string, projectRoot?: string, timeoutMs?: number, onChunk?: (text: string, stream: 'out'|'err') => void }} [opts]
187
+ * @returns {Promise<{ output: string, code: number }|{ error: string }>}
188
+ */
189
+ function runInSandbox(command, opts) {
190
+ const o = opts || {};
191
+ // Shared container follows the active project (no-op when unchanged).
192
+ const remount = ensureProjectMount(o.projectRoot);
193
+ if (!remount.ok && remount.error !== 'sandbox disabled') {
194
+ return Promise.resolve({ error: 'sandbox re-mount failed: ' + remount.error });
195
+ }
196
+ const wd = toSandboxPath(o.workdir, o.projectRoot);
197
+ const timeoutMs = o.timeoutMs || 120000;
198
+ return new Promise((resolve) => {
199
+ let child;
200
+ try {
201
+ // sh -lc keeps pipes/redirections working; -i is not needed because
202
+ // every exec is a fresh non-interactive shell.
203
+ child = spawn('docker', ['exec', '-w', wd, CONTAINER, 'sh', '-lc', String(command || '')],
204
+ { windowsHide: true });
205
+ } catch (e) {
206
+ resolve({ error: 'sandbox exec failed: ' + (e && e.message) });
207
+ return;
208
+ }
209
+ const timer = setTimeout(() => {
210
+ try { child.kill('SIGKILL'); } catch {}
211
+ }, timeoutMs);
212
+ let output = '';
213
+ const MAX_OUT = 10 * 1024 * 1024;
214
+ let truncated = false;
215
+ const push = (/** @type {Buffer} */ d, /** @type {'out'|'err'} */ stream) => {
216
+ const t = d.toString();
217
+ if (!truncated) {
218
+ if (output.length + t.length > MAX_OUT) { output = output.slice(0, MAX_OUT); truncated = true; }
219
+ else output += t;
220
+ }
221
+ if (o.onChunk) { try { o.onChunk(t, stream); } catch {} }
222
+ };
223
+ child.stdout.on('data', (d) => push(d, 'out'));
224
+ child.stderr.on('data', (d) => push(d, 'err'));
225
+ child.on('close', (code) => {
226
+ clearTimeout(timer);
227
+ if (truncated) output += '\n[output truncated at 10 MB]';
228
+ resolve({ output: output || 'command completed', code: code == null ? 1 : code });
229
+ });
230
+ child.on('error', (err) => {
231
+ clearTimeout(timer);
232
+ resolve({ error: 'sandbox exec failed: ' + err.message });
233
+ });
234
+ });
235
+ }
236
+
237
+ /**
238
+ * Build the sandbox: pull the chosen OS image, (re)create the container with
239
+ * the project mounted, install essentials (+ headless chromium when the OS
240
+ * offers it). Idempotent — re-running refreshes. The container is a single
241
+ * GLOBAL resource (~/.loom/config.json records it), so every session and
242
+ * every agent shares the same sandbox; only the mounted project follows the
243
+ * working directory via ensureProjectMount().
244
+ * @param {{ osId?: string, projectRoot?: string, onLog?: (line: string) => void }} [opts]
245
+ * @returns {Promise<{ ok: true, container: string }|{ ok: false, error: string }>}
246
+ */
247
+ async function setupSandbox(opts) {
248
+ const o = opts || {};
249
+ const root = path.resolve(o.projectRoot || process.cwd());
250
+ const log = o.onLog || (() => {});
251
+ const { state, version } = detectDocker();
252
+ if (state === 'not_installed') return { ok: false, error: 'Docker is not installed. Run /sandbox guide for setup steps.' };
253
+ if (state === 'no_daemon') {
254
+ 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.' };
255
+ }
256
+ const osEntry = OS_CATALOG.find((x) => x.id === o.osId) || OS_CATALOG[0];
257
+ const image = osEntry.image;
258
+ log('Docker ' + version + ' detected — using ' + osEntry.label + ' (' + image + ').');
259
+ try {
260
+ log('Pulling ' + image + ' (first run only)…');
261
+ execFileSync('docker', ['pull', image], { encoding: 'utf8', windowsHide: true, timeout: 600000, stdio: ['ignore', 'pipe', 'pipe'] });
262
+ // Recreate from scratch so image updates + mount changes always apply.
263
+ try { execFileSync('docker', ['rm', '-f', CONTAINER], { windowsHide: true, timeout: 30000, stdio: 'ignore' }); } catch {}
264
+ log('Creating container ' + CONTAINER + ' with ' + root + ' mounted at /workspace…');
265
+ execFileSync('docker', [
266
+ 'create',
267
+ '--name', CONTAINER,
268
+ // Isolation: project dir only, no host network, no extra privileges.
269
+ '-v', root + ':/workspace',
270
+ '-w', '/workspace',
271
+ // Idle-forever main process; every agent command is a docker exec.
272
+ image, 'sleep', 'infinity',
273
+ ], { encoding: 'utf8', windowsHide: true, timeout: 60000, stdio: ['ignore', 'pipe', 'pipe'] });
274
+ execFileSync('docker', ['start', CONTAINER], { windowsHide: true, timeout: 60000, stdio: 'ignore' });
275
+ const pkgCmd = osEntry.pkg === 'apk'
276
+ ? 'apk add --no-cache ' + BASE_PACKAGES
277
+ : 'export DEBIAN_FRONTEND=noninteractive; apt-get update -qq && apt-get install -y -qq --no-install-recommends ' + BASE_PACKAGES + ' && rm -rf /var/lib/apt/lists/*';
278
+ log('Installing essentials (' + BASE_PACKAGES + ')…');
279
+ execFileSync('docker', ['exec', CONTAINER, 'sh', '-lc', pkgCmd],
280
+ { encoding: 'utf8', windowsHide: true, timeout: 600000, stdio: ['ignore', 'pipe', 'pipe'] });
281
+ // Headless browser, when the chosen OS offers one — best-effort so a
282
+ // missing chromium package never fails the whole setup.
283
+ if (osEntry.browser) {
284
+ const browserCmd = osEntry.pkg === 'apk'
285
+ ? 'apk add --no-cache chromium'
286
+ : 'export DEBIAN_FRONTEND=noninteractive; apt-get update -qq && apt-get install -y -qq --no-install-recommends chromium && rm -rf /var/lib/apt/lists/*';
287
+ log('Installing a headless Chromium (best-effort)…');
288
+ try {
289
+ execFileSync('docker', ['exec', CONTAINER, 'sh', '-lc', browserCmd],
290
+ { encoding: 'utf8', windowsHide: true, timeout: 600000, stdio: ['ignore', 'pipe', 'pipe'] });
291
+ } catch {
292
+ log('Chromium unavailable in this image — browser tooling will be limited (sandbox continues).');
293
+ }
294
+ }
295
+ const cfg = loadConfig();
296
+ cfg.sandbox = Object.assign({}, cfg.sandbox, {
297
+ enabled: true, pending: false, image, osId: osEntry.id, projectRoot: root,
298
+ });
299
+ saveConfig(cfg);
300
+ log('Sandbox ready' + (osEntry.browser ? ' — including a headless Chromium for the browser tool' : '') + '.');
301
+ log('Agent commands now run inside ' + CONTAINER + ' (/workspace = this project). Every session and agent shares it.');
302
+ return { ok: true, container: CONTAINER };
303
+ } catch (e) {
304
+ return { ok: false, error: 'Sandbox setup failed: ' + (e && e.message ? e.message : String(e)) };
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Make sure the shared container mounts the CURRENT project. Sessions in
310
+ * different directories share one sandbox — when the active project changes
311
+ * we just re-create the container with the new mount (image stays cached).
312
+ * Cheap no-op when the mount already matches.
313
+ * @param {string} [projectRoot]
314
+ * @returns {{ ok: true }|{ ok: false, error: string }}
315
+ */
316
+ function ensureProjectMount(projectRoot) {
317
+ const root = path.resolve(projectRoot || process.cwd());
318
+ const cfg = loadConfig();
319
+ if (!cfg.sandbox || !cfg.sandbox.enabled) return { ok: false, error: 'sandbox disabled' };
320
+ if (cfg.sandbox.projectRoot === root) return { ok: true };
321
+ const image = cfg.sandbox.image || DEFAULT_IMAGE;
322
+ try {
323
+ try { execFileSync('docker', ['rm', '-f', CONTAINER], { windowsHide: true, timeout: 30000, stdio: 'ignore' }); } catch {}
324
+ execFileSync('docker', [
325
+ 'create', '--name', CONTAINER,
326
+ '-v', root + ':/workspace', '-w', '/workspace',
327
+ image, 'sleep', 'infinity',
328
+ ], { encoding: 'utf8', windowsHide: true, timeout: 60000, stdio: ['ignore', 'pipe', 'pipe'] });
329
+ execFileSync('docker', ['start', CONTAINER], { windowsHide: true, timeout: 60000, stdio: 'ignore' });
330
+ cfg.sandbox.projectRoot = root;
331
+ saveConfig(cfg);
332
+ return { ok: true };
333
+ } catch (e) {
334
+ return { ok: false, error: 're-mount failed: ' + (e && e.message ? e.message : String(e)) };
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Should the bash tool route this command through the sandbox? Cheap config
340
+ * check — container liveness is verified at setup time, and exec errors
341
+ * surface per command.
342
+ * @returns {boolean}
343
+ */
344
+ function shouldUseSandbox() {
345
+ if (process.env.LOOM_SANDBOX === '0') return false;
346
+ const cfg = loadConfig();
347
+ return !!(cfg.sandbox && cfg.sandbox.enabled);
348
+ }
349
+
350
+ /**
351
+ * Toggle sandbox command routing (container keeps running either way).
352
+ * @param {boolean} on
353
+ */
354
+ function setSandboxEnabled(on) {
355
+ const cfg = loadConfig();
356
+ cfg.sandbox = Object.assign({}, cfg.sandbox, { enabled: !!on });
357
+ saveConfig(cfg);
358
+ }
359
+
360
+ /**
361
+ * Arm the "waiting for Docker install" flow: the TUI shows the guide and
362
+ * tells the user to come back with "docker is ready".
363
+ */
364
+ function markInstallPending() {
365
+ const cfg = loadConfig();
366
+ cfg.sandbox = Object.assign({}, cfg.sandbox, { pending: true });
367
+ saveConfig(cfg);
368
+ }
369
+
370
+ /**
371
+ * True when the user's message is the "docker is ready" resume signal while
372
+ * an install is pending (or they explicitly ask to set up the sandbox).
373
+ * @param {string} text
374
+ * @returns {boolean}
375
+ */
376
+ function isReadySignal(text) {
377
+ const t = String(text || '').trim().toLowerCase();
378
+ if (!t || t.length > 120) return false;
379
+ if (t.endsWith('?')) return false; // a question ("is docker ready yet?") is not an announcement
380
+ return /\bdocker\b/.test(t) && /\b(ready|installed|done|running|set ?up|finished)\b/.test(t);
381
+ }
382
+
383
+ module.exports = {
384
+ CONTAINER,
385
+ DEFAULT_IMAGE,
386
+ OS_CATALOG,
387
+ detectDocker,
388
+ installGuide,
389
+ installLink,
390
+ openInstallPage,
391
+ sandboxStatus,
392
+ toSandboxPath,
393
+ runInSandbox,
394
+ setupSandbox,
395
+ ensureProjectMount,
396
+ shouldUseSandbox,
397
+ setSandboxEnabled,
398
+ markInstallPending,
399
+ isReadySignal,
400
+ };