From 1c2c4c7ebfe7beae9b75598e3b3a98a44bd54ede Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 11 Jun 2026 21:56:13 +0800 Subject: [PATCH 01/44] feat(opencli): add adapter discovery search --- .../opencode/src/opencli/adapter-registry.ts | 131 ++++++++++++++++++ packages/opencode/src/tool/opencli-search.ts | 56 ++++++++ packages/opencode/src/tool/opencli-search.txt | 3 + packages/opencode/src/tool/registry.ts | 4 + packages/opencode/src/tool/tool-info.ts | 11 ++ .../test/opencli/adapter-registry.test.ts | 20 +++ .../opencode/test/tool/opencli-tools.test.ts | 46 ++++++ packages/opencode/test/tool/registry.test.ts | 48 +++++++ packages/opencode/test/tool/tool-info.test.ts | 8 +- 9 files changed, 324 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/src/opencli/adapter-registry.ts create mode 100644 packages/opencode/src/tool/opencli-search.ts create mode 100644 packages/opencode/src/tool/opencli-search.txt create mode 100644 packages/opencode/test/opencli/adapter-registry.test.ts create mode 100644 packages/opencode/test/tool/opencli-tools.test.ts diff --git a/packages/opencode/src/opencli/adapter-registry.ts b/packages/opencode/src/opencli/adapter-registry.ts new file mode 100644 index 000000000..390fbc4d1 --- /dev/null +++ b/packages/opencode/src/opencli/adapter-registry.ts @@ -0,0 +1,131 @@ +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 + 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 + args: OpenCliManifestEntry["args"] +} + +export const BLOCKED_OPENCLI_COMMANDS = new Set(["instagram/reel"]) + +let loadPromise: Promise<{ + manifestCount: number + canonicalCommands: ReadonlySet + exposedCommands: ReadonlySet +}> | undefined +let manifestCache: OpenCliManifestEntry[] | undefined + +function openCliPackageRoot() { + const cdp = fileURLToPath(import.meta.resolve("@jackwener/opencli/browser/cdp")) + return path.resolve(path.dirname(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 +} + +async function importAdapterModules(manifest: OpenCliManifestEntry[]) { + const root = openCliPackageRoot() + const uniqueModules = new Set(manifest.map((entry) => entry.modulePath)) + for (const modulePath of uniqueModules) { + await import(pathToFileURL(path.join(root, "clis", modulePath)).href) + } +} + +function canonicalCommandSet(): Set { + return new Set([...getRegistry().values()].map((cmd) => fullName(cmd))) +} + +export async function loadOpenCliAdapters() { + loadPromise ??= (async () => { + const manifest = await loadManifest() + await importAdapterModules(manifest) + const canonicalCommands = canonicalCommandSet() + const exposedCommands = new Set([...canonicalCommands].filter((name) => !BLOCKED_OPENCLI_COMMANDS.has(name))) + return { + manifestCount: manifest.length, + canonicalCommands, + exposedCommands, + } + })() + return loadPromise +} + +export async function openCliCommand(name: string): Promise { + await loadOpenCliAdapters() + if (BLOCKED_OPENCLI_COMMANDS.has(name)) return undefined + return getRegistry().get(name) +} + +function scoreCommand(command: CliCommand, query: string) { + const needle = query.trim().toLowerCase() + if (!needle) return 1 + const name = fullName(command).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 { + await loadOpenCliAdapters() + const limit = Math.min(Math.max(options.limit ?? 10, 1), 25) + return [...getRegistry().values()] + .filter((command, index, all) => all.findIndex((other) => fullName(other) === fullName(command)) === index) + .filter((command) => !BLOCKED_OPENCLI_COMMANDS.has(fullName(command))) + .map((command) => ({ command, score: scoreCommand(command, query) })) + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score || fullName(a.command).localeCompare(fullName(b.command))) + .slice(0, limit) + .map(({ command }) => ({ + name: fullName(command), + description: command.description ?? "", + access: command.access, + browser: command.browser !== false, + domain: command.domain, + args: command.args, + })) +} + +export function resetOpenCliAdaptersForTest() { + loadPromise = undefined + manifestCache = undefined +} diff --git a/packages/opencode/src/tool/opencli-search.ts b/packages/opencode/src/tool/opencli-search.ts new file mode 100644 index 000000000..103187342 --- /dev/null +++ b/packages/opencode/src/tool/opencli-search.ts @@ -0,0 +1,56 @@ +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import DESCRIPTION from "./opencli-search.txt" +import { searchOpenCliCommands } 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.", + }), +}) + +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 }) + const output = + results.length === 0 + ? "No bundled OpenCLI adapter commands matched this query." + : results + .map((command) => { + const args = (command.args ?? []) + .map((arg) => `${arg.name}${arg.required ? " (required)" : ""}`) + .join(", ") + return [ + ``, + `description: ${command.description || "No description"}`, + `access: ${command.access}`, + `browser: ${command.browser}`, + command.domain ? `domain: ${command.domain}` : undefined, + args ? `args: ${args}` : "args: none", + "", + ] + .filter(Boolean) + .join("\n") + }) + .join("\n\n") + return { + title: `OpenCLI commands for "${params.query}"`, + output, + 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..c9329797c 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -22,6 +22,7 @@ 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 * as Tool from "./tool" import { Config } from "../config/config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" @@ -169,6 +170,7 @@ export namespace ToolRegistry { const browserWait = yield* BrowserWaitTool const browserScreenshot = yield* BrowserScreenshotTool const browserExtract = yield* BrowserExtractTool + const openCliSearch = yield* OpenCliSearchTool const toolInfoInfo = yield* ToolInfoTool((toolID, output) => plugin.trigger("tool.definition", { toolID }, output), @@ -340,6 +342,7 @@ export namespace ToolRegistry { browserWait: Tool.init(browserWait), browserScreenshot: Tool.init(browserScreenshot), browserExtract: Tool.init(browserExtract), + openCliSearch: Tool.init(openCliSearch), }) return { @@ -379,6 +382,7 @@ export namespace ToolRegistry { tool.browserWait, tool.browserScreenshot, tool.browserExtract, + tool.openCliSearch, ] : []), ], diff --git a/packages/opencode/src/tool/tool-info.ts b/packages/opencode/src/tool/tool-info.ts index d97c69e13..9f831f073 100644 --- a/packages/opencode/src/tool/tool-info.ts +++ b/packages/opencode/src/tool/tool-info.ts @@ -21,6 +21,8 @@ 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 { ProviderTransform } from "../provider/transform" import type { Provider } from "../provider/provider" import type { MessageV2 } from "../session/message-v2" @@ -110,6 +112,13 @@ 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", + }, ] as const // One card per group in tool_info's listing; the member cards appear in the @@ -117,6 +126,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 to discover the right command.", } const BY_ID: Record = Object.fromEntries(DEFERRED.map((d) => [d.id, d])) 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..e26ea2d37 --- /dev/null +++ b/packages/opencode/test/opencli/adapter-registry.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test" +import { loadOpenCliAdapters, searchOpenCliCommands } from "../../src/opencli/adapter-registry" + +describe("opencli adapter registry", () => { + test("loads the packaged manifest and exposes searchable canonical commands", async () => { + const loaded = await loadOpenCliAdapters() + + expect(loaded.manifestCount).toBe(1050) + expect(loaded.canonicalCommands.has("12306/me")).toBe(true) + expect(loaded.canonicalCommands.has("hackernews/search")).toBe(true) + expect(loaded.exposedCommands.has("instagram/reel")).toBe(false) + + const results = await searchOpenCliCommands("12306 account", { limit: 5 }) + expect(results[0]).toMatchObject({ + name: "12306/me", + access: "read", + browser: true, + }) + }) +}) 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..c86aa7e3c --- /dev/null +++ b/packages/opencode/test/tool/opencli-tools.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Layer, type Schema } from "effect" +import { Agent } from "../../src/agent/agent" +import { Instance } from "../../src/project/instance" +import { MessageID, SessionID } from "../../src/session/schema" +import { OpenCliSearchTool } from "../../src/tool/opencli-search" +import type * as Tool from "../../src/tool/tool" +import { Truncate } from "../../src/tool/truncate" + +const ctx = { + 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> + +function exec(tool: unknown, args: unknown) { + return Instance.provide({ + directory: import.meta.dir, + fn: () => + (tool as AnyToolEffect).pipe( + Effect.flatMap((info) => info.init()), + Effect.flatMap((t) => t.execute(args as never, ctx as never)), + Effect.provide(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer)), + Effect.runPromise, + ), + }) +} + +describe("opencli_search", () => { + test("returns discoverable bundled adapter commands without blocked commands", async () => { + const result = await exec(OpenCliSearchTool, { query: "12306 account", limit: 5 }) + + expect(result.title).toBe('OpenCLI commands for "12306 account"') + expect(result.output).toContain('') + expect(result.output).toContain("browser: true") + expect(result.output).not.toContain("instagram/reel") + expect(result.metadata).toMatchObject({ query: "12306 account" }) + }) +}) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index bb51e4302..31568a61d 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -1229,6 +1229,54 @@ 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") + }, + }) + } 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..cd9016c4f 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"] 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", () => { From 223648cdf216e57e5bc93e7203498a429583db73 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 11 Jun 2026 22:04:44 +0800 Subject: [PATCH 02/44] feat(opencli): run adapter commands --- .../opencode/src/opencli/adapter-runner.ts | 137 ++++++++++++++++++ packages/opencode/src/tool/opencli-run.ts | 84 +++++++++++ packages/opencode/src/tool/opencli-run.txt | 3 + packages/opencode/src/tool/registry.ts | 4 + packages/opencode/src/tool/tool-info.ts | 11 +- .../test/opencli/adapter-runner.test.ts | 86 +++++++++++ .../opencode/test/tool/opencli-tools.test.ts | 26 ++++ packages/opencode/test/tool/tool-info.test.ts | 2 +- 8 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/opencli/adapter-runner.ts create mode 100644 packages/opencode/src/tool/opencli-run.ts create mode 100644 packages/opencode/src/tool/opencli-run.txt create mode 100644 packages/opencode/test/opencli/adapter-runner.test.ts diff --git a/packages/opencode/src/opencli/adapter-runner.ts b/packages/opencode/src/opencli/adapter-runner.ts new file mode 100644 index 000000000..301663fa1 --- /dev/null +++ b/packages/opencode/src/opencli/adapter-runner.ts @@ -0,0 +1,137 @@ +import { randomUUID } from "node:crypto" +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" + } +} + +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") { + 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 urlMatchesDomain(url: string | null | undefined, domain: string | undefined) { + if (!url || !domain) return false + try { + const hostname = new URL(url).hostname + return hostname === domain || hostname.endsWith(`.${domain}`) + } 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 !urlMatchesDomain(currentUrl, cmd.domain) +} + +export function resolveOpenCliSiteSession(cmd: CliCommand, override?: string): SiteSessionMode { + if (override === "ephemeral" || override === "persistent") return override + if (override !== undefined && override !== "") { + throw new OpenCliArgumentError(`siteSession must be one of: ephemeral, persistent. Received: "${override}"`) + } + return cmd.siteSession ?? "ephemeral" +} + +export function openCliAdapterSessionID(cmd: CliCommand, siteSession: SiteSessionMode) { + if (siteSession === "persistent") return `site:${cmd.site}` + return `site:${cmd.site}:${randomUUID()}` +} + +export async function runOpenCliAdapterCommand( + cmd: CliCommand, + page: IPage | null, + kwargs: CommandArgs, + options: { debug?: boolean; siteSession?: SiteSessionMode } = {}, +): Promise { + const debug = options.debug ?? false + const siteSession = options.siteSession ?? resolveOpenCliSiteSession(cmd) + const preNavUrl = resolveOpenCliPreNav(cmd) + if (preNavUrl) { + if (!page) throw new OpenCliCommandError(`Command ${fullName(cmd)} requires a browser session for pre-navigation`) + if (await shouldRunOpenCliPreNav(cmd, page, siteSession, preNavUrl)) { + await page.goto(preNavUrl) + } + } + if (cmd.func) { + if (cmd.browser === false) return cmd.func(kwargs, debug) + if (!page) throw new OpenCliCommandError(`Command ${fullName(cmd)} requires a browser session but none was provided`) + return cmd.func(page, kwargs, debug) + } + if (cmd.pipeline) return executePipeline(page, cmd.pipeline, { args: kwargs, debug }) + throw new OpenCliCommandError(`Command ${fullName(cmd)} has no func or pipeline`) +} diff --git a/packages/opencode/src/tool/opencli-run.ts b/packages/opencode/src/tool/opencli-run.ts new file mode 100644 index 000000000..837824b63 --- /dev/null +++ b/packages/opencode/src/tool/opencli-run.ts @@ -0,0 +1,84 @@ +import { Effect, Schema } from "effect" +import { fullName } from "@jackwener/opencli/registry" +import * as Tool from "./tool" +import DESCRIPTION from "./opencli-run.txt" +import { openCliCommand } from "@/opencli/adapter-registry" +import { prepareOpenCliCommandArgs, runOpenCliAdapterCommand } from "@/opencli/adapter-runner" +import { browserAlwaysPatterns } from "./browser-shared" +import { withBrowserPage } from "@/browser/session" + +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 commandPermissionPatterns(command: Awaited>): string[] { + if (!command) return ["*"] + if (typeof command.navigateBefore === "string") return [command.navigateBefore] + if (command.domain) return [`https://${command.domain}/*`] + return ["*"] +} + +function formatAdapterOutput(value: unknown): string { + if (typeof value === "string") return value + return JSON.stringify(value, null, 2) +} + +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 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, params.args ?? {}) + const patterns = commandPermissionPatterns(command) + yield* ctx.ask({ + permission: "browser", + patterns, + always: browserAlwaysPatterns(patterns), + metadata: { action: "opencli_run", command: fullName(command), browser: command.browser !== false }, + }) + + const value = command.browser === false + ? yield* Effect.tryPromise({ + try: () => runOpenCliAdapterCommand(command, null, args), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }) + : yield* Effect.tryPromise({ + try: () => + withBrowserPage(ctx.sessionID, `opencli ${fullName(command)}`, (page) => + runOpenCliAdapterCommand(command, page, args), + { timeoutMs: 60_000, abort: ctx.abort }, + ), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }) + + 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/registry.ts b/packages/opencode/src/tool/registry.ts index c9329797c..13c1e11b8 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -23,6 +23,7 @@ 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" @@ -171,6 +172,7 @@ export namespace ToolRegistry { 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), @@ -343,6 +345,7 @@ export namespace ToolRegistry { browserScreenshot: Tool.init(browserScreenshot), browserExtract: Tool.init(browserExtract), openCliSearch: Tool.init(openCliSearch), + openCliRun: Tool.init(openCliRun), }) return { @@ -383,6 +386,7 @@ export namespace ToolRegistry { 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 9f831f073..00e05c9e9 100644 --- a/packages/opencode/src/tool/tool-info.ts +++ b/packages/opencode/src/tool/tool-info.ts @@ -23,6 +23,8 @@ 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" @@ -119,6 +121,13 @@ const DEFERRED: DeferredEntry[] = [ 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 @@ -127,7 +136,7 @@ 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 to discover the right command.", + "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/opencli/adapter-runner.test.ts b/packages/opencode/test/opencli/adapter-runner.test.ts new file mode 100644 index 000000000..15ba5564b --- /dev/null +++ b/packages/opencode/test/opencli/adapter-runner.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, mock, test } from "bun:test" +import type { CliCommand } from "@jackwener/opencli/registry" +import type { IPage } from "@jackwener/opencli/types" +import { + prepareOpenCliCommandArgs, + runOpenCliAdapterCommand, + shouldRunOpenCliPreNav, +} from "../../src/opencli/adapter-runner" + +describe("opencli adapter runner", () => { + 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: "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).toHaveBeenCalledWith(page, { query: "pawwork" }, false) + }) +}) diff --git a/packages/opencode/test/tool/opencli-tools.test.ts b/packages/opencode/test/tool/opencli-tools.test.ts index c86aa7e3c..3004a32f7 100644 --- a/packages/opencode/test/tool/opencli-tools.test.ts +++ b/packages/opencode/test/tool/opencli-tools.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test" +import { cli, getRegistry } from "@jackwener/opencli/registry" import { Effect, Layer, type Schema } from "effect" import { Agent } from "../../src/agent/agent" import { Instance } from "../../src/project/instance" 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" @@ -44,3 +46,27 @@ describe("opencli_search", () => { expect(result.metadata).toMatchObject({ query: "12306 account" }) }) }) + +describe("opencli_run", () => { + test("runs a registered non-browser adapter through command and args", async () => { + 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 result = await exec(OpenCliRunTool, { command: "pawwork-test/echo", args: { query: "hello" } }) + + expect(result.title).toBe("OpenCLI pawwork-test/echo") + expect(result.output).toContain('"echoed": "hello"') + expect(result.metadata).toMatchObject({ command: "pawwork-test/echo", browser: false }) + } finally { + getRegistry().delete("pawwork-test/echo") + } + }) +}) diff --git a/packages/opencode/test/tool/tool-info.test.ts b/packages/opencode/test/tool/tool-info.test.ts index cd9016c4f..9e9aa3f54 100644 --- a/packages/opencode/test/tool/tool-info.test.ts +++ b/packages/opencode/test/tool/tool-info.test.ts @@ -23,7 +23,7 @@ const BROWSER_TOOLS = [ "browser_screenshot", "browser_extract", ] -const OPENCLI_TOOLS = ["opencli_search"] +const OPENCLI_TOOLS = ["opencli_search", "opencli_run"] function toolPart( tool: string, From fff3e448b5491e8a3b6b3e15700e143c64e39bd2 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 11 Jun 2026 22:12:53 +0800 Subject: [PATCH 03/44] feat(opencli): guard adapter page capabilities --- .../opencode/src/opencli/adapter-runner.ts | 85 ++++++++- .../test/opencli/adapter-capabilities.test.ts | 177 ++++++++++++++++++ .../test/opencli/adapter-runner.test.ts | 78 +++++++- 3 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/test/opencli/adapter-capabilities.test.ts diff --git a/packages/opencode/src/opencli/adapter-runner.ts b/packages/opencode/src/opencli/adapter-runner.ts index 301663fa1..27ada7383 100644 --- a/packages/opencode/src/opencli/adapter-runner.ts +++ b/packages/opencode/src/opencli/adapter-runner.ts @@ -16,6 +16,78 @@ export class OpenCliCommandError extends Error { } } +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 +} + +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): IPage { + return new Proxy(page, { + get(target, prop, receiver) { + if (prop === "then") return undefined + if (prop === "setFileInput" && typeof target.setFileInput !== "function" && typeof target.cdp === "function") { + return (files: string[], selector?: string) => cdpSetFileInput(cmd, target, files, selector) + } + if (prop === "insertText" && typeof target.insertText !== "function" && typeof target.cdp === "function") { + return (text: string) => cdpInsertText(cmd, target, text) + } + if (prop === "nativeType" && typeof target.cdp === "function") { + const value = Reflect.get(target, prop, receiver) + return typeof value === "function" ? value.bind(target) : (text: string) => cdpInsertText(cmd, target, text) + } + if (prop === "nativeClick" && typeof target.cdp === "function") { + const value = Reflect.get(target, prop, receiver) + return typeof value === "function" ? value.bind(target) : (x: number, y: number) => cdpNativeClick(cmd, target, x, y) + } + 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) + return typeof value === "function" ? value.bind(target) : value + }, + }) +} + export function coerceOpenCliArgs(cmdArgs: Arg[], kwargs: CommandArgs): CommandArgs { const result = { ...kwargs } for (const argDef of cmdArgs) { @@ -120,18 +192,19 @@ export async function runOpenCliAdapterCommand( ): Promise { const debug = options.debug ?? false const siteSession = options.siteSession ?? resolveOpenCliSiteSession(cmd) + const adapterPage = page ? createOpenCliAdapterPage(cmd, page) : null const preNavUrl = resolveOpenCliPreNav(cmd) if (preNavUrl) { - if (!page) throw new OpenCliCommandError(`Command ${fullName(cmd)} requires a browser session for pre-navigation`) - if (await shouldRunOpenCliPreNav(cmd, page, siteSession, preNavUrl)) { - await page.goto(preNavUrl) + if (!adapterPage) throw new OpenCliCommandError(`Command ${fullName(cmd)} requires a browser session for pre-navigation`) + if (await shouldRunOpenCliPreNav(cmd, adapterPage, siteSession, preNavUrl)) { + await adapterPage.goto(preNavUrl) } } if (cmd.func) { if (cmd.browser === false) return cmd.func(kwargs, debug) - if (!page) throw new OpenCliCommandError(`Command ${fullName(cmd)} requires a browser session but none was provided`) - return cmd.func(page, 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(page, cmd.pipeline, { args: kwargs, debug }) + if (cmd.pipeline) return executePipeline(adapterPage, cmd.pipeline, { args: kwargs, debug }) throw new OpenCliCommandError(`Command ${fullName(cmd)} has no func or pipeline`) } 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..2d3ff9d6a --- /dev/null +++ b/packages/opencode/test/opencli/adapter-capabilities.test.ts @@ -0,0 +1,177 @@ +import fs from "node:fs/promises" +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", + "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/post", + modulePath: "instagram/post.js", + kind: "page-method", + value: "closeWindow", + }, + { + 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")) + return path.resolve(path.dirname(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 unsupported page methods and daemon Page imports pinned to an explicit baseline", async () => { + const gaps = await scanCapabilities() + + expect(gaps).toEqual(ACCEPTED_CAPABILITY_GAPS) + 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-runner.test.ts b/packages/opencode/test/opencli/adapter-runner.test.ts index 15ba5564b..7bf3008ac 100644 --- a/packages/opencode/test/opencli/adapter-runner.test.ts +++ b/packages/opencode/test/opencli/adapter-runner.test.ts @@ -2,6 +2,7 @@ import { describe, expect, mock, test } from "bun:test" import type { CliCommand } from "@jackwener/opencli/registry" import type { IPage } from "@jackwener/opencli/types" import { + createOpenCliAdapterPage, prepareOpenCliCommandArgs, runOpenCliAdapterCommand, shouldRunOpenCliPreNav, @@ -81,6 +82,81 @@ describe("opencli adapter runner", () => { ok: "pawwork", }) expect(page.goto).toHaveBeenCalledWith("https://example.com") - expect(func).toHaveBeenCalledWith(page, { query: "pawwork" }, false) + expect(func.mock.calls[0]?.[0]).not.toBe(page) + expect(func).toHaveBeenCalledWith(expect.objectContaining({ goto: expect.any(Function) }), { query: "pawwork" }, false) + }) + + 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() }) }) From 5892e41d25d0331e239eaecd8b3b44d97aa59254 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 11 Jun 2026 22:26:53 +0800 Subject: [PATCH 04/44] build(opencli): package adapter runtime assets --- .../electron-builder-app-update.test.ts | 32 +++++ .../electron-builder.config.ts | 26 ++++ .../electron-vite.config.test.ts | 10 +- .../desktop-electron/electron.vite.config.ts | 19 ++- packages/opencode/script/build-node.ts | 20 +++- .../opencode/test/script/build-node.test.ts | 8 ++ .../built-node-opencli-adapters.test.ts | 112 ++++++++++++++++++ 7 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/test/server/built-node-opencli-adapters.test.ts diff --git a/packages/desktop-electron/electron-builder-app-update.test.ts b/packages/desktop-electron/electron-builder-app-update.test.ts index 54c9a29cc..641b60441 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" @@ -136,6 +138,36 @@ 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", + "@mozilla/readability", + "cli-table3", + "commander", + "js-yaml", + "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("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..76dad881b 100644 --- a/packages/desktop-electron/electron-builder.config.ts +++ b/packages/desktop-electron/electron-builder.config.ts @@ -16,6 +16,11 @@ const opencodePackage = requireFromOpencode("./package.json") as { dependencies?: Record devDependencies?: Record } +const openCliPackagePath = requireFromOpencode.resolve("@jackwener/opencli/package.json") +const requireFromOpenCli = createRequire(openCliPackagePath) +const openCliPackage = requireFromOpenCli("./package.json") as { + dependencies?: Record +} type Channel = "dev" | "beta" | "prod" const localizedMacDisplayNameByChannel: Record = { dev: "爪印 Dev", @@ -84,6 +89,26 @@ export function nativeWatcherFileSets() { })) } +export function openCliRuntimePackageNames() { + return ["@jackwener/opencli", ...Object.keys(openCliPackage.dependencies ?? {})] +} + +function openCliRuntimePackageDir(packageName: string) { + const resolver = packageName === "@jackwener/opencli" ? requireFromOpencode : requireFromOpenCli + return path.dirname(resolver.resolve(`${packageName}/package.json`)) +} + +export function openCliRuntimeFileSets() { + return openCliRuntimePackageNames().map((packageName) => ({ + from: openCliRuntimePackageDir(packageName), + to: path.join("node_modules", ...packageName.split("/")), + filter: + packageName === "@jackwener/opencli" + ? ["package.json", "README.md", "LICENSE", "cli-manifest.json", "clis/**/*", "dist/src/**/*"] + : ["**/*"], + })) +} + export function getPublishConfig(channel: Channel): GitHubPublishConfig | undefined { if (channel === "beta") return { provider: "github", owner: "Astro-Han", repo: "pawwork-beta", channel: "latest" } if (channel === "prod") return { provider: "github", owner: "Astro-Han", repo: "pawwork", channel: "latest" } @@ -118,6 +143,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..54ddba425 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,11 @@ test("renderer dedupes the ui workspace package", () => { expect(dedupe).toContain("@opencode-ai/ui") }) + +test("main build externalizes OpenCLI so adapter assets resolve from packaged resources", () => { + const source = readFileSync(path.join(import.meta.dir, "electron.vite.config.ts"), "utf8") + + expect(source).toContain("OPENCLI_EXTERNALS") + expect(source).toContain('"@jackwener/opencli/browser/cdp"') + expect(source).toContain("externalizeDeps: { include: [nodePtyPkg, ...OPENCLI_EXTERNALS] }") +}) diff --git a/packages/desktop-electron/electron.vite.config.ts b/packages/desktop-electron/electron.vite.config.ts index 773e0f64d..e0956681a 100644 --- a/packages/desktop-electron/electron.vite.config.ts +++ b/packages/desktop-electron/electron.vite.config.ts @@ -21,6 +21,23 @@ const buildSha = process.env.PAWWORK_BUILD_SHA ?? "" const OPENCODE_ROOT = path.resolve(process.cwd(), "../opencode") const { runtimeDir: OPENCODE_SERVER_DIST, runtimeEntry: OPENCODE_SERVER_ENTRY } = embeddedServerArtifacts(OPENCODE_ROOT) const missingArtifacts = embeddedServerMissingArtifacts(OPENCODE_ROOT, existsSync) +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", +] if (missingArtifacts.length > 0) { throw new Error(embeddedServerMissingArtifactsMessage(OPENCODE_ROOT, missingArtifacts)) @@ -40,7 +57,7 @@ export default defineConfig({ rollupOptions: { input: { index: "src/main/index.ts" }, }, - externalizeDeps: { include: [nodePtyPkg] }, + externalizeDeps: { include: [nodePtyPkg, ...OPENCLI_EXTERNALS] }, }, plugins: [ { 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/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) + }) + }) +}) From 5165bee73f9630278dbca7ddcffb174b22a75911 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 11 Jun 2026 22:46:03 +0800 Subject: [PATCH 05/44] fix(opencli): keep desktop adapter runtime resolvable --- .../electron-builder-app-update.test.ts | 7 ++ .../electron-builder.config.ts | 72 ++++++++++++++++--- .../electron-vite.config.test.ts | 7 +- .../desktop-electron/electron.vite.config.ts | 19 +---- 4 files changed, 72 insertions(+), 33 deletions(-) diff --git a/packages/desktop-electron/electron-builder-app-update.test.ts b/packages/desktop-electron/electron-builder-app-update.test.ts index 641b60441..8a29648f0 100644 --- a/packages/desktop-electron/electron-builder-app-update.test.ts +++ b/packages/desktop-electron/electron-builder-app-update.test.ts @@ -144,10 +144,17 @@ describe("electron builder app-update config", () => { 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", diff --git a/packages/desktop-electron/electron-builder.config.ts b/packages/desktop-electron/electron-builder.config.ts index 76dad881b..79f3378b6 100644 --- a/packages/desktop-electron/electron-builder.config.ts +++ b/packages/desktop-electron/electron-builder.config.ts @@ -1,4 +1,5 @@ import { execFile } from "node:child_process" +import { existsSync, readFileSync } from "node:fs" import { mkdir, writeFile } from "node:fs/promises" import { createRequire } from "node:module" import path from "node:path" @@ -16,9 +17,8 @@ const opencodePackage = requireFromOpencode("./package.json") as { dependencies?: Record devDependencies?: Record } -const openCliPackagePath = requireFromOpencode.resolve("@jackwener/opencli/package.json") -const requireFromOpenCli = createRequire(openCliPackagePath) -const openCliPackage = requireFromOpenCli("./package.json") as { +type PackageJson = { + name?: string dependencies?: Record } type Channel = "dev" | "beta" | "prod" @@ -89,21 +89,71 @@ export function nativeWatcherFileSets() { })) } -export function openCliRuntimePackageNames() { - return ["@jackwener/opencli", ...Object.keys(openCliPackage.dependencies ?? {})] +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 openCliRuntimePackageDir(packageName: string) { - const resolver = packageName === "@jackwener/opencli" ? requireFromOpencode : requireFromOpenCli +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`)) } +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) +} + export function openCliRuntimeFileSets() { - return openCliRuntimePackageNames().map((packageName) => ({ - from: openCliRuntimePackageDir(packageName), - to: path.join("node_modules", ...packageName.split("/")), + return openCliRuntimePackages().map((pkg) => ({ + from: pkg.dir, + to: path.join("node_modules", ...pkg.name.split("/")), filter: - packageName === "@jackwener/opencli" + pkg.name === "@jackwener/opencli" ? ["package.json", "README.md", "LICENSE", "cli-manifest.json", "clis/**/*", "dist/src/**/*"] : ["**/*"], })) diff --git a/packages/desktop-electron/electron-vite.config.test.ts b/packages/desktop-electron/electron-vite.config.test.ts index 54ddba425..7ebe0310c 100644 --- a/packages/desktop-electron/electron-vite.config.test.ts +++ b/packages/desktop-electron/electron-vite.config.test.ts @@ -16,10 +16,9 @@ test("renderer dedupes the ui workspace package", () => { expect(dedupe).toContain("@opencode-ai/ui") }) -test("main build externalizes OpenCLI so adapter assets resolve from packaged resources", () => { +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("OPENCLI_EXTERNALS") - expect(source).toContain('"@jackwener/opencli/browser/cdp"') - expect(source).toContain("externalizeDeps: { include: [nodePtyPkg, ...OPENCLI_EXTERNALS] }") + 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 e0956681a..773e0f64d 100644 --- a/packages/desktop-electron/electron.vite.config.ts +++ b/packages/desktop-electron/electron.vite.config.ts @@ -21,23 +21,6 @@ const buildSha = process.env.PAWWORK_BUILD_SHA ?? "" const OPENCODE_ROOT = path.resolve(process.cwd(), "../opencode") const { runtimeDir: OPENCODE_SERVER_DIST, runtimeEntry: OPENCODE_SERVER_ENTRY } = embeddedServerArtifacts(OPENCODE_ROOT) const missingArtifacts = embeddedServerMissingArtifacts(OPENCODE_ROOT, existsSync) -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", -] if (missingArtifacts.length > 0) { throw new Error(embeddedServerMissingArtifactsMessage(OPENCODE_ROOT, missingArtifacts)) @@ -57,7 +40,7 @@ export default defineConfig({ rollupOptions: { input: { index: "src/main/index.ts" }, }, - externalizeDeps: { include: [nodePtyPkg, ...OPENCLI_EXTERNALS] }, + externalizeDeps: { include: [nodePtyPkg] }, }, plugins: [ { From f24e6fda615ad613c563d7f1f948e696e61e0612 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 11 Jun 2026 22:46:09 +0800 Subject: [PATCH 06/44] fix(opencli): align adapter tools with browser permission --- packages/opencode/src/permission/index.ts | 12 +++++++---- packages/opencode/src/tool/opencli-run.ts | 16 +++++++------- .../opencode/test/permission/next.test.ts | 6 ++++-- .../opencode/test/tool/opencli-tools.test.ts | 15 +++++++++---- packages/opencode/test/tool/registry.test.ts | 21 +++++++++++++++++++ 5 files changed, 53 insertions(+), 17 deletions(-) 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 index 837824b63..12dfc4e78 100644 --- a/packages/opencode/src/tool/opencli-run.ts +++ b/packages/opencode/src/tool/opencli-run.ts @@ -46,13 +46,15 @@ export const OpenCliRunTool = Tool.define( ) } const args = prepareOpenCliCommandArgs(command, params.args ?? {}) - const patterns = commandPermissionPatterns(command) - yield* ctx.ask({ - permission: "browser", - patterns, - always: browserAlwaysPatterns(patterns), - metadata: { action: "opencli_run", command: fullName(command), browser: command.browser !== false }, - }) + if (command.browser !== false) { + const patterns = commandPermissionPatterns(command) + yield* ctx.ask({ + permission: "browser", + patterns, + always: browserAlwaysPatterns(patterns), + metadata: { action: "opencli_run", command: fullName(command), browser: true }, + }) + } const value = command.browser === false ? yield* Effect.tryPromise({ 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/tool/opencli-tools.test.ts b/packages/opencode/test/tool/opencli-tools.test.ts index 3004a32f7..47adb9985 100644 --- a/packages/opencode/test/tool/opencli-tools.test.ts +++ b/packages/opencode/test/tool/opencli-tools.test.ts @@ -9,7 +9,7 @@ import { OpenCliSearchTool } from "../../src/tool/opencli-search" import type * as Tool from "../../src/tool/tool" import { Truncate } from "../../src/tool/truncate" -const ctx = { +const ctx: Tool.Context = { sessionID: SessionID.make("ses_opencli_tools"), messageID: MessageID.make("message"), callID: "", @@ -22,13 +22,13 @@ const ctx = { type AnyToolEffect = Effect.Effect, Record>, never, never> -function exec(tool: unknown, args: unknown) { +function exec(tool: unknown, args: unknown, ctxOverride: Partial = {}) { return Instance.provide({ directory: import.meta.dir, fn: () => (tool as AnyToolEffect).pipe( Effect.flatMap((info) => info.init()), - Effect.flatMap((t) => t.execute(args as never, ctx as never)), + Effect.flatMap((t) => t.execute(args as never, { ...ctx, ...ctxOverride } as never)), Effect.provide(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer)), Effect.runPromise, ), @@ -60,11 +60,18 @@ describe("opencli_run", () => { }) try { - const result = await exec(OpenCliRunTool, { command: "pawwork-test/echo", args: { query: "hello" } }) + const askLog: Parameters[0][] = [] + const result = await 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).toEqual([]) } finally { getRegistry().delete("pawwork-test/echo") } diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 31568a61d..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() @@ -1269,6 +1270,26 @@ describe("tool.registry", () => { 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 { From 2248cce1c4486d06e40b132835d7bbdaccca6152 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 11 Jun 2026 22:55:18 +0800 Subject: [PATCH 07/44] fix(opencli): copy adapter runtime into desktop build --- .../electron-builder.config.ts | 78 +----------------- .../desktop-electron/electron.vite.config.ts | 11 +++ packages/desktop-electron/opencli-runtime.ts | 81 +++++++++++++++++++ .../src/main/embedded-server-build.test.ts | 4 + 4 files changed, 99 insertions(+), 75 deletions(-) create mode 100644 packages/desktop-electron/opencli-runtime.ts diff --git a/packages/desktop-electron/electron-builder.config.ts b/packages/desktop-electron/electron-builder.config.ts index 79f3378b6..10de406e9 100644 --- a/packages/desktop-electron/electron-builder.config.ts +++ b/packages/desktop-electron/electron-builder.config.ts @@ -1,5 +1,4 @@ import { execFile } from "node:child_process" -import { existsSync, readFileSync } from "node:fs" import { mkdir, writeFile } from "node:fs/promises" import { createRequire } from "node:module" import path from "node:path" @@ -8,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)), "../..") @@ -17,10 +19,6 @@ const opencodePackage = requireFromOpencode("./package.json") as { dependencies?: Record devDependencies?: Record } -type PackageJson = { - name?: string - dependencies?: Record -} type Channel = "dev" | "beta" | "prod" const localizedMacDisplayNameByChannel: Record = { dev: "爪印 Dev", @@ -89,76 +87,6 @@ export function nativeWatcherFileSets() { })) } -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`)) -} - -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) -} - -export function openCliRuntimeFileSets() { - return openCliRuntimePackages().map((pkg) => ({ - from: pkg.dir, - to: path.join("node_modules", ...pkg.name.split("/")), - filter: - pkg.name === "@jackwener/opencli" - ? ["package.json", "README.md", "LICENSE", "cli-manifest.json", "clis/**/*", "dist/src/**/*"] - : ["**/*"], - })) -} - export function getPublishConfig(channel: Channel): GitHubPublishConfig | undefined { if (channel === "beta") return { provider: "github", owner: "Astro-Han", repo: "pawwork-beta", channel: "latest" } if (channel === "prod") return { provider: "github", owner: "Astro-Han", repo: "pawwork", channel: "latest" } diff --git a/packages/desktop-electron/electron.vite.config.ts b/packages/desktop-electron/electron.vite.config.ts index 773e0f64d..e83083890 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 { openCliRuntimePackages } from "./opencli-runtime" const channel = (() => { const raw = process.env.OPENCODE_CHANNEL @@ -66,6 +67,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 fs.cp(pkg.dir, target, { recursive: true }) + } + }, + }, ], }, preload: { diff --git a/packages/desktop-electron/opencli-runtime.ts b/packages/desktop-electron/opencli-runtime.ts new file mode 100644 index 000000000..7226d2967 --- /dev/null +++ b/packages/desktop-electron/opencli-runtime.ts @@ -0,0 +1,81 @@ +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) +} + +export function openCliRuntimeFileSets() { + return openCliRuntimePackages().map((pkg) => ({ + from: pkg.dir, + to: path.join("node_modules", ...pkg.name.split("/")), + filter: + pkg.name === "@jackwener/opencli" + ? ["package.json", "README.md", "LICENSE", "cli-manifest.json", "clis/**/*", "dist/src/**/*"] + : ["**/*"], + })) +} 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..a2a3d692a 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,10 @@ 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", "@mixmark-io", "domino", "package.json"))).toBe(true) expectModelsSnapshotUnchanged(modelsFixture) }) From 2b854aeb8e18a74fc8d4b58cada52f6c19ef9fe0 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 11 Jun 2026 23:06:44 +0800 Subject: [PATCH 08/44] ci(opencli): cover adapter tests on windows --- .github/workflows/windows-advisory.yml | 1 + packages/opencode/test/github/ci-workflow.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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/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", }, { From a9eb255cfebfe17b418f4dcdc0a5d9a03002c34e Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 11 Jun 2026 23:46:37 +0800 Subject: [PATCH 09/44] build(opencli): prune non-runtime package files --- .../electron-builder-app-update.test.ts | 17 +++++ .../desktop-electron/electron.vite.config.ts | 26 +++++++- packages/desktop-electron/opencli-runtime.ts | 63 +++++++++++++++++-- .../src/main/embedded-server-build.test.ts | 10 +++ 4 files changed, 110 insertions(+), 6 deletions(-) diff --git a/packages/desktop-electron/electron-builder-app-update.test.ts b/packages/desktop-electron/electron-builder-app-update.test.ts index 8a29648f0..ad4376dd3 100644 --- a/packages/desktop-electron/electron-builder-app-update.test.ts +++ b/packages/desktop-electron/electron-builder-app-update.test.ts @@ -175,6 +175,23 @@ describe("electron builder app-update config", () => { ) }) + 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.vite.config.ts b/packages/desktop-electron/electron.vite.config.ts index e83083890..6c5408ace 100644 --- a/packages/desktop-electron/electron.vite.config.ts +++ b/packages/desktop-electron/electron.vite.config.ts @@ -9,7 +9,7 @@ import { embeddedServerMissingArtifactsMessage, } from "./src/main/embedded-server-contract" import { createRendererWorkspaceConfig } from "./renderer-workspace-config" -import { openCliRuntimePackages } from "./opencli-runtime" +import { includeOpenCliRuntimeDirectory, includeOpenCliRuntimeFile, openCliRuntimePackages } from "./opencli-runtime" const channel = (() => { const raw = process.env.OPENCODE_CHANNEL @@ -30,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: { @@ -73,7 +95,7 @@ export default defineConfig({ 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 fs.cp(pkg.dir, target, { recursive: true }) + await copyOpenCliRuntimePackage(pkg, target) } }, }, diff --git a/packages/desktop-electron/opencli-runtime.ts b/packages/desktop-electron/opencli-runtime.ts index 7226d2967..8b1058988 100644 --- a/packages/desktop-electron/opencli-runtime.ts +++ b/packages/desktop-electron/opencli-runtime.ts @@ -69,13 +69,68 @@ export function openCliRuntimePackageNames() { return openCliRuntimePackages().map((pkg) => pkg.name) } +const runtimeOnlyExcludes = [ + "!**/.yarn/**", + "!**/{test,tests,__tests__,coverage}/**", + "!**/*.{test,spec}.{js,mjs,cjs,ts,tsx}", +] + +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 (normalized === "clis/test-utils.js") return false + return ( + normalized === "package.json" || + normalized === "README.md" || + normalized === "LICENSE" || + normalized === "cli-manifest.json" || + normalized.startsWith("clis/") || + normalized.startsWith("dist/src/") + ) +} + +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 ( + normalized === "clis" || + normalized.startsWith("clis/") || + normalized === "dist" || + normalized === "dist/src" || + normalized.startsWith("dist/src/") + ) +} + +function openCliRuntimeFilter(packageName: string) { + const includes = + packageName === "@jackwener/opencli" + ? ["package.json", "README.md", "LICENSE", "cli-manifest.json", "clis/**/*", "dist/src/**/*"] + : ["**/*"] + const packageExcludes = packageName === "@jackwener/opencli" ? ["!clis/test-utils.js"] : [] + return [...includes, ...runtimeOnlyExcludes, ...packageExcludes] +} + export function openCliRuntimeFileSets() { return openCliRuntimePackages().map((pkg) => ({ from: pkg.dir, to: path.join("node_modules", ...pkg.name.split("/")), - filter: - pkg.name === "@jackwener/opencli" - ? ["package.json", "README.md", "LICENSE", "cli-manifest.json", "clis/**/*", "dist/src/**/*"] - : ["**/*"], + 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 a2a3d692a..70c558f50 100644 --- a/packages/desktop-electron/src/main/embedded-server-build.test.ts +++ b/packages/desktop-electron/src/main/embedded-server-build.test.ts @@ -65,7 +65,17 @@ test("electron-vite build copies required embedded server wasm sidecars into out 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) }) From fc095600a64ad1f2cb08e117738061a8750c7411 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 00:27:40 +0800 Subject: [PATCH 10/44] fix(opencli): reject blank numeric adapter args --- packages/opencode/src/opencli/adapter-runner.ts | 3 +++ packages/opencode/test/opencli/adapter-runner.test.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/opencode/src/opencli/adapter-runner.ts b/packages/opencode/src/opencli/adapter-runner.ts index 27ada7383..2e0efe843 100644 --- a/packages/opencode/src/opencli/adapter-runner.ts +++ b/packages/opencode/src/opencli/adapter-runner.ts @@ -97,6 +97,9 @@ export function coerceOpenCliArgs(cmdArgs: Arg[], kwargs: CommandArgs): CommandA } 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}"`) diff --git a/packages/opencode/test/opencli/adapter-runner.test.ts b/packages/opencode/test/opencli/adapter-runner.test.ts index 7bf3008ac..93ac0759d 100644 --- a/packages/opencode/test/opencli/adapter-runner.test.ts +++ b/packages/opencode/test/opencli/adapter-runner.test.ts @@ -37,6 +37,9 @@ describe("opencli adapter runner", () => { 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") }) From bff3c3c084103cfe8c2167ba650b94a652768320 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 00:29:06 +0800 Subject: [PATCH 11/44] fix(opencli): index implicit browser commands --- .../opencode/src/opencli/adapter-registry.ts | 2 +- .../test/opencli/adapter-registry.test.ts | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/opencli/adapter-registry.ts b/packages/opencode/src/opencli/adapter-registry.ts index 390fbc4d1..968ab77a2 100644 --- a/packages/opencode/src/opencli/adapter-registry.ts +++ b/packages/opencode/src/opencli/adapter-registry.ts @@ -91,7 +91,7 @@ function scoreCommand(command: CliCommand, query: string) { const needle = query.trim().toLowerCase() if (!needle) return 1 const name = fullName(command).toLowerCase() - const haystack = [name, command.description, command.domain, command.access, command.browser ? "browser" : "http"] + const haystack = [name, command.description, command.domain, command.access, command.browser !== false ? "browser" : "http"] .filter(Boolean) .join(" ") .toLowerCase() diff --git a/packages/opencode/test/opencli/adapter-registry.test.ts b/packages/opencode/test/opencli/adapter-registry.test.ts index e26ea2d37..df1b1d099 100644 --- a/packages/opencode/test/opencli/adapter-registry.test.ts +++ b/packages/opencode/test/opencli/adapter-registry.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { getRegistry, type CliCommand } from "@jackwener/opencli/registry" import { loadOpenCliAdapters, searchOpenCliCommands } from "../../src/opencli/adapter-registry" describe("opencli adapter registry", () => { @@ -17,4 +18,25 @@ describe("opencli adapter registry", () => { browser: 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") + } + }) }) From d4de5ae3d8cbe84c2b9b890d8fb9370dfa104940 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 00:30:00 +0800 Subject: [PATCH 12/44] fix(opencli): widen adapter browser permissions --- packages/opencode/src/tool/opencli-run.ts | 13 ++++++-- .../opencode/test/tool/opencli-tools.test.ts | 31 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/opencli-run.ts b/packages/opencode/src/tool/opencli-run.ts index 12dfc4e78..d621881c7 100644 --- a/packages/opencode/src/tool/opencli-run.ts +++ b/packages/opencode/src/tool/opencli-run.ts @@ -18,9 +18,16 @@ export const Parameters = Schema.Struct({ function commandPermissionPatterns(command: Awaited>): string[] { if (!command) return ["*"] - if (typeof command.navigateBefore === "string") return [command.navigateBefore] - if (command.domain) return [`https://${command.domain}/*`] - return ["*"] + const patterns: string[] = [] + if (typeof command.navigateBefore === "string") { + try { + patterns.push(`${new URL(command.navigateBefore).origin}/*`) + } catch { + patterns.push(command.navigateBefore) + } + } + if (command.domain) patterns.push(`https://${command.domain}/*`) + return patterns.length > 0 ? [...new Set(patterns)] : ["*"] } function formatAdapterOutput(value: unknown): string { diff --git a/packages/opencode/test/tool/opencli-tools.test.ts b/packages/opencode/test/tool/opencli-tools.test.ts index 47adb9985..835d210c4 100644 --- a/packages/opencode/test/tool/opencli-tools.test.ts +++ b/packages/opencode/test/tool/opencli-tools.test.ts @@ -48,6 +48,37 @@ describe("opencli_search", () => { }) describe("opencli_run", () => { + test("asks for origin and domain browser permissions for pre-navigation commands", async () => { + 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 () => [], + }) + + try { + const askLog: Parameters[0][] = [] + await exec(OpenCliRunTool, { command: "pawwork-test/browser-permission", args: {} }, { + ask: (input) => + Effect.sync(() => { + askLog.push(input) + }), + }).catch(() => undefined) + + expect(askLog[0]).toMatchObject({ + permission: "browser", + patterns: ["https://auth.example.com/*", "https://example.com/*"], + }) + } finally { + getRegistry().delete("pawwork-test/browser-permission") + } + }) + test("runs a registered non-browser adapter through command and args", async () => { cli({ site: "pawwork-test", From 85d2313d83ff77174bf3805dc9076b1429d6a38d Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 00:31:12 +0800 Subject: [PATCH 13/44] fix(opencli): describe adapter args in search --- packages/opencode/src/tool/opencli-search.ts | 13 ++++++--- .../opencode/test/tool/opencli-tools.test.ts | 27 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/opencli-search.ts b/packages/opencode/src/tool/opencli-search.ts index 103187342..cec73cc05 100644 --- a/packages/opencode/src/tool/opencli-search.ts +++ b/packages/opencode/src/tool/opencli-search.ts @@ -28,15 +28,22 @@ export const OpenCliSearchTool = Tool.define( : results .map((command) => { const args = (command.args ?? []) - .map((arg) => `${arg.name}${arg.required ? " (required)" : ""}`) - .join(", ") + .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: ${args}` : "args: none", + args ? `args:\n${args}` : "args: none", "", ] .filter(Boolean) diff --git a/packages/opencode/test/tool/opencli-tools.test.ts b/packages/opencode/test/tool/opencli-tools.test.ts index 835d210c4..a71fa2f04 100644 --- a/packages/opencode/test/tool/opencli-tools.test.ts +++ b/packages/opencode/test/tool/opencli-tools.test.ts @@ -45,6 +45,33 @@ describe("opencli_search", () => { expect(result.output).not.toContain("instagram/reel") expect(result.metadata).toMatchObject({ query: "12306 account" }) }) + + test("includes adapter argument metadata needed to run a command", async () => { + 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 = await 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") + } + }) }) describe("opencli_run", () => { From 4d0a4fae755b59aaa175a3e419ee57f54ee106d2 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 00:32:07 +0800 Subject: [PATCH 14/44] refactor(opencli): expose adapter namespaces --- packages/opencode/src/opencli/adapter-registry.ts | 2 ++ packages/opencode/src/opencli/adapter-runner.ts | 2 ++ packages/opencode/test/opencli/adapter-registry.test.ts | 6 +++++- packages/opencode/test/opencli/adapter-runner.test.ts | 5 +++++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/opencli/adapter-registry.ts b/packages/opencode/src/opencli/adapter-registry.ts index 968ab77a2..b795ce8f8 100644 --- a/packages/opencode/src/opencli/adapter-registry.ts +++ b/packages/opencode/src/opencli/adapter-registry.ts @@ -129,3 +129,5 @@ export function resetOpenCliAdaptersForTest() { loadPromise = undefined manifestCache = undefined } + +export * as AdapterRegistry from "./adapter-registry" diff --git a/packages/opencode/src/opencli/adapter-runner.ts b/packages/opencode/src/opencli/adapter-runner.ts index 2e0efe843..fd727690c 100644 --- a/packages/opencode/src/opencli/adapter-runner.ts +++ b/packages/opencode/src/opencli/adapter-runner.ts @@ -211,3 +211,5 @@ export async function runOpenCliAdapterCommand( if (cmd.pipeline) return executePipeline(adapterPage, cmd.pipeline, { args: kwargs, debug }) throw new OpenCliCommandError(`Command ${fullName(cmd)} has no func or pipeline`) } + +export * as AdapterRunner from "./adapter-runner" diff --git a/packages/opencode/test/opencli/adapter-registry.test.ts b/packages/opencode/test/opencli/adapter-registry.test.ts index df1b1d099..1095c21e4 100644 --- a/packages/opencode/test/opencli/adapter-registry.test.ts +++ b/packages/opencode/test/opencli/adapter-registry.test.ts @@ -1,8 +1,12 @@ import { describe, expect, test } from "bun:test" import { getRegistry, type CliCommand } from "@jackwener/opencli/registry" -import { loadOpenCliAdapters, searchOpenCliCommands } from "../../src/opencli/adapter-registry" +import { AdapterRegistry, loadOpenCliAdapters, searchOpenCliCommands } from "../../src/opencli/adapter-registry" describe("opencli adapter registry", () => { + test("exposes the module namespace export", () => { + expect(AdapterRegistry.searchOpenCliCommands).toBe(searchOpenCliCommands) + }) + test("loads the packaged manifest and exposes searchable canonical commands", async () => { const loaded = await loadOpenCliAdapters() diff --git a/packages/opencode/test/opencli/adapter-runner.test.ts b/packages/opencode/test/opencli/adapter-runner.test.ts index 93ac0759d..d5836341f 100644 --- a/packages/opencode/test/opencli/adapter-runner.test.ts +++ b/packages/opencode/test/opencli/adapter-runner.test.ts @@ -2,6 +2,7 @@ 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, @@ -9,6 +10,10 @@ import { } from "../../src/opencli/adapter-runner" 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", From ccbab975128e043fe89f5cf8693ae2d8737bc872 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 00:32:54 +0800 Subject: [PATCH 15/44] refactor(opencli): locate adapter package by manifest --- packages/opencode/src/opencli/adapter-registry.ts | 8 +++++++- .../opencode/test/opencli/adapter-capabilities.test.ts | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/opencli/adapter-registry.ts b/packages/opencode/src/opencli/adapter-registry.ts index b795ce8f8..71c1efe35 100644 --- a/packages/opencode/src/opencli/adapter-registry.ts +++ b/packages/opencode/src/opencli/adapter-registry.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs" import fs from "node:fs/promises" import path from "node:path" import { fileURLToPath, pathToFileURL } from "node:url" @@ -43,7 +44,12 @@ let manifestCache: OpenCliManifestEntry[] | undefined function openCliPackageRoot() { const cdp = fileURLToPath(import.meta.resolve("@jackwener/opencli/browser/cdp")) - return path.resolve(path.dirname(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 { diff --git a/packages/opencode/test/opencli/adapter-capabilities.test.ts b/packages/opencode/test/opencli/adapter-capabilities.test.ts index 2d3ff9d6a..43018a279 100644 --- a/packages/opencode/test/opencli/adapter-capabilities.test.ts +++ b/packages/opencode/test/opencli/adapter-capabilities.test.ts @@ -1,4 +1,5 @@ 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" @@ -73,7 +74,12 @@ const ACCEPTED_CAPABILITY_GAPS: CapabilityGap[] = [ function openCliPackageRoot() { const cdp = fileURLToPath(import.meta.resolve("@jackwener/opencli/browser/cdp")) - return path.resolve(path.dirname(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() { From 2c44257bcfeca1dc50cbd7cdd2c315ce0287a597 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 00:33:26 +0800 Subject: [PATCH 16/44] test(opencli): document accepted closeWindow gap --- packages/opencode/test/opencli/adapter-capabilities.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/test/opencli/adapter-capabilities.test.ts b/packages/opencode/test/opencli/adapter-capabilities.test.ts index 43018a279..abe8887f9 100644 --- a/packages/opencode/test/opencli/adapter-capabilities.test.ts +++ b/packages/opencode/test/opencli/adapter-capabilities.test.ts @@ -58,6 +58,8 @@ const SUPPORTED_PAGE_METHODS = new Set([ ]) const ACCEPTED_CAPABILITY_GAPS: CapabilityGap[] = [ + // instagram/post checks input.page.closeWindow before calling it; without the method, + // it falls back to dialog cleanup instead of failing the adapter command. { command: "instagram/post", modulePath: "instagram/post.js", From 04591b4d9f89a5ade4caf5243707083c9538eb14 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 00:35:07 +0800 Subject: [PATCH 17/44] test(opencli): use effect tool harness --- .../opencode/test/tool/opencli-tools.test.ts | 204 +++++++++--------- 1 file changed, 106 insertions(+), 98 deletions(-) diff --git a/packages/opencode/test/tool/opencli-tools.test.ts b/packages/opencode/test/tool/opencli-tools.test.ts index a71fa2f04..7f87ad834 100644 --- a/packages/opencode/test/tool/opencli-tools.test.ts +++ b/packages/opencode/test/tool/opencli-tools.test.ts @@ -1,8 +1,10 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import { cli, getRegistry } from "@jackwener/opencli/registry" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Effect, Layer, type Schema } from "effect" import { Agent } from "../../src/agent/agent" -import { Instance } from "../../src/project/instance" +import { provideTmpdirInstance } from "../fixture/fixture" +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" @@ -22,116 +24,122 @@ const ctx: Tool.Context = { 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 Instance.provide({ - directory: import.meta.dir, - fn: () => - (tool as AnyToolEffect).pipe( - Effect.flatMap((info) => info.init()), - Effect.flatMap((t) => t.execute(args as never, { ...ctx, ...ctxOverride } as never)), - Effect.provide(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer)), - Effect.runPromise, - ), - }) + return provideTmpdirInstance(() => + (tool as AnyToolEffect).pipe( + Effect.flatMap((info) => info.init()), + Effect.flatMap((t) => t.execute(args as never, { ...ctx, ...ctxOverride } as never)), + ), + ) } describe("opencli_search", () => { - test("returns discoverable bundled adapter commands without blocked commands", async () => { - const result = await exec(OpenCliSearchTool, { query: "12306 account", limit: 5 }) + it.live("returns discoverable bundled adapter commands without blocked commands", () => + Effect.gen(function* () { + const result = yield* exec(OpenCliSearchTool, { query: "12306 account", limit: 5 }) - expect(result.title).toBe('OpenCLI commands for "12306 account"') - expect(result.output).toContain('') - expect(result.output).toContain("browser: true") - expect(result.output).not.toContain("instagram/reel") - expect(result.metadata).toMatchObject({ query: "12306 account" }) - }) + expect(result.title).toBe('OpenCLI commands for "12306 account"') + expect(result.output).toContain('') + expect(result.output).toContain("browser: true") + expect(result.output).not.toContain("instagram/reel") + expect(result.metadata).toMatchObject({ query: "12306 account" }) + }), + ) - test("includes adapter argument metadata needed to run a command", async () => { - 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 () => [], - }) + 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 = await exec(OpenCliSearchTool, { query: "pawwork-test/metadata", limit: 1 }) + 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") - } - }) + 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") + } + }), + ) }) describe("opencli_run", () => { - test("asks for origin and domain browser permissions for pre-navigation commands", async () => { - 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 () => [], - }) - - try { - const askLog: Parameters[0][] = [] - await exec(OpenCliRunTool, { command: "pawwork-test/browser-permission", args: {} }, { - ask: (input) => - Effect.sync(() => { - askLog.push(input) - }), - }).catch(() => undefined) - - expect(askLog[0]).toMatchObject({ - permission: "browser", - patterns: ["https://auth.example.com/*", "https://example.com/*"], + it.live("asks for origin and domain browser permissions for pre-navigation commands", () => + 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 () => [], }) - } finally { - getRegistry().delete("pawwork-test/browser-permission") - } - }) - test("runs a registered non-browser adapter through command and args", async () => { - 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][] = [] + yield* exec(OpenCliRunTool, { command: "pawwork-test/browser-permission", args: {} }, { + ask: (input) => + Effect.sync(() => { + askLog.push(input) + }), + }).pipe(Effect.exit) + + expect(askLog[0]).toMatchObject({ + permission: "browser", + patterns: ["https://auth.example.com/*", "https://example.com/*"], + }) + } finally { + getRegistry().delete("pawwork-test/browser-permission") + } + }), + ) - try { - const askLog: Parameters[0][] = [] - const result = await exec(OpenCliRunTool, { command: "pawwork-test/echo", args: { query: "hello" } }, { - ask: (input) => - Effect.sync(() => { - askLog.push(input) - }), + it.live("runs a registered non-browser adapter through command and args", () => + 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 }], }) - 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).toEqual([]) - } finally { - getRegistry().delete("pawwork-test/echo") - } - }) + 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).toEqual([]) + } finally { + getRegistry().delete("pawwork-test/echo") + } + }), + ) }) From 56462220219be17a7246810186f7b7140c253634 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 00:50:05 +0800 Subject: [PATCH 18/44] fix(opencli): gate adapter side effects --- packages/opencode/src/tool/opencli-run.ts | 86 +++++++++++++++---- .../opencode/test/tool/opencli-tools.test.ts | 76 ++++++++++++++++ 2 files changed, 146 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/tool/opencli-run.ts b/packages/opencode/src/tool/opencli-run.ts index d621881c7..e4bd39d44 100644 --- a/packages/opencode/src/tool/opencli-run.ts +++ b/packages/opencode/src/tool/opencli-run.ts @@ -5,7 +5,7 @@ import DESCRIPTION from "./opencli-run.txt" import { openCliCommand } from "@/opencli/adapter-registry" import { prepareOpenCliCommandArgs, runOpenCliAdapterCommand } from "@/opencli/adapter-runner" import { browserAlwaysPatterns } from "./browser-shared" -import { withBrowserPage } from "@/browser/session" +import { browserPageProbe, withBrowserPage } from "@/browser/session" export const Parameters = Schema.Struct({ command: Schema.String.annotate({ @@ -16,18 +16,74 @@ export const Parameters = Schema.Struct({ }), }) -function commandPermissionPatterns(command: Awaited>): string[] { +function commandKnownBrowserPermissionPatterns(command: Awaited>): string[] { if (!command) return ["*"] + if (typeof command.navigateBefore !== "string") return [] const patterns: string[] = [] - if (typeof command.navigateBefore === "string") { - try { - patterns.push(`${new URL(command.navigateBefore).origin}/*`) - } catch { - patterns.push(command.navigateBefore) - } + try { + patterns.push(`${new URL(command.navigateBefore).origin}/*`) + } catch { + patterns.push(command.navigateBefore) } if (command.domain) patterns.push(`https://${command.domain}/*`) - return patterns.length > 0 ? [...new Set(patterns)] : ["*"] + return [...new Set(patterns)] +} + +function commandMetadata(command: NonNullable>>) { + return { + action: "opencli_run", + command: fullName(command), + browser: command.browser !== false, + access: command.access, + } +} + +function askBrowserPermission( + ctx: Tool.Context, + command: NonNullable>>, + patterns: string[], + metadata: Record = {}, +) { + return ctx.ask({ + permission: "browser", + patterns, + always: browserAlwaysPatterns(patterns), + metadata: { ...commandMetadata(command), ...metadata }, + }) +} + +function askCurrentBrowserPagePermission( + ctx: Tool.Context, + command: NonNullable>>, +) { + return Effect.gen(function* () { + const probe = yield* Effect.tryPromise({ + try: () => browserPageProbe(ctx.sessionID), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }) + const patterns = [probe.url ?? "*"] + yield* askBrowserPermission(ctx, command, patterns) + const recheck = yield* Effect.tryPromise({ + try: () => browserPageProbe(ctx.sessionID), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }) + if (recheck.url !== probe.url) { + yield* askBrowserPermission(ctx, command, [recheck.url ?? "*"], { movedFrom: probe.url ?? undefined }) + } + }) +} + +function askOpenCliWritePermission( + ctx: Tool.Context, + command: NonNullable>>, +) { + const commandName = fullName(command) + return ctx.ask({ + permission: "opencli_write", + patterns: [commandName], + always: [commandName], + metadata: commandMetadata(command), + }) } function formatAdapterOutput(value: unknown): string { @@ -54,13 +110,11 @@ export const OpenCliRunTool = Tool.define( } const args = prepareOpenCliCommandArgs(command, params.args ?? {}) if (command.browser !== false) { - const patterns = commandPermissionPatterns(command) - yield* ctx.ask({ - permission: "browser", - patterns, - always: browserAlwaysPatterns(patterns), - metadata: { action: "opencli_run", command: fullName(command), browser: true }, - }) + const patterns = commandKnownBrowserPermissionPatterns(command) + if (patterns.length > 0) yield* askBrowserPermission(ctx, command, patterns) + else yield* askCurrentBrowserPagePermission(ctx, command) + } else if (command.access === "write") { + yield* askOpenCliWritePermission(ctx, command) } const value = command.browser === false diff --git a/packages/opencode/test/tool/opencli-tools.test.ts b/packages/opencode/test/tool/opencli-tools.test.ts index 7f87ad834..8642236e1 100644 --- a/packages/opencode/test/tool/opencli-tools.test.ts +++ b/packages/opencode/test/tool/opencli-tools.test.ts @@ -3,6 +3,7 @@ import { cli, getRegistry } from "@jackwener/opencli/registry" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Effect, Layer, type Schema } from "effect" import { Agent } from "../../src/agent/agent" +import { BrowserBridge } from "../../src/browser/browser-bridge" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { MessageID, SessionID } from "../../src/session/schema" @@ -79,6 +80,48 @@ describe("opencli_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: "browser", + patterns: ["http://localhost:5173/codex"], + }) + } finally { + BrowserBridge.provideHost(null) + getRegistry().delete("pawwork-test/current-page") + } + }), + ) + it.live("asks for origin and domain browser permissions for pre-navigation commands", () => Effect.gen(function* () { cli({ @@ -142,4 +185,37 @@ describe("opencli_run", () => { } }), ) + + it.live("asks before running a write non-browser adapter", () => + Effect.gen(function* () { + 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) => [{ written: args.query }], + }) + + try { + const askLog: Parameters[0][] = [] + const result = yield* exec(OpenCliRunTool, { command: "pawwork-test/write-http", args: { query: "hello" } }, { + ask: (input) => + Effect.sync(() => { + askLog.push(input) + }), + }) + + expect(askLog[0]).toMatchObject({ + permission: "opencli_write", + patterns: ["pawwork-test/write-http"], + always: ["pawwork-test/write-http"], + }) + expect(result.output).toContain('"written": "hello"') + } finally { + getRegistry().delete("pawwork-test/write-http") + } + }), + ) }) From f28128ee68283322c1452a084efca1c72df37aa7 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 00:50:11 +0800 Subject: [PATCH 19/44] fix(opencli): tolerate failed adapter imports --- .../opencode/src/opencli/adapter-registry.ts | 30 ++++++++++++++++--- .../test/opencli/adapter-registry.test.ts | 28 ++++++++++++++++- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/opencli/adapter-registry.ts b/packages/opencode/src/opencli/adapter-registry.ts index 71c1efe35..7fa3de00a 100644 --- a/packages/opencode/src/opencli/adapter-registry.ts +++ b/packages/opencode/src/opencli/adapter-registry.ts @@ -33,12 +33,18 @@ export type OpenCliCommandSummary = { args: OpenCliManifestEntry["args"] } +export type OpenCliAdapterImportFailure = { + modulePath: string + error: string +} + export const BLOCKED_OPENCLI_COMMANDS = new Set(["instagram/reel"]) let loadPromise: Promise<{ manifestCount: number canonicalCommands: ReadonlySet exposedCommands: ReadonlySet + failedModules: OpenCliAdapterImportFailure[] }> | undefined let manifestCache: OpenCliManifestEntry[] | undefined @@ -60,12 +66,25 @@ async function loadManifest(): Promise { return manifestCache } -async function importAdapterModules(manifest: OpenCliManifestEntry[]) { - const root = openCliPackageRoot() +async function importAdapterModules( + manifest: OpenCliManifestEntry[], + options: { + root?: string + importModule?: (specifier: string) => Promise + } = {}, +) { + const root = options.root ?? openCliPackageRoot() + const importModule = options.importModule ?? ((specifier: string) => import(specifier)) const uniqueModules = new Set(manifest.map((entry) => entry.modulePath)) + const failedModules: OpenCliAdapterImportFailure[] = [] for (const modulePath of uniqueModules) { - await import(pathToFileURL(path.join(root, "clis", modulePath)).href) + try { + await importModule(pathToFileURL(path.join(root, "clis", modulePath)).href) + } catch (err) { + failedModules.push({ modulePath, error: err instanceof Error ? err.message : String(err) }) + } } + return failedModules } function canonicalCommandSet(): Set { @@ -75,18 +94,21 @@ function canonicalCommandSet(): Set { export async function loadOpenCliAdapters() { loadPromise ??= (async () => { const manifest = await loadManifest() - await importAdapterModules(manifest) + const failedModules = await importAdapterModules(manifest) const canonicalCommands = canonicalCommandSet() const exposedCommands = new Set([...canonicalCommands].filter((name) => !BLOCKED_OPENCLI_COMMANDS.has(name))) return { manifestCount: manifest.length, canonicalCommands, exposedCommands, + failedModules, } })() return loadPromise } +export const importOpenCliAdapterModulesForTest = importAdapterModules + export async function openCliCommand(name: string): Promise { await loadOpenCliAdapters() if (BLOCKED_OPENCLI_COMMANDS.has(name)) return undefined diff --git a/packages/opencode/test/opencli/adapter-registry.test.ts b/packages/opencode/test/opencli/adapter-registry.test.ts index 1095c21e4..4eaf1d2a8 100644 --- a/packages/opencode/test/opencli/adapter-registry.test.ts +++ b/packages/opencode/test/opencli/adapter-registry.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "bun:test" import { getRegistry, type CliCommand } from "@jackwener/opencli/registry" -import { AdapterRegistry, loadOpenCliAdapters, searchOpenCliCommands } from "../../src/opencli/adapter-registry" +import { + AdapterRegistry, + importOpenCliAdapterModulesForTest, + loadOpenCliAdapters, + searchOpenCliCommands, + type OpenCliManifestEntry, +} from "../../src/opencli/adapter-registry" describe("opencli adapter registry", () => { test("exposes the module namespace export", () => { @@ -21,6 +27,7 @@ describe("opencli adapter registry", () => { access: "read", browser: true, }) + expect(loaded.failedModules).toEqual([]) }) test("indexes commands with implicit browser support as browser commands", async () => { @@ -43,4 +50,23 @@ describe("opencli adapter registry", () => { getRegistry().delete("000-pawwork-implicit/implicit") } }) + + test("continues loading later adapter modules after one import fails", async () => { + const manifest = [ + { site: "bad", name: "fail", access: "read", type: "js", modulePath: "bad.js" }, + { site: "good", name: "ok", access: "read", type: "js", modulePath: "good.js" }, + ] satisfies OpenCliManifestEntry[] + const imported: string[] = [] + + const failures = await importOpenCliAdapterModulesForTest(manifest, { + root: "/opencli", + importModule: async (specifier) => { + imported.push(specifier) + if (specifier.endsWith("/bad.js")) throw new Error("bad module") + }, + }) + + expect(imported).toEqual(["file:///opencli/clis/bad.js", "file:///opencli/clis/good.js"]) + expect(failures).toEqual([{ modulePath: "bad.js", error: "bad module" }]) + }) }) From 2a2a673dc37af5d201ad6c6b5a7e9a6499c47937 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 01:00:39 +0800 Subject: [PATCH 20/44] fix(opencli): avoid permissive null permission fallback --- packages/opencode/src/tool/opencli-run.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/opencli-run.ts b/packages/opencode/src/tool/opencli-run.ts index e4bd39d44..7896f7424 100644 --- a/packages/opencode/src/tool/opencli-run.ts +++ b/packages/opencode/src/tool/opencli-run.ts @@ -17,7 +17,7 @@ export const Parameters = Schema.Struct({ }) function commandKnownBrowserPermissionPatterns(command: Awaited>): string[] { - if (!command) return ["*"] + if (!command) return [] if (typeof command.navigateBefore !== "string") return [] const patterns: string[] = [] try { From 756b6b686b51986ce7edc5da4f757ed9f8eefee8 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 01:04:08 +0800 Subject: [PATCH 21/44] fix(opencli): tighten adapter permission helper type --- packages/opencode/src/tool/opencli-run.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/opencode/src/tool/opencli-run.ts b/packages/opencode/src/tool/opencli-run.ts index 7896f7424..642565f9c 100644 --- a/packages/opencode/src/tool/opencli-run.ts +++ b/packages/opencode/src/tool/opencli-run.ts @@ -16,8 +16,7 @@ export const Parameters = Schema.Struct({ }), }) -function commandKnownBrowserPermissionPatterns(command: Awaited>): string[] { - if (!command) return [] +function commandKnownBrowserPermissionPatterns(command: NonNullable>>): string[] { if (typeof command.navigateBefore !== "string") return [] const patterns: string[] = [] try { From 6102e713cf5bfc3635fd8d38bacace8b02788027 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 01:17:19 +0800 Subject: [PATCH 22/44] test(opencli): normalize adapter import URL expectations --- packages/opencode/test/opencli/adapter-registry.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/opencli/adapter-registry.test.ts b/packages/opencode/test/opencli/adapter-registry.test.ts index 4eaf1d2a8..75ac5e942 100644 --- a/packages/opencode/test/opencli/adapter-registry.test.ts +++ b/packages/opencode/test/opencli/adapter-registry.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test" import { getRegistry, type CliCommand } from "@jackwener/opencli/registry" +import path from "path" +import { pathToFileURL } from "url" import { AdapterRegistry, importOpenCliAdapterModulesForTest, @@ -66,7 +68,10 @@ describe("opencli adapter registry", () => { }, }) - expect(imported).toEqual(["file:///opencli/clis/bad.js", "file:///opencli/clis/good.js"]) + expect(imported).toEqual([ + pathToFileURL(path.join("/opencli", "clis", "bad.js")).href, + pathToFileURL(path.join("/opencli", "clis", "good.js")).href, + ]) expect(failures).toEqual([{ modulePath: "bad.js", error: "bad module" }]) }) }) From d54b3b6f134eec57272186b778dbdd7902aacb87 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 13:48:56 +0800 Subject: [PATCH 23/44] fix(opencli): tighten adapter write guardrails --- packages/opencode/src/agent/agent.ts | 1 + .../opencode/src/opencli/adapter-runner.ts | 16 ++----- packages/opencode/src/tool/opencli-run.ts | 34 ++++++++++++-- .../test/permission/pawwork-defaults.test.ts | 1 + .../opencode/test/tool/opencli-tools.test.ts | 45 ++++++++++++++++++- 5 files changed, 80 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 104b562ff..49034d068 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -83,6 +83,7 @@ export namespace Agent { const defaults = Permission.fromConfig({ "*": "allow", doom_loop: "ask", + opencli_write: "ask", question: "deny", plan_enter: "deny", plan_exit: "deny", diff --git a/packages/opencode/src/opencli/adapter-runner.ts b/packages/opencode/src/opencli/adapter-runner.ts index fd727690c..6f638349d 100644 --- a/packages/opencode/src/opencli/adapter-runner.ts +++ b/packages/opencode/src/opencli/adapter-runner.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "node:crypto" import { executePipeline } from "@jackwener/opencli/pipeline" import { fullName, type Arg, type CliCommand, type CommandArgs, type IPage, type SiteSessionMode } from "@jackwener/opencli/registry" @@ -174,27 +173,18 @@ export async function shouldRunOpenCliPreNav( return !urlMatchesDomain(currentUrl, cmd.domain) } -export function resolveOpenCliSiteSession(cmd: CliCommand, override?: string): SiteSessionMode { - if (override === "ephemeral" || override === "persistent") return override - if (override !== undefined && override !== "") { - throw new OpenCliArgumentError(`siteSession must be one of: ephemeral, persistent. Received: "${override}"`) - } +function resolveOpenCliSiteSession(cmd: CliCommand): SiteSessionMode { return cmd.siteSession ?? "ephemeral" } -export function openCliAdapterSessionID(cmd: CliCommand, siteSession: SiteSessionMode) { - if (siteSession === "persistent") return `site:${cmd.site}` - return `site:${cmd.site}:${randomUUID()}` -} - export async function runOpenCliAdapterCommand( cmd: CliCommand, page: IPage | null, kwargs: CommandArgs, - options: { debug?: boolean; siteSession?: SiteSessionMode } = {}, + options: { debug?: boolean } = {}, ): Promise { const debug = options.debug ?? false - const siteSession = options.siteSession ?? resolveOpenCliSiteSession(cmd) + const siteSession = resolveOpenCliSiteSession(cmd) const adapterPage = page ? createOpenCliAdapterPage(cmd, page) : null const preNavUrl = resolveOpenCliPreNav(cmd) if (preNavUrl) { diff --git a/packages/opencode/src/tool/opencli-run.ts b/packages/opencode/src/tool/opencli-run.ts index 642565f9c..e12c0ce0a 100644 --- a/packages/opencode/src/tool/opencli-run.ts +++ b/packages/opencode/src/tool/opencli-run.ts @@ -7,6 +7,8 @@ import { prepareOpenCliCommandArgs, runOpenCliAdapterCommand } from "@/opencli/a import { browserAlwaysPatterns } from "./browser-shared" import { browserPageProbe, withBrowserPage } from "@/browser/session" +const OPENCLI_RUN_TIMEOUT_MS = 60_000 + export const Parameters = Schema.Struct({ command: Schema.String.annotate({ description: "Exact OpenCLI adapter command name, for example 'hackernews/search' or '12306/me'.", @@ -90,6 +92,33 @@ function formatAdapterOutput(value: unknown): string { return JSON.stringify(value, null, 2) } +async function runNonBrowserCommand( + command: NonNullable>>, + 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) + } +} + export const OpenCliRunTool = Tool.define( "opencli_run", Effect.gen(function* () { @@ -108,17 +137,16 @@ export const OpenCliRunTool = Tool.define( ) } const args = prepareOpenCliCommandArgs(command, params.args ?? {}) + if (command.access === "write") yield* askOpenCliWritePermission(ctx, command) if (command.browser !== false) { const patterns = commandKnownBrowserPermissionPatterns(command) if (patterns.length > 0) yield* askBrowserPermission(ctx, command, patterns) else yield* askCurrentBrowserPagePermission(ctx, command) - } else if (command.access === "write") { - yield* askOpenCliWritePermission(ctx, command) } const value = command.browser === false ? yield* Effect.tryPromise({ - try: () => runOpenCliAdapterCommand(command, null, args), + try: () => runNonBrowserCommand(command, args, ctx.abort), catch: (err) => (err instanceof Error ? err : new Error(String(err))), }) : yield* Effect.tryPromise({ diff --git a/packages/opencode/test/permission/pawwork-defaults.test.ts b/packages/opencode/test/permission/pawwork-defaults.test.ts index b6ee836f6..8a6456d2f 100644 --- a/packages/opencode/test/permission/pawwork-defaults.test.ts +++ b/packages/opencode/test/permission/pawwork-defaults.test.ts @@ -39,6 +39,7 @@ 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_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/tool/opencli-tools.test.ts b/packages/opencode/test/tool/opencli-tools.test.ts index 8642236e1..3621ff2c5 100644 --- a/packages/opencode/test/tool/opencli-tools.test.ts +++ b/packages/opencode/test/tool/opencli-tools.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { cli, getRegistry } from "@jackwener/opencli/registry" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Effect, Layer, type Schema } from "effect" +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 { provideTmpdirInstance } from "../fixture/fixture" @@ -112,6 +112,11 @@ describe("opencli_run", () => { }).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"], }) @@ -218,4 +223,42 @@ describe("opencli_run", () => { } }), ) + + 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") + } + }), + ) }) From c8a3204c29736878983a3f960712e3f8aae1c4a7 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 13:59:43 +0800 Subject: [PATCH 24/44] fix(opencli): surface adapter load failures --- packages/opencode/src/tool/opencli-search.ts | 83 ++++++++++++------- .../opencode/test/tool/opencli-tools.test.ts | 23 ++++- 2 files changed, 73 insertions(+), 33 deletions(-) diff --git a/packages/opencode/src/tool/opencli-search.ts b/packages/opencode/src/tool/opencli-search.ts index cec73cc05..f92c06b71 100644 --- a/packages/opencode/src/tool/opencli-search.ts +++ b/packages/opencode/src/tool/opencli-search.ts @@ -1,7 +1,12 @@ import { Effect, Schema } from "effect" import * as Tool from "./tool" import DESCRIPTION from "./opencli-search.txt" -import { searchOpenCliCommands } from "@/opencli/adapter-registry" +import { + loadOpenCliAdapters, + searchOpenCliCommands, + type OpenCliAdapterImportFailure, + type OpenCliCommandSummary, +} from "@/opencli/adapter-registry" export const Parameters = Schema.Struct({ query: Schema.String.annotate({ @@ -12,6 +17,49 @@ export const Parameters = Schema.Struct({ }), }) +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") +} + +function formatAdapterFailureWarning(failedModules: OpenCliAdapterImportFailure[]) { + if (failedModules.length === 0) return undefined + const sample = failedModules.slice(0, 3).map((failure) => failure.modulePath) + const more = failedModules.length > sample.length ? `, and ${failedModules.length - sample.length} more` : "" + const noun = failedModules.length === 1 ? "module" : "modules" + return `Warning: ${failedModules.length} OpenCLI adapter ${noun} failed to load (${sample.join(", ")}${more}); some commands may be missing.` +} + +export function formatOpenCliSearchOutput( + results: OpenCliCommandSummary[], + failedModules: OpenCliAdapterImportFailure[] = [], +) { + const body = + results.length === 0 + ? "No bundled OpenCLI adapter commands matched this query." + : results.map(formatOpenCliCommand).join("\n\n") + return [body, formatAdapterFailureWarning(failedModules)].filter(Boolean).join("\n\n") +} + export const OpenCliSearchTool = Tool.define( "opencli_search", Effect.gen(function* () { @@ -21,39 +69,12 @@ export const OpenCliSearchTool = Tool.define( execute: (params: Schema.Schema.Type) => Effect.tryPromise({ try: async () => { + const loaded = await loadOpenCliAdapters() const results = await searchOpenCliCommands(params.query, { limit: params.limit }) - const output = - results.length === 0 - ? "No bundled OpenCLI adapter commands matched this query." - : results - .map((command) => { - 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") - }) - .join("\n\n") return { title: `OpenCLI commands for "${params.query}"`, - output, - metadata: { query: params.query, count: results.length }, + output: formatOpenCliSearchOutput(results, loaded.failedModules), + metadata: { query: params.query, count: results.length, failedModuleCount: loaded.failedModules.length }, } }, catch: (err) => (err instanceof Error ? err : new Error(String(err))), diff --git a/packages/opencode/test/tool/opencli-tools.test.ts b/packages/opencode/test/tool/opencli-tools.test.ts index 3621ff2c5..e135ee31e 100644 --- a/packages/opencode/test/tool/opencli-tools.test.ts +++ b/packages/opencode/test/tool/opencli-tools.test.ts @@ -1,4 +1,4 @@ -import { describe, expect } from "bun:test" +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" @@ -8,7 +8,7 @@ import { provideTmpdirInstance } from "../fixture/fixture" 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 { formatOpenCliSearchOutput, OpenCliSearchTool } from "../../src/tool/opencli-search" import type * as Tool from "../../src/tool/tool" import { Truncate } from "../../src/tool/truncate" @@ -77,6 +77,25 @@ describe("opencli_search", () => { } }), ) + + test("shows adapter load failures after search results", () => { + const output = formatOpenCliSearchOutput( + [ + { + name: "pawwork-test/search", + description: "Search test adapter", + access: "read", + browser: false, + args: [], + }, + ], + [{ modulePath: "broken.js", error: "boom" }], + ) + + expect(output).toContain('') + expect(output).toContain("Warning: 1 OpenCLI adapter module failed to load") + expect(output).toContain("broken.js") + }) }) describe("opencli_run", () => { From 4e211eb33d016c9a4ab10333c3933603c44db71d Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 14:01:15 +0800 Subject: [PATCH 25/44] refactor(opencli): reuse browser action runner --- packages/opencode/src/tool/opencli-run.ts | 76 +++++-------------- .../test/opencli/adapter-registry.test.ts | 2 +- .../opencode/test/tool/opencli-tools.test.ts | 9 +++ 3 files changed, 29 insertions(+), 58 deletions(-) diff --git a/packages/opencode/src/tool/opencli-run.ts b/packages/opencode/src/tool/opencli-run.ts index e12c0ce0a..6377530f4 100644 --- a/packages/opencode/src/tool/opencli-run.ts +++ b/packages/opencode/src/tool/opencli-run.ts @@ -4,10 +4,10 @@ import * as Tool from "./tool" import DESCRIPTION from "./opencli-run.txt" import { openCliCommand } from "@/opencli/adapter-registry" import { prepareOpenCliCommandArgs, runOpenCliAdapterCommand } from "@/opencli/adapter-runner" -import { browserAlwaysPatterns } from "./browser-shared" -import { browserPageProbe, withBrowserPage } from "@/browser/session" +import { runBrowserAction } from "./browser-shared" const OPENCLI_RUN_TIMEOUT_MS = 60_000 +type OpenCliCommand = NonNullable>> export const Parameters = Schema.Struct({ command: Schema.String.annotate({ @@ -18,7 +18,7 @@ export const Parameters = Schema.Struct({ }), }) -function commandKnownBrowserPermissionPatterns(command: NonNullable>>): string[] { +function commandKnownBrowserPermissionPatterns(command: OpenCliCommand): string[] { if (typeof command.navigateBefore !== "string") return [] const patterns: string[] = [] try { @@ -30,7 +30,7 @@ function commandKnownBrowserPermissionPatterns(command: NonNullable>>) { +function commandMetadata(command: OpenCliCommand) { return { action: "opencli_run", command: fullName(command), @@ -39,45 +39,7 @@ function commandMetadata(command: NonNullable>>, - patterns: string[], - metadata: Record = {}, -) { - return ctx.ask({ - permission: "browser", - patterns, - always: browserAlwaysPatterns(patterns), - metadata: { ...commandMetadata(command), ...metadata }, - }) -} - -function askCurrentBrowserPagePermission( - ctx: Tool.Context, - command: NonNullable>>, -) { - return Effect.gen(function* () { - const probe = yield* Effect.tryPromise({ - try: () => browserPageProbe(ctx.sessionID), - catch: (err) => (err instanceof Error ? err : new Error(String(err))), - }) - const patterns = [probe.url ?? "*"] - yield* askBrowserPermission(ctx, command, patterns) - const recheck = yield* Effect.tryPromise({ - try: () => browserPageProbe(ctx.sessionID), - catch: (err) => (err instanceof Error ? err : new Error(String(err))), - }) - if (recheck.url !== probe.url) { - yield* askBrowserPermission(ctx, command, [recheck.url ?? "*"], { movedFrom: probe.url ?? undefined }) - } - }) -} - -function askOpenCliWritePermission( - ctx: Tool.Context, - command: NonNullable>>, -) { +function askOpenCliWritePermission(ctx: Tool.Context, command: OpenCliCommand) { const commandName = fullName(command) return ctx.ask({ permission: "opencli_write", @@ -93,7 +55,7 @@ function formatAdapterOutput(value: unknown): string { } async function runNonBrowserCommand( - command: NonNullable>>, + command: OpenCliCommand, args: Record, abort: AbortSignal, ) { @@ -119,6 +81,18 @@ async function runNonBrowserCommand( } } +function runBrowserCommand(command: OpenCliCommand, args: Record, ctx: Tool.Context) { + const patterns = commandKnownBrowserPermissionPatterns(command) + return runBrowserAction({ + ctx, + label: `opencli ${fullName(command)}`, + patterns: patterns.length > 0 ? patterns : undefined, + metadata: commandMetadata(command), + timeoutMs: OPENCLI_RUN_TIMEOUT_MS, + run: (page) => runOpenCliAdapterCommand(command, page, args), + }) +} + export const OpenCliRunTool = Tool.define( "opencli_run", Effect.gen(function* () { @@ -138,25 +112,13 @@ export const OpenCliRunTool = Tool.define( } const args = prepareOpenCliCommandArgs(command, params.args ?? {}) if (command.access === "write") yield* askOpenCliWritePermission(ctx, command) - if (command.browser !== false) { - const patterns = commandKnownBrowserPermissionPatterns(command) - if (patterns.length > 0) yield* askBrowserPermission(ctx, command, patterns) - else yield* askCurrentBrowserPagePermission(ctx, command) - } 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* Effect.tryPromise({ - try: () => - withBrowserPage(ctx.sessionID, `opencli ${fullName(command)}`, (page) => - runOpenCliAdapterCommand(command, page, args), - { timeoutMs: 60_000, abort: ctx.abort }, - ), - catch: (err) => (err instanceof Error ? err : new Error(String(err))), - }) + : yield* runBrowserCommand(command, args, ctx) return { title: `OpenCLI ${fullName(command)}`, diff --git a/packages/opencode/test/opencli/adapter-registry.test.ts b/packages/opencode/test/opencli/adapter-registry.test.ts index 75ac5e942..b785c7231 100644 --- a/packages/opencode/test/opencli/adapter-registry.test.ts +++ b/packages/opencode/test/opencli/adapter-registry.test.ts @@ -18,7 +18,7 @@ describe("opencli adapter registry", () => { test("loads the packaged manifest and exposes searchable canonical commands", async () => { const loaded = await loadOpenCliAdapters() - expect(loaded.manifestCount).toBe(1050) + expect(loaded.manifestCount).toBeGreaterThan(1000) expect(loaded.canonicalCommands.has("12306/me")).toBe(true) expect(loaded.canonicalCommands.has("hackernews/search")).toBe(true) expect(loaded.exposedCommands.has("instagram/reel")).toBe(false) diff --git a/packages/opencode/test/tool/opencli-tools.test.ts b/packages/opencode/test/tool/opencli-tools.test.ts index e135ee31e..45e3f949d 100644 --- a/packages/opencode/test/tool/opencli-tools.test.ts +++ b/packages/opencode/test/tool/opencli-tools.test.ts @@ -159,6 +159,14 @@ describe("opencli_run", () => { 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][] = [] @@ -174,6 +182,7 @@ describe("opencli_run", () => { patterns: ["https://auth.example.com/*", "https://example.com/*"], }) } finally { + BrowserBridge.provideHost(null) getRegistry().delete("pawwork-test/browser-permission") } }), From 44adda9537eb91bf537690aa18414b785dc3ddee Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 14:21:53 +0800 Subject: [PATCH 26/44] fix(opencli): gate adapter imports behind permissions --- packages/opencode/src/agent/agent.ts | 1 + .../opencode/src/opencli/adapter-registry.ts | 150 +++++++++++------- packages/opencode/src/tool/opencli-run.ts | 53 +++++-- .../test/opencli/adapter-registry.test.ts | 44 +++-- .../test/permission/pawwork-defaults.test.ts | 1 + .../opencode/test/tool/opencli-tools.test.ts | 19 ++- 6 files changed, 163 insertions(+), 105 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 49034d068..9dc3e2647 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -83,6 +83,7 @@ export namespace Agent { const defaults = Permission.fromConfig({ "*": "allow", doom_loop: "ask", + opencli_read: "ask", opencli_write: "ask", question: "deny", plan_enter: "deny", diff --git a/packages/opencode/src/opencli/adapter-registry.ts b/packages/opencode/src/opencli/adapter-registry.ts index 7fa3de00a..764ee7e9d 100644 --- a/packages/opencode/src/opencli/adapter-registry.ts +++ b/packages/opencode/src/opencli/adapter-registry.ts @@ -11,6 +11,7 @@ export type OpenCliManifestEntry = { access: "read" | "write" domain?: string browser?: boolean + navigateBefore?: string | boolean args?: Array<{ name: string type?: string @@ -30,6 +31,7 @@ export type OpenCliCommandSummary = { access: "read" | "write" browser: boolean domain?: string + navigateBefore?: string | boolean args: OpenCliManifestEntry["args"] } @@ -40,13 +42,9 @@ export type OpenCliAdapterImportFailure = { export const BLOCKED_OPENCLI_COMMANDS = new Set(["instagram/reel"]) -let loadPromise: Promise<{ - manifestCount: number - canonicalCommands: ReadonlySet - exposedCommands: ReadonlySet - failedModules: OpenCliAdapterImportFailure[] -}> | undefined let manifestCache: OpenCliManifestEntry[] | undefined +const importedModules = new Set() +const failedModules = new Map() function openCliPackageRoot() { const cdp = fileURLToPath(import.meta.resolve("@jackwener/opencli/browser/cdp")) @@ -66,60 +64,90 @@ async function loadManifest(): Promise { return manifestCache } -async function importAdapterModules( - manifest: OpenCliManifestEntry[], - options: { - root?: string - importModule?: (specifier: string) => Promise - } = {}, -) { - const root = options.root ?? openCliPackageRoot() - const importModule = options.importModule ?? ((specifier: string) => import(specifier)) - const uniqueModules = new Set(manifest.map((entry) => entry.modulePath)) - const failedModules: OpenCliAdapterImportFailure[] = [] - for (const modulePath of uniqueModules) { - try { - await importModule(pathToFileURL(path.join(root, "clis", modulePath)).href) - } catch (err) { - failedModules.push({ modulePath, error: err instanceof Error ? err.message : String(err) }) - } +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 registryCommandSummary(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 + const priorFailure = failedModules.get(modulePath) + if (priorFailure) throw new Error(`Failed to load OpenCLI adapter module ${modulePath}: ${priorFailure.error}`) + try { + await import(pathToFileURL(path.join(openCliPackageRoot(), "clis", modulePath)).href) + importedModules.add(modulePath) + } catch (err) { + const failure = { modulePath, error: err instanceof Error ? err.message : String(err) } + failedModules.set(modulePath, failure) + throw new Error(`Failed to load OpenCLI adapter module ${modulePath}: ${failure.error}`) } - return failedModules } -function canonicalCommandSet(): Set { - return new Set([...getRegistry().values()].map((cmd) => fullName(cmd))) +async function manifestEntryForCommand(name: string) { + return (await loadManifest()).find((entry) => manifestCommandName(entry) === name) } export async function loadOpenCliAdapters() { - loadPromise ??= (async () => { - const manifest = await loadManifest() - const failedModules = await importAdapterModules(manifest) - const canonicalCommands = canonicalCommandSet() - const exposedCommands = new Set([...canonicalCommands].filter((name) => !BLOCKED_OPENCLI_COMMANDS.has(name))) - return { - manifestCount: manifest.length, - canonicalCommands, - exposedCommands, - failedModules, - } - })() - return loadPromise -} - -export const importOpenCliAdapterModulesForTest = importAdapterModules + const manifest = await loadManifest() + const canonicalCommands = new Set([ + ...manifest.map(manifestCommandName), + ...[...getRegistry().values()].map((command) => fullName(command)), + ]) + const exposedCommands = new Set([...canonicalCommands].filter((name) => !BLOCKED_OPENCLI_COMMANDS.has(name))) + return { + manifestCount: manifest.length, + canonicalCommands, + exposedCommands, + failedModules: [...failedModules.values()], + } +} export async function openCliCommand(name: string): Promise { - await loadOpenCliAdapters() if (BLOCKED_OPENCLI_COMMANDS.has(name)) return undefined + const existing = getRegistry().get(name) + if (existing) return existing + const entry = await manifestEntryForCommand(name) + if (!entry) return undefined + await importAdapterModule(entry.modulePath) return getRegistry().get(name) } -function scoreCommand(command: CliCommand, query: string) { +export async function openCliCommandSummary(name: string): Promise { + if (BLOCKED_OPENCLI_COMMANDS.has(name)) return undefined + const existing = getRegistry().get(name) + if (existing) return registryCommandSummary(existing) + const entry = await manifestEntryForCommand(name) + return entry ? manifestCommandSummary(entry) : undefined +} + +function scoreCommand(command: OpenCliCommandSummary, query: string) { const needle = query.trim().toLowerCase() if (!needle) return 1 - const name = fullName(command).toLowerCase() - const haystack = [name, command.description, command.domain, command.access, command.browser !== false ? "browser" : "http"] + const name = command.name.toLowerCase() + const haystack = [name, command.description, command.domain, command.access, command.browser ? "browser" : "http"] .filter(Boolean) .join(" ") .toLowerCase() @@ -134,28 +162,30 @@ export async function searchOpenCliCommands( query: string, options: { limit?: number } = {}, ): Promise { - await loadOpenCliAdapters() + const manifest = await loadManifest() const limit = Math.min(Math.max(options.limit ?? 10, 1), 25) - return [...getRegistry().values()] - .filter((command, index, all) => all.findIndex((other) => fullName(other) === fullName(command)) === index) - .filter((command) => !BLOCKED_OPENCLI_COMMANDS.has(fullName(command))) + 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 = registryCommandSummary(command) + if (!summaries.has(summary.name)) summaries.set(summary.name, summary) + } + return [...summaries.values()] + .filter((command) => !BLOCKED_OPENCLI_COMMANDS.has(command.name)) .map((command) => ({ command, score: scoreCommand(command, query) })) .filter((item) => item.score > 0) - .sort((a, b) => b.score - a.score || fullName(a.command).localeCompare(fullName(b.command))) + .sort((a, b) => b.score - a.score || a.command.name.localeCompare(b.command.name)) .slice(0, limit) - .map(({ command }) => ({ - name: fullName(command), - description: command.description ?? "", - access: command.access, - browser: command.browser !== false, - domain: command.domain, - args: command.args, - })) + .map(({ command }) => command) } export function resetOpenCliAdaptersForTest() { - loadPromise = undefined manifestCache = undefined + importedModules.clear() + failedModules.clear() } export * as AdapterRegistry from "./adapter-registry" diff --git a/packages/opencode/src/tool/opencli-run.ts b/packages/opencode/src/tool/opencli-run.ts index 6377530f4..db7be7f29 100644 --- a/packages/opencode/src/tool/opencli-run.ts +++ b/packages/opencode/src/tool/opencli-run.ts @@ -2,7 +2,7 @@ import { Effect, Schema } from "effect" import { fullName } from "@jackwener/opencli/registry" import * as Tool from "./tool" import DESCRIPTION from "./opencli-run.txt" -import { openCliCommand } from "@/opencli/adapter-registry" +import { openCliCommand, openCliCommandSummary, type OpenCliCommandSummary } from "@/opencli/adapter-registry" import { prepareOpenCliCommandArgs, runOpenCliAdapterCommand } from "@/opencli/adapter-runner" import { runBrowserAction } from "./browser-shared" @@ -18,7 +18,7 @@ export const Parameters = Schema.Struct({ }), }) -function commandKnownBrowserPermissionPatterns(command: OpenCliCommand): string[] { +function commandKnownBrowserPermissionPatterns(command: Pick): string[] { if (typeof command.navigateBefore !== "string") return [] const patterns: string[] = [] try { @@ -30,22 +30,27 @@ function commandKnownBrowserPermissionPatterns(command: OpenCliCommand): string[ return [...new Set(patterns)] } -function commandMetadata(command: OpenCliCommand) { +function commandMetadata(command: OpenCliCommandSummary, args?: Record) { return { action: "opencli_run", - command: fullName(command), - browser: command.browser !== false, + command: command.name, + browser: command.browser, access: command.access, + args, } } -function askOpenCliWritePermission(ctx: Tool.Context, command: OpenCliCommand) { - const commandName = fullName(command) +function askOpenCliAccessPermission( + ctx: Tool.Context, + command: OpenCliCommandSummary, + args: Record, +) { + const permission = command.access === "write" ? "opencli_write" : "opencli_read" return ctx.ask({ - permission: "opencli_write", - patterns: [commandName], - always: [commandName], - metadata: commandMetadata(command), + permission, + patterns: [command.name], + always: [command.name], + metadata: commandMetadata(command, args), }) } @@ -87,7 +92,15 @@ function runBrowserCommand(command: OpenCliCommand, args: Record 0 ? patterns : undefined, - metadata: commandMetadata(command), + metadata: commandMetadata({ + name: fullName(command), + description: command.description ?? "", + access: command.access, + browser: command.browser !== false, + domain: command.domain, + navigateBefore: command.navigateBefore, + args: command.args, + }), timeoutMs: OPENCLI_RUN_TIMEOUT_MS, run: (page) => runOpenCliAdapterCommand(command, page, args), }) @@ -101,17 +114,27 @@ export const OpenCliRunTool = Tool.define( parameters: Parameters, execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { + const requestedArgs = params.args ?? {} + const preview = yield* Effect.tryPromise({ + try: () => openCliCommandSummary(params.command), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }) + if (!preview) { + return yield* Effect.fail( + new Error(`Unknown or unsupported OpenCLI command "${params.command}". Run opencli_search to find one.`), + ) + } + yield* askOpenCliAccessPermission(ctx, preview, requestedArgs) 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.`), + new Error(`OpenCLI command "${params.command}" did not register after its adapter module loaded.`), ) } - const args = prepareOpenCliCommandArgs(command, params.args ?? {}) - if (command.access === "write") yield* askOpenCliWritePermission(ctx, command) + const args = prepareOpenCliCommandArgs(command, requestedArgs) const value = command.browser === false ? yield* Effect.tryPromise({ diff --git a/packages/opencode/test/opencli/adapter-registry.test.ts b/packages/opencode/test/opencli/adapter-registry.test.ts index b785c7231..d475db69e 100644 --- a/packages/opencode/test/opencli/adapter-registry.test.ts +++ b/packages/opencode/test/opencli/adapter-registry.test.ts @@ -1,13 +1,10 @@ import { describe, expect, test } from "bun:test" import { getRegistry, type CliCommand } from "@jackwener/opencli/registry" -import path from "path" -import { pathToFileURL } from "url" import { AdapterRegistry, - importOpenCliAdapterModulesForTest, loadOpenCliAdapters, + openCliCommand, searchOpenCliCommands, - type OpenCliManifestEntry, } from "../../src/opencli/adapter-registry" describe("opencli adapter registry", () => { @@ -15,6 +12,15 @@ describe("opencli adapter registry", () => { expect(AdapterRegistry.searchOpenCliCommands).toBe(searchOpenCliCommands) }) + test("searches the packaged manifest without importing adapter modules", async () => { + getRegistry().delete("spotify/play") + + const results = await searchOpenCliCommands("spotify play", { limit: 5 }) + + expect(results.map((result) => result.name)).toContain("spotify/play") + expect(getRegistry().has("spotify/play")).toBe(false) + }) + test("loads the packaged manifest and exposes searchable canonical commands", async () => { const loaded = await loadOpenCliAdapters() @@ -32,6 +38,15 @@ describe("opencli adapter registry", () => { expect(loaded.failedModules).toEqual([]) }) + 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", @@ -53,25 +68,4 @@ describe("opencli adapter registry", () => { } }) - test("continues loading later adapter modules after one import fails", async () => { - const manifest = [ - { site: "bad", name: "fail", access: "read", type: "js", modulePath: "bad.js" }, - { site: "good", name: "ok", access: "read", type: "js", modulePath: "good.js" }, - ] satisfies OpenCliManifestEntry[] - const imported: string[] = [] - - const failures = await importOpenCliAdapterModulesForTest(manifest, { - root: "/opencli", - importModule: async (specifier) => { - imported.push(specifier) - if (specifier.endsWith("/bad.js")) throw new Error("bad module") - }, - }) - - expect(imported).toEqual([ - pathToFileURL(path.join("/opencli", "clis", "bad.js")).href, - pathToFileURL(path.join("/opencli", "clis", "good.js")).href, - ]) - expect(failures).toEqual([{ modulePath: "bad.js", error: "bad module" }]) - }) }) diff --git a/packages/opencode/test/permission/pawwork-defaults.test.ts b/packages/opencode/test/permission/pawwork-defaults.test.ts index 8a6456d2f..567b7a79d 100644 --- a/packages/opencode/test/permission/pawwork-defaults.test.ts +++ b/packages/opencode/test/permission/pawwork-defaults.test.ts @@ -39,6 +39,7 @@ 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") diff --git a/packages/opencode/test/tool/opencli-tools.test.ts b/packages/opencode/test/tool/opencli-tools.test.ts index 45e3f949d..2fd436880 100644 --- a/packages/opencode/test/tool/opencli-tools.test.ts +++ b/packages/opencode/test/tool/opencli-tools.test.ts @@ -39,13 +39,13 @@ function exec(tool: unknown, args: unknown, ctxOverride: Partial = describe("opencli_search", () => { it.live("returns discoverable bundled adapter commands without blocked commands", () => Effect.gen(function* () { - const result = yield* exec(OpenCliSearchTool, { query: "12306 account", limit: 5 }) + const result = yield* exec(OpenCliSearchTool, { query: "12306/me", limit: 5 }) - expect(result.title).toBe('OpenCLI commands for "12306 account"') + expect(result.title).toBe('OpenCLI commands for "12306/me"') expect(result.output).toContain('') expect(result.output).toContain("browser: true") expect(result.output).not.toContain("instagram/reel") - expect(result.metadata).toMatchObject({ query: "12306 account" }) + expect(result.metadata).toMatchObject({ query: "12306/me" }) }), ) @@ -178,6 +178,11 @@ describe("opencli_run", () => { }).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/*", "https://example.com/*"], }) @@ -188,7 +193,7 @@ describe("opencli_run", () => { }), ) - it.live("runs a registered non-browser adapter through command and args", () => + it.live("asks before running a read non-browser adapter", () => Effect.gen(function* () { cli({ site: "pawwork-test", @@ -212,7 +217,11 @@ describe("opencli_run", () => { 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).toEqual([]) + expect(askLog[0]).toMatchObject({ + permission: "opencli_read", + patterns: ["pawwork-test/echo"], + always: ["pawwork-test/echo"], + }) } finally { getRegistry().delete("pawwork-test/echo") } From b8a16c3cee371e951f8d213b5262e23051ba3f69 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 14:23:36 +0800 Subject: [PATCH 27/44] refactor(desktop): share opencli runtime filters --- packages/desktop-electron/opencli-runtime.ts | 25 ++++++++------------ 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/packages/desktop-electron/opencli-runtime.ts b/packages/desktop-electron/opencli-runtime.ts index 8b1058988..7c430bce8 100644 --- a/packages/desktop-electron/opencli-runtime.ts +++ b/packages/desktop-electron/opencli-runtime.ts @@ -74,6 +74,9 @@ const runtimeOnlyExcludes = [ "!**/{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("/") @@ -93,14 +96,10 @@ export function includeOpenCliRuntimeFile(packageName: string, relativePath: str const normalized = normalizeRelativePath(relativePath) if (isNonRuntimePath(normalized)) return false if (packageName !== "@jackwener/opencli") return true - if (normalized === "clis/test-utils.js") return false + if (openCliRuntimeExcludes.includes(normalized)) return false return ( - normalized === "package.json" || - normalized === "README.md" || - normalized === "LICENSE" || - normalized === "cli-manifest.json" || - normalized.startsWith("clis/") || - normalized.startsWith("dist/src/") + openCliRuntimeFiles.includes(normalized) || + openCliRuntimeDirectories.some((dir) => normalized.startsWith(`${dir}/`)) ) } @@ -109,21 +108,17 @@ export function includeOpenCliRuntimeDirectory(packageName: string, relativePath if (normalized === "") return true if (isNonRuntimePath(normalized)) return false if (packageName !== "@jackwener/opencli") return true - return ( - normalized === "clis" || - normalized.startsWith("clis/") || - normalized === "dist" || - normalized === "dist/src" || - normalized.startsWith("dist/src/") + return openCliRuntimeDirectories.some( + (dir) => normalized === dir || normalized.startsWith(`${dir}/`) || dir.startsWith(`${normalized}/`), ) } function openCliRuntimeFilter(packageName: string) { const includes = packageName === "@jackwener/opencli" - ? ["package.json", "README.md", "LICENSE", "cli-manifest.json", "clis/**/*", "dist/src/**/*"] + ? [...openCliRuntimeFiles, ...openCliRuntimeDirectories.map((dir) => `${dir}/**/*`)] : ["**/*"] - const packageExcludes = packageName === "@jackwener/opencli" ? ["!clis/test-utils.js"] : [] + const packageExcludes = packageName === "@jackwener/opencli" ? openCliRuntimeExcludes.map((file) => `!${file}`) : [] return [...includes, ...runtimeOnlyExcludes, ...packageExcludes] } From 9d87079a155e6993b12976192fac98e143ca9c80 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 14:31:41 +0800 Subject: [PATCH 28/44] fix(opencli): format empty adapter output --- packages/opencode/src/tool/opencli-run.ts | 7 +++- .../opencode/test/tool/opencli-tools.test.ts | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/opencli-run.ts b/packages/opencode/src/tool/opencli-run.ts index db7be7f29..3a5010762 100644 --- a/packages/opencode/src/tool/opencli-run.ts +++ b/packages/opencode/src/tool/opencli-run.ts @@ -56,7 +56,12 @@ function askOpenCliAccessPermission( function formatAdapterOutput(value: unknown): string { if (typeof value === "string") return value - return JSON.stringify(value, null, 2) + 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( diff --git a/packages/opencode/test/tool/opencli-tools.test.ts b/packages/opencode/test/tool/opencli-tools.test.ts index 2fd436880..82f626741 100644 --- a/packages/opencode/test/tool/opencli-tools.test.ts +++ b/packages/opencode/test/tool/opencli-tools.test.ts @@ -4,7 +4,9 @@ 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" @@ -193,6 +195,36 @@ describe("opencli_run", () => { }), ) + 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({ From 9a2cc6d2fbfa2f16d42b5eeacf6c0f9dbf64dc0a Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 14:58:26 +0800 Subject: [PATCH 29/44] fix(opencli): match persistent pre-nav origin --- .../opencode/src/opencli/adapter-runner.ts | 9 ++++--- .../test/opencli/adapter-runner.test.ts | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/opencli/adapter-runner.ts b/packages/opencode/src/opencli/adapter-runner.ts index 6f638349d..dea11ce28 100644 --- a/packages/opencode/src/opencli/adapter-runner.ts +++ b/packages/opencode/src/opencli/adapter-runner.ts @@ -139,11 +139,10 @@ export function resolveOpenCliPreNav(cmd: CliCommand): string | null { return null } -function urlMatchesDomain(url: string | null | undefined, domain: string | undefined) { - if (!url || !domain) return false +function urlMatchesOrigin(url: string | null | undefined, originUrl: string) { + if (!url) return false try { - const hostname = new URL(url).hostname - return hostname === domain || hostname.endsWith(`.${domain}`) + return new URL(url).origin === new URL(originUrl).origin } catch { return false } @@ -170,7 +169,7 @@ export async function shouldRunOpenCliPreNav( if (siteSession !== "persistent" || !cmd.domain) return true if (!isDomainRootPreNav(preNavUrl, cmd.domain)) return true const currentUrl = await page.getCurrentUrl?.().catch(() => null) - return !urlMatchesDomain(currentUrl, cmd.domain) + return !urlMatchesOrigin(currentUrl, preNavUrl) } function resolveOpenCliSiteSession(cmd: CliCommand): SiteSessionMode { diff --git a/packages/opencode/test/opencli/adapter-runner.test.ts b/packages/opencode/test/opencli/adapter-runner.test.ts index d5836341f..c99b4464c 100644 --- a/packages/opencode/test/opencli/adapter-runner.test.ts +++ b/packages/opencode/test/opencli/adapter-runner.test.ts @@ -94,6 +94,30 @@ describe("opencli adapter runner", () => { expect(func).toHaveBeenCalledWith(expect.objectContaining({ goto: expect.any(Function) }), { query: "pawwork" }, false) }) + 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 } } From f69e74367cf6bd0561a93f991fd01a8b286c7467 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 15:01:25 +0800 Subject: [PATCH 30/44] fix(opencli): ask with final command args --- .../opencode/src/opencli/adapter-registry.ts | 6 +-- packages/opencode/src/tool/opencli-run.ts | 48 +++++++++++++++-- .../opencode/test/tool/opencli-tools.test.ts | 52 ++++++++++++++++++- 3 files changed, 99 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/opencli/adapter-registry.ts b/packages/opencode/src/opencli/adapter-registry.ts index 764ee7e9d..3c867f95a 100644 --- a/packages/opencode/src/opencli/adapter-registry.ts +++ b/packages/opencode/src/opencli/adapter-registry.ts @@ -80,7 +80,7 @@ function manifestCommandSummary(entry: OpenCliManifestEntry): OpenCliCommandSumm } } -function registryCommandSummary(command: CliCommand): OpenCliCommandSummary { +export function openCliCommandSummaryFromCommand(command: CliCommand): OpenCliCommandSummary { return { name: fullName(command), description: command.description ?? "", @@ -138,7 +138,7 @@ export async function openCliCommand(name: string): Promise { if (BLOCKED_OPENCLI_COMMANDS.has(name)) return undefined const existing = getRegistry().get(name) - if (existing) return registryCommandSummary(existing) + if (existing) return openCliCommandSummaryFromCommand(existing) const entry = await manifestEntryForCommand(name) return entry ? manifestCommandSummary(entry) : undefined } @@ -170,7 +170,7 @@ export async function searchOpenCliCommands( summaries.set(summary.name, summary) } for (const command of getRegistry().values()) { - const summary = registryCommandSummary(command) + const summary = openCliCommandSummaryFromCommand(command) if (!summaries.has(summary.name)) summaries.set(summary.name, summary) } return [...summaries.values()] diff --git a/packages/opencode/src/tool/opencli-run.ts b/packages/opencode/src/tool/opencli-run.ts index 3a5010762..1c1718f31 100644 --- a/packages/opencode/src/tool/opencli-run.ts +++ b/packages/opencode/src/tool/opencli-run.ts @@ -2,8 +2,13 @@ import { Effect, Schema } from "effect" import { fullName } from "@jackwener/opencli/registry" import * as Tool from "./tool" import DESCRIPTION from "./opencli-run.txt" -import { openCliCommand, openCliCommandSummary, type OpenCliCommandSummary } from "@/opencli/adapter-registry" -import { prepareOpenCliCommandArgs, runOpenCliAdapterCommand } from "@/opencli/adapter-runner" +import { + openCliCommand, + openCliCommandSummary, + openCliCommandSummaryFromCommand, + type OpenCliCommandSummary, +} from "@/opencli/adapter-registry" +import { coerceOpenCliArgs, prepareOpenCliCommandArgs, runOpenCliAdapterCommand } from "@/opencli/adapter-runner" import { runBrowserAction } from "./browser-shared" const OPENCLI_RUN_TIMEOUT_MS = 60_000 @@ -40,6 +45,38 @@ function commandMetadata(command: OpenCliCommandSummary, args?: Record, + actualArgs: Record, +) { + if (!sameJson(commandPermissionShape(preview), commandPermissionShape(actual))) return true + return !sameJson(previewArgs, actualArgs) +} + +function prepareOpenCliPreviewArgs(command: OpenCliCommandSummary, rawArgs: Record) { + return coerceOpenCliArgs((command.args ?? []) as Parameters[0], rawArgs) +} + function askOpenCliAccessPermission( ctx: Tool.Context, command: OpenCliCommandSummary, @@ -129,7 +166,8 @@ export const OpenCliRunTool = Tool.define( new Error(`Unknown or unsupported OpenCLI command "${params.command}". Run opencli_search to find one.`), ) } - yield* askOpenCliAccessPermission(ctx, preview, requestedArgs) + const previewArgs = prepareOpenCliPreviewArgs(preview, requestedArgs) + yield* askOpenCliAccessPermission(ctx, preview, previewArgs) const command = yield* Effect.tryPromise({ try: () => openCliCommand(params.command), catch: (err) => (err instanceof Error ? err : new Error(String(err))), @@ -140,6 +178,10 @@ export const OpenCliRunTool = Tool.define( ) } const args = prepareOpenCliCommandArgs(command, requestedArgs) + const actual = openCliCommandSummaryFromCommand(command) + if (needsFinalOpenCliPermissionAsk(preview, actual, previewArgs, args)) { + yield* askOpenCliAccessPermission(ctx, actual, args) + } const value = command.browser === false ? yield* Effect.tryPromise({ diff --git a/packages/opencode/test/tool/opencli-tools.test.ts b/packages/opencode/test/tool/opencli-tools.test.ts index 82f626741..0ebfe3acf 100644 --- a/packages/opencode/test/tool/opencli-tools.test.ts +++ b/packages/opencode/test/tool/opencli-tools.test.ts @@ -9,7 +9,7 @@ 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 { needsFinalOpenCliPermissionAsk, OpenCliRunTool } from "../../src/tool/opencli-run" import { formatOpenCliSearchOutput, OpenCliSearchTool } from "../../src/tool/opencli-search" import type * as Tool from "../../src/tool/tool" import { Truncate } from "../../src/tool/truncate" @@ -101,6 +101,23 @@ describe("opencli_search", () => { }) describe("opencli_run", () => { + test("refreshes permission when loaded command metadata differs from the preview", () => { + const preview = { + name: "pawwork-test/dynamic", + description: "Preview", + access: "read", + browser: false, + args: [], + } as const + const actual = { + ...preview, + access: "write", + } as const + + expect(needsFinalOpenCliPermissionAsk(preview, actual, {}, {})).toBe(true) + expect(needsFinalOpenCliPermissionAsk(actual, actual, { mode: "safe" }, { mode: "safe" })).toBe(false) + }) + it.live("asks for the current page when a browser command has no pre-navigation URL", () => Effect.gen(function* () { cli({ @@ -293,6 +310,39 @@ describe("opencli_run", () => { }), ) + 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: false, + args: [{ name: "mode", default: "safe" }], + func: async (args) => [{ args }], + }) + + 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 { + getRegistry().delete("pawwork-test/write-defaults") + } + }), + ) + it.live("aborts a non-browser adapter without waiting forever", () => Effect.gen(function* () { const started = yield* Deferred.make() From b44a15937c7c0258690b4c47d12774fdd20a3a7b Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 15:02:27 +0800 Subject: [PATCH 31/44] fix(opencli): clarify write adapter cancellation --- packages/opencode/src/tool/opencli-run.ts | 8 +++- .../opencode/test/tool/opencli-tools.test.ts | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/tool/opencli-run.ts b/packages/opencode/src/tool/opencli-run.ts index 1c1718f31..3e816bdd3 100644 --- a/packages/opencode/src/tool/opencli-run.ts +++ b/packages/opencode/src/tool/opencli-run.ts @@ -108,14 +108,18 @@ async function runNonBrowserCommand( ) { const commandName = fullName(command) if (abort.aborted) throw new Error(`OpenCLI ${commandName} was canceled.`) + const writeInterruption = + command.access === "write" + ? " The non-browser write adapter may still be running; check the target before retrying." + : "" 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.`)), + () => reject(new Error(`OpenCLI ${commandName} timed out after ${OPENCLI_RUN_TIMEOUT_MS}ms.${writeInterruption}`)), OPENCLI_RUN_TIMEOUT_MS, ) - onAbort = () => reject(new Error(`OpenCLI ${commandName} was canceled.`)) + onAbort = () => reject(new Error(`OpenCLI ${commandName} was canceled.${writeInterruption}`)) abort.addEventListener("abort", onAbort, { once: true }) }) const running = runOpenCliAdapterCommand(command, null, args) diff --git a/packages/opencode/test/tool/opencli-tools.test.ts b/packages/opencode/test/tool/opencli-tools.test.ts index 0ebfe3acf..7a56a3abb 100644 --- a/packages/opencode/test/tool/opencli-tools.test.ts +++ b/packages/opencode/test/tool/opencli-tools.test.ts @@ -380,4 +380,46 @@ describe("opencli_run", () => { } }), ) + + it.live("warns when a canceled write non-browser adapter may still be running", () => + Effect.gen(function* () { + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const controller = new AbortController() + let completed = false + cli({ + site: "pawwork-test", + name: "slow-write-http", + access: "write", + description: "Slow write non-browser test adapter", + browser: false, + args: [], + func: async () => { + Effect.runFork(Deferred.succeed(started, undefined)) + await Effect.runPromise(Deferred.await(release)) + completed = true + return [] + }, + }) + + try { + const fiber = yield* exec(OpenCliRunTool, { command: "pawwork-test/slow-write-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) + expect(completed).toBe(false) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(error instanceof Error ? error.message : String(error)).toContain("may still be running") + } + } finally { + yield* Deferred.succeed(release, undefined).pipe(Effect.ignore) + getRegistry().delete("pawwork-test/slow-write-http") + } + }), + ) }) From 8d575c8ec3075ea31ee5ee828602a48571287a59 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 15:03:25 +0800 Subject: [PATCH 32/44] test(opencli): type command summary fixtures --- packages/opencode/test/tool/opencli-tools.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/tool/opencli-tools.test.ts b/packages/opencode/test/tool/opencli-tools.test.ts index 7a56a3abb..1c33cf638 100644 --- a/packages/opencode/test/tool/opencli-tools.test.ts +++ b/packages/opencode/test/tool/opencli-tools.test.ts @@ -9,6 +9,7 @@ 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 type { OpenCliCommandSummary } from "../../src/opencli/adapter-registry" import { needsFinalOpenCliPermissionAsk, OpenCliRunTool } from "../../src/tool/opencli-run" import { formatOpenCliSearchOutput, OpenCliSearchTool } from "../../src/tool/opencli-search" import type * as Tool from "../../src/tool/tool" @@ -108,11 +109,11 @@ describe("opencli_run", () => { access: "read", browser: false, args: [], - } as const + } satisfies OpenCliCommandSummary const actual = { ...preview, access: "write", - } as const + } satisfies OpenCliCommandSummary expect(needsFinalOpenCliPermissionAsk(preview, actual, {}, {})).toBe(true) expect(needsFinalOpenCliPermissionAsk(actual, actual, { mode: "safe" }, { mode: "safe" })).toBe(false) From 8d48226266e7d4b34aa2e3e31f3edd6d496b3af8 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 15:25:13 +0800 Subject: [PATCH 33/44] docs: credit bundled OpenCLI runtime --- README.md | 10 ++++++++++ README_CN.md | 10 ++++++++++ THIRD_PARTY_NOTICES.md | 13 ++++++++++++- .../electron-builder-app-update.test.ts | 14 ++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 58e49b0d0..3d8e1f16b 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 (1000+ site adapters via OpenCLI) | 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 web services through your existing logged-in browser session + ## 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 site adapters. OpenCLI provides the CDP bridge and 1000+ site-specific command adapters that let PawWork interact with web services like Twitter/X, Bilibili, Xiaohongshu, and more using the user's logged-in browser session. 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..33247484f 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 提供 1000+ 站点适配器) | 不支持 | 不支持 | | 本地模型 | 支持(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 和 1000+ 站点命令适配器,让爪印可以通过用户已登录的浏览器会话与 Twitter/X、Bilibili、小红书等网页服务交互。感谢 @jackwener 以 Apache-2.0 开源 OpenCLI 项目。 + ## 常见问题 **爪印 PawWork 免费吗?** diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 4662fba2e..84b480569 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 site adapters through the user's logged-in browser session. + +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 ad4376dd3..f5ca975cd 100644 --- a/packages/desktop-electron/electron-builder-app-update.test.ts +++ b/packages/desktop-electron/electron-builder-app-update.test.ts @@ -106,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", From de743a0370c7c1f2ce477b42c8868eb0a6c14a1a Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 16:06:44 +0800 Subject: [PATCH 34/44] refactor(opencli): ask once for run permission --- .../opencode/src/opencli/adapter-registry.ts | 8 --- packages/opencode/src/tool/opencli-run.ts | 59 ++----------------- .../opencode/test/tool/opencli-tools.test.ts | 42 +++++++------ 3 files changed, 27 insertions(+), 82 deletions(-) diff --git a/packages/opencode/src/opencli/adapter-registry.ts b/packages/opencode/src/opencli/adapter-registry.ts index 3c867f95a..9ea31adb6 100644 --- a/packages/opencode/src/opencli/adapter-registry.ts +++ b/packages/opencode/src/opencli/adapter-registry.ts @@ -135,14 +135,6 @@ export async function openCliCommand(name: string): Promise { - if (BLOCKED_OPENCLI_COMMANDS.has(name)) return undefined - const existing = getRegistry().get(name) - if (existing) return openCliCommandSummaryFromCommand(existing) - const entry = await manifestEntryForCommand(name) - return entry ? manifestCommandSummary(entry) : undefined -} - function scoreCommand(command: OpenCliCommandSummary, query: string) { const needle = query.trim().toLowerCase() if (!needle) return 1 diff --git a/packages/opencode/src/tool/opencli-run.ts b/packages/opencode/src/tool/opencli-run.ts index 3e816bdd3..0267726cd 100644 --- a/packages/opencode/src/tool/opencli-run.ts +++ b/packages/opencode/src/tool/opencli-run.ts @@ -2,13 +2,8 @@ import { Effect, Schema } from "effect" import { fullName } from "@jackwener/opencli/registry" import * as Tool from "./tool" import DESCRIPTION from "./opencli-run.txt" -import { - openCliCommand, - openCliCommandSummary, - openCliCommandSummaryFromCommand, - type OpenCliCommandSummary, -} from "@/opencli/adapter-registry" -import { coerceOpenCliArgs, prepareOpenCliCommandArgs, runOpenCliAdapterCommand } from "@/opencli/adapter-runner" +import { openCliCommand, openCliCommandSummaryFromCommand, type OpenCliCommandSummary } from "@/opencli/adapter-registry" +import { prepareOpenCliCommandArgs, runOpenCliAdapterCommand } from "@/opencli/adapter-runner" import { runBrowserAction } from "./browser-shared" const OPENCLI_RUN_TIMEOUT_MS = 60_000 @@ -45,38 +40,6 @@ function commandMetadata(command: OpenCliCommandSummary, args?: Record, - actualArgs: Record, -) { - if (!sameJson(commandPermissionShape(preview), commandPermissionShape(actual))) return true - return !sameJson(previewArgs, actualArgs) -} - -function prepareOpenCliPreviewArgs(command: OpenCliCommandSummary, rawArgs: Record) { - return coerceOpenCliArgs((command.args ?? []) as Parameters[0], rawArgs) -} - function askOpenCliAccessPermission( ctx: Tool.Context, command: OpenCliCommandSummary, @@ -161,31 +124,17 @@ export const OpenCliRunTool = Tool.define( execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { const requestedArgs = params.args ?? {} - const preview = yield* Effect.tryPromise({ - try: () => openCliCommandSummary(params.command), - catch: (err) => (err instanceof Error ? err : new Error(String(err))), - }) - if (!preview) { - return yield* Effect.fail( - new Error(`Unknown or unsupported OpenCLI command "${params.command}". Run opencli_search to find one.`), - ) - } - const previewArgs = prepareOpenCliPreviewArgs(preview, requestedArgs) - yield* askOpenCliAccessPermission(ctx, preview, previewArgs) 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(`OpenCLI command "${params.command}" did not register after its adapter module loaded.`), + new Error(`Unknown or unsupported OpenCLI command "${params.command}". Run opencli_search to find one.`), ) } const args = prepareOpenCliCommandArgs(command, requestedArgs) - const actual = openCliCommandSummaryFromCommand(command) - if (needsFinalOpenCliPermissionAsk(preview, actual, previewArgs, args)) { - yield* askOpenCliAccessPermission(ctx, actual, args) - } + yield* askOpenCliAccessPermission(ctx, openCliCommandSummaryFromCommand(command), args) const value = command.browser === false ? yield* Effect.tryPromise({ diff --git a/packages/opencode/test/tool/opencli-tools.test.ts b/packages/opencode/test/tool/opencli-tools.test.ts index 1c33cf638..88d55d7e0 100644 --- a/packages/opencode/test/tool/opencli-tools.test.ts +++ b/packages/opencode/test/tool/opencli-tools.test.ts @@ -9,8 +9,7 @@ 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 type { OpenCliCommandSummary } from "../../src/opencli/adapter-registry" -import { needsFinalOpenCliPermissionAsk, OpenCliRunTool } from "../../src/tool/opencli-run" +import { OpenCliRunTool } from "../../src/tool/opencli-run" import { formatOpenCliSearchOutput, OpenCliSearchTool } from "../../src/tool/opencli-search" import type * as Tool from "../../src/tool/tool" import { Truncate } from "../../src/tool/truncate" @@ -102,23 +101,6 @@ describe("opencli_search", () => { }) describe("opencli_run", () => { - test("refreshes permission when loaded command metadata differs from the preview", () => { - const preview = { - name: "pawwork-test/dynamic", - description: "Preview", - access: "read", - browser: false, - args: [], - } satisfies OpenCliCommandSummary - const actual = { - ...preview, - access: "write", - } satisfies OpenCliCommandSummary - - expect(needsFinalOpenCliPermissionAsk(preview, actual, {}, {})).toBe(true) - expect(needsFinalOpenCliPermissionAsk(actual, actual, { mode: "safe" }, { mode: "safe" })).toBe(false) - }) - it.live("asks for the current page when a browser command has no pre-navigation URL", () => Effect.gen(function* () { cli({ @@ -159,6 +141,7 @@ describe("opencli_run", () => { permission: "browser", patterns: ["http://localhost:5173/codex"], }) + expect(askLog).toHaveLength(2) } finally { BrowserBridge.provideHost(null) getRegistry().delete("pawwork-test/current-page") @@ -206,6 +189,7 @@ describe("opencli_run", () => { permission: "browser", patterns: ["https://auth.example.com/*", "https://example.com/*"], }) + expect(askLog).toHaveLength(2) } finally { BrowserBridge.provideHost(null) getRegistry().delete("pawwork-test/browser-permission") @@ -272,6 +256,7 @@ describe("opencli_run", () => { patterns: ["pawwork-test/echo"], always: ["pawwork-test/echo"], }) + expect(askLog).toHaveLength(1) } finally { getRegistry().delete("pawwork-test/echo") } @@ -304,6 +289,7 @@ describe("opencli_run", () => { patterns: ["pawwork-test/write-http"], always: ["pawwork-test/write-http"], }) + expect(askLog).toHaveLength(1) expect(result.output).toContain('"written": "hello"') } finally { getRegistry().delete("pawwork-test/write-http") @@ -338,12 +324,30 @@ describe("opencli_run", () => { args: { mode: "safe" }, }, }) + expect(askLog).toHaveLength(1) } finally { getRegistry().delete("pawwork-test/write-defaults") } }), ) + 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() From 3c839ad24c3d333f40278959432d020a461aee01 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 16:07:51 +0800 Subject: [PATCH 35/44] refactor(opencli): keep search manifest-only --- packages/opencode/src/tool/opencli-search.ts | 27 ++++--------------- .../opencode/test/tool/opencli-tools.test.ts | 12 ++++++--- 2 files changed, 13 insertions(+), 26 deletions(-) diff --git a/packages/opencode/src/tool/opencli-search.ts b/packages/opencode/src/tool/opencli-search.ts index f92c06b71..e0405b012 100644 --- a/packages/opencode/src/tool/opencli-search.ts +++ b/packages/opencode/src/tool/opencli-search.ts @@ -1,12 +1,7 @@ import { Effect, Schema } from "effect" import * as Tool from "./tool" import DESCRIPTION from "./opencli-search.txt" -import { - loadOpenCliAdapters, - searchOpenCliCommands, - type OpenCliAdapterImportFailure, - type OpenCliCommandSummary, -} from "@/opencli/adapter-registry" +import { searchOpenCliCommands, type OpenCliCommandSummary } from "@/opencli/adapter-registry" export const Parameters = Schema.Struct({ query: Schema.String.annotate({ @@ -41,23 +36,12 @@ function formatOpenCliCommand(command: OpenCliCommandSummary) { .join("\n") } -function formatAdapterFailureWarning(failedModules: OpenCliAdapterImportFailure[]) { - if (failedModules.length === 0) return undefined - const sample = failedModules.slice(0, 3).map((failure) => failure.modulePath) - const more = failedModules.length > sample.length ? `, and ${failedModules.length - sample.length} more` : "" - const noun = failedModules.length === 1 ? "module" : "modules" - return `Warning: ${failedModules.length} OpenCLI adapter ${noun} failed to load (${sample.join(", ")}${more}); some commands may be missing.` -} - -export function formatOpenCliSearchOutput( - results: OpenCliCommandSummary[], - failedModules: OpenCliAdapterImportFailure[] = [], -) { +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, formatAdapterFailureWarning(failedModules)].filter(Boolean).join("\n\n") + return body } export const OpenCliSearchTool = Tool.define( @@ -69,12 +53,11 @@ export const OpenCliSearchTool = Tool.define( execute: (params: Schema.Schema.Type) => Effect.tryPromise({ try: async () => { - const loaded = await loadOpenCliAdapters() const results = await searchOpenCliCommands(params.query, { limit: params.limit }) return { title: `OpenCLI commands for "${params.query}"`, - output: formatOpenCliSearchOutput(results, loaded.failedModules), - metadata: { query: params.query, count: results.length, failedModuleCount: loaded.failedModules.length }, + 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/test/tool/opencli-tools.test.ts b/packages/opencode/test/tool/opencli-tools.test.ts index 88d55d7e0..9f4deefdb 100644 --- a/packages/opencode/test/tool/opencli-tools.test.ts +++ b/packages/opencode/test/tool/opencli-tools.test.ts @@ -48,6 +48,7 @@ describe("opencli_search", () => { expect(result.output).toContain("browser: true") expect(result.output).not.toContain("instagram/reel") expect(result.metadata).toMatchObject({ query: "12306/me" }) + expect(result.metadata).not.toHaveProperty("failedModuleCount") }), ) @@ -80,8 +81,11 @@ describe("opencli_search", () => { }), ) - test("shows adapter load failures after search results", () => { - const output = formatOpenCliSearchOutput( + test("omits adapter load failures from search results", () => { + const output = (formatOpenCliSearchOutput as unknown as ( + results: Parameters[0], + failedModules: Array<{ modulePath: string; error: string }>, + ) => string)( [ { name: "pawwork-test/search", @@ -95,8 +99,8 @@ describe("opencli_search", () => { ) expect(output).toContain('') - expect(output).toContain("Warning: 1 OpenCLI adapter module failed to load") - expect(output).toContain("broken.js") + expect(output).not.toContain("Warning:") + expect(output).not.toContain("broken.js") }) }) From 19cb08f34c35e047f0e4eb63f216635fcbc34256 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 16:57:25 +0800 Subject: [PATCH 36/44] refactor(opencli): remove adapter load diagnostics --- .../opencode/src/opencli/adapter-registry.ts | 29 ++----------------- .../test/opencli/adapter-registry.test.ts | 18 +++++------- 2 files changed, 10 insertions(+), 37 deletions(-) diff --git a/packages/opencode/src/opencli/adapter-registry.ts b/packages/opencode/src/opencli/adapter-registry.ts index 9ea31adb6..8701e5b4e 100644 --- a/packages/opencode/src/opencli/adapter-registry.ts +++ b/packages/opencode/src/opencli/adapter-registry.ts @@ -35,16 +35,10 @@ export type OpenCliCommandSummary = { args: OpenCliManifestEntry["args"] } -export type OpenCliAdapterImportFailure = { - modulePath: string - error: string -} - export const BLOCKED_OPENCLI_COMMANDS = new Set(["instagram/reel"]) let manifestCache: OpenCliManifestEntry[] | undefined const importedModules = new Set() -const failedModules = new Map() function openCliPackageRoot() { const cdp = fileURLToPath(import.meta.resolve("@jackwener/opencli/browser/cdp")) @@ -94,15 +88,12 @@ export function openCliCommandSummaryFromCommand(command: CliCommand): OpenCliCo async function importAdapterModule(modulePath: string) { if (importedModules.has(modulePath)) return - const priorFailure = failedModules.get(modulePath) - if (priorFailure) throw new Error(`Failed to load OpenCLI adapter module ${modulePath}: ${priorFailure.error}`) try { await import(pathToFileURL(path.join(openCliPackageRoot(), "clis", modulePath)).href) importedModules.add(modulePath) } catch (err) { - const failure = { modulePath, error: err instanceof Error ? err.message : String(err) } - failedModules.set(modulePath, failure) - throw new Error(`Failed to load OpenCLI adapter module ${modulePath}: ${failure.error}`) + const message = err instanceof Error ? err.message : String(err) + throw new Error(`Failed to load OpenCLI adapter module ${modulePath}: ${message}`) } } @@ -110,21 +101,6 @@ async function manifestEntryForCommand(name: string) { return (await loadManifest()).find((entry) => manifestCommandName(entry) === name) } -export async function loadOpenCliAdapters() { - const manifest = await loadManifest() - const canonicalCommands = new Set([ - ...manifest.map(manifestCommandName), - ...[...getRegistry().values()].map((command) => fullName(command)), - ]) - const exposedCommands = new Set([...canonicalCommands].filter((name) => !BLOCKED_OPENCLI_COMMANDS.has(name))) - return { - manifestCount: manifest.length, - canonicalCommands, - exposedCommands, - failedModules: [...failedModules.values()], - } -} - export async function openCliCommand(name: string): Promise { if (BLOCKED_OPENCLI_COMMANDS.has(name)) return undefined const existing = getRegistry().get(name) @@ -177,7 +153,6 @@ export async function searchOpenCliCommands( export function resetOpenCliAdaptersForTest() { manifestCache = undefined importedModules.clear() - failedModules.clear() } export * as AdapterRegistry from "./adapter-registry" diff --git a/packages/opencode/test/opencli/adapter-registry.test.ts b/packages/opencode/test/opencli/adapter-registry.test.ts index d475db69e..f3457a492 100644 --- a/packages/opencode/test/opencli/adapter-registry.test.ts +++ b/packages/opencode/test/opencli/adapter-registry.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test" import { getRegistry, type CliCommand } from "@jackwener/opencli/registry" import { AdapterRegistry, - loadOpenCliAdapters, openCliCommand, searchOpenCliCommands, } from "../../src/opencli/adapter-registry" @@ -21,21 +20,20 @@ describe("opencli adapter registry", () => { expect(getRegistry().has("spotify/play")).toBe(false) }) - test("loads the packaged manifest and exposes searchable canonical commands", async () => { - const loaded = await loadOpenCliAdapters() - - expect(loaded.manifestCount).toBeGreaterThan(1000) - expect(loaded.canonicalCommands.has("12306/me")).toBe(true) - expect(loaded.canonicalCommands.has("hackernews/search")).toBe(true) - expect(loaded.exposedCommands.has("instagram/reel")).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, }) - expect(loaded.failedModules).toEqual([]) + + 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 () => { From 9b87953bf437e9feaed5821b3f7eb8813a7d643b Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 16:57:56 +0800 Subject: [PATCH 37/44] test(opencli): remove search warning legacy cast --- .../opencode/test/tool/opencli-tools.test.ts | 24 ++----------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/packages/opencode/test/tool/opencli-tools.test.ts b/packages/opencode/test/tool/opencli-tools.test.ts index 9f4deefdb..c2610b9af 100644 --- a/packages/opencode/test/tool/opencli-tools.test.ts +++ b/packages/opencode/test/tool/opencli-tools.test.ts @@ -10,7 +10,7 @@ import { FakeCdpServer, provideFakeHost, scriptCurrentUrl } from "../fake/cdp-se import { testEffect } from "../lib/effect" import { MessageID, SessionID } from "../../src/session/schema" import { OpenCliRunTool } from "../../src/tool/opencli-run" -import { formatOpenCliSearchOutput, OpenCliSearchTool } from "../../src/tool/opencli-search" +import { OpenCliSearchTool } from "../../src/tool/opencli-search" import type * as Tool from "../../src/tool/tool" import { Truncate } from "../../src/tool/truncate" @@ -46,6 +46,7 @@ describe("opencli_search", () => { 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") @@ -81,27 +82,6 @@ describe("opencli_search", () => { }), ) - test("omits adapter load failures from search results", () => { - const output = (formatOpenCliSearchOutput as unknown as ( - results: Parameters[0], - failedModules: Array<{ modulePath: string; error: string }>, - ) => string)( - [ - { - name: "pawwork-test/search", - description: "Search test adapter", - access: "read", - browser: false, - args: [], - }, - ], - [{ modulePath: "broken.js", error: "boom" }], - ) - - expect(output).toContain('') - expect(output).not.toContain("Warning:") - expect(output).not.toContain("broken.js") - }) }) describe("opencli_run", () => { From ba930366a5913cf4afb2f4902aac58edfe189599 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 17:21:09 +0800 Subject: [PATCH 38/44] refactor(opencli): reuse command summary metadata --- packages/opencode/src/tool/opencli-run.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/opencode/src/tool/opencli-run.ts b/packages/opencode/src/tool/opencli-run.ts index 0267726cd..fa1b11648 100644 --- a/packages/opencode/src/tool/opencli-run.ts +++ b/packages/opencode/src/tool/opencli-run.ts @@ -101,15 +101,7 @@ function runBrowserCommand(command: OpenCliCommand, args: Record 0 ? patterns : undefined, - metadata: commandMetadata({ - name: fullName(command), - description: command.description ?? "", - access: command.access, - browser: command.browser !== false, - domain: command.domain, - navigateBefore: command.navigateBefore, - args: command.args, - }), + metadata: commandMetadata(openCliCommandSummaryFromCommand(command)), timeoutMs: OPENCLI_RUN_TIMEOUT_MS, run: (page) => runOpenCliAdapterCommand(command, page, args), }) From caba1b47533aea83c78b72dd671f4fd745cc6f46 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 17:57:05 +0800 Subject: [PATCH 39/44] fix(opencli): tighten browser adapter boundaries --- .../opencode/src/opencli/adapter-runner.ts | 29 +++++++------ packages/opencode/src/tool/opencli-run.ts | 8 +--- .../test/opencli/adapter-runner.test.ts | 41 +++++++++++++++++++ .../opencode/test/tool/opencli-tools.test.ts | 4 +- 4 files changed, 62 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/opencli/adapter-runner.ts b/packages/opencode/src/opencli/adapter-runner.ts index dea11ce28..ca4f865f4 100644 --- a/packages/opencode/src/opencli/adapter-runner.ts +++ b/packages/opencode/src/opencli/adapter-runner.ts @@ -185,20 +185,25 @@ export async function runOpenCliAdapterCommand( const debug = options.debug ?? false const siteSession = resolveOpenCliSiteSession(cmd) const adapterPage = page ? createOpenCliAdapterPage(cmd, page) : null - const preNavUrl = resolveOpenCliPreNav(cmd) - if (preNavUrl) { - if (!adapterPage) throw new OpenCliCommandError(`Command ${fullName(cmd)} requires a browser session for pre-navigation`) - if (await shouldRunOpenCliPreNav(cmd, adapterPage, siteSession, preNavUrl)) { - await adapterPage.goto(preNavUrl) + const resetAfter = cmd.browser !== false && siteSession === "ephemeral" && adapterPage + try { + const preNavUrl = resolveOpenCliPreNav(cmd) + if (preNavUrl) { + if (!adapterPage) throw new OpenCliCommandError(`Command ${fullName(cmd)} requires a browser session for pre-navigation`) + if (await shouldRunOpenCliPreNav(cmd, adapterPage, siteSession, preNavUrl)) { + await adapterPage.goto(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) await adapterPage.goto("about:blank").catch(() => undefined) } - 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`) } export * as AdapterRunner from "./adapter-runner" diff --git a/packages/opencode/src/tool/opencli-run.ts b/packages/opencode/src/tool/opencli-run.ts index fa1b11648..8eeef6e83 100644 --- a/packages/opencode/src/tool/opencli-run.ts +++ b/packages/opencode/src/tool/opencli-run.ts @@ -21,12 +21,8 @@ export const Parameters = Schema.Struct({ function commandKnownBrowserPermissionPatterns(command: Pick): string[] { if (typeof command.navigateBefore !== "string") return [] const patterns: string[] = [] - try { - patterns.push(`${new URL(command.navigateBefore).origin}/*`) - } catch { - patterns.push(command.navigateBefore) - } - if (command.domain) patterns.push(`https://${command.domain}/*`) + patterns.push(command.navigateBefore) + if (command.domain) patterns.push(`https://${command.domain}/`) return [...new Set(patterns)] } diff --git a/packages/opencode/test/opencli/adapter-runner.test.ts b/packages/opencode/test/opencli/adapter-runner.test.ts index c99b4464c..0852014a0 100644 --- a/packages/opencode/test/opencli/adapter-runner.test.ts +++ b/packages/opencode/test/opencli/adapter-runner.test.ts @@ -94,6 +94,47 @@ describe("opencli adapter runner", () => { expect(func).toHaveBeenCalledWith(expect.objectContaining({ goto: expect.any(Function) }), { query: "pawwork" }, false) }) + 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", diff --git a/packages/opencode/test/tool/opencli-tools.test.ts b/packages/opencode/test/tool/opencli-tools.test.ts index c2610b9af..3f1cbec59 100644 --- a/packages/opencode/test/tool/opencli-tools.test.ts +++ b/packages/opencode/test/tool/opencli-tools.test.ts @@ -133,7 +133,7 @@ describe("opencli_run", () => { }), ) - it.live("asks for origin and domain browser permissions for pre-navigation commands", () => + it.live("asks for concrete pre-navigation browser permission targets", () => Effect.gen(function* () { cli({ site: "pawwork-test", @@ -171,7 +171,7 @@ describe("opencli_run", () => { }) expect(askLog[1]).toMatchObject({ permission: "browser", - patterns: ["https://auth.example.com/*", "https://example.com/*"], + patterns: ["https://auth.example.com/login", "https://example.com/"], }) expect(askLog).toHaveLength(2) } finally { From c5351a5b1c3ab7e9d3216bb3b39b51889ae65dff Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 17:57:12 +0800 Subject: [PATCH 40/44] test(opencli): pin global registry contract --- .../opencode/test/opencli/opencli-contract.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 packages/opencode/test/opencli/opencli-contract.test.ts 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) + }) +}) From 0c8edf1b2d622945534c998a1e930404a32c04ea Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 18:33:06 +0800 Subject: [PATCH 41/44] fix(opencli): enforce adapter execution boundaries --- .../opencode/src/opencli/adapter-registry.ts | 12 +- .../opencode/src/opencli/adapter-runner.ts | 155 +++++++++++++++++- packages/opencode/src/tool/opencli-run.ts | 18 +- .../test/opencli/adapter-capabilities.test.ts | 12 +- .../test/opencli/adapter-registry.test.ts | 8 +- .../test/opencli/adapter-runner.test.ts | 98 +++++++++++ .../opencode/test/tool/opencli-tools.test.ts | 118 +++++++++---- 7 files changed, 362 insertions(+), 59 deletions(-) diff --git a/packages/opencode/src/opencli/adapter-registry.ts b/packages/opencode/src/opencli/adapter-registry.ts index 8701e5b4e..20a35afda 100644 --- a/packages/opencode/src/opencli/adapter-registry.ts +++ b/packages/opencode/src/opencli/adapter-registry.ts @@ -74,6 +74,10 @@ function manifestCommandSummary(entry: OpenCliManifestEntry): OpenCliCommandSumm } } +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), @@ -104,11 +108,13 @@ async function manifestEntryForCommand(name: string) { export async function openCliCommand(name: string): Promise { if (BLOCKED_OPENCLI_COMMANDS.has(name)) return undefined const existing = getRegistry().get(name) - if (existing) return existing + 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) - return getRegistry().get(name) + const loaded = getRegistry().get(name) + return loaded && supportedOpenCliCommand(openCliCommandSummaryFromCommand(loaded)) ? loaded : undefined } function scoreCommand(command: OpenCliCommandSummary, query: string) { @@ -142,7 +148,7 @@ export async function searchOpenCliCommands( if (!summaries.has(summary.name)) summaries.set(summary.name, summary) } return [...summaries.values()] - .filter((command) => !BLOCKED_OPENCLI_COMMANDS.has(command.name)) + .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)) diff --git a/packages/opencode/src/opencli/adapter-runner.ts b/packages/opencode/src/opencli/adapter-runner.ts index ca4f865f4..923e60686 100644 --- a/packages/opencode/src/opencli/adapter-runner.ts +++ b/packages/opencode/src/opencli/adapter-runner.ts @@ -25,6 +25,85 @@ function readNodeID(value: unknown, key: string): number | null { 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 cdpSetFileInput(cmd: CliCommand, page: IPage, files: string[], selector = 'input[type="file"]') { const cdp = page.cdp if (typeof cdp !== "function") { @@ -59,29 +138,86 @@ async function cdpNativeClick(cmd: CliCommand, page: IPage, x: number, y: number await cdp.call(page, "Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", clickCount: 1 }) } -export function createOpenCliAdapterPage(cmd: CliCommand, page: IPage): IPage { +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) => cdpSetFileInput(cmd, target, files, selector) + 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) => cdpInsertText(cmd, target, text) + 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" ? value.bind(target) : (text: string) => cdpInsertText(cmd, target, text) + 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" ? value.bind(target) : (x: number, y: number) => cdpNativeClick(cmd, target, x, y) + 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 }, }) @@ -180,18 +316,19 @@ export async function runOpenCliAdapterCommand( cmd: CliCommand, page: IPage | null, kwargs: CommandArgs, - options: { debug?: boolean } = {}, + options: { debug?: boolean; askBrowserPermission?: BrowserPermissionCheck } = {}, ): Promise { const debug = options.debug ?? false const siteSession = resolveOpenCliSiteSession(cmd) - const adapterPage = page ? createOpenCliAdapterPage(cmd, page) : null + const adapterPage = page ? createOpenCliAdapterPage(cmd, page, options) : null const resetAfter = cmd.browser !== false && siteSession === "ephemeral" && adapterPage try { const preNavUrl = resolveOpenCliPreNav(cmd) if (preNavUrl) { - if (!adapterPage) throw new OpenCliCommandError(`Command ${fullName(cmd)} requires a browser session for pre-navigation`) + if (!page || !adapterPage) + throw new OpenCliCommandError(`Command ${fullName(cmd)} requires a browser session for pre-navigation`) if (await shouldRunOpenCliPreNav(cmd, adapterPage, siteSession, preNavUrl)) { - await adapterPage.goto(preNavUrl) + await page.goto(preNavUrl) } } if (cmd.func) { diff --git a/packages/opencode/src/tool/opencli-run.ts b/packages/opencode/src/tool/opencli-run.ts index 8eeef6e83..dac0586ef 100644 --- a/packages/opencode/src/tool/opencli-run.ts +++ b/packages/opencode/src/tool/opencli-run.ts @@ -4,7 +4,7 @@ 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 { runBrowserAction } from "./browser-shared" +import { browserAlwaysPatterns, runBrowserAction } from "./browser-shared" const OPENCLI_RUN_TIMEOUT_MS = 60_000 type OpenCliCommand = NonNullable>> @@ -93,13 +93,25 @@ async function runNonBrowserCommand( 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: commandMetadata(openCliCommandSummaryFromCommand(command)), + metadata, timeoutMs: OPENCLI_RUN_TIMEOUT_MS, - run: (page) => runOpenCliAdapterCommand(command, page, args), + run: (page) => + runOpenCliAdapterCommand(command, page, args, { + askBrowserPermission: (targets, guardMetadata) => + Effect.runPromise( + ctx.ask({ + permission: "browser", + patterns: targets, + always: browserAlwaysPatterns(targets), + metadata: { ...metadata, ...guardMetadata }, + }), + ), + }), }) } diff --git a/packages/opencode/test/opencli/adapter-capabilities.test.ts b/packages/opencode/test/opencli/adapter-capabilities.test.ts index abe8887f9..6b9077a50 100644 --- a/packages/opencode/test/opencli/adapter-capabilities.test.ts +++ b/packages/opencode/test/opencli/adapter-capabilities.test.ts @@ -18,6 +18,7 @@ const SUPPORTED_PAGE_METHODS = new Set([ "autoScroll", "cdp", "click", + "closeWindow", "consoleMessages", "dblClick", "drag", @@ -58,14 +59,6 @@ const SUPPORTED_PAGE_METHODS = new Set([ ]) const ACCEPTED_CAPABILITY_GAPS: CapabilityGap[] = [ - // instagram/post checks input.page.closeWindow before calling it; without the method, - // it falls back to dialog cleanup instead of failing the adapter command. - { - command: "instagram/post", - modulePath: "instagram/post.js", - kind: "page-method", - value: "closeWindow", - }, { command: "instagram/reel", modulePath: "instagram/reel.js", @@ -174,10 +167,11 @@ async function scanCapabilities(): Promise { } describe("opencli adapter capability guard", () => { - test("keeps unsupported page methods and daemon Page imports pinned to an explicit baseline", async () => { + 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 index f3457a492..9b47e9c2e 100644 --- a/packages/opencode/test/opencli/adapter-registry.test.ts +++ b/packages/opencode/test/opencli/adapter-registry.test.ts @@ -12,12 +12,12 @@ describe("opencli adapter registry", () => { }) test("searches the packaged manifest without importing adapter modules", async () => { - getRegistry().delete("spotify/play") + getRegistry().delete("hackernews/search") - const results = await searchOpenCliCommands("spotify play", { limit: 5 }) + const results = await searchOpenCliCommands("hackernews search", { limit: 5 }) - expect(results.map((result) => result.name)).toContain("spotify/play") - expect(getRegistry().has("spotify/play")).toBe(false) + 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 () => { diff --git a/packages/opencode/test/opencli/adapter-runner.test.ts b/packages/opencode/test/opencli/adapter-runner.test.ts index 0852014a0..edc506c36 100644 --- a/packages/opencode/test/opencli/adapter-runner.test.ts +++ b/packages/opencode/test/opencli/adapter-runner.test.ts @@ -94,6 +94,104 @@ describe("opencli adapter runner", () => { expect(func).toHaveBeenCalledWith(expect.objectContaining({ goto: expect.any(Function) }), { query: "pawwork" }, false) }) + test("asks browser permission before adapter-initiated navigation", async () => { + 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("https://example.com/admin/users") + return "done" + }, + } satisfies CliCommand + const asked: string[][] = [] + + await expect( + runOpenCliAdapterCommand(command, page as unknown as IPage, {}, { + askBrowserPermission: async (patterns) => { + asked.push(patterns) + if (patterns.includes("https://example.com/admin/users")) throw new Error("denied admin") + }, + }), + ).rejects.toThrow("denied admin") + + expect(page.goto).not.toHaveBeenCalledWith("https://example.com/admin/users") + expect(asked).toContainEqual(["https://example.com/admin/users"]) + }) + + test("rechecks browser permission after adapter actions that can move the page", async () => { + let currentUrl = "https://example.com/safe" + const page = { + click: mock(async () => { + currentUrl = "https://example.com/admin/users" + 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 (patterns.includes("https://example.com/admin/users")) 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 cdp = mock(async () => ({})) + const page = { + cdp, + getCurrentUrl: mock(async () => "https://example.com/admin/upload"), + 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 (patterns.includes("https://example.com/admin/upload")) 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 () => {}), diff --git a/packages/opencode/test/tool/opencli-tools.test.ts b/packages/opencode/test/tool/opencli-tools.test.ts index 3f1cbec59..48c7ee6f1 100644 --- a/packages/opencode/test/tool/opencli-tools.test.ts +++ b/packages/opencode/test/tool/opencli-tools.test.ts @@ -82,6 +82,28 @@ describe("opencli_search", () => { }), ) + 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", () => { @@ -181,6 +203,45 @@ describe("opencli_run", () => { }), ) + it.live("applies browser permission to adapter-initiated navigation", () => + Effect.gen(function* () { + const server = new FakeCdpServer() + scriptCurrentUrl(server, "https://example.com/page") + provideFakeHost(server) + 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 { + const exit = yield* exec(OpenCliRunTool, { command: "pawwork-test/internal-nav-permission", args: {} }, { + ask: (input) => + input.permission === "browser" && input.patterns.includes("https://example.com/admin/users") + ? (Effect.fail(new Error("denied admin")) as unknown as Effect.Effect) + : Effect.void, + }).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + expect(server.methods).not.toContain("Page.navigate") + } finally { + resetBrowserSessionsForTest() + BrowserBridge.provideHost(null) + getRegistry().delete("pawwork-test/internal-nav-permission") + yield* Effect.promise(() => server.close()) + } + }), + ) + it.live("formats an undefined browser write result as empty output", () => Effect.gen(function* () { const server = new FakeCdpServer() @@ -247,8 +308,9 @@ describe("opencli_run", () => { }), ) - it.live("asks before running a write non-browser adapter", () => + it.live("rejects write non-browser adapters before asking or running", () => Effect.gen(function* () { + let ran = false cli({ site: "pawwork-test", name: "write-http", @@ -256,25 +318,24 @@ describe("opencli_run", () => { description: "Write non-browser test adapter", browser: false, args: [{ name: "query", required: true }], - func: async (args) => [{ written: args.query }], + func: async (args) => { + ran = true + return [{ written: args.query }] + }, }) try { const askLog: Parameters[0][] = [] - const result = yield* exec(OpenCliRunTool, { command: "pawwork-test/write-http", args: { query: "hello" } }, { + const exit = yield* exec(OpenCliRunTool, { command: "pawwork-test/write-http", args: { query: "hello" } }, { ask: (input) => Effect.sync(() => { askLog.push(input) }), - }) + }).pipe(Effect.exit) - expect(askLog[0]).toMatchObject({ - permission: "opencli_write", - patterns: ["pawwork-test/write-http"], - always: ["pawwork-test/write-http"], - }) - expect(askLog).toHaveLength(1) - expect(result.output).toContain('"written": "hello"') + expect(Exit.isFailure(exit)).toBe(true) + expect(askLog).toEqual([]) + expect(ran).toBe(false) } finally { getRegistry().delete("pawwork-test/write-http") } @@ -288,10 +349,14 @@ describe("opencli_run", () => { name: "write-defaults", access: "write", description: "Write default args test adapter", - browser: false, + browser: true, + domain: "example.com", args: [{ name: "mode", default: "safe" }], - func: async (args) => [{ args }], + func: async (_page, args) => [{ args }], }) + const server = new FakeCdpServer() + scriptCurrentUrl(server, "https://example.com/page") + provideFakeHost(server) try { const askLog: Parameters[0][] = [] @@ -308,9 +373,11 @@ describe("opencli_run", () => { args: { mode: "safe" }, }, }) - expect(askLog).toHaveLength(1) } finally { + resetBrowserSessionsForTest() + BrowserBridge.provideHost(null) getRegistry().delete("pawwork-test/write-defaults") + yield* Effect.promise(() => server.close()) } }), ) @@ -370,12 +437,10 @@ describe("opencli_run", () => { }), ) - it.live("warns when a canceled write non-browser adapter may still be running", () => + it.live("does not start write non-browser adapters that would outlive cancellation", () => Effect.gen(function* () { - const started = yield* Deferred.make() - const release = yield* Deferred.make() const controller = new AbortController() - let completed = false + let started = false cli({ site: "pawwork-test", name: "slow-write-http", @@ -384,29 +449,20 @@ describe("opencli_run", () => { browser: false, args: [], func: async () => { - Effect.runFork(Deferred.succeed(started, undefined)) - await Effect.runPromise(Deferred.await(release)) - completed = true + started = true return [] }, }) try { - const fiber = yield* exec(OpenCliRunTool, { command: "pawwork-test/slow-write-http", args: {} }, { + const exit = yield* exec(OpenCliRunTool, { command: "pawwork-test/slow-write-http", args: {} }, { abort: controller.signal, - }).pipe(Effect.forkChild) - yield* Deferred.await(started) + }).pipe(Effect.exit) controller.abort() - const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) - expect(completed).toBe(false) - if (Exit.isFailure(exit)) { - const error = Cause.squash(exit.cause) - expect(error instanceof Error ? error.message : String(error)).toContain("may still be running") - } + expect(started).toBe(false) } finally { - yield* Deferred.succeed(release, undefined).pipe(Effect.ignore) getRegistry().delete("pawwork-test/slow-write-http") } }), From 8e8472c6102351f1370e69f8938c6c0a3c322595 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 18:33:17 +0800 Subject: [PATCH 42/44] docs(opencli): narrow adapter coverage claims --- README.md | 6 +++--- README_CN.md | 6 +++--- THIRD_PARTY_NOTICES.md | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3d8e1f16b..d086a22d8 100644 --- a/README.md +++ b/README.md @@ -34,7 +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 (1000+ site adapters via OpenCLI) | No | No | +| 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 | @@ -67,7 +67,7 @@ PawWork is built for people who want AI agents to do real desktop work, not only - 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 web services through your existing logged-in browser session +- interact with supported web services through PawWork's browser tools ## How It Works @@ -110,7 +110,7 @@ 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 site adapters. OpenCLI provides the CDP bridge and 1000+ site-specific command adapters that let PawWork interact with web services like Twitter/X, Bilibili, Xiaohongshu, and more using the user's logged-in browser session. Thanks to @jackwener for the Apache-2.0 open-source OpenCLI 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 diff --git a/README_CN.md b/README_CN.md index 33247484f..d29e1e47b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -36,7 +36,7 @@ Codex App 和 Claude Cowork 的开源替代方案。自带免费额度,也支 | 免费无需订阅 | 有(OpenCode Zen) | 有限(ChatGPT Free) | 无(需 Pro $20/月) | | 桌面应用 | macOS + Windows | macOS + Windows | macOS + Windows | | 本地文件访问 | 完整工作区 | 默认沙箱 | 用户选择的文件夹 | -| 浏览器自动化 | 支持(通过 OpenCLI 提供 1000+ 站点适配器) | 不支持 | 不支持 | +| 浏览器自动化 | 支持(OpenCLI 支持站点适配器,覆盖范围因站点而异) | 不支持 | 不支持 | | 本地模型 | 支持(Ollama、LM Studio 等) | 仅 CLI 支持(Ollama) | 通过网关(Requesty) | | Office 文件处理 | 支持(Word/Excel/PPT) | 不支持 | 不支持 | | 面向非技术用户 | 是(任务卡片,无需终端) | 面向开发者 | 知识工作 + 编程 | @@ -69,7 +69,7 @@ Codex App 和 Claude Cowork 的开源替代方案。自带免费额度,也支 - 搜索 Twitter/X,并在爪印里起草回复 - 查看 Bilibili 或小红书的热门内容 - 收集受支持网页服务里的帖子、评论或媒体 -- 通过你已经登录的浏览器会话操作网页服务 +- 通过爪印的浏览器工具操作受支持的网页服务 ## 工作方式 @@ -112,7 +112,7 @@ 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 和 1000+ 站点命令适配器,让爪印可以通过用户已登录的浏览器会话与 Twitter/X、Bilibili、小红书等网页服务交互。感谢 @jackwener 以 Apache-2.0 开源 OpenCLI 项目。 +爪印 PawWork 内置 [@jackwener](https://github.com/jackwener) 的 [OpenCLI](https://github.com/jackwener/opencli),用于支持基于浏览器的站点适配器。OpenCLI 提供 CDP bridge 和随包命令适配器,在适配覆盖范围匹配任务时,帮助爪印与 Twitter/X、Bilibili、小红书等网页服务交互。感谢 @jackwener 以 Apache-2.0 开源 OpenCLI 项目。 ## 常见问题 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 84b480569..2f6b60dc7 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -11,7 +11,7 @@ PawWork also bundles OfficeCLI's `officecli-*` and `morph-ppt*` skill directorie ## OpenCLI -PawWork bundles `@jackwener/opencli` by @jackwener to power browser-based site adapters through the user's logged-in browser session. +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`. From 8ad35997b90e509c2cae18c07b9ba008f65f1faf Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 19:07:31 +0800 Subject: [PATCH 43/44] fix(opencli): recheck browser adapter landings --- .../opencode/src/opencli/adapter-runner.ts | 24 +++- packages/opencode/test/fake/cdp-server.ts | 2 + .../test/opencli/adapter-runner.test.ts | 33 +++-- .../opencode/test/tool/opencli-tools.test.ts | 126 +++++++++++++++++- 4 files changed, 169 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/opencli/adapter-runner.ts b/packages/opencode/src/opencli/adapter-runner.ts index 923e60686..5cd9fa987 100644 --- a/packages/opencode/src/opencli/adapter-runner.ts +++ b/packages/opencode/src/opencli/adapter-runner.ts @@ -104,6 +104,25 @@ async function withTargetBrowserPermission( 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") { @@ -321,7 +340,7 @@ export async function runOpenCliAdapterCommand( const debug = options.debug ?? false const siteSession = resolveOpenCliSiteSession(cmd) const adapterPage = page ? createOpenCliAdapterPage(cmd, page, options) : null - const resetAfter = cmd.browser !== false && siteSession === "ephemeral" && adapterPage + const resetAfter = cmd.browser !== false && siteSession === "ephemeral" && page !== null try { const preNavUrl = resolveOpenCliPreNav(cmd) if (preNavUrl) { @@ -329,6 +348,7 @@ export async function runOpenCliAdapterCommand( 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) { @@ -339,7 +359,7 @@ export async function runOpenCliAdapterCommand( 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) await adapterPage.goto("about:blank").catch(() => undefined) + if (resetAfter && page) await page.goto("about:blank").catch(() => undefined) } } 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/opencli/adapter-runner.test.ts b/packages/opencode/test/opencli/adapter-runner.test.ts index edc506c36..96318b357 100644 --- a/packages/opencode/test/opencli/adapter-runner.test.ts +++ b/packages/opencode/test/opencli/adapter-runner.test.ts @@ -9,6 +9,20 @@ import { 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) @@ -95,6 +109,7 @@ describe("opencli adapter runner", () => { }) 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"), @@ -107,7 +122,7 @@ describe("opencli adapter runner", () => { browser: true, args: [], func: async (adapterPage: IPage) => { - await adapterPage.goto("https://example.com/admin/users") + await adapterPage.goto(adminUsersUrl) return "done" }, } satisfies CliCommand @@ -117,20 +132,21 @@ describe("opencli adapter runner", () => { runOpenCliAdapterCommand(command, page as unknown as IPage, {}, { askBrowserPermission: async (patterns) => { asked.push(patterns) - if (patterns.includes("https://example.com/admin/users")) throw new Error("denied admin") + if (hasExactUrlPattern(patterns, adminUsersUrl)) throw new Error("denied admin") }, }), ).rejects.toThrow("denied admin") - expect(page.goto).not.toHaveBeenCalledWith("https://example.com/admin/users") - expect(asked).toContainEqual(["https://example.com/admin/users"]) + 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 = "https://example.com/admin/users" + currentUrl = adminUsersUrl return { matches_n: 1, match_level: "exact" as const } }), getCurrentUrl: mock(async () => currentUrl), @@ -152,7 +168,7 @@ describe("opencli adapter runner", () => { await expect( runOpenCliAdapterCommand(command, page as unknown as IPage, {}, { askBrowserPermission: async (patterns) => { - if (patterns.includes("https://example.com/admin/users")) throw new Error("denied admin") + if (hasExactUrlPattern(patterns, adminUsersUrl)) throw new Error("denied admin") }, }), ).rejects.toThrow("denied admin") @@ -161,10 +177,11 @@ describe("opencli adapter runner", () => { }) 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 () => "https://example.com/admin/upload"), + getCurrentUrl: mock(async () => adminUploadUrl), goto: mock(async () => {}), wait: mock(async () => {}), } @@ -184,7 +201,7 @@ describe("opencli adapter runner", () => { await expect( runOpenCliAdapterCommand(command, page as unknown as IPage, {}, { askBrowserPermission: async (patterns) => { - if (patterns.includes("https://example.com/admin/upload")) throw new Error("denied admin") + if (hasExactUrlPattern(patterns, adminUploadUrl)) throw new Error("denied admin") }, }), ).rejects.toThrow("denied admin") diff --git a/packages/opencode/test/tool/opencli-tools.test.ts b/packages/opencode/test/tool/opencli-tools.test.ts index 48c7ee6f1..ed2c737ad 100644 --- a/packages/opencode/test/tool/opencli-tools.test.ts +++ b/packages/opencode/test/tool/opencli-tools.test.ts @@ -38,6 +38,20 @@ function exec(tool: unknown, args: unknown, ctxOverride: Partial = ) } +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* () { @@ -208,6 +222,13 @@ describe("opencli_run", () => { 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", @@ -224,19 +245,112 @@ describe("opencli_run", () => { }) try { - const exit = yield* exec(OpenCliRunTool, { command: "pawwork-test/internal-nav-permission", args: {} }, { + yield* exec(OpenCliRunTool, { command: "pawwork-test/internal-nav-permission", args: {} }, { ask: (input) => - input.permission === "browser" && input.patterns.includes("https://example.com/admin/users") - ? (Effect.fail(new Error("denied admin")) as unknown as Effect.Effect) - : Effect.void, + 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(server.methods).not.toContain("Page.navigate") + 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/internal-nav-permission") + getRegistry().delete("pawwork-test/redirected-prenav") yield* Effect.promise(() => server.close()) } }), From 222f223375bce7a76bb93f1a0ef3acfeaf439a00 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 12 Jun 2026 19:28:42 +0800 Subject: [PATCH 44/44] refactor(opencli): drop unreachable write interruption branch --- packages/opencode/src/tool/opencli-run.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/tool/opencli-run.ts b/packages/opencode/src/tool/opencli-run.ts index dac0586ef..db98f2e48 100644 --- a/packages/opencode/src/tool/opencli-run.ts +++ b/packages/opencode/src/tool/opencli-run.ts @@ -67,18 +67,14 @@ async function runNonBrowserCommand( ) { const commandName = fullName(command) if (abort.aborted) throw new Error(`OpenCLI ${commandName} was canceled.`) - const writeInterruption = - command.access === "write" - ? " The non-browser write adapter may still be running; check the target before retrying." - : "" 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.${writeInterruption}`)), + () => 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.${writeInterruption}`)) + onAbort = () => reject(new Error(`OpenCLI ${commandName} was canceled.`)) abort.addEventListener("abort", onAbort, { once: true }) }) const running = runOpenCliAdapterCommand(command, null, args)