loom-agent 1.3.2 → 1.5.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 +216 -10
- package/README.md +10 -8
- package/package.json +2 -2
- package/src/core/agents.js +14 -1
- package/src/core/cli.js +30 -2
- package/src/core/hooks.js +28 -14
- package/src/core/memory.js +54 -6
- package/src/core/permissions.js +17 -4
- package/src/core/plugin-cmd.js +88 -1
- package/src/core/sandbox.js +311 -75
- package/src/core/session.js +262 -35
- package/src/core/transcript-undo.js +74 -0
- package/src/core/update.js +116 -6
- package/src/mcp/connector-presets.js +105 -0
- package/src/providers/anthropic.js +122 -25
- package/src/providers/google.js +19 -11
- package/src/providers/nvidia.js +45 -33
- package/src/providers/openai-compat.js +59 -27
- package/src/providers/openai.js +42 -18
- package/src/providers/openrouter.js +56 -26
- package/src/tools/index.js +206 -35
- package/src/tui/App.tsx +250 -36
- package/src/tui/components/Modals.tsx +130 -26
- package/src/tui/mcp-presets.ts +4 -65
- package/src/tui/store.ts +6 -1
package/src/core/sandbox.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
// Docker sandbox — a lightweight, isolated OS
|
|
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.
|
|
2
7
|
//
|
|
3
8
|
// Flow (guided in the TUI via the /sandbox modal):
|
|
4
9
|
// 1. Step 1 checks Docker. If missing, the modal shows the download link
|
|
@@ -8,13 +13,12 @@
|
|
|
8
13
|
// node images, ~250–900 MB). Step 3 pulls it, creates the single
|
|
9
14
|
// GLOBAL loom-sandbox container with the PROJECT mounted at /workspace,
|
|
10
15
|
// installs essentials (+ headless chromium when offered, best-effort)
|
|
11
|
-
// and keeps the container idle-running.
|
|
12
|
-
// 3. With sandbox.enabled, the
|
|
13
|
-
//
|
|
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
|
|
14
20
|
// tools stay on the host — the mount is the same directory, so reads
|
|
15
|
-
// and writes are shared while side effects of
|
|
16
|
-
// ensureProjectMount() re-creates the container when the active project
|
|
17
|
-
// changes, so every session/agent shares one sandbox.
|
|
21
|
+
// and writes are shared while side effects of the command stay contained.
|
|
18
22
|
//
|
|
19
23
|
// Security posture: no --privileged, no host network, only the project dir
|
|
20
24
|
// is mounted (rw), container runs as the image's default non-root user when
|
|
@@ -70,6 +74,28 @@ function detectDocker(runner) {
|
|
|
70
74
|
return { state: 'ready', version };
|
|
71
75
|
}
|
|
72
76
|
|
|
77
|
+
/**
|
|
78
|
+
* NON-BLOCKING Docker detection (spawn-based twin of detectDocker). The TUI
|
|
79
|
+
* must never call the sync version — `docker version` can hang 10s when the
|
|
80
|
+
* daemon is starting, which froze the whole interface. Injectable async
|
|
81
|
+
* runner for tests: (cmd, args) => Promise<{code, out}>.
|
|
82
|
+
* @param {(cmd: string, args: string[]) => Promise<{ code: number, out: string }>} [runner]
|
|
83
|
+
* @returns {Promise<{ state: DockerState, version: string }>}
|
|
84
|
+
*/
|
|
85
|
+
async function detectDockerAsync(runner) {
|
|
86
|
+
const run = runner || ((/** @type {string} */ cmd, /** @type {string[]} */ args) =>
|
|
87
|
+
spawnAsync(cmd, args, { timeoutMs: 10000 }));
|
|
88
|
+
const server = await run('docker', ['version', '--format', '{{.Server.Version}}']);
|
|
89
|
+
if (server.code === 0) {
|
|
90
|
+
const version = String(server.out || '').trim();
|
|
91
|
+
if (version) return { state: 'ready', version };
|
|
92
|
+
return { state: 'no_daemon', version: '' };
|
|
93
|
+
}
|
|
94
|
+
const cli = await run('docker', ['--version']);
|
|
95
|
+
if (cli.code === 0) return { state: 'no_daemon', version: '' };
|
|
96
|
+
return { state: 'not_installed', version: '' };
|
|
97
|
+
}
|
|
98
|
+
|
|
73
99
|
/**
|
|
74
100
|
* Platform-appropriate Docker install guide shown before the user leaves to
|
|
75
101
|
* install. Mentions the restart when the installer requires one, and the
|
|
@@ -161,6 +187,41 @@ function sandboxStatus() {
|
|
|
161
187
|
};
|
|
162
188
|
}
|
|
163
189
|
|
|
190
|
+
/**
|
|
191
|
+
* NON-BLOCKING twin of sandboxStatus (spawn-based docker checks). Use this
|
|
192
|
+
* from the TUI — the sync version freezes the interface while docker answers.
|
|
193
|
+
* @returns {Promise<ReturnType<typeof sandboxStatus>>}
|
|
194
|
+
*/
|
|
195
|
+
async function sandboxStatusAsync() {
|
|
196
|
+
const cfg = loadConfig();
|
|
197
|
+
const sandboxCfg = cfg.sandbox || {};
|
|
198
|
+
const { state, version } = await detectDockerAsync();
|
|
199
|
+
let container = /** @type {'running'|'stopped'|'absent'} */ ('absent');
|
|
200
|
+
if (state === 'ready') {
|
|
201
|
+
const insp = await spawnAsync('docker', ['inspect', '-f', '{{.State.Running}}', CONTAINER], { timeoutMs: 10000 });
|
|
202
|
+
if (insp.code === 0) container = insp.out.trim() === 'true' ? 'running' : 'stopped';
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
docker: state,
|
|
206
|
+
dockerVersion: version,
|
|
207
|
+
enabled: !!sandboxCfg.enabled && container === 'running',
|
|
208
|
+
pending: !!sandboxCfg.pending,
|
|
209
|
+
container,
|
|
210
|
+
image: sandboxCfg.image || DEFAULT_IMAGE,
|
|
211
|
+
osId: sandboxCfg.osId || 'alpine',
|
|
212
|
+
projectRoot: sandboxCfg.projectRoot || '',
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Config-only pending check (no docker involved — zero cost, never blocks).
|
|
218
|
+
* @returns {boolean}
|
|
219
|
+
*/
|
|
220
|
+
function sandboxPending() {
|
|
221
|
+
const cfg = loadConfig();
|
|
222
|
+
return !!(cfg.sandbox && cfg.sandbox.pending);
|
|
223
|
+
}
|
|
224
|
+
|
|
164
225
|
/**
|
|
165
226
|
* Map a host working directory to its path inside the container. The project
|
|
166
227
|
* root is mounted at /workspace; subdirectories map underneath it. Anything
|
|
@@ -179,20 +240,22 @@ function toSandboxPath(hostDir, projectRoot) {
|
|
|
179
240
|
}
|
|
180
241
|
|
|
181
242
|
/**
|
|
182
|
-
* Run
|
|
183
|
-
*
|
|
184
|
-
*
|
|
243
|
+
* Run ONE shell command inside the sandbox container (used by the sandbox
|
|
244
|
+
* tool — host commands stay on the bash tool). Revives the container if it
|
|
245
|
+
* stopped and re-mounts the current project, then streams output via
|
|
246
|
+
* onChunk('out'|'err'); resolves with { output, code } or { error }.
|
|
185
247
|
* @param {string} command
|
|
186
248
|
* @param {{ workdir?: string, projectRoot?: string, timeoutMs?: number, onChunk?: (text: string, stream: 'out'|'err') => void }} [opts]
|
|
187
249
|
* @returns {Promise<{ output: string, code: number }|{ error: string }>}
|
|
188
250
|
*/
|
|
189
|
-
function runInSandbox(command, opts) {
|
|
251
|
+
async function runInSandbox(command, opts) {
|
|
190
252
|
const o = opts || {};
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
253
|
+
// Bring the container back if the daemon restarted, then make sure it
|
|
254
|
+
// mounts the CURRENT project (cheap no-op when both already match).
|
|
255
|
+
const alive = await ensureContainerAlive();
|
|
256
|
+
if (!alive.ok) return { error: alive.error };
|
|
257
|
+
const remount = await ensureProjectMount(o.projectRoot);
|
|
258
|
+
if (!remount.ok) return { error: 'sandbox re-mount failed: ' + remount.error };
|
|
196
259
|
const wd = toSandboxPath(o.workdir, o.projectRoot);
|
|
197
260
|
const timeoutMs = o.timeoutMs || 120000;
|
|
198
261
|
return new Promise((resolve) => {
|
|
@@ -234,21 +297,115 @@ function runInSandbox(command, opts) {
|
|
|
234
297
|
});
|
|
235
298
|
}
|
|
236
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Spawn a host command, line-buffer its output, never block the event loop.
|
|
302
|
+
* Used by setupSandbox so the TUI keeps rendering (and the progress UI keeps
|
|
303
|
+
* updating) while docker pull / package installs run.
|
|
304
|
+
* @param {string} cmd
|
|
305
|
+
* @param {string[]} args
|
|
306
|
+
* @param {{ timeoutMs?: number, onLine?: (line: string, stream: 'out'|'err') => void }} [opts]
|
|
307
|
+
* @returns {Promise<{ code: number, out: string }>}
|
|
308
|
+
*/
|
|
309
|
+
function spawnAsync(cmd, args, opts) {
|
|
310
|
+
const o = opts || {};
|
|
311
|
+
return new Promise((resolve) => {
|
|
312
|
+
let child;
|
|
313
|
+
try {
|
|
314
|
+
child = spawn(cmd, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
315
|
+
} catch (e) {
|
|
316
|
+
resolve({ code: -1, out: (e && e.message) || String(e) });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const timer = o.timeoutMs
|
|
320
|
+
? setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, o.timeoutMs)
|
|
321
|
+
: null;
|
|
322
|
+
let out = '';
|
|
323
|
+
const MAX_OUT = 4 * 1024 * 1024;
|
|
324
|
+
const feed = (/** @type {Buffer} */ d, /** @type {'out'|'err'} */ stream) => {
|
|
325
|
+
const t = d.toString();
|
|
326
|
+
if (out.length < MAX_OUT) out += t;
|
|
327
|
+
if (!o.onLine) return;
|
|
328
|
+
// Line-buffer: docker writes progress with \r on TTYs and \n otherwise;
|
|
329
|
+
// treat both as separators so the UI gets fresh rows, not one blob.
|
|
330
|
+
const lines = t.split(/\r?\n/);
|
|
331
|
+
for (const line of lines) {
|
|
332
|
+
if (!line) continue;
|
|
333
|
+
try { o.onLine(line.replace(/\x1b\[[0-9;]*m/g, ''), stream); } catch {}
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
child.stdout.on('data', (d) => feed(d, 'out'));
|
|
337
|
+
child.stderr.on('data', (d) => feed(d, 'err'));
|
|
338
|
+
child.on('error', (err) => {
|
|
339
|
+
if (timer) clearTimeout(timer);
|
|
340
|
+
resolve({ code: -1, out: (out ? out + '\n' : '') + err.message });
|
|
341
|
+
});
|
|
342
|
+
child.on('close', (code) => {
|
|
343
|
+
if (timer) clearTimeout(timer);
|
|
344
|
+
resolve({ code: code == null ? 1 : code, out });
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// "12.34MB" / "951kB" / "1.2GB" → bytes (docker prints SI-style units).
|
|
350
|
+
function parseBytes(str, unit) {
|
|
351
|
+
const n = parseFloat(str);
|
|
352
|
+
if (!isFinite(n)) return 0;
|
|
353
|
+
const u = String(unit || '').toLowerCase();
|
|
354
|
+
if (u === 'gb') return n * 1e9;
|
|
355
|
+
if (u === 'mb') return n * 1e6;
|
|
356
|
+
if (u === 'kb') return n * 1e3;
|
|
357
|
+
return n;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Fold one docker-pull stdout line into the per-layer progress map. Layers
|
|
362
|
+
* report "Downloading [bar] X/Y", "Extracting [bar] X/Y", then "Pull complete".
|
|
363
|
+
* @param {string} line
|
|
364
|
+
* @param {Map<string, number>} layers layer-id → fraction 0..1
|
|
365
|
+
*/
|
|
366
|
+
function trackPullLine(line, layers) {
|
|
367
|
+
const done = /^([0-9a-f]{8,64}):\s+(?:Download complete|Pull complete)\s*$/i.exec(line);
|
|
368
|
+
if (done) { layers.set(done[1], 1); return; }
|
|
369
|
+
// "Waiting" = queued layer, no bytes yet.
|
|
370
|
+
const waiting = /^([0-9a-f]{8,64}):\s+Waiting\s*$/i.exec(line);
|
|
371
|
+
if (waiting) { layers.set(waiting[1], 0); return; }
|
|
372
|
+
const active = /^([0-9a-f]{8,64}):\s+(?:Downloading|Extracting)\s+(?:\[[^\]]*\]\s+)?([\d.]+)([kmgt]?b)(?:\/([\d.]+)([kmgt]?b))?/i.exec(line);
|
|
373
|
+
if (active) {
|
|
374
|
+
const id = active[1];
|
|
375
|
+
if (active[4]) {
|
|
376
|
+
const total = parseBytes(active[4], active[5]);
|
|
377
|
+
const cur = parseBytes(active[2], active[3]);
|
|
378
|
+
if (total > 0) layers.set(id, Math.min(1, cur / total));
|
|
379
|
+
} else if (!layers.has(id)) {
|
|
380
|
+
layers.set(id, 0);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function pullPercent(layers) {
|
|
386
|
+
if (!layers.size) return null;
|
|
387
|
+
let sum = 0;
|
|
388
|
+
for (const frac of layers.values()) sum += frac;
|
|
389
|
+
return Math.min(99, Math.round((sum / layers.size) * 100));
|
|
390
|
+
}
|
|
391
|
+
|
|
237
392
|
/**
|
|
238
393
|
* Build the sandbox: pull the chosen OS image, (re)create the container with
|
|
239
394
|
* the project mounted, install essentials (+ headless chromium when the OS
|
|
240
|
-
* offers it).
|
|
241
|
-
*
|
|
395
|
+
* offers it). Fully ASYNC — every docker call streams, so the TUI never
|
|
396
|
+
* freezes and onProgress/onLog deliver live updates. The container is a
|
|
397
|
+
* single GLOBAL resource (~/.loom/config.json records it), so every session and
|
|
242
398
|
* every agent shares the same sandbox; only the mounted project follows the
|
|
243
399
|
* working directory via ensureProjectMount().
|
|
244
|
-
* @param {{ osId?: string, projectRoot?: string, onLog?: (line: string) => void }} [opts]
|
|
400
|
+
* @param {{ osId?: string, projectRoot?: string, onLog?: (line: string) => void, onProgress?: (p: { stage: string, pct: number|null, step: number, total: number }) => void }} [opts]
|
|
245
401
|
* @returns {Promise<{ ok: true, container: string }|{ ok: false, error: string }>}
|
|
246
402
|
*/
|
|
247
403
|
async function setupSandbox(opts) {
|
|
248
404
|
const o = opts || {};
|
|
249
405
|
const root = path.resolve(o.projectRoot || process.cwd());
|
|
250
406
|
const log = o.onLog || (() => {});
|
|
251
|
-
const
|
|
407
|
+
const prog = o.onProgress || (() => {});
|
|
408
|
+
const { state, version } = await detectDockerAsync();
|
|
252
409
|
if (state === 'not_installed') return { ok: false, error: 'Docker is not installed. Run /sandbox guide for setup steps.' };
|
|
253
410
|
if (state === 'no_daemon') {
|
|
254
411
|
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.' };
|
|
@@ -256,77 +413,124 @@ async function setupSandbox(opts) {
|
|
|
256
413
|
const osEntry = OS_CATALOG.find((x) => x.id === o.osId) || OS_CATALOG[0];
|
|
257
414
|
const image = osEntry.image;
|
|
258
415
|
log('Docker ' + version + ' detected — using ' + osEntry.label + ' (' + image + ').');
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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).');
|
|
416
|
+
// Stages reported to onProgress: pull → create → start → essentials → browser → done.
|
|
417
|
+
const stages = ['pull', 'create', 'start', 'essentials'].concat(osEntry.browser ? ['browser'] : [], ['done']);
|
|
418
|
+
const stageIdx = (/** @type {string} */ s) => { const i = stages.indexOf(s); return i < 0 ? 0 : i; };
|
|
419
|
+
prog({ stage: 'pull', pct: 0, step: stageIdx('pull'), total: stages.length });
|
|
420
|
+
|
|
421
|
+
// ── Stage 1: pull the image (real per-layer progress, async + streamed) ──
|
|
422
|
+
log('Pulling ' + image + ' (first run only)…');
|
|
423
|
+
const layers = new Map();
|
|
424
|
+
let lastPct = -1;
|
|
425
|
+
const pull = await spawnAsync('docker', ['pull', image], {
|
|
426
|
+
timeoutMs: 600000,
|
|
427
|
+
onLine: (line) => {
|
|
428
|
+
trackPullLine(line, layers);
|
|
429
|
+
const pct = pullPercent(layers);
|
|
430
|
+
if (pct != null && pct !== lastPct) {
|
|
431
|
+
lastPct = pct;
|
|
432
|
+
prog({ stage: 'pull', pct, step: stageIdx('pull'), total: stages.length });
|
|
293
433
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
434
|
+
if (/^status:|^digest:|up to date/i.test(line)) log(line);
|
|
435
|
+
},
|
|
436
|
+
});
|
|
437
|
+
if (pull.code !== 0) {
|
|
438
|
+
return { ok: false, error: 'docker pull failed (exit ' + pull.code + '): ' + pull.out.slice(-400) };
|
|
439
|
+
}
|
|
440
|
+
prog({ stage: 'pull', pct: 100, step: stageIdx('pull'), total: stages.length });
|
|
441
|
+
|
|
442
|
+
// ── Stage 2: recreate the container from scratch (image updates + mount) ──
|
|
443
|
+
prog({ stage: 'create', pct: null, step: stageIdx('create'), total: stages.length });
|
|
444
|
+
await spawnAsync('docker', ['rm', '-f', CONTAINER], { timeoutMs: 30000 });
|
|
445
|
+
log('Creating container ' + CONTAINER + ' with ' + root + ' mounted at /workspace…');
|
|
446
|
+
const create = await spawnAsync('docker', [
|
|
447
|
+
'create',
|
|
448
|
+
'--name', CONTAINER,
|
|
449
|
+
// Isolation: project dir only, no host network, no extra privileges.
|
|
450
|
+
'-v', root + ':/workspace',
|
|
451
|
+
'-w', '/workspace',
|
|
452
|
+
// Idle-forever main process; every agent command is a docker exec.
|
|
453
|
+
image, 'sleep', 'infinity',
|
|
454
|
+
], { timeoutMs: 60000 });
|
|
455
|
+
if (create.code !== 0) {
|
|
456
|
+
return { ok: false, error: 'docker create failed (exit ' + create.code + '): ' + create.out.slice(-300) };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── Stage 3: start it (stays idle in the background until the sandbox
|
|
460
|
+
// tool is explicitly invoked) ──
|
|
461
|
+
prog({ stage: 'start', pct: null, step: stageIdx('start'), total: stages.length });
|
|
462
|
+
const start = await spawnAsync('docker', ['start', CONTAINER], { timeoutMs: 60000 });
|
|
463
|
+
if (start.code !== 0) {
|
|
464
|
+
return { ok: false, error: 'docker start failed (exit ' + start.code + '): ' + start.out.slice(-300) };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ── Stage 4: install essentials (streamed so the UI shows life) ──
|
|
468
|
+
prog({ stage: 'essentials', pct: null, step: stageIdx('essentials'), total: stages.length });
|
|
469
|
+
const pkgCmd = osEntry.pkg === 'apk'
|
|
470
|
+
? 'apk add --no-cache ' + BASE_PACKAGES
|
|
471
|
+
: 'export DEBIAN_FRONTEND=noninteractive; apt-get update -qq && apt-get install -y -qq --no-install-recommends ' + BASE_PACKAGES + ' && rm -rf /var/lib/apt/lists/*';
|
|
472
|
+
log('Installing essentials (' + BASE_PACKAGES + ')…');
|
|
473
|
+
const install = await spawnAsync('docker', ['exec', CONTAINER, 'sh', '-lc', pkgCmd], {
|
|
474
|
+
timeoutMs: 600000,
|
|
475
|
+
onLine: (line) => { if (!/^\s*$/.test(line)) log(line); },
|
|
476
|
+
});
|
|
477
|
+
if (install.code !== 0) {
|
|
478
|
+
return { ok: false, error: 'package install failed (exit ' + install.code + '): ' + install.out.slice(-400) };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ── Stage 5: headless browser when the OS offers one — best-effort, so a
|
|
482
|
+
// missing chromium package never fails the whole setup. ──
|
|
483
|
+
if (osEntry.browser) {
|
|
484
|
+
prog({ stage: 'browser', pct: null, step: stageIdx('browser'), total: stages.length });
|
|
485
|
+
const browserCmd = osEntry.pkg === 'apk'
|
|
486
|
+
? 'apk add --no-cache chromium'
|
|
487
|
+
: 'export DEBIAN_FRONTEND=noninteractive; apt-get update -qq && apt-get install -y -qq --no-install-recommends chromium && rm -rf /var/lib/apt/lists/*';
|
|
488
|
+
log('Installing a headless Chromium (best-effort)…');
|
|
489
|
+
const browser = await spawnAsync('docker', ['exec', CONTAINER, 'sh', '-lc', browserCmd], {
|
|
490
|
+
timeoutMs: 600000,
|
|
491
|
+
onLine: (line) => { if (!/^\s*$/.test(line)) log(line); },
|
|
298
492
|
});
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
return { ok: true, container: CONTAINER };
|
|
303
|
-
} catch (e) {
|
|
304
|
-
return { ok: false, error: 'Sandbox setup failed: ' + (e && e.message ? e.message : String(e)) };
|
|
493
|
+
if (browser.code !== 0) {
|
|
494
|
+
log('Chromium unavailable in this image — browser tooling will be limited (sandbox continues).');
|
|
495
|
+
}
|
|
305
496
|
}
|
|
497
|
+
|
|
498
|
+
prog({ stage: 'done', pct: 100, step: stageIdx('done'), total: stages.length });
|
|
499
|
+
const cfg = loadConfig();
|
|
500
|
+
cfg.sandbox = Object.assign({}, cfg.sandbox, {
|
|
501
|
+
enabled: true, pending: false, image, osId: osEntry.id, projectRoot: root,
|
|
502
|
+
});
|
|
503
|
+
saveConfig(cfg);
|
|
504
|
+
log('Sandbox ready' + (osEntry.browser ? ' — including a headless Chromium for the browser tool' : '') + '.');
|
|
505
|
+
log('Container ' + CONTAINER + ' idles in the background; the sandbox tool uses it only when the agent needs a Linux environment.');
|
|
506
|
+
return { ok: true, container: CONTAINER };
|
|
306
507
|
}
|
|
307
508
|
|
|
308
509
|
/**
|
|
309
510
|
* Make sure the shared container mounts the CURRENT project. Sessions in
|
|
310
511
|
* different directories share one sandbox — when the active project changes
|
|
311
512
|
* we just re-create the container with the new mount (image stays cached).
|
|
312
|
-
* Cheap no-op when the mount already matches.
|
|
513
|
+
* Cheap no-op when the mount already matches. Fully async (never blocks the
|
|
514
|
+
* TUI event loop).
|
|
313
515
|
* @param {string} [projectRoot]
|
|
314
|
-
* @returns {{ ok: true }|{ ok: false, error: string }}
|
|
516
|
+
* @returns {Promise<{ ok: true }|{ ok: false, error: string }>}
|
|
315
517
|
*/
|
|
316
|
-
function ensureProjectMount(projectRoot) {
|
|
518
|
+
async function ensureProjectMount(projectRoot) {
|
|
317
519
|
const root = path.resolve(projectRoot || process.cwd());
|
|
318
520
|
const cfg = loadConfig();
|
|
319
521
|
if (!cfg.sandbox || !cfg.sandbox.enabled) return { ok: false, error: 'sandbox disabled' };
|
|
320
522
|
if (cfg.sandbox.projectRoot === root) return { ok: true };
|
|
321
523
|
const image = cfg.sandbox.image || DEFAULT_IMAGE;
|
|
322
524
|
try {
|
|
323
|
-
|
|
324
|
-
|
|
525
|
+
await spawnAsync('docker', ['rm', '-f', CONTAINER], { timeoutMs: 30000 });
|
|
526
|
+
const create = await spawnAsync('docker', [
|
|
325
527
|
'create', '--name', CONTAINER,
|
|
326
528
|
'-v', root + ':/workspace', '-w', '/workspace',
|
|
327
529
|
image, 'sleep', 'infinity',
|
|
328
|
-
], {
|
|
329
|
-
|
|
530
|
+
], { timeoutMs: 60000 });
|
|
531
|
+
if (create.code !== 0) return { ok: false, error: 're-mount create failed: ' + create.out.slice(-200) };
|
|
532
|
+
const start = await spawnAsync('docker', ['start', CONTAINER], { timeoutMs: 60000 });
|
|
533
|
+
if (start.code !== 0) return { ok: false, error: 're-mount start failed: ' + start.out.slice(-200) };
|
|
330
534
|
cfg.sandbox.projectRoot = root;
|
|
331
535
|
saveConfig(cfg);
|
|
332
536
|
return { ok: true };
|
|
@@ -336,9 +540,35 @@ function ensureProjectMount(projectRoot) {
|
|
|
336
540
|
}
|
|
337
541
|
|
|
338
542
|
/**
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
543
|
+
* Make sure the container exists and is RUNNING before a sandbox-tool call.
|
|
544
|
+
* Revives a stopped container (Docker Desktop restart, reboot) with a quick
|
|
545
|
+
* `docker start`; never recreates (image/mount are setup-time concerns).
|
|
546
|
+
* @returns {Promise<{ ok: true }|{ ok: false, error: string }>}
|
|
547
|
+
*/
|
|
548
|
+
async function ensureContainerAlive() {
|
|
549
|
+
const cfg = loadConfig();
|
|
550
|
+
if (!cfg.sandbox || !cfg.sandbox.enabled) {
|
|
551
|
+
return { ok: false, error: 'Sandbox is not set up. Run /sandbox (or /sandbox setup) first — it only takes a minute and is needed once.' };
|
|
552
|
+
}
|
|
553
|
+
if (process.env.LOOM_SANDBOX === '0') {
|
|
554
|
+
return { ok: false, error: 'Sandbox disabled via LOOM_SANDBOX=0.' };
|
|
555
|
+
}
|
|
556
|
+
const inspect = await spawnAsync('docker', ['inspect', '-f', '{{.State.Running}}', CONTAINER], { timeoutMs: 15000 });
|
|
557
|
+
if (inspect.code !== 0 || !inspect.out.trim()) {
|
|
558
|
+
return { ok: false, error: 'Sandbox container is missing (deleted?). Run /sandbox setup again.' };
|
|
559
|
+
}
|
|
560
|
+
if (inspect.out.trim() === 'true') return { ok: true };
|
|
561
|
+
const start = await spawnAsync('docker', ['start', CONTAINER], { timeoutMs: 60000 });
|
|
562
|
+
if (start.code !== 0) {
|
|
563
|
+
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.' };
|
|
564
|
+
}
|
|
565
|
+
return { ok: true };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Is the sandbox TOOL available (set up and not kill-switched)? Used by the
|
|
570
|
+
* sandbox tool to decide between running and a helpful "run /sandbox first"
|
|
571
|
+
* error — NOT to route ordinary bash commands (those stay on the host).
|
|
342
572
|
* @returns {boolean}
|
|
343
573
|
*/
|
|
344
574
|
function shouldUseSandbox() {
|
|
@@ -348,7 +578,7 @@ function shouldUseSandbox() {
|
|
|
348
578
|
}
|
|
349
579
|
|
|
350
580
|
/**
|
|
351
|
-
* Toggle sandbox
|
|
581
|
+
* Toggle sandbox availability (the container idles either way).
|
|
352
582
|
* @param {boolean} on
|
|
353
583
|
*/
|
|
354
584
|
function setSandboxEnabled(on) {
|
|
@@ -385,15 +615,21 @@ module.exports = {
|
|
|
385
615
|
DEFAULT_IMAGE,
|
|
386
616
|
OS_CATALOG,
|
|
387
617
|
detectDocker,
|
|
618
|
+
detectDockerAsync,
|
|
388
619
|
installGuide,
|
|
389
620
|
installLink,
|
|
390
621
|
openInstallPage,
|
|
391
622
|
sandboxStatus,
|
|
623
|
+
sandboxStatusAsync,
|
|
624
|
+
sandboxPending,
|
|
392
625
|
toSandboxPath,
|
|
393
626
|
runInSandbox,
|
|
394
627
|
setupSandbox,
|
|
395
628
|
ensureProjectMount,
|
|
629
|
+
ensureContainerAlive,
|
|
396
630
|
shouldUseSandbox,
|
|
631
|
+
trackPullLine,
|
|
632
|
+
pullPercent,
|
|
397
633
|
setSandboxEnabled,
|
|
398
634
|
markInstallPending,
|
|
399
635
|
isReadySignal,
|