From 74613e4bc34313c03c5559a578337ef2a519d004 Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Sat, 31 Oct 2026 04:55:24 -0700 Subject: [PATCH] feat(opencode): reuse running OpenCode v2 services (#7435) * feat(opencode): connect v2 client to an existing server * feat(providers): reuse running agent servers Keep shared server sessions isolated and retain native tools while refreshes reuse the running service. * fix(providers): recognize formatted bridge manifests Managed plugin ownership must survive harmless JSON formatting changes. * test(providers): distinguish isolated session bindings Use separate session identities to verify that dedicated helpers keep their environments isolated. --------- Co-authored-by: Mohamed Boudra --- docs/custom-providers.md | 30 +++ docs/providers.md | 2 +- .../scripts/build-opencode-bridge-plugin.mjs | 8 +- .../src/server/agent/provider-runtime.ts | 7 +- .../providers/opencode-bridge-adapter.test.ts | 14 +- .../agent/providers/opencode/bridge.test.ts | 235 +++++++++--------- .../server/agent/providers/opencode/bridge.ts | 20 +- .../providers/opencode/runtime-client.test.ts | 38 +++ .../providers/opencode/runtime-client.ts | 25 ++ .../opencode/test-utils/v2-harness.ts | 3 + .../agent/providers/opencode/v2/agent.test.ts | 20 ++ .../agent/providers/opencode/v2/agent.ts | 21 +- .../providers/opencode/v2/bridge-plugin.mjs | 95 +------ .../opencode/v2/bridge-provisioning.test.ts | 37 +++ .../opencode/v2/bridge-provisioning.ts | 89 +++++++ .../opencode/v2/external-runtime.test.ts | 180 ++++++++++++++ .../opencode/v2/external-server.test.ts | 60 +++++ .../providers/opencode/v2/external-server.ts | 38 +++ .../opencode/v2/owned-runtime.test.ts | 77 ++++++ .../agent/providers/opencode/v2/runtime.ts | 119 ++++++++- .../opencode/v2/service-selection.test.ts | 55 ++++ .../opencode/v2/service-selection.ts | 43 ++++ .../agent/providers/opencode/v2/session.ts | 13 + .../opencode/v2/shared-bridge-plugin.mjs | 207 +++++++++++++++ .../opencode/v2/shared-bridge.test.ts | 168 +++++++++++++ .../providers/opencode/v2/shared-service.ts | 192 ++++++++++++++ .../agent/providers/opencode/v2/state.mjs | 11 + ...ncode-initial-prompt-wait.real.e2e.test.ts | 2 +- .../daemon-e2e/real-provider-test-config.ts | 2 +- 29 files changed, 1578 insertions(+), 233 deletions(-) create mode 100644 packages/server/src/server/agent/providers/opencode/v2/bridge-provisioning.test.ts create mode 100644 packages/server/src/server/agent/providers/opencode/v2/bridge-provisioning.ts create mode 100644 packages/server/src/server/agent/providers/opencode/v2/external-runtime.test.ts create mode 100644 packages/server/src/server/agent/providers/opencode/v2/external-server.test.ts create mode 100644 packages/server/src/server/agent/providers/opencode/v2/external-server.ts create mode 100644 packages/server/src/server/agent/providers/opencode/v2/owned-runtime.test.ts create mode 100644 packages/server/src/server/agent/providers/opencode/v2/service-selection.test.ts create mode 100644 packages/server/src/server/agent/providers/opencode/v2/service-selection.ts create mode 100644 packages/server/src/server/agent/providers/opencode/v2/shared-bridge-plugin.mjs create mode 100644 packages/server/src/server/agent/providers/opencode/v2/shared-bridge.test.ts create mode 100644 packages/server/src/server/agent/providers/opencode/v2/shared-service.ts create mode 100644 packages/server/src/server/agent/providers/opencode/v2/state.mjs diff --git a/docs/custom-providers.md b/docs/custom-providers.md index 615389fe60..1d6d67d453 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -65,6 +65,7 @@ per-agent examples and provider-specific keys. ## Table of Contents - [Extending a built-in provider](#extending-a-built-in-provider) +- [OpenCode with an existing v2 server](#opencode-with-an-existing-v2-server) - [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan) - [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan) - [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint) @@ -107,6 +108,35 @@ See [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai --- +## OpenCode with an existing v2 server + +Paseo first uses an explicitly configured server, then a healthy locally registered OpenCode v2 service, then starts its own helper. It never stops a reused service. To select a server explicitly, set its URL in the provider environment: + +```json +{ + "agents": { + "providers": { + "opencode": { + "env": { + "PASEO_OPENCODE_SERVER_URL": "http://127.0.0.1:4096", + "PASEO_OPENCODE_SERVER_PASSWORD": "" + } + } + } + } +} +``` + +`PASEO_OPENCODE_SERVER_URL` must be an absolute `http` or `https` URL. Omit `PASEO_OPENCODE_SERVER_PASSWORD` when the server has no authentication; when set, Paseo authenticates with HTTP Basic auth as `opencode`. + +A configured URL takes precedence even when unreachable; Paseo reports the connection error. The local service registration supplies its own authentication. For a discovered local service, Paseo installs its bridge package in OpenCode's watched global plugin directory without changing the existing configuration. Refresh reads the existing service's catalogs without starting another server. + +An explicitly configured server needs the bridge installed separately. Connect once and start a session: the setup error gives the local package directory. Copy that directory to the server's OpenCode global `plugins` directory, then retry. For a server on another machine, set `PASEO_OPENCODE_BRIDGE_PORT` in the Paseo OpenCode provider environment before starting Paseo, and set `PASEO_OPENCODE_BRIDGE_URL` to the URL the OpenCode server can use to reach that port. For example, an SSH reverse tunnel can expose the Paseo loopback port on the OpenCode host. The bridge token is exchanged through the OpenCode connection and is not written into the plugin package. + +OpenCode's custom MCP registrations are shared by all sessions in a location. Paseo rejects custom MCP servers and per-agent process environment overrides on a reused server rather than applying them to other sessions or ignoring them. Session identity is applied to each session's shell environment. A Paseo-owned helper supports custom process and MCP settings; it is selected when there is no explicit URL or healthy registered service. + +--- + ## Z.AI (Zhipu) coding plan [Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API. diff --git a/docs/providers.md b/docs/providers.md index 1a43558ae3..b92e171c12 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -89,7 +89,7 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them. -OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving. +OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects versions older than 2.0.10. Paseo selects an explicit server, then a healthy local registered service, then its own helper. It never stops a reused service. A local service loads the Paseo bridge from its watched plugin directory; sessions bind their own bridge credentials through OpenCode RPC. See [OpenCode with an existing v2 server](custom-providers.md#opencode-with-an-existing-v2-server) for setup and isolation limits. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving. Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events. diff --git a/packages/server/scripts/build-opencode-bridge-plugin.mjs b/packages/server/scripts/build-opencode-bridge-plugin.mjs index 69f1e774f0..33cd3402ce 100644 --- a/packages/server/scripts/build-opencode-bridge-plugin.mjs +++ b/packages/server/scripts/build-opencode-bridge-plugin.mjs @@ -1,4 +1,4 @@ -import { mkdir } from "node:fs/promises"; +import { copyFile, mkdir } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -27,4 +27,10 @@ for (const directory of ["", "v2/"]) { target: "es2022", outfile: outputPath, }); + if (directory === "v2/") { + await copyFile( + new URL("../src/server/agent/providers/opencode/v2/state.mjs", import.meta.url), + path.join(path.dirname(outputPath), "state.mjs"), + ); + } } diff --git a/packages/server/src/server/agent/provider-runtime.ts b/packages/server/src/server/agent/provider-runtime.ts index 398d35746d..c266614273 100644 --- a/packages/server/src/server/agent/provider-runtime.ts +++ b/packages/server/src/server/agent/provider-runtime.ts @@ -22,7 +22,12 @@ interface CreateAgentProviderRuntimeOptions { export async function createAgentProviderRuntime( options: CreateAgentProviderRuntimeOptions, ): Promise { - const bridge = new OpenCodeBridge({ paseoHome: options.paseoHome, logger: options.logger }); + const configuredPort = + options.snapshotManager.runtimeSettings?.opencode?.env?.PASEO_OPENCODE_BRIDGE_PORT; + const port = configuredPort === undefined ? undefined : Number(configuredPort); + if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535)) + throw new Error("PASEO_OPENCODE_BRIDGE_PORT must be an integer between 1 and 65535"); + const bridge = new OpenCodeBridge({ paseoHome: options.paseoHome, logger: options.logger, port }); try { await bridge.start(); const snapshotManager = new ProviderSnapshotManager({ diff --git a/packages/server/src/server/agent/providers/opencode-bridge-adapter.test.ts b/packages/server/src/server/agent/providers/opencode-bridge-adapter.test.ts index 133ac3c992..6cb15ccb6d 100644 --- a/packages/server/src/server/agent/providers/opencode-bridge-adapter.test.ts +++ b/packages/server/src/server/agent/providers/opencode-bridge-adapter.test.ts @@ -80,8 +80,12 @@ describe("OpenCode bridge adapter", () => { await rm(paseoHome, { recursive: true, force: true }); }); const runtime = new TestOpenCodeHarness(); - runtime.enqueueClient(new TestOpenCodeClient()); - runtime.enqueueClient(new TestOpenCodeClient()); + const envClient = new TestOpenCodeClient(); + const mcpClient = new TestOpenCodeClient(); + envClient.sessionCreateResponse = { data: { id: "ses_env" } }; + mcpClient.sessionCreateResponse = { data: { id: "ses_mcp" } }; + runtime.enqueueClient(envClient); + runtime.enqueueClient(mcpClient); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { serverManager: runtime, createClient: runtime.createClient, @@ -102,6 +106,12 @@ describe("OpenCode bridge adapter", () => { ); expect(runtime.acquisitions.map(({ kind }) => kind)).toEqual(["dedicated", "dedicated"]); + await expect(readBridgeContext(bridge, "ses_env")).resolves.toEqual({ + env: { PASEO_AGENT_ID: "one", CUSTOM_TOKEN: "secret" }, + }); + await expect(readBridgeContext(bridge, "ses_mcp")).resolves.toEqual({ + env: { PASEO_AGENT_ID: "two" }, + }); await customEnv.close(); await customMcp.close(); }); diff --git a/packages/server/src/server/agent/providers/opencode/bridge.test.ts b/packages/server/src/server/agent/providers/opencode/bridge.test.ts index 33f43c4f79..468baabe14 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.test.ts @@ -20,6 +20,7 @@ import { createPaseoAgentToolManifest, createPaseoToolCatalog } from "../../tool import { serializePaseoToolInputParameters } from "../../tools/paseo-tool-serialization.js"; import { OpenCodeBridge, + decorateOpenCodeV2Env, loadOpenCodeBridgePluginArtifact, materializeOpenCodeV2Plugin, } from "./bridge.js"; @@ -137,33 +138,28 @@ describe("OpenCodeBridge", () => { }; expect(ajv.compile(createDefinition.inputSchema)(createInput)).toBe(true); - const v2Config = z - .object({ plugins: z.array(z.object({ package: z.string() })) }) - .parse(JSON.parse((await bridge.decorateV2ServerEnv({})).OPENCODE_CONFIG_CONTENT)); - const v2Module: { - default: { setup(context: V2TestPluginContext): Promise<() => Promise> }; - } = await import( - pathToFileURL(path.join(fileURLToPath(v2Config.plugins[0]!.package), "server.js")).href - ); const tools = new Map(); - const dispose = await v2Module.default.setup({ - options: plugin, - tool: { - transform: async (transform) => { - transform({ - add: (tool) => { - tools.set(tool.name, tool); - }, - }); - return { dispose: async () => undefined }; + const dispose = await setupBoundV2Plugin( + bridge, + { + tool: { + transform: async (transform) => { + transform({ + add: (tool) => { + tools.set(tool.name, tool); + }, + }); + return { dispose: async () => undefined }; + }, + }, + session: { + context: async () => [], + get: async () => ({ parentID: "bound" }), + hook: async () => ({ dispose: async () => undefined }), }, }, - session: { - context: async () => [], - get: async () => ({ parentID: "bound" }), - hook: async () => ({ dispose: async () => undefined }), - }, - }); + ["bound"], + ); try { const created = await tools .get("paseo_create_agent")! @@ -402,45 +398,35 @@ describe("OpenCodeBridge", () => { const release = bridge.bindSession({ sessionId: "parent", env: {}, tools: catalog }); const releaseDisabled = bridge.bindSession({ sessionId: "disabled", env: {} }); try { - const env = await bridge.decorateV2ServerEnv({}); - const config = z - .object({ - plugins: z.array( - z.object({ - package: z.string(), - options: z.object({ baseUrl: z.string(), token: z.string() }), - }), - ), - }) - .parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT)); - expect(await bridge.decorateV2ServerEnv(env)).toEqual(env); - const plugin = config.plugins[0]!; + const pluginUrl = await bridge.materializeV2Plugin(); + const env = decorateOpenCodeV2Env({}, pluginUrl); + expect(decorateOpenCodeV2Env(env, pluginUrl)).toEqual(env); const tools = new Map(); let filter!: (input: V2TestContext) => Promise; - const module: { - default: { setup(context: V2TestPluginContext): Promise<() => Promise> }; - } = await import(pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href); - const dispose = await module.default.setup({ - options: plugin.options, - tool: { - transform: async (transform) => { - transform({ - add: (tool) => { - tools.set(tool.name, tool); - }, - }); - return { dispose: async () => undefined }; + const dispose = await setupBoundV2Plugin( + bridge, + { + tool: { + transform: async (transform) => { + transform({ + add: (tool) => { + tools.set(tool.name, tool); + }, + }); + return { dispose: async () => undefined }; + }, + }, + session: { + context: async () => [], + get: async ({ sessionID }) => ({ parentID: sessionID === "child" ? "parent" : "" }), + hook: async (_name, callback) => { + filter = callback; + return { dispose: async () => undefined }; + }, }, }, - session: { - context: async () => [], - get: async () => ({ parentID: "parent" }), - hook: async (_name, callback) => { - filter = callback; - return { dispose: async () => undefined }; - }, - }, - }); + ["parent", "disabled"], + ); const allowed: V2TestContext = { sessionID: "child", tools: { paseo_echo_context: {}, native: {} }, @@ -453,6 +439,12 @@ describe("OpenCodeBridge", () => { }; await filter(disabled); expect(Object.keys(disabled.tools)).toEqual(["native"]); + const unrelated: V2TestContext = { + sessionID: "unrelated", + tools: { paseo_echo_context: {}, native: {} }, + }; + await filter(unrelated); + expect(Object.keys(unrelated.tools)).toEqual(["native"]); await expect( tools.get("paseo_echo_context")!.execute({ value: "child result" }, { sessionID: "child" }), ).resolves.toMatchObject({ content: [{ type: "text", text: "child result" }] }); @@ -486,35 +478,33 @@ describe("OpenCodeBridge", () => { await bridge.start(); const release = bridge.bindSession({ sessionId: "caller", env: {}, tools: catalog }); try { - const env = await bridge.decorateV2ServerEnv({}); - const { plugins } = JSON.parse(env.OPENCODE_CONFIG_CONTENT); - const plugin = plugins[0]; - const module = await import( - pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href - ); + const options = bridge.sharedV2Connection(); const tools = new Map(); - const dispose = await module.default.setup({ - options: plugin.options, - tool: { - transform: async (transform: (editor: { add(tool: V2TestTool): void }) => void) => { - transform({ add: (tool) => tools.set(tool.name, tool) }); - return { dispose: async () => undefined }; + const dispose = await setupBoundV2Plugin( + bridge, + { + tool: { + transform: async (transform: (editor: { add(tool: V2TestTool): void }) => void) => { + transform({ add: (tool) => tools.set(tool.name, tool) }); + return { dispose: async () => undefined }; + }, + }, + session: { + context: async () => [], + get: async () => ({ parentID: "" }), + hook: async () => ({ dispose: async () => undefined }), }, }, - session: { - context: async () => [], - get: async () => ({ parentID: "" }), - hook: async () => ({ dispose: async () => undefined }), - }, - } satisfies V2TestPluginContext); + ["caller"], + ); try { const input = { provider: "codex", title: "Invalid provider", initialPrompt: "Hello" }; const response = await fetch( - `${plugin.options.baseUrl}/_internal/opencode/sessions/caller/tools/create_agent`, + `${options.baseUrl}/_internal/opencode/sessions/caller/tools/create_agent`, { method: "POST", headers: { - Authorization: `Bearer ${plugin.options.token}`, + Authorization: `Bearer ${options.token}`, "Content-Type": "application/json", }, body: JSON.stringify(input), @@ -572,35 +562,24 @@ describe("OpenCodeBridge", () => { await bridge.start(); const release = bridge.bindSession({ sessionId: "session", env: {}, tools: catalog }); try { - const env = await bridge.decorateV2ServerEnv({}); - const plugin = z - .object({ - plugins: z.array( - z.object({ - package: z.string(), - options: z.object({ baseUrl: z.string(), token: z.string() }), - }), - ), - }) - .parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT)).plugins[0]!; const pluginTools = new Map(); - const module: { - default: { setup(context: V2TestPluginContext): Promise<() => Promise> }; - } = await import(pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href); - const dispose = await module.default.setup({ - options: plugin.options, - tool: { - transform: async (transform) => { - transform({ add: (tool) => pluginTools.set(tool.name, tool) }); - return { dispose: async () => undefined }; + const dispose = await setupBoundV2Plugin( + bridge, + { + tool: { + transform: async (transform) => { + transform({ add: (tool) => pluginTools.set(tool.name, tool) }); + return { dispose: async () => undefined }; + }, + }, + session: { + context: async () => [], + get: async () => ({ parentID: undefined }), + hook: async () => ({ dispose: async () => undefined }), }, }, - session: { - context: async () => [], - get: async () => ({ parentID: undefined }), - hook: async () => ({ dispose: async () => undefined }), - }, - }); + ["session"], + ); const result = await pluginTools .get("paseo_browser_screenshot")! .execute({}, { sessionID: "session" }); @@ -642,7 +621,8 @@ describe("OpenCodeBridge", () => { const tools = new Map(); let hook!: (input: V2TestContext) => Promise; const dispose = await module.default.setup({ - options: { baseUrl: "", token: "" }, + options: { mode: "structured-only" }, + location: { directory: root }, tool: { transform: async (transform) => { transform({ @@ -657,7 +637,7 @@ describe("OpenCodeBridge", () => { context: async () => history, get: async () => ({ parentID: "parent" }), hook: async (_name, callback) => { - hook = callback; + hook ??= callback; return { dispose: async () => undefined }; }, }, @@ -701,9 +681,13 @@ describe("OpenCodeBridge", () => { name.startsWith("paseo-v2-"), ), ).toEqual([]); - const env = await bridge.decorateV2ServerEnv({ - OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugins: ["user-plugin"] }), - }); + const pluginUrl = await bridge.materializeV2Plugin(); + const env = decorateOpenCodeV2Env( + { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugins: ["user-plugin"] }), + }, + pluginUrl, + ); const config = z .object({ plugins: z.tuple([z.literal("user-plugin"), z.object({ package: z.string() })]) }) .parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT)); @@ -711,6 +695,9 @@ describe("OpenCodeBridge", () => { expect((await stat(directory)).isDirectory()).toBe(true); const entry = await readFile(path.join(directory, "server.js"), "utf8"); expect(entry).toContain('id: "paseo"'); + expect(await readFile(path.join(directory, "state.mjs"), "utf8")).toBe( + await readFile(new URL("./v2/state.mjs", import.meta.url), "utf8"), + ); const manifest = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8")); expect(manifest).toMatchObject({ type: "module", exports: { "./server": "./server.js" } }); } finally { @@ -856,7 +843,8 @@ interface V2TestContext { system?: Array<{ type: "text"; text: string }>; } interface V2TestPluginContext { - options: { baseUrl: string; token: string }; + options: Record; + location?: { directory: string }; tool: { transform( callback: (editor: { add(tool: V2TestTool): void }) => void, @@ -864,10 +852,33 @@ interface V2TestPluginContext { }; session: { context(input: { sessionID: string }): Promise; - get(input: { sessionID: string }): Promise<{ parentID: string }>; + get(input: { sessionID: string }): Promise<{ parentID: string | undefined }>; hook( name: string, callback: (input: V2TestContext) => Promise, ): Promise<{ dispose(): Promise }>; }; } + +async function setupBoundV2Plugin( + bridge: OpenCodeBridge, + context: Pick, + sessionIds: string[], +): Promise<() => Promise> { + const directory = fileURLToPath(await bridge.materializeV2Plugin()); + const module = await import(pathToFileURL(path.join(directory, "server.js")).href); + let bind!: (input: { sessionID: string; baseUrl: string; token: string }) => Promise; + const dispose: () => Promise = await module.default.setup({ + ...context, + options: {}, + location: { directory }, + rpc: { + register: async (_definition: unknown, handlers: { bind: typeof bind }) => { + bind = handlers.bind; + return { dispose: async () => undefined }; + }, + }, + }); + for (const sessionID of sessionIds) await bind({ sessionID, ...bridge.sharedV2Connection() }); + return dispose; +} diff --git a/packages/server/src/server/agent/providers/opencode/bridge.ts b/packages/server/src/server/agent/providers/opencode/bridge.ts index 7abe34475d..87ee07798b 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.ts @@ -30,6 +30,7 @@ class OpenCodeBridgeClientError extends Error { interface OpenCodeBridgeOptions { paseoHome: string; logger: Logger; + port?: number; } interface OpenCodeSessionBinding { @@ -54,6 +55,7 @@ interface OpenCodeConfig { export class OpenCodeBridge { private readonly paseoHome: string; private readonly logger: Logger; + private readonly port: number; private readonly token = randomBytes(32).toString("hex"); private readonly sessions = new Map(); private server: Server | null = null; @@ -65,6 +67,7 @@ export class OpenCodeBridge { constructor(options: OpenCodeBridgeOptions) { this.paseoHome = options.paseoHome; this.logger = options.logger.child({ module: "agent", component: "opencode-bridge" }); + this.port = options.port ?? 0; } async start(): Promise { @@ -75,7 +78,7 @@ export class OpenCodeBridge { }); await new Promise((resolve, reject) => { server.once("error", reject); - server.listen(0, "127.0.0.1", () => { + server.listen(this.port, "127.0.0.1", () => { server.off("error", reject); resolve(); }); @@ -94,6 +97,8 @@ export class OpenCodeBridge { } bindSession(input: BindOpenCodeSessionInput): () => void { + if (this.sessions.has(input.sessionId)) + throw new Error(`OpenCode session ${input.sessionId} is already bound to a Paseo agent`); const binding: OpenCodeSessionBinding = { env: { ...input.env }, ...(input.tools ? { tools: input.tools } : {}), @@ -127,12 +132,13 @@ export class OpenCodeBridge { }; } - async decorateV2ServerEnv(env: Record): Promise> { + async materializeV2Plugin(): Promise { this.v2PluginUrl ??= await this.materializePlugin(2); - return decorateOpenCodeV2Env(env, this.v2PluginUrl, { - baseUrl: this.requireBaseUrl(), - token: this.token, - }); + return this.v2PluginUrl; + } + + sharedV2Connection(baseUrl = this.requireBaseUrl()): OpenCodePluginOptions { + return { baseUrl, token: this.token }; } async close(): Promise { @@ -282,6 +288,8 @@ export async function materializeOpenCodeV2Plugin(paseoHome: string): Promise { + const { OpenCodeRuntimeClient } = await import("./runtime-client.js"); + const { createTestLogger } = await import("../../../../test-utils/test-logger.js"); + const client = new OpenCodeRuntimeClient(createTestLogger(), { + command: { mode: "replace", argv: [process.execPath, "-e", "process.exit(1)"] }, + env: { PASEO_OPENCODE_SERVER_URL: "http://127.0.0.1:4096" }, + }); + try { + const features = await client.listFeatures({ provider: "opencode", cwd: "/tmp/project" }); + expect(features.map((feature) => feature.label)).toEqual(["Auto-accept"]); + } finally { + await client.shutdown(); + } +}); + +test("keeps native Paseo tools available when an external server is configured", async () => { + const { OpenCodeRuntimeClient } = await import("./runtime-client.js"); + const { createTestLogger } = await import("../../../../test-utils/test-logger.js"); + const { OpenCodeBridge } = await import("./bridge.js"); + const bridge = new OpenCodeBridge({ paseoHome: "/tmp", logger: createTestLogger() }); + const external = new OpenCodeRuntimeClient( + createTestLogger(), + { env: { PASEO_OPENCODE_SERVER_URL: "http://127.0.0.1:4096" } }, + { bridge }, + ); + const local = new OpenCodeRuntimeClient(createTestLogger(), undefined, { bridge }); + try { + expect(external.capabilities.supportsNativePaseoTools).toBe(true); + expect(local.capabilities.supportsNativePaseoTools).toBe(true); + } finally { + await external.shutdown(); + await local.shutdown(); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/runtime-client.ts b/packages/server/src/server/agent/providers/opencode/runtime-client.ts index 2a34abe35d..e5fa17021a 100644 --- a/packages/server/src/server/agent/providers/opencode/runtime-client.ts +++ b/packages/server/src/server/agent/providers/opencode/runtime-client.ts @@ -19,7 +19,9 @@ import { import { execCommand } from "../../../../utils/spawn.js"; import { OpenCodeAgentClient } from "../opencode-agent.js"; import type { OpenCodeV2AgentClient } from "./v2/agent.js"; +import { resolveExternalOpenCodeServer } from "./v2/external-server.js"; import { withOpenCodeRuntimeNotice } from "./runtime-notice.js"; +import { selectOpenCodeService } from "./v2/service-selection.js"; // Keep the minimum aligned with the SDK and binary exercised by CI. const MINIMUM_V2: readonly [number, number] = [0, 10]; @@ -67,8 +69,25 @@ export class OpenCodeRuntimeClient implements AgentClient { this.resolveCreateConfig = this.legacy.resolveCreateConfig; this.isCreateConfigUnattended = this.legacy.isCreateConfigUnattended; } + private selectService() { + return selectOpenCodeService({ + explicit: resolveExternalOpenCodeServer({ ...globalThis.process.env, ...this.settings?.env }), + env: this.settings?.env, + }); + } private client(): Promise { this.selected ??= (async () => { + const selection = await this.selectService(); + if (selection.kind !== "owned") { + const { OpenCodeV2AgentClient } = await import("./v2/agent.js"); + return new OpenCodeV2AgentClient({ + logger: this.logger, + settings: this.settings, + managedProcesses: this.options.managedProcesses, + bridge: this.options.bridge, + selection, + }); + } let output: string; try { const launch = await resolveProviderLaunch({ @@ -97,6 +116,7 @@ export class OpenCodeRuntimeClient implements AgentClient { settings: this.settings, managedProcesses: this.options.managedProcesses, bridge: this.options.bridge, + selection, }); })().catch((error: unknown) => { // A failed probe must not poison refresh after the user updates the binary. @@ -106,9 +126,14 @@ export class OpenCodeRuntimeClient implements AgentClient { return this.selected; } async isAvailable() { + const selection = await this.selectService(); + if (selection.kind !== "owned") return true; return this.legacy.isAvailable(); } async getDiagnostic() { + const selection = await this.selectService(); + if (selection.kind !== "owned") + return { diagnostic: `OpenCode server: ${selection.server.url}` }; return this.legacy.getDiagnostic(); } async createSession( diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts b/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts index 505955f64c..eb06928e63 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts @@ -172,6 +172,9 @@ export class V2Harness { event: { subscribe: (options) => this.events(options?.signal) }, }; readonly connection: V2Connection = { + bindSession: async () => {}, + unbindSession: async () => {}, + refreshLocation: async () => {}, client: this.api, release: async () => { this.releases += 1; diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts index 564e1f46c5..e8188730f5 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts @@ -17,6 +17,26 @@ function terminalEvents(events: AgentStreamEvent[]) { } describe("OpenCode v2 session lifecycle", () => { + test("refreshes the existing location before reading a forced catalog", async () => { + const harness = new V2Harness(); + const refreshed: string[] = []; + harness.connection.refreshLocation = async (directory) => { + refreshed.push(directory); + }; + harness.api.model.list = async () => ({ location: harness.info.location, data: [] }); + harness.api.provider.list = async () => ({ + location: harness.info.location, + data: [{ id: "test", name: "Test", activation: "enabled", package: "test" }], + }); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + await client.fetchCatalog({ scope: "workspace", cwd: "/tmp/project", force: true }); + await client.fetchCatalog({ scope: "workspace", cwd: "/tmp/project", force: false }); + expect(refreshed).toEqual(["/tmp/project"]); + await client.shutdown(); + }); test("completes from execution events without a long-lived HTTP wait", async () => { const harness = new V2Harness(); harness.prompt = async (input) => { diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.ts index 2e50e759bd..119fa0ade7 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/agent.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.ts @@ -8,7 +8,7 @@ import { } from "./configuration.js"; import { commands } from "./commands.js"; -import { waitForLocationReady, awaitPaseoPlugin } from "./readiness.js"; +import { waitForLocationReady } from "./readiness.js"; import type { SessionInfo } from "@opencode/client"; @@ -40,6 +40,7 @@ import { importSessionFromPersistence } from "../../../provider-session-import.j import type { OpenCodeBridge } from "../bridge.js"; import { resolveOpenCodeHomeDir } from "../paths.js"; import { V2Runtime, type V2Connection } from "./runtime.js"; +import type { OpenCodeServiceSelection } from "./service-selection.js"; import { modelRef, modesFromV2, modelsFromV2 } from "./mapping.js"; interface V2AgentOptions { @@ -48,6 +49,7 @@ interface V2AgentOptions { managedProcesses?: ManagedProcessRegistry; bridge?: OpenCodeBridge; runtime?: Pick; + selection?: OpenCodeServiceSelection; } export class OpenCodeV2AgentClient implements AgentClient { @@ -56,12 +58,12 @@ export class OpenCodeV2AgentClient implements AgentClient { private readonly runtime: Pick; private readonly connections = new Map(); constructor(private readonly options: V2AgentOptions) { - this.capabilities = { ...V2_CAPABILITIES, supportsNativePaseoTools: Boolean(options.bridge) }; + const bridge = options.bridge; + this.capabilities = { ...V2_CAPABILITIES, supportsNativePaseoTools: Boolean(bridge) }; this.runtime = options.runtime ?? new V2Runtime({ ...options, - decorateEnv: options.bridge ? (env) => options.bridge!.decorateV2ServerEnv(env) : undefined, }); } async isAvailable() { @@ -85,6 +87,10 @@ export class OpenCodeV2AgentClient implements AgentClient { await activity("plugin.list", () => waitForLocationReady({ client: connection.client, location, signal: request.signal }), ); + if (options.force) + await activity("paseo.bridge.refresh", () => + connection.refreshLocation(location.directory, request.signal), + ); const [models, agents, providers] = await Promise.all([ activity("model.list", () => connection.client.model.list({ location }, request)), activity("agent.list", () => connection.client.agent.list({ location }, request)), @@ -163,7 +169,8 @@ export class OpenCodeV2AgentClient implements AgentClient { requiresDedicatedV2Server(config, launch) ? { env: launch?.env, dedicated: true } : {}, ); let ownedConnection = connection; - const unbind = this.options.bridge?.bindSession({ + const bridge = this.options.bridge; + const unbind = bridge?.bindSession({ sessionId: info.id, env: launch?.env ?? {}, tools: launch?.paseoTools, @@ -172,7 +179,7 @@ export class OpenCodeV2AgentClient implements AgentClient { const bindChild = (childId: string) => { this.connections.set(childId, ownedConnection); if (bound.has(childId)) return; - const childUnbind = this.options.bridge?.bindSession({ + const childUnbind = bridge?.bindSession({ sessionId: childId, env: launch?.env ?? {}, tools: launch?.paseoTools, @@ -204,10 +211,6 @@ export class OpenCodeV2AgentClient implements AgentClient { moved, ); try { - if (this.options.bridge) { - const location = { directory: config.cwd }; - await awaitPaseoPlugin({ client: connection.client, location }); - } await session.initialize( launch?.env ? createProviderEnv({ runtimeSettings: this.options.settings, overlays: [launch.env] }) diff --git a/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs b/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs index 1664c0b59f..6971a5b323 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs +++ b/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs @@ -1,100 +1,15 @@ -import { registerStructuredOutput, STRUCTURED_OUTPUT_TOOL } from "./structured-output-plugin.mjs"; +import { registerStructuredOutput } from "./structured-output-plugin.mjs"; +import { registerSharedBridge } from "./shared-bridge-plugin.mjs"; export default { id: "paseo", async setup(context) { const disposeStructuredOutput = await registerStructuredOutput(context); - const { baseUrl, token } = context.options; - if (!baseUrl || !token) return disposeStructuredOutput; - async function request(pathname, body, allowMissing = false) { - const response = await fetch(new URL(`/_internal/opencode${pathname}`, baseUrl), { - method: body === undefined ? "GET" : "POST", - headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }); - if (allowMissing && response.status === 404) return null; - if (!response.ok) { - const payload = await response.json().catch(() => null); - const detail = formatErrorDetail(payload?.issues ?? payload?.details); - const message = - typeof payload?.error === "string" && payload.error - ? payload.error - : `Paseo tool bridge returned HTTP ${response.status}`; - const error = new Error(detail ? `${message}: ${detail}` : message); - error.status = response.status; - if (detail) error.details = detail; - throw error; - } - return response.json(); - } - async function scope(sessionID) { - const visited = new Set(); - while (sessionID && !visited.has(sessionID)) { - visited.add(sessionID); - const binding = await request( - `/sessions/${encodeURIComponent(sessionID)}/context?tools`, - undefined, - true, - ); - if (binding) return { ...binding, sessionID }; - const session = await context.session.get({ sessionID }); - sessionID = session.parentID; - } - throw new Error("OpenCode session is not bound to a Paseo agent"); - } - const manifest = await request("/tools"); - const registration = await context.tool.transform((editor) => { - for (const definition of manifest.tools) { - editor.add({ - name: `paseo_${definition.name}`, - description: definition.description, - input: definition.inputSchema, - options: { codemode: false }, - async execute(input, call) { - const binding = await scope(call.sessionID); - const result = await request( - `/sessions/${encodeURIComponent(binding.sessionID)}/tools/${encodeURIComponent(definition.name)}`, - input, - ); - return { - content: result.content.map(toOpenCodeContent), - metadata: { paseoTool: definition.name }, - }; - }, - }); - } - }); - const filtering = await context.session.hook("context", async (input) => { - const binding = await scope(input.sessionID); - const allowed = new Set(binding.tools.map((name) => `paseo_${name}`)); - for (const name of Object.keys(input.tools)) { - if (name !== STRUCTURED_OUTPUT_TOOL && name.startsWith("paseo_") && !allowed.has(name)) - delete input.tools[name]; - } - }); + if (context.options.mode === "structured-only") return disposeStructuredOutput; + const disposeBridge = await registerSharedBridge(context); return async () => { + await disposeBridge(); await disposeStructuredOutput(); - await filtering.dispose(); - await registration.dispose(); }; }, }; - -function formatErrorDetail(details) { - if (!Array.isArray(details)) return typeof details === "string" ? details : ""; - return details - .map((issue) => { - const path = - Array.isArray(issue?.path) && issue.path.length > 0 ? issue.path.join(".") : "input"; - return `${path}: ${issue?.message ?? "Invalid value"}`; - }) - .join("; "); -} - -// OpenCode tool content is text or a file; MCP images arrive as base64 data. -function toOpenCodeContent(part) { - if (part.type === "image") { - return { type: "file", uri: `data:${part.mimeType};base64,${part.data}`, mime: part.mimeType }; - } - return part; -} diff --git a/packages/server/src/server/agent/providers/opencode/v2/bridge-provisioning.test.ts b/packages/server/src/server/agent/providers/opencode/v2/bridge-provisioning.test.ts new file mode 100644 index 0000000000..52b483cca3 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/bridge-provisioning.test.ts @@ -0,0 +1,37 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { hasManagedBridge, provisionLocalBridge } from "./bridge-provisioning.js"; + +test("installs a watched plugin without changing existing OpenCode configuration", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-opencode-provision-")); + const source = path.join(root, "source.js"); + const config = path.join(root, "opencode.json"); + await writeFile(source, "export default { id: 'paseo' };"); + await writeFile(path.join(root, "state.mjs"), "export const state = new Map();"); + await writeFile(config, '{"plugins":["existing"]}\n'); + try { + expect(await hasManagedBridge(root)).toBe(false); + await Promise.all([ + provisionLocalBridge({ configDirectory: root, artifact: source }), + provisionLocalBridge({ configDirectory: root, artifact: source }), + ]); + expect(await hasManagedBridge(root)).toBe(true); + const manifest = path.join(root, "plugins/paseo-managed-bridge/package.json"); + const formatted = `${JSON.stringify(JSON.parse(await readFile(manifest, "utf8")), null, 2)}\n`; + await writeFile(manifest, formatted); + expect(await hasManagedBridge(root)).toBe(true); + await provisionLocalBridge({ configDirectory: root, artifact: source }); + expect(await readFile(manifest, "utf8")).toBe(formatted); + expect(await readFile(config, "utf8")).toBe('{"plugins":["existing"]}\n'); + expect(await readFile(path.join(root, "plugins/paseo-managed-bridge/state.mjs"), "utf8")).toBe( + "export const state = new Map();", + ); + expect( + await readFile(path.join(root, "plugins/paseo-managed-bridge/server.js"), "utf8"), + ).toContain("id: 'paseo'"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/bridge-provisioning.ts b/packages/server/src/server/agent/providers/opencode/v2/bridge-provisioning.ts new file mode 100644 index 0000000000..ce2626c38b --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/bridge-provisioning.ts @@ -0,0 +1,89 @@ +import { mkdir, readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { writeFileAtomic } from "../../../../atomic-file.js"; + +const PACKAGE = JSON.stringify({ + name: "paseo-managed-bridge", + private: true, + type: "module", + exports: { "./server": "./server.js" }, +}); + +export function openCodeConfigDirectory(env: NodeJS.ProcessEnv): string { + return ( + env.OPENCODE_CONFIG_DIR ?? + path.join(env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config"), "opencode") + ); +} + +export async function hasManagedBridge(configDirectory: string): Promise { + const directory = path.join(configDirectory, "plugins", "paseo-managed-bridge"); + const [manifest, artifact] = await Promise.all([ + readFile(path.join(directory, "package.json"), "utf8").catch(() => ""), + readFile(path.join(directory, "server.js")).catch(() => undefined), + ]); + return isManagedPackage(manifest) && Boolean(artifact?.length); +} + +export async function provisionLocalBridge(input: { + configDirectory: string; + artifact: string; +}): Promise { + const directory = path.join(input.configDirectory, "plugins", "paseo-managed-bridge"); + await mkdir(path.dirname(directory), { recursive: true }); + let created = false; + try { + await mkdir(directory); + created = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + const manifest = path.join(directory, "package.json"); + if (created) await writeFileAtomic(manifest, PACKAGE); + else if (!isManagedPackage(await readManagedManifest(manifest))) + throw new Error(`Cannot install OpenCode bridge: ${directory} is not managed by Paseo`); + const artifact = await readFile(input.artifact); + const state = await readFile(path.join(path.dirname(input.artifact), "state.mjs")); + const stateTarget = path.join(directory, "state.mjs"); + const existingState = await readFile(stateTarget).catch(() => undefined); + if (!existingState) await writeFileAtomic(stateTarget, state); + else if (!existingState.equals(state)) + throw new Error(`Cannot update OpenCode bridge state module at ${stateTarget}`); + const target = path.join(directory, "server.js"); + const existing = await readFile(target).catch(() => undefined); + if (!existing?.equals(artifact)) await writeFileAtomic(target, artifact); +} + +function isManagedPackage(content: string): boolean { + let value: unknown; + try { + value = JSON.parse(content); + } catch { + return false; + } + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const manifest = value as Record; + if (!manifest.exports || typeof manifest.exports !== "object" || Array.isArray(manifest.exports)) + return false; + const exports = manifest.exports as Record; + return ( + manifest.name === "paseo-managed-bridge" && + manifest.private === true && + manifest.type === "module" && + exports["./server"] === "./server.js" + ); +} + +async function readManagedManifest(file: string): Promise { + for (let attempt = 0; attempt < 20; attempt++) { + try { + return await readFile(file, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + await delay(25); + } + } + return ""; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/external-runtime.test.ts b/packages/server/src/server/agent/providers/opencode/v2/external-runtime.test.ts new file mode 100644 index 0000000000..f4f39f4aeb --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/external-runtime.test.ts @@ -0,0 +1,180 @@ +import { createServer, type Server } from "node:http"; +import { createServer as createNetServer } from "node:net"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../../../test-utils/test-logger.js"; +import { V2Runtime } from "./runtime.js"; +import { EXTERNAL_SERVER_PASSWORD_ENV, EXTERNAL_SERVER_URL_ENV } from "./external-server.js"; + +function listen(server: Server): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("Test server did not expose a TCP address")); + return; + } + resolve(address.port); + }); + }); +} + +function close(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + server.closeAllConnections(); + }); +} + +function reserveClosedPort(): Promise { + return new Promise((resolve, reject) => { + const server = createNetServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Could not reserve a test port")); + return; + } + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(address.port); + }); + }); + }); +} + +describe("V2Runtime against an external server", () => { + let server: Server | undefined; + + afterEach(async () => { + if (server) await close(server); + server = undefined; + }); + + test("reuses the configured server and never spawns a local one", async () => { + const requests: Array<{ url: string | undefined; authorization: string | undefined }> = []; + server = createServer((request, response) => { + requests.push({ url: request.url, authorization: request.headers.authorization }); + if (request.url !== "/api/info") { + response.writeHead(404); + response.end(); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ version: "2.0.10", pid: 1, urls: [], paths: { tmp: "/tmp" } })); + }); + const port = await listen(server); + const runtime = new V2Runtime({ + logger: createTestLogger(), + externalServer: { url: `http://127.0.0.1:${port}`, password: "secret" }, + }); + + const first = await runtime.acquire(); + const second = await runtime.acquire(); + expect(first.client).toBe(second.client); + expect(requests).toEqual([ + { + url: "/api/info", + authorization: `Basic ${Buffer.from("opencode:secret").toString("base64")}`, + }, + ]); + + await first.release(); + await second.release(); + const refreshed = await runtime.acquire({ fresh: true }); + expect(refreshed.client).toBe(first.client); + await refreshed.release(); + await runtime.shutdown(); + expect(requests).toHaveLength(1); + }); + + test("selects the external server from provider environment", async () => { + const paths: Array = []; + server = createServer((request, response) => { + paths.push(request.url); + if (request.url !== "/api/info") { + response.writeHead(404); + response.end(); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ version: "2.0.10", pid: 1, urls: [], paths: { tmp: "/tmp" } })); + }); + const port = await listen(server); + const runtime = new V2Runtime({ + logger: createTestLogger(), + settings: { + env: { + [EXTERNAL_SERVER_URL_ENV]: `http://127.0.0.1:${port}`, + [EXTERNAL_SERVER_PASSWORD_ENV]: "", + }, + }, + }); + + const connection = await runtime.acquire(); + expect(paths).toEqual(["/api/info"]); + await connection.release(); + await runtime.shutdown(); + }); + + test("reports an unreachable configured server", async () => { + const port = await reserveClosedPort(); + const runtime = new V2Runtime({ + logger: createTestLogger(), + externalServer: { url: `http://127.0.0.1:${port}` }, + }); + + await expect(runtime.acquire()).rejects.toThrow( + `Cannot reach OpenCode v2 server at http://127.0.0.1:${port}`, + ); + await runtime.shutdown(); + }); + + test("rejects session configuration that requires process isolation", async () => { + const runtime = new V2Runtime({ + logger: createTestLogger(), + externalServer: { url: "http://127.0.0.1:1" }, + }); + await expect(runtime.acquire({ dedicated: true })).rejects.toThrow( + "require an isolated server", + ); + await runtime.shutdown(); + }); + + test("rejects provider process environment on an explicit server", async () => { + const runtime = new V2Runtime({ + logger: createTestLogger(), + externalServer: { url: "http://127.0.0.1:1" }, + settings: { env: { XDG_CONFIG_HOME: "/custom", PROVIDER_KEY: "secret" } }, + }); + await expect(runtime.acquire()).rejects.toThrow("XDG_CONFIG_HOME, PROVIDER_KEY"); + await runtime.shutdown(); + }); + + test("rejects an unsupported explicit server version", async () => { + server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ version: "2.0.9", pid: 1, urls: [], paths: { tmp: "/tmp" } })); + }); + const port = await listen(server); + const runtime = new V2Runtime({ + logger: createTestLogger(), + externalServer: { url: `http://127.0.0.1:${port}` }, + }); + await expect(runtime.acquire()).rejects.toThrow("Unsupported OpenCode version 2.0.9"); + await runtime.shutdown(); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/external-server.test.ts b/packages/server/src/server/agent/providers/opencode/v2/external-server.test.ts new file mode 100644 index 0000000000..f9986215ad --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/external-server.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "vitest"; + +import { + EXTERNAL_SERVER_PASSWORD_ENV, + EXTERNAL_SERVER_URL_ENV, + resolveExternalOpenCodeServer, +} from "./external-server.js"; + +describe("resolveExternalOpenCodeServer", () => { + test("returns undefined when no URL is configured", () => { + expect(resolveExternalOpenCodeServer(undefined)).toBeUndefined(); + expect(resolveExternalOpenCodeServer({ [EXTERNAL_SERVER_URL_ENV]: " " })).toBeUndefined(); + }); + + test("reads the URL and password and removes a trailing slash", () => { + expect( + resolveExternalOpenCodeServer({ + [EXTERNAL_SERVER_URL_ENV]: " http://127.0.0.1:4096/ ", + [EXTERNAL_SERVER_PASSWORD_ENV]: "secret", + }), + ).toEqual({ url: "http://127.0.0.1:4096", password: "secret" }); + }); + + test("omits an empty password", () => { + expect( + resolveExternalOpenCodeServer({ + [EXTERNAL_SERVER_URL_ENV]: "https://opencode.example.com", + [EXTERNAL_SERVER_PASSWORD_ENV]: "", + }), + ).toEqual({ url: "https://opencode.example.com" }); + }); + + test("preserves spaces in the password", () => { + expect( + resolveExternalOpenCodeServer({ + [EXTERNAL_SERVER_URL_ENV]: "https://opencode.example.com", + [EXTERNAL_SERVER_PASSWORD_ENV]: " secret ", + }), + ).toEqual({ url: "https://opencode.example.com", password: " secret " }); + }); + + test("rejects relative and non-HTTP URLs", () => { + expect(() => + resolveExternalOpenCodeServer({ [EXTERNAL_SERVER_URL_ENV]: "127.0.0.1:4096" }), + ).toThrow(`${EXTERNAL_SERVER_URL_ENV} must be an absolute URL, received "127.0.0.1:4096"`); + expect(() => + resolveExternalOpenCodeServer({ [EXTERNAL_SERVER_URL_ENV]: "unix:/tmp/opencode.sock" }), + ).toThrow( + `${EXTERNAL_SERVER_URL_ENV} must use http or https, received "unix:/tmp/opencode.sock"`, + ); + }); + + test("rejects credentials embedded in URLs", () => { + expect(() => + resolveExternalOpenCodeServer({ + [EXTERNAL_SERVER_URL_ENV]: "https://user:password@opencode.example.com", + }), + ).toThrow("must not contain credentials"); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/external-server.ts b/packages/server/src/server/agent/providers/opencode/v2/external-server.ts new file mode 100644 index 0000000000..c00b158ce3 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/external-server.ts @@ -0,0 +1,38 @@ +export const EXTERNAL_SERVER_URL_ENV = "PASEO_OPENCODE_SERVER_URL"; +export const EXTERNAL_SERVER_PASSWORD_ENV = "PASEO_OPENCODE_SERVER_PASSWORD"; + +export interface ExternalOpenCodeServer { + url: string; + password?: string; +} + +/** + * Reads the optional connection to a user-managed OpenCode v2 server from + * provider environment. When absent, Paseo keeps spawning its own server. + */ +export function resolveExternalOpenCodeServer( + env: Record | undefined, +): ExternalOpenCodeServer | undefined { + const rawUrl = env?.[EXTERNAL_SERVER_URL_ENV]?.trim(); + if (!rawUrl) return undefined; + + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new Error(`${EXTERNAL_SERVER_URL_ENV} must be an absolute URL, received "${rawUrl}"`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`${EXTERNAL_SERVER_URL_ENV} must use http or https, received "${rawUrl}"`); + } + if (url.username || url.password) + throw new Error( + `${EXTERNAL_SERVER_URL_ENV} must not contain credentials; use ${EXTERNAL_SERVER_PASSWORD_ENV}`, + ); + + const password = env?.[EXTERNAL_SERVER_PASSWORD_ENV]; + return { + url: url.toString().replace(/\/$/, ""), + ...(password ? { password } : {}), + }; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/owned-runtime.test.ts b/packages/server/src/server/agent/providers/opencode/v2/owned-runtime.test.ts new file mode 100644 index 0000000000..d60535889b --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/owned-runtime.test.ts @@ -0,0 +1,77 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { createTestLogger } from "../../../../../test-utils/test-logger.js"; +import { OpenCodeBridge } from "../bridge.js"; +import { provisionLocalBridge } from "./bridge-provisioning.js"; +import { V2Runtime } from "./runtime.js"; + +test("an owned helper uses an installed bridge once and survives catalog refresh", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-owned-opencode-")); + const config = path.join(root, "config", "opencode"); + const script = path.join(root, "server.cjs"); + const record = path.join(root, "env.json"); + const bridge = new OpenCodeBridge({ paseoHome: root, logger: createTestLogger() }); + await bridge.start(); + const artifact = fileURLToPath(await bridge.materializeV2Plugin()); + await provisionLocalBridge({ + configDirectory: config, + artifact: path.join(artifact, "server.js"), + }); + await writeFile( + script, + ` + const http = require('node:http'); + require('node:fs').writeFileSync(${JSON.stringify(record)}, process.env.OPENCODE_CONFIG_CONTENT); + const server = http.createServer((_request, response) => { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ version: '2.0.10', pid: process.pid, urls: [], paths: { tmp: '/tmp' } })); + }); + server.listen(0, '127.0.0.1', () => console.log('server listening on http://127.0.0.1:' + server.address().port)); + `, + ); + const runtime = new V2Runtime({ + logger: createTestLogger(), + bridge, + selection: { kind: "owned" }, + settings: { + command: { mode: "replace", argv: [process.execPath, script] }, + env: { XDG_CONFIG_HOME: path.join(root, "config"), OPENCODE_CONFIG_CONTENT: "{}" }, + }, + }); + try { + const first = await runtime.acquire(); + const pid = (await first.client.server.info()).pid; + expect(JSON.parse(await readFile(record, "utf8"))).toEqual({}); + await first.release(); + const refreshed = await runtime.acquire({ fresh: true }); + expect((await refreshed.client.server.info()).pid).toBe(pid); + await refreshed.release(); + } finally { + await runtime.shutdown(); + await bridge.close(); + await rm(root, { recursive: true, force: true }); + } +}, 15_000); + +test("reports a bridge port change that has not been applied", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-opencode-port-")); + const bridge = new OpenCodeBridge({ paseoHome: root, logger: createTestLogger() }); + await bridge.start(); + const actual = Number(new URL(bridge.sharedV2Connection().baseUrl).port); + const runtime = new V2Runtime({ + logger: createTestLogger(), + bridge, + selection: { kind: "explicit", server: { url: "http://127.0.0.1:1" } }, + settings: { env: { PASEO_OPENCODE_BRIDGE_PORT: actual === 1 ? "2" : "1" } }, + }); + try { + await expect(runtime.acquire()).rejects.toThrow("restart Paseo to apply the bridge port"); + } finally { + await runtime.shutdown(); + await bridge.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/runtime.ts b/packages/server/src/server/agent/providers/opencode/v2/runtime.ts index cab5544582..0fe830fe45 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/runtime.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/runtime.ts @@ -16,11 +16,25 @@ import type { ManagedProcessRegistry } from "../../../../managed-processes/manag import { resolveOpenCodeHomeDir } from "../paths.js"; import { OpenCodeHttpError } from "../http-error.js"; import { raceProviderRefreshAbort } from "../../../provider-refresh-deadline.js"; +import { resolveExternalOpenCodeServer, type ExternalOpenCodeServer } from "./external-server.js"; +import { selectOpenCodeService, type OpenCodeServiceSelection } from "./service-selection.js"; +import { bridgeBindings, connectSharedService } from "./shared-service.js"; +import { + hasManagedBridge, + openCodeConfigDirectory, + provisionLocalBridge, +} from "./bridge-provisioning.js"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { OpenCodeBridge } from "../bridge.js"; export interface V2Connection { client: V2Api; release(): Promise; retain(): V2Connection; + bindSession(sessionId: string, cwd: string): Promise; + unbindSession(sessionId: string, cwd: string): Promise; + refreshLocation(cwd: string, signal?: AbortSignal): Promise; readonly exited: Promise; } interface Generation { @@ -28,12 +42,17 @@ interface Generation { users: number; stop(): Promise; exited: Promise; + bindSession(sessionId: string, cwd: string): Promise; + unbindSession(sessionId: string, cwd: string): Promise; + refreshLocation(cwd: string, signal?: AbortSignal): Promise; } export interface V2RuntimeOptions { logger: Logger; settings?: ProviderRuntimeSettings; managedProcesses?: ManagedProcessRegistry; - decorateEnv?: (env: Record) => Promise>; + externalServer?: ExternalOpenCodeServer; + selection?: OpenCodeServiceSelection; + bridge?: OpenCodeBridge; } export interface V2RuntimeAcquireOptions { @@ -49,17 +68,36 @@ export class V2Runtime { private generations = new Set(); private starts = new Set>(); private closed = false; + private readonly selection: Promise; - constructor(private readonly options: V2RuntimeOptions) {} + constructor(private readonly options: V2RuntimeOptions) { + this.selection = options.selection + ? Promise.resolve(options.selection) + : selectOpenCodeService({ + explicit: + options.externalServer ?? + resolveExternalOpenCodeServer({ + ...globalThis.process.env, + ...options.settings?.env, + }), + env: options.settings?.env, + }); + } async acquire(input: V2RuntimeAcquireOptions = {}): Promise { if (this.closed) throw new Error("OpenCode runtime is closed"); input.signal?.throwIfAborted(); + this.assertBridgePort(); + const selection = await raceProviderRefreshAbort(input.signal, this.selection); let pending: Promise; - if (input.env || input.dedicated) { + if (selection.kind !== "owned") { + this.assertSharedConfiguration(input, selection); + this.current ??= this.startTracked(); + pending = this.current; + } else if (input.env || input.dedicated) { pending = this.startTracked(input.env); } else { - if (input.fresh || !this.current) this.current = this.startTracked(); + if (!this.current) this.current = this.startTracked(); pending = this.current; } let generation: Generation; @@ -97,6 +135,39 @@ export class V2Runtime { return connection; } + private assertBridgePort(): void { + const port = this.options.settings?.env?.PASEO_OPENCODE_BRIDGE_PORT; + if (!port || !this.options.bridge) return; + const actual = new URL(this.options.bridge.sharedV2Connection().baseUrl).port; + if (Number(port) !== Number(actual)) + throw new Error("PASEO_OPENCODE_BRIDGE_PORT changed; restart Paseo to apply the bridge port"); + } + + private assertSharedConfiguration( + input: V2RuntimeAcquireOptions, + selection: OpenCodeServiceSelection, + ): void { + const connectionEnv = new Set([ + "PASEO_OPENCODE_SERVER_URL", + "PASEO_OPENCODE_SERVER_PASSWORD", + "PASEO_OPENCODE_BRIDGE_URL", + "PASEO_OPENCODE_BRIDGE_PORT", + ]); + if (selection.kind === "discovered") { + connectionEnv.add("XDG_CONFIG_HOME"); + connectionEnv.add("XDG_STATE_HOME"); + connectionEnv.add("OPENCODE_CONFIG_DIR"); + } + const processOverrides = Object.keys(this.options.settings?.env ?? {}).filter( + (key) => !connectionEnv.has(key), + ); + if (input.env || input.dedicated || processOverrides.length) { + throw new Error( + `Custom OpenCode MCP servers or process environment require an isolated server and cannot be used with a shared OpenCode service${processOverrides.length ? `: ${processOverrides.join(", ")}` : ""}.`, + ); + } + } + private lease(generation: Generation, pending: Promise): V2Connection { if (this.closed || !this.generations.has(generation)) throw new Error("OpenCode helper server is no longer running"); @@ -107,7 +178,7 @@ export class V2Runtime { released = true; generation.users -= 1; if (generation.users !== 0) return; - if (this.current === pending) this.current = null; + if (this.current === pending) return; this.generations.delete(generation); await generation.stop(); }; @@ -115,6 +186,9 @@ export class V2Runtime { client: generation.client, release, retain: () => this.lease(generation, pending), + bindSession: generation.bindSession, + unbindSession: generation.unbindSession, + refreshLocation: generation.refreshLocation, exited: generation.exited, }; } @@ -146,6 +220,16 @@ export class V2Runtime { } private async start(env: Record = {}): Promise { + const selection = await this.selection; + if (selection.kind !== "owned") { + const generation: Generation = { + ...(await connectSharedService(selection, this.options)), + users: 0, + }; + this.generations.add(generation); + return generation; + } + const { settings, managedProcesses, logger } = this.options; const launch = await resolveProviderLaunch({ commandConfig: settings?.command, @@ -160,9 +244,20 @@ export class V2Runtime { ...settings?.env, ...env, }; - const decorated = this.options.decorateEnv - ? await this.options.decorateEnv(configured) - : decorateOpenCodeV2Env(configured, await materializeOpenCodeV2Plugin(resolvePaseoHome())); + const bridge = this.options.bridge; + const plugin = bridge + ? await bridge.materializeV2Plugin() + : await materializeOpenCodeV2Plugin(resolvePaseoHome()); + const globalConfig = openCodeConfigDirectory({ ...globalThis.process.env, ...configured }); + const managed = await hasManagedBridge(globalConfig); + if (managed && bridge) + await provisionLocalBridge({ + configDirectory: globalConfig, + artifact: path.join(fileURLToPath(plugin), "server.js"), + }); + const decorated = managed + ? configured + : decorateOpenCodeV2Env(configured, plugin, bridge ? {} : { mode: "structured-only" }); const args = [...launch.args, "serve", "--hostname", "127.0.0.1", "--port", "0"]; const process = spawnProcess(launch.command, args, { cwd, @@ -253,7 +348,13 @@ export class V2Runtime { }, }); await client.server.info({ signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())) }); - const generation: Generation = { client, users: 0, stop, exited }; + const generation: Generation = { + client, + users: 0, + stop, + exited, + ...bridgeBindings(client, bridge), + }; this.generations.add(generation); process.once("exit", () => { this.generations.delete(generation); diff --git a/packages/server/src/server/agent/providers/opencode/v2/service-selection.test.ts b/packages/server/src/server/agent/providers/opencode/v2/service-selection.test.ts new file mode 100644 index 0000000000..876582fb9c --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/service-selection.test.ts @@ -0,0 +1,55 @@ +import { createServer } from "node:http"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { tmpdir } from "node:os"; +import { expect, test } from "vitest"; +import { selectOpenCodeService } from "./service-selection.js"; + +test("selects the registered service with its credential and ignores stale registration", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-service-")); + const file = path.join(directory, "service.json"); + let requests = 0; + const server = createServer((request, response) => { + requests++; + if ( + request.headers.authorization !== `Basic ${Buffer.from("opencode:secret").toString("base64")}` + ) { + response.writeHead(401).end(); + return; + } + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ version: "2.0.10", pid: process.pid })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("No test address"); + const url = `http://127.0.0.1:${address.port}`; + try { + await writeFile( + file, + JSON.stringify({ url, password: "secret", pid: process.pid, version: "2.0.10" }), + ); + expect(await selectOpenCodeService({ file })).toEqual({ + kind: "discovered", + server: { url, password: "secret" }, + }); + expect(requests).toBe(1); + expect( + await selectOpenCodeService({ file, explicit: { url: "http://explicit.test" } }), + ).toEqual({ kind: "explicit", server: { url: "http://explicit.test" } }); + expect(requests).toBe(1); + await writeFile(file, JSON.stringify({ url, password: "secret", pid: process.pid + 1 })); + expect(await selectOpenCodeService({ file })).toEqual({ kind: "owned" }); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/service-selection.ts b/packages/server/src/server/agent/providers/opencode/v2/service-selection.ts new file mode 100644 index 0000000000..eeb2cb0994 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/service-selection.ts @@ -0,0 +1,43 @@ +import { Service } from "@opencode/client/service"; +import { homedir, networkInterfaces } from "node:os"; +import path from "node:path"; +import type { ExternalOpenCodeServer } from "./external-server.js"; + +export type OpenCodeServiceSelection = + | { kind: "owned" } + | { kind: "explicit" | "discovered"; server: ExternalOpenCodeServer }; + +export async function selectOpenCodeService(input: { + explicit?: ExternalOpenCodeServer; + file?: string; + env?: Record; +}): Promise { + if (input.explicit) return { kind: "explicit", server: input.explicit }; + const state = + input.env?.XDG_STATE_HOME ?? + process.env.XDG_STATE_HOME ?? + path.join(homedir(), ".local", "state"); + const endpoint = await Service.discover({ + file: input.file ?? path.join(state, "opencode", "service.json"), + version: compatibleVersion, + }).catch(() => undefined); + if (!endpoint) return { kind: "owned" }; + if (!isLocalOpenCodeHost(new URL(endpoint.url).hostname)) return { kind: "owned" }; + return { + kind: "discovered", + server: { url: endpoint.url, ...(endpoint.auth ? { password: endpoint.auth.password } : {}) }, + }; +} + +export function isLocalOpenCodeHost(input: string): boolean { + const hostname = input.replace(/^\[|\]$/g, ""); + const localAddresses = Object.values(networkInterfaces()).flatMap((items) => + (items ?? []).map((item) => item.address), + ); + return ["localhost", "127.0.0.1", "::1", "0.0.0.0", "::", ...localAddresses].includes(hostname); +} + +export function compatibleVersion(version: string): boolean { + const match = /^2\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version); + return Boolean(match && (Number(match[1]) > 0 || Number(match[2]) >= 10)); +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts index f173ccb301..a167ec0713 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -146,6 +146,7 @@ export class OpenCodeV2Session implements AgentSession { } private async configureConnection() { const location = { directory: this.config.cwd }; + await this.connection.bindSession(this.id, this.config.cwd); await waitForLocationReady({ client: this.client, location, signal: this.abort.signal }); if (this.requiresPaseoPlugin) await awaitPaseoPlugin({ client: this.client, location, signal: this.abort.signal }); @@ -207,6 +208,11 @@ export class OpenCodeV2Session implements AgentSession { if (!this.acquire) throw new Error("OpenCode helper server exited"); await this.stream; await this.sync.catch(() => undefined); + await this.connection + .unbindSession(this.id, this.config.cwd) + .catch((error: unknown) => + this.logger.warn({ error }, "Could not unbind the OpenCode Paseo bridge"), + ); const next = await this.acquire(); if (this.closed) { await next.release(); @@ -386,6 +392,7 @@ export class OpenCodeV2Session implements AgentSession { } async startTurn(prompt: AgentPromptInput, options?: AgentRunOptions) { await this.reconnectIfExited(); + await this.connection.bindSession(this.id, this.config.cwd); return this.turns.startTurn(prompt, options); } steerActiveTurn(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise { @@ -436,6 +443,7 @@ export class OpenCodeV2Session implements AgentSession { } } private async reconcileConnection() { + await this.connection.bindSession(this.id, this.config.cwd); // Session environments are process-local. Reapply this agent's snapshot before // reconciling a new connection, including event-stream reconnections. if (this.launchEnv) @@ -560,6 +568,11 @@ export class OpenCodeV2Session implements AgentSession { await this.stream; await this.reconnecting?.catch(() => undefined); await this.sync.catch(() => undefined); + await this.connection + .unbindSession(this.id, this.config.cwd) + .catch((error: unknown) => + this.logger.warn({ error }, "Could not unbind the OpenCode Paseo bridge"), + ); this.unbind?.(); try { if (!this.persist) await this.client.session.remove({ sessionID: this.id }); diff --git a/packages/server/src/server/agent/providers/opencode/v2/shared-bridge-plugin.mjs b/packages/server/src/server/agent/providers/opencode/v2/shared-bridge-plugin.mjs new file mode 100644 index 0000000000..4be98bfba9 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/shared-bridge-plugin.mjs @@ -0,0 +1,207 @@ +import { STRUCTURED_OUTPUT_TOOL } from "./structured-output-plugin.mjs"; + +// OpenCode owns this plugin for the lifetime of a location. Paseo supplies only +// live, per-session bindings over the authenticated OpenCode RPC channel. +export async function registerSharedBridge(context) { + const state = await import(new URL("./state.mjs", import.meta.url).href); + const { bindings, definitions } = state.stateFor(context.location); + const registrations = new Map(); + let updating = Promise.resolve(); + async function request(binding, pathname, body, allowMissing = false, signal) { + const requestSignal = body === undefined ? AbortSignal.timeout(5_000) : signal; + const response = await fetch(new URL(`/_internal/opencode${pathname}`, binding.baseUrl), { + method: body === undefined ? "GET" : "POST", + headers: { Authorization: `Bearer ${binding.token}`, "Content-Type": "application/json" }, + signal: requestSignal, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + if (allowMissing && response.status === 404) return null; + const payload = await response.json().catch(() => null); + if (!response.ok) { + const detail = formatErrorDetail(payload?.issues ?? payload?.details); + const message = payload?.error ?? `Paseo tool bridge returned HTTP ${response.status}`; + const error = new Error(detail ? `${message}: ${detail}` : message); + error.status = response.status; + if (detail) error.details = detail; + throw error; + } + return payload; + } + async function find(sessionID) { + const seen = new Set(); + while (sessionID && !seen.has(sessionID)) { + seen.add(sessionID); + const binding = bindings.get(sessionID); + if (binding) return { ...binding, sessionID }; + sessionID = (await context.session.get({ sessionID })).parentID; + } + return null; + } + async function install(binding) { + const manifest = await request(binding, "/tools"); + for (const definition of manifest.tools) await installDefinition(definition); + } + async function installDefinition(definition) { + const signature = JSON.stringify(definition); + const known = definitions.get(definition.name); + if (known && JSON.stringify(known) !== signature) + throw new Error(`Conflicting Paseo tool definition: ${definition.name}`); + const old = registrations.get(definition.name); + if (old) { + if (old.signature !== signature) + throw new Error(`Conflicting Paseo tool definition: ${definition.name}`); + return; + } + const registration = await context.tool.transform((editor) => { + editor.add({ + name: `paseo_${definition.name}`, + description: definition.description, + input: definition.inputSchema, + options: { codemode: false }, + async execute(input, call) { + const scope = await find(call.sessionID); + if (!scope) throw new Error("OpenCode session is not bound to a Paseo agent"); + const result = await request( + scope, + `/sessions/${encodeURIComponent(scope.sessionID)}/tools/${encodeURIComponent(definition.name)}`, + input, + false, + call.signal, + ); + return { + content: result.content.map(toOpenCodeContent), + metadata: { paseoTool: definition.name }, + }; + }, + }); + }); + registrations.set(definition.name, { signature, registration }); + definitions.set(definition.name, definition); + } + async function clearUnusedDefinitions() { + if (bindings.size) return; + await Promise.all( + [...registrations.values()].map(({ registration }) => registration.dispose()), + ); + registrations.clear(); + definitions.clear(); + } + // Reinstall live tools before this plugin generation is made visible. + // Definitions are kept in memory, so unavailable owners cannot delay reload. + for (const definition of definitions.values()) await installDefinition(definition); + const object = { type: "object", additionalProperties: true }; + const rpc = await context.rpc.register( + { + id: "paseo.bridge", + methods: { + bind: { input: object, output: object }, + unbind: { input: object, output: object }, + refresh: { input: object, output: object }, + }, + events: {}, + }, + { + bind: async (input) => { + if ( + typeof input.sessionID !== "string" || + typeof input.baseUrl !== "string" || + typeof input.token !== "string" + ) + throw new Error("Invalid Paseo bridge binding"); + const next = updating + .catch(() => undefined) + .then(async () => { + const previous = bindings.get(input.sessionID); + if ( + previous && + (previous.baseUrl !== input.baseUrl || previous.token !== input.token) + ) { + let active; + try { + active = await request( + previous, + `/sessions/${encodeURIComponent(input.sessionID)}/context`, + undefined, + true, + ); + } catch (error) { + // Refusal or rejected credentials mean this endpoint no longer + // serves the previous binding. Other failures are inconclusive. + if ( + error?.status !== 401 && + error?.cause?.code !== "ECONNREFUSED" && + error?.code !== "ConnectionRefused" + ) + throw error; + } + if (active) + return { error: "OpenCode session is already bound to another Paseo bridge" }; + bindings.delete(input.sessionID); + await clearUnusedDefinitions(); + } + await install(input); + bindings.set(input.sessionID, { baseUrl: input.baseUrl, token: input.token }); + return {}; + }); + updating = next; + return await next; + }, + unbind: async (input) => { + const next = updating + .catch(() => undefined) + .then(async () => { + const binding = bindings.get(input.sessionID); + if (binding && binding.token === input.token && binding.baseUrl === input.baseUrl) + bindings.delete(input.sessionID); + await clearUnusedDefinitions(); + return {}; + }); + updating = next; + await next; + return {}; + }, + refresh: async () => { + await context.provider.reload(); + await context.model.reload(); + await context.agent.reload(); + await context.command.reload(); + await context.skill.reload(); + return {}; + }, + }, + ); + const filter = await context.session.hook("context", async (input) => { + const binding = await find(input.sessionID); + const scope = + binding && + (await request(binding, `/sessions/${encodeURIComponent(binding.sessionID)}/context?tools`)); + const allowed = new Set((scope?.tools ?? []).map((name) => `paseo_${name}`)); + for (const name of Object.keys(input.tools)) { + if (name.startsWith("paseo_") && name !== STRUCTURED_OUTPUT_TOOL && !allowed.has(name)) + delete input.tools[name]; + } + }); + return async () => { + await filter.dispose(); + await rpc.dispose(); + await Promise.all( + [...registrations.values()].map(({ registration }) => registration.dispose()), + ); + }; +} + +function toOpenCodeContent(part) { + if (part.type === "image") + return { type: "file", uri: `data:${part.mimeType};base64,${part.data}`, mime: part.mimeType }; + return part; +} + +function formatErrorDetail(details) { + if (!Array.isArray(details)) return typeof details === "string" ? details : ""; + return details + .map((issue) => { + const path = Array.isArray(issue?.path) && issue.path.length ? issue.path.join(".") : "input"; + return `${path}: ${issue?.message ?? "Invalid value"}`; + }) + .join("; "); +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/shared-bridge.test.ts b/packages/server/src/server/agent/providers/opencode/v2/shared-bridge.test.ts new file mode 100644 index 0000000000..c560fa6558 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/shared-bridge.test.ts @@ -0,0 +1,168 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { expect, test } from "vitest"; +import { z } from "zod"; +import { createTestLogger } from "../../../../../test-utils/test-logger.js"; +import { OpenCodeBridge } from "../bridge.js"; + +interface Binding { + sessionID: string; + baseUrl: string; + token: string; +} +interface TestTool { + name: string; + execute(input: { value: string }, call: { sessionID: string }): Promise; +} +interface TestContext { + sessionID: string; + tools: Record; + system: unknown[]; +} + +test("a shared plugin binds Paseo sessions without involving unrelated sessions", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-shared-bridge-")); + const bridge = new OpenCodeBridge({ paseoHome: home, logger: createTestLogger() }); + const tool = { name: "echo", description: "echo", inputSchema: { value: z.string() } }; + const tools = new Map([[tool.name, tool]]); + bridge.setManifestCatalog({ tools }); + await bridge.start(); + const unbind = bridge.bindSession({ + sessionId: "paseo", + env: {}, + tools: { + tools, + getTool: (name) => tools.get(name), + executeTool: async (_name, input) => ({ + content: [{ type: "text", text: String((input as { value: string }).value) }], + }), + }, + }); + expect(() => bridge.bindSession({ sessionId: "paseo", env: {} })).toThrow("already bound"); + try { + const plugin = { + package: await bridge.materializeV2Plugin(), + options: bridge.sharedV2Connection(), + }; + const module = await import( + pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href + ); + const installed = new Map(); + const hooks: Array<(input: TestContext) => Promise> = []; + const refreshed: string[] = []; + let structured = false; + let rpc: Record Promise> = {}; + const context = { + options: {}, + location: { directory: home }, + provider: { + reload: async () => { + refreshed.push("provider"); + }, + }, + model: { + reload: async () => { + refreshed.push("model"); + }, + }, + agent: { + reload: async () => { + refreshed.push("agent"); + }, + }, + command: { + reload: async () => { + refreshed.push("command"); + }, + }, + skill: { + reload: async () => { + refreshed.push("skill"); + }, + }, + rpc: { + register: async (_definition: unknown, handlers: typeof rpc) => { + rpc = handlers; + return { dispose: async () => {} }; + }, + }, + tool: { + transform: async (fn: (editor: { add(item: TestTool): void }) => void) => { + fn({ add: (item) => installed.set(item.name, item) }); + return { dispose: async () => {} }; + }, + }, + session: { + context: async () => + structured ? [{ type: "user", metadata: { paseoOutputSchema: { type: "string" } } }] : [], + get: async () => ({ parentID: "" }), + hook: async (_name: string, fn: (input: TestContext) => Promise) => { + hooks.push(fn); + return { dispose: async () => {} }; + }, + }, + }; + let dispose = await module.default.setup(context); + await rpc.bind({ sessionID: "paseo", ...plugin.options }); + await rpc.refresh({ sessionID: "paseo", ...plugin.options }); + expect(refreshed).toEqual(["provider", "model", "agent", "command", "skill"]); + await expect( + rpc.bind({ sessionID: "paseo", ...plugin.options, token: "another-daemon" }), + ).resolves.toEqual({ error: "OpenCode session is already bound to another Paseo bridge" }); + const own = { sessionID: "paseo", tools: { paseo_echo: {}, native: {} }, system: [] }; + for (const hook of hooks) await hook(own); + expect(Object.keys(own.tools)).toEqual(["paseo_echo", "native"]); + expect( + await installed.get("paseo_echo")!.execute({ value: "ok" }, { sessionID: "paseo" }), + ).toMatchObject({ content: [{ type: "text", text: "ok" }] }); + await dispose(); + hooks.length = 0; + installed.clear(); + dispose = await module.default.setup(context); + const afterReload = { sessionID: "paseo", tools: { paseo_echo: {}, native: {} }, system: [] }; + for (const hook of hooks) await hook(afterReload); + expect(Object.keys(afterReload.tools)).toEqual(["paseo_echo", "native"]); + expect( + await installed.get("paseo_echo")!.execute({ value: "reloaded" }, { sessionID: "paseo" }), + ).toMatchObject({ content: [{ type: "text", text: "reloaded" }] }); + await bridge.close(); + await dispose(); + hooks.length = 0; + installed.clear(); + dispose = await module.default.setup(context); + const unrelated = { sessionID: "other", tools: { paseo_echo: {}, native: {} }, system: [] }; + for (const hook of hooks) await hook(unrelated); + expect(Object.keys(unrelated.tools)).toEqual(["native"]); + structured = true; + const structuredOnly = { + sessionID: "other", + tools: { paseo_structured_output: {} }, + system: [], + }; + for (const hook of hooks) await hook(structuredOnly); + expect(Object.keys(structuredOnly.tools)).toEqual(["paseo_structured_output"]); + const restarted = new OpenCodeBridge({ + paseoHome: home, + logger: createTestLogger(), + port: Number(new URL(plugin.options.baseUrl).port), + }); + restarted.setManifestCatalog({ tools }); + await restarted.start(); + const releaseRestarted = restarted.bindSession({ sessionId: "paseo", env: {} }); + try { + await expect( + rpc.bind({ sessionID: "paseo", ...restarted.sharedV2Connection() }), + ).resolves.toEqual({}); + } finally { + releaseRestarted(); + await restarted.close(); + } + await dispose(); + } finally { + unbind(); + await bridge.close(); + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/shared-service.ts b/packages/server/src/server/agent/providers/opencode/v2/shared-service.ts new file mode 100644 index 0000000000..26fcbb7d26 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/shared-service.ts @@ -0,0 +1,192 @@ +import { OpenCode } from "@opencode/client"; +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Logger } from "pino"; +import type { ProviderRuntimeSettings } from "../../../provider-launch-config.js"; +import type { OpenCodeBridge } from "../bridge.js"; +import { OpenCodeHttpError } from "../http-error.js"; +import { resolveOpenCodeHomeDir } from "../paths.js"; +import { openCodeConfigDirectory, provisionLocalBridge } from "./bridge-provisioning.js"; +import { awaitPaseoPlugin } from "./readiness.js"; +import { + compatibleVersion, + isLocalOpenCodeHost, + type OpenCodeServiceSelection, +} from "./service-selection.js"; +import type { V2Api } from "./api.js"; + +interface SharedServiceOptions { + logger: Logger; + settings?: ProviderRuntimeSettings; + bridge?: OpenCodeBridge; +} + +export interface SharedService { + client: V2Api; + exited: Promise; + stop(): Promise; + bindSession(sessionId: string, cwd: string): Promise; + unbindSession(sessionId: string, cwd: string): Promise; + refreshLocation(cwd: string, signal?: AbortSignal): Promise; +} + +export async function connectSharedService( + selection: Extract, + options: SharedServiceOptions, +): Promise { + const server = selection.server; + const { logger } = options; + const lifetime = new AbortController(); + const closed = new Error(`OpenCode external server connection closed (${server.url})`); + let resolveExited!: (error: Error) => void; + const exited = new Promise((resolve) => { + resolveExited = resolve; + }); + const authorization = server.password + ? `Basic ${Buffer.from(`opencode:${server.password}`).toString("base64")}` + : undefined; + const client = OpenCode.make({ + baseUrl: server.url, + ...(authorization ? { headers: { Authorization: authorization } } : {}), + fetch: async (request, init) => { + const signal = init?.signal + ? AbortSignal.any([init.signal, lifetime.signal]) + : lifetime.signal; + const response = await fetch(request, { ...init, signal }); + const html = response.headers.get("content-type")?.includes("text/html") ?? false; + if (!response.ok || html) { + const requestUrl = request instanceof Request ? request.url : String(request); + throw new OpenCodeHttpError(new URL(requestUrl).pathname, response.status, html); + } + return response; + }, + }); + let info: Awaited>; + try { + info = await client.server.info({ + signal: AbortSignal.any([lifetime.signal, AbortSignal.timeout(30_000)]), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Cannot reach OpenCode v2 server at ${server.url}: ${message}`, { + cause: error, + }); + } + if (!compatibleVersion(info.version)) + throw new Error( + `Unsupported OpenCode version ${info.version}; version 2.0.10 or newer is required`, + ); + const bridge = options.bridge; + const pluginDirectory = bridge ? fileURLToPath(await bridge.materializeV2Plugin()) : undefined; + if (selection.kind === "discovered" && pluginDirectory) { + const env = { ...globalThis.process.env, ...options.settings?.env }; + const configDirectory = openCodeConfigDirectory(env); + const home = resolveOpenCodeHomeDir(); + await mkdir(home, { recursive: true }); + const entries = await client.config.get({ location: { directory: home } }); + if ( + !entries.some( + (entry) => + entry.type === "directory" && path.resolve(entry.path) === path.resolve(configDirectory), + ) + ) + throw new Error( + `OpenCode service does not use the expected configuration directory ${configDirectory}; configure the bridge on that service explicitly`, + ); + await provisionLocalBridge({ + configDirectory, + artifact: path.join(pluginDirectory, "server.js"), + }); + } + const advertised = + options.settings?.env?.PASEO_OPENCODE_BRIDGE_URL ?? + globalThis.process.env.PASEO_OPENCODE_BRIDGE_URL; + validateBridgeAddress(selection, bridge, advertised); + const generation: SharedService = { + client, + stop: async () => { + if (lifetime.signal.aborted) return; + lifetime.abort(closed); + resolveExited(closed); + }, + exited, + ...bridgeBindings(client, bridge, advertised, pluginDirectory), + }; + logger.info({ url: server.url }, "Using existing OpenCode v2 server"); + return generation; +} + +function validateBridgeAddress( + selection: Extract, + bridge: OpenCodeBridge | undefined, + advertised: string | undefined, +): void { + if (advertised) { + let bridgeUrl: URL; + try { + bridgeUrl = new URL(advertised); + } catch { + throw new Error("PASEO_OPENCODE_BRIDGE_URL must be an absolute HTTP or HTTPS URL"); + } + if (bridgeUrl.protocol !== "http:" && bridgeUrl.protocol !== "https:") + throw new Error("PASEO_OPENCODE_BRIDGE_URL must be an absolute HTTP or HTTPS URL"); + } + const hostname = new URL(selection.server.url).hostname; + if (bridge && selection.kind === "explicit" && !advertised && !isLocalOpenCodeHost(hostname)) + throw new Error( + "Remote OpenCode requires PASEO_OPENCODE_BRIDGE_URL pointing to a reachable Paseo bridge tunnel and the Paseo plugin installed on the server", + ); +} + +export function bridgeBindings( + client: ReturnType, + bridge: OpenCodeBridge | undefined, + advertised?: string, + pluginDirectory?: string, +): Pick { + return { + bindSession: async (sessionId, cwd) => { + if (!bridge) return; + const location = { directory: cwd }; + let result: Awaited>; + try { + await awaitPaseoPlugin({ client, location }); + result = await client.rpc.call({ + location, + rpcID: "paseo.bridge", + method: "bind", + input: { sessionID: sessionId, ...bridge.sharedV2Connection(advertised) }, + }); + } catch (error) { + throw new Error( + `Cannot connect the Paseo tool bridge to OpenCode. Install the plugin from ${pluginDirectory ?? "Paseo's runtime directory"} and configure a reachable bridge URL: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + if (result.output && typeof result.output === "object" && "error" in result.output) + throw new Error(String(result.output.error)); + }, + unbindSession: async (sessionId, cwd) => { + if (bridge) + await client.rpc.call( + { + location: { directory: cwd }, + rpcID: "paseo.bridge", + method: "unbind", + input: { sessionID: sessionId, ...bridge.sharedV2Connection(advertised) }, + }, + { signal: AbortSignal.timeout(2_000) }, + ); + }, + refreshLocation: async (cwd, signal) => { + if (!bridge) return; + const location = { directory: cwd }; + await awaitPaseoPlugin({ client, location, signal }); + await client.rpc.call( + { location, rpcID: "paseo.bridge", method: "refresh", input: {} }, + { signal }, + ); + }, + }; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/state.mjs b/packages/server/src/server/agent/providers/opencode/v2/state.mjs new file mode 100644 index 0000000000..858be8f14a --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/state.mjs @@ -0,0 +1,11 @@ +// OpenCode reloads the plugin entry point independently from this module. +// Keep live bindings and tool definitions in memory across those reloads. +const locations = new Map(); + +export function stateFor(location) { + const key = JSON.stringify([location.directory, location.workspaceID ?? null]); + if (!locations.has(key)) { + locations.set(key, { bindings: new Map(), definitions: new Map() }); + } + return locations.get(key); +} diff --git a/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts index e03c66bca0..db278d0249 100644 --- a/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts @@ -46,7 +46,7 @@ describe("daemon E2E (real opencode) - initial prompt wait", () => { } }); - test("waitForFinish does not resolve before an initial prompt using opencode/big-pickle actually completes", async () => { + test("waitForFinish does not resolve before an OpenCode initial prompt completes", async () => { const cwd = tmpCwd(); const { client, daemon } = await createHarness(); diff --git a/packages/server/src/server/daemon-e2e/real-provider-test-config.ts b/packages/server/src/server/daemon-e2e/real-provider-test-config.ts index e54b781e65..e15c618fbf 100644 --- a/packages/server/src/server/daemon-e2e/real-provider-test-config.ts +++ b/packages/server/src/server/daemon-e2e/real-provider-test-config.ts @@ -26,7 +26,7 @@ const PI_AUTH_CONFIG_PATH = join(homedir(), ".pi", "agent", "auth.json"); const CODEX_AUTH_CONFIG_PATH = join(homedir(), ".codex", "auth.json"); const CLAUDE_REAL_TEST_MODEL = "haiku"; const CODEX_REAL_TEST_MODEL = "~openai/gpt-latest"; -const OPENCODE_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite"; +const OPENCODE_REAL_TEST_MODEL = "openrouter/google/gemini-3.5-flash-lite"; const PI_OPENROUTER_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite"; const PI_CODEX_REAL_TEST_MODEL = "openai-codex/gpt-5.4"; const OMP_OPENROUTER_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite";