diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index b1c9df8a03..fd36a75cb9 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -2385,49 +2385,41 @@ describe("OpenCode adapter startTurn error handling", () => { }); test("sends exact Hub MCP permission grants without approving unrelated tools", async () => { - const promptAsync = vi.fn(async () => ({ data: {}, error: undefined })); - const fakeClient = { - global: { - event: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => ({ - stream: { - async *[Symbol.asyncIterator](): AsyncGenerator { - yield { type: "server.connected", properties: {} } as OpenCodeEvent; - await waitForAbort(signal); - }, - }, - })), - }, - session: { promptAsync }, - } as never; - const session = new __openCodeInternals.OpenCodeAgentSession( - { + const cwd = tmpCwd(); + const runtime = new TestOpenCodeHarness(); + const openCode = new TestOpenCodeClient(); + runtime.enqueueClient(openCode); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + try { + const session = await client.createSession({ provider: "opencode", - cwd: "/tmp/test", - providerOptions: { permission: { bash: "ask", hub_reply: "deny" } }, + cwd, + providerOptions: { permission: { bash: "ask", question: "deny" } }, toolPolicy: { preapproved: [{ kind: "mcp", server: "hub", tool: "finish_execution" }], }, - }, - fakeClient, - "ses_unit_test", - createTestLogger(), - ); - - await session.startTurn("finish"); - - expect(promptAsync).toHaveBeenCalledWith( - expect.objectContaining({ - permission: [ + }); + try { + const created = openCode.calls.sessionCreate[0] as { + permission?: Array<{ permission: string; pattern: string; action: string }>; + }; + expect(created.permission).toEqual([ { permission: "hub_finish_execution", pattern: "*", action: "allow" }, { permission: "bash", pattern: "*", action: "ask" }, - { permission: "hub_reply", pattern: "*", action: "deny" }, - ], - }), - ); - expect(promptAsync.mock.calls[0]?.[0].permission).not.toContainEqual( - expect.objectContaining({ permission: "bash", action: "allow" }), - ); - await session.close(); + { permission: "question", pattern: "*", action: "deny" }, + ]); + expect(created.permission).not.toContainEqual( + expect.objectContaining({ permission: "bash", action: "allow" }), + ); + } finally { + await session.close(); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } }); test("waits for the stop abort and provider idle before starting the next prompt", async () => { @@ -6733,3 +6725,124 @@ describe("OpenCode snapshot summary false-idle regression", () => { await session.close(); }); }); + +describe("OpenCode session permission rules", () => { + const logger = createTestLogger(); + function permissionConfig(cwd: string): AgentSessionConfig { + return { + provider: "opencode", + cwd, + providerOptions: { permission: { bash: "ask", external_directory: "allow" } }, + toolPolicy: { preapproved: [{ kind: "mcp", server: "hub", tool: "finish_execution" }] }, + }; + } + // OpenCode stores permission rules on the session (POST /session, PATCH /session/{id}) and + // drops an unknown `permission` field on prompt_async, so rules sent with a prompt never + // reach the server. + const expectedRules = [ + { permission: "hub_finish_execution", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "external_directory", pattern: "*", action: "allow" }, + ]; + + test("creates the session with the agent's permission rules", async () => { + const cwd = tmpCwd(); + const runtime = new TestOpenCodeHarness(); + const openCode = new TestOpenCodeClient(); + runtime.enqueueClient(openCode); + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + try { + const session = await client.createSession(permissionConfig(cwd)); + try { + expect(openCode.calls.sessionCreate[0]).toEqual( + expect.objectContaining({ permission: expectedRules }), + ); + } finally { + await session.close(); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test("applies the agent's permission rules to a resumed session", async () => { + const cwd = tmpCwd(); + const runtime = new TestOpenCodeHarness(); + const openCode = new TestOpenCodeClient(); + runtime.enqueueClient(openCode); + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + try { + const session = await client.resumeSession( + { provider: "opencode", sessionId: "ses_resumed_rules", metadata: { cwd } }, + permissionConfig(cwd), + ); + try { + expect(openCode.calls.sessionUpdate[0]).toEqual( + expect.objectContaining({ + sessionID: "ses_resumed_rules", + permission: expectedRules, + }), + ); + } finally { + await session.close(); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test("fails the resume when the rules cannot be applied", async () => { + const cwd = tmpCwd(); + const runtime = new TestOpenCodeHarness(); + const openCode = new TestOpenCodeClient(); + openCode.sessionUpdateResponse = { error: { data: { message: "session is archived" } } }; + runtime.enqueueClient(openCode); + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + try { + // Resuming without the rules would run the agent under a more permissive policy than + // the one it was configured with, which is the failure this guards. + await expect( + client.resumeSession( + { provider: "opencode", sessionId: "ses_archived", metadata: { cwd } }, + permissionConfig(cwd), + ), + ).rejects.toThrow("Failed to apply OpenCode session permission rules"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test("leaves a session without rules untouched", async () => { + const cwd = tmpCwd(); + const runtime = new TestOpenCodeHarness(); + const openCode = new TestOpenCodeClient(); + runtime.enqueueClient(openCode); + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + try { + const session = await client.resumeSession({ + provider: "opencode", + sessionId: "ses_resumed_no_rules", + metadata: { cwd }, + }); + try { + expect(openCode.calls.sessionUpdate).toEqual([]); + } finally { + await session.close(); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 084ca70d7c..2650529982 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1452,12 +1452,22 @@ export class OpenCodeAgentClient implements AgentClient { directory: openCodeConfig.cwd, }); + // OpenCode stores permission rules on the session, so they are set here and on resume + // rather than sent with each prompt, which drops them. + const permission = buildOpenCodePermissionRules( + openCodeConfig.providerOptions, + openCodeConfig.toolPolicy, + ); + try { // Creating the first session for a directory is part of OpenCode coming up, so it // shares the server startup budget instead of a shorter one that fails agent // creation on contended cold starts. const response = await withTimeout( - client.session.create({ directory: openCodeConfig.cwd }), + client.session.create({ + directory: openCodeConfig.cwd, + ...(permission ? { permission } : {}), + }), OPENCODE_SERVER_STARTUP_TIMEOUT_MS, `OpenCode session.create timed out after ${Math.round( OPENCODE_SERVER_STARTUP_TIMEOUT_MS / 1000, @@ -1527,6 +1537,7 @@ export class OpenCodeAgentClient implements AgentClient { }); try { + await this.applySessionPermissionRules(client, openCodeConfig, handle.sessionId); await this.populateModelContextWindowCache(client, openCodeConfig.cwd); const unbindBridge = this.bindBridgeSession(handle.sessionId, launchContext); @@ -1550,6 +1561,23 @@ export class OpenCodeAgentClient implements AgentClient { } } + private async applySessionPermissionRules( + client: OpencodeClient, + config: OpenCodeAgentConfig, + sessionId: string, + ): Promise { + const permission = buildOpenCodePermissionRules(config.providerOptions, config.toolPolicy); + if (!permission) return; + const response = readOpenCodeRecord( + await client.session.update({ sessionID: sessionId, directory: config.cwd, permission }), + ); + if (response?.error) { + throw new Error( + `Failed to apply OpenCode session permission rules: ${toDiagnosticErrorMessage(response.error)}`, + ); + } + } + private acquireServer( config: OpenCodeAgentConfig, launchContext?: AgentLaunchContext, @@ -3510,10 +3538,6 @@ class OpenCodeAgentSession implements AgentSession { this.config.systemPrompt, this.config.daemonAppendSystemPrompt, ); - const permission = buildOpenCodePermissionRules( - this.config.providerOptions, - this.config.toolPolicy, - ); const model = this.parseModel(this.config.model); const effectiveMode = resolveOpenCodeRuntimeAgentId(this.currentMode); const effectiveVariant = this.config.thinkingOptionId ?? undefined; @@ -3525,7 +3549,6 @@ class OpenCodeAgentSession implements AgentSession { messageID: promptId, parts, ...(systemPrompt ? { system: systemPrompt } : {}), - ...(permission ? { permission } : {}), ...(model ? { model } : {}), ...(effectiveMode ? { agent: effectiveMode } : {}), ...(effectiveVariant ? { variant: effectiveVariant } : {}), @@ -3850,10 +3873,6 @@ class OpenCodeAgentSession implements AgentSession { this.config.systemPrompt, this.config.daemonAppendSystemPrompt, ); - const permission = buildOpenCodePermissionRules( - this.config.providerOptions, - this.config.toolPolicy, - ); const promptResponse = await this.client.session.promptAsync({ sessionID: this.sessionId, directory: this.config.cwd, @@ -3868,7 +3887,6 @@ class OpenCodeAgentSession implements AgentSession { } : {}), ...(systemPrompt ? { system: systemPrompt } : {}), - ...(permission ? { permission } : {}), ...(model ? { model } : {}), ...(effectiveMode ? { agent: effectiveMode } : {}), ...(effectiveVariant ? { variant: effectiveVariant } : {}), 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 index af67c2604a..b074c4a890 100644 --- 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 @@ -11,6 +11,52 @@ import { OpenCodeAgentClient } from "./opencode-agent.js"; import { OpenCodeBridge } from "./opencode/bridge.js"; import { OpenCodeServerManager } from "./opencode/server-manager.js"; +test("real OpenCode server persists provider permissions across creation and resume", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-permissions-")); + const cwd = path.join(root, "repo"); + const logger = createTestLogger(); + const manager = new OpenCodeServerManager({ logger, resolveHomeDir: () => root }); + const client = new OpenCodeAgentClient(logger, undefined, { serverManager: manager }); + let session: Awaited> | undefined; + let inspection: Awaited> | undefined; + + try { + await mkdir(cwd); + session = await client.createSession({ + provider: "opencode", + cwd, + providerOptions: { permission: { external_directory: "allow" } }, + }); + inspection = await manager.acquireCurrent(); + const sdk = createOpencodeClient({ baseUrl: inspection.server.url, directory: cwd }); + async function readPermission(sessionId: string) { + const response = await sdk.session.get({ sessionID: sessionId, directory: cwd }); + if (response.error) throw new Error(JSON.stringify(response.error)); + return response.data?.permission; + } + const handle = session.describePersistence()!; + expect(await readPermission(handle.sessionId)).toEqual([ + { permission: "external_directory", pattern: "*", action: "allow" }, + ]); + + await session.close(); + // OpenCode appends session-update rules and the last matching rule wins, so a resume + // with a different policy leaves both entries in order. + session = await client.resumeSession(handle, { + providerOptions: { permission: { external_directory: "deny" } }, + }); + expect(await readPermission(handle.sessionId)).toEqual([ + { permission: "external_directory", pattern: "*", action: "allow" }, + { permission: "external_directory", pattern: "*", action: "deny" }, + ]); + } finally { + await inspection?.release(); + await session?.close(); + await manager.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}, 120_000); + 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");