diff --git a/.github/workflows/docs-locale-sync.yml b/.github/workflows/docs-locale-sync.yml index fff2ec4292..9689eee6d2 100644 --- a/.github/workflows/docs-locale-sync.yml +++ b/.github/workflows/docs-locale-sync.yml @@ -9,7 +9,8 @@ on: jobs: sync-locales: - if: github.actor != 'opencode-agent[bot]' + if: false + #if: github.actor != 'opencode-agent[bot]' runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: write @@ -34,7 +35,7 @@ jobs: - name: Compute changed English docs id: changes run: | - FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'packages/web/src/content/docs/*.mdx' || true) + FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- ':(glob)packages/web/src/content/docs/*.mdx' || true) if [ -z "$FILES" ]; then echo "has_changes=false" >> "$GITHUB_OUTPUT" echo "No English docs changed in push range" diff --git a/bun.lock b/bun.lock index b7c35c5226..b88c23e29d 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.3.5", + "version": "1.3.6", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -79,7 +79,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.3.5", + "version": "1.3.6", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -113,7 +113,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.3.5", + "version": "1.3.6", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -140,7 +140,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.3.5", + "version": "1.3.6", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -164,7 +164,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.3.5", + "version": "1.3.6", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -188,7 +188,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.3.5", + "version": "1.3.6", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -221,7 +221,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.3.5", + "version": "1.3.6", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -252,7 +252,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.3.5", + "version": "1.3.6", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -281,7 +281,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.3.5", + "version": "1.3.6", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -297,7 +297,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.3.5", + "version": "1.3.6", "bin": { "opencode": "./bin/opencode", }, @@ -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", @@ -422,7 +423,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.3.5", + "version": "1.3.6", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -456,7 +457,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.3.5", + "version": "1.3.6", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -467,7 +468,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.3.5", + "version": "1.3.6", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -502,7 +503,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.3.5", + "version": "1.3.6", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -549,7 +550,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.3.5", + "version": "1.3.6", "dependencies": { "zod": "catalog:", }, @@ -560,7 +561,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.3.5", + "version": "1.3.6", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.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=="], diff --git a/package.json b/package.json index 3b2feedf31..2bb1a95391 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "protobufjs", "tree-sitter", "tree-sitter-bash", + "tree-sitter-powershell", "web-tree-sitter", "electron" ], diff --git a/packages/app/package.json b/packages/app/package.json index 44550383ce..d1ba1d0880 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.3.5", + "version": "1.3.6", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 4fdf5344d0..273d635958 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.3.5", + "version": "1.3.6", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/app/src/routes/go/index.tsx b/packages/console/app/src/routes/go/index.tsx index b7dc9f7ab9..c76887cef0 100644 --- a/packages/console/app/src/routes/go/index.tsx +++ b/packages/console/app/src/routes/go/index.tsx @@ -432,9 +432,7 @@ export default function Home() {
  • - - {i18n.t("go.faq.a5.body")} {i18n.t("common.contactUs")} - + {i18n.t("go.faq.a5.body")}
  • {i18n.t("go.faq.a6")} diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 4acbc79d35..b68af6a11c 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.3.5", + "version": "1.3.6", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 4aff51f4aa..3b15d52fe1 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.3.5", + "version": "1.3.6", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index ffa03293cd..889136b4e4 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.3.5", + "version": "1.3.6", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 48cc00ebee..98c81c9649 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.3.5", + "version": "1.3.6", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 8b00b9a70e..ac7f497ab0 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.3.5", + "version": "1.3.6", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 63c690ee02..980304ca40 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.3.5", + "version": "1.3.6", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 4a34db1427..d3db2b7303 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.3.5" +version = "1.3.6" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.5/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.6/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.5/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.6/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.5/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.6/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.5/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.6/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.5/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.6/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index ea6211adb4..c02b2e1fb0 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.3.5", + "version": "1.3.6", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 70ee34cdc6..d17fe02348 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.5", + "version": "1.3.6", "name": "opencode", "type": "module", "license": "MIT", @@ -142,6 +142,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", diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index 02b2a9741d..31edcf114a 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -140,6 +140,8 @@ npm plugins can declare a version compatibility range in `package.json` using th - Root-worktree fallback (`worktree === "/"` uses `/.opencode`) is covered by regression tests. - `patchPluginConfig` applies all declared manifest targets (`server` and/or `tui`) in one call. - `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors. +- `patchPluginConfig` serializes per-target config writes with `Flock.acquire(...)`. +- `patchPluginConfig` uses targeted `jsonc-parser` edits, so existing JSONC comments are preserved when plugin entries are added or replaced. - Without `--force`, an already-configured npm package name is a no-op. - With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept. - Tuple targets in `oc-plugin` provide default options written into config. @@ -164,7 +166,7 @@ Top-level API groups exposed to `tui(api, options, meta)`: - `api.app.version` - `api.command.register(cb)` / `api.command.trigger(value)` - `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current` -- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `ui.toast`, `ui.dialog` +- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Prompt`, `ui.toast`, `ui.dialog` - `api.keybind.match`, `print`, `create` - `api.tuiConfig` - `api.kv.get`, `set`, `ready` @@ -210,6 +212,7 @@ Command behavior: - `ui.Dialog` is the base dialog wrapper. - `ui.DialogAlert`, `ui.DialogConfirm`, `ui.DialogPrompt`, `ui.DialogSelect` are built-in dialog components. +- `ui.Prompt` renders the same prompt component used by the host app. - `ui.toast(...)` shows a toast. - `ui.dialog` exposes the host dialog stack: - `replace(render, onClose?)` @@ -277,6 +280,7 @@ Current host slot names: - `app` - `home_logo` +- `home_prompt` with props `{ workspace_id? }` - `home_bottom` - `sidebar_title` with props `{ session_id, title, share_url? }` - `sidebar_content` with props `{ session_id }` @@ -289,7 +293,7 @@ Slot notes: - `api.slots.register(plugin)` does not return an unregister function. - Returned ids are `pluginId`, `pluginId:1`, `pluginId:2`, and so on. - Plugin-provided `id` is not allowed. -- The current host renders `home_logo` with `replace`, `sidebar_title` and `sidebar_footer` with `single_winner`, and `app`, `home_bottom`, and `sidebar_content` with the slot library default mode. +- The current host renders `home_logo` and `home_prompt` with `replace`, `sidebar_title` and `sidebar_footer` with `single_winner`, and `app`, `home_bottom`, and `sidebar_content` with the slot library default mode. - Plugins cannot define new slot names in this branch. ### Plugin control and lifecycle diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-variant.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-variant.tsx index fd895e0cf6..872092d23e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-variant.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-variant.tsx @@ -24,7 +24,6 @@ export function DialogVariant() { title={"Select variant"} current={local.model.variant.current()} flat={true} - skipFilter={true} /> ) } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index 1565a30081..dd046c3546 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -387,6 +387,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { }} initialValue={input()} placeholder="Type your own answer" + placeholderColor={theme.textMuted} minHeight={1} maxHeight={6} textColor={theme.text} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx index d29fe05ee9..64bd4fbb0f 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx @@ -103,6 +103,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { ref={(val: TextareaRenderable) => (textarea = val)} initialValue={props.defaultFilename} placeholder="Enter filename" + placeholderColor={theme.textMuted} textColor={theme.text} focusedTextColor={theme.text} cursorColor={theme.text} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx index cb1b8257ab..370fc54bd8 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx @@ -74,6 +74,7 @@ export function DialogPrompt(props: DialogPromptProps) { ref={(val: TextareaRenderable) => (textarea = val)} initialValue={props.value} placeholder={props.placeholder ?? "Enter text"} + placeholderColor={theme.textMuted} textColor={props.busy ? theme.textMuted : theme.text} focusedTextColor={props.busy ? theme.textMuted : theme.text} cursorColor={props.busy ? theme.backgroundElement : theme.text} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 151f73cf7c..34c6ee8787 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -260,6 +260,7 @@ export function DialogSelect(props: DialogSelectProps) { }, 1) }} placeholder={props.placeholder ?? "Search"} + placeholderColor={theme.textMuted} /> diff --git a/packages/opencode/src/plugin/install.ts b/packages/opencode/src/plugin/install.ts index 9640a662bd..8c0a6ee274 100644 --- a/packages/opencode/src/plugin/install.ts +++ b/packages/opencode/src/plugin/install.ts @@ -94,6 +94,13 @@ function pluginSpec(item: unknown) { return item[0] } +function pluginList(data: unknown) { + if (!data || typeof data !== "object" || Array.isArray(data)) return + const item = data as { plugin?: unknown } + if (!Array.isArray(item.plugin)) return + return item.plugin +} + function parseTarget(item: unknown): Target | undefined { if (item === "server" || item === "tui") return { kind: item } if (!Array.isArray(item)) return @@ -118,9 +125,28 @@ function parseTargets(raw: unknown) { return [...map.values()] } -function patchPluginList(list: unknown[], spec: string, next: unknown, force = false): { mode: Mode; list: unknown[] } { +function patch(text: string, path: Array, value: unknown, insert = false) { + return applyEdits( + text, + modify(text, path, value, { + formattingOptions: { + tabSize: 2, + insertSpaces: true, + }, + isArrayInsertion: insert, + }), + ) +} + +function patchPluginList( + text: string, + list: unknown[] | undefined, + spec: string, + next: unknown, + force = false, +): { mode: Mode; text: string } { const pkg = parsePluginSpecifier(spec).pkg - const rows = list.map((item, i) => ({ + const rows = (list ?? []).map((item, i) => ({ item, i, spec: pluginSpec(item), @@ -133,16 +159,22 @@ function patchPluginList(list: unknown[], spec: string, next: unknown, force = f }) if (!dup.length) { + if (!list) { + return { + mode: "add", + text: patch(text, ["plugin"], [next]), + } + } return { mode: "add", - list: [...list, next], + text: patch(text, ["plugin", list.length], next, true), } } if (!force) { return { mode: "noop", - list, + text, } } @@ -150,29 +182,37 @@ function patchPluginList(list: unknown[], spec: string, next: unknown, force = f if (!keep) { return { mode: "noop", - list, + text, } } if (dup.length === 1 && keep.spec === spec) { return { mode: "noop", - list, + text, } } - const idx = new Set(dup.map((item) => item.i)) + let out = text + if (typeof keep.item === "string") { + out = patch(out, ["plugin", keep.i], next) + } + if (Array.isArray(keep.item) && typeof keep.item[0] === "string") { + out = patch(out, ["plugin", keep.i, 0], spec) + } + + const del = dup + .map((item) => item.i) + .filter((i) => i !== keep.i) + .sort((a, b) => b - a) + + for (const i of del) { + out = patch(out, ["plugin", i], undefined) + } + return { mode: "replace", - list: rows.flatMap((row) => { - if (!idx.has(row.i)) return [row.item] - if (row.i !== keep.i) return [] - if (typeof row.item === "string") return [next] - if (Array.isArray(row.item) && typeof row.item[0] === "string") { - return [[spec, ...row.item.slice(1)]] - } - return [row.item] - }), + text: out, } } @@ -289,10 +329,9 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea } } - const list: unknown[] = - data && typeof data === "object" && !Array.isArray(data) && Array.isArray(data.plugin) ? data.plugin : [] + const list = pluginList(data) const item = target.opts ? [spec, target.opts] : spec - const out = patchPluginList(list, spec, item, force) + const out = patchPluginList(text, list, spec, item, force) if (out.mode === "noop") { return { ok: true, @@ -304,13 +343,7 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea } } - const edits = modify(text, ["plugin"], out.list, { - formattingOptions: { - tabSize: 2, - insertSpaces: true, - }, - }) - const write = await dep.write(cfg, applyEdits(text, edits)).catch((error: unknown) => error) + const write = await dep.write(cfg, out.text).catch((error: unknown) => error) if (write instanceof Error) { return { ok: false, diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 8ecd8c7a61..dda0f77d4b 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -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") } diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index eb01739c15..371091722e 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -32,7 +32,6 @@ import { ModelID, ProviderID } from "@/provider/schema" import { Permission } from "@/permission" import { Global } from "@/global" import type { LanguageModelV2Usage } from "@ai-sdk/provider" -import { iife } from "@/util/iife" import { Effect, Layer, Scope, ServiceMap } from "effect" import { makeRuntime } from "@/effect/run-service" @@ -265,27 +264,12 @@ export namespace Session { 0) as number, ) - // OpenRouter provides inputTokens as the total count of input tokens (including cached). - // AFAIK other providers (OpenRouter/OpenAI/Gemini etc.) do it the same way e.g. vercel/ai#8794 (comment) - // Anthropic does it differently though - inputTokens doesn't include cached tokens. - // It looks like OpenCode's cost calculation assumes all providers return inputTokens the same way Anthropic does (I'm guessing getUsage logic was originally implemented with anthropic), so it's causing incorrect cost calculation for OpenRouter and others. - const excludesCachedTokens = !!(input.metadata?.["anthropic"] || input.metadata?.["bedrock"]) - const adjustedInputTokens = safe( - excludesCachedTokens ? inputTokens : inputTokens - cacheReadInputTokens - cacheWriteInputTokens, - ) + // AI SDK v6 normalized inputTokens to include cached tokens across all providers + // (including Anthropic/Bedrock which previously excluded them). Always subtract cache + // tokens to get the non-cached input count for separate cost calculation. + const adjustedInputTokens = safe(inputTokens - cacheReadInputTokens - cacheWriteInputTokens) - const total = iife(() => { - // Anthropic doesn't provide total_tokens, also ai sdk will vastly undercount if we - // don't compute from components - if ( - input.model.api.npm === "@ai-sdk/anthropic" || - input.model.api.npm === "@ai-sdk/amazon-bedrock" || - input.model.api.npm === "@ai-sdk/google-vertex/anthropic" - ) { - return adjustedInputTokens + outputTokens + cacheReadInputTokens + cacheWriteInputTokens - } - return input.usage.totalTokens - }) + const total = input.usage.totalTokens const tokens = { total, diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 86f73d0fd2..526e3f4b1c 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -13,7 +13,7 @@ const log = Log.create({ service: "instruction" }) const FILES = [ "AGENTS.md", - "CLAUDE.md", + ...(Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [] : ["CLAUDE.md"]), "CONTEXT.md", // deprecated ] diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index acc9f63595..a9edf838ca 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1648,9 +1648,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 = { nu: { diff --git a/packages/opencode/src/shell/shell.ts b/packages/opencode/src/shell/shell.ts index a30889d699..df8e8eb7eb 100644 --- a/packages/opencode/src/shell/shell.ts +++ b/packages/opencode/src/shell/shell.ts @@ -9,6 +9,10 @@ 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"]) + const POSIX = new Set(["bash", "dash", "ksh", "sh", "zsh"]) + export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise { const pid = proc.pid if (!pid || opts?.exited?.()) return @@ -39,18 +43,46 @@ 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) !== ".") { + if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || 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() + } + + export function gitbash() { + if (process.platform !== "win32") return + if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH + const git = which("git") + if (!git) return + const file = path.join(git, "..", "..", "bin", "bash.exe") + if (Filesystem.stat(file)?.size) return file + } function fallback() { if (process.platform === "win32") { - if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH - const git = which("git") - if (git) { - // git.exe is typically at: C:\Program Files\Git\cmd\git.exe - // bash.exe is at: C:\Program Files\Git\bin\bash.exe - const bash = path.join(git, "..", "..", "bin", "bash.exe") - if (Filesystem.stat(bash)?.size) return bash - } + const file = gitbash() + if (file) return file return process.env.COMSPEC || "cmd.exe" } if (process.platform === "darwin") return "/bin/zsh" @@ -59,15 +91,20 @@ 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 function posix(file: string) { + return POSIX.has(name(file)) + } + + export const preferred = lazy(() => select(process.env.SHELL)) + + export const acceptable = lazy(() => select(process.env.SHELL, { acceptable: true })) } diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 228c2161b9..50aa9e14ad 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -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,12 +7,12 @@ 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 { Process } from "@/util/process" import { fileURLToPath } from "url" -import { Flag } from "@/flag/flag.ts" +import { Flag } from "@/flag/flag" import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" @@ -20,6 +21,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 + patterns: Set + always: Set +} export const log = Log.create({ service: "bash-tool" }) @@ -30,6 +68,350 @@ 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 source(node: Node) { + return (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim() +} + +function commands(node: Node) { + return node.descendantsOfType("command").filter((child): child is Node => Boolean(child)) +} + +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 auto(key: string, cwd: string, shell: string) { + const name = key.toUpperCase() + if (name === "HOME") return os.homedir() + if (name === "PWD") return cwd + if (name === "PSHOME") return path.dirname(shell) +} + +function expand(text: string, cwd: string, shell: string) { + const out = unquote(text) + .replace(/\$\{env:([^}]+)\}/gi, (_, key: string) => envValue(key) || "") + .replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, key: string) => envValue(key) || "") + .replace(/\$(HOME|PWD|PSHOME)(?=$|[\\/])/gi, (_, key: string) => auto(key, cwd, shell) || "") + return home(out) +} + +function provider(text: string) { + const match = text.match(/^([A-Za-z]+)::(.*)$/) + if (match) { + if (match[1].toLowerCase() !== "filesystem") return + return match[2] + } + const prefix = text.match(/^([A-Za-z]+):(.*)$/) + if (!prefix) return text + if (prefix[1].length === 1) return text + return +} + +function dynamic(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) +} + +async function cygpath(shell: string, text: string) { + const out = await Process.text([shell, "-lc", 'cygpath -w -- "$1"', "_", text], { nothrow: true }) + if (out.code !== 0) return + const file = out.text.trim() + if (!file) return + return Filesystem.normalizePath(file) +} + +async function resolvePath(text: string, root: string, shell: string) { + if (process.platform === "win32") { + if (Shell.posix(shell) && text.startsWith("/") && Filesystem.windowsPath(text) === text) { + const file = await cygpath(shell, text) + if (file) return file + } + return Filesystem.normalizePath(path.resolve(root, Filesystem.windowsPath(text))) + } + return path.resolve(root, text) +} + +async function argPath(arg: string, cwd: string, ps: boolean, shell: string) { + const text = ps ? expand(arg, cwd, shell) : home(unquote(arg)) + const file = text && prefix(text) + if (!file || dynamic(file, ps)) return + const next = ps ? provider(file) : file + if (!next) return + return resolvePath(next, cwd, shell) +} + +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, shell: string): Promise { + const scan: Scan = { + dirs: new Set(), + patterns: new Set(), + always: new Set(), + } + + 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 = await argPath(arg, cwd, ps, shell) + 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(source(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, + windowsHide: true, + }) + } + + return spawn(command, { + shell, + cwd, + env, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + windowsHide: 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 expired = 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(() => { + expired = true + void kill() + }, input.timeout + 100) + + await new Promise((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 (expired) 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\n" + metadata.join("\n") + "\n" + } + + 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,23 +426,36 @@ 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("${maxLines}", String(Truncate.MAX_LINES)).replaceAll( - "${maxBytes}", - String(Truncate.MAX_BYTES), - ), + 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({ command: z.string().describe("The command to execute"), timeout: z.number().describe("Optional timeout in milliseconds").optional(), @@ -77,195 +472,29 @@ export const BashTool = Tool.define("bash", async () => { ), }), async execute(params, ctx) { - const cwd = params.workdir || Instance.directory + const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : 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() - if (!Instance.containsPath(cwd)) directories.add(cwd) - const patterns = new Set() - const always = new Set() + const ps = PS.has(name) + const root = await parse(params.command, ps) + const scan = await collect(root, cwd, ps, shell) + 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((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\n" + resultMetadata.join("\n") + "\n" - } - - 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, - } }, } }) diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt index 3ce39606a0..8d53c90ab4 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/bash.txt @@ -1,6 +1,8 @@ Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures. -All commands run in current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead. +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 && ` 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 && `. Use the `workdir` parameter to change directories instead. diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index 5d8885b2ad..66eba438bc 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -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, }, }) } diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 85be8f9d39..e5509fdfae 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -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) diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 37f00c6b9c..b4ae46df13 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -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,23 @@ 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) + const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1] + return join(normalizePath(dir), "*") + } + // 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. diff --git a/packages/opencode/test/plugin/install.test.ts b/packages/opencode/test/plugin/install.test.ts index e7d39bf87d..24440c10ea 100644 --- a/packages/opencode/test/plugin/install.test.ts +++ b/packages/opencode/test/plugin/install.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" +import { parse as parseJsonc } from "jsonc-parser" import { Filesystem } from "../../src/util/filesystem" import { createPlugTask, type PlugCtx, type PlugDeps } from "../../src/cli/cmd/plug" import { tmpdir } from "../fixture/fixture" @@ -120,6 +121,99 @@ describe("plugin.install.task", () => { expect(tui.plugin).toEqual([["acme@1.2.3", { compact: true }]]) }) + test("preserves JSONC comments when adding plugins to server and tui config", async () => { + await using tmp = await tmpdir() + const target = await plugin(tmp.path, ["server", "tui"]) + const cfg = path.join(tmp.path, ".opencode") + const server = path.join(cfg, "opencode.jsonc") + const tui = path.join(cfg, "tui.jsonc") + await fs.mkdir(cfg, { recursive: true }) + await Bun.write( + server, + `{ + // server head + "plugin": [ + // server keep + "seed@1.0.0" + ], + // server tail + "model": "x" +} +`, + ) + await Bun.write( + tui, + `{ + // tui head + "plugin": [ + // tui keep + "seed@1.0.0" + ], + // tui tail + "theme": "opencode" +} +`, + ) + + const run = createPlugTask( + { + mod: "acme@1.2.3", + }, + deps(path.join(tmp.path, "global"), target), + ) + + const ok = await run(ctx(tmp.path)) + expect(ok).toBe(true) + + const serverText = await fs.readFile(server, "utf8") + const tuiText = await fs.readFile(tui, "utf8") + expect(serverText).toContain("// server head") + expect(serverText).toContain("// server keep") + expect(serverText).toContain("// server tail") + expect(tuiText).toContain("// tui head") + expect(tuiText).toContain("// tui keep") + expect(tuiText).toContain("// tui tail") + + const serverJson = parseJsonc(serverText) as { plugin?: unknown[] } + const tuiJson = parseJsonc(tuiText) as { plugin?: unknown[] } + expect(serverJson.plugin).toEqual(["seed@1.0.0", "acme@1.2.3"]) + expect(tuiJson.plugin).toEqual(["seed@1.0.0", "acme@1.2.3"]) + }) + + test("preserves JSONC comments when force replacing plugin version", async () => { + await using tmp = await tmpdir() + const target = await plugin(tmp.path, ["server"]) + const cfg = path.join(tmp.path, ".opencode", "opencode.jsonc") + await fs.mkdir(path.dirname(cfg), { recursive: true }) + await Bun.write( + cfg, + `{ + "plugin": [ + // keep this note + "acme@1.0.0" + ] +} +`, + ) + + const run = createPlugTask( + { + mod: "acme@2.0.0", + force: true, + }, + deps(path.join(tmp.path, "global"), target), + ) + + const ok = await run(ctx(tmp.path)) + expect(ok).toBe(true) + + const text = await fs.readFile(cfg, "utf8") + expect(text).toContain("// keep this note") + + const json = parseJsonc(text) as { plugin?: unknown[] } + expect(json.plugin).toEqual(["acme@2.0.0"]) + }) + test("supports resolver target pointing to a file", async () => { await using tmp = await tmpdir() const target = await plugin(tmp.path, ["server"]) diff --git a/packages/opencode/test/pty/pty-shell.test.ts b/packages/opencode/test/pty/pty-shell.test.ts new file mode 100644 index 0000000000..65e7e1f901 --- /dev/null +++ b/packages/opencode/test/pty/pty-shell.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { Pty } from "../../src/pty" +import { Shell } from "../../src/shell/shell" +import { tmpdir } from "../fixture/fixture" + +Shell.preferred.reset() + +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 = (() => { + const shell = Shell.preferred() + if (Shell.name(shell) === "bash") return shell + return Shell.gitbash() + })() + 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 }, + ) + } +}) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 9c8559c35a..8f29b77880 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -964,8 +964,9 @@ describe("session.getUsage", () => { expect(result.tokens.cache.write).toBe(300) }) - test("does not subtract cached tokens for anthropic provider", () => { + test("subtracts cached tokens for anthropic provider", () => { const model = createModel({ context: 100_000, output: 32_000 }) + // AI SDK v6 normalizes inputTokens to include cached tokens for all providers const result = Session.getUsage({ model, usage: { @@ -979,7 +980,7 @@ describe("session.getUsage", () => { }, }) - expect(result.tokens.input).toBe(1000) + expect(result.tokens.input).toBe(800) expect(result.tokens.cache.read).toBe(200) }) @@ -1043,11 +1044,10 @@ describe("session.getUsage", () => { "computes total from components for %s models", (npm) => { const model = createModel({ context: 100_000, output: 32_000, npm }) + // AI SDK v6: inputTokens includes cached tokens for all providers const usage = { inputTokens: 1000, outputTokens: 500, - // These providers typically report total as input + output only, - // excluding cache read/write. totalTokens: 1500, cachedInputTokens: 200, } @@ -1064,10 +1064,12 @@ describe("session.getUsage", () => { }, }) - expect(result.tokens.input).toBe(1000) + // inputTokens (1000) includes cache, so adjusted = 1000 - 200 - 300 = 500 + expect(result.tokens.input).toBe(500) expect(result.tokens.cache.read).toBe(200) expect(result.tokens.cache.write).toBe(300) - expect(result.tokens.total).toBe(2000) + // total = adjusted (500) + output (500) + cacheRead (200) + cacheWrite (300) + expect(result.tokens.total).toBe(1500) return } @@ -1081,10 +1083,12 @@ describe("session.getUsage", () => { }, }) - expect(result.tokens.input).toBe(1000) + // inputTokens (1000) includes cache, so adjusted = 1000 - 200 - 300 = 500 + expect(result.tokens.input).toBe(500) expect(result.tokens.cache.read).toBe(200) expect(result.tokens.cache.write).toBe(300) - expect(result.tokens.total).toBe(2000) + // total = adjusted (500) + output (500) + cacheRead (200) + cacheWrite (300) + expect(result.tokens.total).toBe(1500) }, ) }) diff --git a/packages/opencode/test/shell/shell.test.ts b/packages/opencode/test/shell/shell.test.ts new file mode 100644 index 0000000000..760d6dc05a --- /dev/null +++ b/packages/opencode/test/shell/shell.test.ts @@ -0,0 +1,73 @@ +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) => { + 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) + }) + + test("detects posix shells", () => { + expect(Shell.posix("/bin/bash")).toBe(true) + expect(Shell.posix("/bin/fish")).toBe(false) + expect(Shell.posix("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 /usr/bin/bash from env to Git Bash", async () => { + const bash = Shell.gitbash() + if (!bash) return + await withShell("/usr/bin/bash", async () => { + expect(Shell.acceptable()).toBe(bash) + expect(Shell.preferred()).toBe(bash) + }) + }) + + 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) + }) + }) + } +}) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 4d680d494f..0ea8ea073a 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -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" @@ -20,17 +21,107 @@ const ctx = { ask: async () => {}, } +Shell.acceptable.reset() +const quote = (text: string) => `"${text}"` +const squote = (text: string) => `'${text}'` const projectRoot = path.join(__dirname, "../..") +const bin = quote(process.execPath.replaceAll("\\", "/")) +const bash = (() => { + const shell = Shell.acceptable() + if (Shell.name(shell) === "bash") return shell + return Shell.gitbash() +})() +const shells = (() => { + if (process.platform !== "win32") { + const shell = Shell.acceptable() + return [{ label: Shell.name(shell), shell }] + } + + const list = [bash, Bun.which("pwsh"), Bun.which("powershell"), process.env.COMSPEC || Bun.which("cmd.exe")] + .filter((shell): shell is string => Boolean(shell)) + .map((shell) => ({ label: Shell.name(shell), shell })) + + return list.filter( + (item, i) => list.findIndex((other) => other.shell.toLowerCase() === item.shell.toLowerCase()) === i, + ) +})() +const PS = new Set(["pwsh", "powershell"]) +const ps = shells.filter((item) => PS.has(item.label)) + +const sh = () => Shell.name(Shell.acceptable()) +const evalarg = (text: string) => (sh() === "cmd" ? quote(text) : squote(text)) + +const fill = (mode: "lines" | "bytes", n: number) => { + const code = + mode === "lines" + ? "console.log(Array.from({length:Number(Bun.argv[1])},(_,i)=>i+1).join(String.fromCharCode(10)))" + : "process.stdout.write(String.fromCharCode(97).repeat(Number(Bun.argv[1])))" + const text = `${bin} -e ${evalarg(code)} ${n}` + if (PS.has(sh())) return `& ${text}` + return text +} +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) => 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) => { + for (const item of shells) { + test( + `${name} [${item.label}]`, + withShell(item, () => fn(item)), + ) + } +} + +const capture = (requests: Array>, stop?: Error) => ({ + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + if (stop) throw stop + }, +}) + +const mustTruncate = (result: { + metadata: { truncated?: boolean; exit?: number | null } & Record + 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 +134,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> = [] - const testCtx = { - ...ctx, - ask: async (req: Omit) => { - 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 +155,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> = [] - const testCtx = { - ...ctx, - ask: async (req: Omit) => { - 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 +177,616 @@ 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> = [] + 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> = [] + 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") { + if (bash) { + test( + "asks for nested bash command permissions [bash]", + withShell({ label: "bash", shell: bash }, async () => { + await using outerTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "outside.txt"), "x") + }, + }) + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/") + const requests: Array> = [] + await bash.execute( + { + command: `echo $(cat "${file}")`, + description: "Read nested bash file", + }, + 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(glob(path.join(outerTmp.path, "*"))) + expect(bashReq).toBeDefined() + expect(bashReq!.patterns).toContain(`cat "${file}"`) + }, + }) + }), + ) + } + } + + 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> = [] + 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 nested PowerShell command permissions [${item.label}]`, + withShell(item, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const requests: Array> = [] + const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini` + await bash.execute( + { + command: `Write-Output $(Get-Content ${file})`, + description: "Read nested PowerShell file", + }, + 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(glob(path.join(process.env.WINDIR!, "*"))) + expect(bashReq).toBeDefined() + expect(bashReq!.patterns).toContain(`Get-Content ${file}`) + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `asks for external_directory permission for drive-relative PowerShell paths [${item.label}]`, + withShell(item, 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> = [] + await expect( + bash.execute( + { + command: 'Get-Content "C:../outside.txt"', + description: "Read drive-relative file", + }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain(glob(path.join(path.dirname(tmp.path), "*"))) + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `asks for external_directory permission for $HOME PowerShell paths [${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> = [] + await expect( + bash.execute( + { + command: 'Get-Content "$HOME/.ssh/config"', + description: "Read home config", + }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain(glob(path.join(os.homedir(), ".ssh", "*"))) + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `asks for external_directory permission for $PWD PowerShell paths [${item.label}]`, + withShell(item, 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> = [] + await expect( + bash.execute( + { + command: 'Get-Content "$PWD/../outside.txt"', + description: "Read pwd-relative file", + }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain(glob(path.join(path.dirname(tmp.path), "*"))) + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `asks for external_directory permission for $PSHOME PowerShell paths [${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> = [] + await expect( + bash.execute( + { + command: 'Get-Content "$PSHOME/outside.txt"', + description: "Read pshome file", + }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain(glob(path.join(path.dirname(item.shell), "*"))) + }, + }) + }), + ) + } + + 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> = [] + 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> = [] + 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( + `asks for external_directory permission for PowerShell FileSystem paths [${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> = [] + await expect( + bash.execute( + { + command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`, + description: "Read Windows ini from FileSystem provider", + }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain( + Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), + ) + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `asks for external_directory permission for braced PowerShell env paths [${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> = [] + await expect( + bash.execute( + { + command: "Get-Content ${env:WINDIR}/win.ini", + description: "Read Windows ini from braced env", + }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].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> = [] + 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> = [] + 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> = [] - const testCtx = { - ...ctx, - ask: async (req: Omit) => { - 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> = [] - const testCtx = { - ...ctx, - ask: async (req: Omit) => { - 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> = [] + 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], + }) + } + }, + }) + }) + + if (bash) { + test( + "uses Git Bash /tmp semantics for external workdir", + withShell({ label: "bash", shell: bash }, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const err = new Error("stop after permission") + const requests: Array> = [] + const want = glob(path.join(os.tmpdir(), "*")) + await expect( + bash.execute( + { + command: "echo ok", + workdir: "/tmp", + description: "Echo from Git Bash tmp", + }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) + expect(requests[0]).toMatchObject({ + permission: "external_directory", + patterns: [want], + always: [want], + }) + }, + }) + }), + ) + + test( + "uses Git Bash /tmp semantics for external file paths", + withShell({ label: "bash", shell: bash }, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const err = new Error("stop after permission") + const requests: Array> = [] + const want = glob(path.join(os.tmpdir(), "*")) + await expect( + bash.execute( + { + command: "cat /tmp/opencode-does-not-exist", + description: "Read Git Bash tmp file", + }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) + expect(requests[0]).toMatchObject({ + permission: "external_directory", + 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> = [] - const testCtx = { - ...ctx, - ask: async (req: Omit) => { - 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 +794,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> = [] - const testCtx = { - ...ctx, - ask: async (req: Omit) => { - 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> = [] - const testCtx = { - ...ctx, - ask: async (req: Omit) => { - 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> = [] - const testCtx = { - ...ctx, - ask: async (req: Omit) => { - 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 +859,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> = [] - const testCtx = { - ...ctx, - ask: async (req: Omit) => { - 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> = [] - const testCtx = { - ...ctx, - ask: async (req: Omit) => { - 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 +905,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 +925,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 +949,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 +963,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)) diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 0188cbada0..cf95eaf4b1 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -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 = { 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> = [] @@ -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,69 @@ describe("tool.assertExternalDirectory", () => { expect(requests.length).toBe(0) }) + + if (process.platform === "win32") { + test("normalizes Windows path variants to one glob", async () => { + const requests: Array> = [] + 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]) + }) + + test("uses drive root glob for root files", async () => { + const requests: Array> = [] + const ctx: Tool.Context = { + ...baseCtx, + ask: async (req) => { + requests.push(req) + }, + } + + await using tmp = await tmpdir({ git: true }) + const root = path.parse(tmp.path).root + const target = path.join(root, "boot.ini") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await assertExternalDirectory(ctx, target) + }, + }) + + const req = requests.find((r) => r.permission === "external_directory") + const expected = path.join(root, "*") + expect(req).toBeDefined() + expect(req!.patterns).toEqual([expected]) + expect(req!.always).toEqual([expected]) + }) + } }) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 06a7f9a706..d58565f433 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -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> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + 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", "*"))) }, }) }) diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index aea0b1db87..e6ace9c722 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -555,4 +555,13 @@ describe("filesystem", () => { expect(() => Filesystem.resolve(path.join(file, "child"))).toThrow() }) }) + + describe("normalizePathPattern()", () => { + test("preserves drive root globs on Windows", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir() + const root = path.parse(tmp.path).root + expect(Filesystem.normalizePathPattern(path.join(root, "*"))).toBe(path.join(root, "*")) + }) + }) }) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 51d5f983e6..8a6b776c14 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.3.5", + "version": "1.3.6", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index b8bb9782dd..60c4aabe04 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.3.5", + "version": "1.3.6", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 6bd3e2b0d8..a0b8904eb5 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.3.5", + "version": "1.3.6", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 507bb1dc8e..bdc0192e64 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.3.5", + "version": "1.3.6", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index 754341b90d..bc6e1b18ac 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.3.5", + "version": "1.3.6", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index cec35f4256..b054317fb4 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.3.5", + "version": "1.3.6", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/packages/web/src/content/docs/ar/go.mdx b/packages/web/src/content/docs/ar/go.mdx index ffeb2e134a..70786d1344 100644 --- a/packages/web/src/content/docs/ar/go.mdx +++ b/packages/web/src/content/docs/ar/go.mdx @@ -133,9 +133,7 @@ OpenCode Go حاليًا في المرحلة التجريبية (beta). ## الخصوصية -الخطة مصممة بشكل أساسي للمستخدمين الدوليين، مع نماذج مستضافة في الولايات المتحدة، والاتحاد الأوروبي، وسنغافورة من أجل وصول عالمي مستقر. - -تواصل معنا إذا كان لديك أي أسئلة. +صُمِّمت الخطة أساسًا للمستخدمين الدوليين، مع استضافة النماذج في US وEU وSingapore لضمان وصول عالمي مستقر. ويتّبع مزوّدونا سياسة عدم الاحتفاظ بالبيانات، ولا يستخدمون بياناتك في تدريب النماذج. --- diff --git a/packages/web/src/content/docs/bs/go.mdx b/packages/web/src/content/docs/bs/go.mdx index 6a59014359..a91960868d 100644 --- a/packages/web/src/content/docs/bs/go.mdx +++ b/packages/web/src/content/docs/bs/go.mdx @@ -133,9 +133,7 @@ koristi format `opencode-go/`. Na primjer, za Kimi K2.5, koristili bis ## Privatnost -Plan je dizajniran prvenstveno za međunarodne korisnike, sa modelima hostovanim u SAD-u, EU i Singapuru za stabilan globalni pristup. - -Kontaktirajte nas ako imate bilo kakvih pitanja. +Plan je prvenstveno namijenjen međunarodnim korisnicima, a modeli su smješteni u US, EU i Singaporeu radi stabilnog globalnog pristupa. Naši pružaoci usluga primjenjuju politiku nultog zadržavanja podataka i ne koriste vaše podatke za treniranje modela. --- diff --git a/packages/web/src/content/docs/da/go.mdx b/packages/web/src/content/docs/da/go.mdx index db5ad09b30..a14228f39f 100644 --- a/packages/web/src/content/docs/da/go.mdx +++ b/packages/web/src/content/docs/da/go.mdx @@ -133,9 +133,7 @@ bruge `opencode-go/kimi-k2.5` i din config. ## Privatliv -Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for at sikre stabil global adgang. - -Kontakt os, hvis du har spørgsmål. +Planen er primært designet til internationale brugere med modeller hostet i US, EU og Singapore for stabil global adgang. Vores udbydere følger en zero-retention-policy og bruger ikke dine data til modeltræning. --- diff --git a/packages/web/src/content/docs/de/go.mdx b/packages/web/src/content/docs/de/go.mdx index 010de61181..c58d15a2b0 100644 --- a/packages/web/src/content/docs/de/go.mdx +++ b/packages/web/src/content/docs/de/go.mdx @@ -121,9 +121,7 @@ Die [Modell-ID](/docs/config/#models) in deiner OpenCode Config verwendet das Fo ## Datenschutz -Der Plan wurde primär für internationale Nutzer entwickelt, wobei die Modelle für einen stabilen weltweiten Zugriff in den USA, der EU und Singapur gehostet werden. - -Kontaktiere uns, falls du Fragen hast. +Der Plan ist in erster Linie für internationale Nutzer konzipiert, mit in US, EU und Singapore gehosteten Modellen für einen stabilen weltweiten Zugriff. Unsere Anbieter befolgen eine Zero-Retention-Richtlinie und verwenden Ihre Daten nicht für das Modelltraining. --- diff --git a/packages/web/src/content/docs/es/go.mdx b/packages/web/src/content/docs/es/go.mdx index a5de38d05e..3ebadcab37 100644 --- a/packages/web/src/content/docs/es/go.mdx +++ b/packages/web/src/content/docs/es/go.mdx @@ -133,9 +133,7 @@ usa el formato `opencode-go/`. Por ejemplo, para Kimi K2.5, usarías ## Privacidad -El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., la UE y Singapur para un acceso global estable. - -Contáctanos si tienes alguna pregunta. +El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en US, EU y Singapore para ofrecer un acceso global estable. Nuestros proveedores siguen una política de retención cero y no utilizan tus datos para el entrenamiento de modelos. --- diff --git a/packages/web/src/content/docs/fr/go.mdx b/packages/web/src/content/docs/fr/go.mdx index 0e77d95777..83ce6f9954 100644 --- a/packages/web/src/content/docs/fr/go.mdx +++ b/packages/web/src/content/docs/fr/go.mdx @@ -119,9 +119,7 @@ L'[ID de modèle](/docs/config/#models) dans votre configuration OpenCode utilis ## Confidentialité -Le forfait est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable. - -Contactez-nous si vous avez des questions. +Cette offre est conçue avant tout pour les utilisateurs internationaux, avec des modèles hébergés aux US, dans l’EU et à Singapore afin d’assurer un accès mondial stable. Nos fournisseurs appliquent une politique de rétention zéro et n’utilisent pas vos données pour l’entraînement des modèles. --- diff --git a/packages/web/src/content/docs/go.mdx b/packages/web/src/content/docs/go.mdx index d852ef2e4f..d864a31cc9 100644 --- a/packages/web/src/content/docs/go.mdx +++ b/packages/web/src/content/docs/go.mdx @@ -135,8 +135,6 @@ use `opencode-go/kimi-k2.5` in your config. The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access. Our providers follow a zero-retention policy and do not use your data for model training. -Contact us if you have any questions. - --- ## Goals diff --git a/packages/web/src/content/docs/it/go.mdx b/packages/web/src/content/docs/it/go.mdx index a07a643ff3..ec783e43f0 100644 --- a/packages/web/src/content/docs/it/go.mdx +++ b/packages/web/src/content/docs/it/go.mdx @@ -131,9 +131,7 @@ utilizza il formato `opencode-go/`. Ad esempio, per Kimi K2.5, userest ## Privacy -Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati negli Stati Uniti, nell'Unione Europea e a Singapore per un accesso globale stabile. - -Contattaci in caso di domande. +Il piano è pensato principalmente per gli utenti internazionali, con modelli ospitati negli US, nell’EU e a Singapore per un accesso globale stabile. I nostri provider seguono una politica di zero-retention e non utilizzano i tuoi dati per l’addestramento dei modelli. --- diff --git a/packages/web/src/content/docs/ja/go.mdx b/packages/web/src/content/docs/ja/go.mdx index 2d87e24669..e07adbd5b4 100644 --- a/packages/web/src/content/docs/ja/go.mdx +++ b/packages/web/src/content/docs/ja/go.mdx @@ -119,9 +119,7 @@ OpenCode設定の[model id](/docs/config/#models)は、`opencode-go/` ## プライバシー -このプランは主に海外ユーザー向けに設計されており、世界中で安定してアクセスできるよう、モデルは米国、EU、シンガポールでホストされています。 - -ご質問がある場合は、お問い合わせください。 +このプランは主に海外ユーザー向けに設計されており、安定したグローバルアクセスのため、モデルは US、EU、Singapore でホストされています。各プロバイダーはデータを保持しないポリシーに従っており、お客様のデータをモデルのトレーニングに使用することはありません。 --- diff --git a/packages/web/src/content/docs/ko/go.mdx b/packages/web/src/content/docs/ko/go.mdx index 37f7921a92..5938ef5041 100644 --- a/packages/web/src/content/docs/ko/go.mdx +++ b/packages/web/src/content/docs/ko/go.mdx @@ -119,9 +119,7 @@ OpenCode 구성의 [모델 ID](/docs/config/#models)는 `opencode-go/` ## 개인정보 보호 -이 요금제는 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 전 세계 액세스를 위해 모델은 미국, EU 및 싱가포르에 호스팅됩니다. - -궁금한 점이 있으면 문의해 주세요. +이 플랜은 전 세계에서 안정적으로 사용할 수 있도록 US, EU, Singapore에 모델을 호스팅하며, 주로 해외 사용자를 위해 설계되었습니다. 당사의 제공업체는 zero-retention 정책을 따르며, 고객 데이터를 모델 학습에 사용하지 않습니다. --- diff --git a/packages/web/src/content/docs/nb/go.mdx b/packages/web/src/content/docs/nb/go.mdx index f5eeef3e70..f72c9a5e85 100644 --- a/packages/web/src/content/docs/nb/go.mdx +++ b/packages/web/src/content/docs/nb/go.mdx @@ -133,9 +133,7 @@ bruke `opencode-go/kimi-k2.5` i konfigurasjonen din. ## Personvern -Abonnementet er primært utformet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang. - -Kontakt oss hvis du har noen spørsmål. +Planen er primært utformet for internasjonale brukere, med modeller hostet i US, EU og Singapore for stabil global tilgang. Våre leverandører følger en zero-retention-policy og bruker ikke dataene dine til modelltrening. --- diff --git a/packages/web/src/content/docs/permissions.mdx b/packages/web/src/content/docs/permissions.mdx index e4b82942d2..a470fddd76 100644 --- a/packages/web/src/content/docs/permissions.mdx +++ b/packages/web/src/content/docs/permissions.mdx @@ -138,6 +138,7 @@ OpenCode permissions are keyed by tool name, plus a couple of safety guards: - `task` — launching subagents (matches the subagent type) - `skill` — loading a skill (matches the skill name) - `lsp` — running LSP queries (currently non-granular) +- `question` — asking the user questions during execution - `webfetch` — fetching a URL (matches the URL) - `websearch`, `codesearch` — web/code search (matches the query) - `external_directory` — triggered when a tool touches paths outside the project working directory diff --git a/packages/web/src/content/docs/pl/go.mdx b/packages/web/src/content/docs/pl/go.mdx index 0f0840fdc2..a8a84646b7 100644 --- a/packages/web/src/content/docs/pl/go.mdx +++ b/packages/web/src/content/docs/pl/go.mdx @@ -125,9 +125,7 @@ używa formatu `opencode-go/`. Na przykład dla Kimi K2.5 należy uży ## Prywatność -Plan jest przeznaczony przede wszystkim dla użytkowników międzynarodowych, a modele są hostowane w USA, UE i Singapurze, co zapewnia stabilny globalny dostęp. - -Skontaktuj się z nami, jeśli masz jakiekolwiek pytania. +Plan został zaprojektowany przede wszystkim z myślą o użytkownikach międzynarodowych, a modele są hostowane w US, EU i Singapore, aby zapewnić stabilny dostęp na całym świecie. Nasi dostawcy stosują politykę zerowej retencji i nie wykorzystują Twoich danych do trenowania modeli. --- diff --git a/packages/web/src/content/docs/pt-br/go.mdx b/packages/web/src/content/docs/pt-br/go.mdx index 75bb83c262..f008e5d730 100644 --- a/packages/web/src/content/docs/pt-br/go.mdx +++ b/packages/web/src/content/docs/pt-br/go.mdx @@ -133,9 +133,7 @@ usa o formato `opencode-go/`. Por exemplo, para o Kimi K2.5, você usa ## Privacidade -O plano foi projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, na UE e em Singapura para um acesso global estável. - -Entre em contato conosco se você tiver alguma dúvida. +O plano foi projetado principalmente para usuários internacionais, com modelos hospedados em US, EU e Singapore para garantir acesso global estável. Nossos provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelos. --- diff --git a/packages/web/src/content/docs/ru/go.mdx b/packages/web/src/content/docs/ru/go.mdx index 5682ff9ed9..99de50b7ab 100644 --- a/packages/web/src/content/docs/ru/go.mdx +++ b/packages/web/src/content/docs/ru/go.mdx @@ -133,9 +133,7 @@ OpenCode Go включает следующие лимиты: ## Конфиденциальность -План предназначен в первую очередь для пользователей со всего мира, а модели размещены в США, ЕС и Сингапуре для стабильного глобального доступа. - -Свяжитесь с нами, если у вас есть какие-либо вопросы. +Этот план разработан в первую очередь для международных пользователей: модели размещены в US, EU и Singapore, чтобы обеспечить стабильный доступ по всему миру. Наши провайдеры придерживаются политики zero-retention и не используют ваши данные для обучения моделей. --- diff --git a/packages/web/src/content/docs/th/go.mdx b/packages/web/src/content/docs/th/go.mdx index d79dc1060f..cac401b6c3 100644 --- a/packages/web/src/content/docs/th/go.mdx +++ b/packages/web/src/content/docs/th/go.mdx @@ -119,9 +119,7 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: ## Privacy -แพ็กเกจนี้ออกแบบมาเพื่อผู้ใช้ในระดับสากลเป็นหลัก โดยมีโมเดลโฮสต์อยู่ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงทั่วโลกที่เสถียร - -ติดต่อเรา หากคุณมีคำถามใดๆ +แพลนนี้ออกแบบมาสำหรับผู้ใช้ทั่วโลกเป็นหลัก โดยโฮสต์โมเดลไว้ใน US, EU และ Singapore เพื่อให้เข้าถึงได้อย่างเสถียรจากทั่วโลก ผู้ให้บริการของเราปฏิบัติตามนโยบาย zero-retention และไม่นำข้อมูลของคุณไปใช้ในการฝึกโมเดล --- diff --git a/packages/web/src/content/docs/tr/go.mdx b/packages/web/src/content/docs/tr/go.mdx index 7d40b0699a..3c87d38625 100644 --- a/packages/web/src/content/docs/tr/go.mdx +++ b/packages/web/src/content/docs/tr/go.mdx @@ -119,9 +119,7 @@ OpenCode yapılandırmanızdaki [model id](/docs/config/#models) formatı `openc ## Gizlilik -Plan, dünya çapında istikrarlı erişim için ABD, AB ve Singapur'da barındırılan modellerle temel olarak uluslararası kullanıcılar için tasarlanmıştır. - -Herhangi bir sorunuz varsa bizimle iletişime geçin. +Plan, öncelikle uluslararası kullanıcılar için tasarlanmıştır; dünya genelinde istikrarlı erişim sağlamak için modeller US, EU ve Singapore'da barındırılır. Sağlayıcılarımız sıfır veri saklama politikası uygular ve verilerinizi model eğitimi için kullanmaz. --- diff --git a/packages/web/src/content/docs/zh-cn/go.mdx b/packages/web/src/content/docs/zh-cn/go.mdx index 1a6364d12c..722437c3df 100644 --- a/packages/web/src/content/docs/zh-cn/go.mdx +++ b/packages/web/src/content/docs/zh-cn/go.mdx @@ -119,9 +119,7 @@ OpenCode Go 包含以下限制: ## 隐私保护 -该计划主要为国际用户设计,模型托管在美国、欧盟和新加坡,以确保稳定的全球访问。 - -如有任何问题,请 联系我们。 +该方案主要面向国际用户,模型托管在 US、EU 和 Singapore,以提供稳定的全球访问。我们的提供商遵循零保留政策,不会将您的数据用于模型训练。 --- diff --git a/packages/web/src/content/docs/zh-tw/go.mdx b/packages/web/src/content/docs/zh-tw/go.mdx index 2657ecca90..dffe4e2f74 100644 --- a/packages/web/src/content/docs/zh-tw/go.mdx +++ b/packages/web/src/content/docs/zh-tw/go.mdx @@ -119,9 +119,7 @@ OpenCode Go 包含以下限制: ## 隱私權 -此方案主要為國際使用者設計,模型託管於美國、歐盟和新加坡,以提供全球穩定的存取。 - -如果您有任何問題,請與我們聯絡。 +此方案主要為國際使用者設計,模型部署於 US、EU 與 Singapore,以提供穩定的全球存取體驗。我們的供應商遵循零保留政策,不會將你的資料用於模型訓練。 --- diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 9de0de911e..16b83eb013 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.3.5", + "version": "1.3.6", "publisher": "sst-dev", "repository": { "type": "git", diff --git a/turbo.json b/turbo.json index 57e4f11953..cff3381ecb 100644 --- a/turbo.json +++ b/turbo.json @@ -10,7 +10,8 @@ }, "opencode#test": { "dependsOn": ["^build"], - "outputs": [] + "outputs": [], + "passThroughEnv": ["*"] }, "@opencode-ai/app#test": { "dependsOn": ["^build"],