diff --git a/.github/workflows/windows-advisory.yml b/.github/workflows/windows-advisory.yml index 6d97c9bcd..69b360257 100644 --- a/.github/workflows/windows-advisory.yml +++ b/.github/workflows/windows-advisory.yml @@ -162,6 +162,7 @@ jobs: test/worktree test/file/ test/github + test/opencli test/settings test/settings.test.ts report_path: packages/opencode/.artifacts/unit/junit-windows-config-project.xml diff --git a/README.md b/README.md index 58e49b0d0..d086a22d8 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ PawWork is built for people who want AI agents to do real desktop work, not only | Free without subscription | Yes (OpenCode Zen) | Limited (ChatGPT Free) | No (Pro $20/mo required) | | Desktop app | macOS + Windows | macOS + Windows | macOS + Windows | | Local file access | Full workspace access | Sandboxed by default | User-selected folders | +| Browser automation | Yes (supported OpenCLI adapters; coverage varies) | No | No | | Local models | Yes (Ollama, LM Studio, etc.) | CLI only (Ollama) | Via gateway (Requesty) | | Office files (Word/Excel/PPT) | Yes (via OfficeCLI) | No | No | | Non-technical user focus | Yes (task cards, no terminal) | Developer-focused | Knowledge work + coding | @@ -61,6 +62,13 @@ PawWork is built for people who want AI agents to do real desktop work, not only - debug an API error with logs and source files - build a small internal tool from a plain-language request +### Browser and Web Services + +- search Twitter/X and draft a reply without leaving PawWork +- check trending topics on Bilibili or Xiaohongshu +- collect posts, comments, or media from supported web services +- interact with supported web services through PawWork's browser tools + ## How It Works 1. Choose a workspace folder. @@ -102,6 +110,8 @@ Thanks to the OpenCode project and community. PawWork bundles [OfficeCLI](https://github.com/iOfficeAI/OfficeCLI) by iOfficeAI to handle Word, Excel, and PowerPoint files locally. Thanks to iOfficeAI for the Apache-2.0 open-source OfficeCLI project. +PawWork bundles [OpenCLI](https://github.com/jackwener/opencli) by [@jackwener](https://github.com/jackwener) to power browser-based adapters for supported sites. OpenCLI provides the CDP bridge and bundled command adapters that help PawWork interact with web services like Twitter/X, Bilibili, Xiaohongshu, and more where the adapter coverage fits the task. Thanks to @jackwener for the Apache-2.0 open-source OpenCLI project. + ## FAQ **Is PawWork free?** diff --git a/README_CN.md b/README_CN.md index 0376686a0..d29e1e47b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -36,6 +36,7 @@ Codex App 和 Claude Cowork 的开源替代方案。自带免费额度,也支 | 免费无需订阅 | 有(OpenCode Zen) | 有限(ChatGPT Free) | 无(需 Pro $20/月) | | 桌面应用 | macOS + Windows | macOS + Windows | macOS + Windows | | 本地文件访问 | 完整工作区 | 默认沙箱 | 用户选择的文件夹 | +| 浏览器自动化 | 支持(OpenCLI 支持站点适配器,覆盖范围因站点而异) | 不支持 | 不支持 | | 本地模型 | 支持(Ollama、LM Studio 等) | 仅 CLI 支持(Ollama) | 通过网关(Requesty) | | Office 文件处理 | 支持(Word/Excel/PPT) | 不支持 | 不支持 | | 面向非技术用户 | 是(任务卡片,无需终端) | 面向开发者 | 知识工作 + 编程 | @@ -63,6 +64,13 @@ Codex App 和 Claude Cowork 的开源替代方案。自带免费额度,也支 - 结合日志和源码排查 API 报错 - 根据一句自然语言需求做一个小工具 +### 浏览器和网页服务 + +- 搜索 Twitter/X,并在爪印里起草回复 +- 查看 Bilibili 或小红书的热门内容 +- 收集受支持网页服务里的帖子、评论或媒体 +- 通过爪印的浏览器工具操作受支持的网页服务 + ## 工作方式 1. 选择一个工作文件夹。 @@ -104,6 +112,8 @@ bun run dev:desktop 爪印 PawWork 内置 iOfficeAI 的 [OfficeCLI](https://github.com/iOfficeAI/OfficeCLI),用于在本地处理 Word、Excel 和 PowerPoint 文件。感谢 iOfficeAI 以 Apache-2.0 开源 OfficeCLI。 +爪印 PawWork 内置 [@jackwener](https://github.com/jackwener) 的 [OpenCLI](https://github.com/jackwener/opencli),用于支持基于浏览器的站点适配器。OpenCLI 提供 CDP bridge 和随包命令适配器,在适配覆盖范围匹配任务时,帮助爪印与 Twitter/X、Bilibili、小红书等网页服务交互。感谢 @jackwener 以 Apache-2.0 开源 OpenCLI 项目。 + ## 常见问题 **爪印 PawWork 免费吗?** diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 4662fba2e..2f6b60dc7 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -9,7 +9,18 @@ PawWork also bundles OfficeCLI's `officecli-*` and `morph-ppt*` skill directorie - Project: https://github.com/iOfficeAI/OfficeCLI - License: Apache License 2.0 -The Apache License 2.0 text for OfficeCLI follows. +## OpenCLI + +PawWork bundles `@jackwener/opencli` by @jackwener to power browser-based adapters for supported sites. + +PawWork packages the OpenCLI runtime files needed by the desktop app, including its CDP bridge, command registry, browser helpers, bundled site adapters under `clis/**`, and `cli-manifest.json`. + +- Project: https://github.com/jackwener/opencli +- Package: `@jackwener/opencli` +- Version: 1.8.3 +- License: Apache License 2.0 + +The Apache License 2.0 text for OfficeCLI and OpenCLI follows. ## Apache License 2.0 diff --git a/packages/desktop-electron/electron-builder-app-update.test.ts b/packages/desktop-electron/electron-builder-app-update.test.ts index 54c9a29cc..f5ca975cd 100644 --- a/packages/desktop-electron/electron-builder-app-update.test.ts +++ b/packages/desktop-electron/electron-builder-app-update.test.ts @@ -9,6 +9,8 @@ import { getPublishConfig, nativeWatcherFileSets, nativeWatcherPackageNames, + openCliRuntimeFileSets, + openCliRuntimePackageNames, } from "./electron-builder.config" import { serializeAppUpdateConfig } from "./scripts/write-app-update-config" @@ -104,6 +106,20 @@ describe("electron builder app-update config", () => { ) }) + test("third-party notices include bundled OpenCLI attribution", () => { + const notices = readFileSync(join(import.meta.dir, "../..", "THIRD_PARTY_NOTICES.md"), "utf8") + const opencodePackage = JSON.parse( + readFileSync(join(import.meta.dir, "..", "opencode", "package.json"), "utf8"), + ) as { dependencies: Record } + const openCliVersion = opencodePackage.dependencies["@jackwener/opencli"] + + expect(notices).toContain("## OpenCLI") + expect(notices).toContain("https://github.com/jackwener/opencli") + expect(notices).toContain("`@jackwener/opencli`") + expect(notices).toContain(`Version: ${openCliVersion}`) + expect(notices).toContain("Apache License 2.0") + }) + test("native watcher package list covers desktop targets", () => { expect(nativeWatcherPackageNames()).toEqual([ "@parcel/watcher-darwin-arm64", @@ -136,6 +152,60 @@ describe("electron builder app-update config", () => { ) }) + test("packages OpenCLI adapters and runtime dependencies for the embedded server", () => { + const config = createConfig("prod") + const resources = openCliRuntimeFileSets() + + expect(openCliRuntimePackageNames()).toEqual([ + "@jackwener/opencli", + "@mixmark-io/domino", + "@mozilla/readability", + "ansi-regex", + "argparse", + "cli-table3", + "commander", + "emoji-regex", + "is-fullwidth-code-point", + "js-yaml", + "string-width", + "strip-ansi", + "turndown", + "turndown-plugin-gfm", + "undici", + "ws", + ]) + expect(config.extraResources).toEqual( + expect.arrayContaining( + resources.map((resource) => + expect.objectContaining({ + from: resource.from, + to: resource.to, + }), + ), + ), + ) + expect(resources.map((resource) => resource.to)).toEqual( + openCliRuntimePackageNames().map((packageName) => join("node_modules", ...packageName.split("/"))), + ) + }) + + test("packages only OpenCLI runtime files", () => { + const resources = openCliRuntimeFileSets() + + for (const resource of resources) { + expect(resource.filter).toEqual( + expect.arrayContaining([ + "!**/.yarn/**", + "!**/{test,tests,__tests__,coverage}/**", + "!**/*.{test,spec}.{js,mjs,cjs,ts,tsx}", + ]), + ) + } + expect(resources.find((resource) => resource.to.endsWith(join("@jackwener", "opencli")))?.filter).toContain( + "!clis/test-utils.js", + ) + }) + test("afterPack writes app-update.yml to the packager-reported macOS resources path", async () => { const root = mkdtempSync(join(tmpdir(), "pawwork-builder-config-")) roots.push(root) diff --git a/packages/desktop-electron/electron-builder.config.ts b/packages/desktop-electron/electron-builder.config.ts index 28aa5555c..10de406e9 100644 --- a/packages/desktop-electron/electron-builder.config.ts +++ b/packages/desktop-electron/electron-builder.config.ts @@ -7,6 +7,9 @@ import { promisify } from "node:util" import type { Configuration } from "electron-builder" import { writeAppUpdateConfig, type GitHubPublishConfig } from "./scripts/write-app-update-config" +import { openCliRuntimeFileSets, openCliRuntimePackageNames } from "./opencli-runtime" + +export { openCliRuntimeFileSets, openCliRuntimePackageNames } from "./opencli-runtime" const execFileAsync = promisify(execFile) const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..") @@ -118,6 +121,7 @@ const getBase = (channel: Channel): Configuration => ({ }, extraResources: [ ...nativeWatcherFileSets(), + ...openCliRuntimeFileSets(), { from: path.join(rootDir, "skills"), to: "skills", diff --git a/packages/desktop-electron/electron-vite.config.test.ts b/packages/desktop-electron/electron-vite.config.test.ts index cb30a4d06..7ebe0310c 100644 --- a/packages/desktop-electron/electron-vite.config.test.ts +++ b/packages/desktop-electron/electron-vite.config.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { realpathSync } from "node:fs" +import { readFileSync, realpathSync } from "node:fs" import path from "node:path" import { createRendererWorkspaceConfig } from "./renderer-workspace-config" @@ -15,3 +15,10 @@ test("renderer dedupes the ui workspace package", () => { expect(dedupe).toContain("@opencode-ai/ui") }) + +test("main build does not externalize OpenCLI from the desktop bundle", () => { + const source = readFileSync(path.join(import.meta.dir, "electron.vite.config.ts"), "utf8") + + expect(source).toContain("externalizeDeps: { include: [nodePtyPkg] }") + expect(source).not.toContain("OPENCLI_EXTERNALS") +}) diff --git a/packages/desktop-electron/electron.vite.config.ts b/packages/desktop-electron/electron.vite.config.ts index 773e0f64d..6c5408ace 100644 --- a/packages/desktop-electron/electron.vite.config.ts +++ b/packages/desktop-electron/electron.vite.config.ts @@ -9,6 +9,7 @@ import { embeddedServerMissingArtifactsMessage, } from "./src/main/embedded-server-contract" import { createRendererWorkspaceConfig } from "./renderer-workspace-config" +import { includeOpenCliRuntimeDirectory, includeOpenCliRuntimeFile, openCliRuntimePackages } from "./opencli-runtime" const channel = (() => { const raw = process.env.OPENCODE_CHANNEL @@ -29,6 +30,28 @@ if (missingArtifacts.length > 0) { const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}` const rendererWorkspaceConfig = createRendererWorkspaceConfig(process.cwd(), realpathSync) +async function copyOpenCliRuntimePackage(pkg: ReturnType[number], target: string) { + const stack = [{ source: pkg.dir, destination: target, relativePath: "" }] + while (stack.length > 0) { + const current = stack.pop()! + await fs.mkdir(current.destination, { recursive: true }) + for (const entry of await fs.readdir(current.source, { withFileTypes: true })) { + const source = path.join(current.source, entry.name) + const destination = path.join(current.destination, entry.name) + const relativePath = current.relativePath ? path.join(current.relativePath, entry.name) : entry.name + if (entry.isDirectory()) { + if (includeOpenCliRuntimeDirectory(pkg.name, relativePath)) { + stack.push({ source, destination, relativePath }) + } + continue + } + if (entry.isFile() && includeOpenCliRuntimeFile(pkg.name, relativePath)) { + await fs.copyFile(source, destination) + } + } + } +} + export default defineConfig({ main: { define: { @@ -66,6 +89,16 @@ export default defineConfig({ } }, }, + { + name: "opencode:copy-opencli-runtime", + async writeBundle() { + for (const pkg of openCliRuntimePackages()) { + const target = path.join("./out/main/chunks/node_modules", ...pkg.name.split("/")) + await fs.rm(target, { recursive: true, force: true }) + await copyOpenCliRuntimePackage(pkg, target) + } + }, + }, ], }, preload: { diff --git a/packages/desktop-electron/opencli-runtime.ts b/packages/desktop-electron/opencli-runtime.ts new file mode 100644 index 000000000..7c430bce8 --- /dev/null +++ b/packages/desktop-electron/opencli-runtime.ts @@ -0,0 +1,131 @@ +import { existsSync, readFileSync } from "node:fs" +import { createRequire } from "node:module" +import path from "node:path" +import { fileURLToPath } from "node:url" + +type PackageJson = { + name?: string + dependencies?: Record +} + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..") + +function readPackageJson(packageDir: string): PackageJson { + return JSON.parse(readFileSync(path.join(packageDir, "package.json"), "utf8")) as PackageJson +} + +function packageRootFromResolvedEntry(packageName: string, resolvedEntry: string) { + let dir = path.dirname(resolvedEntry) + while (true) { + const packageJsonPath = path.join(dir, "package.json") + if (existsSync(packageJsonPath)) { + const json = JSON.parse(readFileSync(packageJsonPath, "utf8")) as PackageJson + if (json.name === packageName) return dir + } + + const parent = path.dirname(dir) + if (parent === dir) { + throw new Error(`Could not find package root for ${packageName} from ${resolvedEntry}`) + } + dir = parent + } +} + +function resolvePackageRoot(packageName: string, issuerPackageDir: string) { + const resolver = createRequire(path.join(issuerPackageDir, "package.json")) + const resolvedEntry = resolver.resolve(packageName) + if (path.isAbsolute(resolvedEntry)) return packageRootFromResolvedEntry(packageName, resolvedEntry) + // Bun resolves its built-in `undici` compatibility entry to the bare + // specifier. The installed package still exposes package.json in this layout, + // so use it only as a narrow fallback for non-path results. + return path.dirname(resolver.resolve(`${packageName}/package.json`)) +} + +export function openCliRuntimePackages() { + const packages: Array<{ name: string; dir: string; json: PackageJson }> = [] + const seen = new Map() + + function visit(packageName: string, issuerPackageDir: string) { + const dir = resolvePackageRoot(packageName, issuerPackageDir) + const previous = seen.get(packageName) + if (previous) { + if (previous !== dir) throw new Error(`Multiple runtime copies found for ${packageName}: ${previous}, ${dir}`) + return + } + + const json = readPackageJson(dir) + seen.set(packageName, dir) + packages.push({ name: packageName, dir, json }) + for (const dependency of Object.keys(json.dependencies ?? {}).sort()) { + visit(dependency, dir) + } + } + + visit("@jackwener/opencli", path.join(rootDir, "packages", "opencode")) + return packages.sort((a, b) => a.name.localeCompare(b.name)) +} + +export function openCliRuntimePackageNames() { + return openCliRuntimePackages().map((pkg) => pkg.name) +} + +const runtimeOnlyExcludes = [ + "!**/.yarn/**", + "!**/{test,tests,__tests__,coverage}/**", + "!**/*.{test,spec}.{js,mjs,cjs,ts,tsx}", +] +const openCliRuntimeFiles = ["package.json", "README.md", "LICENSE", "cli-manifest.json"] +const openCliRuntimeDirectories = ["clis", "dist/src"] +const openCliRuntimeExcludes = ["clis/test-utils.js"] + +function normalizeRelativePath(relativePath: string) { + return relativePath.split(path.sep).join("/") +} + +function isNonRuntimePath(relativePath: string) { + const normalized = normalizeRelativePath(relativePath) + const parts = normalized.split("/") + if (parts.some((part) => part === ".yarn" || part === "test" || part === "tests" || part === "__tests__" || part === "coverage")) { + return true + } + const basename = parts.at(-1) ?? "" + return /\.(test|spec)\.(js|mjs|cjs|ts|tsx)$/.test(basename) +} + +export function includeOpenCliRuntimeFile(packageName: string, relativePath: string) { + const normalized = normalizeRelativePath(relativePath) + if (isNonRuntimePath(normalized)) return false + if (packageName !== "@jackwener/opencli") return true + if (openCliRuntimeExcludes.includes(normalized)) return false + return ( + openCliRuntimeFiles.includes(normalized) || + openCliRuntimeDirectories.some((dir) => normalized.startsWith(`${dir}/`)) + ) +} + +export function includeOpenCliRuntimeDirectory(packageName: string, relativePath: string) { + const normalized = normalizeRelativePath(relativePath) + if (normalized === "") return true + if (isNonRuntimePath(normalized)) return false + if (packageName !== "@jackwener/opencli") return true + return openCliRuntimeDirectories.some( + (dir) => normalized === dir || normalized.startsWith(`${dir}/`) || dir.startsWith(`${normalized}/`), + ) +} + +function openCliRuntimeFilter(packageName: string) { + const includes = + packageName === "@jackwener/opencli" + ? [...openCliRuntimeFiles, ...openCliRuntimeDirectories.map((dir) => `${dir}/**/*`)] + : ["**/*"] + const packageExcludes = packageName === "@jackwener/opencli" ? openCliRuntimeExcludes.map((file) => `!${file}`) : [] + return [...includes, ...runtimeOnlyExcludes, ...packageExcludes] +} + +export function openCliRuntimeFileSets() { + return openCliRuntimePackages().map((pkg) => ({ + from: pkg.dir, + to: path.join("node_modules", ...pkg.name.split("/")), + filter: openCliRuntimeFilter(pkg.name), + })) +} diff --git a/packages/desktop-electron/src/main/embedded-server-build.test.ts b/packages/desktop-electron/src/main/embedded-server-build.test.ts index 9ad07e9a4..70c558f50 100644 --- a/packages/desktop-electron/src/main/embedded-server-build.test.ts +++ b/packages/desktop-electron/src/main/embedded-server-build.test.ts @@ -62,6 +62,20 @@ test("electron-vite build copies required embedded server wasm sidecars into out for (const matches of requiredWasmMatchers) { expect(files.some((file) => matches(file))).toBe(true) } + expect(fs.existsSync(path.join(outChunksDir, "node_modules", "@jackwener", "opencli", "cli-manifest.json"))).toBe( + true, + ) + expect( + fs.existsSync(path.join(outChunksDir, "node_modules", "@jackwener", "opencli", "clis", "twitter", "like.js")), + ).toBe(true) + expect( + fs.existsSync(path.join(outChunksDir, "node_modules", "@jackwener", "opencli", "clis", "twitter", "like.test.js")), + ).toBe(false) + expect(fs.existsSync(path.join(outChunksDir, "node_modules", "@mixmark-io", "domino", "package.json"))).toBe(true) + expect(fs.existsSync(path.join(outChunksDir, "node_modules", "@mixmark-io", "domino", "lib", "index.js"))).toBe( + true, + ) + expect(fs.existsSync(path.join(outChunksDir, "node_modules", "@mixmark-io", "domino", "test"))).toBe(false) expectModelsSnapshotUnchanged(modelsFixture) }) diff --git a/packages/opencode/script/build-node.ts b/packages/opencode/script/build-node.ts index a4a3970cb..c2c4cb6ee 100755 --- a/packages/opencode/script/build-node.ts +++ b/packages/opencode/script/build-node.ts @@ -10,6 +10,24 @@ const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const dir = path.resolve(__dirname, "..") +const OPENCLI_EXTERNALS = [ + "@jackwener/opencli", + "@jackwener/opencli/browser/cdp", + "@jackwener/opencli/browser/page", + "@jackwener/opencli/browser/utils", + "@jackwener/opencli/download", + "@jackwener/opencli/download/article-download", + "@jackwener/opencli/download/media-download", + "@jackwener/opencli/download/progress", + "@jackwener/opencli/errors", + "@jackwener/opencli/launcher", + "@jackwener/opencli/logger", + "@jackwener/opencli/pipeline", + "@jackwener/opencli/registry", + "@jackwener/opencli/types", + "@jackwener/opencli/utils", +] + process.chdir(dir) await import("./generate.ts") @@ -55,7 +73,7 @@ await Bun.build({ outdir: "./dist/node", format: "esm", sourcemap: "linked", - external: ["jsonc-parser", "@lydell/node-pty"], + external: ["jsonc-parser", "@lydell/node-pty", ...OPENCLI_EXTERNALS], define: { OPENCODE_VERSION: `'${Script.version}'`, OPENCODE_PLUGIN_VERSION: `'${pluginPkg.version}'`, diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 104b562ff..9dc3e2647 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -83,6 +83,8 @@ export namespace Agent { const defaults = Permission.fromConfig({ "*": "allow", doom_loop: "ask", + opencli_read: "ask", + opencli_write: "ask", question: "deny", plan_enter: "deny", plan_exit: "deny", diff --git a/packages/opencode/src/opencli/adapter-registry.ts b/packages/opencode/src/opencli/adapter-registry.ts new file mode 100644 index 000000000..20a35afda --- /dev/null +++ b/packages/opencode/src/opencli/adapter-registry.ts @@ -0,0 +1,164 @@ +import { existsSync } from "node:fs" +import fs from "node:fs/promises" +import path from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" +import { fullName, getRegistry, type CliCommand } from "@jackwener/opencli/registry" + +export type OpenCliManifestEntry = { + site: string + name: string + description?: string + access: "read" | "write" + domain?: string + browser?: boolean + navigateBefore?: string | boolean + args?: Array<{ + name: string + type?: string + required?: boolean + default?: unknown + help?: string + choices?: string[] + positional?: boolean + }> + type: "js" + modulePath: string +} + +export type OpenCliCommandSummary = { + name: string + description: string + access: "read" | "write" + browser: boolean + domain?: string + navigateBefore?: string | boolean + args: OpenCliManifestEntry["args"] +} + +export const BLOCKED_OPENCLI_COMMANDS = new Set(["instagram/reel"]) + +let manifestCache: OpenCliManifestEntry[] | undefined +const importedModules = new Set() + +function openCliPackageRoot() { + const cdp = fileURLToPath(import.meta.resolve("@jackwener/opencli/browser/cdp")) + for (let dir = path.dirname(cdp); ; dir = path.dirname(dir)) { + if (existsSync(path.join(dir, "cli-manifest.json")) && existsSync(path.join(dir, "clis"))) return dir + const parent = path.dirname(dir) + if (parent === dir) break + } + throw new Error(`Unable to locate @jackwener/opencli package root from ${cdp}`) +} + +async function loadManifest(): Promise { + if (manifestCache) return manifestCache + const manifestPath = path.join(openCliPackageRoot(), "cli-manifest.json") + const parsed = JSON.parse(await fs.readFile(manifestPath, "utf8")) as OpenCliManifestEntry[] + manifestCache = parsed.filter((entry) => entry.type === "js" && typeof entry.modulePath === "string") + return manifestCache +} + +function manifestCommandName(entry: Pick) { + return `${entry.site}/${entry.name}` +} + +function manifestCommandSummary(entry: OpenCliManifestEntry): OpenCliCommandSummary { + return { + name: manifestCommandName(entry), + description: entry.description ?? "", + access: entry.access, + browser: entry.browser !== false, + domain: entry.domain, + navigateBefore: entry.navigateBefore, + args: entry.args, + } +} + +function supportedOpenCliCommand(command: OpenCliCommandSummary) { + return !BLOCKED_OPENCLI_COMMANDS.has(command.name) && !(command.access === "write" && command.browser === false) +} + +export function openCliCommandSummaryFromCommand(command: CliCommand): OpenCliCommandSummary { + return { + name: fullName(command), + description: command.description ?? "", + access: command.access, + browser: command.browser !== false, + domain: command.domain, + navigateBefore: command.navigateBefore, + args: command.args, + } +} + +async function importAdapterModule(modulePath: string) { + if (importedModules.has(modulePath)) return + try { + await import(pathToFileURL(path.join(openCliPackageRoot(), "clis", modulePath)).href) + importedModules.add(modulePath) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new Error(`Failed to load OpenCLI adapter module ${modulePath}: ${message}`) + } +} + +async function manifestEntryForCommand(name: string) { + return (await loadManifest()).find((entry) => manifestCommandName(entry) === name) +} + +export async function openCliCommand(name: string): Promise { + if (BLOCKED_OPENCLI_COMMANDS.has(name)) return undefined + const existing = getRegistry().get(name) + if (existing) return supportedOpenCliCommand(openCliCommandSummaryFromCommand(existing)) ? existing : undefined + const entry = await manifestEntryForCommand(name) + if (!entry) return undefined + if (!supportedOpenCliCommand(manifestCommandSummary(entry))) return undefined + await importAdapterModule(entry.modulePath) + const loaded = getRegistry().get(name) + return loaded && supportedOpenCliCommand(openCliCommandSummaryFromCommand(loaded)) ? loaded : undefined +} + +function scoreCommand(command: OpenCliCommandSummary, query: string) { + const needle = query.trim().toLowerCase() + if (!needle) return 1 + const name = command.name.toLowerCase() + const haystack = [name, command.description, command.domain, command.access, command.browser ? "browser" : "http"] + .filter(Boolean) + .join(" ") + .toLowerCase() + if (name === needle) return 100 + if (name.includes(needle)) return 80 + const terms = needle.split(/\s+/).filter(Boolean) + const hits = terms.filter((term) => haystack.includes(term)).length + return hits === 0 ? 0 : hits * 10 +} + +export async function searchOpenCliCommands( + query: string, + options: { limit?: number } = {}, +): Promise { + const manifest = await loadManifest() + const limit = Math.min(Math.max(options.limit ?? 10, 1), 25) + const summaries = new Map() + for (const entry of manifest) { + const summary = manifestCommandSummary(entry) + summaries.set(summary.name, summary) + } + for (const command of getRegistry().values()) { + const summary = openCliCommandSummaryFromCommand(command) + if (!summaries.has(summary.name)) summaries.set(summary.name, summary) + } + return [...summaries.values()] + .filter(supportedOpenCliCommand) + .map((command) => ({ command, score: scoreCommand(command, query) })) + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score || a.command.name.localeCompare(b.command.name)) + .slice(0, limit) + .map(({ command }) => command) +} + +export function resetOpenCliAdaptersForTest() { + manifestCache = undefined + importedModules.clear() +} + +export * as AdapterRegistry from "./adapter-registry" diff --git a/packages/opencode/src/opencli/adapter-runner.ts b/packages/opencode/src/opencli/adapter-runner.ts new file mode 100644 index 000000000..5cd9fa987 --- /dev/null +++ b/packages/opencode/src/opencli/adapter-runner.ts @@ -0,0 +1,366 @@ +import { executePipeline } from "@jackwener/opencli/pipeline" +import { fullName, type Arg, type CliCommand, type CommandArgs, type IPage, type SiteSessionMode } from "@jackwener/opencli/registry" + +export class OpenCliArgumentError extends Error { + constructor(message: string) { + super(message) + this.name = "OpenCliArgumentError" + } +} + +export class OpenCliCommandError extends Error { + constructor(message: string) { + super(message) + this.name = "OpenCliCommandError" + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function readNodeID(value: unknown, key: string): number | null { + if (!isRecord(value)) return null + const nodeID = value[key] + return typeof nodeID === "number" ? nodeID : null +} + +type BrowserPermissionCheck = (patterns: string[], metadata?: Record) => Promise + +const UNGUARDED_PAGE_METHODS = new Set(["getActivePage", "getCurrentUrl", "setActivePage", "wait", "waitForTimeout"]) +const RECHECK_AFTER_PAGE_METHODS = new Set([ + "click", + "closeTab", + "cdp", + "dblClick", + "evaluate", + "evaluateWithArgs", + "goto", + "handleJavaScriptDialog", + "nativeClick", + "newTab", + "pressKey", + "selectTab", +]) + +function currentBrowserPermissionPattern(url: string | null | undefined) { + if (!url) return "*" + try { + const parsed = new URL(url) + return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : "*" + } catch { + return "*" + } +} + +function targetBrowserPermissionPattern(cmd: CliCommand, url: string) { + try { + const parsed = new URL(url) + if (parsed.protocol === "http:" || parsed.protocol === "https:") return parsed.href + } catch { + // fall through to the command-specific error below + } + throw new OpenCliCommandError(`Command ${fullName(cmd)} tried to navigate to unsupported URL: ${JSON.stringify(url)}`) +} + +async function askCurrentBrowserPermission( + cmd: CliCommand, + page: IPage, + askBrowserPermission: BrowserPermissionCheck | undefined, + operation: string, +) { + if (!askBrowserPermission) return + const currentUrl = await page.getCurrentUrl?.().catch(() => null) + await askBrowserPermission([currentBrowserPermissionPattern(currentUrl)], { operation, command: fullName(cmd) }) +} + +async function withCurrentBrowserPermission( + cmd: CliCommand, + page: IPage, + askBrowserPermission: BrowserPermissionCheck | undefined, + operation: string, + run: () => Promise, + recheckAfter = false, +): Promise { + await askCurrentBrowserPermission(cmd, page, askBrowserPermission, operation) + const result = await run() + if (recheckAfter) await askCurrentBrowserPermission(cmd, page, askBrowserPermission, `${operation}:after`) + return result +} + +async function withTargetBrowserPermission( + cmd: CliCommand, + page: IPage, + askBrowserPermission: BrowserPermissionCheck | undefined, + operation: string, + url: string, + run: () => Promise, +): Promise { + if (askBrowserPermission) { + await askBrowserPermission([targetBrowserPermissionPattern(cmd, url)], { operation, command: fullName(cmd) }) + } + const result = await run() + await askCurrentBrowserPermission(cmd, page, askBrowserPermission, `${operation}:after`) + return result +} + +async function readBrowserLocation(page: IPage) { + const evaluated = await page.evaluate?.("window.location.href").catch(() => null) + if (typeof evaluated === "string" && evaluated) return evaluated + return await page.getCurrentUrl?.().catch(() => null) +} + +async function askPreNavRedirectBrowserPermission( + cmd: CliCommand, + page: IPage, + askBrowserPermission: BrowserPermissionCheck | undefined, + preNavUrl: string, +) { + if (!askBrowserPermission) return + const requested = targetBrowserPermissionPattern(cmd, preNavUrl) + const landed = currentBrowserPermissionPattern(await readBrowserLocation(page)) + if (landed === "*" || landed === requested) return + await askBrowserPermission([landed], { operation: "preNav:after", command: fullName(cmd), redirectedFrom: requested }) +} + +async function cdpSetFileInput(cmd: CliCommand, page: IPage, files: string[], selector = 'input[type="file"]') { + const cdp = page.cdp + if (typeof cdp !== "function") { + throw new OpenCliCommandError(`Command ${fullName(cmd)} needs setFileInput, but this browser backend does not expose CDP.`) + } + await cdp.call(page, "DOM.enable", {}).catch(() => undefined) + const documentResult = await cdp.call(page, "DOM.getDocument", {}) + const root = isRecord(documentResult) && isRecord(documentResult.root) ? documentResult.root : undefined + const rootNodeID = readNodeID(root, "nodeId") + if (rootNodeID === null) throw new OpenCliCommandError("DOM.getDocument returned no root node.") + const queryResult = await cdp.call(page, "DOM.querySelector", { nodeId: rootNodeID, selector }) + const nodeID = readNodeID(queryResult, "nodeId") + if (nodeID === null || nodeID <= 0) throw new OpenCliCommandError(`No file input matched selector: ${selector}`) + await cdp.call(page, "DOM.setFileInputFiles", { files, nodeId: nodeID }) +} + +async function cdpInsertText(cmd: CliCommand, page: IPage, text: string) { + const cdp = page.cdp + if (typeof cdp !== "function") { + throw new OpenCliCommandError(`Command ${fullName(cmd)} needs insertText, but this browser backend does not expose CDP.`) + } + await cdp.call(page, "Input.insertText", { text }) +} + +async function cdpNativeClick(cmd: CliCommand, page: IPage, x: number, y: number) { + const cdp = page.cdp + if (typeof cdp !== "function") { + throw new OpenCliCommandError(`Command ${fullName(cmd)} needs nativeClick, but this browser backend does not expose CDP.`) + } + await cdp.call(page, "Input.dispatchMouseEvent", { type: "mouseMoved", x, y }) + await cdp.call(page, "Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", clickCount: 1 }) + await cdp.call(page, "Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", clickCount: 1 }) +} + +export function createOpenCliAdapterPage( + cmd: CliCommand, + page: IPage, + options: { askBrowserPermission?: BrowserPermissionCheck } = {}, +): IPage { + return new Proxy(page, { + get(target, prop, receiver) { + if (prop === "then") return undefined + if (prop === "closeWindow") return async () => {} + if (prop === "goto") { + const goto = target.goto.bind(target) as (url: string, ...args: unknown[]) => Promise + return (url: string, ...args: unknown[]) => + withTargetBrowserPermission(cmd, target, options.askBrowserPermission, "goto", url, () => + goto(url, ...args), + ) + } + if (prop === "fetchJson") { + const value = Reflect.get(target, prop, receiver) + if (typeof value === "function") { + const fetchJson = value.bind(target) as (url: string, ...args: unknown[]) => Promise + return (url: string, ...args: unknown[]) => + withTargetBrowserPermission(cmd, target, options.askBrowserPermission, "fetchJson", url, () => + fetchJson(url, ...args), + ) + } + } + if (prop === "setFileInput" && typeof target.setFileInput !== "function" && typeof target.cdp === "function") { + return (files: string[], selector?: string) => + withCurrentBrowserPermission(cmd, target, options.askBrowserPermission, "setFileInput", () => + cdpSetFileInput(cmd, target, files, selector), + ) + } + if (prop === "insertText" && typeof target.insertText !== "function" && typeof target.cdp === "function") { + return (text: string) => + withCurrentBrowserPermission(cmd, target, options.askBrowserPermission, "insertText", () => + cdpInsertText(cmd, target, text), + ) + } + if (prop === "nativeType" && typeof target.cdp === "function") { + const value = Reflect.get(target, prop, receiver) + return typeof value === "function" + ? (text: string) => + withCurrentBrowserPermission(cmd, target, options.askBrowserPermission, "nativeType", () => + value.call(target, text), + ) + : (text: string) => + withCurrentBrowserPermission(cmd, target, options.askBrowserPermission, "nativeType", () => + cdpInsertText(cmd, target, text), + ) + } + if (prop === "nativeClick" && typeof target.cdp === "function") { + const value = Reflect.get(target, prop, receiver) + return typeof value === "function" + ? (x: number, y: number) => + withCurrentBrowserPermission(cmd, target, options.askBrowserPermission, "nativeClick", () => + value.call(target, x, y), + true, + ) + : (x: number, y: number) => + withCurrentBrowserPermission(cmd, target, options.askBrowserPermission, "nativeClick", () => + cdpNativeClick(cmd, target, x, y), + true, + ) + } + if (prop === "waitForTimeout") { + const value = Reflect.get(target, prop, receiver) + return typeof value === "function" ? value.bind(target) : (ms: number) => target.wait(ms / 1000) + } + const value = Reflect.get(target, prop, receiver) + if (typeof prop === "string" && typeof value === "function" && !UNGUARDED_PAGE_METHODS.has(prop)) { + return (...args: unknown[]) => + withCurrentBrowserPermission( + cmd, + target, + options.askBrowserPermission, + prop, + () => value.call(target, ...args), + RECHECK_AFTER_PAGE_METHODS.has(prop), + ) + } + return typeof value === "function" ? value.bind(target) : value + }, + }) +} + +export function coerceOpenCliArgs(cmdArgs: Arg[], kwargs: CommandArgs): CommandArgs { + const result = { ...kwargs } + for (const argDef of cmdArgs) { + const val = result[argDef.name] + if (argDef.required && (val === undefined || val === null || val === "")) { + throw new OpenCliArgumentError(`Argument "${argDef.name}" is required.`) + } + if (val !== undefined && val !== null) { + if (argDef.type === "int" || argDef.type === "number") { + if (typeof val === "string" && val.trim() === "") { + throw new OpenCliArgumentError(`Argument "${argDef.name}" must be a valid number. Received: "${val}"`) + } + const num = Number(val) + if (Number.isNaN(num)) { + throw new OpenCliArgumentError(`Argument "${argDef.name}" must be a valid number. Received: "${val}"`) + } + result[argDef.name] = num + } else if (argDef.type === "boolean" || argDef.type === "bool") { + if (typeof val === "string") { + const lower = val.toLowerCase() + if (lower === "true" || lower === "1") result[argDef.name] = true + else if (lower === "false" || lower === "0") result[argDef.name] = false + else throw new OpenCliArgumentError(`Argument "${argDef.name}" must be a boolean (true/false). Received: "${val}"`) + } else { + result[argDef.name] = Boolean(val) + } + } + const coercedVal = result[argDef.name] + if (argDef.choices && argDef.choices.length > 0 && !argDef.choices.map(String).includes(String(coercedVal))) { + throw new OpenCliArgumentError( + `Argument "${argDef.name}" must be one of: ${argDef.choices.join(", ")}. Received: "${coercedVal}"`, + ) + } + } else if (argDef.default !== undefined) { + result[argDef.name] = argDef.default + } + } + return result +} + +export function prepareOpenCliCommandArgs(cmd: CliCommand, rawKwargs: CommandArgs): CommandArgs { + const kwargs = coerceOpenCliArgs(cmd.args, rawKwargs) + cmd.validateArgs?.(kwargs) + return kwargs +} + +export function resolveOpenCliPreNav(cmd: CliCommand): string | null { + if (cmd.navigateBefore === false) return null + if (typeof cmd.navigateBefore === "string") return cmd.navigateBefore + return null +} + +function urlMatchesOrigin(url: string | null | undefined, originUrl: string) { + if (!url) return false + try { + return new URL(url).origin === new URL(originUrl).origin + } catch { + return false + } +} + +function isDomainRootPreNav(preNavUrl: string, domain: string | undefined) { + if (!domain) return false + try { + const parsed = new URL(preNavUrl) + const hostnameMatches = parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`) + const rootPath = parsed.pathname === "" || parsed.pathname === "/" + return hostnameMatches && rootPath && parsed.search === "" && parsed.hash === "" + } catch { + return false + } +} + +export async function shouldRunOpenCliPreNav( + cmd: CliCommand, + page: Pick, + siteSession: SiteSessionMode, + preNavUrl: string, +) { + if (siteSession !== "persistent" || !cmd.domain) return true + if (!isDomainRootPreNav(preNavUrl, cmd.domain)) return true + const currentUrl = await page.getCurrentUrl?.().catch(() => null) + return !urlMatchesOrigin(currentUrl, preNavUrl) +} + +function resolveOpenCliSiteSession(cmd: CliCommand): SiteSessionMode { + return cmd.siteSession ?? "ephemeral" +} + +export async function runOpenCliAdapterCommand( + cmd: CliCommand, + page: IPage | null, + kwargs: CommandArgs, + options: { debug?: boolean; askBrowserPermission?: BrowserPermissionCheck } = {}, +): Promise { + const debug = options.debug ?? false + const siteSession = resolveOpenCliSiteSession(cmd) + const adapterPage = page ? createOpenCliAdapterPage(cmd, page, options) : null + const resetAfter = cmd.browser !== false && siteSession === "ephemeral" && page !== null + try { + const preNavUrl = resolveOpenCliPreNav(cmd) + if (preNavUrl) { + if (!page || !adapterPage) + throw new OpenCliCommandError(`Command ${fullName(cmd)} requires a browser session for pre-navigation`) + if (await shouldRunOpenCliPreNav(cmd, adapterPage, siteSession, preNavUrl)) { + await page.goto(preNavUrl) + await askPreNavRedirectBrowserPermission(cmd, page, options.askBrowserPermission, preNavUrl) + } + } + if (cmd.func) { + if (cmd.browser === false) return cmd.func(kwargs, debug) + if (!adapterPage) throw new OpenCliCommandError(`Command ${fullName(cmd)} requires a browser session but none was provided`) + return cmd.func(adapterPage, kwargs, debug) + } + if (cmd.pipeline) return executePipeline(adapterPage, cmd.pipeline, { args: kwargs, debug }) + throw new OpenCliCommandError(`Command ${fullName(cmd)} has no func or pipeline`) + } finally { + if (resetAfter && page) await page.goto("about:blank").catch(() => undefined) + } +} + +export * as AdapterRunner from "./adapter-runner" diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index b0df8a1c6..fb0078ce0 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -420,10 +420,14 @@ export namespace Permission { export function disabled(tools: string[], ruleset: Ruleset): Set { const result = new Set() for (const tool of tools) { - // browser_* tools all ask the `browser` permission key, so a configured - // `permission.browser: deny` disables the whole set (hiding their - // deferred cards and repair hints, not just denying the eventual ask). - const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool.startsWith("browser_") ? "browser" : tool + // Browser-backed tools all ask the `browser` permission key, so a + // configured `permission.browser: deny` disables the whole set (hiding + // their deferred cards and repair hints, not just denying the eventual ask). + const permission = EDIT_TOOLS.includes(tool) + ? "edit" + : tool.startsWith("browser_") || tool.startsWith("opencli_") + ? "browser" + : tool const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission)) if (!rule) continue if (rule.pattern === "*" && rule.action === "deny") result.add(tool) diff --git a/packages/opencode/src/tool/opencli-run.ts b/packages/opencode/src/tool/opencli-run.ts new file mode 100644 index 000000000..db98f2e48 --- /dev/null +++ b/packages/opencode/src/tool/opencli-run.ts @@ -0,0 +1,155 @@ +import { Effect, Schema } from "effect" +import { fullName } from "@jackwener/opencli/registry" +import * as Tool from "./tool" +import DESCRIPTION from "./opencli-run.txt" +import { openCliCommand, openCliCommandSummaryFromCommand, type OpenCliCommandSummary } from "@/opencli/adapter-registry" +import { prepareOpenCliCommandArgs, runOpenCliAdapterCommand } from "@/opencli/adapter-runner" +import { browserAlwaysPatterns, runBrowserAction } from "./browser-shared" + +const OPENCLI_RUN_TIMEOUT_MS = 60_000 +type OpenCliCommand = NonNullable>> + +export const Parameters = Schema.Struct({ + command: Schema.String.annotate({ + description: "Exact OpenCLI adapter command name, for example 'hackernews/search' or '12306/me'.", + }), + args: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({ + description: "Adapter arguments by name. Use opencli_search to inspect available args.", + }), +}) + +function commandKnownBrowserPermissionPatterns(command: Pick): string[] { + if (typeof command.navigateBefore !== "string") return [] + const patterns: string[] = [] + patterns.push(command.navigateBefore) + if (command.domain) patterns.push(`https://${command.domain}/`) + return [...new Set(patterns)] +} + +function commandMetadata(command: OpenCliCommandSummary, args?: Record) { + return { + action: "opencli_run", + command: command.name, + browser: command.browser, + access: command.access, + args, + } +} + +function askOpenCliAccessPermission( + ctx: Tool.Context, + command: OpenCliCommandSummary, + args: Record, +) { + const permission = command.access === "write" ? "opencli_write" : "opencli_read" + return ctx.ask({ + permission, + patterns: [command.name], + always: [command.name], + metadata: commandMetadata(command, args), + }) +} + +function formatAdapterOutput(value: unknown): string { + if (typeof value === "string") return value + if (value === undefined || value === null) return "OpenCLI adapter returned no output." + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} + +async function runNonBrowserCommand( + command: OpenCliCommand, + args: Record, + abort: AbortSignal, +) { + const commandName = fullName(command) + if (abort.aborted) throw new Error(`OpenCLI ${commandName} was canceled.`) + let timer: ReturnType | undefined + let onAbort: (() => void) | undefined + const interrupted = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`OpenCLI ${commandName} timed out after ${OPENCLI_RUN_TIMEOUT_MS}ms.`)), + OPENCLI_RUN_TIMEOUT_MS, + ) + onAbort = () => reject(new Error(`OpenCLI ${commandName} was canceled.`)) + abort.addEventListener("abort", onAbort, { once: true }) + }) + const running = runOpenCliAdapterCommand(command, null, args) + running.catch(() => {}) + try { + return await Promise.race([running, interrupted]) + } finally { + clearTimeout(timer) + if (onAbort) abort.removeEventListener("abort", onAbort) + } +} + +function runBrowserCommand(command: OpenCliCommand, args: Record, ctx: Tool.Context) { + const patterns = commandKnownBrowserPermissionPatterns(command) + const metadata = commandMetadata(openCliCommandSummaryFromCommand(command)) + return runBrowserAction({ + ctx, + label: `opencli ${fullName(command)}`, + patterns: patterns.length > 0 ? patterns : undefined, + metadata, + timeoutMs: OPENCLI_RUN_TIMEOUT_MS, + run: (page) => + runOpenCliAdapterCommand(command, page, args, { + askBrowserPermission: (targets, guardMetadata) => + Effect.runPromise( + ctx.ask({ + permission: "browser", + patterns: targets, + always: browserAlwaysPatterns(targets), + metadata: { ...metadata, ...guardMetadata }, + }), + ), + }), + }) +} + +export const OpenCliRunTool = Tool.define( + "opencli_run", + Effect.gen(function* () { + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const requestedArgs = params.args ?? {} + const command = yield* Effect.tryPromise({ + try: () => openCliCommand(params.command), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }) + if (!command) { + return yield* Effect.fail( + new Error(`Unknown or unsupported OpenCLI command "${params.command}". Run opencli_search to find one.`), + ) + } + const args = prepareOpenCliCommandArgs(command, requestedArgs) + yield* askOpenCliAccessPermission(ctx, openCliCommandSummaryFromCommand(command), args) + + const value = command.browser === false + ? yield* Effect.tryPromise({ + try: () => runNonBrowserCommand(command, args, ctx.abort), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }) + : yield* runBrowserCommand(command, args, ctx) + + return { + title: `OpenCLI ${fullName(command)}`, + output: formatAdapterOutput(value), + metadata: { + command: fullName(command), + access: command.access, + browser: command.browser !== false, + domain: command.domain, + }, + } + }), + } + }), +) diff --git a/packages/opencode/src/tool/opencli-run.txt b/packages/opencode/src/tool/opencli-run.txt new file mode 100644 index 000000000..fa90950fb --- /dev/null +++ b/packages/opencode/src/tool/opencli-run.txt @@ -0,0 +1,3 @@ +Run one bundled OpenCLI adapter command. + +Call opencli_search first when you are unsure which command to use. Pass the exact command name returned by search, such as `hackernews/search` or `12306/me`, and provide adapter arguments in `args`. Commands run against PawWork's visible embedded browser when they need browser access. diff --git a/packages/opencode/src/tool/opencli-search.ts b/packages/opencode/src/tool/opencli-search.ts new file mode 100644 index 000000000..e0405b012 --- /dev/null +++ b/packages/opencode/src/tool/opencli-search.ts @@ -0,0 +1,67 @@ +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import DESCRIPTION from "./opencli-search.txt" +import { searchOpenCliCommands, type OpenCliCommandSummary } from "@/opencli/adapter-registry" + +export const Parameters = Schema.Struct({ + query: Schema.String.annotate({ + description: "Search text: site, command, domain, or task, for example '12306 account' or 'hackernews search'.", + }), + limit: Schema.optional(Schema.Number).annotate({ + description: "Maximum commands to return. Defaults to 10, max 25.", + }), +}) + +function formatOpenCliCommand(command: OpenCliCommandSummary) { + const args = (command.args ?? []) + .map((arg) => { + const details = [`${arg.name}${arg.required ? " (required)" : ""}`] + if (arg.type) details.push(`type: ${arg.type}`) + if (arg.choices && arg.choices.length > 0) details.push(`choices: [${arg.choices.join(", ")}]`) + if (arg.default !== undefined) details.push(`default: ${JSON.stringify(arg.default)}`) + if (arg.help) details.push(`help: ${arg.help}`) + return `- ${details.join(" | ")}` + }) + .join("\n") + return [ + ``, + `description: ${command.description || "No description"}`, + `access: ${command.access}`, + `browser: ${command.browser}`, + command.domain ? `domain: ${command.domain}` : undefined, + args ? `args:\n${args}` : "args: none", + "", + ] + .filter(Boolean) + .join("\n") +} + +export function formatOpenCliSearchOutput(results: OpenCliCommandSummary[]) { + const body = + results.length === 0 + ? "No bundled OpenCLI adapter commands matched this query." + : results.map(formatOpenCliCommand).join("\n\n") + return body +} + +export const OpenCliSearchTool = Tool.define( + "opencli_search", + Effect.gen(function* () { + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type) => + Effect.tryPromise({ + try: async () => { + const results = await searchOpenCliCommands(params.query, { limit: params.limit }) + return { + title: `OpenCLI commands for "${params.query}"`, + output: formatOpenCliSearchOutput(results), + metadata: { query: params.query, count: results.length }, + } + }, + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }), + } + }), +) diff --git a/packages/opencode/src/tool/opencli-search.txt b/packages/opencode/src/tool/opencli-search.txt new file mode 100644 index 000000000..63fa52529 --- /dev/null +++ b/packages/opencode/src/tool/opencli-search.txt @@ -0,0 +1,3 @@ +Search PawWork's bundled OpenCLI site adapters. + +Use this before opencli_run when the user asks to operate a specific website or use a site-specific workflow. It returns command names such as `12306/me` or `hackernews/search`, whether the command needs the embedded browser, and the argument names to pass. diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 4f5fb642a..13c1e11b8 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -22,6 +22,8 @@ import { BrowserTypeTool } from "./browser-type" import { BrowserWaitTool } from "./browser-wait" import { BrowserScreenshotTool } from "./browser-screenshot" import { BrowserExtractTool } from "./browser-extract" +import { OpenCliSearchTool } from "./opencli-search" +import { OpenCliRunTool } from "./opencli-run" import * as Tool from "./tool" import { Config } from "../config/config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" @@ -169,6 +171,8 @@ export namespace ToolRegistry { const browserWait = yield* BrowserWaitTool const browserScreenshot = yield* BrowserScreenshotTool const browserExtract = yield* BrowserExtractTool + const openCliSearch = yield* OpenCliSearchTool + const openCliRun = yield* OpenCliRunTool const toolInfoInfo = yield* ToolInfoTool((toolID, output) => plugin.trigger("tool.definition", { toolID }, output), @@ -340,6 +344,8 @@ export namespace ToolRegistry { browserWait: Tool.init(browserWait), browserScreenshot: Tool.init(browserScreenshot), browserExtract: Tool.init(browserExtract), + openCliSearch: Tool.init(openCliSearch), + openCliRun: Tool.init(openCliRun), }) return { @@ -379,6 +385,8 @@ export namespace ToolRegistry { tool.browserWait, tool.browserScreenshot, tool.browserExtract, + tool.openCliSearch, + tool.openCliRun, ] : []), ], diff --git a/packages/opencode/src/tool/tool-info.ts b/packages/opencode/src/tool/tool-info.ts index d97c69e13..00e05c9e9 100644 --- a/packages/opencode/src/tool/tool-info.ts +++ b/packages/opencode/src/tool/tool-info.ts @@ -21,6 +21,10 @@ import { Parameters as BrowserScreenshotParameters } from "./browser-screenshot" import BrowserScreenshotDescription from "./browser-screenshot.txt" import { Parameters as BrowserExtractParameters } from "./browser-extract" import BrowserExtractDescription from "./browser-extract.txt" +import { Parameters as OpenCliSearchParameters } from "./opencli-search" +import OpenCliSearchDescription from "./opencli-search.txt" +import { Parameters as OpenCliRunParameters } from "./opencli-run" +import OpenCliRunDescription from "./opencli-run.txt" import { ProviderTransform } from "../provider/transform" import type { Provider } from "../provider/provider" import type { MessageV2 } from "../session/message-v2" @@ -110,6 +114,20 @@ const DEFERRED: DeferredEntry[] = [ parameters: BrowserExtractParameters as unknown as Tool.Def["parameters"], group: "browser", }, + { + id: "opencli_search", + card: "Search bundled OpenCLI site adapters by site, task, domain, or command name.", + description: OpenCliSearchDescription, + parameters: OpenCliSearchParameters as unknown as Tool.Def["parameters"], + group: "opencli", + }, + { + id: "opencli_run", + card: "Run one bundled OpenCLI site adapter command returned by opencli_search.", + description: OpenCliRunDescription, + parameters: OpenCliRunParameters as unknown as Tool.Def["parameters"], + group: "opencli", + }, ] as const // One card per group in tool_info's listing; the member cards appear in the @@ -117,6 +135,8 @@ const DEFERRED: DeferredEntry[] = [ const GROUP_CARDS: Record = { browser: "Drive the user-visible embedded browser: navigate, snapshot (numbered element refs), click, type, wait, screenshot, extract page content as markdown. Activates as one set — use for any task that needs to browse, read, or operate a website.", + opencli: + "Find and use bundled OpenCLI site adapters for website-specific workflows. Start with opencli_search, then run the selected command with opencli_run.", } const BY_ID: Record = Object.fromEntries(DEFERRED.map((d) => [d.id, d])) diff --git a/packages/opencode/test/fake/cdp-server.ts b/packages/opencode/test/fake/cdp-server.ts index 1d801bd60..c189ae7df 100644 --- a/packages/opencode/test/fake/cdp-server.ts +++ b/packages/opencode/test/fake/cdp-server.ts @@ -9,6 +9,7 @@ import { BrowserBridge } from "../../src/browser/browser-bridge" export class FakeCdpServer { readonly wss: WebSocketServer readonly methods: string[] = [] + readonly navigatedUrls: string[] = [] readonly handlers = new Map unknown>() private sockets = new Set() private hung: Array<{ ws: WebSocket; id: number }> = [] @@ -31,6 +32,7 @@ export class FakeCdpServer { this.methods.push(cmd.method) if (cmd.method === "Page.navigate") { this.url = (cmd.params as { url?: string } | undefined)?.url ?? null + if (this.url) this.navigatedUrls.push(this.url) } const handler = this.handlers.get(cmd.method) if (handler === HANG) { diff --git a/packages/opencode/test/github/ci-workflow.test.ts b/packages/opencode/test/github/ci-workflow.test.ts index a5eb84066..eb8338981 100644 --- a/packages/opencode/test/github/ci-workflow.test.ts +++ b/packages/opencode/test/github/ci-workflow.test.ts @@ -88,7 +88,7 @@ const windowsOpencodeShards = [ suffix: "opencode-config-project", usesTurbo: false, command: - "cd packages/opencode && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit-windows-config-project.xml test/config test/project test/worktree test/file/ test/github test/settings test/settings.test.ts", + "cd packages/opencode && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit-windows-config-project.xml test/config test/project test/worktree test/file/ test/github test/opencli test/settings test/settings.test.ts", reportPath: "packages/opencode/.artifacts/unit/junit-windows-config-project.xml", }, { diff --git a/packages/opencode/test/opencli/adapter-capabilities.test.ts b/packages/opencode/test/opencli/adapter-capabilities.test.ts new file mode 100644 index 000000000..6b9077a50 --- /dev/null +++ b/packages/opencode/test/opencli/adapter-capabilities.test.ts @@ -0,0 +1,179 @@ +import fs from "node:fs/promises" +import { existsSync } from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { describe, expect, test } from "bun:test" +import ts from "typescript" +import { BLOCKED_OPENCLI_COMMANDS, type OpenCliManifestEntry } from "../../src/opencli/adapter-registry" + +type CapabilityGap = { + command: string + modulePath: string + kind: "browser-page-import" | "page-method" + value: string +} + +const SUPPORTED_PAGE_METHODS = new Set([ + "annotatedScreenshot", + "autoScroll", + "cdp", + "click", + "closeWindow", + "consoleMessages", + "dblClick", + "drag", + "evaluate", + "evaluateWithArgs", + "fetchJson", + "fillText", + "focus", + "getCookies", + "getCurrentUrl", + "getFormState", + "getInterceptedRequests", + "goto", + "handleJavaScriptDialog", + "hover", + "insertText", + "installInterceptor", + "nativeClick", + "nativeKeyPress", + "nativeType", + "networkRequests", + "pressKey", + "readNetworkCapture", + "screenshot", + "scroll", + "scrollTo", + "selectTab", + "setChecked", + "setFileInput", + "snapshot", + "startNetworkCapture", + "tabs", + "typeText", + "uploadFiles", + "wait", + "waitForCapture", + "waitForTimeout", +]) + +const ACCEPTED_CAPABILITY_GAPS: CapabilityGap[] = [ + { + command: "instagram/reel", + modulePath: "instagram/reel.js", + kind: "browser-page-import", + value: "@jackwener/opencli/browser/page", + }, +] + +function openCliPackageRoot() { + const cdp = fileURLToPath(import.meta.resolve("@jackwener/opencli/browser/cdp")) + for (let dir = path.dirname(cdp); ; dir = path.dirname(dir)) { + if (existsSync(path.join(dir, "cli-manifest.json")) && existsSync(path.join(dir, "clis"))) return dir + const parent = path.dirname(dir) + if (parent === dir) break + } + throw new Error(`Unable to locate @jackwener/opencli package root from ${cdp}`) +} + +async function loadManifest() { + const manifestPath = path.join(openCliPackageRoot(), "cli-manifest.json") + const parsed = JSON.parse(await fs.readFile(manifestPath, "utf8")) as OpenCliManifestEntry[] + return parsed.filter((entry) => entry.type === "js" && typeof entry.modulePath === "string") +} + +function isPageLikeReceiver(node: ts.Expression): boolean { + if (ts.isIdentifier(node)) return node.text === "page" || node.text === "activePage" + return ts.isPropertyAccessExpression(node) && node.name.text === "page" +} + +function moduleImportsBrowserPage(source: ts.SourceFile) { + let importsBrowserPage = false + function visit(node: ts.Node) { + if ( + ts.isImportDeclaration(node) && + ts.isStringLiteral(node.moduleSpecifier) && + node.moduleSpecifier.text === "@jackwener/opencli/browser/page" + ) { + importsBrowserPage = true + } + if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments[0] && + ts.isStringLiteral(node.arguments[0]) && + node.arguments[0].text === "@jackwener/opencli/browser/page" + ) { + importsBrowserPage = true + } + ts.forEachChild(node, visit) + } + visit(source) + return importsBrowserPage +} + +function pageMethodCalls(source: ts.SourceFile) { + const methods = new Set() + function visit(node: ts.Node) { + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + isPageLikeReceiver(node.expression.expression) + ) { + methods.add(node.expression.name.text) + } + ts.forEachChild(node, visit) + } + visit(source) + return methods +} + +async function scanCapabilities(): Promise { + const root = openCliPackageRoot() + const commandsByModule = new Map() + for (const entry of await loadManifest()) { + const command = `${entry.site}/${entry.name}` + const commands = commandsByModule.get(entry.modulePath) ?? [] + commands.push(command) + commandsByModule.set(entry.modulePath, commands) + } + + const gaps: CapabilityGap[] = [] + for (const [modulePath, commands] of commandsByModule) { + const sourcePath = path.join(root, "clis", modulePath) + const sourceText = await fs.readFile(sourcePath, "utf8") + const source = ts.createSourceFile(sourcePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS) + if (moduleImportsBrowserPage(source)) { + for (const command of commands) { + gaps.push({ + command, + modulePath, + kind: "browser-page-import", + value: "@jackwener/opencli/browser/page", + }) + } + } + for (const method of pageMethodCalls(source)) { + if (SUPPORTED_PAGE_METHODS.has(method)) continue + for (const command of commands) { + gaps.push({ command, modulePath, kind: "page-method", value: method }) + } + } + } + return gaps.sort((a, b) => + a.command.localeCompare(b.command) || a.kind.localeCompare(b.kind) || a.value.localeCompare(b.value), + ) +} + +describe("opencli adapter capability guard", () => { + test("keeps every exposed adapter free of unsupported page methods", async () => { + const gaps = await scanCapabilities() + + expect(gaps).toEqual(ACCEPTED_CAPABILITY_GAPS) + expect(gaps.filter((gap) => gap.kind === "page-method")).toEqual([]) + expect(gaps.filter((gap) => gap.kind === "browser-page-import").map((gap) => gap.command)).toEqual( + [...BLOCKED_OPENCLI_COMMANDS], + ) + }) +}) diff --git a/packages/opencode/test/opencli/adapter-registry.test.ts b/packages/opencode/test/opencli/adapter-registry.test.ts new file mode 100644 index 000000000..9b47e9c2e --- /dev/null +++ b/packages/opencode/test/opencli/adapter-registry.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test" +import { getRegistry, type CliCommand } from "@jackwener/opencli/registry" +import { + AdapterRegistry, + openCliCommand, + searchOpenCliCommands, +} from "../../src/opencli/adapter-registry" + +describe("opencli adapter registry", () => { + test("exposes the module namespace export", () => { + expect(AdapterRegistry.searchOpenCliCommands).toBe(searchOpenCliCommands) + }) + + test("searches the packaged manifest without importing adapter modules", async () => { + getRegistry().delete("hackernews/search") + + const results = await searchOpenCliCommands("hackernews search", { limit: 5 }) + + expect(results.map((result) => result.name)).toContain("hackernews/search") + expect(getRegistry().has("hackernews/search")).toBe(false) + }) + + test("searches packaged manifest commands and hides blocked commands", async () => { + const results = await searchOpenCliCommands("12306 account", { limit: 5 }) + expect(results[0]).toMatchObject({ + name: "12306/me", + access: "read", + browser: true, + }) + + const hackerNews = await searchOpenCliCommands("hackernews search", { limit: 5 }) + expect(hackerNews.map((result) => result.name)).toContain("hackernews/search") + + const blocked = await searchOpenCliCommands("instagram reel", { limit: 25 }) + expect(blocked.map((result) => result.name)).not.toContain("instagram/reel") + expect(await openCliCommand("instagram/reel")).toBeUndefined() + }) + + test("lazily imports a bundled adapter module when resolving a command", async () => { + getRegistry().delete("12306/me") + + const command = await openCliCommand("12306/me") + + expect(command).toMatchObject({ site: "12306", name: "me" }) + expect(getRegistry().has("12306/me")).toBe(true) + }) + + test("indexes commands with implicit browser support as browser commands", async () => { + const command = { + site: "000-pawwork-implicit", + name: "implicit", + access: "read", + description: "Implicit test adapter", + args: [], + func: async () => [], + } satisfies CliCommand + getRegistry().set("000-pawwork-implicit/implicit", command) + + try { + const results = await searchOpenCliCommands("browser", { limit: 1 }) + + expect(results[0]?.name).toBe("000-pawwork-implicit/implicit") + expect(results[0]?.browser).toBe(true) + } finally { + getRegistry().delete("000-pawwork-implicit/implicit") + } + }) + +}) diff --git a/packages/opencode/test/opencli/adapter-runner.test.ts b/packages/opencode/test/opencli/adapter-runner.test.ts new file mode 100644 index 000000000..96318b357 --- /dev/null +++ b/packages/opencode/test/opencli/adapter-runner.test.ts @@ -0,0 +1,350 @@ +import { describe, expect, mock, test } from "bun:test" +import type { CliCommand } from "@jackwener/opencli/registry" +import type { IPage } from "@jackwener/opencli/types" +import { + AdapterRunner, + createOpenCliAdapterPage, + prepareOpenCliCommandArgs, + runOpenCliAdapterCommand, + shouldRunOpenCliPreNav, +} from "../../src/opencli/adapter-runner" + +function testUrl(path: string, origin = "https://example.com") { + return new URL(path, origin).href +} + +function hasExactUrlPattern(patterns: readonly string[], expected: string) { + return patterns.some((pattern) => { + try { + return new URL(pattern).href === expected + } catch { + return false + } + }) +} + +describe("opencli adapter runner", () => { + test("exposes the module namespace export", () => { + expect(AdapterRunner.runOpenCliAdapterCommand).toBe(runOpenCliAdapterCommand) + }) + + test("prepares args with defaults, type coercion, choices, and validateArgs", () => { + const command = { + site: "demo", + name: "search", + access: "read", + description: "demo", + browser: false, + args: [ + { name: "query", required: true }, + { name: "limit", type: "int", default: 20 }, + { name: "draft", type: "boolean", default: false }, + { name: "sort", choices: ["relevance", "date"], default: "relevance" }, + ], + validateArgs: (args) => { + if (args.query === "bad") throw new Error("bad query") + }, + func: async (args) => args, + } satisfies CliCommand + + expect(prepareOpenCliCommandArgs(command, { query: "pawwork", limit: "5", draft: "true" })).toEqual({ + query: "pawwork", + limit: 5, + draft: true, + sort: "relevance", + }) + expect(() => prepareOpenCliCommandArgs(command, { query: "pawwork", sort: "hot" })).toThrow( + 'Argument "sort" must be one of', + ) + expect(() => prepareOpenCliCommandArgs(command, { query: "pawwork", limit: "" })).toThrow( + 'Argument "limit" must be a valid number', + ) + expect(() => prepareOpenCliCommandArgs(command, { query: "bad" })).toThrow("bad query") + }) + + test("routes non-browser commands without a page", async () => { + const func = mock(async (args: Record) => [{ ok: args.query }]) + const command = { + site: "demo", + name: "http", + access: "read", + description: "demo", + browser: false, + args: [{ name: "query", required: true }], + func, + } satisfies CliCommand + + await expect(runOpenCliAdapterCommand(command, null, { query: "pawwork" })).resolves.toEqual([ + { ok: "pawwork" }, + ]) + expect(func).toHaveBeenCalledWith({ query: "pawwork" }, false) + }) + + test("pre-navigates browser commands before passing the visible page to the adapter", async () => { + const page = { + goto: mock(async () => {}), + getCurrentUrl: mock(async () => "about:blank"), + } + const typedPage = page as unknown as IPage + const func = mock(async (_page: unknown, args: Record) => ({ ok: args.query })) + const command = { + site: "demo", + name: "browser", + access: "read", + description: "demo", + browser: true, + domain: "example.com", + navigateBefore: "https://example.com", + args: [{ name: "query", required: true }], + func, + } satisfies CliCommand + + expect(await shouldRunOpenCliPreNav(command, typedPage, "ephemeral", "https://example.com")).toBe(true) + await expect(runOpenCliAdapterCommand(command, typedPage, { query: "pawwork" })).resolves.toEqual({ + ok: "pawwork", + }) + expect(page.goto).toHaveBeenCalledWith("https://example.com") + expect(func.mock.calls[0]?.[0]).not.toBe(page) + expect(func).toHaveBeenCalledWith(expect.objectContaining({ goto: expect.any(Function) }), { query: "pawwork" }, false) + }) + + test("asks browser permission before adapter-initiated navigation", async () => { + const adminUsersUrl = testUrl("/admin/users") + const page = { + goto: mock(async () => {}), + getCurrentUrl: mock(async () => "https://example.com/start"), + } + const command = { + site: "demo", + name: "nav", + access: "read", + description: "demo", + browser: true, + args: [], + func: async (adapterPage: IPage) => { + await adapterPage.goto(adminUsersUrl) + return "done" + }, + } satisfies CliCommand + const asked: string[][] = [] + + await expect( + runOpenCliAdapterCommand(command, page as unknown as IPage, {}, { + askBrowserPermission: async (patterns) => { + asked.push(patterns) + if (hasExactUrlPattern(patterns, adminUsersUrl)) throw new Error("denied admin") + }, + }), + ).rejects.toThrow("denied admin") + + expect(page.goto).not.toHaveBeenCalledWith(adminUsersUrl) + expect(asked).toContainEqual([adminUsersUrl]) + }) + + test("rechecks browser permission after adapter actions that can move the page", async () => { + const adminUsersUrl = testUrl("/admin/users") + let currentUrl = "https://example.com/safe" + const page = { + click: mock(async () => { + currentUrl = adminUsersUrl + return { matches_n: 1, match_level: "exact" as const } + }), + getCurrentUrl: mock(async () => currentUrl), + goto: mock(async () => {}), + } + const command = { + site: "demo", + name: "click", + access: "write", + description: "demo", + browser: true, + args: [], + func: async (adapterPage: IPage) => { + await adapterPage.click("button") + return "done" + }, + } satisfies CliCommand + + await expect( + runOpenCliAdapterCommand(command, page as unknown as IPage, {}, { + askBrowserPermission: async (patterns) => { + if (hasExactUrlPattern(patterns, adminUsersUrl)) throw new Error("denied admin") + }, + }), + ).rejects.toThrow("denied admin") + + expect(page.click).toHaveBeenCalled() + }) + + test("asks browser permission before adapter file uploads touch CDP", async () => { + const adminUploadUrl = testUrl("/admin/upload") + const cdp = mock(async () => ({})) + const page = { + cdp, + getCurrentUrl: mock(async () => adminUploadUrl), + goto: mock(async () => {}), + wait: mock(async () => {}), + } + const command = { + site: "demo", + name: "upload", + access: "write", + description: "demo", + browser: true, + args: [], + func: async (adapterPage: IPage) => { + await adapterPage.setFileInput?.(["/tmp/file.txt"]) + return "done" + }, + } satisfies CliCommand + + await expect( + runOpenCliAdapterCommand(command, page as unknown as IPage, {}, { + askBrowserPermission: async (patterns) => { + if (hasExactUrlPattern(patterns, adminUploadUrl)) throw new Error("denied admin") + }, + }), + ).rejects.toThrow("denied admin") + + expect(cdp).not.toHaveBeenCalled() + }) + + test("resets ephemeral browser commands after execution", async () => { + const page = { + goto: mock(async () => {}), + getCurrentUrl: mock(async () => "https://example.com/session"), + } + const command = { + site: "demo", + name: "ephemeral", + access: "read", + description: "demo", + browser: true, + args: [], + func: async () => "done", + } satisfies CliCommand + + await expect(runOpenCliAdapterCommand(command, page as unknown as IPage, {})).resolves.toBe("done") + + expect(page.goto).toHaveBeenCalledWith("about:blank") + }) + + test("keeps persistent browser commands on their page after execution", async () => { + const page = { + goto: mock(async () => {}), + getCurrentUrl: mock(async () => "https://example.com/session"), + } + const command = { + site: "demo", + name: "persistent", + access: "read", + description: "demo", + browser: true, + siteSession: "persistent", + args: [], + func: async () => "done", + } satisfies CliCommand + + await expect(runOpenCliAdapterCommand(command, page as unknown as IPage, {})).resolves.toBe("done") + + expect(page.goto).not.toHaveBeenCalled() + }) + + test("only skips persistent root pre-navigation on the same origin", async () => { + const command = { + site: "demo", + name: "persistent", + access: "read", + description: "demo", + browser: true, + domain: "example.com", + navigateBefore: "https://example.com", + siteSession: "persistent", + args: [], + func: async () => [], + } satisfies CliCommand + + async function shouldRun(currentUrl: string) { + const page = { getCurrentUrl: mock(async () => currentUrl) } as unknown as IPage + return await shouldRunOpenCliPreNav(command, page, "persistent", "https://example.com") + } + + expect(await shouldRun("https://example.com/dashboard")).toBe(false) + expect(await shouldRun("https://admin.example.com/dashboard")).toBe(true) + expect(await shouldRun("http://example.com/dashboard")).toBe(true) + }) + + test("adds CDP-backed upload and native text helpers when the visible page only exposes cdp", async () => { + const cdp = mock(async (method: string) => { + if (method === "DOM.getDocument") return { root: { nodeId: 1 } } + if (method === "DOM.querySelector") return { nodeId: 7 } + return {} + }) + const page = { + cdp, + wait: mock(async () => {}), + goto: mock(async () => {}), + getCurrentUrl: mock(async () => "about:blank"), + } as unknown as IPage + const command = { + site: "demo", + name: "upload", + access: "write", + description: "demo", + browser: true, + args: [], + func: async () => undefined, + } satisfies CliCommand + + const adapted = createOpenCliAdapterPage(command, page) + await adapted.setFileInput?.(["/tmp/pawwork.txt"], "input[type='file']") + await adapted.insertText?.("hello") + await (adapted as IPage & { nativeType?: (text: string) => Promise }).nativeType?.("native") + await (adapted as IPage & { nativeClick?: (x: number, y: number) => Promise }).nativeClick?.(10, 20) + await (adapted as IPage & { waitForTimeout?: (ms: number) => Promise }).waitForTimeout?.(5000) + + expect(cdp).toHaveBeenCalledWith("DOM.enable", {}) + expect(cdp).toHaveBeenCalledWith("DOM.getDocument", {}) + expect(cdp).toHaveBeenCalledWith("DOM.querySelector", { nodeId: 1, selector: "input[type='file']" }) + expect(cdp).toHaveBeenCalledWith("DOM.setFileInputFiles", { files: ["/tmp/pawwork.txt"], nodeId: 7 }) + expect(cdp).toHaveBeenCalledWith("Input.insertText", { text: "hello" }) + expect(cdp).toHaveBeenCalledWith("Input.insertText", { text: "native" }) + expect(cdp).toHaveBeenCalledWith("Input.dispatchMouseEvent", { type: "mouseMoved", x: 10, y: 20 }) + expect(cdp).toHaveBeenCalledWith("Input.dispatchMouseEvent", { + type: "mousePressed", + x: 10, + y: 20, + button: "left", + clickCount: 1, + }) + expect(cdp).toHaveBeenCalledWith("Input.dispatchMouseEvent", { + type: "mouseReleased", + x: 10, + y: 20, + button: "left", + clickCount: 1, + }) + expect(page.wait).toHaveBeenCalledWith(5) + }) + + test("does not synthesize optional network capture methods when unsupported", () => { + const page = { + goto: mock(async () => {}), + getCurrentUrl: mock(async () => "about:blank"), + } as unknown as IPage + const command = { + site: "demo", + name: "read", + access: "read", + description: "demo", + browser: true, + args: [], + func: async () => undefined, + } satisfies CliCommand + + const adapted = createOpenCliAdapterPage(command, page) + + expect(adapted.startNetworkCapture).toBeUndefined() + expect(adapted.readNetworkCapture).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/opencli/opencli-contract.test.ts b/packages/opencode/test/opencli/opencli-contract.test.ts new file mode 100644 index 000000000..a7456f6a7 --- /dev/null +++ b/packages/opencode/test/opencli/opencli-contract.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test" +import { getRegistry } from "@jackwener/opencli/registry" + +describe("opencli contract", () => { + test("shares the command registry through the process global", () => { + const globals = globalThis as typeof globalThis & { __opencli_registry__?: unknown } + const registry = globals.__opencli_registry__ + + expect(registry).toBeInstanceOf(Map) + if (!(registry instanceof Map)) throw new Error("OpenCLI registry global is not a Map") + expect(getRegistry()).toBe(registry) + }) +}) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index ecf653746..fa94432c9 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -487,9 +487,9 @@ test("disabled - does not disable when partially denied", () => { expect(result.has("bash")).toBe(false) }) -test("disabled - disables every browser_* tool when the browser key is denied", () => { +test("disabled - disables every browser-backed tool when the browser key is denied", () => { const result = Permission.disabled( - ["browser_navigate", "browser_click", "browser_extract", "bash"], + ["browser_navigate", "browser_click", "browser_extract", "opencli_search", "opencli_run", "bash"], [ { permission: "*", pattern: "*", action: "allow" }, { permission: "browser", pattern: "*", action: "deny" }, @@ -498,6 +498,8 @@ test("disabled - disables every browser_* tool when the browser key is denied", expect(result.has("browser_navigate")).toBe(true) expect(result.has("browser_click")).toBe(true) expect(result.has("browser_extract")).toBe(true) + expect(result.has("opencli_search")).toBe(true) + expect(result.has("opencli_run")).toBe(true) expect(result.has("bash")).toBe(false) }) diff --git a/packages/opencode/test/permission/pawwork-defaults.test.ts b/packages/opencode/test/permission/pawwork-defaults.test.ts index b6ee836f6..567b7a79d 100644 --- a/packages/opencode/test/permission/pawwork-defaults.test.ts +++ b/packages/opencode/test/permission/pawwork-defaults.test.ts @@ -39,6 +39,8 @@ test("build agent uses PawWork permission defaults", async () => { // defaults to allow — the embedded browser is local and fully visible, // which is the safety net; permission.browser rules tighten per URL. expect(Permission.evaluate("browser", "https://example.com/page", build!.permission).action).toBe("allow") + expect(Permission.evaluate("opencli_read", "chatgpt-app/read", build!.permission).action).toBe("ask") + expect(Permission.evaluate("opencli_write", "spotify/play", build!.permission).action).toBe("ask") expect(Permission.evaluate("question", "*", build!.permission).action).toBe("allow") expect(Permission.evaluate("plan_enter", "*", build!.permission).action).toBe("allow") expect(Permission.evaluate("plan_exit", "*", build!.permission).action).toBe("deny") diff --git a/packages/opencode/test/script/build-node.test.ts b/packages/opencode/test/script/build-node.test.ts index dfe17bc31..0d3218300 100644 --- a/packages/opencode/test/script/build-node.test.ts +++ b/packages/opencode/test/script/build-node.test.ts @@ -10,3 +10,11 @@ test("build-node injects both release version and channel defines", async () => expect(source).toContain("Script.version") expect(source).toContain("Script.channel") }) + +test("build-node externalizes OpenCLI so packaged adapter assets resolve from a real package root", async () => { + const source = await fs.readFile(path.join(import.meta.dir, "../../script/build-node.ts"), "utf8") + + expect(source).toContain("OPENCLI_EXTERNALS") + expect(source).toContain('"@jackwener/opencli/browser/cdp"') + expect(source).toContain("...OPENCLI_EXTERNALS") +}) diff --git a/packages/opencode/test/server/built-node-opencli-adapters.test.ts b/packages/opencode/test/server/built-node-opencli-adapters.test.ts new file mode 100644 index 000000000..6f01a6ce7 --- /dev/null +++ b/packages/opencode/test/server/built-node-opencli-adapters.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { pathToFileURL } from "node:url" +import { Process } from "../../src/util/process" +import { tmpdir } from "../fixture/fixture" +import { withEmbeddedServerArtifactLock } from "../shared/embedded-server-artifact-lock" +import { expectModelsSnapshotUnchanged, writeCurrentModelsFixture } from "./models-snapshot-fixture" + +const root = path.join(import.meta.dir, "../..") +const distEntry = path.join(root, "dist", "node", "node.js") + +describe("built node opencli adapters", () => { + test("loads bundled adapter manifest and clis from the built embedded server artifact", async () => { + await withEmbeddedServerArtifactLock(async () => { + await using tmp = await tmpdir() + const modelsFixture = writeCurrentModelsFixture(root, tmp.path) + const runtimeRoot = path.join(tmp.path, "runtime") + const runtimeHome = path.join(runtimeRoot, "home") + const isolatedEnv = { + ...process.env, + MODELS_DEV_API_JSON: modelsFixture.fixture, + HOME: runtimeHome, + USERPROFILE: runtimeHome, + XDG_DATA_HOME: path.join(runtimeRoot, "share"), + XDG_CACHE_HOME: path.join(runtimeRoot, "cache"), + XDG_CONFIG_HOME: path.join(runtimeRoot, "config"), + XDG_STATE_HOME: path.join(runtimeRoot, "state"), + OPENCODE_TEST_HOME: runtimeHome, + OPENCODE_TEST_MANAGED_CONFIG_DIR: path.join(runtimeRoot, "managed"), + OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", + OPENCODE_DB: ":memory:", + OPENCODE_CLIENT: "desktop", + } + + await Promise.all( + [ + isolatedEnv.HOME, + isolatedEnv.XDG_DATA_HOME, + isolatedEnv.XDG_CACHE_HOME, + isolatedEnv.XDG_CONFIG_HOME, + isolatedEnv.XDG_STATE_HOME, + isolatedEnv.OPENCODE_TEST_MANAGED_CONFIG_DIR, + ].map((dir) => fs.mkdir(dir, { recursive: true })), + ) + + await Process.run([process.execPath, "run", "build:embedded-server"], { + cwd: root, + env: isolatedEnv, + }) + expectModelsSnapshotUnchanged(modelsFixture) + + const script = ` + import { Effect } from "effect" + import { Instance, Log, ToolRegistry } from ${JSON.stringify(pathToFileURL(distEntry).href)} + + const directory = process.env.TEST_DIRECTORY + if (!directory) throw new Error("missing TEST_DIRECTORY") + + await Log.init({ level: "DEBUG", print: false }) + let exitCode = 0 + try { + const result = await Instance.provide({ + directory, + fn: async () => { + const tools = await ToolRegistry.tools({ + providerID: "openai", + modelID: "gpt-5", + agent: { name: "build", mode: "primary", permission: [], options: {} }, + activatedTools: new Set(["opencli_search", "opencli_run"]), + }) + const search = tools.find((tool) => tool.id === "opencli_search") + if (!search) throw new Error("opencli_search was not activated from the built registry") + const ctx = { + sessionID: "ses_built_opencli", + messageID: "msg_built_opencli", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + } + return Effect.runPromise(search.execute({ query: "12306 account", limit: 5 }, ctx)) + } + }) + console.log(JSON.stringify({ title: result.title, output: result.output, metadata: result.metadata })) + } catch (error) { + console.error(error instanceof Error ? error.stack : String(error)) + exitCode = 1 + } finally { + await Instance.disposeAll() + } + + await new Promise((resolve) => setTimeout(resolve, 50)) + process.exit(exitCode) + ` + + const result = await Process.run(["node", "--input-type=module", "-e", script], { + cwd: root, + env: { + ...isolatedEnv, + TEST_DIRECTORY: tmp.path, + }, + }) + const output = JSON.parse(result.stdout.toString("utf8")) + expect(output.title).toBe('OpenCLI commands for "12306 account"') + expect(output.output).toContain('') + expect(output.output).not.toContain("instagram/reel") + expect(output.metadata.count).toBeGreaterThan(0) + }) + }) +}) diff --git a/packages/opencode/test/tool/opencli-tools.test.ts b/packages/opencode/test/tool/opencli-tools.test.ts new file mode 100644 index 000000000..ed2c737ad --- /dev/null +++ b/packages/opencode/test/tool/opencli-tools.test.ts @@ -0,0 +1,584 @@ +import { describe, expect, test } from "bun:test" +import { cli, getRegistry } from "@jackwener/opencli/registry" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Cause, Deferred, Effect, Exit, Fiber, Layer, type Schema } from "effect" +import { Agent } from "../../src/agent/agent" +import { BrowserBridge } from "../../src/browser/browser-bridge" +import { resetBrowserSessionsForTest } from "../../src/browser/session" +import { provideTmpdirInstance } from "../fixture/fixture" +import { FakeCdpServer, provideFakeHost, scriptCurrentUrl } from "../fake/cdp-server" +import { testEffect } from "../lib/effect" +import { MessageID, SessionID } from "../../src/session/schema" +import { OpenCliRunTool } from "../../src/tool/opencli-run" +import { OpenCliSearchTool } from "../../src/tool/opencli-search" +import type * as Tool from "../../src/tool/tool" +import { Truncate } from "../../src/tool/truncate" + +const ctx: Tool.Context = { + sessionID: SessionID.make("ses_opencli_tools"), + messageID: MessageID.make("message"), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +type AnyToolEffect = Effect.Effect, Record>, never, never> + +const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer, CrossSpawnSpawner.defaultLayer)) + +function exec(tool: unknown, args: unknown, ctxOverride: Partial = {}) { + return provideTmpdirInstance(() => + (tool as AnyToolEffect).pipe( + Effect.flatMap((info) => info.init()), + Effect.flatMap((t) => t.execute(args as never, { ...ctx, ...ctxOverride } as never)), + ), + ) +} + +function testUrl(path: string, origin = "https://example.com") { + return new URL(path, origin).href +} + +function hasExactUrlPattern(patterns: readonly string[], expected: string) { + return patterns.some((pattern) => { + try { + return new URL(pattern).href === expected + } catch { + return false + } + }) +} + +describe("opencli_search", () => { + it.live("returns discoverable bundled adapter commands without blocked commands", () => + Effect.gen(function* () { + const result = yield* exec(OpenCliSearchTool, { query: "12306/me", limit: 5 }) + + expect(result.title).toBe('OpenCLI commands for "12306/me"') + expect(result.output).toContain('') + expect(result.output).toContain("browser: true") + expect(result.output).not.toContain("Warning:") + expect(result.output).not.toContain("instagram/reel") + expect(result.metadata).toMatchObject({ query: "12306/me" }) + expect(result.metadata).not.toHaveProperty("failedModuleCount") + }), + ) + + it.live("includes adapter argument metadata needed to run a command", () => + Effect.gen(function* () { + cli({ + site: "pawwork-test", + name: "metadata", + access: "read", + description: "Metadata test adapter", + browser: false, + args: [ + { name: "query", type: "string", required: true, help: "Search text" }, + { name: "limit", type: "int", default: 5, help: "Maximum results" }, + { name: "sort", choices: ["new", "top"], default: "new" }, + ], + func: async () => [], + }) + + try { + const result = yield* exec(OpenCliSearchTool, { query: "pawwork-test/metadata", limit: 1 }) + + expect(result.output).toContain('') + expect(result.output).toContain("- query (required) | type: string | help: Search text") + expect(result.output).toContain("- limit | type: int | default: 5 | help: Maximum results") + expect(result.output).toContain('- sort | choices: [new, top] | default: "new"') + } finally { + getRegistry().delete("pawwork-test/metadata") + } + }), + ) + + it.live("does not advertise non-browser write adapters", () => + Effect.gen(function* () { + cli({ + site: "pawwork-test", + name: "http-write-search", + access: "write", + description: "Write HTTP adapter should stay hidden", + browser: false, + args: [], + func: async () => [], + }) + + try { + const result = yield* exec(OpenCliSearchTool, { query: "pawwork-test/http-write-search", limit: 5 }) + + expect(result.output).not.toContain("pawwork-test/http-write-search") + } finally { + getRegistry().delete("pawwork-test/http-write-search") + } + }), + ) + +}) + +describe("opencli_run", () => { + it.live("asks for the current page when a browser command has no pre-navigation URL", () => + Effect.gen(function* () { + cli({ + site: "pawwork-test", + name: "current-page", + access: "write", + description: "Current page permission test adapter", + browser: true, + domain: "localhost", + navigateBefore: true, + args: [], + func: async () => [], + }) + BrowserBridge.provideHost({ + probeSession: async () => ({ url: "http://localhost:5173/codex" }), + resolveEndpoint: async () => { + throw new Error("no test endpoint") + }, + releaseSession: async () => {}, + disposeSession: async () => {}, + }) + + try { + const askLog: Parameters[0][] = [] + yield* exec(OpenCliRunTool, { command: "pawwork-test/current-page", args: {} }, { + ask: (input) => + Effect.sync(() => { + askLog.push(input) + }), + }).pipe(Effect.exit) + + expect(askLog[0]).toMatchObject({ + permission: "opencli_write", + patterns: ["pawwork-test/current-page"], + always: ["pawwork-test/current-page"], + }) + expect(askLog[1]).toMatchObject({ + permission: "browser", + patterns: ["http://localhost:5173/codex"], + }) + expect(askLog).toHaveLength(2) + } finally { + BrowserBridge.provideHost(null) + getRegistry().delete("pawwork-test/current-page") + } + }), + ) + + it.live("asks for concrete pre-navigation browser permission targets", () => + Effect.gen(function* () { + cli({ + site: "pawwork-test", + name: "browser-permission", + access: "read", + description: "Browser permission test adapter", + browser: true, + domain: "example.com", + navigateBefore: "https://auth.example.com/login", + args: [], + func: async () => [], + }) + BrowserBridge.provideHost({ + probeSession: async () => ({ url: "http://localhost:5173/codex" }), + resolveEndpoint: async () => { + throw new Error("no test endpoint") + }, + releaseSession: async () => {}, + disposeSession: async () => {}, + }) + + try { + const askLog: Parameters[0][] = [] + yield* exec(OpenCliRunTool, { command: "pawwork-test/browser-permission", args: {} }, { + ask: (input) => + Effect.sync(() => { + askLog.push(input) + }), + }).pipe(Effect.exit) + + expect(askLog[0]).toMatchObject({ + permission: "opencli_read", + patterns: ["pawwork-test/browser-permission"], + always: ["pawwork-test/browser-permission"], + }) + expect(askLog[1]).toMatchObject({ + permission: "browser", + patterns: ["https://auth.example.com/login", "https://example.com/"], + }) + expect(askLog).toHaveLength(2) + } finally { + BrowserBridge.provideHost(null) + getRegistry().delete("pawwork-test/browser-permission") + } + }), + ) + + it.live("applies browser permission to adapter-initiated navigation", () => + Effect.gen(function* () { + const server = new FakeCdpServer() + scriptCurrentUrl(server, "https://example.com/page") + provideFakeHost(server) + const adminUsersUrl = testUrl("/admin/users") + const events: string[] = [] + server.handlers.set("Page.navigate", (params) => { + const url = (params as { url?: string } | undefined)?.url + if (url) events.push(`navigate:${url}`) + return {} + }) + cli({ + site: "pawwork-test", + name: "internal-nav-permission", + access: "write", + description: "Internal navigation permission test adapter", + browser: true, + domain: "example.com", + navigateBefore: true, + args: [], + func: async (page) => { + await page.goto("https://example.com/admin/users") + return [] + }, + }) + + try { + yield* exec(OpenCliRunTool, { command: "pawwork-test/internal-nav-permission", args: {} }, { + ask: (input) => + Effect.sync(() => { + if (input.permission === "browser" && hasExactUrlPattern(input.patterns, adminUsersUrl)) { + events.push(`ask:${adminUsersUrl}`) + } + }), + }) + + const askIndex = events.indexOf(`ask:${adminUsersUrl}`) + const navigateIndex = events.indexOf(`navigate:${adminUsersUrl}`) + expect(askIndex).toBeGreaterThanOrEqual(0) + expect(navigateIndex).toBeGreaterThanOrEqual(0) + expect(askIndex).toBeLessThan(navigateIndex) + expect(server.navigatedUrls).toContain(adminUsersUrl) + expect(server.navigatedUrls).toContain("about:blank") + } finally { + resetBrowserSessionsForTest() + BrowserBridge.provideHost(null) + getRegistry().delete("pawwork-test/internal-nav-permission") + yield* Effect.promise(() => server.close()) + } + }), + ) + + it.live("resets ephemeral browser commands through the real tool path", () => + Effect.gen(function* () { + const server = new FakeCdpServer() + scriptCurrentUrl(server, "https://example.com/page") + provideFakeHost(server) + cli({ + site: "pawwork-test", + name: "ephemeral-reset", + access: "read", + description: "Ephemeral reset test adapter", + browser: true, + domain: "example.com", + args: [], + func: async () => [], + }) + + try { + const result = yield* exec(OpenCliRunTool, { command: "pawwork-test/ephemeral-reset", args: {} }) + + expect(result.title).toBe("OpenCLI pawwork-test/ephemeral-reset") + expect(server.navigatedUrls).toContain("about:blank") + } finally { + resetBrowserSessionsForTest() + BrowserBridge.provideHost(null) + getRegistry().delete("pawwork-test/ephemeral-reset") + yield* Effect.promise(() => server.close()) + } + }), + ) + + it.live("rechecks the landed URL after pre-navigation redirects", () => + Effect.gen(function* () { + const allowedUrl = testUrl("/login", "https://auth.example.com") + const redirectedUrl = testUrl("/blocked", "https://blocked.example") + const server = new FakeCdpServer() + scriptCurrentUrl(server, "https://example.com/page") + server.handlers.set("Page.navigate", () => { + server.url = redirectedUrl + return {} + }) + provideFakeHost(server) + let ran = false + cli({ + site: "pawwork-test", + name: "redirected-prenav", + access: "read", + description: "Redirected pre-navigation test adapter", + browser: true, + domain: "example.com", + navigateBefore: allowedUrl, + args: [], + func: async () => { + ran = true + return [] + }, + }) + + try { + const askLog: Parameters[0][] = [] + const exit = yield* exec(OpenCliRunTool, { command: "pawwork-test/redirected-prenav", args: {} }, { + ask: (input) => + Effect.sync(() => { + askLog.push(input) + if (input.permission === "browser" && hasExactUrlPattern(input.patterns, redirectedUrl)) { + throw new Error("denied redirect") + } + }), + }).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + expect(ran).toBe(false) + expect(askLog).toContainEqual( + expect.objectContaining({ + permission: "browser", + metadata: expect.objectContaining({ redirectedFrom: allowedUrl }), + }), + ) + } finally { + resetBrowserSessionsForTest() + BrowserBridge.provideHost(null) + getRegistry().delete("pawwork-test/redirected-prenav") + yield* Effect.promise(() => server.close()) + } + }), + ) + + it.live("formats an undefined browser write result as empty output", () => + Effect.gen(function* () { + const server = new FakeCdpServer() + scriptCurrentUrl(server, "https://example.com/page") + provideFakeHost(server) + cli({ + site: "pawwork-test", + name: "empty-browser-write", + access: "write", + description: "Empty browser write result test adapter", + browser: true, + domain: "example.com", + args: [], + func: async () => undefined, + }) + + try { + const result = yield* exec(OpenCliRunTool, { command: "pawwork-test/empty-browser-write", args: {} }) + + expect(result.title).toBe("OpenCLI pawwork-test/empty-browser-write") + expect(result.output).toBe("OpenCLI adapter returned no output.") + } finally { + resetBrowserSessionsForTest() + BrowserBridge.provideHost(null) + getRegistry().delete("pawwork-test/empty-browser-write") + yield* Effect.promise(() => server.close()) + } + }), + ) + + it.live("asks before running a read non-browser adapter", () => + Effect.gen(function* () { + cli({ + site: "pawwork-test", + name: "echo", + access: "read", + description: "Echo test adapter", + browser: false, + args: [{ name: "query", required: true }], + func: async (args) => [{ echoed: args.query }], + }) + + try { + const askLog: Parameters[0][] = [] + const result = yield* exec(OpenCliRunTool, { command: "pawwork-test/echo", args: { query: "hello" } }, { + ask: (input) => + Effect.sync(() => { + askLog.push(input) + }), + }) + + expect(result.title).toBe("OpenCLI pawwork-test/echo") + expect(result.output).toContain('"echoed": "hello"') + expect(result.metadata).toMatchObject({ command: "pawwork-test/echo", browser: false }) + expect(askLog[0]).toMatchObject({ + permission: "opencli_read", + patterns: ["pawwork-test/echo"], + always: ["pawwork-test/echo"], + }) + expect(askLog).toHaveLength(1) + } finally { + getRegistry().delete("pawwork-test/echo") + } + }), + ) + + it.live("rejects write non-browser adapters before asking or running", () => + Effect.gen(function* () { + let ran = false + cli({ + site: "pawwork-test", + name: "write-http", + access: "write", + description: "Write non-browser test adapter", + browser: false, + args: [{ name: "query", required: true }], + func: async (args) => { + ran = true + return [{ written: args.query }] + }, + }) + + try { + const askLog: Parameters[0][] = [] + const exit = yield* exec(OpenCliRunTool, { command: "pawwork-test/write-http", args: { query: "hello" } }, { + ask: (input) => + Effect.sync(() => { + askLog.push(input) + }), + }).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + expect(askLog).toEqual([]) + expect(ran).toBe(false) + } finally { + getRegistry().delete("pawwork-test/write-http") + } + }), + ) + + it.live("asks with defaulted args before running a write adapter", () => + Effect.gen(function* () { + cli({ + site: "pawwork-test", + name: "write-defaults", + access: "write", + description: "Write default args test adapter", + browser: true, + domain: "example.com", + args: [{ name: "mode", default: "safe" }], + func: async (_page, args) => [{ args }], + }) + const server = new FakeCdpServer() + scriptCurrentUrl(server, "https://example.com/page") + provideFakeHost(server) + + try { + const askLog: Parameters[0][] = [] + yield* exec(OpenCliRunTool, { command: "pawwork-test/write-defaults", args: {} }, { + ask: (input) => + Effect.sync(() => { + askLog.push(input) + }), + }) + + expect(askLog[0]).toMatchObject({ + permission: "opencli_write", + metadata: { + args: { mode: "safe" }, + }, + }) + } finally { + resetBrowserSessionsForTest() + BrowserBridge.provideHost(null) + getRegistry().delete("pawwork-test/write-defaults") + yield* Effect.promise(() => server.close()) + } + }), + ) + + it.live("does not ask for unknown or blocked commands", () => + Effect.gen(function* () { + for (const command of ["pawwork-test/missing", "instagram/reel"]) { + const askLog: Parameters[0][] = [] + const exit = yield* exec(OpenCliRunTool, { command, args: {} }, { + ask: (input) => + Effect.sync(() => { + askLog.push(input) + }), + }).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + expect(askLog).toEqual([]) + } + }), + ) + + it.live("aborts a non-browser adapter without waiting forever", () => + Effect.gen(function* () { + const started = yield* Deferred.make() + const controller = new AbortController() + cli({ + site: "pawwork-test", + name: "slow-http", + access: "read", + description: "Slow non-browser test adapter", + browser: false, + args: [], + func: async () => { + Effect.runFork(Deferred.succeed(started, undefined)) + return await new Promise(() => {}) + }, + }) + + try { + const fiber = yield* exec(OpenCliRunTool, { command: "pawwork-test/slow-http", args: {} }, { + abort: controller.signal, + }).pipe(Effect.forkChild) + yield* Deferred.await(started) + controller.abort() + const exit = yield* Fiber.await(fiber) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(error instanceof Error ? error.message : String(error)).toContain( + "OpenCLI pawwork-test/slow-http was canceled", + ) + } + } finally { + getRegistry().delete("pawwork-test/slow-http") + } + }), + ) + + it.live("does not start write non-browser adapters that would outlive cancellation", () => + Effect.gen(function* () { + const controller = new AbortController() + let started = false + cli({ + site: "pawwork-test", + name: "slow-write-http", + access: "write", + description: "Slow write non-browser test adapter", + browser: false, + args: [], + func: async () => { + started = true + return [] + }, + }) + + try { + const exit = yield* exec(OpenCliRunTool, { command: "pawwork-test/slow-write-http", args: {} }, { + abort: controller.signal, + }).pipe(Effect.exit) + controller.abort() + + expect(Exit.isFailure(exit)).toBe(true) + expect(started).toBe(false) + } finally { + getRegistry().delete("pawwork-test/slow-write-http") + } + }), + ) +}) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index bb51e4302..d9363a632 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -17,6 +17,7 @@ import type { MessageV2 } from "../../src/session/message-v2" import { LLM } from "../../src/session/llm" import * as EffectZod from "../../src/util/effect-zod" import { Npm } from "@opencode-ai/core/npm" +import { Permission } from "../../src/permission" afterEach(async () => { await Instance.disposeAll() @@ -1229,6 +1230,74 @@ describe("tool.registry", () => { } }) + test("defers opencli adapters to a desktop-only group", async () => { + await using tmp = await tmpdir() + const previousClient = process.env["OPENCODE_CLIENT"] + try { + delete process.env["OPENCODE_CLIENT"] + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tools = await ToolRegistry.tools({ + providerID: ProviderID.make("openai"), + modelID: ModelID.make("gpt-5"), + agent: { name: "build", mode: "primary", permission: [], options: {} }, + }) + const ids = tools.map((tool) => tool.id) + expect(ids).not.toContain("opencli_search") + expect(tools.find((tool) => tool.id === "tool_info")!.description).not.toContain("opencli") + }, + }) + + await Instance.disposeAll() + process.env["OPENCODE_CLIENT"] = "desktop" + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const deferred = await ToolRegistry.tools({ + providerID: ProviderID.make("openai"), + modelID: ModelID.make("gpt-5"), + agent: { name: "build", mode: "primary", permission: [], options: {} }, + }) + const deferredIds = deferred.map((tool) => tool.id) + expect(deferredIds).not.toContain("opencli_search") + expect(deferred.find((tool) => tool.id === "tool_info")!.description).toContain("**opencli**") + + const activated = await ToolRegistry.tools({ + providerID: ProviderID.make("openai"), + modelID: ModelID.make("gpt-5"), + agent: { name: "build", mode: "primary", permission: [], options: {} }, + activatedTools: new Set(deferredGroupMembers("opencli")), + }) + expect(activated.map((tool) => tool.id)).toContain("opencli_search") + + const browserDenied = [{ permission: "browser", pattern: "*", action: "deny" as const }] + const browserDeniedDeferredAvailable = (id: string) => !Permission.disabled([id], browserDenied).has(id) + const deniedDeferred = await ToolRegistry.tools({ + providerID: ProviderID.make("openai"), + modelID: ModelID.make("gpt-5"), + agent: { name: "build", mode: "primary", permission: [], options: {} }, + deferredAvailable: browserDeniedDeferredAvailable, + }) + expect(deniedDeferred.find((tool) => tool.id === "tool_info")!.description).not.toContain("**opencli**") + + const deniedActivated = await ToolRegistry.tools({ + providerID: ProviderID.make("openai"), + modelID: ModelID.make("gpt-5"), + agent: { name: "build", mode: "primary", permission: [], options: {} }, + activatedTools: new Set(deferredGroupMembers("opencli")), + deferredAvailable: browserDeniedDeferredAvailable, + }) + expect(deniedActivated.map((tool) => tool.id)).not.toContain("opencli_search") + expect(deniedActivated.map((tool) => tool.id)).not.toContain("opencli_run") + }, + }) + } finally { + if (previousClient === undefined) delete process.env["OPENCODE_CLIENT"] + else process.env["OPENCODE_CLIENT"] = previousClient + } + }) + test("tool_info hands back exactly the schema the activated tool will expose, untruncated", async () => { await using tmp = await tmpdir() await Instance.provide({ diff --git a/packages/opencode/test/tool/tool-info.test.ts b/packages/opencode/test/tool/tool-info.test.ts index 0954a3c06..9e9aa3f54 100644 --- a/packages/opencode/test/tool/tool-info.test.ts +++ b/packages/opencode/test/tool/tool-info.test.ts @@ -23,6 +23,7 @@ const BROWSER_TOOLS = [ "browser_screenshot", "browser_extract", ] +const OPENCLI_TOOLS = ["opencli_search", "opencli_run"] function toolPart( tool: string, @@ -38,12 +39,13 @@ function assistant(parts: unknown[]): MessageV2.WithParts { } describe("tool-info", () => { - test("DEFERRED_TOOL_IDS is exactly the worktree tools plus lsp plus the browser group", () => { + test("DEFERRED_TOOL_IDS is exactly the worktree tools plus lsp plus the browser and opencli groups", () => { expect([...DEFERRED_TOOL_IDS].sort()).toEqual( - [...BROWSER_TOOLS, "enter-worktree", "exit-worktree", "lsp"].sort(), + [...BROWSER_TOOLS, ...OPENCLI_TOOLS, "enter-worktree", "exit-worktree", "lsp"].sort(), ) - expect([...DEFERRED_GROUP_IDS]).toEqual(["browser"]) + expect([...DEFERRED_GROUP_IDS].sort()).toEqual(["browser", "opencli"].sort()) expect(deferredGroupMembers("browser").sort()).toEqual([...BROWSER_TOOLS].sort()) + expect(deferredGroupMembers("opencli").sort()).toEqual([...OPENCLI_TOOLS].sort()) }) test("deriveActivatedTools picks only completed tool_info calls for deferred tools", () => {