diff --git a/apps/server/src/managed-provider-sync.e2e.test.ts b/apps/server/src/managed-provider-sync.e2e.test.ts new file mode 100644 index 0000000000..293dbcd427 --- /dev/null +++ b/apps/server/src/managed-provider-sync.e2e.test.ts @@ -0,0 +1,567 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { startServer } from "./server.js"; +import type { ServerConfig } from "./types.js"; + +type Served = { + port: number; + stop: (closeActiveConnections?: boolean) => void | Promise; +}; + +type ProviderListTestItem = { + id: string; + models?: unknown; +}; + +type ProviderListTestBody = { + all?: ProviderListTestItem[]; + providers?: ProviderListTestItem[] | Record; +}; + +const HOST_TOKEN = "owt_provider_sync_host_token"; +const CLIENT_TOKEN = "owt_provider_sync_client_token"; +const stops: Array<() => void | Promise> = []; +const dirs: string[] = []; + +function hostAuth() { + return { "x-openwork-host-token": HOST_TOKEN, "content-type": "application/json" }; +} + +function clientAuth() { + return { authorization: `Bearer ${CLIENT_TOKEN}` }; +} + +function providerPayload() { + return { + revision: "sync-rev-1", + providers: [ + { + id: "lpr_den_nvidia", + providerId: "nvidia", + name: "NVIDIA", + source: "models_dev", + credentialKind: "api_key", + providerConfig: { id: "nvidia", name: "NVIDIA", env: ["NVIDIA_API_KEY"], npm: "@ai-sdk/openai-compatible" }, + models: [ + { id: "deepseek-ai/deepseek-v4-flash", name: "DeepSeek V4 Flash", config: { id: "deepseek-ai/deepseek-v4-flash", limit: { context: 128000 }, experimental: true } }, + { id: "google/gemma-4-31b-it", name: "Gemma-4-31B-IT", config: { id: "google/gemma-4-31b-it" } }, + ], + apiKey: "plain-server-secret", + revision: "provider-rev-1", + }, + { + id: "llmProvider_den_openai", + providerId: "openai", + name: "OpenAI", + source: "models_dev", + credentialKind: "opencode_oauth", + providerConfig: { id: "openai", name: "OpenAI", env: ["OPENAI_API_KEY"], npm: "@ai-sdk/openai" }, + models: [ + { id: "gpt-5.4", name: "GPT-5.4", config: { id: "gpt-5.4", experimental: { modes: { chat: true } }, knowledge: "2026-01" } }, + { id: "gpt-5.5", name: "GPT-5.5", config: { id: "gpt-5.5" } }, + ], + opencodeAuth: JSON.stringify({ type: "oauth", access: "access-secret", refresh: "refresh-secret", expires: 9 }), + revision: "provider-rev-2", + }, + ], + }; +} + +async function boot(options: { failAuth?: boolean; failAuthPath?: string; failAuthDeletePath?: string; failAuthDeletePathOnce?: string; providerListShape?: "all" | "providers-array" | "providers-object"; providerModelsShape?: "record" | "array"; connected?: string[]; initialAuth?: Record } = {}) { + const workspace = mkdtempSync(join(tmpdir(), "openwork-managed-provider-workspace-")); + const stores = mkdtempSync(join(tmpdir(), "openwork-managed-provider-stores-")); + dirs.push(workspace, stores); + process.env.OPENWORK_TOKEN_STORE = join(stores, "tokens.json"); + + const authCalls: Array<{ method: string; path: string; body: unknown }> = []; + const failedDeletePaths = new Set(); + const authStore = new Map(Object.entries(options.initialAuth ?? {})); + const opencode = Bun.serve({ + port: 0, + async fetch(request) { + const url = new URL(request.url); + if (url.pathname.startsWith("/auth/")) { + const body = request.method === "DELETE" || request.method === "GET" ? null : await request.json(); + authCalls.push({ method: request.method, path: url.pathname, body }); + if (request.method === "GET") { + if (!authStore.has(url.pathname)) return Response.json({ error: "not_found" }, { status: 404 }); + return Response.json(authStore.get(url.pathname)); + } + const shouldFailDeleteOnce = request.method === "DELETE" && url.pathname === options.failAuthDeletePathOnce && !failedDeletePaths.has(url.pathname); + if (shouldFailDeleteOnce) failedDeletePaths.add(url.pathname); + if (options.failAuth || url.pathname === options.failAuthPath || (request.method === "DELETE" && url.pathname === options.failAuthDeletePath) || shouldFailDeleteOnce) { + return Response.json({ error: "bad plain-server-secret access-secret refresh-secret" }, { status: 500 }); + } + if (request.method === "DELETE") authStore.delete(url.pathname); + if (request.method === "PUT") authStore.set(url.pathname, body); + return Response.json({ ok: true }); + } + if (url.pathname === "/config/providers") { + const nvidiaModels = options.providerModelsShape === "array" + ? [ + { id: "deepseek-ai/deepseek-v4-flash", name: "DeepSeek V4 Flash" }, + { id: "google/gemma-4-31b-it", name: "Gemma-4-31B-IT" }, + ] + : { + "deepseek-ai/deepseek-v4-flash": { id: "deepseek-ai/deepseek-v4-flash", name: "DeepSeek V4 Flash" }, + "google/gemma-4-31b-it": { id: "google/gemma-4-31b-it", name: "Gemma-4-31B-IT" }, + }; + const openaiModels = options.providerModelsShape === "array" + ? [ + { id: "gpt-5.4", name: "GPT-5.4" }, + { id: "gpt-5.5", name: "GPT-5.5" }, + { id: "gpt-4o", name: "GPT-4o" }, + { id: "gpt-5.4-fast", name: "GPT-5.4 Fast" }, + { id: "o4-mini", name: "o4-mini" }, + ] + : { + "gpt-5.4": { id: "gpt-5.4", name: "GPT-5.4" }, + "gpt-5.5": { id: "gpt-5.5", name: "GPT-5.5" }, + "gpt-4o": { id: "gpt-4o", name: "GPT-4o" }, + "gpt-5.4-fast": { id: "gpt-5.4-fast", name: "GPT-5.4 Fast" }, + "o4-mini": { id: "o4-mini", name: "o4-mini" }, + }; + const providers = [ + { + id: "lpr_den_nvidia", + name: "NVIDIA", + source: "custom", + models: nvidiaModels, + }, + { + id: "openai", + name: "OpenAI", + source: "config", + models: openaiModels, + }, + ]; + if (options.providerListShape === "providers-array") { + return Response.json({ + default: "openai", + providers, + }); + } + if (options.providerListShape === "providers-object") { + return Response.json({ + default: "openai", + providers: Object.fromEntries(providers.map((provider) => [provider.id, provider])), + }); + } + return Response.json({ + all: providers, + connected: options.connected ?? ["lpr_den_nvidia", "openai"], + default: { "lpr_den_nvidia": "deepseek-ai/deepseek-v4-flash", openai: "gpt-5.4" }, + }); + } + return Response.json({ ok: true }); + }, + }); + stops.push(() => opencode.stop(true)); + + const config: ServerConfig = { + host: "127.0.0.1", + port: 0, + token: CLIENT_TOKEN, + hostToken: HOST_TOKEN, + approval: { mode: "auto", timeoutMs: 1000 }, + corsOrigins: ["*"], + workspaces: [{ id: "ws_1", name: "Workspace", path: workspace, workspaceType: "local", preset: "starter", baseUrl: `http://127.0.0.1:${opencode.port}` }], + authorizedRoots: [workspace], + readOnly: false, + startedAt: Date.now(), + tokenSource: "cli", + hostTokenSource: "cli", + logFormat: "pretty", + logRequests: false, + }; + const server = await startServer(config) as Served; + stops.push(() => server.stop(true)); + return { base: `http://127.0.0.1:${server.port}`, workspace, authCalls, authStore }; +} + +function readManagedProviderMetadata(workspace: string) { + const openworkConfig = JSON.parse(readFileSync(join(workspace, ".opencode", "openwork.json"), "utf8")) as { + managedProviders?: { applied?: string[]; revoked?: string[]; revision?: string }; + }; + return openworkConfig.managedProviders ?? {}; +} + +beforeEach(() => { + delete process.env.OPENWORK_TOKEN_STORE; +}); + +afterEach(async () => { + while (stops.length) await stops.pop()?.(); + while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true }); + delete process.env.OPENWORK_TOKEN_STORE; +}); + +describe("managed provider sync runtime route", () => { + test("requires host token and rejects client bearer tokens", async () => { + const { base } = await boot(); + const unauthenticated = await fetch(`${base}/managed-providers/sync`, { method: "POST", body: JSON.stringify(providerPayload()) }); + expect(unauthenticated.status).toBe(401); + + const issued = await fetch(`${base}/tokens`, { method: "POST", headers: hostAuth(), body: JSON.stringify({ scope: "owner" }) }); + const body = (await issued.json()) as { token: string }; + const ownerBearer = await fetch(`${base}/managed-providers/sync`, { + method: "POST", + headers: { authorization: `Bearer ${body.token}`, "content-type": "application/json" }, + body: JSON.stringify(providerPayload()), + }); + expect(ownerBearer.status).toBe(401); + }); + + test("applies API key and OAuth providers idempotently without response leakage", async () => { + const { base, workspace, authCalls } = await boot(); + for (let index = 0; index < 2; index += 1) { + const response = await fetch(`${base}/managed-providers/sync`, { + method: "POST", + headers: hostAuth(), + body: JSON.stringify(providerPayload()), + }); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toEqual({ status: "applied", providerCount: 2, revision: "sync-rev-1" }); + expect(JSON.stringify(body)).not.toContain("plain-server-secret"); + expect(JSON.stringify(body)).not.toContain("refresh-secret"); + } + + const config = readFileSync(join(workspace, "opencode.jsonc"), "utf8"); + expect(config.match(/lpr_den_nvidia/g)?.length).toBe(1); + expect(config.match(/"openai"/g)?.length).toBeGreaterThanOrEqual(1); + expect(config).toContain("gpt-5.4"); + expect(config).toContain("gpt-5.5"); + expect(config).toContain("deepseek-ai/deepseek-v4-flash"); + expect(config).toContain("google/gemma-4-31b-it"); + expect(config).not.toContain("gpt-4o"); + expect(config).not.toContain("gpt-5.4-fast"); + expect(config).not.toContain("o4-mini"); + expect(config).toContain('"experimental": true'); + expect(config).not.toContain('"modes"'); + expect(config).not.toContain('"knowledge"'); + expect(config).not.toContain("plain-server-secret"); + const putCalls = authCalls.filter((call) => call.method === "PUT"); + expect(authCalls.filter((call) => call.method === "GET")).toHaveLength(4); + expect(putCalls).toHaveLength(4); + expect(putCalls[0]?.path).toBe("/auth/lpr_den_nvidia"); + expect(putCalls[0]?.body).toEqual({ type: "api", key: "plain-server-secret" }); + expect(putCalls[1]?.path).toBe("/auth/openai"); + expect(putCalls[1]?.body).toEqual({ type: "oauth", access: "access-secret", refresh: "refresh-secret", expires: 9 }); + }); + + test("filters managed OAuth provider-list models to Den-selected config models", async () => { + const { base } = await boot(); + const sync = await fetch(`${base}/managed-providers/sync`, { + method: "POST", + headers: hostAuth(), + body: JSON.stringify(providerPayload()), + }); + expect(sync.status).toBe(200); + + const response = await fetch(`${base}/workspace/ws_1/opencode/config/providers`, { headers: clientAuth() }); + expect(response.status).toBe(200); + const body = await response.json() as ProviderListTestBody; + const providers = Array.isArray(body.all) ? body.all : []; + const openai = providers.find((provider) => provider?.id === "openai"); + const nvidia = providers.find((provider) => provider?.id === "lpr_den_nvidia"); + + expect(Object.keys(openai?.models ?? {}).sort()).toEqual(["gpt-5.4", "gpt-5.5"]); + expect(Object.keys(openai?.models ?? {})).not.toContain("gpt-4o"); + expect(Object.keys(openai?.models ?? {})).not.toContain("gpt-5.4-fast"); + expect(Object.keys(openai?.models ?? {})).not.toContain("o4-mini"); + expect(Object.keys(nvidia?.models ?? {}).sort()).toEqual(["deepseek-ai/deepseek-v4-flash", "google/gemma-4-31b-it"]); + expect(JSON.stringify(body)).not.toContain("plain-server-secret"); + expect(JSON.stringify(body)).not.toContain("refresh-secret"); + }); + + test("keeps Den-managed config providers visible when OpenCode connected list is empty", async () => { + const { base } = await boot({ connected: [] }); + const sync = await fetch(`${base}/managed-providers/sync`, { + method: "POST", + headers: hostAuth(), + body: JSON.stringify(providerPayload()), + }); + expect(sync.status).toBe(200); + + const response = await fetch(`${base}/workspace/ws_1/opencode/config/providers`, { headers: clientAuth() }); + expect(response.status).toBe(200); + const body = await response.json() as ProviderListTestBody & { connected?: string[] }; + + expect(body.connected?.sort()).toEqual(["lpr_den_nvidia", "openai"]); + }); + + test("filters managed OAuth provider-list models for live providers-array responses", async () => { + const { base } = await boot({ providerListShape: "providers-array" }); + const sync = await fetch(`${base}/managed-providers/sync`, { + method: "POST", + headers: hostAuth(), + body: JSON.stringify(providerPayload()), + }); + expect(sync.status).toBe(200); + + const response = await fetch(`${base}/workspace/ws_1/opencode/config/providers`, { headers: clientAuth() }); + expect(response.status).toBe(200); + const body = await response.json() as ProviderListTestBody; + const providers = Array.isArray(body.providers) ? body.providers : []; + const openai = providers.find((provider) => provider?.id === "openai"); + const nvidia = providers.find((provider) => provider?.id === "lpr_den_nvidia"); + + expect(Object.keys(openai?.models ?? {}).sort()).toEqual(["gpt-5.4", "gpt-5.5"]); + expect(Object.keys(openai?.models ?? {})).not.toContain("gpt-4o"); + expect(Object.keys(openai?.models ?? {})).not.toContain("gpt-5.4-fast"); + expect(Object.keys(openai?.models ?? {})).not.toContain("o4-mini"); + expect(Object.keys(nvidia?.models ?? {}).sort()).toEqual(["deepseek-ai/deepseek-v4-flash", "google/gemma-4-31b-it"]); + expect(JSON.stringify(body)).not.toContain("plain-server-secret"); + expect(JSON.stringify(body)).not.toContain("refresh-secret"); + }); + + test("filters managed OAuth provider-list models for providers-object responses", async () => { + const { base } = await boot({ providerListShape: "providers-object" }); + const sync = await fetch(`${base}/managed-providers/sync`, { + method: "POST", + headers: hostAuth(), + body: JSON.stringify(providerPayload()), + }); + expect(sync.status).toBe(200); + + const response = await fetch(`${base}/workspace/ws_1/opencode/config/providers`, { headers: clientAuth() }); + expect(response.status).toBe(200); + const body = await response.json() as ProviderListTestBody; + const providers = !Array.isArray(body.providers) && body.providers ? body.providers : {}; + const openai = providers.openai; + const nvidia = providers.lpr_den_nvidia; + + expect(Object.keys(openai?.models ?? {}).sort()).toEqual(["gpt-5.4", "gpt-5.5"]); + expect(Object.keys(openai?.models ?? {})).not.toContain("gpt-4o"); + expect(Object.keys(openai?.models ?? {})).not.toContain("gpt-5.4-fast"); + expect(Object.keys(openai?.models ?? {})).not.toContain("o4-mini"); + expect(Object.keys(nvidia?.models ?? {}).sort()).toEqual(["deepseek-ai/deepseek-v4-flash", "google/gemma-4-31b-it"]); + expect(JSON.stringify(body)).not.toContain("plain-server-secret"); + expect(JSON.stringify(body)).not.toContain("refresh-secret"); + }); + + test("sanitizes OpenCode auth apply failures", async () => { + const { base, workspace } = await boot({ failAuth: true }); + const response = await fetch(`${base}/managed-providers/sync`, { + method: "POST", + headers: hostAuth(), + body: JSON.stringify(providerPayload()), + }); + expect(response.status).toBe(502); + const body = await response.json(); + expect(body.status).toBe("failed"); + expect(JSON.stringify(body)).not.toContain("plain-server-secret"); + expect(JSON.stringify(body)).not.toContain("access-secret"); + expect(JSON.stringify(body)).not.toContain("refresh-secret"); + expect(body.reason).toBe("Managed provider sync failed"); + const configPath = join(workspace, "opencode.jsonc"); + expect(existsSync(configPath) ? readFileSync(configPath, "utf8") : "").not.toContain("lpr_den_nvidia"); + }); + + test("authoritatively removes revoked managed providers from config, auth, and provider lists", async () => { + const { base, workspace, authCalls } = await boot(); + const fullPayload = providerPayload(); + const initial = await fetch(`${base}/managed-providers/sync`, { + method: "POST", + headers: hostAuth(), + body: JSON.stringify(fullPayload), + }); + expect(initial.status).toBe(200); + + const nvidiaOnlyPayload = { revision: "sync-rev-2", providers: [fullPayload.providers[0]] }; + const update = await fetch(`${base}/managed-providers/sync`, { + method: "POST", + headers: hostAuth(), + body: JSON.stringify(nvidiaOnlyPayload), + }); + expect(update.status).toBe(200); + expect(await update.json()).toEqual({ status: "applied", providerCount: 1, revision: "sync-rev-2" }); + + const config = readFileSync(join(workspace, "opencode.jsonc"), "utf8"); + expect(config).toContain("lpr_den_nvidia"); + expect(config).not.toContain('"openai"'); + expect(config).not.toContain("gpt-5.4"); + + const response = await fetch(`${base}/workspace/ws_1/opencode/config/providers`, { headers: clientAuth() }); + expect(response.status).toBe(200); + const body = await response.json() as ProviderListTestBody & { connected?: string[] }; + const providers = Array.isArray(body.all) ? body.all : []; + expect(providers.some((provider) => provider.id === "openai")).toBe(false); + expect(body.connected ?? []).not.toContain("openai"); + + expect(authCalls.some((call) => call.method === "DELETE" && call.path === "/auth/openai")).toBe(true); + }); + + test("empty sync removes all managed providers", async () => { + const { base, workspace } = await boot(); + const initial = await fetch(`${base}/managed-providers/sync`, { + method: "POST", + headers: hostAuth(), + body: JSON.stringify(providerPayload()), + }); + expect(initial.status).toBe(200); + + const empty = await fetch(`${base}/managed-providers/sync`, { + method: "POST", + headers: hostAuth(), + body: JSON.stringify({ revision: "sync-empty", providers: [] }), + }); + expect(empty.status).toBe(200); + expect(await empty.json()).toEqual({ status: "applied", providerCount: 0, revision: "sync-empty" }); + + const config = readFileSync(join(workspace, "opencode.jsonc"), "utf8"); + expect(config).not.toContain("lpr_den_nvidia"); + expect(config).not.toContain('"openai"'); + }); + + test("failure on a later provider removes auth written earlier in the same attempt", async () => { + const { base, workspace, authCalls } = await boot({ failAuthPath: "/auth/openai" }); + const response = await fetch(`${base}/managed-providers/sync`, { + method: "POST", + headers: hostAuth(), + body: JSON.stringify(providerPayload()), + }); + expect(response.status).toBe(502); + const configPath = join(workspace, "opencode.jsonc"); + expect(existsSync(configPath) ? readFileSync(configPath, "utf8") : "").not.toContain("lpr_den_nvidia"); + expect(authCalls.some((call) => call.method === "PUT" && call.path === "/auth/lpr_den_nvidia")).toBe(true); + expect(authCalls.some((call) => call.method === "DELETE" && call.path === "/auth/lpr_den_nvidia")).toBe(true); + }); + + test("filters array-shaped provider-list models by Den-managed allowlist", async () => { + const { base } = await boot({ providerListShape: "providers-array", providerModelsShape: "array" }); + const sync = await fetch(`${base}/managed-providers/sync`, { + method: "POST", + headers: hostAuth(), + body: JSON.stringify(providerPayload()), + }); + expect(sync.status).toBe(200); + + const response = await fetch(`${base}/workspace/ws_1/opencode/config/providers`, { headers: clientAuth() }); + expect(response.status).toBe(200); + const body = await response.json() as ProviderListTestBody; + const providers = Array.isArray(body.providers) ? body.providers : []; + const openai = providers.find((provider) => provider?.id === "openai"); + const nvidia = providers.find((provider) => provider?.id === "lpr_den_nvidia"); + + expect(Array.isArray(openai?.models)).toBe(true); + expect(Array.isArray(nvidia?.models)).toBe(true); + expect((openai?.models as Array<{ id: string }>).map((model) => model.id)).toEqual(["gpt-5.4", "gpt-5.5"]); + expect((openai?.models as Array<{ id: string }>).map((model) => model.id)).not.toContain("gpt-4o"); + expect((openai?.models as Array<{ id: string }>).map((model) => model.id)).not.toContain("gpt-5.4-fast"); + expect((openai?.models as Array<{ id: string }>).map((model) => model.id)).not.toContain("o4-mini"); + expect((nvidia?.models as Array<{ id: string }>).map((model) => model.id)).toEqual(["deepseek-ai/deepseek-v4-flash", "google/gemma-4-31b-it"]); + }); + + test("failure on a later provider restores previous working auth written earlier in the same attempt", async () => { + const previousAuth = { type: "api", key: "previous-working-key" }; + const { base, authCalls, authStore } = await boot({ + failAuthPath: "/auth/openai", + initialAuth: { "/auth/lpr_den_nvidia": previousAuth }, + }); + const response = await fetch(`${base}/managed-providers/sync`, { + method: "POST", + headers: hostAuth(), + body: JSON.stringify(providerPayload()), + }); + expect(response.status).toBe(502); + expect(authStore.get("/auth/lpr_den_nvidia")).toEqual(previousAuth); + expect(authCalls.some((call) => call.method === "GET" && call.path === "/auth/lpr_den_nvidia")).toBe(true); + expect(authCalls.filter((call) => call.method === "PUT" && call.path === "/auth/lpr_den_nvidia")).toHaveLength(2); + expect(authCalls.some((call) => call.method === "DELETE" && call.path === "/auth/lpr_den_nvidia")).toBe(false); + }); + + test("rejects duplicate runtime provider ids before config or auth mutation", async () => { + const previousAuth = { type: "api", key: "previous-working-key" }; + const { base, workspace, authCalls, authStore } = await boot({ + initialAuth: { "/auth/lpr_den_nvidia": previousAuth }, + }); + const payload = providerPayload(); + const duplicateProvider = { ...payload.providers[0] }; + duplicateProvider.name = "Duplicate NVIDIA"; + duplicateProvider.revision = "provider-rev-duplicate"; + duplicateProvider.apiKey = "new-secret-that-must-not-be-written"; + payload.providers = [payload.providers[0], duplicateProvider]; + + const response = await fetch(`${base}/managed-providers/sync`, { + method: "POST", + headers: hostAuth(), + body: JSON.stringify(payload), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ code: "duplicate_provider_runtime_id" }); + expect(authCalls).toHaveLength(0); + expect(authStore.get("/auth/lpr_den_nvidia")).toEqual(previousAuth); + const configPath = join(workspace, "opencode.jsonc"); + expect(existsSync(configPath) ? readFileSync(configPath, "utf8") : "").not.toContain("new-secret-that-must-not-be-written"); + }); + + test("stale auth deletion failure does not restore config that references stale providers", async () => { + const { base, workspace, authCalls } = await boot({ failAuthDeletePath: "/auth/openai" }); + const fullPayload = providerPayload(); + const initial = await fetch(`${base}/managed-providers/sync`, { + method: "POST", + headers: hostAuth(), + body: JSON.stringify(fullPayload), + }); + expect(initial.status).toBe(200); + + const nvidiaOnlyPayload = { revision: "sync-rev-2", providers: [fullPayload.providers[0]] }; + const update = await fetch(`${base}/managed-providers/sync`, { + method: "POST", + headers: hostAuth(), + body: JSON.stringify(nvidiaOnlyPayload), + }); + + expect(update.status).toBe(502); + const body = await update.json(); + expect(body).toMatchObject({ status: "failed", providerCount: 1, revision: "sync-rev-2" }); + const config = readFileSync(join(workspace, "opencode.jsonc"), "utf8"); + expect(config).toContain("lpr_den_nvidia"); + expect(config).not.toContain('"openai"'); + expect(config).not.toContain("gpt-5.4"); + const metadata = readManagedProviderMetadata(workspace); + expect(metadata.applied?.sort()).toEqual(["lpr_den_nvidia", "openai"]); + expect(metadata.revoked).toContain("openai"); + expect(authCalls.some((call) => call.method === "DELETE" && call.path === "/auth/openai")).toBe(true); + }); + + test("retries stale auth deletion after a previous deletion failure", async () => { + const { base, workspace, authCalls } = await boot({ failAuthDeletePathOnce: "/auth/openai" }); + const fullPayload = providerPayload(); + const initial = await fetch(`${base}/managed-providers/sync`, { + method: "POST", + headers: hostAuth(), + body: JSON.stringify(fullPayload), + }); + expect(initial.status).toBe(200); + + const nvidiaOnlyPayload = { revision: "sync-rev-2", providers: [fullPayload.providers[0]] }; + const firstUpdate = await fetch(`${base}/managed-providers/sync`, { + method: "POST", + headers: hostAuth(), + body: JSON.stringify(nvidiaOnlyPayload), + }); + expect(firstUpdate.status).toBe(502); + expect(readManagedProviderMetadata(workspace).applied?.sort()).toEqual(["lpr_den_nvidia", "openai"]); + + const retry = await fetch(`${base}/managed-providers/sync`, { + method: "POST", + headers: hostAuth(), + body: JSON.stringify(nvidiaOnlyPayload), + }); + expect(retry.status).toBe(200); + expect(await retry.json()).toEqual({ status: "applied", providerCount: 1, revision: "sync-rev-2" }); + + const deleteAttempts = authCalls.filter((call) => call.method === "DELETE" && call.path === "/auth/openai"); + expect(deleteAttempts).toHaveLength(2); + const metadata = readManagedProviderMetadata(workspace); + expect(metadata.applied).toEqual(["lpr_den_nvidia"]); + expect(metadata.revoked).toContain("openai"); + }); +}); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index dd636d921c..8166e2e16a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -12,7 +12,7 @@ import { deleteSkill, listSkills, upsertSkill } from "./skills.js"; import { installHubSkill, listHubSkills } from "./skill-hub.js"; import { deleteCommand, listCommands, repairCommands, upsertCommand } from "./commands.js"; import { ApiError, formatError } from "./errors.js"; -import { readJsoncFile, updateJsoncTopLevel, writeJsoncFile } from "./jsonc.js"; +import { readJsoncFile, updateJsoncPath, updateJsoncTopLevel, writeJsoncFile } from "./jsonc.js"; import { recordAudit, readAuditEntries, readLastAudit } from "./audit.js"; import { ReloadEventStore } from "./events.js"; import { computeReloadFingerprint } from "./reload-fingerprint.js"; @@ -1004,9 +1004,172 @@ async function proxyOpencodeRequest(input: { body, }); + if (workspace && isProviderListProxyRequest(method, proxyPath)) { + return filterManagedProviderListResponse(workspace.path, response); + } + return sanitizeProxyResponse(response); } +function isProviderListProxyRequest(method: string, proxyPath: string) { + return method === "GET" && normalizeOpencodeProxyPath(proxyPath) === "/config/providers"; +} + +type ManagedProviderAccessPolicy = { + allowedModelsByProvider: Map>; + revokedProviderIds: Set; +}; + +async function filterManagedProviderListResponse(workspaceRoot: string, response: Response): Promise { + if (!response.ok) return sanitizeProxyResponse(response); + + const text = await response.text(); + let data: unknown; + try { + data = JSON.parse(text) as unknown; + } catch { + return proxyTextResponse(response, text); + } + + let policy: ManagedProviderAccessPolicy; + try { + policy = await readManagedProviderAccessPolicy(workspaceRoot); + } catch { + return proxyJsonResponse(response, data); + } + if (policy.allowedModelsByProvider.size === 0 && policy.revokedProviderIds.size === 0) return proxyJsonResponse(response, data); + + return proxyJsonResponse(response, filterProviderListModels(data, policy)); +} + +function readStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) : []; +} + +async function readManagedProviderAccessPolicy(workspaceRoot: string): Promise { + const openwork = await readOpenworkConfig(workspaceRoot); + const managedProviders = openwork.managedProviders; + if (!isRecordValue(managedProviders) || managedProviders.source !== "den") { + return { allowedModelsByProvider: new Map(), revokedProviderIds: new Set() }; + } + + const opencode = await readOpencodeConfig(workspaceRoot); + const providers = opencode.provider; + const revokedProviderIds = new Set(readStringArray(managedProviders.revoked)); + if (!isRecordValue(providers)) return { allowedModelsByProvider: new Map(), revokedProviderIds }; + + const allowlist = new Map>(); + for (const [providerId, providerConfig] of Object.entries(providers)) { + if (!isRecordValue(providerConfig) || !isRecordValue(providerConfig.models)) continue; + const modelIds = Object.keys(providerConfig.models); + if (modelIds.length > 0) allowlist.set(providerId, new Set(modelIds)); + } + return { allowedModelsByProvider: allowlist, revokedProviderIds }; +} + +function filterProviderListModels(data: unknown, policy: ManagedProviderAccessPolicy): unknown { + if (!isRecordValue(data)) return data; + const managedProviderIds = new Set(policy.allowedModelsByProvider.keys()); + if (Array.isArray(data.all)) { + const all = data.all + .filter((provider) => !isRevokedProviderListItem(provider, policy.revokedProviderIds)) + .map((provider) => filterProviderListItem(provider, policy.allowedModelsByProvider)); + return { + ...data, + all, + connected: mergeManagedConnectedProviderIds(data.connected, all, managedProviderIds, policy.revokedProviderIds), + }; + } + if (Array.isArray(data.providers)) { + const providers = data.providers + .filter((provider) => !isRevokedProviderListItem(provider, policy.revokedProviderIds)) + .map((provider) => filterProviderListItem(provider, policy.allowedModelsByProvider)); + return { + ...data, + providers, + connected: mergeManagedConnectedProviderIds(data.connected, providers, managedProviderIds, policy.revokedProviderIds), + }; + } + if (isRecordValue(data.providers)) { + const providers = Object.fromEntries( + Object.entries(data.providers) + .filter(([providerId, provider]) => !policy.revokedProviderIds.has(providerId) && !isRevokedProviderListItem(provider, policy.revokedProviderIds)) + .map(([providerId, provider]) => [providerId, filterProviderListItem(provider, policy.allowedModelsByProvider, providerId)]), + ); + return { + ...data, + providers, + connected: mergeManagedConnectedProviderIds(data.connected, Object.keys(providers), managedProviderIds, policy.revokedProviderIds), + }; + } + return data; +} + +function isRevokedProviderListItem(provider: unknown, revokedProviderIds: Set) { + return isRecordValue(provider) && typeof provider.id === "string" && revokedProviderIds.has(provider.id); +} + +function mergeManagedConnectedProviderIds(connected: unknown, providers: unknown[], managedProviderIds: Set, revokedProviderIds: Set): string[] | undefined { + if (!Array.isArray(connected)) return undefined; + const providerIds = new Set( + providers.map((provider) => { + if (typeof provider === "string") return provider; + if (isRecordValue(provider) && typeof provider.id === "string") return provider.id; + return ""; + }).filter(Boolean), + ); + const next = new Set(connected.filter((id): id is string => typeof id === "string" && !revokedProviderIds.has(id))); + for (const providerId of managedProviderIds) { + if (providerIds.has(providerId)) next.add(providerId); + } + return [...next]; +} + +function filterProviderListItem(provider: unknown, allowedModelsByProvider: Map>, fallbackProviderId?: string): unknown { + if (!isRecordValue(provider)) return provider; + const providerId = typeof provider.id === "string" ? provider.id : fallbackProviderId; + if (!providerId) return provider; + const allowed = allowedModelsByProvider.get(providerId); + if (!allowed) return provider; + if (Array.isArray(provider.models)) { + return { + ...provider, + models: provider.models.filter((model) => isRecordValue(model) && typeof model.id === "string" && allowed.has(model.id)), + }; + } + if (!isRecordValue(provider.models)) return provider; + return { + ...provider, + models: Object.fromEntries(Object.entries(provider.models).filter(([modelId]) => allowed.has(modelId))), + }; +} + +function proxyJsonResponse(upstream: Response, data: unknown): Response { + const headers = sanitizedProxyHeaders(upstream.headers); + headers.set("Content-Type", "application/json"); + return new Response(JSON.stringify(data), { + status: upstream.status, + statusText: upstream.statusText, + headers, + }); +} + +function proxyTextResponse(upstream: Response, text: string): Response { + return new Response(text, { + status: upstream.status, + statusText: upstream.statusText, + headers: sanitizedProxyHeaders(upstream.headers), + }); +} + +function sanitizedProxyHeaders(input: Headers): Headers { + const headers = new Headers(input); + headers.delete("content-encoding"); + headers.delete("transfer-encoding"); + headers.delete("content-length"); + return headers; +} + /** * Strip hop-by-hop and transport-level headers that Bun's native fetch keeps * in the upstream response even after it has already decoded the body for us. @@ -1015,14 +1178,10 @@ async function proxyOpencodeRequest(input: { * code that reaches through /opencode/* (including session.create). */ function sanitizeProxyResponse(response: Response): Response { - const headers = new Headers(response.headers); - headers.delete("content-encoding"); - headers.delete("transfer-encoding"); - headers.delete("content-length"); return new Response(response.body, { status: response.status, statusText: response.statusText, - headers, + headers: sanitizedProxyHeaders(response.headers), }); } @@ -1055,7 +1214,51 @@ function withCors(response: Response, request: Request, config: ServerConfig) { return new Response(response.body, { status: response.status, headers }); } +async function fetchOpencodeJson( + config: ServerConfig, + workspace: WorkspaceInfo, + path: string, + init: { method?: string; body?: unknown } = {}, +): Promise { + const connection = resolveWorkspaceOpencodeConnection(config, workspace); + const baseUrl = connection.baseUrl?.trim(); + if (!baseUrl) { + throw new ApiError(502, "opencode_unavailable", "OpenCode base URL is not configured"); + } + + const target = new URL(path, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`); + const headers = new Headers({ "Content-Type": "application/json" }); + const directory = resolveOpencodeDirectory(workspace); + if (directory) { + headers.set("X-OpenCode-Directory", directory); + headers.set("X-Opencode-Directory", directory); + } + if (connection.authHeader) { + headers.set("Authorization", connection.authHeader); + } + + const response = await fetch(target, { + method: init.method ?? "GET", + headers, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + }); + const text = await response.text(); + const json = text ? parseOpencodeErrorBody(text) : null; + if (!response.ok) { + throw new ApiError(502, "opencode_request_failed", "OpenCode request failed", { + status: response.status, + body: json, + path, + }); + } + return json; +} + async function requireClient(request: Request, config: ServerConfig, tokens: TokenService): Promise { + const hostToken = request.headers.get("x-openwork-host-token"); + if (hostToken && hostToken === config.hostToken) { + return { type: "host", tokenHash: hashToken(hostToken), scope: "owner" }; + } const header = request.headers.get("authorization") ?? ""; const match = header.match(/^Bearer\s+(.+)$/i); const token = match?.[1]; @@ -1811,6 +2014,21 @@ function serializeWorkspace(workspace: ServerConfig["workspaces"][number]) { }; } +function serializeClientWorkspace(workspace: ServerConfig["workspaces"][number]) { + const { openworkToken, opencodeUsername, opencodePassword, ...rest } = workspace; + const opencodeDirectory = resolveOpencodeDirectory(workspace); + const opencode = workspace.baseUrl || opencodeDirectory + ? { + baseUrl: workspace.baseUrl, + directory: opencodeDirectory ?? undefined, + } + : undefined; + return { + ...rest, + opencode, + }; +} + function createRoutes( config: ServerConfig, approvals: ApprovalService, @@ -1962,7 +2180,7 @@ function createRoutes( corsOrigins: config.corsOrigins, workspaceCount: 1, activeWorkspaceId: workspace.id, - workspace: serializeWorkspace(workspace), + workspace: serializeClientWorkspace(workspace), authorizedRoots: config.authorizedRoots, server: { host: config.host, @@ -1982,7 +2200,7 @@ function createRoutes( addRoute(routes, "GET", "/w/:id/workspaces", "client", async (ctx) => { const workspace = await resolveWorkspace(config, ctx.params.id); - return jsonResponse({ items: [serializeWorkspace(workspace)], activeId: workspace.id }); + return jsonResponse({ items: [serializeClientWorkspace(workspace)], activeId: workspace.id }); }); addRoute(routes, "GET", "/status", "client", async () => { @@ -1997,7 +2215,7 @@ function createRoutes( corsOrigins: config.corsOrigins, workspaceCount: config.workspaces.length, activeWorkspaceId: active?.id ?? null, - workspace: active ? serializeWorkspace(active) : null, + workspace: active ? serializeClientWorkspace(active) : null, authorizedRoots: config.authorizedRoots, server: { host: config.host, @@ -2258,6 +2476,111 @@ function createRoutes( return jsonResponse({ ok: true }); }); + addRoute(routes, "POST", "/managed-providers/sync", "host-token", async (ctx) => { + ensureWritable(config); + const body = await readJsonBody(ctx.request); + const payload = parseManagedProviderSyncPayload(body); + const workspace = config.workspaces[0]; + if (!workspace) { + throw new ApiError(409, "workspace_unavailable", "No worker workspace is available for managed provider sync"); + } + + const configFingerprintBefore = await computeReloadFingerprint(workspace.path, "config"); + const opencodeConfigFile = opencodeConfigPath(workspace.path); + const opencodeConfigBefore = existsSync(opencodeConfigFile) + ? await readFile(opencodeConfigFile, "utf8") + : null; + const previousManagedProviderIds = await readAppliedManagedProviderRuntimeIds(workspace.path); + const previousRevokedProviderIds = await readRevokedManagedProviderRuntimeIds(workspace.path); + const currentManagedProviderIds = new Set(payload.providers.map((provider) => getManagedProviderRuntimeId(provider))); + const staleManagedProviderIds = [...previousManagedProviderIds].filter((providerId) => !currentManagedProviderIds.has(providerId)); + const revokedManagedProviderIds = new Set([...previousRevokedProviderIds, ...staleManagedProviderIds]); + for (const providerId of currentManagedProviderIds) revokedManagedProviderIds.delete(providerId); + + const applied: string[] = []; + const previousAuthByProviderId = new Map(); + try { + await applyManagedProviderConfigSet(workspace.path, payload.providers, previousManagedProviderIds); + for (const provider of payload.providers) { + const providerId = getManagedProviderRuntimeId(provider); + previousAuthByProviderId.set(providerId, await readManagedProviderAuth(config, workspace, providerId)); + await applyManagedProviderAuth(config, workspace, provider); + applied.push(providerId); + } + + await writeOpenworkConfig(workspace.path, { + managedProviders: { + source: "den", + revision: payload.revision, + applied, + appliedAt: new Date().toISOString(), + }, + }, true); + } catch (error) { + if (opencodeConfigBefore === null) { + await rm(opencodeConfigFile, { force: true }); + } else { + await writeFile(opencodeConfigFile, opencodeConfigBefore, "utf8"); + } + await rollbackAppliedManagedProviderAuth(config, workspace, previousAuthByProviderId); + return jsonResponse({ + status: "failed", + providerCount: applied.length, + revision: payload.revision, + reason: sanitizeManagedProviderApplyError(error), + }, 502); + } + + const writeManagedProviderSyncMetadata = async (nextApplied: string[]) => writeOpenworkConfig(workspace.path, { + managedProviders: { + source: "den", + revision: payload.revision, + applied: nextApplied, + revoked: [...revokedManagedProviderIds], + appliedAt: new Date().toISOString(), + }, + }, true); + + const pendingCleanupApplied = [...new Set([...applied, ...staleManagedProviderIds])]; + await writeManagedProviderSyncMetadata(pendingCleanupApplied); + + try { + for (const providerId of staleManagedProviderIds) { + await deleteManagedProviderAuth(config, workspace, providerId); + } + } catch (error) { + if (configFingerprintBefore !== await computeReloadFingerprint(workspace.path, "config")) { + emitReloadEvent(ctx.reloadEvents, workspace, "config", buildConfigTrigger(opencodeConfigPath(workspace.path))); + } + return jsonResponse({ + status: "failed", + providerCount: applied.length, + revision: payload.revision, + reason: sanitizeManagedProviderApplyError(error), + }, 502); + } + + if (staleManagedProviderIds.length > 0) { + await writeManagedProviderSyncMetadata(applied); + } + + await recordAudit(workspace.path, { + id: shortId(), + workspaceId: workspace.id, + actor: ctx.actor ?? { type: "host" }, + action: "managedProviders.sync", + target: "opencode.json", + summary: `Synced ${applied.length} managed provider${applied.length === 1 ? "" : "s"}`, + timestamp: Date.now(), + }); + + if (configFingerprintBefore !== await computeReloadFingerprint(workspace.path, "config")) { + emitReloadEvent(ctx.reloadEvents, workspace, "config", buildConfigTrigger(opencodeConfigPath(workspace.path))); + } + + return jsonResponse({ status: "applied", providerCount: applied.length, revision: payload.revision }); + }); + addRoute(routes, "POST", "/voice/realtime/session", "host", async (ctx) => { const body = await readJsonBody(ctx.request); return jsonResponse(await createOpenAiRealtimeVoiceSession(env, body)); @@ -4676,6 +4999,268 @@ function normalizeOpencodeScope(value: string | null | undefined): "project" | " return value?.trim().toLowerCase() === "global" ? "global" : "project"; } +type ManagedProviderSyncProvider = { + id: string; + providerId: string; + name: string; + source: string; + credentialKind: "api_key" | "opencode_oauth"; + providerConfig: Record; + models: Array<{ id: string; name: string; config: Record }>; + apiKey?: string; + opencodeAuth?: string; + revision: string; +}; + +type ManagedProviderSyncPayload = { + providers: ManagedProviderSyncProvider[]; + revision: string; +}; + +function isRecordValue(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readRequiredString(record: Record, key: string): string { + const value = record[key]; + if (typeof value !== "string" || !value.trim()) { + throw new ApiError(400, "invalid_payload", `${key} must be a non-empty string`); + } + return value.trim(); +} + +function readOptionalString(record: Record, key: string): string | undefined { + const value = record[key]; + if (value === undefined || value === null) return undefined; + if (typeof value !== "string") { + throw new ApiError(400, "invalid_payload", `${key} must be a string`); + } + const trimmed = value.trim(); + return trimmed || undefined; +} + +function parseManagedProviderSyncPayload(input: unknown): ManagedProviderSyncPayload { + if (!isRecordValue(input) || !Array.isArray(input.providers)) { + throw new ApiError(400, "invalid_payload", "providers must be an array"); + } + const revision = readRequiredString(input, "revision"); + const providers = input.providers.map((entry) => { + if (!isRecordValue(entry)) { + throw new ApiError(400, "invalid_payload", "Each provider must be an object"); + } + const rawCredentialKind = entry.credentialKind; + if (rawCredentialKind !== "api_key" && rawCredentialKind !== "opencode_oauth") { + throw new ApiError(400, "invalid_payload", "credentialKind must be api_key or opencode_oauth"); + } + const credentialKind: ManagedProviderSyncProvider["credentialKind"] = rawCredentialKind; + const providerConfig = entry.providerConfig; + if (!isRecordValue(providerConfig)) { + throw new ApiError(400, "invalid_payload", "providerConfig must be an object"); + } + const modelsInput = entry.models; + if (!Array.isArray(modelsInput)) { + throw new ApiError(400, "invalid_payload", "models must be an array"); + } + const models = modelsInput.map((model) => { + if (!isRecordValue(model) || !isRecordValue(model.config)) { + throw new ApiError(400, "invalid_payload", "Each model must include config"); + } + return { + id: readRequiredString(model, "id"), + name: readRequiredString(model, "name"), + config: model.config, + }; + }); + return { + id: readRequiredString(entry, "id"), + providerId: readRequiredString(entry, "providerId"), + name: readRequiredString(entry, "name"), + source: readRequiredString(entry, "source"), + credentialKind, + providerConfig, + models, + apiKey: readOptionalString(entry, "apiKey"), + opencodeAuth: readOptionalString(entry, "opencodeAuth"), + revision: readRequiredString(entry, "revision"), + }; + }); + const runtimeProviderIds = new Set(); + for (const provider of providers) { + const runtimeProviderId = getManagedProviderRuntimeId(provider); + if (runtimeProviderIds.has(runtimeProviderId)) { + throw new ApiError(400, "duplicate_provider_runtime_id", "Managed provider runtime ids must be unique"); + } + runtimeProviderIds.add(runtimeProviderId); + } + return { providers, revision }; +} + +function getManagedProviderEnv(config: Record) { + return Array.isArray(config.env) ? config.env.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) : []; +} + +export function getManagedProviderRuntimeId(provider: Pick) { + if (provider.source === "openwork") return "openwork"; + if (provider.credentialKind === "opencode_oauth") return provider.providerId.trim(); + return provider.id.trim(); +} + +export function buildManagedProviderRuntimeConfig(provider: ManagedProviderSyncProvider) { + const models = Object.fromEntries(provider.models.map((model) => [model.id, buildManagedProviderModelRuntimeConfig(model)])); + const next: Record = { + id: provider.providerId, + name: provider.name, + env: getManagedProviderEnv(provider.providerConfig), + models, + }; + for (const key of ["npm", "api", "options", "whitelist", "blacklist"] as const) { + const value = provider.providerConfig[key]; + if (value !== undefined) next[key] = value; + } + return next; +} + +async function readManagedProviderRuntimeIds(workspaceRoot: string, key: "applied" | "revoked"): Promise> { + const openwork = await readOpenworkConfig(workspaceRoot); + const managedProviders = openwork.managedProviders; + if (!isRecordValue(managedProviders) || managedProviders.source !== "den") return new Set(); + return new Set(readStringArray(managedProviders[key])); +} + +async function readAppliedManagedProviderRuntimeIds(workspaceRoot: string): Promise> { + return readManagedProviderRuntimeIds(workspaceRoot, "applied"); +} + +async function readRevokedManagedProviderRuntimeIds(workspaceRoot: string): Promise> { + return readManagedProviderRuntimeIds(workspaceRoot, "revoked"); +} + +async function applyManagedProviderConfigSet(workspaceRoot: string, providers: ManagedProviderSyncProvider[], previousManagedProviderIds: Set) { + const config = await readOpencodeConfig(workspaceRoot); + const providerConfig = isRecordValue(config.provider) ? { ...config.provider } : {}; + const currentProviderIds = new Set(providers.map((provider) => getManagedProviderRuntimeId(provider))); + + for (const providerId of previousManagedProviderIds) { + if (!currentProviderIds.has(providerId)) { + delete providerConfig[providerId]; + } + } + + for (const provider of providers) { + providerConfig[getManagedProviderRuntimeId(provider)] = buildManagedProviderRuntimeConfig(provider); + } + + const nextConfig = { ...config }; + if (Object.keys(providerConfig).length > 0) { + nextConfig.provider = providerConfig; + } else { + delete nextConfig.provider; + } + + await writeJsoncFile(opencodeConfigPath(workspaceRoot), nextConfig); +} + +function buildManagedProviderModelRuntimeConfig(model: ManagedProviderSyncProvider["models"][number]) { + const raw = model.config; + const next: Record = { id: model.id, name: model.name }; + + for (const key of ["family", "release_date", "status"] as const) { + const value = raw[key]; + if (typeof value === "string") next[key] = value; + } + + for (const key of ["attachment", "reasoning", "temperature", "tool_call", "interleaved", "experimental"] as const) { + const value = raw[key]; + if (typeof value === "boolean") next[key] = value; + } + + for (const key of ["cost", "limit", "modalities", "options", "headers", "provider", "variants"] as const) { + const value = raw[key]; + if (isRecordValue(value)) next[key] = value; + } + + return next; +} + +async function applyManagedProviderConfig(workspaceRoot: string, provider: ManagedProviderSyncProvider) { + const providerId = getManagedProviderRuntimeId(provider); + await updateJsoncPath(opencodeConfigPath(workspaceRoot), ["provider", providerId], buildManagedProviderRuntimeConfig(provider)); +} + +function parseManagedOpencodeAuth(provider: ManagedProviderSyncProvider): unknown { + if (provider.credentialKind === "api_key") { + if (!provider.apiKey) throw new ApiError(400, "missing_provider_credential", "Managed provider is missing an API credential"); + return { type: "api", key: provider.apiKey }; + } + if (!provider.opencodeAuth) throw new ApiError(400, "missing_provider_credential", "Managed provider is missing an OAuth credential"); + try { + const auth = JSON.parse(provider.opencodeAuth) as unknown; + if (!isRecordValue(auth) || auth.type !== "oauth") throw new Error("invalid auth"); + return auth; + } catch { + throw new ApiError(400, "invalid_provider_credential", "Managed provider OAuth credential is invalid"); + } +} + +async function applyManagedProviderAuth(config: ServerConfig, workspace: WorkspaceInfo, provider: ManagedProviderSyncProvider) { + const providerId = getManagedProviderRuntimeId(provider); + await fetchOpencodeJson(config, workspace, `/auth/${encodeURIComponent(providerId)}`, { + method: "PUT", + body: parseManagedOpencodeAuth(provider), + }); +} + +async function readManagedProviderAuth(config: ServerConfig, workspace: WorkspaceInfo, providerId: string) { + try { + return await fetchOpencodeJson(config, workspace, `/auth/${encodeURIComponent(providerId)}`, { + method: "GET", + }); + } catch (error) { + if (error instanceof ApiError && error.code === "opencode_request_failed" && isRecordValue(error.details) && error.details.status === 404) { + return null; + } + throw error; + } +} + +async function deleteManagedProviderAuth(config: ServerConfig, workspace: WorkspaceInfo, providerId: string) { + try { + await fetchOpencodeJson(config, workspace, `/auth/${encodeURIComponent(providerId)}`, { + method: "DELETE", + }); + } catch (error) { + if (error instanceof ApiError && error.code === "opencode_request_failed" && isRecordValue(error.details) && error.details.status === 404) { + return; + } + throw error; + } +} + +async function restoreManagedProviderAuth(config: ServerConfig, workspace: WorkspaceInfo, providerId: string, previousAuth: unknown | null) { + if (previousAuth === null) { + await deleteManagedProviderAuth(config, workspace, providerId); + return; + } + await fetchOpencodeJson(config, workspace, `/auth/${encodeURIComponent(providerId)}`, { + method: "PUT", + body: previousAuth, + }); +} + +async function rollbackAppliedManagedProviderAuth(config: ServerConfig, workspace: WorkspaceInfo, previousAuthByProviderId: Map) { + await Promise.all([...previousAuthByProviderId.entries()].map(async ([providerId, previousAuth]) => { + try { + await restoreManagedProviderAuth(config, workspace, providerId, previousAuth); + } catch { + // Best-effort restoration. The sync response remains failed and sanitized. + } + })); +} + +function sanitizeManagedProviderApplyError(error: unknown) { + return "Managed provider sync failed"; +} + function resolveOpencodeConfigFilePath(scope: "project" | "global", workspaceRoot: string): string { if (scope === "global") { const base = join(homedir(), ".config", "opencode"); diff --git a/ee/apps/den-api/src/routes/org/llm-providers.ts b/ee/apps/den-api/src/routes/org/llm-providers.ts index 5cc66da166..5ca15f55d4 100644 --- a/ee/apps/den-api/src/routes/org/llm-providers.ts +++ b/ee/apps/den-api/src/routes/org/llm-providers.ts @@ -145,6 +145,24 @@ function isOrganizationAdmin(payload: { currentMember: { isOwner: boolean; role: return payload.currentMember.isOwner || memberHasRole(payload.currentMember.role, "admin") } +export function getCredentialFlags(provider: Pick) { + const hasApiKey = Boolean(provider.apiKey && provider.apiKey.trim().length > 0) + const hasOpencodeAuth = Boolean(provider.opencodeAuth && provider.opencodeAuth.trim().length > 0) + return { + hasApiKey, + hasOpencodeAuth, + hasCredential: provider.credentialKind === "opencode_oauth" ? hasOpencodeAuth : hasApiKey, + } +} + +export function redactLlmProviderCredentials(provider: T): Omit & { apiKey: undefined; opencodeAuth: undefined } { + return { + ...provider, + apiKey: undefined, + opencodeAuth: undefined, + } +} + function canManageLlmProvider( payload: { currentMember: { id: MemberId; isOwner: boolean; role: string } }, provider: LlmProviderRow, @@ -464,7 +482,7 @@ async function loadLlmProviders(input: { return providers.map((provider) => ({ ...provider, - hasApiKey: Boolean(provider.apiKey && provider.apiKey.trim().length > 0), + ...getCredentialFlags(provider), models: (modelsByProviderId.get(provider.id) ?? []) .map((model) => ({ id: model.modelId, @@ -607,8 +625,7 @@ export function registerOrgLlmProviderRoutes ({ - ...provider, - apiKey: undefined, + ...redactLlmProviderCredentials(provider), canManage: canManageLlmProvider(payload, provider), })), }) @@ -678,6 +695,8 @@ export function registerOrgLlmProviderRoutes ({ id: model.modelId, @@ -781,10 +800,13 @@ export function registerOrgLlmProviderRoutes(app: Hono) { registerWorkerActivityRoutes(app) registerWorkerBillingRoutes(app) registerWorkerCoreRoutes(app) + registerManagedProviderSyncRoutes(app as unknown as Hono<{ Variables: WorkerRouteVariables }>) registerWorkerRuntimeRoutes(app) } diff --git a/ee/apps/den-api/src/routes/workers/managed-providers.ts b/ee/apps/den-api/src/routes/workers/managed-providers.ts new file mode 100644 index 0000000000..0928f217c8 --- /dev/null +++ b/ee/apps/den-api/src/routes/workers/managed-providers.ts @@ -0,0 +1,213 @@ +import { and, eq, inArray, or } from "@openwork-ee/den-db/drizzle" +import { LlmProviderAccessTable, LlmProviderModelTable, LlmProviderTable } from "@openwork-ee/den-db/schema" +import { normalizeDenTypeId } from "@openwork-ee/utils/typeid" +import type { Hono } from "hono" +import { describeRoute } from "hono-openapi" +import { z } from "zod" +import { db } from "../../db.js" +import { paramValidator, requireUserMiddleware, resolveMemberTeamsMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js" +import { forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, unauthorizedSchema } from "../../openapi.js" +import { memberHasRole } from "../org/shared.js" +import { fetchWorkerRuntimeJson, getWorkerByIdForOrg, parseWorkerIdParam, type WorkerId, type WorkerRouteVariables, workerIdParamSchema } from "./shared.js" + +type LlmProviderRow = typeof LlmProviderTable.$inferSelect +type LlmProviderModelRow = typeof LlmProviderModelTable.$inferSelect +type OrganizationId = LlmProviderRow["organizationId"] +type MemberId = typeof LlmProviderAccessTable.$inferSelect.orgMembershipId +type TeamId = typeof LlmProviderAccessTable.$inferSelect.teamId + +export type ManagedProviderSyncProvider = { + id: string + providerId: string + name: string + source: LlmProviderRow["source"] + credentialKind: LlmProviderRow["credentialKind"] + providerConfig: Record + models: Array<{ id: string; name: string; config: Record }> + apiKey?: string + opencodeAuth?: string + revision: string +} + +type ManagedProviderRouteDeps = { + middlewares?: never[] + getWorker?: (workerId: WorkerId, orgId: OrganizationId) => Promise<{ id: WorkerId } | null> + listProviders?: (orgId: OrganizationId) => Promise + pushRuntime?: (workerId: WorkerId, payload: { providers: ManagedProviderSyncProvider[]; revision: string }) => Promise<{ ok: boolean; status: number; payload: unknown }> +} + +const managedProviderSyncResponseSchema = z.object({ + status: z.enum(["applied", "failed"]), + providerCount: z.number().int().min(0), + revision: z.string(), + reason: z.string().optional(), +}).meta({ ref: "ManagedProviderSyncResponse" }) + +export function canSyncManagedProviders(payload: { currentMember: { isOwner: boolean; role: string } }) { + return payload.currentMember.isOwner || memberHasRole(payload.currentMember.role, "admin") +} + +function credentialPresent(provider: Pick) { + return provider.credentialKind === "opencode_oauth" + ? Boolean(provider.opencodeAuth?.trim()) + : Boolean(provider.apiKey?.trim()) +} + +function revisionForProvider(provider: Pick, models: LlmProviderModelRow[]) { + return [ + provider.id, + provider.credentialKind, + provider.updatedAt instanceof Date ? provider.updatedAt.toISOString() : String(provider.updatedAt), + models.map((model) => `${model.modelId}:${model.name}`).sort().join(","), + ].join(":") +} + +export function computeManagedProviderRevision(providers: Pick[]) { + return providers.map((provider) => `${provider.id}:${provider.revision}`).sort().join("|") || "empty" +} + +export function sanitizeManagedProviderSyncFailure(payload: unknown) { + return "Worker provider sync failed." +} + +async function listAccessibleManagedProviderIds(input: { + organizationId: OrganizationId + currentMemberId: NonNullable + memberTeamIds: NonNullable[] +}) { + const rows = await db + .select({ llmProviderId: LlmProviderAccessTable.llmProviderId }) + .from(LlmProviderAccessTable) + .innerJoin(LlmProviderTable, eq(LlmProviderAccessTable.llmProviderId, LlmProviderTable.id)) + .where(input.memberTeamIds.length > 0 + ? and( + eq(LlmProviderTable.organizationId, input.organizationId), + or( + eq(LlmProviderAccessTable.orgMembershipId, input.currentMemberId), + inArray(LlmProviderAccessTable.teamId, input.memberTeamIds), + ), + ) + : and( + eq(LlmProviderTable.organizationId, input.organizationId), + eq(LlmProviderAccessTable.orgMembershipId, input.currentMemberId), + )) + + return [...new Set(rows.map((row) => row.llmProviderId))] +} + +export async function listManagedProviderSyncProviders(input: OrganizationId | { + organizationId: OrganizationId + currentMemberId: NonNullable + memberTeamIds: NonNullable[] +}) { + const organizationId = typeof input === "string" ? input : input.organizationId + const accessibleProviderIds = typeof input === "string" + ? null + : await listAccessibleManagedProviderIds(input) + + if (accessibleProviderIds && accessibleProviderIds.length === 0) return [] + + const providers = await db + .select() + .from(LlmProviderTable) + .where(accessibleProviderIds + ? and(eq(LlmProviderTable.organizationId, organizationId), inArray(LlmProviderTable.id, accessibleProviderIds)) + : eq(LlmProviderTable.organizationId, organizationId)) + + const eligible = providers.filter(credentialPresent) + if (!eligible.length) return [] + + const models = await db + .select() + .from(LlmProviderModelTable) + .where(inArray(LlmProviderModelTable.llmProviderId, eligible.map((provider) => provider.id))) + + return eligible.map((provider) => { + const providerModels = models.filter((model) => model.llmProviderId === provider.id) + return { + id: provider.id, + providerId: provider.providerId, + name: provider.name, + source: provider.source, + credentialKind: provider.credentialKind, + providerConfig: provider.providerConfig, + models: providerModels.map((model) => ({ id: model.modelId, name: model.name, config: model.modelConfig })), + ...(provider.credentialKind === "api_key" && provider.apiKey ? { apiKey: provider.apiKey } : {}), + ...(provider.credentialKind === "opencode_oauth" && provider.opencodeAuth ? { opencodeAuth: provider.opencodeAuth } : {}), + revision: revisionForProvider(provider, providerModels), + } + }) +} + +export function registerManagedProviderSyncRoutes(app: Hono<{ Variables: WorkerRouteVariables }>, deps: ManagedProviderRouteDeps = {}) { + const routeMiddlewares = deps.middlewares ?? [requireUserMiddleware, resolveOrganizationContextMiddleware, resolveMemberTeamsMiddleware, paramValidator(workerIdParamSchema)] + const getWorker = deps.getWorker ?? getWorkerByIdForOrg + const listProviders = deps.listProviders ?? listManagedProviderSyncProviders + const pushRuntime = deps.pushRuntime ?? ((workerId, payload) => fetchWorkerRuntimeJson({ + workerId, + path: "/managed-providers/sync", + method: "POST", + body: payload, + })) + + app.post( + "/v1/workers/:id/managed-providers/sync", + describeRoute({ + tags: ["Workers", "Managed Providers"], + summary: "Sync managed providers to worker runtime", + description: "Applies organization-managed provider config/auth to a static worker through the host-token runtime channel.", + responses: { + 200: jsonResponse("Managed providers applied successfully.", managedProviderSyncResponseSchema), + 400: jsonResponse("The worker path parameters were invalid.", invalidRequestSchema), + 401: jsonResponse("The caller must be signed in to sync providers.", unauthorizedSchema), + 403: jsonResponse("Only organization owners and admins can sync providers.", forbiddenSchema), + 404: jsonResponse("The worker could not be found.", notFoundSchema), + 502: jsonResponse("The worker runtime failed to apply managed providers.", managedProviderSyncResponseSchema), + }, + }), + ...(routeMiddlewares as never[]), + async (c) => { + const orgId = c.get("activeOrganizationId") + const organizationContext = c.get("organizationContext") + const memberTeams = c.get("memberTeams") ?? [] + const params = c.req.valid("param" as never) as { id: string } + + if (!orgId) return c.json({ error: "worker_not_found" }, 404) + if (!organizationContext || !canSyncManagedProviders(organizationContext)) { + return c.json({ error: "forbidden", message: "Only organization owners and admins can sync managed providers." }, 403) + } + + let workerId: WorkerId + try { + workerId = parseWorkerIdParam(params.id) + } catch { + return c.json({ error: "worker_not_found" }, 404) + } + + const normalizedOrgId = normalizeDenTypeId("organization", orgId) + const worker = await getWorker(workerId, normalizedOrgId) + if (!worker) return c.json({ error: "worker_not_found" }, 404) + + const providers = deps.listProviders + ? await listProviders(normalizedOrgId) + : await listManagedProviderSyncProviders({ + organizationId: normalizedOrgId, + currentMemberId: organizationContext.currentMember.id, + memberTeamIds: memberTeams.map((team) => team.id), + }) + const revision = computeManagedProviderRevision(providers) + + const runtime = await pushRuntime(worker.id, { providers, revision }) + if (!runtime.ok) { + return c.json({ + status: "failed", + providerCount: providers.length, + revision, + reason: sanitizeManagedProviderSyncFailure(runtime.payload), + }, 502) + } + + return c.json({ status: "applied", providerCount: providers.length, revision }) + }, + ) +} diff --git a/ee/apps/den-api/src/routes/workers/shared.ts b/ee/apps/den-api/src/routes/workers/shared.ts index cbf1f01d25..b5f4a6f71b 100644 --- a/ee/apps/den-api/src/routes/workers/shared.ts +++ b/ee/apps/den-api/src/routes/workers/shared.ts @@ -15,7 +15,7 @@ import { z } from "zod" import { requireCloudWorkerAccess } from "../../billing/polar.js" import { db } from "../../db.js" import { env } from "../../env.js" -import type { UserOrganizationsContext } from "../../middleware/index.js" +import type { MemberTeamsContext, OrganizationContextVariables, UserOrganizationsContext } from "../../middleware/index.js" import { denTypeIdSchema } from "../../openapi.js" import type { AuthContextVariables } from "../../session.js" import { deprovisionWorker, provisionWorker } from "../../workers/provisioner.js" @@ -49,7 +49,7 @@ export const workerIdParamSchema = z.object({ id: denTypeIdSchema("worker"), }) -export type WorkerRouteVariables = AuthContextVariables & Partial +export type WorkerRouteVariables = AuthContextVariables & Partial & Partial & Partial type WorkerRow = typeof WorkerTable.$inferSelect type WorkerInstanceRow = typeof WorkerInstanceTable.$inferSelect @@ -192,7 +192,16 @@ async function resolveConnectUrlFromCandidates(workerId: WorkerId, instanceUrl: } async function getWorkerRuntimeAccess(workerId: WorkerId) { - const instance = await getLatestWorkerInstance(workerId) + const workerRows = await db + .select({ status: WorkerTable.status }) + .from(WorkerTable) + .where(eq(WorkerTable.id, workerId)) + .limit(1) + if (workerRows[0]?.status !== "healthy") { + return null + } + + const instance = await getLatestHealthyWorkerInstance(workerId) const tokenRows = await db .select() .from(WorkerTokenTable) @@ -284,6 +293,24 @@ export async function getLatestWorkerInstance(workerId: WorkerId) { return rows[0] ?? null } +export async function getLatestHealthyWorkerInstance(workerId: WorkerId) { + const rows = await db + .select() + .from(WorkerInstanceTable) + .where(and(eq(WorkerInstanceTable.worker_id, workerId), eq(WorkerInstanceTable.status, "healthy"))) + .orderBy(desc(WorkerInstanceTable.created_at)) + .limit(1) + + return rows[0] ?? null +} + +export function isWorkerRuntimeSyncTarget(input: { workerStatus?: string | null; instanceStatus?: string | null; instanceUrl?: string | null; hostToken?: string | null }) { + return input.workerStatus === "healthy" + && input.instanceStatus === "healthy" + && Boolean(input.instanceUrl?.trim()) + && Boolean(input.hostToken?.trim()) +} + export function toInstanceResponse(instance: WorkerInstanceRow | null) { if (!instance) { return null diff --git a/ee/apps/den-api/test/managed-provider-sync.test.ts b/ee/apps/den-api/test/managed-provider-sync.test.ts new file mode 100644 index 0000000000..3898df8ec5 --- /dev/null +++ b/ee/apps/den-api/test/managed-provider-sync.test.ts @@ -0,0 +1,153 @@ +import { beforeAll, expect, test } from "bun:test" +import { Hono } from "hono" +import { createDenTypeId } from "@openwork-ee/utils/typeid" +import { paramValidator } from "../src/middleware/validation.js" + +function seedRequiredEnv() { + process.env.DATABASE_URL = process.env.DATABASE_URL ?? "mysql://root:password@127.0.0.1:3306/openwork_test" + process.env.DEN_DB_ENCRYPTION_KEY = process.env.DEN_DB_ENCRYPTION_KEY ?? "x".repeat(32) + process.env.BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET ?? "y".repeat(32) + process.env.BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://127.0.0.1:8790" + process.env.CORS_ORIGINS = process.env.CORS_ORIGINS ?? "http://127.0.0.1:8790" +} + +let managedProviderModule: typeof import("../src/routes/workers/managed-providers.js") +let workersSharedModule: typeof import("../src/routes/workers/shared.js") + +beforeAll(async () => { + seedRequiredEnv() + managedProviderModule = await import("../src/routes/workers/managed-providers.js") + workersSharedModule = await import("../src/routes/workers/shared.js") +}) + +function createApp(input: { + role?: string + isOwner?: boolean + listProviders?: Parameters[1]["listProviders"] + pushRuntime?: Parameters[1]["pushRuntime"] +}) { + const app = new Hono() + const orgId = createDenTypeId("organization") + const workerId = createDenTypeId("worker") + const provider = { + id: createDenTypeId("llmProvider"), + providerId: "anthropic", + name: "Anthropic", + source: "models_dev" as const, + credentialKind: "api_key" as const, + providerConfig: { id: "anthropic", name: "Anthropic", env: ["ANTHROPIC_API_KEY"], npm: "@ai-sdk/anthropic" }, + models: [{ id: "claude", name: "Claude", config: { id: "claude" } }], + apiKey: "plain-provider-secret-den-test", + revision: "rev-1", + } + managedProviderModule.registerManagedProviderSyncRoutes(app as never, { + middlewares: [ + async (c, next) => { + c.set("activeOrganizationId", orgId) + c.set("organizationContext", { + organization: { id: orgId }, + currentMember: { id: createDenTypeId("member"), userId: createDenTypeId("user"), role: input.role ?? "admin", isOwner: input.isOwner ?? false }, + }) + await next() + }, + paramValidator(workersSharedModule.workerIdParamSchema), + ] as never, + getWorker: async (id, activeOrgId) => id === workerId && activeOrgId === orgId ? { id } : null, + listProviders: input.listProviders ?? (async () => [provider]), + pushRuntime: input.pushRuntime ?? (async () => ({ ok: true, status: 200, payload: { status: "applied" } })), + }) + return { app, workerId, provider } +} + +test("managed provider sync rejects non-admin members", async () => { + const { app, workerId } = createApp({ role: "member" }) + const response = await app.request(`http://den.local/v1/workers/${workerId}/managed-providers/sync`, { method: "POST" }) + expect(response.status).toBe(403) +}) + +test("managed provider sync sends credentials only to worker runtime and redacts response", async () => { + const calls: unknown[] = [] + const { app, workerId, provider } = createApp({ + pushRuntime: async (_workerId, payload) => { + calls.push(payload) + return { ok: true, status: 200, payload: { status: "applied", apiKey: provider.apiKey } } + }, + }) + + const response = await app.request(`http://den.local/v1/workers/${workerId}/managed-providers/sync`, { method: "POST" }) + expect(response.status).toBe(200) + const body = await response.json() + expect(JSON.stringify(body)).not.toContain("plain-provider-secret") + expect(JSON.stringify(calls[0])).toContain("plain-provider-secret-den-test") + expect(body).toMatchObject({ status: "applied", providerCount: 1 }) +}) + +test("managed provider sync sanitizes worker failures", async () => { + const { app, workerId } = createApp({ + pushRuntime: async () => ({ ok: false, status: 500, payload: { message: "failed with plain-provider-secret-den-test access-token-den refresh-token-den" } }), + }) + const response = await app.request(`http://den.local/v1/workers/${workerId}/managed-providers/sync`, { method: "POST" }) + expect(response.status).toBe(502) + const body = await response.json() + expect(body.status).toBe("failed") + expect(JSON.stringify(body)).not.toContain("plain-provider-secret") + expect(JSON.stringify(body)).not.toContain("access-token-den") + expect(JSON.stringify(body)).not.toContain("refresh-token-den") + expect(body.reason).toBe("Worker provider sync failed.") +}) + +test("managed provider sync pushes an empty provider set so workers remove revoked providers", async () => { + let called = false + const { app, workerId } = createApp({ + listProviders: async () => [], + pushRuntime: async (_workerId, payload) => { + called = true + expect(payload).toEqual({ providers: [], revision: "empty" }) + return { ok: true, status: 200, payload: { status: "applied" } } + }, + }) + + const response = await app.request(`http://den.local/v1/workers/${workerId}/managed-providers/sync`, { method: "POST" }) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ status: "applied", providerCount: 0, revision: "empty" }) + expect(called).toBe(true) +}) + +test("managed provider sync reports missing worker as not found", async () => { + const { app } = createApp({ role: "admin" }) + const missingWorker = createDenTypeId("worker") + const response = await app.request(`http://den.local/v1/workers/${missingWorker}/managed-providers/sync`, { method: "POST" }) + expect(response.status).toBe(404) +}) + +test("managed provider revision is stable and redaction helper removes token-shaped secrets", () => { + expect(managedProviderModule.computeManagedProviderRevision([{ id: "b", revision: "2" }, { id: "a", revision: "1" }])).toBe("a:1|b:2") + expect(managedProviderModule.sanitizeManagedProviderSyncFailure({ message: "bad plain-secret access-token refresh-token" })).toBe("Worker provider sync failed.") +}) + +test("managed provider runtime sync targets only current healthy worker instances", () => { + expect(workersSharedModule.isWorkerRuntimeSyncTarget({ + workerStatus: "healthy", + instanceStatus: "healthy", + instanceUrl: "https://worker.example.com", + hostToken: "host-token", + })).toBe(true) + expect(workersSharedModule.isWorkerRuntimeSyncTarget({ + workerStatus: "failed", + instanceStatus: "healthy", + instanceUrl: "https://worker.example.com", + hostToken: "host-token", + })).toBe(false) + expect(workersSharedModule.isWorkerRuntimeSyncTarget({ + workerStatus: "healthy", + instanceStatus: "failed", + instanceUrl: "https://stale-reservation.example.com", + hostToken: "host-token", + })).toBe(false) + expect(workersSharedModule.isWorkerRuntimeSyncTarget({ + workerStatus: "healthy", + instanceStatus: "healthy", + instanceUrl: "", + hostToken: "host-token", + })).toBe(false) +}) diff --git a/ee/packages/den-db/drizzle/0021_llm_provider_opencode_oauth.sql b/ee/packages/den-db/drizzle/0021_llm_provider_opencode_oauth.sql new file mode 100644 index 0000000000..96f71ae066 --- /dev/null +++ b/ee/packages/den-db/drizzle/0021_llm_provider_opencode_oauth.sql @@ -0,0 +1,3 @@ +ALTER TABLE `llm_provider` + ADD COLUMN `credential_kind` enum('api_key','opencode_oauth') NOT NULL DEFAULT 'api_key' AFTER `provider_config`, + ADD COLUMN `opencode_auth` text AFTER `api_key`; diff --git a/ee/packages/den-db/drizzle/meta/_journal.json b/ee/packages/den-db/drizzle/meta/_journal.json index 5022ccf520..671a3a44b7 100644 --- a/ee/packages/den-db/drizzle/meta/_journal.json +++ b/ee/packages/den-db/drizzle/meta/_journal.json @@ -138,9 +138,14 @@ { "idx": 20, "version": "5", +<<<<<<< HEAD + "when": 1777486400000, + "tag": "0020_llm_provider_opencode_oauth", +======= "when": 1780426749385, "tag": "0020_breezy_siren", +>>>>>>> upstream/dev "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/ee/packages/den-db/src/schema/sharables/llm-providers.ts b/ee/packages/den-db/src/schema/sharables/llm-providers.ts index c4ded95170..25ad41480d 100644 --- a/ee/packages/den-db/src/schema/sharables/llm-providers.ts +++ b/ee/packages/den-db/src/schema/sharables/llm-providers.ts @@ -30,7 +30,11 @@ export const LlmProviderTable = mysqlTable( providerConfig: json("provider_config") .$type>() .notNull(), + credentialKind: mysqlEnum("credential_kind", ["api_key", "opencode_oauth"]) + .notNull() + .default("api_key"), apiKey: encryptedTextColumn("api_key"), + opencodeAuth: encryptedTextColumn("opencode_auth"), createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { fsp: 3 }) .notNull()