diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 181f664b3ed00..733a4ecfd8c3d 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -40,6 +40,7 @@ import { TestClock } from "effect/testing"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; +import { T3_CODE_BROWSER_TOOL_INSTRUCTIONS } from "@t3tools/provider-core/server/orchestrationInstructions"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import { @@ -93,7 +94,22 @@ const mcpRules = [ { action: "t3-code-*", resource: "*", effect: "deny" }, { action: "t3-code-thread_opencode2-adapter_*", resource: "*", effect: "allow" }, ]; -const t3Rules = [{ action: "*", resource: "*", effect: "allow" }, ...mcpRules]; +const t3Rules = [ + { action: "*", resource: "*", effect: "allow" }, + { action: "browser", resource: "*", effect: "deny" }, + { action: "skill", resource: "browser", effect: "deny" }, + ...mcpRules, +]; +const browserAllows = [ + { action: "browser", resource: "*", effect: "allow" }, + { action: "skill", resource: "browser", effect: "allow" }, +]; +const browserDenies = t3Rules.slice(1, 3); +const withBrowserRules = (rules: ReadonlyArray, browser: ReadonlyArray) => [ + ...rules.slice(0, 3), + ...browser, + ...rules.slice(3), +]; const TITLE = "Prime check"; const sessionInfo = (overrides: Record = {}) => ({ id: SESSION, @@ -335,7 +351,13 @@ const resumed = ( ? [ out("agent.list", ""), reply("agent.list", agentList), - out("session.update", { sessionID: SESSION, permissions: supervisedRules }), + out("session.update", { + sessionID: SESSION, + permissions: + options?.external === true + ? withBrowserRules(supervisedRules, browserDenies) + : supervisedRules, + }), reply("session.update", null), ] : []), @@ -795,7 +817,10 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { agent: "general", model: { id: "big-pickle", providerID: "opencode", variant: "default" }, }); - const backgroundLaunch = (child: string): ReadonlyArray => { + const backgroundLaunch = ( + child: string, + permissions?: ReadonlyArray, + ): ReadonlyArray => { const call = "call-background"; const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: call }; return [ @@ -809,7 +834,10 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { input: { description: "Sleep", prompt: "sleep", background: true }, executed: false, }), - event("session.created", childCreated(child)), + event("session.created", { + ...childCreated(child), + ...(permissions === undefined ? {} : { permissions }), + }), event("session.tool.progress", { ...tool, metadata: { sessionID: child, status: "running" }, @@ -907,52 +935,108 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); - it.effect("gives a background subagent the rules of a mode changed while it runs", () => - Effect.gen(function* () { - const { runtime, thread } = yield* resumed([ - ...backgroundLaunch(CHILD), - event("session.execution.succeeded", { sessionID: SESSION }), - out("agent.list", ""), - reply("agent.list", agentList), - out("session.update", { sessionID: SESSION, permissions: supervisedRules }), - reply("session.update", null), - out("session.update", { - sessionID: CHILD, - permissions: [...supervisedRules.slice(0, 3), ...mcpRules], - }), - reply("session.update", null), - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - event("session.execution.succeeded", { sessionID: SESSION }), - ]); - const firstEnded = yield* Deferred.make(); - const ended = yield* runtime.events.pipe( - Stream.filter( - ( - event, - ): event is Extract => - event.type === "turn.terminal", - ), - Stream.tap(() => Deferred.succeed(firstEnded, undefined)), - Stream.take(2), - Stream.runCollect, - Effect.forkScoped, - ); - yield* runtime.startTurn(withLineage(thread)); - yield* Deferred.await(firstEnded); - yield* runtime.startTurn({ - ...withLineage(thread), - runId: RunId.make("run:opencode2-adapter:2"), - runOrdinal: 2, - providerTurnOrdinal: 2, - attemptId: RunAttemptId.make("attempt:opencode2-adapter:2"), - runtimePolicy: policy("approval-required"), - }); - assert.deepEqual( - [...(yield* Fiber.join(ended))].map((terminal) => terminal.status), - ["completed", "completed"], - ); - }).pipe(Effect.scoped), + it.effect.each([false, true])( + "gives a background subagent the rules of a mode changed while it runs (external=%s)", + (external) => + Effect.gen(function* () { + const launch = backgroundLaunch( + CHILD, + external ? [...t3Rules, ...browserAllows] : undefined, + ); + const { runtime, thread } = yield* resumed( + [ + ...launch.slice(0, -1), + ...(external + ? [ + out("session.update", { sessionID: CHILD, permissions: t3Rules }), + reply("session.update", null), + ] + : []), + ...launch.slice(-1), + event("session.execution.succeeded", { sessionID: SESSION }), + out("agent.list", ""), + reply("agent.list", agentList), + out("session.update", { + sessionID: SESSION, + permissions: external + ? withBrowserRules(supervisedRules, browserDenies) + : supervisedRules, + }), + reply("session.update", null), + out("session.update", { + sessionID: CHILD, + permissions: [ + ...supervisedRules.slice(0, 3), + ...(external ? browserDenies : []), + ...mcpRules, + ], + }), + reply("session.update", null), + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + ], + { external }, + ); + const firstEnded = yield* Deferred.make(); + const ended = yield* runtime.events.pipe( + Stream.filter( + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "turn.terminal" } + > => event.type === "turn.terminal", + ), + Stream.tap(() => Deferred.succeed(firstEnded, undefined)), + Stream.take(2), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(withLineage(thread)); + yield* Deferred.await(firstEnded); + yield* runtime.startTurn({ + ...withLineage(thread), + runId: RunId.make("run:opencode2-adapter:2"), + runOrdinal: 2, + providerTurnOrdinal: 2, + attemptId: RunAttemptId.make("attempt:opencode2-adapter:2"), + runtimePolicy: policy("approval-required"), + }); + assert.deepEqual( + [...(yield* Fiber.join(ended))].map((terminal) => terminal.status), + ["completed", "completed"], + ); + }).pipe(Effect.scoped), + ); + + it.effect.each([false, true])( + "denies a child that inherited full access before it can open a browser (external=%s)", + (external) => + Effect.gen(function* () { + const inherited = [{ action: "*", resource: "*", effect: "allow" }]; + const launch = backgroundLaunch(CHILD, inherited); + const { runtime, thread } = yield* resumed( + [ + ...launch.slice(0, -1), + out("session.update", { + sessionID: CHILD, + permissions: [ + ...inherited, + { action: "browser", resource: "*", effect: "deny" }, + { action: "skill", resource: "browser", effect: "deny" }, + ], + }), + reply("session.update", null), + ...launch.slice(-1), + event("session.execution.succeeded", { sessionID: SESSION }), + ], + { external }, + ); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(withLineage(thread)); + assert.equal((yield* Fiber.join(terminal))?.status, "completed"); + }).pipe(Effect.scoped), ); it.effect( @@ -1964,29 +2048,122 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); - it.effect("gives a resumed session T3's rules when it was made with others", () => + it.effect.each([ + { + name: "managed full access", + external: false, + runtimePolicy: policy(), + browser: browserAllows, + expected: t3Rules, + }, + { + name: "disconnected external full access", + external: true, + runtimePolicy: policy(), + browser: [], + expected: t3Rules, + }, + { + name: "omitted external permissions", + external: true, + runtimePolicy: policy(), + browser: undefined, + expected: t3Rules, + }, + { + name: "attached external full access", + external: true, + runtimePolicy: policy(), + browser: browserAllows, + expected: withBrowserRules(t3Rules, browserAllows), + }, + { + name: "managed supervised", + external: false, + runtimePolicy: policy("approval-required"), + browser: browserAllows, + expected: supervisedRules, + }, + { + name: "attached external supervised", + external: true, + runtimePolicy: policy("approval-required"), + browser: browserAllows, + expected: withBrowserRules(supervisedRules, browserAllows), + }, + { + name: "explicit external supervised denial", + external: true, + runtimePolicy: policy("approval-required"), + browser: browserDenies, + expected: withBrowserRules(supervisedRules, browserDenies), + }, + { + name: "attached external plan", + external: true, + runtimePolicy: { ...policy("approval-required"), interactionMode: "plan" as const }, + browser: browserAllows, + expected: [ + ...supervisedRules.slice(0, 3), + ...browserAllows, + { action: "edit", resource: "*", effect: "deny" }, + ...supervisedRules.slice(3), + ], + }, + { + name: "replacing external attachment", + external: true, + runtimePolicy: policy(), + browser: [...browserAllows, ...browserAllows], + expected: withBrowserRules(t3Rules, [...browserAllows, ...browserAllows]), + }, + { + name: "external grant followed by denial", + external: true, + runtimePolicy: policy(), + browser: [...browserAllows, { action: "browser", resource: "*", effect: "deny" }], + expected: withBrowserRules(t3Rules, [ + ...browserAllows, + { action: "browser", resource: "*", effect: "deny" }, + ]), + }, + ])("sets resumed browser rules ($name)", ({ external, runtimePolicy, browser, expected }) => Effect.gen(function* () { - const runtime = yield* openCode2ReplayRuntimeWithInstructions([ - ...opening, - out("session.get", { sessionID: SESSION }), - replyData( - "session.get", - sessionInfo({ - permissions: [ - { action: "*", resource: "*", effect: "allow" }, - { action: "subagent", resource: "*", effect: "deny" }, - ], + const runtime = yield* openCode2ReplayRuntimeWithInstructions( + [ + ...opening, + out("session.get", { sessionID: SESSION }), + replyData( + "session.get", + sessionInfo({ + permissions: + browser === undefined + ? undefined + : [ + { action: "*", resource: "*", effect: "allow" }, + { action: "subagent", resource: "*", effect: "deny" }, + ...browser, + ], + }), + ), + ...noOpenRequests, + ...(runtimePolicy.runtimeMode !== "full-access" || + runtimePolicy.interactionMode === "plan" + ? [out("agent.list", ""), reply("agent.list", agentList)] + : []), + out("session.update", { + sessionID: SESSION, + permissions: expected, }), - ), - ...noOpenRequests, - out("session.update", { sessionID: SESSION, permissions: t3Rules }), - reply("session.update", null), - ]); + reply("session.update", null), + ], + { external }, + ); yield* runtime.resumeThread({ providerThread: providerThread(yield* DateTime.now), threadId, modelSelection: bigPickle, - runtimePolicy: policy(), + runtimePolicy, }); }).pipe(Effect.scoped), ); @@ -2232,42 +2409,78 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); - it.effect("keeps 'allow this session' in the session's rules, not OpenCode's saved grants", () => - Effect.gen(function* () { - const { runtime, thread } = yield* resumed( - [ - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - shellAskEvent, - out("session.update", { - sessionID: SESSION, - permissions: [ - ...supervisedRules.slice(0, 3), - { action: "shell", resource: "echo *", effect: "allow" }, - ...supervisedRules.slice(3), - ], - }), - reply("session.update", null), - out("permission.reply", { - sessionID: SESSION, - requestID: shellAsk.data.id, - decision: "once", - }), - reply("permission.reply", null), - event("session.execution.succeeded", { sessionID: SESSION }), - ], - { supervised: true }, - ); - const requested = yield* requestOf(runtime).pipe(Effect.forkScoped); - const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); - yield* runtime.startTurn(turnInput(thread, bigPickle, "approval-required")); - const request = yield* Fiber.join(requested); - yield* runtime.respondToRuntimeRequest({ - requestId: request!.id, - decision: "acceptForSession", - }); - assert.equal((yield* Fiber.join(terminal))?.status, "completed"); - }).pipe(Effect.scoped), + it.effect.each(["shell", "browser"] as const)( + "keeps 'allow this session' in the session's rules, not OpenCode's saved grants (%s)", + (action) => + Effect.gen(function* () { + const browser = action === "browser"; + const ask = browser + ? { ...shellAsk.data, action, resources: ["*"], save: ["*"] } + : shellAsk.data; + const browserAsk = { action: "browser", resource: "*", effect: "ask" }; + const { runtime, thread } = yield* resumed( + [ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + ...(browser + ? [ + event("session.permissions", { + sessionID: SESSION, + permissions: withBrowserRules(supervisedRules, [...browserDenies, browserAsk]), + }), + ] + : []), + event("permission.asked", ask), + out("session.update", { + sessionID: SESSION, + permissions: [ + ...supervisedRules.slice(0, 3), + ...(browser ? [...browserDenies, browserAsk] : []), + { action, resource: browser ? "*" : "echo *", effect: "allow" }, + ...supervisedRules.slice(3), + ], + }), + reply("session.update", null), + out("permission.reply", { + sessionID: SESSION, + requestID: shellAsk.data.id, + decision: "once", + }), + reply("permission.reply", null), + ...(browser + ? [ + event("session.permissions", { + sessionID: SESSION, + permissions: withBrowserRules(supervisedRules, browserDenies), + }), + ] + : []), + event("session.execution.succeeded", { sessionID: SESSION }), + ...(browser + ? [ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + ] + : []), + ], + { supervised: true, external: browser }, + ); + const requested = yield* requestOf(runtime).pipe(Effect.forkScoped); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread, bigPickle, "approval-required")); + const request = yield* Fiber.join(requested); + yield* runtime.respondToRuntimeRequest({ + requestId: request!.id, + decision: "acceptForSession", + }); + assert.equal((yield* Fiber.join(terminal))?.status, "completed"); + if (browser) { + const next = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread, bigPickle, "approval-required")); + assert.equal((yield* Fiber.join(next))?.status, "completed"); + } + }).pipe(Effect.scoped), ); it.effect("reads the agents again while a fresh server still lists none", () => @@ -2357,6 +2570,117 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { replyData("session.form.list", forms), ]; + it.effect.each(["detach", "replacement", "stream gap"] as const)( + "refreshes external browser permissions after %s", + (change) => + Effect.gen(function* () { + const attached = [...t3Rules, ...browserAllows]; + const runtime = yield* openCode2ReplayRuntimeWithInstructions( + [ + ...opening, + out("session.get", { sessionID: SESSION }), + replyData("session.get", sessionInfo({ permissions: attached })), + ...noOpenRequests, + out("session.update", { + sessionID: SESSION, + permissions: withBrowserRules(t3Rules, browserAllows), + }), + reply("session.update", null), + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + ...(change === "stream gap" + ? [...reconnected({ [SESSION]: { type: "running" } }), ...openRequests([])] + : change === "replacement" + ? [ + event("session.permissions", { + sessionID: SESSION, + permissions: [...attached, ...browserAllows], + }), + event("session.permissions", { sessionID: SESSION, permissions: attached }), + ] + : [event("session.permissions", { sessionID: SESSION, permissions: t3Rules })]), + event("session.execution.succeeded", { sessionID: SESSION }), + ...(change === "stream gap" + ? [ + out("session.get", { sessionID: SESSION }), + replyData("session.get", sessionInfo({ permissions: t3Rules })), + ] + : []), + out("agent.list", ""), + reply("agent.list", agentList), + out("session.update", { + sessionID: SESSION, + permissions: withBrowserRules(supervisedRules, [ + ...browserDenies, + ...(change === "replacement" ? browserAllows : []), + ]), + }), + reply("session.update", null), + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + ], + { external: true }, + ); + const thread = yield* runtime.resumeThread({ + providerThread: providerThread(yield* DateTime.now), + threadId, + modelSelection: bigPickle, + runtimePolicy: policy(), + }); + for (const mode of ["full-access", "approval-required"] as const) { + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread, bigPickle, mode)); + assert.equal((yield* Fiber.join(terminal))?.status, "completed"); + } + }).pipe(Effect.scoped), + ); + + it.effect("rewrites browser rules when the desktop changes them during the update", () => + Effect.gen(function* () { + const attached = [...t3Rules, ...browserAllows]; + const runtime = yield* openCode2ReplayRuntimeWithInstructions( + [ + ...opening, + out("session.get", { sessionID: SESSION }), + replyData("session.get", sessionInfo({ permissions: attached })), + ...noOpenRequests, + out("session.update", { + sessionID: SESSION, + permissions: withBrowserRules(t3Rules, browserAllows), + }), + reply("session.update", null), + out("agent.list", ""), + reply("agent.list", agentList), + out("session.update", { + sessionID: SESSION, + permissions: withBrowserRules(supervisedRules, [...browserDenies, ...browserAllows]), + }), + event("session.permissions", { sessionID: SESSION, permissions: t3Rules }), + reply("session.update", null), + out("session.update", { + sessionID: SESSION, + permissions: withBrowserRules(supervisedRules, browserDenies), + }), + reply("session.update", null), + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + ], + { external: true }, + ); + const thread = yield* runtime.resumeThread({ + providerThread: providerThread(yield* DateTime.now), + threadId, + modelSelection: bigPickle, + runtimePolicy: policy(), + }); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread, bigPickle, "approval-required")); + assert.equal((yield* Fiber.join(terminal))?.status, "completed"); + }).pipe(Effect.scoped), + ); + const turnItems = (collected: ReadonlyArray) => collected.flatMap((event) => event.type === "turn_item.updated" ? [`${event.turnItem.type}:${event.turnItem.status}`] : [], @@ -2776,7 +3100,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { providerInstanceId: instanceId, endpoint: "http://127.0.0.1:3773/mcp", authorizationHeader: "Bearer thread-credential", - browserToolsAvailable: false, + browserToolsAvailable: true, }); yield* Effect.addFinalizer(() => mcpSessions.clear(threadId)); const server = "t3-code-thread_opencode2-adapter"; @@ -2795,6 +3119,13 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { promptAccepted, event("session.execution.succeeded", { sessionID: SESSION }), ]; + const turnWithReconnect = [ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + ...reconnected({ [SESSION]: { type: "running" } }), + ...openRequests([]), + event("session.execution.succeeded", { sessionID: SESSION }), + ]; const status = (state: "connected" | "failed" | "pending") => [ out("mcp.list", { "location[directory]": WORK }), reply("mcp.list", { @@ -2825,7 +3156,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { out("mcp.connect", { server, "location[directory]": WORK }), reply("mcp.connect", null), ...status("connected"), - ...turn, + ...turnWithReconnect, // Native add returns 204 even when its handshake fails; the same config is a no-op. add, reply("mcp.add", null), @@ -2851,7 +3182,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { add, reply("mcp.add", { status: 500, body: { message: "MCP unavailable" } }), ...instructionsWithoutMcp, - ...turn, + ...turnWithReconnect, out("mcp.remove", { server, "location[directory]": WORK }), reply("mcp.remove", null), ], @@ -2868,6 +3199,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { "Use `delegate_task` for child work using project memory", ); assert.include(firstInstructions.value, "native OpenCode subagents do not inherit"); + assert.include(firstInstructions.value, T3_CODE_BROWSER_TOOL_INSTRUCTIONS.trim()); assert.lengthOf(instructions, 4); assert.deepEqual(instructions[2], instructions[0]); // A transient registration failure changes availability, not the adapter's supported features. @@ -2946,6 +3278,8 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { sessionInfo({ permissions: [ { action: "*", resource: "*", effect: "allow" }, + { action: "browser", resource: "*", effect: "deny" }, + { action: "skill", resource: "browser", effect: "deny" }, { action: "t3-code-*", resource: "*", effect: "deny" }, { action: `${server}_*`, resource: "*", effect: "allow" }, ], @@ -4456,33 +4790,50 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); - it.effect("moves a fork into its target thread's worktree before the first prompt", () => - Effect.gen(function* () { - const FORK = "ses_f1484db83ffeLGtrRCFimo1H0e"; - const target = "/work/opencode2-fork-target"; - const { runtime, thread } = yield* resumed([ - out("session.fork", { sessionID: SESSION }), - replyData("session.fork", sessionInfo({ id: FORK })), - out("session.update", { - sessionID: FORK, - permissions: [ - { action: "*", resource: "*", effect: "allow" }, - { action: "t3-code-*", resource: "*", effect: "deny" }, - { action: "t3-code-thread_opencode2-adapter_fork_*", resource: "*", effect: "allow" }, + it.effect.each([false, true])( + "moves a fork into its target thread's worktree before the first prompt (external=%s)", + (external) => + Effect.gen(function* () { + const FORK = "ses_f1484db83ffeLGtrRCFimo1H0e"; + const target = "/work/opencode2-fork-target"; + const { runtime, thread } = yield* resumed( + [ + out("session.fork", { sessionID: SESSION }), + replyData( + "session.fork", + sessionInfo({ + id: FORK, + ...(external ? { permissions: [...t3Rules, ...browserAllows] } : {}), + }), + ), + out("session.update", { + sessionID: FORK, + permissions: [ + { action: "*", resource: "*", effect: "allow" }, + { action: "browser", resource: "*", effect: "deny" }, + { action: "skill", resource: "browser", effect: "deny" }, + { action: "t3-code-*", resource: "*", effect: "deny" }, + { + action: "t3-code-thread_opencode2-adapter_fork_*", + resource: "*", + effect: "allow", + }, + ], + }), + reply("session.update", null), + // OpenCode makes the fork where its source runs; the target thread runs elsewhere. + out("session.move", { sessionID: FORK, directory: target }), + reply("session.move", null), ], - }), - reply("session.update", null), - // OpenCode makes the fork where its source runs; the target thread runs elsewhere. - out("session.move", { sessionID: FORK, directory: target }), - reply("session.move", null), - ]); - const forked = yield* runtime.forkThread({ - sourceProviderThread: thread, - targetThreadId: ThreadId.make("thread:opencode2-adapter:fork"), - runtimePolicy: { ...policy(), cwd: target }, - }); - assert.equal(forked.nativeThreadRef?.nativeId, FORK); - }).pipe(Effect.scoped), + { external }, + ); + const forked = yield* runtime.forkThread({ + sourceProviderThread: thread, + targetThreadId: ThreadId.make("thread:opencode2-adapter:fork"), + runtimePolicy: { ...policy(), cwd: target }, + }); + assert.equal(forked.nativeThreadRef?.nativeId, FORK); + }).pipe(Effect.scoped), ); const continued = (after: ReadonlyArray) => diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts index eea36ac3b769d..65c0f6fdb2b34 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts @@ -65,7 +65,11 @@ const mcpRules = (name: string) => [ { action: "t3-code-*", resource: "*", effect: "deny" }, { action: `t3-code-thread_${name}_*`, resource: "*", effect: "allow" }, ]; -const FULL_ACCESS = [{ action: "*", resource: "*", effect: "allow" }]; +const FULL_ACCESS = [ + { action: "*", resource: "*", effect: "allow" }, + { action: "browser", resource: "*", effect: "deny" }, + { action: "skill", resource: "browser", effect: "deny" }, +]; const t3Rules = (name: string) => [...FULL_ACCESS, ...mcpRules(name)]; @@ -113,12 +117,13 @@ const autoEditRules = (name: string) => [ { action: "shell", resource: "*", effect: "ask" }, { action: "edit", resource: "*", effect: "allow" }, { action: "external_directory", resource: "*", effect: "ask" }, + ...FULL_ACCESS.slice(1), ...BUILD_PATHS, ...mcpRules(name), ]; const planRules = (name: string) => [ - { action: "*", resource: "*", effect: "allow" }, + ...FULL_ACCESS, { action: "edit", resource: "*", effect: "deny" }, ...PLAN_PATHS, ...mcpRules(name), @@ -752,8 +757,9 @@ describe("OpenCode 2 through the orchestrator", () => { "settles a background subagent whose end was lost with the stream, and takes the next turn", () => Effect.gen(function* () { + const name = "opencode2-background-lost"; const { result, projection } = yield* backgroundReconnect({ - name: "opencode2-background-lost", + name, cut: "session.step.started.3", reconnect: (parent) => [ @@ -789,6 +795,13 @@ describe("OpenCode 2 through the orchestrator", () => { ], cursor: {}, }), + out("session.get", { sessionID: parent }), + reply("session.get", { + data: { + ...sessionInfo("", t3Rules(name), name).data, + id: parent, + }, + }), out("session.prompt", { sessionID: parent, id: "", text: "" }), reply("session.prompt", { data: { diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_fork/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_fork/opencode_transcript.ndjson index a483f04813b31..22fb78d13b588 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_fork/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_fork/opencode_transcript.ndjson @@ -49,7 +49,7 @@ {"type": "emit_inbound", "label": "message.list.synthetic.response", "frame": {"type": "sdk.response", "operation": "message.list", "data": {"data": [], "cursor": {}}}} {"type": "expect_outbound", "label": "session.fork", "frame": {"type": "session.fork", "input": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "before": "msg_0eb7b1f9f001g95Bduh3bi8syS"}}} {"type": "emit_inbound", "label": "session.fork.response", "frame": {"type": "sdk.response", "operation": "session.fork", "data": {"data": {"id": "ses_f1484db83ffeLGtrRCFimo1H0e", "fork": {"sessionID": "ses_f1484ea8affe5aTo8CcgbGIszj", "boundary": {"type": "before", "messageID": "msg_0eb7b1f9f001g95Bduh3bi8syS"}}, "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790657111165, "updated": 1790657111165}, "title": "spike fork (fork #1)", "location": {"directory": ""}, "permissions": [{"action": "*", "resource": "*", "effect": "allow"}, {"action": "subagent", "resource": "*", "effect": "deny"}]}}}} -{"type": "expect_outbound", "label": "session.update", "frame": {"type": "session.update", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "permissions": [{"action": "*", "resource": "*", "effect": "allow"}, {"action": "t3-code-*", "resource": "*", "effect": "deny"}, {"action": "t3-code-thread_opencode2-fork_target_*", "resource": "*", "effect": "allow"}]}}} +{"type": "expect_outbound", "label": "session.update", "frame": {"type": "session.update", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "permissions": [{"action": "*", "resource": "*", "effect": "allow"}, {"action": "browser", "resource": "*", "effect": "deny"}, {"action": "skill", "resource": "browser", "effect": "deny"}, {"action": "t3-code-*", "resource": "*", "effect": "deny"}, {"action": "t3-code-thread_opencode2-fork_target_*", "resource": "*", "effect": "allow"}]}}} {"type": "emit_inbound", "label": "session.update.response", "frame": {"type": "sdk.response", "operation": "session.update", "data": null}} {"type": "emit_inbound", "label": "session.forked", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b247d001fXzFroPg5P0bps", "created": 1790657111165, "type": "session.forked", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "parentID": "ses_f1484ea8affe5aTo8CcgbGIszj", "boundary": {"type": "before", "messageID": "msg_0eb7b1f9f001g95Bduh3bi8syS"}, "instructions": {"core/environment": "db4a793a05fec32fdfa985a3432125a86d6cb42debc8b9ee8f6a99fbfcf2f93b", "core/date": "bf08b8486572ab3eef677a3c9ee12468e411ae41a8f4d2fb6cf02a6ca73f1986", "core/codemode": "616a7ad8b8e40e8a71eab238dfbbe9eaf0f4a9e6fdad853226d075bfe602e1e6", "core/skill-guidance": "0d001919496102654717308c72635523bf7c3cd99c81e058cc79f2947104f9a5"}, "instructionEntries": []}, "durable": {"aggregateID": "ses_f1484db83ffeLGtrRCFimo1H0e", "seq": 0, "version": 2}}}} {"type": "expect_outbound", "label": "session.update.title", "frame": {"type": "session.update", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "title": ""}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_nested_background/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_nested_background/opencode_transcript.ndjson index 4204f275fd8dc..53bb6c32e6bbc 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_nested_background/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_nested_background/opencode_transcript.ndjson @@ -1,4 +1,4 @@ -{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_nested_background", "metadata": {"source": "OpenCode 2.0.18 live run, openrouter/deepseek/deepseek-v4-flash, experimental.subagent_depth 2", "capturedAt": "2026-10-01", "nativeSessionId": "ses_f08c08821ffefFAD3sMjkyP0Fd", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary: a foreground subagent launches its own background subagent and returns at once, so the thread's turn ends while that grandchild still runs. When the grandchild ends, OpenCode reports it to the foreground subagent's session and wakes that session to answer the report. Nesting needs experimental.subagent_depth 2. T3's per-turn instructions entry (answered 204, as 2.0.18 does) is written before the thread's first turn."}} +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_nested_background", "metadata": {"source": "OpenCode 2.0.18 live run, openrouter/deepseek/deepseek-v4-flash, experimental.subagent_depth 2", "capturedAt": "2026-10-01", "nativeSessionId": "ses_f08c08821ffefFAD3sMjkyP0Fd", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary: a foreground subagent launches its own background subagent and returns at once, so the thread's turn ends while that grandchild still runs. When the grandchild ends, OpenCode reports it to the foreground subagent's session and wakes that session to answer the report. Nesting needs experimental.subagent_depth 2. T3's per-turn instructions entry (answered 204, as 2.0.18 does) is written before the thread's first turn. Child sessions that inherit full access are denied the browser before they run; those session.update exchanges are replay expectations, not captured native traffic."}} {"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} {"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": ""}}} {"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": ""}, "data": [{"id": "deepseek/deepseek-v4-flash", "modelID": "deepseek/deepseek-v4-flash", "providerID": "openrouter", "family": "deepseek-flash", "name": "DeepSeek V4 Flash", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openrouter", "settings": {"baseURL": "https://openrouter.ai/api/v1"}, "headers": {"HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [{"id": "none", "settings": {"reasoning": {"enabled": false}}}, {"id": "high", "settings": {"reasoning": {"effort": "high"}}}, {"id": "xhigh", "settings": {"reasoning": {"effort": "xhigh"}}}], "time": {"released": 1776988800000}, "cost": [{"input": 0.04186, "output": 0.08372, "cache": {"read": 0.008372, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "output": 131072}}]}}} @@ -37,6 +37,8 @@ {"type": "emit_inbound", "label": "session.tool.called", "frame": {"type": "sdk.event", "event": {"id": "evt_0f73f91d2001Z0SmwHjjebCK4a", "created": 1790854533586, "type": "session.tool.called", "location": {"directory": ""}, "data": {"sessionID": "ses_f08c08821ffefFAD3sMjkyP0Fd", "assistantMessageID": "msg_0f73f7b54001L2fnmF6VSokmbn", "id": "call_3d90a8b7d44c49f6ae053fd7", "input": {"agent": "general", "description": "Chain subagents with background", "prompt": "Use the subagent tool with background set to true to delegate to the general subagent with the prompt: Run the shell command `sleep 25` with the shell tool, then reply exactly GRANDCHILD_OK. As soon as it is launched, reply exactly MIDDLE_OK and end your turn without waiting for it."}, "executed": false}, "durable": {"aggregateID": "ses_f08c08821ffefFAD3sMjkyP0Fd", "seq": 10, "version": 1}}}} {"type": "emit_inbound", "label": "session.step.streamed", "frame": {"type": "sdk.event", "event": {"id": "evt_0f73f91d5001vrzflyt1WQFGva", "created": 1790854533589, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f08c08821ffefFAD3sMjkyP0Fd", "assistantMessageID": "msg_0f73f7b54001L2fnmF6VSokmbn"}, "durable": {"aggregateID": "ses_f08c08821ffefFAD3sMjkyP0Fd", "seq": 11, "version": 1}}}} {"type": "emit_inbound", "label": "session.created.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0f73f91dd001BBDOI1lGk77sTL", "created": 1790854533597, "type": "session.created", "location": {"directory": ""}, "data": {"sessionID": "ses_f08c06e25ffeF0asx24ydmo3oy", "slug": "kind-forest", "version": "2.0.18", "projectID": "3b9597874c088ee0849b0d68c211296e47876139", "parentID": "ses_f08c08821ffefFAD3sMjkyP0Fd", "location": {"directory": ""}, "subpath": "", "title": "Chain subagents with background", "agent": "general", "permissions": [{"action": "*", "resource": "*", "effect": "allow"}], "model": {"id": "deepseek/deepseek-v4-flash", "providerID": "openrouter", "variant": "default"}}, "durable": {"aggregateID": "ses_f08c06e25ffeF0asx24ydmo3oy", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "session.update.dmo3oy", "frame": {"type": "session.update", "input": {"sessionID": "ses_f08c06e25ffeF0asx24ydmo3oy", "permissions": [{"action": "*", "resource": "*", "effect": "allow"}, {"action": "browser", "resource": "*", "effect": "deny"}, {"action": "skill", "resource": "browser", "effect": "deny"}]}}} +{"type": "emit_inbound", "label": "session.update.dmo3oy.response", "frame": {"type": "sdk.response", "operation": "session.update", "data": null}} {"type": "emit_inbound", "label": "session.tool.progress", "frame": {"type": "sdk.event", "event": {"id": "evt_0f73f91e00015k1c4ULxR6t2Sb", "created": 1790854533600, "type": "session.tool.progress", "location": {"directory": ""}, "data": {"sessionID": "ses_f08c08821ffefFAD3sMjkyP0Fd", "assistantMessageID": "msg_0f73f7b54001L2fnmF6VSokmbn", "id": "call_3d90a8b7d44c49f6ae053fd7", "metadata": {"sessionID": "ses_f08c06e25ffeF0asx24ydmo3oy", "status": "running"}}}}} {"type": "emit_inbound", "label": "session.inbox.enqueued.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0f73f91e200143nPcVfCFMVD3q", "created": 1790854533602, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0f73f91e0002lgRNb7FtMzcoQu", "sessionID": "ses_f08c06e25ffeF0asx24ydmo3oy", "item": {"type": "user", "payload": {"text": "You are a subagent spawned by another session.\nUse the subagent tool with background set to true to delegate to the general subagent with the prompt: Run the shell command `sleep 25` with the shell tool, then reply exactly GRANDCHILD_OK. As soon as it is launched, reply exactly MIDDLE_OK and end your turn without waiting for it."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f08c06e25ffeF0asx24ydmo3oy", "seq": 1, "version": 1}}}} {"type": "emit_inbound", "label": "session.execution.started.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0f73f91e4001B2I7Jd3RFqZj5q", "created": 1790854533604, "type": "session.execution.started", "data": {"sessionID": "ses_f08c06e25ffeF0asx24ydmo3oy"}, "durable": {"aggregateID": "ses_f08c06e25ffeF0asx24ydmo3oy", "seq": 2, "version": 1}}}} @@ -54,6 +56,8 @@ {"type": "emit_inbound", "label": "session.tool.called.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0f73f9a9c001cLUHqUYgc8Zqn2", "created": 1790854535836, "type": "session.tool.called", "location": {"directory": ""}, "data": {"sessionID": "ses_f08c06e25ffeF0asx24ydmo3oy", "assistantMessageID": "msg_0f73f91f80010gHxZ9GpYK99C8", "id": "call_8272fb6a288c4f94869b6ce6", "input": {"agent": "general", "description": "Run sleep 25 and reply GRANDCHILD_OK", "prompt": "Run the shell command `sleep 25` with the shell tool, then reply exactly GRANDCHILD_OK.", "background": true}, "executed": false}, "durable": {"aggregateID": "ses_f08c06e25ffeF0asx24ydmo3oy", "seq": 10, "version": 1}}}} {"type": "emit_inbound", "label": "session.step.streamed.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0f73f9a9e0010vdKL0cnaLnGmC", "created": 1790854535838, "type": "session.step.streamed", "location": {"directory": ""}, "data": {"sessionID": "ses_f08c06e25ffeF0asx24ydmo3oy", "assistantMessageID": "msg_0f73f91f80010gHxZ9GpYK99C8"}, "durable": {"aggregateID": "ses_f08c06e25ffeF0asx24ydmo3oy", "seq": 11, "version": 1}}}} {"type": "emit_inbound", "label": "session.created.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0f73f9aa50016B9Hd1m1bEAc0t", "created": 1790854535845, "type": "session.created", "location": {"directory": ""}, "data": {"sessionID": "ses_f08c0655dffeXJa3hcJz6wum9I", "slug": "glowing-engine", "version": "2.0.18", "projectID": "3b9597874c088ee0849b0d68c211296e47876139", "parentID": "ses_f08c06e25ffeF0asx24ydmo3oy", "location": {"directory": ""}, "subpath": "", "title": "Run sleep 25 and reply GRANDCHILD_OK", "agent": "general", "permissions": [{"action": "*", "resource": "*", "effect": "allow"}], "model": {"id": "deepseek/deepseek-v4-flash", "providerID": "openrouter", "variant": "default"}}, "durable": {"aggregateID": "ses_f08c0655dffeXJa3hcJz6wum9I", "seq": 0, "version": 1}}}} +{"type": "expect_outbound", "label": "session.update.6wum9I", "frame": {"type": "session.update", "input": {"sessionID": "ses_f08c0655dffeXJa3hcJz6wum9I", "permissions": [{"action": "*", "resource": "*", "effect": "allow"}, {"action": "browser", "resource": "*", "effect": "deny"}, {"action": "skill", "resource": "browser", "effect": "deny"}]}}} +{"type": "emit_inbound", "label": "session.update.6wum9I.response", "frame": {"type": "sdk.response", "operation": "session.update", "data": null}} {"type": "emit_inbound", "label": "session.tool.progress.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0f73f9aa8001oExBPMb8eSiZZZ", "created": 1790854535848, "type": "session.tool.progress", "location": {"directory": ""}, "data": {"sessionID": "ses_f08c06e25ffeF0asx24ydmo3oy", "assistantMessageID": "msg_0f73f91f80010gHxZ9GpYK99C8", "id": "call_8272fb6a288c4f94869b6ce6", "metadata": {"sessionID": "ses_f08c0655dffeXJa3hcJz6wum9I", "status": "running"}}}}} {"type": "emit_inbound", "label": "session.inbox.enqueued.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0f73f9aa90011WInpdfGI7KKg2", "created": 1790854535849, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0f73f9aa8002Ljjv27sm66EBoB", "sessionID": "ses_f08c0655dffeXJa3hcJz6wum9I", "item": {"type": "user", "payload": {"text": "You are a subagent spawned by another session.\nRun the shell command `sleep 25` with the shell tool, then reply exactly GRANDCHILD_OK."}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f08c0655dffeXJa3hcJz6wum9I", "seq": 1, "version": 1}}}} {"type": "emit_inbound", "label": "session.tool.success", "frame": {"type": "sdk.event", "event": {"id": "evt_0f73f9aad0012MAYhIH05xebgp", "created": 1790854535853, "type": "session.tool.success", "location": {"directory": ""}, "data": {"sessionID": "ses_f08c06e25ffeF0asx24ydmo3oy", "assistantMessageID": "msg_0f73f91f80010gHxZ9GpYK99C8", "id": "call_8272fb6a288c4f94869b6ce6", "content": [{"type": "text", "text": "The subagent is working in the background (sessionID: ses_f08c0655dffeXJa3hcJz6wum9I). You will be notified automatically when it finishes.\nDO NOT sleep, poll for progress, ask the subagent for status, or duplicate this subagent's work; avoid working with the same files or topics it is using.\nWork on non-overlapping tasks, or briefly tell the user what you launched and end your response."}], "metadata": {"sessionID": "ses_f08c0655dffeXJa3hcJz6wum9I", "status": "running", "truncated": false}, "executed": false}, "durable": {"aggregateID": "ses_f08c06e25ffeF0asx24ydmo3oy", "seq": 12, "version": 2}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_resume_after_restart/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_resume_after_restart/opencode_transcript.ndjson index 71c807f28abb0..331fe2eacd1e6 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_resume_after_restart/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_resume_after_restart/opencode_transcript.ndjson @@ -1,4 +1,4 @@ -{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_resume_after_restart", "metadata": {"source": "opencode2-spike/recordings/resume_after_restart.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f147f6435ffeBiqeQBvUukyPaj", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder: a shell turn whose server is stopped mid-command (the event stream ends with no execution end), then the restarted server. After the restart `session/active` is empty, `session.get` has no outcome, and the history shows the shell call cancelled. T3 resubscribes, reads those in the order its reconnect does (active, then the history since the prompt), finds no `idle` item after the prompt, and ends the turn as interrupted. The next turn is a live 2.0.18 answer from the same session after a restart (oc2-9 orphan_b recording)."}} +{"type": "transcript_start", "provider": "opencode", "protocol": "opencode2-http.sse", "version": "2.0.18", "scenario": "opencode2_resume_after_restart", "metadata": {"source": "opencode2-spike/recordings/resume_after_restart.ndjson", "capturedAt": "2026-09-28", "nativeSessionId": "ses_f147f6435ffeBiqeQBvUukyPaj", "description": "A real OpenCode 2.0.18 session recorded at the HTTP and SSE boundary by the spike recorder: a shell turn whose server is stopped mid-command (the event stream ends with no execution end), then the restarted server. After the restart `session/active` is empty, `session.get` has no outcome, and the history shows the shell call cancelled. T3 resubscribes, reads those in the order its reconnect does (active, then the history since the prompt), finds no `idle` item after the prompt, and ends the turn as interrupted. The next turn is a live 2.0.18 answer from the same session after a restart (oc2-9 orphan_b recording).", "replayAdaptations": "T3 replay-boundary expectations session.get.permissions and session.update.permissions were added for the post-reconnect permission refresh before the second prompt. The GET response is synthesized from the recorded session snapshot, whose optional permissions are omitted, so T3 restores its rules. These added exchanges are not newly captured native traffic."}} {"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} {"type": "expect_outbound", "label": "model.list", "frame": {"type": "model.list", "input": {"location[directory]": ""}}} {"type": "emit_inbound", "label": "model.list.response", "frame": {"type": "sdk.response", "operation": "model.list", "data": {"location": {"directory": ""}, "data": [{"id": "space-bunny-free", "modelID": "space-bunny-free", "providerID": "opencode", "name": "Space Bunny Free", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text", "image", "video"], "output": ["text"]}, "variants": [{"id": "low", "settings": {"reasoningEffort": "low"}}, {"id": "medium", "settings": {"reasoningEffort": "medium"}}, {"id": "high", "settings": {"reasoningEffort": "high"}}, {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}}, {"id": "max", "settings": {"reasoningEffort": "max"}}], "time": {"released": 1790121600000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 1048576, "input": 524288, "output": 524288}}, {"id": "big-pickle", "modelID": "big-pickle", "providerID": "opencode", "family": "big-pickle", "name": "Big Pickle", "compatibility": {"reasoningField": "reasoning_content"}, "package": "@opencode/ai/providers/openai-compatible", "settings": {"apiKey": "public", "baseURL": "https://opencode.ai/zen/v1", "provider": "opencode"}, "capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [], "time": {"released": 1760659200000}, "cost": [{"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}], "status": "active", "enabled": true, "limit": {"context": 200000, "input": 160000, "output": 32000}}]}}} @@ -31,6 +31,10 @@ {"type": "emit_inbound", "label": "session.active.response", "frame": {"type": "sdk.response", "operation": "session.active", "data": {"data": {}}}} {"type": "expect_outbound", "label": "message.list", "frame": {"type": "message.list", "input": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "order": "desc", "limit": "50"}}} {"type": "emit_inbound", "label": "message.list.response", "frame": {"type": "sdk.response", "operation": "message.list", "data": {"data": [{"id": "msg_0eb809c070018it3RDEnTWfluL", "time": {"created": 1790657469481, "streamed": 1790657471264, "completed": 1790657473514}, "type": "assistant", "agent": "build", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "content": [{"type": "text", "text": "I'll run that command now."}, {"type": "tool", "id": "call_function_pzunphrbm0be_1", "name": "shell", "executed": false, "state": {"status": "error", "input": {"command": "sleep 25 && echo RESUMED", "timeout": 40000}, "error": {"type": "unknown", "message": "Command cancelled"}, "metadata": {"shellID": "sh_0eb80a32a001qv1E0XuVCb3w7I"}}, "time": {"created": 1790657471251, "ran": 1790657471258, "completed": 1790657473465}}], "snapshot": {"start": "d580eac455a946e5bef414d21d0abbfb32eabdae", "end": "d580eac455a946e5bef414d21d0abbfb32eabdae", "files": []}, "finish": "tool-calls", "rawFinish": "tool_calls", "cost": 0, "tokens": {"input": 8764, "output": 49, "reasoning": 0, "cache": {"read": 486, "write": 0}}}, {"id": "msg_0eb809beb001jL03JQgCeojw1j", "time": {"created": 1790657469440}, "text": "Run the shell command `sleep 25 && echo RESUMED` with the bash tool, then reply with its output.", "type": "user"}], "cursor": {}}}} +{"type": "expect_outbound", "label": "session.get.permissions", "frame": {"type": "session.get", "input": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj"}}} +{"type": "emit_inbound", "label": "session.get.permissions.response", "frame": {"type": "sdk.response", "operation": "session.get", "data": {"data": {"id": "ses_f147f6435ffeBiqeQBvUukyPaj", "projectID": "global", "model": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}, "cost": 0, "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, "time": {"created": 1790657469405, "updated": 1790657469405}, "title": "spike restart_a", "location": {"directory": ""}}}}} +{"type": "expect_outbound", "label": "session.update.permissions", "frame": {"type": "session.update", "input": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "permissions": ""}}} +{"type": "emit_inbound", "label": "session.update.permissions.response", "frame": {"type": "sdk.response", "operation": "session.update", "data": null}} {"type": "expect_outbound", "label": "session.prompt.2", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "id": "msg_0f0f3d724001pB8VE4h7fMXnjc", "text": ""}}} {"type": "emit_inbound", "label": "session.prompt.response.2", "frame": {"type": "sdk.response", "operation": "session.prompt", "data": {"data": {"id": "msg_0f0f3d724001pB8VE4h7fMXnjc", "sessionID": "ses_f147f6435ffeBiqeQBvUukyPaj", "time": {"created": 1790748907944}, "type": "user", "payload": {"text": "What did I last ask you to run? Answer in one short sentence."}, "delivery": "steer"}}}} {"type": "emit_inbound", "label": "plugin.updated", "frame": {"type": "sdk.event", "event": {"id": "evt_0f0f3d9a6001PmOgj7Wz31URka", "created": 1790748907942, "type": "plugin.updated", "location": {"directory": ""}, "data": {}}}} diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 60b52bd48afa1..d3e3df7802ddc 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -42,6 +42,7 @@ import type * as ProviderEventLoggers from "@t3tools/provider-core/server/Provid import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; +import { T3_CODE_BROWSER_TOOL_INSTRUCTIONS } from "@t3tools/provider-core/server/orchestrationInstructions"; import { advanceOpenCodePromptAdmission, @@ -1185,112 +1186,121 @@ describe("OpenCodeAdapterV2", () => { ), ); - it.effect("checks native MCP status before advertising tools and reconnects on later turns", () => - Effect.gen(function* () { - const suffix = "mcp-availability"; - const threadId = ThreadId.make(`thread-opencode-${suffix}`); - const mcpSessions = yield* McpProviderSessions.McpProviderSessions; - yield* mcpSessions.set({ - environmentId: EnvironmentId.make("environment:opencode-adapter"), - threadId, - providerSessionId: "mcp:opencode-adapter", - providerInstanceId: ProviderInstanceId.make(`opencode-${suffix}`), - endpoint: "http://127.0.0.1:3773/mcp", - authorizationHeader: "Bearer thread-credential", - browserToolsAvailable: false, - }); - const nativeEvents = asyncEventStream(); - const nativeSessionId = "native-opencode-mcp-availability"; - const systems: string[] = []; - const operations: string[] = []; - let connectionStatus: "failed" | "connected" | "needs_auth" = "failed"; - const statusMap = () => ({ - data: { - "t3-code": { - status: connectionStatus, - ...(connectionStatus === "connected" ? {} : { error: "Unavailable" }), - }, - }, - }); - const harness = yield* makeOpenCodeRuntimeHarness( - suffix, - nativeSessionId, - { - event: { - subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { - options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); - return { stream: nativeEvents.stream }; + it.effect.each([false, true])( + "checks native MCP status before advertising tools and reconnects on later turns (browser=%s)", + (browserToolsAvailable) => + Effect.gen(function* () { + const suffix = "mcp-availability"; + const threadId = ThreadId.make(`thread-opencode-${suffix}`); + const mcpSessions = yield* McpProviderSessions.McpProviderSessions; + yield* mcpSessions.set({ + environmentId: EnvironmentId.make("environment:opencode-adapter"), + threadId, + providerSessionId: "mcp:opencode-adapter", + providerInstanceId: ProviderInstanceId.make(`opencode-${suffix}`), + endpoint: "http://127.0.0.1:3773/mcp", + authorizationHeader: "Bearer thread-credential", + browserToolsAvailable, + }); + const nativeEvents = asyncEventStream(); + const nativeSessionId = "native-opencode-mcp-availability"; + const systems: string[] = []; + const operations: string[] = []; + let connectionStatus: "failed" | "connected" | "needs_auth" = "failed"; + const statusMap = () => ({ + data: { + "t3-code": { + status: connectionStatus, + ...(connectionStatus === "connected" ? {} : { error: "Unavailable" }), }, }, - session: { - create: async () => ({ - data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, - }), - promptAsync: async (input: { system: string; messageID: string }) => { - systems.push(input.system); - await nativeEvents.push({ - type: "message.updated", - properties: { - sessionID: nativeSessionId, - info: { - id: input.messageID, + }); + const harness = yield* makeOpenCodeRuntimeHarness( + suffix, + nativeSessionId, + { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { + once: true, + }); + return { stream: nativeEvents.stream }; + }, + }, + session: { + create: async () => ({ + data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, + }), + promptAsync: async (input: { system: string; messageID: string }) => { + systems.push(input.system); + await nativeEvents.push({ + type: "message.updated", + properties: { sessionID: nativeSessionId, - role: "user", - time: { created: 1 }, + info: { + id: input.messageID, + sessionID: nativeSessionId, + role: "user", + time: { created: 1 }, + }, }, - }, - }); - return { data: true }; - }, - }, - mcp: { - add: async () => { - operations.push("add"); - return statusMap(); - }, - status: async () => { - operations.push("status"); - return statusMap(); + }); + return { data: true }; + }, }, - connect: async () => { - operations.push("connect"); - if (connectionStatus === "failed") connectionStatus = "connected"; - // Native connect also acknowledges an unsuccessful authentication handshake. - return { data: true }; + mcp: { + add: async () => { + operations.push("add"); + return statusMap(); + }, + status: async () => { + operations.push("status"); + return statusMap(); + }, + connect: async () => { + operations.push("connect"); + if (connectionStatus === "failed") connectionStatus = "connected"; + // Native connect also acknowledges an unsuccessful authentication handshake. + return { data: true }; + }, }, }, - }, - {}, - {}, - { external: false }, - ); - for (let index = 0; index < 3; index++) { - if (index === 2) connectionStatus = "needs_auth"; - yield* harness.startTurn(`turn ${index + 1}`); - if (index === 1) { - assert.include(systems[index]!, "## T3 Code orchestration"); - assert.include(systems[index]!, "native OpenCode subagents do not inherit"); - } else { - assert.notInclude(systems[index]!, "## T3 Code orchestration"); - assert.notInclude(systems[index]!, "native OpenCode subagents"); - } - yield* Effect.promise(() => - nativeEvents.push({ type: "session.idle", properties: { sessionID: nativeSessionId } }), + {}, + {}, + { external: false }, ); - } - assert.deepEqual(operations, [ - "add", - "status", - "connect", - "status", - "status", - "connect", - "status", - ]); - }).pipe( - Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), - Effect.scoped, - ), + for (let index = 0; index < 3; index++) { + if (index === 2) connectionStatus = "needs_auth"; + yield* harness.startTurn(`turn ${index + 1}`); + if (index === 1) { + assert.include(systems[index]!, "## T3 Code orchestration"); + assert.include(systems[index]!, "native OpenCode subagents do not inherit"); + assert.equal( + systems[index]!.includes(T3_CODE_BROWSER_TOOL_INSTRUCTIONS.trim()), + browserToolsAvailable, + ); + } else { + assert.notInclude(systems[index]!, "## T3 Code orchestration"); + assert.notInclude(systems[index]!, "native OpenCode subagents"); + assert.notInclude(systems[index]!, T3_CODE_BROWSER_TOOL_INSTRUCTIONS.trim()); + } + yield* Effect.promise(() => + nativeEvents.push({ type: "session.idle", properties: { sessionID: nativeSessionId } }), + ); + } + assert.deepEqual(operations, [ + "add", + "status", + "connect", + "status", + "status", + "connect", + "status", + ]); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), ); it.effect("presents OpenCode MCP calls without treating remote tools as local edits", () => diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index d043fcdfc1057..1671aad9dc324 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -62,7 +62,10 @@ import { summarizeNativeProtocolPayload, } from "@t3tools/provider-core/server/nativeProtocolLogging"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; -import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; +import { + T3_CODE_BROWSER_TOOL_INSTRUCTIONS, + t3OrchestrationSystemPrompt, +} from "@t3tools/provider-core/server/orchestrationInstructions"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; import { readableProviderErrorMessage } from "./providerErrorMessage.ts"; @@ -2999,6 +3002,9 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function ...(agent === undefined ? {} : { agent }), ...(variant === undefined ? {} : { variant }), system: [ + mcpSession?.browserToolsAvailable === true + ? T3_CODE_BROWSER_TOOL_INSTRUCTIONS.trim() + : undefined, t3OrchestrationSystemPrompt( mcpSession !== undefined, mcpSession?.capabilities?.has("device") === true, diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 1217963d2634d..c75c3cb8d6569 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -59,7 +59,10 @@ import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; import { readableProviderErrorMessage } from "../providerErrorMessage.ts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; -import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; +import { + T3_CODE_BROWSER_TOOL_INSTRUCTIONS, + t3OrchestrationSystemPrompt, +} from "@t3tools/provider-core/server/orchestrationInstructions"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import * as KeyedLock from "@t3tools/shared/KeyedLock"; import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; @@ -356,6 +359,55 @@ interface PendingRequest { const rule = (action: string, effect: Rule["effect"]): Rule => ({ action, resource: "*", effect }); +const browserRule = (entry: Rule) => + (entry.action === "browser" && entry.resource === "*") || + (entry.action === "skill" && entry.resource === "browser"); + +// A full-access `*` allow matches the browser. Drop inherited allows, then deny whatever that still turns on. +const withoutInheritedBrowserAccess = (rules: ReadonlyArray): ReadonlyArray => { + const kept = rules.filter((entry) => !browserRule(entry) || entry.effect !== "allow"); + let browser: Rule["effect"] | undefined; + let skill: Rule["effect"] | undefined; + for (const entry of kept) { + if (entry.action === "*" && entry.resource === "*") { + browser = entry.effect; + skill = entry.effect; + } else if (entry.action === "browser" && entry.resource === "*") { + browser = entry.effect; + } else if (entry.action === "skill" && entry.resource === "browser") { + skill = entry.effect; + } + } + return [ + ...kept, + ...(browser === "allow" ? [rule("browser", "deny")] : []), + ...(skill === "allow" + ? [{ action: "skill", resource: "browser", effect: "deny" } satisfies Rule] + : []), + ]; +}; + +const nativeBrowserRules = ( + rules: ReadonlyArray, + deniedByDefault: boolean, +): ReadonlyArray => { + const kept: Array = []; + for (const entry of rules) { + if (!browserRule(entry)) continue; + const previous = kept.findLast( + (rule) => rule.action === entry.action && rule.resource === entry.resource, + ); + // Attachment replacement removes the first allow; preserve every allow and its later denies. + if ( + entry.effect === "deny" && + (previous?.effect ?? (deniedByDefault ? "deny" : undefined)) === "deny" + ) + continue; + kept.push(entry); + } + return kept; +}; + /** OpenCode hides tools for names over 64 chars and rejects names over 100; hash long thread IDs. */ export const t3McpServerName = Effect.fn("t3McpServerName")(function* (threadId: string) { const name = `t3-code-${threadId.replaceAll(/[^a-zA-Z0-9_-]/g, "_")}`; @@ -381,15 +433,28 @@ const sessionRules = ( paths: ReadonlyArray, grants: ReadonlyArray, mcpServerName: string | null, + browser: ReadonlyArray, ): ReadonlyArray => [ ...(policy.runtimeMode === "full-access" - ? [rule("*", "allow")] + ? [ + rule("*", "allow"), + // OpenCode's desktop grants browser access to individual attached sessions. + rule("browser", "deny"), + { action: "skill", resource: "browser", effect: "deny" } satisfies Rule, + ] : [ rule("shell", "ask"), rule("edit", policy.runtimeMode === "auto-accept-edits" ? "allow" : "ask"), rule("external_directory", "ask"), ]), - ...grants, + ...browser, + ...grants.filter( + (grant) => + !browserRule(grant) || + browser.findLast( + (entry) => entry.action === grant.action && entry.resource === grant.resource, + )?.effect === "ask", + ), // Plan edits are re-allowed only for plan paths; free-tier sessions refuse denied shell/read. ...(policy.interactionMode === "plan" ? [rule("edit", "deny")] : []), ...paths, @@ -2291,6 +2356,15 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const handleEvent = Effect.fnUntraced(function* (event: OpenCode2Client.OpenCode2StreamEvent) { const sessionId = sessionOfEvent(event); + if (connection.external && event.type === "session.permissions") { + const state = threads.get(event.data.sessionID); + if (state !== undefined) state.rules = event.data.permissions; + const info = announced.get(event.data.sessionID); + if (info !== undefined) { + announced.set(event.data.sessionID, { ...info, permissions: event.data.permissions }); + } + return; + } const cleared = sessionId === undefined ? undefined : clearing.get(sessionId); if (cleared !== undefined) { if (event.type === "unreadable.execution.ended" || executionEnd(event.type)) { @@ -2328,7 +2402,26 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const owner = ownerOf(event.data.parentID); if (owner !== undefined) { childOwners.set(event.data.sessionID, owner); - announced.set(event.data.sessionID, event.data); + let info = event.data; + if (info.permissions !== undefined) { + const permissions = withoutInheritedBrowserAccess(info.permissions); + if (!sameRules(info.permissions, permissions)) { + const updated = yield* client.session + .update({ sessionID: info.sessionID, permissions }) + .pipe( + Effect.timeout(REQUEST_REPLY_TIMEOUT), + Effect.tapCause((cause) => + Effect.logWarning( + "Could not clear a child's inherited browser permissions.", + cause, + ), + ), + Effect.exit, + ); + if (Exit.isSuccess(updated)) info = { ...info, permissions }; + } + } + announced.set(event.data.sessionID, info); holderOf(event.data.parentID)?.wakes.at(-1)?.children.add(event.data.sessionID); } return; @@ -2673,8 +2766,12 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr client = next.client; currentScope = scope; yield* Scope.close(previous, Exit.void); - // A restarted server forgot T3's MCP servers; the next turn adds them again. - for (const state of threads.values()) state.mcp = undefined; + if (connection.external) { + for (const state of threads.values()) state.rules = undefined; + for (const [id, info] of announced) { + announced.set(id, { ...info, permissions: undefined }); + } + } yield* lock.withPermit(reconcile).pipe(Effect.timeout(RECONCILE_TIMEOUT)); return stream; }).pipe( @@ -2811,31 +2908,62 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); const rulesFor = Effect.fnUntraced(function* ( - thread: Pick, + thread: Pick, policy: RulesPolicy, appThreadId: string | null, + preserveBrowser = true, ) { const plan = policy.interactionMode === "plan"; const paths = policy.runtimeMode === "full-access" && !plan ? [] : yield* pathsFor(thread.directory, plan ? [thread.agent, "plan"] : [thread.agent]); - return sessionRules( - policy, - paths, - policy.runtimeMode === "full-access" ? [] : thread.grants, - appThreadId === null ? null : yield* mcpServerNameFor(appThreadId), - ); + const mcpServerName = appThreadId === null ? null : yield* mcpServerNameFor(appThreadId); + const previous = thread.rules; + return { + previous, + rules: sessionRules( + policy, + paths, + policy.runtimeMode === "full-access" ? [] : thread.grants, + mcpServerName, + connection.external && preserveBrowser + ? nativeBrowserRules(previous ?? [], policy.runtimeMode === "full-access") + : [], + ), + }; }); - const writeRules = Effect.fnUntraced(function* (state: ThreadState, policy: RulesPolicy) { - const rules = yield* rulesFor(state, policy, rootOf(state).providerThread.appThreadId); - if (!sameRules(state.rules, rules)) { + const writeRules = Effect.fnUntraced(function* ( + state: ThreadState, + policy: RulesPolicy, + preserveBrowser = true, + ) { + if (connection.external && preserveBrowser && state.rules === undefined) { + const native = yield* client.session.get({ sessionID: Session.ID.make(state.sessionId) }); + if (state.rules === undefined) state.rules = native.permissions ?? []; + } + // A desktop permission event can land while an update is in flight. + let caughtUp = false; + while (!caughtUp) { + const { previous, rules } = yield* rulesFor( + state, + policy, + rootOf(state).providerThread.appThreadId, + preserveBrowser, + ); + if (sameRules(previous, rules)) { + caughtUp = true; + continue; + } yield* client.session.update({ sessionID: Session.ID.make(state.sessionId), permissions: rules, }); - state.rules = rules; + if (state.rules === previous) { + state.rules = rules; + caughtUp = true; + } } state.policy = policy; }); @@ -2867,14 +2995,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr existing.directory = directory; existing.agent = native.agent ?? existing.agent; existing.title = native.title; - existing.rules = native.permissions; + existing.rules = native.permissions ?? []; return existing; } const state = newThreadState(native.id, providerThread, directory, undefined); state.model = native.model; state.agent = native.agent ?? state.agent; state.title = native.title; - state.rules = native.permissions; + state.rules = native.permissions ?? []; threads.set(native.id, state); return state; }; @@ -3145,6 +3273,9 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } const instructions = [ buildRuntimeInstructions({ harness: "OpenCode", model: turnInput.modelSelection.model }), + mcpAvailable && mcpSession?.browserToolsAvailable === true + ? T3_CODE_BROWSER_TOOL_INSTRUCTIONS.trim() + : undefined, t3OrchestrationSystemPrompt(mcpAvailable, mcpSession?.capabilities?.has("device") === true), mcpAvailable ? OPENCODE_NATIVE_CHILD_INSTRUCTIONS : undefined, ] @@ -3480,8 +3611,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } const directory = threadInput.runtimePolicy.cwd ?? host.paths.cwd; const policy = threadInput.runtimePolicy; - const permissions = yield* rulesFor( - { directory, agent: "build", grants: [] }, + const { rules: permissions } = yield* rulesFor( + { directory, agent: "build", grants: [], rules: undefined }, policy, threadInput.threadId, ); @@ -4114,7 +4245,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr // Forks inherit source worktrees and skip initial resume; relocate to the destination now. const cwd = forkInput.runtimePolicy?.cwd; const state = register(providerThread, forked, cwd ?? forked.location.directory); - yield* writeRules(state, forkInput.runtimePolicy ?? state.policy); + yield* writeRules(state, forkInput.runtimePolicy ?? state.policy, false); if (cwd != null && forked.location.directory !== cwd) { yield* client.session.move({ sessionID: Session.ID.make(forked.id),