From d188470116a4adf88fac86e8870f834ccdab40f3 Mon Sep 17 00:00:00 2001 From: Aayush Shah Date: Mon, 9 Feb 2026 23:25:17 -0500 Subject: [PATCH] *: add testbox remote execution support When testbox config is present, tool execution follows a two-phase model tied to the existing plan/build agent architecture. The plan agent continues to run locally for read-only exploration, while the build agent requires a `testbox` parameter on every tool call (read, write, edit, bash, glob, grep, list), routing execution to an SSH-accessible CI runner with the repo's full environment. A new `testbox/` module handles resolution and transport. The `Testbox.resolve()` function calls an external API to map a testbox name to SSH connection details, caching results with a TTL. The `SSH` transport layer uses ControlMaster multiplexing and provides primitives (exec, readFile, writeFile, glob, grep, list) that each tool delegates to when a connection is present. +------------------+ | LLM Agent | +--------+---------+ | +------------+------------+ | | +--------v--------+ +---------v---------+ | Plan Agent | | Build Agent | | (read-only) | | (testbox required)| +--------+--------+ +---------+---------+ | | +--------v--------+ +---------v---------+ | Local Filesystem| | requireTestbox() | +-----------------+ +---------+---------+ | +--------v--------+ | Testbox.resolve | | (External API) | +--------+--------+ | +--------v--------+ | SSH Transport | | (ControlMaster) | +--------+--------+ | +--------v--------+ | Testbox (CI) | | rg, cat, bash | +-----------------+ Co-authored-by: Cursor --- packages/opencode/src/config/config.ts | 6 + packages/opencode/src/session/prompt.ts | 13 +- packages/opencode/src/testbox/index.ts | 45 ++++++ packages/opencode/src/testbox/ssh.ts | 203 ++++++++++++++++++++++++ packages/opencode/src/tool/bash.ts | 49 ++++++ packages/opencode/src/tool/bash.txt | 3 + packages/opencode/src/tool/edit.ts | 76 +++++++++ packages/opencode/src/tool/edit.txt | 2 + packages/opencode/src/tool/glob.ts | 42 +++++ packages/opencode/src/tool/glob.txt | 2 + packages/opencode/src/tool/grep.ts | 84 ++++++++++ packages/opencode/src/tool/grep.txt | 2 + packages/opencode/src/tool/ls.ts | 74 +++++++++ packages/opencode/src/tool/ls.txt | 2 + packages/opencode/src/tool/read.ts | 71 +++++++++ packages/opencode/src/tool/read.txt | 2 + packages/opencode/src/tool/write.ts | 36 +++++ packages/opencode/src/tool/write.txt | 2 + 18 files changed, 712 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/testbox/index.ts create mode 100644 packages/opencode/src/testbox/ssh.ts diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a231a5300724..b20c2c684d0a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -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(), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index d7f73b4f6097..ae4eea0a84bb 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -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" @@ -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") { @@ -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, }) } @@ -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) diff --git a/packages/opencode/src/testbox/index.ts b/packages/opencode/src/testbox/index.ts new file mode 100644 index 000000000000..4833f395eb1b --- /dev/null +++ b/packages/opencode/src/testbox/index.ts @@ -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() +const TTL = 5 * 60 * 1000 + +export namespace Testbox { + export async function resolve(name: string): Promise { + 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 { + 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.") + } +} diff --git a/packages/opencode/src/testbox/ssh.ts b/packages/opencode/src/testbox/ssh.ts new file mode 100644 index 000000000000..820f7b2068fc --- /dev/null +++ b/packages/opencode/src/testbox/ssh.ts @@ -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((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 { + 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 { + 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((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 { + 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 { + 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 { + 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, "'\\''")}'` +} diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 67559b78c085..6314b82f140b 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -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 @@ -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.`) diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt index 9fbc9fcf37e4..48d51afd610f 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/bash.txt @@ -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. diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 0bf1d6792bc2..95af126e57ed 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -17,6 +17,8 @@ import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Snapshot } from "@/snapshot" import { assertExternalDirectory } from "./external-directory" +import { Testbox } from "../testbox" +import { SSH, type TestboxConnection } from "../testbox/ssh" const MAX_DIAGNOSTICS_PER_FILE = 20 @@ -24,6 +26,76 @@ function normalizeLineEndings(text: string): string { return text.replaceAll("\r\n", "\n") } +async function remoteEdit( + conn: TestboxConnection, + params: { filePath: string; oldString: string; newString: string; replaceAll?: boolean }, + ctx: Tool.Context, +) { + if (params.oldString === params.newString) throw new Error("oldString and newString must be different") + + const filepath = params.filePath + + if (params.oldString === "") { + const diff = trimDiff(createTwoFilesPatch(filepath, filepath, "", params.newString)) + await ctx.ask({ + permission: "edit", + patterns: [filepath], + always: ["*"], + metadata: { filepath, diff }, + }) + await SSH.writeFile(conn, filepath, params.newString) + + const filediff: Snapshot.FileDiff = { + file: filepath, + before: "", + after: params.newString, + additions: params.newString.split("\n").length, + deletions: 0, + } + + return { + metadata: { diagnostics: {}, diff, filediff }, + title: filepath, + output: "Edit applied successfully.", + } + } + + const contentOld = await SSH.readFile(conn, filepath) + const contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll) + const diff = trimDiff( + createTwoFilesPatch(filepath, filepath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)), + ) + + await ctx.ask({ + permission: "edit", + patterns: [filepath], + always: ["*"], + metadata: { filepath, diff }, + }) + + await SSH.writeFile(conn, filepath, contentNew) + + const filediff: Snapshot.FileDiff = { + file: filepath, + before: contentOld, + after: contentNew, + additions: 0, + deletions: 0, + } + for (const change of diffLines(contentOld, contentNew)) { + if (change.added) filediff.additions += change.count || 0 + if (change.removed) filediff.deletions += change.count || 0 + } + + ctx.metadata({ metadata: { diff, filediff, diagnostics: {} } }) + + return { + metadata: { diagnostics: {}, diff, filediff }, + title: filepath, + output: "Edit applied successfully.", + } +} + export const EditTool = Tool.define("edit", { description: DESCRIPTION, parameters: z.object({ @@ -31,8 +103,12 @@ export const EditTool = Tool.define("edit", { oldString: z.string().describe("The text to replace"), newString: z.string().describe("The text to replace it with (must be different from oldString)"), replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), + 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) return remoteEdit(conn, params, ctx) + if (!params.filePath) { throw new Error("filePath is required") } diff --git a/packages/opencode/src/tool/edit.txt b/packages/opencode/src/tool/edit.txt index 863efb8409c0..4cc462b26421 100644 --- a/packages/opencode/src/tool/edit.txt +++ b/packages/opencode/src/tool/edit.txt @@ -8,3 +8,5 @@ Usage: - The edit will FAIL if `oldString` is not found in the file with an error "oldString not found in content". - The edit will FAIL if `oldString` is found multiple times in the file with an error "oldString found multiple times and requires more code context to uniquely identify the intended match". Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`. - Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance. + +Testbox: If testbox mode is configured, you must provide the `testbox` parameter specifying which testbox to execute on. The file will be read and written on the remote testbox via SSH. diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 6943795f8837..e0f2f50c9106 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -5,6 +5,44 @@ import DESCRIPTION from "./glob.txt" import { Ripgrep } from "../file/ripgrep" import { Instance } from "../project/instance" import { assertExternalDirectory } from "./external-directory" +import { Testbox } from "../testbox" +import { SSH, type TestboxConnection } from "../testbox/ssh" + +async function remoteGlob( + conn: TestboxConnection, + params: { pattern: string; path?: string }, + ctx: Tool.Context, +) { + await ctx.ask({ + permission: "glob", + patterns: [params.pattern], + always: ["*"], + metadata: { pattern: params.pattern, path: params.path }, + }) + + const cwd = params.path ?? conn.directory + const files = await SSH.glob(conn, params.pattern, { cwd, abort: ctx.abort }) + + const limit = 100 + const truncated = files.length > limit + const limited = truncated ? files.slice(0, limit) : files + + const output = [] + if (limited.length === 0) output.push("No files found") + else { + output.push(...limited) + if (truncated) { + output.push("") + output.push("(Results are truncated. Consider using a more specific path or pattern.)") + } + } + + return { + title: params.path ?? conn.directory, + metadata: { count: limited.length, truncated }, + output: output.join("\n"), + } +} export const GlobTool = Tool.define("glob", { description: DESCRIPTION, @@ -16,8 +54,12 @@ export const GlobTool = Tool.define("glob", { .describe( `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`, ), + 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) return remoteGlob(conn, params, ctx) + await ctx.ask({ permission: "glob", patterns: [params.pattern], diff --git a/packages/opencode/src/tool/glob.txt b/packages/opencode/src/tool/glob.txt index 627da6cae9d7..7ff7b7eaf505 100644 --- a/packages/opencode/src/tool/glob.txt +++ b/packages/opencode/src/tool/glob.txt @@ -4,3 +4,5 @@ - Use this tool when you need to find files by name patterns - When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead - You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful. + +Testbox: If testbox mode is configured, you must provide the `testbox` parameter specifying which testbox to execute on. The glob search will run on the remote testbox via SSH. diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index c10b4dfb88a6..78eb04755b3e 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -6,17 +6,101 @@ import DESCRIPTION from "./grep.txt" import { Instance } from "../project/instance" import path from "path" import { assertExternalDirectory } from "./external-directory" +import { Testbox } from "../testbox" +import { SSH, type TestboxConnection } from "../testbox/ssh" const MAX_LINE_LENGTH = 2000 +async function remoteGrep( + conn: TestboxConnection, + params: { pattern: string; path?: string; include?: string }, + ctx: Tool.Context, +) { + if (!params.pattern) throw new Error("pattern is required") + + await ctx.ask({ + permission: "grep", + patterns: [params.pattern], + always: ["*"], + metadata: { pattern: params.pattern, path: params.path, include: params.include }, + }) + + const raw = await SSH.grep(conn, params.pattern, params.path, { + include: params.include, + abort: ctx.abort, + }) + + if (!raw.trim()) { + return { + title: params.pattern, + metadata: { matches: 0, truncated: false }, + output: "No files found", + } + } + + const lines = raw.trim().split(/\r?\n/) + const matches: { path: string; lineNum: number; lineText: string }[] = [] + + for (const line of lines) { + if (!line) continue + const [filePath, lineNumStr, ...lineTextParts] = line.split("|") + if (!filePath || !lineNumStr || lineTextParts.length === 0) continue + matches.push({ + path: filePath, + lineNum: parseInt(lineNumStr, 10), + lineText: lineTextParts.join("|"), + }) + } + + const limit = 100 + const truncated = matches.length > limit + const final = truncated ? matches.slice(0, limit) : matches + + if (final.length === 0) { + return { + title: params.pattern, + metadata: { matches: 0, truncated: false }, + output: "No files found", + } + } + + const output = [`Found ${final.length} matches`] + let currentFile = "" + for (const match of final) { + if (currentFile !== match.path) { + if (currentFile !== "") output.push("") + currentFile = match.path + output.push(`${match.path}:`) + } + const text = + match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText + output.push(` Line ${match.lineNum}: ${text}`) + } + + if (truncated) { + output.push("") + output.push("(Results are truncated. Consider using a more specific path or pattern.)") + } + + return { + title: params.pattern, + metadata: { matches: final.length, truncated }, + output: output.join("\n"), + } +} + export const GrepTool = Tool.define("grep", { description: DESCRIPTION, parameters: z.object({ pattern: z.string().describe("The regex pattern to search for in file contents"), path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."), include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'), + 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) return remoteGrep(conn, params, ctx) + if (!params.pattern) { throw new Error("pattern is required") } diff --git a/packages/opencode/src/tool/grep.txt b/packages/opencode/src/tool/grep.txt index adf583695aef..916e0e9b88d9 100644 --- a/packages/opencode/src/tool/grep.txt +++ b/packages/opencode/src/tool/grep.txt @@ -6,3 +6,5 @@ - Use this tool when you need to find files containing specific patterns - If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`. - When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead + +Testbox: If testbox mode is configured, you must provide the `testbox` parameter specifying which testbox to execute on. The search will run on the remote testbox via SSH. diff --git a/packages/opencode/src/tool/ls.ts b/packages/opencode/src/tool/ls.ts index b848e969b74e..6ac6998ed008 100644 --- a/packages/opencode/src/tool/ls.ts +++ b/packages/opencode/src/tool/ls.ts @@ -5,6 +5,8 @@ import DESCRIPTION from "./ls.txt" import { Instance } from "../project/instance" import { Ripgrep } from "../file/ripgrep" import { assertExternalDirectory } from "./external-directory" +import { Testbox } from "../testbox" +import { SSH, type TestboxConnection } from "../testbox/ssh" export const IGNORE_PATTERNS = [ "node_modules/", @@ -35,13 +37,85 @@ export const IGNORE_PATTERNS = [ const LIMIT = 100 +async function remoteList( + conn: TestboxConnection, + params: { path?: string; ignore?: string[] }, + ctx: Tool.Context, +) { + const searchPath = params.path ?? conn.directory + + await ctx.ask({ + permission: "list", + patterns: [searchPath], + always: ["*"], + metadata: { path: searchPath }, + }) + + const files = await SSH.list(conn, searchPath, { abort: ctx.abort }) + const relative = files.map((f) => path.posix.relative(searchPath, f)) + + // Build directory structure + const dirs = new Set() + const filesByDir = new Map() + + for (const file of relative) { + const dir = path.posix.dirname(file) + const parts = dir === "." ? [] : dir.split("/") + + for (let i = 0; i <= parts.length; i++) { + const dirPath = i === 0 ? "." : parts.slice(0, i).join("/") + dirs.add(dirPath) + } + + if (!filesByDir.has(dir)) filesByDir.set(dir, []) + filesByDir.get(dir)!.push(path.posix.basename(file)) + } + + function renderDir(dirPath: string, depth: number): string { + const indent = " ".repeat(depth) + let output = "" + + if (depth > 0) { + output += `${indent}${path.posix.basename(dirPath)}/\n` + } + + const childIndent = " ".repeat(depth + 1) + const children = Array.from(dirs) + .filter((d) => path.posix.dirname(d) === dirPath && d !== dirPath) + .sort() + + for (const child of children) { + output += renderDir(child, depth + 1) + } + + const dirFiles = filesByDir.get(dirPath) || [] + for (const file of dirFiles.sort()) { + output += `${childIndent}${file}\n` + } + + return output + } + + const output = `${searchPath}/\n` + renderDir(".", 0) + + return { + title: searchPath, + metadata: { count: files.length, truncated: files.length >= 100 }, + output, + } +} + export const ListTool = Tool.define("list", { description: DESCRIPTION, parameters: z.object({ path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(), ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(), + 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) return remoteList(conn, params, ctx) + const searchPath = path.resolve(Instance.directory, params.path || ".") await assertExternalDirectory(ctx, searchPath, { kind: "directory" }) diff --git a/packages/opencode/src/tool/ls.txt b/packages/opencode/src/tool/ls.txt index 543720d46b18..953b6145c23d 100644 --- a/packages/opencode/src/tool/ls.txt +++ b/packages/opencode/src/tool/ls.txt @@ -1 +1,3 @@ Lists files and directories in a given path. The path parameter must be absolute; omit it to use the current workspace directory. You can optionally provide an array of glob patterns to ignore with the ignore parameter. You should generally prefer the Glob and Grep tools, if you know which directories to search. + +Testbox: If testbox mode is configured, you must provide the `testbox` parameter specifying which testbox to execute on. The directory listing will run on the remote testbox via SSH. diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index f230cdf44cbb..e3e97bac04c4 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -9,19 +9,90 @@ import { Instance } from "../project/instance" import { Identifier } from "../id/id" import { assertExternalDirectory } from "./external-directory" import { InstructionPrompt } from "../session/instruction" +import { Testbox } from "../testbox" +import { SSH, type TestboxConnection } from "../testbox/ssh" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 const MAX_BYTES = 50 * 1024 +async function remoteRead( + conn: TestboxConnection, + params: { filePath: string; offset?: number; limit?: number }, + ctx: Tool.Context, +) { + const filepath = params.filePath + const title = filepath + + await ctx.ask({ + permission: "read", + patterns: [filepath], + always: ["*"], + metadata: {}, + }) + + const content = await SSH.readFile(conn, filepath) + const lines = content.split("\n") + + const limit = params.limit ?? DEFAULT_READ_LIMIT + const offset = params.offset || 0 + + const raw: string[] = [] + let bytes = 0 + let truncatedByBytes = false + for (let i = offset; i < Math.min(lines.length, offset + limit); i++) { + const line = lines[i].length > MAX_LINE_LENGTH ? lines[i].substring(0, MAX_LINE_LENGTH) + "..." : lines[i] + const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0) + if (bytes + size > MAX_BYTES) { + truncatedByBytes = true + break + } + raw.push(line) + bytes += size + } + + const formatted = raw.map((line, index) => `${(index + offset + 1).toString().padStart(5, "0")}| ${line}`) + const preview = raw.slice(0, 20).join("\n") + + let output = "\n" + output += formatted.join("\n") + + const totalLines = lines.length + const lastReadLine = offset + raw.length + const hasMoreLines = totalLines > lastReadLine + const truncated = hasMoreLines || truncatedByBytes + + if (truncatedByBytes) { + output += `\n\n(Output truncated at ${MAX_BYTES} bytes. Use 'offset' parameter to read beyond line ${lastReadLine})` + } else if (hasMoreLines) { + output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${lastReadLine})` + } else { + output += `\n\n(End of file - total ${totalLines} lines)` + } + output += "\n" + + return { + title, + output, + metadata: { + preview, + truncated, + }, + } +} + export const ReadTool = Tool.define("read", { description: DESCRIPTION, parameters: z.object({ filePath: z.string().describe("The path to the file to read"), offset: z.coerce.number().describe("The line number to start reading from (0-based)").optional(), limit: z.coerce.number().describe("The number of lines to read (defaults to 2000)").optional(), + 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) return remoteRead(conn, params, ctx) + let filepath = params.filePath if (!path.isAbsolute(filepath)) { filepath = path.resolve(Instance.directory, filepath) diff --git a/packages/opencode/src/tool/read.txt b/packages/opencode/src/tool/read.txt index b5bffee263e3..568b67f18002 100644 --- a/packages/opencode/src/tool/read.txt +++ b/packages/opencode/src/tool/read.txt @@ -10,3 +10,5 @@ Usage: - You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful. - If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents. - You can read image files using this tool. + +Testbox: If testbox mode is configured, you must provide the `testbox` parameter specifying which testbox to execute on. The file will be read from the remote testbox via SSH. diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index eca64d30374d..cc464559ee8c 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -12,17 +12,53 @@ import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { trimDiff } from "./edit" import { assertExternalDirectory } from "./external-directory" +import { Testbox } from "../testbox" +import { SSH, type TestboxConnection } from "../testbox/ssh" const MAX_DIAGNOSTICS_PER_FILE = 20 const MAX_PROJECT_DIAGNOSTICS_FILES = 5 +async function remoteWrite( + conn: TestboxConnection, + params: { content: string; filePath: string }, + ctx: Tool.Context, +) { + const filepath = params.filePath + + await ctx.ask({ + permission: "edit", + patterns: [filepath], + always: ["*"], + metadata: { + filepath, + diff: "(remote write)", + }, + }) + + await SSH.writeFile(conn, filepath, params.content) + + return { + title: filepath, + metadata: { + diagnostics: {}, + filepath, + exists: true, + }, + output: "Wrote file successfully.", + } +} + export const WriteTool = Tool.define("write", { description: DESCRIPTION, parameters: z.object({ content: z.string().describe("The content to write to the file"), filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"), + 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) return remoteWrite(conn, params, ctx) + const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) await assertExternalDirectory(ctx, filepath) diff --git a/packages/opencode/src/tool/write.txt b/packages/opencode/src/tool/write.txt index 063cbb1f0387..b31690cb21e3 100644 --- a/packages/opencode/src/tool/write.txt +++ b/packages/opencode/src/tool/write.txt @@ -6,3 +6,5 @@ Usage: - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. - NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. - Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked. + +Testbox: If testbox mode is configured, you must provide the `testbox` parameter specifying which testbox to execute on. The file will be written on the remote testbox via SSH.