mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-24 06:45:22 +00:00
fix(windows): normalize shell permission paths
Use the PowerShell parser for pwsh and collapse equivalent Windows path forms into canonical permission keys.
This commit is contained in:
6
bun.lock
6
bun.lock
@@ -369,6 +369,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",
|
||||
@@ -562,8 +563,9 @@
|
||||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"electron",
|
||||
"esbuild",
|
||||
"tree-sitter-powershell",
|
||||
"electron",
|
||||
"web-tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
],
|
||||
@@ -4363,6 +4365,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=="],
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"protobufjs",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"tree-sitter-powershell",
|
||||
"web-tree-sitter",
|
||||
"electron"
|
||||
],
|
||||
|
||||
@@ -123,6 +123,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",
|
||||
|
||||
@@ -6,7 +6,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 { Language, type Node } from "web-tree-sitter"
|
||||
|
||||
import { $ } from "bun"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
@@ -20,6 +20,7 @@ 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"])
|
||||
|
||||
export const log = Log.create({ service: "bash-tool" })
|
||||
|
||||
@@ -30,6 +31,34 @@ const resolveWasm = (asset: string) => {
|
||||
return fileURLToPath(url)
|
||||
}
|
||||
|
||||
function parts(node: Node) {
|
||||
const out = []
|
||||
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(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(child.text)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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,19 +73,26 @@ 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 = process.platform === "win32" ? path.win32.basename(shell, ".exe") : path.basename(shell)
|
||||
const lower = name.toLowerCase()
|
||||
const chain =
|
||||
name.toLowerCase() === "powershell"
|
||||
lower === "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 })
|
||||
@@ -84,12 +120,15 @@ export const BashTool = Tool.define("bash", async () => {
|
||||
),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const cwd = params.workdir || Instance.directory
|
||||
const cwd =
|
||||
process.platform === "win32" && path.isAbsolute(params.workdir || Instance.directory)
|
||||
? Filesystem.normalizePath(params.workdir || Instance.directory)
|
||||
: 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.`)
|
||||
}
|
||||
const timeout = params.timeout ?? DEFAULT_TIMEOUT
|
||||
const tree = await parser().then((p) => p.parse(params.command))
|
||||
const tree = await parser().then((p) => (PS.has(lower) ? p.ps : p.bash).parse(params.command))
|
||||
if (!tree) {
|
||||
throw new Error("Failed to parse command")
|
||||
}
|
||||
@@ -103,22 +142,9 @@ export const BashTool = Tool.define("bash", async () => {
|
||||
|
||||
// Get full command text including redirects if present
|
||||
let commandText = node.parent?.type === "redirected_statement" ? node.parent.text : node.text
|
||||
commandText = commandText.trim()
|
||||
|
||||
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)
|
||||
}
|
||||
const command = parts(node)
|
||||
|
||||
// not an exhaustive list, but covers most common cases
|
||||
if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"].includes(command[0])) {
|
||||
@@ -132,8 +158,7 @@ export const BashTool = Tool.define("bash", async () => {
|
||||
.then((x) => x.trim())
|
||||
log.info("resolved path", { arg, resolved })
|
||||
if (resolved) {
|
||||
const normalized =
|
||||
process.platform === "win32" ? Filesystem.windowsPath(resolved).replace(/\//g, "\\") : resolved
|
||||
const normalized = process.platform === "win32" ? Filesystem.normalizePath(resolved) : resolved
|
||||
if (!Instance.containsPath(normalized)) {
|
||||
const dir = (await Filesystem.isDir(normalized)) ? normalized : path.dirname(normalized)
|
||||
directories.add(dir)
|
||||
@@ -151,6 +176,7 @@ export const BashTool = Tool.define("bash", async () => {
|
||||
|
||||
if (directories.size > 0) {
|
||||
const globs = Array.from(directories).map((dir) => {
|
||||
if (process.platform === "win32") return Filesystem.normalizePathPattern(path.join(dir, "*"))
|
||||
// Preserve POSIX-looking paths with /s, even on Windows
|
||||
if (dir.startsWith("/")) return `${dir.replace(/[\\/]+$/, "")}/*`
|
||||
return path.join(dir, "*")
|
||||
|
||||
@@ -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 } from "path"
|
||||
import { dirname, join, relative, 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]), "*")
|
||||
}
|
||||
|
||||
export function windowsPath(p: string): string {
|
||||
if (process.platform !== "win32") return p
|
||||
return (
|
||||
|
||||
@@ -21,6 +21,7 @@ const ctx = {
|
||||
}
|
||||
|
||||
const projectRoot = path.join(__dirname, "../..")
|
||||
const win = process.env.WINDIR?.replaceAll("\\", "/")
|
||||
const bin = process.execPath.replaceAll("\\", "/")
|
||||
const file = path.join(projectRoot, "test/tool/fixtures/output.ts").replaceAll("\\", "/")
|
||||
const kind = () => path.win32.basename(process.env.SHELL || "", ".exe").toLowerCase()
|
||||
@@ -46,26 +47,49 @@ const shells = (() => {
|
||||
|
||||
return list.filter((item, i) => list.findIndex((x) => x.shell.toLowerCase() === item.shell.toLowerCase()) === i)
|
||||
})()
|
||||
const ps = shells.filter((item) => ["pwsh", "powershell"].includes(item.label))
|
||||
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 = (shell: string, fn: () => Promise<void>) => async () => {
|
||||
const prev = process.env.SHELL
|
||||
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
|
||||
const withShell =
|
||||
(item: { label: string; shell: string }, fn: (item: { label: string; shell: string }) => Promise<void>) =>
|
||||
async () => {
|
||||
const prev = process.env.SHELL
|
||||
process.env.SHELL = item.shell
|
||||
Shell.acceptable.reset()
|
||||
Shell.preferred.reset()
|
||||
try {
|
||||
await fn(item)
|
||||
} 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))
|
||||
}
|
||||
}
|
||||
|
||||
const each = (name: string, fn: () => Promise<void>) => {
|
||||
for (const item of shells) {
|
||||
test(`${name} [${item.label}]`, withShell(item.shell, fn))
|
||||
}
|
||||
const mustTruncate = (result: { metadata: any; output: string }, item: { label: string; shell: string }) => {
|
||||
if (result.metadata.truncated) return
|
||||
throw new Error(
|
||||
[
|
||||
`shell: ${item.label}`,
|
||||
`path: ${item.shell}`,
|
||||
`exit: ${String(result.metadata.exit)}`,
|
||||
"output:",
|
||||
result.output,
|
||||
].join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
describe("tool.bash", () => {
|
||||
@@ -144,6 +168,76 @@ describe("tool.bash permissions", () => {
|
||||
})
|
||||
})
|
||||
|
||||
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<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
await bash.execute(
|
||||
{
|
||||
command: "Write-Host foo; if ($?) { Write-Host bar }",
|
||||
description: "Check PowerShell conditional",
|
||||
},
|
||||
testCtx,
|
||||
)
|
||||
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!.patterns).not.toContain("0")
|
||||
expect(bashReq!.always).toContain("Write-Host *")
|
||||
expect(bashReq!.always).not.toContain("0 *")
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (win) {
|
||||
for (const item of ps) {
|
||||
test(
|
||||
`asks for external_directory permission for PowerShell file args [${item.label}]`,
|
||||
withShell(item, async () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
await bash.execute(
|
||||
{
|
||||
command: `cat ${win}/win.ini`,
|
||||
description: "Read Windows ini",
|
||||
},
|
||||
testCtx,
|
||||
)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
expect(extDirReq!.patterns).toContain(
|
||||
Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")),
|
||||
)
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
each("asks for external_directory permission when cd to parent", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
@@ -185,9 +279,9 @@ describe("tool.bash permissions", () => {
|
||||
}
|
||||
await bash.execute(
|
||||
{
|
||||
command: "ls",
|
||||
command: "echo ok",
|
||||
workdir: os.tmpdir(),
|
||||
description: "List temp dir",
|
||||
description: "Echo from temp dir",
|
||||
},
|
||||
testCtx,
|
||||
)
|
||||
@@ -198,6 +292,52 @@ describe("tool.bash permissions", () => {
|
||||
})
|
||||
})
|
||||
|
||||
if (process.platform === "win32") {
|
||||
for (const item of shells) {
|
||||
test(
|
||||
`normalizes external_directory workdir variants on Windows [${item.label}]`,
|
||||
withShell(item, async () => {
|
||||
await using outerTmp = await tmpdir()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
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<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
|
||||
await bash.execute(
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: dir,
|
||||
description: "Echo from external dir",
|
||||
},
|
||||
testCtx,
|
||||
)
|
||||
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect({ dir, patterns: extDirReq?.patterns, always: extDirReq?.always }).toEqual({
|
||||
dir,
|
||||
patterns: [want],
|
||||
always: [want],
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}),
|
||||
{ timeout: 20000 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
each("asks for external_directory permission when file arg is outside project", async () => {
|
||||
await using outerTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
@@ -329,13 +469,10 @@ describe("tool.bash permissions", () => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
const command = ["pwsh", "powershell"].includes(kind())
|
||||
? "Write-Output test > output.txt"
|
||||
: "cat > /tmp/output.txt"
|
||||
await bash.execute({ command, description: "Redirect ls output" }, testCtx)
|
||||
await bash.execute({ command: "echo test > output.txt", description: "Redirect test output" }, testCtx)
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(bashReq).toBeDefined()
|
||||
expect(bashReq!.patterns).toContain(command)
|
||||
expect(bashReq!.patterns).toContain("echo test > output.txt")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -364,7 +501,7 @@ describe("tool.bash permissions", () => {
|
||||
})
|
||||
|
||||
describe("tool.bash truncation", () => {
|
||||
each("truncates output exceeding line limit", async () => {
|
||||
each("truncates output exceeding line limit", async (item) => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
@@ -377,14 +514,14 @@ describe("tool.bash truncation", () => {
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect((result.metadata as any).truncated).toBe(true)
|
||||
mustTruncate(result, item)
|
||||
expect(result.output).toContain("truncated")
|
||||
expect(result.output).toContain("The tool call succeeded but the output was truncated")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
each("truncates output exceeding byte limit", async () => {
|
||||
each("truncates output exceeding byte limit", async (item) => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
@@ -397,7 +534,7 @@ describe("tool.bash truncation", () => {
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect((result.metadata as any).truncated).toBe(true)
|
||||
mustTruncate(result, item)
|
||||
expect(result.output).toContain("truncated")
|
||||
expect(result.output).toContain("The tool call succeeded but the output was truncated")
|
||||
},
|
||||
@@ -422,7 +559,7 @@ describe("tool.bash truncation", () => {
|
||||
})
|
||||
})
|
||||
|
||||
each("full output is saved to file when truncated", async () => {
|
||||
each("full output is saved to file when truncated", async (item) => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
@@ -435,7 +572,7 @@ describe("tool.bash truncation", () => {
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect((result.metadata as any).truncated).toBe(true)
|
||||
mustTruncate(result, item)
|
||||
|
||||
const filepath = (result.metadata as any).outputPath
|
||||
expect(filepath).toBeTruthy()
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { Tool } from "../../src/tool/tool"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { assertExternalDirectory } from "../../src/tool/external-directory"
|
||||
import type { PermissionNext } from "../../src/permission/next"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const baseCtx: Omit<Tool.Context, "ask"> = {
|
||||
sessionID: "test",
|
||||
@@ -15,6 +17,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<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
@@ -65,7 +70,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,
|
||||
@@ -91,7 +96,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,
|
||||
@@ -124,4 +129,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<PermissionNext.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])
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -20,6 +20,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({
|
||||
@@ -74,11 +78,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<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.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) => {
|
||||
@@ -100,7 +137,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", "*")))
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user