From c343f57d71307a9eb3b746b907dab296bed17875 Mon Sep 17 00:00:00 2001 From: FreshlyBrewedCode Date: Sat, 19 Sep 2026 11:38:38 +0000 Subject: [PATCH 1/4] feat(adapter): add prepareWorkspace to AgentAdapter interface (#37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0012 §3: the adapter prepares its own workspace. The opencode adapter writes opencode.json with the never-ask permission policy (#24). Test adapters (corpus replay, slow fake) no-op. Co-Authored-By: opencode-go/qwen3.7-plus --- src/lib/sandbox-config.test.ts | 14 +++++++------- src/replay/adapter.ts | 4 ++++ src/runtime/agent-adapter.ts | 7 +++++++ src/runtime/opencode-adapter.ts | 5 +++++ 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/lib/sandbox-config.test.ts b/src/lib/sandbox-config.test.ts index 18697df..aef292e 100644 --- a/src/lib/sandbox-config.test.ts +++ b/src/lib/sandbox-config.test.ts @@ -2,13 +2,11 @@ import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "bun:test"; -import { resetClone } from "./clone"; +import { opencodeAdapter } from "../runtime/opencode-adapter"; -const IDENTITY = { name: "Test Bot", email: "test@factory.local" }; - -describe("resetClone (#24 headless permission policy)", () => { - test("the legacy clone path writes the same opencode.json policy", async () => { - const root = mkdtempSync(join(tmpdir(), "factory-clone-sandbox-config-test-")); +describe("opencodeAdapter.prepareWorkspace (#24 headless permission policy)", () => { + test("writes opencode.json with the never-ask policy and excludes it from staging", async () => { + const root = mkdtempSync(join(tmpdir(), "factory-adapter-sandbox-config-test-")); const seed = join(root, "seed-repo"); await Bun.$`git init -b main -q ${seed}`.quiet(); await Bun.$`echo one > ${join(seed, "seed.txt")}`.quiet(); @@ -16,7 +14,9 @@ describe("resetClone (#24 headless permission policy)", () => { await Bun.$`git -C ${seed} -c user.name=seed -c user.email=seed@seed.local commit -q -m seed`.quiet(); const dir = join(root, "run-a"); - await resetClone(dir, seed, IDENTITY); + await Bun.$`git clone -q ${seed} ${dir}`.quiet(); + + await opencodeAdapter.prepareWorkspace(dir); expect(existsSync(join(dir, "opencode.json"))).toBe(true); const config = JSON.parse(await Bun.$`cat ${join(dir, "opencode.json")}`.text()) as { diff --git a/src/replay/adapter.ts b/src/replay/adapter.ts index 6f5a85f..5130bcc 100644 --- a/src/replay/adapter.ts +++ b/src/replay/adapter.ts @@ -65,6 +65,8 @@ export function createCorpusReplayAdapter(path: string): AgentAdapter { let cursor = 0; return { + async prepareWorkspace(_dir: string): Promise {}, + stream(_options: AgentAdapterOptions): AsyncIterable { const index = cursor; cursor += 1; @@ -95,6 +97,8 @@ export function createCorpusReplayAdapter(path: string): AgentAdapter { */ export function createSlowFakeAdapter(chunks: ReadonlyArray, delayMs = 20): AgentAdapter { return { + async prepareWorkspace(_dir: string): Promise {}, + stream(_options: AgentAdapterOptions): AsyncIterable { return { async *[Symbol.asyncIterator]() { diff --git a/src/runtime/agent-adapter.ts b/src/runtime/agent-adapter.ts index 5af341a..cd9c064 100644 --- a/src/runtime/agent-adapter.ts +++ b/src/runtime/agent-adapter.ts @@ -46,4 +46,11 @@ export interface AgentAdapterYield { export interface AgentAdapter { stream(options: AgentAdapterOptions): AsyncIterable; + /** + * ADR 0012 §3: the adapter prepares its own workspace before the first + * agent step. The opencode adapter writes `opencode.json` with a + * never-ask permission policy (#24) and excludes it from staging. Other + * adapters may no-op (replay, scratch). + */ + prepareWorkspace(dir: string): Promise; } diff --git a/src/runtime/opencode-adapter.ts b/src/runtime/opencode-adapter.ts index edefb3f..538f9d0 100644 --- a/src/runtime/opencode-adapter.ts +++ b/src/runtime/opencode-adapter.ts @@ -21,6 +21,7 @@ import type { AgentAdapterYield, AgentSignal, } from "./agent-adapter"; +import { writeHeadlessPermissions } from "../lib/sandbox-config"; /** * Inspect one raw AG-UI chunk for opencode-specific signals. Returns the @@ -86,6 +87,10 @@ async function freePort(): Promise { } export const opencodeAdapter: AgentAdapter = { + async prepareWorkspace(dir: string): Promise { + await writeHeadlessPermissions(dir); + }, + async *stream(options: AgentAdapterOptions): AsyncIterable { const sandboxDefinition = defineSandbox({ id: "factory-run", From 5ee46b6b5ccc4c86f38046c9dc53393e3876d491 Mon Sep 17 00:00:00 2001 From: FreshlyBrewedCode Date: Sat, 19 Sep 2026 11:39:29 +0000 Subject: [PATCH 2/4] feat(runtime): call adapter.prepareWorkspace before workflow runs (#37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0012 §3: workspace preparation is the agent runtime's responsibility. Called for clone workspaces only — scratch has no git tree to prepare. Covers both the per-run allocateWorkspace path and the legacy resetClone path, since both reach startRun. Co-Authored-By: opencode-go/qwen3.7-plus --- src/runtime/agent-step.test.ts | 1 + src/runtime/agent-step.usage.test.ts | 1 + src/runtime/run.test.ts | 53 ++++++++++++++++++++++++++++ src/runtime/run.ts | 4 +++ 4 files changed, 59 insertions(+) diff --git a/src/runtime/agent-step.test.ts b/src/runtime/agent-step.test.ts index a33f438..17069d1 100644 --- a/src/runtime/agent-step.test.ts +++ b/src/runtime/agent-step.test.ts @@ -9,6 +9,7 @@ function makeYield(chunk: unknown, signal?: AgentSignal): AgentAdapterYield { function signalAdapter(yields: ReadonlyArray) { return { + async prepareWorkspace(_dir: string): Promise {}, stream() { return { async *[Symbol.asyncIterator]() { diff --git a/src/runtime/agent-step.usage.test.ts b/src/runtime/agent-step.usage.test.ts index 00e65b4..0031220 100644 --- a/src/runtime/agent-step.usage.test.ts +++ b/src/runtime/agent-step.usage.test.ts @@ -28,6 +28,7 @@ async function runBlock(chunks: ReadonlyArray) { model: "model", prompt: "prompt", adapter: { + async prepareWorkspace(_dir: string): Promise {}, async *stream(): AsyncGenerator { for (const chunk of chunks) { yield { chunk }; diff --git a/src/runtime/run.test.ts b/src/runtime/run.test.ts index 19880b6..aa57c8d 100644 --- a/src/runtime/run.test.ts +++ b/src/runtime/run.test.ts @@ -11,6 +11,7 @@ import type { RunEvent } from "../events"; import { defineWorkflow, Schema } from "../workflow"; import { createSlowFakeAdapter } from "../replay/adapter"; import { startRun, RunCancelledSignal } from "./run"; +import type { AgentAdapter } from "./agent-adapter"; describe("RunCancelledSignal as TaggedError (#34)", () => { test("carries the _tag", () => { @@ -299,3 +300,55 @@ describe("startRun model precedence (issue #16)", () => { ); }); }); + +describe("startRun prepareWorkspace (ADR 0012 §3, #37)", () => { + function trackingAdapter(): AgentAdapter & { readonly prepared: Array } { + const prepared: Array = []; + const inner = createSlowFakeAdapter([]); + return { + prepared, + async prepareWorkspace(dir: string): Promise { + prepared.push(dir); + }, + stream: inner.stream.bind(inner), + }; + } + + test("calls adapter.prepareWorkspace for a clone workspace before the workflow runs", async () => { + const events: Array = []; + const adapter = trackingAdapter(); + const workflow = defineWorkflow("prep-clone", { + input: Schema.Struct({}), + run: async () => ({}), + }); + + await startRun(workflow, { + runId: "run-prep-clone", + dir: "/tmp/clone-dir", + input: {}, + adapter, + onEvent: (event) => events.push(event), + }).result; + + expect(adapter.prepared).toEqual(["/tmp/clone-dir"]); + }); + + test("does not call adapter.prepareWorkspace for a scratch workspace", async () => { + const adapter = trackingAdapter(); + const workflow = defineWorkflow("prep-scratch", { + input: Schema.Struct({}), + run: async () => ({}), + }); + + await startRun(workflow, { + runId: "run-prep-scratch", + dir: "/tmp/scratch-dir", + input: {}, + adapter, + workspaceKind: "scratch", + onEvent: () => {}, + }).result; + + expect(adapter.prepared).toEqual([]); + }); +}); diff --git a/src/runtime/run.ts b/src/runtime/run.ts index 7119c4f..b933b78 100644 --- a/src/runtime/run.ts +++ b/src/runtime/run.ts @@ -478,6 +478,10 @@ export function startRun( }); try { + if (workspaceKind === "clone") { + await options.adapter.prepareWorkspace(options.dir); + } + const output = await workflow.run(ctx, decodedInput); if (workflow.output !== undefined) { From 2113bbf9bf2b7825fda9720211169628fd3079c8 Mon Sep 17 00:00:00 2001 From: FreshlyBrewedCode Date: Sat, 19 Sep 2026 11:44:23 +0000 Subject: [PATCH 3/4] refactor(workspace): remove headless permission writing from workspace allocator (#37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/workspace.ts and lib/clone.ts no longer import or call writeHeadlessPermissions. The existsSync guard in allocateWorkspace (needed only for the injected-fake-exec test) is gone along with the responsibility. The opencode adapter now owns workspace preparation via prepareWorkspace (ADR 0012 §3), called by startRun when the caller signals a clone workspace was provisioned. The daemon sets the flag when allocateWorkspace ran; the CLI/HTTP legacy paths set it when resetClone ran. sandbox-config.test.ts now tests opencodeAdapter.prepareWorkspace directly instead of going through resetClone. Co-Authored-By: opencode-go/qwen3.7-plus --- .../.tanstack-projected-1e95f9272dfe038f | 0 src/cli.ts | 1 + src/lib/clone.ts | 4 --- src/lib/sandbox-config.ts | 5 ++-- src/lib/workspace.test.ts | 26 ------------------- src/lib/workspace.ts | 8 ------ src/runtime/run.test.ts | 6 ++--- src/runtime/run.ts | 9 ++++++- src/server/http.ts | 3 +++ src/server/runs.ts | 9 +++++++ 10 files changed, 27 insertions(+), 44 deletions(-) create mode 100644 data/src/factory/.factory/workspaces/run-69780571-15e8-442a-83cf-2abda1fc3f2e/.tanstack-projected-1e95f9272dfe038f diff --git a/data/src/factory/.factory/workspaces/run-69780571-15e8-442a-83cf-2abda1fc3f2e/.tanstack-projected-1e95f9272dfe038f b/data/src/factory/.factory/workspaces/run-69780571-15e8-442a-83cf-2abda1fc3f2e/.tanstack-projected-1e95f9272dfe038f new file mode 100644 index 0000000..e69de29 diff --git a/src/cli.ts b/src/cli.ts index a1ff987..d0c1fcf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -72,6 +72,7 @@ export async function runCli(options: CliOptions): Promise { dir: options.dir, input: options.input, adapter: options.adapter, + prepareWorkspace: options.clone !== undefined, ...(repo !== undefined ? { repo } : {}), onEvent: (event) => { console.log(formatEvent(event)); diff --git a/src/lib/clone.ts b/src/lib/clone.ts index 6c5b894..bc3a91a 100644 --- a/src/lib/clone.ts +++ b/src/lib/clone.ts @@ -8,7 +8,6 @@ import { existsSync } from "node:fs"; import { rm } from "node:fs/promises"; import { hostExec } from "./exec"; -import { writeHeadlessPermissions } from "./sandbox-config"; export interface GitIdentity { readonly name: string; @@ -46,7 +45,4 @@ export async function resetClone( ); } } - - // Headless permission policy (#24), same as the workspace path. - await writeHeadlessPermissions(dir); } diff --git a/src/lib/sandbox-config.ts b/src/lib/sandbox-config.ts index e829b16..803da42 100644 --- a/src/lib/sandbox-config.ts +++ b/src/lib/sandbox-config.ts @@ -7,8 +7,9 @@ * ask is a permanent silent deadlock of the run, its WIP slot, and its * workspace. * - * `allocateWorkspace`/`resetClone` write `opencode.json` into every run tree - * with `permission: {"*": "allow"}`, verified against opencode's + * ADR 0012 §3 (#37): the opencode adapter calls `writeHeadlessPermissions` + * from `prepareWorkspace`, writing `opencode.json` with `permission: + * {"*": "allow"}` into every run tree. Verified against opencode's * `PermissionConfig` schema (`"*"`, `"allow"`, and every category including * `external_directory` are accepted keys). The tree is a throwaway sandbox * clone where `gh`/`git` already run host-side; the asks this eliminates are diff --git a/src/lib/workspace.test.ts b/src/lib/workspace.test.ts index 5fe93df..dfa8305 100644 --- a/src/lib/workspace.test.ts +++ b/src/lib/workspace.test.ts @@ -145,32 +145,6 @@ describe("allocateWorkspace (D28)", () => { rmSync(root, { recursive: true, force: true }); }); - test("the allocated tree carries the headless permission policy (#24), excluded from staging", async () => { - const root = mkdtempSync(join(tmpdir(), "factory-workspace-sandbox-config-test-")); - const workspaceRoot = join(root, "workspaces"); - const seed = join(root, "seed-repo"); - await seedRepo(seed, "one"); - - const dir = await allocateWorkspace({ - runId: "run-a", - workspaceRoot, - sshUrl: seed, - identity: IDENTITY, - retainedWorkspaces: 10, - }); - - const config = JSON.parse(await Bun.$`cat ${join(dir, "opencode.json")}`.text()) as { - permission: Record; - }; - expect(config.permission).toEqual({ "*": "allow" }); - - const untracked = ( - await Bun.$`git -C ${dir} status --porcelain --untracked-files=all`.text() - ).trim(); - expect(untracked).toBe(""); - rmSync(root, { recursive: true, force: true }); - }); - test("write-back from an allocated tree pushes to the configured remote, against a bare mirror (H1)", async () => { const root = mkdtempSync(join(tmpdir(), "factory-workspace-push-test-")); const workspaceRoot = join(root, "workspaces"); diff --git a/src/lib/workspace.ts b/src/lib/workspace.ts index e0ce4c7..94008f9 100644 --- a/src/lib/workspace.ts +++ b/src/lib/workspace.ts @@ -28,7 +28,6 @@ import { join } from "node:path"; import type { GitIdentity } from "./clone"; import { hostExec, type ExecFn, type ExecResult } from "./exec"; import type { WorkspaceKind } from "../workflow"; -import { writeHeadlessPermissions } from "./sandbox-config"; const MIRROR_DIR = ".mirror.git"; @@ -149,13 +148,6 @@ export async function allocateWorkspace(input: WorkspaceAllocationInput): Promis } } - // Headless permission policy (#24): the sandboxed serve must never park a - // turn on a permission ask. Guarded so an injected test fake (which spawns - // nothing and therefore leaves `dir` absent) does not fail the allocation. - if (existsSync(dir)) { - await writeHeadlessPermissions(dir); - } - await evictOldWorkspaces( workspaceRoot, retainedWorkspaces, diff --git a/src/runtime/run.test.ts b/src/runtime/run.test.ts index aa57c8d..637d2ba 100644 --- a/src/runtime/run.test.ts +++ b/src/runtime/run.test.ts @@ -314,7 +314,7 @@ describe("startRun prepareWorkspace (ADR 0012 §3, #37)", () => { }; } - test("calls adapter.prepareWorkspace for a clone workspace before the workflow runs", async () => { + test("calls adapter.prepareWorkspace when prepareWorkspace is true", async () => { const events: Array = []; const adapter = trackingAdapter(); const workflow = defineWorkflow("prep-clone", { @@ -327,13 +327,14 @@ describe("startRun prepareWorkspace (ADR 0012 §3, #37)", () => { dir: "/tmp/clone-dir", input: {}, adapter, + prepareWorkspace: true, onEvent: (event) => events.push(event), }).result; expect(adapter.prepared).toEqual(["/tmp/clone-dir"]); }); - test("does not call adapter.prepareWorkspace for a scratch workspace", async () => { + test("does not call adapter.prepareWorkspace when prepareWorkspace is absent", async () => { const adapter = trackingAdapter(); const workflow = defineWorkflow("prep-scratch", { input: Schema.Struct({}), @@ -345,7 +346,6 @@ describe("startRun prepareWorkspace (ADR 0012 §3, #37)", () => { dir: "/tmp/scratch-dir", input: {}, adapter, - workspaceKind: "scratch", onEvent: () => {}, }).result; diff --git a/src/runtime/run.ts b/src/runtime/run.ts index b933b78..abf9554 100644 --- a/src/runtime/run.ts +++ b/src/runtime/run.ts @@ -74,6 +74,13 @@ export interface StartRunOptions { * failure message. */ readonly workspaceKind?: WorkspaceKind; + /** + * ADR 0012 §3 (#37): when true, the runtime calls `adapter.prepareWorkspace` + * before the workflow runs. The caller sets this when it allocated a clone + * workspace (daemon's `allocateWorkspace` or legacy `resetClone`). Absent, + * no preparation happens (explicit caller-managed dirs, scratch). + */ + readonly prepareWorkspace?: boolean; /** * The context service behind `ctx.dispatch` (issue #14). Absent, the ctx * member is still present but throws — in-process execution is legacy and @@ -478,7 +485,7 @@ export function startRun( }); try { - if (workspaceKind === "clone") { + if (options.prepareWorkspace === true) { await options.adapter.prepareWorkspace(options.dir); } diff --git a/src/server/http.ts b/src/server/http.ts index 1088e3c..26ea22e 100644 --- a/src/server/http.ts +++ b/src/server/http.ts @@ -457,6 +457,9 @@ export function createHandler(options: ServerOptions): (req: Request) => Promise input: body.input, adapter: options.adapter, ...(typeof body.dedupeKey === "string" ? { dedupeKey: body.dedupeKey } : {}), + ...(body.clone !== undefined && typeof body.dir === "string" + ? { prepareWorkspace: true } + : {}), }; let runId: string; try { diff --git a/src/server/runs.ts b/src/server/runs.ts index 3f37c41..5ad9653 100644 --- a/src/server/runs.ts +++ b/src/server/runs.ts @@ -160,6 +160,13 @@ export interface StartTrackedRunOptions { * through to the run's model precedence chain. */ readonly agentOverrides?: { readonly model?: string }; + /** + * ADR 0012 §3 (#37): when true, the runtime calls `adapter.prepareWorkspace` + * before the workflow runs. The caller sets this when it has done a + * `resetClone` on `dir`. Combined with the daemon's own allocation check, + * this covers both clone paths. + */ + readonly prepareWorkspace?: boolean; } /** @@ -368,6 +375,8 @@ export async function startTrackedRun( dir, ...(options.repo !== undefined ? { repo: options.repo } : {}), workspaceKind: kind, + prepareWorkspace: + options.prepareWorkspace === true || (workspaceAllocated && kind === "clone"), ...(dispatch !== undefined ? { dispatch } : {}), ...(options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {}), ...(options.dedupeKey !== undefined ? { dedupeKey: options.dedupeKey } : {}), From cdf760eb7ca021bd7778cf0ea0c660f7356f824a Mon Sep 17 00:00:00 2001 From: FreshlyBrewedCode Date: Sat, 19 Sep 2026 11:46:13 +0000 Subject: [PATCH 4/4] fix: add prepareWorkspace to inline test adapters + gitignore tanstack artifacts (#37) The signalAdapter helper in agent-step.test.ts was missing the new prepareWorkspace method required by the AgentAdapter interface. Also gitignore .tanstack-projected-* and data/ created by sandbox tooling. Co-Authored-By: opencode-go/qwen3.7-plus --- .gitignore | 4 ++++ .../.tanstack-projected-1e95f9272dfe038f | 0 2 files changed, 4 insertions(+) delete mode 100644 data/src/factory/.factory/workspaces/run-69780571-15e8-442a-83cf-2abda1fc3f2e/.tanstack-projected-1e95f9272dfe038f diff --git a/.gitignore b/.gitignore index 32f0e3d..c67c60a 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,7 @@ playwright-report/ # per-machine Tailscale cert for the Vite dev server (`tailscale cert`) .certs/ + +# tanstack sandbox projected state +.tanstack-projected-* +data/ diff --git a/data/src/factory/.factory/workspaces/run-69780571-15e8-442a-83cf-2abda1fc3f2e/.tanstack-projected-1e95f9272dfe038f b/data/src/factory/.factory/workspaces/run-69780571-15e8-442a-83cf-2abda1fc3f2e/.tanstack-projected-1e95f9272dfe038f deleted file mode 100644 index e69de29..0000000