mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-24 06:45:22 +00:00
Apply PR #16069: feat(windows): add first-class pwsh/powershell support
This commit is contained in:
6
bun.lock
6
bun.lock
@@ -378,6 +378,7 @@
|
||||
"solid-js": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"turndown": "7.2.0",
|
||||
"ulid": "catalog:",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
@@ -593,8 +594,9 @@
|
||||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"electron",
|
||||
"esbuild",
|
||||
"tree-sitter-powershell",
|
||||
"electron",
|
||||
"web-tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
],
|
||||
@@ -4485,6 +4487,8 @@
|
||||
|
||||
"tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
|
||||
|
||||
"tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
"protobufjs",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"tree-sitter-powershell",
|
||||
"web-tree-sitter",
|
||||
"electron"
|
||||
],
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
"solid-js": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"turndown": "7.2.0",
|
||||
"ulid": "catalog:",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
|
||||
@@ -176,7 +176,7 @@ export namespace Pty {
|
||||
const id = PtyID.ascending()
|
||||
const command = input.command || Shell.preferred()
|
||||
const args = input.args || []
|
||||
if (command.endsWith("sh")) {
|
||||
if (Shell.login(command)) {
|
||||
args.push("-l")
|
||||
}
|
||||
|
||||
|
||||
@@ -1638,9 +1638,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}
|
||||
await Session.updatePart(part)
|
||||
const shell = Shell.preferred()
|
||||
const shellName = (
|
||||
process.platform === "win32" ? path.win32.basename(shell, ".exe") : path.basename(shell)
|
||||
).toLowerCase()
|
||||
const shellName = Shell.name(shell)
|
||||
|
||||
const invocations: Record<string, { args: string[] }> = {
|
||||
nu: {
|
||||
|
||||
@@ -9,6 +9,9 @@ import { setTimeout as sleep } from "node:timers/promises"
|
||||
const SIGKILL_TIMEOUT_MS = 200
|
||||
|
||||
export namespace Shell {
|
||||
const BLACKLIST = new Set(["fish", "nu"])
|
||||
const LOGIN = new Set(["bash", "dash", "fish", "ksh", "sh", "zsh"])
|
||||
|
||||
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
|
||||
const pid = proc.pid
|
||||
if (!pid || opts?.exited?.()) return
|
||||
@@ -39,7 +42,29 @@ export namespace Shell {
|
||||
}
|
||||
}
|
||||
}
|
||||
const BLACKLIST = new Set(["fish", "nu"])
|
||||
|
||||
function full(file: string) {
|
||||
if (process.platform !== "win32") return file
|
||||
const shell = Filesystem.windowsPath(file)
|
||||
if (path.win32.dirname(shell) !== ".") return shell
|
||||
return Bun.which(shell) || shell
|
||||
}
|
||||
|
||||
function pick() {
|
||||
const pwsh = Bun.which("pwsh")
|
||||
if (pwsh) return pwsh
|
||||
const powershell = Bun.which("powershell")
|
||||
if (powershell) return powershell
|
||||
}
|
||||
|
||||
function select(file: string | undefined, opts?: { acceptable?: boolean }) {
|
||||
if (file && (!opts?.acceptable || !BLACKLIST.has(name(file)))) return full(file)
|
||||
if (process.platform === "win32") {
|
||||
const shell = pick()
|
||||
if (shell) return shell
|
||||
}
|
||||
return fallback()
|
||||
}
|
||||
|
||||
function fallback() {
|
||||
if (process.platform === "win32") {
|
||||
@@ -59,15 +84,16 @@ export namespace Shell {
|
||||
return "/bin/sh"
|
||||
}
|
||||
|
||||
export const preferred = lazy(() => {
|
||||
const s = process.env.SHELL
|
||||
if (s) return s
|
||||
return fallback()
|
||||
})
|
||||
export function name(file: string) {
|
||||
if (process.platform === "win32") return path.win32.parse(Filesystem.windowsPath(file)).name.toLowerCase()
|
||||
return path.basename(file).toLowerCase()
|
||||
}
|
||||
|
||||
export const acceptable = lazy(() => {
|
||||
const s = process.env.SHELL
|
||||
if (s && !BLACKLIST.has(process.platform === "win32" ? path.win32.basename(s) : path.basename(s))) return s
|
||||
return fallback()
|
||||
})
|
||||
export function login(file: string) {
|
||||
return LOGIN.has(name(file))
|
||||
}
|
||||
|
||||
export const preferred = lazy(() => select(process.env.SHELL))
|
||||
|
||||
export const acceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import z from "zod"
|
||||
import os from "os"
|
||||
import { spawn } from "child_process"
|
||||
import { Tool } from "./tool"
|
||||
import path from "path"
|
||||
@@ -6,8 +7,7 @@ import DESCRIPTION from "./bash.txt"
|
||||
import { Log } from "../util/log"
|
||||
import { Instance } from "../project/instance"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Language } from "web-tree-sitter"
|
||||
import fs from "fs/promises"
|
||||
import { Language, type Node } from "web-tree-sitter"
|
||||
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { fileURLToPath } from "url"
|
||||
@@ -20,6 +20,43 @@ import { Plugin } from "@/plugin"
|
||||
|
||||
const MAX_METADATA_LENGTH = 30_000
|
||||
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
|
||||
const PS = new Set(["powershell", "pwsh"])
|
||||
const CWD = new Set(["cd", "push-location", "set-location"])
|
||||
const FILES = new Set([
|
||||
...CWD,
|
||||
"rm",
|
||||
"cp",
|
||||
"mv",
|
||||
"mkdir",
|
||||
"touch",
|
||||
"chmod",
|
||||
"chown",
|
||||
"cat",
|
||||
// Leave PowerShell aliases out for now. Common ones like cat/cp/mv/rm/mkdir
|
||||
// already hit the entries above, and alias normalization should happen in one
|
||||
// place later so we do not risk double-prompting.
|
||||
"get-content",
|
||||
"set-content",
|
||||
"add-content",
|
||||
"copy-item",
|
||||
"move-item",
|
||||
"remove-item",
|
||||
"new-item",
|
||||
"rename-item",
|
||||
])
|
||||
const FLAGS = new Set(["-destination", "-literalpath", "-path"])
|
||||
const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])
|
||||
|
||||
type Part = {
|
||||
type: string
|
||||
text: string
|
||||
}
|
||||
|
||||
type Scan = {
|
||||
dirs: Set<string>
|
||||
patterns: Set<string>
|
||||
always: Set<string>
|
||||
}
|
||||
|
||||
export const log = Log.create({ service: "bash-tool" })
|
||||
|
||||
@@ -30,6 +67,338 @@ const resolveWasm = (asset: string) => {
|
||||
return fileURLToPath(url)
|
||||
}
|
||||
|
||||
function parts(node: Node) {
|
||||
const out: Part[] = []
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i)
|
||||
if (!child) continue
|
||||
if (child.type === "command_elements") {
|
||||
for (let j = 0; j < child.childCount; j++) {
|
||||
const item = child.child(j)
|
||||
if (!item || item.type === "command_argument_sep" || item.type === "redirection") continue
|
||||
out.push({ type: item.type, text: item.text })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (
|
||||
child.type !== "command_name" &&
|
||||
child.type !== "command_name_expr" &&
|
||||
child.type !== "word" &&
|
||||
child.type !== "string" &&
|
||||
child.type !== "raw_string" &&
|
||||
child.type !== "concatenation"
|
||||
) {
|
||||
continue
|
||||
}
|
||||
out.push({ type: child.type, text: child.text })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function commandText(node: Node) {
|
||||
return (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim()
|
||||
}
|
||||
|
||||
function nested(node: Node) {
|
||||
let parent = node.parent
|
||||
while (parent) {
|
||||
if (parent.type === "command") return true
|
||||
parent = parent.parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function commands(node: Node) {
|
||||
const out: Node[] = []
|
||||
for (const child of node.descendantsOfType("command")) {
|
||||
if (!child || nested(child)) continue
|
||||
out.push(child)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function unquote(text: string) {
|
||||
if (text.length < 2) return text
|
||||
const first = text[0]
|
||||
const last = text[text.length - 1]
|
||||
if ((first === '"' || first === "'") && first === last) return text.slice(1, -1)
|
||||
return text
|
||||
}
|
||||
|
||||
function home(text: string) {
|
||||
if (text === "~") return os.homedir()
|
||||
if (text.startsWith("~/") || text.startsWith("~\\")) return path.join(os.homedir(), text.slice(2))
|
||||
return text
|
||||
}
|
||||
|
||||
function envValue(key: string) {
|
||||
if (process.platform !== "win32") return process.env[key]
|
||||
const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase())
|
||||
return name ? process.env[name] : undefined
|
||||
}
|
||||
|
||||
function expandEnv(text: string) {
|
||||
const out = unquote(text).replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, key: string) => {
|
||||
const value = envValue(key)
|
||||
return value || ""
|
||||
})
|
||||
return home(out)
|
||||
}
|
||||
|
||||
function drive(text: string) {
|
||||
return /^[A-Za-z]:($|[\\/])/.test(text)
|
||||
}
|
||||
|
||||
function provider(text: string) {
|
||||
return /^[A-Za-z]+:/.test(text) && !drive(text)
|
||||
}
|
||||
|
||||
function dynamicPath(text: string, ps: boolean) {
|
||||
if (text.startsWith("(") || text.startsWith("@(")) return true
|
||||
if (text.includes("$(") || text.includes("${") || text.includes("`")) return true
|
||||
if (ps) return /\$(?!env:)/i.test(text)
|
||||
return text.includes("$")
|
||||
}
|
||||
|
||||
function prefix(text: string) {
|
||||
const match = /[?*\[]/.exec(text)
|
||||
if (!match) return text
|
||||
if (match.index === 0) return
|
||||
return text.slice(0, match.index)
|
||||
}
|
||||
|
||||
function resolvePath(text: string, root: string) {
|
||||
if (process.platform === "win32") {
|
||||
return Filesystem.normalizePath(path.resolve(root, Filesystem.windowsPath(text)))
|
||||
}
|
||||
return path.resolve(root, text)
|
||||
}
|
||||
|
||||
function argPath(arg: string, cwd: string, ps: boolean) {
|
||||
const text = ps ? expandEnv(arg) : home(unquote(arg))
|
||||
const file = text && prefix(text)
|
||||
if (!file || dynamicPath(file, ps)) return
|
||||
if (ps && provider(file)) return
|
||||
return resolvePath(file, cwd)
|
||||
}
|
||||
|
||||
function pathArgs(list: Part[], ps: boolean) {
|
||||
if (!ps) {
|
||||
return list
|
||||
.slice(1)
|
||||
.filter((item) => !item.text.startsWith("-") && !(list[0]?.text === "chmod" && item.text.startsWith("+")))
|
||||
.map((item) => item.text)
|
||||
}
|
||||
|
||||
const out: string[] = []
|
||||
let want = false
|
||||
for (const item of list.slice(1)) {
|
||||
if (want) {
|
||||
out.push(item.text)
|
||||
want = false
|
||||
continue
|
||||
}
|
||||
if (item.type === "command_parameter") {
|
||||
const flag = item.text.toLowerCase()
|
||||
if (SWITCHES.has(flag)) continue
|
||||
want = FLAGS.has(flag)
|
||||
continue
|
||||
}
|
||||
out.push(item.text)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function collect(root: Node, cwd: string, ps: boolean): Promise<Scan> {
|
||||
const scan: Scan = {
|
||||
dirs: new Set<string>(),
|
||||
patterns: new Set<string>(),
|
||||
always: new Set<string>(),
|
||||
}
|
||||
|
||||
for (const node of commands(root)) {
|
||||
const command = parts(node)
|
||||
const tokens = command.map((item) => item.text)
|
||||
const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0]
|
||||
|
||||
if (cmd && FILES.has(cmd)) {
|
||||
for (const arg of pathArgs(command, ps)) {
|
||||
const resolved = argPath(arg, cwd, ps)
|
||||
log.info("resolved path", { arg, resolved })
|
||||
if (!resolved || Instance.containsPath(resolved)) continue
|
||||
const dir = (await Filesystem.isDir(resolved)) ? resolved : path.dirname(resolved)
|
||||
scan.dirs.add(dir)
|
||||
}
|
||||
}
|
||||
|
||||
if (tokens.length && (!cmd || !CWD.has(cmd))) {
|
||||
scan.patterns.add(commandText(node))
|
||||
scan.always.add(BashArity.prefix(tokens).join(" ") + " *")
|
||||
}
|
||||
}
|
||||
|
||||
return scan
|
||||
}
|
||||
|
||||
function preview(text: string) {
|
||||
if (text.length <= MAX_METADATA_LENGTH) return text
|
||||
return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..."
|
||||
}
|
||||
|
||||
async function parse(command: string, ps: boolean) {
|
||||
const tree = await parser().then((p) => (ps ? p.ps : p.bash).parse(command))
|
||||
if (!tree) throw new Error("Failed to parse command")
|
||||
return tree.rootNode
|
||||
}
|
||||
|
||||
async function ask(ctx: Tool.Context, scan: Scan) {
|
||||
if (scan.dirs.size > 0) {
|
||||
const globs = Array.from(scan.dirs).map((dir) => {
|
||||
if (process.platform === "win32") return Filesystem.normalizePathPattern(path.join(dir, "*"))
|
||||
return path.join(dir, "*")
|
||||
})
|
||||
await ctx.ask({
|
||||
permission: "external_directory",
|
||||
patterns: globs,
|
||||
always: globs,
|
||||
metadata: {},
|
||||
})
|
||||
}
|
||||
|
||||
if (scan.patterns.size === 0) return
|
||||
await ctx.ask({
|
||||
permission: "bash",
|
||||
patterns: Array.from(scan.patterns),
|
||||
always: Array.from(scan.always),
|
||||
metadata: {},
|
||||
})
|
||||
}
|
||||
|
||||
async function shellEnv(ctx: Tool.Context, cwd: string) {
|
||||
const extra = await Plugin.trigger("shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} })
|
||||
return {
|
||||
...process.env,
|
||||
...extra.env,
|
||||
}
|
||||
}
|
||||
|
||||
function launch(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
|
||||
if (process.platform === "win32" && PS.has(name)) {
|
||||
return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
})
|
||||
}
|
||||
|
||||
return spawn(command, {
|
||||
shell,
|
||||
cwd,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
})
|
||||
}
|
||||
|
||||
async function run(
|
||||
input: {
|
||||
shell: string
|
||||
name: string
|
||||
command: string
|
||||
cwd: string
|
||||
env: NodeJS.ProcessEnv
|
||||
timeout: number
|
||||
description: string
|
||||
},
|
||||
ctx: Tool.Context,
|
||||
) {
|
||||
const proc = launch(input.shell, input.name, input.command, input.cwd, input.env)
|
||||
let output = ""
|
||||
|
||||
ctx.metadata({
|
||||
metadata: {
|
||||
output: "",
|
||||
description: input.description,
|
||||
},
|
||||
})
|
||||
|
||||
const append = (chunk: Buffer) => {
|
||||
output += chunk.toString()
|
||||
ctx.metadata({
|
||||
metadata: {
|
||||
output: preview(output),
|
||||
description: input.description,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
proc.stdout?.on("data", append)
|
||||
proc.stderr?.on("data", append)
|
||||
|
||||
let timedOut = false
|
||||
let aborted = false
|
||||
let exited = false
|
||||
|
||||
const kill = () => Shell.killTree(proc, { exited: () => exited })
|
||||
|
||||
if (ctx.abort.aborted) {
|
||||
aborted = true
|
||||
await kill()
|
||||
}
|
||||
|
||||
const abort = () => {
|
||||
aborted = true
|
||||
void kill()
|
||||
}
|
||||
|
||||
ctx.abort.addEventListener("abort", abort, { once: true })
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
void kill()
|
||||
}, input.timeout + 100)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer)
|
||||
ctx.abort.removeEventListener("abort", abort)
|
||||
}
|
||||
|
||||
proc.once("exit", () => {
|
||||
exited = true
|
||||
})
|
||||
|
||||
proc.once("close", () => {
|
||||
exited = true
|
||||
cleanup()
|
||||
resolve()
|
||||
})
|
||||
|
||||
proc.once("error", (error) => {
|
||||
exited = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
|
||||
const metadata: string[] = []
|
||||
if (timedOut) metadata.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
|
||||
if (aborted) metadata.push("User aborted the command")
|
||||
if (metadata.length > 0) {
|
||||
output += "\n\n<bash_metadata>\n" + metadata.join("\n") + "\n</bash_metadata>"
|
||||
}
|
||||
|
||||
return {
|
||||
title: input.description,
|
||||
metadata: {
|
||||
output: preview(output),
|
||||
exit: proc.exitCode,
|
||||
description: input.description,
|
||||
},
|
||||
output,
|
||||
}
|
||||
}
|
||||
|
||||
const parser = lazy(async () => {
|
||||
const { Parser } = await import("web-tree-sitter")
|
||||
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
|
||||
@@ -44,20 +413,34 @@ const parser = lazy(async () => {
|
||||
const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
|
||||
with: { type: "wasm" },
|
||||
})
|
||||
const { default: psWasm } = await import("tree-sitter-powershell/tree-sitter-powershell.wasm" as string, {
|
||||
with: { type: "wasm" },
|
||||
})
|
||||
const bashPath = resolveWasm(bashWasm)
|
||||
const bashLanguage = await Language.load(bashPath)
|
||||
const p = new Parser()
|
||||
p.setLanguage(bashLanguage)
|
||||
return p
|
||||
const psPath = resolveWasm(psWasm)
|
||||
const [bashLanguage, psLanguage] = await Promise.all([Language.load(bashPath), Language.load(psPath)])
|
||||
const bash = new Parser()
|
||||
bash.setLanguage(bashLanguage)
|
||||
const ps = new Parser()
|
||||
ps.setLanguage(psLanguage)
|
||||
return { bash, ps }
|
||||
})
|
||||
|
||||
// TODO: we may wanna rename this tool so it works better on other shells
|
||||
export const BashTool = Tool.define("bash", async () => {
|
||||
const shell = Shell.acceptable()
|
||||
const name = Shell.name(shell)
|
||||
const chain =
|
||||
name === "powershell"
|
||||
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
|
||||
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
|
||||
log.info("bash tool using shell", { shell })
|
||||
|
||||
return {
|
||||
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
|
||||
.replaceAll("${os}", process.platform)
|
||||
.replaceAll("${shell}", name)
|
||||
.replaceAll("${chaining}", chain)
|
||||
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
|
||||
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
|
||||
parameters: z.object({
|
||||
@@ -76,195 +459,29 @@ export const BashTool = Tool.define("bash", async () => {
|
||||
),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const cwd = params.workdir || Instance.directory
|
||||
const cwd = resolvePath(params.workdir || Instance.directory, Instance.directory)
|
||||
if (params.timeout !== undefined && params.timeout < 0) {
|
||||
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
|
||||
}
|
||||
const timeout = params.timeout ?? DEFAULT_TIMEOUT
|
||||
const tree = await parser().then((p) => p.parse(params.command))
|
||||
if (!tree) {
|
||||
throw new Error("Failed to parse command")
|
||||
}
|
||||
const directories = new Set<string>()
|
||||
if (!Instance.containsPath(cwd)) directories.add(cwd)
|
||||
const patterns = new Set<string>()
|
||||
const always = new Set<string>()
|
||||
const ps = PS.has(name)
|
||||
const root = await parse(params.command, ps)
|
||||
const scan = await collect(root, cwd, ps)
|
||||
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
|
||||
await ask(ctx, scan)
|
||||
|
||||
for (const node of tree.rootNode.descendantsOfType("command")) {
|
||||
if (!node) continue
|
||||
|
||||
// Get full command text including redirects if present
|
||||
let commandText = node.parent?.type === "redirected_statement" ? node.parent.text : node.text
|
||||
|
||||
const command = []
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i)
|
||||
if (!child) continue
|
||||
if (
|
||||
child.type !== "command_name" &&
|
||||
child.type !== "word" &&
|
||||
child.type !== "string" &&
|
||||
child.type !== "raw_string" &&
|
||||
child.type !== "concatenation"
|
||||
) {
|
||||
continue
|
||||
}
|
||||
command.push(child.text)
|
||||
}
|
||||
|
||||
// not an exhaustive list, but covers most common cases
|
||||
if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"].includes(command[0])) {
|
||||
for (const arg of command.slice(1)) {
|
||||
if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue
|
||||
const resolved = await fs.realpath(path.resolve(cwd, arg)).catch(() => "")
|
||||
log.info("resolved path", { arg, resolved })
|
||||
if (resolved) {
|
||||
const normalized =
|
||||
process.platform === "win32" ? Filesystem.windowsPath(resolved).replace(/\//g, "\\") : resolved
|
||||
if (!Instance.containsPath(normalized)) {
|
||||
const dir = (await Filesystem.isDir(normalized)) ? normalized : path.dirname(normalized)
|
||||
directories.add(dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cd covered by above check
|
||||
if (command.length && command[0] !== "cd") {
|
||||
patterns.add(commandText)
|
||||
always.add(BashArity.prefix(command).join(" ") + " *")
|
||||
}
|
||||
}
|
||||
|
||||
if (directories.size > 0) {
|
||||
const globs = Array.from(directories).map((dir) => {
|
||||
// Preserve POSIX-looking paths with /s, even on Windows
|
||||
if (dir.startsWith("/")) return `${dir.replace(/[\\/]+$/, "")}/*`
|
||||
return path.join(dir, "*")
|
||||
})
|
||||
await ctx.ask({
|
||||
permission: "external_directory",
|
||||
patterns: globs,
|
||||
always: globs,
|
||||
metadata: {},
|
||||
})
|
||||
}
|
||||
|
||||
if (patterns.size > 0) {
|
||||
await ctx.ask({
|
||||
permission: "bash",
|
||||
patterns: Array.from(patterns),
|
||||
always: Array.from(always),
|
||||
metadata: {},
|
||||
})
|
||||
}
|
||||
|
||||
const shellEnv = await Plugin.trigger(
|
||||
"shell.env",
|
||||
{ cwd, sessionID: ctx.sessionID, callID: ctx.callID },
|
||||
{ env: {} },
|
||||
return run(
|
||||
{
|
||||
shell,
|
||||
name,
|
||||
command: params.command,
|
||||
cwd,
|
||||
env: await shellEnv(ctx, cwd),
|
||||
timeout,
|
||||
description: params.description,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
const proc = spawn(params.command, {
|
||||
shell,
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...shellEnv.env,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
windowsHide: process.platform === "win32",
|
||||
})
|
||||
|
||||
let output = ""
|
||||
|
||||
// Initialize metadata with empty output
|
||||
ctx.metadata({
|
||||
metadata: {
|
||||
output: "",
|
||||
description: params.description,
|
||||
},
|
||||
})
|
||||
|
||||
const append = (chunk: Buffer) => {
|
||||
output += chunk.toString()
|
||||
ctx.metadata({
|
||||
metadata: {
|
||||
// truncate the metadata to avoid GIANT blobs of data (has nothing to do w/ what agent can access)
|
||||
output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
|
||||
description: params.description,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
proc.stdout?.on("data", append)
|
||||
proc.stderr?.on("data", append)
|
||||
|
||||
let timedOut = false
|
||||
let aborted = false
|
||||
let exited = false
|
||||
|
||||
const kill = () => Shell.killTree(proc, { exited: () => exited })
|
||||
|
||||
if (ctx.abort.aborted) {
|
||||
aborted = true
|
||||
await kill()
|
||||
}
|
||||
|
||||
const abortHandler = () => {
|
||||
aborted = true
|
||||
void kill()
|
||||
}
|
||||
|
||||
ctx.abort.addEventListener("abort", abortHandler, { once: true })
|
||||
|
||||
const timeoutTimer = setTimeout(() => {
|
||||
timedOut = true
|
||||
void kill()
|
||||
}, timeout + 100)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeoutTimer)
|
||||
ctx.abort.removeEventListener("abort", abortHandler)
|
||||
}
|
||||
|
||||
proc.once("exit", () => {
|
||||
exited = true
|
||||
cleanup()
|
||||
resolve()
|
||||
})
|
||||
|
||||
proc.once("error", (error) => {
|
||||
exited = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
|
||||
const resultMetadata: string[] = []
|
||||
|
||||
if (timedOut) {
|
||||
resultMetadata.push(`bash tool terminated command after exceeding timeout ${timeout} ms`)
|
||||
}
|
||||
|
||||
if (aborted) {
|
||||
resultMetadata.push("User aborted the command")
|
||||
}
|
||||
|
||||
if (resultMetadata.length > 0) {
|
||||
output += "\n\n<bash_metadata>\n" + resultMetadata.join("\n") + "\n</bash_metadata>"
|
||||
}
|
||||
|
||||
return {
|
||||
title: params.description,
|
||||
metadata: {
|
||||
output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
|
||||
exit: proc.exitCode,
|
||||
description: params.description,
|
||||
},
|
||||
output,
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
|
||||
|
||||
Be aware: OS: ${os}, Shell: ${shell}
|
||||
|
||||
All commands run in ${directory} by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd <directory> && <command>` patterns - use `workdir` instead.
|
||||
|
||||
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
|
||||
@@ -35,7 +37,7 @@ Usage notes:
|
||||
- Communication: Output text directly (NOT echo/printf)
|
||||
- When issuing multiple commands:
|
||||
- If the commands are independent and can run in parallel, make multiple Bash tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Bash tool calls in parallel.
|
||||
- If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m "message" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead.
|
||||
- ${chaining}
|
||||
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
|
||||
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
|
||||
- AVOID using `cd <directory> && <command>`. Use the `workdir` parameter to change directories instead.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "path"
|
||||
import type { Tool } from "./tool"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
||||
type Kind = "file" | "directory"
|
||||
|
||||
@@ -14,19 +15,23 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
|
||||
|
||||
if (options?.bypass) return
|
||||
|
||||
if (Instance.containsPath(target)) return
|
||||
const full = process.platform === "win32" ? Filesystem.normalizePath(target) : target
|
||||
if (Instance.containsPath(full)) return
|
||||
|
||||
const kind = options?.kind ?? "file"
|
||||
const parentDir = kind === "directory" ? target : path.dirname(target)
|
||||
const glob = path.join(parentDir, "*").replaceAll("\\", "/")
|
||||
const dir = kind === "directory" ? full : path.dirname(full)
|
||||
const glob =
|
||||
process.platform === "win32"
|
||||
? Filesystem.normalizePathPattern(path.join(dir, "*"))
|
||||
: path.join(dir, "*").replaceAll("\\", "/")
|
||||
|
||||
await ctx.ask({
|
||||
permission: "external_directory",
|
||||
patterns: [glob],
|
||||
always: [glob],
|
||||
metadata: {
|
||||
filepath: target,
|
||||
parentDir,
|
||||
filepath: full,
|
||||
parentDir: dir,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ export const ReadTool = Tool.define("read", {
|
||||
if (!path.isAbsolute(filepath)) {
|
||||
filepath = path.resolve(Instance.directory, filepath)
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
filepath = Filesystem.normalizePath(filepath)
|
||||
}
|
||||
const title = path.relative(Instance.worktree, filepath)
|
||||
|
||||
const stat = Filesystem.stat(filepath)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { chmod, mkdir, readFile, writeFile } from "fs/promises"
|
||||
import { createWriteStream, existsSync, statSync } from "fs"
|
||||
import { lookup } from "mime-types"
|
||||
import { realpathSync } from "fs"
|
||||
import { dirname, join, relative, resolve as pathResolve } from "path"
|
||||
import { dirname, join, relative, resolve as pathResolve, win32 } from "path"
|
||||
import { Readable } from "stream"
|
||||
import { pipeline } from "stream/promises"
|
||||
import { Glob } from "./glob"
|
||||
@@ -106,13 +106,22 @@ export namespace Filesystem {
|
||||
*/
|
||||
export function normalizePath(p: string): string {
|
||||
if (process.platform !== "win32") return p
|
||||
const resolved = win32.normalize(win32.resolve(windowsPath(p)))
|
||||
try {
|
||||
return realpathSync.native(p)
|
||||
return realpathSync.native(resolved)
|
||||
} catch {
|
||||
return p
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePathPattern(p: string): string {
|
||||
if (process.platform !== "win32") return p
|
||||
if (p === "*") return p
|
||||
const match = p.match(/^(.*)[\\/]\*$/)
|
||||
if (!match) return normalizePath(p)
|
||||
return join(normalizePath(match[1]), "*")
|
||||
}
|
||||
|
||||
// We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary.
|
||||
// Also resolves symlinks so that callers using the result as a cache key
|
||||
// always get the same canonical path for a given physical directory.
|
||||
|
||||
52
packages/opencode/test/pty/pty-shell.test.ts
Normal file
52
packages/opencode/test/pty/pty-shell.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Pty } from "../../src/pty"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("pty shell args", () => {
|
||||
if (process.platform !== "win32") return
|
||||
|
||||
const ps = Bun.which("pwsh") || Bun.which("powershell")
|
||||
if (ps) {
|
||||
test(
|
||||
"does not add login args to pwsh",
|
||||
async () => {
|
||||
await using dir = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: dir.path,
|
||||
fn: async () => {
|
||||
const info = await Pty.create({ command: ps, title: "pwsh" })
|
||||
try {
|
||||
expect(info.args).toEqual([])
|
||||
} finally {
|
||||
await Pty.remove(info.id)
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
}
|
||||
|
||||
const bash = Bun.which("bash")
|
||||
if (bash) {
|
||||
test(
|
||||
"adds login args to bash",
|
||||
async () => {
|
||||
await using dir = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: dir.path,
|
||||
fn: async () => {
|
||||
const info = await Pty.create({ command: bash, title: "bash" })
|
||||
try {
|
||||
expect(info.args).toEqual(["-l"])
|
||||
} finally {
|
||||
await Pty.remove(info.id)
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
}
|
||||
})
|
||||
58
packages/opencode/test/shell/shell.test.ts
Normal file
58
packages/opencode/test/shell/shell.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Shell } from "../../src/shell/shell"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
|
||||
const withShell = async (shell: string | undefined, fn: () => void | Promise<void>) => {
|
||||
const prev = process.env.SHELL
|
||||
if (shell === undefined) delete process.env.SHELL
|
||||
else process.env.SHELL = shell
|
||||
Shell.acceptable.reset()
|
||||
Shell.preferred.reset()
|
||||
try {
|
||||
await fn()
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.SHELL
|
||||
else process.env.SHELL = prev
|
||||
Shell.acceptable.reset()
|
||||
Shell.preferred.reset()
|
||||
}
|
||||
}
|
||||
|
||||
describe("shell", () => {
|
||||
test("normalizes shell names", () => {
|
||||
expect(Shell.name("/bin/bash")).toBe("bash")
|
||||
if (process.platform === "win32") {
|
||||
expect(Shell.name("C:/tools/NU.EXE")).toBe("nu")
|
||||
expect(Shell.name("C:/tools/PWSH.EXE")).toBe("pwsh")
|
||||
}
|
||||
})
|
||||
|
||||
test("detects login shells", () => {
|
||||
expect(Shell.login("/bin/bash")).toBe(true)
|
||||
expect(Shell.login("C:/tools/pwsh.exe")).toBe(false)
|
||||
})
|
||||
|
||||
if (process.platform === "win32") {
|
||||
test("rejects blacklisted shells case-insensitively", async () => {
|
||||
await withShell("NU.EXE", async () => {
|
||||
expect(Shell.name(Shell.acceptable())).not.toBe("nu")
|
||||
})
|
||||
})
|
||||
|
||||
test("normalizes Git Bash shell paths from env", async () => {
|
||||
const shell = "/cygdrive/c/Program Files/Git/bin/bash.exe"
|
||||
await withShell(shell, async () => {
|
||||
expect(Shell.preferred()).toBe(Filesystem.windowsPath(shell))
|
||||
})
|
||||
})
|
||||
|
||||
test("resolves bare PowerShell shells", async () => {
|
||||
const shell = Bun.which("pwsh") || Bun.which("powershell")
|
||||
if (!shell) return
|
||||
await withShell(path.win32.basename(shell), async () => {
|
||||
expect(Shell.preferred()).toBe(shell)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Shell } from "../../src/shell/shell"
|
||||
import { BashTool } from "../../src/tool/bash"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
@@ -21,16 +22,90 @@ const ctx = {
|
||||
}
|
||||
|
||||
const projectRoot = path.join(__dirname, "../..")
|
||||
const bin = process.execPath.replaceAll("\\", "/")
|
||||
const file = path.join(projectRoot, "test/tool/fixtures/output.ts").replaceAll("\\", "/")
|
||||
const shells = (() => {
|
||||
if (process.platform !== "win32") {
|
||||
const shell = process.env.SHELL || Bun.which("bash") || "/bin/sh"
|
||||
return [{ label: path.basename(shell), shell }]
|
||||
}
|
||||
|
||||
const list = [
|
||||
{ label: "git bash", shell: process.env.SHELL || Bun.which("bash") },
|
||||
{ label: "pwsh", shell: Bun.which("pwsh") },
|
||||
{ label: "powershell", shell: Bun.which("powershell") },
|
||||
{ label: "cmd", shell: process.env.COMSPEC || Bun.which("cmd.exe") },
|
||||
].filter((item): item is { label: string; shell: string } => Boolean(item.shell))
|
||||
|
||||
return list.filter(
|
||||
(item, i) => list.findIndex((other) => other.shell.toLowerCase() === item.shell.toLowerCase()) === i,
|
||||
)
|
||||
})()
|
||||
const ps = shells.filter((item) => item.label === "pwsh" || item.label === "powershell")
|
||||
|
||||
const fill = (mode: "lines" | "bytes", n: number) => `${bin} ${file} ${mode} ${n}`
|
||||
const glob = (p: string) =>
|
||||
process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
|
||||
|
||||
const forms = (dir: string) => {
|
||||
if (process.platform !== "win32") return [dir]
|
||||
const full = Filesystem.normalizePath(dir)
|
||||
const slash = full.replaceAll("\\", "/")
|
||||
const root = slash.replace(/^[A-Za-z]:/, "")
|
||||
return Array.from(new Set([full, slash, root, root.toLowerCase()]))
|
||||
}
|
||||
|
||||
const withShell = (item: { label: string; shell: string }, fn: () => Promise<void>) => async () => {
|
||||
const prev = process.env.SHELL
|
||||
process.env.SHELL = item.shell
|
||||
Shell.acceptable.reset()
|
||||
Shell.preferred.reset()
|
||||
try {
|
||||
await fn()
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.SHELL
|
||||
else process.env.SHELL = prev
|
||||
Shell.acceptable.reset()
|
||||
Shell.preferred.reset()
|
||||
}
|
||||
}
|
||||
|
||||
const each = (name: string, fn: (item: { label: string; shell: string }) => Promise<void>) => {
|
||||
for (const item of shells) {
|
||||
test(
|
||||
`${name} [${item.label}]`,
|
||||
withShell(item, () => fn(item)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const capture = (requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">>, stop?: Error) => ({
|
||||
...ctx,
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
if (stop) throw stop
|
||||
},
|
||||
})
|
||||
|
||||
const mustTruncate = (result: {
|
||||
metadata: { truncated?: boolean; exit?: number | null } & Record<string, unknown>
|
||||
output: string
|
||||
}) => {
|
||||
if (result.metadata.truncated) return
|
||||
throw new Error(
|
||||
[`shell: ${process.env.SHELL || ""}`, `exit: ${String(result.metadata.exit)}`, "output:", result.output].join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
describe("tool.bash", () => {
|
||||
test("basic", async () => {
|
||||
each("basic", async () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: "echo 'test'",
|
||||
command: "echo test",
|
||||
description: "Echo test message",
|
||||
},
|
||||
ctx,
|
||||
@@ -43,25 +118,19 @@ describe("tool.bash", () => {
|
||||
})
|
||||
|
||||
describe("tool.bash permissions", () => {
|
||||
test("asks for bash permission with correct pattern", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
each("asks for bash permission with correct pattern", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
await bash.execute(
|
||||
{
|
||||
command: "echo hello",
|
||||
description: "Echo hello",
|
||||
},
|
||||
testCtx,
|
||||
capture(requests),
|
||||
)
|
||||
expect(requests.length).toBe(1)
|
||||
expect(requests[0].permission).toBe("bash")
|
||||
@@ -70,25 +139,19 @@ describe("tool.bash permissions", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("asks for bash permission with multiple commands", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
each("asks for bash permission with multiple commands", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
await bash.execute(
|
||||
{
|
||||
command: "echo foo && echo bar",
|
||||
description: "Echo twice",
|
||||
},
|
||||
testCtx,
|
||||
capture(requests),
|
||||
)
|
||||
expect(requests.length).toBe(1)
|
||||
expect(requests[0].permission).toBe("bash")
|
||||
@@ -98,88 +161,316 @@ describe("tool.bash permissions", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("asks for external_directory permission when cd to parent", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
for (const item of ps) {
|
||||
test(
|
||||
`parses PowerShell conditionals for permission prompts [${item.label}]`,
|
||||
withShell(item, async () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
command: "Write-Host foo; if ($?) { Write-Host bar }",
|
||||
description: "Check PowerShell conditional",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(bashReq).toBeDefined()
|
||||
expect(bashReq!.patterns).toContain("Write-Host foo")
|
||||
expect(bashReq!.patterns).toContain("Write-Host bar")
|
||||
expect(bashReq!.always).toContain("Write-Host *")
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
each("asks for external_directory permission for wildcard external paths", async () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*"
|
||||
const want = process.platform === "win32" ? glob(path.join(process.env.WINDIR!, "*")) : "/etc/*"
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: `cat ${file}`,
|
||||
description: "Read wildcard path",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
expect(extDirReq!.patterns).toContain(want)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
if (process.platform === "win32") {
|
||||
for (const item of ps) {
|
||||
test(
|
||||
`asks for external_directory permission for PowerShell paths after switches [${item.label}]`,
|
||||
withShell(item, async () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`,
|
||||
description: "Copy Windows ini",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*")))
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const item of ps) {
|
||||
test(
|
||||
`asks for external_directory permission for missing PowerShell env paths [${item.label}]`,
|
||||
withShell(item, async () => {
|
||||
const key = "OPENCODE_TEST_MISSING"
|
||||
const prev = process.env[key]
|
||||
delete process.env[key]
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "")
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`,
|
||||
description: "Read Windows ini with missing env",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*")))
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env[key]
|
||||
else process.env[key] = prev
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const item of ps) {
|
||||
test(
|
||||
`asks for external_directory permission for PowerShell env paths [${item.label}]`,
|
||||
withShell(item, async () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
command: "Get-Content $env:WINDIR/win.ini",
|
||||
description: "Read Windows ini from env",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
expect(extDirReq!.patterns).toContain(
|
||||
Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")),
|
||||
)
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const item of ps) {
|
||||
test(
|
||||
`treats Set-Location like cd for permissions [${item.label}]`,
|
||||
withShell(item, async () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
command: "Set-Location C:/Windows",
|
||||
description: "Change location",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(extDirReq).toBeDefined()
|
||||
expect(extDirReq!.patterns).toContain(
|
||||
Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")),
|
||||
)
|
||||
expect(bashReq).toBeUndefined()
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const item of ps) {
|
||||
test(
|
||||
`does not add nested PowerShell expressions to permission prompts [${item.label}]`,
|
||||
withShell(item, async () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
command: "Write-Output ('a' * 3)",
|
||||
description: "Write repeated text",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(bashReq).toBeDefined()
|
||||
expect(bashReq!.patterns).not.toContain("a * 3")
|
||||
expect(bashReq!.always).not.toContain("a *")
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
each("asks for external_directory permission when cd to parent", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
await bash.execute(
|
||||
{
|
||||
command: "cd ../",
|
||||
description: "Change to parent directory",
|
||||
},
|
||||
testCtx,
|
||||
)
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: "cd ../",
|
||||
description: "Change to parent directory",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("asks for external_directory permission when workdir is outside project", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
each("asks for external_directory permission when workdir is outside project", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
await bash.execute(
|
||||
{
|
||||
command: "ls",
|
||||
workdir: os.tmpdir(),
|
||||
description: "List temp dir",
|
||||
},
|
||||
testCtx,
|
||||
)
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: os.tmpdir(),
|
||||
description: "Echo from temp dir",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
expect(extDirReq!.patterns).toContain(path.join(os.tmpdir(), "*"))
|
||||
expect(extDirReq!.patterns).toContain(glob(path.join(os.tmpdir(), "*")))
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("asks for external_directory permission when file arg is outside project", async () => {
|
||||
if (process.platform === "win32") {
|
||||
test("normalizes external_directory workdir variants on Windows", async () => {
|
||||
const err = new Error("stop after permission")
|
||||
await using outerTmp = await tmpdir()
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const want = Filesystem.normalizePathPattern(path.join(outerTmp.path, "*"))
|
||||
|
||||
for (const dir of forms(outerTmp.path)) {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: dir,
|
||||
description: "Echo from external dir",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect({ dir, patterns: extDirReq?.patterns, always: extDirReq?.always }).toEqual({
|
||||
dir,
|
||||
patterns: [want],
|
||||
always: [want],
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
each("asks for external_directory permission when file arg is outside project", async () => {
|
||||
await using outerTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "outside.txt"), "x")
|
||||
},
|
||||
})
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
const filepath = path.join(outerTmp.path, "outside.txt")
|
||||
await bash.execute(
|
||||
{
|
||||
command: `cat ${filepath}`,
|
||||
description: "Read external file",
|
||||
},
|
||||
testCtx,
|
||||
)
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: `cat ${filepath}`,
|
||||
description: "Read external file",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
const expected = path.join(outerTmp.path, "*")
|
||||
const expected = glob(path.join(outerTmp.path, "*"))
|
||||
expect(extDirReq).toBeDefined()
|
||||
expect(extDirReq!.patterns).toContain(expected)
|
||||
expect(extDirReq!.always).toContain(expected)
|
||||
@@ -187,82 +478,64 @@ describe("tool.bash permissions", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("does not ask for external_directory permission when rm inside project", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
each("does not ask for external_directory permission when rm inside project", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "tmpfile"), "x")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
|
||||
await Bun.write(path.join(tmp.path, "tmpfile"), "x")
|
||||
|
||||
await bash.execute(
|
||||
{
|
||||
command: `rm -rf ${path.join(tmp.path, "nested")}`,
|
||||
description: "remove nested dir",
|
||||
description: "Remove nested dir",
|
||||
},
|
||||
testCtx,
|
||||
capture(requests),
|
||||
)
|
||||
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("includes always patterns for auto-approval", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
each("includes always patterns for auto-approval", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
await bash.execute(
|
||||
{
|
||||
command: "git log --oneline -5",
|
||||
description: "Git log",
|
||||
},
|
||||
testCtx,
|
||||
capture(requests),
|
||||
)
|
||||
expect(requests.length).toBe(1)
|
||||
expect(requests[0].always.length).toBeGreaterThan(0)
|
||||
expect(requests[0].always.some((p) => p.endsWith("*"))).toBe(true)
|
||||
expect(requests[0].always.some((item) => item.endsWith("*"))).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("does not ask for bash permission when command is cd only", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
each("does not ask for bash permission when command is cd only", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
await bash.execute(
|
||||
{
|
||||
command: "cd .",
|
||||
description: "Stay in current directory",
|
||||
},
|
||||
testCtx,
|
||||
capture(requests),
|
||||
)
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(bashReq).toBeUndefined()
|
||||
@@ -270,45 +543,38 @@ describe("tool.bash permissions", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("matches redirects in permission pattern", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
each("matches redirects in permission pattern", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
await bash.execute({ command: "cat > /tmp/output.txt", description: "Redirect ls output" }, testCtx)
|
||||
await expect(
|
||||
bash.execute(
|
||||
{ command: "echo test > output.txt", description: "Redirect test output" },
|
||||
capture(requests, err),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(bashReq).toBeDefined()
|
||||
expect(bashReq!.patterns).toContain("cat > /tmp/output.txt")
|
||||
expect(bashReq!.patterns).toContain("echo test > output.txt")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("always pattern has space before wildcard to not include different commands", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
each("always pattern has space before wildcard to not include different commands", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
await bash.execute({ command: "ls -la", description: "List" }, testCtx)
|
||||
await bash.execute({ command: "ls -la", description: "List" }, capture(requests))
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(bashReq).toBeDefined()
|
||||
const pattern = bashReq!.always[0]
|
||||
expect(pattern).toBe("ls *")
|
||||
expect(bashReq!.always[0]).toBe("ls *")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -323,12 +589,12 @@ describe("tool.bash truncation", () => {
|
||||
const lineCount = Truncate.MAX_LINES + 500
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: `seq 1 ${lineCount}`,
|
||||
command: fill("lines", lineCount),
|
||||
description: "Generate lines exceeding limit",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect((result.metadata as any).truncated).toBe(true)
|
||||
mustTruncate(result)
|
||||
expect(result.output).toContain("truncated")
|
||||
expect(result.output).toContain("The tool call succeeded but the output was truncated")
|
||||
},
|
||||
@@ -343,12 +609,12 @@ describe("tool.bash truncation", () => {
|
||||
const byteCount = Truncate.MAX_BYTES + 10000
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: `head -c ${byteCount} /dev/zero | tr '\\0' 'a'`,
|
||||
command: fill("bytes", byteCount),
|
||||
description: "Generate bytes exceeding limit",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect((result.metadata as any).truncated).toBe(true)
|
||||
mustTruncate(result)
|
||||
expect(result.output).toContain("truncated")
|
||||
expect(result.output).toContain("The tool call succeeded but the output was truncated")
|
||||
},
|
||||
@@ -367,9 +633,8 @@ describe("tool.bash truncation", () => {
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect((result.metadata as any).truncated).toBe(false)
|
||||
const eol = process.platform === "win32" ? "\r\n" : "\n"
|
||||
expect(result.output).toBe(`hello${eol}`)
|
||||
expect((result.metadata as { truncated?: boolean }).truncated).toBe(false)
|
||||
expect(result.output).toContain("hello")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -382,18 +647,18 @@ describe("tool.bash truncation", () => {
|
||||
const lineCount = Truncate.MAX_LINES + 100
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: `seq 1 ${lineCount}`,
|
||||
command: fill("lines", lineCount),
|
||||
description: "Generate lines for file check",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect((result.metadata as any).truncated).toBe(true)
|
||||
mustTruncate(result)
|
||||
|
||||
const filepath = (result.metadata as any).outputPath
|
||||
const filepath = (result.metadata as { outputPath?: string }).outputPath
|
||||
expect(filepath).toBeTruthy()
|
||||
|
||||
const saved = await Filesystem.readText(filepath)
|
||||
const lines = saved.trim().split("\n")
|
||||
const saved = await Filesystem.readText(filepath!)
|
||||
const lines = saved.trim().split(/\r?\n/)
|
||||
expect(lines.length).toBe(lineCount)
|
||||
expect(lines[0]).toBe("1")
|
||||
expect(lines[lineCount - 1]).toBe(String(lineCount))
|
||||
|
||||
@@ -3,6 +3,8 @@ import path from "path"
|
||||
import type { Tool } from "../../src/tool/tool"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { assertExternalDirectory } from "../../src/tool/external-directory"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import type { Permission } from "../../src/permission"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
@@ -16,6 +18,9 @@ const baseCtx: Omit<Tool.Context, "ask"> = {
|
||||
metadata: () => {},
|
||||
}
|
||||
|
||||
const glob = (p: string) =>
|
||||
process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
|
||||
|
||||
describe("tool.assertExternalDirectory", () => {
|
||||
test("no-ops for empty target", async () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
@@ -66,7 +71,7 @@ describe("tool.assertExternalDirectory", () => {
|
||||
|
||||
const directory = "/tmp/project"
|
||||
const target = "/tmp/outside/file.txt"
|
||||
const expected = path.join(path.dirname(target), "*").replaceAll("\\", "/")
|
||||
const expected = glob(path.join(path.dirname(target), "*"))
|
||||
|
||||
await Instance.provide({
|
||||
directory,
|
||||
@@ -92,7 +97,7 @@ describe("tool.assertExternalDirectory", () => {
|
||||
|
||||
const directory = "/tmp/project"
|
||||
const target = "/tmp/outside"
|
||||
const expected = path.join(target, "*").replaceAll("\\", "/")
|
||||
const expected = glob(path.join(target, "*"))
|
||||
|
||||
await Instance.provide({
|
||||
directory,
|
||||
@@ -125,4 +130,42 @@ describe("tool.assertExternalDirectory", () => {
|
||||
|
||||
expect(requests.length).toBe(0)
|
||||
})
|
||||
|
||||
if (process.platform === "win32") {
|
||||
test("normalizes Windows path variants to one glob", async () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
|
||||
await using outerTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "outside.txt"), "x")
|
||||
},
|
||||
})
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const target = path.join(outerTmp.path, "outside.txt")
|
||||
const alt = target
|
||||
.replace(/^[A-Za-z]:/, "")
|
||||
.replaceAll("\\", "/")
|
||||
.toLowerCase()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await assertExternalDirectory(ctx, alt)
|
||||
},
|
||||
})
|
||||
|
||||
const req = requests.find((r) => r.permission === "external_directory")
|
||||
const expected = glob(path.join(outerTmp.path, "*"))
|
||||
expect(req).toBeDefined()
|
||||
expect(req!.patterns).toEqual([expected])
|
||||
expect(req!.always).toEqual([expected])
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
14
packages/opencode/test/tool/fixtures/output.ts
Normal file
14
packages/opencode/test/tool/fixtures/output.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
const mode = Bun.argv[2]
|
||||
const n = Number(Bun.argv[3])
|
||||
|
||||
if (mode === "lines") {
|
||||
console.log(Array.from({ length: n }, (_, i) => i + 1).join("\n"))
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (mode === "bytes") {
|
||||
process.stdout.write("a".repeat(n))
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
throw new Error(`unknown mode: ${mode}`)
|
||||
@@ -25,6 +25,10 @@ const ctx = {
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
|
||||
const glob = (p: string) =>
|
||||
process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
|
||||
|
||||
describe("tool.read external_directory permission", () => {
|
||||
test("allows reading absolute path inside project directory", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
@@ -79,11 +83,44 @@ describe("tool.read external_directory permission", () => {
|
||||
await read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, testCtx)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
expect(extDirReq!.patterns.some((p) => p.includes(outerTmp.path.replaceAll("\\", "/")))).toBe(true)
|
||||
expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "*")))
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
if (process.platform === "win32") {
|
||||
test("normalizes read permission paths on Windows", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "test.txt"), "hello world")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
const target = path.join(tmp.path, "test.txt")
|
||||
const alt = target
|
||||
.replace(/^[A-Za-z]:/, "")
|
||||
.replaceAll("\\", "/")
|
||||
.toLowerCase()
|
||||
await read.execute({ filePath: alt }, testCtx)
|
||||
const readReq = requests.find((r) => r.permission === "read")
|
||||
expect(readReq).toBeDefined()
|
||||
expect(readReq!.patterns).toEqual([full(target)])
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test("asks for directory-scoped external_directory permission when reading external directory", async () => {
|
||||
await using outerTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
@@ -105,7 +142,7 @@ describe("tool.read external_directory permission", () => {
|
||||
await read.execute({ filePath: path.join(outerTmp.path, "external") }, testCtx)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
expect(extDirReq!.patterns).toContain(path.join(outerTmp.path, "external", "*").replaceAll("\\", "/"))
|
||||
expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "external", "*")))
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
},
|
||||
"opencode#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
"outputs": [],
|
||||
"passThroughEnv": ["*"]
|
||||
},
|
||||
"@opencode-ai/app#test": {
|
||||
"dependsOn": ["^build"],
|
||||
|
||||
Reference in New Issue
Block a user