From 431abd8347ef1db71e4d434b0c9b4c0940879ae0 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:33:57 -0400 Subject: [PATCH 1/3] fix(oauth): forward upstream error categories to clients The route boundary collapsed every upstream OAuth failure into a single generic message, leaving all six mappings in the frontend's mapOAuthError unreachable. Classify the failure and forward the matched category so clients surface specific guidance. Hoist the category list into @opencode-manager/shared as the single source of truth for both sides; the frontend map is now typed Record so a missing message fails typecheck. --- backend/src/routes/oauth.test.ts | 192 +++++++++++++++++++++++++++++++ backend/src/routes/oauth.ts | 19 ++- frontend/src/lib/oauthErrors.ts | 11 +- shared/src/schemas/auth.ts | 11 ++ 4 files changed, 226 insertions(+), 7 deletions(-) create mode 100644 backend/src/routes/oauth.test.ts diff --git a/backend/src/routes/oauth.test.ts b/backend/src/routes/oauth.test.ts new file mode 100644 index 000000000..a76d68353 --- /dev/null +++ b/backend/src/routes/oauth.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi } from 'vitest' +import { Hono } from 'hono' +import { createOAuthRoutes, classifyOAuthError } from './oauth' +import { createStubOpenCodeClient } from '../../test/helpers/stub-opencode-client' + +function createTestApp(clientOverrides: Parameters[0] = {}): Hono { + const app = new Hono() + app.route('/oauth', createOAuthRoutes(createStubOpenCodeClient(clientOverrides))) + return app +} + +describe('classifyOAuthError', () => { + it('returns the matched category for each known substring', () => { + expect(classifyOAuthError('invalid code provided', 'callback')).toBe('invalid code') + expect(classifyOAuthError('session has expired', 'authorize')).toBe('expired') + expect(classifyOAuthError('User access denied', 'authorize')).toBe('access denied') + expect(classifyOAuthError('upstream server error', 'callback')).toBe('server error') + expect(classifyOAuthError('provider not found in registry', 'authorize')).toBe('provider not found') + expect(classifyOAuthError('invalid method selected', 'authorize')).toBe('invalid method') + }) + + it('falls back to the phase-specific generic for unrecognized text', () => { + expect(classifyOAuthError('something unexpected', 'authorize')).toBe('OAuth authorization failed') + expect(classifyOAuthError('something unexpected', 'callback')).toBe('OAuth callback failed') + }) +}) + +describe('oauth routes /auth-methods', () => { + it('wraps the upstream catalogue as { providers }', async () => { + const upstream = { anthropic: [{ type: 'oauth', label: 'Anthropic OAuth' }] } + const app = createTestApp({ + forward: vi.fn(async () => new Response(JSON.stringify(upstream), { status: 200 })), + }) + const res = await app.request('/oauth/auth-methods') + expect(res.status).toBe(200) + const data = (await res.json()) as { providers: typeof upstream } + expect(data.providers).toEqual(upstream) + }) + + it('returns 500 when upstream fails', async () => { + const app = createTestApp({ + forward: vi.fn(async () => new Response('internal failure', { status: 500 })), + }) + const res = await app.request('/oauth/auth-methods') + expect(res.status).toBe(500) + }) +}) + +describe('oauth routes /:id/oauth/authorize', () => { + const validBody = { method: 0 } + + it('forwards the validated body and returns the parsed response', async () => { + const upstream = { + url: 'https://auth.example.com', + method: 'auto' as const, + instructions: 'Open this page', + } + const forward = vi.fn(async () => new Response(JSON.stringify(upstream), { status: 200 })) + const app = createTestApp({ forward }) + const res = await app.request('/oauth/openai/oauth/authorize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(validBody), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual(upstream) + expect(forward).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/provider/openai/oauth/authorize', + }), + ) + }) + + it('returns 400 on invalid body', async () => { + const app = createTestApp() + const res = await app.request('/oauth/openai/oauth/authorize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(res.status).toBe(400) + }) + + it('classifies known upstream error categories', async () => { + const cases: Array<[string, string]> = [ + ['invalid code provided', 'invalid code'], + ['session expired', 'expired'], + ['access denied by user', 'access denied'], + ['server error', 'server error'], + ['provider not found', 'provider not found'], + ['invalid method index', 'invalid method'], + ] + for (const [upstreamText, expected] of cases) { + const app = createTestApp({ + forward: vi.fn(async () => new Response(upstreamText, { status: 500 })), + }) + const res = await app.request('/oauth/openai/oauth/authorize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(validBody), + }) + expect(res.status).toBe(500) + const body = (await res.json()) as { error: string } + expect(body.error).toBe(expected) + } + }) + + it('falls back to the generic authorize message for unknown upstream errors', async () => { + const app = createTestApp({ + forward: vi.fn(async () => new Response('totally unexpected', { status: 500 })), + }) + const res = await app.request('/oauth/openai/oauth/authorize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(validBody), + }) + expect(res.status).toBe(500) + const body = (await res.json()) as { error: string } + expect(body.error).toBe('OAuth authorization failed') + }) +}) + +describe('oauth routes /:id/oauth/callback', () => { + const validBody = { method: 0 } + + it('forwards the validated body and returns upstream data', async () => { + const forward = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })) + const app = createTestApp({ forward }) + const res = await app.request('/oauth/openai/oauth/callback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(validBody), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true }) + expect(forward).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/provider/openai/oauth/callback', + }), + ) + }) + + it('returns 400 on invalid callback body', async () => { + const app = createTestApp() + const res = await app.request('/oauth/openai/oauth/callback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ foo: 'bar' }), + }) + expect(res.status).toBe(400) + }) + + it('classifies known upstream error categories on callback failures', async () => { + const cases: Array<[string, string]> = [ + ['invalid code provided', 'invalid code'], + ['token expired', 'expired'], + ['access denied', 'access denied'], + ['server error during exchange', 'server error'], + ['provider not found', 'provider not found'], + ['invalid method', 'invalid method'], + ] + for (const [upstreamText, expected] of cases) { + const app = createTestApp({ + forward: vi.fn(async () => new Response(upstreamText, { status: 500 })), + }) + const res = await app.request('/oauth/openai/oauth/callback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(validBody), + }) + expect(res.status).toBe(500) + const body = (await res.json()) as { error: string } + expect(body.error).toBe(expected) + } + }) + + it('falls back to the generic callback message for unknown upstream errors', async () => { + const app = createTestApp({ + forward: vi.fn(async () => new Response('unknown boom', { status: 500 })), + }) + const res = await app.request('/oauth/openai/oauth/callback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(validBody), + }) + expect(res.status).toBe(500) + const body = (await res.json()) as { error: string } + expect(body.error).toBe('OAuth callback failed') + }) +}) diff --git a/backend/src/routes/oauth.ts b/backend/src/routes/oauth.ts index 516e4ae9f..cbfd50773 100644 --- a/backend/src/routes/oauth.ts +++ b/backend/src/routes/oauth.ts @@ -5,11 +5,24 @@ import { logger } from '../utils/logger' import { OAuthAuthorizeRequestSchema, OAuthAuthorizeResponseSchema, - OAuthCallbackRequestSchema + OAuthCallbackRequestSchema, + OAUTH_ERROR_CATEGORIES } from '../../../shared/src/schemas/auth' import { reloadOpenCodeConfig } from '../services/opencode-restart' import type { OpenCodeSupervisor } from '../services/opencode-supervisor' +type OAuthPhase = 'authorize' | 'callback' + +export function classifyOAuthError(text: string, phase: OAuthPhase): string { + const lower = text.toLowerCase() + for (const category of OAUTH_ERROR_CATEGORIES) { + if (lower.includes(category)) { + return category + } + } + return phase === 'authorize' ? 'OAuth authorization failed' : 'OAuth callback failed' +} + export function createOAuthRoutes(openCodeClient: OpenCodeClient, openCodeSupervisor?: OpenCodeSupervisor) { const app = new Hono() @@ -29,7 +42,7 @@ export function createOAuthRoutes(openCodeClient: OpenCodeClient, openCodeSuperv if (!response.ok) { const error = await response.text() logger.error(`OAuth authorize failed for ${providerId}:`, error) - return c.json({ error: 'OAuth authorization failed' }, 500) + return c.json({ error: classifyOAuthError(error, 'authorize') }, 500) } const data = await response.json() @@ -61,7 +74,7 @@ export function createOAuthRoutes(openCodeClient: OpenCodeClient, openCodeSuperv if (!response.ok) { const error = await response.text() logger.error(`OAuth callback failed for ${providerId}:`, error) - return c.json({ error: 'OAuth callback failed' }, 500) + return c.json({ error: classifyOAuthError(error, 'callback') }, 500) } const data = await response.json() diff --git a/frontend/src/lib/oauthErrors.ts b/frontend/src/lib/oauthErrors.ts index 0f00e220f..368d7098d 100644 --- a/frontend/src/lib/oauthErrors.ts +++ b/frontend/src/lib/oauthErrors.ts @@ -1,4 +1,6 @@ -const ERROR_MAPPINGS: Record = { +import { OAUTH_ERROR_CATEGORIES, type OAuthErrorCategory } from '@opencode-manager/shared/schemas' + +const ERROR_MAPPINGS: Record = { 'invalid code': 'Invalid authorization code. Please try the OAuth flow again.', 'expired': 'Authorization code has expired. Please try the OAuth flow again.', 'access denied': 'Access was denied. Please check the permissions and try again.', @@ -14,9 +16,10 @@ export function mapOAuthError(err: unknown, context: 'authorize' | 'callback'): if (!(err instanceof Error)) return defaultMessage - for (const [key, message] of Object.entries(ERROR_MAPPINGS)) { - if (err.message.toLowerCase().includes(key)) { - return message + const lower = err.message.toLowerCase() + for (const category of OAUTH_ERROR_CATEGORIES) { + if (lower.includes(category)) { + return ERROR_MAPPINGS[category] } } diff --git a/shared/src/schemas/auth.ts b/shared/src/schemas/auth.ts index b954f0c72..232791004 100644 --- a/shared/src/schemas/auth.ts +++ b/shared/src/schemas/auth.ts @@ -71,3 +71,14 @@ export const OAuthCallbackRequestSchema = z.object({ export const ProviderAuthMethodsResponseSchema = z.object({ providers: z.record(z.string(), z.array(ProviderAuthMethodSchema)), }).or(ProviderAuthMethodsSchema); + +export const OAUTH_ERROR_CATEGORIES = [ + "invalid code", + "expired", + "access denied", + "server error", + "provider not found", + "invalid method", +] as const; + +export type OAuthErrorCategory = (typeof OAUTH_ERROR_CATEGORIES)[number]; From 97454c87942deef681c0e33b7cf544e9bf370533 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:09:29 -0400 Subject: [PATCH 2/3] fix(oauth): match upstream provider auth errors by type instead of substrings The previous mapping matched invented substrings ("invalid code", "expired", "access denied", ...) that opencode never emits, so every real failure fell through to the generic message and left the frontend map unreachable. It also returned 500 for what upstream reports as 400. opencode serialises these failures as a structured contract: ProviderAuthError ({ name, data }) or InvalidRequestError ({ _tag, message }) at HTTP 400. Model both shapes in shared, discriminate on name/_tag, and forward the exact code plus upstream detail so clients can map errors precisely. Pin the contract to upstream with a compile-time conformance assertion against @opencode-ai/sdk's generated ProviderOauthAuthorizeErrors[400] and ProviderOauthCallbackErrors[400], added as a type-only devDependency. Any added, removed, renamed, or retyped upstream variant now fails typecheck. Also stop discarding the structured error at the frontend boundary, where handleApiError rewrapped FetchError in a bare Error and dropped the code that fetchWrapper had already parsed. Verified against a live opencode 1.18.7 server: callback without a pending authorization returns 400 ProviderAuthOauthMissing and is now forwarded as such. Tests are pinned to captured responses, including the multiline message BadRequest carries. --- backend/src/routes/oauth.test.ts | 249 ++++++++++++++++----------- backend/src/routes/oauth.ts | 59 +++++-- frontend/src/api/oauth.ts | 57 ++---- frontend/src/lib/oauthErrors.test.ts | 37 ++++ frontend/src/lib/oauthErrors.ts | 30 ++-- pnpm-lock.yaml | 10 ++ shared/package.json | 1 + shared/src/schemas/auth.ts | 65 ++++++- 8 files changed, 338 insertions(+), 170 deletions(-) create mode 100644 frontend/src/lib/oauthErrors.test.ts diff --git a/backend/src/routes/oauth.test.ts b/backend/src/routes/oauth.test.ts index a76d68353..c90d51076 100644 --- a/backend/src/routes/oauth.test.ts +++ b/backend/src/routes/oauth.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, vi } from 'vitest' import { Hono } from 'hono' -import { createOAuthRoutes, classifyOAuthError } from './oauth' +import { createOAuthRoutes, buildOAuthFailure } from './oauth' import { createStubOpenCodeClient } from '../../test/helpers/stub-opencode-client' +import { PROVIDER_AUTH_ERROR_NAMES } from '../../../shared/src/schemas/auth' function createTestApp(clientOverrides: Parameters[0] = {}): Hono { const app = new Hono() @@ -9,19 +10,103 @@ function createTestApp(clientOverrides: Parameters { - it('returns the matched category for each known substring', () => { - expect(classifyOAuthError('invalid code provided', 'callback')).toBe('invalid code') - expect(classifyOAuthError('session has expired', 'authorize')).toBe('expired') - expect(classifyOAuthError('User access denied', 'authorize')).toBe('access denied') - expect(classifyOAuthError('upstream server error', 'callback')).toBe('server error') - expect(classifyOAuthError('provider not found in registry', 'authorize')).toBe('provider not found') - expect(classifyOAuthError('invalid method selected', 'authorize')).toBe('invalid method') +function upstreamFailure(body: unknown, status = 400): Parameters[0] { + return { + forward: vi.fn(async () => new Response(JSON.stringify(body), { status })), + } +} + +describe('buildOAuthFailure against captured opencode 1.18.7 responses', () => { + it('classifies a real ProviderAuthOauthMissing callback response', () => { + const failure = buildOAuthFailure( + '{"name":"ProviderAuthOauthMissing","data":{"providerID":"openai"}}', + 400, + 'callback', + ) + expect(failure.status).toBe(400) + expect(failure.payload).toEqual({ + error: 'OAuth callback failed', + code: 'ProviderAuthOauthMissing', + }) }) - it('falls back to the phase-specific generic for unrecognized text', () => { - expect(classifyOAuthError('something unexpected', 'authorize')).toBe('OAuth authorization failed') - expect(classifyOAuthError('something unexpected', 'callback')).toBe('OAuth callback failed') + it('flattens the multiline message a real BadRequest payload carries', () => { + const failure = buildOAuthFailure( + '{"name":"BadRequest","data":{"message":"Missing key\\n at [\\"method\\"]","kind":"Payload"}}', + 400, + 'callback', + ) + expect(failure.payload.code).toBe('BadRequest') + expect(failure.payload.detail).toBe('Missing key at ["method"]') + }) + + it('falls back without a code for a real UnknownError defect response', () => { + const failure = buildOAuthFailure( + '{"name":"UnknownError","data":{"message":"Unexpected server error. Check server logs for details.","ref":"err_00568566"}}', + 500, + 'authorize', + ) + expect(failure.status).toBe(500) + expect(failure.payload).toEqual({ error: 'OAuth authorization failed' }) + }) +}) + +describe('buildOAuthFailure', () => { + it('forwards every upstream ProviderAuthError name as the code', () => { + for (const name of PROVIDER_AUTH_ERROR_NAMES) { + const failure = buildOAuthFailure(JSON.stringify({ name, data: {} }), 400, 'authorize') + expect(failure.payload.code).toBe(name) + expect(failure.status).toBe(400) + } + }) + + it('forwards the tagged InvalidRequestError shape', () => { + const failure = buildOAuthFailure( + JSON.stringify({ _tag: 'InvalidRequestError', message: 'method out of range' }), + 400, + 'authorize', + ) + expect(failure.payload.code).toBe('InvalidRequestError') + expect(failure.payload.detail).toBe('method out of range') + }) + + it('surfaces upstream message and field as detail for validation failures', () => { + const failure = buildOAuthFailure( + JSON.stringify({ + name: 'ProviderAuthValidationFailed', + data: { field: 'apiKey', message: 'must start with sk-' }, + }), + 400, + 'authorize', + ) + expect(failure.payload.code).toBe('ProviderAuthValidationFailed') + expect(failure.payload.detail).toBe('must start with sk- — field: apiKey') + }) + + it('omits detail when upstream carries no message or field', () => { + const failure = buildOAuthFailure( + JSON.stringify({ name: 'ProviderAuthOauthCallbackFailed', data: {} }), + 400, + 'callback', + ) + expect(failure.payload.detail).toBeUndefined() + expect(failure.payload.error).toBe('OAuth callback failed') + }) + + it('falls back to the phase generic without a code for non-JSON bodies', () => { + const failure = buildOAuthFailure('upstream exploded', 500, 'authorize') + expect(failure.payload).toEqual({ error: 'OAuth authorization failed' }) + expect(failure.status).toBe(500) + }) + + it('falls back to the phase generic for JSON that is not the upstream contract', () => { + const failure = buildOAuthFailure(JSON.stringify({ name: 'SomethingElse' }), 400, 'callback') + expect(failure.payload).toEqual({ error: 'OAuth callback failed' }) + }) + + it('replaces out-of-range upstream statuses with 502', () => { + expect(buildOAuthFailure('boom', 200, 'authorize').status).toBe(502) + expect(buildOAuthFailure('boom', 0, 'authorize').status).toBe(502) }) }) @@ -49,6 +134,14 @@ describe('oauth routes /auth-methods', () => { describe('oauth routes /:id/oauth/authorize', () => { const validBody = { method: 0 } + function authorizeRequest(app: Hono, body: unknown = validBody) { + return app.request('/oauth/openai/oauth/authorize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + it('forwards the validated body and returns the parsed response', async () => { const upstream = { url: 'https://auth.example.com', @@ -57,11 +150,7 @@ describe('oauth routes /:id/oauth/authorize', () => { } const forward = vi.fn(async () => new Response(JSON.stringify(upstream), { status: 200 })) const app = createTestApp({ forward }) - const res = await app.request('/oauth/openai/oauth/authorize', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(validBody), - }) + const res = await authorizeRequest(app) expect(res.status).toBe(200) expect(await res.json()).toEqual(upstream) expect(forward).toHaveBeenCalledWith( @@ -73,65 +162,45 @@ describe('oauth routes /:id/oauth/authorize', () => { }) it('returns 400 on invalid body', async () => { - const app = createTestApp() - const res = await app.request('/oauth/openai/oauth/authorize', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }) + const res = await authorizeRequest(createTestApp(), {}) expect(res.status).toBe(400) }) - it('classifies known upstream error categories', async () => { - const cases: Array<[string, string]> = [ - ['invalid code provided', 'invalid code'], - ['session expired', 'expired'], - ['access denied by user', 'access denied'], - ['server error', 'server error'], - ['provider not found', 'provider not found'], - ['invalid method index', 'invalid method'], - ] - for (const [upstreamText, expected] of cases) { - const app = createTestApp({ - forward: vi.fn(async () => new Response(upstreamText, { status: 500 })), - }) - const res = await app.request('/oauth/openai/oauth/authorize', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(validBody), - }) - expect(res.status).toBe(500) - const body = (await res.json()) as { error: string } - expect(body.error).toBe(expected) - } + it('forwards the upstream error code and 400 status', async () => { + const app = createTestApp( + upstreamFailure({ name: 'ProviderAuthOauthMissing', data: { providerID: 'openai' } }), + ) + const res = await authorizeRequest(app) + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ + error: 'OAuth authorization failed', + code: 'ProviderAuthOauthMissing', + }) }) - it('falls back to the generic authorize message for unknown upstream errors', async () => { - const app = createTestApp({ - forward: vi.fn(async () => new Response('totally unexpected', { status: 500 })), - }) - const res = await app.request('/oauth/openai/oauth/authorize', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(validBody), - }) + it('falls back to the generic authorize message for unrecognized upstream errors', async () => { + const app = createTestApp(upstreamFailure('totally unexpected', 500)) + const res = await authorizeRequest(app) expect(res.status).toBe(500) - const body = (await res.json()) as { error: string } - expect(body.error).toBe('OAuth authorization failed') + expect(await res.json()).toEqual({ error: 'OAuth authorization failed' }) }) }) describe('oauth routes /:id/oauth/callback', () => { const validBody = { method: 0 } - it('forwards the validated body and returns upstream data', async () => { - const forward = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })) - const app = createTestApp({ forward }) - const res = await app.request('/oauth/openai/oauth/callback', { + function callbackRequest(app: Hono, body: unknown = validBody) { + return app.request('/oauth/openai/oauth/callback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(validBody), + body: JSON.stringify(body), }) + } + + it('forwards the validated body and returns upstream data', async () => { + const forward = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })) + const app = createTestApp({ forward }) + const res = await callbackRequest(app) expect(res.status).toBe(200) expect(await res.json()).toEqual({ ok: true }) expect(forward).toHaveBeenCalledWith( @@ -143,50 +212,30 @@ describe('oauth routes /:id/oauth/callback', () => { }) it('returns 400 on invalid callback body', async () => { - const app = createTestApp() - const res = await app.request('/oauth/openai/oauth/callback', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ foo: 'bar' }), - }) + const res = await callbackRequest(createTestApp(), { foo: 'bar' }) expect(res.status).toBe(400) }) - it('classifies known upstream error categories on callback failures', async () => { - const cases: Array<[string, string]> = [ - ['invalid code provided', 'invalid code'], - ['token expired', 'expired'], - ['access denied', 'access denied'], - ['server error during exchange', 'server error'], - ['provider not found', 'provider not found'], - ['invalid method', 'invalid method'], - ] - for (const [upstreamText, expected] of cases) { - const app = createTestApp({ - forward: vi.fn(async () => new Response(upstreamText, { status: 500 })), - }) - const res = await app.request('/oauth/openai/oauth/callback', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(validBody), - }) - expect(res.status).toBe(500) - const body = (await res.json()) as { error: string } - expect(body.error).toBe(expected) - } + it('forwards the upstream code and validation detail', async () => { + const app = createTestApp( + upstreamFailure({ + name: 'ProviderAuthValidationFailed', + data: { field: 'code', message: 'code is not valid' }, + }), + ) + const res = await callbackRequest(app) + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ + error: 'OAuth callback failed', + code: 'ProviderAuthValidationFailed', + detail: 'code is not valid — field: code', + }) }) - it('falls back to the generic callback message for unknown upstream errors', async () => { - const app = createTestApp({ - forward: vi.fn(async () => new Response('unknown boom', { status: 500 })), - }) - const res = await app.request('/oauth/openai/oauth/callback', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(validBody), - }) + it('falls back to the generic callback message for unrecognized upstream errors', async () => { + const app = createTestApp(upstreamFailure('unknown boom', 500)) + const res = await callbackRequest(app) expect(res.status).toBe(500) - const body = (await res.json()) as { error: string } - expect(body.error).toBe('OAuth callback failed') + expect(await res.json()).toEqual({ error: 'OAuth callback failed' }) }) }) diff --git a/backend/src/routes/oauth.ts b/backend/src/routes/oauth.ts index cbfd50773..94393c85f 100644 --- a/backend/src/routes/oauth.ts +++ b/backend/src/routes/oauth.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono' +import type { ContentfulStatusCode } from 'hono/utils/http-status' import type { OpenCodeClient } from '../services/opencode/client' import { z } from 'zod' import { logger } from '../utils/logger' @@ -6,21 +7,57 @@ import { OAuthAuthorizeRequestSchema, OAuthAuthorizeResponseSchema, OAuthCallbackRequestSchema, - OAUTH_ERROR_CATEGORIES + OpenCodeOAuthErrorSchema, + oauthErrorCode } from '../../../shared/src/schemas/auth' +import type { OAuthErrorCode } from '../../../shared/src/schemas/auth' import { reloadOpenCodeConfig } from '../services/opencode-restart' import type { OpenCodeSupervisor } from '../services/opencode-supervisor' type OAuthPhase = 'authorize' | 'callback' -export function classifyOAuthError(text: string, phase: OAuthPhase): string { - const lower = text.toLowerCase() - for (const category of OAUTH_ERROR_CATEGORIES) { - if (lower.includes(category)) { - return category - } +const PHASE_FALLBACK: Record = { + authorize: 'OAuth authorization failed', + callback: 'OAuth callback failed', +} + +interface OAuthFailure { + payload: { error: string; code?: OAuthErrorCode; detail?: string } + status: ContentfulStatusCode +} + +export function buildOAuthFailure(body: string, upstreamStatus: number, phase: OAuthPhase): OAuthFailure { + const status: ContentfulStatusCode = + upstreamStatus >= 400 && upstreamStatus <= 599 ? (upstreamStatus as ContentfulStatusCode) : 502 + + let parsed: unknown + try { + parsed = JSON.parse(body) + } catch { + return { payload: { error: PHASE_FALLBACK[phase] }, status } + } + + const result = OpenCodeOAuthErrorSchema.safeParse(parsed) + if (!result.success) { + return { payload: { error: PHASE_FALLBACK[phase] }, status } + } + + const error = result.data + const isTagged = '_tag' in error + const message = isTagged ? error.message : error.data.message + const field = isTagged ? error.field : error.data.field + const detail = [message?.replace(/\s+/g, ' ').trim(), field ? `field: ${field}` : undefined] + .filter(Boolean) + .join(' — ') + + return { + payload: { + error: PHASE_FALLBACK[phase], + code: oauthErrorCode(error), + ...(detail ? { detail } : {}), + }, + status, } - return phase === 'authorize' ? 'OAuth authorization failed' : 'OAuth callback failed' } export function createOAuthRoutes(openCodeClient: OpenCodeClient, openCodeSupervisor?: OpenCodeSupervisor) { @@ -42,7 +79,8 @@ export function createOAuthRoutes(openCodeClient: OpenCodeClient, openCodeSuperv if (!response.ok) { const error = await response.text() logger.error(`OAuth authorize failed for ${providerId}:`, error) - return c.json({ error: classifyOAuthError(error, 'authorize') }, 500) + const failure = buildOAuthFailure(error, response.status, 'authorize') + return c.json(failure.payload, failure.status) } const data = await response.json() @@ -74,7 +112,8 @@ export function createOAuthRoutes(openCodeClient: OpenCodeClient, openCodeSuperv if (!response.ok) { const error = await response.text() logger.error(`OAuth callback failed for ${providerId}:`, error) - return c.json({ error: classifyOAuthError(error, 'callback') }, 500) + const failure = buildOAuthFailure(error, response.status, 'callback') + return c.json(failure.payload, failure.status) } const data = await response.json() diff --git a/frontend/src/api/oauth.ts b/frontend/src/api/oauth.ts index f0e62249b..19a523d2c 100644 --- a/frontend/src/api/oauth.ts +++ b/frontend/src/api/oauth.ts @@ -1,6 +1,6 @@ import { API_BASE_URL } from "@/config" import type { components, operations } from "./opencode-types" -import { fetchWrapper, FetchError } from "./fetchWrapper" +import { fetchWrapper } from "./fetchWrapper" type OpenCodeAuthorizeRequest = NonNullable["content"]["application/json"] @@ -14,46 +14,25 @@ export interface ProviderAuthMethods { [providerId: string]: ProviderAuthMethod[] } -function handleApiError(error: unknown, context: string): never { - if (error instanceof FetchError) { - throw new Error(`${context}: ${error.message}`) - } - throw error -} - export const oauthApi = { - authorize: async (providerId: string, method: number, inputs?: OpenCodeAuthorizeRequest["inputs"]): Promise => { - try { - return await fetchWrapper(`${API_BASE_URL}/api/oauth/${providerId}/oauth/authorize`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ method, inputs }), - }) - } catch (error) { - handleApiError(error, "OAuth authorization failed") - } - }, - - callback: async (providerId: string, request: OAuthCallbackRequest): Promise => { - try { - return await fetchWrapper(`${API_BASE_URL}/api/oauth/${providerId}/oauth/callback`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request), - }) - } catch (error) { - handleApiError(error, "OAuth callback failed") - } - }, + authorize: async (providerId: string, method: number, inputs?: OpenCodeAuthorizeRequest["inputs"]): Promise => + fetchWrapper(`${API_BASE_URL}/api/oauth/${providerId}/oauth/authorize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method, inputs }), + }), + + callback: async (providerId: string, request: OAuthCallbackRequest): Promise => + fetchWrapper(`${API_BASE_URL}/api/oauth/${providerId}/oauth/callback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }), getAuthMethods: async (): Promise => { - try { - const { providers, ...rest } = await fetchWrapper<{ providers?: ProviderAuthMethods } & ProviderAuthMethods>( - `${API_BASE_URL}/api/oauth/auth-methods` - ) - return providers || rest - } catch (error) { - handleApiError(error, "Failed to get provider auth methods") - } + const { providers, ...rest } = await fetchWrapper<{ providers?: ProviderAuthMethods } & ProviderAuthMethods>( + `${API_BASE_URL}/api/oauth/auth-methods` + ) + return providers || rest }, } diff --git a/frontend/src/lib/oauthErrors.test.ts b/frontend/src/lib/oauthErrors.test.ts new file mode 100644 index 000000000..70a70e86e --- /dev/null +++ b/frontend/src/lib/oauthErrors.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest' +import { FetchError } from '@opencode-manager/shared' +import { OAUTH_ERROR_CODES } from '@opencode-manager/shared/schemas' +import { mapOAuthError } from './oauthErrors' + +describe('mapOAuthError', () => { + it('maps every upstream error code to a distinct specific message', () => { + const messages = OAUTH_ERROR_CODES.map((code) => + mapOAuthError(new FetchError('OAuth authorization failed', 400, code), 'authorize'), + ) + for (const message of messages) { + expect(message).not.toBe('OAuth authorization failed') + expect(message).not.toBe('Failed to initiate OAuth authorization') + } + expect(new Set(messages).size).toBe(OAUTH_ERROR_CODES.length) + }) + + it('appends upstream detail when present', () => { + const err = new FetchError( + 'OAuth callback failed', + 400, + 'ProviderAuthValidationFailed', + 'must start with sk- — field: apiKey', + ) + expect(mapOAuthError(err, 'callback')).toContain('must start with sk- — field: apiKey') + }) + + it('falls back to the raw message for codes outside the contract', () => { + const err = new FetchError('Upstream unavailable', 502, 'SOMETHING_ELSE') + expect(mapOAuthError(err, 'authorize')).toBe('Upstream unavailable') + }) + + it('falls back to the phase default for non-Error values', () => { + expect(mapOAuthError('nope', 'authorize')).toBe('Failed to initiate OAuth authorization') + expect(mapOAuthError(undefined, 'callback')).toBe('Failed to complete OAuth callback') + }) +}) diff --git a/frontend/src/lib/oauthErrors.ts b/frontend/src/lib/oauthErrors.ts index 368d7098d..5ca793ba8 100644 --- a/frontend/src/lib/oauthErrors.ts +++ b/frontend/src/lib/oauthErrors.ts @@ -1,12 +1,17 @@ -import { OAUTH_ERROR_CATEGORIES, type OAuthErrorCategory } from '@opencode-manager/shared/schemas' +import { FetchError } from '@opencode-manager/shared' +import { OAUTH_ERROR_CODES, type OAuthErrorCode } from '@opencode-manager/shared/schemas' -const ERROR_MAPPINGS: Record = { - 'invalid code': 'Invalid authorization code. Please try the OAuth flow again.', - 'expired': 'Authorization code has expired. Please try the OAuth flow again.', - 'access denied': 'Access was denied. Please check the permissions and try again.', - 'server error': 'Server error occurred. Please try again later.', - 'provider not found': 'Provider is not available or does not support OAuth.', - 'invalid method': 'Invalid authentication method selected.', +const ERROR_MESSAGES: Record = { + BadRequest: 'The provider rejected the request. Please try the OAuth flow again.', + ProviderAuthOauthMissing: 'No authorization is in progress for this provider. Please start the OAuth flow again.', + ProviderAuthOauthCodeMissing: 'An authorization code is required. Please paste the code from the provider.', + ProviderAuthOauthCallbackFailed: 'The provider rejected the authorization. It may have expired — please try again.', + ProviderAuthValidationFailed: 'Some authentication details were invalid. Please check them and try again.', + InvalidRequestError: 'The request was invalid. Please check your details and try again.', +} + +function isOAuthErrorCode(code: string): code is OAuthErrorCode { + return (OAUTH_ERROR_CODES as readonly string[]).includes(code) } export function mapOAuthError(err: unknown, context: 'authorize' | 'callback'): string { @@ -16,11 +21,10 @@ export function mapOAuthError(err: unknown, context: 'authorize' | 'callback'): if (!(err instanceof Error)) return defaultMessage - const lower = err.message.toLowerCase() - for (const category of OAUTH_ERROR_CATEGORIES) { - if (lower.includes(category)) { - return ERROR_MAPPINGS[category] - } + if (err instanceof FetchError && err.code && isOAuthErrorCode(err.code)) { + return err.detail + ? `${ERROR_MESSAGES[err.code]} (${err.detail})` + : ERROR_MESSAGES[err.code] } return err.message || defaultMessage diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63149eabb..18c14f8d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -320,6 +320,9 @@ importers: specifier: ^4.1.12 version: 4.3.2 devDependencies: + '@opencode-ai/sdk': + specifier: 1.18.8 + version: 1.18.8 typescript: specifier: ^5 version: 5.9.3 @@ -1081,6 +1084,9 @@ packages: resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} + '@opencode-ai/sdk@1.18.8': + resolution: {integrity: sha512-8vi5UBKFFgc+fnyKhZhGUxiIWMtUuN00VAInnK9JO6g6EC8WvWefP+ccTBQD023zod69JaEoAzGgO8hdxXHUaw==} + '@opentui/core-darwin-arm64@0.1.107': resolution: {integrity: sha512-Yqt2/9Ntw0IdtPA/qmHvXCE16y4Jq5/btCmuzN9/opzqZ5rYGYYVtiBii3LezGcTZYuJQZthjvh8MLPXXwA2EQ==} cpu: [arm64] @@ -5621,6 +5627,10 @@ snapshots: '@noble/hashes@2.0.1': {} + '@opencode-ai/sdk@1.18.8': + dependencies: + cross-spawn: 7.0.6 + '@opentui/core-darwin-arm64@0.1.107': optional: true diff --git a/shared/package.json b/shared/package.json index fa591a736..d524738d3 100644 --- a/shared/package.json +++ b/shared/package.json @@ -29,6 +29,7 @@ "dotenv": "^17.2.3" }, "devDependencies": { + "@opencode-ai/sdk": "1.18.8", "typescript": "^5" } } diff --git a/shared/src/schemas/auth.ts b/shared/src/schemas/auth.ts index 232791004..9adcbf0d3 100644 --- a/shared/src/schemas/auth.ts +++ b/shared/src/schemas/auth.ts @@ -1,4 +1,8 @@ import { z } from "zod"; +import type { + ProviderOauthAuthorizeErrors, + ProviderOauthCallbackErrors, +} from "@opencode-ai/sdk/v2/types"; export const AuthEntrySchema = z.object({ type: z.enum(["api", "oauth"]), @@ -72,13 +76,58 @@ export const ProviderAuthMethodsResponseSchema = z.object({ providers: z.record(z.string(), z.array(ProviderAuthMethodSchema)), }).or(ProviderAuthMethodsSchema); -export const OAUTH_ERROR_CATEGORIES = [ - "invalid code", - "expired", - "access denied", - "server error", - "provider not found", - "invalid method", +export const PROVIDER_AUTH_ERROR_NAMES = [ + "BadRequest", + "ProviderAuthOauthMissing", + "ProviderAuthOauthCodeMissing", + "ProviderAuthOauthCallbackFailed", + "ProviderAuthValidationFailed", ] as const; -export type OAuthErrorCategory = (typeof OAUTH_ERROR_CATEGORIES)[number]; +export type ProviderAuthErrorName = (typeof PROVIDER_AUTH_ERROR_NAMES)[number]; + +export const ProviderAuthErrorSchema = z.object({ + name: z.enum(PROVIDER_AUTH_ERROR_NAMES), + data: z.object({ + providerID: z.string().optional(), + field: z.string().optional(), + message: z.string().optional(), + kind: z.string().optional(), + }), +}); + +export const InvalidRequestErrorSchema = z.object({ + _tag: z.literal("InvalidRequestError"), + message: z.string(), + kind: z.string().optional(), + field: z.string().optional(), +}); + +export const OpenCodeOAuthErrorSchema = z.union([ + ProviderAuthErrorSchema, + InvalidRequestErrorSchema, +]); + +export type OpenCodeOAuthError = z.infer; + +export const OAUTH_ERROR_CODES = [ + ...PROVIDER_AUTH_ERROR_NAMES, + "InvalidRequestError", +] as const; + +export type OAuthErrorCode = (typeof OAUTH_ERROR_CODES)[number]; + +export function oauthErrorCode(error: OpenCodeOAuthError): OAuthErrorCode { + return "_tag" in error ? error._tag : error.name; +} + +type MutuallyAssignable = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +type Assert = T; + +export type OpenCodeOAuthErrorMatchesAuthorize = Assert< + MutuallyAssignable +>; + +export type OpenCodeOAuthErrorMatchesCallback = Assert< + MutuallyAssignable +>; From f5500bfc6f2ac21c87cf202b7e506c88fffd91e7 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:09:35 -0400 Subject: [PATCH 3/3] fix(settings): persist recovered OpenCode config and accept provider api/npm fields Applying a default config with auto-removed fields wrote the stripped content to disk but left the stored record holding the rejected fields, so the database and the on-disk config disagreed until the next write. Persist the applied content and return 409 when the config disappears mid-recovery. Add the provider-level api, npm, whitelist, and blacklist fields so valid opencode provider configs survive a parse round-trip instead of being stripped. --- backend/src/routes/settings.ts | 21 ++-- backend/test/routes/settings.test.ts | 116 ++++++++++++++++++ .../opencode-config-provider-schema.test.ts | 71 +++++++++++ shared/src/schemas/settings.ts | 4 + 4 files changed, 205 insertions(+), 7 deletions(-) create mode 100644 backend/test/services/opencode-config-provider-schema.test.ts diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 1d529e06e..89a0e5864 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -578,20 +578,27 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic }, 500) } - const contentToWrite = patchResult.removedFields && patchResult.removedFields.length > 0 + const removedFields = patchResult.removedFields ?? [] + const contentToWrite = removedFields.length > 0 ? JSON.stringify(patchResult.appliedConfig ?? config.content, null, 2) : config.rawContent - + await writeFileContent(configPath, contentToWrite) logger.info(`Wrote default config to: ${configPath}`) - - if (patchResult.removedFields && patchResult.removedFields.length > 0) { - logger.info(`Config applied with auto-removed fields: ${patchResult.removedFields.join(', ')}`) - return c.json({ ...config, removedFields: patchResult.removedFields }) + + if (removedFields.length > 0) { + logger.info(`Config applied with auto-removed fields: ${removedFields.join(', ')}`) + const persisted = settingsService.updateOpenCodeConfig(configName, { content: contentToWrite }, userId) + if (!persisted) { + return c.json({ + error: 'OpenCode config was removed while applying recovered fields', + }, 409) + } + return c.json({ ...persisted, removedFields }) } } } - + return c.json(config) } catch (error) { logger.error('Failed to update OpenCode config:', error) diff --git a/backend/test/routes/settings.test.ts b/backend/test/routes/settings.test.ts index 17fb097f4..d0598649e 100644 --- a/backend/test/routes/settings.test.ts +++ b/backend/test/routes/settings.test.ts @@ -425,6 +425,122 @@ describe('Settings Routes - OpenCode Upgrade', () => { ) expect(json.removedFields).toEqual(['command.review']) }) + + it('persists recovery-cleaned content back to the DB after a default-config PUT with removedFields (audit regression)', async () => { + mockGetOpenCodeConfigByName.mockReturnValue({ + id: 2, + name: 'cleaned', + content: {}, + rawContent: '{}', + isValid: true, + isDefault: true, + createdAt: 1, + updatedAt: 1, + }) + const firstConfig = { + id: 2, + name: 'cleaned', + content: { command: { review: true }, theme: 'dark' }, + rawContent: '{"command":{"review":true},"theme":"dark"}', + isValid: true, + isDefault: true, + createdAt: 1, + updatedAt: 2, + } + const persistedConfig = { + ...firstConfig, + content: { theme: 'dark' }, + rawContent: '{\n "theme": "dark"\n}', + updatedAt: 3, + } + mockUpdateOpenCodeConfig + .mockReturnValueOnce(firstConfig) + .mockReturnValueOnce(persistedConfig) + mockPatchConfigWithRecovery.mockResolvedValueOnce({ + success: true, + appliedConfig: { theme: 'dark' }, + removedFields: ['command.review'], + }) + + const req = new Request('http://localhost/opencode-configs/cleaned', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: '{"command":{"review":true},"theme":"dark"}', + isDefault: true, + }), + }) + const res = await settingsApp.fetch(req) + const json = await res.json() as Record + + expect(res.status).toBe(200) + expect(json.removedFields).toEqual(['command.review']) + expect(mockUpdateOpenCodeConfig).toHaveBeenCalledTimes(2) + expect(mockUpdateOpenCodeConfig).toHaveBeenNthCalledWith( + 2, + 'cleaned', + { content: '{\n "theme": "dark"\n}' }, + 'default', + ) + expect(mockWriteFileContent).toHaveBeenCalledWith( + '/tmp/test-workspace/.config/opencode.json', + '{\n "theme": "dark"\n}', + ) + }) + + it('returns 409 instead of 200 when the recovery persistence write reports the config row was removed concurrently (audit regression)', async () => { + mockGetOpenCodeConfigByName.mockReturnValue({ + id: 2, + name: 'cleaned', + content: {}, + rawContent: '{}', + isValid: true, + isDefault: true, + createdAt: 1, + updatedAt: 1, + }) + const firstConfig = { + id: 2, + name: 'cleaned', + content: { command: { review: true }, theme: 'dark' }, + rawContent: '{"command":{"review":true},"theme":"dark"}', + isValid: true, + isDefault: true, + createdAt: 1, + updatedAt: 2, + } + mockUpdateOpenCodeConfig + .mockReturnValueOnce(firstConfig) + .mockReturnValueOnce(null) + mockPatchConfigWithRecovery.mockResolvedValueOnce({ + success: true, + appliedConfig: { theme: 'dark' }, + removedFields: ['command.review'], + }) + + const req = new Request('http://localhost/opencode-configs/cleaned', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: '{"command":{"review":true},"theme":"dark"}', + isDefault: true, + }), + }) + const res = await settingsApp.fetch(req) + const json = await res.json() as Record + + expect(res.status).toBe(409) + expect(json.error).toBe( + 'OpenCode config was removed while applying recovered fields', + ) + expect(mockUpdateOpenCodeConfig).toHaveBeenCalledTimes(2) + expect(mockUpdateOpenCodeConfig).toHaveBeenNthCalledWith( + 2, + 'cleaned', + { content: '{\n "theme": "dark"\n}' }, + 'default', + ) + }) }) describe('OpenCode import routes', () => { diff --git a/backend/test/services/opencode-config-provider-schema.test.ts b/backend/test/services/opencode-config-provider-schema.test.ts new file mode 100644 index 000000000..2d316d56b --- /dev/null +++ b/backend/test/services/opencode-config-provider-schema.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { OpenCodeConfigSchema } from "@opencode-manager/shared/schemas"; + +describe("OpenCodeConfigSchema - provider api/npm round-trip", () => { + it("preserves a provider-level api URL through parse", () => { + const input = { + provider: { + "my-api": { + name: "My API", + api: "https://api.example.com/v1", + options: { baseURL: "https://api.example.com/v1" }, + }, + }, + }; + const parsed = OpenCodeConfigSchema.parse(input); + expect(parsed.provider?.["my-api"]?.api).toBe("https://api.example.com/v1"); + expect(parsed.provider?.["my-api"]?.options?.baseURL).toBe("https://api.example.com/v1"); + }); + + it("preserves a provider-level npm package through parse", () => { + const input = { + provider: { + "my-npm": { + name: "My NPM Provider", + npm: "@scope/opencode-provider", + }, + }, + }; + const parsed = OpenCodeConfigSchema.parse(input); + expect(parsed.provider?.["my-npm"]?.npm).toBe("@scope/opencode-provider"); + }); + + it("preserves whitelist and blacklist model filters through parse", () => { + const input = { + provider: { + openai: { + whitelist: ["gpt-4o", "gpt-4o-mini"], + blacklist: ["gpt-3.5"], + }, + }, + }; + const parsed = OpenCodeConfigSchema.parse(input); + expect(parsed.provider?.openai?.whitelist).toEqual(["gpt-4o", "gpt-4o-mini"]); + expect(parsed.provider?.openai?.blacklist).toEqual(["gpt-3.5"]); + }); + + it("round-trips a full provider-with-models config without losing api or npm", () => { + const input = { + "$schema": "https://opencode.ai/config.json", + provider: { + custom: { + name: "Custom", + api: "https://api.custom.example/v1", + npm: "custom-provider", + models: { + "custom-1": { + id: "custom-1", + name: "Custom 1", + limit: { context: 200000, output: 8192 }, + }, + }, + }, + }, + }; + const parsed = OpenCodeConfigSchema.parse(input); + const roundTripped = OpenCodeConfigSchema.parse(JSON.parse(JSON.stringify(parsed))); + expect(roundTripped.provider?.custom?.api).toBe("https://api.custom.example/v1"); + expect(roundTripped.provider?.custom?.npm).toBe("custom-provider"); + expect(roundTripped.provider?.custom?.models?.["custom-1"]?.id).toBe("custom-1"); + }); +}); diff --git a/shared/src/schemas/settings.ts b/shared/src/schemas/settings.ts index 3a6eea81b..679d685d1 100644 --- a/shared/src/schemas/settings.ts +++ b/shared/src/schemas/settings.ts @@ -301,6 +301,10 @@ export const ProviderConfigSchema = z.object({ source: ProviderSourceSchema.optional(), env: z.array(z.string()).optional().default([]), key: z.string().optional(), + api: z.string().optional(), + npm: z.string().optional(), + whitelist: z.array(z.string()).optional(), + blacklist: z.array(z.string()).optional(), options: z.record(z.string(), z.any()).optional(), models: z.record(z.string(), ModelConfigSchema).optional(), });