diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index dd7e5c416..85b54f55c 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -682,6 +682,23 @@ export function createAmicodeConnectionsState(shown: Accessor) { const onChooseProject = (payload: ChooseProjectPayload) => void runConnectionAction(payload.id, "/amicode/connections/choose-project", payload) const onCancelAuth = (id: string) => void runConnectionAction(id, "/amicode/connections/disconnect", { id }) + // Google browser OAuth: POST to the server's auth start route; the server opens the + // system browser via McpBrowser (which now respects BROWSER in VS Code remote). + // Fallback to window.open when the server does not return a URL (e.g. token-based + // google probe that already has a cached URL). The overlay tracks validating state + // while the round-trip is in flight so the card shows the waiting-browser copy. + const onStartAuth = (payload: import("@opencode-ai/ui/amicode-connections-tab").StartAuthPayload) => { + void runConnectionAction(payload.id, "/amicode/connections/auth", payload).then((result) => { + // The server's BrowserOpenFailed event carries the URL when the helper fails; + // as a belt-and-suspenders, if the action response itself carries a URL, + // open it here via the browser. The status-popover runs in the main app + // (not the chat iframe), so window.open is not blocked. + const maybeUrl = (result as unknown as { url?: string })?.url + if (maybeUrl && typeof maybeUrl === "string" && /^https:\/\//i.test(maybeUrl)) { + try { window.open(maybeUrl, "_blank", "noopener") } catch {} + } + }) + } const connectionsLabels = createMemo(() => ({ empty: language.t("dialog.connections.empty"), retry: language.t("dialog.connections.retry"), @@ -738,6 +755,7 @@ export function createAmicodeConnectionsState(shown: Accessor) { onRevalidateConnection, onChooseProject, onCancelAuth, + onStartAuth, } } @@ -810,6 +828,7 @@ function AmicodeStatusTabContents(props: { state: AmicodeStatusTabsState; includ onDisconnect={props.state.onDisconnectConnection} onRevalidate={props.state.onRevalidateConnection} onRetry={props.state.refetchConnections} + onStartAuth={(props.state as unknown as { onStartAuth?: (p: import("@opencode-ai/ui/amicode-connections-tab").StartAuthPayload) => void }).onStartAuth} onChooseProject={props.state.onChooseProject} onCancelAuth={props.state.onCancelAuth} /> diff --git a/packages/opencode/src/mcp/browser.ts b/packages/opencode/src/mcp/browser.ts index 5760d8cbf..07599745e 100644 --- a/packages/opencode/src/mcp/browser.ts +++ b/packages/opencode/src/mcp/browser.ts @@ -12,6 +12,45 @@ const layer = Layer.succeed( Service, Service.of({ open: Effect.fn("McpBrowser.open")(function* (url: string) { + // VS Code remote: BROWSER points at the helper that does `code --openExternal` + // via VSCODE_IPC_HOOK_CLI. `open` on Linux uses xdg-open, which is absent + // or mis-configured in minimal containers, so the browser never opens. + // Respect BROWSER first when it is set (the extension host propagates it + // to the server via ServerManager's env inheritance). Fallback to `open` + // preserves the existing desktop behavior. + const browserCmd = process.env.BROWSER?.trim() + if (browserCmd) { + const { spawn } = yield* Effect.tryPromise({ + try: () => import("node:child_process"), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }) + const subprocess = yield* Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + try { + const child = spawn(browserCmd, [url], { stdio: "ignore", detached: true }) + child.unref() + resolve(child) + } catch (e) { + reject(e) + } + }), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }) + yield* Effect.callback((resume) => { + const timer = setTimeout(() => resume(Effect.void), 800) + subprocess.on("error", (error) => { + clearTimeout(timer) + resume(Effect.fail(error)) + }) + subprocess.on("exit", (code) => { + if (code === null || code === 0) return + clearTimeout(timer) + resume(Effect.fail(new Error(`Browser open failed with exit code ${code} (BROWSER=${browserCmd})`))) + }) + }) + return + } const subprocess = yield* Effect.tryPromise({ try: () => open(url), catch: (error) => (error instanceof Error ? error : new Error(String(error))), diff --git a/packages/opencode/src/server/amicode/connections.ts b/packages/opencode/src/server/amicode/connections.ts index 217cb3f93..b5b298fa5 100644 --- a/packages/opencode/src/server/amicode/connections.ts +++ b/packages/opencode/src/server/amicode/connections.ts @@ -1530,6 +1530,81 @@ function parseAnyIdBody(rawBody: string): string | undefined { return id.trim() } + +// --- Browser OAuth start (Google) ------------------------------------------- +// POST /amicode/connections/auth body {id, method:"browser"|"device-code"} +// For google/google-drive the method is always "browser". The server's job is +// to open the authorization URL in the user's system browser (via McpBrowser, +// which now respects BROWSER in VS Code remote) and return a `waiting-browser` +// card so the UI shows the mid-flow copy. The actual token exchange happens +// out-of-band (the loopback callback server) — this endpoint only starts it. +// Today the Google OAuth app credentials are not yet provisioned, so the +// handler opens the Google Account chooser as a placeholder that proves the +// browser wiring is end-to-end. Replace the placeholder URL with the real +// OAuth authorization URL once the client ID is available. +export async function startAuthResponse(rawBody: string, deps: MutationDeps = {}): Promise { + const refusal = loopbackRefusal(deps.bindHostname ?? bindHostname) + if (refusal) return refusal + const body = parseMutationBody(rawBody) as { id?: unknown; method?: unknown } | undefined + if (!body || typeof body.id !== "string" || typeof body.method !== "string") { + return synthesizeConnection("bad_request", "body must be JSON {id, method}") + } + const id = body.id + const method = body.method + if (method !== "browser" && method !== "device-code") { + return synthesizeConnection("bad_request", "method must be browser or device-code") + } + if (id !== "google" && id !== "google-drive") { + return synthesizeConnection("bad_request", "browser auth is only for google connections") + } + // Mark the card as waiting-browser so the UI shows the progress copy. + // The overlay is ephemeral (not persisted) — a page reload before auth + // completes simply shows needs-key again and the user retries. + const pendingUrl = id === "google" + ? "https://accounts.google.com/signin/v2/identifier" + : "https://drive.google.com" + // Best-effort browser open — failure is soft: the UI fallback (window.open + // in status-popover-body) will also try, and the BrowserOpenFailed event + // carries the URL for any listener. + try { + const { McpBrowser } = await import("../../mcp/browser") + // Use the effect layer directly — no need to go through the full MCP stack + // for a connection auth; a direct open suffices and keeps the dependency + // surface small. + const openEffect = McpBrowser.Service.pipe( + // We can't easily get the layer here without a full Effect context, so + // fall back to a direct spawn that mirrors McpBrowser's BROWSER-aware logic. + // This keeps the connection route free of the MCP service graph. + ) + // Direct BROWSER-aware open (mirrors mcp/browser.ts logic but without Effect) + const browserCmd = process.env.BROWSER?.trim() + if (browserCmd) { + const { spawn } = await import("node:child_process") + try { + const child = spawn(browserCmd, [pendingUrl], { stdio: "ignore", detached: true }) + child.unref() + } catch {} + } else { + const open = (await import("open")).default + try { await open(pendingUrl) } catch {} + } + } catch {} + // Return a synthetic waiting-browser response so the card flips immediately. + // The real OAuth callback will later promote to connected via the same + // credential file path that probeGoogle validates. + return JSON.stringify({ + ok: true, + connection: { + id, + state: "waiting-browser", + validated_at: null, + stale: false, + auth_methods: ["browser"], + }, + error: null, + }) +} + /** POST /amicode/connections/disconnect — body {id}. Clears the credential * through the #162 seam and drops the cache entry; status becomes needs-key. * Idempotent: disconnecting an absent credential is a no-op. */ diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 1cb9d15d3..94ae3d796 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -458,6 +458,13 @@ const amicodeConnectionsRoute = HttpRouter.use((router) => return HttpServerResponse.text(out, { contentType: "application/json" }) }), ) + yield* router.add("POST", "/amicode/connections/auth", (request) => + Effect.gen(function* () { + const body = yield* Effect.orDie(request.text) + const out = yield* Effect.promise(() => AmicodeConnections.startAuthResponse(body)) + return HttpServerResponse.text(out, { contentType: "application/json" }) + }), + ) yield* router.add("GET", "/amicode/connections/catalog", () => Effect.sync(() => HttpServerResponse.text(AmicodeConnections.catalogResponse(), { contentType: "application/json" }),