diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index a7171ce5..2dc3428c 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -126,7 +126,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // config, so the model/provider are preserved. This is what makes the // chat actually author + run solves instead of behaving like vanilla // opencode (the session cwd is the workspace, not opencodeProject.projectDir). - OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent(opencodeProject.agentsPath, opencodeProject.templatePath), + OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent(opencodeProject.agentsPath, opencodeProject.templatePath, runsRoot), }, channel: opencodeChannel, }); diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index 5f9e2d9a..b6631082 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -49,12 +49,17 @@ export function resolveJuliaProject(configValue: string): string { * the turn hang forever (headless) and nags the user on every solve (GUI). * * `external_directory` is the only load-bearing line, and it's SCOPED (least - * privilege) to the two roots the agent's file tools actually touch: + * privilege) to the three roots the agent's file tools actually touch: * - the bundled templates dir — the agent READS the solve template there; - * - /tmp/amicode-work — the scratch dir it WRITES solve.jl into. + * - /tmp/amicode-work — the scratch dir it WRITES solve.jl into; + * - the runs root — AGENTS.md tells the agent to READ a run's + * FINISHED/result.toml for results and run.log for failure tracebacks; + * without the grant each such read is an "ask" prompt (one per solve, + * worse on failures — the nag the 2026-07-03 live test hit). * (amico-run's own writes to ~/.amico/runs|julia are the subprocess's, not the - * agent's file tools, so they need no grant.) The path-scoped object form is - * accepted by opencode 1.17.3 (verified via `opencode debug config`). + * agent's file tools, so they need no grant — only the agent's read-backs do.) + * The path-scoped object form is accepted by opencode 1.17.3 (verified via + * `opencode debug config`). * * Merge safety: opencode DEEP-merges this `permission` object over the user's * global config — verified against 1.17.3 (a global `permission.doom_loop` @@ -66,7 +71,7 @@ export function resolveJuliaProject(configValue: string): string { * `webfetch` is intentionally NOT set — the solve flow never fetches a URL. */ const SCRATCH_DIR = "/tmp/amicode-work"; // matches AGENTS.md step 2/3 -export function buildOpencodeConfigContent(agentsPath: string, templatePath: string): string { +export function buildOpencodeConfigContent(agentsPath: string, templatePath: string, runsRoot: string): string { const templatesDir = path.dirname(templatePath); return JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -79,6 +84,7 @@ export function buildOpencodeConfigContent(agentsPath: string, templatePath: str [`${templatesDir}/**`]: "allow", // (belt-and-suspenders for the dir) [`${SCRATCH_DIR}/**`]: "allow", // solve.jl + solve.log it writes [`/private${SCRATCH_DIR}/**`]: "allow", // macOS: /tmp → /private/tmp + [`${runsRoot}/**`]: "allow", // run read-backs: FINISHED/result.toml/run.log }, }, }); diff --git a/packages/extension/test/opencode_config.test.ts b/packages/extension/test/opencode_config.test.ts index 9620d58d..f1961796 100644 --- a/packages/extension/test/opencode_config.test.ts +++ b/packages/extension/test/opencode_config.test.ts @@ -32,17 +32,21 @@ describe('resolveJuliaProject', () => { describe('buildOpencodeConfigContent', () => { const TPL = '/ext/templates/solve_template.jl' it('emits valid JSON whose instructions points at the (absolute) agents file', () => { - const cfg = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL)) + const cfg = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/home/u/.amico/runs/default')) expect(cfg.instructions).toEqual(['/abs/AGENTS.md']) }) - it('scopes external_directory to the template + scratch roots (least privilege), drops webfetch', () => { - const cfg = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL)) + it('scopes external_directory to the template + scratch + runs roots (least privilege), drops webfetch', () => { + const cfg = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/home/u/.amico/runs/default')) const ed = cfg.permission.external_directory expect(typeof ed).toBe('object') // path-scoped, NOT a blanket "allow" expect(ed[TPL]).toBe('allow') // the template file the agent reads expect(ed['/ext/templates/**']).toBe('allow') // its dir (belt-and-suspenders) expect(ed['/tmp/amicode-work/**']).toBe('allow') // scratch it writes solve.jl into expect(ed['/private/tmp/amicode-work/**']).toBe('allow') // macOS: /tmp → /private/tmp + // The runs root: AGENTS.md tells the agent to read FINISHED/result.toml for + // results and run.log for tracebacks — without this grant every such read is + // an external_directory "ask" prompt (one per solve, worse on failures). + expect(ed['/home/u/.amico/runs/default/**']).toBe('allow') expect(cfg.permission.bash).toBe('allow') // runs amico-run (compound launch) expect(cfg.permission.edit).toBe('allow') // fills the FILL-IN block expect(cfg.permission.webfetch).toBeUndefined() // unused by the solve flow — dropped @@ -55,7 +59,7 @@ describe('buildOpencodeConfigContent', () => { const prev = process.env.ANTHROPIC_API_KEY process.env.ANTHROPIC_API_KEY = SENTINEL try { - const content = buildOpencodeConfigContent('/abs/AGENTS.md', TPL) + const content = buildOpencodeConfigContent('/abs/AGENTS.md', TPL, '/home/u/.amico/runs/default') expect(content).not.toContain(SENTINEL) // no env-sourced key leaks in expect(content).not.toMatch(/sk-[A-Za-z0-9-]{16,}/) // no key-shaped string at all expect(content.toLowerCase()).not.toMatch(/"(apikey|api_key|authorization|bearer|token)"\s*:/) @@ -95,12 +99,15 @@ describe.skipIf(!existsSync(OC_BIN))('opencode config injection + merge (1.17.3) const out = execFileSync(OC_BIN, ['debug', 'config'], { encoding: 'utf8', env: { ...process.env, HOME: home, XDG_CONFIG_HOME: join(home, '.config'), - OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent(agentsPath, '/ext/templates/solve_template.jl') }, + OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent(agentsPath, '/ext/templates/solve_template.jl', join(home, '.amico', 'runs', 'default')) }, }) const cfg = JSON.parse(out) // our injection landed (the false-green boot_smoke couldn't catch): expect(cfg.instructions).toContain(agentsPath) // the AGENTS.md instruction injection expect(typeof cfg.permission.external_directory).toBe('object') // our injected permission key + // the runs-root grant survives the real deep-merge — the agent's post-solve + // FINISHED/result.toml/run.log read-backs must not "ask" on every run: + expect(cfg.permission.external_directory[join(home, '.amico', 'runs', 'default') + '/**']).toBe('allow') // the user's global config SURVIVED the deep-merge: expect(cfg.model).toBe('anthropic/claude-sonnet-4-6') // provider/model preserved (Q129 needs this) expect(cfg.permission.doom_loop).toBe('deny') // user permission key preserved (#22)