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/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.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/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/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/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/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", diff --git a/src/runtime/run.test.ts b/src/runtime/run.test.ts index 19880b6..637d2ba 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 when prepareWorkspace is true", 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, + prepareWorkspace: true, + onEvent: (event) => events.push(event), + }).result; + + expect(adapter.prepared).toEqual(["/tmp/clone-dir"]); + }); + + test("does not call adapter.prepareWorkspace when prepareWorkspace is absent", 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, + onEvent: () => {}, + }).result; + + expect(adapter.prepared).toEqual([]); + }); +}); diff --git a/src/runtime/run.ts b/src/runtime/run.ts index 7119c4f..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,6 +485,10 @@ export function startRun( }); try { + if (options.prepareWorkspace === true) { + await options.adapter.prepareWorkspace(options.dir); + } + const output = await workflow.run(ctx, decodedInput); if (workflow.output !== undefined) { 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 } : {}),