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 e3874cac85..61c374a5e8 100644 --- a/apps/app/src/app/lib/den.ts +++ b/apps/app/src/app/lib/den.ts @@ -116,6 +116,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; @@ -134,10 +148,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; @@ -145,6 +162,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 DenOrgMarketplaceResolved = { @@ -588,8 +614,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 { @@ -647,9 +685,11 @@ export async function initializeDenBootstrapConfig(): Promise { const parsed = parseDenOrgLlmProviderModel(model); @@ -1120,6 +1163,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 } : {}), }; } @@ -1845,6 +1910,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", @@ -1873,6 +1963,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", @@ -1904,7 +2020,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, @@ -1915,7 +2031,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, @@ -1929,6 +2045,34 @@ export function createDenClient(options: { baseUrl: string; apiBaseUrl?: string return provider; }, + async syncWorkerManagedProviders(orgId: string, workerId: string): Promise { + const raw = await requestJsonRaw( + baseUrls, + `/v1/workers/${encodeURIComponent(workerId)}/managed-providers/sync`, + { + method: "POST", + token, + organizationId: orgId, + body: {}, + }, + ); + if (!raw.ok) { + const reason = isRecord(raw.json) && typeof raw.json.reason === "string" && raw.json.reason.trim() + ? raw.json.reason.trim() + : getErrorMessage(raw.json, `Request failed with ${raw.status}.`); + throw new DenApiError(raw.status, "managed_provider_sync_failed", reason); + } + + const result = getDenManagedProviderSyncResult(raw.json); + 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.ts b/apps/app/src/app/lib/desktop.ts index caee7a7d6f..5ea828ce38 100644 --- a/apps/app/src/app/lib/desktop.ts +++ b/apps/app/src/app/lib/desktop.ts @@ -244,6 +244,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); @@ -263,8 +269,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. @@ -274,7 +280,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("__fetch", url, { method, headers, body }); @@ -302,8 +308,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(); } @@ -311,7 +317,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("__fetch", url, { method, headers, body, timeoutMs }); diff --git a/apps/app/src/app/lib/openwork-links.ts b/apps/app/src/app/lib/openwork-links.ts index 2508ad929e..504f96f135 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; @@ -41,9 +47,9 @@ 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 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(); if (!normalizedHostUrl || !token) { return null; } @@ -61,6 +67,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 +92,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 5ab36f7d40..4f3cba5f9d 100644 --- a/apps/app/src/app/lib/openwork-server.ts +++ b/apps/app/src/app/lib/openwork-server.ts @@ -576,6 +576,30 @@ 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 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(/\/+$/, ""); + } + } 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; @@ -694,7 +718,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) ?? ""; @@ -703,7 +727,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..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"; @@ -31,6 +33,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. */ @@ -58,6 +62,7 @@ type WorkspaceEndpointInput = Pick< | "openworkClientToken" | "openworkHostToken" | "openworkWorkspaceId" + | "remoteType" > | null | undefined; /** @@ -82,24 +87,36 @@ 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 { 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 +133,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 +146,7 @@ export function resolveWorkspaceEndpoint( return { baseUrl, token, + hostToken, workspaceId, isRemote: true, client, @@ -149,6 +169,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 bd81d849f4..ff402e2fd3 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-listener"; import { newProvidersEvent } from "@/app/lib/provider-events"; +import { buildCloudManagedModelOptions } from "@/app/cloud/managed-provider-models"; function getProviderDisplayName(providerId: string) { return providerId @@ -55,12 +56,13 @@ function getProviderDisplayName(providerId: string) { } function useModelOptions(open: boolean) { - const { client, opencodeBaseUrl, selectedWorkspaceRoot } = useWorkspace(); + const { client, opencodeBaseUrl, openworkToken, selectedWorkspaceRoot, cloudManagedModelIdsByProvider } = useWorkspace(); const checkDesktopRestriction = useCheckDesktopRestriction(); const { data, refetch } = useProviderListQuery({ client, baseUrl: opencodeBaseUrl, + openworkToken, directory: selectedWorkspaceRoot, enabled: Boolean(client), }); @@ -89,21 +91,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 +112,7 @@ function useModelOptions(open: boolean) { return true; }); - }, [checkDesktopRestriction, data]); + }, [checkDesktopRestriction, cloudManagedModelIdsByProvider, data]); } type ModelSelectModelItem = { 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..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 @@ -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,49 @@ 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) + .then(() => setError(null)) + .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 d9790776ae..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 @@ -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,95 @@ 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" && worker.isMine) ?? null; + if (!healthyWorker) { + if (workers.some((worker) => worker.status === "healthy")) { + throw new Error("A healthy cloud worker exists, but only its owner can open it."); + } + if (workers.length > 0) { + throw new Error("No healthy owned 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() || ""; + 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, + 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, + }); + try { + window.dispatchEvent(new CustomEvent("openwork-server-settings-changed")); + } catch { + // Best-effort only. + } + + return createdId || null; + }, [denClient, orgId, settings.apiBaseUrl, 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 +386,11 @@ export function ResourceSelectionPage() { {error} + ) : continueError ? ( + + + {continueError} + ) : hasResources ? ( You have access to the following resources. @@ -410,10 +503,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 +603,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 e2c8aee85b..ecb8618639 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 "../../../infra/provider-list-query"; +import { ensureProviderListQuery, refreshProviderListQueries } from "../../../infra/provider-list-query"; import type { OpenworkServerStoreSnapshot } from "../openwork-server-store"; /** @@ -133,6 +133,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>(); @@ -178,10 +208,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"; @@ -200,7 +226,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, { @@ -763,12 +789,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.`, @@ -1383,14 +1409,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 }, @@ -1450,6 +1507,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); } @@ -1511,6 +1588,9 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) return message; }; + const shouldSurfaceCloudProviderSyncError = (reason: CloudProviderSyncReason) => + reason === "settings_cloud_opened"; + const getCloudProviderSyncContextKey = () => { const settings = readDenSettings(); return [ @@ -1547,6 +1627,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; @@ -1556,6 +1708,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(); @@ -1616,6 +1773,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 @@ -1627,7 +1785,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) }); } - if (failures.length > 0) { + if (failures.length > 0 && !configChanged) { throw new Error(failures.join("\n")); } } @@ -1641,7 +1799,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/session/modals/use-model-picker.ts b/apps/app/src/react-app/domains/session/modals/use-model-picker.ts index d3b6124ea7..b823d246fa 100644 --- a/apps/app/src/react-app/domains/session/modals/use-model-picker.ts +++ b/apps/app/src/react-app/domains/session/modals/use-model-picker.ts @@ -6,6 +6,7 @@ import { useEffect, useMemo, useState } from "react"; import { isDesktopProviderBlocked } from "@/app/cloud/desktop-app-restrictions"; +import { buildCloudManagedModelOptions } from "@/app/cloud/managed-provider-models"; import type { Client, ModelOption } from "@/app/types"; import { useCheckDesktopRestriction } from "@/react-app/domains/cloud/desktop-config-provider"; import { @@ -21,13 +22,15 @@ import { export type UseModelPickerInput = { client: Client | null; baseUrl: string; + openworkToken?: string | null; + cloudManagedModelIdsByProvider?: Map>; workspaceRoot: string; /** Optional: surface option-load failures (settings shows a toast; the session route stays silent). */ onLoadError?: (error: unknown) => void; }; export function useModelPicker(input: UseModelPickerInput) { - const { client, baseUrl, workspaceRoot, onLoadError } = input; + const { client, baseUrl, openworkToken, cloudManagedModelIdsByProvider, workspaceRoot, onLoadError } = input; const checkDesktopRestriction = useCheckDesktopRestriction(); const [open, setOpen] = useState(false); @@ -82,6 +85,7 @@ export function useModelPicker(input: UseModelPickerInput) { const data = await ensureProviderListQuery(getReactQueryClient(), { client, baseUrl, + openworkToken, directory: workspaceRoot || undefined, }); if (cancelled || !data?.all) return; @@ -96,28 +100,11 @@ export function useModelPicker(input: UseModelPickerInput) { } 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: cloudManagedModelIdsByProvider ?? new Map>(), + isRecommendedProvider: (providerId) => !seenIds.has(providerId) || recentProviderIds.has(providerId), + }); setModelOptions(options); } catch (error) { // Default: silent — the picker surfaces an empty list rather than @@ -128,7 +115,7 @@ export function useModelPicker(input: UseModelPickerInput) { return () => { cancelled = true; }; - }, [open, baseUrl, client, recentProviderIds, workspaceRoot]); + }, [open, baseUrl, client, cloudManagedModelIdsByProvider, openworkToken, recentProviderIds, workspaceRoot]); // Apply org-level restrictions (dev #1505) on top of the raw model list // so the picker never surfaces blocked options: 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..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,11 +11,12 @@ import { } from "../../../../app/lib/den"; import { denSettingsChangedEvent } from "../../../../app/lib/den-session-events"; -type CloudActiveOrganization = Pick; +type CloudActiveOrganization = Pick; type CloudSessionContextValue = { client: DenClient; baseUrl: string; + apiBaseUrl: string; setBaseUrl: React.Dispatch>; authToken: string; setAuthToken: React.Dispatch>; @@ -55,6 +56,7 @@ export function CloudSessionProvider({ children }: CloudSessionProviderProps) { id, name: initial.activeOrgName?.trim() || "", slug: initial.activeOrgSlug?.trim() || "", + role: "member", }; }); const activeOrgName = activeOrganization?.name ?? ""; @@ -80,6 +82,7 @@ export function CloudSessionProvider({ children }: CloudSessionProviderProps) { () => ({ client, baseUrl, + apiBaseUrl, setBaseUrl, authToken, setAuthToken, @@ -94,7 +97,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/cloud/sections.tsx b/apps/app/src/react-app/domains/settings/cloud/sections.tsx index 57435f9f9a..b84947260a 100644 --- a/apps/app/src/react-app/domains/settings/cloud/sections.tsx +++ b/apps/app/src/react-app/domains/settings/cloud/sections.tsx @@ -320,8 +320,8 @@ function CloudWorkerListItem({ openingWorkerId, worker, onOpenWorker }: CloudWor variant="outline" size="sm" onClick={() => void onOpenWorker(worker.workerId, worker.workerName)} - disabled={[openingWorkerId !== null, !status.canOpen].some(Boolean)} - title={!status.canOpen ? t("den.worker_not_ready_title") : undefined} + disabled={[openingWorkerId !== null, !status.canOpen, !worker.isMine].some(Boolean)} + title={!worker.isMine ? "Only the worker owner can open connection tokens." : !status.canOpen ? t("den.worker_not_ready_title") : undefined} > {openingWorkerId === worker.workerId ? t("den.opening") : t("den.open")} @@ -332,13 +332,14 @@ function CloudWorkerListItem({ openingWorkerId, worker, onOpenWorker }: CloudWor 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 @@ -391,7 +392,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")} @@ -401,7 +402,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")} @@ -675,19 +676,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) { @@ -722,6 +727,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 ? ( @@ -790,6 +804,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; @@ -802,6 +817,7 @@ export function CloudProvidersSection({ actionId, actionKind, busy, + canManageProviders, rows, onImport, onRefresh, @@ -875,6 +891,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 1e48065a12..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 @@ -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) || @@ -221,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} 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/remote-workspace-diagnostics.ts b/apps/app/src/react-app/domains/workspace/remote-workspace-diagnostics.ts index 8820a82e22..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,9 +202,8 @@ export function resolveRemoteWorkspaceConnectionTarget(workspace: WorkspaceInfo) null; const hostBaseUrl = stripOpenworkWorkspaceMount(normalizedHostUrl); const token = - trim(workspace.openworkToken) || trim(workspace.openworkClientToken) || - trim(workspace.openworkHostToken); + trim(workspace.openworkToken); return { ok: true, 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..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 @@ -49,9 +49,8 @@ export function useRemoteWorkspaceConnectionEditor { @@ -93,8 +93,8 @@ export function useRemoteWorkspaceConnectionEditor; + connected?: string[]; + default?: ProviderListResponse["default"]; +}; + const connectedProviderSnapshots = new Map(); const connectedProviderSnapshotChanges = new Map(); export function providerListQueryKey(input: { baseUrl?: string | null; + openworkToken?: string | null; directory?: string | null; }) { return [ ...PROVIDER_LIST_QUERY_ROOT, input.baseUrl?.trim() ?? "", + input.openworkToken?.trim() ? "openwork-token" : "", input.directory?.trim() ?? "", ] as const; } @@ -43,17 +54,61 @@ export async function refreshProviderListQueries(queryClient: QueryClient) { export async function fetchProviderList(input: { client: Client; baseUrl?: string | null; + openworkToken?: string | null; directory?: string | null; }): Promise { - const value = unwrap( - await input.client.provider.list({ - directory: input.directory?.trim() || undefined, - }), - ); + const directConfiguredProviders = await fetchOpenworkConfiguredProviders(input); + if (directConfiguredProviders) { + recordConnectedProviderSnapshot(input, directConfiguredProviders); + return directConfiguredProviders; + } + + const parameters = { + directory: input.directory?.trim() || undefined, + }; + const configuredProviders = await input.client.config.providers(parameters); + const value = normalizeProviderListResponse(unwrap(configuredProviders)); recordConnectedProviderSnapshot(input, value); return value; } +async function fetchOpenworkConfiguredProviders(input: { + baseUrl?: string | null; + openworkToken?: string | null; +}): Promise { + const token = input.openworkToken?.trim(); + const baseUrl = input.baseUrl?.trim().replace(/\/+$/, ""); + if (!token || !baseUrl || !/\/workspace\/[^/]+\/opencode$/.test(baseUrl)) return null; + + const fetchImpl = isDesktopRuntime() ? desktopFetch : globalThis.fetch; + const response = await fetchImpl(`${baseUrl}/config/providers`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch configured providers (${response.status})`); + } + return normalizeProviderListResponse(await response.json()); +} + +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), + default: value.default ?? {}, + }; +} + export function getConnectedProviderItems(value: ProviderListResponse | null | undefined) { const connected = new Set(value?.connected ?? []); return (value?.all ?? []).filter( @@ -88,6 +143,7 @@ export function isModelAvailableInConnectedProviders( export function getConnectedProviderSnapshotChange(input: { baseUrl?: string | null; + openworkToken?: string | null; directory?: string | null; }) { return connectedProviderSnapshotChanges.get(connectedProviderSnapshotKey(input)) ?? null; @@ -96,6 +152,7 @@ export function getConnectedProviderSnapshotChange(input: { function recordConnectedProviderSnapshot( input: { baseUrl?: string | null; + openworkToken?: string | null; directory?: string | null; }, value: ProviderListResponse, @@ -113,6 +170,7 @@ function recordConnectedProviderSnapshot( function connectedProviderSnapshotKey(input: { baseUrl?: string | null; + openworkToken?: string | null; directory?: string | null; }) { return JSON.stringify(providerListQueryKey(input)); @@ -167,6 +225,7 @@ export function ensureProviderListQuery( input: { client: Client; baseUrl?: string | null; + openworkToken?: string | null; directory?: string | null; force?: boolean; }, @@ -191,6 +250,7 @@ export function ensureProviderListQuery( export function useProviderListQuery(input: { client: Client | null; baseUrl?: string | null; + openworkToken?: string | null; directory?: string | null; enabled?: boolean; }) { @@ -210,6 +270,7 @@ export function useProviderListQuery(input: { return fetchProviderList({ client: input.client, baseUrl: input.baseUrl, + openworkToken: input.openworkToken, directory: input.directory, }); }, diff --git a/apps/app/src/react-app/shell/session-route.tsx b/apps/app/src/react-app/shell/session-route.tsx index 5cc9393aab..c596adb9e8 100644 --- a/apps/app/src/react-app/shell/session-route.tsx +++ b/apps/app/src/react-app/shell/session-route.tsx @@ -146,6 +146,7 @@ import { useReactRenderWatchdog } from "./react-render-watchdog"; import { readDenSettings } from "@/app/lib/den"; import { denSessionUpdatedEvent } from "@/app/lib/den-session-events"; +import { buildCloudManagedModelIdsByProvider } from "@/app/cloud/managed-provider-models"; import { filterProviderList } from "@/app/utils/providers"; import { ensureDesktopLocalOpenworkConnection } from "./desktop-local-openwork"; @@ -481,7 +482,6 @@ export function SessionRoute() { onSaved: handleRemoteWorkspaceConnectionSaved, }); - const workspaceSessionGroups = useMemo( () => toSessionGroups(workspaces, sessionsByWorkspaceId, errorsByWorkspaceId, new Set(retryingWorkspaceIds)), [errorsByWorkspaceId, retryingWorkspaceIds, sessionsByWorkspaceId, workspaces], @@ -542,10 +542,31 @@ export function SessionRoute() { return next; }, [errorsByWorkspaceId, workspaceConnectionOverrides, workspaces]); + const { store: sessionProviderAuthStore, snapshot: sessionProviderAuthSnapshot } = + useSessionProviderAuth({ + opencodeClient, + providers, + providerDefaults, + providerConnectedIds, + disabledProviderIds, + selectedWorkspace, + selectedWorkspaceEndpoint, + selectedWorkspaceRoot, + selectedWorkspaceId, + setProviders, + setProviderDefaults, + setProviderConnectedIds, + setDisabledProviderIds, + }); + const cloudManagedModelIdsByProvider = useMemo( + () => buildCloudManagedModelIdsByProvider(sessionProviderAuthSnapshot.importedCloudProviders), + [sessionProviderAuthSnapshot.importedCloudProviders], + ); const mcpConnectedCount = useMcpConnectedCount(opencodeClient, selectedWorkspaceRoot); const providerListQuery = useProviderListQuery({ client: opencodeClient, baseUrl: opencodeBaseUrl, + openworkToken: selectedWorkspaceServerToken, directory: selectedWorkspaceRoot || undefined, }); const { providerCatalog, modelVariantLabel, modelBehaviorOptions, modelVariantValue } = @@ -557,6 +578,8 @@ export function SessionRoute() { const modelPicker = useModelPicker({ client: opencodeClient, baseUrl: opencodeBaseUrl, + openworkToken: selectedWorkspaceServerToken, + cloudManagedModelIdsByProvider, workspaceRoot: selectedWorkspaceRoot, }); const selectedModelUnavailable = Boolean( @@ -588,23 +611,6 @@ export function SessionRoute() { workspaceId: selectedWorkspaceId, providerConnectedIds, }); - - const { store: sessionProviderAuthStore, snapshot: sessionProviderAuthSnapshot } = - useSessionProviderAuth({ - opencodeClient, - providers, - providerDefaults, - providerConnectedIds, - disabledProviderIds, - selectedWorkspace, - selectedWorkspaceEndpoint, - selectedWorkspaceRoot, - selectedWorkspaceId, - setProviders, - setProviderDefaults, - setProviderConnectedIds, - setDisabledProviderIds, - }); const { activePermission, permissionReplyBusy, @@ -675,6 +681,7 @@ export function SessionRoute() { await ensureProviderListQuery(getReactQueryClient(), { client: opencodeClient, baseUrl: opencodeBaseUrl, + openworkToken: selectedWorkspaceServerToken, directory: selectedWorkspaceRoot || undefined, }), disabledProviders, @@ -691,7 +698,7 @@ export function SessionRoute() { return () => { cancelled = true; }; - }, [opencodeBaseUrl, opencodeClient, selectedWorkspaceRoot, denSessionVersion]); + }, [opencodeBaseUrl, opencodeClient, selectedWorkspaceRoot, selectedWorkspaceServerToken, denSessionVersion]); const modelLabel = local.prefs.defaultModel ? resolveModelDisplayName(local.prefs.defaultModel.modelID) @@ -1475,7 +1482,9 @@ export function SessionRoute() { {opencodeClient && selectedWorkspaceEndpoint && opencodeBaseUrl && selectedWorkspaceServerToken ? ( selectedWorkspace ? { + ...selectedWorkspace, id: selectedWorkspace.id, name: selectedWorkspace.name ?? selectedWorkspace.displayNameResolved, path: selectedWorkspace.path ?? "", @@ -704,6 +706,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) || @@ -819,7 +825,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; @@ -843,6 +853,8 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { const modelPicker = useModelPicker({ client: opencodeClient, baseUrl: opencodeBaseUrl, + openworkToken: selectedWorkspaceEndpoint?.token ?? null, + cloudManagedModelIdsByProvider, workspaceRoot: selectedWorkspaceRoot, onLoadError: handleModelPickerLoadError, }); @@ -1621,7 +1633,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; @@ -1634,7 +1646,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { }); } }, [notFoundRouteError]); - const routeOpenworkCapabilities: OpenworkServerCapabilities | null = openworkClient + const routeOpenworkCapabilities: OpenworkServerCapabilities | null = workspaceOpenworkClient ? ROUTE_OPENWORK_CAPABILITIES : null; const environmentRuntimeKey = buildOpenworkEnvRuntimeKey({ @@ -1820,6 +1832,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; }) => { @@ -1833,6 +1851,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, @@ -1930,7 +1954,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { return ( false} + connectRemoteWorkspace={handleCreateRemoteWorkspace} onOpenAccount={openCloudAccountSettings} /> ); diff --git a/apps/app/src/react-app/shell/startup-deep-links.ts b/apps/app/src/react-app/shell/startup-deep-links.ts index 5d4994f36d..c2aa852c30 100644 --- a/apps/app/src/react-app/shell/startup-deep-links.ts +++ b/apps/app/src/react-app/shell/startup-deep-links.ts @@ -2,6 +2,7 @@ import { pushPendingDeepLinks, } from "../../app/lib/deep-link-bridge"; import { subscribeDesktopDeepLinks } from "../../app/lib/desktop"; +import { stripRemoteConnectQuery } from "../../app/lib/openwork-links"; import { isDesktopRuntime } from "../../app/utils"; let started = false; @@ -12,6 +13,10 @@ export function startDeepLinkBridge(): void { if (!isDesktopRuntime()) { pushPendingDeepLinks(window, [window.location.href]); + const strippedUrl = stripRemoteConnectQuery(window.location.href); + if (strippedUrl) { + window.history.replaceState(window.history.state, "", strippedUrl); + } return; } diff --git a/apps/app/src/react-app/shell/welcome-route.tsx b/apps/app/src/react-app/shell/welcome-route.tsx index c2a51ee349..74e5bf6ea8 100644 --- a/apps/app/src/react-app/shell/welcome-route.tsx +++ b/apps/app/src/react-app/shell/welcome-route.tsx @@ -123,12 +123,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..48eea01a27 100644 --- a/apps/app/src/react-app/shell/workspace-provider.ts +++ b/apps/app/src/react-app/shell/workspace-provider.ts @@ -5,7 +5,9 @@ import type { Client } from "@/app/types"; type WorkspaceContextValue = { client: Client | null; opencodeBaseUrl: string; + openworkToken: string; selectedWorkspaceRoot: string; + cloudManagedModelIdsByProvider: Map>; }; const WorkspaceContext = React.createContext(null); @@ -13,19 +15,29 @@ const WorkspaceContext = React.createContext(null) type WorkspaceProviderProps = { client: Client | null; opencodeBaseUrl?: string; + openworkToken?: string; selectedWorkspaceRoot: string; + cloudManagedModelIdsByProvider?: Map>; children: React.ReactNode; }; export function WorkspaceProvider({ client, opencodeBaseUrl = "", + openworkToken = "", selectedWorkspaceRoot, + cloudManagedModelIdsByProvider, children, }: WorkspaceProviderProps) { const value = React.useMemo( - () => ({ client, opencodeBaseUrl, selectedWorkspaceRoot }), - [client, opencodeBaseUrl, selectedWorkspaceRoot], + () => ({ + client, + opencodeBaseUrl, + openworkToken, + selectedWorkspaceRoot, + cloudManagedModelIdsByProvider: cloudManagedModelIdsByProvider ?? new Map>(), + }), + [client, cloudManagedModelIdsByProvider, opencodeBaseUrl, openworkToken, 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..cba224501f --- /dev/null +++ b/apps/app/tests/den-managed-provider-sync.test.ts @@ -0,0 +1,101 @@ +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({ + status: "failed", + reason: "Worker provider sync failed.", + 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..2f9a3647c9 --- /dev/null +++ b/apps/app/tests/managed-provider-models.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, test } from "bun:test"; + +import { + buildCloudManagedModelOptions, + buildCloudManagedModelIdsByProvider, + hasCloudManagedModelAllowlist, + isCloudManagedModelAllowed, +} from "../src/app/cloud/managed-provider-models"; +import { createClient } from "../src/app/lib/opencode"; +import type { CloudImportedProvider } from "../src/app/cloud/import-state"; +import type { ProviderListItem } from "../src/app/types"; +import { + fetchProviderList, + getConnectedProviderItems, + normalizeProviderListResponse, +} from "../src/react-app/infra/provider-list-query"; + +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-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 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(55); + 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("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("does not fallback to the full available catalog when configured providers are unavailable", 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 === "/provider") { + return Response.json({ + all: [provider("openai", "openAI_2", staleOpenAiModelIds())], + connected: ["openai", "opencode"], + default: {}, + }); + } + return new Response("not found", { status: 404 }); + }, + }); + + try { + await expect(fetchProviderList({ client: createClient(server.url.toString()) })).rejects.toThrow(); + expect(requests).toEqual(["/config/providers"]); + } finally { + server.stop(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/openwork-links.test.ts b/apps/app/tests/openwork-links.test.ts new file mode 100644 index 0000000000..f441a7cd1c --- /dev/null +++ b/apps/app/tests/openwork-links.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; + +import { parseRemoteConnectDeepLink } from "../src/app/lib/openwork-links"; + +describe("parseRemoteConnectDeepLink", () => { + 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", + }); + }); + + 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..e68ed86d5a --- /dev/null +++ b/apps/app/tests/openwork-server.test.ts @@ -0,0 +1,38 @@ +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", () => { + 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/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/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..8e8d463de1 --- /dev/null +++ b/apps/desktop/electron/browser-native-tools.mjs @@ -0,0 +1,919 @@ +/** + * 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; + } + + 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); + } + + /** 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 canConnect = meta.bucket === "ready" && sandbox.isMine; 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 (
@@ -154,13 +93,9 @@ function SandboxCard({
@@ -219,64 +155,12 @@ 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. + {sandbox.isMine ? "Connection details will appear once this workspace is ready." : "Only the worker owner can connect to this worker."}

)}
@@ -299,9 +183,14 @@ export function BackgroundAgentsScreen() { workersBusy, workersLoadedOnce, workersError, + launchBusy, + launchError, + launchStatus, + launchWorker, renameWorker, renameBusyWorkerId, } = useDenFlow(); + const { orgId } = useOrgDashboard(); async function loadConnectionDetails(workerId: string, workerName: string) { setConnectBusyWorkerId(workerId); @@ -338,13 +227,25 @@ export function BackgroundAgentsScreen() { tokens.clientToken, workerId, workerName, - { autoConnect: true }, + { + autoConnect: true, + clientToken: tokens.clientToken, + denBaseUrl: window.location.origin, + denApiBaseUrl: `${window.location.origin}/api/den`, + denOrgId: orgId, + }, ), openworkDeepLink: buildOpenworkDeepLink( tokens.openworkUrl, tokens.clientToken, workerId, workerName, + { + clientToken: tokens.clientToken, + denBaseUrl: window.location.origin, + denApiBaseUrl: `${window.location.origin}/api/den`, + denOrgId: orgId, + }, ), }; @@ -363,7 +264,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; } @@ -386,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 ? ( @@ -435,10 +348,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) { @@ -458,3 +369,4 @@ export function BackgroundAgentsScreen() { ); } +import { useState } from "react"; diff --git a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-data.test.tsx b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-data.test.tsx new file mode 100644 index 0000000000..e27d50b1cd --- /dev/null +++ b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-data.test.tsx @@ -0,0 +1,62 @@ +import { parseDenLlmProvidersResponse } from "./llm-provider-data"; + +declare const test: (name: string, fn: () => void | Promise) => void; +declare const expect: any; + +test("parses pending invited LLM provider member access entries", () => { + const parsed = parseDenLlmProvidersResponse({ + llmProviders: [ + { + id: "llmProvider_pending", + organizationId: "org_pending", + createdByOrgMembershipId: "om_creator", + source: "models_dev", + providerId: "openai", + name: "OpenAI", + providerConfig: {}, + credentialKind: "opencode_oauth", + hasApiKey: false, + hasOpencodeAuth: true, + hasCredential: true, + createdAt: "2026-06-10T00:00:00.000Z", + updatedAt: "2026-06-10T00:00:00.000Z", + canManage: true, + accessibleVia: { orgMembershipIds: [], teamIds: [] }, + models: [], + access: { + members: [ + { + id: "llmProviderAccess_pending", + orgMembershipId: "om_pending", + role: "member", + user: { + id: null, + name: null, + email: "pending@example.com", + image: null, + }, + createdAt: "2026-06-10T00:00:00.000Z", + }, + ], + teams: [], + }, + }, + ], + }); + + expect(parsed).toHaveLength(1); + expect(parsed[0]?.access.members).toEqual([ + { + id: "llmProviderAccess_pending", + orgMembershipId: "om_pending", + role: "member", + user: { + id: null, + name: "pending@example.com", + email: "pending@example.com", + image: null, + }, + createdAt: "2026-06-10T00:00:00.000Z", + }, + ]); +}); diff --git a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-data.tsx b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-data.tsx index 751b911462..f71625dd47 100644 --- a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-data.tsx +++ b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-data.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { getErrorMessage, requestJson } from "../../_lib/den-flow"; export type DenLlmProviderSource = "models_dev" | "custom" | "openwork"; +export type DenLlmProviderCredentialKind = "api_key" | "opencode_oauth"; export type DenLlmProviderModel = { id: string; @@ -18,7 +19,7 @@ export type DenLlmProviderMemberAccess = { role: string; createdAt: string | null; user: { - id: string; + id: string | null; name: string; email: string; image: string | null; @@ -41,7 +42,10 @@ export type DenLlmProvider = { providerId: string; name: string; providerConfig: Record; + credentialKind: DenLlmProviderCredentialKind; hasApiKey: boolean; + hasOpencodeAuth: boolean; + hasCredential: boolean; createdAt: string | null; updatedAt: string | null; canManage: boolean; @@ -122,10 +126,12 @@ function asLlmProviderMemberAccess(value: unknown): DenLlmProviderMemberAccess | const id = asString(value.id); const orgMembershipId = asString(value.orgMembershipId); const role = asString(value.role); - const userId = asString(value.user.id); - const name = asString(value.user.name); + const rawUserId = value.user.id; + const userId = rawUserId === null ? null : asString(rawUserId); + const validUserId = rawUserId === null || (typeof rawUserId === "string" && rawUserId.length > 0); const email = asString(value.user.email); - if (!id || !orgMembershipId || !role || !userId || !name || !email) { + const name = asString(value.user.name) || email; + if (!id || !orgMembershipId || !role || !validUserId || !name || !email) { return null; } @@ -143,6 +149,12 @@ function asLlmProviderMemberAccess(value: unknown): DenLlmProviderMemberAccess | }; } +export function parseDenLlmProvidersResponse(payload: unknown): DenLlmProvider[] { + return isRecord(payload) && Array.isArray(payload.llmProviders) + ? payload.llmProviders.map(asLlmProvider).filter((entry): entry is DenLlmProvider => entry !== null) + : []; +} + function asLlmProviderTeamAccess(value: unknown): DenLlmProviderTeamAccess | null { if (!isRecord(value)) { return null; @@ -178,6 +190,7 @@ function asLlmProvider(value: unknown): DenLlmProvider | null { value.source === "models_dev" || value.source === "custom" || value.source === "openwork" ? value.source : null; + const credentialKind = value.credentialKind === "opencode_oauth" ? "opencode_oauth" : "api_key"; if (!id || !organizationId || !createdByOrgMembershipId || !providerId || !name || !source) { return null; } @@ -190,7 +203,10 @@ function asLlmProvider(value: unknown): DenLlmProvider | null { providerId, name, providerConfig: asJsonRecord(value.providerConfig), + credentialKind, hasApiKey: value.hasApiKey === true, + hasOpencodeAuth: value.hasOpencodeAuth === true, + hasCredential: value.hasCredential === true || value.hasApiKey === true || value.hasOpencodeAuth === true, createdAt: asIsoString(value.createdAt), updatedAt: asIsoString(value.updatedAt), canManage: value.canManage === true, @@ -417,9 +433,7 @@ export function useOrgLlmProviders( throw new Error(getErrorMessage(payload, `Failed to load providers (${response.status}).`)); } - const nextProviders = isRecord(payload) && Array.isArray(payload.llmProviders) - ? payload.llmProviders.map(asLlmProvider).filter((entry): entry is DenLlmProvider => entry !== null) - : []; + const nextProviders = parseDenLlmProvidersResponse(payload); setLlmProviders(nextProviders); } catch (loadError) { setError(loadError instanceof Error ? loadError.message : "Failed to load the provider library."); diff --git a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-detail-screen.tsx b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-detail-screen.tsx index c208bfdaa9..4b50aac65c 100644 --- a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-detail-screen.tsx +++ b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-detail-screen.tsx @@ -185,10 +185,10 @@ export function LlmProviderDetailScreen({
- {provider.hasApiKey + {provider.hasCredential ? "Credential saved" : "Credential missing"}
@@ -221,10 +221,10 @@ export function LlmProviderDetailScreen({

- Updated + Credential

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

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