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)

OP OpenVibers committed 12 days ago · b1f2fa20fd · 6 files, +131 −10

Changes

ddiff --git a/bin/openvibe-codes.js b/bin/openvibe-codes.js
iindex 7959e33..9b949bd 100755
---- a/bin/openvibe-codes.js
++++ b/bin/openvibe-codes.js
@@@ -25,7 +25,9 @@ const USAGE = `openvibe-codes ${VERSION} — code with any agent, from your term
aider, 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)
ddiff --git a/harness/adapters/cli.js b/harness/adapters/cli.js
iindex 035b745..1176c40 100644
---- a/harness/adapters/cli.js
++++ b/harness/adapters/cli.js
@@@ -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));
ddiff --git a/harness/index.js b/harness/index.js
iindex 67543cb..fadad35 100644
---- a/harness/index.js
++++ b/harness/index.js
@@@ -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 } : {}),
ddiff --git a/harness/job.js b/harness/job.js
iindex 8e90a06..ce54a03 100644
---- a/harness/job.js
++++ b/harness/job.js
@@@ -512,6 +512,10 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
const 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,
ddiff --git a/test/harness-run.test.js b/test/harness-run.test.js
iindex 499483a..d7389ae 100644
---- a/test/harness-run.test.js
++++ b/test/harness-run.test.js
@@@ -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');
ddiff --git a/test/harness-sandbox.test.js b/test/harness-sandbox.test.js
nnew file mode 100644
iindex 0000000..d52454c
---- /dev/null
++++ b/test/harness-sandbox.test.js
@@@ -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); });