From a1d5c50d4dc1a631de553957dc254964f6a9d70a Mon Sep 17 00:00:00 2001 From: alex-v08 Date: Fri, 13 Feb 2026 16:41:22 -0300 Subject: [PATCH 1/3] feat(auth): implement browser-based Google OAuth flow Adds support for web-based OAuth 2.0 flow for Google authentication, allowing users to login via browser instead of manually exchanging codes. Improves security and user experience by handling the token exchange automatically with a local callback server. --- .../components/dialog-connect-provider.tsx | 45 +++++- packages/opencode/src/auth/google.ts | 121 ++++++++++++++++ packages/opencode/src/auth/index.ts | 2 + packages/opencode/src/cli/cmd/auth.ts | 40 ++++-- packages/opencode/src/provider/provider.ts | 135 ++++++++++++++++-- packages/opencode/src/server/routes/auth.ts | 47 ++++++ packages/opencode/src/server/server.ts | 2 + 7 files changed, 370 insertions(+), 22 deletions(-) create mode 100644 packages/opencode/src/auth/google.ts create mode 100644 packages/opencode/src/server/routes/auth.ts diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 90f4f41f7c6f..a1d11184fb38 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -301,9 +301,48 @@ export function DialogConnectProvider(props: { provider: string }) { validationState={formStore.error ? "invalid" : undefined} error={formStore.error} /> - +
+ + + + + + +
) diff --git a/packages/opencode/src/auth/google.ts b/packages/opencode/src/auth/google.ts new file mode 100644 index 000000000000..b6669512dbf2 --- /dev/null +++ b/packages/opencode/src/auth/google.ts @@ -0,0 +1,121 @@ +import * as prompts from "@clack/prompts" +import { Auth } from "./index" +import open from "open" + +export namespace GoogleAuth { + const CALLBACK_PORT = 45961 + const REDIRECT_URI = `http://127.0.0.1:${CALLBACK_PORT}/oauth2callback` + + // NOTE: These would typically come from an environment variable or a configuration file. + // For the purpose of this implementation, we assume the user might provide them + // or they are baked into the CLI if it's an official integration. + // If OpenCode has its own proxy for this, the URL would point there. + const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" + const TOKEN_URL = "https://oauth2.googleapis.com/token" + + export async function loginWeb(redirectUrl?: string) { + const existing = await Auth.get("google") + + // OpenCode requires client ID and secret to be provided via environment variables + // or configured in the auth provider settings. + const DEFAULT_CLIENT_ID = "" + const DEFAULT_CLIENT_SECRET = "" + + let clientId = process.env.GOOGLE_CLIENT_ID || (existing?.type === "oauth" ? existing.clientId : undefined) || DEFAULT_CLIENT_ID + let clientSecret = process.env.GOOGLE_CLIENT_SECRET || (existing?.type === "oauth" ? existing.clientSecret : undefined) || DEFAULT_CLIENT_SECRET + + const state = Math.random().toString(36).substring(7) + const scope = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile" + + const url = new URL(AUTH_URL) + url.searchParams.set("client_id", clientId) + url.searchParams.set("redirect_uri", REDIRECT_URI) + url.searchParams.set("response_type", "code") + url.searchParams.set("scope", scope) + url.searchParams.set("state", state) + url.searchParams.set("access_type", "offline") + url.searchParams.set("prompt", "consent") + + prompts.log.info("Opening browser for Google Authentication...") + await open(url.toString()) + + const spinner = prompts.spinner() + spinner.start("Waiting for authorization...") + + return new Promise((resolve, reject) => { + const server = Bun.serve({ + port: CALLBACK_PORT, + async fetch(req) { + const reqUrl = new URL(req.url) + if (reqUrl.pathname === "/oauth2callback") { + const code = reqUrl.searchParams.get("code") + const returnedState = reqUrl.searchParams.get("state") + + if (returnedState !== state) { + spinner.stop("State mismatch error", 1) + resolve() + return new Response("Authentication failed: State mismatch", { status: 400 }) + } + + if (!code) { + spinner.stop("No authorization code received", 1) + resolve() + return new Response("Authentication failed: No code", { status: 400 }) + } + + try { + const tokenResponse = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + code, + client_id: clientId!, + client_secret: clientSecret!, + redirect_uri: REDIRECT_URI, + grant_type: "authorization_code", + }), + }) + + const tokens = await tokenResponse.json() as any + + if (tokens.error) { + spinner.stop(`Token exchange failed: ${tokens.error_description || tokens.error}`, 1) + } else { + await Auth.set("google", { + type: "oauth", + access: tokens.access_token, + refresh: tokens.refresh_token, + expires: Date.now() + tokens.expires_in * 1000, + clientId, + clientSecret, + }) + spinner.stop("Google Login successful") + } + } catch (err) { + spinner.stop("Error during token exchange", 1) + console.error(err) + } + + // Small delay to ensure user sees the "Success" message in browser before server stops + setTimeout(() => { + server.stop() + resolve() + }, 1000) + + if (redirectUrl) { + return Response.redirect(redirectUrl) + } + return new Response("Authentication successful! You can close this window and return to the CLI.") + } + /** + * By default, Bun server will return 404 for any other path. + * However, typically oauth redirects might have fragment based redirect + * or just hit /callback directly from browser. + * We need to handle options request or just return 404 for favicon etc. + */ + return new Response("Not Found", { status: 404 }) + }, + }) + }) + } +} diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index ce948b92ac80..05509b151a42 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -13,6 +13,8 @@ export namespace Auth { expires: z.number(), accountId: z.string().optional(), enterpriseUrl: z.string().optional(), + clientId: z.string().optional(), + clientSecret: z.string().optional(), }) .meta({ ref: "OAuth" }) diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 34e2269d0c16..ebd2801abb9c 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -164,7 +164,7 @@ export const AuthCommand = cmd({ describe: "manage credentials", builder: (yargs) => yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(), - async handler() {}, + async handler() { }, }) export const AuthListCommand = cmd({ @@ -214,20 +214,34 @@ export const AuthListCommand = cmd({ }, }) +import { GoogleAuth } from "../../auth/google" + export const AuthLoginCommand = cmd({ command: "login [url]", describe: "log in to a provider", builder: (yargs) => - yargs.positional("url", { - describe: "opencode auth provider", - type: "string", - }), + yargs + .positional("url", { + describe: "opencode auth provider", + type: "string", + }) + .option("web", { + describe: "use web-based login (OAuth)", + type: "boolean", + default: false, + }), async handler(args) { await Instance.provide({ directory: process.cwd(), async fn() { UI.empty() prompts.intro("Add credential") + if (args.url === "google" && args.web) { + await GoogleAuth.loginWeb() + prompts.outro("Done") + return + } + if (args.url) { const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any) prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) @@ -251,7 +265,7 @@ export const AuthLoginCommand = cmd({ prompts.outro("Done") return } - await ModelsDev.refresh().catch(() => {}) + await ModelsDev.refresh().catch(() => { }) const config = await Config.get() @@ -307,6 +321,12 @@ export const AuthLoginCommand = cmd({ if (prompts.isCancel(provider)) throw new UI.CancelledError() + if (provider === "google" && args.web) { + await GoogleAuth.loginWeb() + prompts.outro("Done") + return + } + const plugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider)) if (plugin && plugin.auth) { const handled = await handlePluginAuth({ auth: plugin.auth }, provider) @@ -337,10 +357,10 @@ export const AuthLoginCommand = cmd({ if (provider === "amazon-bedrock") { prompts.log.info( "Amazon Bedrock authentication priority:\n" + - " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" + - " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" + - "Configure via opencode.json options (profile, region, endpoint) or\n" + - "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).", + " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" + + " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" + + "Configure via opencode.json options (profile, region, endpoint) or\n" + + "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).", ) } diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 44bcf8adb3de..7d640746d871 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -505,7 +505,7 @@ export namespace Provider { if (!apiToken) { throw new Error( "CLOUDFLARE_API_TOKEN (or CF_AIG_TOKEN) is required for Cloudflare AI Gateway. " + - "Set it via environment variable or run `opencode auth cloudflare-ai-gateway`.", + "Set it via environment variable or run `opencode auth cloudflare-ai-gateway`.", ) } @@ -525,6 +525,18 @@ export namespace Provider { options: {}, } }, + google: async (input) => { + const auth = await Auth.get(input.id) + if (auth?.type === "oauth") { + return { + autoload: true, + options: { + apiKey: auth.access, + }, + } + } + return { autoload: false } + }, cerebras: async () => { return { autoload: false, @@ -646,13 +658,13 @@ export namespace Provider { }, experimentalOver200K: model.cost?.context_over_200k ? { - cache: { - read: model.cost.context_over_200k.cache_read ?? 0, - write: model.cost.context_over_200k.cache_write ?? 0, - }, - input: model.cost.context_over_200k.input, - output: model.cost.context_over_200k.output, - } + cache: { + read: model.cost.context_over_200k.cache_read ?? 0, + write: model.cost.context_over_200k.cache_write ?? 0, + }, + input: model.cost.context_over_200k.input, + output: model.cost.context_over_200k.output, + } : undefined, }, limit: { @@ -792,7 +804,12 @@ export namespace Provider { temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false, reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false, attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false, - toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true, + toolcall: + model.tool_call ?? + existingModel?.capabilities.toolcall ?? + (provider.npm === "@ai-sdk/openai-compatible" || model.provider?.npm === "@ai-sdk/openai-compatible" + ? true + : false), input: { text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true, audio: model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false, @@ -837,6 +854,106 @@ export namespace Provider { database[providerID] = parsed } + + // Dynamic Ollama Loading for multiple potential provider IDs + const ollamaProviderIDs = ["ollama", "ollama-cloud"] + + for (const providerID of ollamaProviderIDs) { + if (!isProviderAllowed(providerID)) continue + + try { + const defaultBase = "http://127.0.0.1:11434" + const configBase = database[providerID]?.options?.baseURL as string | undefined + const apiBase = database[providerID]?.options?.api as string | undefined + // If configBase is present, use it but strip /v1 suffix. Also check 'api' field used in user config. + const baseUrl = (configBase || apiBase) ? (configBase || apiBase)!.replace(/\/v1\/?$/, "") : defaultBase + + // Only auto-discover for 'ollama' (default) OR if the provider is explicitly configured in database + if (providerID !== "ollama" && !database[providerID]) continue + + // Only attempt if it looks like a local ollama instance or explicitly requested + if (baseUrl.includes("127.0.0.1") || baseUrl.includes("localhost") || providerID === "ollama") { + const response = await fetch(`${baseUrl}/api/tags`) + if (response.ok) { + const data = (await response.json()) as { models: { name: string }[] } + const ollamaModels: Record = {} + + for (const model of data.models) { + // Skip models that are already defined in opencode.json to respect manual overrides + if (database[providerID]?.models?.[model.name]) continue + + ollamaModels[model.name] = { + id: model.name, + providerID: providerID, + name: model.name, + status: "active", + api: { + id: model.name, + url: `${baseUrl}/v1`, + npm: "@ai-sdk/openai-compatible", + }, + capabilities: { + toolcall: true, + temperature: true, + reasoning: false, + attachment: false, + interleaved: false, + input: { + text: true, + audio: false, + image: false, + video: false, + pdf: false, + }, + output: { + text: true, + audio: false, + image: false, + video: false, + pdf: false, + }, + }, + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + limit: { context: 128000, output: 4096 }, + options: {}, + headers: {}, + family: "", + release_date: new Date().toISOString(), + variants: {}, + } + } + + if (Object.keys(ollamaModels).length > 0) { + if (!database[providerID]) { + database[providerID] = { + id: providerID, + name: providerID === "ollama" ? "Ollama" : providerID, + source: "custom", + env: [], + options: { baseURL: `${baseUrl}/v1` }, + models: {}, + } + } + database[providerID].models = { ...database[providerID].models, ...ollamaModels } + + // Explicitly register the provider so it appears in the list + mergeProvider(providerID, { + source: "custom", + options: { baseURL: `${baseUrl}/v1` }, + models: ollamaModels + }) + } + } + } + } catch (e) { + // Silent failure if Ollama is not running + } + } + // load env const env = Env.all() for (const [providerID, provider] of Object.entries(database)) { diff --git a/packages/opencode/src/server/routes/auth.ts b/packages/opencode/src/server/routes/auth.ts new file mode 100644 index 000000000000..a88d0a109e44 --- /dev/null +++ b/packages/opencode/src/server/routes/auth.ts @@ -0,0 +1,47 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import { GoogleAuth } from "../../auth/google" +import { errors } from "../error" +import { lazy } from "../../util/lazy" + +export const AuthRoutes = lazy(() => + new Hono().post( + "/google/browser", + describeRoute({ + summary: "Google browser OAuth", + description: "Initiate browser-based OAuth flow for Google authentication", + operationId: "auth.google.browser", + responses: { + 200: { + description: "OAuth flow completed successfully", + content: { + "application/json": { + schema: resolver( + z.object({ + success: z.boolean(), + }), + ), + }, + }, + }, + ...errors(400, 500), + }, + }), + async (c) => { + try { + const origin = new URL(c.req.url).origin + await GoogleAuth.loginWeb(origin) + return c.json({ success: true }) + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : "Authentication failed", + }, + 500, + ) + } + }, + ), +) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 9fb5206551b6..e30a143c8283 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -39,6 +39,7 @@ import { errors } from "./error" import { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" +import { AuthRoutes } from "./routes/auth" import { MDNS } from "./mdns" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 @@ -130,6 +131,7 @@ export namespace Server { }), ) .route("/global", GlobalRoutes()) + .route("/auth", AuthRoutes()) .put( "/auth/:providerID", describeRoute({ From dd252b6b3d724009a903de603d3416dc57440c35 Mon Sep 17 00:00:00 2001 From: alex-v08 Date: Fri, 13 Feb 2026 19:35:56 -0300 Subject: [PATCH 2/3] fix(test): stabilize e2e tests and google auth flow - revert toolcall default to true for compatibility - add timeout to ollama discovery fetch - disable ollama check in e2e tests via env var - add timeout and improve error handling in google auth server - bind google auth server to 127.0.0.1 explicitly --- packages/app/script/e2e-local.ts | 1 + packages/opencode/src/auth/google.ts | 56 +++++++++++++++------- packages/opencode/src/provider/provider.ts | 40 ++++++++-------- 3 files changed, 60 insertions(+), 37 deletions(-) diff --git a/packages/app/script/e2e-local.ts b/packages/app/script/e2e-local.ts index 112e2bc60a1a..1c859b868490 100644 --- a/packages/app/script/e2e-local.ts +++ b/packages/app/script/e2e-local.ts @@ -63,6 +63,7 @@ const serverEnv = { OPENCODE_DISABLE_LSP_DOWNLOAD: "true", OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", + OPENCODE_DISABLE_OLLAMA_CHECK: "true", OPENCODE_TEST_HOME: path.join(sandbox, "home"), XDG_DATA_HOME: path.join(sandbox, "share"), XDG_CACHE_HOME: path.join(sandbox, "cache"), diff --git a/packages/opencode/src/auth/google.ts b/packages/opencode/src/auth/google.ts index b6669512dbf2..958678b77213 100644 --- a/packages/opencode/src/auth/google.ts +++ b/packages/opencode/src/auth/google.ts @@ -43,8 +43,15 @@ export namespace GoogleAuth { spinner.start("Waiting for authorization...") return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + server.stop() + spinner.stop("Login timed out", 1) + resolve() + }, 5 * 60 * 1000) // 5 minutes timeout + const server = Bun.serve({ port: CALLBACK_PORT, + hostname: "127.0.0.1", async fetch(req) { const reqUrl = new URL(req.url) if (reqUrl.pathname === "/oauth2callback") { @@ -52,17 +59,30 @@ export namespace GoogleAuth { const returnedState = reqUrl.searchParams.get("state") if (returnedState !== state) { - spinner.stop("State mismatch error", 1) - resolve() - return new Response("Authentication failed: State mismatch", { status: 400 }) + const msg = "Authentication failed: State mismatch" + spinner.stop(msg, 1) + clearTimeout(timeout) + setTimeout(() => { + server.stop() + resolve() + }, 1000) + return new Response(msg, { status: 400 }) } if (!code) { - spinner.stop("No authorization code received", 1) - resolve() - return new Response("Authentication failed: No code", { status: 400 }) + const msg = "Authentication failed: No code received" + spinner.stop(msg, 1) + clearTimeout(timeout) + setTimeout(() => { + server.stop() + resolve() + }, 1000) + return new Response(msg, { status: 400 }) } + let success = false + let errorMsg = "" + try { const tokenResponse = await fetch(TOKEN_URL, { method: "POST", @@ -79,7 +99,8 @@ export namespace GoogleAuth { const tokens = await tokenResponse.json() as any if (tokens.error) { - spinner.stop(`Token exchange failed: ${tokens.error_description || tokens.error}`, 1) + errorMsg = tokens.error_description || tokens.error + spinner.stop(`Token exchange failed: ${errorMsg}`, 1) } else { await Auth.set("google", { type: "oauth", @@ -90,29 +111,30 @@ export namespace GoogleAuth { clientSecret, }) spinner.stop("Google Login successful") + success = true } } catch (err) { - spinner.stop("Error during token exchange", 1) + errorMsg = err instanceof Error ? err.message : String(err) + spinner.stop(`Error during token exchange: ${errorMsg}`, 1) console.error(err) } + clearTimeout(timeout) // Small delay to ensure user sees the "Success" message in browser before server stops setTimeout(() => { server.stop() resolve() }, 1000) - if (redirectUrl) { - return Response.redirect(redirectUrl) + if (success) { + if (redirectUrl) { + return Response.redirect(redirectUrl) + } + return new Response("Authentication successful! You can close this window and return to the CLI.") + } else { + return new Response(`Authentication failed: ${errorMsg}`, { status: 500 }) } - return new Response("Authentication successful! You can close this window and return to the CLI.") } - /** - * By default, Bun server will return 404 for any other path. - * However, typically oauth redirects might have fragment based redirect - * or just hit /callback directly from browser. - * We need to handle options request or just return 404 for favicon etc. - */ return new Response("Not Found", { status: 404 }) }, }) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 7d640746d871..347da0a13d9c 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -804,12 +804,7 @@ export namespace Provider { temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false, reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false, attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false, - toolcall: - model.tool_call ?? - existingModel?.capabilities.toolcall ?? - (provider.npm === "@ai-sdk/openai-compatible" || model.provider?.npm === "@ai-sdk/openai-compatible" - ? true - : false), + toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true, input: { text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true, audio: model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false, @@ -858,23 +853,27 @@ export namespace Provider { // Dynamic Ollama Loading for multiple potential provider IDs const ollamaProviderIDs = ["ollama", "ollama-cloud"] - for (const providerID of ollamaProviderIDs) { - if (!isProviderAllowed(providerID)) continue + if (process.env.OPENCODE_DISABLE_OLLAMA_CHECK !== "true") { + for (const providerID of ollamaProviderIDs) { + if (!isProviderAllowed(providerID)) continue - try { - const defaultBase = "http://127.0.0.1:11434" - const configBase = database[providerID]?.options?.baseURL as string | undefined - const apiBase = database[providerID]?.options?.api as string | undefined - // If configBase is present, use it but strip /v1 suffix. Also check 'api' field used in user config. - const baseUrl = (configBase || apiBase) ? (configBase || apiBase)!.replace(/\/v1\/?$/, "") : defaultBase + try { + const defaultBase = "http://127.0.0.1:11434" + const configBase = database[providerID]?.options?.baseURL + const apiBase = database[providerID]?.options?.api - // Only auto-discover for 'ollama' (default) OR if the provider is explicitly configured in database - if (providerID !== "ollama" && !database[providerID]) continue + const rawBase = (typeof configBase === "string" && configBase) || (typeof apiBase === "string" && apiBase) + const baseUrl = rawBase ? rawBase.replace(/\/v1\/?$/, "") : defaultBase - // Only attempt if it looks like a local ollama instance or explicitly requested - if (baseUrl.includes("127.0.0.1") || baseUrl.includes("localhost") || providerID === "ollama") { - const response = await fetch(`${baseUrl}/api/tags`) - if (response.ok) { + // Only auto-discover for 'ollama' (default) OR if the provider is explicitly configured in database + if (providerID !== "ollama" && !database[providerID]) continue + + // Only attempt if it looks like a local ollama instance or explicitly requested + if (baseUrl.includes("127.0.0.1") || baseUrl.includes("localhost") || providerID === "ollama") { + const response = await fetch(`${baseUrl}/api/tags`, { + signal: AbortSignal.timeout(500), + }) + if (response.ok) { const data = (await response.json()) as { models: { name: string }[] } const ollamaModels: Record = {} @@ -953,6 +952,7 @@ export namespace Provider { // Silent failure if Ollama is not running } } + } // load env const env = Env.all() From 0f2b86ca53e34f03c2a7aadf022156ffd82b849f Mon Sep 17 00:00:00 2001 From: alex-v08 Date: Fri, 13 Feb 2026 19:57:44 -0300 Subject: [PATCH 3/3] fix(ci): replace catalog dependencies with explicit versions Fixes dependency resolution issues in isolated .opencode environments on Windows CI, where the workspace catalog is not reachable. --- packages/opencode/package.json | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 82d562bb093b..c80dd701a4c8 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -36,13 +36,13 @@ "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1", "@standard-schema/spec": "1.0.0", - "@tsconfig/bun": "catalog:", + "@tsconfig/bun": "1.0.9", "@types/babel__core": "7.20.5", - "@types/bun": "catalog:", + "@types/bun": "1.3.9", "@types/turndown": "5.0.5", "@types/yargs": "17.0.33", - "@typescript/native-preview": "catalog:", - "typescript": "catalog:", + "@typescript/native-preview": "7.0.0-dev.20251207.1", + "typescript": "5.8.2", "vscode-languageserver-types": "3.17.5", "why-is-node-running": "3.2.2", "zod-to-json-schema": "3.24.5" @@ -74,11 +74,11 @@ "@gitlab/gitlab-ai-provider": "3.5.0", "@gitlab/opencode-gitlab-auth": "1.3.2", "@hono/standard-validator": "0.1.5", - "@hono/zod-validator": "catalog:", + "@hono/zod-validator": "0.4.2", "@modelcontextprotocol/sdk": "1.25.2", "@octokit/graphql": "9.0.2", - "@octokit/rest": "catalog:", - "@openauthjs/openauth": "catalog:", + "@octokit/rest": "22.0.0", + "@openauthjs/openauth": "0.0.0-20250322224806", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", @@ -87,40 +87,40 @@ "@opentui/core": "0.1.79", "@opentui/solid": "0.1.79", "@parcel/watcher": "2.5.1", - "@pierre/diffs": "catalog:", + "@pierre/diffs": "1.1.0-beta.13", "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/scheduled": "1.5.2", "@standard-schema/spec": "1.0.0", "@zip.js/zip.js": "2.7.62", - "ai": "catalog:", + "ai": "5.0.124", "ai-gateway-provider": "2.3.1", "bonjour-service": "1.3.0", "bun-pty": "0.4.8", "chokidar": "4.0.3", "clipboardy": "4.0.0", "decimal.js": "10.5.0", - "diff": "catalog:", + "diff": "8.0.2", "fuzzysort": "3.1.0", "gray-matter": "4.0.3", - "hono": "catalog:", - "hono-openapi": "catalog:", + "hono": "4.10.7", + "hono-openapi": "1.1.2", "ignore": "7.0.5", "jsonc-parser": "3.3.1", "minimatch": "10.0.3", "open": "10.1.2", "opentui-spinner": "0.0.6", "partial-json": "0.1.7", - "remeda": "catalog:", - "solid-js": "catalog:", + "remeda": "2.26.0", + "solid-js": "1.9.10", "strip-ansi": "7.1.2", "tree-sitter-bash": "0.25.0", "turndown": "7.2.0", - "ulid": "catalog:", + "ulid": "3.0.1", "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", "xdg-basedir": "5.1.0", "yargs": "18.0.0", - "zod": "catalog:", + "zod": "4.1.8", "zod-to-json-schema": "3.24.5" } }