From 286033449f47968ed08c5417f77600759a61e686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 23 May 2026 18:26:03 +0200 Subject: [PATCH 01/23] feat(den): add provider credential contract base Add the LLM provider credential kind/opencode auth storage contract, migration, and passive credential redaction/flags needed by follow-up provider credential and worker sync PRs. --- .../den-api/src/routes/org/llm-providers.ts | 33 ++++++++++++++++--- .../0019_llm_provider_opencode_oauth.sql | 3 ++ ee/packages/den-db/drizzle/meta/_journal.json | 7 ++++ .../src/schema/sharables/llm-providers.ts | 4 +++ 4 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 ee/packages/den-db/drizzle/0019_llm_provider_opencode_oauth.sql 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..994b43565e 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>() .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() From d28b45ffece7ebd7c9730589fc58fde34d2b8495 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 02/23] 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 f592203c489a36bc0d1413a048b8c3491093aa8d 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 03/23] 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 778d9fb05f..394f2a31c6 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 542a3dcdfe8e719b3226451516fac28bb8e41a34 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 04/23] 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 394f2a31c6..72a26b7126 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -3770,8 +3770,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 9776240fa60c5c3cb86421008f7025c9a3f19e63 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 05/23] 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 c53a9cf1ca9a894b640203cdf0ed6fbb3d0503da 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 06/23] 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 From bf09ed323bcd30baa31b6a0774a6d3b46b4fa135 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 07/23] fix(server): restore managed provider auth apply --- apps/server/src/server.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 72a26b7126..d7e2e7640d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -621,6 +621,10 @@ async function fetchOpencodeJson( } 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]; From f7ce71cfd15c8216cc0a83b8b3e768fdf6fcbbcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 27 May 2026 12:49:51 +0200 Subject: [PATCH 08/23] fix: TASK-2026-05-26-017 sanitize managed provider model config Translate Den catalog model metadata through an explicit OpenCode-compatible allowlist before writing managed provider runtime config. Preserve boolean experimental values while dropping incompatible catalog metadata covered by focused regression tests. --- .../src/managed-provider-sync.e2e.test.ts | 7 ++++-- apps/server/src/server.ts | 24 ++++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/server/src/managed-provider-sync.e2e.test.ts b/apps/server/src/managed-provider-sync.e2e.test.ts index e6ce13610a..8e1470145c 100644 --- a/apps/server/src/managed-provider-sync.e2e.test.ts +++ b/apps/server/src/managed-provider-sync.e2e.test.ts @@ -31,7 +31,7 @@ function providerPayload() { 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 } } }], + models: [{ id: "claude", name: "Claude", config: { id: "claude", limit: { context: 200000 }, experimental: true } }], apiKey: "plain-server-secret", revision: "provider-rev-1", }, @@ -42,7 +42,7 @@ function providerPayload() { 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" } }], + models: [{ id: "gpt-5", name: "GPT-5", config: { id: "gpt-5", experimental: { modes: { chat: true } }, knowledge: "2026-01" } }], opencodeAuth: JSON.stringify({ type: "oauth", access: "access-secret", refresh: "refresh-secret", expires: 9 }), revision: "provider-rev-2", }, @@ -136,6 +136,9 @@ describe("managed provider sync runtime route", () => { 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).toContain('"experimental": true'); + expect(config).not.toContain('"modes"'); + expect(config).not.toContain('"knowledge"'); expect(config).not.toContain("plain-server-secret"); expect(authCalls).toHaveLength(4); expect(JSON.stringify(authCalls[0])).toContain("plain-server-secret"); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d7e2e7640d..47d394d582 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -3731,7 +3731,7 @@ export function getManagedProviderRuntimeId(provider: Pick [model.id, { ...model.config, id: model.id, name: model.name }])); + const models = Object.fromEntries(provider.models.map((model) => [model.id, buildManagedProviderModelRuntimeConfig(model)])); const next: Record = { id: provider.providerId, name: provider.name, @@ -3745,6 +3745,28 @@ export function buildManagedProviderRuntimeConfig(provider: ManagedProviderSyncP return next; } +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)); From 2a26ba3ee6aa7a85d20f521b444a0b39f074dea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 27 May 2026 18:25:18 +0200 Subject: [PATCH 09/23] fix: TASK-2026-05-26-020 filter managed OAuth models Filter Den-managed provider-list responses to configured model IDs so OAuth providers keep native auth IDs without exposing the full OpenCode catalog. Adds focused regression coverage for OpenAI OAuth and NVIDIA API-key managed providers. --- .../src/managed-provider-sync.e2e.test.ts | 86 ++++++++++++++-- apps/server/src/server.ts | 98 ++++++++++++++++++- 2 files changed, 172 insertions(+), 12 deletions(-) diff --git a/apps/server/src/managed-provider-sync.e2e.test.ts b/apps/server/src/managed-provider-sync.e2e.test.ts index 8e1470145c..7a26b6415c 100644 --- a/apps/server/src/managed-provider-sync.e2e.test.ts +++ b/apps/server/src/managed-provider-sync.e2e.test.ts @@ -11,6 +11,11 @@ type Served = { stop: (closeActiveConnections?: boolean) => void | Promise; }; +type ProviderListTestItem = { + id: string; + models?: Record; +}; + const HOST_TOKEN = "owt_provider_sync_host_token"; const CLIENT_TOKEN = "owt_provider_sync_client_token"; const stops: Array<() => void | Promise> = []; @@ -25,13 +30,16 @@ function providerPayload() { revision: "sync-rev-1", providers: [ { - id: "llmProvider_den_anthropic", - providerId: "anthropic", - name: "Anthropic", + id: "lpr_den_nvidia", + providerId: "nvidia", + name: "NVIDIA", 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 }, experimental: true } }], + 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", }, @@ -42,7 +50,10 @@ function providerPayload() { 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", experimental: { modes: { chat: true } }, knowledge: "2026-01" } }], + 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", }, @@ -66,6 +77,35 @@ async function boot(options: { failAuth?: boolean } = {}) { if (options.failAuth) return Response.json({ error: "bad plain-server-secret access-secret refresh-secret" }, { status: 500 }); return Response.json({ ok: true }); } + if (url.pathname === "/config/providers") { + return Response.json({ + all: [ + { + id: "lpr_den_nvidia", + name: "NVIDIA", + source: "custom", + models: { + "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" }, + }, + }, + { + id: "openai", + name: "OpenAI", + source: "config", + models: { + "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" }, + }, + }, + ], + connected: ["lpr_den_nvidia", "openai"], + default: { "lpr_den_nvidia": "deepseek-ai/deepseek-v4-flash", openai: "gpt-5.4" }, + }); + } return Response.json({ ok: true }); }, }); @@ -134,8 +174,15 @@ describe("managed provider sync runtime route", () => { } const config = readFileSync(join(workspace, "opencode.jsonc"), "utf8"); - expect(config.match(/llmProvider_den_anthropic/g)?.length).toBe(1); + 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"'); @@ -145,6 +192,31 @@ describe("managed provider sync runtime route", () => { expect(JSON.stringify(authCalls[1])).toContain("refresh-secret"); }); + 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: { "x-openwork-host-token": HOST_TOKEN } }); + expect(response.status).toBe(200); + const body = await response.json() as { all?: ProviderListTestItem[] }; + 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("sanitizes OpenCode auth apply failures", async () => { const { base } = await boot({ failAuth: true }); const response = await fetch(`${base}/managed-providers/sync`, { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 47d394d582..1cbca5da08 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -529,9 +529,101 @@ 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"; +} + +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 allowedModelsByProvider: Map>; + try { + allowedModelsByProvider = await readManagedProviderModelAllowlist(workspaceRoot); + } catch { + return proxyJsonResponse(response, data); + } + if (allowedModelsByProvider.size === 0) return proxyJsonResponse(response, data); + + return proxyJsonResponse(response, filterProviderListModels(data, allowedModelsByProvider)); +} + +async function readManagedProviderModelAllowlist(workspaceRoot: string): Promise>> { + const openwork = await readOpenworkConfig(workspaceRoot); + const managedProviders = openwork.managedProviders; + if (!isRecordValue(managedProviders) || managedProviders.source !== "den") return new Map(); + + const opencode = await readOpencodeConfig(workspaceRoot); + const providers = opencode.provider; + if (!isRecordValue(providers)) return new Map(); + + 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 allowlist; +} + +function filterProviderListModels(data: unknown, allowedModelsByProvider: Map>): unknown { + if (!isRecordValue(data) || !Array.isArray(data.all)) return data; + return { + ...data, + all: data.all.map((provider) => filterProviderListItem(provider, allowedModelsByProvider)), + }; +} + +function filterProviderListItem(provider: unknown, allowedModelsByProvider: Map>): unknown { + if (!isRecordValue(provider) || typeof provider.id !== "string") return provider; + const allowed = allowedModelsByProvider.get(provider.id); + if (!allowed || !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. @@ -540,14 +632,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), }); } From 3690f388748978333603fc957050bed8deeb9d61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 28 May 2026 02:34:03 +0200 Subject: [PATCH 10/23] fix: TASK-2026-05-26-021 handle live provider shapes Apply only product code from the mixed integration commit for the managed provider sync PR branch, excluding workflow and evidence artifacts. --- .../src/managed-provider-sync.e2e.test.ts | 116 ++++++++++++++---- apps/server/src/server.ts | 35 ++++-- 2 files changed, 119 insertions(+), 32 deletions(-) diff --git a/apps/server/src/managed-provider-sync.e2e.test.ts b/apps/server/src/managed-provider-sync.e2e.test.ts index 7a26b6415c..bdd1b54e4c 100644 --- a/apps/server/src/managed-provider-sync.e2e.test.ts +++ b/apps/server/src/managed-provider-sync.e2e.test.ts @@ -16,6 +16,11 @@ type ProviderListTestItem = { models?: Record; }; +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> = []; @@ -61,7 +66,7 @@ function providerPayload() { }; } -async function boot(options: { failAuth?: boolean } = {}) { +async function boot(options: { failAuth?: boolean; providerListShape?: "all" | "providers-array" | "providers-object" } = {}) { const workspace = mkdtempSync(join(tmpdir(), "openwork-managed-provider-workspace-")); const stores = mkdtempSync(join(tmpdir(), "openwork-managed-provider-stores-")); dirs.push(workspace, stores); @@ -78,30 +83,43 @@ async function boot(options: { failAuth?: boolean } = {}) { return Response.json({ ok: true }); } if (url.pathname === "/config/providers") { - return Response.json({ - all: [ - { - id: "lpr_den_nvidia", - name: "NVIDIA", - source: "custom", - models: { - "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 providers = [ + { + id: "lpr_den_nvidia", + name: "NVIDIA", + source: "custom", + models: { + "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" }, }, - { - id: "openai", - name: "OpenAI", - source: "config", - models: { - "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" }, - }, + }, + { + id: "openai", + name: "OpenAI", + source: "config", + models: { + "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" }, }, - ], + }, + ]; + 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: ["lpr_den_nvidia", "openai"], default: { "lpr_den_nvidia": "deepseek-ai/deepseek-v4-flash", openai: "gpt-5.4" }, }); @@ -203,7 +221,7 @@ describe("managed provider sync runtime route", () => { const response = await fetch(`${base}/workspace/ws_1/opencode/config/providers`, { headers: { "x-openwork-host-token": HOST_TOKEN } }); expect(response.status).toBe(200); - const body = await response.json() as { all?: ProviderListTestItem[] }; + 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"); @@ -217,6 +235,56 @@ describe("managed provider sync runtime route", () => { expect(JSON.stringify(body)).not.toContain("refresh-secret"); }); + 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: { "x-openwork-host-token": HOST_TOKEN } }); + 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: { "x-openwork-host-token": HOST_TOKEN } }); + 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 } = await boot({ failAuth: true }); const response = await fetch(`${base}/managed-providers/sync`, { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1cbca5da08..a3fa77847c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -581,16 +581,35 @@ async function readManagedProviderModelAllowlist(workspaceRoot: string): Promise } function filterProviderListModels(data: unknown, allowedModelsByProvider: Map>): unknown { - if (!isRecordValue(data) || !Array.isArray(data.all)) return data; - return { - ...data, - all: data.all.map((provider) => filterProviderListItem(provider, allowedModelsByProvider)), - }; + if (!isRecordValue(data)) return data; + if (Array.isArray(data.all)) { + return { + ...data, + all: data.all.map((provider) => filterProviderListItem(provider, allowedModelsByProvider)), + }; + } + if (Array.isArray(data.providers)) { + return { + ...data, + providers: data.providers.map((provider) => filterProviderListItem(provider, allowedModelsByProvider)), + }; + } + if (isRecordValue(data.providers)) { + return { + ...data, + providers: Object.fromEntries( + Object.entries(data.providers).map(([providerId, provider]) => [providerId, filterProviderListItem(provider, allowedModelsByProvider, providerId)]), + ), + }; + } + return data; } -function filterProviderListItem(provider: unknown, allowedModelsByProvider: Map>): unknown { - if (!isRecordValue(provider) || typeof provider.id !== "string") return provider; - const allowed = allowedModelsByProvider.get(provider.id); +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 || !isRecordValue(provider.models)) return provider; return { ...provider, From 596108c2ed668de9fcbe0b28ff1f3cf81114c461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 28 May 2026 09:47:01 +0200 Subject: [PATCH 11/23] fix: TASK-2026-05-26-024 surface managed provider connections Ensure Den-managed providers remain visible to the desktop model picker when OpenCode returns an empty connected provider list, and cover the static Den regression. --- .../src/managed-provider-sync.e2e.test.ts | 20 +++++++++++++++++-- apps/server/src/server.ts | 20 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/apps/server/src/managed-provider-sync.e2e.test.ts b/apps/server/src/managed-provider-sync.e2e.test.ts index bdd1b54e4c..0b7b2995ec 100644 --- a/apps/server/src/managed-provider-sync.e2e.test.ts +++ b/apps/server/src/managed-provider-sync.e2e.test.ts @@ -66,7 +66,7 @@ function providerPayload() { }; } -async function boot(options: { failAuth?: boolean; providerListShape?: "all" | "providers-array" | "providers-object" } = {}) { +async function boot(options: { failAuth?: boolean; providerListShape?: "all" | "providers-array" | "providers-object"; connected?: string[] } = {}) { const workspace = mkdtempSync(join(tmpdir(), "openwork-managed-provider-workspace-")); const stores = mkdtempSync(join(tmpdir(), "openwork-managed-provider-stores-")); dirs.push(workspace, stores); @@ -120,7 +120,7 @@ async function boot(options: { failAuth?: boolean; providerListShape?: "all" | " } return Response.json({ all: providers, - connected: ["lpr_den_nvidia", "openai"], + connected: options.connected ?? ["lpr_den_nvidia", "openai"], default: { "lpr_den_nvidia": "deepseek-ai/deepseek-v4-flash", openai: "gpt-5.4" }, }); } @@ -235,6 +235,22 @@ describe("managed provider sync runtime route", () => { 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: { "x-openwork-host-token": HOST_TOKEN } }); + 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`, { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e566149e85..9488584e01 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -769,16 +769,19 @@ async function readManagedProviderModelAllowlist(workspaceRoot: string): Promise function filterProviderListModels(data: unknown, allowedModelsByProvider: Map>): unknown { if (!isRecordValue(data)) return data; + const managedProviderIds = new Set(allowedModelsByProvider.keys()); if (Array.isArray(data.all)) { return { ...data, all: data.all.map((provider) => filterProviderListItem(provider, allowedModelsByProvider)), + connected: mergeManagedConnectedProviderIds(data.connected, data.all, managedProviderIds), }; } if (Array.isArray(data.providers)) { return { ...data, providers: data.providers.map((provider) => filterProviderListItem(provider, allowedModelsByProvider)), + connected: mergeManagedConnectedProviderIds(data.connected, data.providers, managedProviderIds), }; } if (isRecordValue(data.providers)) { @@ -787,11 +790,28 @@ function filterProviderListModels(data: unknown, allowedModelsByProvider: Map [providerId, filterProviderListItem(provider, allowedModelsByProvider, providerId)]), ), + connected: mergeManagedConnectedProviderIds(data.connected, Object.keys(data.providers), managedProviderIds), }; } return data; } +function mergeManagedConnectedProviderIds(connected: unknown, providers: unknown[], managedProviderIds: 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")); + 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; From 396af65867fadcd96f9e9cf47c6881bf13410871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 28 May 2026 10:22:56 +0200 Subject: [PATCH 12/23] fix: TASK-2026-05-26-024 apply managed provider auth Use OpenCode's PUT /auth/{providerID} contract with the auth object as the request body so Den-managed API key and OAuth credentials are applied before runtime model invocation. --- apps/server/src/managed-provider-sync.e2e.test.ts | 12 ++++++++---- apps/server/src/server.ts | 4 ++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/server/src/managed-provider-sync.e2e.test.ts b/apps/server/src/managed-provider-sync.e2e.test.ts index 0b7b2995ec..17581297be 100644 --- a/apps/server/src/managed-provider-sync.e2e.test.ts +++ b/apps/server/src/managed-provider-sync.e2e.test.ts @@ -72,13 +72,13 @@ async function boot(options: { failAuth?: boolean; providerListShape?: "all" | " dirs.push(workspace, stores); process.env.OPENWORK_TOKEN_STORE = join(stores, "tokens.json"); - const authCalls: unknown[] = []; + const authCalls: Array<{ method: string; path: string; body: 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()); + authCalls.push({ method: request.method, path: url.pathname, body: await request.json() }); if (options.failAuth) return Response.json({ error: "bad plain-server-secret access-secret refresh-secret" }, { status: 500 }); return Response.json({ ok: true }); } @@ -206,8 +206,12 @@ describe("managed provider sync runtime route", () => { expect(config).not.toContain('"knowledge"'); expect(config).not.toContain("plain-server-secret"); expect(authCalls).toHaveLength(4); - expect(JSON.stringify(authCalls[0])).toContain("plain-server-secret"); - expect(JSON.stringify(authCalls[1])).toContain("refresh-secret"); + expect(authCalls[0]?.method).toBe("PUT"); + expect(authCalls[0]?.path).toBe("/auth/lpr_den_nvidia"); + expect(authCalls[0]?.body).toEqual({ type: "api", key: "plain-server-secret" }); + expect(authCalls[1]?.method).toBe("PUT"); + expect(authCalls[1]?.path).toBe("/auth/openai"); + expect(authCalls[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 () => { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9488584e01..778e90a408 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -4139,8 +4139,8 @@ function parseManagedOpencodeAuth(provider: ManagedProviderSyncProvider): unknow 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) }, + method: "PUT", + body: parseManagedOpencodeAuth(provider), }); } From 9fba3bf3a4240e38da1617f43cd016ebf02eab7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 28 May 2026 23:38:33 +0200 Subject: [PATCH 13/23] fix: TASK-2026-05-28-008 address PR 1939 reviews Add the managed-provider sync 502 OpenAPI response, correct OAuth credential presence reporting, and roll back opencode config writes when runtime auth application fails. --- apps/server/src/managed-provider-sync.e2e.test.ts | 6 ++++-- apps/server/src/server.ts | 9 +++++++++ ee/apps/den-api/src/routes/org/llm-providers.ts | 5 ++++- ee/apps/den-api/src/routes/workers/managed-providers.ts | 1 + 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/server/src/managed-provider-sync.e2e.test.ts b/apps/server/src/managed-provider-sync.e2e.test.ts index 17581297be..b836b6d5ef 100644 --- a/apps/server/src/managed-provider-sync.e2e.test.ts +++ b/apps/server/src/managed-provider-sync.e2e.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -306,7 +306,7 @@ describe("managed provider sync runtime route", () => { }); test("sanitizes OpenCode auth apply failures", async () => { - const { base } = await boot({ failAuth: true }); + const { base, workspace } = await boot({ failAuth: true }); const response = await fetch(`${base}/managed-providers/sync`, { method: "POST", headers: hostAuth(), @@ -319,5 +319,7 @@ describe("managed provider sync runtime route", () => { 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"); }); }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 778e90a408..27693f76da 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1968,6 +1968,10 @@ function createRoutes( } const configFingerprintBefore = await computeReloadFingerprint(workspace.path, "config"); + const opencodeConfigFile = opencodeConfigPath(workspace.path); + const opencodeConfigBefore = existsSync(opencodeConfigFile) + ? await readFile(opencodeConfigFile, "utf8") + : null; const applied: string[] = []; try { for (const provider of payload.providers) { @@ -1976,6 +1980,11 @@ function createRoutes( applied.push(provider.id); } } catch (error) { + if (opencodeConfigBefore === null) { + await rm(opencodeConfigFile, { force: true }); + } else { + await writeFile(opencodeConfigFile, opencodeConfigBefore, "utf8"); + } return jsonResponse({ status: "failed", providerCount: applied.length, 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 994b43565e..5ca15f55d4 100644 --- a/ee/apps/den-api/src/routes/org/llm-providers.ts +++ b/ee/apps/den-api/src/routes/org/llm-providers.ts @@ -946,7 +946,10 @@ export function registerOrgLlmProviderRoutes Date: Fri, 5 Jun 2026 06:16:59 +0200 Subject: [PATCH 14/23] fix: TASK-2026-06-05-001 restore managed provider config writer Import the JSONC path updater used by managed provider sync so runtime config writes succeed before auth is applied and rollback tests remain meaningful. --- apps/server/src/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index a8f6c7c141..c69265b484 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"; From 07474f0196ea3f48bda24ad2e4309013be0e28a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Tue, 9 Jun 2026 23:56:30 +0200 Subject: [PATCH 15/23] fix: TASK-2026-06-09-002 make managed provider sync authoritative Remove stale managed providers and auth during authoritative Den sync, roll back auth writes on failed sync attempts, and filter revoked providers from runtime provider lists. --- .../src/managed-provider-sync.e2e.test.ts | 77 ++++++++- apps/server/src/server.ts | 159 +++++++++++++++--- .../src/routes/workers/managed-providers.ts | 3 - .../test/managed-provider-sync.test.ts | 9 +- 4 files changed, 210 insertions(+), 38 deletions(-) diff --git a/apps/server/src/managed-provider-sync.e2e.test.ts b/apps/server/src/managed-provider-sync.e2e.test.ts index b836b6d5ef..82f7284448 100644 --- a/apps/server/src/managed-provider-sync.e2e.test.ts +++ b/apps/server/src/managed-provider-sync.e2e.test.ts @@ -66,7 +66,7 @@ function providerPayload() { }; } -async function boot(options: { failAuth?: boolean; providerListShape?: "all" | "providers-array" | "providers-object"; connected?: string[] } = {}) { +async function boot(options: { failAuth?: boolean; failAuthPath?: string; providerListShape?: "all" | "providers-array" | "providers-object"; connected?: string[] } = {}) { const workspace = mkdtempSync(join(tmpdir(), "openwork-managed-provider-workspace-")); const stores = mkdtempSync(join(tmpdir(), "openwork-managed-provider-stores-")); dirs.push(workspace, stores); @@ -78,8 +78,9 @@ async function boot(options: { failAuth?: boolean; providerListShape?: "all" | " async fetch(request) { const url = new URL(request.url); if (url.pathname.startsWith("/auth/")) { - authCalls.push({ method: request.method, path: url.pathname, body: await request.json() }); - if (options.failAuth) return Response.json({ error: "bad plain-server-secret access-secret refresh-secret" }, { status: 500 }); + const body = request.method === "DELETE" ? null : await request.json(); + authCalls.push({ method: request.method, path: url.pathname, body }); + if (options.failAuth || url.pathname === options.failAuthPath) return Response.json({ error: "bad plain-server-secret access-secret refresh-secret" }, { status: 500 }); return Response.json({ ok: true }); } if (url.pathname === "/config/providers") { @@ -322,4 +323,74 @@ describe("managed provider sync runtime route", () => { 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: { "x-openwork-host-token": HOST_TOKEN } }); + 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); + }); }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9009b4c264..18316686cf 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1015,6 +1015,11 @@ 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); @@ -1026,25 +1031,32 @@ async function filterManagedProviderListResponse(workspaceRoot: string, response return proxyTextResponse(response, text); } - let allowedModelsByProvider: Map>; + let policy: ManagedProviderAccessPolicy; try { - allowedModelsByProvider = await readManagedProviderModelAllowlist(workspaceRoot); + policy = await readManagedProviderAccessPolicy(workspaceRoot); } catch { return proxyJsonResponse(response, data); } - if (allowedModelsByProvider.size === 0) return proxyJsonResponse(response, data); + if (policy.allowedModelsByProvider.size === 0 && policy.revokedProviderIds.size === 0) return proxyJsonResponse(response, data); + + return proxyJsonResponse(response, filterProviderListModels(data, policy)); +} - return proxyJsonResponse(response, filterProviderListModels(data, allowedModelsByProvider)); +function readStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) : []; } -async function readManagedProviderModelAllowlist(workspaceRoot: string): Promise>> { +async function readManagedProviderAccessPolicy(workspaceRoot: string): Promise { const openwork = await readOpenworkConfig(workspaceRoot); const managedProviders = openwork.managedProviders; - if (!isRecordValue(managedProviders) || managedProviders.source !== "den") return new Map(); + if (!isRecordValue(managedProviders) || managedProviders.source !== "den") { + return { allowedModelsByProvider: new Map(), revokedProviderIds: new Set() }; + } const opencode = await readOpencodeConfig(workspaceRoot); const providers = opencode.provider; - if (!isRecordValue(providers)) return new Map(); + 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)) { @@ -1052,39 +1064,52 @@ async function readManagedProviderModelAllowlist(workspaceRoot: string): Promise const modelIds = Object.keys(providerConfig.models); if (modelIds.length > 0) allowlist.set(providerId, new Set(modelIds)); } - return allowlist; + return { allowedModelsByProvider: allowlist, revokedProviderIds }; } -function filterProviderListModels(data: unknown, allowedModelsByProvider: Map>): unknown { +function filterProviderListModels(data: unknown, policy: ManagedProviderAccessPolicy): unknown { if (!isRecordValue(data)) return data; - const managedProviderIds = new Set(allowedModelsByProvider.keys()); + 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: data.all.map((provider) => filterProviderListItem(provider, allowedModelsByProvider)), - connected: mergeManagedConnectedProviderIds(data.connected, data.all, managedProviderIds), + 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: data.providers.map((provider) => filterProviderListItem(provider, allowedModelsByProvider)), - connected: mergeManagedConnectedProviderIds(data.connected, data.providers, managedProviderIds), + 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: Object.fromEntries( - Object.entries(data.providers).map(([providerId, provider]) => [providerId, filterProviderListItem(provider, allowedModelsByProvider, providerId)]), - ), - connected: mergeManagedConnectedProviderIds(data.connected, Object.keys(data.providers), managedProviderIds), + providers, + connected: mergeManagedConnectedProviderIds(data.connected, Object.keys(providers), managedProviderIds, policy.revokedProviderIds), }; } return data; } -function mergeManagedConnectedProviderIds(connected: unknown, providers: unknown[], managedProviderIds: Set): string[] | undefined { +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) => { @@ -1093,7 +1118,7 @@ function mergeManagedConnectedProviderIds(connected: unknown, providers: unknown return ""; }).filter(Boolean), ); - const next = new Set(connected.filter((id): id is string => typeof id === "string")); + 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); } @@ -2443,12 +2468,25 @@ function createRoutes( 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 authApplied: string[] = []; try { + await applyManagedProviderConfigSet(workspace.path, payload.providers, previousManagedProviderIds); for (const provider of payload.providers) { - await applyManagedProviderConfig(workspace.path, provider); await applyManagedProviderAuth(config, workspace, provider); - applied.push(provider.id); + const providerId = getManagedProviderRuntimeId(provider); + authApplied.push(providerId); + applied.push(providerId); + } + for (const providerId of staleManagedProviderIds) { + await deleteManagedProviderAuth(config, workspace, providerId); } } catch (error) { if (opencodeConfigBefore === null) { @@ -2456,6 +2494,7 @@ function createRoutes( } else { await writeFile(opencodeConfigFile, opencodeConfigBefore, "utf8"); } + await rollbackAppliedManagedProviderAuth(config, workspace, authApplied); return jsonResponse({ status: "failed", providerCount: applied.length, @@ -2465,12 +2504,13 @@ function createRoutes( } await writeOpenworkConfig(workspace.path, { - managedProviders: { - source: "den", - revision: payload.revision, - applied, - appliedAt: new Date().toISOString(), - }, + managedProviders: { + source: "den", + revision: payload.revision, + applied, + revoked: [...revokedManagedProviderIds], + appliedAt: new Date().toISOString(), + }, }, true); await recordAudit(workspace.path, { @@ -5021,6 +5061,46 @@ export function buildManagedProviderRuntimeConfig(provider: ManagedProviderSyncP 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 }; @@ -5071,6 +5151,29 @@ async function applyManagedProviderAuth(config: ServerConfig, workspace: Workspa }); } +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 rollbackAppliedManagedProviderAuth(config: ServerConfig, workspace: WorkspaceInfo, providerIds: string[]) { + await Promise.all(providerIds.map(async (providerId) => { + try { + await deleteManagedProviderAuth(config, workspace, providerId); + } catch { + // Best-effort cleanup. The sync response remains failed and sanitized. + } + })); +} + function sanitizeManagedProviderApplyError(error: unknown) { return "Managed provider sync failed"; } 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 10ed6d29fd..628688cc24 100644 --- a/ee/apps/den-api/src/routes/workers/managed-providers.ts +++ b/ee/apps/den-api/src/routes/workers/managed-providers.ts @@ -149,9 +149,6 @@ 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) { 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 20b52ebffa..717f01d86f 100644 --- a/ee/apps/den-api/test/managed-provider-sync.test.ts +++ b/ee/apps/den-api/test/managed-provider-sync.test.ts @@ -96,20 +96,21 @@ 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 () => { +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 () => { + pushRuntime: async (_workerId, payload) => { called = true - return { ok: false, status: 500, payload: { message: "should not be called" } } + 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(false) + expect(called).toBe(true) }) test("managed provider sync reports missing worker as not found", async () => { From 4a245ad50f298d00fd019beee64e3ffb736c41a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 10 Jun 2026 00:11:19 +0200 Subject: [PATCH 16/23] test: TASK-2026-06-09-002 align provider sync proxy auth Update managed-provider sync proxy assertions to use the normal bearer token path so the tests remain valid after host tokens are limited to host-only routes. --- apps/server/src/managed-provider-sync.e2e.test.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/server/src/managed-provider-sync.e2e.test.ts b/apps/server/src/managed-provider-sync.e2e.test.ts index 82f7284448..6be616c233 100644 --- a/apps/server/src/managed-provider-sync.e2e.test.ts +++ b/apps/server/src/managed-provider-sync.e2e.test.ts @@ -30,6 +30,10 @@ 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", @@ -224,7 +228,7 @@ describe("managed provider sync runtime route", () => { }); expect(sync.status).toBe(200); - const response = await fetch(`${base}/workspace/ws_1/opencode/config/providers`, { headers: { "x-openwork-host-token": HOST_TOKEN } }); + 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 : []; @@ -249,7 +253,7 @@ describe("managed provider sync runtime route", () => { }); expect(sync.status).toBe(200); - const response = await fetch(`${base}/workspace/ws_1/opencode/config/providers`, { headers: { "x-openwork-host-token": HOST_TOKEN } }); + 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[] }; @@ -265,7 +269,7 @@ describe("managed provider sync runtime route", () => { }); expect(sync.status).toBe(200); - const response = await fetch(`${base}/workspace/ws_1/opencode/config/providers`, { headers: { "x-openwork-host-token": HOST_TOKEN } }); + 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 : []; @@ -290,7 +294,7 @@ describe("managed provider sync runtime route", () => { }); expect(sync.status).toBe(200); - const response = await fetch(`${base}/workspace/ws_1/opencode/config/providers`, { headers: { "x-openwork-host-token": HOST_TOKEN } }); + 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 : {}; @@ -348,7 +352,7 @@ describe("managed provider sync runtime route", () => { expect(config).not.toContain('"openai"'); expect(config).not.toContain("gpt-5.4"); - const response = await fetch(`${base}/workspace/ws_1/opencode/config/providers`, { headers: { "x-openwork-host-token": HOST_TOKEN } }); + 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 : []; From fc10acbfe5fd345b4602a80d787a84de31972d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 10 Jun 2026 08:11:26 +0200 Subject: [PATCH 17/23] fix: TASK-2026-06-10-005 defer stale auth deletion Move stale managed-provider auth deletion after config commit so rollback never restores config that references already-deleted stale auth, with regression coverage for deletion failure. --- .../src/managed-provider-sync.e2e.test.ts | 33 +++++++++++++++++-- apps/server/src/server.ts | 19 +++++++++-- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/apps/server/src/managed-provider-sync.e2e.test.ts b/apps/server/src/managed-provider-sync.e2e.test.ts index 6be616c233..3a20953d73 100644 --- a/apps/server/src/managed-provider-sync.e2e.test.ts +++ b/apps/server/src/managed-provider-sync.e2e.test.ts @@ -70,7 +70,7 @@ function providerPayload() { }; } -async function boot(options: { failAuth?: boolean; failAuthPath?: string; providerListShape?: "all" | "providers-array" | "providers-object"; connected?: string[] } = {}) { +async function boot(options: { failAuth?: boolean; failAuthPath?: string; failAuthDeletePath?: string; providerListShape?: "all" | "providers-array" | "providers-object"; connected?: string[] } = {}) { const workspace = mkdtempSync(join(tmpdir(), "openwork-managed-provider-workspace-")); const stores = mkdtempSync(join(tmpdir(), "openwork-managed-provider-stores-")); dirs.push(workspace, stores); @@ -84,7 +84,9 @@ async function boot(options: { failAuth?: boolean; failAuthPath?: string; provid if (url.pathname.startsWith("/auth/")) { const body = request.method === "DELETE" ? null : await request.json(); authCalls.push({ method: request.method, path: url.pathname, body }); - if (options.failAuth || url.pathname === options.failAuthPath) return Response.json({ error: "bad plain-server-secret access-secret refresh-secret" }, { status: 500 }); + if (options.failAuth || url.pathname === options.failAuthPath || (request.method === "DELETE" && url.pathname === options.failAuthDeletePath)) { + return Response.json({ error: "bad plain-server-secret access-secret refresh-secret" }, { status: 500 }); + } return Response.json({ ok: true }); } if (url.pathname === "/config/providers") { @@ -397,4 +399,31 @@ describe("managed provider sync runtime route", () => { 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("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"); + expect(authCalls.some((call) => call.method === "DELETE" && call.path === "/auth/openai")).toBe(true); + }); }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 18316686cf..5d586b85ae 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -2485,9 +2485,6 @@ function createRoutes( authApplied.push(providerId); applied.push(providerId); } - for (const providerId of staleManagedProviderIds) { - await deleteManagedProviderAuth(config, workspace, providerId); - } } catch (error) { if (opencodeConfigBefore === null) { await rm(opencodeConfigFile, { force: true }); @@ -2513,6 +2510,22 @@ function createRoutes( }, }, true); + 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); + } + await recordAudit(workspace.path, { id: shortId(), workspaceId: workspace.id, From 79913b540068f244e8289d5d5562debd9de8fcd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 10 Jun 2026 08:35:58 +0200 Subject: [PATCH 18/23] fix: TASK-2026-06-10-005 retry stale auth cleanup Keep stale managed provider IDs in metadata until auth deletion succeeds so failed stale cleanup remains retryable without restoring config that references deleted auth. --- .../src/managed-provider-sync.e2e.test.ts | 51 ++++++++++++++++++- apps/server/src/server.ts | 11 +++- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/apps/server/src/managed-provider-sync.e2e.test.ts b/apps/server/src/managed-provider-sync.e2e.test.ts index 3a20953d73..3825331964 100644 --- a/apps/server/src/managed-provider-sync.e2e.test.ts +++ b/apps/server/src/managed-provider-sync.e2e.test.ts @@ -70,13 +70,14 @@ function providerPayload() { }; } -async function boot(options: { failAuth?: boolean; failAuthPath?: string; failAuthDeletePath?: string; providerListShape?: "all" | "providers-array" | "providers-object"; connected?: string[] } = {}) { +async function boot(options: { failAuth?: boolean; failAuthPath?: string; failAuthDeletePath?: string; failAuthDeletePathOnce?: string; providerListShape?: "all" | "providers-array" | "providers-object"; connected?: string[] } = {}) { 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 opencode = Bun.serve({ port: 0, async fetch(request) { @@ -84,7 +85,9 @@ async function boot(options: { failAuth?: boolean; failAuthPath?: string; failAu if (url.pathname.startsWith("/auth/")) { const body = request.method === "DELETE" ? null : await request.json(); authCalls.push({ method: request.method, path: url.pathname, body }); - if (options.failAuth || url.pathname === options.failAuthPath || (request.method === "DELETE" && url.pathname === options.failAuthDeletePath)) { + 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 }); } return Response.json({ ok: true }); @@ -157,6 +160,13 @@ async function boot(options: { failAuth?: boolean; failAuthPath?: string; failAu return { base: `http://127.0.0.1:${server.port}`, workspace, authCalls }; } +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; }); @@ -424,6 +434,43 @@ describe("managed provider sync runtime route", () => { 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 5d586b85ae..48d70f70fa 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -2500,16 +2500,19 @@ function createRoutes( }, 502); } - await writeOpenworkConfig(workspace.path, { + const writeManagedProviderSyncMetadata = async (nextApplied: string[]) => writeOpenworkConfig(workspace.path, { managedProviders: { source: "den", revision: payload.revision, - applied, + 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); @@ -2526,6 +2529,10 @@ function createRoutes( }, 502); } + if (staleManagedProviderIds.length > 0) { + await writeManagedProviderSyncMetadata(applied); + } + await recordAudit(workspace.path, { id: shortId(), workspaceId: workspace.id, From c4fcfbf7197bcfa8906372e9e2166bb9ffd1a5ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 10 Jun 2026 13:57:11 +0200 Subject: [PATCH 19/23] fix: TASK-2026-06-10-008 harden managed provider sync Sync only to healthy current worker runtimes and restore prior provider auth on failed runtime credential updates. --- .../src/managed-provider-sync.e2e.test.ts | 45 ++++++++++++++----- apps/server/src/server.ts | 40 +++++++++++++---- ee/apps/den-api/src/routes/workers/shared.ts | 29 +++++++++++- .../test/managed-provider-sync.test.ts | 27 +++++++++++ 4 files changed, 122 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 3825331964..904a77535a 100644 --- a/apps/server/src/managed-provider-sync.e2e.test.ts +++ b/apps/server/src/managed-provider-sync.e2e.test.ts @@ -70,7 +70,7 @@ function providerPayload() { }; } -async function boot(options: { failAuth?: boolean; failAuthPath?: string; failAuthDeletePath?: string; failAuthDeletePathOnce?: string; providerListShape?: "all" | "providers-array" | "providers-object"; connected?: string[] } = {}) { +async function boot(options: { failAuth?: boolean; failAuthPath?: string; failAuthDeletePath?: string; failAuthDeletePathOnce?: string; providerListShape?: "all" | "providers-array" | "providers-object"; 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); @@ -78,18 +78,25 @@ async function boot(options: { failAuth?: boolean; failAuthPath?: string; failAu 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" ? null : await request.json(); + 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") { @@ -157,7 +164,7 @@ async function boot(options: { failAuth?: boolean; failAuthPath?: string; failAu }; const server = await startServer(config) as Served; stops.push(() => server.stop(true)); - return { base: `http://127.0.0.1:${server.port}`, workspace, authCalls }; + return { base: `http://127.0.0.1:${server.port}`, workspace, authCalls, authStore }; } function readManagedProviderMetadata(workspace: string) { @@ -222,13 +229,13 @@ describe("managed provider sync runtime route", () => { expect(config).not.toContain('"modes"'); expect(config).not.toContain('"knowledge"'); expect(config).not.toContain("plain-server-secret"); - expect(authCalls).toHaveLength(4); - expect(authCalls[0]?.method).toBe("PUT"); - expect(authCalls[0]?.path).toBe("/auth/lpr_den_nvidia"); - expect(authCalls[0]?.body).toEqual({ type: "api", key: "plain-server-secret" }); - expect(authCalls[1]?.method).toBe("PUT"); - expect(authCalls[1]?.path).toBe("/auth/openai"); - expect(authCalls[1]?.body).toEqual({ type: "oauth", access: "access-secret", refresh: "refresh-secret", expires: 9 }); + 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 () => { @@ -410,6 +417,24 @@ describe("managed provider sync runtime route", () => { expect(authCalls.some((call) => call.method === "DELETE" && call.path === "/auth/lpr_den_nvidia")).toBe(true); }); + 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("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(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 48d70f70fa..b9e49edd46 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -2476,13 +2476,13 @@ function createRoutes( for (const providerId of currentManagedProviderIds) revokedManagedProviderIds.delete(providerId); const applied: string[] = []; - const authApplied: string[] = []; + const previousAuthByProviderId = new Map(); try { await applyManagedProviderConfigSet(workspace.path, payload.providers, previousManagedProviderIds); for (const provider of payload.providers) { - await applyManagedProviderAuth(config, workspace, provider); const providerId = getManagedProviderRuntimeId(provider); - authApplied.push(providerId); + previousAuthByProviderId.set(providerId, await readManagedProviderAuth(config, workspace, providerId)); + await applyManagedProviderAuth(config, workspace, provider); applied.push(providerId); } } catch (error) { @@ -2491,7 +2491,7 @@ function createRoutes( } else { await writeFile(opencodeConfigFile, opencodeConfigBefore, "utf8"); } - await rollbackAppliedManagedProviderAuth(config, workspace, authApplied); + await rollbackAppliedManagedProviderAuth(config, workspace, previousAuthByProviderId); return jsonResponse({ status: "failed", providerCount: applied.length, @@ -5171,6 +5171,19 @@ async function applyManagedProviderAuth(config: ServerConfig, workspace: Workspa }); } +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)}`, { @@ -5184,12 +5197,23 @@ async function deleteManagedProviderAuth(config: ServerConfig, workspace: Worksp } } -async function rollbackAppliedManagedProviderAuth(config: ServerConfig, workspace: WorkspaceInfo, providerIds: string[]) { - await Promise.all(providerIds.map(async (providerId) => { +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 deleteManagedProviderAuth(config, workspace, providerId); + await restoreManagedProviderAuth(config, workspace, providerId, previousAuth); } catch { - // Best-effort cleanup. The sync response remains failed and sanitized. + // Best-effort restoration. The sync response remains failed and sanitized. } })); } diff --git a/ee/apps/den-api/src/routes/workers/shared.ts b/ee/apps/den-api/src/routes/workers/shared.ts index 22af505323..37742b2e3c 100644 --- a/ee/apps/den-api/src/routes/workers/shared.ts +++ b/ee/apps/den-api/src/routes/workers/shared.ts @@ -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 index 717f01d86f..3898df8ec5 100644 --- a/ee/apps/den-api/test/managed-provider-sync.test.ts +++ b/ee/apps/den-api/test/managed-provider-sync.test.ts @@ -124,3 +124,30 @@ test("managed provider revision is stable and redaction helper removes token-sha 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) +}) From cb2553873ed2365792a60e013f00f558dc08a1e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 10 Jun 2026 14:42:40 +0200 Subject: [PATCH 20/23] fix: TASK-2026-06-10-008 reject duplicate managed provider runtime ids Validate managed provider runtime id uniqueness before mutating config or auth state so rollback snapshots cannot be overwritten by duplicate payload entries. Adds a regression proving duplicate ids fail without touching existing auth. --- .../src/managed-provider-sync.e2e.test.ts | 26 +++++++++++++++++++ apps/server/src/server.ts | 8 ++++++ 2 files changed, 34 insertions(+) diff --git a/apps/server/src/managed-provider-sync.e2e.test.ts b/apps/server/src/managed-provider-sync.e2e.test.ts index 904a77535a..df93a93fd4 100644 --- a/apps/server/src/managed-provider-sync.e2e.test.ts +++ b/apps/server/src/managed-provider-sync.e2e.test.ts @@ -435,6 +435,32 @@ describe("managed provider sync runtime route", () => { 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(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b9e49edd46..60832c9e0e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -5053,6 +5053,14 @@ function parseManagedProviderSyncPayload(input: unknown): ManagedProviderSyncPay 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 }; } From 9e1ee05ed99dc28abc3ad026319d54594c879f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 10 Jun 2026 15:11:55 +0200 Subject: [PATCH 21/23] fix: TASK-2026-06-10-008 guard array provider models Add an explicit array guard around provider-list model filtering and cover array-shaped provider model lists so managed allowlists do not collapse them through numeric Object.entries keys. --- .../src/managed-provider-sync.e2e.test.ts | 63 +++++++++++++++---- apps/server/src/server.ts | 2 +- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/apps/server/src/managed-provider-sync.e2e.test.ts b/apps/server/src/managed-provider-sync.e2e.test.ts index df93a93fd4..9a915a5cdd 100644 --- a/apps/server/src/managed-provider-sync.e2e.test.ts +++ b/apps/server/src/managed-provider-sync.e2e.test.ts @@ -13,7 +13,7 @@ type Served = { type ProviderListTestItem = { id: string; - models?: Record; + models?: unknown; }; type ProviderListTestBody = { @@ -70,7 +70,7 @@ function providerPayload() { }; } -async function boot(options: { failAuth?: boolean; failAuthPath?: string; failAuthDeletePath?: string; failAuthDeletePathOnce?: string; providerListShape?: "all" | "providers-array" | "providers-object"; connected?: string[]; initialAuth?: Record } = {}) { +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); @@ -100,27 +100,42 @@ async function boot(options: { failAuth?: boolean; failAuthPath?: string; failAu 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: { - "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" }, - }, + models: nvidiaModels, }, { id: "openai", name: "OpenAI", source: "config", - models: { - "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" }, - }, + models: openaiModels, }, ]; if (options.providerListShape === "providers-array") { @@ -417,6 +432,28 @@ describe("managed provider sync runtime route", () => { expect(authCalls.some((call) => call.method === "DELETE" && call.path === "/auth/lpr_den_nvidia")).toBe(true); }); + test("preserves array-shaped provider-list models instead of filtering by numeric keys", 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", "gpt-4o", "gpt-5.4-fast", "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({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 60832c9e0e..cc9a780a24 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1130,7 +1130,7 @@ function filterProviderListItem(provider: unknown, allowedModelsByProvider: Map< const providerId = typeof provider.id === "string" ? provider.id : fallbackProviderId; if (!providerId) return provider; const allowed = allowedModelsByProvider.get(providerId); - if (!allowed || !isRecordValue(provider.models)) return provider; + if (!allowed || Array.isArray(provider.models) || !isRecordValue(provider.models)) return provider; return { ...provider, models: Object.fromEntries(Object.entries(provider.models).filter(([modelId]) => allowed.has(modelId))), From eeca524dab186883793487640bb5979dca4e6718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 10 Jun 2026 15:56:05 +0200 Subject: [PATCH 22/23] fix: TASK-2026-06-10-008 filter array provider models Filter array-shaped provider-list models by Den managed-provider allowlists while preserving array shape. Adds regression coverage so allowed model ids remain visible and disallowed ids are removed. --- apps/server/src/managed-provider-sync.e2e.test.ts | 7 +++++-- apps/server/src/server.ts | 9 ++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/server/src/managed-provider-sync.e2e.test.ts b/apps/server/src/managed-provider-sync.e2e.test.ts index 9a915a5cdd..293dbcd427 100644 --- a/apps/server/src/managed-provider-sync.e2e.test.ts +++ b/apps/server/src/managed-provider-sync.e2e.test.ts @@ -432,7 +432,7 @@ describe("managed provider sync runtime route", () => { expect(authCalls.some((call) => call.method === "DELETE" && call.path === "/auth/lpr_den_nvidia")).toBe(true); }); - test("preserves array-shaped provider-list models instead of filtering by numeric keys", async () => { + 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", @@ -450,7 +450,10 @@ describe("managed provider sync runtime route", () => { 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", "gpt-4o", "gpt-5.4-fast", "o4-mini"]); + 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"]); }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index cc9a780a24..a175b65a5d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1130,7 +1130,14 @@ function filterProviderListItem(provider: unknown, allowedModelsByProvider: Map< const providerId = typeof provider.id === "string" ? provider.id : fallbackProviderId; if (!providerId) return provider; const allowed = allowedModelsByProvider.get(providerId); - if (!allowed || Array.isArray(provider.models) || !isRecordValue(provider.models)) return provider; + 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))), From 89b3f9d234174a748266a5d4551ca4014e3bfd8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 10 Jun 2026 18:26:52 +0200 Subject: [PATCH 23/23] fix: TASK-2026-06-10-009 harden managed provider sync --- apps/server/src/server.ts | 30 ++++++++- .../src/routes/workers/managed-providers.ts | 61 ++++++++++++++++--- ee/apps/den-api/src/routes/workers/shared.ts | 4 +- 3 files changed, 83 insertions(+), 12 deletions(-) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index a175b65a5d..8166e2e16a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -2014,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, @@ -2165,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, @@ -2185,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 () => { @@ -2200,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, @@ -2492,6 +2507,15 @@ function createRoutes( 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 }); 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 628688cc24..0928f217c8 100644 --- a/ee/apps/den-api/src/routes/workers/managed-providers.ts +++ b/ee/apps/den-api/src/routes/workers/managed-providers.ts @@ -1,11 +1,11 @@ -import { eq, inArray } from "@openwork-ee/den-db/drizzle" -import { LlmProviderModelTable, LlmProviderTable } from "@openwork-ee/den-db/schema" +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, resolveOrganizationContextMiddleware } from "../../middleware/index.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" @@ -13,6 +13,8 @@ import { fetchWorkerRuntimeJson, getWorkerByIdForOrg, parseWorkerIdParam, type W 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 @@ -68,11 +70,49 @@ export function sanitizeManagedProviderSyncFailure(payload: unknown) { return "Worker provider sync failed." } -export async function listManagedProviderSyncProviders(organizationId: OrganizationId) { +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(eq(LlmProviderTable.organizationId, organizationId)) + .where(accessibleProviderIds + ? and(eq(LlmProviderTable.organizationId, organizationId), inArray(LlmProviderTable.id, accessibleProviderIds)) + : eq(LlmProviderTable.organizationId, organizationId)) const eligible = providers.filter(credentialPresent) if (!eligible.length) return [] @@ -100,7 +140,7 @@ export async function listManagedProviderSyncProviders(organizationId: Organizat } export function registerManagedProviderSyncRoutes(app: Hono<{ Variables: WorkerRouteVariables }>, deps: ManagedProviderRouteDeps = {}) { - const routeMiddlewares = deps.middlewares ?? [requireUserMiddleware, resolveOrganizationContextMiddleware, paramValidator(workerIdParamSchema)] + 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({ @@ -129,6 +169,7 @@ export function registerManagedProviderSyncRoutes(app: Hono<{ Variables: WorkerR 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) @@ -147,7 +188,13 @@ export function registerManagedProviderSyncRoutes(app: Hono<{ Variables: WorkerR const worker = await getWorker(workerId, normalizedOrgId) if (!worker) return c.json({ error: "worker_not_found" }, 404) - const providers = await listProviders(normalizedOrgId) + 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 }) diff --git a/ee/apps/den-api/src/routes/workers/shared.ts b/ee/apps/den-api/src/routes/workers/shared.ts index 37742b2e3c..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 { OrganizationContextVariables, 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 & Partial +export type WorkerRouteVariables = AuthContextVariables & Partial & Partial & Partial type WorkerRow = typeof WorkerTable.$inferSelect type WorkerInstanceRow = typeof WorkerInstanceTable.$inferSelect