diff --git a/bun.lock b/bun.lock index 0898a51c83..b2fa4cd155 100644 --- a/bun.lock +++ b/bun.lock @@ -402,8 +402,11 @@ "immer": "11.1.4", "jsonc-parser": "3.3.1", "semver": "^7.6.3", + "tree-sitter-bash": "0.25.0", + "tree-sitter-powershell": "0.25.10", "turndown": "7.2.0", "venice-ai-sdk-provider": "2.1.1", + "web-tree-sitter": "0.25.10", "which": "6.0.1", "zod": "catalog:", }, @@ -1073,9 +1076,11 @@ }, "trustedDependencies": [ "esbuild", + "tree-sitter-powershell", "protobufjs", "electron", "web-tree-sitter", + "tree-sitter-bash", ], "patchedDependencies": { "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", @@ -4072,10 +4077,10 @@ "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], - "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], - "ffi-rs": ["ffi-rs@1.3.2", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-x64": "1.3.2", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-x64-musl": "1.3.2", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2" } }, "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ=="], + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -5634,6 +5639,10 @@ "traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="], + "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=="], + "treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -6856,6 +6865,8 @@ "topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "tree-sitter-bash/node-addon-api": ["node-addon-api@8.9.0", "", {}, "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q=="], + "tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="], diff --git a/packages/cli/package.json b/packages/cli/package.json index 94a4b20aee..5f0ea9dddf 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -40,6 +40,9 @@ "open": "10.1.2", "semver": "catalog:", "solid-js": "catalog:", + "tree-sitter-bash": "0.25.0", + "tree-sitter-powershell": "0.25.10", + "web-tree-sitter": "0.25.10", "uqr": "0.1.3", "ws": "8.21.0" }, diff --git a/packages/cli/script/build-node.ts b/packages/cli/script/build-node.ts index 9a67fd35d2..8bb9dda9f6 100644 --- a/packages/cli/script/build-node.ts +++ b/packages/cli/script/build-node.ts @@ -62,8 +62,8 @@ for (const target of targets) { await rm("dist-node", { recursive: true, force: true }) const assetHash = await hashNodeAssets(assets) const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target } - await build(mainConfig(input)) await copyNodeAssets(assets) + await build(mainConfig(input)) const host = target.platform === process.platform && target.arch === process.arch if (host) { diff --git a/packages/cli/script/node-assets.ts b/packages/cli/script/node-assets.ts index 3f4823d823..adb871212e 100644 --- a/packages/cli/script/node-assets.ts +++ b/packages/cli/script/node-assets.ts @@ -3,7 +3,7 @@ import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises" import path from "node:path" import { fileURLToPath } from "node:url" import { getNodeAssets } from "@opentui/core/node-assets" -import { attentionSoundAssets, type NodeTarget, photonWasmAsset } from "../src/node/target" +import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target" const dir = path.resolve(import.meta.dirname, "..") @@ -43,6 +43,10 @@ export async function collectNodeAssets(target: NodeTarget) { key: photonWasmAsset, source: fileURLToPath(import.meta.resolve(photonWasmAsset)), }, + ...Object.values(shellParserWasmAssets).map((key) => ({ + key, + source: fileURLToPath(import.meta.resolve(key)), + })), ...attentionSoundAssets.map((key) => ({ key, source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)), diff --git a/packages/cli/src/node/target.ts b/packages/cli/src/node/target.ts index f39db8f820..4e62e4a02d 100644 --- a/packages/cli/src/node/target.ts +++ b/packages/cli/src/node/target.ts @@ -29,6 +29,11 @@ export function nodeTarget(platform: string, arch: string) { } export const photonWasmAsset = "@silvia-odwyer/photon-node/photon_rs_bg.wasm" +export const shellParserWasmAssets = { + runtime: "web-tree-sitter/tree-sitter.wasm", + bash: "tree-sitter-bash/tree-sitter-bash.wasm", + powershell: "tree-sitter-powershell/tree-sitter-powershell.wasm", +} as const export const nodeExecArgv = ["--experimental-ffi", "--use-system-ca", "--disable-warning=ExperimentalWarning"] as const export const attentionSoundAssets = [ diff --git a/packages/cli/vite.node.config.ts b/packages/cli/vite.node.config.ts index 7d7caf6d05..8b7bf9c826 100644 --- a/packages/cli/vite.node.config.ts +++ b/packages/cli/vite.node.config.ts @@ -3,7 +3,13 @@ import { readFile } from "node:fs/promises" import { createRequire } from "node:module" import { defineConfig, type Plugin, type UserConfig } from "vite" import solid from "vite-plugin-solid" -import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset } from "./src/node/target" +import { + nodeExecArgv, + nodeTarget, + type NodeTarget, + photonWasmAsset, + shellParserWasmAssets, +} from "./src/node/target" const dir = import.meta.dirname @@ -194,6 +200,9 @@ process.env.OTUI_ASSET_ROOT = __ocAssetRoot process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)}) process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)}) process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)}) +process.env.OPENCODE_TREE_SITTER_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.runtime)}) +process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.bash)}) +process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)}) process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)}) process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)}) try { diff --git a/packages/core/package.json b/packages/core/package.json index 4d6f46c1c2..3b208f6f25 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -41,6 +41,11 @@ "node": "./src/image/photon-wasm.node.ts", "default": "./src/image/photon-wasm.bun.ts" }, + "#shell-parser-wasm": { + "bun": "./src/shell/parser-wasm.bun.ts", + "node": "./src/shell/parser-wasm.node.ts", + "default": "./src/shell/parser-wasm.bun.ts" + }, "#process-lock-ffi": { "bun": "./src/util/process-lock-ffi.bun.ts", "node": "./src/util/process-lock-ffi.node.ts", @@ -119,7 +124,10 @@ "jsonc-parser": "3.3.1", "semver": "^7.6.3", "turndown": "7.2.0", + "tree-sitter-bash": "0.25.0", + "tree-sitter-powershell": "0.25.10", "venice-ai-sdk-provider": "2.1.1", + "web-tree-sitter": "0.25.10", "which": "6.0.1", "zod": "catalog:" }, diff --git a/packages/core/src/shell/parse.ts b/packages/core/src/shell/parse.ts new file mode 100644 index 0000000000..ec09e7e718 --- /dev/null +++ b/packages/core/src/shell/parse.ts @@ -0,0 +1,296 @@ +export * as ShellParse from "./parse" + +import { Effect } from "effect" +import { fileURLToPath } from "url" +import os from "os" +import path from "path" +import type { Node } from "web-tree-sitter" +import { shellParserWasm } from "#shell-parser-wasm" +import { ShellSelect } from "./select" + +type Part = { type: string; text: string } +const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"]) +const POWERSHELL_PATH_FLAGS = new Set(["-literalpath", "-path"]) + +const ARITY: Record = { + cat: 1, + cd: 1, + chmod: 1, + chown: 1, + cp: 1, + echo: 1, + env: 1, + export: 1, + grep: 1, + kill: 1, + killall: 1, + ln: 1, + ls: 1, + mkdir: 1, + mv: 1, + ps: 1, + pwd: 1, + rm: 1, + rmdir: 1, + sleep: 1, + source: 1, + tail: 1, + touch: 1, + unset: 1, + which: 1, + aws: 3, + az: 3, + bazel: 2, + brew: 2, + bun: 2, + "bun run": 3, + "bun x": 3, + cargo: 2, + "cargo add": 3, + "cargo run": 3, + cdk: 2, + cf: 2, + cmake: 2, + composer: 2, + consul: 2, + "consul kv": 3, + crictl: 2, + deno: 2, + "deno task": 3, + doctl: 3, + docker: 2, + "docker builder": 3, + "docker compose": 3, + "docker container": 3, + "docker image": 3, + "docker network": 3, + "docker volume": 3, + eksctl: 2, + "eksctl create": 3, + firebase: 2, + flyctl: 2, + gcloud: 3, + gh: 3, + git: 2, + "git config": 3, + "git remote": 3, + "git stash": 3, + go: 2, + gradle: 2, + helm: 2, + heroku: 2, + hugo: 2, + ip: 2, + "ip addr": 3, + "ip link": 3, + "ip netns": 3, + "ip route": 3, + kind: 2, + "kind create": 3, + kubectl: 2, + "kubectl kustomize": 3, + "kubectl rollout": 3, + kustomize: 2, + make: 2, + mc: 2, + "mc admin": 3, + minikube: 2, + mongosh: 2, + mysql: 2, + mvn: 2, + ng: 2, + npm: 2, + "npm exec": 3, + "npm init": 3, + "npm run": 3, + "npm view": 3, + npx: 2, + nvm: 2, + nx: 2, + openssl: 2, + "openssl req": 3, + "openssl x509": 3, + pip: 2, + pipenv: 2, + pnpm: 2, + "pnpm dlx": 3, + "pnpm exec": 3, + "pnpm run": 3, + poetry: 2, + podman: 2, + "podman container": 3, + "podman image": 3, + psql: 2, + pulumi: 2, + "pulumi stack": 3, + python: 2, + pyenv: 2, + rake: 2, + rbenv: 2, + "redis-cli": 2, + rustup: 2, + serverless: 2, + sfdx: 3, + skaffold: 2, + sls: 2, + sst: 2, + swift: 2, + systemctl: 2, + terraform: 2, + "terraform workspace": 3, + tmux: 2, + turbo: 2, + ufw: 2, + vault: 2, + "vault auth": 3, + "vault kv": 3, + vercel: 2, + volta: 2, + wp: 2, + yarn: 2, + "yarn dlx": 3, + "yarn run": 3, +} + +export const scan = Effect.fn("ShellParse.scan")(function* (command: string, shell: string, cwd: string) { + const parsers = yield* Effect.promise(load) + const powershell = ShellSelect.ps(shell) + const tree = (powershell ? parsers.ps : parsers.bash).parse(command) + if (!tree) return yield* Effect.fail(new Error("Failed to parse shell command")) + + return yield* Effect.acquireUseRelease( + Effect.succeed(tree), + (tree) => + Effect.sync(() => + tree.rootNode.descendantsOfType("command").reduce( + (result, node) => { + if (!node) return result + const command = parts(node) + const tokens = command.map((part) => part.text) + if (tokens.length === 0) return result + const name = powershell ? tokens[0].toLowerCase() : tokens[0] + if (CWD.has(name)) { + result.directories.push(...directoryArgs(command, powershell, cwd, shell)) + return result + } + result.commands.push({ + resource: (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim(), + save: `${prefix(tokens).join(" ")} *`, + }) + return result + }, + { commands: [] as Array<{ resource: string; save: string }>, directories: [] as string[] }, + ), + ), + (tree) => Effect.sync(() => tree.delete()), + ) +}) + +function parts(node: Node) { + return Array.from({ length: node.childCount }).flatMap((_, index): Part[] => { + const child = node.child(index) + if (!child) return [] + if (child.type === "command_elements") + return Array.from({ length: child.childCount }).flatMap((_, itemIndex): Part[] => { + const item = child.child(itemIndex) + if (!item || item.type === "command_argument_sep" || item.type === "redirection") return [] + return [{ type: item.type, text: item.text }] + }) + if (!["command_name", "command_name_expr", "word", "string", "raw_string", "concatenation"].includes(child.type)) + return [] + return [{ type: child.type, text: child.text }] + }) +} + +function directoryArgs(command: Part[], powershell: boolean, cwd: string, shell: string) { + if (!powershell) + return command + .slice(1) + .filter((part) => !part.text.startsWith("-")) + .map((part) => directoryArgument(part.text, powershell, cwd, shell)) + .filter((part) => part !== undefined) + + const directories: string[] = [] + let path = false + for (const part of command.slice(1)) { + if (path) { + const value = directoryArgument(part.text, powershell, cwd, shell) + if (value) directories.push(value) + path = false + continue + } + if (part.type === "command_parameter") { + path = POWERSHELL_PATH_FLAGS.has(part.text.toLowerCase()) + continue + } + const value = directoryArgument(part.text, powershell, cwd, shell) + if (value) directories.push(value) + } + return directories +} + +function directoryArgument(value: string, powershell: boolean, cwd: string, shell: string) { + const quote = value[0] + const text = (quote === '"' || quote === "'") && value.at(-1) === quote ? value.slice(1, -1) : value + if (!powershell) return expandKnownDirectory(text) + + // PowerShell exposes environment variables through $env:NAME and provides these + // automatic directory variables. Expand only values we can determine without executing code. + return expandKnownDirectory( + text + .replace(/\$\{env:([^}]+)\}/gi, (_, key: string) => environment(key) ?? "") + .replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, key: string) => environment(key) ?? "") + .replace(/\$(HOME|PWD|PSHOME)(?=$|[\\/])/gi, (_, key: string) => { + if (key.toUpperCase() === "HOME") return os.homedir() + if (key.toUpperCase() === "PWD") return cwd + return path.dirname(shell) + }), + ) +} + +function expandKnownDirectory(value: string) { + // Unknown shell expressions cannot be resolved safely during permission analysis. + if (value.includes("$") || value.includes("`") || value.startsWith("(")) return + if (value === "~") return os.homedir() + if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2)) + return value +} + +function environment(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 prefix(tokens: string[]) { + for (let length = tokens.length; length > 0; length--) { + const arity = ARITY[tokens.slice(0, length).join(" ")] + if (arity !== undefined) return tokens.slice(0, arity) + } + return tokens.slice(0, 1) +} + +function resolve(asset: string) { + if (asset.startsWith("file://")) return fileURLToPath(asset) + if (path.isAbsolute(asset)) return asset + return fileURLToPath(new URL(asset, import.meta.url)) +} + +const load = (() => { + let loading: ReturnType | undefined + return () => (loading ??= initialize()) +})() + +async function initialize() { + const { Parser, Language } = await import("web-tree-sitter") + await Parser.init({ locateFile: () => resolve(shellParserWasm.runtime) }) + const [bashLanguage, psLanguage] = await Promise.all([ + Language.load(resolve(shellParserWasm.bash)), + Language.load(resolve(shellParserWasm.powershell)), + ]) + const bash = new Parser() + bash.setLanguage(bashLanguage) + const ps = new Parser() + ps.setLanguage(psLanguage) + return { bash, ps } +} diff --git a/packages/core/src/shell/parser-wasm.bun.ts b/packages/core/src/shell/parser-wasm.bun.ts new file mode 100644 index 0000000000..278a169d9b --- /dev/null +++ b/packages/core/src/shell/parser-wasm.bun.ts @@ -0,0 +1,8 @@ +// @ts-ignore Bun embeds static file imports when compiling the CLI. +import runtime from "web-tree-sitter/tree-sitter.wasm" with { type: "file" } +// @ts-ignore Bun embeds static file imports when compiling the CLI. +import bash from "tree-sitter-bash/tree-sitter-bash.wasm" with { type: "file" } +// @ts-ignore Bun embeds static file imports when compiling the CLI. +import powershell from "tree-sitter-powershell/tree-sitter-powershell.wasm" with { type: "file" } + +export const shellParserWasm = { runtime, bash, powershell } diff --git a/packages/core/src/shell/parser-wasm.node.ts b/packages/core/src/shell/parser-wasm.node.ts new file mode 100644 index 0000000000..9b2527c9cc --- /dev/null +++ b/packages/core/src/shell/parser-wasm.node.ts @@ -0,0 +1,12 @@ +import { createRequire } from "node:module" + +const require = createRequire(import.meta.url) + +export const shellParserWasm = { + runtime: process.env.OPENCODE_TREE_SITTER_WASM_PATH ?? require.resolve("web-tree-sitter/tree-sitter.wasm"), + bash: + process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH ?? require.resolve("tree-sitter-bash/tree-sitter-bash.wasm"), + powershell: + process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH ?? + require.resolve("tree-sitter-powershell/tree-sitter-powershell.wasm"), +} diff --git a/packages/core/src/tool/plugin/shell.ts b/packages/core/src/tool/plugin/shell.ts index 89ad765a83..ae6413df1e 100644 --- a/packages/core/src/tool/plugin/shell.ts +++ b/packages/core/src/tool/plugin/shell.ts @@ -1,5 +1,6 @@ export * as ShellTool from "./shell" +import path from "path" import { ToolFailure } from "@opencode-ai/ai" import type { Content } from "@opencode-ai/schema/tool" import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin" @@ -11,6 +12,7 @@ import { PluginRuntime } from "../../plugin/runtime" import { NonNegativeInt } from "../../schema" import { SessionSchema } from "../../session/schema" import { Shell } from "../../shell" +import { ShellParse } from "../../shell/parse" export const name = "shell" export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000 @@ -75,18 +77,6 @@ const modelOutput = (output: Output): string | undefined => { return `Command exited with code ${output.exit}.` } -/** - * Minimal core shell boundary. Keep parity debt visible without pulling the - * legacy shell runtime into core. - */ -// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction. -// TODO: Port BashArity reusable command-prefix approvals. -// TODO: Add plugin shell.env environment augmentation once plugin hooks exist. -// TODO: Persist job status and define restart recovery before exposing remote observation. -// TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined. -// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it. -// TODO: Revisit binary output handling if stdout/stderr decoding is text-only. - export const Plugin = { id: "opencode.tool.shell", effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) { @@ -158,24 +148,34 @@ export const Plugin = { (invocation) => Effect.gen(function* () { const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" }) + const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.canonical) + const directories = yield* Effect.forEach(parsed.directories, (directory) => + mutation.resolve({ path: path.resolve(target.canonical, directory), kind: "directory" }), + ) invocation.cwd = target.canonical finalTimeout = invocation.timeout - const external = target.externalDirectory - if (external) + const external = [target, ...directories] + .map((item) => item.externalDirectory) + .filter((item) => item !== undefined) + .filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index) + if (external.length > 0) yield* permission.assert({ - ...LocationMutation.externalDirectoryPermission(external), + action: "external_directory", + resources: external.map((item) => item.resource), + save: external.map((item) => item.save), + sessionID: context.sessionID, + agent: context.agent, + source, + }) + if (parsed.commands.length > 0) + yield* permission.assert({ + action: name, + resources: parsed.commands.map((command) => command.resource), + save: parsed.commands.map((command) => command.save), sessionID: context.sessionID, agent: context.agent, source, }) - yield* permission.assert({ - action: name, - resources: [invocation.command], - save: [invocation.command], - sessionID: context.sessionID, - agent: context.agent, - source, - }) if ((yield* fsUtil.stat(target.canonical)).type !== "Directory") return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`)) }), diff --git a/packages/core/test/shell-parse.test.ts b/packages/core/test/shell-parse.test.ts new file mode 100644 index 0000000000..8b7525a854 --- /dev/null +++ b/packages/core/test/shell-parse.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import os from "os" +import path from "path" +import { ShellParse } from "@opencode-ai/core/shell/parse" + +describe("ShellParse", () => { + test("splits bash commands and derives reusable prefixes", async () => { + const result = await Effect.runPromise( + ShellParse.scan("git status && npm run test -- --watch", "/bin/bash", "/workspace"), + ) + expect(result).toEqual({ + commands: [ + { resource: "git status", save: "git status *" }, + { resource: "npm run test -- --watch", save: "npm run test *" }, + ], + directories: [], + }) + }) + + test("splits PowerShell commands case-insensitively", async () => { + const result = await Effect.runPromise( + ShellParse.scan("Get-ChildItem; Write-Output 'done'", "C:\\Program Files\\PowerShell\\7\\pwsh.exe", "C:\\workspace"), + ) + expect(result.commands).toEqual([ + { resource: "Get-ChildItem", save: "Get-ChildItem *" }, + { resource: "Write-Output 'done'", save: "Write-Output *" }, + ]) + }) + + test("does not permission directory changes separately", async () => { + const result = await Effect.runPromise(ShellParse.scan("cd 'src dir' && git status", "/bin/bash", "/workspace")) + expect(result).toEqual({ + commands: [{ resource: "git status", save: "git status *" }], + directories: ["src dir"], + }) + }) + + test("extracts PowerShell directory parameters", async () => { + const result = await Effect.runPromise( + ShellParse.scan("Set-Location -LiteralPath '..\\outside'; Get-ChildItem", "pwsh", "C:\\workspace"), + ) + expect(result.directories).toEqual(["..\\outside"]) + }) + + test("expands deterministic directory variables", async () => { + const bash = await Effect.runPromise(ShellParse.scan("cd ~/src", "/bin/bash", "/workspace")) + expect(bash.directories).toEqual([path.join(os.homedir(), "src")]) + + const powershell = await Effect.runPromise( + ShellParse.scan('Set-Location "$PWD/src"; Set-Location $PSHOME', "/usr/local/bin/pwsh", "/workspace"), + ) + expect(powershell.directories).toEqual(["/workspace/src", "/usr/local/bin"]) + }) +}) diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 33f81161ce..1e313e723e 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -1,5 +1,6 @@ import fs from "fs/promises" import { realpathSync } from "node:fs" +import os from "os" import path from "path" import { describe, expect } from "bun:test" import { DateTime, Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect" @@ -222,7 +223,10 @@ describe("ShellTool", () => { type: "text", text: expect.stringContaining("Command exited with code 0."), }) - expect(assertions).toMatchObject([{ sessionID, action: "shell", resources: [helloCommand] }]) + expect(assertions).toMatchObject([ + { sessionID, action: "shell", resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand] }, + ]) + expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"]) }), ) }, @@ -253,6 +257,29 @@ describe("ShellTool", () => { ), ) + it.live("permissions compound commands separately", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + return withSession(tmp.path, (registry) => + executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")), + ).pipe( + Effect.andThen( + Effect.sync(() => { + expect(assertions).toHaveLength(1) + expect(assertions[0]).toMatchObject({ + resources: ["printf one", "printf two"], + save: ["printf *", "printf *"], + }) + }), + ), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), + ), + ) + it.live("captures stderr-only and mixed stdout/stderr output", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), @@ -325,6 +352,51 @@ describe("ShellTool", () => { ), ) + it.live("approves an external directory used by a directory-change command", () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([active, outside]) => { + reset() + const command = isWindows + ? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path` + : `cd '${outside.path}' && pwd` + return withSession(active.path, (registry) => executeTool(registry, call({ command }, "call-external-cd"))).pipe( + Effect.andThen( + Effect.sync(() => { + expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"]) + expect(assertions[0]).toMatchObject({ + resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")], + }) + }), + ), + ) + }, + ([active, outside]) => + Effect.promise(() => + Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), + ), + ), + ) + + it.live("approves an expanded external home directory", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + const command = isWindows ? "Set-Location $HOME; (Get-Location).Path" : "cd ~ && pwd" + return withSession(tmp.path, (registry) => executeTool(registry, call({ command }, "call-external-home"))).pipe( + Effect.andThen( + Effect.sync(() => { + expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"]) + expect(assertions[0]?.resources[0]).toStartWith(os.homedir().replaceAll("\\", "/")) + }), + ), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), + ), + ) + it.live("does not execute after external-directory or shell denial", () => Effect.acquireUseRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), @@ -466,7 +538,7 @@ describe("ShellTool", () => { (tmp) => { reset() return withSession(tmp.path, (registry) => - executeTool(registry, call({ command: timeoutOutputCommand, timeout: 50 })), + executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 500 : 50 })), ).pipe( Effect.andThen((settled) => Effect.sync(() => {