From 3bb9c5478febc9eba242387b030d7acc9635972b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 10 Jun 2026 20:33:59 +0200 Subject: [PATCH 01/22] feat(app): support managed cloud provider bootstrap --- apps/app/scripts/workspace-endpoint.test.ts | 34 + .../src/app/cloud/managed-provider-models.ts | 63 + apps/app/src/app/lib/den.ts | 80 +- apps/app/src/app/lib/desktop-types.ts | 4 + apps/app/src/app/lib/openwork-links.ts | 19 + apps/app/src/app/lib/openwork-server.ts | 26 +- apps/app/src/app/lib/workspace-endpoint.ts | 14 +- apps/app/src/components/model-select.tsx | 24 +- .../domains/cloud/org-onboarding-page.tsx | 141 +- .../connections/provider-auth/store.ts | 188 +- .../settings/cloud/cloud-session-provider.tsx | 4 +- .../settings/pages/cloud-providers-view.tsx | 4 +- .../settings/pages/cloud-workers-view.tsx | 128 +- .../src/react-app/domains/workspace/types.ts | 4 + .../use-remote-workspace-connection-editor.ts | 4 +- .../app/src/react-app/shell/session-route.tsx | 42 +- .../src/react-app/shell/settings-route.tsx | 53 +- .../app/src/react-app/shell/welcome-route.tsx | 10 +- .../src/react-app/shell/workspace-provider.ts | 12 +- .../tests/den-managed-provider-sync.test.ts | 102 + .../app/tests/managed-provider-models.test.ts | 154 + .../provider-auth-managed-providers.test.ts | 72 + apps/desktop/electron/bootstrap-config.mjs | 151 + .../electron/bootstrap-config.test.mjs | 106 + apps/desktop/electron/browser-mcp.mjs | 377 + .../desktop/electron/browser-native-tools.mjs | 918 +++ .../electron/browser-native-tools.test.mjs | 59 + apps/desktop/electron/desktop-fetch.mjs | 40 + apps/desktop/electron/desktop-fetch.test.mjs | 38 + apps/desktop/electron/main.cjs | 6 + apps/desktop/electron/main.mjs | 476 +- .../desktop/electron/menu-overlay-preload.mjs | 6 +- .../desktop/electron/opencode-config-json.mjs | 10 + .../electron/opencode-config-json.test.mjs | 24 + apps/desktop/electron/preload.mjs | 6 +- apps/desktop/electron/remote-workspace.mjs | 114 + .../electron/remote-workspace.test.mjs | 85 + apps/desktop/electron/runtime.test.mjs | 3 +- apps/desktop/package.json | 3 + .../scripts/check-packaged-startup.mjs | 53 + apps/server/src/env-routes.e2e.test.ts | 76 + .../src/managed-provider-sync.e2e.test.ts | 567 ++ apps/server/src/server.ts | 627 +- apps/server/src/types.ts | 12 + .../server/src/workspace-activate.e2e.test.ts | 18 + apps/server/src/workspaces.ts | 6 + .../src/routes/auth/desktop-handoff.ts | 56 +- .../den-api/src/routes/org/llm-providers.ts | 470 +- ee/apps/den-api/src/routes/workers/index.ts | 2 + .../src/routes/workers/managed-providers.ts | 219 + ee/apps/den-api/src/routes/workers/shared.ts | 32 +- ee/apps/den-api/test/desktop-handoff.test.ts | 32 + .../llm-provider-access-lifecycle.test.ts | 170 + .../test/llm-provider-credentials.test.ts | 73 + .../den-api/test/llm-providers-oauth.test.ts | 220 + .../test/managed-provider-sync.test.ts | 153 + ee/apps/den-web/app/(den)/_lib/den-flow.ts | 32 +- .../(den)/_providers/den-flow-provider.tsx | 16 +- .../_components/background-agents-screen.tsx | 18 +- .../_components/llm-provider-data.test.tsx | 62 + .../_components/llm-provider-data.tsx | 28 +- .../llm-provider-detail-screen.tsx | 8 +- .../llm-provider-editor-screen.tsx | 238 +- .../_components/llm-providers-screen.tsx | 4 +- .../0021_llm_provider_opencode_oauth.sql | 3 + .../den-db/drizzle/meta/0021_snapshot.json | 7314 +++++++++++++++++ ee/packages/den-db/drizzle/meta/_journal.json | 9 +- .../src/schema/sharables/llm-providers.ts | 4 + pnpm-lock.yaml | 13 + 69 files changed, 13769 insertions(+), 370 deletions(-) create mode 100644 apps/app/scripts/workspace-endpoint.test.ts create mode 100644 apps/app/src/app/cloud/managed-provider-models.ts create mode 100644 apps/app/tests/den-managed-provider-sync.test.ts create mode 100644 apps/app/tests/managed-provider-models.test.ts create mode 100644 apps/app/tests/provider-auth-managed-providers.test.ts 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/browser-native-tools.test.mjs create mode 100644 apps/desktop/electron/desktop-fetch.mjs create mode 100644 apps/desktop/electron/desktop-fetch.test.mjs create mode 100644 apps/desktop/electron/main.cjs create mode 100644 apps/desktop/electron/opencode-config-json.mjs create mode 100644 apps/desktop/electron/opencode-config-json.test.mjs create mode 100644 apps/desktop/scripts/check-packaged-startup.mjs 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/desktop-handoff.test.ts create mode 100644 ee/apps/den-api/test/llm-provider-access-lifecycle.test.ts create mode 100644 ee/apps/den-api/test/llm-provider-credentials.test.ts create mode 100644 ee/apps/den-api/test/llm-providers-oauth.test.ts create mode 100644 ee/apps/den-api/test/managed-provider-sync.test.ts create mode 100644 ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-data.test.tsx create mode 100644 ee/packages/den-db/drizzle/0021_llm_provider_opencode_oauth.sql create mode 100644 ee/packages/den-db/drizzle/meta/0021_snapshot.json diff --git a/apps/app/scripts/workspace-endpoint.test.ts b/apps/app/scripts/workspace-endpoint.test.ts new file mode 100644 index 0000000000..e4d087f068 --- /dev/null +++ b/apps/app/scripts/workspace-endpoint.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { resolveWorkspaceEndpoint } from "../src/app/lib/workspace-endpoint"; + +describe("resolveWorkspaceEndpoint", () => { + test("does not use remote host token as bearer authorization", () => { + const endpoint = resolveWorkspaceEndpoint({ + id: "rem_ws_123", + workspaceType: "remote", + baseUrl: "https://worker.example.test", + openworkHostUrl: "https://worker.example.test", + openworkToken: null, + openworkClientToken: null, + openworkHostToken: "host-token-must-not-be-bearer", + openworkWorkspaceId: "ws_123", + } as never, { baseUrl: "http://127.0.0.1:8791", token: "local-token" }); + + expect(endpoint?.token).toBe(""); + }); + + test("uses remote client token before local server token", () => { + const endpoint = resolveWorkspaceEndpoint({ + id: "rem_ws_123", + workspaceType: "remote", + baseUrl: "https://worker.example.test", + openworkHostUrl: "https://worker.example.test", + openworkToken: null, + openworkClientToken: "remote-client-token", + openworkHostToken: "host-token", + openworkWorkspaceId: "ws_123", + } as never, { baseUrl: "http://127.0.0.1:8791", token: "local-token" }); + + expect(endpoint?.token).toBe("remote-client-token"); + }); +}); diff --git a/apps/app/src/app/cloud/managed-provider-models.ts b/apps/app/src/app/cloud/managed-provider-models.ts new file mode 100644 index 0000000000..6758262118 --- /dev/null +++ b/apps/app/src/app/cloud/managed-provider-models.ts @@ -0,0 +1,63 @@ +import type { CloudImportedProvider } from "./import-state"; +import type { ModelOption, ProviderListItem } from "../types"; + +export function buildCloudManagedModelIdsByProvider( + importedCloudProviders: Record | null | undefined, +): Map> { + const next = new Map>(); + for (const imported of Object.values(importedCloudProviders ?? {})) { + const providerId = imported.providerId.trim(); + if (!providerId) continue; + const modelIds = imported.modelIds.map((id) => id.trim()).filter(Boolean); + if (!modelIds.length) continue; + const merged = next.get(providerId) ?? new Set(); + for (const modelId of modelIds) merged.add(modelId); + next.set(providerId, merged); + } + return next; +} + +export function isCloudManagedModelAllowed( + cloudManagedModelIdsByProvider: Map>, + providerId: string, + modelId: string, +) { + const allowedModelIds = cloudManagedModelIdsByProvider.get(providerId); + return !allowedModelIds || allowedModelIds.has(modelId); +} + +export function hasCloudManagedModelAllowlist( + cloudManagedModelIdsByProvider: Map>, + providerId: string, +) { + return cloudManagedModelIdsByProvider.has(providerId); +} + +export function buildCloudManagedModelOptions(input: { + providers: ProviderListItem[]; + cloudManagedModelIdsByProvider: Map>; + isRecommendedProvider?: (providerId: string) => boolean; +}): ModelOption[] { + const options: ModelOption[] = []; + for (const provider of input.providers) { + const isCloudManaged = hasCloudManagedModelAllowlist(input.cloudManagedModelIdsByProvider, provider.id); + for (const [modelId, model] of Object.entries(provider.models)) { + if (!isCloudManagedModelAllowed(input.cloudManagedModelIdsByProvider, provider.id, modelId)) continue; + options.push({ + providerID: provider.id, + modelID: modelId, + title: model.name || modelId, + description: provider.name, + behaviorTitle: "Reasoning", + behaviorLabel: "Default", + behaviorDescription: "", + behaviorValue: null, + isFree: false, + isConnected: true, + isRecommended: input.isRecommendedProvider?.(provider.id), + source: isCloudManaged || /^lpr_/i.test(provider.id) ? "cloud" : undefined, + }); + } + } + return options; +} diff --git a/apps/app/src/app/lib/den.ts b/apps/app/src/app/lib/den.ts index 47049fee49..f29bb66e51 100644 --- a/apps/app/src/app/lib/den.ts +++ b/apps/app/src/app/lib/den.ts @@ -129,10 +129,13 @@ export type DenOrgLlmProviderModel = { export type DenOrgLlmProvider = { id: string; source: "models_dev" | "custom" | "openwork"; + credentialKind: "api_key" | "opencode_oauth"; providerId: string; name: string; providerConfig: Record; hasApiKey: boolean; + hasOpencodeAuth: boolean; + hasCredential: boolean; models: DenOrgLlmProviderModel[]; createdAt: string | null; updatedAt: string | null; @@ -140,6 +143,15 @@ export type DenOrgLlmProvider = { export type DenOrgLlmProviderConnection = DenOrgLlmProvider & { apiKey: string | null; + opencodeAuth: string | null; +}; + +export type DenManagedProviderSyncResult = { + status: "applied" | "failed"; + providerCount: number; + revision: string; + providerIds?: string[]; + reason?: string; }; export type DenPluginConfigObjectType = "skill" | "agent" | "command" | "tool" | "mcp" | "hook" | "context" | "custom"; @@ -675,8 +687,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 { @@ -734,9 +758,11 @@ export async function initializeDenBootstrapConfig(): Promise { const parsed = parseDenOrgLlmProviderModel(model); @@ -1234,6 +1263,28 @@ 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, + }; +} + +function getDenManagedProviderSyncResult(payload: unknown): DenManagedProviderSyncResult | null { + if (!isRecord(payload)) return null; + if (payload.status !== "applied" && payload.status !== "failed") return null; + if (typeof payload.providerCount !== "number" || !Number.isInteger(payload.providerCount) || payload.providerCount < 0) return null; + if (typeof payload.revision !== "string") return null; + const rawProviderIds = Array.isArray(payload.providerIds) + ? payload.providerIds + : Array.isArray(payload.appliedProviderIds) + ? payload.appliedProviderIds + : undefined; + const providerIds = rawProviderIds ? readStringArray(rawProviderIds) : undefined; + if (rawProviderIds && providerIds?.length !== payload.providerCount) return null; + return { + status: payload.status, + providerCount: payload.providerCount, + revision: payload.revision, + ...(providerIds ? { providerIds } : {}), + ...(typeof payload.reason === "string" ? { reason: payload.reason } : {}), }; } @@ -2069,7 +2120,7 @@ export function createDenClient(options: { baseUrl: string; apiBaseUrl?: string }, async listOrgLlmProviders(orgId: string): Promise { - const payload = await requestJson(baseUrls, "/v1/llm-providers", { + const payload = await requestJson(baseUrls, "/v1/llm-providers?scope=usable", { method: "GET", token, organizationId: orgId, @@ -2080,7 +2131,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, @@ -2094,6 +2145,27 @@ export function createDenClient(options: { baseUrl: string; apiBaseUrl?: string return provider; }, + async syncWorkerManagedProviders(orgId: string, workerId: string): Promise { + const payload = await requestJson( + baseUrls, + `/v1/workers/${encodeURIComponent(workerId)}/managed-providers/sync`, + { + method: "POST", + token, + organizationId: orgId, + body: {}, + }, + ); + const result = getDenManagedProviderSyncResult(payload); + if (!result) { + throw new DenApiError(500, "invalid_managed_provider_sync_payload", "Managed provider sync response was invalid."); + } + if (result.status !== "applied") { + throw new DenApiError(502, "managed_provider_sync_failed", result.reason ?? "Managed provider sync failed."); + } + return result; + }, + async listOrgMarketplaces(orgId: string): Promise { const payload = await requestJson( baseUrls, diff --git a/apps/app/src/app/lib/desktop-types.ts b/apps/app/src/app/lib/desktop-types.ts index c84e6b6124..71512c94d4 100644 --- a/apps/app/src/app/lib/desktop-types.ts +++ b/apps/app/src/app/lib/desktop-types.ts @@ -81,6 +81,10 @@ export type WorkspaceInfo = { openworkHostToken?: string | null; openworkWorkspaceId?: string | null; openworkWorkspaceName?: string | null; + openworkDenBaseUrl?: string | null; + openworkDenApiBaseUrl?: 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-links.ts b/apps/app/src/app/lib/openwork-links.ts index 2508ad929e..e4ed30790f 100644 --- a/apps/app/src/app/lib/openwork-links.ts +++ b/apps/app/src/app/lib/openwork-links.ts @@ -4,6 +4,12 @@ import { normalizeOpenworkServerUrl } from "./openwork-server"; export type RemoteWorkspaceDefaults = { openworkHostUrl?: string | null; openworkToken?: string | null; + openworkClientToken?: string | null; + openworkHostToken?: string | null; + openworkDenBaseUrl?: string | null; + openworkDenApiBaseUrl?: string | null; + openworkDenOrgId?: string | null; + openworkDenWorkerId?: string | null; directory?: string | null; displayName?: string | null; autoConnect?: boolean; @@ -44,6 +50,7 @@ export function parseRemoteConnectDeepLink(rawUrl: string): RemoteWorkspaceDefau const tokenRaw = url.searchParams.get("openworkToken") ?? url.searchParams.get("accessToken") ?? ""; const normalizedHostUrl = normalizeOpenworkServerUrl(hostUrlRaw); const token = tokenRaw.trim(); + const clientToken = url.searchParams.get("openworkClientToken")?.trim() || token; if (!normalizedHostUrl || !token) { return null; } @@ -61,6 +68,12 @@ export function parseRemoteConnectDeepLink(rawUrl: string): RemoteWorkspaceDefau return { openworkHostUrl: normalizedHostUrl, openworkToken: token, + openworkClientToken: clientToken || null, + openworkHostToken: url.searchParams.get("openworkHostToken")?.trim() || null, + openworkDenBaseUrl: url.searchParams.get("openworkDenBaseUrl")?.trim() || null, + openworkDenApiBaseUrl: url.searchParams.get("openworkDenApiBaseUrl")?.trim() || null, + openworkDenOrgId: url.searchParams.get("openworkDenOrgId")?.trim() || null, + openworkDenWorkerId: url.searchParams.get("openworkDenWorkerId")?.trim() || workerId || null, directory: null, displayName: displayName || null, autoConnect, @@ -80,6 +93,12 @@ export function stripRemoteConnectQuery(rawUrl: string): string | null { "openworkHostUrl", "openworkUrl", "openworkToken", + "openworkClientToken", + "openworkHostToken", + "openworkDenBaseUrl", + "openworkDenApiBaseUrl", + "openworkDenOrgId", + "openworkDenWorkerId", "accessToken", "workerId", "workerName", diff --git a/apps/app/src/app/lib/openwork-server.ts b/apps/app/src/app/lib/openwork-server.ts index 9d01bb6615..4d0ff1445b 100644 --- a/apps/app/src/app/lib/openwork-server.ts +++ b/apps/app/src/app/lib/openwork-server.ts @@ -560,6 +560,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; @@ -678,7 +700,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) ?? ""; @@ -687,7 +709,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..87b2bbae00 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,17 @@ function pickRemoteBaseUrl(workspace: WorkspaceEndpointInput): string { function pickRemoteToken(workspace: WorkspaceEndpointInput): string { if (!workspace) return ""; return ( - workspace.openworkToken ?? workspace.openworkClientToken ?? - workspace.openworkHostToken ?? + workspace.openworkToken ?? "" ).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 +122,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 +135,7 @@ export function resolveWorkspaceEndpoint( return { baseUrl, token, + hostToken, workspaceId, isRemote: true, client, @@ -149,6 +158,7 @@ export function resolveWorkspaceEndpoint( return { baseUrl: localBaseUrl, token: localToken, + hostToken: "", workspaceId, isRemote: false, client, diff --git a/apps/app/src/components/model-select.tsx b/apps/app/src/components/model-select.tsx index b0386536c1..d18f131446 100644 --- a/apps/app/src/components/model-select.tsx +++ b/apps/app/src/components/model-select.tsx @@ -45,6 +45,7 @@ import { import { isDesktopProviderBlocked } from "@/app/cloud/desktop-app-restrictions"; import { openModelPickerEvent } from "@/react-app/shell/new-providers-toast"; import { newProvidersEvent } from "@/app/lib/provider-events"; +import { buildCloudManagedModelOptions } from "@/app/cloud/managed-provider-models"; function getProviderDisplayName(providerId: string) { return providerId @@ -55,7 +56,7 @@ function getProviderDisplayName(providerId: string) { } function useModelOptions(open: boolean) { - const { client, opencodeBaseUrl, selectedWorkspaceRoot } = useWorkspace(); + const { client, opencodeBaseUrl, selectedWorkspaceRoot, cloudManagedModelIdsByProvider } = useWorkspace(); const checkDesktopRestriction = useCheckDesktopRestriction(); const { data, refetch } = useProviderListQuery({ @@ -89,21 +90,10 @@ function useModelOptions(open: boolean) { restriction: "allowCustomProviders", }); - const options = getConnectedProviderItems(data) - .flatMap((provider) => - Object.entries(provider.models).map(([id, model]) => ({ - providerID: provider.id, - modelID: id, - title: model.name, - description: provider.name, - behaviorTitle: "Reasoning", - behaviorLabel: "Default", - behaviorDescription: "", - behaviorValue: null, - isFree: false, - isConnected: true, - })), - ); + const options = buildCloudManagedModelOptions({ + providers: getConnectedProviderItems(data), + cloudManagedModelIdsByProvider, + }); return options.filter((option) => { if ( @@ -121,7 +111,7 @@ function useModelOptions(open: boolean) { return true; }); - }, [checkDesktopRestriction, data]); + }, [checkDesktopRestriction, cloudManagedModelIdsByProvider, data]); } type ModelSelectModelItem = { diff --git a/apps/app/src/react-app/domains/cloud/org-onboarding-page.tsx b/apps/app/src/react-app/domains/cloud/org-onboarding-page.tsx index d9790776ae..42baed522d 100644 --- a/apps/app/src/react-app/domains/cloud/org-onboarding-page.tsx +++ b/apps/app/src/react-app/domains/cloud/org-onboarding-page.tsx @@ -25,8 +25,22 @@ import { type DenOrgSummary, type DenWorkerSummary, } from "@/app/lib/den"; +import { + resolveWorkspaceListSelectedId, + workspaceCreateRemote, + workspaceSetRuntimeActive, + workspaceSetSelected, + type WorkspaceList, +} from "@/app/lib/desktop"; +import { + stripOpenworkWorkspaceMount, + writeOpenworkServerSettings, +} from "@/app/lib/openwork-server"; import { usePlatform } from "../../kernel/platform"; +import { useLocal } from "../../kernel/local-provider"; import { useBootState } from "../../shell/boot-state"; +import { writeActiveWorkspaceId } from "../../shell/session-memory"; +import { workspaceSessionRoute } from "../../shell/workspace-routes"; import { resolveModelDisplayName, resolveProviderDisplayName } from "@/app/utils"; import { ProviderIcon } from "../../design-system/provider-icon"; import { writeStoredDefaultModel } from "../../kernel/model-config"; @@ -204,6 +218,7 @@ export function OrgOnboardingPage() { export function ResourceSelectionPage() { const navigate = useNavigate(); const platform = usePlatform(); + const local = useLocal(); const { markRouteReady } = useBootState(); const { authToken, denClient, orgId, orgName, settings } = useDenClient(); @@ -212,6 +227,8 @@ export function ResourceSelectionPage() { modelId: string; label: string; } | null>(null); + const [continueBusy, setContinueBusy] = useState(false); + const [continueError, setContinueError] = useState(null); // Redirect if no auth or no org — can't show onboarding without them useEffect(() => { @@ -255,24 +272,94 @@ export function ResourceSelectionPage() { }), }); - const handleContinue = useCallback(() => { - // If user picked a default model, write it - if (selectedDefault) { - writeStoredDefaultModel({ - providerID: selectedDefault.providerId, - modelID: selectedDefault.modelId, - }); + const connectHealthyWorker = useCallback(async () => { + if (!orgId) { + return null; } - // Mark all providers shown on this page as "seen" so the global - // toast doesn't re-fire for them on the next sync interval. - markProvidersSeen(providers); - if (providers.length > 0) { - try { - window.localStorage.setItem(RELOAD_AFTER_ONBOARDING_KEY, "1"); - } catch {} + + const healthyWorker = workers.find((worker) => worker.status === "healthy") ?? null; + if (!healthyWorker) { + if (workers.length > 0) { + throw new Error("No healthy cloud worker is attached to this organization yet."); + } + return null; } - navigate("/session", { replace: true }); - }, [navigate, providers, selectedDefault]); + + const tokens = await denClient.getWorkerTokens(healthyWorker.workerId, orgId); + const openworkUrl = tokens.openworkUrl?.trim() ?? ""; + const openworkHostUrl = stripOpenworkWorkspaceMount(openworkUrl); + const accessToken = tokens.clientToken?.trim() || tokens.ownerToken?.trim() || ""; + if (!openworkUrl || !accessToken) { + throw new Error("The shared worker is not ready yet."); + } + + const list = await workspaceCreateRemote({ + baseUrl: openworkUrl, + openworkHostUrl: openworkUrl, + openworkToken: accessToken, + openworkClientToken: tokens.clientToken?.trim() || null, + openworkHostToken: tokens.hostToken?.trim() || null, + openworkDenBaseUrl: settings.baseUrl, + openworkDenApiBaseUrl: settings.apiBaseUrl, + openworkDenOrgId: orgId, + openworkDenWorkerId: healthyWorker.workerId, + displayName: healthyWorker.workerName, + directory: null, + remoteType: "openwork", + }) as WorkspaceList; + + const createdId = + resolveWorkspaceListSelectedId(list) || + list.workspaces[list.workspaces.length - 1]?.id || + ""; + + if (createdId) { + await workspaceSetSelected(createdId).catch(() => undefined); + await workspaceSetRuntimeActive(createdId).catch(() => undefined); + writeActiveWorkspaceId(createdId); + } + + writeOpenworkServerSettings({ + urlOverride: openworkHostUrl || openworkUrl, + token: accessToken, + hostToken: tokens.hostToken?.trim() || undefined, + }); + try { + window.dispatchEvent(new CustomEvent("openwork-server-settings-changed")); + } catch { + // Best-effort only. + } + + return createdId || null; + }, [denClient, orgId, settings.baseUrl, workers]); + + const handleContinue = useCallback(async () => { + setContinueBusy(true); + setContinueError(null); + try { + if (selectedDefault) { + writeStoredDefaultModel({ + providerID: selectedDefault.providerId, + modelID: selectedDefault.modelId, + }); + } + + markProvidersSeen(providers); + if (providers.length > 0) { + try { + window.localStorage.setItem(RELOAD_AFTER_ONBOARDING_KEY, "1"); + } catch {} + } + + const workspaceId = await connectHealthyWorker(); + local.setPrefs((previous) => ({ ...previous, hasCompletedOnboarding: true })); + navigate(workspaceId ? workspaceSessionRoute(workspaceId) : "/session", { replace: true }); + } catch (error) { + setContinueError(error instanceof Error ? error.message : "Could not connect the shared worker."); + } finally { + setContinueBusy(false); + } + }, [connectHealthyWorker, local, navigate, providers, selectedDefault]); const totalModels = providers.reduce((sum, provider) => sum + provider.models.length, 0); const hasResources = providers.length > 0 || marketplaces.length > 0 || workers.length > 0; @@ -298,6 +385,11 @@ export function ResourceSelectionPage() { {error} + ) : continueError ? ( + + + {continueError} + ) : hasResources ? ( You have access to the following resources. @@ -410,10 +502,10 @@ export function ResourceSelectionPage() { className="w-fit" type="button" size="lg" - onClick={handleContinue} - disabled={loading} + onClick={() => void handleContinue()} + disabled={loading || continueBusy} > - {hasResources ? "Continue to workspace" : "Continue"} + {continueBusy ? "Connecting workspace..." : hasResources ? "Continue to workspace" : "Continue"} @@ -510,9 +602,16 @@ interface ProviderCardProps { } | null) => void; } +function getCloudManagedProviderId( + provider: Pick, +) { + if (provider.source === "openwork") return "openwork"; + if (provider.credentialKind === "opencode_oauth") return provider.providerId.trim(); + return provider.id.trim(); +} + function ProviderCard({ provider, selectedDefault, onSelectDefault }: ProviderCardProps) { - // The local provider ID matches the cloud provider's org-level ID - const localProviderId = provider.id.trim(); + const localProviderId = getCloudManagedProviderId(provider); const firstModel = provider.models[0] ?? null; const isSelected = selectedDefault?.providerId === localProviderId; diff --git a/apps/app/src/react-app/domains/connections/provider-auth/store.ts b/apps/app/src/react-app/domains/connections/provider-auth/store.ts index d37135f948..a97dae8337 100644 --- a/apps/app/src/react-app/domains/connections/provider-auth/store.ts +++ b/apps/app/src/react-app/domains/connections/provider-auth/store.ts @@ -32,7 +32,7 @@ import { filterProviderList, } from "../../../../app/utils/providers"; import { getReactQueryClient } from "../../../infra/query-client"; -import { ensureProviderListQuery } from "../provider-list-query"; +import { ensureProviderListQuery, refreshProviderListQueries } from "../provider-list-query"; import type { OpenworkServerStore } from "../openwork-server-store"; import { denSessionUpdatedEvent, @@ -119,6 +119,36 @@ type MutableState = { export type ProviderAuthStore = ReturnType; +export const getCloudManagedProviderId = ( + provider: Pick, +) => { + if (provider.source === "openwork") return "openwork"; + if (provider.credentialKind === "opencode_oauth") return provider.providerId.trim(); + return provider.id.trim(); +}; + +export function resolveAppliedManagedProvidersFromSyncResult( + result: { providerCount: number; providerIds?: string[] }, + liveProviders: DenOrgLlmProvider[], +) { + if (Array.isArray(result.providerIds)) { + const appliedIds = new Set(result.providerIds.map((id) => id.trim()).filter(Boolean)); + return liveProviders.filter((provider) => appliedIds.has(provider.id)); + } + + if (result.providerCount === liveProviders.length) { + return liveProviders; + } + + if (result.providerCount === 0) { + return []; + } + + throw new Error( + "Remote worker synced only part of the organization provider set but did not identify which providers were applied. Imported provider state was left unchanged.", + ); +} + export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) { const listeners = new Set<() => void>(); @@ -164,10 +194,6 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) const sameStringList = (a: string[], b: string[]) => a.length === b.length && a.every((value, index) => value === b[index]); - const getCloudManagedProviderId = ( - provider: Pick, - ) => provider.source === "openwork" ? "openwork" : provider.id.trim(); - const getProviderAuthWorkerType = (): "local" | "remote" => options.selectedWorkspaceDisplay().workspaceType === "remote" ? "remote" : "local"; @@ -186,7 +212,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) } for (const provider of state.cloudOrgProviders) { - const id = provider.providerId.trim(); + const id = getCloudManagedProviderId(provider); if (!id || merged.has(id)) continue; if (isDesktopProviderBlocked({ providerId: id, checkRestriction: options.checkDesktopAppRestriction })) continue; merged.set(id, { @@ -749,12 +775,12 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) ) => { const localProviderId = getCloudManagedProviderId(provider); const existingImported = state.importedCloudProviders[provider.id] ?? null; + const importedWithSameLocalId = Object.values(state.importedCloudProviders).find( + (entry) => entry.providerId === localProviderId && entry.cloudProviderId !== provider.id, + ); if ( - existingImported && - existingImported.providerId !== localProviderId && - Object.values(state.importedCloudProviders).some( - (entry) => entry.providerId === localProviderId && entry.cloudProviderId !== provider.id, - ) + importedWithSameLocalId && + (!existingImported || existingImported.providerId !== localProviderId) ) { throw new Error( `${localProviderId} is already imported from another cloud provider. Remove it before importing this one.`, @@ -1368,14 +1394,45 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) const existingImported = state.importedCloudProviders[cloudProviderId] ?? null; const localProviderId = getCloudManagedProviderId(provider); const apiKey = provider.apiKey?.trim() ?? ""; + const opencodeAuth = provider.opencodeAuth?.trim() ?? ""; const env = getCloudProviderEnv(provider.providerConfig); - if (!apiKey && env.length > 0) { + if (provider.credentialKind === "opencode_oauth" && !opencodeAuth) { + throw new Error(`${provider.name} does not have a stored OpenCode OAuth credential yet.`); + } + if (provider.credentialKind === "api_key" && !apiKey && env.length > 0) { throw new Error(`${provider.name} does not have a stored organization credential yet.`); } await assertCloudProviderImportSafe(provider); - if (apiKey) { + if (provider.credentialKind === "opencode_oauth" && opencodeAuth) { + let parsedAuth: unknown; + try { + parsedAuth = JSON.parse(opencodeAuth); + } catch { + throw new Error(`${provider.name} has invalid OpenCode OAuth JSON.`); + } + if (!parsedAuth || typeof parsedAuth !== "object" || Array.isArray(parsedAuth)) { + throw new Error(`${provider.name} OpenCode OAuth auth must be a JSON object.`); + } + const authRecord = parsedAuth as Record; + if (authRecord.type !== "oauth") { + throw new Error(`${provider.name} OpenCode OAuth auth must include type "oauth".`); + } + if (typeof authRecord.access !== "string" || !authRecord.access.trim()) { + throw new Error(`${provider.name} OpenCode OAuth auth must include an access token.`); + } + if (typeof authRecord.refresh !== "string" || !authRecord.refresh.trim()) { + throw new Error(`${provider.name} OpenCode OAuth auth must include a refresh token.`); + } + if (typeof authRecord.expires !== "number" || !Number.isFinite(authRecord.expires) || authRecord.expires < 0) { + throw new Error(`${provider.name} OpenCode OAuth auth must include a non-negative numeric expires value.`); + } + await c.auth.set({ + providerID: localProviderId, + auth: parsedAuth as Parameters[0]["auth"], + }); + } else if (apiKey) { await c.auth.set({ providerID: localProviderId, auth: { type: "api", key: apiKey }, @@ -1435,6 +1492,26 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) } async function connectCloudProvider(cloudProviderId: string) { + const target = getRemoteManagedProviderSyncTarget(); + if (target) { + setStateField("providerAuthError", null); + try { + const liveProviders = await refreshCloudOrgProviders({ force: true }); + const provider = liveProviders.find((entry) => entry.id === cloudProviderId); + if (!provider) { + throw new Error("Organization provider is no longer available."); + } + const appliedProviders = await syncRemoteManagedProviders("settings_cloud_opened", liveProviders, state.importedCloudProviders); + if (!appliedProviders?.some((entry) => entry.id === provider.id)) { + throw new Error(`${provider.name} does not have a stored organization credential yet.`); + } + return `${t("status.connected")} ${provider.name}`; + } catch (error) { + const message = describeProviderError(error, "Failed to sync organization provider to the remote worker."); + setStateField("providerAuthError", message); + throw error instanceof Error ? error : new Error(message); + } + } return await connectCloudProviderInternal(cloudProviderId); } @@ -1496,6 +1573,9 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) return message; }; + const shouldSurfaceCloudProviderSyncError = (reason: CloudProviderSyncReason) => + reason === "settings_cloud_opened"; + const getCloudProviderSyncContextKey = () => { const settings = readDenSettings(); return [ @@ -1532,6 +1612,78 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) (importedProvider.updatedAt ?? null) !== (provider.updatedAt ?? null) || !sameStringList(importedProvider.modelIds, sortStrings(provider.models.map((model) => model.id))); + const getRemoteManagedProviderSyncTarget = () => { + const workspace = options.selectedWorkspaceDisplay(); + if (workspace.workspaceType !== "remote") return null; + const workerId = workspace.openworkDenWorkerId?.trim() ?? ""; + if (!workerId) return null; + + const settings = readDenSettings(); + const orgId = settings.activeOrgId?.trim() ?? ""; + if (!settings.authToken?.trim() || !orgId) return null; + const workspaceOrgId = workspace.openworkDenOrgId?.trim() ?? ""; + if (workspaceOrgId && workspaceOrgId !== orgId) return null; + + return { settings, orgId, workerId }; + }; + + const rememberRemoteManagedProviderSync = async (providers: DenOrgLlmProvider[]) => { + const nextImportedProviders = Object.fromEntries( + providers.map((provider) => [ + provider.id, + { + cloudProviderId: provider.id, + providerId: getCloudManagedProviderId(provider), + sourceProviderId: provider.providerId, + name: provider.name, + source: provider.source, + updatedAt: provider.updatedAt ?? null, + modelIds: getProviderModelIds(provider), + importedAt: Date.now(), + }, + ]), + ); + await persistImportedCloudProviders(nextImportedProviders); + }; + + const syncRemoteManagedProviders = async ( + reason: CloudProviderSyncReason, + liveProviders: DenOrgLlmProvider[], + importedProviders: Record, + ) => { + const target = getRemoteManagedProviderSyncTarget(); + if (!target) return null; + + const den = createDenClient({ + baseUrl: target.settings.baseUrl, + apiBaseUrl: target.settings.apiBaseUrl, + token: target.settings.authToken, + }); + const syncResult = await den.syncWorkerManagedProviders(target.orgId, target.workerId); + const appliedProviders = resolveAppliedManagedProvidersFromSyncResult(syncResult, liveProviders); + await rememberRemoteManagedProviderSync(appliedProviders); + await refreshProviders({ dispose: true }).catch(() => null); + await refreshProviderListQueries(getReactQueryClient()).catch(() => undefined); + const newlyImported = appliedProviders.filter((provider) => !importedProviders[provider.id]); + if (newlyImported.length > 0) { + dispatchNewProviders({ + providers: newlyImported.map((provider) => { + const firstModel = provider.models[0] ?? null; + const localProviderId = getCloudManagedProviderId(provider); + return { + id: localProviderId, + name: provider.name, + providerId: localProviderId, + firstModelId: firstModel?.id, + firstModelName: firstModel?.name ?? firstModel?.id, + }; + }), + source: reason === "sign_in" ? "sign_in" : "cloud_sync", + }); + } + return appliedProviders; + }; + async function performCloudProviderSync(reason: CloudProviderSyncReason) { if (!hasCloudProviderSyncPrerequisites()) { return; @@ -1541,6 +1693,11 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) refreshImportedCloudProviders(), refreshCloudOrgProviders({ force: true }), ]); + + if (await syncRemoteManagedProviders(reason, liveProviders, importedProviders)) { + return; + } + const liveProviderMap = new Map(liveProviders.map((provider) => [provider.id, provider])); const failures: string[] = []; const processedLiveProviderIds = new Set(); @@ -1601,6 +1758,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) if (configChanged) { await refreshProviders({ dispose: true }).catch(() => null); + await refreshProviderListQueries(getReactQueryClient()).catch(() => undefined); } // Notify the UI about newly imported providers so the global toast @@ -1612,7 +1770,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) }); } - if (failures.length > 0) { + if (failures.length > 0 && !configChanged) { throw new Error(failures.join("\n")); } } @@ -1626,7 +1784,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) const request = performCloudProviderSync(reason) .catch((error) => { const message = logCloudProviderSyncError(reason, error); - if (reason === "settings_cloud_opened") { + if (shouldSurfaceCloudProviderSyncError(reason)) { setStateField("providerAuthError", message); } }) diff --git a/apps/app/src/react-app/domains/settings/cloud/cloud-session-provider.tsx b/apps/app/src/react-app/domains/settings/cloud/cloud-session-provider.tsx index 3dcdc56260..c6dc4d6e46 100644 --- a/apps/app/src/react-app/domains/settings/cloud/cloud-session-provider.tsx +++ b/apps/app/src/react-app/domains/settings/cloud/cloud-session-provider.tsx @@ -16,6 +16,7 @@ type CloudActiveOrganization = Pick; type CloudSessionContextValue = { client: DenClient; baseUrl: string; + apiBaseUrl: string; setBaseUrl: React.Dispatch>; authToken: string; setAuthToken: React.Dispatch>; @@ -80,6 +81,7 @@ export function CloudSessionProvider({ children }: CloudSessionProviderProps) { () => ({ client, baseUrl, + apiBaseUrl, setBaseUrl, authToken, setAuthToken, @@ -94,7 +96,7 @@ export function CloudSessionProvider({ children }: CloudSessionProviderProps) { activeOrgName, hasActiveOrg, }), - [activeOrgName, activeOrganization, authToken, baseUrl, client, hasActiveOrg, isSignedIn, statusMessage, user], + [activeOrgName, activeOrganization, apiBaseUrl, authToken, baseUrl, client, hasActiveOrg, isSignedIn, statusMessage, user], ); return {children}; diff --git a/apps/app/src/react-app/domains/settings/pages/cloud-providers-view.tsx b/apps/app/src/react-app/domains/settings/pages/cloud-providers-view.tsx index 1e48065a12..7b92f31c4f 100644 --- a/apps/app/src/react-app/domains/settings/pages/cloud-providers-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/cloud-providers-view.tsx @@ -11,6 +11,7 @@ import { useCloudSession } from "@/react-app/domains/settings/cloud/cloud-sessio import { CloudProvidersSection, type CloudProviderRow } from "@/react-app/domains/settings/cloud/sections"; import type { useDenSession } from "@/react-app/domains/settings/cloud/use-den-session"; import { SettingsNotice, SettingsStack } from "@/react-app/domains/settings/settings-section"; +import { getCloudManagedProviderId } from "@/react-app/domains/connections/provider-auth/store"; type CloudProvidersSession = Pick< ReturnType, @@ -54,9 +55,10 @@ export function CloudProvidersView({ const rows = React.useMemo(() => { const nextRows: CloudProviderRow[] = cloudOrgProviders.map((provider) => { const imported = importedCloudProviders[provider.id] ?? null; + const localProviderId = getCloudManagedProviderId(provider); const status = !imported ? "available" - : imported.providerId !== provider.id.trim() || + : imported.providerId !== localProviderId || imported.sourceProviderId !== provider.providerId || (imported.source ?? null) !== (provider.source ?? null) || (imported.updatedAt ?? null) !== (provider.updatedAt ?? null) || 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 e2b08b5207..b1f07c7136 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 @@ -3,6 +3,7 @@ import * as React from "react"; import { toast } from "@/components/ui/sonner"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; import { t } from "@/i18n"; import { useCloudSession } from "@/react-app/domains/settings/cloud/cloud-session-provider"; @@ -13,6 +14,11 @@ export type CloudWorkersViewProps = { connectRemoteWorkspace: (input: { openworkHostUrl?: string | null; openworkToken?: string | null; + openworkClientToken?: string | null; + openworkDenBaseUrl?: string | null; + openworkDenApiBaseUrl?: string | null; + openworkDenOrgId?: string | null; + openworkDenWorkerId?: string | null; directory?: string | null; displayName?: string | null; }) => Promise; @@ -23,12 +29,21 @@ export function CloudWorkersView({ connectRemoteWorkspace, onOpenAccount, }: CloudWorkersViewProps) { - const { activeOrganization: activeOrg, authToken, client, isSignedIn, user } = useCloudSession(); + const { activeOrganization: activeOrg, apiBaseUrl, authToken, baseUrl, client, isSignedIn, user } = useCloudSession(); const [workersBusy, setWorkersBusy] = React.useState(false); + const [launchBusy, setLaunchBusy] = 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 canAttachStaticWorker = activeOrg?.role === "owner" || activeOrg?.role === "admin"; const refreshWorkers = React.useCallback( async (quiet = false) => { @@ -69,6 +84,29 @@ export function CloudWorkersView({ void refreshWorkers(true); }, [activeOrgId, refreshWorkers, user]); + const launchWorker = React.useCallback(async () => { + if (!activeOrgId) { + setWorkersError(t("den.error_choose_org")); + return; + } + + setLaunchBusy(true); + setWorkersError(null); + try { + const worker = await client.createWorker(activeOrgId, { + name: "OpenWork workspace", + source: "manual", + }); + setWorkers((current) => [worker, ...current.filter((entry) => entry.workerId !== worker.workerId)]); + toast.success(`Launching ${worker.workerName}`); + void refreshWorkers(true); + } catch (error) { + setWorkersError(error instanceof Error ? error.message : "Cloud worker launch failed."); + } finally { + setLaunchBusy(false); + } + }, [activeOrgId, client, refreshWorkers]); + const openWorker = React.useCallback( async (workerId: string, workerName: string) => { if (!activeOrgId) { @@ -82,7 +120,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() || ""; if (!openworkUrl || !accessToken) { throw new Error(t("den.error_worker_not_ready")); } @@ -90,6 +128,11 @@ export function CloudWorkersView({ const ok = await connectRemoteWorkspace({ openworkHostUrl: openworkUrl, openworkToken: accessToken, + openworkClientToken: tokens.clientToken?.trim() || null, + openworkDenBaseUrl: baseUrl, + openworkDenApiBaseUrl: apiBaseUrl, + openworkDenOrgId: activeOrgId, + openworkDenWorkerId: workerId, directory: null, displayName: workerName, }); @@ -108,9 +151,47 @@ export function CloudWorkersView({ setOpeningWorkerId(null); } }, - [activeOrgId, client, connectRemoteWorkspace], + [activeOrgId, apiBaseUrl, baseUrl, client, connectRemoteWorkspace], ); + 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: "" })); + toast.success(`Attached ${worker.workerName}`); + 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, staticWorkerForm]); + if (!isSignedIn) { return ( @@ -130,11 +211,52 @@ export function CloudWorkersView({ return ( + {canAttachStaticWorker ? +
+
+
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" + /> +
+
+ +
+
+
: null} diff --git a/apps/app/src/react-app/domains/workspace/types.ts b/apps/app/src/react-app/domains/workspace/types.ts index d4753233b5..aa5831b214 100644 --- a/apps/app/src/react-app/domains/workspace/types.ts +++ b/apps/app/src/react-app/domains/workspace/types.ts @@ -7,6 +7,10 @@ export type RemoteWorkspaceInput = { openworkToken?: string | null; openworkClientToken?: string | null; openworkHostToken?: string | null; + openworkDenBaseUrl?: string | null; + openworkDenApiBaseUrl?: string | null; + openworkDenOrgId?: string | null; + openworkDenWorkerId?: string | null; directory?: string | null; displayName?: string | null; closeModal?: boolean; diff --git a/apps/app/src/react-app/domains/workspace/use-remote-workspace-connection-editor.ts b/apps/app/src/react-app/domains/workspace/use-remote-workspace-connection-editor.ts index 20b000976a..a43e7d96fe 100644 --- a/apps/app/src/react-app/domains/workspace/use-remote-workspace-connection-editor.ts +++ b/apps/app/src/react-app/domains/workspace/use-remote-workspace-connection-editor.ts @@ -93,8 +93,8 @@ export function useRemoteWorkspaceConnectionEditor 0) return; if (local.prefs.hasCompletedOnboarding) return; - navigate("/welcome", { replace: true }); - }, [loading, local.prefs.hasCompletedOnboarding, navigate, workspaces.length]); + navigate(denAuth.isSignedIn ? "/onboarding" : "/welcome", { replace: true }); + }, [denAuth.isSignedIn, loading, local.prefs.hasCompletedOnboarding, navigate, workspaces.length]); // NOTE: Blueprint seeding was removed from the route. // It was firing `materializeBlueprintSessions` + a session re-fetch on every @@ -1736,6 +1740,10 @@ export function SessionRoute() { // sync here so sign-in applies opencode.json changes before Settings opens. useCloudProviderAutoSync(sessionProviderAuthStore.runCloudProviderSync); const sessionProviderAuthSnapshot = useProviderAuthStoreSnapshot(sessionProviderAuthStore); + const cloudManagedModelIdsByProvider = useMemo( + () => buildCloudManagedModelIdsByProvider(sessionProviderAuthSnapshot.importedCloudProviders), + [sessionProviderAuthSnapshot.importedCloudProviders], + ); const permissionQueryKey = useMemo( () => selectedWorkspaceId && selectedSessionId @@ -2012,28 +2020,11 @@ export function SessionRoute() { } catch { seenIds = new Set(); } - const options: ModelOption[] = []; - for (const provider of getConnectedProviderItems(data)) { - const modelIds = Object.keys(provider.models); - const isNew = !seenIds.has(provider.id) || recentProviderIds.has(provider.id); - for (const id of modelIds) { - const model = provider.models[id]; - options.push({ - providerID: provider.id, - modelID: id, - title: model.name || id, - description: provider.name, - behaviorTitle: "Reasoning", - behaviorLabel: "Default", - behaviorDescription: "", - behaviorValue: null, - isFree: false, - isConnected: true, - isRecommended: isNew, - source: /^lpr_/i.test(provider.id) ? "cloud" as const : undefined, - }); - } - } + const options = buildCloudManagedModelOptions({ + providers: getConnectedProviderItems(data), + cloudManagedModelIdsByProvider, + isRecommendedProvider: (providerId) => !seenIds.has(providerId) || recentProviderIds.has(providerId), + }); setModelOptions(options); } catch { // Silent: the picker surfaces an empty list rather than blocking the UI. @@ -2042,7 +2033,7 @@ export function SessionRoute() { return () => { cancelled = true; }; - }, [modelPickerOpen, opencodeBaseUrl, opencodeClient, recentProviderIds, selectedWorkspaceRoot]); + }, [cloudManagedModelIdsByProvider, modelPickerOpen, opencodeBaseUrl, opencodeClient, recentProviderIds, selectedWorkspaceRoot]); // Apply org-level restrictions (dev #1505) on top of the raw model list // so the picker never surfaces blocked options: @@ -2915,6 +2906,7 @@ export function SessionRoute() { client={opencodeClient} opencodeBaseUrl={opencodeBaseUrl} selectedWorkspaceRoot={selectedWorkspaceRoot} + cloudManagedModelIdsByProvider={cloudManagedModelIdsByProvider} > {opencodeClient && selectedWorkspaceEndpoint && opencodeBaseUrl && selectedWorkspaceServerToken ? ( selectedWorkspace ? { + ...selectedWorkspace, id: selectedWorkspace.id, name: selectedWorkspace.name ?? selectedWorkspace.displayNameResolved, path: selectedWorkspace.path ?? "", @@ -831,6 +833,10 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { Object.values(providerAuthSnapshot.importedCloudProviders ?? {}).some(isOpenWorkCloudProvider), [providerAuthSnapshot.cloudOrgProviders, providerAuthSnapshot.importedCloudProviders], ); + const cloudManagedModelIdsByProvider = useMemo( + () => buildCloudManagedModelIdsByProvider(providerAuthSnapshot.importedCloudProviders), + [providerAuthSnapshot.importedCloudProviders], + ); const [openWorkModelsPromoHidden, setOpenWorkModelsPromoHidden] = useState(isOpenWorkModelsPromoHidden); const openWorkModelsConnected = (cloudSession.isSignedIn && hasOpenWorkCloudProvider) || @@ -937,7 +943,11 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { ); 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; @@ -1223,28 +1233,11 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { } catch { seenIds = new Set(); } - const options: ModelOption[] = []; - for (const provider of getConnectedProviderItems(data)) { - const modelIds = Object.keys(provider.models); - const isNew = !seenIds.has(provider.id); - for (const id of modelIds) { - const model = provider.models[id]; - options.push({ - providerID: provider.id, - modelID: id, - title: model.name || id, - description: provider.name, - behaviorTitle: "Reasoning", - behaviorLabel: "Default", - behaviorDescription: "", - behaviorValue: null, - isFree: false, - isConnected: true, - isRecommended: isNew, - source: /^lpr_/i.test(provider.id) ? "cloud" as const : undefined, - }); - } - } + const options = buildCloudManagedModelOptions({ + providers: getConnectedProviderItems(data), + cloudManagedModelIdsByProvider, + isRecommendedProvider: (providerId) => !seenIds.has(providerId), + }); setModelOptions(options); } catch (error) { toast.error( @@ -1257,7 +1250,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { return () => { cancelled = true; }; - }, [modelPickerOpen, opencodeBaseUrl, opencodeClient, selectedWorkspaceRoot]); + }, [cloudManagedModelIdsByProvider, modelPickerOpen, opencodeBaseUrl, opencodeClient, selectedWorkspaceRoot]); useEffect(() => { local.setUi((previous) => ({ ...previous, view: "settings", tab: route.tab })); @@ -1989,6 +1982,12 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { const handleCreateRemoteWorkspace = async (input: { openworkHostUrl?: string | null; openworkToken?: string | null; + openworkClientToken?: string | null; + openworkHostToken?: string | null; + openworkDenBaseUrl?: string | null; + openworkDenApiBaseUrl?: string | null; + openworkDenOrgId?: string | null; + openworkDenWorkerId?: string | null; directory?: string | null; displayName?: string | null; }) => { @@ -2002,6 +2001,12 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { baseUrl: baseUrlValue, openworkHostUrl: baseUrlValue, openworkToken: input.openworkToken?.trim() || null, + openworkClientToken: input.openworkClientToken?.trim() || null, + openworkHostToken: input.openworkHostToken?.trim() || null, + openworkDenBaseUrl: input.openworkDenBaseUrl?.trim() || null, + openworkDenApiBaseUrl: input.openworkDenApiBaseUrl?.trim() || null, + openworkDenOrgId: input.openworkDenOrgId?.trim() || null, + openworkDenWorkerId: input.openworkDenWorkerId?.trim() || null, displayName: input.displayName?.trim() || null, directory: input.directory?.trim() || null, remoteType, @@ -2298,7 +2303,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { case "cloud-workers": return ( false} + connectRemoteWorkspace={handleCreateRemoteWorkspace} onOpenAccount={openCloudAccountSettings} /> ); diff --git a/apps/app/src/react-app/shell/welcome-route.tsx b/apps/app/src/react-app/shell/welcome-route.tsx index c2a51ee349..e91ec1f65b 100644 --- a/apps/app/src/react-app/shell/welcome-route.tsx +++ b/apps/app/src/react-app/shell/welcome-route.tsx @@ -31,6 +31,7 @@ import { buildOpenworkWorkspaceBaseUrl, createOpenworkServerClient } from "../.. import { writeActiveWorkspaceId, writeLastSessionFor } from "./session-memory"; import { workspaceSessionRoute } from "./workspace-routes"; import { ensureDesktopLocalOpenworkConnection } from "./desktop-local-openwork"; +import { useDenAuth } from "../domains/cloud/den-auth-provider"; function folderNameFromPath(path: string) { const normalized = path.replace(/\\/g, "/").replace(/\/+$/, ""); @@ -123,12 +124,17 @@ export function WelcomeRoute() { const [state, dispatch] = useReducer(welcomeReducer, initialWelcomeState); const [manualFolder, setManualFolder] = useState(""); - // If user already completed onboarding, redirect away immediately. + // Cloud-signed-in users should continue through org onboarding rather than + // the local workspace welcome flow. useEffect(() => { + if (denAuth.isSignedIn) { + navigate("/onboarding", { replace: true }); + return; + } if (local.prefs.hasCompletedOnboarding) { navigate("/session", { replace: true }); } - }, [local.prefs.hasCompletedOnboarding, navigate]); + }, [denAuth.isSignedIn, local.prefs.hasCompletedOnboarding, navigate]); const markOnboardingComplete = useCallback(() => { local.setPrefs((prev) => ({ ...prev, hasCompletedOnboarding: true })); 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/app/tests/den-managed-provider-sync.test.ts b/apps/app/tests/den-managed-provider-sync.test.ts new file mode 100644 index 0000000000..51adc79fef --- /dev/null +++ b/apps/app/tests/den-managed-provider-sync.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { createDenClient, DenApiError } from "../src/app/lib/den"; + +const originalFetch = globalThis.fetch; + +describe("Den managed provider worker sync client", () => { + afterEach(() => { + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: originalFetch, + }); + }); + + test("posts to the org-scoped worker sync endpoint", async () => { + const calls: Array<{ url: string; method: string; org: string | null; authorized: boolean; body: string | null }> = []; + const fetchMock: typeof fetch = async (input, init) => { + const headers = new Headers(init?.headers); + calls.push({ + url: String(input), + method: init?.method ?? "GET", + org: headers.get("x-openwork-legacy-org-id"), + authorized: headers.get("authorization") === "Bearer user-token", + body: typeof init?.body === "string" ? init.body : null, + }); + return new Response(JSON.stringify({ + status: "applied", + providerCount: 1, + revision: "safe-revision", + providerIds: ["lpr_applied"], + }), { + headers: { "Content-Type": "application/json" }, + status: 200, + }); + }; + + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: fetchMock, + }); + + const client = createDenClient({ baseUrl: "http://den.local", token: "user-token" }); + const result = await client.syncWorkerManagedProviders("org_test", "wrk_test"); + + expect(result).toEqual({ status: "applied", providerCount: 1, revision: "safe-revision", providerIds: ["lpr_applied"] }); + expect(calls).toEqual([{ + url: "http://den.local/v1/workers/wrk_test/managed-providers/sync", + method: "POST", + org: "org_test", + authorized: true, + body: "{}", + }]); + }); + + test("surfaces sanitized worker sync failures", async () => { + const secret = "sk-secret-value"; + const fetchMock: typeof fetch = async () => new Response(JSON.stringify({ + error: "managed_provider_sync_failed", + message: "Worker provider sync failed.", + details: { redacted: true }, + secret, + }), { + headers: { "Content-Type": "application/json" }, + status: 502, + }); + + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: fetchMock, + }); + + const client = createDenClient({ baseUrl: "http://den.local", token: "user-token" }); + + await expect(client.syncWorkerManagedProviders("org_test", "wrk_test")).rejects.toThrow("Worker provider sync failed."); + try { + await client.syncWorkerManagedProviders("org_test", "wrk_test"); + } catch (error) { + expect(error).toBeInstanceOf(DenApiError); + expect(error instanceof Error ? error.message.includes(secret) : true).toBe(false); + } + }); + + test("rejects sync payloads whose applied provider IDs do not match the provider count", async () => { + const fetchMock: typeof fetch = async () => new Response(JSON.stringify({ + status: "applied", + providerCount: 2, + revision: "mismatch-revision", + providerIds: ["lpr_only_one"], + }), { + headers: { "Content-Type": "application/json" }, + status: 200, + }); + + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: fetchMock, + }); + + const client = createDenClient({ baseUrl: "http://den.local", token: "user-token" }); + await expect(client.syncWorkerManagedProviders("org_test", "wrk_test")).rejects.toThrow("Managed provider sync response was invalid."); + }); +}); diff --git a/apps/app/tests/managed-provider-models.test.ts b/apps/app/tests/managed-provider-models.test.ts new file mode 100644 index 0000000000..3dbc991528 --- /dev/null +++ b/apps/app/tests/managed-provider-models.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from "bun:test"; + +import { + buildCloudManagedModelOptions, + buildCloudManagedModelIdsByProvider, + hasCloudManagedModelAllowlist, + isCloudManagedModelAllowed, +} from "../src/app/cloud/managed-provider-models"; +import type { CloudImportedProvider } from "../src/app/cloud/import-state"; +import type { ProviderListItem } from "../src/app/types"; + +function importedProvider(input: Pick): CloudImportedProvider { + return { + ...input, + source: "models_dev", + updatedAt: null, + importedAt: 1, + }; +} + +function visibleModelIds(providerId: string, modelIds: string[], allowlist: Map>) { + return modelIds.filter((modelId) => isCloudManagedModelAllowed(allowlist, providerId, modelId)); +} + +function provider(id: string, name: string, modelIds: string[]): ProviderListItem { + return { + id, + name, + source: "config", + models: Object.fromEntries(modelIds.map((modelId) => [modelId, { id: modelId, name: modelId }])), + }; +} + +function staleOpenAiModelIds(): string[] { + const explicit = [ + "gpt-5.4", + "gpt-5.5", + "gpt-5.5-pro", + "gpt-5.5-fast", + "text-embedding-3-large", + "gpt-4o", + "gpt-image-1-mini", + "gpt-5.4-fast", + "o4-mini", + ]; + const generated = Array.from({ length: 45 }, (_, index) => `stale-openai-catalog-${index + 1}`); + return [...explicit, ...generated]; +} + +describe("managed cloud provider model allowlists", () => { + test("session modal and compact select option builder filters 54 stale OpenAI models to selected IDs", () => { + const allowlist = buildCloudManagedModelIdsByProvider({ + lpr_openai: importedProvider({ + cloudProviderId: "lpr_openai", + providerId: "openai", + sourceProviderId: "openai", + name: "openAI_server", + modelIds: ["gpt-5.4", "gpt-5.5"], + }), + }); + + const rawOpenAiProviderListIds = staleOpenAiModelIds(); + + expect(rawOpenAiProviderListIds).toHaveLength(54); + expect(hasCloudManagedModelAllowlist(allowlist, "openai")).toBe(true); + expect(visibleModelIds("openai", rawOpenAiProviderListIds, allowlist)).toEqual(["gpt-5.4", "gpt-5.5"]); + expect(buildCloudManagedModelOptions({ + providers: [provider("openai", "openAI_server", rawOpenAiProviderListIds)], + cloudManagedModelIdsByProvider: allowlist, + isRecommendedProvider: (providerId) => providerId === "openai", + }).map((option) => ({ + providerID: option.providerID, + modelID: option.modelID, + source: option.source, + isRecommended: option.isRecommended, + }))).toEqual([ + { providerID: "openai", modelID: "gpt-5.4", source: "cloud", isRecommended: true }, + { providerID: "openai", modelID: "gpt-5.5", source: "cloud", isRecommended: true }, + ]); + }); + + test("keeps API-key NVIDIA managed provider selected IDs intact", () => { + const allowlist = buildCloudManagedModelIdsByProvider({ + lpr_nvidia: importedProvider({ + cloudProviderId: "lpr_nvidia", + providerId: "lpr_nvidia", + sourceProviderId: "nvidia", + name: "nvidia", + modelIds: ["deepseek-ai/deepseek-v4-flash", "google/gemma-4-31b-it"], + }), + }); + + expect(visibleModelIds("lpr_nvidia", ["deepseek-ai/deepseek-v4-flash", "google/gemma-4-31b-it"], allowlist)).toEqual([ + "deepseek-ai/deepseek-v4-flash", + "google/gemma-4-31b-it", + ]); + expect(buildCloudManagedModelOptions({ + providers: [provider("lpr_nvidia", "nvidia", ["deepseek-ai/deepseek-v4-flash", "google/gemma-4-31b-it"])], + cloudManagedModelIdsByProvider: allowlist, + }).map((option) => option.modelID)).toEqual([ + "deepseek-ai/deepseek-v4-flash", + "google/gemma-4-31b-it", + ]); + }); + + test("does not filter non-managed providers without imported model IDs", () => { + const allowlist = buildCloudManagedModelIdsByProvider({}); + + expect(visibleModelIds("anthropic", ["claude-sonnet", "claude-opus"], allowlist)).toEqual([ + "claude-sonnet", + "claude-opus", + ]); + }); + + test("merges duplicate imported provider model allowlists by provider ID", () => { + const allowlist = buildCloudManagedModelIdsByProvider({ + llmProvider_openai_one: importedProvider({ + cloudProviderId: "llmProvider_openai_one", + providerId: "openai", + sourceProviderId: "openai", + name: "OpenAI one", + modelIds: ["gpt-5.4"], + }), + llmProvider_openai_two: importedProvider({ + cloudProviderId: "llmProvider_openai_two", + providerId: "openai", + sourceProviderId: "openai", + name: "OpenAI two", + modelIds: ["gpt-5.5"], + }), + }); + + expect(visibleModelIds("openai", ["gpt-5.4", "gpt-5.5", "gpt-4o"], allowlist)).toEqual(["gpt-5.4", "gpt-5.5"]); + }); + + test("model picker options for OAuth-managed providers keep runtime provider IDs for defaults", () => { + const allowlist = buildCloudManagedModelIdsByProvider({ + lpr_den_openai: importedProvider({ + cloudProviderId: "lpr_den_openai", + providerId: "openai", + sourceProviderId: "openai", + name: "OpenAI from Den", + modelIds: ["gpt-5.5"], + }), + }); + + expect(buildCloudManagedModelOptions({ + providers: [provider("openai", "OpenAI", ["gpt-5.5"])], + cloudManagedModelIdsByProvider: allowlist, + }).map((option) => ({ providerID: option.providerID, modelID: option.modelID }))).toEqual([ + { providerID: "openai", modelID: "gpt-5.5" }, + ]); + }); +}); diff --git a/apps/app/tests/provider-auth-managed-providers.test.ts b/apps/app/tests/provider-auth-managed-providers.test.ts new file mode 100644 index 0000000000..38f73db0c1 --- /dev/null +++ b/apps/app/tests/provider-auth-managed-providers.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; + +import type { DenOrgLlmProvider } from "../src/app/lib/den"; +import { + getCloudManagedProviderId, + resolveAppliedManagedProvidersFromSyncResult, +} from "../src/react-app/domains/connections/provider-auth/store"; + +function provider(input: Partial & Pick): DenOrgLlmProvider { + return { + source: "models_dev", + credentialKind: "api_key", + name: input.providerId, + providerConfig: {}, + hasApiKey: true, + hasOpencodeAuth: false, + hasCredential: true, + models: [], + createdAt: null, + updatedAt: null, + ...input, + }; +} + +describe("cloud managed provider import identity", () => { + test("resolves runtime provider IDs for OAuth and OpenWork managed providers", () => { + expect(getCloudManagedProviderId(provider({ + id: "lpr_openai", + providerId: "openai", + credentialKind: "opencode_oauth", + }))).toBe("openai"); + + expect(getCloudManagedProviderId(provider({ + id: "lpr_openwork", + providerId: "openwork-cloud", + source: "openwork", + }))).toBe("openwork"); + + expect(getCloudManagedProviderId(provider({ + id: "lpr_nvidia", + providerId: "nvidia", + credentialKind: "api_key", + }))).toBe("lpr_nvidia"); + }); + + test("remote sync only records providers identified as applied by Den", () => { + const liveProviders = [ + provider({ id: "lpr_applied", providerId: "openai" }), + provider({ id: "lpr_filtered", providerId: "anthropic" }), + ]; + + expect(resolveAppliedManagedProvidersFromSyncResult({ + providerCount: 1, + providerIds: ["lpr_applied"], + }, liveProviders).map((entry) => entry.id)).toEqual(["lpr_applied"]); + }); + + test("remote sync clears imported state when Den applies an empty provider set", () => { + expect(resolveAppliedManagedProvidersFromSyncResult({ + providerCount: 0, + }, [provider({ id: "lpr_filtered", providerId: "anthropic" })])).toEqual([]); + }); + + test("remote sync refuses ambiguous partial results without applied provider IDs", () => { + expect(() => resolveAppliedManagedProvidersFromSyncResult({ + providerCount: 1, + }, [ + provider({ id: "lpr_applied", providerId: "openai" }), + provider({ id: "lpr_filtered", providerId: "anthropic" }), + ])).toThrow("did not identify which providers were applied"); + }); +}); diff --git a/apps/desktop/electron/bootstrap-config.mjs b/apps/desktop/electron/bootstrap-config.mjs new file mode 100644 index 0000000000..6ecab7abfe --- /dev/null +++ b/apps/desktop/electron/bootstrap-config.mjs @@ -0,0 +1,151 @@ +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); + // Legacy remote OpenWork records predate Den-origin metadata. Keep them in + // persisted desktop state so startup/filtering is non-destructive; only hide + // records that explicitly belong to a different Den origin. + if (!workspaceDenOrigin) return true; + return workspaceDenOrigin === activeDenOrigin; +} + +export function filterWorkspacesForManagedDen(workspaces, denBaseUrl) { + const input = Array.isArray(workspaces) ? workspaces : []; + return input.filter((workspace) => isWorkspaceCompatibleWithManagedDen(workspace, denBaseUrl)); +} + +const PERSISTED_WORKSPACES_FOR_WRITE = "__openworkPersistedWorkspacesForWrite"; + +export function runtimeWorkspaceStateForManagedDen(state, denBaseUrl) { + const persistedWorkspaces = Array.isArray(state?.workspaces) ? state.workspaces : []; + const runtimeState = { + ...state, + workspaces: filterWorkspacesForManagedDen(persistedWorkspaces, denBaseUrl), + }; + return attachPersistedWorkspacesForWrite(runtimeState, persistedWorkspaces); +} + +export function attachPersistedWorkspacesForWrite(state, persistedWorkspaces) { + Object.defineProperty(state, PERSISTED_WORKSPACES_FOR_WRITE, { + value: Array.isArray(persistedWorkspaces) ? persistedWorkspaces : [], + enumerable: false, + }); + return state; +} + +export function mergeWorkspaceListsPreservingHidden(persistedWorkspaces, runtimeWorkspaces) { + const output = Array.isArray(persistedWorkspaces) ? [...persistedWorkspaces] : []; + const indexById = new Map(); + output.forEach((workspace, index) => { + const workspaceId = String(workspace?.id ?? "").trim(); + if (workspaceId) indexById.set(workspaceId, index); + }); + for (const workspace of Array.isArray(runtimeWorkspaces) ? runtimeWorkspaces : []) { + const workspaceId = String(workspace?.id ?? "").trim(); + if (!workspaceId) { + output.push(workspace); + continue; + } + const existingIndex = indexById.get(workspaceId); + if (existingIndex === undefined) { + indexById.set(workspaceId, output.length); + output.push(workspace); + continue; + } + output[existingIndex] = workspace; + } + return output; +} + +export function persistedWorkspacesForRuntimeState(state) { + return Array.isArray(state?.[PERSISTED_WORKSPACES_FOR_WRITE]) + ? state[PERSISTED_WORKSPACES_FOR_WRITE] + : null; +} diff --git a/apps/desktop/electron/bootstrap-config.test.mjs b/apps/desktop/electron/bootstrap-config.test.mjs new file mode 100644 index 0000000000..4644272e48 --- /dev/null +++ b/apps/desktop/electron/bootstrap-config.test.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; + +import { + attachPersistedWorkspacesForWrite, + desktopBootstrapCandidates, + filterWorkspacesForManagedDen, + managedDesktopBootstrapPath, + mergeWorkspaceListsPreservingHidden, + normalizeDesktopBootstrapConfig, + persistedWorkspacesForRuntimeState, + runtimeWorkspaceStateForManagedDen, +} 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 keeps legacy remote OpenWork workspaces non-destructively", () => { + 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", "legacy", "current-den", "other-remote"], + ); +}); + +test("managed Den runtime state hides incompatible workspaces while preserving persistence input", () => { + const persistedWorkspaces = [ + { 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" }, + { id: "local", workspaceType: "local", path: "/repo" }, + ]; + + const runtimeState = runtimeWorkspaceStateForManagedDen( + { selectedId: "current-den", workspaces: persistedWorkspaces }, + "http://den.company.local:3005/api/den", + ); + + assert.deepEqual(runtimeState.workspaces.map((workspace) => workspace.id), ["current-den", "local"]); + assert.deepEqual(persistedWorkspacesForRuntimeState(runtimeState), persistedWorkspaces); +}); + +test("managed Den writes merge compatible runtime edits without dropping hidden persisted entries", () => { + const persistedWorkspaces = [ + { 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", name: "Before" }, + ]; + const runtimeState = attachPersistedWorkspacesForWrite( + { workspaces: [{ id: "current-den", workspaceType: "remote", remoteType: "openwork", openworkDenBaseUrl: "http://den.company.local:3005", name: "After" }] }, + persistedWorkspaces, + ); + + assert.deepEqual( + mergeWorkspaceListsPreservingHidden(persistedWorkspacesForRuntimeState(runtimeState), runtimeState.workspaces), + [ + { 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", name: "After" }, + ], + ); +}); diff --git a/apps/desktop/electron/browser-mcp.mjs b/apps/desktop/electron/browser-mcp.mjs new file mode 100644 index 0000000000..76a8a4c3aa --- /dev/null +++ b/apps/desktop/electron/browser-mcp.mjs @@ -0,0 +1,377 @@ +/** + * 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, + listTabs, + createTab, + closeTab, + selectTab, + 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..69c09add0c --- /dev/null +++ b/apps/desktop/electron/browser-native-tools.mjs @@ -0,0 +1,918 @@ +/** + * 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"; + +export const SCREENSHOT_FORMATS = ["png", "jpeg"]; + +export function evaluateScriptCallFunctionOptions(functionDeclaration, argObjectIds) { + return { + objectId: argObjectIds[0], + functionDeclaration: `function(...args) { + const fn = (${functionDeclaration}); + return fn.apply(args[0] ?? this, args); + }`, + arguments: argObjectIds.map((objectId) => ({ objectId })), + returnByValue: true, + }; +} + +// ── 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(SCREENSHOT_FORMATS).default("png") + .describe('Format. Default: "png"'), + quality: z.number().min(0).max(100).optional() + .describe("JPEG 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 event.currentTarget.select()} - /> - - - - ); -} - function SandboxCard({ sandbox, expanded, details, - connectBusy, renameBusy, onToggle, - onRefresh, onRename, }: { sandbox: WorkerListItem; expanded: boolean; details: ConnectionDetails | null; - connectBusy: boolean; renameBusy: boolean; onToggle: () => void; - onRefresh: () => void; onRename: () => void; }) { - const [showTokens, setShowTokens] = useState(false); - const [copiedField, setCopiedField] = useState(null); const meta = getWorkerStatusMeta(sandbox.status); const canConnect = meta.bucket === "ready"; const connectionUrl = details?.openworkUrl ?? sandbox.instanceUrl ?? null; - const ownerToken = details?.ownerToken ?? null; - const clientToken = details?.clientToken ?? null; const openWebUrl = details?.openworkAppConnectUrl ?? null; const openDesktopUrl = details?.openworkDeepLink ?? null; - async function handleCopy(field: string, text: string) { - await navigator.clipboard.writeText(text); - setCopiedField(field); - window.setTimeout(() => { - setCopiedField((current) => (current === field ? null : current)); - }, 2000); - } - - const credentialFields = [ - connectionUrl ? { id: "url", label: "Connection URL", value: connectionUrl } : null, - ownerToken ? { id: "owner", label: "Owner token", value: ownerToken } : null, - clientToken ? { id: "client", label: "Client token", value: clientToken } : null, - ].filter((field): field is { id: string; label: string; value: string } => Boolean(field)); - return (
@@ -155,12 +92,7 @@ function SandboxCard({
{canConnect ? ( -
- - - {showTokens ? ( -
-
- - Access Tokens - - -
- - {credentialFields.length > 0 ? ( - credentialFields.map((field) => ( - - )) - ) : ( -

- {connectBusy - ? "Loading connection credentials..." - : "Connection credentials will appear here once the workspace is ready."} -

- )} -
- ) : null} -
+

+ {connectionUrl ? "Connection is ready. Use the buttons above to open this worker." : "Connection details are still preparing."} +

) : (

Connection details will appear once this workspace is ready. @@ -449,10 +329,8 @@ export function BackgroundAgentsScreen() { sandbox={sandbox} expanded={expandedWorkerId === sandbox.workerId} details={connectionDetailsByWorkerId[sandbox.workerId] ?? null} - connectBusy={connectBusyWorkerId === sandbox.workerId} renameBusy={renameBusyWorkerId === sandbox.workerId} onToggle={() => void toggleSandbox(sandbox)} - onRefresh={() => void loadConnectionDetails(sandbox.workerId, sandbox.workerName)} onRename={() => { const nextName = window.prompt("Rename workspace", sandbox.workerName)?.trim(); if (!nextName || nextName === sandbox.workerName) { @@ -472,3 +350,4 @@ export function BackgroundAgentsScreen() { ); } +import { useState } from "react"; From 0978662fecec926346b4fb9965d85202ce86f365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 03:16:37 +0200 Subject: [PATCH 05/22] fix(cloud): tighten managed credential access --- ee/apps/den-api/src/routes/org/llm-providers.ts | 15 +++------------ .../_components/background-agents-screen.tsx | 10 ++++++---- 2 files changed, 9 insertions(+), 16 deletions(-) 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 f60335f2af..f2998021c8 100644 --- a/ee/apps/den-api/src/routes/org/llm-providers.ts +++ b/ee/apps/den-api/src/routes/org/llm-providers.ts @@ -934,7 +934,6 @@ export function registerOrgLlmProviderRoutes { const payload = c.get("organizationContext") - const memberTeams = c.get("memberTeams") ?? [] const params = c.req.valid("param") let llmProviderId: LlmProviderId @@ -955,6 +954,7 @@ export function registerOrgLlmProviderRoutes { const payload = c.get("organizationContext") - const memberTeams = c.get("memberTeams") ?? [] const params = c.req.valid("param") let llmProviderId: LlmProviderId @@ -1032,16 +1031,8 @@ export function registerOrgLlmProviderRoutes void; }) { const meta = getWorkerStatusMeta(sandbox.status); - const canConnect = meta.bucket === "ready"; + const canConnect = meta.bucket === "ready" && sandbox.isMine; const connectionUrl = details?.openworkUrl ?? sandbox.instanceUrl ?? null; const openWebUrl = details?.openworkAppConnectUrl ?? null; const openDesktopUrl = details?.openworkDeepLink ?? null; @@ -94,6 +94,7 @@ function SandboxCard({ type="button" onClick={onToggle} disabled={!canConnect} + title={!sandbox.isMine ? "Only the worker owner can connect to this worker." : meta.bucket !== "ready" ? "This worker is not ready yet." : undefined} className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors ${ expanded ? "bg-gray-100 text-gray-900 hover:bg-gray-200" @@ -106,9 +107,10 @@ function SandboxCard({ @@ -157,7 +159,7 @@ function SandboxCard({

) : (

- Connection details will appear once this workspace is ready. + {sandbox.isMine ? "Connection details will appear once this workspace is ready." : "Only the worker owner can connect to this worker."}

)}
@@ -257,7 +259,7 @@ export function BackgroundAgentsScreen() { async function toggleSandbox(worker: WorkerListItem) { const meta = getWorkerStatusMeta(worker.status); - if (meta.bucket !== "ready") { + if (meta.bucket !== "ready" || !worker.isMine) { return; } From d629baab72be56c3aaafb17c313bccd3e7c45e19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 03:23:32 +0200 Subject: [PATCH 06/22] fix(cloud): require worker ownership for mutations --- ee/apps/den-api/src/routes/workers/core.ts | 6 ++++++ ee/apps/den-api/src/routes/workers/runtime.ts | 6 ++++++ .../den-web/app/(den)/_components/dashboard-screen.tsx | 9 ++++++--- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/ee/apps/den-api/src/routes/workers/core.ts b/ee/apps/den-api/src/routes/workers/core.ts index da510b26d3..4606d8c1ff 100644 --- a/ee/apps/den-api/src/routes/workers/core.ts +++ b/ee/apps/den-api/src/routes/workers/core.ts @@ -514,6 +514,12 @@ export function registerWorkerCoreRoutes void redeployWorker(selectedWorker.workerId)} - disabled={!isSelectedWorkerFailed || redeployBusyWorkerId !== null || deleteBusyWorkerId !== null || actionBusy !== null || launchBusy} + disabled={!selectedWorker.isMine || !isSelectedWorkerFailed || redeployBusyWorkerId !== null || deleteBusyWorkerId !== null || actionBusy !== null || launchBusy} + title={!selectedWorker.isMine ? "Only the worker owner can redeploy this worker." : undefined} > {redeployBusyWorkerId === selectedWorker.workerId ? "Redeploying..." : "Redeploy"} @@ -408,7 +409,8 @@ export function DashboardScreen({ showSidebar = true }: { showSidebar?: boolean type="button" className="rounded-[12px] border border-rose-200 bg-rose-50 px-3 py-2 text-xs font-semibold text-rose-700 transition hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-50" onClick={() => void deleteWorker(selectedWorker.workerId)} - disabled={deleteBusyWorkerId !== null || redeployBusyWorkerId !== null || actionBusy !== null || launchBusy} + disabled={!selectedWorker.isMine || deleteBusyWorkerId !== null || redeployBusyWorkerId !== null || actionBusy !== null || launchBusy} + title={!selectedWorker.isMine ? "Only the worker owner can delete this worker." : undefined} > {deleteBusyWorkerId === selectedWorker.workerId ? "Deleting..." : "Delete worker"} @@ -450,7 +452,8 @@ export function DashboardScreen({ showSidebar = true }: { showSidebar?: boolean type="button" className="rounded-[12px] bg-[#011627] px-3 py-2 text-xs font-semibold text-white transition hover:bg-black disabled:cursor-not-allowed disabled:opacity-50" onClick={() => void upgradeRuntime()} - disabled={runtimeUpgradeBusy || runtimeBusy || !isReady} + disabled={!selectedWorker.isMine || runtimeUpgradeBusy || runtimeBusy || !isReady} + title={!selectedWorker.isMine ? "Only the worker owner can upgrade this worker runtime." : undefined} > {runtimeUpgradeBusy || runtimeSnapshot?.upgrade.status === "running" ? "Upgrading..." : "Upgrade runtime"} From 103dbe3bd727bf07ee90789fc2a5437eeb9b1019 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 03:35:09 +0200 Subject: [PATCH 07/22] fix(cloud): hide provider credential actions for members --- .../domains/settings/cloud/cloud-session-provider.tsx | 3 ++- .../src/react-app/domains/settings/cloud/sections.tsx | 10 +++++++--- .../domains/settings/cloud/use-den-session.tsx | 3 ++- .../domains/settings/pages/cloud-providers-view.tsx | 1 + 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/app/src/react-app/domains/settings/cloud/cloud-session-provider.tsx b/apps/app/src/react-app/domains/settings/cloud/cloud-session-provider.tsx index c6dc4d6e46..83da14d6e5 100644 --- a/apps/app/src/react-app/domains/settings/cloud/cloud-session-provider.tsx +++ b/apps/app/src/react-app/domains/settings/cloud/cloud-session-provider.tsx @@ -11,7 +11,7 @@ import { } from "../../../../app/lib/den"; import { denSettingsChangedEvent } from "../../../../app/lib/den-session-events"; -type CloudActiveOrganization = Pick; +type CloudActiveOrganization = Pick; type CloudSessionContextValue = { client: DenClient; @@ -56,6 +56,7 @@ export function CloudSessionProvider({ children }: CloudSessionProviderProps) { id, name: initial.activeOrgName?.trim() || "", slug: initial.activeOrgSlug?.trim() || "", + role: "member", }; }); const activeOrgName = activeOrganization?.name ?? ""; diff --git a/apps/app/src/react-app/domains/settings/cloud/sections.tsx b/apps/app/src/react-app/domains/settings/cloud/sections.tsx index 981f7aedcd..b0cb230501 100644 --- a/apps/app/src/react-app/domains/settings/cloud/sections.tsx +++ b/apps/app/src/react-app/domains/settings/cloud/sections.tsx @@ -433,13 +433,14 @@ function SkillHubListItem({ actionId, actionKind, row, onImport, onRemove, onSyn interface CloudProviderListItemProps { actionId: string | null; actionKind: ResourceActionKind | null; + canManageProviders: boolean; row: CloudProviderRow; onImport: (cloudProviderId: string, providerName: string) => void | Promise; onRemove?: (cloudProviderId: string, providerName: string) => void | Promise; onSync: (cloudProviderId: string, providerName: string) => void | Promise; } -function CloudProviderListItem({ actionId, actionKind, row, onImport, onRemove, onSync }: CloudProviderListItemProps) { +function CloudProviderListItem({ actionId, actionKind, canManageProviders, row, onImport, onRemove, onSync }: CloudProviderListItemProps) { const actionBusy = actionId === row.cloudProviderId; const actionLabel = !actionBusy ? null @@ -492,7 +493,7 @@ function CloudProviderListItem({ actionId, actionKind, row, onImport, onRemove, variant="outline" size="sm" onClick={() => void onSync(row.cloudProviderId, row.name)} - disabled={actionId !== null} + disabled={actionId !== null || !canManageProviders} > {actionBusy && actionKind === "sync" ? t("den.syncing") : t("den.sync")} @@ -502,7 +503,7 @@ function CloudProviderListItem({ actionId, actionKind, row, onImport, onRemove, variant="outline" size="sm" onClick={() => void onImport(row.cloudProviderId, row.name)} - disabled={actionId !== null} + disabled={actionId !== null || !canManageProviders} > {actionBusy ? actionLabel : t("den.import_provider")} @@ -1004,6 +1005,7 @@ export interface CloudProvidersSectionProps { actionId: string | null; actionKind: ResourceActionKind | null; busy: boolean; + canManageProviders: boolean; rows: CloudProviderRow[]; onImport: (cloudProviderId: string, providerName: string) => void | Promise; onRefresh: () => void | Promise; @@ -1016,6 +1018,7 @@ export function CloudProvidersSection({ actionId, actionKind, busy, + canManageProviders, rows, onImport, onRefresh, @@ -1089,6 +1092,7 @@ export function CloudProvidersSection({ key={row.key} actionId={actionId} actionKind={actionKind} + canManageProviders={canManageProviders} row={row} onImport={onImport} onRemove={onRemove} diff --git a/apps/app/src/react-app/domains/settings/cloud/use-den-session.tsx b/apps/app/src/react-app/domains/settings/cloud/use-den-session.tsx index 7ebfa8d35c..e66c3e2f6b 100644 --- a/apps/app/src/react-app/domains/settings/cloud/use-den-session.tsx +++ b/apps/app/src/react-app/domains/settings/cloud/use-den-session.tsx @@ -316,7 +316,7 @@ export function useDenSession({ }); // Push to context immediately so consumers see the new org if (nextOrg) { - setActiveOrganization({ id: nextOrg.id, name: nextOrg.name, slug: nextOrg.slug }); + setActiveOrganization({ id: nextOrg.id, name: nextOrg.name, slug: nextOrg.slug, role: nextOrg.role }); } else if (!next) { setActiveOrganization(null); } @@ -495,6 +495,7 @@ export function useDenSession({ id: nextOrg.id, name: nextOrg.name, slug: nextOrg.slug, + role: nextOrg.role, }); // 5. Force a full server sync (Den + localStorage reconciliation) diff --git a/apps/app/src/react-app/domains/settings/pages/cloud-providers-view.tsx b/apps/app/src/react-app/domains/settings/pages/cloud-providers-view.tsx index 7b92f31c4f..075c88aef8 100644 --- a/apps/app/src/react-app/domains/settings/pages/cloud-providers-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/cloud-providers-view.tsx @@ -223,6 +223,7 @@ export function CloudProvidersView({ actionId={actionId} actionKind={actionKind} busy={busy} + canManageProviders={activeOrg?.role === "owner" || activeOrg?.role === "admin"} rows={rows} onImport={importProvider} onRefresh={refresh} From ac20f6fb6c29616213e735509477f0b872b6d9d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 03:43:48 +0200 Subject: [PATCH 08/22] fix(cloud): harden managed provider auth gates --- ee/apps/den-api/src/routes/auth/desktop-handoff.ts | 2 +- ee/apps/den-api/src/routes/org/llm-providers.ts | 10 ++++++++++ ee/apps/den-api/test/desktop-handoff.test.ts | 8 ++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/ee/apps/den-api/src/routes/auth/desktop-handoff.ts b/ee/apps/den-api/src/routes/auth/desktop-handoff.ts index 5b1891c7ba..f3a5b85a32 100644 --- a/ee/apps/den-api/src/routes/auth/desktop-handoff.ts +++ b/ee/apps/den-api/src/routes/auth/desktop-handoff.ts @@ -160,7 +160,7 @@ export function resolveDesktopDenBaseUrl(request: Request) { if (isWebAppHost(url.hostname) || isConfiguredBrowserOrigin(url.origin)) { return withDenProxyPath(url.origin) } - return origin + throw new DesktopHandoffBaseUrlError("Desktop handoff could not resolve a trusted Den base URL from request configuration.") } catch { throw new DesktopHandoffBaseUrlError("Desktop handoff could not resolve a trusted Den base URL from request configuration.") } 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 f2998021c8..2c581c0981 100644 --- a/ee/apps/den-api/src/routes/org/llm-providers.ts +++ b/ee/apps/den-api/src/routes/org/llm-providers.ts @@ -293,6 +293,10 @@ export function canUseOpenAiOAuthCredentialFlow(payload: { currentMember: { isOw return isOrganizationAdmin(payload) } +function requiresOpenAiOAuthCredentialPermission(input: { credentialKind?: string; opencodeAuth?: string }) { + return input.credentialKind === "opencode_oauth" || Boolean(input.opencodeAuth?.trim()) +} + export function canImportLlmProviderCredential(payload: { currentMember: { isOwner: boolean; role: string } }) { return isOrganizationAdmin(payload) } @@ -1078,6 +1082,9 @@ export function registerOrgLlmProviderRoutes desktopHandoffModule.resolveDesktopDenBaseUrl(request)).toThrow("trusted Den base URL") }) + +test("desktop handoff Den base URL rejects valid but untrusted forwarded hosts", () => { + const request = new Request("http://den-api.internal/v1/auth/desktop-handoff", { + headers: { "x-forwarded-host": "attacker.example", "x-forwarded-proto": "https" }, + }) + + expect(() => desktopHandoffModule.resolveDesktopDenBaseUrl(request)).toThrow("trusted Den base URL") +}) From 0c2cacf50bbae5e6cf88d5d9105be66a2c6c0aa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 03:53:15 +0200 Subject: [PATCH 09/22] fix(cloud): isolate managed provider sync state --- .../src/managed-provider-sync.e2e.test.ts | 28 +++++++++---------- apps/server/src/server.ts | 9 ++++-- .../src/routes/workers/managed-providers.ts | 7 +---- ee/apps/den-api/test/desktop-handoff.test.ts | 8 ++++++ 4 files changed, 30 insertions(+), 22 deletions(-) diff --git a/apps/server/src/managed-provider-sync.e2e.test.ts b/apps/server/src/managed-provider-sync.e2e.test.ts index 293dbcd427..542bcb01ea 100644 --- a/apps/server/src/managed-provider-sync.e2e.test.ts +++ b/apps/server/src/managed-provider-sync.e2e.test.ts @@ -249,7 +249,7 @@ describe("managed provider sync runtime route", () => { expect(putCalls).toHaveLength(4); expect(putCalls[0]?.path).toBe("/auth/lpr_den_nvidia"); expect(putCalls[0]?.body).toEqual({ type: "api", key: "plain-server-secret" }); - expect(putCalls[1]?.path).toBe("/auth/openai"); + expect(putCalls[1]?.path).toBe("/auth/llmProvider_den_openai"); expect(putCalls[1]?.body).toEqual({ type: "oauth", access: "access-secret", refresh: "refresh-secret", expires: 9 }); }); @@ -390,10 +390,10 @@ describe("managed provider sync runtime route", () => { expect(response.status).toBe(200); const body = await response.json() as ProviderListTestBody & { connected?: string[] }; const providers = Array.isArray(body.all) ? body.all : []; - expect(providers.some((provider) => provider.id === "openai")).toBe(false); - expect(body.connected ?? []).not.toContain("openai"); + expect(providers.some((provider) => provider.id === "openai")).toBe(true); + expect(body.connected ?? []).toContain("openai"); - expect(authCalls.some((call) => call.method === "DELETE" && call.path === "/auth/openai")).toBe(true); + expect(authCalls.some((call) => call.method === "DELETE" && call.path === "/auth/llmProvider_den_openai")).toBe(true); }); test("empty sync removes all managed providers", async () => { @@ -419,7 +419,7 @@ describe("managed provider sync runtime route", () => { }); test("failure on a later provider removes auth written earlier in the same attempt", async () => { - const { base, workspace, authCalls } = await boot({ failAuthPath: "/auth/openai" }); + const { base, workspace, authCalls } = await boot({ failAuthPath: "/auth/llmProvider_den_openai" }); const response = await fetch(`${base}/managed-providers/sync`, { method: "POST", headers: hostAuth(), @@ -460,7 +460,7 @@ describe("managed provider sync runtime route", () => { test("failure on a later provider restores previous working auth written earlier in the same attempt", async () => { const previousAuth = { type: "api", key: "previous-working-key" }; const { base, authCalls, authStore } = await boot({ - failAuthPath: "/auth/openai", + failAuthPath: "/auth/llmProvider_den_openai", initialAuth: { "/auth/lpr_den_nvidia": previousAuth }, }); const response = await fetch(`${base}/managed-providers/sync`, { @@ -502,7 +502,7 @@ describe("managed provider sync runtime route", () => { }); test("stale auth deletion failure does not restore config that references stale providers", async () => { - const { base, workspace, authCalls } = await boot({ failAuthDeletePath: "/auth/openai" }); + const { base, workspace, authCalls } = await boot({ failAuthDeletePath: "/auth/llmProvider_den_openai" }); const fullPayload = providerPayload(); const initial = await fetch(`${base}/managed-providers/sync`, { method: "POST", @@ -526,13 +526,13 @@ describe("managed provider sync runtime route", () => { expect(config).not.toContain('"openai"'); expect(config).not.toContain("gpt-5.4"); const metadata = readManagedProviderMetadata(workspace); - expect(metadata.applied?.sort()).toEqual(["lpr_den_nvidia", "openai"]); - expect(metadata.revoked).toContain("openai"); - expect(authCalls.some((call) => call.method === "DELETE" && call.path === "/auth/openai")).toBe(true); + expect(metadata.applied?.sort()).toEqual(["llmProvider_den_openai", "lpr_den_nvidia"]); + expect(metadata.revoked).toContain("llmProvider_den_openai"); + expect(authCalls.some((call) => call.method === "DELETE" && call.path === "/auth/llmProvider_den_openai")).toBe(true); }); test("retries stale auth deletion after a previous deletion failure", async () => { - const { base, workspace, authCalls } = await boot({ failAuthDeletePathOnce: "/auth/openai" }); + const { base, workspace, authCalls } = await boot({ failAuthDeletePathOnce: "/auth/llmProvider_den_openai" }); const fullPayload = providerPayload(); const initial = await fetch(`${base}/managed-providers/sync`, { method: "POST", @@ -548,7 +548,7 @@ describe("managed provider sync runtime route", () => { body: JSON.stringify(nvidiaOnlyPayload), }); expect(firstUpdate.status).toBe(502); - expect(readManagedProviderMetadata(workspace).applied?.sort()).toEqual(["lpr_den_nvidia", "openai"]); + expect(readManagedProviderMetadata(workspace).applied?.sort()).toEqual(["llmProvider_den_openai", "lpr_den_nvidia"]); const retry = await fetch(`${base}/managed-providers/sync`, { method: "POST", @@ -558,10 +558,10 @@ describe("managed provider sync runtime route", () => { expect(retry.status).toBe(200); expect(await retry.json()).toEqual({ status: "applied", providerCount: 1, revision: "sync-rev-2" }); - const deleteAttempts = authCalls.filter((call) => call.method === "DELETE" && call.path === "/auth/openai"); + const deleteAttempts = authCalls.filter((call) => call.method === "DELETE" && call.path === "/auth/llmProvider_den_openai"); expect(deleteAttempts).toHaveLength(2); const metadata = readManagedProviderMetadata(workspace); expect(metadata.applied).toEqual(["lpr_den_nvidia"]); - expect(metadata.revoked).toContain("openai"); + expect(metadata.revoked).toContain("llmProvider_den_openai"); }); }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3ec529d00a..e1689fbbac 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1068,7 +1068,13 @@ async function readManagedProviderAccessPolicy(workspaceRoot: string): Promise 0) allowlist.set(providerId, new Set(modelIds)); + if (modelIds.length > 0) { + const allowed = new Set(modelIds); + allowlist.set(providerId, allowed); + if (typeof providerConfig.id === "string" && providerConfig.id.trim()) { + allowlist.set(providerConfig.id.trim(), allowed); + } + } } return { allowedModelsByProvider: allowlist, revokedProviderIds }; } @@ -5184,7 +5190,6 @@ function getManagedProviderEnv(config: Record) { export function getManagedProviderRuntimeId(provider: Pick) { if (provider.source === "openwork") return "openwork"; - if (provider.credentialKind === "opencode_oauth") return provider.providerId.trim(); return provider.id.trim(); } 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 07cf99d239..e5560f2521 100644 --- a/ee/apps/den-api/src/routes/workers/managed-providers.ts +++ b/ee/apps/den-api/src/routes/workers/managed-providers.ts @@ -170,7 +170,6 @@ export function registerManagedProviderSyncRoutes(app: Hono<{ Variables: WorkerR async (c) => { const orgId = c.get("activeOrganizationId") const organizationContext = c.get("organizationContext") - const memberTeams = c.get("memberTeams") ?? [] const params = c.req.valid("param" as never) as { id: string } if (!orgId) return c.json({ error: "worker_not_found" }, 404) @@ -191,11 +190,7 @@ export function registerManagedProviderSyncRoutes(app: Hono<{ Variables: WorkerR const providers = deps.listProviders ? await listProviders(normalizedOrgId) - : await listManagedProviderSyncProviders({ - organizationId: normalizedOrgId, - currentMemberId: organizationContext.currentMember.id, - memberTeamIds: memberTeams.map((team) => team.id), - }) + : await listManagedProviderSyncProviders(normalizedOrgId) const revision = computeManagedProviderRevision(providers) const runtime = await pushRuntime(worker.id, { providers, revision }) diff --git a/ee/apps/den-api/test/desktop-handoff.test.ts b/ee/apps/den-api/test/desktop-handoff.test.ts index c9f8f385ec..8ad7f7a596 100644 --- a/ee/apps/den-api/test/desktop-handoff.test.ts +++ b/ee/apps/den-api/test/desktop-handoff.test.ts @@ -38,3 +38,11 @@ test("desktop handoff Den base URL rejects valid but untrusted forwarded hosts", expect(() => desktopHandoffModule.resolveDesktopDenBaseUrl(request)).toThrow("trusted Den base URL") }) + +test("desktop handoff Den base URL rejects app-prefixed attacker hosts", () => { + const request = new Request("http://den-api.internal/v1/auth/desktop-handoff", { + headers: { "x-forwarded-host": "app.attacker.com", "x-forwarded-proto": "https" }, + }) + + expect(() => desktopHandoffModule.resolveDesktopDenBaseUrl(request)).toThrow("trusted Den base URL") +}) From 1551035aad2860f70b93262942e97b82d3b20096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 04:01:52 +0200 Subject: [PATCH 10/22] fix(cloud): preserve remote workspace settings credentials --- apps/app/src/react-app/shell/settings-route.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index ec5e1ee9ff..295d5c308a 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -217,8 +217,12 @@ function mergeRouteWorkspaces( return path ? [[path, workspace] as const] : []; }), ); + const remoteDesktopIds = new Set( + desktopWorkspaces.flatMap((workspace) => workspace.workspaceType === "remote" ? [workspace.id] : []), + ); + const filteredServer = serverWorkspaces.filter((workspace) => !remoteDesktopIds.has(workspace.id)); - const mergedServer = serverWorkspaces.map((workspace) => { + const mergedServer = filteredServer.map((workspace) => { const match = desktopById.get(workspace.id) ?? desktopByPath.get(normalizeDirectoryPath(workspace.path ?? "")); From cd10d7b6a921c875745da478833974f405c1de7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 04:13:40 +0200 Subject: [PATCH 11/22] fix(cloud): sync providers by worker owner access --- .../src/routes/workers/managed-providers.ts | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) 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 e5560f2521..56e012a134 100644 --- a/ee/apps/den-api/src/routes/workers/managed-providers.ts +++ b/ee/apps/den-api/src/routes/workers/managed-providers.ts @@ -1,5 +1,5 @@ -import { and, eq, inArray, or } from "@openwork-ee/den-db/drizzle" -import { LlmProviderAccessTable, LlmProviderModelTable, LlmProviderTable } from "@openwork-ee/den-db/schema" +import { and, eq, inArray, isNull, or } from "@openwork-ee/den-db/drizzle" +import { LlmProviderAccessTable, LlmProviderModelTable, LlmProviderTable, MemberTable, TeamMemberTable } from "@openwork-ee/den-db/schema" import { normalizeDenTypeId } from "@openwork-ee/utils/typeid" import type { Hono } from "hono" import { describeRoute } from "hono-openapi" @@ -31,7 +31,7 @@ export type ManagedProviderSyncProvider = { type ManagedProviderRouteDeps = { middlewares?: never[] - getWorker?: (workerId: WorkerId, orgId: OrganizationId) => Promise<{ id: WorkerId } | null> + getWorker?: (workerId: WorkerId, orgId: OrganizationId) => Promise<{ id: WorkerId; created_by_user_id: typeof MemberTable.$inferSelect.userId } | null> listProviders?: (orgId: OrganizationId) => Promise pushRuntime?: (workerId: WorkerId, payload: { providers: ManagedProviderSyncProvider[]; revision: string }) => Promise<{ ok: boolean; status: number; payload: unknown }> } @@ -188,9 +188,34 @@ export function registerManagedProviderSyncRoutes(app: Hono<{ Variables: WorkerR const worker = await getWorker(workerId, normalizedOrgId) if (!worker) return c.json({ error: "worker_not_found" }, 404) - const providers = deps.listProviders - ? await listProviders(normalizedOrgId) - : await listManagedProviderSyncProviders(normalizedOrgId) + let providers: ManagedProviderSyncProvider[] + if (deps.listProviders) { + providers = await listProviders(normalizedOrgId) + } else { + if (!worker.created_by_user_id) return c.json({ error: "worker_owner_not_found" }, 404) + const workerOwnerMembers = await db + .select({ id: MemberTable.id }) + .from(MemberTable) + .where(and( + eq(MemberTable.organizationId, normalizedOrgId), + eq(MemberTable.userId, worker.created_by_user_id), + isNull(MemberTable.removedAt), + )) + .limit(1) + const workerOwnerMember = workerOwnerMembers[0] + if (!workerOwnerMember) return c.json({ error: "worker_owner_not_found" }, 404) + + const workerOwnerTeams = await db + .select({ id: TeamMemberTable.teamId }) + .from(TeamMemberTable) + .where(eq(TeamMemberTable.orgMembershipId, workerOwnerMember.id)) + + providers = await listManagedProviderSyncProviders({ + organizationId: normalizedOrgId, + currentMemberId: workerOwnerMember.id, + memberTeamIds: workerOwnerTeams.map((team) => team.id), + }) + } const revision = computeManagedProviderRevision(providers) const runtime = await pushRuntime(worker.id, { providers, revision }) From 340e40dbbcf48490129b5b860b46f98a1b324fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 04:21:55 +0200 Subject: [PATCH 12/22] fix(cloud): revoke managed provider aliases --- .../src/managed-provider-sync.e2e.test.ts | 4 +-- apps/server/src/server.ts | 25 +++++++++++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/apps/server/src/managed-provider-sync.e2e.test.ts b/apps/server/src/managed-provider-sync.e2e.test.ts index 542bcb01ea..dc3ee783f8 100644 --- a/apps/server/src/managed-provider-sync.e2e.test.ts +++ b/apps/server/src/managed-provider-sync.e2e.test.ts @@ -390,8 +390,8 @@ describe("managed provider sync runtime route", () => { expect(response.status).toBe(200); const body = await response.json() as ProviderListTestBody & { connected?: string[] }; const providers = Array.isArray(body.all) ? body.all : []; - expect(providers.some((provider) => provider.id === "openai")).toBe(true); - expect(body.connected ?? []).toContain("openai"); + expect(providers.some((provider) => provider.id === "openai")).toBe(false); + expect(body.connected ?? []).not.toContain("openai"); expect(authCalls.some((call) => call.method === "DELETE" && call.path === "/auth/llmProvider_den_openai")).toBe(true); }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e1689fbbac..54026b5a29 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -2500,11 +2500,18 @@ function createRoutes( ? await readFile(opencodeConfigFile, "utf8") : null; const previousManagedProviderIds = await readAppliedManagedProviderRuntimeIds(workspace.path); + const previousVisibleProviderIds = await readManagedProviderVisibleIds(workspace.path, previousManagedProviderIds); const previousRevokedProviderIds = await readRevokedManagedProviderRuntimeIds(workspace.path); const currentManagedProviderIds = new Set(payload.providers.map((provider) => getManagedProviderRuntimeId(provider))); const staleManagedProviderIds = [...previousManagedProviderIds].filter((providerId) => !currentManagedProviderIds.has(providerId)); - const revokedManagedProviderIds = new Set([...previousRevokedProviderIds, ...staleManagedProviderIds]); - for (const providerId of currentManagedProviderIds) revokedManagedProviderIds.delete(providerId); + const staleVisibleProviderIds = staleManagedProviderIds + .map((providerId) => previousVisibleProviderIds.get(providerId)) + .filter((providerId): providerId is string => Boolean(providerId)); + const revokedManagedProviderIds = new Set([...previousRevokedProviderIds, ...staleManagedProviderIds, ...staleVisibleProviderIds]); + for (const provider of payload.providers) { + revokedManagedProviderIds.delete(getManagedProviderRuntimeId(provider)); + revokedManagedProviderIds.delete(provider.providerId); + } const applied: string[] = []; const previousAuthByProviderId = new Map(); @@ -5223,6 +5230,20 @@ async function readRevokedManagedProviderRuntimeIds(workspaceRoot: string): Prom return readManagedProviderRuntimeIds(workspaceRoot, "revoked"); } +async function readManagedProviderVisibleIds(workspaceRoot: string, providerIds: Set): Promise> { + if (providerIds.size === 0) return new Map(); + const config = await readOpencodeConfig(workspaceRoot); + const providers = isRecordValue(config.provider) ? config.provider : {}; + const visibleIds = new Map(); + for (const providerId of providerIds) { + const provider = providers[providerId]; + if (isRecordValue(provider) && typeof provider.id === "string" && provider.id.trim()) { + visibleIds.set(providerId, provider.id.trim()); + } + } + return visibleIds; +} + async function applyManagedProviderConfigSet(workspaceRoot: string, providers: ManagedProviderSyncProvider[], previousManagedProviderIds: Set) { const config = await readOpencodeConfig(workspaceRoot); const providerConfig = isRecordValue(config.provider) ? { ...config.provider } : {}; From 4878e9729db2f6b13b57fff3c8257051a83516c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 07:37:17 +0200 Subject: [PATCH 13/22] fix(integration): handle remote connect links safely --- .../domains/cloud/den-auth-provider.tsx | 53 ++++++++++++++++++- .../domains/cloud/org-onboarding-page.tsx | 6 +-- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/apps/app/src/react-app/domains/cloud/den-auth-provider.tsx b/apps/app/src/react-app/domains/cloud/den-auth-provider.tsx index c4b2a80546..ccb06cbc0a 100644 --- a/apps/app/src/react-app/domains/cloud/den-auth-provider.tsx +++ b/apps/app/src/react-app/domains/cloud/den-auth-provider.tsx @@ -30,7 +30,18 @@ import { drainPendingDeepLinks, type DeepLinkBridgeDetail, } from "../../../app/lib/deep-link-bridge"; -import { parseDenAuthDeepLink } from "../../../app/lib/openwork-links"; +import { + parseDenAuthDeepLink, + parseRemoteConnectDeepLink, + type RemoteWorkspaceDefaults, +} from "../../../app/lib/openwork-links"; +import { + resolveWorkspaceListSelectedId, + workspaceCreateRemote, + workspaceSetRuntimeActive, + workspaceSetSelected, + type WorkspaceList, +} from "../../../app/lib/desktop"; export type DenAuthStatus = "checking" | "signed_in" | "signed_out"; @@ -61,6 +72,7 @@ export function DenAuthProvider({ children }: DenAuthProviderProps) { // Monotonic token so stale async refreshes can't clobber a newer result. const refreshTokenRef = useRef(0); const handledGrantsRef = useRef>(new Set()); + const handledRemoteConnectRef = useRef>(new Set()); const refresh = useCallback(async () => { const currentRun = ++refreshTokenRef.current; @@ -130,8 +142,47 @@ export function DenAuthProvider({ children }: DenAuthProviderProps) { useEffect(() => { if (typeof window === "undefined") return; + const connectRemoteWorkspace = async (remote: RemoteWorkspaceDefaults) => { + const hostUrl = remote.openworkHostUrl?.trim() ?? ""; + const clientToken = remote.openworkClientToken?.trim() || remote.openworkToken?.trim() || ""; + if (!hostUrl || !clientToken) { + throw new Error("Remote workspace link is missing connection details."); + } + + const list = await workspaceCreateRemote({ + baseUrl: hostUrl, + openworkHostUrl: hostUrl, + openworkToken: clientToken, + openworkClientToken: clientToken, + openworkDenBaseUrl: remote.openworkDenBaseUrl?.trim() || null, + openworkDenApiBaseUrl: remote.openworkDenApiBaseUrl?.trim() || null, + openworkDenOrgId: remote.openworkDenOrgId?.trim() || null, + openworkDenWorkerId: remote.openworkDenWorkerId?.trim() || null, + directory: remote.directory?.trim() || null, + displayName: remote.displayName?.trim() || null, + remoteType: "openwork", + }) as WorkspaceList; + + const workspaceId = resolveWorkspaceListSelectedId(list) || list.workspaces[list.workspaces.length - 1]?.id || ""; + if (workspaceId) { + await workspaceSetSelected(workspaceId).catch(() => undefined); + await workspaceSetRuntimeActive(workspaceId).catch(() => undefined); + } + }; + const handleUrls = (urls: readonly string[]) => { for (const rawUrl of urls) { + const remoteConnect = parseRemoteConnectDeepLink(rawUrl); + if (remoteConnect) { + if (handledRemoteConnectRef.current.has(rawUrl)) continue; + handledRemoteConnectRef.current.add(rawUrl); + void connectRemoteWorkspace(remoteConnect).catch((error) => { + handledRemoteConnectRef.current.delete(rawUrl); + setError(error instanceof Error ? error.message : "Failed to connect remote workspace."); + }); + continue; + } + const parsed = parseDenAuthDeepLink(rawUrl); if (!parsed || handledGrantsRef.current.has(parsed.grant)) continue; handledGrantsRef.current.add(parsed.grant); diff --git a/apps/app/src/react-app/domains/cloud/org-onboarding-page.tsx b/apps/app/src/react-app/domains/cloud/org-onboarding-page.tsx index d2969b6b97..c601281fea 100644 --- a/apps/app/src/react-app/domains/cloud/org-onboarding-page.tsx +++ b/apps/app/src/react-app/domains/cloud/org-onboarding-page.tsx @@ -291,7 +291,7 @@ export function ResourceSelectionPage() { const tokens = await denClient.getWorkerTokens(healthyWorker.workerId, orgId); const openworkUrl = tokens.openworkUrl?.trim() ?? ""; const openworkHostUrl = stripOpenworkWorkspaceMount(openworkUrl); - const accessToken = tokens.clientToken?.trim() || tokens.ownerToken?.trim() || ""; + const accessToken = tokens.clientToken?.trim() || ""; if (!openworkUrl || !accessToken) { throw new Error("The shared worker is not ready yet."); } @@ -301,7 +301,6 @@ export function ResourceSelectionPage() { openworkHostUrl: openworkUrl, openworkToken: accessToken, openworkClientToken: tokens.clientToken?.trim() || null, - openworkHostToken: tokens.hostToken?.trim() || null, openworkDenBaseUrl: settings.baseUrl, openworkDenApiBaseUrl: settings.apiBaseUrl, openworkDenOrgId: orgId, @@ -325,7 +324,6 @@ export function ResourceSelectionPage() { writeOpenworkServerSettings({ urlOverride: openworkHostUrl || openworkUrl, token: accessToken, - hostToken: tokens.hostToken?.trim() || undefined, }); try { window.dispatchEvent(new CustomEvent("openwork-server-settings-changed")); @@ -334,7 +332,7 @@ export function ResourceSelectionPage() { } return createdId || null; - }, [denClient, orgId, settings.baseUrl, workers]); + }, [denClient, orgId, settings.apiBaseUrl, settings.baseUrl, workers]); const handleContinue = useCallback(async () => { setContinueBusy(true); From 90e4ecf6101ddc704b21fbf93f725c15aa56f90e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 05:14:37 +0200 Subject: [PATCH 14/22] fix(integration): close remote token edge cases --- .../workspace/remote-workspace-diagnostics.ts | 3 +- .../use-remote-workspace-connection-editor.ts | 3 +- .../src/react-app/shell/startup-deep-links.ts | 5 +++ .../src/routes/workers/managed-providers.ts | 36 ++++++++++--------- .../test/managed-provider-sync.test.ts | 23 ++++++++++-- 5 files changed, 48 insertions(+), 22 deletions(-) diff --git a/apps/app/src/react-app/domains/workspace/remote-workspace-diagnostics.ts b/apps/app/src/react-app/domains/workspace/remote-workspace-diagnostics.ts index 8820a82e22..b7e92cdb0b 100644 --- a/apps/app/src/react-app/domains/workspace/remote-workspace-diagnostics.ts +++ b/apps/app/src/react-app/domains/workspace/remote-workspace-diagnostics.ts @@ -203,8 +203,7 @@ export function resolveRemoteWorkspaceConnectionTarget(workspace: WorkspaceInfo) const hostBaseUrl = stripOpenworkWorkspaceMount(normalizedHostUrl); const token = trim(workspace.openworkToken) || - trim(workspace.openworkClientToken) || - trim(workspace.openworkHostToken); + trim(workspace.openworkClientToken); return { ok: true, diff --git a/apps/app/src/react-app/domains/workspace/use-remote-workspace-connection-editor.ts b/apps/app/src/react-app/domains/workspace/use-remote-workspace-connection-editor.ts index a43e7d96fe..f189f2069c 100644 --- a/apps/app/src/react-app/domains/workspace/use-remote-workspace-connection-editor.ts +++ b/apps/app/src/react-app/domains/workspace/use-remote-workspace-connection-editor.ts @@ -51,7 +51,6 @@ export function useRemoteWorkspaceConnectionEditor team.id), }) + } } const revision = computeManagedProviderRevision(providers) 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 6c799f3da5..cd7346952c 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,8 @@ beforeAll(async () => { function createApp(input: { role?: string isOwner?: boolean + getWorker?: Parameters[1]["getWorker"] + useProductionListProviders?: boolean listProviders?: Parameters[1]["listProviders"] pushRuntime?: Parameters[1]["pushRuntime"] }) { @@ -52,8 +54,8 @@ function createApp(input: { }, paramValidator(workersSharedModule.workerIdParamSchema), ] as never, - getWorker: async (id, activeOrgId) => id === workerId && activeOrgId === orgId ? { id } : null, - listProviders: input.listProviders ?? (async () => [provider]), + getWorker: input.getWorker ?? (async (id, activeOrgId) => id === workerId && activeOrgId === orgId ? { id } : null), + listProviders: input.useProductionListProviders ? undefined : input.listProviders ?? (async () => [provider]), pushRuntime: input.pushRuntime ?? (async () => ({ ok: true, status: 200, payload: { status: "applied" } })), }) return { app, workerId, provider } @@ -113,6 +115,23 @@ test("managed provider sync pushes an empty provider set so workers remove revok expect(called).toBe(true) }) +test("managed provider sync pushes an empty provider set when the worker owner is gone", async () => { + const calls: unknown[] = [] + const { app, workerId } = createApp({ + getWorker: async (id) => id === workerId ? { id, created_by_user_id: null } : null, + useProductionListProviders: true, + pushRuntime: async (_workerId, payload) => { + calls.push(payload) + return { ok: true, status: 200, payload: { status: "applied" } } + }, + }) + + const response = await app.request(`http://den.local/v1/workers/${workerId}/managed-providers/sync`, { method: "POST" }) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ status: "applied", providerCount: 0, providerIds: [], revision: "empty" }) + expect(calls).toEqual([{ providers: [], revision: "empty" }]) +}) + test("managed provider sync reports missing worker as not found", async () => { const { app } = createApp({ role: "admin" }) const missingWorker = createDenTypeId("worker") From e2381dff1950f4992c5665f4b406013f8d919362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 05:24:40 +0200 Subject: [PATCH 15/22] fix(integration): require client tokens for remote connects --- ee/apps/den-web/app/(den)/_lib/den-flow.ts | 2 +- ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ee/apps/den-web/app/(den)/_lib/den-flow.ts b/ee/apps/den-web/app/(den)/_lib/den-flow.ts index 5734e62ca6..6af39602c0 100644 --- a/ee/apps/den-web/app/(den)/_lib/den-flow.ts +++ b/ee/apps/den-web/app/(den)/_lib/den-flow.ts @@ -522,7 +522,7 @@ export function getWorkerTokens(payload: unknown): WorkerTokens | null { const openworkUrl = connect && typeof connect.openworkUrl === "string" ? connect.openworkUrl : null; const workspaceId = connect && typeof connect.workspaceId === "string" ? connect.workspaceId : null; - if (!clientToken && !ownerToken && !hostToken) { + if (!clientToken) { return null; } diff --git a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx index 1377bec888..9b3d93a49e 100644 --- a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx +++ b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx @@ -229,7 +229,7 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { ? listItemToWorker(selectedWorker, worker) : worker; const openworkConnectUrl = activeWorker?.openworkUrl ?? activeWorker?.instanceUrl ?? null; - const preferredOpenworkToken = activeWorker?.clientToken ?? activeWorker?.ownerToken ?? null; + const preferredOpenworkToken = activeWorker?.clientToken ?? null; const hasWorkspaceScopedUrl = Boolean(openworkConnectUrl && /\/w\/[^/?#]+/.test(openworkConnectUrl)); const openworkDeepLink = buildOpenworkDeepLink( openworkConnectUrl, @@ -566,7 +566,7 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { }; } - const accessToken = candidate.clientToken?.trim() ?? candidate.ownerToken?.trim() ?? ""; + const accessToken = candidate.clientToken?.trim() ?? ""; if (!accessToken) { const mountedWorkspaceId = parseWorkspaceIdFromUrl(instanceUrl); return { @@ -1847,7 +1847,7 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { if (pendingRestoredWorkerId === worker.workerId) { return; } - if (worker.ownerToken || worker.clientToken) { + if (worker.clientToken) { return; } if (actionBusy !== null || launchBusy) { From 0288d8f2a94ce305dc4fce1a45cce6067cf57bd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 05:31:45 +0200 Subject: [PATCH 16/22] fix(integration): prefer client tokens in remote links --- apps/app/src/app/lib/openwork-links.ts | 5 +++-- .../workspace/remote-workspace-diagnostics.ts | 4 ++-- .../use-remote-workspace-connection-editor.ts | 4 ++-- apps/app/tests/openwork-links.test.ts | 20 +++++++++++++++++++ apps/server/src/server.ts | 5 +++-- .../server/src/workspace-activate.e2e.test.ts | 2 +- 6 files changed, 31 insertions(+), 9 deletions(-) create mode 100644 apps/app/tests/openwork-links.test.ts diff --git a/apps/app/src/app/lib/openwork-links.ts b/apps/app/src/app/lib/openwork-links.ts index e4ed30790f..3240a27e45 100644 --- a/apps/app/src/app/lib/openwork-links.ts +++ b/apps/app/src/app/lib/openwork-links.ts @@ -47,10 +47,11 @@ export function parseRemoteConnectDeepLink(rawUrl: string): RemoteWorkspaceDefau } const hostUrlRaw = url.searchParams.get("openworkHostUrl") ?? url.searchParams.get("openworkUrl") ?? ""; - const tokenRaw = url.searchParams.get("openworkToken") ?? url.searchParams.get("accessToken") ?? ""; + const clientTokenRaw = url.searchParams.get("openworkClientToken") ?? ""; + const tokenRaw = clientTokenRaw || url.searchParams.get("openworkToken") || url.searchParams.get("accessToken") || ""; const normalizedHostUrl = normalizeOpenworkServerUrl(hostUrlRaw); const token = tokenRaw.trim(); - const clientToken = url.searchParams.get("openworkClientToken")?.trim() || token; + const clientToken = clientTokenRaw.trim() || token; if (!normalizedHostUrl || !token) { return null; } diff --git a/apps/app/src/react-app/domains/workspace/remote-workspace-diagnostics.ts b/apps/app/src/react-app/domains/workspace/remote-workspace-diagnostics.ts index b7e92cdb0b..311ff07895 100644 --- a/apps/app/src/react-app/domains/workspace/remote-workspace-diagnostics.ts +++ b/apps/app/src/react-app/domains/workspace/remote-workspace-diagnostics.ts @@ -202,8 +202,8 @@ export function resolveRemoteWorkspaceConnectionTarget(workspace: WorkspaceInfo) null; const hostBaseUrl = stripOpenworkWorkspaceMount(normalizedHostUrl); const token = - trim(workspace.openworkToken) || - trim(workspace.openworkClientToken); + trim(workspace.openworkClientToken) || + trim(workspace.openworkToken); return { ok: true, diff --git a/apps/app/src/react-app/domains/workspace/use-remote-workspace-connection-editor.ts b/apps/app/src/react-app/domains/workspace/use-remote-workspace-connection-editor.ts index f189f2069c..7bfd01995d 100644 --- a/apps/app/src/react-app/domains/workspace/use-remote-workspace-connection-editor.ts +++ b/apps/app/src/react-app/domains/workspace/use-remote-workspace-connection-editor.ts @@ -49,8 +49,8 @@ export function useRemoteWorkspaceConnectionEditor { + test("accepts client-token-only remote connect links", () => { + expect(parseRemoteConnectDeepLink("openwork://connect-remote?openworkHostUrl=https%3A%2F%2Fworker.example.test&openworkClientToken=client-token")).toMatchObject({ + openworkHostUrl: "https://worker.example.test", + openworkToken: "client-token", + openworkClientToken: "client-token", + }); + }); + + test("prefers client token over legacy access token", () => { + expect(parseRemoteConnectDeepLink("openwork://connect-remote?openworkHostUrl=https%3A%2F%2Fworker.example.test&openworkToken=legacy-token&openworkClientToken=client-token")).toMatchObject({ + openworkToken: "client-token", + openworkClientToken: "client-token", + }); + }); +}); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 54026b5a29..2764c21b84 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -2670,6 +2670,7 @@ function createRoutes( : rawOpenworkHostUrl; const openworkToken = readStringField(body, "openworkToken"); const openworkClientToken = readStringField(body, "openworkClientToken"); + const remoteOpenworkToken = remoteType === "openwork" ? openworkClientToken || openworkToken : openworkToken; const openworkHostToken = readStringField(body, "openworkHostToken"); const openworkDenBaseUrl = readStringField(body, "openworkDenBaseUrl"); const openworkDenApiBaseUrl = readStringField(body, "openworkDenApiBaseUrl"); @@ -2688,7 +2689,7 @@ function createRoutes( if (remoteType === "openwork" && !openworkWorkspaceId) { const discovered = await discoverOpenworkWorkspace({ hostUrl: openworkHostUrl ?? baseUrl, - token: openworkToken, + token: remoteOpenworkToken, hostToken: openworkHostToken, directory, }); @@ -2718,7 +2719,7 @@ function createRoutes( ...(directory ? { directory } : {}), ...(displayName ? { displayName } : {}), ...(remoteType === "openwork" && openworkHostUrl ? { openworkHostUrl } : {}), - ...(openworkToken ? { openworkToken } : {}), + ...(remoteOpenworkToken ? { openworkToken: remoteOpenworkToken } : {}), ...(remoteType === "openwork" && openworkClientToken ? { openworkClientToken } : {}), ...(remoteType === "openwork" && openworkHostToken ? { openworkHostToken } : {}), ...(remoteType === "openwork" && openworkDenBaseUrl ? { openworkDenBaseUrl } : {}), diff --git a/apps/server/src/workspace-activate.e2e.test.ts b/apps/server/src/workspace-activate.e2e.test.ts index 170ee0a028..37ef680d75 100644 --- a/apps/server/src/workspace-activate.e2e.test.ts +++ b/apps/server/src/workspace-activate.e2e.test.ts @@ -371,7 +371,7 @@ describe("workspace lifecycle registry", () => { expect(body.workspaces[0].openworkDenWorkerId).toBe("wrk_test"); expect(body.workspaces[0].openworkWorkspaceId).toBe("ws_remote"); expect(body.workspaces[0].openworkWorkspaceName).toBe("Remote Project"); - expect(remote.requests[0]).toEqual({ pathname: "/workspaces", authorization: "Bearer remote_token" }); + expect(remote.requests[0]).toEqual({ pathname: "/workspaces", authorization: "Bearer remote_client_token" }); const persisted = await readPersistedConfig(configPath); const workspaces = workspacesFromConfig(persisted); From 7684441175d9dfbb922b29efaa48c7ccd60fb724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 07:13:14 +0200 Subject: [PATCH 17/22] fix(integration): scope browser proxy auth --- .../desktop/electron/browser-native-tools.mjs | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/apps/desktop/electron/browser-native-tools.mjs b/apps/desktop/electron/browser-native-tools.mjs index 69c09add0c..8e8d463de1 100644 --- a/apps/desktop/electron/browser-native-tools.mjs +++ b/apps/desktop/electron/browser-native-tools.mjs @@ -187,6 +187,11 @@ class NativeSnapshot { return object.objectId; } + async sendCommand(command, params) { + const wc = this.#ensureDebugger(); + return wc.debugger.sendCommand(command, params); + } + /** Get node data for a uid (used by upload_file for backendDOMNodeId). */ getNodeData(uid) { return this.#nodes.get(uid); @@ -636,7 +641,6 @@ export function createNativeBuiltinServer({ includeSnapshot: z.boolean().optional(), }, async (params) => { - const w = wc(); const tokens = params.key.split("+"); const mainKey = tokens.pop(); const modifiers = [...tokens]; // defensive copy before reverse @@ -649,17 +653,16 @@ export function createNativeBuiltinServer({ if (m === "Shift") flags |= 8; } - const dbg = w.debugger; for (const m of modifiers) { - await dbg.sendCommand("Input.dispatchKeyEvent", { type: "rawKeyDown", key: m, modifiers: flags }); + await snap.sendCommand("Input.dispatchKeyEvent", { type: "rawKeyDown", key: m, modifiers: flags }); } - await dbg.sendCommand("Input.dispatchKeyEvent", { type: "rawKeyDown", key: mainKey, text: mainKey.length === 1 ? mainKey : "", modifiers: flags }); + await snap.sendCommand("Input.dispatchKeyEvent", { type: "rawKeyDown", key: mainKey, text: mainKey.length === 1 ? mainKey : "", modifiers: flags }); if (mainKey.length === 1) { - await dbg.sendCommand("Input.dispatchKeyEvent", { type: "char", text: mainKey, modifiers: flags }); + await snap.sendCommand("Input.dispatchKeyEvent", { type: "char", text: mainKey, modifiers: flags }); } - await dbg.sendCommand("Input.dispatchKeyEvent", { type: "keyUp", key: mainKey, modifiers: flags }); + await snap.sendCommand("Input.dispatchKeyEvent", { type: "keyUp", key: mainKey, modifiers: flags }); for (const m of [...modifiers].reverse()) { - await dbg.sendCommand("Input.dispatchKeyEvent", { type: "keyUp", key: m, modifiers: flags }); + await snap.sendCommand("Input.dispatchKeyEvent", { type: "keyUp", key: m, modifiers: flags }); } const text = `Successfully pressed key: ${params.key}`; @@ -680,13 +683,12 @@ export function createNativeBuiltinServer({ submitKey: z.string().optional().describe('Optional key to press after typing, e.g. "Enter", "Tab"'), }, async (params) => { - const dbg = wc().debugger; for (const ch of params.text) { - await dbg.sendCommand("Input.dispatchKeyEvent", { type: "char", text: ch }); + await snap.sendCommand("Input.dispatchKeyEvent", { type: "char", text: ch }); } if (params.submitKey) { - await dbg.sendCommand("Input.dispatchKeyEvent", { type: "rawKeyDown", key: params.submitKey }); - await dbg.sendCommand("Input.dispatchKeyEvent", { type: "keyUp", key: params.submitKey }); + await snap.sendCommand("Input.dispatchKeyEvent", { type: "rawKeyDown", key: params.submitKey }); + await snap.sendCommand("Input.dispatchKeyEvent", { type: "keyUp", key: params.submitKey }); } return { content: [{ type: "text", text: `Typed "${params.text}"${params.submitKey ? ` and pressed ${params.submitKey}` : ""}` }] }; }, @@ -770,7 +772,7 @@ export function createNativeBuiltinServer({ promptText: z.string().optional().describe("Optional prompt text to enter"), }, async (params) => { - await wc().debugger.sendCommand("Page.handleJavaScriptDialog", { + await snap.sendCommand("Page.handleJavaScriptDialog", { accept: params.action === "accept", promptText: params.promptText, }); @@ -869,19 +871,18 @@ export function createNativeBuiltinServer({ .describe("User agent to emulate. Empty string to clear."), }, async (params) => { - const dbg = wc().debugger; const results = []; if (params.colorScheme) { const media = params.colorScheme === "auto" ? "" : params.colorScheme; - await dbg.sendCommand("Emulation.setEmulatedMedia", { + await snap.sendCommand("Emulation.setEmulatedMedia", { features: [{ name: "prefers-color-scheme", value: media || "" }], }); results.push(`Color scheme: ${params.colorScheme}`); } if (params.userAgent !== undefined) { - await dbg.sendCommand("Emulation.setUserAgentOverride", { + await snap.sendCommand("Emulation.setUserAgentOverride", { userAgent: params.userAgent || "", }); results.push(`User agent: ${params.userAgent || "(cleared)"}`); From d62152be6ae596711680a6cd92ba3f0cb9753e71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 07:43:32 +0200 Subject: [PATCH 18/22] fix(app): remove duplicate Den auth import --- apps/app/src/react-app/shell/welcome-route.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/app/src/react-app/shell/welcome-route.tsx b/apps/app/src/react-app/shell/welcome-route.tsx index e91ec1f65b..74e5bf6ea8 100644 --- a/apps/app/src/react-app/shell/welcome-route.tsx +++ b/apps/app/src/react-app/shell/welcome-route.tsx @@ -31,7 +31,6 @@ import { buildOpenworkWorkspaceBaseUrl, createOpenworkServerClient } from "../.. import { writeActiveWorkspaceId, writeLastSessionFor } from "./session-memory"; import { workspaceSessionRoute } from "./workspace-routes"; import { ensureDesktopLocalOpenworkConnection } from "./desktop-local-openwork"; -import { useDenAuth } from "../domains/cloud/den-auth-provider"; function folderNameFromPath(path: string) { const normalized = path.replace(/\\/g, "/").replace(/\/+$/, ""); From 8287611ced56360189fe12d49ed36707432d6c00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 07:55:58 +0200 Subject: [PATCH 19/22] fix(cloud): address managed provider review findings --- apps/app/src/app/lib/desktop.ts | 18 ++++++++---- apps/app/src/app/lib/openwork-links.ts | 6 ++-- apps/app/src/app/lib/openwork-server.ts | 7 ++--- .../domains/cloud/den-auth-provider.tsx | 10 ++++--- .../use-remote-workspace-connection-editor.ts | 5 ++-- apps/app/tests/openwork-links.test.ts | 7 +++++ apps/app/tests/openwork-server.test.ts | 14 ++++++++++ apps/desktop/electron/desktop-fetch.mjs | 28 ++++++++++++++++--- apps/desktop/electron/desktop-fetch.test.mjs | 24 ++++++++++++++++ 9 files changed, 95 insertions(+), 24 deletions(-) create mode 100644 apps/app/tests/openwork-server.test.ts diff --git a/apps/app/src/app/lib/desktop.ts b/apps/app/src/app/lib/desktop.ts index d19c317af7..6efb6cf3a1 100644 --- a/apps/app/src/app/lib/desktop.ts +++ b/apps/app/src/app/lib/desktop.ts @@ -210,6 +210,12 @@ function isLoopbackUrl(input: RequestInfo | URL): boolean { } } +async function serializeFetchBody(body: RequestInit["body"] | null | undefined): Promise { + if (body === null || body === undefined) return undefined; + if (typeof body === "string") return body; + return new Response(body).text(); +} + export const desktopFetch: typeof globalThis.fetch = async (input, init) => { if (isLoopbackUrl(input)) { return globalThis.fetch(input, init); @@ -229,8 +235,8 @@ export const desktopFetch: typeof globalThis.fetch = async (input, init) => { method = init?.method ?? input.method; const headersSource = init?.headers ? new Headers(init.headers) : input.headers; headers = Object.fromEntries(headersSource.entries()); - if (typeof init?.body === "string") { - body = init.body; + if (init?.body !== undefined) { + body = await serializeFetchBody(init.body); } else if (input.body) { // Request body is a stream — buffer to text so it survives the IPC hop // to the Electron main process. @@ -240,7 +246,7 @@ export const desktopFetch: typeof globalThis.fetch = async (input, init) => { url = typeof input === "string" ? input : input.toString(); method = init?.method; headers = init?.headers ? Object.fromEntries(new Headers(init.headers).entries()) : undefined; - body = typeof init?.body === "string" ? init.body : undefined; + body = await serializeFetchBody(init?.body); } const result = await invokeElectronHelper<{ @@ -273,8 +279,8 @@ export async function desktopFetchViaMain(input: RequestInfo | URL, init?: Reque method = init?.method ?? input.method; const headersSource = init?.headers ? new Headers(init.headers) : input.headers; headers = Object.fromEntries(headersSource.entries()); - if (typeof init?.body === "string") { - body = init.body; + if (init?.body !== undefined) { + body = await serializeFetchBody(init.body); } else if (input.body) { body = await input.clone().text(); } @@ -282,7 +288,7 @@ export async function desktopFetchViaMain(input: RequestInfo | URL, init?: Reque url = typeof input === "string" ? input : input.toString(); method = init?.method; headers = init?.headers ? Object.fromEntries(new Headers(init.headers).entries()) : undefined; - body = typeof init?.body === "string" ? init.body : undefined; + body = await serializeFetchBody(init?.body); } const result = await invokeElectronHelper<{ diff --git a/apps/app/src/app/lib/openwork-links.ts b/apps/app/src/app/lib/openwork-links.ts index 3240a27e45..504f96f135 100644 --- a/apps/app/src/app/lib/openwork-links.ts +++ b/apps/app/src/app/lib/openwork-links.ts @@ -47,11 +47,9 @@ export function parseRemoteConnectDeepLink(rawUrl: string): RemoteWorkspaceDefau } const hostUrlRaw = url.searchParams.get("openworkHostUrl") ?? url.searchParams.get("openworkUrl") ?? ""; - const clientTokenRaw = url.searchParams.get("openworkClientToken") ?? ""; - const tokenRaw = clientTokenRaw || url.searchParams.get("openworkToken") || url.searchParams.get("accessToken") || ""; + const clientToken = (url.searchParams.get("openworkClientToken") ?? "").trim(); + const token = clientToken || (url.searchParams.get("openworkToken") ?? "").trim() || (url.searchParams.get("accessToken") ?? "").trim(); const normalizedHostUrl = normalizeOpenworkServerUrl(hostUrlRaw); - const token = tokenRaw.trim(); - const clientToken = clientTokenRaw.trim() || token; if (!normalizedHostUrl || !token) { return null; } diff --git a/apps/app/src/app/lib/openwork-server.ts b/apps/app/src/app/lib/openwork-server.ts index 4d0ff1445b..81c12fef2c 100644 --- a/apps/app/src/app/lib/openwork-server.ts +++ b/apps/app/src/app/lib/openwork-server.ts @@ -567,10 +567,9 @@ export function stripOpenworkWorkspaceMount(input: string) { 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 mountIndex = segments.length - 2; + const mountSegment = segments[mountIndex]; + if ((mountSegment === "workspace" || mountSegment === "w") && segments[mountIndex + 1]) { const prefix = segments.slice(0, mountIndex).join("/"); url.pathname = prefix ? `/${prefix}` : "/"; return url.toString().replace(/\/+$/, ""); diff --git a/apps/app/src/react-app/domains/cloud/den-auth-provider.tsx b/apps/app/src/react-app/domains/cloud/den-auth-provider.tsx index ccb06cbc0a..b177fcde4b 100644 --- a/apps/app/src/react-app/domains/cloud/den-auth-provider.tsx +++ b/apps/app/src/react-app/domains/cloud/den-auth-provider.tsx @@ -176,10 +176,12 @@ export function DenAuthProvider({ children }: DenAuthProviderProps) { if (remoteConnect) { if (handledRemoteConnectRef.current.has(rawUrl)) continue; handledRemoteConnectRef.current.add(rawUrl); - void connectRemoteWorkspace(remoteConnect).catch((error) => { - handledRemoteConnectRef.current.delete(rawUrl); - setError(error instanceof Error ? error.message : "Failed to connect remote workspace."); - }); + void connectRemoteWorkspace(remoteConnect) + .then(() => setError(null)) + .catch((error) => { + handledRemoteConnectRef.current.delete(rawUrl); + setError(error instanceof Error ? error.message : "Failed to connect remote workspace."); + }); continue; } diff --git a/apps/app/src/react-app/domains/workspace/use-remote-workspace-connection-editor.ts b/apps/app/src/react-app/domains/workspace/use-remote-workspace-connection-editor.ts index 7bfd01995d..51518cb672 100644 --- a/apps/app/src/react-app/domains/workspace/use-remote-workspace-connection-editor.ts +++ b/apps/app/src/react-app/domains/workspace/use-remote-workspace-connection-editor.ts @@ -58,6 +58,7 @@ export function useRemoteWorkspaceConnectionEditor { @@ -92,7 +93,7 @@ export function useRemoteWorkspaceConnectionEditor { openworkClientToken: "client-token", }); }); + + test("falls back when client token is blank", () => { + expect(parseRemoteConnectDeepLink("openwork://connect-remote?openworkHostUrl=https%3A%2F%2Fworker.example.test&openworkToken=legacy-token&openworkClientToken=%20%20")).toMatchObject({ + openworkToken: "legacy-token", + openworkClientToken: null, + }); + }); }); diff --git a/apps/app/tests/openwork-server.test.ts b/apps/app/tests/openwork-server.test.ts new file mode 100644 index 0000000000..5a9e163b5b --- /dev/null +++ b/apps/app/tests/openwork-server.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "bun:test"; + +import { stripOpenworkWorkspaceMount } from "../src/app/lib/openwork-server"; + +describe("stripOpenworkWorkspaceMount", () => { + test("strips trailing workspace mounts", () => { + expect(stripOpenworkWorkspaceMount("https://worker.example.test/base/workspace/ws_123")).toBe("https://worker.example.test/base"); + expect(stripOpenworkWorkspaceMount("https://worker.example.test/base/w/ws_123")).toBe("https://worker.example.test/base"); + }); + + test("preserves non-mount path segments named workspace", () => { + expect(stripOpenworkWorkspaceMount("https://worker.example.test/base/workspace/ws_123/api")).toBe("https://worker.example.test/base/workspace/ws_123/api"); + }); +}); diff --git a/apps/desktop/electron/desktop-fetch.mjs b/apps/desktop/electron/desktop-fetch.mjs index ca5bc9c846..47a45baceb 100644 --- a/apps/desktop/electron/desktop-fetch.mjs +++ b/apps/desktop/electron/desktop-fetch.mjs @@ -13,23 +13,43 @@ export async function desktopFetch(urlInput, initInput = {}, fetchImpl = fetch) ? Math.max(1, Math.floor(timeoutMs)) : null; const controller = timeout ? new AbortController() : null; - const timer = timeout && controller ? setTimeout(() => controller.abort(), timeout) : null; - const signal = controller?.signal; + const externalSignal = init.signal && typeof init.signal === "object" && typeof init.signal.addEventListener === "function" + ? init.signal + : null; + let timedOut = false; + const timer = timeout && controller ? setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeout) : null; + let removeExternalAbort = null; + if (controller && externalSignal) { + if (externalSignal.aborted) { + controller.abort(externalSignal.reason); + } else { + removeExternalAbort = () => controller.abort(externalSignal.reason); + externalSignal.addEventListener("abort", removeExternalAbort, { once: true }); + } + } + const signal = controller?.signal ?? externalSignal ?? undefined; + const body = Object.prototype.hasOwnProperty.call(init, "body") ? init.body : undefined; let response; try { response = await fetchImpl(url, { method: typeof init.method === "string" ? init.method : undefined, headers: init.headers && typeof init.headers === "object" ? init.headers : undefined, - body: typeof init.body === "string" ? init.body : undefined, + body, signal, }); } catch (error) { - if (signal?.aborted) { + if (timedOut) { throw new Error(`Fetch timed out after ${timeout}ms`); } throw error; } finally { if (timer) clearTimeout(timer); + if (externalSignal && removeExternalAbort) { + externalSignal.removeEventListener("abort", removeExternalAbort); + } } return { status: response.status, diff --git a/apps/desktop/electron/desktop-fetch.test.mjs b/apps/desktop/electron/desktop-fetch.test.mjs index 928b94604c..985353fbb5 100644 --- a/apps/desktop/electron/desktop-fetch.test.mjs +++ b/apps/desktop/electron/desktop-fetch.test.mjs @@ -28,6 +28,20 @@ test("desktop fetch forwards method, headers, body, and response details", async assert.equal(result.body, "ok"); }); +test("desktop fetch forwards non-string bodies", async () => { + const body = new URLSearchParams({ key: "value" }); + const calls = []; + await desktopFetch("https://worker.example.test/env", { + method: "POST", + body, + }, async (url, init) => { + calls.push({ url, init }); + return new Response("ok"); + }); + + assert.equal(calls[0].init.body, body); +}); + test("desktop fetch honors timeoutMs with a controlled error", async () => { await assert.rejects( desktopFetch("https://worker.example.test/slow", { timeoutMs: 1 }, async (_url, init) => new Promise((_resolve, reject) => { @@ -36,3 +50,13 @@ test("desktop fetch honors timeoutMs with a controlled error", async () => { /Fetch timed out after 1ms/, ); }); + +test("desktop fetch honors caller abort signal", async () => { + const controller = new AbortController(); + const request = desktopFetch("https://worker.example.test/abort", { signal: controller.signal }, async (_url, init) => new Promise((_resolve, reject) => { + init.signal.addEventListener("abort", () => reject(init.signal.reason ?? new Error("aborted")), { once: true }); + })); + + controller.abort(new Error("caller aborted")); + await assert.rejects(request, /caller aborted/); +}); From 31b350aa45fb48241f1fedc4344a2735b2ec4d44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 08:07:18 +0200 Subject: [PATCH 20/22] fix(app): strip workspace mounts with trailing paths --- apps/app/src/app/lib/openwork-server.ts | 9 ++++++--- apps/app/tests/openwork-server.test.ts | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/app/src/app/lib/openwork-server.ts b/apps/app/src/app/lib/openwork-server.ts index 81c12fef2c..9be04054a4 100644 --- a/apps/app/src/app/lib/openwork-server.ts +++ b/apps/app/src/app/lib/openwork-server.ts @@ -567,9 +567,12 @@ export function stripOpenworkWorkspaceMount(input: string) { try { const url = new URL(normalized); const segments = url.pathname.split("/").filter(Boolean); - const mountIndex = segments.length - 2; - const mountSegment = segments[mountIndex]; - if ((mountSegment === "workspace" || mountSegment === "w") && segments[mountIndex + 1]) { + const mountIndex = segments.findIndex((segment, index) => { + if (segment !== "workspace" && segment !== "w") return false; + const workspaceId = segments[index + 1] ?? ""; + return workspaceId.startsWith("ws_") || workspaceId.startsWith("workspace_") || workspaceId.startsWith("rem_") || index + 2 === segments.length; + }); + if (mountIndex >= 0 && segments[mountIndex + 1]) { const prefix = segments.slice(0, mountIndex).join("/"); url.pathname = prefix ? `/${prefix}` : "/"; return url.toString().replace(/\/+$/, ""); diff --git a/apps/app/tests/openwork-server.test.ts b/apps/app/tests/openwork-server.test.ts index 5a9e163b5b..a44f75354d 100644 --- a/apps/app/tests/openwork-server.test.ts +++ b/apps/app/tests/openwork-server.test.ts @@ -6,9 +6,10 @@ describe("stripOpenworkWorkspaceMount", () => { test("strips trailing workspace mounts", () => { expect(stripOpenworkWorkspaceMount("https://worker.example.test/base/workspace/ws_123")).toBe("https://worker.example.test/base"); expect(stripOpenworkWorkspaceMount("https://worker.example.test/base/w/ws_123")).toBe("https://worker.example.test/base"); + expect(stripOpenworkWorkspaceMount("https://worker.example.test/base/workspace/ws_123/api")).toBe("https://worker.example.test/base"); }); test("preserves non-mount path segments named workspace", () => { - expect(stripOpenworkWorkspaceMount("https://worker.example.test/base/workspace/ws_123/api")).toBe("https://worker.example.test/base/workspace/ws_123/api"); + expect(stripOpenworkWorkspaceMount("https://worker.example.test/base/workspace/docs/api")).toBe("https://worker.example.test/base/workspace/docs/api"); }); }); From 4f31bdc4579a62e7d2b9e61a3cd328d289efb700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 12 Jun 2026 13:03:54 +0200 Subject: [PATCH 21/22] fix(cloud): finish managed provider 0.16.2 validation --- apps/app/src/app/lib/den.ts | 65 +++++++++++++ apps/app/src/app/lib/workspace-endpoint.ts | 13 ++- .../connections/provider-list-query.ts | 39 +++++++- .../domains/settings/cloud/sections.tsx | 15 ++- .../settings/pages/cloud-workers-view.tsx | 46 ++++++++++ .../src/react-app/shell/settings-route.tsx | 6 +- .../app/tests/managed-provider-models.test.ts | 64 ++++++++++++- apps/app/tests/openwork-server.test.ts | 23 +++++ apps/desktop/electron/desktop-fetch.mjs | 1 + .../src/managed-provider-sync.e2e.test.ts | 92 +++++++++++-------- apps/server/src/server.ts | 12 ++- .../(den)/_components/dashboard-screen.tsx | 19 ++-- .../(den)/_providers/den-flow-provider.tsx | 3 +- .../_components/background-agents-screen.tsx | 21 ++++- .../_components/org-dashboard-shell.tsx | 13 ++- 15 files changed, 362 insertions(+), 70 deletions(-) diff --git a/apps/app/src/app/lib/den.ts b/apps/app/src/app/lib/den.ts index e10fe1b1bc..56167cd8ed 100644 --- a/apps/app/src/app/lib/den.ts +++ b/apps/app/src/app/lib/den.ts @@ -111,6 +111,20 @@ 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 DenWorkerLaunchInput = { + name: string; + source?: "manual" | "signup_auto"; +}; + export type DenMcpToken = { token: string; expiresAt: string; @@ -2030,6 +2044,31 @@ export function createDenClient(options: { baseUrl: string; apiBaseUrl?: string return getWorkers(payload); }, + async createWorker(orgId: string, input: DenWorkerLaunchInput): Promise { + const payload = await requestJson(baseUrls, "/v1/workers", { + method: "POST", + token, + organizationId: orgId, + body: { + name: input.name, + destination: "cloud", + source: input.source ?? "manual", + }, + }); + const workers = getWorkers({ + workers: isRecord(payload) && isRecord(payload.worker) + ? [{ ...payload.worker, instance: isRecord(payload.instance) ? payload.instance : null }] + : isRecord(payload) + ? [payload] + : [], + }); + const worker = workers[0]; + if (!worker) { + throw new DenApiError(500, "invalid_worker_create_payload", "Worker launch response was missing worker details."); + } + return worker; + }, + async mintMcpToken(orgId: string): Promise { const payload = await requestJson(baseUrls, "/v1/mcp/token", { method: "POST", @@ -2058,6 +2097,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", diff --git a/apps/app/src/app/lib/workspace-endpoint.ts b/apps/app/src/app/lib/workspace-endpoint.ts index 87b2bbae00..1d1cb33cca 100644 --- a/apps/app/src/app/lib/workspace-endpoint.ts +++ b/apps/app/src/app/lib/workspace-endpoint.ts @@ -23,6 +23,8 @@ import type { WorkspaceInfo } from "./desktop"; import { buildOpenworkWorkspaceBaseUrl, createOpenworkServerClient, + parseOpenworkWorkspaceIdFromUrl, + stripOpenworkWorkspaceMount, type OpenworkServerClient, } from "./openwork-server"; @@ -60,6 +62,7 @@ type WorkspaceEndpointInput = Pick< | "openworkClientToken" | "openworkHostToken" | "openworkWorkspaceId" + | "remoteType" > | null | undefined; /** @@ -84,12 +87,20 @@ export function workspaceServerId(workspace: WorkspaceEndpointInput): string { if (!isRemoteWorkspace(workspace)) return id; const explicit = workspace.openworkWorkspaceId?.trim(); if (explicit) return explicit; + if (workspace.remoteType !== "opencode") { + const parsed = parseOpenworkWorkspaceIdFromUrl(workspace.openworkHostUrl ?? "") + ?? parseOpenworkWorkspaceIdFromUrl(workspace.baseUrl ?? ""); + if (parsed) return parsed; + } return id.startsWith("rem_") ? id.slice("rem_".length) : id; } function pickRemoteBaseUrl(workspace: WorkspaceEndpointInput): string { if (!workspace) return ""; - return (workspace.baseUrl ?? workspace.openworkHostUrl ?? "").trim(); + const baseUrl = (workspace.baseUrl ?? workspace.openworkHostUrl ?? "").trim(); + return workspace.remoteType === "opencode" + ? baseUrl + : stripOpenworkWorkspaceMount(baseUrl); } function pickRemoteToken(workspace: WorkspaceEndpointInput): string { diff --git a/apps/app/src/react-app/domains/connections/provider-list-query.ts b/apps/app/src/react-app/domains/connections/provider-list-query.ts index 053418d921..4fd089be0b 100644 --- a/apps/app/src/react-app/domains/connections/provider-list-query.ts +++ b/apps/app/src/react-app/domains/connections/provider-list-query.ts @@ -21,6 +21,13 @@ export type ConnectedProviderSnapshotChange = { next: ConnectedProviderSnapshot; }; +type ConfiguredProviderListResponse = { + all?: ProviderListItem[]; + providers?: ProviderListItem[] | Record; + connected?: string[]; + default: ProviderListResponse["default"]; +}; + const connectedProviderSnapshots = new Map(); const connectedProviderSnapshotChanges = new Map(); @@ -45,15 +52,39 @@ export async function fetchProviderList(input: { baseUrl?: string | null; directory?: string | null; }): Promise { - const value = unwrap( - await input.client.provider.list({ - directory: input.directory?.trim() || undefined, - }), + const parameters = { + directory: input.directory?.trim() || undefined, + }; + const configuredProviders = await input.client.config.providers(parameters); + const value = normalizeProviderListResponse( + configuredProviders.data !== undefined + ? configuredProviders.data + : configuredProviders.response.status === 404 || configuredProviders.response.status === 405 + ? unwrap(await input.client.provider.list(parameters)) + : unwrap(configuredProviders), ); recordConnectedProviderSnapshot(input, value); return value; } +export function normalizeProviderListResponse( + value: ProviderListResponse | ConfiguredProviderListResponse, +): ProviderListResponse { + const providers = "providers" in value ? value.providers : undefined; + const all = Array.isArray(providers) + ? providers + : providers && typeof providers === "object" + ? Object.values(providers) + : Array.isArray(value.all) + ? value.all + : []; + return { + ...value, + all, + connected: value.connected ?? all.map((provider) => provider.id), + }; +} + export function getConnectedProviderItems(value: ProviderListResponse | null | undefined) { const connected = new Set(value?.connected ?? []); return (value?.all ?? []).filter( diff --git a/apps/app/src/react-app/domains/settings/cloud/sections.tsx b/apps/app/src/react-app/domains/settings/cloud/sections.tsx index b0cb230501..93bb1dc493 100644 --- a/apps/app/src/react-app/domains/settings/cloud/sections.tsx +++ b/apps/app/src/react-app/domains/settings/cloud/sections.tsx @@ -777,19 +777,23 @@ export function MarketplacePluginsSection({ } export interface CloudWorkersSectionProps { + launchBusy: boolean; openingWorkerId: string | null; workers: CloudWorker[]; workersBusy: boolean; workersError: string | null; + onLaunchWorker: () => void | Promise; onOpenWorker: (workerId: string, workerName: string) => void | Promise; onRefreshWorkers: () => void | Promise; } export function CloudWorkersSection({ + launchBusy, openingWorkerId, workers, workersBusy, workersError, + onLaunchWorker, onOpenWorker, onRefreshWorkers, }: CloudWorkersSectionProps) { @@ -824,6 +828,13 @@ export function CloudWorkersSection({ {t("den.cloud_workers_hint")} + {workersError} : null} {!workersBusy && workers.length === 0 ? ( - {t("den.no_cloud_workers")} + + No cloud workers are visible for this org yet. Launch one here, then open it from this tab. + ) : null} {workers.length > 0 ? ( 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 ed54deb981..b1f07c7136 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 @@ -3,6 +3,7 @@ import * as React from "react"; import { toast } from "@/components/ui/sonner"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; import { t } from "@/i18n"; import { useCloudSession } from "@/react-app/domains/settings/cloud/cloud-session-provider"; @@ -32,8 +33,15 @@ export function CloudWorkersView({ const [workersBusy, setWorkersBusy] = React.useState(false); const [launchBusy, setLaunchBusy] = 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 canAttachStaticWorker = activeOrg?.role === "owner" || activeOrg?.role === "admin"; @@ -146,6 +154,44 @@ export function CloudWorkersView({ [activeOrgId, apiBaseUrl, baseUrl, client, connectRemoteWorkspace], ); + 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: "" })); + toast.success(`Attached ${worker.workerName}`); + 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, staticWorkerForm]); + if (!isSignedIn) { return ( diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index 295d5c308a..9dc9309ff7 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -1791,7 +1791,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { }), [connectionsSnapshot.mcpServers, connectionsStore.quickConnect, enablementContext, extensionController, extensionsStore], ); - const routeOpenworkStatus = openworkClient ? "connected" : "disconnected"; + const routeOpenworkStatus = workspaceOpenworkClient ? "connected" : "disconnected"; const notFoundRouteError = !loading && routeWorkspaceId && !selectedWorkspace ? "Workspace was not found. Select a new workspace from the sidebar." : null; @@ -1800,7 +1800,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { toast.error(notFoundRouteError); } }, [notFoundRouteError]); - const routeOpenworkCapabilities: OpenworkServerCapabilities | null = openworkClient + const routeOpenworkCapabilities: OpenworkServerCapabilities | null = workspaceOpenworkClient ? ROUTE_OPENWORK_CAPABILITIES : null; const environmentRuntimeKey = buildOpenworkEnvRuntimeKey({ @@ -2108,7 +2108,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { return ( ): CloudImportedProvider { return { @@ -40,6 +46,7 @@ function staleOpenAiModelIds(): string[] { "text-embedding-3-large", "gpt-4o", "gpt-image-1-mini", + "gpt-5.4-mini", "gpt-5.4-fast", "o4-mini", ]; @@ -48,7 +55,7 @@ function staleOpenAiModelIds(): string[] { } describe("managed cloud provider model allowlists", () => { - test("session modal and compact select option builder filters 54 stale OpenAI models to selected IDs", () => { + test("session modal and compact select option builder filters stale OpenAI models to selected IDs", () => { const allowlist = buildCloudManagedModelIdsByProvider({ lpr_openai: importedProvider({ cloudProviderId: "lpr_openai", @@ -61,7 +68,7 @@ describe("managed cloud provider model allowlists", () => { const rawOpenAiProviderListIds = staleOpenAiModelIds(); - expect(rawOpenAiProviderListIds).toHaveLength(54); + expect(rawOpenAiProviderListIds).toHaveLength(55); expect(hasCloudManagedModelAllowlist(allowlist, "openai")).toBe(true); expect(visibleModelIds("openai", rawOpenAiProviderListIds, allowlist)).toEqual(["gpt-5.4", "gpt-5.5"]); expect(buildCloudManagedModelOptions({ @@ -79,6 +86,59 @@ describe("managed cloud provider model allowlists", () => { ]); }); + test("prefers worker-filtered providers over stale all catalog when both are present", () => { + const filteredOpenAi = provider("openai", "openAI", ["gpt-5.4", "gpt-5.4-mini", "gpt-5.5"]); + const response = { + all: [provider("openai", "openAI", staleOpenAiModelIds())], + providers: [filteredOpenAi], + connected: ["openai"], + default: {}, + }; + + expect( + buildCloudManagedModelOptions({ + providers: getConnectedProviderItems(normalizeProviderListResponse(response)), + cloudManagedModelIdsByProvider: new Map(), + }).map((option) => option.modelID), + ).toEqual(["gpt-5.4", "gpt-5.4-mini", "gpt-5.5"]); + }); + + test("fetches configured providers instead of the full available catalog", async () => { + const requests: string[] = []; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const url = new URL(request.url); + requests.push(url.pathname); + if (url.pathname === "/config/providers") { + return Response.json({ + providers: [provider("openai", "openAI_2", ["gpt-5.4", "gpt-5.5"])], + connected: ["openai"], + default: {}, + }); + } + if (url.pathname === "/provider") { + return Response.json({ + all: [provider("openai", "openAI_2", staleOpenAiModelIds())], + connected: ["openai", "opencode"], + default: {}, + }); + } + return new Response("not found", { status: 404 }); + }, + }); + + try { + const response = await fetchProviderList({ client: createClient(server.url.toString()) }); + + expect(requests).toEqual(["/config/providers"]); + expect(getConnectedProviderItems(response).flatMap((item) => Object.keys(item.models))).toEqual(["gpt-5.4", "gpt-5.5"]); + } finally { + server.stop(true); + } + }); + test("keeps API-key NVIDIA managed provider selected IDs intact", () => { const allowlist = buildCloudManagedModelIdsByProvider({ lpr_nvidia: importedProvider({ diff --git a/apps/app/tests/openwork-server.test.ts b/apps/app/tests/openwork-server.test.ts index a44f75354d..e68ed86d5a 100644 --- a/apps/app/tests/openwork-server.test.ts +++ b/apps/app/tests/openwork-server.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test"; +import type { WorkspaceInfo } from "../src/app/lib/desktop"; import { stripOpenworkWorkspaceMount } from "../src/app/lib/openwork-server"; +import { resolveWorkspaceEndpoint } from "../src/app/lib/workspace-endpoint"; describe("stripOpenworkWorkspaceMount", () => { test("strips trailing workspace mounts", () => { @@ -13,3 +15,24 @@ describe("stripOpenworkWorkspaceMount", () => { expect(stripOpenworkWorkspaceMount("https://worker.example.test/base/workspace/docs/api")).toBe("https://worker.example.test/base/workspace/docs/api"); }); }); + +describe("resolveWorkspaceEndpoint", () => { + test("strips stale OpenWork workspace mounts before composing endpoint URLs", () => { + const workspace: WorkspaceInfo = { + id: "rem_ws_123", + name: "Remote workspace", + path: "", + preset: "remote", + workspaceType: "remote", + remoteType: "openwork", + baseUrl: "https://worker.example.test/base/workspace/ws_123", + openworkToken: "client-token", + }; + + const endpoint = resolveWorkspaceEndpoint(workspace, { baseUrl: "http://127.0.0.1:8787", token: null }); + + expect(endpoint?.baseUrl).toBe("https://worker.example.test/base"); + expect(endpoint?.workspaceId).toBe("ws_123"); + expect(endpoint?.mountedBaseUrl).toBe("https://worker.example.test/base/workspace/ws_123"); + }); +}); diff --git a/apps/desktop/electron/desktop-fetch.mjs b/apps/desktop/electron/desktop-fetch.mjs index 47a45baceb..eaedfb23f5 100644 --- a/apps/desktop/electron/desktop-fetch.mjs +++ b/apps/desktop/electron/desktop-fetch.mjs @@ -36,6 +36,7 @@ export async function desktopFetch(urlInput, initInput = {}, fetchImpl = fetch) try { response = await fetchImpl(url, { method: typeof init.method === "string" ? init.method : undefined, + redirect: "manual", headers: init.headers && typeof init.headers === "object" ? init.headers : undefined, body, signal, diff --git a/apps/server/src/managed-provider-sync.e2e.test.ts b/apps/server/src/managed-provider-sync.e2e.test.ts index dc3ee783f8..ee394deeb3 100644 --- a/apps/server/src/managed-provider-sync.e2e.test.ts +++ b/apps/server/src/managed-provider-sync.e2e.test.ts @@ -70,7 +70,7 @@ function providerPayload() { }; } -async function boot(options: { failAuth?: boolean; failAuthPath?: string; failAuthDeletePath?: string; failAuthDeletePathOnce?: string; providerListShape?: "all" | "providers-array" | "providers-object"; providerModelsShape?: "record" | "array"; connected?: string[]; initialAuth?: Record } = {}) { +async function boot(options: { failAuth?: boolean; failAuthPath?: string; failAuthDeletePath?: string; failAuthDeletePathOnce?: string; providerListShape?: "all" | "providers-array" | "providers-object"; providerModelsShape?: "record" | "array"; connected?: string[]; omitConnected?: boolean; initialAuth?: Record } = {}) { const workspace = mkdtempSync(join(tmpdir(), "openwork-managed-provider-workspace-")); const stores = mkdtempSync(join(tmpdir(), "openwork-managed-provider-stores-")); dirs.push(workspace, stores); @@ -126,7 +126,7 @@ async function boot(options: { failAuth?: boolean; failAuthPath?: string; failAu }; const providers = [ { - id: "lpr_den_nvidia", + id: "nvidia", name: "NVIDIA", source: "custom", models: nvidiaModels, @@ -152,8 +152,8 @@ async function boot(options: { failAuth?: boolean; failAuthPath?: string; failAu } return Response.json({ all: providers, - connected: options.connected ?? ["lpr_den_nvidia", "openai"], - default: { "lpr_den_nvidia": "deepseek-ai/deepseek-v4-flash", openai: "gpt-5.4" }, + ...(options.omitConnected ? {} : { connected: options.connected ?? ["nvidia", "openai"] }), + default: { "nvidia": "deepseek-ai/deepseek-v4-flash", openai: "gpt-5.4" }, }); } return Response.json({ ok: true }); @@ -231,7 +231,7 @@ describe("managed provider sync runtime route", () => { } const config = readFileSync(join(workspace, "opencode.jsonc"), "utf8"); - expect(config.match(/lpr_den_nvidia/g)?.length).toBe(1); + expect(config.match(/"nvidia"/g)?.length).toBeGreaterThanOrEqual(1); expect(config.match(/"openai"/g)?.length).toBeGreaterThanOrEqual(1); expect(config).toContain("gpt-5.4"); expect(config).toContain("gpt-5.5"); @@ -247,9 +247,9 @@ describe("managed provider sync runtime route", () => { const putCalls = authCalls.filter((call) => call.method === "PUT"); expect(authCalls.filter((call) => call.method === "GET")).toHaveLength(4); expect(putCalls).toHaveLength(4); - expect(putCalls[0]?.path).toBe("/auth/lpr_den_nvidia"); + expect(putCalls[0]?.path).toBe("/auth/nvidia"); expect(putCalls[0]?.body).toEqual({ type: "api", key: "plain-server-secret" }); - expect(putCalls[1]?.path).toBe("/auth/llmProvider_den_openai"); + expect(putCalls[1]?.path).toBe("/auth/openai"); expect(putCalls[1]?.body).toEqual({ type: "oauth", access: "access-secret", refresh: "refresh-secret", expires: 9 }); }); @@ -267,7 +267,7 @@ describe("managed provider sync runtime route", () => { const body = await response.json() as ProviderListTestBody; const providers = Array.isArray(body.all) ? body.all : []; const openai = providers.find((provider) => provider?.id === "openai"); - const nvidia = providers.find((provider) => provider?.id === "lpr_den_nvidia"); + const nvidia = providers.find((provider) => provider?.id === "nvidia"); expect(Object.keys(openai?.models ?? {}).sort()).toEqual(["gpt-5.4", "gpt-5.5"]); expect(Object.keys(openai?.models ?? {})).not.toContain("gpt-4o"); @@ -291,7 +291,23 @@ describe("managed provider sync runtime route", () => { expect(response.status).toBe(200); const body = await response.json() as ProviderListTestBody & { connected?: string[] }; - expect(body.connected?.sort()).toEqual(["lpr_den_nvidia", "openai"]); + expect(body.connected?.sort()).toEqual(["nvidia", "openai"]); + }); + + test("marks Den-managed providers connected when OpenCode omits connected list", async () => { + const { base } = await boot({ omitConnected: true }); + const sync = await fetch(`${base}/managed-providers/sync`, { + method: "POST", + headers: hostAuth(), + body: JSON.stringify(providerPayload()), + }); + expect(sync.status).toBe(200); + + const response = await fetch(`${base}/workspace/ws_1/opencode/config/providers`, { headers: clientAuth() }); + expect(response.status).toBe(200); + const body = await response.json() as ProviderListTestBody & { connected?: string[] }; + + expect(body.connected?.sort()).toEqual(["nvidia", "openai"]); }); test("filters managed OAuth provider-list models for live providers-array responses", async () => { @@ -308,7 +324,7 @@ describe("managed provider sync runtime route", () => { const body = await response.json() as ProviderListTestBody; const providers = Array.isArray(body.providers) ? body.providers : []; const openai = providers.find((provider) => provider?.id === "openai"); - const nvidia = providers.find((provider) => provider?.id === "lpr_den_nvidia"); + const nvidia = providers.find((provider) => provider?.id === "nvidia"); expect(Object.keys(openai?.models ?? {}).sort()).toEqual(["gpt-5.4", "gpt-5.5"]); expect(Object.keys(openai?.models ?? {})).not.toContain("gpt-4o"); @@ -333,7 +349,7 @@ describe("managed provider sync runtime route", () => { const body = await response.json() as ProviderListTestBody; const providers = !Array.isArray(body.providers) && body.providers ? body.providers : {}; const openai = providers.openai; - const nvidia = providers.lpr_den_nvidia; + const nvidia = providers.nvidia; expect(Object.keys(openai?.models ?? {}).sort()).toEqual(["gpt-5.4", "gpt-5.5"]); expect(Object.keys(openai?.models ?? {})).not.toContain("gpt-4o"); @@ -359,7 +375,7 @@ describe("managed provider sync runtime route", () => { expect(JSON.stringify(body)).not.toContain("refresh-secret"); expect(body.reason).toBe("Managed provider sync failed"); const configPath = join(workspace, "opencode.jsonc"); - expect(existsSync(configPath) ? readFileSync(configPath, "utf8") : "").not.toContain("lpr_den_nvidia"); + expect(existsSync(configPath) ? readFileSync(configPath, "utf8") : "").not.toContain("nvidia"); }); test("authoritatively removes revoked managed providers from config, auth, and provider lists", async () => { @@ -382,7 +398,7 @@ describe("managed provider sync runtime route", () => { expect(await update.json()).toEqual({ status: "applied", providerCount: 1, revision: "sync-rev-2" }); const config = readFileSync(join(workspace, "opencode.jsonc"), "utf8"); - expect(config).toContain("lpr_den_nvidia"); + expect(config).toContain("nvidia"); expect(config).not.toContain('"openai"'); expect(config).not.toContain("gpt-5.4"); @@ -393,7 +409,7 @@ describe("managed provider sync runtime route", () => { expect(providers.some((provider) => provider.id === "openai")).toBe(false); expect(body.connected ?? []).not.toContain("openai"); - expect(authCalls.some((call) => call.method === "DELETE" && call.path === "/auth/llmProvider_den_openai")).toBe(true); + expect(authCalls.some((call) => call.method === "DELETE" && call.path === "/auth/openai")).toBe(true); }); test("empty sync removes all managed providers", async () => { @@ -414,12 +430,12 @@ describe("managed provider sync runtime route", () => { expect(await empty.json()).toEqual({ status: "applied", providerCount: 0, revision: "sync-empty" }); const config = readFileSync(join(workspace, "opencode.jsonc"), "utf8"); - expect(config).not.toContain("lpr_den_nvidia"); + expect(config).not.toContain("nvidia"); expect(config).not.toContain('"openai"'); }); test("failure on a later provider removes auth written earlier in the same attempt", async () => { - const { base, workspace, authCalls } = await boot({ failAuthPath: "/auth/llmProvider_den_openai" }); + const { base, workspace, authCalls } = await boot({ failAuthPath: "/auth/openai" }); const response = await fetch(`${base}/managed-providers/sync`, { method: "POST", headers: hostAuth(), @@ -427,9 +443,9 @@ describe("managed provider sync runtime route", () => { }); expect(response.status).toBe(502); const configPath = join(workspace, "opencode.jsonc"); - expect(existsSync(configPath) ? readFileSync(configPath, "utf8") : "").not.toContain("lpr_den_nvidia"); - expect(authCalls.some((call) => call.method === "PUT" && call.path === "/auth/lpr_den_nvidia")).toBe(true); - expect(authCalls.some((call) => call.method === "DELETE" && call.path === "/auth/lpr_den_nvidia")).toBe(true); + expect(existsSync(configPath) ? readFileSync(configPath, "utf8") : "").not.toContain("nvidia"); + expect(authCalls.some((call) => call.method === "PUT" && call.path === "/auth/nvidia")).toBe(true); + expect(authCalls.some((call) => call.method === "DELETE" && call.path === "/auth/nvidia")).toBe(true); }); test("filters array-shaped provider-list models by Den-managed allowlist", async () => { @@ -446,7 +462,7 @@ describe("managed provider sync runtime route", () => { const body = await response.json() as ProviderListTestBody; const providers = Array.isArray(body.providers) ? body.providers : []; const openai = providers.find((provider) => provider?.id === "openai"); - const nvidia = providers.find((provider) => provider?.id === "lpr_den_nvidia"); + const nvidia = providers.find((provider) => provider?.id === "nvidia"); expect(Array.isArray(openai?.models)).toBe(true); expect(Array.isArray(nvidia?.models)).toBe(true); @@ -460,8 +476,8 @@ describe("managed provider sync runtime route", () => { test("failure on a later provider restores previous working auth written earlier in the same attempt", async () => { const previousAuth = { type: "api", key: "previous-working-key" }; const { base, authCalls, authStore } = await boot({ - failAuthPath: "/auth/llmProvider_den_openai", - initialAuth: { "/auth/lpr_den_nvidia": previousAuth }, + failAuthPath: "/auth/openai", + initialAuth: { "/auth/nvidia": previousAuth }, }); const response = await fetch(`${base}/managed-providers/sync`, { method: "POST", @@ -469,16 +485,16 @@ describe("managed provider sync runtime route", () => { body: JSON.stringify(providerPayload()), }); expect(response.status).toBe(502); - expect(authStore.get("/auth/lpr_den_nvidia")).toEqual(previousAuth); - expect(authCalls.some((call) => call.method === "GET" && call.path === "/auth/lpr_den_nvidia")).toBe(true); - expect(authCalls.filter((call) => call.method === "PUT" && call.path === "/auth/lpr_den_nvidia")).toHaveLength(2); - expect(authCalls.some((call) => call.method === "DELETE" && call.path === "/auth/lpr_den_nvidia")).toBe(false); + expect(authStore.get("/auth/nvidia")).toEqual(previousAuth); + expect(authCalls.some((call) => call.method === "GET" && call.path === "/auth/nvidia")).toBe(true); + expect(authCalls.filter((call) => call.method === "PUT" && call.path === "/auth/nvidia")).toHaveLength(2); + expect(authCalls.some((call) => call.method === "DELETE" && call.path === "/auth/nvidia")).toBe(false); }); test("rejects duplicate runtime provider ids before config or auth mutation", async () => { const previousAuth = { type: "api", key: "previous-working-key" }; const { base, workspace, authCalls, authStore } = await boot({ - initialAuth: { "/auth/lpr_den_nvidia": previousAuth }, + initialAuth: { "/auth/nvidia": previousAuth }, }); const payload = providerPayload(); const duplicateProvider = { ...payload.providers[0] }; @@ -496,13 +512,13 @@ describe("managed provider sync runtime route", () => { expect(response.status).toBe(400); expect(await response.json()).toMatchObject({ code: "duplicate_provider_runtime_id" }); expect(authCalls).toHaveLength(0); - expect(authStore.get("/auth/lpr_den_nvidia")).toEqual(previousAuth); + expect(authStore.get("/auth/nvidia")).toEqual(previousAuth); const configPath = join(workspace, "opencode.jsonc"); expect(existsSync(configPath) ? readFileSync(configPath, "utf8") : "").not.toContain("new-secret-that-must-not-be-written"); }); test("stale auth deletion failure does not restore config that references stale providers", async () => { - const { base, workspace, authCalls } = await boot({ failAuthDeletePath: "/auth/llmProvider_den_openai" }); + const { base, workspace, authCalls } = await boot({ failAuthDeletePath: "/auth/openai" }); const fullPayload = providerPayload(); const initial = await fetch(`${base}/managed-providers/sync`, { method: "POST", @@ -522,17 +538,17 @@ describe("managed provider sync runtime route", () => { const body = await update.json(); expect(body).toMatchObject({ status: "failed", providerCount: 1, revision: "sync-rev-2" }); const config = readFileSync(join(workspace, "opencode.jsonc"), "utf8"); - expect(config).toContain("lpr_den_nvidia"); + expect(config).toContain("nvidia"); expect(config).not.toContain('"openai"'); expect(config).not.toContain("gpt-5.4"); const metadata = readManagedProviderMetadata(workspace); - expect(metadata.applied?.sort()).toEqual(["llmProvider_den_openai", "lpr_den_nvidia"]); - expect(metadata.revoked).toContain("llmProvider_den_openai"); - expect(authCalls.some((call) => call.method === "DELETE" && call.path === "/auth/llmProvider_den_openai")).toBe(true); + expect(metadata.applied?.sort()).toEqual(["nvidia", "openai"]); + expect(metadata.revoked).toContain("openai"); + expect(authCalls.some((call) => call.method === "DELETE" && call.path === "/auth/openai")).toBe(true); }); test("retries stale auth deletion after a previous deletion failure", async () => { - const { base, workspace, authCalls } = await boot({ failAuthDeletePathOnce: "/auth/llmProvider_den_openai" }); + const { base, workspace, authCalls } = await boot({ failAuthDeletePathOnce: "/auth/openai" }); const fullPayload = providerPayload(); const initial = await fetch(`${base}/managed-providers/sync`, { method: "POST", @@ -548,7 +564,7 @@ describe("managed provider sync runtime route", () => { body: JSON.stringify(nvidiaOnlyPayload), }); expect(firstUpdate.status).toBe(502); - expect(readManagedProviderMetadata(workspace).applied?.sort()).toEqual(["llmProvider_den_openai", "lpr_den_nvidia"]); + expect(readManagedProviderMetadata(workspace).applied?.sort()).toEqual(["nvidia", "openai"]); const retry = await fetch(`${base}/managed-providers/sync`, { method: "POST", @@ -558,10 +574,10 @@ describe("managed provider sync runtime route", () => { expect(retry.status).toBe(200); expect(await retry.json()).toEqual({ status: "applied", providerCount: 1, revision: "sync-rev-2" }); - const deleteAttempts = authCalls.filter((call) => call.method === "DELETE" && call.path === "/auth/llmProvider_den_openai"); + const deleteAttempts = authCalls.filter((call) => call.method === "DELETE" && call.path === "/auth/openai"); expect(deleteAttempts).toHaveLength(2); const metadata = readManagedProviderMetadata(workspace); - expect(metadata.applied).toEqual(["lpr_den_nvidia"]); - expect(metadata.revoked).toContain("llmProvider_den_openai"); + expect(metadata.applied).toEqual(["nvidia"]); + expect(metadata.revoked).toContain("openai"); }); }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 2764c21b84..0ca339bf21 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1121,8 +1121,7 @@ function isRevokedProviderListItem(provider: unknown, revokedProviderIds: Set, revokedProviderIds: Set): string[] | undefined { - if (!Array.isArray(connected)) return undefined; +function mergeManagedConnectedProviderIds(connected: unknown, providers: unknown[], managedProviderIds: Set, revokedProviderIds: Set): string[] { const providerIds = new Set( providers.map((provider) => { if (typeof provider === "string") return provider; @@ -1130,7 +1129,7 @@ function mergeManagedConnectedProviderIds(connected: unknown, providers: unknown return ""; }).filter(Boolean), ); - const next = new Set(connected.filter((id): id is string => typeof id === "string" && !revokedProviderIds.has(id))); + const next = new Set(Array.isArray(connected) ? connected.filter((id): id is string => typeof id === "string" && !revokedProviderIds.has(id)) : []); for (const providerId of managedProviderIds) { if (providerIds.has(providerId)) next.add(providerId); } @@ -5196,9 +5195,12 @@ 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) { +export function getManagedProviderRuntimeId(provider: Pick) { if (provider.source === "openwork") return "openwork"; - return provider.id.trim(); + const configId = isRecordValue(provider.providerConfig) && typeof provider.providerConfig.id === "string" + ? provider.providerConfig.id.trim() + : ""; + return configId || provider.providerId.trim() || provider.id.trim(); } export function buildManagedProviderRuntimeConfig(provider: ManagedProviderSyncProvider) { diff --git a/ee/apps/den-web/app/(den)/_components/dashboard-screen.tsx b/ee/apps/den-web/app/(den)/_components/dashboard-screen.tsx index ee8f6cc60e..d9f759a71e 100644 --- a/ee/apps/den-web/app/(den)/_components/dashboard-screen.tsx +++ b/ee/apps/den-web/app/(den)/_components/dashboard-screen.tsx @@ -189,6 +189,9 @@ export function DashboardScreen({ showSidebar = true }: { showSidebar?: boolean isSelectedWorkerFailed, ownedWorkerCount, billingSummary, + launchError, + launchStatus, + launchWorker, refreshWorkers, checkWorkerStatus, deleteWorker, @@ -530,13 +533,17 @@ export function DashboardScreen({ showSidebar = true }: { showSidebar?: boolean

No workers yet

-

Existing cloud workers will appear here when available.

- Launch a workspace to connect OpenWork web or desktop.

+ {launchError ?

{launchError}

: null} + {!launchError && launchStatus ?

{launchStatus}

: null} +
)} diff --git a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx index 9b3d93a49e..7f659a231a 100644 --- a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx +++ b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx @@ -1253,7 +1253,8 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { headers: authToken ? { Authorization: `Bearer ${authToken}` } : undefined, body: JSON.stringify({ name: resolvedLaunchName, - destination: "cloud" + destination: "cloud", + source: options.source ?? "manual" }) }, 12000 diff --git a/ee/apps/den-web/app/(den)/dashboard/_components/background-agents-screen.tsx b/ee/apps/den-web/app/(den)/dashboard/_components/background-agents-screen.tsx index ac97238a40..3b8128bc24 100644 --- a/ee/apps/den-web/app/(den)/dashboard/_components/background-agents-screen.tsx +++ b/ee/apps/den-web/app/(den)/dashboard/_components/background-agents-screen.tsx @@ -11,6 +11,7 @@ import { Search, } from "lucide-react"; import { DenInput } from "../../_components/ui/input"; +import { DenButton } from "../../_components/ui/button"; import { DashboardPageTemplate } from "../../_components/ui/dashboard-page-template"; import { OPENWORK_APP_CONNECT_BASE_URL, @@ -182,6 +183,10 @@ export function BackgroundAgentsScreen() { workersBusy, workersLoadedOnce, workersError, + launchBusy, + launchError, + launchStatus, + launchWorker, renameWorker, renameBusyWorkerId, } = useDenFlow(); @@ -282,8 +287,20 @@ export function BackgroundAgentsScreen() { description="Run selected workflows in the background without asking each teammate to run them locally. Coming soon." colors={["#E9FFE0", "#3E9A1D", "#B3F750", "#51F0A3"]} > -
- New cloud workspaces are no longer available from this page. Existing workspaces remain available below. +
+
+
+

Shared workspace

+

+ Launch an OpenWork workspace for this organization. In static mode, Den attaches the pre-provisioned worker from the configured pool. +

+ {launchError ?

{launchError}

: null} + {!launchError && launchStatus ?

{launchStatus}

: null} +
+ void launchWorker({ source: "manual" })}> + Launch workspace + +
{workersError ? ( diff --git a/ee/apps/den-web/app/(den)/dashboard/_components/org-dashboard-shell.tsx b/ee/apps/den-web/app/(den)/dashboard/_components/org-dashboard-shell.tsx index 24eb8d8f56..5647be8279 100644 --- a/ee/apps/den-web/app/(den)/dashboard/_components/org-dashboard-shell.tsx +++ b/ee/apps/den-web/app/(den)/dashboard/_components/org-dashboard-shell.tsx @@ -187,13 +187,12 @@ export function OrgDashboardShell({ children }: { children: React.ReactNode }) { }, ...(access.isAdmin ? [ - // NOTE: Shared Workspace soft-disabled — uncomment to re-enable - // { - // href: activeOrg ? getBackgroundAgentsRoute(activeOrg.slug) : "#", - // label: "Shared Workspace", - // icon: Bot, - // badge: "Alpha", - // }, + { + href: activeOrg ? getBackgroundAgentsRoute(activeOrg.slug) : "#", + label: "Shared Workspace", + icon: Bot, + badge: "Alpha", + }, { href: activeOrg ? getInferenceRoute(activeOrg.slug) : "#", label: "OpenWork Models", From c8c4da74771c19ed78a34342dc1b64d86d3309e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 12 Jun 2026 13:07:47 +0200 Subject: [PATCH 22/22] fix(cloud): repair standalone worker ownership guards --- ee/apps/den-api/src/routes/workers/core.ts | 2 ++ ee/apps/den-api/src/routes/workers/runtime.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/ee/apps/den-api/src/routes/workers/core.ts b/ee/apps/den-api/src/routes/workers/core.ts index 4606d8c1ff..161f31e89b 100644 --- a/ee/apps/den-api/src/routes/workers/core.ts +++ b/ee/apps/den-api/src/routes/workers/core.ts @@ -440,6 +440,7 @@ export function registerWorkerCoreRoutes { const orgId = c.get("activeOrganizationId") + const user = c.get("user") const params = c.req.valid("param") if (!orgId) { @@ -496,6 +497,7 @@ export function registerWorkerCoreRoutes { const orgId = c.get("activeOrganizationId") + const user = c.get("user") const params = c.req.valid("param") if (!orgId) { diff --git a/ee/apps/den-api/src/routes/workers/runtime.ts b/ee/apps/den-api/src/routes/workers/runtime.ts index 6489ad0677..b0d04f9c65 100644 --- a/ee/apps/den-api/src/routes/workers/runtime.ts +++ b/ee/apps/den-api/src/routes/workers/runtime.ts @@ -78,6 +78,7 @@ export function registerWorkerRuntimeRoutes { const orgId = c.get("activeOrganizationId") + const user = c.get("user") const params = c.req.valid("param") const body = c.req.valid("json")