From d57efd3963272d613d756d32f09faa46a6cf0eda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 22 May 2026 09:14:32 +0200 Subject: [PATCH 01/88] fix(den-api): build on Windows --- ee/apps/den-api/scripts/build.mjs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/ee/apps/den-api/scripts/build.mjs b/ee/apps/den-api/scripts/build.mjs index 5fe9bab9b9..4cd48ac232 100644 --- a/ee/apps/den-api/scripts/build.mjs +++ b/ee/apps/den-api/scripts/build.mjs @@ -8,7 +8,8 @@ const serviceDir = path.resolve(scriptDir, "..") const repoRoot = path.resolve(serviceDir, "..", "..", "..") const desktopPackagePath = path.join(repoRoot, "apps", "desktop", "package.json") const generatedVersionPath = path.join(serviceDir, "src", "generated", "app-version.ts") -const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm" +const pnpmCommand = "pnpm" +const useShellForPnpm = process.platform === "win32" const fallbackAppVersion = "0.0.0" function readDesktopVersion() { @@ -40,13 +41,24 @@ function writeGeneratedVersionFile(latestAppVersion) { ) } +function quoteShellArg(value) { + return `"${String(value).replace(/"/g, '\\"')}"` +} + function run(command, args) { - const result = spawnSync(command, args, { + const shellCommand = [command, ...args.map(quoteShellArg)].join(" ") + const result = spawnSync(useShellForPnpm ? shellCommand : command, useShellForPnpm ? [] : args, { cwd: serviceDir, env: process.env, stdio: "inherit", + shell: useShellForPnpm, }) + if (result.error) { + console.error(result.error) + process.exit(1) + } + if (result.status !== 0) { process.exit(result.status ?? 1) } From 636c597213bd9fdb64236679f87a62cc6412d83d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 22 May 2026 09:16:44 +0200 Subject: [PATCH 02/88] feat(desktop): support managed bootstrap state --- apps/app/src/app/lib/den.ts | 57 +- apps/app/src/app/lib/desktop-types.ts | 3 + apps/app/src/app/lib/openwork-server.ts | 26 +- apps/app/src/app/lib/workspace-endpoint.ts | 13 +- .../settings/pages/cloud-workers-view.tsx | 104 +- .../workspace/create-workspace-modal.tsx | 17 +- .../src/react-app/domains/workspace/types.ts | 3 + .../use-remote-workspace-connection-editor.ts | 2 +- .../src/react-app/shell/settings-route.tsx | 46 +- .../src/react-app/shell/workspace-provider.ts | 12 +- apps/desktop/electron/bootstrap-config.mjs | 101 ++ .../electron/bootstrap-config.test.mjs | 67 ++ apps/desktop/electron/browser-mcp.mjs | 373 +++++++ .../desktop/electron/browser-native-tools.mjs | 906 ++++++++++++++++++ apps/desktop/electron/main.cjs | 6 + apps/desktop/electron/main.mjs | 448 +++++---- .../desktop/electron/menu-overlay-preload.mjs | 6 +- apps/desktop/electron/preload.mjs | 6 +- apps/desktop/package.json | 11 +- .../scripts/check-packaged-startup.mjs | 53 + apps/server/src/server.ts | 8 + 21 files changed, 2043 insertions(+), 225 deletions(-) create mode 100644 apps/desktop/electron/bootstrap-config.mjs create mode 100644 apps/desktop/electron/bootstrap-config.test.mjs create mode 100644 apps/desktop/electron/browser-mcp.mjs create mode 100644 apps/desktop/electron/browser-native-tools.mjs create mode 100644 apps/desktop/electron/main.cjs create mode 100644 apps/desktop/scripts/check-packaged-startup.mjs diff --git a/apps/app/src/app/lib/den.ts b/apps/app/src/app/lib/den.ts index ccd40d2290..c46eaf564c 100644 --- a/apps/app/src/app/lib/den.ts +++ b/apps/app/src/app/lib/den.ts @@ -100,6 +100,15 @@ export type DenWorkerTokens = { workspaceId: string | null; }; +export type DenStaticWorkerAttachInput = { + name: string; + description?: string | null; + url: string; + clientToken: string; + hostToken: string; + activityToken?: string | null; +}; + export type DenOrgLlmProviderModel = { id: string; name: string; @@ -113,7 +122,10 @@ export type DenOrgLlmProvider = { providerId: string; name: string; providerConfig: Record; + credentialKind: "api_key" | "opencode_oauth"; hasApiKey: boolean; + hasOpencodeAuth: boolean; + hasCredential: boolean; models: DenOrgLlmProviderModel[]; createdAt: string | null; updatedAt: string | null; @@ -121,6 +133,7 @@ export type DenOrgLlmProvider = { export type DenOrgLlmProviderConnection = DenOrgLlmProvider & { apiKey: string | null; + opencodeAuth: string | null; }; export type DenPluginConfigObjectType = "skill" | "agent" | "command" | "tool" | "mcp" | "hook" | "context" | "custom"; @@ -429,8 +442,20 @@ function syncBootstrapSettingsToLocalStorage(config: DenBootstrapConfig) { return; } + const previousBaseUrl = window.localStorage.getItem(STORAGE_BASE_URL); + const previousOrigin = normalizeDenBaseUrl(previousBaseUrl) ?? ""; + const nextOrigin = normalizeDenBaseUrl(config.baseUrl) ?? ""; + const denOriginChanged = Boolean(previousOrigin && nextOrigin && previousOrigin !== nextOrigin); + window.localStorage.setItem(STORAGE_BASE_URL, config.baseUrl); window.localStorage.setItem(STORAGE_API_BASE_URL, config.apiBaseUrl); + + if (denOriginChanged) { + window.localStorage.removeItem(STORAGE_AUTH_TOKEN); + window.localStorage.removeItem(STORAGE_ACTIVE_ORG_ID); + window.localStorage.removeItem(STORAGE_ACTIVE_ORG_SLUG); + window.localStorage.removeItem(STORAGE_ACTIVE_ORG_NAME); + } } function getPendingBootstrapConfig(next: DenSettings): DenBootstrapConfig | null { @@ -887,7 +912,10 @@ function parseDenOrgLlmProvider(value: unknown): DenOrgLlmProvider | null { providerId: value.providerId, name: value.name, providerConfig: isRecord(value.providerConfig) ? value.providerConfig : {}, + credentialKind: value.credentialKind === "opencode_oauth" ? "opencode_oauth" : "api_key", hasApiKey: value.hasApiKey === true, + hasOpencodeAuth: value.hasOpencodeAuth === true, + hasCredential: value.hasCredential === true || value.hasApiKey === true || value.hasOpencodeAuth === true, models: Array.isArray(value.models) ? value.models.flatMap((model) => { const parsed = parseDenOrgLlmProviderModel(model); @@ -923,6 +951,7 @@ function getDenOrgLlmProviderConnection(payload: unknown): DenOrgLlmProviderConn return { ...provider, apiKey: typeof payload.llmProvider.apiKey === "string" ? payload.llmProvider.apiKey : null, + opencodeAuth: typeof payload.llmProvider.opencodeAuth === "string" ? payload.llmProvider.opencodeAuth : null, }; } @@ -1391,6 +1420,32 @@ export function createDenClient(options: { baseUrl: string; apiBaseUrl?: string return tokens; }, + async attachStaticWorker(orgId: string, input: DenStaticWorkerAttachInput): Promise { + const payload = await requestJson(baseUrls, "/v1/workers/static-attach", { + method: "POST", + token, + organizationId: orgId, + body: { + name: input.name, + description: input.description ?? undefined, + url: input.url, + clientToken: input.clientToken, + hostToken: input.hostToken, + activityToken: input.activityToken ?? undefined, + }, + }); + const workers = getWorkers({ + workers: isRecord(payload) && isRecord(payload.worker) + ? [{ ...payload.worker, instance: isRecord(payload.instance) ? payload.instance : null }] + : [], + }); + const worker = workers[0]; + if (!worker) { + throw new DenApiError(500, "invalid_worker_attach_payload", "Static worker attach response was missing worker details."); + } + return worker; + }, + async listOrgSkills(orgId: string): Promise { const payload = await requestJson(baseUrls, "/v1/skills", { method: "GET", @@ -1464,7 +1519,7 @@ export function createDenClient(options: { baseUrl: string; apiBaseUrl?: string async getOrgLlmProviderConnection(orgId: string, llmProviderId: string): Promise { const payload = await requestJson( baseUrls, - `/v1/llm-providers/${encodeURIComponent(llmProviderId)}/connect`, + `/v1/llm-providers/${encodeURIComponent(llmProviderId)}/import-credential`, { method: "GET", token, diff --git a/apps/app/src/app/lib/desktop-types.ts b/apps/app/src/app/lib/desktop-types.ts index c5351a6768..3d9ab1184f 100644 --- a/apps/app/src/app/lib/desktop-types.ts +++ b/apps/app/src/app/lib/desktop-types.ts @@ -66,6 +66,9 @@ export type WorkspaceInfo = { openworkHostToken?: string | null; openworkWorkspaceId?: string | null; openworkWorkspaceName?: string | null; + openworkDenBaseUrl?: string | null; + openworkDenOrgId?: string | null; + openworkDenWorkerId?: string | null; sandboxBackend?: "docker" | "microsandbox" | null; sandboxRunId?: string | null; sandboxContainerName?: string | null; diff --git a/apps/app/src/app/lib/openwork-server.ts b/apps/app/src/app/lib/openwork-server.ts index 9f09379174..236df3b292 100644 --- a/apps/app/src/app/lib/openwork-server.ts +++ b/apps/app/src/app/lib/openwork-server.ts @@ -389,6 +389,28 @@ export function parseOpenworkWorkspaceIdFromUrl(input: string) { } } +export function stripOpenworkWorkspaceMount(input: string) { + const normalized = normalizeOpenworkServerUrl(input) ?? ""; + if (!normalized) return ""; + + try { + const url = new URL(normalized); + const segments = url.pathname.split("/").filter(Boolean); + const workspaceIndex = segments.indexOf("workspace"); + const legacyIndex = segments.indexOf("w"); + const mountIndex = workspaceIndex >= 0 ? workspaceIndex : legacyIndex; + if (mountIndex >= 0 && segments[mountIndex + 1]) { + const prefix = segments.slice(0, mountIndex).join("/"); + url.pathname = prefix ? `/${prefix}` : "/"; + return url.toString().replace(/\/+$/, ""); + } + } catch { + // Fall through to the normalized value below. + } + + return normalized.replace(/\/+$/, ""); +} + export function buildOpenworkWorkspaceBaseUrl(hostUrl: string, workspaceId?: string | null) { const normalized = normalizeOpenworkServerUrl(hostUrl) ?? ""; if (!normalized) return null; @@ -507,7 +529,7 @@ export function stripOpenworkConnectInviteFromUrl(input: string) { export function readOpenworkServerSettings(): OpenworkServerSettings { if (typeof window === "undefined") return {}; try { - const urlOverride = normalizeOpenworkServerUrl( + const urlOverride = stripOpenworkWorkspaceMount( window.localStorage.getItem(STORAGE_URL_OVERRIDE) ?? "", ); const portRaw = window.localStorage.getItem(STORAGE_PORT_OVERRIDE) ?? ""; @@ -516,7 +538,7 @@ export function readOpenworkServerSettings(): OpenworkServerSettings { const hostToken = window.localStorage.getItem(STORAGE_HOST_AUTH_KEY) ?? undefined; const remoteAccessRaw = window.localStorage.getItem(STORAGE_REMOTE_ACCESS) ?? ""; return { - urlOverride: urlOverride ?? undefined, + urlOverride: urlOverride || undefined, portOverride: Number.isNaN(portOverride) ? undefined : portOverride, token: token?.trim() || undefined, hostToken: hostToken?.trim() || undefined, diff --git a/apps/app/src/app/lib/workspace-endpoint.ts b/apps/app/src/app/lib/workspace-endpoint.ts index 20c0d72979..7cdd132a9d 100644 --- a/apps/app/src/app/lib/workspace-endpoint.ts +++ b/apps/app/src/app/lib/workspace-endpoint.ts @@ -31,6 +31,8 @@ export type ResolvedWorkspaceEndpoint = { baseUrl: string; /** Auth token for that server. May be empty for unauthenticated local servers. */ token: string; + /** Host/admin token for routes that require worker mutation privileges. */ + hostToken: string; /** Workspace id as the owning server expects it in URL paths. No `rem_` prefix. */ workspaceId: string; /** True when the workspace lives on a remote OpenWork worker, not the user's local server. */ @@ -93,13 +95,18 @@ function pickRemoteBaseUrl(workspace: WorkspaceEndpointInput): string { function pickRemoteToken(workspace: WorkspaceEndpointInput): string { if (!workspace) return ""; return ( - workspace.openworkToken ?? workspace.openworkClientToken ?? + workspace.openworkToken ?? workspace.openworkHostToken ?? "" ).trim(); } +function pickRemoteHostToken(workspace: WorkspaceEndpointInput): string { + if (!workspace) return ""; + return (workspace.openworkHostToken ?? "").trim(); +} + /** * Resolve the right server endpoint for a workspace. Returns null when the * workspace can't be reached (remote with no baseUrl, or local with no local @@ -116,10 +123,12 @@ export function resolveWorkspaceEndpoint( const baseUrl = pickRemoteBaseUrl(workspace); if (!baseUrl) return null; const token = pickRemoteToken(workspace); + const hostToken = pickRemoteHostToken(workspace); const workspaceId = workspaceServerId(workspace); const client = createOpenworkServerClient({ baseUrl, token: token || undefined, + hostToken: hostToken || undefined, }); const mountedBaseUrl = ( buildOpenworkWorkspaceBaseUrl(baseUrl, workspaceId) ?? baseUrl @@ -127,6 +136,7 @@ export function resolveWorkspaceEndpoint( return { baseUrl, token, + hostToken, workspaceId, isRemote: true, client, @@ -149,6 +159,7 @@ export function resolveWorkspaceEndpoint( return { baseUrl: localBaseUrl, token: localToken, + hostToken: "", workspaceId, isRemote: false, client, diff --git a/apps/app/src/react-app/domains/settings/pages/cloud-workers-view.tsx b/apps/app/src/react-app/domains/settings/pages/cloud-workers-view.tsx index 55dc4276d6..05baf0a462 100644 --- a/apps/app/src/react-app/domains/settings/pages/cloud-workers-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/cloud-workers-view.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; import { t } from "@/i18n"; import { useStatusToasts } from "../../shell-feedback/status-toasts"; @@ -13,6 +14,11 @@ export type CloudWorkersViewProps = { connectRemoteWorkspace: (input: { openworkHostUrl?: string | null; openworkToken?: string | null; + openworkClientToken?: string | null; + openworkHostToken?: string | null; + openworkDenBaseUrl?: string | null; + openworkDenOrgId?: string | null; + openworkDenWorkerId?: string | null; directory?: string | null; displayName?: string | null; }) => Promise; @@ -23,12 +29,19 @@ export function CloudWorkersView({ connectRemoteWorkspace, onOpenAccount, }: CloudWorkersViewProps) { - const { activeOrganization: activeOrg, authToken, client, isSignedIn, user } = useCloudSession(); + const { activeOrganization: activeOrg, authToken, baseUrl, client, isSignedIn, user } = useCloudSession(); const { showToast } = useStatusToasts(); const [workersBusy, setWorkersBusy] = React.useState(false); const [openingWorkerId, setOpeningWorkerId] = React.useState(null); + const [attachBusy, setAttachBusy] = React.useState(false); const [workers, setWorkers] = React.useState([]); const [workersError, setWorkersError] = React.useState(null); + const [staticWorkerForm, setStaticWorkerForm] = React.useState({ + name: "LAN static worker", + url: "", + clientToken: "", + hostToken: "", + }); const activeOrgId = activeOrg?.id ?? ""; const refreshWorkers = React.useCallback( @@ -84,7 +97,7 @@ export function CloudWorkersView({ try { const tokens = await client.getWorkerTokens(workerId, activeOrgId); const openworkUrl = tokens.openworkUrl?.trim() ?? ""; - const accessToken = tokens.ownerToken?.trim() || tokens.clientToken?.trim() || ""; + const accessToken = tokens.clientToken?.trim() || tokens.ownerToken?.trim() || ""; if (!openworkUrl || !accessToken) { throw new Error(t("den.error_worker_not_ready")); } @@ -92,6 +105,11 @@ export function CloudWorkersView({ const ok = await connectRemoteWorkspace({ openworkHostUrl: openworkUrl, openworkToken: accessToken, + openworkClientToken: tokens.clientToken?.trim() || null, + openworkHostToken: tokens.hostToken?.trim() || null, + openworkDenBaseUrl: baseUrl, + openworkDenOrgId: activeOrgId, + openworkDenWorkerId: workerId, directory: null, displayName: workerName, }); @@ -113,9 +131,50 @@ export function CloudWorkersView({ setOpeningWorkerId(null); } }, - [activeOrgId, client, connectRemoteWorkspace, showToast], + [activeOrgId, baseUrl, client, connectRemoteWorkspace, showToast], ); + const attachStaticWorker = React.useCallback(async () => { + if (!activeOrgId) { + setWorkersError(t("den.error_choose_org")); + return; + } + + const name = staticWorkerForm.name.trim(); + const url = staticWorkerForm.url.trim(); + const clientToken = staticWorkerForm.clientToken.trim(); + const hostToken = staticWorkerForm.hostToken.trim(); + if (!name || !url || !clientToken || !hostToken) { + setWorkersError("Name, URL, client token, and host token are required to attach a static worker."); + return; + } + + setAttachBusy(true); + setWorkersError(null); + try { + const worker = await client.attachStaticWorker(activeOrgId, { + name, + url, + clientToken, + hostToken, + }); + setWorkers((current) => [worker, ...current.filter((entry) => entry.workerId !== worker.workerId)]); + setStaticWorkerForm((current) => ({ ...current, url: "", clientToken: "", hostToken: "" })); + showToast({ + title: `Attached ${worker.workerName}`, + tone: "success", + }); + void refreshWorkers(true); + } catch (error) { + const status = typeof error === "object" && error !== null && "status" in error ? Number((error as { status?: unknown }).status) : null; + setWorkersError(status === 403 + ? "Only organization owners and admins can attach static workers. Ask an operator to register this worker." + : error instanceof Error ? error.message : "Static worker attach failed."); + } finally { + setAttachBusy(false); + } + }, [activeOrgId, client, refreshWorkers, showToast, staticWorkerForm]); + if (!isSignedIn) { return ( @@ -135,6 +194,45 @@ export function CloudWorkersView({ return ( + +
+
+
Admin/operator: attach LAN static worker
+
+ Organization owners and admins can register a pre-running OpenWork worker without manual database changes. The URL and tokens must match the worker container environment. +
+
+
+ setStaticWorkerForm((current) => ({ ...current, name: event.currentTarget.value }))} + placeholder="Worker name" + /> + setStaticWorkerForm((current) => ({ ...current, url: event.currentTarget.value }))} + placeholder="http://192.168.1.50:8787" + /> + setStaticWorkerForm((current) => ({ ...current, clientToken: event.currentTarget.value }))} + placeholder="OPENWORK_TOKEN" + type="password" + /> + setStaticWorkerForm((current) => ({ ...current, hostToken: event.currentTarget.value }))} + placeholder="OPENWORK_HOST_TOKEN" + type="password" + /> +
+
+ +
+
+
({ openworkHostUrl: workspace?.openworkHostUrl ?? workspace?.baseUrl ?? "", openworkToken: - workspace?.openworkToken ?? workspace?.openworkClientToken ?? + workspace?.openworkToken ?? workspace?.openworkHostToken ?? "", directory: workspace?.directory ?? workspace?.path ?? "", diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index b087a47aac..5390019292 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -39,7 +39,6 @@ import { AiSettingsView } from "../domains/settings/pages/ai-view"; // Side-effect imports: register extension config components into the registry. import "../domains/settings/openai-image-gen-config"; import "../domains/settings/ollama-config"; -import "../domains/settings/browser-extension-config"; import { getExtensionConfigSlot, type ExtensionConfigContext } from "../domains/settings/extension-registry"; import { PreferencesView } from "../domains/settings/pages/preferences-view"; import { ShellCustomizationView } from "../domains/settings/pages/shell-view"; @@ -556,6 +555,7 @@ function SettingsRouteContent() { () => selectedWorkspace ? { + ...selectedWorkspace, id: selectedWorkspace.id, name: selectedWorkspace.name ?? selectedWorkspace.displayNameResolved, path: selectedWorkspace.path ?? "", @@ -710,7 +710,13 @@ function SettingsRouteContent() { selectedWorkspaceDisplay: () => routeStateRef.current.selectedWorkspaceDisplay, selectedWorkspaceRoot: () => routeStateRef.current.selectedWorkspaceRoot, runtimeWorkspaceId: () => routeStateRef.current.runtimeWorkspaceId, - openworkServer: openworkServerStore, + openworkServer: { + getSnapshot: () => ({ + openworkServerStatus: routeStateRef.current.openworkServerStatus, + openworkServerClient: routeStateRef.current.openworkServerClient, + openworkServerCapabilities: routeStateRef.current.openworkServerCapabilities, + }), + } as never, setProviders, setProviderDefaults, setProviderConnectedIds, @@ -766,6 +772,20 @@ function SettingsRouteContent() { Object.values(providerAuthSnapshot.importedCloudProviders ?? {}).some(isOpenWorkCloudProvider), [providerAuthSnapshot.cloudOrgProviders, providerAuthSnapshot.importedCloudProviders], ); + + const cloudManagedModelIdsByProvider = useMemo(() => { + const next = new Map>(); + for (const imported of Object.values(providerAuthSnapshot.importedCloudProviders ?? {})) { + const providerId = imported.providerId?.trim(); + if (!providerId) continue; + const modelIds = imported.modelIds + .map((id) => id.trim()) + .filter(Boolean); + if (!modelIds.length) continue; + next.set(providerId, new Set(modelIds)); + } + return next; + }, [providerAuthSnapshot.importedCloudProviders]); const showOpenWorkModelsSubscribe = !cloudSession.isSignedIn || !hasOpenWorkCloudProvider; const subscribeToOpenWorkModels = useCallback(() => { @@ -848,7 +868,11 @@ function SettingsRouteContent() { ); const opencodeBaseUrl = selectedWorkspaceEndpoint?.opencodeBaseUrl ?? ""; const runtimeWorkspaceId = selectedWorkspaceEndpoint?.workspaceId ?? selectedWorkspace?.id ?? null; + const workspaceOpenworkClient = selectedWorkspaceEndpoint?.client ?? openworkClient; routeStateRef.current.runtimeWorkspaceId = runtimeWorkspaceId; + routeStateRef.current.openworkServerClient = workspaceOpenworkClient; + routeStateRef.current.openworkServerStatus = workspaceOpenworkClient ? "connected" : "disconnected"; + routeStateRef.current.openworkServerCapabilities = workspaceOpenworkClient ? ROUTE_OPENWORK_CAPABILITIES : null; const opencodeClient = useMemo(() => { if (!selectedWorkspaceEndpoint || !selectedWorkspaceEndpoint.token) return null; @@ -1089,7 +1113,9 @@ function SettingsRouteContent() { for (const provider of getConnectedProviderItems(data)) { const modelIds = Object.keys(provider.models); const isNew = !seenIds.has(provider.id); + const cloudManagedModelIds = cloudManagedModelIdsByProvider.get(provider.id); for (const id of modelIds) { + if (cloudManagedModelIds && !cloudManagedModelIds.has(id)) continue; const model = provider.models[id]; options.push({ providerID: provider.id, @@ -1103,7 +1129,7 @@ function SettingsRouteContent() { isFree: false, isConnected: true, isRecommended: isNew, - source: /^lpr_/i.test(provider.id) ? "cloud" as const : undefined, + source: cloudManagedModelIds || /^lpr_/i.test(provider.id) ? "cloud" as const : undefined, }); } } @@ -1119,7 +1145,7 @@ function SettingsRouteContent() { return () => { cancelled = true; }; - }, [modelPickerOpen, opencodeBaseUrl, opencodeClient, selectedWorkspaceRoot]); + }, [cloudManagedModelIdsByProvider, modelPickerOpen, opencodeBaseUrl, opencodeClient, selectedWorkspaceRoot]); useEffect(() => { local.setUi((previous) => ({ ...previous, view: "settings", tab: route.tab })); @@ -1750,6 +1776,11 @@ function SettingsRouteContent() { const handleCreateRemoteWorkspace = async (input: { openworkHostUrl?: string | null; openworkToken?: string | null; + openworkClientToken?: string | null; + openworkHostToken?: string | null; + openworkDenBaseUrl?: string | null; + openworkDenOrgId?: string | null; + openworkDenWorkerId?: string | null; directory?: string | null; displayName?: string | null; }) => { @@ -1762,6 +1793,11 @@ function SettingsRouteContent() { baseUrl: baseUrlValue, openworkHostUrl: baseUrlValue, openworkToken: input.openworkToken?.trim() || null, + openworkClientToken: input.openworkClientToken?.trim() || null, + openworkHostToken: input.openworkHostToken?.trim() || null, + openworkDenBaseUrl: input.openworkDenBaseUrl?.trim() || null, + openworkDenOrgId: input.openworkDenOrgId?.trim() || null, + openworkDenWorkerId: input.openworkDenWorkerId?.trim() || null, displayName: input.displayName?.trim() || null, directory: input.directory?.trim() || null, remoteType: "openwork", @@ -2021,7 +2057,7 @@ function SettingsRouteContent() { case "cloud-workers": return ( false} + connectRemoteWorkspace={handleCreateRemoteWorkspace} onOpenAccount={openCloudAccountSettings} /> ); diff --git a/apps/app/src/react-app/shell/workspace-provider.ts b/apps/app/src/react-app/shell/workspace-provider.ts index fc3af36762..78f8970205 100644 --- a/apps/app/src/react-app/shell/workspace-provider.ts +++ b/apps/app/src/react-app/shell/workspace-provider.ts @@ -6,6 +6,7 @@ type WorkspaceContextValue = { client: Client | null; opencodeBaseUrl: string; selectedWorkspaceRoot: string; + cloudManagedModelIdsByProvider: Map>; }; const WorkspaceContext = React.createContext(null); @@ -14,6 +15,7 @@ type WorkspaceProviderProps = { client: Client | null; opencodeBaseUrl?: string; selectedWorkspaceRoot: string; + cloudManagedModelIdsByProvider?: Map>; children: React.ReactNode; }; @@ -21,11 +23,17 @@ export function WorkspaceProvider({ client, opencodeBaseUrl = "", selectedWorkspaceRoot, + cloudManagedModelIdsByProvider, children, }: WorkspaceProviderProps) { const value = React.useMemo( - () => ({ client, opencodeBaseUrl, selectedWorkspaceRoot }), - [client, opencodeBaseUrl, selectedWorkspaceRoot], + () => ({ + client, + opencodeBaseUrl, + selectedWorkspaceRoot, + cloudManagedModelIdsByProvider: cloudManagedModelIdsByProvider ?? new Map>(), + }), + [client, cloudManagedModelIdsByProvider, opencodeBaseUrl, selectedWorkspaceRoot], ); return React.createElement(WorkspaceContext.Provider, { value }, children); diff --git a/apps/desktop/electron/bootstrap-config.mjs b/apps/desktop/electron/bootstrap-config.mjs new file mode 100644 index 0000000000..ee14a94b20 --- /dev/null +++ b/apps/desktop/electron/bootstrap-config.mjs @@ -0,0 +1,101 @@ +import os from "node:os"; +import path from "node:path"; + +export const DEFAULT_DEN_BASE_URL = "https://app.openworklabs.com"; + +export function envFlagEnabled(name, env = process.env) { + const value = env[name]?.trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes" || value === "on"; +} + +function configHomePath({ env = process.env, platform = process.platform, homedir = os.homedir() } = {}) { + if (env.XDG_CONFIG_HOME?.trim()) return env.XDG_CONFIG_HOME.trim(); + if (platform === "win32" && env.APPDATA?.trim()) return env.APPDATA.trim(); + return path.join(homedir, ".config"); +} + +export function managedDesktopBootstrapPath({ env = process.env, platform = process.platform } = {}) { + if (platform === "win32") { + const programData = env.ProgramData?.trim() || env.PROGRAMDATA?.trim() || "C:\\ProgramData"; + return path.join(programData, "OpenWork", "desktop-bootstrap.json"); + } + if (platform === "darwin") { + return path.join("/Library", "Application Support", "OpenWork", "desktop-bootstrap.json"); + } + return path.join("/etc", "openwork", "desktop-bootstrap.json"); +} + +export function userDesktopBootstrapPath(options = {}) { + return path.join(configHomePath(options), "openwork", "desktop-bootstrap.json"); +} + +export function legacyDevDesktopBootstrapPath({ homedir = os.homedir() } = {}) { + return path.join(homedir, ".config", "openwork", "desktop-bootstrap.json"); +} + +export function desktopBootstrapCandidates(options = {}) { + const { env = process.env } = options; + const candidates = []; + const envOverride = env.OPENWORK_DESKTOP_BOOTSTRAP_PATH?.trim(); + if (envOverride) { + candidates.push({ source: "env", path: envOverride }); + } + candidates.push( + { source: "managed", path: managedDesktopBootstrapPath(options) }, + { source: "user", path: userDesktopBootstrapPath(options) }, + ); + const legacyDevPath = legacyDevDesktopBootstrapPath(options); + if (!candidates.some((candidate) => candidate.path === legacyDevPath)) { + candidates.push({ source: "user-dev", path: legacyDevPath }); + } + return candidates; +} + +export function defaultDesktopBootstrapConfig({ env = process.env } = {}) { + return { + baseUrl: DEFAULT_DEN_BASE_URL, + apiBaseUrl: null, + requireSignin: envFlagEnabled("OPENWORK_FORCE_SIGNIN", env), + source: "default", + path: null, + }; +} + +export function normalizeDesktopBootstrapConfig(input, options = {}) { + const baseUrl = typeof input?.baseUrl === "string" ? input.baseUrl.trim() : ""; + if (!baseUrl) throw new Error("baseUrl is required"); + const apiBaseUrl = typeof input?.apiBaseUrl === "string" && input.apiBaseUrl.trim().length > 0 + ? input.apiBaseUrl.trim() + : null; + return { + baseUrl, + apiBaseUrl, + requireSignin: envFlagEnabled("OPENWORK_FORCE_SIGNIN", options.env) || input?.requireSignin === true, + }; +} + +export function normalizeUrlOrigin(input) { + const raw = String(input ?? "").trim(); + if (!raw) return ""; + try { + return new URL(raw).origin.replace(/\/+$/, "").toLowerCase(); + } catch { + return raw.replace(/\/+$/, "").toLowerCase(); + } +} + +export function isWorkspaceCompatibleWithManagedDen(workspace, denBaseUrl) { + if (workspace?.workspaceType !== "remote" || workspace?.remoteType !== "openwork") return true; + const activeDenOrigin = normalizeUrlOrigin(denBaseUrl); + if (!activeDenOrigin) return true; + const workspaceDenOrigin = normalizeUrlOrigin(workspace?.openworkDenBaseUrl); + // A managed Den configuration must not silently reuse legacy remote OpenWork + // records that lack Den-origin metadata; those may point at stale labs/sites. + if (!workspaceDenOrigin) return false; + return workspaceDenOrigin === activeDenOrigin; +} + +export function filterWorkspacesForManagedDen(workspaces, denBaseUrl) { + const input = Array.isArray(workspaces) ? workspaces : []; + return input.filter((workspace) => isWorkspaceCompatibleWithManagedDen(workspace, denBaseUrl)); +} diff --git a/apps/desktop/electron/bootstrap-config.test.mjs b/apps/desktop/electron/bootstrap-config.test.mjs new file mode 100644 index 0000000000..85b2349b57 --- /dev/null +++ b/apps/desktop/electron/bootstrap-config.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; + +import { + desktopBootstrapCandidates, + filterWorkspacesForManagedDen, + managedDesktopBootstrapPath, + normalizeDesktopBootstrapConfig, +} from "./bootstrap-config.mjs"; + +test("desktop bootstrap candidates use env, managed, user/dev, then defaults", () => { + const env = { + OPENWORK_DESKTOP_BOOTSTRAP_PATH: "D:\\managed\\override.json", + ProgramData: "C:\\ProgramData", + APPDATA: "C:\\Users\\Alice\\AppData\\Roaming", + }; + + const candidates = desktopBootstrapCandidates({ + env, + platform: "win32", + homedir: "C:\\Users\\Alice", + }); + + assert.deepEqual(candidates.map((candidate) => candidate.source), [ + "env", + "managed", + "user", + "user-dev", + ]); + assert.equal(candidates[0].path, env.OPENWORK_DESKTOP_BOOTSTRAP_PATH); + assert.equal(candidates[1].path, path.join("C:\\ProgramData", "OpenWork", "desktop-bootstrap.json")); + assert.equal(candidates[2].path, path.join("C:\\Users\\Alice\\AppData\\Roaming", "openwork", "desktop-bootstrap.json")); + assert.equal(candidates[3].path, path.join("C:\\Users\\Alice", ".config", "openwork", "desktop-bootstrap.json")); +}); + +test("windows managed bootstrap defaults to ProgramData without env override", () => { + assert.equal( + managedDesktopBootstrapPath({ env: {}, platform: "win32" }), + path.join("C:\\ProgramData", "OpenWork", "desktop-bootstrap.json"), + ); +}); + +test("normalize desktop bootstrap honors forced sign-in env", () => { + assert.deepEqual( + normalizeDesktopBootstrapConfig( + { baseUrl: " http://den.local:3005 ", apiBaseUrl: "", requireSignin: false }, + { env: { OPENWORK_FORCE_SIGNIN: "true" } }, + ), + { baseUrl: "http://den.local:3005", apiBaseUrl: null, requireSignin: true }, + ); +}); + +test("managed Den filtering rejects ambiguous legacy remote OpenWork workspaces", () => { + const workspaces = [ + { id: "local", workspaceType: "local" }, + { id: "legacy", workspaceType: "remote", remoteType: "openwork", openworkHostUrl: "http://old-worker:8787" }, + { id: "wrong-den", workspaceType: "remote", remoteType: "openwork", openworkDenBaseUrl: "http://old-den:3005" }, + { id: "current-den", workspaceType: "remote", remoteType: "openwork", openworkDenBaseUrl: "http://den.company.local:3005/api/den" }, + { id: "other-remote", workspaceType: "remote", remoteType: "opencode" }, + ]; + + assert.deepEqual( + filterWorkspacesForManagedDen(workspaces, "http://den.company.local:3005").map((workspace) => workspace.id), + ["local", "current-den", "other-remote"], + ); +}); diff --git a/apps/desktop/electron/browser-mcp.mjs b/apps/desktop/electron/browser-mcp.mjs new file mode 100644 index 0000000000..a1a8dfcbef --- /dev/null +++ b/apps/desktop/electron/browser-mcp.mjs @@ -0,0 +1,373 @@ +/** + * In-process browser MCP servers. + * + * Two servers: + * 1. "openwork-browser" — controls the embedded WebContentsView using + * native Electron webContents APIs (no Puppeteer, no app-level CDP). + * 2. "chrome" — connects to the user's external Chrome via Puppeteer/CDP. + * + * Both are exposed as HTTP MCP endpoints that OpenCode connects to as + * remote MCP servers. + */ + +import { createServer } from "node:http"; +import { randomUUID } from "node:crypto"; + +// ── Native built-in browser server ──────────────────────────────────── +import { createNativeBuiltinServer } from "./browser-native-tools.mjs"; + +// ── Chrome DevTools MCP internals (for EXTERNAL Chrome only) ────────── +// IMPORTANT: never import main.js — it runs parseArguments at module load. +import "chrome-devtools-mcp/build/src/polyfill.js"; + +import { + McpServer, + SetLevelRequestSchema, + puppeteer, +} from "chrome-devtools-mcp/build/src/third_party/index.js"; + +import { tools as chromeDevtoolsTools } from "chrome-devtools-mcp/build/src/tools/tools.js"; +import { McpContext } from "chrome-devtools-mcp/build/src/McpContext.js"; +import { McpResponse } from "chrome-devtools-mcp/build/src/McpResponse.js"; +import { Mutex } from "chrome-devtools-mcp/build/src/Mutex.js"; + +// MCP SDK HTTP transport — works with the same McpServer +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; + +// ── Helpers ──────────────────────────────────────────────────────────── + +function noop() {} + +/** Wrap a promise with a timeout. Rejects with a descriptive error. */ +function withTimeout(promise, ms, label) { + let timer; + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label}: timed out after ${ms}ms`)), ms); + }), + ]).finally(() => clearTimeout(timer)); +} + +/** + * Target filter for the EXTERNAL Chrome server — accept all normal pages, + * skip chrome:// and extension pages. + */ +const EXTERNAL_TARGET_FILTER = (target) => { + const url = target.url(); + if (url === "chrome://newtab/") return true; + if (url.startsWith("chrome://") || url.startsWith("chrome-extension://")) return false; + return true; +}; + +async function connectExternalBrowser(browserURL) { + return withTimeout( + puppeteer.connect({ + browserURL, + targetFilter: EXTERNAL_TARGET_FILTER, + defaultViewport: null, + }), + 10_000, + "connectExternalBrowser", + ); +} + +/** + * Create an MCP server backed by chrome-devtools-mcp tools. + * Used ONLY for the external Chrome server. + */ +function createExternalChromeServer({ getBrowser }) { + const server = new McpServer( + { name: "chrome", version: "0.1.0" }, + { capabilities: { logging: {} } }, + ); + + server.server.setRequestHandler(SetLevelRequestSchema, () => ({})); + + const mutex = new Mutex(); + let context = null; + let lastBrowser = null; + + async function getContext() { + const browser = await getBrowser(); + if (!browser?.connected) { + throw new Error("Browser not connected for chrome"); + } + if (browser !== lastBrowser) { + lastBrowser = browser; + context = await McpContext.from(browser, noop, { + experimentalDevToolsDebugging: false, + experimentalIncludeAllPages: false, + performanceCrux: false, + }); + } + return context; + } + + for (const tool of chromeDevtoolsTools) { + server.tool( + tool.name, + tool.description, + tool.schema, + async (params) => { + const guard = await mutex.acquire(); + try { + const ctx = await getContext(); + const response = new McpResponse(); + const TOOL_TIMEOUT = 30_000; + await withTimeout( + tool.handler({ params }, response, ctx), + TOOL_TIMEOUT, + `chrome/${tool.name}`, + ); + const { content } = await response.handle(tool.name, ctx); + return { content }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text", text: `Error: ${msg}` }] }; + } finally { + guard.dispose(); + } + }, + ); + } + + return server; +} + +// ── HTTP wrappers ────────────────────────────────────────────────────── + +/** + * Start an MCP-over-HTTP server on a random localhost port. + * + * Uses one StreamableHTTPServerTransport per session. Each new session + * (no mcp-session-id header) gets its own transport + server instance + * created by the factory. + * + * Returns { port, close }. + */ +async function startMcpHttpServer(mcpServerFactory, preferredPort = 0) { + const sessions = new Map(); + + const httpServer = createServer(async (req, res) => { + try { + const url = new URL(req.url ?? "/", `http://127.0.0.1`); + + if (req.method === "GET" && url.pathname === "/health") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + return; + } + + if (url.pathname !== "/mcp") { + res.writeHead(404); + res.end("Not found"); + return; + } + + const sessionId = req.headers["mcp-session-id"]; + + if (req.method === "POST") { + // Existing session + if (sessionId && sessions.has(sessionId)) { + const transport = sessions.get(sessionId); + await transport.handleRequest(req, res); + return; + } + + // New session — create a fresh transport + server + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id) => { + sessions.set(id, transport); + }, + }); + const server = mcpServerFactory(); + await server.connect(transport); + await transport.handleRequest(req, res); + return; + } + + if (req.method === "GET") { + if (sessionId && sessions.has(sessionId)) { + await sessions.get(sessionId).handleRequest(req, res); + return; + } + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "No session. Send a POST first." })); + return; + } + + if (req.method === "DELETE") { + if (sessionId && sessions.has(sessionId)) { + const transport = sessions.get(sessionId); + sessions.delete(sessionId); + await transport.close(); + } + res.writeHead(200); + res.end(); + return; + } + + res.writeHead(405); + res.end("Method not allowed"); + } catch (err) { + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) })); + } + } + }); + + async function listen(portToTry) { + return new Promise((resolve, reject) => { + httpServer.once("error", reject); + httpServer.listen(portToTry, "127.0.0.1", () => { + const address = httpServer.address(); + resolve(typeof address === "object" && address ? address.port : portToTry); + }); + }); + } + + let port; + try { + port = await listen(preferredPort); + } catch (error) { + if (!preferredPort || error?.code !== "EADDRINUSE") throw error; + port = await listen(0); + } + + return { + port, + close: () => new Promise((resolve) => httpServer.close(resolve)), + }; +} + +// ── Public API ───────────────────────────────────────────────────────── + +/** + * Boot both MCP servers. + * + * @param {object} opts + * @param {Function} opts.getWebContents — () => WebContents | null (active built-in browser tab) + * @param {Function} opts.listTabs — () => BrowserTabInfo[] + * @param {Function} opts.createTab — (url?: string) => tabId + * @param {Function} opts.closeTab — (tabId: string) => tabId | null + * @param {Function} opts.selectTab — (tabId: string) => tabId + * @param {Function} opts.onBuiltinToolCall — called before each built-in browser tool (opens panel) + * @param {Function} opts.onHideBrowser — called to close the browser panel + * @returns {Promise<{ builtinPort: number, externalPort: number, _snapshotReset: () => void, stop: () => Promise }>} + */ +export async function startBrowserMcpServers({ + getWebContents, + listTabs, + createTab, + closeTab, + selectTab, + onBuiltinToolCall, + onHideBrowser, +}) { + let externalBrowser = null; + + // ── Built-in browser: native Electron APIs ──────────────────────── + let builtinSnapshotReset = null; + function createBuiltinFactory() { + const srv = createNativeBuiltinServer({ + getWebContents, + onToolCall: onBuiltinToolCall, + onHideBrowser, + }); + builtinSnapshotReset = /** @type {any} */ (srv)._snapshotReset; + return srv; + } + + // ── External Chrome: Puppeteer + CDP (unchanged) ────────────────── + + async function probeExternalChrome() { + for (const port of [9222, 9229]) { + try { + const res = await fetch(`http://127.0.0.1:${port}/json/version`, { + signal: AbortSignal.timeout(2000), + }); + if (res.ok) return { connected: true, port }; + } catch { /* not available */ } + } + return { connected: false, port: null }; + } + + function createExternalFactory() { + const server = createExternalChromeServer({ + getBrowser: async () => { + if (!externalBrowser?.connected) { + for (const port of [9222, 9229]) { + try { + externalBrowser = await connectExternalBrowser(`http://127.0.0.1:${port}`); + return externalBrowser; + } catch { /* not available */ } + } + throw new Error( + "Chrome is not reachable. " + + "Enable remote debugging in your Chrome: go to chrome://inspect/#remote-debugging and turn it on. " + + "No restart needed on Chrome 144+." + ); + } + return externalBrowser; + }, + }); + + // Diagnostic tool — lets the agent check Chrome availability before + // attempting browsing, so it can guide the user instead of failing. + server.tool( + "chrome_status", + "Check whether the user's real Chrome browser is reachable via remote " + + "debugging. Call this BEFORE using any other chrome tool. If status is " + + "unavailable, tell the user to enable remote debugging in Chrome: " + + "chrome://inspect/#remote-debugging → enable → allow connections. " + + "No Chrome restart is needed on Chrome 144+.", + {}, + async () => { + const probe = await probeExternalChrome(); + if (probe.connected) { + return { + content: [{ + type: "text", + text: JSON.stringify({ + connected: true, + port: probe.port, + hint: "Chrome is reachable. You can now use chrome tools to control the user's browser.", + }), + }], + }; + } + return { + content: [{ + type: "text", + text: JSON.stringify({ + connected: false, + port: null, + hint: "Chrome is not reachable. Ask the user to enable remote debugging: " + + "open chrome://inspect/#remote-debugging in Chrome, enable it, and allow " + + "incoming connections. No restart needed on Chrome 144+. " + + "Alternatively, offer to use the built-in openwork-browser instead.", + }), + }], + }; + }, + ); + + return server; + } + + const builtin = await startMcpHttpServer(createBuiltinFactory, 64883); + const external = await startMcpHttpServer(createExternalFactory, 64884); + + return { + builtinPort: builtin.port, + externalPort: external.port, + _snapshotReset: () => builtinSnapshotReset?.(), + async stop() { + await Promise.all([builtin.close(), external.close()]); + try { externalBrowser?.disconnect(); } catch {} + }, + }; +} diff --git a/apps/desktop/electron/browser-native-tools.mjs b/apps/desktop/electron/browser-native-tools.mjs new file mode 100644 index 0000000000..fdc8714870 --- /dev/null +++ b/apps/desktop/electron/browser-native-tools.mjs @@ -0,0 +1,906 @@ +/** + * Native Electron MCP server for the built-in WebContentsView. + * + * Replaces Puppeteer-over-CDP with direct webContents APIs. + * Minimal CDP is used via webContents.debugger for: + * - Accessibility tree snapshots (Accessibility.getFullAXTree) + * - DOM node resolution for uid-based click/fill (DOM.resolveNode) + * - Input dispatch for drag/key operations (Input.dispatch*) + * - Emulation overrides (Emulation.*) + * + * Everything else uses Electron's native webContents methods: + * - loadURL(), goBack(), goForward(), reload() + * - capturePage() + * - executeJavaScript() + */ + +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Import MCP SDK + zod directly — no chrome-devtools-mcp dependency. +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +// ── Snapshot manager ────────────────────────────────────────────────── +// +// Manages the a11y tree snapshot and uid→backendDOMNodeId mapping. +// Uses webContents.debugger for CDP Accessibility calls (scoped to +// this single WebContentsView, no app-level --remote-debugging-port). + +class NativeSnapshot { + #getWebContents; + #nodes = new Map(); // uid → node data + #snapshotCounter = 0; + #stableIdMap = new Map(); // backendDOMNodeId → uid (stable across snapshots) + #debuggerReady = false; + #attachedWebContents = null; + + constructor(getWebContents) { + this.#getWebContents = getWebContents; + } + + #ensureDebugger() { + const wc = this.#getWebContents(); + if (!wc || wc.isDestroyed()) throw new Error("No browser page available."); + if (this.#attachedWebContents && this.#attachedWebContents !== wc) { + try { this.#attachedWebContents.debugger?.detach(); } catch { /* ok */ } + this.#debuggerReady = false; + this.#attachedWebContents = null; + } + if (!this.#debuggerReady) { + try { + wc.debugger.attach("1.3"); + } catch { + // Already attached — fine + } + this.#debuggerReady = true; + this.#attachedWebContents = wc; + wc.once("destroyed", () => { + if (this.#attachedWebContents === wc) this.#attachedWebContents = null; + this.#debuggerReady = false; + }); + } + return wc; + } + + async take(verbose = false) { + const wc = this.#ensureDebugger(); + await wc.debugger.sendCommand("Accessibility.enable"); + const { nodes: rawNodes } = await wc.debugger.sendCommand( + "Accessibility.getFullAXTree", + ); + + // Build a lookup from CDP nodeId → raw node + const cdpById = new Map(); + for (const n of rawNodes) cdpById.set(n.nodeId, n); + + this.#snapshotCounter++; + const sid = this.#snapshotCounter; + let counter = 0; + this.#nodes.clear(); + const seenBackendIds = new Set(); + + const processNode = (cdpNode) => { + const bid = cdpNode.backendDOMNodeId; + const bidKey = String(bid ?? ""); + + // Re-use stable uid when the same DOM node appears across snapshots + let uid; + if (bidKey && this.#stableIdMap.has(bidKey)) { + uid = this.#stableIdMap.get(bidKey); + } else { + uid = `${sid}_${counter++}`; + if (bidKey) this.#stableIdMap.set(bidKey, uid); + } + if (bidKey) seenBackendIds.add(bidKey); + + const role = cdpNode.role?.value ?? ""; + const name = cdpNode.name?.value ?? ""; + const value = cdpNode.value?.value; + const ignored = cdpNode.ignored ?? false; + + // Extract meaningful properties + const props = {}; + for (const p of cdpNode.properties ?? []) { + if (p.value?.value !== undefined) props[p.name] = p.value.value; + } + + const children = (cdpNode.childIds ?? []) + .map((id) => cdpById.get(id)) + .filter(Boolean) + .map(processNode); + + const node = { uid, role, name, value, ignored, backendDOMNodeId: bid, props, children }; + this.#nodes.set(uid, node); + return node; + }; + + if (!rawNodes[0]) return "Empty page — no accessibility tree."; + const root = processNode(rawNodes[0]); + + // Prune stale mappings + for (const key of this.#stableIdMap.keys()) { + if (!seenBackendIds.has(key)) this.#stableIdMap.delete(key); + } + + return this.#format(root, verbose); + } + + #format(node, verbose, depth = 0) { + if (!node) return ""; + if ((node.ignored || node.role === "none") && !verbose) { + return node.children.map((c) => this.#format(c, verbose, depth)).join(""); + } + + const indent = " ".repeat(depth); + const parts = [`uid=${node.uid}`]; + if (node.role) parts.push(node.role === "none" ? "ignored" : node.role); + if (node.name) parts.push(`"${node.name}"`); + if (node.value !== undefined) parts.push(`value="${node.value}"`); + + for (const [k, v] of Object.entries(node.props)) { + if (typeof v === "boolean" && v) parts.push(k); + else if (typeof v === "string" || typeof v === "number") parts.push(`${k}="${v}"`); + } + + const lines = [indent + parts.join(" ")]; + for (const child of node.children) { + const s = this.#format(child, verbose, depth + 1); + if (s) lines.push(s); + } + return lines.join("\n"); + } + + /** Resolve a snapshot uid to a CDP RemoteObject objectId. */ + async resolveElement(uid) { + if (!this.#nodes.size) { + throw new Error("No snapshot found. Use take_snapshot to capture one."); + } + const node = this.#nodes.get(uid); + if (!node) throw new Error(`No such element found in the snapshot (uid: ${uid}).`); + if (!node.backendDOMNodeId) { + throw new Error(`Element "${uid}" (${node.role}) has no backing DOM node.`); + } + + const wc = this.#ensureDebugger(); + const { object } = await wc.debugger.sendCommand("DOM.resolveNode", { + backendNodeId: node.backendDOMNodeId, + }); + if (!object?.objectId) { + throw new Error(`Element "${uid}" no longer exists on the page.`); + } + return object.objectId; + } + + /** Get node data for a uid (used by upload_file for backendDOMNodeId). */ + getNodeData(uid) { + return this.#nodes.get(uid); + } + + /** Reset snapshot state. Call when the WebContentsView is destroyed. */ + reset() { + try { this.#attachedWebContents?.debugger?.detach(); } catch { /* ok */ } + this.#debuggerReady = false; + this.#attachedWebContents = null; + this.#nodes.clear(); + this.#stableIdMap.clear(); + } +} + +// ── MCP server factory ──────────────────────────────────────────────── + +/** + * Create an MCP server for the built-in browser using native Electron APIs. + * + * @param {object} opts + * @param {Function} opts.getWebContents - () => active webContents | null + * @param {Function} [opts.listTabs] - () => browser tab info[] + * @param {Function} [opts.createTab] - (url?: string) => tabId + * @param {Function} [opts.closeTab] - (tabId: string) => tabId | null + * @param {Function} [opts.selectTab] - (tabId: string) => tabId + * @param {Function} [opts.onToolCall] - called before each tool + * @param {Function} [opts.onHideBrowser] - called to close the browser panel + * @returns {McpServer} + */ +export function createNativeBuiltinServer({ + getWebContents, + listTabs, + createTab, + closeTab, + selectTab, + onToolCall, + onHideBrowser, +}) { + const server = new McpServer( + { name: "openwork-browser", version: "0.2.0" }, + { capabilities: { logging: {} } }, + ); + + const snap = new NativeSnapshot(getWebContents); + + // Expose reset so main.mjs can call it when the view is destroyed + /** @type {any} */ (server)._snapshotReset = () => snap.reset(); + + function wc() { + const c = getWebContents(); + if (!c || c.isDestroyed()) throw new Error("Built-in browser is not open."); + return c; + } + + function tabs() { + return typeof listTabs === "function" ? listTabs() : []; + } + + function resolveTabId(pageId) { + const availableTabs = tabs(); + if (typeof pageId === "number") { + return availableTabs[pageId - 1]?.tabId ?? null; + } + const id = String(pageId ?? "").trim(); + return availableTabs.some((tab) => tab.tabId === id) ? id : null; + } + + /** Navigate and wait for the page to load. Simple event-based wait — + * the about:blank preload in createBrowserView prevents session-restore races. */ + function navigateAndWait(webContents, url, timeoutMs = 30_000) { + return new Promise((resolve) => { + const timer = setTimeout(resolve, timeoutMs); + const done = () => { clearTimeout(timer); resolve(); }; + webContents.once("did-finish-load", done); + webContents.once("did-fail-load", done); + webContents.loadURL(url); + }); + } + + /** Wait for a navigation action (back/forward/reload) to complete. + * Rejects on timeout so the caller reports the failure honestly. */ + function waitForNav(webContents, timeoutMs = 30_000) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Navigation timed out")), timeoutMs); + const done = () => { clearTimeout(timer); resolve(); }; + webContents.once("did-finish-load", done); + webContents.once("did-fail-load", done); + }); + } + + // Helper: run a tool body inside an error boundary + function defineTool(name, description, schema, handler) { + server.tool(name, description, schema, async (params) => { + try { + await onToolCall?.(name, params); + return await handler(params); + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message ?? err}` }] }; + } + }); + } + + // ── Navigation ──────────────────────────────────────────────────── + + defineTool( + "navigate_page", + "Go to a URL, or back, forward, or reload.", + { + url: z.string().optional().describe("Target URL (only type=url)"), + type: z.enum(["url", "back", "forward", "reload"]).optional() + .describe("Navigate by URL, back/forward in history, or reload."), + timeout: z.number().int().optional() + .describe("Maximum wait time in milliseconds. Default: 30000"), + ignoreCache: z.boolean().optional() + .describe("Whether to ignore cache on reload."), + }, + async (params) => { + const w = wc(); + const type = params.type ?? "url"; + const timeout = params.timeout ?? 30_000; + + if (type === "url") { + const url = String(params.url ?? "").trim(); + if (!url) throw new Error("navigate_page requires a url for type=url"); + await navigateAndWait(w, url, timeout); + } else if (type === "back") { + if (w.navigationHistory?.canGoBack?.() ?? w.canGoBack()) { + const p = waitForNav(w, timeout); + w.goBack(); + await p; + } + } else if (type === "forward") { + if (w.navigationHistory?.canGoForward?.() ?? w.canGoForward()) { + const p = waitForNav(w, timeout); + w.goForward(); + await p; + } + } else if (type === "reload") { + const p = waitForNav(w, timeout); + params.ignoreCache ? w.reloadIgnoringCache() : w.reload(); + await p; + } + + return { content: [{ type: "text", text: `Navigated to ${w.getURL()}` }] }; + }, + ); + + // ── Snapshot ────────────────────────────────────────────────────── + + defineTool( + "take_snapshot", + "Take a text snapshot of the currently selected page based on the a11y tree. " + + "The snapshot lists page elements along with a unique identifier (uid). " + + "Always use the latest snapshot. Prefer taking a snapshot over taking a screenshot.", + { + verbose: z.boolean().optional() + .describe("Include all possible information in the full a11y tree. Default: false."), + filePath: z.string().optional() + .describe("Save snapshot to this path instead of returning inline."), + }, + async (params) => { + const text = await snap.take(params.verbose ?? false); + if (params.filePath) { + await writeFile(params.filePath, text, "utf8"); + return { content: [{ type: "text", text: `Saved snapshot to ${params.filePath}.` }] }; + } + return { content: [{ type: "text", text: "## Latest page snapshot\n" + text }] }; + }, + ); + + // ── Screenshot ──────────────────────────────────────────────────── + + defineTool( + "take_screenshot", + "Take a screenshot of the page or element.", + { + format: z.enum(["png", "jpeg", "webp"]).default("png") + .describe('Format. Default: "png"'), + quality: z.number().min(0).max(100).optional() + .describe("JPEG/WebP quality (0-100). Ignored for PNG."), + uid: z.string().optional() + .describe("Element uid from snapshot. Omit for page screenshot."), + fullPage: z.boolean().optional() + .describe("Full scrollable page screenshot. Incompatible with uid."), + filePath: z.string().optional() + .describe("Save screenshot to this path instead of returning inline."), + }, + async (params) => { + const w = wc(); + if (params.uid && params.fullPage) throw new Error('Cannot use both "uid" and "fullPage".'); + + let imageBuffer; + const fmt = params.format ?? "png"; + + if (params.uid) { + // Element screenshot via bounding rect — clamp to viewport + const objectId = await snap.resolveElement(params.uid); + const { result } = await w.debugger.sendCommand("Runtime.callFunctionOn", { + objectId, + functionDeclaration: `function() { + this.scrollIntoViewIfNeeded(); + const r = this.getBoundingClientRect(); + return JSON.stringify({ + x: Math.max(0, Math.round(r.x)), + y: Math.max(0, Math.round(r.y)), + width: Math.round(Math.min(r.width, window.innerWidth - Math.max(0, r.x))), + height: Math.round(Math.min(r.height, window.innerHeight - Math.max(0, r.y))) + }); + }`, + returnByValue: true, + }); + const rect = JSON.parse(result.value); + if (rect.width > 0 && rect.height > 0) { + const img = await w.capturePage(rect); + imageBuffer = fmt === "jpeg" ? img.toJPEG(params.quality ?? 80) : img.toPNG(); + } else { + // Element not visible — fall back to viewport screenshot + const img = await w.capturePage(); + imageBuffer = fmt === "jpeg" ? img.toJPEG(params.quality ?? 80) : img.toPNG(); + } + } else { + const img = await w.capturePage(); + imageBuffer = fmt === "jpeg" ? img.toJPEG(params.quality ?? 80) : img.toPNG(); + } + + if (params.filePath) { + await writeFile(params.filePath, imageBuffer); + return { content: [{ type: "text", text: `Screenshot saved to ${params.filePath}.` }] }; + } + if (imageBuffer.length >= 2_000_000) { + const p = join(tmpdir(), `openwork-ss-${Date.now()}.${fmt}`); + await writeFile(p, imageBuffer); + return { content: [{ type: "text", text: `Screenshot saved to ${p} (${(imageBuffer.length / 1024) | 0} KB).` }] }; + } + return { content: [{ type: "image", mimeType: `image/${fmt}`, data: imageBuffer.toString("base64") }] }; + }, + ); + + // ── Click ───────────────────────────────────────────────────────── + + defineTool( + "click", + "Clicks on the provided element.", + { + uid: z.string().describe("Element uid from page snapshot"), + dblClick: z.boolean().optional().describe("Double click. Default: false."), + includeSnapshot: z.boolean().optional().describe("Include snapshot in response. Default: false."), + }, + async (params) => { + const objectId = await snap.resolveElement(params.uid); + const w = wc(); + await w.debugger.sendCommand("Runtime.callFunctionOn", { + objectId, + functionDeclaration: `function(dbl) { + this.scrollIntoViewIfNeeded(); + this.click(); + if (dbl) this.click(); + }`, + arguments: [{ value: !!params.dblClick }], + }); + const text = params.dblClick ? "Successfully double clicked on the element" : "Successfully clicked on the element"; + if (params.includeSnapshot) { + return { content: [{ type: "text", text }, { type: "text", text: await snap.take(false) }] }; + } + return { content: [{ type: "text", text }] }; + }, + ); + + // ── Hover ───────────────────────────────────────────────────────── + + defineTool( + "hover", + "Hover over the provided element.", + { + uid: z.string().describe("Element uid from page snapshot"), + includeSnapshot: z.boolean().optional(), + }, + async (params) => { + const objectId = await snap.resolveElement(params.uid); + const w = wc(); + await w.debugger.sendCommand("Runtime.callFunctionOn", { + objectId, + functionDeclaration: `function() { + this.scrollIntoViewIfNeeded(); + this.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); + this.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + }`, + }); + const text = "Successfully hovered over the element"; + if (params.includeSnapshot) { + return { content: [{ type: "text", text }, { type: "text", text: await snap.take(false) }] }; + } + return { content: [{ type: "text", text }] }; + }, + ); + + // ── Fill ────────────────────────────────────────────────────────── + + const FILL_FN = `function(val) { + this.scrollIntoViewIfNeeded(); + this.focus(); + if (this.tagName === 'SELECT') { + const opt = Array.from(this.options).find(o => o.text === val || o.value === val); + if (opt) this.value = opt.value; else this.value = val; + } else { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set + || Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set; + if (setter) setter.call(this, val); else this.value = val; + } + this.dispatchEvent(new Event('input', { bubbles: true })); + this.dispatchEvent(new Event('change', { bubbles: true })); + }`; + + defineTool( + "fill", + "Type text into an input, text area, or select an option from a setStaticWorkerForm((current) => ({ ...current, name: event.currentTarget.value }))} + placeholder="Worker name" + /> + setStaticWorkerForm((current) => ({ ...current, url: event.currentTarget.value }))} + placeholder="http://192.168.1.50:8787" + /> + setStaticWorkerForm((current) => ({ ...current, clientToken: event.currentTarget.value }))} + placeholder="OPENWORK_TOKEN" + type="password" + /> + setStaticWorkerForm((current) => ({ ...current, hostToken: event.currentTarget.value }))} + placeholder="OPENWORK_HOST_TOKEN" + type="password" + /> + +
+ +
+ + ) { + return fetch(`${url}${path}`, { + method: "GET", + redirect: "manual", + headers: { Accept: "application/json", ...headers }, + signal: AbortSignal.timeout(env.staticWorkers.healthcheckTimeoutMs), + }) +} + +function formatIpForUrl(address: string) { + return address.includes(":") ? `[${address}]` : address +} + +export async function fetchPinnedStaticWorker(target: ValidatedStaticWorkerAttachUrl, path: string, headers: Record) { + const original = new URL(target.url) + const resolvedAddress = target.resolvedAddresses[0]?.address + const pinned = resolvedAddress ? new URL(target.url) : original + if (resolvedAddress) { + pinned.hostname = formatIpForUrl(resolvedAddress) + } + const hostHeader = original.port ? `${original.hostname}:${original.port}` : original.hostname + return fetch(`${pinned.toString().replace(/\/+$/, "")}${path}`, { + method: "GET", + redirect: "manual", + headers: { + Accept: "application/json", + ...(resolvedAddress && original.protocol === "http:" ? { Host: hostHeader } : {}), + ...headers, + }, + signal: AbortSignal.timeout(env.staticWorkers.healthcheckTimeoutMs), + }) +} + +export async function assertStaticWorkerReachable(url: string | ValidatedStaticWorkerAttachUrl, clientToken: string, hostToken: string) { + const fetchWorker = typeof url === "string" + ? (path: string, headers: Record) => fetchStaticWorker(url, path, headers) + : (path: string, headers: Record) => fetchPinnedStaticWorker(url, path, headers) + + const clientResponse = await fetchWorker("/workspaces", { + Authorization: `Bearer ${clientToken}`, + }) + + if (!clientResponse.ok) { + throw new Error(`Worker rejected the provided client token with HTTP ${clientResponse.status}`) + } + + const hostResponse = await fetchWorker("/env/keys", { + "X-OpenWork-Host-Token": hostToken, + }) + + if (!hostResponse.ok) { + throw new Error(`Worker rejected the provided host token with HTTP ${hostResponse.status}`) + } +} + +type StaticAttachTx = Pick +type StaticAttachInput = z.infer +type StaticAttachRouteDeps = { + middlewares?: MiddlewareHandler<{ Variables: WorkerRouteVariables }>[] + data?: StaticAttachTx + lookup?: Parameters[2] + fetchReachable?: (url: ValidatedStaticWorkerAttachUrl, clientToken: string, hostToken: string) => Promise + lock?: (run: (tx: StaticAttachTx) => Promise) => Promise + getWorkerLimit?: typeof getOrganizationLimitStatus +} + +async function findActiveStaticWorkerByUrl(tx: Pick, normalizedUrl: string) { + return tx + .select({ id: WorkerInstanceTable.id }) + .from(WorkerInstanceTable) + .where( + and( + eq(WorkerInstanceTable.provider, "static"), + eq(WorkerInstanceTable.url, normalizedUrl), + inArray(WorkerInstanceTable.status, ["provisioning", "healthy"]), + ), + ) + .limit(1) +} + +function staticAttachDuplicateResponse() { + return { + error: "worker_url_already_attached", + message: "This static worker URL is already attached to an active Den worker.", + } +} + +export function registerStaticWorkerAttachRoute(app: Hono<{ Variables: WorkerRouteVariables }>, deps: StaticAttachRouteDeps = {}) { + const routeMiddlewares = deps.middlewares ?? [requireUserMiddleware, resolveOrganizationContextMiddleware, jsonValidator(attachStaticWorkerSchema)] + const data = deps.data ?? db + const fetchReachable = deps.fetchReachable ?? assertStaticWorkerReachable + const lock = deps.lock ?? ((run) => withStaticAssignmentLock(run)) + const getWorkerLimit = deps.getWorkerLimit ?? getOrganizationLimitStatus + + app.post( + "/v1/workers/static-attach", + describeRoute({ + tags: ["Workers"], + summary: "Attach static worker", + description: "Registers a pre-running LAN/OpenWork worker for the active organization using its existing runtime URL and tokens.", + responses: { + 201: jsonResponse("Static worker attached successfully.", staticWorkerAttachResponseSchema), + 400: jsonResponse("The static worker attach payload was invalid.", invalidRequestSchema), + 401: jsonResponse("The caller must be signed in to attach workers.", unauthorizedSchema), + 403: jsonResponse("Only organization owners and admins can attach static workers.", forbiddenSchema), + 409: jsonResponse("The organization has reached its worker limit or the URL is already attached.", orgLimitReachedSchema.or(z.object({ error: z.literal("worker_url_already_attached"), message: z.string() }))), + }, + }), + ...(routeMiddlewares as never[]), + async (c) => { + const user = c.get("user") + const orgId = c.get("activeOrganizationId") + const organizationContext = c.get("organizationContext") + const input = c.req.valid("json" as never) as StaticAttachInput + + if (!user?.id) { + return c.json({ error: "unauthorized" }, 401) + } + + if (!orgId) { + return c.json({ error: "organization_unavailable" }, 400) + } + + const normalizedOrgId = normalizeDenTypeId("organization", orgId) + const normalizedUserId = normalizeDenTypeId("user", user.id) + + if (!organizationContext || !canAttachStaticWorkerForMember(organizationContext)) { + return c.json({ + error: "forbidden", + message: "Only organization owners and admins can attach static workers.", + }, 403) + } + + const validatedUrl = await validateResolvedStaticWorkerAttachUrl(input.url, { + allowPrivate: env.staticWorkers.allowPrivateAttach, + allowedHosts: env.staticWorkers.attachAllowedHosts, + allowedCidrs: env.staticWorkers.attachAllowedCidrs, + }, deps.lookup) + if (!validatedUrl.ok) { + return c.json({ error: "invalid_request", message: validatedUrl.message }, 400) + } + + const normalizedUrl = validatedUrl.url + const existing = await findActiveStaticWorkerByUrl(data, normalizedUrl) + if (existing.length > 0) { + return c.json(staticAttachDuplicateResponse(), 409) + } + + const workerLimit = await getWorkerLimit(normalizedOrgId, "workers") + if (workerLimit.exceeded) { + return c.json({ + error: "org_limit_reached", + limitType: "workers", + limit: workerLimit.limit, + currentCount: workerLimit.currentCount, + message: `This workspace currently supports up to ${workerLimit.limit} workers. Contact support to increase the limit.`, + }, 409) + } + + try { + await fetchReachable(validatedUrl, input.clientToken.trim(), input.hostToken.trim()) + } catch (error) { + return c.json({ + error: "invalid_request", + message: "Static worker verification failed with the provided URL and tokens.", + }, 400) + } + + const workerId = createDenTypeId("worker") + const instanceId = createDenTypeId("workerInstance") + const activityToken = input.activityToken?.trim() || token() + const now = new Date() + + const inserted = await lock(async (tx) => { + const duplicateRows = await findActiveStaticWorkerByUrl(tx, normalizedUrl) + if (duplicateRows.length > 0) { + return false + } + + await tx.insert(WorkerTable).values({ + id: workerId, + org_id: normalizedOrgId, + created_by_user_id: normalizedUserId, + name: input.name, + description: input.description?.trim() || null, + destination: "cloud", + status: "healthy", + image_version: null, + workspace_path: null, + sandbox_backend: "static", + } as never) + + await tx.insert(WorkerTokenTable).values([ + { + id: createDenTypeId("workerToken"), + worker_id: workerId, + scope: "host", + token: input.hostToken.trim(), + }, + { + id: createDenTypeId("workerToken"), + worker_id: workerId, + scope: "client", + token: input.clientToken.trim(), + }, + { + id: createDenTypeId("workerToken"), + worker_id: workerId, + scope: "activity", + token: activityToken, + }, + ] as never) + + await tx.insert(WorkerInstanceTable).values({ + id: instanceId, + worker_id: workerId, + provider: "static", + region: "on-prem", + url: normalizedUrl, + status: "healthy", + } as never) + return true + }) + + if (!inserted) { + return c.json(staticAttachDuplicateResponse(), 409) + } + + return c.json({ + worker: toWorkerResponse( + { + id: workerId, + org_id: normalizedOrgId, + created_by_user_id: normalizedUserId, + name: input.name, + description: input.description?.trim() || null, + destination: "cloud", + status: "healthy", + image_version: null, + workspace_path: null, + sandbox_backend: "static", + last_heartbeat_at: null, + last_active_at: null, + created_at: now, + updated_at: now, + }, + normalizedUserId, + ), + instance: toInstanceResponse({ + id: instanceId, + worker_id: workerId, + provider: "static", + region: "on-prem", + url: normalizedUrl, + status: "healthy", + created_at: now, + updated_at: now, + }), + launch: { mode: "attached", pollAfterMs: 0 }, + }, 201) + }, + ) +} + export function registerWorkerCoreRoutes(app: Hono) { app.get( "/v1/workers", @@ -324,6 +603,8 @@ export function registerWorkerCoreRoutes) + app.get( "/v1/workers/:id", describeRoute({ diff --git a/ee/apps/den-api/src/routes/workers/shared.ts b/ee/apps/den-api/src/routes/workers/shared.ts index 9bd16c8c48..5bfa9bf4be 100644 --- a/ee/apps/den-api/src/routes/workers/shared.ts +++ b/ee/apps/den-api/src/routes/workers/shared.ts @@ -1,5 +1,7 @@ import { randomBytes } from "node:crypto" -import { and, asc, desc, eq, isNull } from "@openwork-ee/den-db/drizzle" +import { lookup as dnsLookup } from "node:dns/promises" +import { isIP } from "node:net" +import { and, asc, desc, eq, inArray, isNull, sql } from "@openwork-ee/den-db/drizzle" import { AuditEventTable, AuthUserTable, @@ -13,12 +15,18 @@ import { import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid" import { z } from "zod" import { getCloudWorkerBillingStatus, requireCloudWorkerAccess, setCloudWorkerSubscriptionCancellation } from "../../billing/polar.js" -import { db } from "../../db.js" +import { db, dbClient } 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" +import { + checkStaticWorkerHealth, + deprovisionWorker, + normalizeStaticWorkerUrl, + provisionWorker, + selectStaticWorkerUrlFromPool, +} from "../../workers/provisioner.js" import { customDomainForWorker } from "../../workers/vanity-domain.js" export const createWorkerSchema = z.object({ @@ -30,6 +38,15 @@ export const createWorkerSchema = z.object({ imageVersion: z.string().optional(), }) +export const attachStaticWorkerSchema = z.object({ + name: z.string().trim().min(1).max(255), + description: z.string().trim().max(1024).optional(), + url: z.string().url(), + clientToken: z.string().trim().min(1).max(128), + hostToken: z.string().trim().min(1).max(128), + activityToken: z.string().trim().min(1).max(128).optional(), +}) + export const updateWorkerSchema = z.object({ name: z.string().trim().min(1).max(255), }) @@ -60,12 +77,21 @@ export const workerIdParamSchema = z.object({ }) export type WorkerRouteVariables = AuthContextVariables & Partial + & Partial type WorkerRow = typeof WorkerTable.$inferSelect type WorkerInstanceRow = typeof WorkerInstanceTable.$inferSelect export type WorkerId = WorkerRow["id"] type OrgId = typeof MemberTable.$inferSelect.organizationId type UserId = typeof AuthUserTable.$inferSelect.id +type StaticAssignmentDb = Pick +type MySqlLockConnection = { + query: (statement: string, values?: unknown[]) => Promise + release: () => void +} +type MySqlLockPool = { + getConnection: () => Promise +} export const token = () => randomBytes(32).toString("hex") @@ -73,6 +99,262 @@ export function parseWorkerIdParam(value: string): WorkerId { return normalizeDenTypeId("worker", value) } +export function normalizeWorkerRuntimeUrl(value: string): string { + const parsed = new URL(value.trim()) + parsed.hash = "" + parsed.search = "" + parsed.username = "" + parsed.password = "" + parsed.pathname = parsed.pathname.replace(/\/+$/, "") + return parsed.toString().replace(/\/+$/, "") +} + +export type StaticWorkerAttachUrlPolicy = { + allowPrivate: boolean + allowedHosts: readonly string[] + allowedCidrs: readonly string[] +} + +export type DnsLookupAddress = { + address: string + family: 4 | 6 +} + +export type StaticWorkerDnsLookup = (hostname: string) => Promise + +export type ValidatedStaticWorkerAttachUrl = { + ok: true + url: string + resolvedAddresses: DnsLookupAddress[] +} + +export function canAttachStaticWorkerForMember(payload: { currentMember: { isOwner: boolean; role: string } }) { + return payload.currentMember.isOwner || payload.currentMember.role.split(",").map((role) => role.trim()).includes("admin") +} + +function parseIpv4(value: string) { + const parts = value.split(".") + if (parts.length !== 4) { + return null + } + let result = 0 + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) { + return null + } + const octet = Number(part) + if (octet < 0 || octet > 255) { + return null + } + result = (result << 8) + octet + } + return result >>> 0 +} + +function ipv4InCidr(host: string, cidr: string) { + const [base, prefixRaw] = cidr.split("/") + const ip = parseIpv4(host) + const baseIp = parseIpv4(base ?? "") + const prefix = Number(prefixRaw) + if (ip === null || baseIp === null || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) { + return false + } + const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0 + return (ip & mask) === (baseIp & mask) +} + +function parseIpv6(value: string): bigint | null { + const normalized = value.toLowerCase() + const zoneIndex = normalized.indexOf("%") + const withoutZone = zoneIndex === -1 ? normalized : normalized.slice(0, zoneIndex) + const [headRaw, tailRaw, extra] = withoutZone.split("::") + if (extra !== undefined) { + return null + } + + const parsePart = (part: string) => { + if (!part) { + return [] as number[] + } + const entries = part.split(":") + const words: number[] = [] + for (const entry of entries) { + if (!entry) { + return null + } + if (entry.includes(".")) { + const ipv4 = parseIpv4(entry) + if (ipv4 === null) { + return null + } + words.push((ipv4 >>> 16) & 0xffff, ipv4 & 0xffff) + continue + } + if (!/^[0-9a-f]{1,4}$/.test(entry)) { + return null + } + words.push(Number.parseInt(entry, 16)) + } + return words + } + + const head = parsePart(headRaw ?? "") + const tail = parsePart(tailRaw ?? "") + if (!head || !tail) { + return null + } + + const missing = tailRaw === undefined ? 0 : 8 - head.length - tail.length + if (missing < 0) { + return null + } + const words = [...head, ...Array.from({ length: missing }, () => 0), ...tail] + if (words.length !== 8) { + return null + } + + return words.reduce((result, word) => (result << 16n) + BigInt(word), 0n) +} + +function ipv6InCidr(host: string, cidr: string) { + const [base, prefixRaw] = cidr.split("/") + const ip = parseIpv6(host) + const baseIp = parseIpv6(base ?? "") + const prefix = Number(prefixRaw) + if (ip === null || baseIp === null || !Number.isInteger(prefix) || prefix < 0 || prefix > 128) { + return false + } + const hostBits = 128 - prefix + const mask = prefix === 0 ? 0n : ((1n << 128n) - 1n) ^ ((1n << BigInt(hostBits)) - 1n) + return (ip & mask) === (baseIp & mask) +} + +function ipInCidr(host: string, cidr: string) { + return isIP(host) === 4 ? ipv4InCidr(host, cidr) : isIP(host) === 6 ? ipv6InCidr(host, cidr) : false +} + +function isPrivateIpv4(hostname: string) { + const ip = parseIpv4(hostname) + if (ip === null) { + return false + } + return ipv4InCidr(hostname, "10.0.0.0/8") + || ipv4InCidr(hostname, "172.16.0.0/12") + || ipv4InCidr(hostname, "192.168.0.0/16") + || ipv4InCidr(hostname, "127.0.0.0/8") + || ipv4InCidr(hostname, "169.254.0.0/16") + || ip === 0 +} + +function isUnsafeIpv6(hostname: string) { + const ip = parseIpv6(hostname) + if (ip === null) { + return false + } + return ip === 0n + || ip === 1n + || ipv6InCidr(hostname, "fc00::/7") + || ipv6InCidr(hostname, "fe80::/10") + || ipv6InCidr(hostname, "::ffff:0:0/96") +} + +function isUnsafeAddress(hostname: string) { + return isPrivateIpv4(hostname) || isUnsafeIpv6(hostname) +} + +function isLocalHostname(hostname: string) { + const normalized = hostname.toLowerCase() + return normalized === "localhost" || normalized.endsWith(".local") || normalized.endsWith(".localhost") +} + +export function validateStaticWorkerAttachUrl(value: string, policy: StaticWorkerAttachUrlPolicy) { + let parsed: URL + try { + parsed = new URL(value.trim()) + } catch { + return { ok: false as const, message: "Worker URL must be a valid URL." } + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return { ok: false as const, message: "Worker URL must use http or https." } + } + if (parsed.username || parsed.password) { + return { ok: false as const, message: "Worker URL must not include credentials." } + } + if (parsed.search || parsed.hash) { + return { ok: false as const, message: "Worker URL must not include query parameters or fragments." } + } + + const hostname = parsed.hostname.toLowerCase() + const allowedHosts = new Set(policy.allowedHosts.map((host) => host.trim().toLowerCase()).filter(Boolean)) + const hostExplicitlyAllowed = allowedHosts.has(hostname) + const cidrAllowed = policy.allowedCidrs.some((cidr) => ipInCidr(hostname, cidr.trim())) + const privateOrLocal = isUnsafeAddress(hostname) || isLocalHostname(hostname) + + if (privateOrLocal && !policy.allowPrivate && !hostExplicitlyAllowed && !cidrAllowed) { + return { + ok: false as const, + message: "Private and LAN worker URLs require explicit on-prem attach policy or an allowed host/CIDR.", + } + } + + return { ok: true as const, url: normalizeWorkerRuntimeUrl(value), resolvedAddresses: [] as DnsLookupAddress[] } +} + +async function defaultDnsLookup(hostname: string) { + if (isIP(hostname)) { + return [{ address: hostname, family: isIP(hostname) as 4 | 6 }] + } + return dnsLookup(hostname, { all: true }) as Promise +} + +export async function validateResolvedStaticWorkerAttachUrl( + value: string, + policy: StaticWorkerAttachUrlPolicy, + lookup: StaticWorkerDnsLookup = defaultDnsLookup, +) { + const basic = validateStaticWorkerAttachUrl(value, policy) + if (!basic.ok) { + return basic + } + + const parsed = new URL(basic.url) + const hostname = parsed.hostname.toLowerCase() + const allowedHosts = new Set(policy.allowedHosts.map((host) => host.trim().toLowerCase()).filter(Boolean)) + const hostExplicitlyAllowed = allowedHosts.has(hostname) + + let addresses: DnsLookupAddress[] + try { + addresses = await lookup(hostname) + } catch { + return { ok: false as const, message: "Worker URL hostname could not be resolved." } + } + + if (addresses.length === 0) { + return { ok: false as const, message: "Worker URL hostname could not be resolved." } + } + + if (parsed.protocol === "https:" && !isIP(hostname)) { + return { + ok: false as const, + message: "HTTPS static worker attach requires a literal IP address so verification cannot be redirected by DNS rebinding.", + } + } + + for (const entry of addresses) { + const address = entry.address.toLowerCase() + const cidrAllowed = policy.allowedCidrs.some((cidr) => ipInCidr(address, cidr.trim())) + if (isUnsafeAddress(address) && !policy.allowPrivate && !hostExplicitlyAllowed && !cidrAllowed) { + return { + ok: false as const, + message: "Worker URL resolves to a private, loopback, link-local, or metadata address that is not explicitly allowed.", + } + } + } + + return { ...basic, resolvedAddresses: addresses } +} + export function parseUserId(value: string): UserId { return normalizeDenTypeId("user", value) } @@ -81,9 +363,8 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null } -function normalizeUrl(value: string): string { - return value.trim().replace(/\/+$/, "") -} +const normalizeUrl = normalizeWorkerRuntimeUrl +const STATIC_PROVISIONING_LOCK_NAME = "den_static_provisioner_assignment" function parseWorkspaceSelection(payload: unknown): { workspaceId: string; openworkUrl: string } | null { if (!isRecord(payload) || !Array.isArray(payload.items)) { @@ -303,6 +584,168 @@ export async function getLatestWorkerInstance(workerId: WorkerId) { return rows[0] ?? null } +async function getUnavailableStaticWorkerUrls() { + if (env.provisionerMode !== "static") { + return [] + } + + const rows = await db + .select({ url: WorkerInstanceTable.url }) + .from(WorkerInstanceTable) + .where( + and( + eq(WorkerInstanceTable.provider, "static"), + inArray(WorkerInstanceTable.status, ["provisioning", "healthy"]), + ), + ) + + return rows.map((row) => normalizeUrl(row.url)).filter(Boolean) +} + +function staticReservationStaleBefore() { + return new Date(Date.now() - env.staticWorkers.reservationTtlMs) +} + +async function markStaleStaticReservationsFailed(tx: StaticAssignmentDb) { + await tx + .update(WorkerInstanceTable) + .set({ status: "failed" }) + .where( + and( + eq(WorkerInstanceTable.provider, "static"), + eq(WorkerInstanceTable.status, "provisioning"), + sql`${WorkerInstanceTable.updated_at} < ${staticReservationStaleBefore()}`, + ), + ) +} + +export function readMySqlLockAcquired(result: unknown) { + const rows = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : result + if (!Array.isArray(rows)) { + return 0 + } + const first = rows[0] + if (first && typeof first === "object" && "acquired" in first) { + return Number((first as { acquired: unknown }).acquired) + } + return 0 +} + +function getMySqlLockPool(): MySqlLockPool { + if (dbClient && typeof dbClient === "object" && "getConnection" in dbClient && typeof dbClient.getConnection === "function") { + return dbClient as MySqlLockPool + } + throw new Error("Static worker assignment locking requires MySQL DB_MODE") +} + +export async function withStaticAssignmentLockUsing(input: { + pool: MySqlLockPool + transaction: (run: (tx: StaticAssignmentDb) => Promise) => Promise + run: (tx: StaticAssignmentDb) => Promise +}) { + const connection = await input.pool.getConnection() + let lockAcquired = false + + try { + const lockRows = await connection.query(`SELECT GET_LOCK(?, 10) AS acquired`, [STATIC_PROVISIONING_LOCK_NAME]) + const acquired = readMySqlLockAcquired(lockRows) + + if (acquired !== 1) { + throw new Error("Timed out waiting for static worker assignment lock") + } + + lockAcquired = true + return await input.transaction(input.run) + } finally { + if (lockAcquired) { + await connection.query(`SELECT RELEASE_LOCK(?)`, [STATIC_PROVISIONING_LOCK_NAME]) + } + connection.release() + } +} + +export async function withStaticAssignmentLock(run: (tx: StaticAssignmentDb) => Promise) { + return withStaticAssignmentLockUsing({ + pool: getMySqlLockPool(), + transaction: (callback) => db.transaction(callback), + run, + }) +} + +async function reserveStaticWorkerInstance(input: { + workerId: WorkerId +}) { + return withStaticAssignmentLock(async (tx) => { + await markStaleStaticReservationsFailed(tx) + + const rows = await tx + .select({ url: WorkerInstanceTable.url }) + .from(WorkerInstanceTable) + .where( + and( + eq(WorkerInstanceTable.provider, "static"), + inArray(WorkerInstanceTable.status, ["provisioning", "healthy"]), + ), + ) + + const url = normalizeStaticWorkerUrl(selectStaticWorkerUrlFromPool(input.workerId, { + ...env.staticWorkers, + unavailableUrls: rows.map((row) => row.url), + })) + const instanceId = createDenTypeId("workerInstance") + + await tx.insert(WorkerInstanceTable).values({ + id: instanceId, + worker_id: input.workerId, + provider: "static", + region: "on-prem", + url, + status: "provisioning", + }) + + return { instanceId, url } + }) +} + +async function continueStaticCloudProvisioning(input: { + workerId: WorkerId + name: string + hostToken: string + clientToken: string + activityToken: string +}) { + const reservation = await reserveStaticWorkerInstance({ workerId: input.workerId }) + + try { + await checkStaticWorkerHealth(reservation.url, env.staticWorkers) + + await db.transaction(async (tx) => { + await tx + .update(WorkerTable) + .set({ status: "healthy" }) + .where(eq(WorkerTable.id, input.workerId)) + + await tx + .update(WorkerInstanceTable) + .set({ status: "healthy" }) + .where(eq(WorkerInstanceTable.id, reservation.instanceId)) + }) + } catch (error) { + await db.transaction(async (tx) => { + await tx + .update(WorkerTable) + .set({ status: "failed" }) + .where(eq(WorkerTable.id, input.workerId)) + + await tx + .update(WorkerInstanceTable) + .set({ status: "failed" }) + .where(eq(WorkerInstanceTable.id, reservation.instanceId)) + }) + throw error + } +} + export function toInstanceResponse(instance: WorkerInstanceRow | null) { if (!instance) { return null @@ -346,12 +789,18 @@ export async function continueCloudProvisioning(input: { activityToken: string }) { try { + if (env.provisionerMode === "static") { + await continueStaticCloudProvisioning(input) + return + } + const provisioned = await provisionWorker({ workerId: input.workerId, name: input.name, hostToken: input.hostToken, clientToken: input.clientToken, activityToken: input.activityToken, + unavailableStaticWorkerUrls: await getUnavailableStaticWorkerUrls(), }) await db From e1b58882e8e217d0a512a5519cedd45def344f1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 22 May 2026 09:49:13 +0200 Subject: [PATCH 09/88] test(den): cover static attach security --- .../den-api/test/provisioner-static.test.ts | 371 ++++++++++++++++++ 1 file changed, 371 insertions(+) diff --git a/ee/apps/den-api/test/provisioner-static.test.ts b/ee/apps/den-api/test/provisioner-static.test.ts index f819d793fa..b32ddb8399 100644 --- a/ee/apps/den-api/test/provisioner-static.test.ts +++ b/ee/apps/den-api/test/provisioner-static.test.ts @@ -1,4 +1,8 @@ import { afterAll, beforeAll, expect, test } from "bun:test" +import { Hono } from "hono" +import { jsonValidator } from "../src/middleware/validation.js" +import { WorkerInstanceTable, WorkerTable, WorkerTokenTable } from "@openwork-ee/den-db/schema" +import { createDenTypeId } from "@openwork-ee/utils/typeid" import type { ProvisionedInstance, StaticWorkerConfig } from "../src/workers/provisioner.js" function seedRequiredEnv() { @@ -14,6 +18,8 @@ function seedRequiredEnv() { let provisionerModule: typeof import("../src/workers/provisioner.js") let envModule: typeof import("../src/env.js") +let workersSharedModule: typeof import("../src/routes/workers/shared.js") +let workersCoreModule: typeof import("../src/routes/workers/core.js") let server: ReturnType let staticWorkerUrl: string @@ -28,6 +34,110 @@ function staticWorkerConfig(overrides: Partial = {}): Static } } +function createFakeStaticAttachStore() { + const instances: Array> = [] + const workers: Array> = [] + const tokens: Array> = [] + const selectedUrl = { value: "" } + + const data = { + select() { + return { + from(table: unknown) { + return { + where() { + return { + async limit() { + if (table !== WorkerInstanceTable) { + return [] + } + return instances + .filter((entry) => entry.provider === "static" + && entry.url === selectedUrl.value + && (entry.status === "provisioning" || entry.status === "healthy")) + .map((entry) => ({ id: entry.id })) + }, + } + }, + } + }, + } + }, + insert(table: unknown) { + return { + async values(value: unknown) { + const values = Array.isArray(value) ? value : [value] + if (table === WorkerInstanceTable) { + instances.push(...values as Record[]) + selectedUrl.value = String((values[0] as Record).url ?? "") + } else if (table === WorkerTokenTable) { + tokens.push(...values as Record[]) + } else if (table === WorkerTable) { + workers.push(...values as Record[]) + } + }, + } + }, + } + + return { data, instances, workers, tokens, selectedUrl } +} + +function createStaticAttachRouteApp(input: { + role?: string + isOwner?: boolean + store?: ReturnType + fetchReachable?: typeof workersCoreModule.assertStaticWorkerReachable + lookup?: Parameters[2] +}) { + const app = new Hono() + const store = input.store ?? createFakeStaticAttachStore() + const userId = createDenTypeId("user") + const orgId = createDenTypeId("organization") + const memberId = createDenTypeId("member") + + workersCoreModule.registerStaticWorkerAttachRoute(app as never, { + data: store.data as never, + lookup: input.lookup ?? (async () => [{ address: "203.0.113.10", family: 4 }]), + fetchReachable: input.fetchReachable ?? (async () => undefined), + getWorkerLimit: async () => ({ exceeded: false, limit: 10, currentCount: 0 }), + lock: async (run) => run(store.data as never), + middlewares: [ + async (c, next) => { + c.set("user", { id: userId, email: "admin@example.com", name: "Admin" }) + c.set("activeOrganizationId", orgId) + c.set("organizationContext", { + organization: { id: orgId }, + currentMember: { + id: memberId, + userId, + role: input.role ?? "admin", + createdAt: new Date(), + isOwner: input.isOwner ?? false, + }, + }) + await next() + }, + jsonValidator(workersSharedModule.attachStaticWorkerSchema), + ] as never, + }) + return { app, store } +} + +async function postStaticAttach(app: Hono, overrides: Record = {}) { + return app.request("http://den.local/v1/workers/static-attach", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "Static Attach Route Worker", + url: "http://worker.example.com", + clientToken: "valid-client-token", + hostToken: "valid-host-token", + ...overrides, + }), + }) +} + beforeAll(async () => { seedRequiredEnv() server = Bun.serve({ @@ -61,6 +171,8 @@ beforeAll(async () => { staticWorkerUrl = `http://127.0.0.1:${server.port}` envModule = await import("../src/env.js") provisionerModule = await import("../src/workers/provisioner.js") + workersSharedModule = await import("../src/routes/workers/shared.js") + workersCoreModule = await import("../src/routes/workers/core.js") }) afterAll(() => { @@ -144,6 +256,265 @@ test("static selector allows failed or stopped URLs when DB active set excludes expect(selected).toBe(staticWorkerUrl) }) +test("static DB assignment lock releases only after reservation transaction completes", async () => { + const events: string[] = [] + const result = await workersSharedModule.withStaticAssignmentLockUsing({ + pool: { + async getConnection() { + return { + async query(statement: string) { + if (statement.includes("GET_LOCK")) { + events.push("lock:acquired") + return [[{ acquired: 1 }], []] + } + if (statement.includes("RELEASE_LOCK")) { + events.push("lock:released") + return [[{}], []] + } + throw new Error(`unexpected query: ${statement}`) + }, + release() { + events.push("connection:released") + }, + } + }, + }, + async transaction(run) { + events.push("transaction:started") + const value = await run({} as never) + events.push("transaction:committed") + return value + }, + async run() { + events.push("reservation:inserted") + return "reserved" + }, + }) + + expect(result).toBe("reserved") + expect(events).toEqual([ + "lock:acquired", + "transaction:started", + "reservation:inserted", + "transaction:committed", + "lock:released", + "connection:released", + ]) +}) + +test("static attach permission gate allows owners and admins only", () => { + expect(workersSharedModule.canAttachStaticWorkerForMember({ currentMember: { isOwner: true, role: "member" } })).toBe(true) + expect(workersSharedModule.canAttachStaticWorkerForMember({ currentMember: { isOwner: false, role: "admin" } })).toBe(true) + expect(workersSharedModule.canAttachStaticWorkerForMember({ currentMember: { isOwner: false, role: "member" } })).toBe(false) +}) + +test("static attach route requires authentication", async () => { + const app = new Hono() + workersCoreModule.registerWorkerCoreRoutes(app) + + const response = await app.request("http://den.local/v1/workers/static-attach", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Static", url: staticWorkerUrl, clientToken: "valid-client-token", hostToken: "valid-host-token" }), + }) + + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ error: "unauthorized" }) +}) + +test("static attach route succeeds for organization admin without token echo", async () => { + const { app, store } = createStaticAttachRouteApp({ role: "admin" }) + const response = await postStaticAttach(app) + const payload = await response.json() as Record + + expect(response.status).toBe(201) + expect(payload.worker).toBeTruthy() + expect(payload.instance).toBeTruthy() + expect(JSON.stringify(payload).includes("valid-client-token")).toBe(false) + expect(JSON.stringify(payload).includes("valid-host-token")).toBe(false) + expect(store.tokens.map((entry) => entry.scope).sort()).toEqual(["activity", "client", "host"]) +}) + +test("static attach route rejects ordinary organization members", async () => { + const { app } = createStaticAttachRouteApp({ role: "member", isOwner: false }) + const response = await postStaticAttach(app) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toMatchObject({ error: "forbidden" }) +}) + +test("static attach route rejects duplicate URLs before verification", async () => { + const store = createFakeStaticAttachStore() + store.instances.push({ id: "existing", provider: "static", url: "http://worker.example.com", status: "healthy" }) + store.selectedUrl.value = "http://worker.example.com" + const { app } = createStaticAttachRouteApp({ store }) + const response = await postStaticAttach(app) + + expect(response.status).toBe(409) + await expect(response.json()).resolves.toMatchObject({ error: "worker_url_already_attached" }) +}) + +test("static attach route re-checks duplicate URL inside lock before insert", async () => { + const store = createFakeStaticAttachStore() + const app = new Hono() + workersCoreModule.registerStaticWorkerAttachRoute(app as never, { + data: store.data as never, + lookup: async () => [{ address: "203.0.113.10", family: 4 }], + fetchReachable: async () => undefined, + getWorkerLimit: async () => ({ exceeded: false, limit: 10, currentCount: 0 }), + lock: async (run) => { + store.instances.push({ id: "raced", provider: "static", url: "http://worker.example.com", status: "healthy" }) + store.selectedUrl.value = "http://worker.example.com" + return run(store.data as never) + }, + middlewares: [ + async (c, next) => { + const userId = createDenTypeId("user") + const orgId = createDenTypeId("organization") + c.set("user", { id: userId, email: "admin@example.com" }) + c.set("activeOrganizationId", orgId) + c.set("organizationContext", { currentMember: { id: createDenTypeId("member"), userId, role: "admin", createdAt: new Date(), isOwner: false } }) + await next() + }, + jsonValidator(workersSharedModule.attachStaticWorkerSchema), + ] as never, + }) + + const response = await postStaticAttach(app) + expect(response.status).toBe(409) + expect(store.workers).toHaveLength(0) +}) + +test("static attach route rejects invalid URL", async () => { + const { app } = createStaticAttachRouteApp({}) + const response = await postStaticAttach(app, { url: "ftp://worker.example.com" }) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: "invalid_request" }) +}) + +test("static attach route rejects invalid client and host tokens", async () => { + const clientFailure = createStaticAttachRouteApp({ + fetchReachable: async () => { throw new Error("arbitrary upstream text valid-client-token") }, + }) + const clientResponse = await postStaticAttach(clientFailure.app) + const clientPayload = await clientResponse.json() as Record + expect(clientResponse.status).toBe(400) + expect(clientPayload.message).toBe("Static worker verification failed with the provided URL and tokens.") + expect(JSON.stringify(clientPayload).includes("valid-client-token")).toBe(false) + + const hostFailure = createStaticAttachRouteApp({ + fetchReachable: async () => { throw new Error("arbitrary upstream text valid-host-token") }, + }) + const hostResponse = await postStaticAttach(hostFailure.app) + const hostPayload = await hostResponse.json() as Record + expect(hostResponse.status).toBe(400) + expect(hostPayload.message).toBe("Static worker verification failed with the provided URL and tokens.") + expect(JSON.stringify(hostPayload).includes("valid-host-token")).toBe(false) +}) + +test("static attach URL policy rejects unsafe URLs and allows explicit on-prem hosts", () => { + const defaultPolicy = { allowPrivate: false, allowedHosts: [], allowedCidrs: [] } + + expect(workersSharedModule.validateStaticWorkerAttachUrl("ftp://worker.example.com", defaultPolicy)).toMatchObject({ ok: false }) + expect(workersSharedModule.validateStaticWorkerAttachUrl("http://user:pass@worker.example.com", defaultPolicy)).toMatchObject({ ok: false }) + expect(workersSharedModule.validateStaticWorkerAttachUrl("http://worker.example.com/?token=abc", defaultPolicy)).toMatchObject({ ok: false }) + expect(workersSharedModule.validateStaticWorkerAttachUrl("http://127.0.0.1:8787", defaultPolicy)).toMatchObject({ ok: false }) + expect(workersSharedModule.validateStaticWorkerAttachUrl("http://127.0.0.1:8787", { ...defaultPolicy, allowedCidrs: ["127.0.0.0/8"] })).toMatchObject({ + ok: true, + url: "http://127.0.0.1:8787", + }) + expect(workersSharedModule.validateStaticWorkerAttachUrl("http://lan-worker.local:8787", { ...defaultPolicy, allowedHosts: ["lan-worker.local"] })).toMatchObject({ ok: true }) +}) + +test("static attach URL policy blocks DNS names resolving to unsafe IPv4 and IPv6 addresses", async () => { + await expect(workersSharedModule.validateResolvedStaticWorkerAttachUrl( + "http://public-name.example.com", + { allowPrivate: false, allowedHosts: [], allowedCidrs: [] }, + async () => [{ address: "127.0.0.1", family: 4 }], + )).resolves.toMatchObject({ ok: false }) + + await expect(workersSharedModule.validateResolvedStaticWorkerAttachUrl( + "http://public-name.example.com", + { allowPrivate: false, allowedHosts: [], allowedCidrs: [] }, + async () => [{ address: "fe80::1", family: 6 }], + )).resolves.toMatchObject({ ok: false }) + + await expect(workersSharedModule.validateResolvedStaticWorkerAttachUrl( + "http://public-name.example.com", + { allowPrivate: false, allowedHosts: [], allowedCidrs: ["127.0.0.0/8"] }, + async () => [{ address: "127.0.0.1", family: 4 }], + )).resolves.toMatchObject({ ok: true }) + + await expect(workersSharedModule.validateResolvedStaticWorkerAttachUrl( + "http://allowed-host.example.com", + { allowPrivate: false, allowedHosts: ["allowed-host.example.com"], allowedCidrs: [] }, + async () => [{ address: "::1", family: 6 }], + )).resolves.toMatchObject({ ok: true }) +}) + +test("static attach URL policy rejects HTTPS hostnames because verification cannot safely IP-pin TLS", async () => { + await expect(workersSharedModule.validateResolvedStaticWorkerAttachUrl( + "https://worker.example.com", + { allowPrivate: false, allowedHosts: [], allowedCidrs: [] }, + async () => [{ address: "203.0.113.10", family: 4 }], + )).resolves.toMatchObject({ ok: false }) +}) + +test("static attach verification fetch uses the validated IP address instead of re-resolving hostname", async () => { + const seenHosts: string[] = [] + const pinServer = Bun.serve({ + port: 0, + fetch(request) { + seenHosts.push(request.headers.get("host") ?? "") + const url = new URL(request.url) + if (url.pathname === "/workspaces") { + return Response.json({ items: [] }) + } + if (url.pathname === "/env/keys") { + return Response.json({ keys: [] }) + } + return new Response("not found", { status: 404 }) + }, + }) + try { + const target = await workersSharedModule.validateResolvedStaticWorkerAttachUrl( + `http://rebinding-worker.test:${pinServer.port}`, + { allowPrivate: false, allowedHosts: [], allowedCidrs: ["127.0.0.0/8"] }, + async () => [{ address: "127.0.0.1", family: 4 }], + ) + expect(target.ok).toBe(true) + if (target.ok) { + await workersCoreModule.assertStaticWorkerReachable(target, "valid-client-token", "valid-host-token") + } + expect(seenHosts).toEqual([`rebinding-worker.test:${pinServer.port}`, `rebinding-worker.test:${pinServer.port}`]) + } finally { + pinServer.stop(true) + } +}) + +test("static attach worker token verification succeeds without following redirects", async () => { + await expect(workersCoreModule.assertStaticWorkerReachable(staticWorkerUrl, "valid-client-token", "valid-host-token")).resolves.toBeUndefined() + + const redirectResponse = await workersCoreModule.fetchStaticWorker(staticWorkerUrl, "/redirect-workspaces", {}) + expect(redirectResponse.status).toBe(302) +}) + +test("static attach worker token verification rejects invalid client and host tokens without echoing tokens", async () => { + await expect(workersCoreModule.assertStaticWorkerReachable(staticWorkerUrl, "invalid-client-token", "valid-host-token")) + .rejects.toThrow("Worker rejected the provided client token with HTTP 401") + await expect(workersCoreModule.assertStaticWorkerReachable(staticWorkerUrl, "valid-client-token", "invalid-host-token")) + .rejects.toThrow("Worker rejected the provided host token with HTTP 403") + + try { + await workersCoreModule.assertStaticWorkerReachable(staticWorkerUrl, "invalid-client-token", "valid-host-token") + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + expect(message.includes("invalid-client-token")).toBe(false) + expect(message.includes("valid-host-token")).toBe(false) + } +}) + test("static provisioner fails clearly when no worker URLs are configured", async () => { await expect(provisionerModule.provisionStaticWorker( { From 928a08f7dd73256345f8abc94a63dc7aa6630c05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 23 May 2026 03:35:01 +0200 Subject: [PATCH 10/88] fix(den): make demo seed script portable --- ee/apps/den-api/package.json | 4 ++-- ee/apps/den-api/scripts/seed-demo-org-runner.mjs | 9 +++++++++ ee/apps/den-api/scripts/seed-demo-org.ts | 3 ++- packaging/docker/ONPREM_DEN_STATIC_RUNBOOK.md | 14 ++++++++++++++ pnpm-lock.yaml | 6 +++--- 5 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 ee/apps/den-api/scripts/seed-demo-org-runner.mjs diff --git a/ee/apps/den-api/package.json b/ee/apps/den-api/package.json index 796c5bba42..cf5f140e1e 100644 --- a/ee/apps/den-api/package.json +++ b/ee/apps/den-api/package.json @@ -9,7 +9,7 @@ "build:email": "pnpm --filter @openwork/email build", "build:den-db": "pnpm --filter @openwork-ee/den-db build", "backfill:desktop-policies": "pnpm run build:den-db && tsx scripts/backfill-desktop-policies.ts", - "seed:demo-org": "pnpm run build:den-db && sh -lc 'DEN_WEB_PORT=${DEN_WEB_PORT:-3005}; OPENWORK_DEV_MODE=${OPENWORK_DEV_MODE:-1} DATABASE_URL=${DATABASE_URL:-mysql://root:password@127.0.0.1:3306/openwork_den} DEN_DB_ENCRYPTION_KEY=${DEN_DB_ENCRYPTION_KEY:-local-dev-db-encryption-key-please-change-1234567890} BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-local-dev-secret-not-for-production-use!!} BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:$DEN_WEB_PORT} tsx scripts/seed-demo-org.ts'", + "seed:demo-org": "pnpm run build:den-db && node --import tsx scripts/seed-demo-org-runner.mjs", "start": "node dist/server.js" }, "dependencies": { @@ -36,12 +36,12 @@ "nanoid": "^5.1.11", "openapi-types": "^12.1.3", "stripe": "^22.1.1", + "tsx": "^4.15.7", "zod": "^4.3.6" }, "devDependencies": { "@types/json-schema": "^7.0.15", "@types/node": "^20.11.30", - "tsx": "^4.15.7", "typescript": "^5.5.4" } } diff --git a/ee/apps/den-api/scripts/seed-demo-org-runner.mjs b/ee/apps/den-api/scripts/seed-demo-org-runner.mjs new file mode 100644 index 0000000000..96037bc953 --- /dev/null +++ b/ee/apps/den-api/scripts/seed-demo-org-runner.mjs @@ -0,0 +1,9 @@ +const denWebPort = process.env.DEN_WEB_PORT?.trim() || "3005" + +process.env.OPENWORK_DEV_MODE ??= "1" +process.env.DATABASE_URL ??= "mysql://root:password@127.0.0.1:3306/openwork_den" +process.env.DEN_DB_ENCRYPTION_KEY ??= "local-dev-db-encryption-key-please-change-1234567890" +process.env.BETTER_AUTH_SECRET ??= "local-dev-secret-not-for-production-use!!" +process.env.BETTER_AUTH_URL ??= `http://localhost:${denWebPort}` + +await import("./seed-demo-org.ts") diff --git a/ee/apps/den-api/scripts/seed-demo-org.ts b/ee/apps/den-api/scripts/seed-demo-org.ts index 42c18d75aa..8b60a4a645 100644 --- a/ee/apps/den-api/scripts/seed-demo-org.ts +++ b/ee/apps/den-api/scripts/seed-demo-org.ts @@ -1037,7 +1037,8 @@ async function main() { log("✓", `done in ${elapsedSeconds}s`) log(" ", `${memberIdsByEmail.size} members · ${teamIdsByName.size} teams · ${seededPlugins} plugins · ${seededObjects} config objects`) console.log() - log("→", `login: ${DEMO_OWNER_EMAIL} / ${DEMO_OWNER_PASSWORD}`) + log("→", `login email: ${DEMO_OWNER_EMAIL}`) + log("→", "login password: use the DEN_DEMO_OWNER_PASSWORD value supplied to this seed run") log("→", "open: /organization or /dashboard") console.log() } diff --git a/packaging/docker/ONPREM_DEN_STATIC_RUNBOOK.md b/packaging/docker/ONPREM_DEN_STATIC_RUNBOOK.md index 0645acd3c4..f6ca364f6b 100644 --- a/packaging/docker/ONPREM_DEN_STATIC_RUNBOOK.md +++ b/packaging/docker/ONPREM_DEN_STATIC_RUNBOOK.md @@ -212,6 +212,20 @@ Email/password sign-in stays enabled for break-glass administrator access. Micro This batch does not implement a first-admin/org bootstrap flow. Before enabling Entra auto-join in production, ensure a first Den administrator and target organization already exist through a supported setup path for your deployment. A dedicated first-admin/org bootstrap capability remains a future setup prerequisite. Entra auto-join only adds Microsoft users to an existing organization; it never creates the initial organization and never assigns `owner`. +For disposable E2E and operator smoke-test deployments, create a demo owner and organization with the Den seed tool instead of editing database rows manually. Run this from the Compose host after the `den` service is healthy, using a temporary password kept out of shell history where possible: + +```bash +read -rs -p "Demo owner password: " DEN_DEMO_OWNER_PASSWORD; echo +export DEN_DEMO_OWNER_PASSWORD +docker compose -p openwork-den-static -f packaging/docker/docker-compose.den-dev.yml exec \ + -e DEN_DEMO_OWNER_EMAIL=admin@acme.test \ + -e DEN_DEMO_OWNER_PASSWORD \ + -e DEN_DEMO_SEED_FETCH_GITHUB=0 \ + den pnpm --dir /app/ee/apps/den-api run seed:demo-org +``` + +The seed command prints the demo owner email, organization summary, and object counts, but does not print the supplied password. Use `-- --reset` at the end only for disposable environments when you intentionally want to recreate the demo organization. + ### Entra app registration 1. In Microsoft Entra admin center, create or open an App registration for Den. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c65f37d072..209c217332 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -541,6 +541,9 @@ importers: stripe: specifier: ^22.1.1 version: 22.1.1(@types/node@20.12.12) + tsx: + specifier: ^4.15.7 + version: 4.21.0 zod: specifier: ^4.3.6 version: 4.3.6 @@ -551,9 +554,6 @@ importers: '@types/node': specifier: ^20.11.30 version: 20.12.12 - tsx: - specifier: ^4.15.7 - version: 4.21.0 typescript: specifier: ^5.5.4 version: 5.9.3 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 11/88] 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 12/88] 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 13/88] 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 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 14/88] 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 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 15/88] 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 c7ae20f90c9c4bbf894ea870e1db7b4c7edf5329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 23 May 2026 22:54:28 +0200 Subject: [PATCH 16/88] feat(den): handle OAuth provider credentials Add Den API create/update/read/import handling for API-key versus OpenCode OAuth provider credentials on top of the credential contract base. --- .../den-api/src/routes/org/llm-providers.ts | 140 ++++++++++++++++-- .../test/llm-provider-credentials.test.ts | 61 ++++++++ 2 files changed, 190 insertions(+), 11 deletions(-) create mode 100644 ee/apps/den-api/test/llm-provider-credentials.test.ts 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..e3c4438cca 100644 --- a/ee/apps/den-api/src/routes/org/llm-providers.ts +++ b/ee/apps/den-api/src/routes/org/llm-providers.ts @@ -73,10 +73,12 @@ const customProviderSchema = z.object({ const llmProviderWriteSchema = z.object({ name: z.string().trim().min(1).max(255), source: z.enum(["models_dev", "custom"]), + credentialKind: z.enum(["api_key", "opencode_oauth"]).optional().default("api_key"), providerId: z.string().trim().min(1).max(255).optional(), modelIds: z.array(z.string().trim().min(1).max(255)).min(1).optional(), customConfigText: z.string().trim().min(1).optional(), apiKey: z.string().trim().max(65535).optional(), + opencodeAuth: z.string().trim().max(65535).optional(), memberIds: z.array(denTypeIdSchema("member")).max(500).optional().default([]), teamIds: z.array(denTypeIdSchema("team")).max(500).optional().default([]), }).superRefine((value, ctx) => { @@ -105,6 +107,14 @@ const llmProviderWriteSchema = z.object({ message: "Paste a custom provider config.", }) } + + if (value.credentialKind === "opencode_oauth" && value.source === "models_dev" && value.providerId?.trim().toLowerCase() !== "openai") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["credentialKind"], + message: "OpenCode OAuth credentials can only be used with the OpenAI catalog provider.", + }) + } }) const providerCatalogListResponseSchema = z.object({ @@ -163,6 +173,33 @@ export function redactLlmProviderCredentials).type !== "oauth") { + throw new Error("invalid auth") + } + return JSON.stringify(parsed) + } catch { + throw createFailure(400, "invalid_opencode_auth", "OpenCode OAuth credential must be valid OAuth auth JSON.") + } +} + +function buildLlmProviderCredentialPayload(provider: LlmProviderRow) { + return { + ...redactLlmProviderCredentials(provider), + ...getCredentialFlags(provider), + apiKey: provider.credentialKind === "api_key" ? provider.apiKey : undefined, + opencodeAuth: provider.credentialKind === "opencode_oauth" ? provider.opencodeAuth : undefined, + } +} + function canManageLlmProvider( payload: { currentMember: { id: MemberId; isOwner: boolean; role: string } }, provider: LlmProviderRow, @@ -292,6 +329,10 @@ async function resolveTeamIds(input: { } async function normalizeLlmProviderInput(input: z.infer) { + const credentialKind = input.credentialKind + const apiKey = credentialKind === "api_key" ? input.apiKey?.trim() || null : null + const opencodeAuth = credentialKind === "opencode_oauth" ? normalizeOpencodeAuth(input.opencodeAuth) : null + if (input.source === "models_dev") { const provider = await getModelsDevProvider(input.providerId ?? "") if (!provider) { @@ -308,10 +349,9 @@ async function normalizeLlmProviderInput(input: z.infer { + const payload = c.get("organizationContext") + const params = c.req.valid("param") + + let llmProviderId: LlmProviderId + try { + llmProviderId = parseLlmProviderId(params.llmProviderId) + } catch { + return c.json({ error: "llm_provider_not_found" }, 404) + } + + const providerRows = await db + .select() + .from(LlmProviderTable) + .where(and(eq(LlmProviderTable.id, llmProviderId), eq(LlmProviderTable.organizationId, payload.organization.id))) + .limit(1) + + const provider = providerRows[0] + if (!provider) return c.json({ error: "llm_provider_not_found" }, 404) + if (!canImportLlmProviderCredential(payload)) { + return c.json({ error: "forbidden", message: "Only organization admins can import provider credentials." }, 403) + } + + const models = await db + .select() + .from(LlmProviderModelTable) + .where(eq(LlmProviderModelTable.llmProviderId, llmProviderId)) + + return c.json({ + llmProvider: { + ...buildLlmProviderCredentialPayload(provider), + models: models + .map((model) => ({ + id: model.modelId, + name: model.name, + config: model.modelConfig, + createdAt: model.createdAt, + })) + .sort((left, right) => left.name.localeCompare(right.name)), + }, + }) + }, + ) + app.post( "/v1/llm-providers", describeRoute({ @@ -751,10 +855,12 @@ export function registerOrgLlmProviderRoutes { + seedRequiredEnv() + llmProviderModule = await import("../src/routes/org/llm-providers.js") +}) + +test("generic provider payload redaction removes API key and OAuth auth material", () => { + const redacted = llmProviderModule.redactLlmProviderCredentials({ + id: "llmProvider_secret_123", + apiKey: "plain-secret", + opencodeAuth: JSON.stringify({ type: "oauth", access: "access", refresh: "refresh", expires: 1 }), + }) + + expect(redacted).toEqual({ + id: "llmProvider_secret_123", + apiKey: undefined, + opencodeAuth: undefined, + }) +}) + +test("credential flags expose presence only, never credential values", () => { + expect(llmProviderModule.getCredentialFlags({ + credentialKind: "opencode_oauth", + apiKey: "plain-secret", + opencodeAuth: JSON.stringify({ type: "oauth", access: "access", refresh: "refresh", expires: 1 }), + })).toEqual({ hasApiKey: true, hasOpencodeAuth: true, hasCredential: true }) +}) + +test("credential import permission gate requires organization admin role", () => { + const owner = { currentMember: { isOwner: true, role: "member" } } + const admin = { currentMember: { isOwner: false, role: "admin" } } + const creatorOnly = { currentMember: { isOwner: false, role: "member" } } + + expect(llmProviderModule.canImportLlmProviderCredential(owner)).toBe(true) + expect(llmProviderModule.canImportLlmProviderCredential(admin)).toBe(true) + expect(llmProviderModule.canImportLlmProviderCredential(creatorOnly)).toBe(false) +}) + +test("purpose-specific import endpoint requires authentication", async () => { + const app = new Hono() + llmProviderModule.registerOrgLlmProviderRoutes(app) + + const response = await app.request("http://den.local/v1/llm-providers/llmProvider_secret_123/import-credential", { + method: "GET", + }) + + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ error: "unauthorized" }) +}) 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 17/88] 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 d91928e592972139ca2beba7098d24f7534722fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 24 May 2026 01:01:34 +0200 Subject: [PATCH 18/88] feat(den): add OpenAI OAuth provider flow Add Den API OpenAI OAuth device-flow routes/tests and Den Web provider UI for OAuth-backed provider credentials on top of the credential handling stack. --- .../den-api/src/routes/org/llm-providers.ts | 359 ++++++++++++++---- .../den-api/test/llm-providers-oauth.test.ts | 198 ++++++++++ .../_components/llm-provider-data.tsx | 8 + .../llm-provider-detail-screen.tsx | 8 +- .../llm-provider-editor-screen.tsx | 224 ++++++++++- .../_components/llm-providers-screen.tsx | 4 +- 6 files changed, 704 insertions(+), 97 deletions(-) create mode 100644 ee/apps/den-api/test/llm-providers-oauth.test.ts 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 e3c4438cca..5dd50e7d33 100644 --- a/ee/apps/den-api/src/routes/org/llm-providers.ts +++ b/ee/apps/den-api/src/routes/org/llm-providers.ts @@ -1,7 +1,6 @@ -import { and, desc, eq, inArray, isNotNull, isNull, or } from "@openwork-ee/den-db/drizzle" +import { and, desc, eq, inArray, isNotNull, or } from "@openwork-ee/den-db/drizzle" import { AuthUserTable, - InvitationTable, LlmProviderAccessTable, LlmProviderModelTable, LlmProviderTable, @@ -33,6 +32,10 @@ type LlmProviderAccessId = typeof LlmProviderAccessTable.$inferSelect.id type MemberId = typeof MemberTable.$inferSelect.id type TeamId = typeof TeamTable.$inferSelect.id type LlmProviderRow = typeof LlmProviderTable.$inferSelect +const OPENAI_AUTH_ISSUER = "https://auth.openai.com" +const OPENAI_CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" +const OPENAI_DEVICE_REDIRECT_URI = `${OPENAI_AUTH_ISSUER}/deviceauth/callback` +const OPENAI_DEVICE_POLLING_SAFETY_MARGIN_MS = 3000 type RouteFailure = { status: number @@ -40,11 +43,6 @@ type RouteFailure = { message?: string } -function getInvitedMemberName(email: string) { - const [localPart, domain = "invited"] = email.split("@") - return `${localPart} ${domain.split(".")[0] ?? "invited"}`.trim() -} - const providerCatalogParamsSchema = z.object({ providerId: z.string().trim().min(1).max(255), }) @@ -108,12 +106,14 @@ const llmProviderWriteSchema = z.object({ }) } - if (value.credentialKind === "opencode_oauth" && value.source === "models_dev" && value.providerId?.trim().toLowerCase() !== "openai") { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["credentialKind"], - message: "OpenCode OAuth credentials can only be used with the OpenAI catalog provider.", - }) + if (value.credentialKind === "opencode_oauth") { + if (value.source === "models_dev" && !isOpencodeOauthProviderAllowed(value.providerId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["credentialKind"], + message: "OpenCode OAuth credentials can only be used with the OpenAI catalog provider.", + }) + } } }) @@ -143,6 +143,24 @@ const conflictSchema = z.object({ message: z.string().optional(), }).meta({ ref: "ConflictError" }) +const openAiOauthStartResponseSchema = z.object({ + verificationUrl: z.string(), + userCode: z.string(), + deviceAuthId: z.string(), + intervalMs: z.number(), +}).meta({ ref: "OpenAiOauthStartResponse" }) + +const openAiOauthCompleteSchema = z.object({ + deviceAuthId: z.string().trim().min(1), + userCode: z.string().trim().min(1), +}) + +const openAiOauthCompleteResponseSchema = z.object({ + opencodeAuth: z.string(), + accountId: z.string().nullable(), + expires: z.number(), +}).meta({ ref: "OpenAiOauthCompleteResponse" }) + function createFailure(status: number, error: string, message?: string): RouteFailure { return { status, error, message } } @@ -151,55 +169,130 @@ function isRouteFailure(value: unknown): value is RouteFailure { return typeof value === "object" && value !== null && "status" in value && "error" in value } -function isOrganizationAdmin(payload: { currentMember: { isOwner: boolean; role: string } }) { - return payload.currentMember.isOwner || memberHasRole(payload.currentMember.role, "admin") +function parseJwtClaims(token: string): Record | null { + const parts = token.split(".") + if (parts.length !== 3 || !parts[1]) return null + try { + return JSON.parse(Buffer.from(parts[1], "base64url").toString()) as Record + } catch { + return null + } } -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, +function extractOpenAiAccountId(tokens: { id_token?: string; access_token?: string }) { + const claims = tokens.id_token ? parseJwtClaims(tokens.id_token) : tokens.access_token ? parseJwtClaims(tokens.access_token) : null + if (!claims) return null + const apiAuth = claims["https://api.openai.com/auth"] + if (typeof claims.chatgpt_account_id === "string") return claims.chatgpt_account_id + if (apiAuth && typeof apiAuth === "object" && !Array.isArray(apiAuth) && typeof (apiAuth as Record).chatgpt_account_id === "string") { + return (apiAuth as Record).chatgpt_account_id + } + const organizations = claims.organizations + if (Array.isArray(organizations)) { + const first = organizations.find((entry): entry is Record => typeof entry === "object" && entry !== null && !Array.isArray(entry)) + if (typeof first?.id === "string") return first.id } + return null } -export function redactLlmProviderCredentials(provider: T): Omit & { apiKey: undefined; opencodeAuth: undefined } { +export async function startOpenAiDeviceAuth() { + const response = await fetch(`${OPENAI_AUTH_ISSUER}/api/accounts/deviceauth/usercode`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": "opencode/den", + }, + body: JSON.stringify({ client_id: OPENAI_CODEX_CLIENT_ID }), + }) + if (!response.ok) { + throw createFailure(502, "openai_oauth_start_failed", `OpenAI device authorization failed with ${response.status}.`) + } + const data = await response.json() as { device_auth_id?: unknown; user_code?: unknown; interval?: unknown } + if (typeof data.device_auth_id !== "string" || typeof data.user_code !== "string") { + throw createFailure(502, "openai_oauth_start_failed", "OpenAI device authorization response was incomplete.") + } + const interval = Math.max(Number.parseInt(String(data.interval ?? "5"), 10) || 5, 1) * 1000 return { - ...provider, - apiKey: undefined, - opencodeAuth: undefined, + verificationUrl: `${OPENAI_AUTH_ISSUER}/codex/device`, + userCode: data.user_code, + deviceAuthId: data.device_auth_id, + intervalMs: interval + OPENAI_DEVICE_POLLING_SAFETY_MARGIN_MS, } } -export function canImportLlmProviderCredential(payload: { currentMember: { isOwner: boolean; role: string } }) { - return isOrganizationAdmin(payload) -} +export async function completeOpenAiDeviceAuth(input: { deviceAuthId: string; userCode: string }) { + const deviceResponse = await fetch(`${OPENAI_AUTH_ISSUER}/api/accounts/deviceauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": "opencode/den", + }, + body: JSON.stringify({ + device_auth_id: input.deviceAuthId, + user_code: input.userCode, + }), + }) -function normalizeOpencodeAuth(value: string | undefined) { - const trimmed = value?.trim() ?? "" - if (!trimmed) return null - try { - const parsed = JSON.parse(trimmed) as unknown - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || (parsed as Record).type !== "oauth") { - throw new Error("invalid auth") - } - return JSON.stringify(parsed) - } catch { - throw createFailure(400, "invalid_opencode_auth", "OpenCode OAuth credential must be valid OAuth auth JSON.") + if (deviceResponse.status === 403 || deviceResponse.status === 404) { + throw createFailure(409, "openai_oauth_pending", "OpenAI authorization is not complete yet.") + } + if (!deviceResponse.ok) { + throw createFailure(502, "openai_oauth_complete_failed", `OpenAI device authorization failed with ${deviceResponse.status}.`) + } + const deviceData = await deviceResponse.json() as { authorization_code?: unknown; code_verifier?: unknown } + if (typeof deviceData.authorization_code !== "string" || typeof deviceData.code_verifier !== "string") { + throw createFailure(502, "openai_oauth_complete_failed", "OpenAI device token response was incomplete.") } -} -function buildLlmProviderCredentialPayload(provider: LlmProviderRow) { + const tokenResponse = await fetch(`${OPENAI_AUTH_ISSUER}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: deviceData.authorization_code, + redirect_uri: OPENAI_DEVICE_REDIRECT_URI, + client_id: OPENAI_CODEX_CLIENT_ID, + code_verifier: deviceData.code_verifier, + }).toString(), + }) + if (!tokenResponse.ok) { + throw createFailure(502, "openai_oauth_complete_failed", `OpenAI token exchange failed with ${tokenResponse.status}.`) + } + const tokens = await tokenResponse.json() as { id_token?: string; access_token?: string; refresh_token?: string; expires_in?: number } + if (!tokens.access_token || !tokens.refresh_token) { + throw createFailure(502, "openai_oauth_complete_failed", "OpenAI token response did not include OAuth tokens.") + } + const expires = Date.now() + (tokens.expires_in ?? 3600) * 1000 + const accountId = extractOpenAiAccountId(tokens) return { - ...redactLlmProviderCredentials(provider), - ...getCredentialFlags(provider), - apiKey: provider.credentialKind === "api_key" ? provider.apiKey : undefined, - opencodeAuth: provider.credentialKind === "opencode_oauth" ? provider.opencodeAuth : undefined, + opencodeAuth: JSON.stringify({ + type: "oauth", + refresh: tokens.refresh_token, + access: tokens.access_token, + expires, + ...(accountId ? { accountId } : {}), + }), + accountId, + expires, } } +function isOrganizationAdmin(payload: { currentMember: { isOwner: boolean; role: string } }) { + return payload.currentMember.isOwner || memberHasRole(payload.currentMember.role, "admin") +} + +export function isOpencodeOauthProviderAllowed(providerId: string | undefined | null) { + return providerId?.trim().toLowerCase() === "openai" +} + +export function canUseOpenAiOAuthCredentialFlow(payload: { currentMember: { isOwner: boolean; role: string } }) { + return isOrganizationAdmin(payload) +} + +export function canImportLlmProviderCredential(payload: { currentMember: { isOwner: boolean; role: string } }) { + return isOrganizationAdmin(payload) +} + function canManageLlmProvider( payload: { currentMember: { id: MemberId; isOwner: boolean; role: string } }, provider: LlmProviderRow, @@ -230,6 +323,66 @@ function parseLlmProviderAccessId(value: string) { return normalizeDenTypeId("llmProviderAccess", value) } +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 buildLlmProviderCredentialPayload(provider: LlmProviderRow) { + return { + ...redactLlmProviderCredentials(provider), + ...getCredentialFlags(provider), + apiKey: provider.credentialKind === "api_key" ? provider.apiKey : undefined, + opencodeAuth: provider.credentialKind === "opencode_oauth" ? provider.opencodeAuth : undefined, + } +} + +function normalizeOpencodeAuth(value: string | undefined) { + const trimmed = value?.trim() + if (!trimmed) return null + + try { + const parsed = JSON.parse(trimmed) as unknown + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("OpenCode OAuth auth must be a JSON object.") + } + const auth = parsed as Record + if (auth.type !== "oauth") { + throw new Error('OpenCode OAuth auth must include "type": "oauth".') + } + if (typeof auth.access !== "string" || !auth.access.trim()) { + throw new Error("OpenCode OAuth auth must include an access token.") + } + if (typeof auth.refresh !== "string" || !auth.refresh.trim()) { + throw new Error("OpenCode OAuth auth must include a refresh token.") + } + if (typeof auth.expires !== "number" || !Number.isFinite(auth.expires) || auth.expires < 0) { + throw new Error("OpenCode OAuth auth must include a non-negative numeric expires value.") + } + } catch (error) { + throw createFailure( + 400, + "invalid_opencode_auth", + error instanceof Error ? error.message : "OpenCode OAuth auth must be valid JSON.", + ) + } + + return trimmed +} + function parseMemberId(value: string) { return normalizeDenTypeId("member", value) } @@ -290,7 +443,7 @@ async function resolveMemberIds(input: { const rows = await db .select({ id: MemberTable.id }) .from(MemberTable) - .where(and(eq(MemberTable.organizationId, input.organizationId), inArray(MemberTable.id, memberIds), isNull(MemberTable.removedAt))) + .where(and(eq(MemberTable.organizationId, input.organizationId), inArray(MemberTable.id, memberIds))) if (rows.length !== memberIds.length) { throw createFailure(404, "member_not_found") @@ -382,6 +535,9 @@ async function normalizeLlmProviderInput(input: z.infer left.name.localeCompare(right.name)), access: { - members: (memberAccessByProviderId.get(provider.id) ?? []).map((row) => { - const email = row.user?.email ?? row.invitation?.email ?? "invited@example.com" - return { - id: row.access.id, - orgMembershipId: row.member.id, - role: row.member.role, - user: { - id: row.user?.id ?? row.member.id, - name: row.user?.name ?? getInvitedMemberName(email), - email, - image: row.user?.image ?? null, - }, - createdAt: row.access.createdAt, - } - }), + members: (memberAccessByProviderId.get(provider.id) ?? []).map((row) => ({ + id: row.access.id, + orgMembershipId: row.member.id, + role: row.member.role, + user: row.user, + createdAt: row.access.createdAt, + })), teams: (teamAccessByProviderId.get(provider.id) ?? []).map((row) => ({ id: row.access.id, teamId: row.team.id, @@ -563,6 +707,69 @@ async function loadLlmProviders(input: { } export function registerOrgLlmProviderRoutes }>(app: Hono) { + app.post( + "/v1/llm-providers/openai-oauth/start", + describeRoute({ + tags: ["LLM Providers"], + summary: "Start OpenAI OAuth device flow", + description: "Starts the same OpenAI/ChatGPT device auth flow used by OpenCode and returns the user code.", + responses: { + 200: jsonResponse("OpenAI OAuth device flow started successfully.", openAiOauthStartResponseSchema), + 401: jsonResponse("The caller must be signed in to connect OpenAI.", unauthorizedSchema), + 403: jsonResponse("Only organization admins can connect OpenAI OAuth credentials.", forbiddenSchema), + 502: jsonResponse("OpenAI OAuth could not be started.", conflictSchema), + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + async (c) => { + const payload = c.get("organizationContext") + if (!canUseOpenAiOAuthCredentialFlow(payload)) { + return c.json({ error: "forbidden", message: "Only organization admins can connect OpenAI OAuth credentials." }, 403) + } + + try { + return c.json(await startOpenAiDeviceAuth()) + } catch (error) { + if (isRouteFailure(error)) return c.json({ error: error.error, message: error.message }, { status: error.status as 409 | 502 }) + throw error + } + }, + ) + + app.post( + "/v1/llm-providers/openai-oauth/complete", + describeRoute({ + tags: ["LLM Providers"], + summary: "Complete OpenAI OAuth device flow", + description: "Completes OpenAI device auth and returns an OpenCode-native OAuth auth object serialized as JSON.", + responses: { + 200: jsonResponse("OpenAI OAuth completed successfully.", openAiOauthCompleteResponseSchema), + 401: jsonResponse("The caller must be signed in to complete OpenAI auth.", unauthorizedSchema), + 403: jsonResponse("Only organization admins can connect OpenAI OAuth credentials.", forbiddenSchema), + 409: jsonResponse("OpenAI authorization is still pending.", conflictSchema), + 502: jsonResponse("OpenAI OAuth could not be completed.", conflictSchema), + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + jsonValidator(openAiOauthCompleteSchema), + async (c) => { + const payload = c.get("organizationContext") + if (!canUseOpenAiOAuthCredentialFlow(payload)) { + return c.json({ error: "forbidden", message: "Only organization admins can connect OpenAI OAuth credentials." }, 403) + } + + const input = c.req.valid("json") + try { + return c.json(await completeOpenAiDeviceAuth(input)) + } catch (error) { + if (isRouteFailure(error)) return c.json({ error: error.error, message: error.message }, { status: error.status as 409 | 502 }) + throw error + } + }, + ) + app.get( "/v1/llm-provider-catalog", describeRoute({ @@ -737,8 +944,7 @@ export function registerOrgLlmProviderRoutes ({ @@ -757,11 +963,11 @@ export function registerOrgLlmProviderRoutes { + seedRequiredEnv() + llmProviderModule = await import("../src/routes/org/llm-providers.js") +}) + +function createRouteApp() { + const app = new Hono() + llmProviderModule.registerOrgLlmProviderRoutes(app) + return app +} + +function jwtWithClaims(claims: Record) { + return [ + Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"), + Buffer.from(JSON.stringify(claims)).toString("base64url"), + "signature", + ].join(".") +} + +test("generic provider payload redaction removes API key and OAuth auth material", () => { + const redacted = llmProviderModule.redactLlmProviderCredentials({ + id: "llmProvider_secret_123", + apiKey: "sk-secret", + opencodeAuth: JSON.stringify({ type: "oauth", access: "access", refresh: "refresh", expires: 1 }), + }) + + expect(redacted).toEqual({ + id: "llmProvider_secret_123", + apiKey: undefined, + opencodeAuth: undefined, + }) +}) + +test("credential flags expose presence only, never credential values", () => { + expect(llmProviderModule.getCredentialFlags({ + credentialKind: "opencode_oauth", + apiKey: "sk-secret", + opencodeAuth: JSON.stringify({ type: "oauth", access: "access", refresh: "refresh", expires: 1 }), + })).toEqual({ hasApiKey: true, hasOpencodeAuth: true, hasCredential: true }) +}) + +test("OpenCode OAuth credential type rejects non-OpenAI providers", () => { + expect(llmProviderModule.isOpencodeOauthProviderAllowed("openai")).toBe(true) + expect(llmProviderModule.isOpencodeOauthProviderAllowed(" OpenAI ")).toBe(true) + expect(llmProviderModule.isOpencodeOauthProviderAllowed("anthropic")).toBe(false) + expect(llmProviderModule.isOpencodeOauthProviderAllowed(undefined)).toBe(false) +}) + +test("OAuth credential and import permission gates require organization admin role", () => { + const owner = { currentMember: { isOwner: true, role: "member" } } + const admin = { currentMember: { isOwner: false, role: "admin" } } + const creatorOnly = { currentMember: { isOwner: false, role: "member" } } + + expect(llmProviderModule.canUseOpenAiOAuthCredentialFlow(owner)).toBe(true) + expect(llmProviderModule.canUseOpenAiOAuthCredentialFlow(admin)).toBe(true) + expect(llmProviderModule.canUseOpenAiOAuthCredentialFlow(creatorOnly)).toBe(false) + expect(llmProviderModule.canImportLlmProviderCredential(owner)).toBe(true) + expect(llmProviderModule.canImportLlmProviderCredential(admin)).toBe(true) + expect(llmProviderModule.canImportLlmProviderCredential(creatorOnly)).toBe(false) +}) + +test("OpenAI OAuth routes require an authenticated caller before returning credential material", async () => { + const app = createRouteApp() + + const startResponse = await app.request("http://den.local/v1/llm-providers/openai-oauth/start", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }) + expect(startResponse.status).toBe(401) + await expect(startResponse.json()).resolves.toEqual({ error: "unauthorized" }) + + const completeResponse = await app.request("http://den.local/v1/llm-providers/openai-oauth/complete", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ deviceAuthId: "dev", userCode: "code" }), + }) + expect(completeResponse.status).toBe(401) + await expect(completeResponse.json()).resolves.toEqual({ error: "unauthorized" }) +}) + +test("purpose-specific import endpoint requires authentication", async () => { + const app = createRouteApp() + const response = await app.request("http://den.local/v1/llm-providers/llmProvider_secret_123/import-credential", { + method: "GET", + }) + + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ error: "unauthorized" }) +}) + +test("OpenAI OAuth completion reports pending authorization without tokens", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = (async () => new Response(JSON.stringify({}), { status: 403 })) as typeof fetch + try { + await expect(llmProviderModule.completeOpenAiDeviceAuth({ + deviceAuthId: "device-pending", + userCode: "CODE", + })).rejects.toMatchObject({ error: "openai_oauth_pending", status: 409 }) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("OpenAI OAuth start reports upstream failure without credential material", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = (async () => new Response(JSON.stringify({ error: "upstream" }), { status: 500 })) as typeof fetch + try { + await expect(llmProviderModule.startOpenAiDeviceAuth()).rejects.toMatchObject({ + error: "openai_oauth_start_failed", + status: 502, + }) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("OpenAI OAuth completion reports token exchange failure without credential material", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith("/api/accounts/deviceauth/token")) { + return Response.json({ authorization_code: "authorization-code", code_verifier: "verifier" }) + } + if (url.endsWith("/oauth/token")) { + return new Response(JSON.stringify({ error: "exchange_failed" }), { status: 500 }) + } + return new Response("not found", { status: 404 }) + }) as typeof fetch + + try { + await expect(llmProviderModule.completeOpenAiDeviceAuth({ + deviceAuthId: "device-failure", + userCode: "CODE", + })).rejects.toMatchObject({ error: "openai_oauth_complete_failed", status: 502 }) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("OpenAI OAuth completion returns importable OpenCode OAuth auth on success", async () => { + const originalFetch = globalThis.fetch + const calls: string[] = [] + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input) + calls.push(url) + if (url.endsWith("/api/accounts/deviceauth/token")) { + return Response.json({ authorization_code: "authorization-code", code_verifier: "verifier" }) + } + if (url.endsWith("/oauth/token")) { + return Response.json({ + access_token: jwtWithClaims({ chatgpt_account_id: "acct_123" }), + refresh_token: "refresh-token", + expires_in: 60, + }) + } + return new Response("not found", { status: 404 }) + }) as typeof fetch + + try { + const completed = await llmProviderModule.completeOpenAiDeviceAuth({ + deviceAuthId: "device-complete", + userCode: "CODE", + }) + const auth = JSON.parse(completed.opencodeAuth) as Record + + expect(calls).toHaveLength(2) + expect(auth.type).toBe("oauth") + expect(typeof auth.access).toBe("string") + expect(auth.refresh).toBe("refresh-token") + expect(auth.accountId).toBe("acct_123") + expect(completed.accountId).toBe("acct_123") + expect(typeof auth.expires).toBe("number") + } finally { + globalThis.fetch = originalFetch + } +}) + +test("LLM provider migration journal remains valid JSON", async () => { + const journal = await readFile(new URL("../../../packages/den-db/drizzle/meta/_journal.json", import.meta.url), "utf8") + const parsed = JSON.parse(journal) as { entries?: Array<{ tag?: string }> } + + expect(parsed.entries?.some((entry) => entry.tag === "0019_llm_provider_opencode_oauth")).toBe(true) +}) diff --git a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-data.tsx b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-data.tsx index 751b911462..53c89d7e0e 100644 --- a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-data.tsx +++ b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-data.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { getErrorMessage, requestJson } from "../../_lib/den-flow"; export type DenLlmProviderSource = "models_dev" | "custom" | "openwork"; +export type DenLlmProviderCredentialKind = "api_key" | "opencode_oauth"; export type DenLlmProviderModel = { id: string; @@ -41,7 +42,10 @@ export type DenLlmProvider = { providerId: string; name: string; providerConfig: Record; + credentialKind: DenLlmProviderCredentialKind; hasApiKey: boolean; + hasOpencodeAuth: boolean; + hasCredential: boolean; createdAt: string | null; updatedAt: string | null; canManage: boolean; @@ -178,6 +182,7 @@ function asLlmProvider(value: unknown): DenLlmProvider | null { value.source === "models_dev" || value.source === "custom" || value.source === "openwork" ? value.source : null; + const credentialKind = value.credentialKind === "opencode_oauth" ? "opencode_oauth" : "api_key"; if (!id || !organizationId || !createdByOrgMembershipId || !providerId || !name || !source) { return null; } @@ -190,7 +195,10 @@ function asLlmProvider(value: unknown): DenLlmProvider | null { providerId, name, providerConfig: asJsonRecord(value.providerConfig), + credentialKind, hasApiKey: value.hasApiKey === true, + hasOpencodeAuth: value.hasOpencodeAuth === true, + hasCredential: value.hasCredential === true || value.hasApiKey === true || value.hasOpencodeAuth === true, createdAt: asIsoString(value.createdAt), updatedAt: asIsoString(value.updatedAt), canManage: value.canManage === true, diff --git a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-detail-screen.tsx b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-detail-screen.tsx index c208bfdaa9..4b50aac65c 100644 --- a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-detail-screen.tsx +++ b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-detail-screen.tsx @@ -185,10 +185,10 @@ export function LlmProviderDetailScreen({
- {provider.hasApiKey + {provider.hasCredential ? "Credential saved" : "Credential missing"}
@@ -221,10 +221,10 @@ export function LlmProviderDetailScreen({

- Updated + Credential

- {formatProviderTimestamp(provider.updatedAt)} + {provider.credentialKind === "opencode_oauth" ? "OpenCode OAuth" : "API key"}

diff --git a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-editor-screen.tsx b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-editor-screen.tsx index fec4fc356b..d07a559201 100644 --- a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-editor-screen.tsx +++ b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-editor-screen.tsx @@ -34,6 +34,7 @@ import { requestLlmProviderCatalogDetail, useOrgLlmProviders, type DenLlmProvider, + type DenLlmProviderCredentialKind, type DenModelsDevProviderDetail, type DenModelsDevProviderSummary, } from "./llm-provider-data"; @@ -88,7 +89,18 @@ export function LlmProviderEditorScreen({ const [customConfigText, setCustomConfigText] = useState( buildCustomProviderTemplate(), ); + const [credentialKind, setCredentialKind] = + useState("api_key"); const [apiKey, setApiKey] = useState(""); + const [opencodeAuth, setOpencodeAuth] = useState(""); + const [openAiOauthBusy, setOpenAiOauthBusy] = useState(false); + const [openAiOauthError, setOpenAiOauthError] = useState(null); + const [openAiOauthSession, setOpenAiOauthSession] = useState<{ + verificationUrl: string; + userCode: string; + deviceAuthId: string; + intervalMs: number; + } | null>(null); const [selectedMemberIds, setSelectedMemberIds] = useState([]); const [selectedTeamIds, setSelectedTeamIds] = useState([]); const [saveBusy, setSaveBusy] = useState(false); @@ -146,7 +158,11 @@ export function LlmProviderEditorScreen({ ? buildEditableCustomProviderText(provider) : buildCustomProviderTemplate(), ); + setCredentialKind(provider.credentialKind); setApiKey(""); + setOpencodeAuth(""); + setOpenAiOauthError(null); + setOpenAiOauthSession(null); return; } @@ -159,9 +175,90 @@ export function LlmProviderEditorScreen({ ); setSelectedTeamIds([]); setCustomConfigText(buildCustomProviderTemplate()); + setCredentialKind("api_key"); setApiKey(""); + setOpencodeAuth(""); + setOpenAiOauthError(null); + setOpenAiOauthSession(null); }, [orgContext?.currentMember.id, provider]); + useEffect(() => { + setOpenAiOauthError(null); + setOpenAiOauthSession(null); + }, [credentialKind, selectedProviderId, source]); + + async function startOpenAiOauth() { + setOpenAiOauthBusy(true); + setOpenAiOauthError(null); + try { + const { response, payload } = await requestJson( + "/v1/llm-providers/openai-oauth/start", + { method: "POST", body: JSON.stringify({}) }, + 20000, + ); + if (!response.ok) { + throw new Error(getErrorMessage(payload, `Failed to start OpenAI OAuth (${response.status}).`)); + } + if (!payload || typeof payload !== "object") { + throw new Error("OpenAI OAuth response was empty."); + } + const data = payload as Record; + if ( + typeof data.verificationUrl !== "string" || + typeof data.userCode !== "string" || + typeof data.deviceAuthId !== "string" || + typeof data.intervalMs !== "number" + ) { + throw new Error("OpenAI OAuth response was incomplete."); + } + setOpenAiOauthSession({ + verificationUrl: data.verificationUrl, + userCode: data.userCode, + deviceAuthId: data.deviceAuthId, + intervalMs: data.intervalMs, + }); + window.open(data.verificationUrl, "_blank", "noopener,noreferrer"); + } catch (error) { + setOpenAiOauthError(error instanceof Error ? error.message : "Could not start OpenAI OAuth."); + } finally { + setOpenAiOauthBusy(false); + } + } + + async function completeOpenAiOauth() { + if (!openAiOauthSession) { + setOpenAiOauthError("Start OpenAI OAuth first."); + return; + } + setOpenAiOauthBusy(true); + setOpenAiOauthError(null); + try { + const { response, payload } = await requestJson( + "/v1/llm-providers/openai-oauth/complete", + { + method: "POST", + body: JSON.stringify({ + deviceAuthId: openAiOauthSession.deviceAuthId, + userCode: openAiOauthSession.userCode, + }), + }, + 20000, + ); + if (!response.ok) { + throw new Error(getErrorMessage(payload, response.status === 409 ? "OpenAI authorization is not complete yet." : `Failed to complete OpenAI OAuth (${response.status}).`)); + } + if (!payload || typeof payload !== "object" || typeof (payload as Record).opencodeAuth !== "string") { + throw new Error("OpenAI OAuth completion response was incomplete."); + } + setOpencodeAuth((payload as { opencodeAuth: string }).opencodeAuth); + setOpenAiOauthSession(null); + } catch (error) { + setOpenAiOauthError(error instanceof Error ? error.message : "Could not complete OpenAI OAuth."); + } finally { + setOpenAiOauthBusy(false); + } + } + useEffect(() => { if (source !== "models_dev" || !orgId || !selectedProviderId) { setCatalogDetail(null); @@ -286,6 +383,11 @@ export function LlmProviderEditorScreen({ } } + if (credentialKind === "opencode_oauth" && source === "models_dev" && selectedProviderId !== "openai") { + setSaveError("OpenCode OAuth credentials are only available for the OpenAI catalog provider."); + return; + } + if (source === "custom" && !customConfigText.trim()) { setSaveError("Paste a custom provider config."); return; @@ -297,6 +399,7 @@ export function LlmProviderEditorScreen({ const body: Record = { name: providerName.trim(), source, + credentialKind, memberIds: [...new Set(selectedMemberIds)], teamIds: [...new Set(selectedTeamIds)], }; @@ -308,10 +411,14 @@ export function LlmProviderEditorScreen({ body.customConfigText = customConfigText; } - if (apiKey.trim() || !provider) { + if (credentialKind === "api_key" && (apiKey.trim() || !provider || provider.credentialKind !== "api_key")) { body.apiKey = apiKey.trim(); } + if (credentialKind === "opencode_oauth" && (opencodeAuth.trim() || !provider || provider.credentialKind !== "opencode_oauth")) { + body.opencodeAuth = opencodeAuth.trim(); + } + const path = provider ? `/v1/llm-providers/${encodeURIComponent(provider.id)}` : `/v1/llm-providers`; @@ -607,28 +714,113 @@ export function LlmProviderEditorScreen({ Credential - {provider?.hasApiKey ? ( + {provider?.hasCredential ? ( Existing credential saved ) : null} -