openvibers/openvibe.codes Commits
Edit means edits on your own computer: agents with no edits-only mode run unattended only in a Node sandbox or for full (#219)
Changes
@@@ -25,7 +25,9 @@ const USAGE = `openvibe-codes ${VERSION} — code with any agent, from your termaider, minimax-code, deepseek, openai-compatible--model <name> the first agent's model--permission read|edit|full read: look and answer; edit: change files (default);-- full: anything, unattended++ full: anything, unattended. OpenCode and Command Code have no++ edits-only mode: here they need full (on a paired machine's++ sandboxed jobs, edit is enough)--cwd <dir> where the agent works (default: here)--no-handoff stop at the first agent's result--attempts <n> at most this many agents in one run (default 3)@@@ -142,12 +142,21 @@ const codex = {};// ── OpenCode: `opencode run --format json` (text, tool_use, step_finish, error parts) ──++/**++ * May an agent with no edits-only mode run unattended (approve every tool, the shell included)? For "full", or for++ * "edit" inside a sandbox (a job on the person's Node: its own namespaces, a read-only root, no home folder), never++ * for "edit" on the person's own computer, where "edit" promises file changes only.++ */++const unattended = (t) => t.permission === 'full' || (t.permission === 'edit' && t.sandboxed === true);++const OC_NAMES = { shell: 'Bash', bash: 'Bash', read: 'Read', edit: 'Edit', write: 'Write', patch: 'Edit', multiedit: 'Edit', grep: 'Grep', glob: 'Glob', list: 'LS', webfetch: 'WebFetch', websearch: 'WebSearch', task: 'Task', todowrite: 'TodoWrite' };const opencode = {id: 'opencode', name: 'OpenCode', kind: 'cli', bin: 'opencode',++ // --auto approves every tool, the shell included: there is no edits-only mode (harness/index.js unattended()).++ editsOnly: false,start(t, ctx = {}) {const args = ['run', '--format', 'json'];-- if (t.permission !== 'read') args.push('--auto');++ if (unattended(t)) args.push('--auto');if (t.model) args.push('-m', t.model);if (t.resume) args.push('-s', t.resume);args.push('--', t.prompt);@@@ -190,9 +199,11 @@ const opencode = {const CC_NAMES = { read_file: 'Read', shell: 'Bash', run_shell: 'Bash', shell_command: 'Bash', read_directory: 'LS', grep: 'Grep', glob: 'Glob', edit_file: 'Edit', write_file: 'Write', list_directory: 'LS', web_fetch: 'WebFetch', task: 'Task' };const commandCode = {id: 'command-code', name: 'Command Code', kind: 'cli', bin: 'cmd',++ // --yolo approves everything: there is no edits-only mode (harness/index.js unattended()).++ editsOnly: false,start(t, ctx = {}) {const args = [`--print=${t.prompt}`, '--output-format', 'json', '--skip-onboarding', '--no-auto-update', '-t'];-- if (t.permission !== 'read') args.push('--yolo');++ if (unattended(t)) args.push('--yolo');if (t.model) args.push('-m', t.model);if (t.effort) args.push('--effort', effortFor('command-code', t.effort));if (t.resume) args.push('--resume', t.resume);@@@ -229,6 +240,8 @@ const aider = {id: 'aider', name: 'Aider', kind: 'cli', bin: 'aider',start(t, ctx = {}) {const args = ['--message', t.prompt, '--yes-always', '--no-pretty', '--no-stream', '--no-check-update', '--no-show-release-notes'];++ // --yes-always would also run every shell command it suggests: outside a sandbox, "edit" is edits only.++ if (t.permission === 'edit' && !t.sandboxed) args.push('--no-suggest-shell-commands');if (t.permission === 'read') args.push('--dry-run');if (t.model) args.push('--model', t.model);if (t.effort) args.push('--reasoning-effort', effortFor('aider', t.effort));@@@ -130,6 +130,16 @@ function handoffNote({ prompt, from, why, tally, cwd }) {].filter((l) => l !== '').join('\n');}++/**++ * Why an agent cannot take a run at this permission, or null. An agent with no edits-only mode (OpenCode, Command Code:++ * their unattended flag approves the shell too) takes an "edit" run only inside a sandbox; on the person's own++ * computer it takes "full" (they asked for anything) or nothing.++ */++function unfitFor(adapter, { permission, sandboxed }) {++ if (!adapter || adapter.editsOnly !== false || permission !== 'edit' || sandboxed) return null;++ return `${adapter.name || adapter.id} has no edits-only mode: outside a sandbox it would run any command. Use --permission full, or run it on a paired machine`;++}++function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch, store = createStore({ env }), catalog = createHarnesses() } = {}) {const adapters = adaptersFor(env);@@@ -138,15 +148,16 @@ function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch,}/** The placement decision among the agents available here; `exclude` leaves harnesses out (a hand-off's earlier attempts). */-- function route({ task = 'edit', requirements = {}, exclude = [] } = {}) {++ function route({ task = 'edit', requirements = {}, exclude = [], permission = null, sandboxed = false } = {}) {const opts = {};const why = {};for (const h of catalog.list()) {const a = availability(adapters[h.id], env);-- const down = !a.available || exclude.includes(h.id);++ const unfit = unfitFor(adapters[h.id], { permission, sandboxed });++ const down = !a.available || exclude.includes(h.id) || !!unfit;for (const agent of catalog.agents(h.id)) {const id = `${h.id}:${agent.id || agent.model}`;-- if (down) { opts[id] = { health: 'down' }; why[id] = exclude.includes(h.id) ? 'already tried in this run' : a.reason; }++ if (down) { opts[id] = { health: 'down' }; why[id] = exclude.includes(h.id) ? 'already tried in this run' : unfit || a.reason; }}}const decision = placement.route({ task, extra: requirements, harnesses: catalog, opts });@@@ -154,7 +165,7 @@ function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch,return decision;}-- function run({ prompt, task = 'edit', agent = null, model = null, effort = null, cwd = process.cwd(), permission = 'edit', resume = null, handoff = true, maxAttempts = 3, stallMs = 10 * 60_000, timeoutMs, maxTurns, requirements = {}, signal, adapterOptions = null } = {}) {++ function run({ prompt, task = 'edit', agent = null, model = null, effort = null, cwd = process.cwd(), permission = 'edit', resume = null, handoff = true, maxAttempts = 3, stallMs = 10 * 60_000, timeoutMs, maxTurns, requirements = {}, signal, adapterOptions = null, sandboxed = false } = {}) {if (!prompt || !String(prompt).trim()) throw new Error('a run needs a prompt');if (!['read', 'edit', 'full'].includes(permission)) throw new Error('permission is read, edit or full');const runId = store.newRunId();@@@ -177,8 +188,10 @@ function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch,final = ev.result({ ok: false, text: `${agent} is not available here: ${a.reason || 'unknown agent'}` });yield final; break;}++ const unfit = unfitFor(adapters[agent], { permission, sandboxed });++ if (unfit) { final = ev.result({ ok: false, text: unfit }); yield final; break; }} else {-- const decision = route({ task, requirements, exclude: tried });++ const decision = route({ task, requirements, exclude: tried, permission, sandboxed });if (!decision.selected) {const reason = attempt === 1 ? 'no available agent can take this task' : 'no other agent is available to take over';const text = `${reason}: ${(decision.candidates || []).map((c) => `${c.id} (${c.reason || 'excluded'})`).join('; ')}`;@@@ -200,7 +213,7 @@ function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch,const controller = new AbortController();if (signal) signal.addEventListener('abort', () => controller.abort(), { once: true });current = adapter.start({-- prompt: nextPrompt, cwd, model: modelFor, effort, resume: attempt === 1 ? resume : null, permission, maxTurns, timeoutMs, signal: controller.signal,++ prompt: nextPrompt, cwd, model: modelFor, effort, resume: attempt === 1 ? resume : null, permission, sandboxed: sandboxed === true, maxTurns, timeoutMs, signal: controller.signal,// Extra flags a caller needs (the conductor's MCP config and permission rules); the adapter decides// which of them its CLI understands. Nothing here can replace the fields above....(adapterOptions && typeof adapterOptions === 'object' ? { options: adapterOptions } : {}),@@@ -512,6 +512,10 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tmconst run = harness.run({prompt: job.prompt, task: 'edit', agent: job.agent, model: job.model, effort: job.effort || null, cwd: work,permission: job.permission, handoff: !job.agent, maxAttempts: job.kind === 'conductor' ? 1 : 3,++ // A job on the person's Node runs in its sandbox (own namespaces, a read-only root, no home folder, only++ // what the job was given); the Node sets OPENVIBE_JOB_ID there. Only inside it may an agent with no++ // edits-only mode take an "edit" task unattended (harness/index.js unfitFor).++ sandboxed: !!env.OPENVIBE_JOB_ID,// A task carried on in the same conversation resumes its session (RESUME in the environment, or the job's// own resume field).resume: job.resume || null,@@@ -43,7 +43,8 @@ const results = (events) => events.filter((e) => e.type === 'result');for (const [agent, word] of [['claude-code', 'claude'], ['codex', 'codex'], ['opencode', 'opencode'], ['command-code', 'cmd']]) {await check(`${agent}: the agent's output becomes the shared events, with one result`, async () => {-- const events = await collect(harness().run({ prompt: 'fix a.js', agent, cwd: f.work, handoff: false }));++ // OpenCode and Command Code take an "edit" run only inside a sandbox (test/harness-sandbox.test.js): run as a Node job.++ const events = await collect(harness().run({ prompt: 'fix a.js', agent, cwd: f.work, handoff: false, sandboxed: true }));assert.strictEqual(events[0].type, 'system');assert.strictEqual(events[0].subtype, 'init');assert.ok(events[0].session_id, 'a session id to resume');nnew file mode 100644@@@ -0,0 +1,88 @@++'use strict';++/**++ * "Edit" means edits on the person's own computer (the owner, 2026-11-01: "make sure our system for the agents is++ * sandboxed properly"). OpenCode's --auto and Command Code's --yolo approve every tool, the shell included: there is no++ * edits-only mode, so they run unattended only for "full", or for "edit" inside a sandbox (a job on the person's Node,++ * which sets OPENVIBE_JOB_ID). Outside one, an "edit" run never starts them: routing leaves them out with the reason, a++ * run that names one is refused in words, and Aider, which says yes to everything it asks, suggests no shell commands.++ */++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { check, done } = require('./helpers/boot');++const cli = require('../harness/adapters/cli');++const { createHarness } = require('../harness');++const { createStore } = require('../harness/sessions');++const { fakeAgents } = require('./helpers/fake-agents');++++// One stand-in for every CLI: it records its argv and says nothing.++const FAKE = `++const fs = require('fs');++if (process.argv.includes('--version')) { console.log('9.9.9'); process.exit(0); }++fs.writeFileSync(process.env.FAKE_RECORD, JSON.stringify({ argv: process.argv.slice(2) }));++`;++const collect = async (run) => { const out = []; for await (const e of run) out.push(e); return out; };++++(async () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-sandbox-'));++ const bin = path.join(dir, 'bin');++ fs.mkdirSync(bin);++ ['claude', 'codex', 'cmd', 'aider', 'opencode'].forEach((name) => fs.writeFileSync(path.join(bin, name), `#!${process.execPath}\n'use strict';\n${FAKE}\n`, { mode: 0o755 }));++ const record = path.join(dir, 'record.json');++ const argvOf = async (adapter, task) => {++ fs.rmSync(record, { force: true });++ for await (const e of adapter.start({ prompt: 'tidy the module', cwd: dir, ...task }, { env: { PATH: bin, FAKE_RECORD: record } })) void e;++ return JSON.parse(fs.readFileSync(record, 'utf8')).argv;++ };++ const f = fakeAgents({ claude: 'ok', codex: 'ok', opencode: 'ok', cmd: 'ok' });++ const harness = () => createHarness({ env: f.env, store: createStore({ env: f.env }) });++ try {++ await check('OpenCode and Command Code run unattended only for "full" or inside a sandbox', async () => {++ assert.strictEqual(cli.opencode.editsOnly, false);++ assert.strictEqual(cli.commandCode.editsOnly, false);++ assert.ok(!(await argvOf(cli.opencode, { permission: 'edit' })).includes('--auto'), 'edit on the person’s computer: no --auto');++ assert.ok((await argvOf(cli.opencode, { permission: 'edit', sandboxed: true })).includes('--auto'), 'edit in a Node sandbox: --auto');++ assert.ok((await argvOf(cli.opencode, { permission: 'full' })).includes('--auto'), 'full: --auto');++ assert.ok(!(await argvOf(cli.opencode, { permission: 'read', sandboxed: true })).includes('--auto'), 'read: never');++ assert.ok(!(await argvOf(cli.commandCode, { permission: 'edit' })).includes('--yolo'), 'Command Code: no --yolo for edit outside a sandbox');++ assert.ok((await argvOf(cli.commandCode, { permission: 'edit', sandboxed: true })).includes('--yolo'));++ assert.ok((await argvOf(cli.commandCode, { permission: 'full' })).includes('--yolo'));++ assert.ok(!(await argvOf(cli.commandCode, { permission: 'edit', sandboxed: 'yes' })).includes('--yolo'), 'only a real true counts');++ });++++ await check('Aider suggests no shell commands for "edit" on the person’s computer', async () => {++ assert.ok((await argvOf(cli.aider, { permission: 'edit' })).includes('--no-suggest-shell-commands'));++ assert.ok(!(await argvOf(cli.aider, { permission: 'edit', sandboxed: true })).includes('--no-suggest-shell-commands'), 'in a sandbox it may run the tests it suggests');++ assert.ok(!(await argvOf(cli.aider, { permission: 'full' })).includes('--no-suggest-shell-commands'));++ });++++ await check('routing an "edit" run outside a sandbox leaves them out, and says why', () => {++ const h = harness();++ const out = h.route({ task: 'edit', permission: 'edit', sandboxed: false });++ const left = (id) => out.candidates.filter((c) => c.id.startsWith(`${id}:`));++ ['opencode', 'command-code'].forEach((id) => left(id).forEach((c) => {++ assert.strictEqual(c.included, false, `${c.id} is left out`);++ assert.match(c.reason, /no edits-only mode/, c.reason);++ }));++ assert.ok(!/^(opencode|command-code):/.test(out.selected || ''), `selected ${out.selected}`);++ const inBox = h.route({ task: 'edit', permission: 'edit', sandboxed: true });++ assert.ok(inBox.candidates.filter((c) => /^(opencode|command-code):/.test(c.id)).some((c) => c.included !== false), 'inside a sandbox they may be picked');++ });++++ await check('a run that names one for "edit" outside a sandbox is refused in words, and nothing starts', async () => {++ const events = await collect(harness().run({ prompt: 'tidy it', agent: 'opencode', permission: 'edit', cwd: dir, handoff: false }));++ const result = events.find((e) => e.type === 'result');++ assert.ok(result && result.is_error, 'refused');++ assert.match(result.result, /OpenCode has no edits-only mode/);++ assert.match(result.result, /--permission full|paired machine/);++ assert.ok(!events.some((e) => e.type === 'assistant'), 'the agent never ran');++ const ok = await collect(harness().run({ prompt: 'tidy it', agent: 'opencode', permission: 'edit', sandboxed: true, cwd: dir, handoff: false }));++ assert.ok(ok.some((e) => e.type === 'assistant'), 'inside a sandbox it runs');++ });++ } finally {++ fs.rmSync(dir, { recursive: true, force: true });++ f.cleanup();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -25,7 +25,9 @@ const USAGE = `openvibe-codes ${VERSION} — code with any agent, from your term
@@ -25,7 +25,9 @@ const USAGE = `openvibe-codes ${VERSION} — code with any agent, from your term
aider, minimax-code, deepseek, openai-compatible
aider, minimax-code, deepseek, openai-compatible
--model <name> the first agent's model
--model <name> the first agent's model
--permission read|edit|full read: look and answer; edit: change files (default);
--permission read|edit|full read: look and answer; edit: change files (default);
- full: anything, unattended
+ full: anything, unattended. OpenCode and Command Code have no
+ edits-only mode: here they need full (on a paired machine's
+ sandboxed jobs, edit is enough)
--cwd <dir> where the agent works (default: here)
--cwd <dir> where the agent works (default: here)
--no-handoff stop at the first agent's result
--no-handoff stop at the first agent's result
--attempts <n> at most this many agents in one run (default 3)
--attempts <n> at most this many agents in one run (default 3)
@@ -142,12 +142,21 @@ const codex = {
@@ -142,12 +142,21 @@ const codex = {
};
};
// ── OpenCode: `opencode run --format json` (text, tool_use, step_finish, error parts) ──
// ── OpenCode: `opencode run --format json` (text, tool_use, step_finish, error parts) ──
+/**
+ * May an agent with no edits-only mode run unattended (approve every tool, the shell included)? For "full", or for
+ * "edit" inside a sandbox (a job on the person's Node: its own namespaces, a read-only root, no home folder), never
+ * for "edit" on the person's own computer, where "edit" promises file changes only.
+ */
+const unattended = (t) => t.permission === 'full' || (t.permission === 'edit' && t.sandboxed === true);
+
const OC_NAMES = { shell: 'Bash', bash: 'Bash', read: 'Read', edit: 'Edit', write: 'Write', patch: 'Edit', multiedit: 'Edit', grep: 'Grep', glob: 'Glob', list: 'LS', webfetch: 'WebFetch', websearch: 'WebSearch', task: 'Task', todowrite: 'TodoWrite' };
const OC_NAMES = { shell: 'Bash', bash: 'Bash', read: 'Read', edit: 'Edit', write: 'Write', patch: 'Edit', multiedit: 'Edit', grep: 'Grep', glob: 'Glob', list: 'LS', webfetch: 'WebFetch', websearch: 'WebSearch', task: 'Task', todowrite: 'TodoWrite' };
const opencode = {
const opencode = {
id: 'opencode', name: 'OpenCode', kind: 'cli', bin: 'opencode',
id: 'opencode', name: 'OpenCode', kind: 'cli', bin: 'opencode',
+ // --auto approves every tool, the shell included: there is no edits-only mode (harness/index.js unattended()).
+ editsOnly: false,
start(t, ctx = {}) {
start(t, ctx = {}) {
const args = ['run', '--format', 'json'];
const args = ['run', '--format', 'json'];
- if (t.permission !== 'read') args.push('--auto');
+ if (unattended(t)) args.push('--auto');
if (t.model) args.push('-m', t.model);
if (t.model) args.push('-m', t.model);
if (t.resume) args.push('-s', t.resume);
if (t.resume) args.push('-s', t.resume);
args.push('--', t.prompt);
args.push('--', t.prompt);
@@ -190,9 +199,11 @@ const opencode = {
@@ -190,9 +199,11 @@ const opencode = {
const CC_NAMES = { read_file: 'Read', shell: 'Bash', run_shell: 'Bash', shell_command: 'Bash', read_directory: 'LS', grep: 'Grep', glob: 'Glob', edit_file: 'Edit', write_file: 'Write', list_directory: 'LS', web_fetch: 'WebFetch', task: 'Task' };
const CC_NAMES = { read_file: 'Read', shell: 'Bash', run_shell: 'Bash', shell_command: 'Bash', read_directory: 'LS', grep: 'Grep', glob: 'Glob', edit_file: 'Edit', write_file: 'Write', list_directory: 'LS', web_fetch: 'WebFetch', task: 'Task' };
const commandCode = {
const commandCode = {
id: 'command-code', name: 'Command Code', kind: 'cli', bin: 'cmd',
id: 'command-code', name: 'Command Code', kind: 'cli', bin: 'cmd',
+ // --yolo approves everything: there is no edits-only mode (harness/index.js unattended()).
+ editsOnly: false,
start(t, ctx = {}) {
start(t, ctx = {}) {
const args = [`--print=${t.prompt}`, '--output-format', 'json', '--skip-onboarding', '--no-auto-update', '-t'];
const args = [`--print=${t.prompt}`, '--output-format', 'json', '--skip-onboarding', '--no-auto-update', '-t'];
- if (t.permission !== 'read') args.push('--yolo');
+ if (unattended(t)) args.push('--yolo');
if (t.model) args.push('-m', t.model);
if (t.model) args.push('-m', t.model);
if (t.effort) args.push('--effort', effortFor('command-code', t.effort));
if (t.effort) args.push('--effort', effortFor('command-code', t.effort));
if (t.resume) args.push('--resume', t.resume);
if (t.resume) args.push('--resume', t.resume);
@@ -229,6 +240,8 @@ const aider = {
@@ -229,6 +240,8 @@ const aider = {
id: 'aider', name: 'Aider', kind: 'cli', bin: 'aider',
id: 'aider', name: 'Aider', kind: 'cli', bin: 'aider',
start(t, ctx = {}) {
start(t, ctx = {}) {
const args = ['--message', t.prompt, '--yes-always', '--no-pretty', '--no-stream', '--no-check-update', '--no-show-release-notes'];
const args = ['--message', t.prompt, '--yes-always', '--no-pretty', '--no-stream', '--no-check-update', '--no-show-release-notes'];
+ // --yes-always would also run every shell command it suggests: outside a sandbox, "edit" is edits only.
+ if (t.permission === 'edit' && !t.sandboxed) args.push('--no-suggest-shell-commands');
if (t.permission === 'read') args.push('--dry-run');
if (t.permission === 'read') args.push('--dry-run');
if (t.model) args.push('--model', t.model);
if (t.model) args.push('--model', t.model);
if (t.effort) args.push('--reasoning-effort', effortFor('aider', t.effort));
if (t.effort) args.push('--reasoning-effort', effortFor('aider', t.effort));
@@ -130,6 +130,16 @@ function handoffNote({ prompt, from, why, tally, cwd }) {
@@ -130,6 +130,16 @@ function handoffNote({ prompt, from, why, tally, cwd }) {
].filter((l) => l !== '').join('\n');
].filter((l) => l !== '').join('\n');
}
}
+/**
+ * Why an agent cannot take a run at this permission, or null. An agent with no edits-only mode (OpenCode, Command Code:
+ * their unattended flag approves the shell too) takes an "edit" run only inside a sandbox; on the person's own
+ * computer it takes "full" (they asked for anything) or nothing.
+ */
+function unfitFor(adapter, { permission, sandboxed }) {
+ if (!adapter || adapter.editsOnly !== false || permission !== 'edit' || sandboxed) return null;
+ return `${adapter.name || adapter.id} has no edits-only mode: outside a sandbox it would run any command. Use --permission full, or run it on a paired machine`;
+}
+
function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch, store = createStore({ env }), catalog = createHarnesses() } = {}) {
function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch, store = createStore({ env }), catalog = createHarnesses() } = {}) {
const adapters = adaptersFor(env);
const adapters = adaptersFor(env);
@@ -138,15 +148,16 @@ function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch,
@@ -138,15 +148,16 @@ function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch,
}
}
/** The placement decision among the agents available here; `exclude` leaves harnesses out (a hand-off's earlier attempts). */
/** The placement decision among the agents available here; `exclude` leaves harnesses out (a hand-off's earlier attempts). */
- function route({ task = 'edit', requirements = {}, exclude = [] } = {}) {
+ function route({ task = 'edit', requirements = {}, exclude = [], permission = null, sandboxed = false } = {}) {
const opts = {};
const opts = {};
const why = {};
const why = {};
for (const h of catalog.list()) {
for (const h of catalog.list()) {
const a = availability(adapters[h.id], env);
const a = availability(adapters[h.id], env);
- const down = !a.available || exclude.includes(h.id);
+ const unfit = unfitFor(adapters[h.id], { permission, sandboxed });
+ const down = !a.available || exclude.includes(h.id) || !!unfit;
for (const agent of catalog.agents(h.id)) {
for (const agent of catalog.agents(h.id)) {
const id = `${h.id}:${agent.id || agent.model}`;
const id = `${h.id}:${agent.id || agent.model}`;
- if (down) { opts[id] = { health: 'down' }; why[id] = exclude.includes(h.id) ? 'already tried in this run' : a.reason; }
+ if (down) { opts[id] = { health: 'down' }; why[id] = exclude.includes(h.id) ? 'already tried in this run' : unfit || a.reason; }
}
}
}
}
const decision = placement.route({ task, extra: requirements, harnesses: catalog, opts });
const decision = placement.route({ task, extra: requirements, harnesses: catalog, opts });
@@ -154,7 +165,7 @@ function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch,
@@ -154,7 +165,7 @@ function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch,
return decision;
return decision;
}
}
- function run({ prompt, task = 'edit', agent = null, model = null, effort = null, cwd = process.cwd(), permission = 'edit', resume = null, handoff = true, maxAttempts = 3, stallMs = 10 * 60_000, timeoutMs, maxTurns, requirements = {}, signal, adapterOptions = null } = {}) {
+ function run({ prompt, task = 'edit', agent = null, model = null, effort = null, cwd = process.cwd(), permission = 'edit', resume = null, handoff = true, maxAttempts = 3, stallMs = 10 * 60_000, timeoutMs, maxTurns, requirements = {}, signal, adapterOptions = null, sandboxed = false } = {}) {
if (!prompt || !String(prompt).trim()) throw new Error('a run needs a prompt');
if (!prompt || !String(prompt).trim()) throw new Error('a run needs a prompt');
if (!['read', 'edit', 'full'].includes(permission)) throw new Error('permission is read, edit or full');
if (!['read', 'edit', 'full'].includes(permission)) throw new Error('permission is read, edit or full');
const runId = store.newRunId();
const runId = store.newRunId();
@@ -177,8 +188,10 @@ function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch,
@@ -177,8 +188,10 @@ function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch,
final = ev.result({ ok: false, text: `${agent} is not available here: ${a.reason || 'unknown agent'}` });
final = ev.result({ ok: false, text: `${agent} is not available here: ${a.reason || 'unknown agent'}` });
yield final; break;
yield final; break;
}
}
+ const unfit = unfitFor(adapters[agent], { permission, sandboxed });
+ if (unfit) { final = ev.result({ ok: false, text: unfit }); yield final; break; }
} else {
} else {
- const decision = route({ task, requirements, exclude: tried });
+ const decision = route({ task, requirements, exclude: tried, permission, sandboxed });
if (!decision.selected) {
if (!decision.selected) {
const reason = attempt === 1 ? 'no available agent can take this task' : 'no other agent is available to take over';
const reason = attempt === 1 ? 'no available agent can take this task' : 'no other agent is available to take over';
const text = `${reason}: ${(decision.candidates || []).map((c) => `${c.id} (${c.reason || 'excluded'})`).join('; ')}`;
const text = `${reason}: ${(decision.candidates || []).map((c) => `${c.id} (${c.reason || 'excluded'})`).join('; ')}`;
@@ -200,7 +213,7 @@ function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch,
@@ -200,7 +213,7 @@ function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch,
const controller = new AbortController();
const controller = new AbortController();
if (signal) signal.addEventListener('abort', () => controller.abort(), { once: true });
if (signal) signal.addEventListener('abort', () => controller.abort(), { once: true });
current = adapter.start({
current = adapter.start({
- prompt: nextPrompt, cwd, model: modelFor, effort, resume: attempt === 1 ? resume : null, permission, maxTurns, timeoutMs, signal: controller.signal,
+ prompt: nextPrompt, cwd, model: modelFor, effort, resume: attempt === 1 ? resume : null, permission, sandboxed: sandboxed === true, maxTurns, timeoutMs, signal: controller.signal,
// Extra flags a caller needs (the conductor's MCP config and permission rules); the adapter decides
// Extra flags a caller needs (the conductor's MCP config and permission rules); the adapter decides
// which of them its CLI understands. Nothing here can replace the fields above.
// which of them its CLI understands. Nothing here can replace the fields above.
...(adapterOptions && typeof adapterOptions === 'object' ? { options: adapterOptions } : {}),
...(adapterOptions && typeof adapterOptions === 'object' ? { options: adapterOptions } : {}),
@@ -512,6 +512,10 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
@@ -512,6 +512,10 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
const run = harness.run({
const run = harness.run({
prompt: job.prompt, task: 'edit', agent: job.agent, model: job.model, effort: job.effort || null, cwd: work,
prompt: job.prompt, task: 'edit', agent: job.agent, model: job.model, effort: job.effort || null, cwd: work,
permission: job.permission, handoff: !job.agent, maxAttempts: job.kind === 'conductor' ? 1 : 3,
permission: job.permission, handoff: !job.agent, maxAttempts: job.kind === 'conductor' ? 1 : 3,
+ // A job on the person's Node runs in its sandbox (own namespaces, a read-only root, no home folder, only
+ // what the job was given); the Node sets OPENVIBE_JOB_ID there. Only inside it may an agent with no
+ // edits-only mode take an "edit" task unattended (harness/index.js unfitFor).
+ sandboxed: !!env.OPENVIBE_JOB_ID,
// A task carried on in the same conversation resumes its session (RESUME in the environment, or the job's
// A task carried on in the same conversation resumes its session (RESUME in the environment, or the job's
// own resume field).
// own resume field).
resume: job.resume || null,
resume: job.resume || null,
@@ -43,7 +43,8 @@ const results = (events) => events.filter((e) => e.type === 'result');
@@ -43,7 +43,8 @@ const results = (events) => events.filter((e) => e.type === 'result');
for (const [agent, word] of [['claude-code', 'claude'], ['codex', 'codex'], ['opencode', 'opencode'], ['command-code', 'cmd']]) {
for (const [agent, word] of [['claude-code', 'claude'], ['codex', 'codex'], ['opencode', 'opencode'], ['command-code', 'cmd']]) {
await check(`${agent}: the agent's output becomes the shared events, with one result`, async () => {
await check(`${agent}: the agent's output becomes the shared events, with one result`, async () => {
- const events = await collect(harness().run({ prompt: 'fix a.js', agent, cwd: f.work, handoff: false }));
+ // OpenCode and Command Code take an "edit" run only inside a sandbox (test/harness-sandbox.test.js): run as a Node job.
+ const events = await collect(harness().run({ prompt: 'fix a.js', agent, cwd: f.work, handoff: false, sandboxed: true }));
assert.strictEqual(events[0].type, 'system');
assert.strictEqual(events[0].type, 'system');
assert.strictEqual(events[0].subtype, 'init');
assert.strictEqual(events[0].subtype, 'init');
assert.ok(events[0].session_id, 'a session id to resume');
assert.ok(events[0].session_id, 'a session id to resume');
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+'use strict';
+/**
+ * "Edit" means edits on the person's own computer (the owner, 2026-11-01: "make sure our system for the agents is
+ * sandboxed properly"). OpenCode's --auto and Command Code's --yolo approve every tool, the shell included: there is no
+ * edits-only mode, so they run unattended only for "full", or for "edit" inside a sandbox (a job on the person's Node,
+ * which sets OPENVIBE_JOB_ID). Outside one, an "edit" run never starts them: routing leaves them out with the reason, a
+ * run that names one is refused in words, and Aider, which says yes to everything it asks, suggests no shell commands.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { check, done } = require('./helpers/boot');
+const cli = require('../harness/adapters/cli');
+const { createHarness } = require('../harness');
+const { createStore } = require('../harness/sessions');
+const { fakeAgents } = require('./helpers/fake-agents');
+
+// One stand-in for every CLI: it records its argv and says nothing.
+const FAKE = `
+const fs = require('fs');
+if (process.argv.includes('--version')) { console.log('9.9.9'); process.exit(0); }
+fs.writeFileSync(process.env.FAKE_RECORD, JSON.stringify({ argv: process.argv.slice(2) }));
+`;
+const collect = async (run) => { const out = []; for await (const e of run) out.push(e); return out; };
+
+(async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-sandbox-'));
+ const bin = path.join(dir, 'bin');
+ fs.mkdirSync(bin);
+ ['claude', 'codex', 'cmd', 'aider', 'opencode'].forEach((name) => fs.writeFileSync(path.join(bin, name), `#!${process.execPath}\n'use strict';\n${FAKE}\n`, { mode: 0o755 }));
+ const record = path.join(dir, 'record.json');
+ const argvOf = async (adapter, task) => {
+ fs.rmSync(record, { force: true });
+ for await (const e of adapter.start({ prompt: 'tidy the module', cwd: dir, ...task }, { env: { PATH: bin, FAKE_RECORD: record } })) void e;
+ return JSON.parse(fs.readFileSync(record, 'utf8')).argv;
+ };
+ const f = fakeAgents({ claude: 'ok', codex: 'ok', opencode: 'ok', cmd: 'ok' });
+ const harness = () => createHarness({ env: f.env, store: createStore({ env: f.env }) });
+ try {
+ await check('OpenCode and Command Code run unattended only for "full" or inside a sandbox', async () => {
+ assert.strictEqual(cli.opencode.editsOnly, false);
+ assert.strictEqual(cli.commandCode.editsOnly, false);
+ assert.ok(!(await argvOf(cli.opencode, { permission: 'edit' })).includes('--auto'), 'edit on the person’s computer: no --auto');
+ assert.ok((await argvOf(cli.opencode, { permission: 'edit', sandboxed: true })).includes('--auto'), 'edit in a Node sandbox: --auto');
+ assert.ok((await argvOf(cli.opencode, { permission: 'full' })).includes('--auto'), 'full: --auto');
+ assert.ok(!(await argvOf(cli.opencode, { permission: 'read', sandboxed: true })).includes('--auto'), 'read: never');
+ assert.ok(!(await argvOf(cli.commandCode, { permission: 'edit' })).includes('--yolo'), 'Command Code: no --yolo for edit outside a sandbox');
+ assert.ok((await argvOf(cli.commandCode, { permission: 'edit', sandboxed: true })).includes('--yolo'));
+ assert.ok((await argvOf(cli.commandCode, { permission: 'full' })).includes('--yolo'));
+ assert.ok(!(await argvOf(cli.commandCode, { permission: 'edit', sandboxed: 'yes' })).includes('--yolo'), 'only a real true counts');
+ });
+
+ await check('Aider suggests no shell commands for "edit" on the person’s computer', async () => {
+ assert.ok((await argvOf(cli.aider, { permission: 'edit' })).includes('--no-suggest-shell-commands'));
+ assert.ok(!(await argvOf(cli.aider, { permission: 'edit', sandboxed: true })).includes('--no-suggest-shell-commands'), 'in a sandbox it may run the tests it suggests');
+ assert.ok(!(await argvOf(cli.aider, { permission: 'full' })).includes('--no-suggest-shell-commands'));
+ });
+
+ await check('routing an "edit" run outside a sandbox leaves them out, and says why', () => {
+ const h = harness();
+ const out = h.route({ task: 'edit', permission: 'edit', sandboxed: false });
+ const left = (id) => out.candidates.filter((c) => c.id.startsWith(`${id}:`));
+ ['opencode', 'command-code'].forEach((id) => left(id).forEach((c) => {
+ assert.strictEqual(c.included, false, `${c.id} is left out`);
+ assert.match(c.reason, /no edits-only mode/, c.reason);
+ }));
+ assert.ok(!/^(opencode|command-code):/.test(out.selected || ''), `selected ${out.selected}`);
+ const inBox = h.route({ task: 'edit', permission: 'edit', sandboxed: true });
+ assert.ok(inBox.candidates.filter((c) => /^(opencode|command-code):/.test(c.id)).some((c) => c.included !== false), 'inside a sandbox they may be picked');
+ });
+
+ await check('a run that names one for "edit" outside a sandbox is refused in words, and nothing starts', async () => {
+ const events = await collect(harness().run({ prompt: 'tidy it', agent: 'opencode', permission: 'edit', cwd: dir, handoff: false }));
+ const result = events.find((e) => e.type === 'result');
+ assert.ok(result && result.is_error, 'refused');
+ assert.match(result.result, /OpenCode has no edits-only mode/);
+ assert.match(result.result, /--permission full|paired machine/);
+ assert.ok(!events.some((e) => e.type === 'assistant'), 'the agent never ran');
+ const ok = await collect(harness().run({ prompt: 'tidy it', agent: 'opencode', permission: 'edit', sandboxed: true, cwd: dir, handoff: false }));
+ assert.ok(ok.some((e) => e.type === 'assistant'), 'inside a sandbox it runs');
+ });
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ f.cleanup();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });