diff --git a/docs/providers.md b/docs/providers.md index c33c299a50..8e98fde809 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -77,7 +77,9 @@ 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 MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path. +OpenCode 1 keeps MCP and process environment outside the session boundary. Paseo shares one OpenCode server for ordinary agents and installs a daemon-owned plugin through `OPENCODE_CONFIG_CONTENT`. The plugin reads the exact agent environment and caller-scoped Paseo tool catalog from the daemon's private loopback bridge for each OpenCode session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifact contains no session data or secrets. + +An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. Keep that isolation until OpenCode exposes those values as session-owned configuration. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers. OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event. diff --git a/packages/server/package.json b/packages/server/package.json index f0b42f7335..ea558e5cef 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -38,7 +38,7 @@ "clean": "node ../../scripts/clean-package-dist.mjs", "build": "npm run build:lib && npm run build:scripts", "build:clean": "npm run clean && npm run build", - "build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs'); fs.rmSync('dist/server/skills',{recursive:true,force:true}); fs.cpSync('../../skills','dist/server/skills',{recursive:true});\"", + "build:lib": "tsc -p tsconfig.server.json --incremental false && node scripts/build-opencode-bridge-plugin.mjs && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs'); fs.rmSync('dist/server/skills',{recursive:true,force:true}); fs.cpSync('../../skills','dist/server/skills',{recursive:true});\"", "build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"", "prepack": "npm run build:clean && npm --prefix ../.. run build:daemon-web-ui", "start": "node dist/scripts/supervisor-entrypoint.js", diff --git a/packages/server/scripts/build-opencode-bridge-plugin.mjs b/packages/server/scripts/build-opencode-bridge-plugin.mjs new file mode 100644 index 0000000000..596f429e51 --- /dev/null +++ b/packages/server/scripts/build-opencode-bridge-plugin.mjs @@ -0,0 +1,25 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { build } from "esbuild"; + +const sourcePath = fileURLToPath( + new URL("../src/server/agent/providers/opencode/bridge-plugin.mjs", import.meta.url), +); +const outputPath = fileURLToPath( + new URL( + "../dist/server/server/agent/providers/opencode/bridge-plugin.bundle.mjs", + import.meta.url, + ), +); + +await mkdir(path.dirname(outputPath), { recursive: true }); +await build({ + entryPoints: [sourcePath], + bundle: true, + format: "esm", + platform: "neutral", + target: "es2022", + outfile: outputPath, +}); diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 58e45d37c7..c65db34b2d 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -42,6 +42,7 @@ import { GenericACPAgentClient } from "./providers/generic-acp-agent.js"; import { KimiACPAgentClient } from "./providers/kimi-acp-agent.js"; import { KiroACPAgentClient } from "./providers/kiro-acp-agent.js"; import { OpenCodeAgentClient } from "./providers/opencode-agent.js"; +import type { OpenCodeBridge } from "./providers/opencode/bridge.js"; import { OmpAgentClient } from "./providers/omp/agent.js"; import type { OmpRuntime } from "./providers/omp/runtime.js"; import { PiRpcAgentClient } from "./providers/pi/agent.js"; @@ -108,12 +109,14 @@ export interface BuildProviderRegistryOptions { managedProcesses?: ManagedProcessRegistry; isDev?: boolean; ompRuntime?: OmpRuntime; + openCodeBridge?: OpenCodeBridge; } interface ProviderClientFactoryOptions extends Pick< BuildProviderRegistryOptions, "workspaceGitService" | "managedProcesses" | "ompRuntime" > { + openCodeBridge?: OpenCodeBridge; providerParams?: unknown; customProvider?: { id: string; @@ -212,6 +215,7 @@ const PROVIDER_CLIENT_FACTORIES: Record = { opencode: (logger, runtimeSettings, options) => new OpenCodeAgentClient(logger, runtimeSettings, { managedProcesses: options?.managedProcesses, + bridge: options?.openCodeBridge, }), pi: (logger, runtimeSettings, options) => new PiRpcAgentClient({ @@ -700,7 +704,7 @@ function buildResolvedBuiltinProviders( runtimeSettings: AgentProviderRuntimeSettingsMap | undefined, options: Pick< BuildProviderRegistryOptions, - "workspaceGitService" | "managedProcesses" | "ompRuntime" + "workspaceGitService" | "managedProcesses" | "ompRuntime" | "openCodeBridge" >, isDev: boolean, ): Map { @@ -732,6 +736,7 @@ function buildResolvedBuiltinProviders( workspaceGitService: options.workspaceGitService, managedProcesses: options.managedProcesses, ompRuntime: options.ompRuntime, + openCodeBridge: options.openCodeBridge, providerParams: override?.params, }), contract: PROVIDER_CONTRACTS[definition.id] ?? UNSUPPORTED_PROVIDER_CONTRACT, @@ -744,7 +749,7 @@ function buildResolvedBuiltinProviders( function addDerivedProviders( resolvedProviders: Map, providerOverrides: Record, - options: Pick, + options: Pick, ): void { for (const [providerId, override] of Object.entries(providerOverrides)) { if (resolvedProviders.has(providerId) || BUILTIN_PROVIDER_IDS.includes(providerId)) { @@ -840,6 +845,7 @@ function addDerivedProviders( createBaseClient: (logger) => baseFactory(logger, mergedRuntimeSettings, { managedProcesses: options.managedProcesses, + openCodeBridge: options.openCodeBridge, providerParams, customProvider: { id: providerId, @@ -865,11 +871,13 @@ export function buildProviderRegistry( workspaceGitService: options?.workspaceGitService, managedProcesses: options?.managedProcesses, ompRuntime: options?.ompRuntime, + openCodeBridge: options?.openCodeBridge, }, options?.isDev === true, ); addDerivedProviders(resolvedProviders, providerOverrides, { managedProcesses: options?.managedProcesses, + openCodeBridge: options?.openCodeBridge, }); return Object.fromEntries( diff --git a/packages/server/src/server/agent/provider-runtime.test.ts b/packages/server/src/server/agent/provider-runtime.test.ts new file mode 100644 index 0000000000..33b874d2a1 --- /dev/null +++ b/packages/server/src/server/agent/provider-runtime.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { createTestLogger } from "../../test-utils/test-logger.js"; +import { createAgentProviderRuntime } from "./provider-runtime.js"; +import { OpenCodeBridge } from "./providers/opencode/bridge.js"; +import type { PaseoToolCatalog } from "./tools/types.js"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("agent provider runtime", () => { + test("owns bridge injection, catalog forwarding, and idempotent shutdown", async () => { + vi.spyOn(OpenCodeBridge.prototype, "start").mockResolvedValue(); + const setManifestCatalog = vi + .spyOn(OpenCodeBridge.prototype, "setManifestCatalog") + .mockImplementation(() => undefined); + const close = vi.spyOn(OpenCodeBridge.prototype, "close").mockResolvedValue(); + const runtime = await createAgentProviderRuntime({ + paseoHome: "/tmp/paseo-provider-runtime-test", + logger: createTestLogger(), + snapshotManager: {}, + }); + const catalog = emptyCatalog(); + + expect( + runtime.snapshotManager.getAgentManagerProviderState().clients.opencode?.capabilities + .supportsNativePaseoTools, + ).toBe(true); + runtime.setPaseoToolCatalog(catalog); + await Promise.all([runtime.shutdown(), runtime.shutdown()]); + + expect(setManifestCatalog).toHaveBeenCalledOnce(); + expect(setManifestCatalog).toHaveBeenCalledWith(catalog); + expect(close).toHaveBeenCalledOnce(); + }); + + test("closes the bridge when startup fails", async () => { + vi.spyOn(OpenCodeBridge.prototype, "start").mockRejectedValue(new Error("startup failed")); + const close = vi.spyOn(OpenCodeBridge.prototype, "close").mockResolvedValue(); + + await expect( + createAgentProviderRuntime({ + paseoHome: "/tmp/paseo-provider-runtime-test", + logger: createTestLogger(), + snapshotManager: {}, + }), + ).rejects.toThrow("startup failed"); + expect(close).toHaveBeenCalledOnce(); + }); +}); + +function emptyCatalog(): PaseoToolCatalog { + return { + tools: new Map(), + getTool: () => undefined, + executeTool: async () => { + throw new Error("Unknown tool"); + }, + }; +} diff --git a/packages/server/src/server/agent/provider-runtime.ts b/packages/server/src/server/agent/provider-runtime.ts new file mode 100644 index 0000000000..9094527739 --- /dev/null +++ b/packages/server/src/server/agent/provider-runtime.ts @@ -0,0 +1,57 @@ +import type { Logger } from "pino"; + +import { + ProviderSnapshotManager, + type ProviderSnapshotManagerOptions, +} from "./provider-snapshot-manager.js"; +import { OpenCodeBridge } from "./providers/opencode/bridge.js"; +import type { PaseoToolCatalog } from "./tools/types.js"; + +export interface AgentProviderRuntime { + snapshotManager: ProviderSnapshotManager; + setPaseoToolCatalog(catalog: PaseoToolCatalog | null): void; + shutdown(): Promise; +} + +interface CreateAgentProviderRuntimeOptions { + paseoHome: string; + logger: Logger; + snapshotManager: Omit; +} + +export async function createAgentProviderRuntime( + options: CreateAgentProviderRuntimeOptions, +): Promise { + const bridge = new OpenCodeBridge({ paseoHome: options.paseoHome, logger: options.logger }); + try { + await bridge.start(); + const snapshotManager = new ProviderSnapshotManager({ + ...options.snapshotManager, + logger: options.logger.child({ module: "provider-snapshot-manager" }), + openCodeBridge: bridge, + }); + let shutdownPromise: Promise | null = null; + return { + snapshotManager, + setPaseoToolCatalog: (catalog) => bridge.setManifestCatalog(catalog), + shutdown: () => { + shutdownPromise ??= shutdownProviderRuntime(snapshotManager, bridge); + return shutdownPromise; + }, + }; + } catch (error) { + await bridge.close().catch(() => undefined); + throw error; + } +} + +async function shutdownProviderRuntime( + snapshotManager: ProviderSnapshotManager, + bridge: OpenCodeBridge, +): Promise { + try { + await snapshotManager.shutdown(); + } finally { + await bridge.close(); + } +} diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 2541124156..de7a69be39 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -23,6 +23,7 @@ import { import type { ManagedAgent } from "./agent-manager.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; import type { ManagedProcessRegistry } from "../managed-processes/managed-processes.js"; +import type { OpenCodeBridge } from "./providers/opencode/bridge.js"; import type { AgentProviderRuntimeSettingsMap, ProviderOverride, @@ -98,6 +99,7 @@ export interface ProviderSnapshotManagerOptions { extraClients?: Partial>; refreshTimeoutMs?: number; diagnosticTimeoutMs?: number; + openCodeBridge?: OpenCodeBridge; } interface ProviderSnapshotRefreshOptions { @@ -208,6 +210,7 @@ export class ProviderSnapshotManager { private readonly logger: Logger; private readonly workspaceGitService?: Pick; private readonly managedProcesses?: ManagedProcessRegistry; + private readonly openCodeBridge?: OpenCodeBridge; private readonly isDev: boolean; private readonly extraClients: Partial>; private runtimeSettings: AgentProviderRuntimeSettingsMap | undefined; @@ -221,6 +224,7 @@ export class ProviderSnapshotManager { this.logger = options.logger; this.workspaceGitService = options.workspaceGitService; this.managedProcesses = options.managedProcesses; + this.openCodeBridge = options.openCodeBridge; this.isDev = options.isDev === true; this.extraClients = options.extraClients ?? {}; this.runtimeSettings = options.runtimeSettings; @@ -589,6 +593,7 @@ export class ProviderSnapshotManager { providerOverrides: this.providerOverrides, workspaceGitService: this.workspaceGitService, managedProcesses: this.managedProcesses, + openCodeBridge: this.openCodeBridge, isDev: this.isDev, }); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 574ca43f62..3feb1dd2e8 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -82,6 +82,7 @@ import { type OpenCodeServerAcquisition, type OpenCodeServerManagerLike, } from "./opencode/server-manager.js"; +import type { OpenCodeBridge } from "./opencode/bridge.js"; import { OpenCodeEventConsumer, type OpenCodeEventStreamDiagnostics, @@ -346,6 +347,16 @@ type OpenCodeAgentConfig = Omit & { provider: "opencode"; providerOptions: OpenCodeProviderOptions; }; + +const OPENCODE_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD"]); + +function requiresDedicatedOpenCodeServer( + config: OpenCodeAgentConfig, + launchContext?: AgentLaunchContext, +): boolean { + if (config.mcpServers && Object.keys(config.mcpServers).length > 0) return true; + return Object.keys(launchContext?.env ?? {}).some((key) => !OPENCODE_SESSION_ENV_KEYS.has(key)); +} type OpenCodeMessageRole = "user" | "assistant"; type OpenCodePersistedSession = OpenCodeSession | OpenCodeGlobalSession; @@ -1366,6 +1377,7 @@ interface OpenCodeAgentClientDeps { createClient?: OpenCodeClientFactory; resolveHomeDir?: () => string; managedProcesses?: ManagedProcessRegistry; + bridge?: OpenCodeBridge; } type OpenCodeClientFactory = (options: { baseUrl: string; directory: string }) => OpencodeClient; @@ -1376,7 +1388,7 @@ function createSdkOpenCodeClient(options: { baseUrl: string; directory: string } export class OpenCodeAgentClient implements AgentClient { readonly provider = "opencode" as const; - readonly capabilities = OPENCODE_CAPABILITIES; + readonly capabilities: AgentCapabilityFlags; readonly resolveCreateConfig = resolveOpenCodeCreateConfig; readonly isCreateConfigUnattended = isOpenCodeCreateConfigUnattended; @@ -1386,6 +1398,7 @@ export class OpenCodeAgentClient implements AgentClient { private readonly logger: Logger; private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly modelContextWindows = new Map(); + private readonly bridge?: OpenCodeBridge; constructor( logger: Logger, @@ -1393,6 +1406,11 @@ export class OpenCodeAgentClient implements AgentClient { deps: OpenCodeAgentClientDeps = {}, ) { this.logger = logger.child({ module: "agent", provider: "opencode" }); + this.bridge = deps.bridge; + this.capabilities = { + ...OPENCODE_CAPABILITIES, + ...(this.bridge ? { supportsNativePaseoTools: true } : {}), + }; this.runtimeSettings = runtimeSettings; this.createOpenCodeClient = deps.createClient ?? createSdkOpenCodeClient; this.serverManager = @@ -1407,6 +1425,9 @@ export class OpenCodeAgentClient implements AgentClient { logger: eventLogger, createClient: (baseUrl) => this.createOpenCodeClient({ baseUrl, directory: "" }), }), + decorateServerEnv: this.bridge + ? (env) => this.bridge?.decorateServerEnv(env) ?? env + : undefined, }); this.resolveHomeDir = deps.resolveHomeDir ?? resolveOpenCodeHomeDir; } @@ -1417,9 +1438,7 @@ export class OpenCodeAgentClient implements AgentClient { options?: AgentCreateSessionOptions, ): Promise { const openCodeConfig = this.assertConfig(config); - const acquisition = launchContext?.env - ? await this.serverManager.acquireDedicated(launchContext.env) - : await this.serverManager.acquireCurrent(); + const acquisition = await this.acquireServer(openCodeConfig, launchContext); const { url } = acquisition.server; const client = this.createOpenCodeClient({ baseUrl: url, @@ -1448,6 +1467,7 @@ export class OpenCodeAgentClient implements AgentClient { } await this.populateModelContextWindowCache(client, openCodeConfig.cwd); + const unbindBridge = this.bindBridgeSession(session.id, launchContext); return new OpenCodeAgentSession( openCodeConfig, @@ -1460,6 +1480,8 @@ export class OpenCodeAgentClient implements AgentClient { options?.persistSession, launchContext?.agentId, url, + false, + unbindBridge, ); } catch (error) { await acquisition.release(); @@ -1490,10 +1512,7 @@ export class OpenCodeAgentClient implements AgentClient { ? this.serverManager.acquireExisting(registeredServerUrl) : null; const acquisition = - registeredAcquisition ?? - (launchContext?.env - ? await this.serverManager.acquireDedicated(launchContext.env) - : await this.serverManager.acquireCurrent()); + registeredAcquisition ?? (await this.acquireServer(openCodeConfig, launchContext)); const { url } = acquisition.server; const client = this.createOpenCodeClient({ baseUrl: url, @@ -1502,6 +1521,7 @@ export class OpenCodeAgentClient implements AgentClient { try { await this.populateModelContextWindowCache(client, openCodeConfig.cwd); + const unbindBridge = this.bindBridgeSession(handle.sessionId, launchContext); return new OpenCodeAgentSession( openCodeConfig, @@ -1515,6 +1535,7 @@ export class OpenCodeAgentClient implements AgentClient { launchContext?.agentId, url, registeredAcquisition !== null, + unbindBridge, ); } catch (error) { await acquisition.release(); @@ -1522,6 +1543,30 @@ export class OpenCodeAgentClient implements AgentClient { } } + private acquireServer( + config: OpenCodeAgentConfig, + launchContext?: AgentLaunchContext, + ): Promise { + if (!this.bridge || requiresDedicatedOpenCodeServer(config, launchContext)) { + return launchContext?.env + ? this.serverManager.acquireDedicated(launchContext.env) + : this.serverManager.acquireCurrent(); + } + return this.serverManager.acquireCurrent(); + } + + private bindBridgeSession( + sessionId: string, + launchContext?: AgentLaunchContext, + ): (() => void) | undefined { + if (!this.bridge || !launchContext) return undefined; + return this.bridge.bindSession({ + sessionId, + env: launchContext.env ?? {}, + tools: launchContext.paseoTools, + }); + } + async fetchCatalog( options: FetchCatalogOptions, context?: ProviderRefreshContext, @@ -3306,6 +3351,7 @@ class OpenCodeAgentSession implements AgentSession { private readonly unrelatedSessionIds = new Set(); private selectedModelContextWindowMaxTokens: number | undefined; private releaseServer: (() => Promise) | null; + private releaseBridge: (() => void) | null; private ingress = Promise.resolve(); private gapRepairRevision = 0; private recoveryAbortController = new AbortController(); @@ -3325,6 +3371,7 @@ class OpenCodeAgentSession implements AgentSession { private readonly agentId?: string, private readonly serverUrl?: string, private readonly externallyDriven = false, + releaseBridge?: () => void, ) { this.config = config; this.client = client; @@ -3334,6 +3381,7 @@ class OpenCodeAgentSession implements AgentSession { this.currentMode = normalizeOpenCodeModeId(config.modeId); this.autoAcceptEnabled = !config.toolPolicy && isOpenCodeAutoAcceptEnabled(config); this.releaseServer = releaseServer ?? null; + this.releaseBridge = releaseBridge ?? null; this.persistSession = persistSession; this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens( config.model, @@ -4847,6 +4895,8 @@ class OpenCodeAgentSession implements AgentSession { await this.deleteProviderSessionIfEphemeral(); this.turnState = { status: "idle" }; } finally { + this.releaseBridge?.(); + this.releaseBridge = null; await this.releaseServer?.(); this.releaseServer = null; } 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 new file mode 100644 index 0000000000..133ac3c992 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode-bridge-adapter.test.ts @@ -0,0 +1,123 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { OpenCodeAgentClient } from "./opencode-agent.js"; +import { OpenCodeBridge } from "./opencode/bridge.js"; +import { + TestOpenCodeClient, + TestOpenCodeHarness, +} from "./opencode/test-utils/test-opencode-harness.js"; + +describe("OpenCode bridge adapter", () => { + const cleanups: Array<() => Promise> = []; + + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); + }); + + test("shares the server and binds exact managed env per OpenCode session", async () => { + const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-adapter-")); + const bridge = new OpenCodeBridge({ paseoHome, logger: createTestLogger() }); + await bridge.start(); + cleanups.push(async () => { + await bridge.close(); + await rm(paseoHome, { recursive: true, force: true }); + }); + + const runtime = new TestOpenCodeHarness(); + const firstClient = new TestOpenCodeClient(); + const secondClient = new TestOpenCodeClient(); + firstClient.sessionCreateResponse = { data: { id: "ses_first" } }; + secondClient.sessionCreateResponse = { data: { id: "ses_second" } }; + runtime.enqueueClient(firstClient); + runtime.enqueueClient(secondClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + bridge, + }); + + const first = await client.createSession( + { provider: "opencode", cwd: "/workspace/one" }, + { + agentId: "agent-one", + env: { PASEO_AGENT_ID: "agent-one", PASEO_AGENT_CWD: "/workspace/one" }, + }, + ); + const second = await client.createSession( + { provider: "opencode", cwd: "/workspace/two" }, + { + agentId: "agent-two", + env: { PASEO_AGENT_ID: "agent-two", PASEO_AGENT_CWD: "/workspace/two" }, + }, + ); + + expect(client.capabilities.supportsNativePaseoTools).toBe(true); + expect(runtime.acquisitions.map(({ kind }) => kind)).toEqual(["current", "current"]); + await expect(readBridgeContext(bridge, "ses_first")).resolves.toEqual({ + env: { PASEO_AGENT_ID: "agent-one", PASEO_AGENT_CWD: "/workspace/one" }, + }); + await expect(readBridgeContext(bridge, "ses_second")).resolves.toEqual({ + env: { PASEO_AGENT_ID: "agent-two", PASEO_AGENT_CWD: "/workspace/two" }, + }); + + await first.close(); + await expect(readBridgeContext(bridge, "ses_first")).rejects.toThrow("404"); + await expect(readBridgeContext(bridge, "ses_second")).resolves.toBeDefined(); + await second.close(); + }); + + test("keeps process-scoped env and directory-scoped MCP on dedicated servers", async () => { + const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-adapter-")); + const bridge = new OpenCodeBridge({ paseoHome, logger: createTestLogger() }); + await bridge.start(); + cleanups.push(async () => { + await bridge.close(); + await rm(paseoHome, { recursive: true, force: true }); + }); + const runtime = new TestOpenCodeHarness(); + runtime.enqueueClient(new TestOpenCodeClient()); + runtime.enqueueClient(new TestOpenCodeClient()); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + bridge, + }); + + const customEnv = await client.createSession( + { provider: "opencode", cwd: "/workspace/one" }, + { env: { PASEO_AGENT_ID: "one", CUSTOM_TOKEN: "secret" } }, + ); + const customMcp = await client.createSession( + { + provider: "opencode", + cwd: "/workspace/two", + mcpServers: { custom: { transport: "http", url: "http://127.0.0.1:9999/mcp" } }, + }, + { env: { PASEO_AGENT_ID: "two" } }, + ); + + expect(runtime.acquisitions.map(({ kind }) => kind)).toEqual(["dedicated", "dedicated"]); + await customEnv.close(); + await customMcp.close(); + }); +}); + +async function readBridgeContext(bridge: OpenCodeBridge, sessionId: string): Promise { + const decorated = bridge.decorateServerEnv({}); + const config = JSON.parse(decorated.OPENCODE_CONFIG_CONTENT); + const [, options] = config.plugin[0]; + const response = await fetch( + new URL( + `/_internal/opencode/sessions/${encodeURIComponent(sessionId)}/context`, + options.baseUrl, + ), + { headers: { Authorization: `Bearer ${options.token}` } }, + ); + if (!response.ok) throw new Error(`OpenCode bridge returned ${response.status}`); + return response.json(); +} diff --git a/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts new file mode 100644 index 0000000000..af67c2604a --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts @@ -0,0 +1,158 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; +import { expect, test } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import type { PaseoToolCatalog } from "../tools/types.js"; +import { OpenCodeAgentClient } from "./opencode-agent.js"; +import { OpenCodeBridge } from "./opencode/bridge.js"; +import { OpenCodeServerManager } from "./opencode/server-manager.js"; + +test("real OpenCode server shares one process while shell.env stays session-scoped", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-real-")); + const firstCwd = path.join(root, "first"); + const secondCwd = path.join(root, "second"); + const logger = createTestLogger(); + const bridge = new OpenCodeBridge({ paseoHome: root, logger }); + await bridge.start(); + const firstTools = createCallerCatalog("real-agent-one"); + const secondTools = createCallerCatalog("real-agent-two"); + bridge.setManifestCatalog(firstTools); + const manager = new OpenCodeServerManager({ + logger, + resolveHomeDir: () => root, + decorateServerEnv: (env) => bridge.decorateServerEnv(env), + }); + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: manager, + bridge, + }); + let first: Awaited> | undefined; + let second: Awaited> | undefined; + let inspection: Awaited> | undefined; + + try { + await Promise.all([ + mkdir(firstCwd, { recursive: true }), + mkdir(secondCwd, { recursive: true }), + ]); + first = await client.createSession( + { provider: "opencode", cwd: firstCwd, model: "opencode/big-pickle", modeId: "build" }, + { + agentId: "real-agent-one", + env: { PASEO_AGENT_ID: "real-agent-one", PASEO_AGENT_CWD: firstCwd }, + paseoTools: firstTools, + }, + { persistSession: false }, + ); + second = await client.createSession( + { provider: "opencode", cwd: secondCwd }, + { + agentId: "real-agent-two", + env: { PASEO_AGENT_ID: "real-agent-two", PASEO_AGENT_CWD: secondCwd }, + paseoTools: secondTools, + }, + { persistSession: false }, + ); + + inspection = await manager.acquireCurrent(); + const sdk = createOpencodeClient({ baseUrl: inspection.server.url, directory: root }); + const [firstShell, secondShell] = await Promise.all([ + sdk.session.shell({ + sessionID: requireSessionId(first), + directory: firstCwd, + agent: "build", + command: 'printf "%s|%s" "$PASEO_AGENT_ID" "$PASEO_AGENT_CWD"', + }), + sdk.session.shell({ + sessionID: requireSessionId(second), + directory: secondCwd, + agent: "build", + command: 'printf "%s|%s" "$PASEO_AGENT_ID" "$PASEO_AGENT_CWD"', + }), + ]); + + expect(firstShell.error).toBeUndefined(); + expect(secondShell.error).toBeUndefined(); + expect(JSON.stringify(firstShell.data)).toContain(`real-agent-one|${firstCwd}`); + expect(JSON.stringify(secondShell.data)).toContain(`real-agent-two|${secondCwd}`); + expect(inspection.server.url).toMatch(/^http:\/\/127\.0\.0\.1:/); + + const agentResult = await first.run( + [ + "Use the bash tool to run: env | grep -E '^(PASEO_AGENT_ID|PASEO_AGENT_CWD)='", + "Then report both values in your response:", + "AGENT=real-agent-one", + `CWD=${firstCwd}`, + ].join("\n"), + ); + expect(agentResult.timeline).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "tool_call", name: "bash", status: "completed" }), + ]), + ); + const envReport = readAssistantText(agentResult.timeline); + expect(envReport).toContain("real-agent-one"); + expect(envReport).toContain(firstCwd); + + const callerResult = await first.run( + [ + "Use the paseo_report_caller_agent_id tool to read your Paseo caller agent ID.", + "Then report that ID in your response.", + ].join("\n"), + ); + expect(callerResult.timeline).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "tool_call", + name: "paseo_report_caller_agent_id", + status: "completed", + }), + ]), + ); + expect(readAssistantText(callerResult.timeline)).toContain("real-agent-one"); + } finally { + await inspection?.release(); + await first?.close(); + await second?.close(); + await manager.shutdown(); + await bridge.close(); + await rm(root, { recursive: true, force: true }); + } +}, 240_000); + +function requireSessionId(session: { id: string | null }): string { + if (!session.id) throw new Error("OpenCode session has no id"); + return session.id; +} + +function createCallerCatalog(callerAgentId: string): PaseoToolCatalog { + const tool = { + name: "report_caller_agent_id", + title: "Report Paseo caller agent ID", + description: "Returns the caller agent ID assigned by Paseo.", + inputSchema: {}, + async handler() { + return { content: [{ type: "text", text: callerAgentId }] }; + }, + }; + const tools = new Map([[tool.name, tool]]); + return { + tools, + getTool: (name) => tools.get(name), + async executeTool(name, input, context) { + const definition = tools.get(name); + if (!definition) throw new Error(`Unknown tool: ${name}`); + return definition.handler(input, context ?? {}); + }, + }; +} + +function readAssistantText(timeline: ReadonlyArray<{ type: string; text?: string }>): string { + return timeline + .flatMap((item) => (item.type === "assistant_message" ? [item.text ?? ""] : [])) + .join(""); +} diff --git a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts index 200632cc6f..74c6143c1f 100644 --- a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts +++ b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts @@ -5,6 +5,8 @@ import { createServer, type ServerResponse } from "node:http"; import net from "node:net"; import os from "node:os"; import path from "node:path"; +import { Writable } from "node:stream"; +import pino, { type Logger } from "pino"; import { afterEach, describe, expect, test, vi } from "vitest"; import { findExecutable } from "../../../executable-resolution/executable-resolution.js"; @@ -28,6 +30,26 @@ afterEach(() => { }); describe("OpenCodeServerManager generations", () => { + test("logs generation lifecycle transitions", async () => { + const { logger, records } = createCapturingLogger(); + const { manager } = createTestManager([4081, 4082], { logger }); + + const first = await manager.acquireCurrent(); + const second = await manager.acquireNew(); + await second.release(); + await first.release(); + await manager.shutdown(); + + expect(records).toEqual( + expect.arrayContaining([ + expect.objectContaining({ msg: "OpenCode server generation started", port: 4081 }), + expect.objectContaining({ msg: "OpenCode server generation retired", port: 4081 }), + expect.objectContaining({ msg: "OpenCode server generation started", port: 4082 }), + expect.objectContaining({ msg: "OpenCode server generation released", port: 4082 }), + expect.objectContaining({ msg: "OpenCode server generation exited", port: 4081 }), + ]), + ); + }); test("shares one real SDK event stream across acquisitions until generation shutdown", async () => { const responses: ServerResponse[] = []; let requestCount = 0; @@ -400,6 +422,7 @@ function createTestManager( autoAnnounce?: boolean; baseEnv?: Record; opencodeHomeDir?: string; + logger?: Logger; } = {}, ): { manager: OpenCodeServerManager; @@ -411,7 +434,7 @@ function createTestManager( }); return { manager: new OpenCodeServerManager({ - logger: createTestLogger(), + logger: options.logger ?? createTestLogger(), baseEnv: options.baseEnv, managedProcesses: runtime.managedProcesses, portAllocator: runtime.allocatePort, @@ -424,6 +447,17 @@ function createTestManager( }; } +function createCapturingLogger(): { logger: Logger; records: Array> } { + const records: Array> = []; + const stream = new Writable({ + write(chunk, _encoding, callback) { + records.push(JSON.parse(chunk.toString()) as Record); + callback(); + }, + }); + return { logger: pino({ level: "info" }, stream), records }; +} + class FakeOpenCodeServerRuntime { readonly managedProcesses = new FakeManagedProcesses(); readonly terminatedPorts: number[] = []; diff --git a/packages/server/src/server/agent/providers/opencode/bridge-plugin.mjs b/packages/server/src/server/agent/providers/opencode/bridge-plugin.mjs new file mode 100644 index 0000000000..058ade60f8 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/bridge-plugin.mjs @@ -0,0 +1,169 @@ +import { z } from "zod"; + +const INTERNAL_PREFIX = "/_internal/opencode"; + +export default async function paseoPlugin(input, options) { + const request = async (pathname, init) => { + const response = await fetch(new URL(pathname, options.baseUrl), { + ...init, + headers: { + Authorization: `Bearer ${options.token}`, + ...init?.headers, + }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const error = new Error(payload.error ?? `Paseo OpenCode bridge failed: ${response.status}`); + error.status = response.status; + throw error; + } + return payload; + }; + + let manifest; + try { + manifest = await request(`${INTERNAL_PREFIX}/tools`); + } catch (error) { + logPluginError("manifest", {}, error); + throw error; + } + const tools = {}; + for (const definition of manifest.tools ?? []) { + tools[`paseo_${definition.name}`] = { + description: definition.description, + args: jsonSchemaObjectToZodShape(definition.inputSchema), + execute: async (args, context) => { + let result; + try { + result = await request( + `${INTERNAL_PREFIX}/sessions/${encodeURIComponent(context.sessionID)}/tools/${encodeURIComponent(definition.name)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(args), + }, + ); + } catch (error) { + logPluginError("tool", { sessionID: context.sessionID, tool: definition.name }, error); + throw error; + } + return { + title: definition.title, + output: formatToolResult(result), + metadata: { paseoTool: definition.name }, + }; + }, + }; + } + + return { + tool: tools, + "shell.env": async ({ cwd, sessionID }, output) => { + if (!sessionID) return; + let context; + try { + context = await resolveSessionContext(input.client, request, sessionID, cwd); + } catch (error) { + logPluginError("shell.env", { sessionID }, error); + throw error; + } + Object.assign(output.env, context.env); + }, + }; +} + +function logPluginError(stage, context, error) { + console.error(`[paseo-opencode-plugin] ${stage} failed`, { + ...context, + error: error instanceof Error ? error.message : String(error), + }); +} + +async function resolveSessionContext(client, request, sessionID, cwd, visited = new Set()) { + if (visited.has(sessionID)) throw new Error("OpenCode session parent cycle"); + visited.add(sessionID); + try { + return await request(`${INTERNAL_PREFIX}/sessions/${encodeURIComponent(sessionID)}/context`); + } catch (error) { + if (error.status !== 404) throw error; + const response = await client.session.get({ + path: { id: sessionID }, + query: { directory: cwd }, + }); + const parentID = response?.data?.parentID; + if (!parentID) throw error; + return resolveSessionContext(client, request, parentID, cwd, visited); + } +} + +function formatToolResult(result) { + if (!Array.isArray(result.content)) return JSON.stringify(result, null, 2); + const text = result.content + .map((part) => (part?.type === "text" && typeof part.text === "string" ? part.text : null)) + .filter((part) => part !== null) + .join("\n"); + return text || JSON.stringify(result.structuredContent ?? result.content, null, 2); +} + +function jsonSchemaObjectToZodShape(schema) { + const properties = + schema?.properties && typeof schema.properties === "object" ? schema.properties : {}; + const required = new Set(Array.isArray(schema?.required) ? schema.required : []); + const shape = {}; + for (const [key, property] of Object.entries(properties)) { + const parsed = jsonSchemaToZod(property); + shape[key] = required.has(key) ? parsed : parsed.optional(); + } + return shape; +} + +function jsonSchemaToZod(schema) { + if (!schema || typeof schema !== "object") return z.any(); + if (schema.anyOf) return unionToZod(schema.anyOf); + if (schema.oneOf) return unionToZod(schema.oneOf); + if (schema.const !== undefined) return z.literal(schema.const); + if (Array.isArray(schema.enum) && schema.enum.length > 0) { + if (schema.enum.length === 1) return z.literal(schema.enum[0]); + if (schema.enum.every((item) => typeof item === "string")) return z.enum(schema.enum); + return z.union(schema.enum.map((item) => z.literal(item))); + } + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + const nullable = types.includes("null"); + const type = types.find((item) => item !== "null"); + let result; + switch (type) { + case "string": + result = z.string(); + break; + case "integer": + result = z.number().int(); + break; + case "number": + result = z.number(); + break; + case "boolean": + result = z.boolean(); + break; + case "array": + result = z.array(jsonSchemaToZod(schema.items)); + break; + case "object": + result = z.object(jsonSchemaObjectToZodShape(schema)); + break; + default: + result = z.any(); + } + if (typeof schema.description === "string") result = result.describe(schema.description); + return nullable ? result.nullable() : result; +} + +function unionToZod(items) { + const nonNull = items.filter((item) => item?.type !== "null"); + const nullable = nonNull.length !== items.length; + const parsed = nonNull.map(jsonSchemaToZod); + let result; + if (parsed.length === 0) result = z.null(); + else if (parsed.length === 1) result = parsed[0]; + else result = z.union(parsed); + return nullable ? result.nullable() : result; +} diff --git a/packages/server/src/server/agent/providers/opencode/bridge.test.ts b/packages/server/src/server/agent/providers/opencode/bridge.test.ts new file mode 100644 index 0000000000..261487c279 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/bridge.test.ts @@ -0,0 +1,226 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { z } from "zod"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import type { PaseoToolCatalog } from "../../tools/types.js"; +import { OpenCodeBridge, loadOpenCodeBridgePluginArtifact } from "./bridge.js"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +function createCatalog(): PaseoToolCatalog { + const tool = { + name: "echo_context", + title: "Echo context", + description: "Returns the supplied value.", + inputSchema: { value: z.string() }, + async handler(input: unknown) { + const parsed = z.object({ value: z.string() }).parse(input); + return { content: [{ type: "text", text: parsed.value }] }; + }, + }; + const tools = new Map([[tool.name, tool]]); + return { + tools, + getTool(name) { + return tools.get(name); + }, + async executeTool(name, input, context) { + const definition = tools.get(name); + if (!definition) throw new Error(`Unknown tool: ${name}`); + return await definition.handler(input, context ?? {}); + }, + }; +} + +function readPluginOptions(env: Record): { + baseUrl: string; + token: string; + pluginUrl: string; +} { + const config = JSON.parse(env.OPENCODE_CONFIG_CONTENT) as { + plugin: Array<[string, { baseUrl: string; token: string }]>; + }; + const [pluginUrl, options] = config.plugin[0]; + return { ...options, pluginUrl }; +} + +describe("OpenCodeBridge", () => { + test("loads packaged bundle bytes without invoking source compilation", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-opencode-artifact-")); + temporaryDirectories.push(root); + const moduleUrl = pathToFileURL(path.join(root, "bridge.js")).href; + const bundle = Buffer.from("export default async () => ({})"); + await writeFile(path.join(root, "bridge-plugin.bundle.mjs"), bundle); + const compileSource = vi.fn(async () => { + throw new Error("packaged runtime must not compile"); + }); + + await expect(loadOpenCodeBridgePluginArtifact(moduleUrl, compileSource)).resolves.toEqual( + bundle, + ); + expect(compileSource).not.toHaveBeenCalled(); + + await rm(path.join(root, "bridge-plugin.bundle.mjs")); + await expect(loadOpenCodeBridgePluginArtifact(moduleUrl, compileSource)).rejects.toThrow( + "artifact is missing", + ); + expect(compileSource).not.toHaveBeenCalled(); + }); + + test("allows source modules to compile the development artifact", async () => { + const artifact = new Uint8Array([1, 2, 3]); + const compileSource = vi.fn(async () => artifact); + const moduleUrl = new URL("./bridge.ts", import.meta.url).href; + + await expect(loadOpenCodeBridgePluginArtifact(moduleUrl, compileSource)).resolves.toBe( + artifact, + ); + expect(compileSource).toHaveBeenCalledWith( + fileURLToPath(new URL("./bridge-plugin.mjs", moduleUrl)), + ); + }); + + test("serves authenticated session context and caller-scoped tools", async () => { + const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-bridge-")); + temporaryDirectories.push(paseoHome); + const catalog = createCatalog(); + const bridge = new OpenCodeBridge({ paseoHome, logger: createTestLogger() }); + await bridge.start(); + bridge.setManifestCatalog(catalog); + const release = bridge.bindSession({ + sessionId: "ses_one", + env: { + PASEO_AGENT_ID: "agent-one", + PASEO_AGENT_CWD: "/workspace/one", + CUSTOM_VALUE: "one", + }, + tools: catalog, + }); + + try { + const plugin = readPluginOptions(bridge.decorateServerEnv({})); + expect(plugin.pluginUrl).toMatch(/^file:/); + + const unauthorized = await fetch( + `${plugin.baseUrl}/_internal/opencode/sessions/ses_one/context`, + ); + expect(unauthorized.status).toBe(401); + + const headers = { Authorization: `Bearer ${plugin.token}` }; + const context = await fetch(`${plugin.baseUrl}/_internal/opencode/sessions/ses_one/context`, { + headers, + }); + expect(await context.json()).toEqual({ + env: { + PASEO_AGENT_ID: "agent-one", + PASEO_AGENT_CWD: "/workspace/one", + CUSTOM_VALUE: "one", + }, + }); + + const manifest = await fetch(`${plugin.baseUrl}/_internal/opencode/tools`, { headers }); + expect(await manifest.json()).toEqual({ + tools: [ + { + name: "echo_context", + title: "Echo context", + description: "Returns the supplied value.", + inputSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + $schema: "http://json-schema.org/draft-07/schema#", + }, + }, + ], + }); + + const execution = await fetch( + `${plugin.baseUrl}/_internal/opencode/sessions/ses_one/tools/echo_context`, + { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ value: "correct agent" }), + }, + ); + expect(await execution.json()).toEqual({ + content: [{ type: "text", text: "correct agent" }], + }); + + const pluginModule = await import(plugin.pluginUrl); + const hooks = await pluginModule.default( + { client: { session: { get: async () => ({ data: {} }) } } }, + { + baseUrl: plugin.baseUrl, + token: plugin.token, + }, + ); + await expect( + hooks.tool.paseo_echo_context.execute( + { value: "through bundled plugin" }, + { sessionID: "ses_one" }, + ), + ).resolves.toMatchObject({ output: "through bundled plugin" }); + + release(); + const pluginError = vi.spyOn(console, "error").mockImplementation(() => undefined); + await expect( + hooks["shell.env"]({ cwd: "/workspace/one", sessionID: "ses_one" }, { env: {} }), + ).rejects.toThrow("not bound"); + expect(pluginError).toHaveBeenCalledWith( + "[paseo-opencode-plugin] shell.env failed", + expect.objectContaining({ sessionID: "ses_one", error: expect.stringContaining("bound") }), + ); + pluginError.mockRestore(); + const released = await fetch( + `${plugin.baseUrl}/_internal/opencode/sessions/ses_one/context`, + { headers }, + ); + expect(released.status).toBe(404); + } finally { + release(); + await bridge.close(); + } + }); + + test("preserves user OpenCode config while installing one content-addressed plugin", async () => { + const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-bridge-config-")); + temporaryDirectories.push(paseoHome); + const bridge = new OpenCodeBridge({ paseoHome, logger: createTestLogger() }); + await bridge.start(); + + try { + const first = bridge.decorateServerEnv({ + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + model: "provider/model", + plugin: ["user-plugin"], + }), + }); + const second = bridge.decorateServerEnv(first); + const config = JSON.parse(second.OPENCODE_CONFIG_CONTENT) as { + model: string; + plugin: Array; + }; + + expect(config.model).toBe("provider/model"); + expect(config.plugin[0]).toBe("user-plugin"); + expect(config.plugin).toHaveLength(2); + expect(config.plugin[1]?.[0]).toMatch(/paseo-[a-f0-9]{64}\.mjs$/); + } finally { + await bridge.close(); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/bridge.ts b/packages/server/src/server/agent/providers/opencode/bridge.ts new file mode 100644 index 0000000000..98e12e7eb1 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/bridge.ts @@ -0,0 +1,305 @@ +import { createHash, randomBytes } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import type { Logger } from "pino"; + +import { writeFileAtomic } from "../../../atomic-file.js"; +import { + addModelVisibleStructuredContent, + serializePaseoToolInputParameters, +} from "../../tools/paseo-tool-serialization.js"; +import type { PaseoToolCatalog } from "../../tools/types.js"; + +const INTERNAL_PREFIX = "/_internal/opencode"; +const MAX_REQUEST_BYTES = 1024 * 1024; + +interface OpenCodeBridgeOptions { + paseoHome: string; + logger: Logger; +} + +interface OpenCodeSessionBinding { + env: Record; + tools?: PaseoToolCatalog; +} + +interface BindOpenCodeSessionInput extends OpenCodeSessionBinding { + sessionId: string; +} + +interface OpenCodePluginOptions { + baseUrl: string; + token: string; +} + +interface OpenCodeConfig { + plugin?: Array]>; + [key: string]: unknown; +} + +export class OpenCodeBridge { + private readonly paseoHome: string; + private readonly logger: Logger; + private readonly token = randomBytes(32).toString("hex"); + private readonly sessions = new Map(); + private server: Server | null = null; + private baseUrl: string | null = null; + private pluginUrl: string | null = null; + private manifestCatalog: PaseoToolCatalog | null = null; + + constructor(options: OpenCodeBridgeOptions) { + this.paseoHome = options.paseoHome; + this.logger = options.logger.child({ module: "agent", component: "opencode-bridge" }); + } + + async start(): Promise { + if (this.server) return; + this.pluginUrl = await this.materializePlugin(); + const server = createServer((request, response) => { + void this.route(request, response); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") { + await closeServer(server); + throw new Error("OpenCode bridge did not expose a TCP address"); + } + this.server = server; + this.baseUrl = `http://127.0.0.1:${address.port}`; + } + + setManifestCatalog(catalog: PaseoToolCatalog | null): void { + this.manifestCatalog = catalog; + } + + bindSession(input: BindOpenCodeSessionInput): () => void { + const binding: OpenCodeSessionBinding = { + env: { ...input.env }, + ...(input.tools ? { tools: input.tools } : {}), + }; + this.sessions.set(input.sessionId, binding); + return () => { + if (this.sessions.get(input.sessionId) === binding) { + this.sessions.delete(input.sessionId); + } + }; + } + + decorateServerEnv(env: Record): Record { + const pluginUrl = this.requirePluginUrl(); + const options: OpenCodePluginOptions = { + baseUrl: this.requireBaseUrl(), + token: this.token, + }; + const config = parseOpenCodeConfig(env.OPENCODE_CONFIG_CONTENT); + const plugins = config.plugin ?? []; + const withoutBridge = plugins.filter((entry) => { + const specifier = Array.isArray(entry) ? entry[0] : entry; + return !specifier.includes("/paseo-") || !specifier.endsWith(".mjs"); + }); + return { + ...env, + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + ...config, + plugin: [...withoutBridge, [pluginUrl, options]], + }), + }; + } + + async close(): Promise { + const server = this.server; + this.server = null; + this.baseUrl = null; + this.sessions.clear(); + if (server) await closeServer(server); + } + + private async materializePlugin(): Promise { + const artifact = await loadOpenCodeBridgePluginArtifact(import.meta.url); + const digest = createHash("sha256").update(artifact).digest("hex"); + const destination = path.join(this.paseoHome, "runtime", "opencode", `paseo-${digest}.mjs`); + await writeFileAtomic(destination, artifact); + return pathToFileURL(destination).href; + } + + private async route(request: IncomingMessage, response: ServerResponse): Promise { + try { + if (request.headers.authorization !== `Bearer ${this.token}`) { + sendJson(response, 401, { error: "Unauthorized" }); + return; + } + const url = new URL(request.url ?? "/", this.requireBaseUrl()); + if (request.method === "GET" && url.pathname === `${INTERNAL_PREFIX}/tools`) { + sendJson(response, 200, { tools: this.serializeManifest() }); + return; + } + + const contextMatch = url.pathname.match( + new RegExp(`^${INTERNAL_PREFIX}/sessions/([^/]+)/context$`), + ); + if (request.method === "GET" && contextMatch) { + const binding = this.sessions.get(decodeURIComponent(contextMatch[1])); + if (!binding) { + sendJson(response, 404, { error: "OpenCode session is not bound to a Paseo agent" }); + return; + } + sendJson(response, 200, { env: binding.env }); + return; + } + + const toolMatch = url.pathname.match( + new RegExp(`^${INTERNAL_PREFIX}/sessions/([^/]+)/tools/([^/]+)$`), + ); + if (request.method === "POST" && toolMatch) { + await this.executeTool({ + sessionId: decodeURIComponent(toolMatch[1]), + toolName: decodeURIComponent(toolMatch[2]), + request, + response, + }); + return; + } + + sendJson(response, 404, { error: "Not found" }); + } catch (error) { + this.logger.warn({ err: error }, "OpenCode bridge request failed"); + if (!response.headersSent) { + sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) }); + } else { + response.destroy(error instanceof Error ? error : new Error(String(error))); + } + } + } + + private serializeManifest(): Array> { + const catalog = this.manifestCatalog; + if (!catalog) return []; + return [...catalog.tools.values()].map((tool) => { + const definition: Record = { + name: tool.name, + description: tool.description, + inputSchema: serializePaseoToolInputParameters(tool), + }; + if (tool.title) definition.title = tool.title; + return definition; + }); + } + + private async executeTool(input: { + sessionId: string; + toolName: string; + request: IncomingMessage; + response: ServerResponse; + }): Promise { + const binding = this.sessions.get(input.sessionId); + if (!binding) { + sendJson(input.response, 404, { error: "OpenCode session is not bound to a Paseo agent" }); + return; + } + if (!binding.tools) { + sendJson(input.response, 403, { error: "Paseo tools are disabled for this session" }); + return; + } + const body = await readJsonBody(input.request); + const controller = new AbortController(); + input.request.once("aborted", () => + controller.abort(new Error("OpenCode tool request was aborted")), + ); + const result = await binding.tools.executeTool(input.toolName, body, { + signal: controller.signal, + }); + sendJson(input.response, 200, addModelVisibleStructuredContent(result)); + } + + private requireBaseUrl(): string { + if (!this.baseUrl) throw new Error("OpenCode bridge is not started"); + return this.baseUrl; + } + + private requirePluginUrl(): string { + if (!this.pluginUrl) throw new Error("OpenCode bridge plugin is not materialized"); + return this.pluginUrl; + } +} + +type CompileOpenCodeBridgePlugin = (sourcePath: string) => Promise; + +export async function loadOpenCodeBridgePluginArtifact( + moduleUrl: string, + compileSource: CompileOpenCodeBridgePlugin = compileOpenCodeBridgePlugin, +): Promise { + const bundleUrl = new URL("./bridge-plugin.bundle.mjs", moduleUrl); + try { + return await readFile(bundleUrl); + } catch (error) { + if (!fileURLToPath(moduleUrl).endsWith(`${path.sep}bridge.ts`)) { + throw new Error("Bundled OpenCode bridge plugin artifact is missing", { cause: error }); + } + } + return compileSource(fileURLToPath(new URL("./bridge-plugin.mjs", moduleUrl))); +} + +async function compileOpenCodeBridgePlugin(sourcePath: string): Promise { + const source = await readFile(sourcePath, "utf8"); + const { build } = await import("esbuild"); + const bundled = await build({ + stdin: { + contents: source, + resolveDir: path.dirname(sourcePath), + sourcefile: path.basename(sourcePath), + }, + bundle: true, + format: "esm", + platform: "neutral", + target: "es2022", + write: false, + }); + const artifact = bundled.outputFiles[0]?.contents; + if (!artifact) throw new Error("Failed to bundle the OpenCode bridge plugin"); + return artifact; +} + +function parseOpenCodeConfig(value: string | undefined): OpenCodeConfig { + if (!value?.trim()) return {}; + const parsed: unknown = JSON.parse(value); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("OPENCODE_CONFIG_CONTENT must contain a JSON object"); + } + return parsed as OpenCodeConfig; +} + +async function readJsonBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.byteLength; + if (size > MAX_REQUEST_BYTES) throw new Error("OpenCode bridge request body is too large"); + chunks.push(buffer); + } + if (chunks.length === 0) return {}; + return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown; +} + +function sendJson(response: ServerResponse, status: number, body: unknown): void { + response.writeHead(status, { "Content-Type": "application/json" }); + response.end(JSON.stringify(body)); +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); +} diff --git a/packages/server/src/server/agent/providers/opencode/event-consumer.test.ts b/packages/server/src/server/agent/providers/opencode/event-consumer.test.ts index c5a63bcb18..aad9702ad0 100644 --- a/packages/server/src/server/agent/providers/opencode/event-consumer.test.ts +++ b/packages/server/src/server/agent/providers/opencode/event-consumer.test.ts @@ -249,6 +249,37 @@ describe("OpenCodeEventConsumer", () => { expect(inputs).toEqual([arbitraryRecord("/one")]); }); + test("logs OpenCode plugin load errors from the shared stream", async () => { + const upstream = await createSseUpstream(); + const logger = createRecordingLogger(); + const consumer = new OpenCodeEventConsumer({ + serverUrl: upstream.url, + processExit: new Promise(() => undefined), + logger, + }); + cleanups.push(async () => { + await consumer.close(); + await upstream.close(); + }); + + await upstream.connected(1); + upstream.send(0, connectedRecord("/workspace")); + await consumer.ready(); + upstream.send(0, pluginErrorRecord("/workspace")); + + await eventually(() => + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + directory: "/workspace", + error: expect.objectContaining({ + data: expect.objectContaining({ message: expect.stringContaining("plugin") }), + }), + }), + "OpenCode plugin failed", + ), + ); + }); + test("reconnects after a socket error with a delivered-record backoff reset", async () => { const upstream = await createSseUpstream(); const timing = new ControlledTiming(); @@ -449,6 +480,21 @@ function arbitraryRecord(directory: string) { }; } +function pluginErrorRecord(directory: string) { + return { + directory, + payload: { + type: "session.error", + properties: { + error: { + name: "UnknownError", + data: { message: "Failed to load plugin file:///paseo-plugin.mjs" }, + }, + }, + }, + }; +} + async function createSseUpstream() { const responses: ServerResponse[] = []; const requests: Array<{ url: string | undefined }> = []; diff --git a/packages/server/src/server/agent/providers/opencode/event-consumer.ts b/packages/server/src/server/agent/providers/opencode/event-consumer.ts index 7394a3f628..064d6d6905 100644 --- a/packages/server/src/server/agent/providers/opencode/event-consumer.ts +++ b/packages/server/src/server/agent/providers/opencode/event-consumer.ts @@ -186,6 +186,7 @@ export class OpenCodeEventConsumer implements OpenCodeEventSource { this.resolveReady(); continue; } + this.logPluginFailure(event); this.publish(event); } let outcome: OpenCodeConnectionOutcome = "ended"; @@ -211,6 +212,20 @@ export class OpenCodeEventConsumer implements OpenCodeEventSource { } } + private logPluginFailure(event: GlobalEvent): void { + if (event.payload.type !== "session.error") return; + const error = event.payload.properties.error; + if (!containsPluginError(error)) return; + this.logger.warn( + { + directory: event.directory, + sessionId: event.payload.properties.sessionID, + error, + }, + "OpenCode plugin failed", + ); + } + private logConnectionFailure( result: OpenCodeConnectionResult, attempt: number, @@ -261,6 +276,14 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function containsPluginError(error: unknown): boolean { + try { + return JSON.stringify(error).toLowerCase().includes("plugin"); + } catch { + return false; + } +} + export type OpenCodeEventConsumerFactory = ( options: Pick, ) => OpenCodeEventConsumer; diff --git a/packages/server/src/server/agent/providers/opencode/server-manager.ts b/packages/server/src/server/agent/providers/opencode/server-manager.ts index b95c4e0b7e..4161cd9876 100644 --- a/packages/server/src/server/agent/providers/opencode/server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/server-manager.ts @@ -73,6 +73,7 @@ export interface OpenCodeServerManagerOptions { resolveHomeDir?: () => string; spawnServerProcess?: OpenCodeServerProcessSpawner; createEventSource?: OpenCodeEventConsumerFactory; + decorateServerEnv?: (env: Record) => Record; } export class OpenCodeServerManager implements OpenCodeServerManagerLike { @@ -93,6 +94,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { private readonly resolveHomeDir: () => string; private readonly spawnServerProcess: OpenCodeServerProcessSpawner; private readonly createEventSource: OpenCodeEventConsumerFactory; + private readonly decorateServerEnv?: (env: Record) => Record; constructor(options: OpenCodeServerManagerOptions) { this.logger = options.logger; @@ -109,6 +111,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { this.spawnServerProcess = options.spawnServerProcess ?? spawnProcess; this.createEventSource = options.createEventSource ?? ((input) => new OpenCodeEventConsumer(input)); + this.decorateServerEnv = options.decorateServerEnv; } static getInstance( @@ -232,6 +235,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { } this.retiredServers.delete(server); + this.logger.info(generationLogContext(server), "OpenCode server generation released"); await this.killServer(server); } @@ -294,6 +298,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { existing.retired = true; this.retiredServers.add(existing); this.currentServer = null; + this.logger.info(generationLogContext(existing), "OpenCode server generation retired"); await this.cleanupRetiredServers(); } if (this.startPromise) { @@ -301,6 +306,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { pending.retired = true; this.retiredServers.add(pending); this.currentServer = null; + this.logger.info(generationLogContext(pending), "OpenCode server generation retired"); await this.cleanupRetiredServers(); } } @@ -316,6 +322,15 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { const serverCwd = this.resolveHomeDir(); mkdirSync(serverCwd, { recursive: true }); + const existingConfigContent = + launchEnv?.OPENCODE_CONFIG_CONTENT ?? + this.runtimeSettings?.env?.OPENCODE_CONFIG_CONTENT ?? + (typeof this.baseEnv?.OPENCODE_CONFIG_CONTENT === "string" + ? this.baseEnv.OPENCODE_CONFIG_CONTENT + : process.env.OPENCODE_CONFIG_CONTENT); + const bridgeEnv = this.decorateServerEnv?.( + existingConfigContent ? { OPENCODE_CONFIG_CONTENT: existingConfigContent } : {}, + ); const serverProcess = this.spawnServerProcess(launchPrefix.command, serverArgs, { cwd: serverCwd, detached: process.platform !== "win32", @@ -323,7 +338,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { ...createProviderEnvSpec({ baseEnv: this.baseEnv, runtimeSettings: this.runtimeSettings, - overlays: [launchEnv], + overlays: [launchEnv, bridgeEnv], }), }); const managedProcessRecord = this.recordManagedServerProcess({ @@ -346,6 +361,10 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { events: this.createEventSource({ serverUrl: url, processExit, logger: this.logger }), managedProcessRecord, }; + this.logger.info( + { ...generationLogContext(server), dedicated: launchEnv !== undefined }, + "OpenCode server generation started", + ); void managedProcessRecord.then((record) => { if (record && server.managedProcessRecord === managedProcessRecord) { server.managedProcessId = record.id; @@ -416,7 +435,11 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { failStartup(new Error(buildStartupErrorMessage(headline))); }); - serverProcess.on("exit", (code) => { + serverProcess.on("exit", (code, signal) => { + this.logger.info( + { ...generationLogContext(server), code, signal }, + "OpenCode server generation exited", + ); resolveProcessExit(new Error(`OpenCode server exited with code ${code}`)); this.removeManagedServerRecord(server); if (!started) { @@ -452,6 +475,9 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { ...(this.currentServer ? [this.currentServer] : []), ...Array.from(this.retiredServers), ]; + for (const server of servers) { + this.logger.info(generationLogContext(server), "OpenCode server generation stopping"); + } await Promise.all(servers.map((server) => this.killServer(server))); this.currentServer = null; this.retiredServers.clear(); @@ -560,6 +586,16 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { } } +function generationLogContext(server: OpenCodeServerGeneration): Record { + return { + pid: server.process.pid, + port: server.port, + url: server.url, + refCount: server.refCount, + retired: server.retired, + }; +} + async function waitForServerAcquisition( operation: Promise, signal: AbortSignal | undefined, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 66ecb02597..e3c62c9195 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -137,7 +137,7 @@ import { type PaseoToolHostDependencies, } from "./agent/tools/paseo-tools.js"; import type { PaseoToolRuntimeContext } from "./agent/tools/types.js"; -import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; +import { createAgentProviderRuntime } from "./agent/provider-runtime.js"; import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; import { @@ -881,17 +881,20 @@ export async function createPaseoDaemon( workspaceGitService, logger, }); - const providerSnapshotLogger = logger.child({ module: "provider-snapshot-manager" }); - const providerSnapshotManager = new ProviderSnapshotManager({ - logger: providerSnapshotLogger, - refreshTimeoutMs: config.providerCatalogRefreshTimeoutMs, - runtimeSettings: config.agentProviderSettings, - providerOverrides: config.providerOverrides, - workspaceGitService, - managedProcesses, - isDev: config.isDev === true, - extraClients: config.agentClients, + const agentProviderRuntime = await createAgentProviderRuntime({ + paseoHome: config.paseoHome, + logger, + snapshotManager: { + refreshTimeoutMs: config.providerCatalogRefreshTimeoutMs, + runtimeSettings: config.agentProviderSettings, + providerOverrides: config.providerOverrides, + workspaceGitService, + managedProcesses, + isDev: config.isDev === true, + extraClients: config.agentClients, + }, }); + const providerSnapshotManager = agentProviderRuntime.snapshotManager; daemonConfigStore.onFieldChange("catalogRefreshTimeoutMs", (value) => { providerSnapshotManager.setRefreshTimeoutMs(typeof value === "number" ? value : undefined); }); @@ -1370,8 +1373,12 @@ export async function createPaseoDaemon( }); const createAgentToolCatalog = (runtime: PaseoToolRuntimeContext) => createPaseoToolCatalog(createAgentToolHostDependencies(runtime)); + const setAgentProviderToolsEnabled = (enabled: boolean) => { + agentProviderRuntime.setPaseoToolCatalog(enabled ? createAgentToolCatalog({}) : null); + }; agentManager.setPaseoToolCatalogFactory(createAgentToolCatalog); agentManager.setPaseoToolsEnabled(config.mcpInjectIntoAgents !== false); + setAgentProviderToolsEnabled(config.mcpEnabled !== false && config.mcpInjectIntoAgents !== false); let mcpEnabled = config.mcpEnabled ?? true; let agentMcpBaseUrl: string | null = null; @@ -1544,10 +1551,12 @@ export async function createPaseoDaemon( const inject = daemonConfigStore.get().mcp.injectIntoAgents !== false; agentManager.setMcpBaseUrl(mcpEnabled && inject ? mcpBaseUrl : null); agentManager.setPaseoToolsEnabled(mcpEnabled && inject); + setAgentProviderToolsEnabled(mcpEnabled && inject); }); daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => { agentManager.setMcpBaseUrl(mcpEnabled && value ? mcpBaseUrl : null); agentManager.setPaseoToolsEnabled(mcpEnabled && value !== false); + setAgentProviderToolsEnabled(mcpEnabled && value !== false); }); daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => { agentManager.setAppendSystemPrompt(typeof value === "string" ? value : ""); @@ -1698,6 +1707,7 @@ export async function createPaseoDaemon( } catch (error) { await pluginRuntime.stopAllPlugins().catch(() => undefined); await serviceProxy.stopStandalone().catch(() => undefined); + await agentProviderRuntime.shutdown().catch(() => undefined); if (mainStarted) { httpServer.closeAllConnections(); await new Promise((resolve) => httpServer.close(() => resolve())); @@ -1718,7 +1728,7 @@ export async function createPaseoDaemon( await agentManager.flushForShutdown().catch(() => undefined); detachAgentStoragePersistence(); await agentStorage.flush().catch(() => undefined); - await providerSnapshotManager.shutdown(); + await agentProviderRuntime.shutdown(); terminalManager.killAll(); await speechService.stop(); await scheduleService.stop().catch(() => undefined); diff --git a/scripts/trace-daemon.mjs b/scripts/trace-daemon.mjs index 4e8968d0d6..137ca13172 100644 --- a/scripts/trace-daemon.mjs +++ b/scripts/trace-daemon.mjs @@ -57,6 +57,8 @@ const additionalInputs = [ "packages/server/dist/server/terminal/shell-integration/**", // Silero VAD ONNX model (sherpa speech provider) "packages/server/dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx", + // OpenCode loads this plugin from a content-addressed runtime copy. + "packages/server/dist/server/server/agent/providers/opencode/bridge-plugin.bundle.mjs", // Server runtime config files (read by path, not require) "packages/server/.env.example", // CLI shebang script wrapping dist/index.js