diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 7f9a03d948a0..da8cc8e0ccd2 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -1,5 +1,4 @@ import path from "path" -import { exec } from "child_process" import * as prompts from "@clack/prompts" import { map, pipe, sortBy, values } from "remeda" import { Octokit } from "@octokit/rest" @@ -17,6 +16,7 @@ import type { } from "@octokit/webhooks-types" import { UI } from "../ui" import { cmd } from "./cmd" +import { Browser } from "@/util/browser" import { ModelsDev } from "../../provider/models" import { Instance } from "@/project/instance" import { bootstrap } from "../bootstrap" @@ -311,17 +311,8 @@ export const GithubInstallCommand = cmd({ // Open browser const url = "https://github.com/apps/opencode-agent" - const command = - process.platform === "darwin" - ? `open "${url}"` - : process.platform === "win32" - ? `start "" "${url}"` - : `xdg-open "${url}"` - - exec(command, (error) => { - if (error) { - prompts.log.warn(`Could not open browser. Please visit: ${url}`) - } + Browser.open(url).catch(() => { + prompts.log.warn(`Could not open browser. Please visit: ${url}`) }) // Wait for installation diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 95719215e324..d02d22834e12 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -7,6 +7,7 @@ import { UI } from "../ui" import { MCP } from "../../mcp" import { McpAuth } from "../../mcp/auth" import { McpOAuthProvider } from "../../mcp/oauth-provider" +import { McpOAuthCallback } from "../../mcp/oauth-callback" import { Config } from "../../config/config" import { Instance } from "../../project/instance" import { Installation } from "../../installation" @@ -682,6 +683,7 @@ export const McpDebugCommand = cmd({ // Try to discover OAuth metadata const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined + await McpOAuthCallback.ensureRunning() const authProvider = new McpOAuthProvider( serverName, serverConfig.url, @@ -693,6 +695,7 @@ export const McpDebugCommand = cmd({ { onRedirect: async () => {}, }, + McpOAuthCallback.port(), ) prompts.log.info("Testing OAuth flow (without completing authorization)...") diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx index 9cfa30d4df98..99052162cb22 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx @@ -27,7 +27,6 @@ export function DialogMcp() { const [loading, setLoading] = createSignal(null) const options = createMemo(() => { - // Track sync data and loading state to trigger re-render when they change const mcpData = sync.data.mcp const loadingMcp = loading() @@ -50,13 +49,11 @@ export function DialogMcp() { keybind: Keybind.parse("space")[0], title: "toggle", onTrigger: async (option: DialogSelectOption) => { - // Prevent toggling while an operation is already in progress if (loading() !== null) return setLoading(option.value) try { await local.mcp.toggle(option.value) - // Refresh MCP status from server const status = await sdk.client.mcp.status() if (status.data) { sync.set("mcp", status.data) @@ -72,15 +69,5 @@ export function DialogMcp() { }, ]) - return ( - { - // Don't close on select, only on escape - }} - /> - ) + return {}} /> } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx index 3b6b5ef21827..f6ba8f0f0bd8 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -79,7 +79,7 @@ export function DialogStatus() { {(val) => val().error} Disabled in configuration - Needs authentication (run: opencode mcp auth {key}) + Needs authentication (enable to start auth flow) {(val) => (val() as { error: string }).error} diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 0fe056f21f2f..d7b6b9907d84 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -3,7 +3,7 @@ import { UI } from "../ui" import { cmd } from "./cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "../../flag/flag" -import open from "open" +import { Browser } from "@/util/browser" import { networkInterfaces } from "os" function getNetworkIPs() { @@ -68,11 +68,11 @@ export const WebCommand = cmd({ } // Open localhost in browser - open(localhostUrl.toString()).catch(() => {}) + Browser.open(localhostUrl.toString()) } else { const displayUrl = server.url.toString() UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl) - open(displayUrl).catch(() => {}) + Browser.open(displayUrl) } await new Promise(() => {}) diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index 0f91a35b8754..297e2e5aa935 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -25,6 +25,7 @@ export namespace McpAuth { codeVerifier: z.string().optional(), oauthState: z.string().optional(), serverUrl: z.string().optional(), // Track the URL these credentials are for + redirectUri: z.string().optional(), // Track redirect URI used during client registration }) export type Entry = z.infer @@ -80,9 +81,15 @@ export namespace McpAuth { await set(mcpName, entry, serverUrl) } - export async function updateClientInfo(mcpName: string, clientInfo: ClientInfo, serverUrl?: string): Promise { + export async function updateClientInfo( + mcpName: string, + clientInfo: ClientInfo, + serverUrl?: string, + redirectUri?: string, + ): Promise { const entry = (await get(mcpName)) ?? {} entry.clientInfo = clientInfo + if (redirectUri) entry.redirectUri = redirectUri await set(mcpName, entry, serverUrl) } diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 3c29fe03d30a..c6be0d42e2d2 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -6,6 +6,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import { CallToolResultSchema, + type CallToolResult, type Tool as MCPToolDef, ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js" @@ -22,7 +23,7 @@ import { McpAuth } from "./auth" import { BusEvent } from "../bus/bus-event" import { Bus } from "@/bus" import { TuiEvent } from "@/cli/cmd/tui/event" -import open from "open" +import { Browser } from "@/util/browser" export namespace MCP { const log = Log.create({ service: "mcp" }) @@ -132,7 +133,7 @@ export namespace MCP { description: mcpTool.description ?? "", inputSchema: jsonSchema(schema), execute: async (args: unknown) => { - return client.callTool( + const result = (await client.callTool( { name: mcpTool.name, arguments: (args || {}) as Record, @@ -142,7 +143,17 @@ export namespace MCP { resetTimeoutOnProgress: true, timeout, }, - ) + )) as CallToolResult + const content = Array.isArray(result?.content) ? result.content : [] + const text = content + .filter((c): c is { type: "text"; text: string } => c.type === "text" && typeof c.text === "string") + .map((c) => c.text) + .join("\n") + return { + output: text || JSON.stringify(result ?? {}), + title: mcpTool.name, + metadata: { isError: result?.isError }, + } }, }) } @@ -308,6 +319,7 @@ export namespace MCP { let authProvider: McpOAuthProvider | undefined if (!oauthDisabled) { + await McpOAuthCallback.ensureRunning() authProvider = new McpOAuthProvider( key, mcp.url, @@ -322,6 +334,7 @@ export namespace MCP { // Store the URL - actual browser opening is handled by startAuth }, }, + McpOAuthCallback.port(), ) } @@ -380,10 +393,9 @@ export namespace MCP { // Store transport for later finishAuth call pendingOAuthTransports.set(key, transport) status = { status: "needs_auth" as const } - // Show toast for needs_auth Bus.publish(TuiEvent.ToastShow, { title: "MCP Authentication Required", - message: `Server "${key}" requires authentication. Run: opencode mcp auth ${key}`, + message: `Server "${key}" requires authentication. Enable it to start the auth flow.`, variant: "warning", duration: 8000, }).catch((e) => log.debug("failed to show toast", { error: e })) @@ -391,7 +403,7 @@ export namespace MCP { break } - log.debug("transport connection failed", { + log.info("transport connection failed", { key, transport: name, url: mcp.url, @@ -537,6 +549,17 @@ export namespace MCP { return } + if (result.status.status === "needs_auth") { + try { + const authResult = await authenticate(name) + const s = await state() + s.status[name] = authResult + return + } catch (error) { + log.error("auto-auth failed during connect", { name, error }) + } + } + const s = await state() s.status[name] = result.status if (result.mcpClient) { @@ -729,8 +752,9 @@ export namespace MCP { // Start the callback server await McpOAuthCallback.ensureRunning() - // Generate and store a cryptographically secure state parameter BEFORE creating the provider - // The SDK will call provider.state() to read this value + // Clear stale auth data so we start fresh + await McpAuth.remove(mcpName) + const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32))) .map((b) => b.toString(16).padStart(2, "0")) .join("") @@ -753,6 +777,7 @@ export namespace MCP { capturedUrl = url }, }, + McpOAuthCallback.port(), ) // Create transport with auth provider @@ -806,14 +831,10 @@ export namespace MCP { // when the IdP has an active SSO session and redirects immediately const callbackPromise = McpOAuthCallback.waitForCallback(oauthState) - try { - const subprocess = await open(authorizationUrl) - // The open package spawns a detached process and returns immediately. - // We need to listen for errors which fire asynchronously: - // - "error" event: command not found (ENOENT) - // - "exit" with non-zero code: command exists but failed (e.g., no display) + const subprocess = await Browser.open(authorizationUrl, { callbackPort: McpOAuthCallback.port() }) + if (subprocess) { + // Not in VS Code context — wait for subprocess errors await new Promise((resolve, reject) => { - // Give the process a moment to fail if it's going to const timeout = setTimeout(() => resolve(), 500) subprocess.on("error", (error) => { clearTimeout(timeout) @@ -825,12 +846,10 @@ export namespace MCP { reject(new Error(`Browser open failed with exit code ${code}`)) } }) + }).catch((error) => { + log.warn("failed to open browser, user must open URL manually", { mcpName, error }) + Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl }) }) - } catch (error) { - // Browser opening failed (e.g., in remote/headless sessions like SSH, devcontainers) - // Emit event so CLI can display the URL for manual opening - log.warn("failed to open browser, user must open URL manually", { mcpName, error }) - Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl }) } // Wait for callback using the already-registered promise diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index bb3b56f2e95f..3d4249352236 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -1,5 +1,5 @@ import { Log } from "../util/log" -import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider" +import { OAUTH_CALLBACK_PATH } from "./oauth-provider" const log = Log.create({ service: "mcp.oauth-callback" }) @@ -56,17 +56,16 @@ export namespace McpOAuthCallback { const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes + export function port(): number { + if (!server?.port) throw new Error("OAuth callback server not running") + return server.port + } + export async function ensureRunning(): Promise { if (server) return - const running = await isPortInUse() - if (running) { - log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT }) - return - } - server = Bun.serve({ - port: OAUTH_CALLBACK_PORT, + port: 0, fetch(req) { const url = new URL(req.url) @@ -133,7 +132,7 @@ export namespace McpOAuthCallback { }, }) - log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT }) + log.info("oauth callback server started", { port: server.port }) } export function waitForCallback(oauthState: string): Promise { @@ -158,28 +157,6 @@ export namespace McpOAuthCallback { } } - export async function isPortInUse(): Promise { - return new Promise((resolve) => { - Bun.connect({ - hostname: "127.0.0.1", - port: OAUTH_CALLBACK_PORT, - socket: { - open(socket) { - socket.end() - resolve(true) - }, - error() { - resolve(false) - }, - data() {}, - close() {}, - }, - }).catch(() => { - resolve(false) - }) - }) - } - export async function stop(): Promise { if (server) { server.stop() diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index 164b1d1f143d..e71192f9393b 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -10,7 +10,6 @@ import { Log } from "../util/log" const log = Log.create({ service: "mcp.oauth" }) -const OAUTH_CALLBACK_PORT = 19876 const OAUTH_CALLBACK_PATH = "/mcp/oauth/callback" export interface McpOAuthConfig { @@ -29,10 +28,11 @@ export class McpOAuthProvider implements OAuthClientProvider { private serverUrl: string, private config: McpOAuthConfig, private callbacks: McpOAuthCallbacks, + private callbackPort: number, ) {} get redirectUrl(): string { - return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}` + return `http://localhost:${this.callbackPort}${OAUTH_CALLBACK_PATH}` } get clientMetadata(): OAuthClientMetadata { @@ -64,6 +64,11 @@ export class McpOAuthProvider implements OAuthClientProvider { log.info("client secret expired, need to re-register", { mcpName: this.mcpName }) return undefined } + // Check if redirect URI changed (dynamic port) + if (entry.redirectUri && entry.redirectUri !== this.redirectUrl) { + log.info("redirect URI changed, need to re-register", { mcpName: this.mcpName }) + return undefined + } return { client_id: entry.clientInfo.clientId, client_secret: entry.clientInfo.clientSecret, @@ -84,6 +89,7 @@ export class McpOAuthProvider implements OAuthClientProvider { clientSecretExpiresAt: info.client_secret_expires_at, }, this.serverUrl, + this.redirectUrl, ) log.info("saved dynamically registered client", { mcpName: this.mcpName, @@ -144,10 +150,12 @@ export class McpOAuthProvider implements OAuthClientProvider { async state(): Promise { const entry = await McpAuth.get(this.mcpName) - if (!entry?.oauthState) { - throw new Error(`No OAuth state saved for MCP server: ${this.mcpName}`) - } - return entry.oauthState + if (entry?.oauthState) return entry.oauthState + const state = Array.from(crypto.getRandomValues(new Uint8Array(32))) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + await McpAuth.updateOAuthState(this.mcpName, state) + return state } async invalidateCredentials(type: "all" | "client" | "tokens"): Promise { @@ -173,4 +181,4 @@ export class McpOAuthProvider implements OAuthClientProvider { } } -export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } +export { OAUTH_CALLBACK_PATH } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 43ad9a09d399..a1e93d9a8a33 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -44,7 +44,6 @@ import { SessionStatus } from "./status" import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" -import { Truncate } from "@/tool/truncation" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false diff --git a/packages/opencode/src/util/browser.ts b/packages/opencode/src/util/browser.ts new file mode 100644 index 000000000000..792096418ada --- /dev/null +++ b/packages/opencode/src/util/browser.ts @@ -0,0 +1,24 @@ +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { Ide } from "@/ide" +import openUrl from "open" +import z from "zod/v4" + +export const BrowserOpen = BusEvent.define( + "browser.open", + z.object({ + url: z.string(), + callbackPort: z.number().optional(), + }), +) + +export namespace Browser { + export async function open(url: string, options?: { callbackPort?: number }) { + Bus.publish(BrowserOpen, { + url, + callbackPort: options?.callbackPort, + }) + if (Ide.alreadyInstalled()) return + return openUrl(url) + } +} diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index ee4429be7576..bcd679cda60d 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -96,6 +96,7 @@ beforeEach(() => { openShouldFail = false openCalledWith = undefined transportCalls.length = 0 + delete process.env.OPENCODE_CALLER }) // Import modules after mocking diff --git a/packages/opencode/test/util/browser.test.ts b/packages/opencode/test/util/browser.test.ts new file mode 100644 index 000000000000..168ce7a8c394 --- /dev/null +++ b/packages/opencode/test/util/browser.test.ts @@ -0,0 +1,122 @@ +import { test, expect, mock, beforeEach } from "bun:test" + +let openCalledWith: string | undefined + +mock.module("open", () => ({ + default: async (url: string) => { + openCalledWith = url + return {} // Return a mock subprocess + }, +})) + +// Import modules after mocking +const { Browser, BrowserOpen } = await import("../../src/util/browser") +const { Bus } = await import("../../src/bus") +const { Instance } = await import("../../src/project/instance") +const { tmpdir } = await import("../fixture/fixture") + +beforeEach(() => { + openCalledWith = undefined + delete process.env.OPENCODE_CALLER +}) + +test("publishes BrowserOpen event but does not call open when OPENCODE_CALLER=vscode", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + process.env.OPENCODE_CALLER = "vscode" + + const events: Array<{ url: string; callbackPort?: number }> = [] + const unsubscribe = Bus.subscribe(BrowserOpen, (evt) => { + events.push(evt.properties) + }) + + await Browser.open("https://example.com") + + unsubscribe() + + expect(events.length).toBe(1) + expect(events[0].url).toBe("https://example.com") + expect(openCalledWith).toBeUndefined() + }, + }) +}) + +test("publishes BrowserOpen event and calls open when OPENCODE_CALLER is unset", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + delete process.env.OPENCODE_CALLER + + const events: Array<{ url: string; callbackPort?: number }> = [] + const unsubscribe = Bus.subscribe(BrowserOpen, (evt) => { + events.push(evt.properties) + }) + + await Browser.open("https://example.com") + + unsubscribe() + + expect(events.length).toBe(1) + expect(events[0].url).toBe("https://example.com") + expect(openCalledWith).toBe("https://example.com") + }, + }) +}) + +test("event payload contains correct url", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const events: Array<{ url: string; callbackPort?: number }> = [] + const unsubscribe = Bus.subscribe(BrowserOpen, (evt) => { + events.push(evt.properties) + }) + + await Browser.open("https://auth.example.com/login") + + unsubscribe() + + expect(events.length).toBe(1) + expect(events[0].url).toBe("https://auth.example.com/login") + }, + }) +}) + +test("event payload contains callbackPort when provided", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const events: Array<{ url: string; callbackPort?: number }> = [] + const unsubscribe = Bus.subscribe(BrowserOpen, (evt) => { + events.push(evt.properties) + }) + + await Browser.open("https://auth.example.com", { callbackPort: 19876 }) + + unsubscribe() + + expect(events.length).toBe(1) + expect(events[0].url).toBe("https://auth.example.com") + expect(events[0].callbackPort).toBe(19876) + }, + }) +}) + +test("open is called with the correct URL in non-VS Code context", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + delete process.env.OPENCODE_CALLER + + await Browser.open("https://specific-url.com") + + expect(openCalledWith).toBe("https://specific-url.com") + }, + }) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index efb7e202e120..5f6412365463 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -769,6 +769,21 @@ export type EventTuiSessionSelect = { } } +export type EventIdeInstalled = { + type: "ide.installed" + properties: { + ide: string + } +} + +export type EventBrowserOpen = { + type: "browser.open" + properties: { + url: string + callbackPort?: number + } +} + export type EventMcpToolsChanged = { type: "mcp.tools.changed" properties: { @@ -970,6 +985,8 @@ export type Event = | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect + | EventIdeInstalled + | EventBrowserOpen | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted diff --git a/sdks/vscode/.gitignore b/sdks/vscode/.gitignore index 53c37a16608c..15c3536758e2 100644 --- a/sdks/vscode/.gitignore +++ b/sdks/vscode/.gitignore @@ -1 +1,2 @@ -dist \ No newline at end of file +dist +*.vsix \ No newline at end of file diff --git a/sdks/vscode/bun.lock b/sdks/vscode/bun.lock index 085f0661a093..dde10fcf7976 100644 --- a/sdks/vscode/bun.lock +++ b/sdks/vscode/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "opencode-agent", diff --git a/sdks/vscode/src/extension.ts b/sdks/vscode/src/extension.ts index 105ab0293a8a..76e7250752bd 100644 --- a/sdks/vscode/src/extension.ts +++ b/sdks/vscode/src/extension.ts @@ -2,17 +2,23 @@ export function deactivate() {} import * as vscode from "vscode" +import { parseSSELines, isBrowserOpen, createDeduplicator } from "./sse" const TERMINAL_NAME = "opencode" +const sseConnections = new Map() +const dedup = createDeduplicator() +const log = vscode.window.createOutputChannel("opencode", { log: true }) + export function activate(context: vscode.ExtensionContext) { + context.subscriptions.push(log) let openNewTerminalDisposable = vscode.commands.registerCommand("opencode.openNewTerminal", async () => { await openTerminal() }) let openTerminalDisposable = vscode.commands.registerCommand("opencode.openTerminal", async () => { // An opencode terminal already exists => focus it - const existingTerminal = vscode.window.terminals.find((t) => t.name === TERMINAL_NAME) + const existingTerminal = vscode.window.terminals.find((t: vscode.Terminal) => t.name === TERMINAL_NAME) if (existingTerminal) { existingTerminal.show() return @@ -33,14 +39,98 @@ export function activate(context: vscode.ExtensionContext) { } if (terminal.name === TERMINAL_NAME) { - // @ts-ignore - const port = terminal.creationOptions.env?.["_EXTENSION_OPENCODE_PORT"] + const options = terminal.creationOptions as vscode.TerminalOptions + const port = options.env?.["_EXTENSION_OPENCODE_PORT"] port ? await appendPrompt(parseInt(port), fileRef) : terminal.sendText(fileRef, false) terminal.show() } }) - context.subscriptions.push(openTerminalDisposable, addFilepathDisposable) + context.subscriptions.push(openTerminalDisposable, addFilepathDisposable, openNewTerminalDisposable) + + const terminalCloseListener = vscode.window.onDidCloseTerminal((terminal: vscode.Terminal) => { + const controller = sseConnections.get(terminal) + if (controller) { + controller.abort() + sseConnections.delete(terminal) + } + }) + context.subscriptions.push(terminalCloseListener) + + async function connectSSE(port: number, terminal: vscode.Terminal, signal: AbortSignal) { + let backoff = 1000 + const maxBackoff = 30000 + + async function connect() { + if (signal.aborted) return + + log.info(`[sse] connecting to http://localhost:${port}/global/event`) + const response = await fetch(`http://localhost:${port}/global/event`, { signal }).catch((e) => { + log.debug(`[sse] fetch failed: ${e}`) + return null + }) + if (!response || !response.body) { + if (signal.aborted) return + log.debug(`[sse] no response, retrying in ${backoff}ms`) + await new Promise((resolve) => setTimeout(resolve, backoff)) + backoff = Math.min(backoff * 2, maxBackoff) + return connect() + } + + log.info(`[sse] connected to port ${port}`) + backoff = 1000 + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = "" + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const result = parseSSELines(buffer) + buffer = result.remainder + + for (const event of result.events) { + log.debug(`[sse] event: ${event.type}`) + if (!isBrowserOpen(event)) continue + + const url = event.properties.url + const callbackPort = event.properties.callbackPort + log.info(`[sse] browser.open event: url=${url}, callbackPort=${callbackPort}`) + + if (dedup.isDuplicate(url)) { + log.debug(`[sse] duplicate url, skipping`) + continue + } + + if (callbackPort) { + log.info(`[sse] calling asExternalUri for callback port ${callbackPort}`) + const mapped = await vscode.env.asExternalUri(vscode.Uri.parse(`http://127.0.0.1:${callbackPort}`)) + log.info(`[sse] asExternalUri result: ${mapped.toString()}`) + } + + log.info(`[sse] calling openExternal: ${url}`) + const opened = await vscode.env.openExternal(vscode.Uri.parse(url)) + log.info(`[sse] openExternal result: ${opened}`) + } + } + } catch (e) { + if (signal.aborted) return + log.error(`[sse] stream error: ${e}`) + } + + if (!signal.aborted) { + log.info(`[sse] stream ended, reconnecting in ${backoff}ms`) + await new Promise((resolve) => setTimeout(resolve, backoff)) + backoff = Math.min(backoff * 2, maxBackoff) + return connect() + } + } + + connect().catch((e) => log.error(`[sse] connect failed: ${e}`)) + } async function openTerminal() { // Create a new terminal in split screen @@ -64,6 +154,10 @@ export function activate(context: vscode.ExtensionContext) { terminal.show() terminal.sendText(`opencode --port ${port}`) + const controller = new AbortController() + sseConnections.set(terminal, controller) + connectSSE(port, terminal, controller.signal) + const fileRef = getActiveFile() if (!fileRef) { return diff --git a/sdks/vscode/src/sse.ts b/sdks/vscode/src/sse.ts new file mode 100644 index 000000000000..5e6a6ae517a5 --- /dev/null +++ b/sdks/vscode/src/sse.ts @@ -0,0 +1,52 @@ +export interface SSEEvent { + type: string + properties: Record +} + +export interface BrowserOpenEvent { + type: "browser.open" + properties: { + url: string + callbackPort?: number + } +} + +export function parseSSELines(buffer: string): { events: SSEEvent[]; remainder: string } { + const lines = buffer.split("\n") + const remainder = lines.pop() || "" + const events: SSEEvent[] = [] + + for (const line of lines) { + if (!line.startsWith("data: ")) continue + const json = line.slice(6) + try { + const parsed = JSON.parse(json) + // Global SSE events are wrapped as { directory, payload: { type, properties } } + // Instance SSE events are { type, properties } directly + const event = parsed.payload ?? parsed + events.push(event as SSEEvent) + } catch {} + } + + return { events, remainder } +} + +export function isBrowserOpen(event: SSEEvent): event is BrowserOpenEvent { + return event.type === "browser.open" && typeof (event.properties as Record).url === "string" +} + +export function createDeduplicator(windowMs = 5000) { + const recent = new Map() + + return { + isDuplicate(url: string, now = Date.now()) { + const last = recent.get(url) + if (last && now - last < windowMs) return true + recent.set(url, now) + return false + }, + clear() { + recent.clear() + }, + } +} diff --git a/sdks/vscode/test/sse.test.ts b/sdks/vscode/test/sse.test.ts new file mode 100644 index 000000000000..548689cd6b89 --- /dev/null +++ b/sdks/vscode/test/sse.test.ts @@ -0,0 +1,149 @@ +import { test, expect, beforeEach } from "bun:test" +import { parseSSELines, isBrowserOpen, createDeduplicator } from "../src/sse" +import type { SSEEvent } from "../src/sse" + +test("parseSSELines parses a single event", () => { + const buffer = 'data: {"type":"browser.open","properties":{"url":"https://example.com"}}\n' + const result = parseSSELines(buffer) + expect(result.events.length).toBe(1) + expect(result.events[0].type).toBe("browser.open") + expect(result.remainder).toBe("") +}) + +test("parseSSELines parses multiple events", () => { + const buffer = + 'data: {"type":"browser.open","properties":{"url":"https://a.com"}}\n' + + 'data: {"type":"browser.open","properties":{"url":"https://b.com"}}\n' + const result = parseSSELines(buffer) + expect(result.events.length).toBe(2) + expect((result.events[0].properties as { url: string }).url).toBe("https://a.com") + expect((result.events[1].properties as { url: string }).url).toBe("https://b.com") +}) + +test("parseSSELines preserves incomplete line as remainder", () => { + const buffer = 'data: {"type":"browser.open","properties":{"url":"https://a.com"}}\ndata: {"type":"bro' + const result = parseSSELines(buffer) + expect(result.events.length).toBe(1) + expect(result.remainder).toBe('data: {"type":"bro') +}) + +test("parseSSELines skips non-data lines", () => { + const buffer = 'event: message\ndata: {"type":"ping","properties":{}}\nid: 123\n' + const result = parseSSELines(buffer) + expect(result.events.length).toBe(1) + expect(result.events[0].type).toBe("ping") +}) + +test("parseSSELines returns empty events for empty buffer", () => { + const result = parseSSELines("") + expect(result.events.length).toBe(0) + expect(result.remainder).toBe("") +}) + +test("parseSSELines skips malformed JSON", () => { + const buffer = "data: not-json\ndata: {bad\n" + 'data: {"type":"good","properties":{}}\n' + const result = parseSSELines(buffer) + expect(result.events.length).toBe(1) + expect(result.events[0].type).toBe("good") +}) + +test("parseSSELines handles buffer with only newlines", () => { + const result = parseSSELines("\n\n\n") + expect(result.events.length).toBe(0) + expect(result.remainder).toBe("") +}) + +test("isBrowserOpen returns true for browser.open events", () => { + const event: SSEEvent = { type: "browser.open", properties: { url: "https://example.com" } } + expect(isBrowserOpen(event)).toBe(true) +}) + +test("isBrowserOpen returns false for other event types", () => { + const event: SSEEvent = { type: "ping", properties: {} } + expect(isBrowserOpen(event)).toBe(false) +}) + +test("isBrowserOpen returns false when url is missing", () => { + const event: SSEEvent = { type: "browser.open", properties: {} } + expect(isBrowserOpen(event)).toBe(false) +}) + +test("isBrowserOpen returns false when url is not a string", () => { + const event: SSEEvent = { type: "browser.open", properties: { url: 123 } } + expect(isBrowserOpen(event)).toBe(false) +}) + +test("isBrowserOpen preserves callbackPort", () => { + const event: SSEEvent = { type: "browser.open", properties: { url: "https://x.com", callbackPort: 9999 } } + expect(isBrowserOpen(event)).toBe(true) + if (isBrowserOpen(event)) { + expect(event.properties.callbackPort).toBe(9999) + } +}) + +let dedup: ReturnType + +beforeEach(() => { + dedup = createDeduplicator(5000) +}) + +test("first occurrence is not a duplicate", () => { + expect(dedup.isDuplicate("https://example.com", 1000)).toBe(false) +}) + +test("same URL within window is a duplicate", () => { + dedup.isDuplicate("https://example.com", 1000) + expect(dedup.isDuplicate("https://example.com", 3000)).toBe(true) +}) + +test("same URL after window expires is not a duplicate", () => { + dedup.isDuplicate("https://example.com", 1000) + expect(dedup.isDuplicate("https://example.com", 7000)).toBe(false) +}) + +test("different URLs are independent", () => { + dedup.isDuplicate("https://a.com", 1000) + expect(dedup.isDuplicate("https://b.com", 1000)).toBe(false) +}) + +test("same URL at exactly the window boundary is not a duplicate", () => { + dedup.isDuplicate("https://example.com", 1000) + expect(dedup.isDuplicate("https://example.com", 6000)).toBe(false) +}) + +test("clear resets deduplication state", () => { + dedup.isDuplicate("https://example.com", 1000) + dedup.clear() + expect(dedup.isDuplicate("https://example.com", 2000)).toBe(false) +}) + +test("dedup updates timestamp on non-duplicate re-entry", () => { + dedup.isDuplicate("https://example.com", 1000) + expect(dedup.isDuplicate("https://example.com", 7000)).toBe(false) + expect(dedup.isDuplicate("https://example.com", 10000)).toBe(true) +}) + +test("end-to-end: parse SSE buffer, filter browser.open, dedup", () => { + const dedup = createDeduplicator(5000) + const buffer = + 'data: {"type":"ping","properties":{}}\n' + + 'data: {"type":"browser.open","properties":{"url":"https://auth.com/login","callbackPort":8080}}\n' + + 'data: {"type":"browser.open","properties":{"url":"https://auth.com/login","callbackPort":8080}}\n' + + 'data: {"type":"browser.open","properties":{"url":"https://other.com"}}\n' + + const { events } = parseSSELines(buffer) + const now = Date.now() + const actions: Array<{ url: string; callbackPort?: number }> = [] + + for (const event of events) { + if (!isBrowserOpen(event)) continue + if (dedup.isDuplicate(event.properties.url, now)) continue + actions.push(event.properties) + } + + expect(actions.length).toBe(2) + expect(actions[0].url).toBe("https://auth.com/login") + expect(actions[0].callbackPort).toBe(8080) + expect(actions[1].url).toBe("https://other.com") + expect(actions[1].callbackPort).toBeUndefined() +}) diff --git a/sdks/vscode/tsconfig.json b/sdks/vscode/tsconfig.json index 710f9ede464c..7fbcf29c1c04 100644 --- a/sdks/vscode/tsconfig.json +++ b/sdks/vscode/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "module": "Node16", "target": "ES2022", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], "sourceMap": true, "rootDir": "src", "typeRoots": ["./node_modules/@types"], @@ -12,5 +12,6 @@ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ // "noUnusedParameters": true, /* Report errors on unused parameters. */ - } + }, + "exclude": ["test"] }