// Opt-in real-host check: OPENCODE_204=/absolute/path/to/2.0.4/opencode bun scripts/test-multi-work-host.ts // Only fake Herdr/model servers and fresh temporary repositories/databases are used. import { mkdir, mkdtemp, writeFile, chmod, readFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { createServer } from "node:net"; import { OpenCode } from "@opencode/client"; import { command } from "../src/herdr.js"; import { z } from "zod"; const binary = process.env.OPENCODE_204; if (!binary) throw new Error("Set OPENCODE_204 to an isolated OpenCode 2.0.4 binary"); const temp = await mkdtemp("/tmp/opencode/native-multi-work-"); console.log(`Fixture retained at ${temp}`); const root = join(temp, "root"), destination = join(temp, "child"), bin = join(temp, "bin"), config = join(temp, "config"); for (const path of [root, bin, config, join(temp, "plugin")]) await mkdir(path); const signal = AbortSignal.timeout(120_000); if ((await command([binary, "--version"], temp, signal)) !== "opencode v2.0.4") throw new Error("Exact 2.0.4 binary required"); await command(["git", "init", "-b", "main"], root, signal); await command( [ "git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--allow-empty", "-m", "fixture", ], root, signal, ); const inventory = (path: string, id: string) => JSON.stringify({ result: { source: { repo_key: join(root, ".git"), repo_root: root, source_checkout_path: path, source_workspace_id: id, }, worktrees: [ { path, is_linked_worktree: path !== root, is_bare: false, open_workspace_id: id }, ], }, }); await writeFile( join(bin, "herdr"), `#!/bin/sh\ncase "$1 $2" in\n"worktree create") git -C '${root}' worktree add -b "$6" '${destination}' "$8" >&2 || exit 1; printf '{"result":{"workspace":{"workspace_id":"w2"},"worktree":{"path":"${destination}","branch":"%s","is_linked_worktree":true}}}' "$6"; exit ;;\nesac\ncase "$1 $2 $4" in\n"workspace list ") echo '{"result":{"workspaces":[{"workspace_id":"w1"}]}}' ;;\n"worktree list w1") echo '${inventory(root, "w1")}' ;;\n"worktree list w2") echo '${inventory(destination, "w2")}' ;;\n*) exit 9 ;;\nesac\n`, ); await chmod(join(bin, "herdr"), 0o755); const built = await Bun.build({ entrypoints: [resolve("tests/fixtures/native-host-plugin.ts")], outdir: join(temp, "plugin"), naming: "index.js", target: "bun", format: "esm", }); if (!built.success) throw new Error(String(built.logs)); await writeFile( join(config, "opencode.json"), JSON.stringify({ plugins: [join(temp, "plugin")], agents: { build: { permissions: [{ action: "herdr_worktree", resource: "*", effect: "deny" }] }, }, experimental: { subagent_depth: 2 }, snapshots: false, update: "disable", model: "fixture/gpt-6-astra", }), ); let holdModel = false; const modelEntered = Promise.withResolvers(); const releaseModel = Promise.withResolvers(); const model = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { await request.text(); if (holdModel) { modelEntered.resolve(); await releaseModel.promise; } const chunk = { id: "fixture", object: "chat.completion.chunk", created: 0, model: "fixture", choices: [ { index: 0, delta: { role: "assistant", content: "FIXTURE_COMPLETE" }, finish_reason: null, }, ], }; return new Response( `data: ${JSON.stringify(chunk)}\n\ndata: ${JSON.stringify({ ...chunk, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\ndata: [DONE]\n\n`, { headers: { "content-type": "text/event-stream" } }, ); }, }); const socket = createServer(); await new Promise((done) => socket.listen(0, "127.0.0.1", done)); const { port } = z.object({ port: z.number() }).parse(socket.address()); await new Promise((done) => socket.close(() => done())); // Do not inherit user credentials, discovery URLs, Herdr identity, or config overrides. const env = { PATH: `${bin}:${process.env.PATH}`, HOME: join(temp, "home"), XDG_CONFIG_HOME: config, XDG_DATA_HOME: join(temp, "data"), XDG_CACHE_HOME: join(temp, "cache"), XDG_STATE_HOME: join(temp, "state"), OPENCODE_CONFIG_DIR: config, OPENCODE_DB: join(temp, "fixture.sqlite"), FIXTURE_MODEL_URL: `http://127.0.0.1:${model.port}/v1`, FIXTURE_TRACE: join(temp, "admissions.jsonl"), }; const start = () => Bun.spawn([binary, "serve", "--hostname", "127.0.0.1", "--port", String(port)], { cwd: root, env, stdout: Bun.file(join(temp, "server.log")), stderr: Bun.file(join(temp, "server-errors.log")), }); let server = start(); const headers = { authorization: "" }; const client = OpenCode.make({ baseUrl: `http://127.0.0.1:${port}`, headers }); async function ready() { for (let attempt = 0; attempt < 200; attempt++) { try { const password = (await readFile(join(temp, "server.log"), "utf8")).match( /server password (\S+)/, )?.[1]; if (password) headers.authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`; await client.server.status(); return; } catch { await Bun.sleep(100); } } throw new Error( `Host did not start: ${await readFile(join(temp, "server-errors.log"), "utf8")}`, ); } try { await ready(); const location = { directory: root }; for (let attempt = 0; attempt < 100; attempt++) { const agents = await client.agent.list({ location }); if (agents.data.some((agent) => agent.id === "multi-work")) break; if (attempt === 99) throw new Error( `multi-work unavailable: ${JSON.stringify(await client.plugin.list({ location }))}`, ); await Bun.sleep(100); } const parent = await client.session.create({ location, agent: "multi-work" }); const denied = await client.rpc.call({ rpcID: "multi-work-test", method: "delegate", location, input: { parent: parent.id, background: false }, }); if (!z.string().parse(denied.output).includes("herdr_worktree requires explicit allow")) throw new Error(`Late configured build deny was not enforced: ${JSON.stringify(denied)}`); await client.session.update({ sessionID: parent.id, permissions: [{ action: "herdr_worktree", resource: "*", effect: "allow" }], }); const result = await client.rpc.call({ rpcID: "multi-work-test", method: "delegate", location, input: { parent: parent.id, background: false }, }); const output = z .object({ metadata: z.object({ sessionID: z.string(), status: z.literal("completed") }), content: z.string(), }) .parse(JSON.parse(z.string().parse(result.output))); if (!output.content.includes("FIXTURE_COMPLETE")) throw new Error("Missing native child result"); const child = await client.session.get({ sessionID: output.metadata.sessionID }); if (child.parentID !== parent.id || child.location.directory !== destination) throw new Error("Child ancestry/placement mismatch"); const admission = z.array(z.object({ id: z.string(), directory: z.string() })).parse( (await readFile(env.FIXTURE_TRACE, "utf8")) .trim() .split("\n") .map((line) => JSON.parse(line)), ); if ( !admission.some((entry) => entry.id === child.id && entry.directory === destination) || admission.some((entry) => entry.id === child.id && entry.directory !== destination) ) throw new Error("Prompt admitted before move"); server.kill(); await server.exited; server = start(); await ready(); await client.agent.list({ location }); const resumed = await client.rpc.call({ rpcID: "multi-work-test", method: "delegate", location, input: { parent: parent.id, child: child.id, background: true }, }); const background = z .object({ metadata: z.object({ sessionID: z.literal(child.id), status: z.literal("running") }), }) .parse(JSON.parse(z.string().parse(resumed.output))); console.log("Native background child", background.metadata.sessionID); await client.session.wait({ sessionID: child.id }); if ((await client.session.get({ sessionID: child.id })).outcome !== "succeeded") throw new Error("Background child did not complete"); for (let attempt = 0; attempt < 100; attempt++) { const messages = await client.session.context({ sessionID: parent.id }); if ( messages.some( (message) => message.type === "synthetic" && message.text.includes(child.id) && message.text.includes("FIXTURE_COMPLETE"), ) ) break; if (attempt === 99) throw new Error("Missing native background completion notification"); await Bun.sleep(100); } const finalAdmissions = (await readFile(env.FIXTURE_TRACE, "utf8")) .trim() .split("\n") .map((line) => z.object({ id: z.string(), directory: z.string() }).parse(JSON.parse(line))); if ( finalAdmissions.filter((entry) => entry.id === child.id && entry.directory === destination) .length !== 2 ) throw new Error("Expected two child prompts at destination"); if ((await client.session.get({ sessionID: parent.id })).location.directory !== root) throw new Error("Parent moved"); await client.session.wait({ sessionID: parent.id }); holdModel = true; const cancel = new AbortController(); const pending = client.rpc.call( { rpcID: "multi-work-test", method: "delegate", location, input: { parent: parent.id, child: child.id, background: false }, }, { signal: cancel.signal }, ); const deadline = AbortSignal.timeout(10_000); await Promise.race([ modelEntered.promise, pending.then(() => { throw new Error("Foreground unexpectedly finished before cancellation"); }), new Promise((_resolve, reject) => deadline.addEventListener("abort", () => reject(new Error("Model was not reached")), { once: true, }), ), ]); cancel.abort(); let rejected = false; await pending.catch(() => { rejected = true; }); if (!rejected) throw new Error("Cancelled RPC did not reject"); for (let attempt = 0; attempt < 100; attempt++) { if ((await client.session.get({ sessionID: child.id })).outcome === "interrupted") break; if (attempt === 99) throw new Error("Native cancellation did not interrupt the child"); await Bun.sleep(50); } console.log( "PASS: configured build deny + session override, native foreground result, child move-before-prompt, persisted binding resume after host restart, background completion + parent notification, native foreground interruption", ); } finally { releaseModel.resolve(); server.kill(); await server.exited; model.stop(true); }