From 3eda9898523bae2486a864be585084b474fe4e04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 22 May 2026 18:00:17 +0200 Subject: [PATCH 1/5] feat(den): sync managed providers to workers --- .../src/managed-provider-sync.e2e.test.ts | 157 ++++++++++++++ apps/server/src/server.ts | 198 ++++++++++++++++++ ee/apps/den-api/src/routes/workers/index.ts | 2 + .../src/routes/workers/managed-providers.ts | 167 +++++++++++++++ .../test/managed-provider-sync.test.ts | 106 ++++++++++ 5 files changed, 630 insertions(+) create mode 100644 apps/server/src/managed-provider-sync.e2e.test.ts create mode 100644 ee/apps/den-api/src/routes/workers/managed-providers.ts create mode 100644 ee/apps/den-api/test/managed-provider-sync.test.ts 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..8c8d9f65d9 --- /dev/null +++ b/apps/server/src/managed-provider-sync.e2e.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { 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; +}; + +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 providerPayload() { + return { + revision: "sync-rev-1", + providers: [ + { + id: "llmProvider_den_anthropic", + providerId: "anthropic", + name: "Anthropic", + source: "models_dev", + credentialKind: "api_key", + providerConfig: { id: "anthropic", name: "Anthropic", env: ["ANTHROPIC_API_KEY"], npm: "@ai-sdk/anthropic" }, + models: [{ id: "claude", name: "Claude", config: { id: "claude", limit: { context: 200000 } } }], + apiKey: "sk-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", name: "GPT-5", config: { id: "gpt-5" } }], + opencodeAuth: JSON.stringify({ type: "oauth", access: "access-secret", refresh: "refresh-secret", expires: 9 }), + revision: "provider-rev-2", + }, + ], + }; +} + +async function boot(options: { failAuth?: boolean } = {}) { + 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: unknown[] = []; + const opencode = Bun.serve({ + port: 0, + async fetch(request) { + const url = new URL(request.url); + if (url.pathname.startsWith("/auth/")) { + authCalls.push(await request.json()); + if (options.failAuth) return Response.json({ error: "bad sk-server-secret" }, { status: 500 }); + return Response.json({ ok: true }); + } + 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 }; +} + +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("sk-server-secret"); + expect(JSON.stringify(body)).not.toContain("refresh-secret"); + } + + const config = readFileSync(join(workspace, "opencode.jsonc"), "utf8"); + expect(config.match(/llmProvider_den_anthropic/g)?.length).toBe(1); + expect(config.match(/"openai"/g)?.length).toBeGreaterThanOrEqual(1); + expect(config).not.toContain("sk-server-secret"); + expect(authCalls).toHaveLength(4); + expect(JSON.stringify(authCalls[0])).toContain("sk-server-secret"); + expect(JSON.stringify(authCalls[1])).toContain("refresh-secret"); + }); + + test("sanitizes OpenCode auth apply failures", async () => { + const { base } = 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("sk-server-secret"); + }); +}); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 064b79961e..778d9fb05f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1546,6 +1546,58 @@ 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 applied: string[] = []; + try { + for (const provider of payload.providers) { + await applyManagedProviderConfig(workspace.path, provider); + await applyManagedProviderAuth(config, workspace, provider); + applied.push(provider.id); + } + } catch (error) { + return jsonResponse({ + status: "failed", + providerCount: applied.length, + revision: payload.revision, + reason: sanitizeManagedProviderApplyError(error), + }, 502); + } + + await writeOpenworkConfig(workspace.path, { + managedProviders: { + source: "den", + revision: payload.revision, + applied, + appliedAt: new Date().toISOString(), + }, + }, true); + + 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", "/workspaces/local", "host", async (ctx) => { ensureWritable(config); const body = await readJsonBody(ctx.request); @@ -3536,6 +3588,152 @@ 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"), + }; + }); + 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, { ...model.config, id: model.id, name: model.name }])); + 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 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: "POST", + body: { providerID: providerId, auth: parseManagedOpencodeAuth(provider) }, + }); +} + +function sanitizeManagedProviderApplyError(error: unknown) { + const message = error instanceof ApiError || error instanceof Error ? error.message : "Managed provider sync failed"; + return message.replace(/sk-[A-Za-z0-9_-]+/g, "[redacted]").slice(0, 300); +} + 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/workers/index.ts b/ee/apps/den-api/src/routes/workers/index.ts index 4c14419dfb..c9282da1b8 100644 --- a/ee/apps/den-api/src/routes/workers/index.ts +++ b/ee/apps/den-api/src/routes/workers/index.ts @@ -3,11 +3,13 @@ import type { WorkerRouteVariables } from "./shared.js" import { registerWorkerActivityRoutes } from "./activity.js" import { registerWorkerBillingRoutes } from "./billing.js" import { registerWorkerCoreRoutes } from "./core.js" +import { registerManagedProviderSyncRoutes } from "./managed-providers.js" import { registerWorkerRuntimeRoutes } from "./runtime.js" export function registerWorkerRoutes(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..7a94ec0cd9 --- /dev/null +++ b/ee/apps/den-api/src/routes/workers/managed-providers.ts @@ -0,0 +1,167 @@ +import { eq, inArray } from "@openwork-ee/den-db/drizzle" +import { 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, 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"] + +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) { + if (!payload || typeof payload !== "object") return "Worker provider sync failed." + const record = payload as Record + const message = typeof record.message === "string" ? record.message : typeof record.error === "string" ? record.error : "Worker provider sync failed." + return message.replace(/sk-[A-Za-z0-9_-]+/g, "[redacted]").slice(0, 300) +} + +export async function listManagedProviderSyncProviders(organizationId: OrganizationId) { + const providers = await db + .select() + .from(LlmProviderTable) + .where(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, 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), + }, + }), + ...(routeMiddlewares as never[]), + async (c) => { + const orgId = c.get("activeOrganizationId") + const organizationContext = c.get("organizationContext") + 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 = await listProviders(normalizedOrgId) + 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/test/managed-provider-sync.test.ts b/ee/apps/den-api/test/managed-provider-sync.test.ts new file mode 100644 index 0000000000..b10d0cf478 --- /dev/null +++ b/ee/apps/den-api/test/managed-provider-sync.test.ts @@ -0,0 +1,106 @@ +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 + 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: "sk-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: 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("sk-secret") + expect(JSON.stringify(calls[0])).toContain("sk-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 sk-secret-den-test" } }), + }) + 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("sk-secret") + expect(body.reason).toContain("[redacted]") +}) + +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 sk-live-secret" })).toBe("bad [redacted]") +}) From f91e024dd76d0c9f499d7db5128bd114510707fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 22 May 2026 18:08:32 +0200 Subject: [PATCH 2/5] fix(den): harden provider sync verification --- .../server/src/managed-provider-sync.e2e.test.ts | 15 +++++++++------ apps/server/src/server.ts | 3 +-- .../src/routes/workers/managed-providers.ts | 5 +---- .../den-api/test/managed-provider-sync.test.ts | 16 +++++++++------- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/apps/server/src/managed-provider-sync.e2e.test.ts b/apps/server/src/managed-provider-sync.e2e.test.ts index 8c8d9f65d9..e6ce13610a 100644 --- a/apps/server/src/managed-provider-sync.e2e.test.ts +++ b/apps/server/src/managed-provider-sync.e2e.test.ts @@ -32,7 +32,7 @@ function providerPayload() { credentialKind: "api_key", providerConfig: { id: "anthropic", name: "Anthropic", env: ["ANTHROPIC_API_KEY"], npm: "@ai-sdk/anthropic" }, models: [{ id: "claude", name: "Claude", config: { id: "claude", limit: { context: 200000 } } }], - apiKey: "sk-server-secret", + apiKey: "plain-server-secret", revision: "provider-rev-1", }, { @@ -63,7 +63,7 @@ async function boot(options: { failAuth?: boolean } = {}) { const url = new URL(request.url); if (url.pathname.startsWith("/auth/")) { authCalls.push(await request.json()); - if (options.failAuth) return Response.json({ error: "bad sk-server-secret" }, { status: 500 }); + if (options.failAuth) return Response.json({ error: "bad plain-server-secret access-secret refresh-secret" }, { status: 500 }); return Response.json({ ok: true }); } return Response.json({ ok: true }); @@ -129,16 +129,16 @@ describe("managed provider sync runtime route", () => { 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("sk-server-secret"); + 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(/llmProvider_den_anthropic/g)?.length).toBe(1); expect(config.match(/"openai"/g)?.length).toBeGreaterThanOrEqual(1); - expect(config).not.toContain("sk-server-secret"); + expect(config).not.toContain("plain-server-secret"); expect(authCalls).toHaveLength(4); - expect(JSON.stringify(authCalls[0])).toContain("sk-server-secret"); + expect(JSON.stringify(authCalls[0])).toContain("plain-server-secret"); expect(JSON.stringify(authCalls[1])).toContain("refresh-secret"); }); @@ -152,6 +152,9 @@ describe("managed provider sync runtime route", () => { expect(response.status).toBe(502); const body = await response.json(); expect(body.status).toBe("failed"); - expect(JSON.stringify(body)).not.toContain("sk-server-secret"); + 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"); }); }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 778d9fb05f..c772998a2f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -3730,8 +3730,7 @@ async function applyManagedProviderAuth(config: ServerConfig, workspace: Workspa } function sanitizeManagedProviderApplyError(error: unknown) { - const message = error instanceof ApiError || error instanceof Error ? error.message : "Managed provider sync failed"; - return message.replace(/sk-[A-Za-z0-9_-]+/g, "[redacted]").slice(0, 300); + return "Managed provider sync failed"; } function resolveOpencodeConfigFilePath(scope: "project" | "global", workspaceRoot: string): string { diff --git a/ee/apps/den-api/src/routes/workers/managed-providers.ts b/ee/apps/den-api/src/routes/workers/managed-providers.ts index 7a94ec0cd9..4363d324be 100644 --- a/ee/apps/den-api/src/routes/workers/managed-providers.ts +++ b/ee/apps/den-api/src/routes/workers/managed-providers.ts @@ -65,10 +65,7 @@ export function computeManagedProviderRevision(providers: Pick - const message = typeof record.message === "string" ? record.message : typeof record.error === "string" ? record.error : "Worker provider sync failed." - return message.replace(/sk-[A-Za-z0-9_-]+/g, "[redacted]").slice(0, 300) + return "Worker provider sync failed." } export async function listManagedProviderSyncProviders(organizationId: OrganizationId) { diff --git a/ee/apps/den-api/test/managed-provider-sync.test.ts b/ee/apps/den-api/test/managed-provider-sync.test.ts index b10d0cf478..0136c1f210 100644 --- a/ee/apps/den-api/test/managed-provider-sync.test.ts +++ b/ee/apps/den-api/test/managed-provider-sync.test.ts @@ -36,7 +36,7 @@ function createApp(input: { 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: "sk-secret-den-test", + apiKey: "plain-provider-secret-den-test", revision: "rev-1", } managedProviderModule.registerManagedProviderSyncRoutes(app as never, { @@ -76,21 +76,23 @@ test("managed provider sync sends credentials only to worker runtime and redacts 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("sk-secret") - expect(JSON.stringify(calls[0])).toContain("sk-secret-den-test") + 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 sk-secret-den-test" } }), + 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("sk-secret") - expect(body.reason).toContain("[redacted]") + 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 reports missing worker as not found", async () => { @@ -102,5 +104,5 @@ test("managed provider sync reports missing worker as not found", async () => { 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 sk-live-secret" })).toBe("bad [redacted]") + expect(managedProviderModule.sanitizeManagedProviderSyncFailure({ message: "bad plain-secret access-token refresh-token" })).toBe("Worker provider sync failed.") }) From afa4b02b7785c5e39843543fe01974b48b1213a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 23 May 2026 02:25:31 +0200 Subject: [PATCH 3/5] fix(server): restore managed provider auth apply --- apps/server/src/server.ts | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index c772998a2f..72a26b7126 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -580,6 +580,46 @@ 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 header = request.headers.get("authorization") ?? ""; const match = header.match(/^Bearer\s+(.+)$/i); From 475c0b1aafffeb31b5d1ec9e196c1ddfc2a63d61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 23 May 2026 06:32:34 +0200 Subject: [PATCH 4/5] fix(den): treat empty provider sync as applied --- .../src/routes/workers/managed-providers.ts | 4 ++++ .../test/managed-provider-sync.test.ts | 19 ++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/ee/apps/den-api/src/routes/workers/managed-providers.ts b/ee/apps/den-api/src/routes/workers/managed-providers.ts index 4363d324be..ae0ed7716d 100644 --- a/ee/apps/den-api/src/routes/workers/managed-providers.ts +++ b/ee/apps/den-api/src/routes/workers/managed-providers.ts @@ -148,6 +148,10 @@ export function registerManagedProviderSyncRoutes(app: Hono<{ Variables: WorkerR const providers = await listProviders(normalizedOrgId) const revision = computeManagedProviderRevision(providers) + if (providers.length === 0) { + return c.json({ status: "applied", providerCount: 0, revision }) + } + const runtime = await pushRuntime(worker.id, { providers, revision }) if (!runtime.ok) { return c.json({ diff --git a/ee/apps/den-api/test/managed-provider-sync.test.ts b/ee/apps/den-api/test/managed-provider-sync.test.ts index 0136c1f210..20b52ebffa 100644 --- a/ee/apps/den-api/test/managed-provider-sync.test.ts +++ b/ee/apps/den-api/test/managed-provider-sync.test.ts @@ -23,6 +23,7 @@ beforeAll(async () => { function createApp(input: { role?: string isOwner?: boolean + listProviders?: Parameters[1]["listProviders"] pushRuntime?: Parameters[1]["pushRuntime"] }) { const app = new Hono() @@ -52,7 +53,7 @@ function createApp(input: { paramValidator(workersSharedModule.workerIdParamSchema), ] as never, getWorker: async (id, activeOrgId) => id === workerId && activeOrgId === orgId ? { id } : null, - listProviders: async () => [provider], + listProviders: input.listProviders ?? (async () => [provider]), pushRuntime: input.pushRuntime ?? (async () => ({ ok: true, status: 200, payload: { status: "applied" } })), }) return { app, workerId, provider } @@ -95,6 +96,22 @@ test("managed provider sync sanitizes worker failures", async () => { expect(body.reason).toBe("Worker provider sync failed.") }) +test("managed provider sync treats an empty provider set as applied without calling worker", async () => { + let called = false + const { app, workerId } = createApp({ + listProviders: async () => [], + pushRuntime: async () => { + called = true + return { ok: false, status: 500, payload: { message: "should not be called" } } + }, + }) + + 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(false) +}) + test("managed provider sync reports missing worker as not found", async () => { const { app } = createApp({ role: "admin" }) const missingWorker = createDenTypeId("worker") From 51df20a58e105014109799bcb7d501d1c3e07e25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 23 May 2026 17:11:54 +0200 Subject: [PATCH 5/5] fix: type worker organization context Include organization context variables in worker route typing so managed provider sync typechecks without changing runtime behavior. --- ee/apps/den-api/src/routes/workers/shared.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ee/apps/den-api/src/routes/workers/shared.ts b/ee/apps/den-api/src/routes/workers/shared.ts index 9bd16c8c48..cc38273e9d 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 { getCloudWorkerBillingStatus, requireCloudWorkerAccess, setCloudWorkerSubscriptionCancellation } from "../../billing/polar.js" import { db } from "../../db.js" import { env } from "../../env.js" -import type { UserOrganizationsContext } from "../../middleware/index.js" +import type { OrganizationContextVariables, UserOrganizationsContext } from "../../middleware/index.js" import { denTypeIdSchema } from "../../openapi.js" import type { AuthContextVariables } from "../../session.js" import { deprovisionWorker, provisionWorker } from "../../workers/provisioner.js" @@ -59,7 +59,7 @@ export const workerIdParamSchema = z.object({ id: denTypeIdSchema("worker"), }) -export type WorkerRouteVariables = AuthContextVariables & Partial +export type WorkerRouteVariables = AuthContextVariables & Partial & Partial type WorkerRow = typeof WorkerTable.$inferSelect type WorkerInstanceRow = typeof WorkerInstanceTable.$inferSelect