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
6 changes: 6 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1163,6 +1163,12 @@ export namespace Config {
prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"),
})
.optional(),
testbox: z
.object({
api: z.string().describe("URL of the testbox resolution API for resolving testbox names to SSH connection details"),
})
.optional()
.describe("Testbox configuration for remote execution on SSH-accessible CI runners"),
experimental: z
.object({
disable_paste_summary: z.boolean().optional(),
Expand Down
13 changes: 11 additions & 2 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { Plugin } from "../plugin"
import PROMPT_PLAN from "../session/prompt/plan.txt"
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
import MAX_STEPS from "../session/prompt/max-steps.txt"
import { Config } from "../config/config"
import { defer } from "../util/defer"
import { clone } from "remeda"
import { ToolRegistry } from "../tool/registry"
Expand Down Expand Up @@ -1236,6 +1237,11 @@ export namespace SessionPrompt {
const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
if (!userMessage) return input.messages

const cfg = await Config.get()
const testboxSuffix = cfg.testbox
? "\n\nTestbox mode is active. You MUST specify a `testbox` parameter on every tool call (read, write, edit, bash, glob, grep, list). Available testboxes are resolved by name via the testbox API."
: ""

// Original logic when experimental plan mode is disabled
if (!Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE) {
if (input.agent.name === "plan") {
Expand All @@ -1255,7 +1261,7 @@ export namespace SessionPrompt {
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: BUILD_SWITCH,
text: BUILD_SWITCH + testboxSuffix,
synthetic: true,
})
}
Expand All @@ -1276,7 +1282,10 @@ export namespace SessionPrompt {
sessionID: userMessage.info.sessionID,
type: "text",
text:
BUILD_SWITCH + "\n\n" + `A plan file exists at ${plan}. You should execute on the plan defined within it`,
BUILD_SWITCH +
testboxSuffix +
"\n\n" +
`A plan file exists at ${plan}. You should execute on the plan defined within it`,
synthetic: true,
})
userMessage.parts.push(part)
Expand Down
45 changes: 45 additions & 0 deletions packages/opencode/src/testbox/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Config } from "@/config/config"
import { Log } from "@/util/log"
import type { Tool } from "@/tool/tool"
import type { TestboxConnection } from "./ssh"

const log = Log.create({ service: "testbox" })

const cache = new Map<string, { conn: TestboxConnection; expires: number }>()
const TTL = 5 * 60 * 1000

export namespace Testbox {
export async function resolve(name: string): Promise<TestboxConnection> {
const cached = cache.get(name)
if (cached && cached.expires > Date.now()) return cached.conn

const config = await Config.get()
const api = config.testbox?.api ?? process.env["OPENCODE_TESTBOX_API"]
if (!api) throw new Error("testbox API URL not configured (set testbox.api in config or OPENCODE_TESTBOX_API)")

const url = api.endsWith("/") ? `${api}${name}` : `${api}/${name}`
log.info("resolving testbox", { name, url })

const response = await fetch(url)
if (!response.ok) throw new Error(`Failed to resolve testbox "${name}": ${response.status} ${response.statusText}`)

const conn = (await response.json()) as TestboxConnection
if (!conn.host || !conn.user || !conn.directory) {
throw new Error(`Invalid testbox response for "${name}": missing host, user, or directory`)
}

cache.set(name, { conn, expires: Date.now() + TTL })
return conn
}

export async function requireTestbox(
params: { testbox?: string },
ctx: Tool.Context,
): Promise<TestboxConnection | null> {
const config = await Config.get()
if (!config.testbox) return null
if (params.testbox) return resolve(params.testbox)
if (ctx.agent === "plan") return null
throw new Error("testbox parameter is required. Specify which testbox to execute on.")
}
}
203 changes: 203 additions & 0 deletions packages/opencode/src/testbox/ssh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { spawn } from "child_process"
import path from "path"
import { Global } from "@/global"
import fs from "fs/promises"
import { Log } from "@/util/log"

const log = Log.create({ service: "testbox.ssh" })

export interface TestboxConnection {
host: string
user: string
port?: number
key?: string
directory: string
}

const controlDir = path.join(Global.Path.data, "ssh")
await fs.mkdir(controlDir, { recursive: true })

function args(conn: TestboxConnection): string[] {
const result = [
"-o",
"ControlMaster=auto",
"-o",
`ControlPath=${path.join(controlDir, "%C")}`,
"-o",
"ControlPersist=600",
"-o",
"StrictHostKeyChecking=accept-new",
"-o",
"BatchMode=yes",
]
if (conn.port) result.push("-p", String(conn.port))
if (conn.key) result.push("-i", conn.key)
result.push(`${conn.user}@${conn.host}`)
return result
}

function resolve(conn: TestboxConnection, filepath: string) {
if (path.isAbsolute(filepath)) return filepath
return path.posix.join(conn.directory, filepath)
}

export namespace SSH {
export async function exec(
conn: TestboxConnection,
command: string,
opts?: { cwd?: string; timeout?: number; abort?: AbortSignal },
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const cwd = opts?.cwd ?? conn.directory
const wrapped = `cd ${quote(cwd)} && ${command}`
log.info("exec", { host: conn.host, command: wrapped })

const proc = spawn("ssh", [...args(conn), wrapped], {
stdio: ["ignore", "pipe", "pipe"],
})

let stdout = ""
let stderr = ""
proc.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString()
})
proc.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString()
})

let timedOut = false
let aborted = false

const kill = () => {
try {
proc.kill("SIGTERM")
} catch {}
}

if (opts?.abort?.aborted) {
aborted = true
kill()
}

const abortHandler = () => {
aborted = true
kill()
}
opts?.abort?.addEventListener("abort", abortHandler, { once: true })

const timer = opts?.timeout
? setTimeout(() => {
timedOut = true
kill()
}, opts.timeout)
: undefined

const exitCode = await new Promise<number>((resolve, reject) => {
proc.once("exit", (code) => {
clearTimeout(timer)
opts?.abort?.removeEventListener("abort", abortHandler)
resolve(code ?? 1)
})
proc.once("error", (err) => {
clearTimeout(timer)
opts?.abort?.removeEventListener("abort", abortHandler)
reject(err)
})
})

if (timedOut) stderr += "\n[testbox] command timed out"
if (aborted) stderr += "\n[testbox] command aborted"

return { stdout, stderr, exitCode }
}

export async function readFile(conn: TestboxConnection, filepath: string): Promise<string> {
const resolved = resolve(conn, filepath)
const result = await exec(conn, `cat ${quote(resolved)}`)
if (result.exitCode !== 0) throw new Error(`Failed to read ${resolved} on testbox: ${result.stderr}`)
return result.stdout
}

export async function writeFile(conn: TestboxConnection, filepath: string, content: string): Promise<void> {
const resolved = resolve(conn, filepath)
log.info("writeFile", { host: conn.host, filepath: resolved })

// Ensure parent directory exists
const dir = path.posix.dirname(resolved)
await exec(conn, `mkdir -p ${quote(dir)}`)

const proc = spawn("ssh", [...args(conn), `cat > ${quote(resolved)}`], {
stdio: ["pipe", "pipe", "pipe"],
})

let stderr = ""
proc.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString()
})

proc.stdin?.write(content)
proc.stdin?.end()

const exitCode = await new Promise<number>((resolve, reject) => {
proc.once("exit", (code) => resolve(code ?? 1))
proc.once("error", reject)
})

if (exitCode !== 0) throw new Error(`Failed to write ${resolved} on testbox: ${stderr}`)
}

export async function glob(
conn: TestboxConnection,
pattern: string,
opts?: { cwd?: string; abort?: AbortSignal },
): Promise<string[]> {
const cwd = opts?.cwd ?? conn.directory
const result = await exec(conn, `rg --files --glob ${quote(pattern)} ${quote(cwd)} 2>/dev/null`, {
abort: opts?.abort,
})
if (result.exitCode !== 0 && result.exitCode !== 1) {
throw new Error(`Failed to glob on testbox: ${result.stderr}`)
}
return result.stdout
.trim()
.split("\n")
.filter((line) => line.length > 0)
}

export async function grep(
conn: TestboxConnection,
pattern: string,
searchPath?: string,
opts?: { include?: string; abort?: AbortSignal },
): Promise<string> {
const target = searchPath ? resolve(conn, searchPath) : conn.directory
let cmd = `rg -nH --hidden --no-messages --field-match-separator='|' --regexp ${quote(pattern)}`
if (opts?.include) cmd += ` --glob ${quote(opts.include)}`
cmd += ` ${quote(target)}`
const result = await exec(conn, cmd, { abort: opts?.abort })
// exit 1 = no matches, that's fine
if (result.exitCode !== 0 && result.exitCode !== 1 && result.exitCode !== 2) {
throw new Error(`Failed to grep on testbox: ${result.stderr}`)
}
return result.stdout
}

export async function list(
conn: TestboxConnection,
dir?: string,
opts?: { abort?: AbortSignal },
): Promise<string[]> {
const target = dir ? resolve(conn, dir) : conn.directory
const result = await exec(conn, `rg --files ${quote(target)} 2>/dev/null | head -100`, { abort: opts?.abort })
if (result.exitCode !== 0 && result.exitCode !== 1) {
throw new Error(`Failed to list on testbox: ${result.stderr}`)
}
return result.stdout
.trim()
.split("\n")
.filter((line) => line.length > 0)
}
}

function quote(s: string): string {
return `'${s.replace(/'/g, "'\\''")}'`
}
49 changes: 49 additions & 0 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { Shell } from "@/shell/shell"
import { BashArity } from "@/permission/arity"
import { Truncate } from "./truncation"
import { Plugin } from "@/plugin"
import { Testbox } from "../testbox"
import { SSH, type TestboxConnection } from "../testbox/ssh"

const MAX_METADATA_LENGTH = 30_000
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
Expand Down Expand Up @@ -74,8 +76,55 @@ export const BashTool = Tool.define("bash", async () => {
.describe(
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
),
testbox: z.string().optional().describe("Name of the testbox to execute on remotely"),
}),
async execute(params, ctx) {
const conn = await Testbox.requireTestbox(params, ctx)
if (conn) {
const cwd = params.workdir || conn.directory
const timeout = params.timeout ?? DEFAULT_TIMEOUT

await ctx.ask({
permission: "bash",
patterns: [params.command],
always: [params.command.split(" ")[0] + " *"],
metadata: {},
})

ctx.metadata({
metadata: {
output: "",
description: params.description,
},
})

const result = await SSH.exec(conn, params.command, {
cwd,
timeout,
abort: ctx.abort,
})

const output = result.stdout + result.stderr

ctx.metadata({
metadata: {
output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
exit: result.exitCode,
description: params.description,
},
})

return {
title: params.description,
metadata: {
output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
exit: result.exitCode,
description: params.description,
},
output,
}
}

const cwd = params.workdir || Instance.directory
if (params.timeout !== undefined && params.timeout < 0) {
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/tool/bash.txt
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,6 @@ Important:

# Other common operations
- View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments

# Testbox
If testbox mode is configured, you must provide the `testbox` parameter specifying which testbox to execute on. The command will run on the remote testbox via SSH.
Loading