Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
1 change: 1 addition & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export async function runCli(options: CliOptions): Promise<number> {
dir: options.dir,
input: options.input,
adapter: options.adapter,
prepareWorkspace: options.clone !== undefined,
...(repo !== undefined ? { repo } : {}),
onEvent: (event) => {
console.log(formatEvent(event));
Expand Down
4 changes: 0 additions & 4 deletions src/lib/clone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -46,7 +45,4 @@ export async function resetClone(
);
}
}

// Headless permission policy (#24), same as the workspace path.
await writeHeadlessPermissions(dir);
}
14 changes: 7 additions & 7 deletions src/lib/sandbox-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@ 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();
await Bun.$`git -C ${seed} add seed.txt`.quiet();
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 {
Expand Down
5 changes: 3 additions & 2 deletions src/lib/sandbox-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,7 +20,7 @@
* ships it into a commit.
*/

import { appendFile, writeFile } from "node:fs/promises";

Check warning on line 23 in src/lib/sandbox-config.ts

View workflow job for this annotation

GitHub Actions / check

effecttsgo(node-builtin-import)

src/lib/sandbox-config.ts:23:39: This module reference uses the `fs` module, the corresponding Effect API is `FileSystem` from `effect`.

Check warning on line 23 in src/lib/sandbox-config.ts

View workflow job for this annotation

GitHub Actions / check

effecttsgo(node-builtin-import)

src/lib/sandbox-config.ts:23:39: This module reference uses the `fs` module, the corresponding Effect API is `FileSystem` from `effect`.
import { join } from "node:path";

const CONFIG_FILENAME = "opencode.json";
Expand All @@ -29,7 +30,7 @@
permission: { "*": "allow" },
});

export async function writeHeadlessPermissions(dir: string): Promise<void> {

Check warning on line 33 in src/lib/sandbox-config.ts

View workflow job for this annotation

GitHub Actions / check

effecttsgo(async-function)

src/lib/sandbox-config.ts:33:23: This code declares an async function, consider representing this async control flow with Effect values and `Effect.gen`.

Check warning on line 33 in src/lib/sandbox-config.ts

View workflow job for this annotation

GitHub Actions / check

effecttsgo(async-function)

src/lib/sandbox-config.ts:33:23: This code declares an async function, consider representing this async control flow with Effect values and `Effect.gen`.
await writeFile(join(dir, CONFIG_FILENAME), `${CONFIG_CONTENT}\n`);

const excludePath = join(dir, ".git", "info", "exclude");
Expand Down
26 changes: 0 additions & 26 deletions src/lib/workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
};
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");
Expand Down
8 changes: 0 additions & 8 deletions src/lib/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/replay/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export function createCorpusReplayAdapter(path: string): AgentAdapter {
let cursor = 0;

return {
async prepareWorkspace(_dir: string): Promise<void> {},

stream(_options: AgentAdapterOptions): AsyncIterable<AgentAdapterYield> {
const index = cursor;
cursor += 1;
Expand Down Expand Up @@ -95,6 +97,8 @@ export function createCorpusReplayAdapter(path: string): AgentAdapter {
*/
export function createSlowFakeAdapter(chunks: ReadonlyArray<unknown>, delayMs = 20): AgentAdapter {
return {
async prepareWorkspace(_dir: string): Promise<void> {},

stream(_options: AgentAdapterOptions): AsyncIterable<AgentAdapterYield> {
return {
async *[Symbol.asyncIterator]() {
Expand Down
7 changes: 7 additions & 0 deletions src/runtime/agent-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,11 @@ export interface AgentAdapterYield {

export interface AgentAdapter {
stream(options: AgentAdapterOptions): AsyncIterable<AgentAdapterYield>;
/**
* 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<void>;
}
1 change: 1 addition & 0 deletions src/runtime/agent-step.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ function makeYield(chunk: unknown, signal?: AgentSignal): AgentAdapterYield {

function signalAdapter(yields: ReadonlyArray<AgentAdapterYield>) {
return {
async prepareWorkspace(_dir: string): Promise<void> {},
stream() {
return {
async *[Symbol.asyncIterator]() {
Expand Down
1 change: 1 addition & 0 deletions src/runtime/agent-step.usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ async function runBlock(chunks: ReadonlyArray<unknown>) {
model: "model",
prompt: "prompt",
adapter: {
async prepareWorkspace(_dir: string): Promise<void> {},
async *stream(): AsyncGenerator<AgentAdapterYield> {
for (const chunk of chunks) {
yield { chunk };
Expand Down
5 changes: 5 additions & 0 deletions src/runtime/opencode-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -86,6 +87,10 @@ async function freePort(): Promise<number> {
}

export const opencodeAdapter: AgentAdapter = {
async prepareWorkspace(dir: string): Promise<void> {
await writeHeadlessPermissions(dir);
},

async *stream(options: AgentAdapterOptions): AsyncIterable<AgentAdapterYield> {
const sandboxDefinition = defineSandbox({
id: "factory-run",
Expand Down
53 changes: 53 additions & 0 deletions src/runtime/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -299,3 +300,55 @@ describe("startRun model precedence (issue #16)", () => {
);
});
});

describe("startRun prepareWorkspace (ADR 0012 §3, #37)", () => {
function trackingAdapter(): AgentAdapter & { readonly prepared: Array<string> } {
const prepared: Array<string> = [];
const inner = createSlowFakeAdapter([]);
return {
prepared,
async prepareWorkspace(dir: string): Promise<void> {
prepared.push(dir);
},
stream: inner.stream.bind(inner),
};
}

test("calls adapter.prepareWorkspace when prepareWorkspace is true", async () => {
const events: Array<RunEvent> = [];
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([]);
});
});
11 changes: 11 additions & 0 deletions src/runtime/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -478,6 +485,10 @@ export function startRun<I, O>(
});

try {
if (options.prepareWorkspace === true) {
await options.adapter.prepareWorkspace(options.dir);
}

const output = await workflow.run(ctx, decodedInput);

if (workflow.output !== undefined) {
Expand Down
3 changes: 3 additions & 0 deletions src/server/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions src/server/runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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 } : {}),
Expand Down
Loading