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/STATS.md b/STATS.md index e6b2bad574..25c954e627 100644 --- a/STATS.md +++ b/STATS.md @@ -142,3 +142,4 @@ Legacy cumulative release-asset totals. For classified v2 buckets, see `STATS_V2 | 2026-06-06 | 988,487 (+5,040) | 988,487 (+5,040) | | 2026-06-07 | 992,289 (+3,802) | 992,289 (+3,802) | | 2026-06-08 | 997,450 (+5,161) | 997,450 (+5,161) | +| 2026-06-09 | 1,002,601 (+5,151) | 1,002,601 (+5,151) | diff --git a/STATS_V2.md b/STATS_V2.md index bd2e9b1861..dea122e089 100644 --- a/STATS_V2.md +++ b/STATS_V2.md @@ -98,3 +98,4 @@ Classified GitHub release asset snapshots. `Manual installs` counts installer do | 2026-06-06 | 124,222 (+784) | 690,834 (+66) | 173,431 (+4,190) | 988,487 (+5,040) | | 2026-06-07 | 124,760 (+538) | 690,875 (+41) | 176,654 (+3,223) | 992,289 (+3,802) | | 2026-06-08 | 125,538 (+778) | 690,913 (+38) | 180,999 (+4,345) | 997,450 (+5,161) | +| 2026-06-09 | 126,150 (+612) | 690,953 (+40) | 185,498 (+4,499) | 1,002,601 (+5,151) | diff --git a/apps/app/package.json b/apps/app/package.json index 6965f3a939..923b4e0bb2 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -1,7 +1,7 @@ { "name": "@openwork/app", "private": true, - "version": "0.15.2", + "version": "0.15.3", "type": "module", "scripts": { "dev": "OPENWORK_DEV_MODE=1 vite", @@ -50,7 +50,7 @@ "@fontsource-variable/ibm-plex-sans": "^5.2.8", "@heroicons/react": "^2.2.0", "@lexical/react": "^0.35.0", - "@opencode-ai/sdk": "^1.15.12", + "@opencode-ai/sdk": "^1.16.2", "@openwork/types": "workspace:*", "@openwork/ui": "workspace:*", "@paper-design/shaders-react": "0.0.72", diff --git a/apps/app/scripts/artifacts.test.ts b/apps/app/scripts/artifacts.test.ts index 36c2e4e00a..4c5499bf65 100644 --- a/apps/app/scripts/artifacts.test.ts +++ b/apps/app/scripts/artifacts.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test"; import type { UIMessage } from "ai"; import type { OpenTarget } from "../src/react-app/domains/session/artifacts/open-target"; -import { getArtifactsFromMessages } from "../src/lib/artifacts"; +import { canPreviewArtifact, getArtifactsFromMessages } from "../src/lib/artifacts"; describe("getArtifactsFromMessages", () => { it("includes verified slide deck targets mentioned in assistant summaries", () => { @@ -58,4 +58,56 @@ describe("getArtifactsFromMessages", () => { exists: true, }); }); + + it("can list artifacts from assistant text without target fallbacks", () => { + const messages: UIMessage[] = [{ + id: "msg_text", + role: "assistant", + parts: [{ type: "text", text: "Created reports/artifact-eval.md, decks/update.pptx, and src/widget.tsx", state: "done" }], + }]; + + expect(getArtifactsFromMessages(messages, [], { includeTargetFallbacks: false }).map((artifact) => artifact.path)).toEqual([ + "src/widget.tsx", + "decks/update.pptx", + "reports/artifact-eval.md", + ]); + }); + + it("orders verified artifacts by newest update time and marks unsupported previews", () => { + const messages: UIMessage[] = [{ + id: "msg_order", + role: "assistant", + parts: [{ type: "text", text: "Created reports/old.md and reports/new.md and src/widget.tsx", state: "done" }], + }]; + const targets: OpenTarget[] = [ + { + id: "file:reports/old.md", + kind: "file", + value: "reports/old.md", + name: "old.md", + preview: "markdown", + confidence: 65, + reason: "message", + exists: true, + updatedAt: 1, + }, + { + id: "file:reports/new.md", + kind: "file", + value: "reports/new.md", + name: "new.md", + preview: "markdown", + confidence: 65, + reason: "message", + exists: true, + updatedAt: 2, + }, + ]; + + const artifacts = getArtifactsFromMessages(messages, targets, { includeTargetFallbacks: false }); + + expect(artifacts.map((artifact) => artifact.path)).toEqual(["reports/new.md", "reports/old.md", "src/widget.tsx"]); + expect(canPreviewArtifact(artifacts[0])).toBe(true); + expect(canPreviewArtifact(artifacts[2])).toBe(false); + }); }); 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 3bcc07194e..69ca1ffb6d 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; @@ -121,10 +130,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; @@ -132,6 +144,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"; @@ -562,8 +583,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 { @@ -1028,10 +1061,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); @@ -1067,6 +1103,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 } : {}), }; } @@ -1818,6 +1876,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", @@ -1891,7 +1975,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, @@ -1905,6 +1989,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 95d2d2cb4d..650bc285c6 100644 --- a/apps/app/src/app/lib/openwork-server.ts +++ b/apps/app/src/app/lib/openwork-server.ts @@ -531,6 +531,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; @@ -649,7 +671,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) ?? ""; @@ -658,7 +680,7 @@ export function readOpenworkServerSettings(): OpenworkServerSettings { const hostToken = window.localStorage.getItem(STORAGE_HOST_AUTH_KEY) ?? undefined; const remoteAccessRaw = window.localStorage.getItem(STORAGE_REMOTE_ACCESS) ?? ""; return { - urlOverride: urlOverride ?? undefined, + urlOverride: urlOverride || undefined, portOverride: Number.isNaN(portOverride) ? undefined : portOverride, token: token?.trim() || undefined, hostToken: hostToken?.trim() || undefined, diff --git a/apps/app/src/app/lib/workspace-endpoint.ts b/apps/app/src/app/lib/workspace-endpoint.ts index 20c0d72979..7cdd132a9d 100644 --- a/apps/app/src/app/lib/workspace-endpoint.ts +++ b/apps/app/src/app/lib/workspace-endpoint.ts @@ -31,6 +31,8 @@ export type ResolvedWorkspaceEndpoint = { baseUrl: string; /** Auth token for that server. May be empty for unauthenticated local servers. */ token: string; + /** Host/admin token for routes that require worker mutation privileges. */ + hostToken: string; /** Workspace id as the owning server expects it in URL paths. No `rem_` prefix. */ workspaceId: string; /** True when the workspace lives on a remote OpenWork worker, not the user's local server. */ @@ -93,13 +95,18 @@ function pickRemoteBaseUrl(workspace: WorkspaceEndpointInput): string { function pickRemoteToken(workspace: WorkspaceEndpointInput): string { if (!workspace) return ""; return ( - workspace.openworkToken ?? workspace.openworkClientToken ?? + workspace.openworkToken ?? workspace.openworkHostToken ?? "" ).trim(); } +function pickRemoteHostToken(workspace: WorkspaceEndpointInput): string { + if (!workspace) return ""; + return (workspace.openworkHostToken ?? "").trim(); +} + /** * Resolve the right server endpoint for a workspace. Returns null when the * workspace can't be reached (remote with no baseUrl, or local with no local @@ -116,10 +123,12 @@ export function resolveWorkspaceEndpoint( const baseUrl = pickRemoteBaseUrl(workspace); if (!baseUrl) return null; const token = pickRemoteToken(workspace); + const hostToken = pickRemoteHostToken(workspace); const workspaceId = workspaceServerId(workspace); const client = createOpenworkServerClient({ baseUrl, token: token || undefined, + hostToken: hostToken || undefined, }); const mountedBaseUrl = ( buildOpenworkWorkspaceBaseUrl(baseUrl, workspaceId) ?? baseUrl @@ -127,6 +136,7 @@ export function resolveWorkspaceEndpoint( return { baseUrl, token, + hostToken, workspaceId, isRemote: true, client, @@ -149,6 +159,7 @@ export function resolveWorkspaceEndpoint( return { baseUrl: localBaseUrl, token: localToken, + hostToken: "", workspaceId, isRemote: false, client, diff --git a/apps/app/src/components/chat/artifact.tsx b/apps/app/src/components/chat/artifact.tsx index 4cd2b2380e..3b57dab53c 100644 --- a/apps/app/src/components/chat/artifact.tsx +++ b/apps/app/src/components/chat/artifact.tsx @@ -7,13 +7,12 @@ import { ArtifactIcon } from "@/components/chat/artifact-icon"; import { DescriptiveButton, DescriptiveButtonContent, - DescriptiveButtonDescription, DescriptiveButtonIcon, DescriptiveButtonTitle, } from "@/components/descriptive-button"; import { type ArtifactItem, - getArtifactTypeLabel, + canPreviewArtifact, useArtifacts, usePreviewArtifact, } from "@/lib/artifacts"; @@ -22,34 +21,57 @@ interface ArtifactButtonProps { artifact: ArtifactItem } +const MAX_ARTIFACT_TITLE_LENGTH = 20; + +function compactArtifactTitle(name: string) { + return name.length > MAX_ARTIFACT_TITLE_LENGTH + ? `${name.slice(0, MAX_ARTIFACT_TITLE_LENGTH - 1)}…` + : name; +} + function ArtifactButton({ artifact }: ArtifactButtonProps) { const previewArtifact = usePreviewArtifact(); + const canOpen = canPreviewArtifact(artifact); + const title = compactArtifactTitle(artifact.name); + + const content = ( + <> + + + + + {title} + + {canOpen ? : null} + + ); + + if (!canOpen) { + return ( +
+ {content} +
+ ); + } return ( previewArtifact(artifact)} + title={`Open ${artifact.name}`} > - - - - - {artifact.name} - - {getArtifactTypeLabel(artifact.type)} - - - + {content} ); } interface ArtifactListProps { messages: UIMessage[] + includeTargetFallbacks?: boolean } -export function ArtifactList({ messages }: ArtifactListProps) { - const artifacts = useArtifacts(messages); +export function ArtifactList({ messages, includeTargetFallbacks = false }: ArtifactListProps) { + const artifacts = useArtifacts(messages, { includeTargetFallbacks }); if (artifacts.length === 0) { return null; @@ -57,7 +79,7 @@ export function ArtifactList({ messages }: ArtifactListProps) { return (
-
+
{artifacts.map((artifact) => ( ))} diff --git a/apps/app/src/components/chat/message-list.tsx b/apps/app/src/components/chat/message-list.tsx index 8fe362b65d..f372ffb0cb 100644 --- a/apps/app/src/components/chat/message-list.tsx +++ b/apps/app/src/components/chat/message-list.tsx @@ -41,6 +41,7 @@ import { WebsearchTool } from "@/components/tools/websearch" import { useMessageList, useSessionErrorMessage } from "@/components/chat/message-list-provider" import { ArtifactList } from "@/components/chat/artifact" import { TaskSuggestions } from "@/components/chat/task-suggestions" +import { selectStepGroupOpen, useSessionStepDisclosureStore } from "@/react-app/domains/session/surface/step-disclosure-store" import { DescriptiveButtonContent, DescriptiveButtonDescription, @@ -170,7 +171,6 @@ function FileMessage({ part }: FileMessageProps) { alt={title} loading="lazy" decoding="async" - className="size-full object-cover" /> ) } @@ -552,11 +552,21 @@ const isMessageEmptyGroup = (messages: UIMessageWithIndex[]) => const getRenderableMessages = (messages: UIMessageWithIndex[]) => messages.flatMap((item) => { - const parts = item.message.parts.filter((part) => part.type === "text" || part.type === "file"); + const renderableMessage = getRenderableMessage(item.message); - return parts.length > 0 ? [{ ...item, message: { ...item.message, parts } }] : [] + return renderableMessage ? [{ ...item, message: renderableMessage }] : [] }) +function getRenderableMessage(message: UIMessage) { + const parts = message.parts.filter((part) => part.type === "text" || part.type === "file"); + + return parts.length > 0 ? { ...message, parts } : null; +} + +function MessageArtifacts(props: { message: UIMessage }) { + return ; +} + interface AssistantMessageGroupProps { items: UIMessageWithIndex[] messages: UIMessage[] @@ -568,8 +578,11 @@ function MessageGroup({ messages, isStreaming, }: AssistantMessageGroupProps) { - const { onRevertToUserMessage, onForkAtMessage } = useMessageList() - const [open, setOpen] = React.useState(false) + const { workspaceId, sessionId, onRevertToUserMessage, onForkAtMessage } = useMessageList() + const firstItem = items[0] + const stepGroupId = firstItem?.message.id ?? "empty-assistant-group" + const open = useSessionStepDisclosureStore((state) => selectStepGroupOpen(state.stepGroupsByWorkspace, workspaceId, sessionId, stepGroupId)) + const setStepGroupOpen = useSessionStepDisclosureStore((state) => state.setStepGroupOpen) // Only run layout animations while the collapsible is expanding/collapsing. // Otherwise (e.g. while streaming) layout changes apply instantly. const [isAnimating, setIsAnimating] = React.useState(false) @@ -598,7 +611,7 @@ function MessageGroup({ open={open} onOpenChange={(next) => { setIsAnimating(true) - setOpen(next) + setStepGroupOpen(workspaceId, sessionId, stepGroupId, next) }} > @@ -623,30 +636,38 @@ function MessageGroup({ isStreaming={isLastMessage && isStreaming} isLastStep={isLastStep} /> + ) })} - {!open ? renderableItems.map(({ index, message }) => ( - setIsAnimating(false)} - > - - - )) : null} + {!open ? items.map(({ index, message }) => { + const renderableMessage = getRenderableMessage(message) + const isLastMessage = index === messages.length - 1 + + return ( + setIsAnimating(false)} + > + {renderableMessage ? ( + + ) : null} + + + ) + }) : null} - item.message)} /> {lastTextMessage && !isStreaming && (
@@ -719,6 +740,7 @@ export function MessageList({ messages, status, retryStatus }: MessageListProps) isStreaming={isLastMessage && isStreaming} isLastStep={isLastStep} /> +
) })} diff --git a/apps/app/src/components/markdown/markdown.tsx b/apps/app/src/components/markdown/markdown.tsx index 4837bd17b4..3cc0ce906d 100644 --- a/apps/app/src/components/markdown/markdown.tsx +++ b/apps/app/src/components/markdown/markdown.tsx @@ -83,6 +83,7 @@ function createEmojiAliases() { } const emojiAliases = createEmojiAliases(); +const MARKDOWN_IMAGE_PREVIEW_MAX_HEIGHT = 100; function parseShikiLanguage(lang: string) { const normalized = lang.trim().split(/\s+/)[0]?.toLowerCase() ?? ""; @@ -93,14 +94,53 @@ function hasFencedCodeBlock(text: string) { return /(^|\n)```/.test(text); } +function estimatedRenderedImageHeight(image: HTMLImageElement) { + if (!image.naturalWidth || !image.naturalHeight) return 0; + + const renderedWidth = image.clientWidth || image.getBoundingClientRect().width; + return renderedWidth > 0 + ? (image.naturalHeight / image.naturalWidth) * renderedWidth + : image.naturalHeight; +} + +function syncMarkdownImagePreviews(root: HTMLElement) { + const previews = root.querySelectorAll("[data-openwork-image-preview]"); + + for (const preview of previews) { + if (!(preview instanceof HTMLElement)) continue; + + const image = preview.querySelector("img"); + const button = preview.querySelector("[data-openwork-image-toggle]"); + if (!(image instanceof HTMLImageElement) || !(button instanceof HTMLButtonElement)) continue; + + const previewable = estimatedRenderedImageHeight(image) > MARKDOWN_IMAGE_PREVIEW_MAX_HEIGHT; + button.hidden = !previewable; + + if (!previewable) { + preview.style.maxHeight = ""; + continue; + } + + const expanded = preview.dataset.openworkImagePreview === "expanded"; + preview.style.maxHeight = expanded ? "" : `${MARKDOWN_IMAGE_PREVIEW_MAX_HEIGHT}px`; + + const label = button.querySelector("[data-openwork-image-toggle-label]"); + if (label) label.textContent = expanded ? "Show less" : "Show full image"; + } +} + function sanitizeMarkdownHtml(value: string) { return DOMPurify.sanitize(value, { ADD_ATTR: [ "checked", "class", + "data-openwork-image-preview", + "data-openwork-image-toggle", + "data-openwork-image-toggle-label", "data-openwork-shiki", "decoding", "disabled", + "hidden", "loading", "rel", "start", @@ -177,7 +217,7 @@ const baseMarkedOptions = { const safe = escapeAttribute(safeHref(href)); const titleAttr = title ? ` title="${escapeAttribute(title)}"` : ""; - return `${escapeAttribute(text)}`; + return `${escapeAttribute(text)}`; }, table(token) { const header = token.header.map((cell) => this.tablecell({ ...cell, header: true })).join(""); @@ -311,6 +351,53 @@ function MarkdownBlockInner({ }); }, [html, highlightQuery]); + useEffect(() => { + const root = rootRef.current; + if (!root) return; + + const sync = () => syncMarkdownImagePreviews(root); + + sync(); + + const handleLoad = (event: Event) => { + if (event.target instanceof HTMLImageElement) sync(); + }; + + const handleClick = (event: MouseEvent) => { + if (!(event.target instanceof Element)) return; + + const button = event.target.closest("[data-openwork-image-toggle]"); + if (!(button instanceof HTMLButtonElement)) return; + + const preview = button.closest("[data-openwork-image-preview]"); + if (!(preview instanceof HTMLElement)) return; + + preview.dataset.openworkImagePreview = preview.dataset.openworkImagePreview === "expanded" + ? "collapsed" + : "expanded"; + sync(); + }; + + root.addEventListener("load", handleLoad, true); + root.addEventListener("click", handleClick); + + if (globalThis.ResizeObserver === undefined) { + return () => { + root.removeEventListener("load", handleLoad, true); + root.removeEventListener("click", handleClick); + }; + } + + const observer = new ResizeObserver(sync); + observer.observe(root); + + return () => { + observer.disconnect(); + root.removeEventListener("load", handleLoad, true); + root.removeEventListener("click", handleClick); + }; + }, [html]); + if (!html) { return null; } 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/components/ui/image.tsx b/apps/app/src/components/ui/image.tsx index 70e00bda9f..99b8ed2887 100644 --- a/apps/app/src/components/ui/image.tsx +++ b/apps/app/src/components/ui/image.tsx @@ -9,10 +9,13 @@ export type GeneratedImageLike = { } export type ImageProps = GeneratedImageLike & - React.ComponentProps<"img"> & { + Omit, "src"> & { alt: string + previewMaxHeight?: number } +const DEFAULT_PREVIEW_MAX_HEIGHT = 100 + function getImageSrc({ base64, mediaType, @@ -30,9 +33,14 @@ export const Image = ({ mediaType = "image/png", className, alt, + previewMaxHeight = DEFAULT_PREVIEW_MAX_HEIGHT, + onLoad, ...props }: ImageProps) => { const [objectUrl, setObjectUrl] = React.useState(undefined) + const [expanded, setExpanded] = React.useState(false) + const [canExpand, setCanExpand] = React.useState(false) + const imageRef = React.useRef(null) React.useEffect(() => { if (uint8Array && mediaType) { @@ -50,6 +58,42 @@ export const Image = ({ const base64Src = getImageSrc({ base64, mediaType }) const imageSrc = src ?? base64Src ?? objectUrl + const updateCanExpand = React.useCallback((image: HTMLImageElement) => { + if (previewMaxHeight <= 0) { + setCanExpand(false) + return + } + + if (!image.naturalWidth || !image.naturalHeight) { + setCanExpand(false) + return + } + + const renderedWidth = image.clientWidth || image.getBoundingClientRect().width + const renderedHeight = renderedWidth > 0 + ? (image.naturalHeight / image.naturalWidth) * renderedWidth + : image.naturalHeight + + setCanExpand(renderedHeight > previewMaxHeight) + }, [previewMaxHeight]) + + React.useEffect(() => { + setExpanded(false) + }, [imageSrc]) + + React.useEffect(() => { + const image = imageRef.current + if (!image) return + + updateCanExpand(image) + + if (globalThis.ResizeObserver === undefined) return + + const observer = new ResizeObserver(() => updateCanExpand(image)) + observer.observe(image) + return () => observer.disconnect() + }, [imageSrc, updateCanExpand]) + if (!imageSrc) { return (
{ + updateCanExpand(event.currentTarget) + onLoad?.(event) + }} {...props} /> ) + + if (previewMaxHeight <= 0) { + return image + } + + return ( +
+
+ {image} + {!expanded && canExpand ? ( +
+ +
+ ) : null} +
+ {expanded && canExpand ? ( + + ) : null} +
+ ) } diff --git a/apps/app/src/lib/artifacts.ts b/apps/app/src/lib/artifacts.ts index a7c98507d7..b8235ce028 100644 --- a/apps/app/src/lib/artifacts.ts +++ b/apps/app/src/lib/artifacts.ts @@ -17,9 +17,19 @@ export type ArtifactItem = { path: string type: ArtifactType messageId: string + messageIndex: number + updatedAt?: number legacy_target: OpenTarget } +type ArtifactEntry = ArtifactItem & { + sequence: number +} + +type GetArtifactsOptions = { + includeTargetFallbacks?: boolean +} + const WORKSPACES_PREFIX_PATTERN = /^workspaces\/[^/]+\//i; const WORKSPACE_ID_PREFIX_PATTERN = /^workspace\/(?:ws_[^/]+|\d+|[0-9a-f-]{6,})\//i; @@ -124,6 +134,10 @@ export function getArtifactTypeLabel(type: ArtifactType) { return ARTIFACT_TYPE_LABELS[type]; } +export function canPreviewArtifact(artifact: ArtifactItem) { + return isCollectibleArtifactTarget(artifact.legacy_target); +} + function getArtifactName(path: string) { const segments = path.split(/[/\\]/); @@ -202,10 +216,32 @@ function parseApplyPatchPaths(patchText: string) { return paths; } +const FILE_PATTERN = /(?:^|[\s"'`([{])((?:\.{1,2}[/\\]|~[/\\]|[/\\])?[\w.\-]+(?:[/\\][\w.\-]+)+\.[a-z][a-z0-9]{0,9}|[\w.\-]+\.[a-z][a-z0-9]{0,9})/gi; +const ASSISTANT_ARTIFACT_MENTION_PATTERN = /\b(?:artifact|created|deck|deliverable|exported|file|generated|opened|presentation|saved|slides?|updated|wrote)\b/i; + +function getArtifactPathsFromText(text: string) { + if (!ASSISTANT_ARTIFACT_MENTION_PATTERN.test(text)) return []; + const paths: string[] = []; + + FILE_PATTERN.lastIndex = 0; + for (const match of text.matchAll(FILE_PATTERN)) { + if (match[1] && getArtifactType(match[1]) !== "unknown") { + paths.push(match[1]); + } + } + + return paths; +} + function getArtifactPathsFromMessage(message: UIMessage) { const paths: (string | undefined)[] = []; for (const part of message.parts) { + if (part.type === "text" && message.role === "assistant") { + paths.push(...getArtifactPathsFromText(part.text)); + continue; + } + if (part.type === "source-document") { if (part.filename) { paths.push(part.filename); @@ -233,20 +269,26 @@ function getArtifactPathsFromMessage(message: UIMessage) { } } - return paths.map((path) => path?.trim().toLowerCase()).filter((path) => path) as string[]; + return paths.flatMap((path) => { + const normalized = path?.trim().toLowerCase(); + return normalized ? [normalized] : []; + }); } function addArtifact( - artifacts: Map, + artifacts: Map, path: string, messageId: string, + messageIndex: number, + sequence: number, verifiedTargets: OpenTarget[], verifiedTarget?: OpenTarget, ) { const normalized = normalizeArtifactPath(path); const key = normalized.toLowerCase(); - const name = verifiedTarget?.name ?? getArtifactName(normalized); const type = getArtifactType(normalized); + const legacyTarget = verifiedTarget ?? openTargetFromArtifactPath(normalized, getArtifactName(normalized), type, verifiedTargets); + const name = legacyTarget.name; artifacts.set(key, { id: key, @@ -254,35 +296,52 @@ function addArtifact( path: normalized, type, messageId, - legacy_target: verifiedTarget ?? openTargetFromArtifactPath(normalized, name, type, verifiedTargets), + messageIndex, + sequence, + updatedAt: legacyTarget.updatedAt, + legacy_target: legacyTarget, }); } -export function getArtifactsFromMessages(messages: UIMessage[], openTargets: OpenTarget[] = []) { - const artifacts = new Map(); +export function getArtifactsFromMessages(messages: UIMessage[], openTargets: OpenTarget[] = [], options: GetArtifactsOptions = {}) { + const artifacts = new Map(); + let sequence = 0; - for (const message of messages) { + messages.forEach((message, messageIndex) => { for (const path of getArtifactPathsFromMessage(message)) { - addArtifact(artifacts, path, message.id, openTargets); + addArtifact(artifacts, path, message.id, messageIndex, sequence, openTargets); + sequence += 1; } - } + }); - const fallbackMessageId = messages[messages.length - 1]?.id ?? "open-target"; - for (const target of openTargets) { - if (isCollectibleArtifactTarget(target)) { - addArtifact(artifacts, target.value, fallbackMessageId, openTargets, target); + if (options.includeTargetFallbacks ?? true) { + const fallbackMessageId = messages[messages.length - 1]?.id ?? "open-target"; + for (const target of openTargets) { + if (isCollectibleArtifactTarget(target)) { + addArtifact(artifacts, target.value, fallbackMessageId, messages.length, sequence, openTargets, target); + sequence += 1; + } } } - return [...artifacts.values()]; + return [...artifacts.values()].sort((left, right) => { + const updatedAtDelta = (right.updatedAt ?? 0) - (left.updatedAt ?? 0); + if (updatedAtDelta !== 0) return updatedAtDelta; + + const messageDelta = right.messageIndex - left.messageIndex; + if (messageDelta !== 0) return messageDelta; + + return right.sequence - left.sequence; + }); } -export function useArtifacts(messages: UIMessage[]) { +export function useArtifacts(messages: UIMessage[], options: GetArtifactsOptions = {}) { const { openTargets } = useOpenTargets(); + const includeTargetFallbacks = options.includeTargetFallbacks ?? false; return React.useMemo( - () => getArtifactsFromMessages(messages, openTargets), - [messages, openTargets], + () => getArtifactsFromMessages(messages, openTargets, { includeTargetFallbacks }), + [includeTargetFallbacks, messages, openTargets], ); } 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/session/surface/composer/composer.tsx b/apps/app/src/react-app/domains/session/surface/composer/composer.tsx index aba6d729b8..3da4be68a6 100644 --- a/apps/app/src/react-app/domains/session/surface/composer/composer.tsx +++ b/apps/app/src/react-app/domains/session/surface/composer/composer.tsx @@ -1030,7 +1030,7 @@ export function ReactSessionComposer(props: ComposerProps) { return (
{ @@ -1184,9 +1184,9 @@ export function ReactSessionComposer(props: ComposerProps) { }} /> - {/* Action row — attach/inbox/tools on the left, send on the right */} -
-
+ {/* Action row — attachments, quick actions, model controls, and send */} +
+
{ fileInput = element ?? undefined; @@ -1413,6 +1413,25 @@ export function ReactSessionComposer(props: ComposerProps) {
) : null}
+ + + {props.modelUnavailable ? ( + Model no longer available + ) : null} + +
{/* @@ -1498,98 +1517,6 @@ export function ReactSessionComposer(props: ComposerProps) {
- {/* Below-panel control strip: agent + model + behavior variant */} -
-
- {/* TODO: Decide what to do with agent selection before showing this control again. -
- - {agentMenuOpen ? ( -
-
- {t("composer.agent_label")} -
-
event.preventDefault()} - > - - {agents.map((agent, index) => { - const active = props.selectedAgent === agent.name; - return ( - - ); - })} -
-
- ) : null} -
- */} - - - {props.modelUnavailable ? ( - Model no longer available - ) : null} - - -
- - {/* Status label removed — redundant with the footer bar */} -
); diff --git a/apps/app/src/react-app/domains/session/surface/session-surface.tsx b/apps/app/src/react-app/domains/session/surface/session-surface.tsx index 7112b0f43e..efacb2d717 100644 --- a/apps/app/src/react-app/domains/session/surface/session-surface.tsx +++ b/apps/app/src/react-app/domains/session/surface/session-surface.tsx @@ -1222,7 +1222,7 @@ export function SessionSurface(props: SessionSurfaceProps) { />
-
+
{(props.providerConnectedCount ?? 0) === 0 ? ( +
+
+ 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 @@ -1715,6 +1719,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 @@ -1991,28 +1999,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. @@ -2021,7 +2012,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: @@ -2837,6 +2828,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 ?? "", @@ -818,6 +820,10 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { Object.values(providerAuthSnapshot.importedCloudProviders ?? {}).some(isOpenWorkCloudProvider), [providerAuthSnapshot.cloudOrgProviders, providerAuthSnapshot.importedCloudProviders], ); + const cloudManagedModelIdsByProvider = useMemo( + () => buildCloudManagedModelIdsByProvider(providerAuthSnapshot.importedCloudProviders), + [providerAuthSnapshot.importedCloudProviders], + ); const [openWorkModelsPromoHidden, setOpenWorkModelsPromoHidden] = useState(isOpenWorkModelsPromoHidden); const showOpenWorkModelsSubscribe = (!cloudSession.isSignedIn || !hasOpenWorkCloudProvider) && !openWorkModelsPromoHidden; @@ -920,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; @@ -1206,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( @@ -1240,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 })); @@ -1965,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; }) => { @@ -1978,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, @@ -2267,7 +2270,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 5a48de5881..6223fd6fcd 100644 --- a/apps/app/src/react-app/shell/welcome-route.tsx +++ b/apps/app/src/react-app/shell/welcome-route.tsx @@ -28,6 +28,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(/\/+$/, ""); @@ -109,15 +110,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..7eff9369a6 --- /dev/null +++ b/apps/desktop/electron/bootstrap-config.mjs @@ -0,0 +1,102 @@ +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)); +} diff --git a/apps/desktop/electron/bootstrap-config.test.mjs b/apps/desktop/electron/bootstrap-config.test.mjs new file mode 100644 index 0000000000..d1ad2148c6 --- /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 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"], + ); +}); 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