loom-agent 1.3.2 → 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.
- package/CHANGELOG.md +27 -10
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/core/sandbox.js +250 -74
- package/src/core/session.js +1 -0
- package/src/tools/index.js +27 -19
- package/src/tui/App.tsx +3 -3
- package/src/tui/components/Modals.tsx +66 -7
package/CHANGELOG.md
CHANGED
|
@@ -29,16 +29,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
29
29
|
modal: (1) Docker check — if missing, shows the platform install link with
|
|
30
30
|
an "open download page" action and remembers the pending install; (2) OS
|
|
31
31
|
pick — Alpine (~250 MB, apk), Debian Slim (~600 MB, apt) or Ubuntu
|
|
32
|
-
(~900 MB, apt); (3) live build with
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
32
|
+
(~900 MB, apt); (3) live build with a real progress UI: per-stage
|
|
33
|
+
checklist (pull → create → start → essentials → browser → ready), a
|
|
34
|
+
percentage bar driven by actual docker-pull layer bytes, and streamed
|
|
35
|
+
install logs. Setup is fully asynchronous — the TUI never freezes — and
|
|
36
|
+
the wizard can be esc'd while setup continues in the background. Setup
|
|
37
|
+
pulls the chosen `node:20-*` image, creates the single shared
|
|
38
|
+
`loom-sandbox` container with the project mounted at `/workspace`,
|
|
39
|
+
installs essentials (git, bash, curl, ripgrep, coreutils) plus a headless
|
|
40
|
+
Chromium where offered (best-effort). Isolation: project dir only, no host
|
|
41
|
+
network, no `--privileged`. The container idles in the background; the
|
|
42
|
+
dedicated **`sandbox` tool** runs one command inside it only when the
|
|
43
|
+
agent decides isolation is needed (see Changed). `LOOM_SANDBOX=0` is a
|
|
44
|
+
hard kill-switch. Saying "docker is ready" after installing reopens the
|
|
45
|
+
wizard at the OS step.
|
|
42
46
|
- **Drive-to-completion harness (`src/core/session.js`).** When the model ends
|
|
43
47
|
a turn with todos still pending/in_progress (the "stops between tasks"
|
|
44
48
|
failure), the loop injects a continuation listing the unfinished items — up
|
|
@@ -82,6 +86,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
82
86
|
todos are surfaced to the UI whenever the guard engaged.
|
|
83
87
|
|
|
84
88
|
### Changed
|
|
89
|
+
- **Sandbox is now opt-in per command, not a global router.** Enabling the
|
|
90
|
+
sandbox no longer hijacks the bash tool — every host command keeps running
|
|
91
|
+
on your machine (Windows included) exactly as before. Instead, a dedicated
|
|
92
|
+
**`sandbox` tool** appears; the agent calls it only when isolation is
|
|
93
|
+
genuinely needed (you asked for the sandbox, the command is risky, or a
|
|
94
|
+
Linux-only environment is required). Each call revives the container if it
|
|
95
|
+
stopped (e.g. after a Docker Desktop restart) and re-mounts the current
|
|
96
|
+
project, then runs just that one command via `docker exec`. The container
|
|
97
|
+
idles in the background between calls; `/sandbox off` disables the tool
|
|
98
|
+
(not the host), `/sandbox on` re-enables it. The setup wizard's build step
|
|
99
|
+
also became fully non-blocking: docker pull streams per-layer progress
|
|
100
|
+
into a live percentage bar, package installs stream their output, and the
|
|
101
|
+
TUI stays responsive throughout (esc lets setup finish in the background).
|
|
85
102
|
- **Slash popup decluttered.** The autocomplete popup and command palette
|
|
86
103
|
now show a short one-line description per command — argument syntax is no
|
|
87
104
|
longer appended (it lives in `/help` and the README table). Duplicate
|
package/README.md
CHANGED
|
@@ -163,7 +163,7 @@ self-test client (`node scripts/acp-smoke.js`) live in **[docs/acp.md](docs/acp.
|
|
|
163
163
|
| `/tasks` | — | Background tasks |
|
|
164
164
|
| `/share` | — | Export chat as HTML |
|
|
165
165
|
| `/worktree` | `<name>` | New git worktree |
|
|
166
|
-
| `/sandbox` | `setup\|status\|on\|off\|rm` | Docker sandbox |
|
|
166
|
+
| `/sandbox` | `setup\|status\|on\|off\|rm` | Docker sandbox (opt-in `sandbox` tool; host untouched) |
|
|
167
167
|
| `/style` | `[preset\|text]` | Output style preset |
|
|
168
168
|
| `/vim` | — | Toggle vim editing |
|
|
169
169
|
| `/remember` | `<fact>` | Save a fact to LOOM.md |
|
package/package.json
CHANGED
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
|
|
@@ -179,20 +183,22 @@ function toSandboxPath(hostDir, projectRoot) {
|
|
|
179
183
|
}
|
|
180
184
|
|
|
181
185
|
/**
|
|
182
|
-
* Run
|
|
183
|
-
*
|
|
184
|
-
*
|
|
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 }.
|
|
185
190
|
* @param {string} command
|
|
186
191
|
* @param {{ workdir?: string, projectRoot?: string, timeoutMs?: number, onChunk?: (text: string, stream: 'out'|'err') => void }} [opts]
|
|
187
192
|
* @returns {Promise<{ output: string, code: number }|{ error: string }>}
|
|
188
193
|
*/
|
|
189
|
-
function runInSandbox(command, opts) {
|
|
194
|
+
async function runInSandbox(command, opts) {
|
|
190
195
|
const o = opts || {};
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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 };
|
|
196
202
|
const wd = toSandboxPath(o.workdir, o.projectRoot);
|
|
197
203
|
const timeoutMs = o.timeoutMs || 120000;
|
|
198
204
|
return new Promise((resolve) => {
|
|
@@ -234,20 +240,114 @@ function runInSandbox(command, opts) {
|
|
|
234
240
|
});
|
|
235
241
|
}
|
|
236
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
|
+
|
|
237
335
|
/**
|
|
238
336
|
* Build the sandbox: pull the chosen OS image, (re)create the container with
|
|
239
337
|
* the project mounted, install essentials (+ headless chromium when the OS
|
|
240
|
-
* offers it).
|
|
241
|
-
*
|
|
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
|
|
242
341
|
* every agent shares the same sandbox; only the mounted project follows the
|
|
243
342
|
* working directory via ensureProjectMount().
|
|
244
|
-
* @param {{ osId?: string, projectRoot?: string, onLog?: (line: string) => void }} [opts]
|
|
343
|
+
* @param {{ osId?: string, projectRoot?: string, onLog?: (line: string) => void, onProgress?: (p: { stage: string, pct: number|null, step: number, total: number }) => void }} [opts]
|
|
245
344
|
* @returns {Promise<{ ok: true, container: string }|{ ok: false, error: string }>}
|
|
246
345
|
*/
|
|
247
346
|
async function setupSandbox(opts) {
|
|
248
347
|
const o = opts || {};
|
|
249
348
|
const root = path.resolve(o.projectRoot || process.cwd());
|
|
250
349
|
const log = o.onLog || (() => {});
|
|
350
|
+
const prog = o.onProgress || (() => {});
|
|
251
351
|
const { state, version } = detectDocker();
|
|
252
352
|
if (state === 'not_installed') return { ok: false, error: 'Docker is not installed. Run /sandbox guide for setup steps.' };
|
|
253
353
|
if (state === 'no_daemon') {
|
|
@@ -256,77 +356,124 @@ async function setupSandbox(opts) {
|
|
|
256
356
|
const osEntry = OS_CATALOG.find((x) => x.id === o.osId) || OS_CATALOG[0];
|
|
257
357
|
const image = osEntry.image;
|
|
258
358
|
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).');
|
|
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 });
|
|
293
376
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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); },
|
|
298
435
|
});
|
|
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)) };
|
|
436
|
+
if (browser.code !== 0) {
|
|
437
|
+
log('Chromium unavailable in this image — browser tooling will be limited (sandbox continues).');
|
|
438
|
+
}
|
|
305
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 };
|
|
306
450
|
}
|
|
307
451
|
|
|
308
452
|
/**
|
|
309
453
|
* Make sure the shared container mounts the CURRENT project. Sessions in
|
|
310
454
|
* different directories share one sandbox — when the active project changes
|
|
311
455
|
* we just re-create the container with the new mount (image stays cached).
|
|
312
|
-
* Cheap no-op when the mount already matches.
|
|
456
|
+
* Cheap no-op when the mount already matches. Fully async (never blocks the
|
|
457
|
+
* TUI event loop).
|
|
313
458
|
* @param {string} [projectRoot]
|
|
314
|
-
* @returns {{ ok: true }|{ ok: false, error: string }}
|
|
459
|
+
* @returns {Promise<{ ok: true }|{ ok: false, error: string }>}
|
|
315
460
|
*/
|
|
316
|
-
function ensureProjectMount(projectRoot) {
|
|
461
|
+
async function ensureProjectMount(projectRoot) {
|
|
317
462
|
const root = path.resolve(projectRoot || process.cwd());
|
|
318
463
|
const cfg = loadConfig();
|
|
319
464
|
if (!cfg.sandbox || !cfg.sandbox.enabled) return { ok: false, error: 'sandbox disabled' };
|
|
320
465
|
if (cfg.sandbox.projectRoot === root) return { ok: true };
|
|
321
466
|
const image = cfg.sandbox.image || DEFAULT_IMAGE;
|
|
322
467
|
try {
|
|
323
|
-
|
|
324
|
-
|
|
468
|
+
await spawnAsync('docker', ['rm', '-f', CONTAINER], { timeoutMs: 30000 });
|
|
469
|
+
const create = await spawnAsync('docker', [
|
|
325
470
|
'create', '--name', CONTAINER,
|
|
326
471
|
'-v', root + ':/workspace', '-w', '/workspace',
|
|
327
472
|
image, 'sleep', 'infinity',
|
|
328
|
-
], {
|
|
329
|
-
|
|
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) };
|
|
330
477
|
cfg.sandbox.projectRoot = root;
|
|
331
478
|
saveConfig(cfg);
|
|
332
479
|
return { ok: true };
|
|
@@ -336,9 +483,35 @@ function ensureProjectMount(projectRoot) {
|
|
|
336
483
|
}
|
|
337
484
|
|
|
338
485
|
/**
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
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).
|
|
342
515
|
* @returns {boolean}
|
|
343
516
|
*/
|
|
344
517
|
function shouldUseSandbox() {
|
|
@@ -348,7 +521,7 @@ function shouldUseSandbox() {
|
|
|
348
521
|
}
|
|
349
522
|
|
|
350
523
|
/**
|
|
351
|
-
* Toggle sandbox
|
|
524
|
+
* Toggle sandbox availability (the container idles either way).
|
|
352
525
|
* @param {boolean} on
|
|
353
526
|
*/
|
|
354
527
|
function setSandboxEnabled(on) {
|
|
@@ -393,7 +566,10 @@ module.exports = {
|
|
|
393
566
|
runInSandbox,
|
|
394
567
|
setupSandbox,
|
|
395
568
|
ensureProjectMount,
|
|
569
|
+
ensureContainerAlive,
|
|
396
570
|
shouldUseSandbox,
|
|
571
|
+
trackPullLine,
|
|
572
|
+
pullPercent,
|
|
397
573
|
setSandboxEnabled,
|
|
398
574
|
markInstallPending,
|
|
399
575
|
isReadySignal,
|
package/src/core/session.js
CHANGED
|
@@ -263,6 +263,7 @@ ${this.loadSkills()}
|
|
|
263
263
|
## Mode
|
|
264
264
|
You are in ${this.mode === 'build' ? 'BUILD MODE (full agent)' : this.mode === 'plan' ? 'PLAN MODE (read-only analysis)' : 'CHAT MODE (conversation only)'}.
|
|
265
265
|
${this.mode === 'build' ? `- You have all tools available: read, write, edit, bash, glob, grep, webfetch, and MCP tools.
|
|
266
|
+
- bash runs on the user's machine. Only use the sandbox tool when isolation is genuinely needed (the user asked for it, or the command is risky/Linux-only); never for ordinary commands.
|
|
266
267
|
- Inspect, edit, and verify the result yourself end-to-end.
|
|
267
268
|
- When done, summarize in 1-2 sentences.` : ''}${this.mode === 'plan' ? `- Read-only tools only: read, glob, grep, webfetch, todowrite. No MCP tools.
|
|
268
269
|
- Investigate thoroughly, then output a "## Plan" with ordered steps (exact file path + what changes each step makes). No narration.` : ''}${this.mode === 'chat' ? `- No tools available; answer conversationally.
|
package/src/tools/index.js
CHANGED
|
@@ -186,31 +186,13 @@ const TOOLS = {
|
|
|
186
186
|
},
|
|
187
187
|
bash: {
|
|
188
188
|
name: 'bash',
|
|
189
|
-
description: 'Execute a shell command.',
|
|
189
|
+
description: 'Execute a shell command on the user\'s machine.',
|
|
190
190
|
parameters: {
|
|
191
191
|
command: { type: 'string', required: true, description: 'The command to execute' },
|
|
192
192
|
workdir: { type: 'string', required: false, description: 'Working directory' },
|
|
193
193
|
background: { type: 'boolean', required: false, description: 'Run without waiting (dev servers, long builds). Returns a task id immediately; check with the /tasks panel.' },
|
|
194
194
|
},
|
|
195
195
|
async execute(params, ctx) {
|
|
196
|
-
// Docker sandbox: when enabled (/sandbox setup), every command runs
|
|
197
|
-
// inside the loom-sandbox container instead of the host shell.
|
|
198
|
-
// Background tasks stay on the host (they outlive tool timeouts).
|
|
199
|
-
const sandbox = require('../core/sandbox');
|
|
200
|
-
if (!params.background && sandbox.shouldUseSandbox()) {
|
|
201
|
-
if (!pathAllowed(params.workdir || cwd)) return { error: sandboxDenied('bash', params.workdir || cwd) };
|
|
202
|
-
const risk = params._approved ? null : commandRiskLabel(params.command);
|
|
203
|
-
if (risk) return { error: `Blocked by safety filter: ${risk}. Approve it via the permission prompt to run it anyway.` };
|
|
204
|
-
const stream = ctx && typeof ctx.stream === 'function' ? ctx.stream : null;
|
|
205
|
-
const res = await sandbox.runInSandbox(params.command, {
|
|
206
|
-
workdir: params.workdir || cwd,
|
|
207
|
-
onChunk: stream ? (text, which) => stream(text, which) : undefined,
|
|
208
|
-
});
|
|
209
|
-
if ('error' in res) return { error: res.error };
|
|
210
|
-
let out = res.output;
|
|
211
|
-
if (res.code !== 0) out += `\n[exit code: ${res.code}]`;
|
|
212
|
-
return out || 'command completed';
|
|
213
|
-
}
|
|
214
196
|
// Background mode: fire-and-forget via the task registry.
|
|
215
197
|
if (params.background) {
|
|
216
198
|
const bt = require('../core/background-tasks');
|
|
@@ -283,6 +265,32 @@ const TOOLS = {
|
|
|
283
265
|
});
|
|
284
266
|
}
|
|
285
267
|
},
|
|
268
|
+
sandbox: {
|
|
269
|
+
name: 'sandbox',
|
|
270
|
+
description: 'Run a shell command INSIDE the Docker sandbox (isolated Linux container, project mounted at /workspace). Use ONLY when isolation is needed: the user asked for the sandbox, the command is risky (installs, deletions, untrusted scripts), or a Linux-only environment is required. For ordinary commands use bash (host). One command per call; the container idles in the background between calls.',
|
|
271
|
+
parameters: {
|
|
272
|
+
command: { type: 'string', required: true, description: 'The Linux shell command to run inside the container' },
|
|
273
|
+
workdir: { type: 'string', required: false, description: 'Working directory inside the container (host paths under the project map to /workspace/...)' },
|
|
274
|
+
timeoutMs: { type: 'number', required: false, description: 'Kill the command after this many ms (default 120000)' },
|
|
275
|
+
},
|
|
276
|
+
async execute(params, ctx) {
|
|
277
|
+
// Opt-in isolation path: nothing is routed here automatically. The
|
|
278
|
+
// container is revived/re-mounted on demand, then torn back to idle.
|
|
279
|
+
const sandbox = require('../core/sandbox');
|
|
280
|
+
const risk = params._approved ? null : commandRiskLabel(params.command);
|
|
281
|
+
if (risk) return { error: `Blocked by safety filter: ${risk}. Approve it via the permission prompt to run it anyway.` };
|
|
282
|
+
const stream = ctx && typeof ctx.stream === 'function' ? ctx.stream : null;
|
|
283
|
+
const res = await sandbox.runInSandbox(params.command, {
|
|
284
|
+
workdir: params.workdir || cwd,
|
|
285
|
+
timeoutMs: typeof params.timeoutMs === 'number' && params.timeoutMs > 0 ? params.timeoutMs : undefined,
|
|
286
|
+
onChunk: stream ? (text, which) => stream(text, which) : undefined,
|
|
287
|
+
});
|
|
288
|
+
if ('error' in res) return { error: res.error };
|
|
289
|
+
let out = res.output;
|
|
290
|
+
if (res.code !== 0) out += `\n[exit code: ${res.code}]`;
|
|
291
|
+
return out || 'command completed';
|
|
292
|
+
}
|
|
293
|
+
},
|
|
286
294
|
grep: {
|
|
287
295
|
name: 'grep',
|
|
288
296
|
description: 'Search file contents using regular expressions.',
|
package/src/tui/App.tsx
CHANGED
|
@@ -1096,13 +1096,13 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
|
|
|
1096
1096
|
if (ssub === "guide") { appendMessage({ role: "system", content: sandbox.installGuide() }); return; }
|
|
1097
1097
|
if (ssub === "off") {
|
|
1098
1098
|
sandbox.setSandboxEnabled(false);
|
|
1099
|
-
appendMessage({ role: "system", content: "Sandbox off —
|
|
1099
|
+
appendMessage({ role: "system", content: "Sandbox off — the sandbox tool is disabled; the agent works on your machine as usual. The container is kept (run /sandbox on to re-enable, /sandbox rm to delete it)." });
|
|
1100
1100
|
return;
|
|
1101
1101
|
}
|
|
1102
1102
|
if (ssub === "on") {
|
|
1103
1103
|
if (st.container !== "running") { appendMessage({ role: "system", content: "Sandbox container is not running. Run /sandbox setup first." }); return; }
|
|
1104
1104
|
sandbox.setSandboxEnabled(true);
|
|
1105
|
-
appendMessage({ role: "system", content: "Sandbox on — agent
|
|
1105
|
+
appendMessage({ role: "system", content: "Sandbox on — the agent can use the sandbox tool for isolated commands inside " + sandbox.CONTAINER + " when needed. Everything else stays on your machine." });
|
|
1106
1106
|
return;
|
|
1107
1107
|
}
|
|
1108
1108
|
if (ssub === "rm") {
|
|
@@ -1117,7 +1117,7 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
|
|
|
1117
1117
|
var line = "Sandbox: ";
|
|
1118
1118
|
if (st.docker === "not_installed") line += "Docker not installed";
|
|
1119
1119
|
else if (st.docker === "no_daemon") line += "Docker installed, daemon not running";
|
|
1120
|
-
else line += "Docker " + st.dockerVersion + " \u00b7 container " + st.container + (st.enabled ? " \u00b7
|
|
1120
|
+
else line += "Docker " + st.dockerVersion + " \u00b7 container " + st.container + (st.enabled ? " \u00b7 sandbox tool on" : " \u00b7 sandbox tool off");
|
|
1121
1121
|
if (st.pending) line += "\nWaiting for you to install Docker \u2014 when it's done say \"docker is ready\" (or run /sandbox).";
|
|
1122
1122
|
else if (st.docker === "not_installed") line += "\nRun /sandbox for the setup wizard.";
|
|
1123
1123
|
else if (st.container === "absent") line += "\nRun /sandbox to build it.";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Modals -- provider picker, model picker, key input, base URL, settings, palette.
|
|
2
|
-
import { createSignal, createMemo, onMount } from "solid-js";
|
|
2
|
+
import { createSignal, createMemo, onMount, onCleanup } from "solid-js";
|
|
3
3
|
import { useKeyboard, usePaste } from "@opentui/solid";
|
|
4
4
|
import { palette } from "../theme.ts";
|
|
5
5
|
import * as kbs from "../keybinds.ts";
|
|
@@ -663,8 +663,9 @@ export function ConnectorsModal() {
|
|
|
663
663
|
|
|
664
664
|
// ── Sandbox setup wizard ──
|
|
665
665
|
// Three steps: docker check (with install link when missing) → OS pick →
|
|
666
|
-
// live setup
|
|
667
|
-
//
|
|
666
|
+
// live setup with a real progress bar (setup streams from the core layer —
|
|
667
|
+
// the TUI never freezes). The container is global and idles in the
|
|
668
|
+
// background; the agent's sandbox tool uses it on demand.
|
|
668
669
|
export function SandboxModal() {
|
|
669
670
|
const sb = require("../../core/sandbox.js");
|
|
670
671
|
const [step, setStep] = createSignal<"docker" | "os" | "setup">("docker");
|
|
@@ -673,6 +674,30 @@ export function SandboxModal() {
|
|
|
673
674
|
const [logs, setLogs] = createSignal<string[]>([]);
|
|
674
675
|
const [busy, setBusy] = createSignal(false);
|
|
675
676
|
const [err, setErr] = createSignal<string | null>(null);
|
|
677
|
+
const [prog, setProg] = createSignal<{ stage: string; pct: number | null; step: number; total: number } | null>(null);
|
|
678
|
+
const [spin, setSpin] = createSignal(0);
|
|
679
|
+
let spinTimer: ReturnType<typeof setInterval> | null = null;
|
|
680
|
+
onCleanup(() => { if (spinTimer) clearInterval(spinTimer); });
|
|
681
|
+
|
|
682
|
+
// Spinner frames while setup is busy (drives the "working…" indicator).
|
|
683
|
+
function startSpinner() {
|
|
684
|
+
if (spinTimer) clearInterval(spinTimer);
|
|
685
|
+
spinTimer = setInterval(() => setSpin((s) => (s + 1) % 10), 120);
|
|
686
|
+
}
|
|
687
|
+
function stopSpinner() {
|
|
688
|
+
if (spinTimer) clearInterval(spinTimer);
|
|
689
|
+
spinTimer = null;
|
|
690
|
+
}
|
|
691
|
+
const SPIN_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
|
|
692
|
+
|
|
693
|
+
const STAGE_LABELS: Record<string, string> = {
|
|
694
|
+
pull: "Pulling image",
|
|
695
|
+
create: "Creating container",
|
|
696
|
+
start: "Starting container",
|
|
697
|
+
essentials: "Installing essentials",
|
|
698
|
+
browser: "Installing browser",
|
|
699
|
+
done: "Ready",
|
|
700
|
+
};
|
|
676
701
|
|
|
677
702
|
// Docker already installed → advance straight to OS selection (with a
|
|
678
703
|
// brief "installed" flash so the user sees the check happened).
|
|
@@ -693,14 +718,18 @@ export function SandboxModal() {
|
|
|
693
718
|
setStep("setup");
|
|
694
719
|
setBusy(true);
|
|
695
720
|
setErr(null);
|
|
721
|
+
setProg(null);
|
|
722
|
+
startSpinner();
|
|
696
723
|
setLogs(["Preparing " + os.label + " (" + os.image + ")…"]);
|
|
697
724
|
sb.setupSandbox({
|
|
698
725
|
osId: os.id,
|
|
699
726
|
onLog: (/** @type {string} */ l) => setLogs((xs) => [...xs, l]),
|
|
727
|
+
onProgress: (/** @type {{ stage: string; pct: number | null; step: number; total: number }} */ p) => setProg(p),
|
|
700
728
|
}).then((/** @type {{ ok: boolean, error?: string }} */ res) => {
|
|
701
729
|
setBusy(false);
|
|
730
|
+
stopSpinner();
|
|
702
731
|
if (res.ok) {
|
|
703
|
-
appendMessage({ role: "system", content: "Sandbox is live (" + os.label + ").
|
|
732
|
+
appendMessage({ role: "system", content: "Sandbox is live (" + os.label + "). The container idles in the background; the agent will use it via the sandbox tool whenever isolation is needed. Your project is mounted at /workspace." });
|
|
704
733
|
showToast("Sandbox ready — " + os.label, "ok");
|
|
705
734
|
closeModal();
|
|
706
735
|
} else {
|
|
@@ -805,9 +834,39 @@ export function SandboxModal() {
|
|
|
805
834
|
</box>
|
|
806
835
|
) : null}
|
|
807
836
|
{step() === "setup" ? (
|
|
808
|
-
<box flexDirection="column" maxWidth={
|
|
809
|
-
{
|
|
810
|
-
{
|
|
837
|
+
<box flexDirection="column" maxWidth={72}>
|
|
838
|
+
{/* Stage checklist with a live pull-progress bar (pct from core). */}
|
|
839
|
+
{prog() ? (() => {
|
|
840
|
+
const p = prog()!;
|
|
841
|
+
const os = sb.OS_CATALOG[osSel()];
|
|
842
|
+
// Same stage order the core reports (browser only when offered).
|
|
843
|
+
const keys = ["pull", "create", "start", "essentials"]
|
|
844
|
+
.concat(os.browser ? ["browser"] : [], ["done"])
|
|
845
|
+
.slice(0, p.total);
|
|
846
|
+
return (
|
|
847
|
+
<box flexDirection="column" marginBottom={1}>
|
|
848
|
+
{keys.map((key, i) => {
|
|
849
|
+
const done = i < p.step || p.stage === "done";
|
|
850
|
+
const active = i === p.step && p.stage !== "done";
|
|
851
|
+
const mark = done ? "✓" : active ? SPIN_FRAMES[spin()] : "○";
|
|
852
|
+
const color = done ? ui.success : active ? ui.primary : ui.fgDim;
|
|
853
|
+
let bar = "";
|
|
854
|
+
if (active && p.pct != null) {
|
|
855
|
+
const width = 20;
|
|
856
|
+
const filled = Math.round((p.pct / 100) * width);
|
|
857
|
+
bar = " [" + "█".repeat(filled) + "░".repeat(width - filled) + "] " + p.pct + "%";
|
|
858
|
+
} else if (active) {
|
|
859
|
+
bar = " …";
|
|
860
|
+
}
|
|
861
|
+
return (
|
|
862
|
+
<text fg={color}>{mark + " " + STAGE_LABELS[key] + bar}</text>
|
|
863
|
+
);
|
|
864
|
+
})}
|
|
865
|
+
</box>
|
|
866
|
+
);
|
|
867
|
+
})() : <text fg={ui.fgMuted}>Starting…</text>}
|
|
868
|
+
{logs().slice(-6).map((l) => <text fg={ui.fgMuted}>{"▸ " + l.slice(0, 68)}</text>)}
|
|
869
|
+
{busy() ? <text fg={ui.primary}>{SPIN_FRAMES[spin()] + " working — you can esc; setup continues in the background"}</text> : null}
|
|
811
870
|
{err() ? (
|
|
812
871
|
<box flexDirection="column" marginTop={1}>
|
|
813
872
|
<text fg={ui.error}>{err()}</text>
|