diff --git a/.dockerignore b/.dockerignore index 333d6134f2..d4441fb599 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,10 +1,14 @@ .git .github .opencode +.codenomad node_modules **/node_modules tmp dist **/dist +artifacts +apps/desktop/src-tauri/target +**/target .env .env.* 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 8e592ebcd9..c6e766d7a2 100644 --- a/apps/app/src/app/lib/den.ts +++ b/apps/app/src/app/lib/den.ts @@ -119,6 +119,15 @@ export type DenMcpToken = { resource: string; }; +export type DenStaticWorkerAttachInput = { + name: string; + description?: string | null; + url: string; + clientToken: string; + hostToken: string; + activityToken?: string | null; +}; + export type DenOrgLlmProviderModel = { id: string; name: string; @@ -129,10 +138,13 @@ export type DenOrgLlmProviderModel = { export type DenOrgLlmProvider = { id: string; source: "models_dev" | "custom" | "openwork"; + credentialKind: "api_key" | "opencode_oauth"; providerId: string; name: string; providerConfig: Record; hasApiKey: boolean; + hasOpencodeAuth: boolean; + hasCredential: boolean; models: DenOrgLlmProviderModel[]; createdAt: string | null; updatedAt: string | null; @@ -140,6 +152,15 @@ export type DenOrgLlmProvider = { export type DenOrgLlmProviderConnection = DenOrgLlmProvider & { apiKey: string | null; + opencodeAuth: string | null; +}; + +export type DenManagedProviderSyncResult = { + status: "applied" | "failed"; + providerCount: number; + revision: string; + providerIds?: string[]; + reason?: string; }; export type DenPluginConfigObjectType = "skill" | "agent" | "command" | "tool" | "mcp" | "hook" | "context" | "custom"; @@ -590,8 +611,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 { @@ -626,51 +659,18 @@ export async function initializeDenBootstrapConfig(): Promise setTimeout(resolve, SHELL_BOOTSTRAP_RETRY_DELAY_MS)); - } - } + try { + const bootstrap = await getDesktopBootstrapConfigFromShell() as ShellDesktopBootstrapConfig; + applyDesktopBootstrapConfig(resolveDenBootstrapConfig(bootstrap)); + } catch { + desktopBootstrapConfig = resolveDenBootstrapConfig({ + baseUrl: BUILD_DEN_BASE_URL, + apiBaseUrl: BUILD_DEN_API_BASE_URL, + requireSignin: BUILD_DEN_REQUIRE_SIGNIN, + }); + syncBootstrapSettingsToLocalStorage(desktopBootstrapConfig); } - // All quick attempts failed. Keep build defaults in memory only — do NOT - // sync them to localStorage: previously synced values from a successful - // boot are more trustworthy than build defaults, and clobbering them - // silently reverted custom/self-hosted control planes to the production - // URL until a manual reload. - desktopBootstrapConfig = resolveDenBootstrapConfig({ - baseUrl: BUILD_DEN_BASE_URL, - apiBaseUrl: BUILD_DEN_API_BASE_URL, - requireSignin: BUILD_DEN_REQUIRE_SIGNIN, - }); - - // Heal in the background without blocking boot: once the bridge comes up, - // apply the real shell config and notify listeners. - void (async () => { - for (let attempt = 0; attempt < 15; attempt += 1) { - await new Promise((resolve) => setTimeout(resolve, 2_000)); - try { - const bootstrap = await getDesktopBootstrapConfigFromShell() as ShellDesktopBootstrapConfig; - applyDesktopBootstrapConfig(resolveDenBootstrapConfig(bootstrap)); - dispatchDenSettingsChanged({ settings: readDenSettings() }); - return; - } catch { - // Bridge still unavailable — keep trying. - } - } - })(); - return desktopBootstrapConfig; } @@ -1110,10 +1110,13 @@ function parseDenOrgLlmProvider(value: unknown): DenOrgLlmProvider | null { return { id: value.id, source: value.source, + credentialKind: value.credentialKind === "opencode_oauth" ? "opencode_oauth" : "api_key", providerId: value.providerId, name: value.name, providerConfig: parseJsonRecord(value.providerConfig), hasApiKey: value.hasApiKey === true, + hasOpencodeAuth: value.hasOpencodeAuth === true, + hasCredential: value.hasCredential === true || value.hasApiKey === true || value.hasOpencodeAuth === true, models: Array.isArray(value.models) ? value.models.flatMap((model) => { const parsed = parseDenOrgLlmProviderModel(model); @@ -1149,6 +1152,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 } : {}), }; } @@ -1914,6 +1939,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", @@ -1987,7 +2038,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, @@ -2001,6 +2052,27 @@ export function createDenClient(options: { baseUrl: string; apiBaseUrl?: string return provider; }, + async syncWorkerManagedProviders(orgId: string, workerId: string): Promise { + const payload = await requestJson( + baseUrls, + `/v1/workers/${encodeURIComponent(workerId)}/managed-providers/sync`, + { + method: "POST", + token, + organizationId: orgId, + body: {}, + }, + ); + const result = getDenManagedProviderSyncResult(payload); + if (!result) { + throw new DenApiError(500, "invalid_managed_provider_sync_payload", "Managed provider sync response was invalid."); + } + if (result.status !== "applied") { + throw new DenApiError(502, "managed_provider_sync_failed", result.reason ?? "Managed provider sync failed."); + } + return result; + }, + async listOrgMarketplaces(orgId: string): Promise { const payload = await requestJson( baseUrls, diff --git a/apps/app/src/app/lib/desktop-types.ts b/apps/app/src/app/lib/desktop-types.ts index c84e6b6124..cc8faebbbc 100644 --- a/apps/app/src/app/lib/desktop-types.ts +++ b/apps/app/src/app/lib/desktop-types.ts @@ -81,6 +81,9 @@ export type WorkspaceInfo = { openworkHostToken?: string | null; openworkWorkspaceId?: string | null; openworkWorkspaceName?: string | null; + openworkDenBaseUrl?: string | null; + openworkDenOrgId?: string | null; + openworkDenWorkerId?: string | null; sandboxBackend?: "docker" | "microsandbox" | null; sandboxRunId?: string | null; sandboxContainerName?: string | null; diff --git a/apps/app/src/app/lib/openwork-server.ts b/apps/app/src/app/lib/openwork-server.ts index c738e2e599..e8c8d6a769 100644 --- a/apps/app/src/app/lib/openwork-server.ts +++ b/apps/app/src/app/lib/openwork-server.ts @@ -532,6 +532,28 @@ export function parseOpenworkWorkspaceIdFromUrl(input: string) { } } +export function stripOpenworkWorkspaceMount(input: string) { + const normalized = normalizeOpenworkServerUrl(input) ?? ""; + if (!normalized) return ""; + + try { + const url = new URL(normalized); + const segments = url.pathname.split("/").filter(Boolean); + const workspaceIndex = segments.indexOf("workspace"); + const legacyIndex = segments.indexOf("w"); + const mountIndex = workspaceIndex >= 0 ? workspaceIndex : legacyIndex; + if (mountIndex >= 0 && segments[mountIndex + 1]) { + const prefix = segments.slice(0, mountIndex).join("/"); + url.pathname = prefix ? `/${prefix}` : "/"; + return url.toString().replace(/\/+$/, ""); + } + } catch { + // Fall through to the normalized value below. + } + + return normalized.replace(/\/+$/, ""); +} + export function buildOpenworkWorkspaceBaseUrl(hostUrl: string, workspaceId?: string | null) { const normalized = normalizeOpenworkServerUrl(hostUrl) ?? ""; if (!normalized) return null; @@ -650,7 +672,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) ?? ""; @@ -659,7 +681,7 @@ export function readOpenworkServerSettings(): OpenworkServerSettings { const hostToken = window.localStorage.getItem(STORAGE_HOST_AUTH_KEY) ?? undefined; const remoteAccessRaw = window.localStorage.getItem(STORAGE_REMOTE_ACCESS) ?? ""; return { - urlOverride: urlOverride ?? undefined, + urlOverride: urlOverride || undefined, portOverride: Number.isNaN(portOverride) ? undefined : portOverride, token: token?.trim() || undefined, hostToken: hostToken?.trim() || undefined, diff --git a/apps/app/src/app/lib/workspace-endpoint.ts b/apps/app/src/app/lib/workspace-endpoint.ts index 20c0d72979..87b2bbae00 100644 --- a/apps/app/src/app/lib/workspace-endpoint.ts +++ b/apps/app/src/app/lib/workspace-endpoint.ts @@ -31,6 +31,8 @@ export type ResolvedWorkspaceEndpoint = { baseUrl: string; /** Auth token for that server. May be empty for unauthenticated local servers. */ token: string; + /** Host/admin token for routes that require worker mutation privileges. */ + hostToken: string; /** Workspace id as the owning server expects it in URL paths. No `rem_` prefix. */ workspaceId: string; /** True when the workspace lives on a remote OpenWork worker, not the user's local server. */ @@ -93,13 +95,17 @@ function pickRemoteBaseUrl(workspace: WorkspaceEndpointInput): string { function pickRemoteToken(workspace: WorkspaceEndpointInput): string { if (!workspace) return ""; return ( - workspace.openworkToken ?? workspace.openworkClientToken ?? - workspace.openworkHostToken ?? + workspace.openworkToken ?? "" ).trim(); } +function pickRemoteHostToken(workspace: WorkspaceEndpointInput): string { + if (!workspace) return ""; + return (workspace.openworkHostToken ?? "").trim(); +} + /** * Resolve the right server endpoint for a workspace. Returns null when the * workspace can't be reached (remote with no baseUrl, or local with no local @@ -116,10 +122,12 @@ export function resolveWorkspaceEndpoint( const baseUrl = pickRemoteBaseUrl(workspace); if (!baseUrl) return null; const token = pickRemoteToken(workspace); + const hostToken = pickRemoteHostToken(workspace); const workspaceId = workspaceServerId(workspace); const client = createOpenworkServerClient({ baseUrl, token: token || undefined, + hostToken: hostToken || undefined, }); const mountedBaseUrl = ( buildOpenworkWorkspaceBaseUrl(baseUrl, workspaceId) ?? baseUrl @@ -127,6 +135,7 @@ export function resolveWorkspaceEndpoint( return { baseUrl, token, + hostToken, workspaceId, isRemote: true, client, @@ -149,6 +158,7 @@ export function resolveWorkspaceEndpoint( return { baseUrl: localBaseUrl, token: localToken, + hostToken: "", workspaceId, isRemote: false, client, diff --git a/apps/app/src/components/model-select.tsx b/apps/app/src/components/model-select.tsx index b0386536c1..d18f131446 100644 --- a/apps/app/src/components/model-select.tsx +++ b/apps/app/src/components/model-select.tsx @@ -45,6 +45,7 @@ import { import { isDesktopProviderBlocked } from "@/app/cloud/desktop-app-restrictions"; import { openModelPickerEvent } from "@/react-app/shell/new-providers-toast"; import { newProvidersEvent } from "@/app/lib/provider-events"; +import { buildCloudManagedModelOptions } from "@/app/cloud/managed-provider-models"; function getProviderDisplayName(providerId: string) { return providerId @@ -55,7 +56,7 @@ function getProviderDisplayName(providerId: string) { } function useModelOptions(open: boolean) { - const { client, opencodeBaseUrl, selectedWorkspaceRoot } = useWorkspace(); + const { client, opencodeBaseUrl, selectedWorkspaceRoot, cloudManagedModelIdsByProvider } = useWorkspace(); const checkDesktopRestriction = useCheckDesktopRestriction(); const { data, refetch } = useProviderListQuery({ @@ -89,21 +90,10 @@ function useModelOptions(open: boolean) { restriction: "allowCustomProviders", }); - const options = getConnectedProviderItems(data) - .flatMap((provider) => - Object.entries(provider.models).map(([id, model]) => ({ - providerID: provider.id, - modelID: id, - title: model.name, - description: provider.name, - behaviorTitle: "Reasoning", - behaviorLabel: "Default", - behaviorDescription: "", - behaviorValue: null, - isFree: false, - isConnected: true, - })), - ); + const options = buildCloudManagedModelOptions({ + providers: getConnectedProviderItems(data), + cloudManagedModelIdsByProvider, + }); return options.filter((option) => { if ( @@ -121,7 +111,7 @@ function useModelOptions(open: boolean) { return true; }); - }, [checkDesktopRestriction, data]); + }, [checkDesktopRestriction, cloudManagedModelIdsByProvider, data]); } type ModelSelectModelItem = { diff --git a/apps/app/src/react-app/domains/cloud/org-onboarding-page.tsx b/apps/app/src/react-app/domains/cloud/org-onboarding-page.tsx index d9790776ae..3a24cf561d 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,93 @@ 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; + } + + const healthyWorker = workers.find((worker) => worker.status === "healthy") ?? null; + if (!healthyWorker) { + if (workers.length > 0) { + throw new Error("No healthy cloud worker is attached to this organization yet."); + } + return null; + } + + const tokens = await denClient.getWorkerTokens(healthyWorker.workerId, orgId); + const openworkUrl = tokens.openworkUrl?.trim() ?? ""; + const openworkHostUrl = stripOpenworkWorkspaceMount(openworkUrl); + const accessToken = tokens.clientToken?.trim() || tokens.ownerToken?.trim() || ""; + if (!openworkUrl || !accessToken) { + throw new Error("The shared worker is not ready yet."); } - // 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 list = await workspaceCreateRemote({ + baseUrl: openworkUrl, + openworkHostUrl: openworkUrl, + openworkToken: accessToken, + openworkClientToken: tokens.clientToken?.trim() || null, + openworkHostToken: tokens.hostToken?.trim() || null, + openworkDenBaseUrl: settings.baseUrl, + 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); } - navigate("/session", { replace: true }); - }, [navigate, providers, selectedDefault]); + + writeOpenworkServerSettings({ + urlOverride: openworkHostUrl || openworkUrl, + token: accessToken, + hostToken: tokens.hostToken?.trim() || undefined, + }); + try { + window.dispatchEvent(new CustomEvent("openwork-server-settings-changed")); + } catch { + // Best-effort only. + } + + return createdId || null; + }, [denClient, orgId, settings.baseUrl, workers]); + + const handleContinue = useCallback(async () => { + setContinueBusy(true); + setContinueError(null); + try { + if (selectedDefault) { + writeStoredDefaultModel({ + providerID: selectedDefault.providerId, + modelID: selectedDefault.modelId, + }); + } + + markProvidersSeen(providers); + if (providers.length > 0) { + try { + window.localStorage.setItem(RELOAD_AFTER_ONBOARDING_KEY, "1"); + } catch {} + } + + const workspaceId = await connectHealthyWorker(); + local.setPrefs((previous) => ({ ...previous, hasCompletedOnboarding: true })); + navigate(workspaceId ? workspaceSessionRoute(workspaceId) : "/session", { replace: true }); + } catch (error) { + setContinueError(error instanceof Error ? error.message : "Could not connect the shared worker."); + } finally { + setContinueBusy(false); + } + }, [connectHealthyWorker, local, navigate, providers, selectedDefault]); const totalModels = providers.reduce((sum, provider) => sum + provider.models.length, 0); const hasResources = providers.length > 0 || marketplaces.length > 0 || workers.length > 0; @@ -298,6 +384,11 @@ export function ResourceSelectionPage() { {error} + ) : continueError ? ( + + + {continueError} + ) : hasResources ? ( You have access to the following resources. @@ -410,10 +501,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"} 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 80791a51c2..152fa5bbcd 100644 --- a/apps/app/src/react-app/domains/connections/provider-auth/store.ts +++ b/apps/app/src/react-app/domains/connections/provider-auth/store.ts @@ -32,7 +32,7 @@ import { filterProviderList, } from "../../../../app/utils/providers"; import { getReactQueryClient } from "../../../infra/query-client"; -import { ensureProviderListQuery } from "../provider-list-query"; +import { ensureProviderListQuery, refreshProviderListQueries } from "../provider-list-query"; import type { OpenworkServerStore } from "../openwork-server-store"; import { denSessionUpdatedEvent, @@ -119,6 +119,36 @@ type MutableState = { export type ProviderAuthStore = ReturnType; +export const getCloudManagedProviderId = ( + provider: Pick, +) => { + if (provider.source === "openwork") return "openwork"; + if (provider.credentialKind === "opencode_oauth") return provider.providerId.trim(); + return provider.id.trim(); +}; + +export function resolveAppliedManagedProvidersFromSyncResult( + result: { providerCount: number; providerIds?: string[] }, + liveProviders: DenOrgLlmProvider[], +) { + if (Array.isArray(result.providerIds)) { + const appliedIds = new Set(result.providerIds.map((id) => id.trim()).filter(Boolean)); + return liveProviders.filter((provider) => appliedIds.has(provider.id)); + } + + if (result.providerCount === liveProviders.length) { + return liveProviders; + } + + if (result.providerCount === 0) { + return []; + } + + throw new Error( + "Remote worker synced only part of the organization provider set but did not identify which providers were applied. Imported provider state was left unchanged.", + ); +} + export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) { const listeners = new Set<() => void>(); @@ -164,10 +194,6 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) const sameStringList = (a: string[], b: string[]) => a.length === b.length && a.every((value, index) => value === b[index]); - const getCloudManagedProviderId = ( - provider: Pick, - ) => provider.source === "openwork" ? "openwork" : provider.id.trim(); - const getProviderAuthWorkerType = (): "local" | "remote" => options.selectedWorkspaceDisplay().workspaceType === "remote" ? "remote" : "local"; @@ -1344,14 +1370,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 }, @@ -1411,6 +1468,23 @@ 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."); + } + await syncRemoteManagedProviders("settings_cloud_opened", liveProviders, state.importedCloudProviders); + 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); } @@ -1472,6 +1546,9 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) return message; }; + const shouldSurfaceCloudProviderSyncError = (reason: CloudProviderSyncReason) => + reason === "settings_cloud_opened"; + const getCloudProviderSyncContextKey = () => { const settings = readDenSettings(); return [ @@ -1508,6 +1585,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 false; + + 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 true; + }; + async function performCloudProviderSync(reason: CloudProviderSyncReason) { if (!hasCloudProviderSyncPrerequisites()) { return; @@ -1517,6 +1666,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(); @@ -1577,6 +1731,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 @@ -1602,7 +1757,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) const request = performCloudProviderSync(reason) .catch((error) => { const message = logCloudProviderSyncError(reason, error); - if (reason === "settings_cloud_opened") { + if (shouldSurfaceCloudProviderSyncError(reason)) { setStateField("providerAuthError", message); } }) diff --git a/apps/app/src/react-app/domains/settings/pages/cloud-providers-view.tsx b/apps/app/src/react-app/domains/settings/pages/cloud-providers-view.tsx index 1e48065a12..7b92f31c4f 100644 --- a/apps/app/src/react-app/domains/settings/pages/cloud-providers-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/cloud-providers-view.tsx @@ -11,6 +11,7 @@ import { useCloudSession } from "@/react-app/domains/settings/cloud/cloud-sessio import { CloudProvidersSection, type CloudProviderRow } from "@/react-app/domains/settings/cloud/sections"; import type { useDenSession } from "@/react-app/domains/settings/cloud/use-den-session"; import { SettingsNotice, SettingsStack } from "@/react-app/domains/settings/settings-section"; +import { getCloudManagedProviderId } from "@/react-app/domains/connections/provider-auth/store"; type CloudProvidersSession = Pick< ReturnType, @@ -54,9 +55,10 @@ export function CloudProvidersView({ const rows = React.useMemo(() => { const nextRows: CloudProviderRow[] = cloudOrgProviders.map((provider) => { const imported = importedCloudProviders[provider.id] ?? null; + const localProviderId = getCloudManagedProviderId(provider); const status = !imported ? "available" - : imported.providerId !== provider.id.trim() || + : imported.providerId !== localProviderId || imported.sourceProviderId !== provider.providerId || (imported.source ?? null) !== (provider.source ?? null) || (imported.updatedAt ?? null) !== (provider.updatedAt ?? null) || diff --git a/apps/app/src/react-app/domains/settings/pages/cloud-workers-view.tsx b/apps/app/src/react-app/domains/settings/pages/cloud-workers-view.tsx index e2b08b5207..a57dd36081 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; + openworkHostToken?: string | null; + openworkDenBaseUrl?: string | null; + openworkDenOrgId?: string | null; + openworkDenWorkerId?: string | null; directory?: string | null; displayName?: string | null; }) => Promise; @@ -23,11 +29,18 @@ export function CloudWorkersView({ connectRemoteWorkspace, onOpenAccount, }: CloudWorkersViewProps) { - const { activeOrganization: activeOrg, authToken, client, isSignedIn, user } = useCloudSession(); + const { activeOrganization: activeOrg, authToken, baseUrl, client, isSignedIn, user } = useCloudSession(); const [workersBusy, setWorkersBusy] = React.useState(false); const [openingWorkerId, setOpeningWorkerId] = React.useState(null); + const [attachBusy, setAttachBusy] = React.useState(false); const [workers, setWorkers] = React.useState([]); const [workersError, setWorkersError] = React.useState(null); + const [staticWorkerForm, setStaticWorkerForm] = React.useState({ + name: "LAN static worker", + url: "", + clientToken: "", + hostToken: "", + }); const activeOrgId = activeOrg?.id ?? ""; const refreshWorkers = React.useCallback( @@ -82,7 +95,7 @@ export function CloudWorkersView({ try { const tokens = await client.getWorkerTokens(workerId, activeOrgId); const openworkUrl = tokens.openworkUrl?.trim() ?? ""; - const accessToken = tokens.ownerToken?.trim() || tokens.clientToken?.trim() || ""; + const accessToken = tokens.clientToken?.trim() || tokens.ownerToken?.trim() || ""; if (!openworkUrl || !accessToken) { throw new Error(t("den.error_worker_not_ready")); } @@ -90,6 +103,11 @@ export function CloudWorkersView({ const ok = await connectRemoteWorkspace({ openworkHostUrl: openworkUrl, openworkToken: accessToken, + openworkClientToken: tokens.clientToken?.trim() || null, + openworkHostToken: tokens.hostToken?.trim() || null, + openworkDenBaseUrl: baseUrl, + openworkDenOrgId: activeOrgId, + openworkDenWorkerId: workerId, directory: null, displayName: workerName, }); @@ -108,9 +126,47 @@ export function CloudWorkersView({ setOpeningWorkerId(null); } }, - [activeOrgId, client, connectRemoteWorkspace], + [activeOrgId, 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,6 +186,45 @@ export function CloudWorkersView({ return ( + +
+
+
Admin/operator: attach LAN static worker
+
+ Organization owners and admins can register a pre-running OpenWork worker without manual database changes. The URL and tokens must match the worker container environment. +
+
+
+ setStaticWorkerForm((current) => ({ ...current, name: event.currentTarget.value }))} + placeholder="Worker name" + /> + setStaticWorkerForm((current) => ({ ...current, url: event.currentTarget.value }))} + placeholder="http://192.168.1.50:8787" + /> + setStaticWorkerForm((current) => ({ ...current, clientToken: event.currentTarget.value }))} + placeholder="OPENWORK_TOKEN" + type="password" + /> + setStaticWorkerForm((current) => ({ ...current, hostToken: event.currentTarget.value }))} + placeholder="OPENWORK_HOST_TOKEN" + type="password" + /> +
+
+ +
+
+
0) return; if (local.prefs.hasCompletedOnboarding) return; - navigate("/welcome", { replace: true }); - }, [loading, local.prefs.hasCompletedOnboarding, navigate, workspaces.length]); + navigate(denAuth.isSignedIn ? "/onboarding" : "/welcome", { replace: true }); + }, [denAuth.isSignedIn, loading, local.prefs.hasCompletedOnboarding, navigate, workspaces.length]); // NOTE: Blueprint seeding was removed from the route. // It was firing `materializeBlueprintSessions` + a session re-fetch on every @@ -1722,6 +1726,10 @@ export function SessionRoute() { // sync here so sign-in applies opencode.json changes before Settings opens. useCloudProviderAutoSync(sessionProviderAuthStore.runCloudProviderSync); const sessionProviderAuthSnapshot = useProviderAuthStoreSnapshot(sessionProviderAuthStore); + const cloudManagedModelIdsByProvider = useMemo( + () => buildCloudManagedModelIdsByProvider(sessionProviderAuthSnapshot.importedCloudProviders), + [sessionProviderAuthSnapshot.importedCloudProviders], + ); const permissionQueryKey = useMemo( () => selectedWorkspaceId && selectedSessionId @@ -1998,28 +2006,11 @@ export function SessionRoute() { } catch { seenIds = new Set(); } - const options: ModelOption[] = []; - for (const provider of getConnectedProviderItems(data)) { - const modelIds = Object.keys(provider.models); - const isNew = !seenIds.has(provider.id) || recentProviderIds.has(provider.id); - for (const id of modelIds) { - const model = provider.models[id]; - options.push({ - providerID: provider.id, - modelID: id, - title: model.name || id, - description: provider.name, - behaviorTitle: "Reasoning", - behaviorLabel: "Default", - behaviorDescription: "", - behaviorValue: null, - isFree: false, - isConnected: true, - isRecommended: isNew, - source: /^lpr_/i.test(provider.id) ? "cloud" as const : undefined, - }); - } - } + const options = buildCloudManagedModelOptions({ + providers: getConnectedProviderItems(data), + cloudManagedModelIdsByProvider, + isRecommendedProvider: (providerId) => !seenIds.has(providerId) || recentProviderIds.has(providerId), + }); setModelOptions(options); } catch { // Silent: the picker surfaces an empty list rather than blocking the UI. @@ -2028,7 +2019,7 @@ export function SessionRoute() { return () => { cancelled = true; }; - }, [modelPickerOpen, opencodeBaseUrl, opencodeClient, recentProviderIds, selectedWorkspaceRoot]); + }, [cloudManagedModelIdsByProvider, modelPickerOpen, opencodeBaseUrl, opencodeClient, recentProviderIds, selectedWorkspaceRoot]); // Apply org-level restrictions (dev #1505) on top of the raw model list // so the picker never surfaces blocked options: @@ -2892,6 +2883,7 @@ export function SessionRoute() { client={opencodeClient} opencodeBaseUrl={opencodeBaseUrl} selectedWorkspaceRoot={selectedWorkspaceRoot} + cloudManagedModelIdsByProvider={cloudManagedModelIdsByProvider} > {opencodeClient && selectedWorkspaceEndpoint && opencodeBaseUrl && selectedWorkspaceServerToken ? ( selectedWorkspace ? { + ...selectedWorkspace, id: selectedWorkspace.id, name: selectedWorkspace.name ?? selectedWorkspace.displayNameResolved, path: selectedWorkspace.path ?? "", @@ -819,12 +820,12 @@ 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) || - hasOpenWorkModelsProvider(providerConnectedIds); - const showOpenWorkModelsSubscribe = !openWorkModelsConnected && !openWorkModelsPromoHidden; - const showOpenWorkModelsConnect = !openWorkModelsConnected && openWorkModelsPromoHidden; + const showOpenWorkModelsSubscribe = (!cloudSession.isSignedIn || !hasOpenWorkCloudProvider) && !openWorkModelsPromoHidden; useEffect(() => { const handlePromoChanged = () => setOpenWorkModelsPromoHidden(isOpenWorkModelsPromoHidden()); @@ -925,7 +926,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; @@ -1211,28 +1216,11 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { } catch { seenIds = new Set(); } - const options: ModelOption[] = []; - for (const provider of getConnectedProviderItems(data)) { - const modelIds = Object.keys(provider.models); - const isNew = !seenIds.has(provider.id); - for (const id of modelIds) { - const model = provider.models[id]; - options.push({ - providerID: provider.id, - modelID: id, - title: model.name || id, - description: provider.name, - behaviorTitle: "Reasoning", - behaviorLabel: "Default", - behaviorDescription: "", - behaviorValue: null, - isFree: false, - isConnected: true, - isRecommended: isNew, - source: /^lpr_/i.test(provider.id) ? "cloud" as const : undefined, - }); - } - } + const options = buildCloudManagedModelOptions({ + providers: getConnectedProviderItems(data), + cloudManagedModelIdsByProvider, + isRecommendedProvider: (providerId) => !seenIds.has(providerId), + }); setModelOptions(options); } catch (error) { toast.error( @@ -1245,7 +1233,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { return () => { cancelled = true; }; - }, [modelPickerOpen, opencodeBaseUrl, opencodeClient, selectedWorkspaceRoot]); + }, [cloudManagedModelIdsByProvider, modelPickerOpen, opencodeBaseUrl, opencodeClient, selectedWorkspaceRoot]); useEffect(() => { local.setUi((previous) => ({ ...previous, view: "settings", tab: route.tab })); @@ -1601,10 +1589,6 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { // the settings route owns the provider-auth store. useCloudProviderAutoSync(providerAuthStore.runCloudProviderSync); - // Keep the Den cloud MCP configured with a fresh first-party token while - // signed in: connects on sign-in, re-mints on org switch and before expiry. - useCloudProviderAutoSync(() => connectionsStore.syncCloudControlMcp()); - useEffect(() => { if (route.tab !== "cloud-providers") return; void providerAuthStore.runCloudProviderSync("settings_cloud_opened"); @@ -1974,6 +1958,11 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { const handleCreateRemoteWorkspace = async (input: { openworkHostUrl?: string | null; openworkToken?: string | null; + openworkClientToken?: string | null; + openworkHostToken?: string | null; + openworkDenBaseUrl?: string | null; + openworkDenOrgId?: string | null; + openworkDenWorkerId?: string | null; directory?: string | null; displayName?: string | null; }) => { @@ -1987,6 +1976,11 @@ 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, + openworkDenOrgId: input.openworkDenOrgId?.trim() || null, + openworkDenWorkerId: input.openworkDenWorkerId?.trim() || null, displayName: input.displayName?.trim() || null, directory: input.directory?.trim() || null, remoteType, @@ -2120,7 +2114,6 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { Object.values(providerAuthSnapshot.importedCloudProviders ?? {}).map((p) => p.providerId) )} showOpenWorkModelsSubscribe={showOpenWorkModelsSubscribe} - showOpenWorkModelsConnect={showOpenWorkModelsConnect} onSubscribeOpenWorkModels={subscribeToOpenWorkModels} onDismissOpenWorkModels={dismissOpenWorkModelsPromo} cloudProvidersView={ @@ -2207,7 +2200,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { mcpConnectingName={connectionsSnapshot.mcpConnectingName} selectedMcp={connectionsSnapshot.selectedMcp} setSelectedMcp={(name) => connectionsStore.setSelectedMcp(name)} - quickConnect={extensionItems.quickConnectEntries} + quickConnect={extensionItems.installedMcpEntries} enablementContext={enablementContext} builtInExtensionsDisabled={builtInExtensionsDisabled} connectMcp={(entry) => { @@ -2281,7 +2274,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { case "cloud-workers": return ( false} + connectRemoteWorkspace={handleCreateRemoteWorkspace} onOpenAccount={openCloudAccountSettings} /> ); diff --git a/apps/app/src/react-app/shell/welcome-route.tsx b/apps/app/src/react-app/shell/welcome-route.tsx index 2eafdd78bd..af9d407f93 100644 --- a/apps/app/src/react-app/shell/welcome-route.tsx +++ b/apps/app/src/react-app/shell/welcome-route.tsx @@ -29,6 +29,7 @@ import { buildDenAuthUrl, readDenSettings } from "../../app/lib/den"; import { writeActiveWorkspaceId, writeLastSessionFor } from "./session-memory"; import { workspaceSessionRoute } from "./workspace-routes"; import { ensureDesktopLocalOpenworkConnection } from "./desktop-local-openwork"; +import { useDenAuth } from "../domains/cloud/den-auth-provider"; function folderNameFromPath(path: string) { const normalized = path.replace(/\\/g, "/").replace(/\/+$/, ""); @@ -110,15 +111,21 @@ export function WelcomeRoute() { const navigate = useNavigate(); const local = useLocal(); const platform = usePlatform(); + const denAuth = useDenAuth(); const [state, dispatch] = useReducer(welcomeReducer, initialWelcomeState); const [manualFolder, setManualFolder] = useState(""); - // If user already completed onboarding, redirect away immediately. + // Cloud-signed-in users should continue through org onboarding rather than + // the local workspace welcome flow. useEffect(() => { + if (denAuth.isSignedIn) { + navigate("/onboarding", { replace: true }); + return; + } if (local.prefs.hasCompletedOnboarding) { navigate("/session", { replace: true }); } - }, [local.prefs.hasCompletedOnboarding, navigate]); + }, [denAuth.isSignedIn, local.prefs.hasCompletedOnboarding, navigate]); const markOnboardingComplete = useCallback(() => { local.setPrefs((prev) => ({ ...prev, hasCompletedOnboarding: true })); diff --git a/apps/app/src/react-app/shell/workspace-provider.ts b/apps/app/src/react-app/shell/workspace-provider.ts index fc3af36762..78f8970205 100644 --- a/apps/app/src/react-app/shell/workspace-provider.ts +++ b/apps/app/src/react-app/shell/workspace-provider.ts @@ -6,6 +6,7 @@ type WorkspaceContextValue = { client: Client | null; opencodeBaseUrl: string; selectedWorkspaceRoot: string; + cloudManagedModelIdsByProvider: Map>; }; const WorkspaceContext = React.createContext(null); @@ -14,6 +15,7 @@ type WorkspaceProviderProps = { client: Client | null; opencodeBaseUrl?: string; selectedWorkspaceRoot: string; + cloudManagedModelIdsByProvider?: Map>; children: React.ReactNode; }; @@ -21,11 +23,17 @@ export function WorkspaceProvider({ client, opencodeBaseUrl = "", selectedWorkspaceRoot, + cloudManagedModelIdsByProvider, children, }: WorkspaceProviderProps) { const value = React.useMemo( - () => ({ client, opencodeBaseUrl, selectedWorkspaceRoot }), - [client, opencodeBaseUrl, selectedWorkspaceRoot], + () => ({ + client, + opencodeBaseUrl, + selectedWorkspaceRoot, + cloudManagedModelIdsByProvider: cloudManagedModelIdsByProvider ?? new Map>(), + }), + [client, cloudManagedModelIdsByProvider, opencodeBaseUrl, selectedWorkspaceRoot], ); return React.createElement(WorkspaceContext.Provider, { value }, children); diff --git a/apps/app/tests/den-managed-provider-sync.test.ts b/apps/app/tests/den-managed-provider-sync.test.ts new file mode 100644 index 0000000000..51adc79fef --- /dev/null +++ b/apps/app/tests/den-managed-provider-sync.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { createDenClient, DenApiError } from "../src/app/lib/den"; + +const originalFetch = globalThis.fetch; + +describe("Den managed provider worker sync client", () => { + afterEach(() => { + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: originalFetch, + }); + }); + + test("posts to the org-scoped worker sync endpoint", async () => { + const calls: Array<{ url: string; method: string; org: string | null; authorized: boolean; body: string | null }> = []; + const fetchMock: typeof fetch = async (input, init) => { + const headers = new Headers(init?.headers); + calls.push({ + url: String(input), + method: init?.method ?? "GET", + org: headers.get("x-openwork-legacy-org-id"), + authorized: headers.get("authorization") === "Bearer user-token", + body: typeof init?.body === "string" ? init.body : null, + }); + return new Response(JSON.stringify({ + status: "applied", + providerCount: 1, + revision: "safe-revision", + providerIds: ["lpr_applied"], + }), { + headers: { "Content-Type": "application/json" }, + status: 200, + }); + }; + + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: fetchMock, + }); + + const client = createDenClient({ baseUrl: "http://den.local", token: "user-token" }); + const result = await client.syncWorkerManagedProviders("org_test", "wrk_test"); + + expect(result).toEqual({ status: "applied", providerCount: 1, revision: "safe-revision", providerIds: ["lpr_applied"] }); + expect(calls).toEqual([{ + url: "http://den.local/v1/workers/wrk_test/managed-providers/sync", + method: "POST", + org: "org_test", + authorized: true, + body: "{}", + }]); + }); + + test("surfaces sanitized worker sync failures", async () => { + const secret = "sk-secret-value"; + const fetchMock: typeof fetch = async () => new Response(JSON.stringify({ + error: "managed_provider_sync_failed", + message: "Worker provider sync failed.", + details: { redacted: true }, + secret, + }), { + headers: { "Content-Type": "application/json" }, + status: 502, + }); + + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: fetchMock, + }); + + const client = createDenClient({ baseUrl: "http://den.local", token: "user-token" }); + + await expect(client.syncWorkerManagedProviders("org_test", "wrk_test")).rejects.toThrow("Worker provider sync failed."); + try { + await client.syncWorkerManagedProviders("org_test", "wrk_test"); + } catch (error) { + expect(error).toBeInstanceOf(DenApiError); + expect(error instanceof Error ? error.message.includes(secret) : true).toBe(false); + } + }); + + test("rejects sync payloads whose applied provider IDs do not match the provider count", async () => { + const fetchMock: typeof fetch = async () => new Response(JSON.stringify({ + status: "applied", + providerCount: 2, + revision: "mismatch-revision", + providerIds: ["lpr_only_one"], + }), { + headers: { "Content-Type": "application/json" }, + status: 200, + }); + + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: fetchMock, + }); + + const client = createDenClient({ baseUrl: "http://den.local", token: "user-token" }); + await expect(client.syncWorkerManagedProviders("org_test", "wrk_test")).rejects.toThrow("Managed provider sync response was invalid."); + }); +}); diff --git a/apps/app/tests/managed-provider-models.test.ts b/apps/app/tests/managed-provider-models.test.ts new file mode 100644 index 0000000000..3dbc991528 --- /dev/null +++ b/apps/app/tests/managed-provider-models.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from "bun:test"; + +import { + buildCloudManagedModelOptions, + buildCloudManagedModelIdsByProvider, + hasCloudManagedModelAllowlist, + isCloudManagedModelAllowed, +} from "../src/app/cloud/managed-provider-models"; +import type { CloudImportedProvider } from "../src/app/cloud/import-state"; +import type { ProviderListItem } from "../src/app/types"; + +function importedProvider(input: Pick): CloudImportedProvider { + return { + ...input, + source: "models_dev", + updatedAt: null, + importedAt: 1, + }; +} + +function visibleModelIds(providerId: string, modelIds: string[], allowlist: Map>) { + return modelIds.filter((modelId) => isCloudManagedModelAllowed(allowlist, providerId, modelId)); +} + +function provider(id: string, name: string, modelIds: string[]): ProviderListItem { + return { + id, + name, + source: "config", + models: Object.fromEntries(modelIds.map((modelId) => [modelId, { id: modelId, name: modelId }])), + }; +} + +function staleOpenAiModelIds(): string[] { + const explicit = [ + "gpt-5.4", + "gpt-5.5", + "gpt-5.5-pro", + "gpt-5.5-fast", + "text-embedding-3-large", + "gpt-4o", + "gpt-image-1-mini", + "gpt-5.4-fast", + "o4-mini", + ]; + const generated = Array.from({ length: 45 }, (_, index) => `stale-openai-catalog-${index + 1}`); + return [...explicit, ...generated]; +} + +describe("managed cloud provider model allowlists", () => { + test("session modal and compact select option builder filters 54 stale OpenAI models to selected IDs", () => { + const allowlist = buildCloudManagedModelIdsByProvider({ + lpr_openai: importedProvider({ + cloudProviderId: "lpr_openai", + providerId: "openai", + sourceProviderId: "openai", + name: "openAI_server", + modelIds: ["gpt-5.4", "gpt-5.5"], + }), + }); + + const rawOpenAiProviderListIds = staleOpenAiModelIds(); + + expect(rawOpenAiProviderListIds).toHaveLength(54); + expect(hasCloudManagedModelAllowlist(allowlist, "openai")).toBe(true); + expect(visibleModelIds("openai", rawOpenAiProviderListIds, allowlist)).toEqual(["gpt-5.4", "gpt-5.5"]); + expect(buildCloudManagedModelOptions({ + providers: [provider("openai", "openAI_server", rawOpenAiProviderListIds)], + cloudManagedModelIdsByProvider: allowlist, + isRecommendedProvider: (providerId) => providerId === "openai", + }).map((option) => ({ + providerID: option.providerID, + modelID: option.modelID, + source: option.source, + isRecommended: option.isRecommended, + }))).toEqual([ + { providerID: "openai", modelID: "gpt-5.4", source: "cloud", isRecommended: true }, + { providerID: "openai", modelID: "gpt-5.5", source: "cloud", isRecommended: true }, + ]); + }); + + test("keeps API-key NVIDIA managed provider selected IDs intact", () => { + const allowlist = buildCloudManagedModelIdsByProvider({ + lpr_nvidia: importedProvider({ + cloudProviderId: "lpr_nvidia", + providerId: "lpr_nvidia", + sourceProviderId: "nvidia", + name: "nvidia", + modelIds: ["deepseek-ai/deepseek-v4-flash", "google/gemma-4-31b-it"], + }), + }); + + expect(visibleModelIds("lpr_nvidia", ["deepseek-ai/deepseek-v4-flash", "google/gemma-4-31b-it"], allowlist)).toEqual([ + "deepseek-ai/deepseek-v4-flash", + "google/gemma-4-31b-it", + ]); + expect(buildCloudManagedModelOptions({ + providers: [provider("lpr_nvidia", "nvidia", ["deepseek-ai/deepseek-v4-flash", "google/gemma-4-31b-it"])], + cloudManagedModelIdsByProvider: allowlist, + }).map((option) => option.modelID)).toEqual([ + "deepseek-ai/deepseek-v4-flash", + "google/gemma-4-31b-it", + ]); + }); + + test("does not filter non-managed providers without imported model IDs", () => { + const allowlist = buildCloudManagedModelIdsByProvider({}); + + expect(visibleModelIds("anthropic", ["claude-sonnet", "claude-opus"], allowlist)).toEqual([ + "claude-sonnet", + "claude-opus", + ]); + }); + + test("merges duplicate imported provider model allowlists by provider ID", () => { + const allowlist = buildCloudManagedModelIdsByProvider({ + llmProvider_openai_one: importedProvider({ + cloudProviderId: "llmProvider_openai_one", + providerId: "openai", + sourceProviderId: "openai", + name: "OpenAI one", + modelIds: ["gpt-5.4"], + }), + llmProvider_openai_two: importedProvider({ + cloudProviderId: "llmProvider_openai_two", + providerId: "openai", + sourceProviderId: "openai", + name: "OpenAI two", + modelIds: ["gpt-5.5"], + }), + }); + + expect(visibleModelIds("openai", ["gpt-5.4", "gpt-5.5", "gpt-4o"], allowlist)).toEqual(["gpt-5.4", "gpt-5.5"]); + }); + + test("model picker options for OAuth-managed providers keep runtime provider IDs for defaults", () => { + const allowlist = buildCloudManagedModelIdsByProvider({ + lpr_den_openai: importedProvider({ + cloudProviderId: "lpr_den_openai", + providerId: "openai", + sourceProviderId: "openai", + name: "OpenAI from Den", + modelIds: ["gpt-5.5"], + }), + }); + + expect(buildCloudManagedModelOptions({ + providers: [provider("openai", "OpenAI", ["gpt-5.5"])], + cloudManagedModelIdsByProvider: allowlist, + }).map((option) => ({ providerID: option.providerID, modelID: option.modelID }))).toEqual([ + { providerID: "openai", modelID: "gpt-5.5" }, + ]); + }); +}); diff --git a/apps/app/tests/provider-auth-managed-providers.test.ts b/apps/app/tests/provider-auth-managed-providers.test.ts new file mode 100644 index 0000000000..38f73db0c1 --- /dev/null +++ b/apps/app/tests/provider-auth-managed-providers.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; + +import type { DenOrgLlmProvider } from "../src/app/lib/den"; +import { + getCloudManagedProviderId, + resolveAppliedManagedProvidersFromSyncResult, +} from "../src/react-app/domains/connections/provider-auth/store"; + +function provider(input: Partial & Pick): DenOrgLlmProvider { + return { + source: "models_dev", + credentialKind: "api_key", + name: input.providerId, + providerConfig: {}, + hasApiKey: true, + hasOpencodeAuth: false, + hasCredential: true, + models: [], + createdAt: null, + updatedAt: null, + ...input, + }; +} + +describe("cloud managed provider import identity", () => { + test("resolves runtime provider IDs for OAuth and OpenWork managed providers", () => { + expect(getCloudManagedProviderId(provider({ + id: "lpr_openai", + providerId: "openai", + credentialKind: "opencode_oauth", + }))).toBe("openai"); + + expect(getCloudManagedProviderId(provider({ + id: "lpr_openwork", + providerId: "openwork-cloud", + source: "openwork", + }))).toBe("openwork"); + + expect(getCloudManagedProviderId(provider({ + id: "lpr_nvidia", + providerId: "nvidia", + credentialKind: "api_key", + }))).toBe("lpr_nvidia"); + }); + + test("remote sync only records providers identified as applied by Den", () => { + const liveProviders = [ + provider({ id: "lpr_applied", providerId: "openai" }), + provider({ id: "lpr_filtered", providerId: "anthropic" }), + ]; + + expect(resolveAppliedManagedProvidersFromSyncResult({ + providerCount: 1, + providerIds: ["lpr_applied"], + }, liveProviders).map((entry) => entry.id)).toEqual(["lpr_applied"]); + }); + + test("remote sync clears imported state when Den applies an empty provider set", () => { + expect(resolveAppliedManagedProvidersFromSyncResult({ + providerCount: 0, + }, [provider({ id: "lpr_filtered", providerId: "anthropic" })])).toEqual([]); + }); + + test("remote sync refuses ambiguous partial results without applied provider IDs", () => { + expect(() => resolveAppliedManagedProvidersFromSyncResult({ + providerCount: 1, + }, [ + provider({ id: "lpr_applied", providerId: "openai" }), + provider({ id: "lpr_filtered", providerId: "anthropic" }), + ])).toThrow("did not identify which providers were applied"); + }); +}); diff --git a/apps/desktop/electron/bootstrap-config.mjs b/apps/desktop/electron/bootstrap-config.mjs new file mode 100644 index 0000000000..6ecab7abfe --- /dev/null +++ b/apps/desktop/electron/bootstrap-config.mjs @@ -0,0 +1,151 @@ +import os from "node:os"; +import path from "node:path"; + +export const DEFAULT_DEN_BASE_URL = "https://app.openworklabs.com"; + +export function envFlagEnabled(name, env = process.env) { + const value = env[name]?.trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes" || value === "on"; +} + +function configHomePath({ env = process.env, platform = process.platform, homedir = os.homedir() } = {}) { + if (env.XDG_CONFIG_HOME?.trim()) return env.XDG_CONFIG_HOME.trim(); + if (platform === "win32" && env.APPDATA?.trim()) return env.APPDATA.trim(); + return path.join(homedir, ".config"); +} + +export function managedDesktopBootstrapPath({ env = process.env, platform = process.platform } = {}) { + if (platform === "win32") { + const programData = env.ProgramData?.trim() || env.PROGRAMDATA?.trim() || "C:\\ProgramData"; + return path.join(programData, "OpenWork", "desktop-bootstrap.json"); + } + if (platform === "darwin") { + return path.join("/Library", "Application Support", "OpenWork", "desktop-bootstrap.json"); + } + return path.join("/etc", "openwork", "desktop-bootstrap.json"); +} + +export function userDesktopBootstrapPath(options = {}) { + return path.join(configHomePath(options), "openwork", "desktop-bootstrap.json"); +} + +export function legacyDevDesktopBootstrapPath({ homedir = os.homedir() } = {}) { + return path.join(homedir, ".config", "openwork", "desktop-bootstrap.json"); +} + +export function desktopBootstrapCandidates(options = {}) { + const { env = process.env } = options; + const candidates = []; + const envOverride = env.OPENWORK_DESKTOP_BOOTSTRAP_PATH?.trim(); + if (envOverride) { + candidates.push({ source: "env", path: envOverride }); + } + candidates.push( + { source: "managed", path: managedDesktopBootstrapPath(options) }, + { source: "user", path: userDesktopBootstrapPath(options) }, + ); + const legacyDevPath = legacyDevDesktopBootstrapPath(options); + if (!candidates.some((candidate) => candidate.path === legacyDevPath)) { + candidates.push({ source: "user-dev", path: legacyDevPath }); + } + return candidates; +} + +export function defaultDesktopBootstrapConfig({ env = process.env } = {}) { + return { + baseUrl: DEFAULT_DEN_BASE_URL, + apiBaseUrl: null, + requireSignin: envFlagEnabled("OPENWORK_FORCE_SIGNIN", env), + source: "default", + path: null, + }; +} + +export function normalizeDesktopBootstrapConfig(input, options = {}) { + const baseUrl = typeof input?.baseUrl === "string" ? input.baseUrl.trim() : ""; + if (!baseUrl) throw new Error("baseUrl is required"); + const apiBaseUrl = typeof input?.apiBaseUrl === "string" && input.apiBaseUrl.trim().length > 0 + ? input.apiBaseUrl.trim() + : null; + return { + baseUrl, + apiBaseUrl, + requireSignin: envFlagEnabled("OPENWORK_FORCE_SIGNIN", options.env) || input?.requireSignin === true, + }; +} + +export function normalizeUrlOrigin(input) { + const raw = String(input ?? "").trim(); + if (!raw) return ""; + try { + return new URL(raw).origin.replace(/\/+$/, "").toLowerCase(); + } catch { + return raw.replace(/\/+$/, "").toLowerCase(); + } +} + +export function isWorkspaceCompatibleWithManagedDen(workspace, denBaseUrl) { + if (workspace?.workspaceType !== "remote" || workspace?.remoteType !== "openwork") return true; + const activeDenOrigin = normalizeUrlOrigin(denBaseUrl); + if (!activeDenOrigin) return true; + const workspaceDenOrigin = normalizeUrlOrigin(workspace?.openworkDenBaseUrl); + // Legacy remote OpenWork records predate Den-origin metadata. Keep them in + // persisted desktop state so startup/filtering is non-destructive; only hide + // records that explicitly belong to a different Den origin. + if (!workspaceDenOrigin) return true; + return workspaceDenOrigin === activeDenOrigin; +} + +export function filterWorkspacesForManagedDen(workspaces, denBaseUrl) { + const input = Array.isArray(workspaces) ? workspaces : []; + return input.filter((workspace) => isWorkspaceCompatibleWithManagedDen(workspace, denBaseUrl)); +} + +const PERSISTED_WORKSPACES_FOR_WRITE = "__openworkPersistedWorkspacesForWrite"; + +export function runtimeWorkspaceStateForManagedDen(state, denBaseUrl) { + const persistedWorkspaces = Array.isArray(state?.workspaces) ? state.workspaces : []; + const runtimeState = { + ...state, + workspaces: filterWorkspacesForManagedDen(persistedWorkspaces, denBaseUrl), + }; + return attachPersistedWorkspacesForWrite(runtimeState, persistedWorkspaces); +} + +export function attachPersistedWorkspacesForWrite(state, persistedWorkspaces) { + Object.defineProperty(state, PERSISTED_WORKSPACES_FOR_WRITE, { + value: Array.isArray(persistedWorkspaces) ? persistedWorkspaces : [], + enumerable: false, + }); + return state; +} + +export function mergeWorkspaceListsPreservingHidden(persistedWorkspaces, runtimeWorkspaces) { + const output = Array.isArray(persistedWorkspaces) ? [...persistedWorkspaces] : []; + const indexById = new Map(); + output.forEach((workspace, index) => { + const workspaceId = String(workspace?.id ?? "").trim(); + if (workspaceId) indexById.set(workspaceId, index); + }); + for (const workspace of Array.isArray(runtimeWorkspaces) ? runtimeWorkspaces : []) { + const workspaceId = String(workspace?.id ?? "").trim(); + if (!workspaceId) { + output.push(workspace); + continue; + } + const existingIndex = indexById.get(workspaceId); + if (existingIndex === undefined) { + indexById.set(workspaceId, output.length); + output.push(workspace); + continue; + } + output[existingIndex] = workspace; + } + return output; +} + +export function persistedWorkspacesForRuntimeState(state) { + return Array.isArray(state?.[PERSISTED_WORKSPACES_FOR_WRITE]) + ? state[PERSISTED_WORKSPACES_FOR_WRITE] + : null; +} diff --git a/apps/desktop/electron/bootstrap-config.test.mjs b/apps/desktop/electron/bootstrap-config.test.mjs new file mode 100644 index 0000000000..4644272e48 --- /dev/null +++ b/apps/desktop/electron/bootstrap-config.test.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; + +import { + attachPersistedWorkspacesForWrite, + desktopBootstrapCandidates, + filterWorkspacesForManagedDen, + managedDesktopBootstrapPath, + mergeWorkspaceListsPreservingHidden, + normalizeDesktopBootstrapConfig, + persistedWorkspacesForRuntimeState, + runtimeWorkspaceStateForManagedDen, +} from "./bootstrap-config.mjs"; + +test("desktop bootstrap candidates use env, managed, user/dev, then defaults", () => { + const env = { + OPENWORK_DESKTOP_BOOTSTRAP_PATH: "D:\\managed\\override.json", + ProgramData: "C:\\ProgramData", + APPDATA: "C:\\Users\\Alice\\AppData\\Roaming", + }; + + const candidates = desktopBootstrapCandidates({ + env, + platform: "win32", + homedir: "C:\\Users\\Alice", + }); + + assert.deepEqual(candidates.map((candidate) => candidate.source), [ + "env", + "managed", + "user", + "user-dev", + ]); + assert.equal(candidates[0].path, env.OPENWORK_DESKTOP_BOOTSTRAP_PATH); + assert.equal(candidates[1].path, path.join("C:\\ProgramData", "OpenWork", "desktop-bootstrap.json")); + assert.equal(candidates[2].path, path.join("C:\\Users\\Alice\\AppData\\Roaming", "openwork", "desktop-bootstrap.json")); + assert.equal(candidates[3].path, path.join("C:\\Users\\Alice", ".config", "openwork", "desktop-bootstrap.json")); +}); + +test("windows managed bootstrap defaults to ProgramData without env override", () => { + assert.equal( + managedDesktopBootstrapPath({ env: {}, platform: "win32" }), + path.join("C:\\ProgramData", "OpenWork", "desktop-bootstrap.json"), + ); +}); + +test("normalize desktop bootstrap honors forced sign-in env", () => { + assert.deepEqual( + normalizeDesktopBootstrapConfig( + { baseUrl: " http://den.local:3005 ", apiBaseUrl: "", requireSignin: false }, + { env: { OPENWORK_FORCE_SIGNIN: "true" } }, + ), + { baseUrl: "http://den.local:3005", apiBaseUrl: null, requireSignin: true }, + ); +}); + +test("managed Den filtering keeps legacy remote OpenWork workspaces non-destructively", () => { + const workspaces = [ + { id: "local", workspaceType: "local" }, + { id: "legacy", workspaceType: "remote", remoteType: "openwork", openworkHostUrl: "http://old-worker:8787" }, + { id: "wrong-den", workspaceType: "remote", remoteType: "openwork", openworkDenBaseUrl: "http://old-den:3005" }, + { id: "current-den", workspaceType: "remote", remoteType: "openwork", openworkDenBaseUrl: "http://den.company.local:3005/api/den" }, + { id: "other-remote", workspaceType: "remote", remoteType: "opencode" }, + ]; + + assert.deepEqual( + filterWorkspacesForManagedDen(workspaces, "http://den.company.local:3005").map((workspace) => workspace.id), + ["local", "legacy", "current-den", "other-remote"], + ); +}); + +test("managed Den runtime state hides incompatible workspaces while preserving persistence input", () => { + const persistedWorkspaces = [ + { id: "wrong-den", workspaceType: "remote", remoteType: "openwork", openworkDenBaseUrl: "http://old-den:3005" }, + { id: "current-den", workspaceType: "remote", remoteType: "openwork", openworkDenBaseUrl: "http://den.company.local:3005" }, + { id: "local", workspaceType: "local", path: "/repo" }, + ]; + + const runtimeState = runtimeWorkspaceStateForManagedDen( + { selectedId: "current-den", workspaces: persistedWorkspaces }, + "http://den.company.local:3005/api/den", + ); + + assert.deepEqual(runtimeState.workspaces.map((workspace) => workspace.id), ["current-den", "local"]); + assert.deepEqual(persistedWorkspacesForRuntimeState(runtimeState), persistedWorkspaces); +}); + +test("managed Den writes merge compatible runtime edits without dropping hidden persisted entries", () => { + const persistedWorkspaces = [ + { id: "wrong-den", workspaceType: "remote", remoteType: "openwork", openworkDenBaseUrl: "http://old-den:3005" }, + { id: "current-den", workspaceType: "remote", remoteType: "openwork", openworkDenBaseUrl: "http://den.company.local:3005", name: "Before" }, + ]; + const runtimeState = attachPersistedWorkspacesForWrite( + { workspaces: [{ id: "current-den", workspaceType: "remote", remoteType: "openwork", openworkDenBaseUrl: "http://den.company.local:3005", name: "After" }] }, + persistedWorkspaces, + ); + + assert.deepEqual( + mergeWorkspaceListsPreservingHidden(persistedWorkspacesForRuntimeState(runtimeState), runtimeState.workspaces), + [ + { id: "wrong-den", workspaceType: "remote", remoteType: "openwork", openworkDenBaseUrl: "http://old-den:3005" }, + { id: "current-den", workspaceType: "remote", remoteType: "openwork", openworkDenBaseUrl: "http://den.company.local:3005", name: "After" }, + ], + ); +}); diff --git a/apps/desktop/electron/browser-mcp.mjs b/apps/desktop/electron/browser-mcp.mjs new file mode 100644 index 0000000000..76a8a4c3aa --- /dev/null +++ b/apps/desktop/electron/browser-mcp.mjs @@ -0,0 +1,377 @@ +/** + * In-process browser MCP servers. + * + * Two servers: + * 1. "openwork-browser" — controls the embedded WebContentsView using + * native Electron webContents APIs (no Puppeteer, no app-level CDP). + * 2. "chrome" — connects to the user's external Chrome via Puppeteer/CDP. + * + * Both are exposed as HTTP MCP endpoints that OpenCode connects to as + * remote MCP servers. + */ + +import { createServer } from "node:http"; +import { randomUUID } from "node:crypto"; + +// ── Native built-in browser server ──────────────────────────────────── +import { createNativeBuiltinServer } from "./browser-native-tools.mjs"; + +// ── Chrome DevTools MCP internals (for EXTERNAL Chrome only) ────────── +// IMPORTANT: never import main.js — it runs parseArguments at module load. +import "chrome-devtools-mcp/build/src/polyfill.js"; + +import { + McpServer, + SetLevelRequestSchema, + puppeteer, +} from "chrome-devtools-mcp/build/src/third_party/index.js"; + +import { tools as chromeDevtoolsTools } from "chrome-devtools-mcp/build/src/tools/tools.js"; +import { McpContext } from "chrome-devtools-mcp/build/src/McpContext.js"; +import { McpResponse } from "chrome-devtools-mcp/build/src/McpResponse.js"; +import { Mutex } from "chrome-devtools-mcp/build/src/Mutex.js"; + +// MCP SDK HTTP transport — works with the same McpServer +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; + +// ── Helpers ──────────────────────────────────────────────────────────── + +function noop() {} + +/** Wrap a promise with a timeout. Rejects with a descriptive error. */ +function withTimeout(promise, ms, label) { + let timer; + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label}: timed out after ${ms}ms`)), ms); + }), + ]).finally(() => clearTimeout(timer)); +} + +/** + * Target filter for the EXTERNAL Chrome server — accept all normal pages, + * skip chrome:// and extension pages. + */ +const EXTERNAL_TARGET_FILTER = (target) => { + const url = target.url(); + if (url === "chrome://newtab/") return true; + if (url.startsWith("chrome://") || url.startsWith("chrome-extension://")) return false; + return true; +}; + +async function connectExternalBrowser(browserURL) { + return withTimeout( + puppeteer.connect({ + browserURL, + targetFilter: EXTERNAL_TARGET_FILTER, + defaultViewport: null, + }), + 10_000, + "connectExternalBrowser", + ); +} + +/** + * Create an MCP server backed by chrome-devtools-mcp tools. + * Used ONLY for the external Chrome server. + */ +function createExternalChromeServer({ getBrowser }) { + const server = new McpServer( + { name: "chrome", version: "0.1.0" }, + { capabilities: { logging: {} } }, + ); + + server.server.setRequestHandler(SetLevelRequestSchema, () => ({})); + + const mutex = new Mutex(); + let context = null; + let lastBrowser = null; + + async function getContext() { + const browser = await getBrowser(); + if (!browser?.connected) { + throw new Error("Browser not connected for chrome"); + } + if (browser !== lastBrowser) { + lastBrowser = browser; + context = await McpContext.from(browser, noop, { + experimentalDevToolsDebugging: false, + experimentalIncludeAllPages: false, + performanceCrux: false, + }); + } + return context; + } + + for (const tool of chromeDevtoolsTools) { + server.tool( + tool.name, + tool.description, + tool.schema, + async (params) => { + const guard = await mutex.acquire(); + try { + const ctx = await getContext(); + const response = new McpResponse(); + const TOOL_TIMEOUT = 30_000; + await withTimeout( + tool.handler({ params }, response, ctx), + TOOL_TIMEOUT, + `chrome/${tool.name}`, + ); + const { content } = await response.handle(tool.name, ctx); + return { content }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text", text: `Error: ${msg}` }] }; + } finally { + guard.dispose(); + } + }, + ); + } + + return server; +} + +// ── HTTP wrappers ────────────────────────────────────────────────────── + +/** + * Start an MCP-over-HTTP server on a random localhost port. + * + * Uses one StreamableHTTPServerTransport per session. Each new session + * (no mcp-session-id header) gets its own transport + server instance + * created by the factory. + * + * Returns { port, close }. + */ +async function startMcpHttpServer(mcpServerFactory, preferredPort = 0) { + const sessions = new Map(); + + const httpServer = createServer(async (req, res) => { + try { + const url = new URL(req.url ?? "/", `http://127.0.0.1`); + + if (req.method === "GET" && url.pathname === "/health") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + return; + } + + if (url.pathname !== "/mcp") { + res.writeHead(404); + res.end("Not found"); + return; + } + + const sessionId = req.headers["mcp-session-id"]; + + if (req.method === "POST") { + // Existing session + if (sessionId && sessions.has(sessionId)) { + const transport = sessions.get(sessionId); + await transport.handleRequest(req, res); + return; + } + + // New session — create a fresh transport + server + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id) => { + sessions.set(id, transport); + }, + }); + const server = mcpServerFactory(); + await server.connect(transport); + await transport.handleRequest(req, res); + return; + } + + if (req.method === "GET") { + if (sessionId && sessions.has(sessionId)) { + await sessions.get(sessionId).handleRequest(req, res); + return; + } + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "No session. Send a POST first." })); + return; + } + + if (req.method === "DELETE") { + if (sessionId && sessions.has(sessionId)) { + const transport = sessions.get(sessionId); + sessions.delete(sessionId); + await transport.close(); + } + res.writeHead(200); + res.end(); + return; + } + + res.writeHead(405); + res.end("Method not allowed"); + } catch (err) { + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) })); + } + } + }); + + async function listen(portToTry) { + return new Promise((resolve, reject) => { + httpServer.once("error", reject); + httpServer.listen(portToTry, "127.0.0.1", () => { + const address = httpServer.address(); + resolve(typeof address === "object" && address ? address.port : portToTry); + }); + }); + } + + let port; + try { + port = await listen(preferredPort); + } catch (error) { + if (!preferredPort || error?.code !== "EADDRINUSE") throw error; + port = await listen(0); + } + + return { + port, + close: () => new Promise((resolve) => httpServer.close(resolve)), + }; +} + +// ── Public API ───────────────────────────────────────────────────────── + +/** + * Boot both MCP servers. + * + * @param {object} opts + * @param {Function} opts.getWebContents — () => WebContents | null (active built-in browser tab) + * @param {Function} opts.listTabs — () => BrowserTabInfo[] + * @param {Function} opts.createTab — (url?: string) => tabId + * @param {Function} opts.closeTab — (tabId: string) => tabId | null + * @param {Function} opts.selectTab — (tabId: string) => tabId + * @param {Function} opts.onBuiltinToolCall — called before each built-in browser tool (opens panel) + * @param {Function} opts.onHideBrowser — called to close the browser panel + * @returns {Promise<{ builtinPort: number, externalPort: number, _snapshotReset: () => void, stop: () => Promise }>} + */ +export async function startBrowserMcpServers({ + getWebContents, + listTabs, + createTab, + closeTab, + selectTab, + onBuiltinToolCall, + onHideBrowser, +}) { + let externalBrowser = null; + + // ── Built-in browser: native Electron APIs ──────────────────────── + let builtinSnapshotReset = null; + function createBuiltinFactory() { + const srv = createNativeBuiltinServer({ + getWebContents, + listTabs, + createTab, + closeTab, + selectTab, + onToolCall: onBuiltinToolCall, + onHideBrowser, + }); + builtinSnapshotReset = /** @type {any} */ (srv)._snapshotReset; + return srv; + } + + // ── External Chrome: Puppeteer + CDP (unchanged) ────────────────── + + async function probeExternalChrome() { + for (const port of [9222, 9229]) { + try { + const res = await fetch(`http://127.0.0.1:${port}/json/version`, { + signal: AbortSignal.timeout(2000), + }); + if (res.ok) return { connected: true, port }; + } catch { /* not available */ } + } + return { connected: false, port: null }; + } + + function createExternalFactory() { + const server = createExternalChromeServer({ + getBrowser: async () => { + if (!externalBrowser?.connected) { + for (const port of [9222, 9229]) { + try { + externalBrowser = await connectExternalBrowser(`http://127.0.0.1:${port}`); + return externalBrowser; + } catch { /* not available */ } + } + throw new Error( + "Chrome is not reachable. " + + "Enable remote debugging in your Chrome: go to chrome://inspect/#remote-debugging and turn it on. " + + "No restart needed on Chrome 144+." + ); + } + return externalBrowser; + }, + }); + + // Diagnostic tool — lets the agent check Chrome availability before + // attempting browsing, so it can guide the user instead of failing. + server.tool( + "chrome_status", + "Check whether the user's real Chrome browser is reachable via remote " + + "debugging. Call this BEFORE using any other chrome tool. If status is " + + "unavailable, tell the user to enable remote debugging in Chrome: " + + "chrome://inspect/#remote-debugging → enable → allow connections. " + + "No Chrome restart is needed on Chrome 144+.", + {}, + async () => { + const probe = await probeExternalChrome(); + if (probe.connected) { + return { + content: [{ + type: "text", + text: JSON.stringify({ + connected: true, + port: probe.port, + hint: "Chrome is reachable. You can now use chrome tools to control the user's browser.", + }), + }], + }; + } + return { + content: [{ + type: "text", + text: JSON.stringify({ + connected: false, + port: null, + hint: "Chrome is not reachable. Ask the user to enable remote debugging: " + + "open chrome://inspect/#remote-debugging in Chrome, enable it, and allow " + + "incoming connections. No restart needed on Chrome 144+. " + + "Alternatively, offer to use the built-in openwork-browser instead.", + }), + }], + }; + }, + ); + + return server; + } + + const builtin = await startMcpHttpServer(createBuiltinFactory, 64883); + const external = await startMcpHttpServer(createExternalFactory, 64884); + + return { + builtinPort: builtin.port, + externalPort: external.port, + _snapshotReset: () => builtinSnapshotReset?.(), + async stop() { + await Promise.all([builtin.close(), external.close()]); + try { externalBrowser?.disconnect(); } catch {} + }, + }; +} diff --git a/apps/desktop/electron/browser-native-tools.mjs b/apps/desktop/electron/browser-native-tools.mjs new file mode 100644 index 0000000000..69c09add0c --- /dev/null +++ b/apps/desktop/electron/browser-native-tools.mjs @@ -0,0 +1,918 @@ +/** + * Native Electron MCP server for the built-in WebContentsView. + * + * Replaces Puppeteer-over-CDP with direct webContents APIs. + * Minimal CDP is used via webContents.debugger for: + * - Accessibility tree snapshots (Accessibility.getFullAXTree) + * - DOM node resolution for uid-based click/fill (DOM.resolveNode) + * - Input dispatch for drag/key operations (Input.dispatch*) + * - Emulation overrides (Emulation.*) + * + * Everything else uses Electron's native webContents methods: + * - loadURL(), goBack(), goForward(), reload() + * - capturePage() + * - executeJavaScript() + */ + +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Import MCP SDK + zod directly — no chrome-devtools-mcp dependency. +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +export const SCREENSHOT_FORMATS = ["png", "jpeg"]; + +export function evaluateScriptCallFunctionOptions(functionDeclaration, argObjectIds) { + return { + objectId: argObjectIds[0], + functionDeclaration: `function(...args) { + const fn = (${functionDeclaration}); + return fn.apply(args[0] ?? this, args); + }`, + arguments: argObjectIds.map((objectId) => ({ objectId })), + returnByValue: true, + }; +} + +// ── Snapshot manager ────────────────────────────────────────────────── +// +// Manages the a11y tree snapshot and uid→backendDOMNodeId mapping. +// Uses webContents.debugger for CDP Accessibility calls (scoped to +// this single WebContentsView, no app-level --remote-debugging-port). + +class NativeSnapshot { + #getWebContents; + #nodes = new Map(); // uid → node data + #snapshotCounter = 0; + #stableIdMap = new Map(); // backendDOMNodeId → uid (stable across snapshots) + #debuggerReady = false; + #attachedWebContents = null; + + constructor(getWebContents) { + this.#getWebContents = getWebContents; + } + + #ensureDebugger() { + const wc = this.#getWebContents(); + if (!wc || wc.isDestroyed()) throw new Error("No browser page available."); + if (this.#attachedWebContents && this.#attachedWebContents !== wc) { + try { this.#attachedWebContents.debugger?.detach(); } catch { /* ok */ } + this.#debuggerReady = false; + this.#attachedWebContents = null; + } + if (!this.#debuggerReady) { + try { + wc.debugger.attach("1.3"); + } catch { + // Already attached — fine + } + this.#debuggerReady = true; + this.#attachedWebContents = wc; + wc.once("destroyed", () => { + if (this.#attachedWebContents === wc) this.#attachedWebContents = null; + this.#debuggerReady = false; + }); + } + return wc; + } + + async take(verbose = false) { + const wc = this.#ensureDebugger(); + await wc.debugger.sendCommand("Accessibility.enable"); + const { nodes: rawNodes } = await wc.debugger.sendCommand( + "Accessibility.getFullAXTree", + ); + + // Build a lookup from CDP nodeId → raw node + const cdpById = new Map(); + for (const n of rawNodes) cdpById.set(n.nodeId, n); + + this.#snapshotCounter++; + const sid = this.#snapshotCounter; + let counter = 0; + this.#nodes.clear(); + const seenBackendIds = new Set(); + + const processNode = (cdpNode) => { + const bid = cdpNode.backendDOMNodeId; + const bidKey = String(bid ?? ""); + + // Re-use stable uid when the same DOM node appears across snapshots + let uid; + if (bidKey && this.#stableIdMap.has(bidKey)) { + uid = this.#stableIdMap.get(bidKey); + } else { + uid = `${sid}_${counter++}`; + if (bidKey) this.#stableIdMap.set(bidKey, uid); + } + if (bidKey) seenBackendIds.add(bidKey); + + const role = cdpNode.role?.value ?? ""; + const name = cdpNode.name?.value ?? ""; + const value = cdpNode.value?.value; + const ignored = cdpNode.ignored ?? false; + + // Extract meaningful properties + const props = {}; + for (const p of cdpNode.properties ?? []) { + if (p.value?.value !== undefined) props[p.name] = p.value.value; + } + + const children = (cdpNode.childIds ?? []) + .map((id) => cdpById.get(id)) + .filter(Boolean) + .map(processNode); + + const node = { uid, role, name, value, ignored, backendDOMNodeId: bid, props, children }; + this.#nodes.set(uid, node); + return node; + }; + + if (!rawNodes[0]) return "Empty page — no accessibility tree."; + const root = processNode(rawNodes[0]); + + // Prune stale mappings + for (const key of this.#stableIdMap.keys()) { + if (!seenBackendIds.has(key)) this.#stableIdMap.delete(key); + } + + return this.#format(root, verbose); + } + + #format(node, verbose, depth = 0) { + if (!node) return ""; + if ((node.ignored || node.role === "none") && !verbose) { + return node.children.map((c) => this.#format(c, verbose, depth)).join(""); + } + + const indent = " ".repeat(depth); + const parts = [`uid=${node.uid}`]; + if (node.role) parts.push(node.role === "none" ? "ignored" : node.role); + if (node.name) parts.push(`"${node.name}"`); + if (node.value !== undefined) parts.push(`value="${node.value}"`); + + for (const [k, v] of Object.entries(node.props)) { + if (typeof v === "boolean" && v) parts.push(k); + else if (typeof v === "string" || typeof v === "number") parts.push(`${k}="${v}"`); + } + + const lines = [indent + parts.join(" ")]; + for (const child of node.children) { + const s = this.#format(child, verbose, depth + 1); + if (s) lines.push(s); + } + return lines.join("\n"); + } + + /** Resolve a snapshot uid to a CDP RemoteObject objectId. */ + async resolveElement(uid) { + if (!this.#nodes.size) { + throw new Error("No snapshot found. Use take_snapshot to capture one."); + } + const node = this.#nodes.get(uid); + if (!node) throw new Error(`No such element found in the snapshot (uid: ${uid}).`); + if (!node.backendDOMNodeId) { + throw new Error(`Element "${uid}" (${node.role}) has no backing DOM node.`); + } + + const wc = this.#ensureDebugger(); + const { object } = await wc.debugger.sendCommand("DOM.resolveNode", { + backendNodeId: node.backendDOMNodeId, + }); + if (!object?.objectId) { + throw new Error(`Element "${uid}" no longer exists on the page.`); + } + return object.objectId; + } + + /** Get node data for a uid (used by upload_file for backendDOMNodeId). */ + getNodeData(uid) { + return this.#nodes.get(uid); + } + + /** Reset snapshot state. Call when the WebContentsView is destroyed. */ + reset() { + try { this.#attachedWebContents?.debugger?.detach(); } catch { /* ok */ } + this.#debuggerReady = false; + this.#attachedWebContents = null; + this.#nodes.clear(); + this.#stableIdMap.clear(); + } +} + +// ── MCP server factory ──────────────────────────────────────────────── + +/** + * Create an MCP server for the built-in browser using native Electron APIs. + * + * @param {object} opts + * @param {Function} opts.getWebContents - () => active webContents | null + * @param {Function} [opts.listTabs] - () => browser tab info[] + * @param {Function} [opts.createTab] - (url?: string) => tabId + * @param {Function} [opts.closeTab] - (tabId: string) => tabId | null + * @param {Function} [opts.selectTab] - (tabId: string) => tabId + * @param {Function} [opts.onToolCall] - called before each tool + * @param {Function} [opts.onHideBrowser] - called to close the browser panel + * @returns {McpServer} + */ +export function createNativeBuiltinServer({ + getWebContents, + listTabs, + createTab, + closeTab, + selectTab, + onToolCall, + onHideBrowser, +}) { + const server = new McpServer( + { name: "openwork-browser", version: "0.2.0" }, + { capabilities: { logging: {} } }, + ); + + const snap = new NativeSnapshot(getWebContents); + + // Expose reset so main.mjs can call it when the view is destroyed + /** @type {any} */ (server)._snapshotReset = () => snap.reset(); + + function wc() { + const c = getWebContents(); + if (!c || c.isDestroyed()) throw new Error("Built-in browser is not open."); + return c; + } + + function tabs() { + return typeof listTabs === "function" ? listTabs() : []; + } + + function resolveTabId(pageId) { + const availableTabs = tabs(); + if (typeof pageId === "number") { + return availableTabs[pageId - 1]?.tabId ?? null; + } + const id = String(pageId ?? "").trim(); + return availableTabs.some((tab) => tab.tabId === id) ? id : null; + } + + /** Navigate and wait for the page to load. Simple event-based wait — + * the about:blank preload in createBrowserView prevents session-restore races. */ + function navigateAndWait(webContents, url, timeoutMs = 30_000) { + return new Promise((resolve) => { + const timer = setTimeout(resolve, timeoutMs); + const done = () => { clearTimeout(timer); resolve(); }; + webContents.once("did-finish-load", done); + webContents.once("did-fail-load", done); + webContents.loadURL(url); + }); + } + + /** Wait for a navigation action (back/forward/reload) to complete. + * Rejects on timeout so the caller reports the failure honestly. */ + function waitForNav(webContents, timeoutMs = 30_000) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Navigation timed out")), timeoutMs); + const done = () => { clearTimeout(timer); resolve(); }; + webContents.once("did-finish-load", done); + webContents.once("did-fail-load", done); + }); + } + + // Helper: run a tool body inside an error boundary + function defineTool(name, description, schema, handler) { + server.tool(name, description, schema, async (params) => { + try { + await onToolCall?.(name, params); + return await handler(params); + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message ?? err}` }] }; + } + }); + } + + // ── Navigation ──────────────────────────────────────────────────── + + defineTool( + "navigate_page", + "Go to a URL, or back, forward, or reload.", + { + url: z.string().optional().describe("Target URL (only type=url)"), + type: z.enum(["url", "back", "forward", "reload"]).optional() + .describe("Navigate by URL, back/forward in history, or reload."), + timeout: z.number().int().optional() + .describe("Maximum wait time in milliseconds. Default: 30000"), + ignoreCache: z.boolean().optional() + .describe("Whether to ignore cache on reload."), + }, + async (params) => { + const w = wc(); + const type = params.type ?? "url"; + const timeout = params.timeout ?? 30_000; + + if (type === "url") { + const url = String(params.url ?? "").trim(); + if (!url) throw new Error("navigate_page requires a url for type=url"); + await navigateAndWait(w, url, timeout); + } else if (type === "back") { + if (w.navigationHistory?.canGoBack?.() ?? w.canGoBack()) { + const p = waitForNav(w, timeout); + w.goBack(); + await p; + } + } else if (type === "forward") { + if (w.navigationHistory?.canGoForward?.() ?? w.canGoForward()) { + const p = waitForNav(w, timeout); + w.goForward(); + await p; + } + } else if (type === "reload") { + const p = waitForNav(w, timeout); + params.ignoreCache ? w.reloadIgnoringCache() : w.reload(); + await p; + } + + return { content: [{ type: "text", text: `Navigated to ${w.getURL()}` }] }; + }, + ); + + // ── Snapshot ────────────────────────────────────────────────────── + + defineTool( + "take_snapshot", + "Take a text snapshot of the currently selected page based on the a11y tree. " + + "The snapshot lists page elements along with a unique identifier (uid). " + + "Always use the latest snapshot. Prefer taking a snapshot over taking a screenshot.", + { + verbose: z.boolean().optional() + .describe("Include all possible information in the full a11y tree. Default: false."), + filePath: z.string().optional() + .describe("Save snapshot to this path instead of returning inline."), + }, + async (params) => { + const text = await snap.take(params.verbose ?? false); + if (params.filePath) { + await writeFile(params.filePath, text, "utf8"); + return { content: [{ type: "text", text: `Saved snapshot to ${params.filePath}.` }] }; + } + return { content: [{ type: "text", text: "## Latest page snapshot\n" + text }] }; + }, + ); + + // ── Screenshot ──────────────────────────────────────────────────── + + defineTool( + "take_screenshot", + "Take a screenshot of the page or element.", + { + format: z.enum(SCREENSHOT_FORMATS).default("png") + .describe('Format. Default: "png"'), + quality: z.number().min(0).max(100).optional() + .describe("JPEG quality (0-100). Ignored for PNG."), + uid: z.string().optional() + .describe("Element uid from snapshot. Omit for page screenshot."), + fullPage: z.boolean().optional() + .describe("Full scrollable page screenshot. Incompatible with uid."), + filePath: z.string().optional() + .describe("Save screenshot to this path instead of returning inline."), + }, + async (params) => { + const w = wc(); + if (params.uid && params.fullPage) throw new Error('Cannot use both "uid" and "fullPage".'); + + let imageBuffer; + const fmt = params.format ?? "png"; + + if (params.uid) { + // Element screenshot via bounding rect — clamp to viewport + const objectId = await snap.resolveElement(params.uid); + const { result } = await w.debugger.sendCommand("Runtime.callFunctionOn", { + objectId, + functionDeclaration: `function() { + this.scrollIntoViewIfNeeded(); + const r = this.getBoundingClientRect(); + return JSON.stringify({ + x: Math.max(0, Math.round(r.x)), + y: Math.max(0, Math.round(r.y)), + width: Math.round(Math.min(r.width, window.innerWidth - Math.max(0, r.x))), + height: Math.round(Math.min(r.height, window.innerHeight - Math.max(0, r.y))) + }); + }`, + returnByValue: true, + }); + const rect = JSON.parse(result.value); + if (rect.width > 0 && rect.height > 0) { + const img = await w.capturePage(rect); + imageBuffer = fmt === "jpeg" ? img.toJPEG(params.quality ?? 80) : img.toPNG(); + } else { + // Element not visible — fall back to viewport screenshot + const img = await w.capturePage(); + imageBuffer = fmt === "jpeg" ? img.toJPEG(params.quality ?? 80) : img.toPNG(); + } + } else { + const img = await w.capturePage(); + imageBuffer = fmt === "jpeg" ? img.toJPEG(params.quality ?? 80) : img.toPNG(); + } + + if (params.filePath) { + await writeFile(params.filePath, imageBuffer); + return { content: [{ type: "text", text: `Screenshot saved to ${params.filePath}.` }] }; + } + if (imageBuffer.length >= 2_000_000) { + const p = join(tmpdir(), `openwork-ss-${Date.now()}.${fmt}`); + await writeFile(p, imageBuffer); + return { content: [{ type: "text", text: `Screenshot saved to ${p} (${(imageBuffer.length / 1024) | 0} KB).` }] }; + } + return { content: [{ type: "image", mimeType: `image/${fmt}`, data: imageBuffer.toString("base64") }] }; + }, + ); + + // ── Click ───────────────────────────────────────────────────────── + + defineTool( + "click", + "Clicks on the provided element.", + { + uid: z.string().describe("Element uid from page snapshot"), + dblClick: z.boolean().optional().describe("Double click. Default: false."), + includeSnapshot: z.boolean().optional().describe("Include snapshot in response. Default: false."), + }, + async (params) => { + const objectId = await snap.resolveElement(params.uid); + const w = wc(); + await w.debugger.sendCommand("Runtime.callFunctionOn", { + objectId, + functionDeclaration: `function(dbl) { + this.scrollIntoViewIfNeeded(); + this.click(); + if (dbl) this.click(); + }`, + arguments: [{ value: !!params.dblClick }], + }); + const text = params.dblClick ? "Successfully double clicked on the element" : "Successfully clicked on the element"; + if (params.includeSnapshot) { + return { content: [{ type: "text", text }, { type: "text", text: await snap.take(false) }] }; + } + return { content: [{ type: "text", text }] }; + }, + ); + + // ── Hover ───────────────────────────────────────────────────────── + + defineTool( + "hover", + "Hover over the provided element.", + { + uid: z.string().describe("Element uid from page snapshot"), + includeSnapshot: z.boolean().optional(), + }, + async (params) => { + const objectId = await snap.resolveElement(params.uid); + const w = wc(); + await w.debugger.sendCommand("Runtime.callFunctionOn", { + objectId, + functionDeclaration: `function() { + this.scrollIntoViewIfNeeded(); + this.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); + this.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + }`, + }); + const text = "Successfully hovered over the element"; + if (params.includeSnapshot) { + return { content: [{ type: "text", text }, { type: "text", text: await snap.take(false) }] }; + } + return { content: [{ type: "text", text }] }; + }, + ); + + // ── Fill ────────────────────────────────────────────────────────── + + const FILL_FN = `function(val) { + this.scrollIntoViewIfNeeded(); + this.focus(); + if (this.tagName === 'SELECT') { + const opt = Array.from(this.options).find(o => o.text === val || o.value === val); + if (opt) this.value = opt.value; else this.value = val; + } else { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set + || Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set; + if (setter) setter.call(this, val); else this.value = val; + } + this.dispatchEvent(new Event('input', { bubbles: true })); + this.dispatchEvent(new Event('change', { bubbles: true })); + }`; + + defineTool( + "fill", + "Type text into an input, text area, or select an option from a