diff --git a/apps/app/src/app/lib/den.ts b/apps/app/src/app/lib/den.ts index 7d07ab3c5..71f3d7e61 100644 --- a/apps/app/src/app/lib/den.ts +++ b/apps/app/src/app/lib/den.ts @@ -111,6 +111,15 @@ export type DenWorkerTokens = { workspaceId: string | null; }; +export type DenStaticWorkerAttachInput = { + name: string; + description?: string | null; + url: string; + clientToken: string; + hostToken: string; + activityToken?: string | null; +}; + export type DenOrgLlmProviderModel = { id: string; name: string; @@ -124,7 +133,10 @@ export type DenOrgLlmProvider = { providerId: string; name: string; providerConfig: Record; + credentialKind: "api_key" | "opencode_oauth"; hasApiKey: boolean; + hasOpencodeAuth: boolean; + hasCredential: boolean; models: DenOrgLlmProviderModel[]; createdAt: string | null; updatedAt: string | null; @@ -132,6 +144,7 @@ export type DenOrgLlmProvider = { export type DenOrgLlmProviderConnection = DenOrgLlmProvider & { apiKey: string | null; + opencodeAuth: string | null; }; export type DenPluginConfigObjectType = "skill" | "agent" | "command" | "tool" | "mcp" | "hook" | "context" | "custom"; @@ -452,8 +465,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 { @@ -910,7 +935,10 @@ function parseDenOrgLlmProvider(value: unknown): DenOrgLlmProvider | null { providerId: value.providerId, name: value.name, providerConfig: isRecord(value.providerConfig) ? value.providerConfig : {}, + credentialKind: value.credentialKind === "opencode_oauth" ? "opencode_oauth" : "api_key", hasApiKey: value.hasApiKey === true, + hasOpencodeAuth: value.hasOpencodeAuth === true, + hasCredential: value.hasCredential === true || value.hasApiKey === true || value.hasOpencodeAuth === true, models: Array.isArray(value.models) ? value.models.flatMap((model) => { const parsed = parseDenOrgLlmProviderModel(model); @@ -946,6 +974,7 @@ function getDenOrgLlmProviderConnection(payload: unknown): DenOrgLlmProviderConn return { ...provider, apiKey: typeof payload.llmProvider.apiKey === "string" ? payload.llmProvider.apiKey : null, + opencodeAuth: typeof payload.llmProvider.opencodeAuth === "string" ? payload.llmProvider.opencodeAuth : null, }; } @@ -1684,6 +1713,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", @@ -1757,7 +1812,7 @@ export function createDenClient(options: { baseUrl: string; apiBaseUrl?: string async getOrgLlmProviderConnection(orgId: string, llmProviderId: string): Promise { const payload = await requestJson( baseUrls, - `/v1/llm-providers/${encodeURIComponent(llmProviderId)}/connect`, + `/v1/llm-providers/${encodeURIComponent(llmProviderId)}/import-credential`, { method: "GET", token, diff --git a/apps/app/src/app/lib/desktop-types.ts b/apps/app/src/app/lib/desktop-types.ts index c5351a676..3d9ab1184 100644 --- a/apps/app/src/app/lib/desktop-types.ts +++ b/apps/app/src/app/lib/desktop-types.ts @@ -66,6 +66,9 @@ export type WorkspaceInfo = { openworkHostToken?: string | null; openworkWorkspaceId?: string | null; openworkWorkspaceName?: string | null; + openworkDenBaseUrl?: string | null; + openworkDenOrgId?: string | null; + openworkDenWorkerId?: string | null; sandboxBackend?: "docker" | "microsandbox" | null; sandboxRunId?: string | null; sandboxContainerName?: string | null; diff --git a/apps/app/src/app/lib/openwork-server.ts b/apps/app/src/app/lib/openwork-server.ts index 698b57b47..288dea63f 100644 --- a/apps/app/src/app/lib/openwork-server.ts +++ b/apps/app/src/app/lib/openwork-server.ts @@ -446,6 +446,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; @@ -564,7 +586,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) ?? ""; @@ -573,7 +595,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 20c0d7297..7cdd132a9 100644 --- a/apps/app/src/app/lib/workspace-endpoint.ts +++ b/apps/app/src/app/lib/workspace-endpoint.ts @@ -31,6 +31,8 @@ export type ResolvedWorkspaceEndpoint = { baseUrl: string; /** Auth token for that server. May be empty for unauthenticated local servers. */ token: string; + /** Host/admin token for routes that require worker mutation privileges. */ + hostToken: string; /** Workspace id as the owning server expects it in URL paths. No `rem_` prefix. */ workspaceId: string; /** True when the workspace lives on a remote OpenWork worker, not the user's local server. */ @@ -93,13 +95,18 @@ function pickRemoteBaseUrl(workspace: WorkspaceEndpointInput): string { function pickRemoteToken(workspace: WorkspaceEndpointInput): string { if (!workspace) return ""; return ( - workspace.openworkToken ?? workspace.openworkClientToken ?? + workspace.openworkToken ?? workspace.openworkHostToken ?? "" ).trim(); } +function pickRemoteHostToken(workspace: WorkspaceEndpointInput): string { + if (!workspace) return ""; + return (workspace.openworkHostToken ?? "").trim(); +} + /** * Resolve the right server endpoint for a workspace. Returns null when the * workspace can't be reached (remote with no baseUrl, or local with no local @@ -116,10 +123,12 @@ export function resolveWorkspaceEndpoint( const baseUrl = pickRemoteBaseUrl(workspace); if (!baseUrl) return null; const token = pickRemoteToken(workspace); + const hostToken = pickRemoteHostToken(workspace); const workspaceId = workspaceServerId(workspace); const client = createOpenworkServerClient({ baseUrl, token: token || undefined, + hostToken: hostToken || undefined, }); const mountedBaseUrl = ( buildOpenworkWorkspaceBaseUrl(baseUrl, workspaceId) ?? baseUrl @@ -127,6 +136,7 @@ export function resolveWorkspaceEndpoint( return { baseUrl, token, + hostToken, workspaceId, isRemote: true, client, @@ -149,6 +159,7 @@ export function resolveWorkspaceEndpoint( return { baseUrl: localBaseUrl, token: localToken, + hostToken: "", workspaceId, isRemote: false, client, diff --git a/apps/app/src/react-app/domains/settings/pages/cloud-workers-view.tsx b/apps/app/src/react-app/domains/settings/pages/cloud-workers-view.tsx index 55dc4276d..05baf0a46 100644 --- a/apps/app/src/react-app/domains/settings/pages/cloud-workers-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/cloud-workers-view.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; import { t } from "@/i18n"; import { useStatusToasts } from "../../shell-feedback/status-toasts"; @@ -13,6 +14,11 @@ export type CloudWorkersViewProps = { connectRemoteWorkspace: (input: { openworkHostUrl?: string | null; openworkToken?: string | null; + openworkClientToken?: string | null; + openworkHostToken?: string | null; + openworkDenBaseUrl?: string | null; + openworkDenOrgId?: string | null; + openworkDenWorkerId?: string | null; directory?: string | null; displayName?: string | null; }) => Promise; @@ -23,12 +29,19 @@ export function CloudWorkersView({ connectRemoteWorkspace, onOpenAccount, }: CloudWorkersViewProps) { - const { activeOrganization: activeOrg, authToken, client, isSignedIn, user } = useCloudSession(); + const { activeOrganization: activeOrg, authToken, baseUrl, client, isSignedIn, user } = useCloudSession(); const { showToast } = useStatusToasts(); const [workersBusy, setWorkersBusy] = React.useState(false); const [openingWorkerId, setOpeningWorkerId] = React.useState(null); + const [attachBusy, setAttachBusy] = React.useState(false); const [workers, setWorkers] = React.useState([]); const [workersError, setWorkersError] = React.useState(null); + const [staticWorkerForm, setStaticWorkerForm] = React.useState({ + name: "LAN static worker", + url: "", + clientToken: "", + hostToken: "", + }); const activeOrgId = activeOrg?.id ?? ""; const refreshWorkers = React.useCallback( @@ -84,7 +97,7 @@ export function CloudWorkersView({ try { const tokens = await client.getWorkerTokens(workerId, activeOrgId); const openworkUrl = tokens.openworkUrl?.trim() ?? ""; - const accessToken = tokens.ownerToken?.trim() || tokens.clientToken?.trim() || ""; + const accessToken = tokens.clientToken?.trim() || tokens.ownerToken?.trim() || ""; if (!openworkUrl || !accessToken) { throw new Error(t("den.error_worker_not_ready")); } @@ -92,6 +105,11 @@ export function CloudWorkersView({ const ok = await connectRemoteWorkspace({ openworkHostUrl: openworkUrl, openworkToken: accessToken, + openworkClientToken: tokens.clientToken?.trim() || null, + openworkHostToken: tokens.hostToken?.trim() || null, + openworkDenBaseUrl: baseUrl, + openworkDenOrgId: activeOrgId, + openworkDenWorkerId: workerId, directory: null, displayName: workerName, }); @@ -113,9 +131,50 @@ export function CloudWorkersView({ setOpeningWorkerId(null); } }, - [activeOrgId, client, connectRemoteWorkspace, showToast], + [activeOrgId, baseUrl, client, connectRemoteWorkspace, showToast], ); + const attachStaticWorker = React.useCallback(async () => { + if (!activeOrgId) { + setWorkersError(t("den.error_choose_org")); + return; + } + + const name = staticWorkerForm.name.trim(); + const url = staticWorkerForm.url.trim(); + const clientToken = staticWorkerForm.clientToken.trim(); + const hostToken = staticWorkerForm.hostToken.trim(); + if (!name || !url || !clientToken || !hostToken) { + setWorkersError("Name, URL, client token, and host token are required to attach a static worker."); + return; + } + + setAttachBusy(true); + setWorkersError(null); + try { + const worker = await client.attachStaticWorker(activeOrgId, { + name, + url, + clientToken, + hostToken, + }); + setWorkers((current) => [worker, ...current.filter((entry) => entry.workerId !== worker.workerId)]); + setStaticWorkerForm((current) => ({ ...current, url: "", clientToken: "", hostToken: "" })); + showToast({ + title: `Attached ${worker.workerName}`, + tone: "success", + }); + void refreshWorkers(true); + } catch (error) { + const status = typeof error === "object" && error !== null && "status" in error ? Number((error as { status?: unknown }).status) : null; + setWorkersError(status === 403 + ? "Only organization owners and admins can attach static workers. Ask an operator to register this worker." + : error instanceof Error ? error.message : "Static worker attach failed."); + } finally { + setAttachBusy(false); + } + }, [activeOrgId, client, refreshWorkers, showToast, staticWorkerForm]); + if (!isSignedIn) { return ( @@ -135,6 +194,45 @@ export function CloudWorkersView({ return ( + +
+
+
Admin/operator: attach LAN static worker
+
+ Organization owners and admins can register a pre-running OpenWork worker without manual database changes. The URL and tokens must match the worker container environment. +
+
+
+ setStaticWorkerForm((current) => ({ ...current, name: event.currentTarget.value }))} + placeholder="Worker name" + /> + setStaticWorkerForm((current) => ({ ...current, url: event.currentTarget.value }))} + placeholder="http://192.168.1.50:8787" + /> + setStaticWorkerForm((current) => ({ ...current, clientToken: event.currentTarget.value }))} + placeholder="OPENWORK_TOKEN" + type="password" + /> + setStaticWorkerForm((current) => ({ ...current, hostToken: event.currentTarget.value }))} + placeholder="OPENWORK_HOST_TOKEN" + type="password" + /> +
+
+ +
+
+
selectedWorkspace ? { + ...selectedWorkspace, id: selectedWorkspace.id, name: selectedWorkspace.name ?? selectedWorkspace.displayNameResolved, path: selectedWorkspace.path ?? "", @@ -825,6 +826,20 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { Object.values(providerAuthSnapshot.importedCloudProviders ?? {}).some(isOpenWorkCloudProvider), [providerAuthSnapshot.cloudOrgProviders, providerAuthSnapshot.importedCloudProviders], ); + + const cloudManagedModelIdsByProvider = useMemo(() => { + const next = new Map>(); + for (const imported of Object.values(providerAuthSnapshot.importedCloudProviders ?? {})) { + const providerId = imported.providerId?.trim(); + if (!providerId) continue; + const modelIds = imported.modelIds + .map((id) => id.trim()) + .filter(Boolean); + if (!modelIds.length) continue; + next.set(providerId, new Set(modelIds)); + } + return next; + }, [providerAuthSnapshot.importedCloudProviders]); const showOpenWorkModelsSubscribe = !cloudSession.isSignedIn || !hasOpenWorkCloudProvider; const subscribeToOpenWorkModels = useCallback(() => { @@ -907,7 +922,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; @@ -1220,7 +1239,9 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { for (const provider of getConnectedProviderItems(data)) { const modelIds = Object.keys(provider.models); const isNew = !seenIds.has(provider.id); + const cloudManagedModelIds = cloudManagedModelIdsByProvider.get(provider.id); for (const id of modelIds) { + if (cloudManagedModelIds && !cloudManagedModelIds.has(id)) continue; const model = provider.models[id]; options.push({ providerID: provider.id, @@ -1234,7 +1255,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { isFree: false, isConnected: true, isRecommended: isNew, - source: /^lpr_/i.test(provider.id) ? "cloud" as const : undefined, + source: cloudManagedModelIds || /^lpr_/i.test(provider.id) ? "cloud" as const : undefined, }); } } @@ -1250,7 +1271,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 })); @@ -1913,6 +1934,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; }) => { @@ -1926,6 +1952,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, @@ -2250,7 +2281,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { case "cloud-workers": return ( false} + connectRemoteWorkspace={handleCreateRemoteWorkspace} onOpenAccount={openCloudAccountSettings} /> ); diff --git a/apps/app/src/react-app/shell/workspace-provider.ts b/apps/app/src/react-app/shell/workspace-provider.ts index fc3af3676..78f897020 100644 --- a/apps/app/src/react-app/shell/workspace-provider.ts +++ b/apps/app/src/react-app/shell/workspace-provider.ts @@ -6,6 +6,7 @@ type WorkspaceContextValue = { client: Client | null; opencodeBaseUrl: string; selectedWorkspaceRoot: string; + cloudManagedModelIdsByProvider: Map>; }; const WorkspaceContext = React.createContext(null); @@ -14,6 +15,7 @@ type WorkspaceProviderProps = { client: Client | null; opencodeBaseUrl?: string; selectedWorkspaceRoot: string; + cloudManagedModelIdsByProvider?: Map>; children: React.ReactNode; }; @@ -21,11 +23,17 @@ export function WorkspaceProvider({ client, opencodeBaseUrl = "", selectedWorkspaceRoot, + cloudManagedModelIdsByProvider, children, }: WorkspaceProviderProps) { const value = React.useMemo( - () => ({ client, opencodeBaseUrl, selectedWorkspaceRoot }), - [client, opencodeBaseUrl, selectedWorkspaceRoot], + () => ({ + client, + opencodeBaseUrl, + selectedWorkspaceRoot, + cloudManagedModelIdsByProvider: cloudManagedModelIdsByProvider ?? new Map>(), + }), + [client, cloudManagedModelIdsByProvider, opencodeBaseUrl, selectedWorkspaceRoot], ); return React.createElement(WorkspaceContext.Provider, { value }, children); diff --git a/apps/desktop/electron/bootstrap-config.mjs b/apps/desktop/electron/bootstrap-config.mjs new file mode 100644 index 000000000..ee14a94b2 --- /dev/null +++ b/apps/desktop/electron/bootstrap-config.mjs @@ -0,0 +1,101 @@ +import os from "node:os"; +import path from "node:path"; + +export const DEFAULT_DEN_BASE_URL = "https://app.openworklabs.com"; + +export function envFlagEnabled(name, env = process.env) { + const value = env[name]?.trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes" || value === "on"; +} + +function configHomePath({ env = process.env, platform = process.platform, homedir = os.homedir() } = {}) { + if (env.XDG_CONFIG_HOME?.trim()) return env.XDG_CONFIG_HOME.trim(); + if (platform === "win32" && env.APPDATA?.trim()) return env.APPDATA.trim(); + return path.join(homedir, ".config"); +} + +export function managedDesktopBootstrapPath({ env = process.env, platform = process.platform } = {}) { + if (platform === "win32") { + const programData = env.ProgramData?.trim() || env.PROGRAMDATA?.trim() || "C:\\ProgramData"; + return path.join(programData, "OpenWork", "desktop-bootstrap.json"); + } + if (platform === "darwin") { + return path.join("/Library", "Application Support", "OpenWork", "desktop-bootstrap.json"); + } + return path.join("/etc", "openwork", "desktop-bootstrap.json"); +} + +export function userDesktopBootstrapPath(options = {}) { + return path.join(configHomePath(options), "openwork", "desktop-bootstrap.json"); +} + +export function legacyDevDesktopBootstrapPath({ homedir = os.homedir() } = {}) { + return path.join(homedir, ".config", "openwork", "desktop-bootstrap.json"); +} + +export function desktopBootstrapCandidates(options = {}) { + const { env = process.env } = options; + const candidates = []; + const envOverride = env.OPENWORK_DESKTOP_BOOTSTRAP_PATH?.trim(); + if (envOverride) { + candidates.push({ source: "env", path: envOverride }); + } + candidates.push( + { source: "managed", path: managedDesktopBootstrapPath(options) }, + { source: "user", path: userDesktopBootstrapPath(options) }, + ); + const legacyDevPath = legacyDevDesktopBootstrapPath(options); + if (!candidates.some((candidate) => candidate.path === legacyDevPath)) { + candidates.push({ source: "user-dev", path: legacyDevPath }); + } + return candidates; +} + +export function defaultDesktopBootstrapConfig({ env = process.env } = {}) { + return { + baseUrl: DEFAULT_DEN_BASE_URL, + apiBaseUrl: null, + requireSignin: envFlagEnabled("OPENWORK_FORCE_SIGNIN", env), + source: "default", + path: null, + }; +} + +export function normalizeDesktopBootstrapConfig(input, options = {}) { + const baseUrl = typeof input?.baseUrl === "string" ? input.baseUrl.trim() : ""; + if (!baseUrl) throw new Error("baseUrl is required"); + const apiBaseUrl = typeof input?.apiBaseUrl === "string" && input.apiBaseUrl.trim().length > 0 + ? input.apiBaseUrl.trim() + : null; + return { + baseUrl, + apiBaseUrl, + requireSignin: envFlagEnabled("OPENWORK_FORCE_SIGNIN", options.env) || input?.requireSignin === true, + }; +} + +export function normalizeUrlOrigin(input) { + const raw = String(input ?? "").trim(); + if (!raw) return ""; + try { + return new URL(raw).origin.replace(/\/+$/, "").toLowerCase(); + } catch { + return raw.replace(/\/+$/, "").toLowerCase(); + } +} + +export function isWorkspaceCompatibleWithManagedDen(workspace, denBaseUrl) { + if (workspace?.workspaceType !== "remote" || workspace?.remoteType !== "openwork") return true; + const activeDenOrigin = normalizeUrlOrigin(denBaseUrl); + if (!activeDenOrigin) return true; + const workspaceDenOrigin = normalizeUrlOrigin(workspace?.openworkDenBaseUrl); + // A managed Den configuration must not silently reuse legacy remote OpenWork + // records that lack Den-origin metadata; those may point at stale labs/sites. + if (!workspaceDenOrigin) return false; + return workspaceDenOrigin === activeDenOrigin; +} + +export function filterWorkspacesForManagedDen(workspaces, denBaseUrl) { + const input = Array.isArray(workspaces) ? workspaces : []; + return input.filter((workspace) => isWorkspaceCompatibleWithManagedDen(workspace, denBaseUrl)); +} diff --git a/apps/desktop/electron/bootstrap-config.test.mjs b/apps/desktop/electron/bootstrap-config.test.mjs new file mode 100644 index 000000000..85b2349b5 --- /dev/null +++ b/apps/desktop/electron/bootstrap-config.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; + +import { + desktopBootstrapCandidates, + filterWorkspacesForManagedDen, + managedDesktopBootstrapPath, + normalizeDesktopBootstrapConfig, +} from "./bootstrap-config.mjs"; + +test("desktop bootstrap candidates use env, managed, user/dev, then defaults", () => { + const env = { + OPENWORK_DESKTOP_BOOTSTRAP_PATH: "D:\\managed\\override.json", + ProgramData: "C:\\ProgramData", + APPDATA: "C:\\Users\\Alice\\AppData\\Roaming", + }; + + const candidates = desktopBootstrapCandidates({ + env, + platform: "win32", + homedir: "C:\\Users\\Alice", + }); + + assert.deepEqual(candidates.map((candidate) => candidate.source), [ + "env", + "managed", + "user", + "user-dev", + ]); + assert.equal(candidates[0].path, env.OPENWORK_DESKTOP_BOOTSTRAP_PATH); + assert.equal(candidates[1].path, path.join("C:\\ProgramData", "OpenWork", "desktop-bootstrap.json")); + assert.equal(candidates[2].path, path.join("C:\\Users\\Alice\\AppData\\Roaming", "openwork", "desktop-bootstrap.json")); + assert.equal(candidates[3].path, path.join("C:\\Users\\Alice", ".config", "openwork", "desktop-bootstrap.json")); +}); + +test("windows managed bootstrap defaults to ProgramData without env override", () => { + assert.equal( + managedDesktopBootstrapPath({ env: {}, platform: "win32" }), + path.join("C:\\ProgramData", "OpenWork", "desktop-bootstrap.json"), + ); +}); + +test("normalize desktop bootstrap honors forced sign-in env", () => { + assert.deepEqual( + normalizeDesktopBootstrapConfig( + { baseUrl: " http://den.local:3005 ", apiBaseUrl: "", requireSignin: false }, + { env: { OPENWORK_FORCE_SIGNIN: "true" } }, + ), + { baseUrl: "http://den.local:3005", apiBaseUrl: null, requireSignin: true }, + ); +}); + +test("managed Den filtering rejects ambiguous legacy remote OpenWork workspaces", () => { + const workspaces = [ + { id: "local", workspaceType: "local" }, + { id: "legacy", workspaceType: "remote", remoteType: "openwork", openworkHostUrl: "http://old-worker:8787" }, + { id: "wrong-den", workspaceType: "remote", remoteType: "openwork", openworkDenBaseUrl: "http://old-den:3005" }, + { id: "current-den", workspaceType: "remote", remoteType: "openwork", openworkDenBaseUrl: "http://den.company.local:3005/api/den" }, + { id: "other-remote", workspaceType: "remote", remoteType: "opencode" }, + ]; + + assert.deepEqual( + filterWorkspacesForManagedDen(workspaces, "http://den.company.local:3005").map((workspace) => workspace.id), + ["local", "current-den", "other-remote"], + ); +}); diff --git a/apps/desktop/electron/browser-mcp.mjs b/apps/desktop/electron/browser-mcp.mjs new file mode 100644 index 000000000..a1a8dfcbe --- /dev/null +++ b/apps/desktop/electron/browser-mcp.mjs @@ -0,0 +1,373 @@ +/** + * In-process browser MCP servers. + * + * Two servers: + * 1. "openwork-browser" — controls the embedded WebContentsView using + * native Electron webContents APIs (no Puppeteer, no app-level CDP). + * 2. "chrome" — connects to the user's external Chrome via Puppeteer/CDP. + * + * Both are exposed as HTTP MCP endpoints that OpenCode connects to as + * remote MCP servers. + */ + +import { createServer } from "node:http"; +import { randomUUID } from "node:crypto"; + +// ── Native built-in browser server ──────────────────────────────────── +import { createNativeBuiltinServer } from "./browser-native-tools.mjs"; + +// ── Chrome DevTools MCP internals (for EXTERNAL Chrome only) ────────── +// IMPORTANT: never import main.js — it runs parseArguments at module load. +import "chrome-devtools-mcp/build/src/polyfill.js"; + +import { + McpServer, + SetLevelRequestSchema, + puppeteer, +} from "chrome-devtools-mcp/build/src/third_party/index.js"; + +import { tools as chromeDevtoolsTools } from "chrome-devtools-mcp/build/src/tools/tools.js"; +import { McpContext } from "chrome-devtools-mcp/build/src/McpContext.js"; +import { McpResponse } from "chrome-devtools-mcp/build/src/McpResponse.js"; +import { Mutex } from "chrome-devtools-mcp/build/src/Mutex.js"; + +// MCP SDK HTTP transport — works with the same McpServer +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; + +// ── Helpers ──────────────────────────────────────────────────────────── + +function noop() {} + +/** Wrap a promise with a timeout. Rejects with a descriptive error. */ +function withTimeout(promise, ms, label) { + let timer; + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label}: timed out after ${ms}ms`)), ms); + }), + ]).finally(() => clearTimeout(timer)); +} + +/** + * Target filter for the EXTERNAL Chrome server — accept all normal pages, + * skip chrome:// and extension pages. + */ +const EXTERNAL_TARGET_FILTER = (target) => { + const url = target.url(); + if (url === "chrome://newtab/") return true; + if (url.startsWith("chrome://") || url.startsWith("chrome-extension://")) return false; + return true; +}; + +async function connectExternalBrowser(browserURL) { + return withTimeout( + puppeteer.connect({ + browserURL, + targetFilter: EXTERNAL_TARGET_FILTER, + defaultViewport: null, + }), + 10_000, + "connectExternalBrowser", + ); +} + +/** + * Create an MCP server backed by chrome-devtools-mcp tools. + * Used ONLY for the external Chrome server. + */ +function createExternalChromeServer({ getBrowser }) { + const server = new McpServer( + { name: "chrome", version: "0.1.0" }, + { capabilities: { logging: {} } }, + ); + + server.server.setRequestHandler(SetLevelRequestSchema, () => ({})); + + const mutex = new Mutex(); + let context = null; + let lastBrowser = null; + + async function getContext() { + const browser = await getBrowser(); + if (!browser?.connected) { + throw new Error("Browser not connected for chrome"); + } + if (browser !== lastBrowser) { + lastBrowser = browser; + context = await McpContext.from(browser, noop, { + experimentalDevToolsDebugging: false, + experimentalIncludeAllPages: false, + performanceCrux: false, + }); + } + return context; + } + + for (const tool of chromeDevtoolsTools) { + server.tool( + tool.name, + tool.description, + tool.schema, + async (params) => { + const guard = await mutex.acquire(); + try { + const ctx = await getContext(); + const response = new McpResponse(); + const TOOL_TIMEOUT = 30_000; + await withTimeout( + tool.handler({ params }, response, ctx), + TOOL_TIMEOUT, + `chrome/${tool.name}`, + ); + const { content } = await response.handle(tool.name, ctx); + return { content }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text", text: `Error: ${msg}` }] }; + } finally { + guard.dispose(); + } + }, + ); + } + + return server; +} + +// ── HTTP wrappers ────────────────────────────────────────────────────── + +/** + * Start an MCP-over-HTTP server on a random localhost port. + * + * Uses one StreamableHTTPServerTransport per session. Each new session + * (no mcp-session-id header) gets its own transport + server instance + * created by the factory. + * + * Returns { port, close }. + */ +async function startMcpHttpServer(mcpServerFactory, preferredPort = 0) { + const sessions = new Map(); + + const httpServer = createServer(async (req, res) => { + try { + const url = new URL(req.url ?? "/", `http://127.0.0.1`); + + if (req.method === "GET" && url.pathname === "/health") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + return; + } + + if (url.pathname !== "/mcp") { + res.writeHead(404); + res.end("Not found"); + return; + } + + const sessionId = req.headers["mcp-session-id"]; + + if (req.method === "POST") { + // Existing session + if (sessionId && sessions.has(sessionId)) { + const transport = sessions.get(sessionId); + await transport.handleRequest(req, res); + return; + } + + // New session — create a fresh transport + server + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id) => { + sessions.set(id, transport); + }, + }); + const server = mcpServerFactory(); + await server.connect(transport); + await transport.handleRequest(req, res); + return; + } + + if (req.method === "GET") { + if (sessionId && sessions.has(sessionId)) { + await sessions.get(sessionId).handleRequest(req, res); + return; + } + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "No session. Send a POST first." })); + return; + } + + if (req.method === "DELETE") { + if (sessionId && sessions.has(sessionId)) { + const transport = sessions.get(sessionId); + sessions.delete(sessionId); + await transport.close(); + } + res.writeHead(200); + res.end(); + return; + } + + res.writeHead(405); + res.end("Method not allowed"); + } catch (err) { + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) })); + } + } + }); + + async function listen(portToTry) { + return new Promise((resolve, reject) => { + httpServer.once("error", reject); + httpServer.listen(portToTry, "127.0.0.1", () => { + const address = httpServer.address(); + resolve(typeof address === "object" && address ? address.port : portToTry); + }); + }); + } + + let port; + try { + port = await listen(preferredPort); + } catch (error) { + if (!preferredPort || error?.code !== "EADDRINUSE") throw error; + port = await listen(0); + } + + return { + port, + close: () => new Promise((resolve) => httpServer.close(resolve)), + }; +} + +// ── Public API ───────────────────────────────────────────────────────── + +/** + * Boot both MCP servers. + * + * @param {object} opts + * @param {Function} opts.getWebContents — () => WebContents | null (active built-in browser tab) + * @param {Function} opts.listTabs — () => BrowserTabInfo[] + * @param {Function} opts.createTab — (url?: string) => tabId + * @param {Function} opts.closeTab — (tabId: string) => tabId | null + * @param {Function} opts.selectTab — (tabId: string) => tabId + * @param {Function} opts.onBuiltinToolCall — called before each built-in browser tool (opens panel) + * @param {Function} opts.onHideBrowser — called to close the browser panel + * @returns {Promise<{ builtinPort: number, externalPort: number, _snapshotReset: () => void, stop: () => Promise }>} + */ +export async function startBrowserMcpServers({ + getWebContents, + listTabs, + createTab, + closeTab, + selectTab, + onBuiltinToolCall, + onHideBrowser, +}) { + let externalBrowser = null; + + // ── Built-in browser: native Electron APIs ──────────────────────── + let builtinSnapshotReset = null; + function createBuiltinFactory() { + const srv = createNativeBuiltinServer({ + getWebContents, + onToolCall: onBuiltinToolCall, + onHideBrowser, + }); + builtinSnapshotReset = /** @type {any} */ (srv)._snapshotReset; + return srv; + } + + // ── External Chrome: Puppeteer + CDP (unchanged) ────────────────── + + async function probeExternalChrome() { + for (const port of [9222, 9229]) { + try { + const res = await fetch(`http://127.0.0.1:${port}/json/version`, { + signal: AbortSignal.timeout(2000), + }); + if (res.ok) return { connected: true, port }; + } catch { /* not available */ } + } + return { connected: false, port: null }; + } + + function createExternalFactory() { + const server = createExternalChromeServer({ + getBrowser: async () => { + if (!externalBrowser?.connected) { + for (const port of [9222, 9229]) { + try { + externalBrowser = await connectExternalBrowser(`http://127.0.0.1:${port}`); + return externalBrowser; + } catch { /* not available */ } + } + throw new Error( + "Chrome is not reachable. " + + "Enable remote debugging in your Chrome: go to chrome://inspect/#remote-debugging and turn it on. " + + "No restart needed on Chrome 144+." + ); + } + return externalBrowser; + }, + }); + + // Diagnostic tool — lets the agent check Chrome availability before + // attempting browsing, so it can guide the user instead of failing. + server.tool( + "chrome_status", + "Check whether the user's real Chrome browser is reachable via remote " + + "debugging. Call this BEFORE using any other chrome tool. If status is " + + "unavailable, tell the user to enable remote debugging in Chrome: " + + "chrome://inspect/#remote-debugging → enable → allow connections. " + + "No Chrome restart is needed on Chrome 144+.", + {}, + async () => { + const probe = await probeExternalChrome(); + if (probe.connected) { + return { + content: [{ + type: "text", + text: JSON.stringify({ + connected: true, + port: probe.port, + hint: "Chrome is reachable. You can now use chrome tools to control the user's browser.", + }), + }], + }; + } + return { + content: [{ + type: "text", + text: JSON.stringify({ + connected: false, + port: null, + hint: "Chrome is not reachable. Ask the user to enable remote debugging: " + + "open chrome://inspect/#remote-debugging in Chrome, enable it, and allow " + + "incoming connections. No restart needed on Chrome 144+. " + + "Alternatively, offer to use the built-in openwork-browser instead.", + }), + }], + }; + }, + ); + + return server; + } + + const builtin = await startMcpHttpServer(createBuiltinFactory, 64883); + const external = await startMcpHttpServer(createExternalFactory, 64884); + + return { + builtinPort: builtin.port, + externalPort: external.port, + _snapshotReset: () => builtinSnapshotReset?.(), + async stop() { + await Promise.all([builtin.close(), external.close()]); + try { externalBrowser?.disconnect(); } catch {} + }, + }; +} diff --git a/apps/desktop/electron/browser-native-tools.mjs b/apps/desktop/electron/browser-native-tools.mjs new file mode 100644 index 000000000..69c09add0 --- /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