diff --git a/src/browser/components/ChatInput/CreationControls.remoteServers.test.tsx b/src/browser/components/ChatInput/CreationControls.remoteServers.test.tsx new file mode 100644 index 0000000000..c416adcdfd --- /dev/null +++ b/src/browser/components/ChatInput/CreationControls.remoteServers.test.tsx @@ -0,0 +1,210 @@ +import React from "react"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { GlobalWindow } from "happy-dom"; +import { cleanup, render, waitFor } from "@testing-library/react"; + +import type { APIClient } from "@/browser/contexts/API"; +import { TooltipProvider } from "@/browser/components/ui/tooltip"; +import type { WorkspaceNameState } from "@/browser/hooks/useWorkspaceName"; +import { EXPERIMENT_IDS, getExperimentKey } from "@/common/constants/experiments"; +import { RUNTIME_MODE, type ParsedRuntime } from "@/common/types/runtime"; +import type { RuntimeChoice } from "@/browser/utils/runtimeUi"; +import type { RemoteMuxServerConfig } from "@/common/types/project"; + +import { CreationControls } from "./CreationControls"; +import type { RuntimeAvailabilityState } from "./useCreationWorkspace"; + +interface RemoteMuxServerListEntry { + config: RemoteMuxServerConfig; + hasAuthToken: boolean; +} + +let projects = new Map(); + +void mock.module("@/browser/contexts/ProjectContext", () => ({ + useProjectContext: () => ({ projects }), +})); + +void mock.module("@/browser/contexts/WorkspaceContext", () => ({ + useWorkspaceContext: () => ({ + beginWorkspaceCreation: () => { + // noop for tests + }, + }), +})); + +let currentApi: { remoteServers: { list: () => Promise } } | null = + null; + +void mock.module("@/browser/contexts/API", () => ({ + useAPI: () => ({ + api: (currentApi as unknown as APIClient | null) ?? null, + status: currentApi ? ("connected" as const) : ("connecting" as const), + error: null, + }), +})); + +const DEFAULT_NAME_STATE: WorkspaceNameState = { + name: "test-workspace", + title: null, + isGenerating: false, + autoGenerate: false, + error: null, + setAutoGenerate: () => undefined, + setName: () => undefined, +}; + +const DEFAULT_RUNTIME_AVAILABILITY: RuntimeAvailabilityState = { + status: "loaded", + data: { + local: { available: true }, + worktree: { available: true }, + ssh: { available: true }, + docker: { available: true }, + devcontainer: { available: true }, + }, +}; + +const DEFAULT_RUNTIME: ParsedRuntime = { mode: RUNTIME_MODE.WORKTREE }; + +const REMOTE_MUX_SERVERS_EXPERIMENT_KEY = getExperimentKey(EXPERIMENT_IDS.REMOTE_MUX_SERVERS); + +function enableRemoteMuxServersExperiment() { + globalThis.window.localStorage.setItem(REMOTE_MUX_SERVERS_EXPERIMENT_KEY, JSON.stringify(true)); +} + +function Harness(props: { initialCreateOnRemote: boolean }) { + const [createOnRemote, setCreateOnRemote] = React.useState(props.initialCreateOnRemote); + const [remoteServerId, setRemoteServerId] = React.useState(null); + + return ( + +
+
{createOnRemote ? "remote" : "local"}
+ undefined} + selectedRuntime={DEFAULT_RUNTIME} + coderConfigFallback={{}} + sshHostFallback="" + defaultRuntimeMode={RUNTIME_MODE.WORKTREE as RuntimeChoice} + onSelectedRuntimeChange={() => undefined} + onSetDefaultRuntime={() => undefined} + disabled={false} + projectPath="/projects/demo" + projectName="demo" + nameState={DEFAULT_NAME_STATE} + runtimeAvailabilityState={DEFAULT_RUNTIME_AVAILABILITY} + createOnRemote={createOnRemote} + onCreateOnRemoteChange={setCreateOnRemote} + remoteServerId={remoteServerId} + onRemoteServerIdChange={setRemoteServerId} + /> +
+
+ ); +} + +describe("CreationControls remote server availability", () => { + beforeEach(() => { + globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis; + globalThis.document = globalThis.window.document; + globalThis.window.localStorage.clear(); + projects = new Map([["/projects/demo", {}]]); + }); + + afterEach(() => { + cleanup(); + currentApi = null; + globalThis.window = undefined as unknown as Window & typeof globalThis; + globalThis.document = undefined as unknown as Document; + }); + + test("does not render Create-on controls when no remote servers are configured", async () => { + enableRemoteMuxServersExperiment(); + + const listMock = mock(() => Promise.resolve([])); + currentApi = { + remoteServers: { + list: () => listMock(), + }, + }; + + const view = render(); + + await waitFor(() => expect(listMock.mock.calls.length).toBe(1)); + + await waitFor(() => { + expect(view.queryByLabelText("Create on")).toBeNull(); + expect(view.getByTestId("createOnRemote").textContent).toBe("local"); + }); + }); + + test("renders Create-on controls when at least one remote server is configured", async () => { + enableRemoteMuxServersExperiment(); + + const listMock = mock(() => + Promise.resolve([ + { + config: { + id: "remote-1", + label: "Remote 1", + baseUrl: "https://example.com", + enabled: true, + projectMappings: [], + }, + hasAuthToken: false, + }, + ] satisfies RemoteMuxServerListEntry[]) + ); + + currentApi = { + remoteServers: { + list: () => listMock(), + }, + }; + + const view = render(); + + await waitFor(() => expect(listMock.mock.calls.length).toBe(1)); + + await waitFor(() => { + expect(view.getByLabelText("Create on")).toBeTruthy(); + expect(view.getByTestId("createOnRemote").textContent).toBe("local"); + }); + }); + + test("does not render Create-on controls when experiment is disabled (even if remote servers are configured)", async () => { + const listMock = mock(() => + Promise.resolve([ + { + config: { + id: "remote-1", + label: "Remote 1", + baseUrl: "https://example.com", + enabled: true, + projectMappings: [], + }, + hasAuthToken: false, + }, + ] satisfies RemoteMuxServerListEntry[]) + ); + + currentApi = { + remoteServers: { + list: () => listMock(), + }, + }; + + const view = render(); + + await waitFor(() => { + expect(view.queryByLabelText("Create on")).toBeNull(); + expect(view.getByTestId("createOnRemote").textContent).toBe("local"); + }); + + expect(listMock.mock.calls.length).toBe(0); + }); +}); diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index 15a84c9133..ad8d6ecd23 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { RUNTIME_MODE, type CoderWorkspaceConfig, @@ -21,6 +21,9 @@ import { } from "../ui/select"; import { Loader2, Wand2, X } from "lucide-react"; import { PlatformPaths } from "@/common/utils/paths"; +import { useAPI } from "@/browser/contexts/API"; +import { useExperimentValue } from "@/browser/hooks/useExperiments"; +import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { useProjectContext } from "@/browser/contexts/ProjectContext"; import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { cn } from "@/common/lib/utils"; @@ -34,7 +37,7 @@ import { } from "@/browser/utils/runtimeUi"; import type { WorkspaceNameState } from "@/browser/hooks/useWorkspaceName"; import type { CoderInfo } from "@/common/orpc/schemas/coder"; -import type { SectionConfig } from "@/common/types/project"; +import type { RemoteMuxServerConfig, SectionConfig } from "@/common/types/project"; import { resolveSectionColor } from "@/common/constants/ui"; import { CoderAvailabilityMessage, @@ -104,6 +107,17 @@ function CredentialSharingCheckbox(props: { ); } +interface RemoteMuxServerListEntry { + config: RemoteMuxServerConfig; + hasAuthToken: boolean; +} + +function formatRemoteServerLabel(config: RemoteMuxServerConfig): string { + const label = config.label.trim().length > 0 ? config.label.trim() : config.id; + const baseUrl = config.baseUrl.trim(); + return baseUrl.length > 0 ? `${label} — ${baseUrl}` : label; +} + interface CreationControlsProps { branches: string[]; /** Whether branches have finished loading (to distinguish loading vs non-git repo) */ @@ -129,6 +143,12 @@ interface CreationControlsProps { nameState: WorkspaceNameState; /** Runtime availability state for each mode */ runtimeAvailabilityState: RuntimeAvailabilityState; + /** Whether to create the workspace on a remote mux server (vs locally). */ + createOnRemote: boolean; + onCreateOnRemoteChange: (createOnRemote: boolean) => void; + /** Selected remote mux server id (only used when createOnRemote is true). */ + remoteServerId: string | null; + onRemoteServerIdChange: (remoteServerId: string | null) => void; /** Available sections for this project */ sections?: SectionConfig[]; /** Currently selected section ID */ @@ -492,7 +512,121 @@ function RuntimeButtonGroup(props: RuntimeButtonGroupProps) { export function CreationControls(props: CreationControlsProps) { const { projects } = useProjectContext(); const { beginWorkspaceCreation } = useWorkspaceContext(); - const { nameState, runtimeAvailabilityState } = props; + const { + nameState, + runtimeAvailabilityState, + createOnRemote, + remoteServerId, + onCreateOnRemoteChange, + onRemoteServerIdChange, + } = props; + const { api } = useAPI(); + const remoteMuxServersEnabled = useExperimentValue(EXPERIMENT_IDS.REMOTE_MUX_SERVERS); + + const [remoteServers, setRemoteServers] = useState([]); + const [remoteServersStatus, setRemoteServersStatus] = useState<"loading" | "loaded" | "error">( + "loading" + ); + const [remoteServersError, setRemoteServersError] = useState(null); + const remoteServersRequestIdRef = useRef(0); + + useEffect(() => { + // Bump requestId so in-flight results are ignored when the experiment/API toggles. + const requestId = remoteServersRequestIdRef.current + 1; + remoteServersRequestIdRef.current = requestId; + + if (!remoteMuxServersEnabled || !api) { + setRemoteServers([]); + setRemoteServersStatus("loaded"); + setRemoteServersError(null); + return; + } + + setRemoteServersStatus("loading"); + setRemoteServersError(null); + + try { + api.remoteServers + .list() + .then((result) => { + if (remoteServersRequestIdRef.current !== requestId) { + return; + } + + setRemoteServers(result as RemoteMuxServerListEntry[]); + setRemoteServersStatus("loaded"); + }) + .catch((error: unknown) => { + if (remoteServersRequestIdRef.current !== requestId) { + return; + } + + setRemoteServers([]); + setRemoteServersStatus("error"); + setRemoteServersError(error instanceof Error ? error.message : String(error)); + }); + } catch { + // Storybook mocks (and older backends) may not expose remoteServers yet. + // Treat "remote servers unsupported" the same as "no remote servers configured": + // empty list + disable remote creation option. + if (remoteServersRequestIdRef.current !== requestId) { + return; + } + + setRemoteServers([]); + setRemoteServersStatus("loaded"); + setRemoteServersError(null); + } + }, [api, remoteMuxServersEnabled]); + + const enabledRemoteServers = remoteServers.filter((entry) => entry.config.enabled !== false); + const hasRemoteServers = enabledRemoteServers.length > 0; + const firstRemoteServerId = hasRemoteServers ? enabledRemoteServers[0].config.id : null; + + const shouldRenderCreateTargetGroup = hasRemoteServers; + + // If no remotes are configured, force creation back to local so the user can't + // get stuck in a hidden `createOnRemote=true` state. + useEffect(() => { + if (remoteServersStatus === "loading") { + return; + } + + if (hasRemoteServers) { + return; + } + + if (createOnRemote) { + onCreateOnRemoteChange(false); + } + + if (remoteServerId !== null) { + onRemoteServerIdChange(null); + } + }, [ + remoteServersStatus, + hasRemoteServers, + createOnRemote, + remoteServerId, + onCreateOnRemoteChange, + onRemoteServerIdChange, + ]); + + useEffect(() => { + if (!createOnRemote) { + return; + } + + if (typeof remoteServerId === "string" && remoteServerId.trim().length > 0) { + return; + } + + if (!firstRemoteServerId) { + return; + } + + onRemoteServerIdChange(firstRemoteServerId); + }, [createOnRemote, remoteServerId, firstRemoteServerId, onRemoteServerIdChange]); // Extract mode from discriminated union for convenience const runtimeMode = props.selectedRuntime.mode; @@ -678,6 +812,67 @@ export function CreationControls(props: CreationControlsProps) { )} + {/* Create target - local vs remote mux server */} + {shouldRenderCreateTargetGroup && ( +
+ +
+ onCreateOnRemoteChange(value === "remote")} + disabled={props.disabled} + > + + + + + Local + + Remote + + + + + {createOnRemote && ( + <> + {remoteServersStatus === "loading" && ( + + )} + {remoteServersStatus === "loaded" && hasRemoteServers && ( + onRemoteServerIdChange(value)} + disabled={props.disabled} + > + + + + + {enabledRemoteServers.map((entry) => ( + + {formatRemoteServerLabel(entry.config)} + + ))} + + + )} + + )} +
+ + {remoteServersStatus === "error" && ( +

+ Failed to load remote servers{remoteServersError ? `: ${remoteServersError}` : "."} +

+ )} +
+ )} {/* Runtime type - button group */}
diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 86f6db0421..68e5d484bf 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -744,6 +744,10 @@ const ChatInputInner: React.FC = (props) => { projectName: props.projectName, nameState: creationState.nameState, runtimeAvailabilityState: creationState.runtimeAvailabilityState, + createOnRemote: creationState.createOnRemote, + onCreateOnRemoteChange: creationState.setCreateOnRemote, + remoteServerId: creationState.remoteServerId, + onRemoteServerIdChange: creationState.setRemoteServerId, sections: creationSections, selectedSectionId, onSectionChange: handleCreationSectionChange, diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index 9a1f328466..69bbb0dc7d 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -156,6 +156,12 @@ interface UseCreationWorkspaceReturn { setDefaultRuntimeChoice: (choice: RuntimeChoice) => void; toast: Toast | null; setToast: (toast: Toast | null) => void; + /** Whether to create the workspace on a remote mux server (vs locally). */ + createOnRemote: boolean; + setCreateOnRemote: (createOnRemote: boolean) => void; + /** Remote server ID to create the workspace on (only used when createOnRemote is true). */ + remoteServerId: string | null; + setRemoteServerId: (remoteServerId: string | null) => void; isSending: boolean; handleSend: ( message: string, @@ -218,6 +224,8 @@ export function useCreationWorkspace({ const [recommendedTrunk, setRecommendedTrunk] = useState(null); const [toast, setToast] = useState(null); const [isSending, setIsSending] = useState(false); + const [createOnRemote, setCreateOnRemote] = useState(false); + const [remoteServerId, setRemoteServerId] = useState(null); // The confirmed identity being used for workspace creation (set after waitForGeneration resolves) const [creatingWithIdentity, setCreatingWithIdentity] = useState(null); const [runtimeAvailabilityState, setRuntimeAvailabilityState] = @@ -320,35 +328,50 @@ export function useCreationWorkspace({ return { success: false }; } - // Build runtime config early (used later for workspace creation) - let runtimeSelection = settings.selectedRuntime; - - if (runtimeSelection.mode === RUNTIME_MODE.DEVCONTAINER) { - const devcontainerSelection = resolveDevcontainerSelection({ - selectedRuntime: runtimeSelection, - availabilityState: runtimeAvailabilityState, + const trimmedRemoteServerId = typeof remoteServerId === "string" ? remoteServerId.trim() : ""; + if (createOnRemote && !trimmedRemoteServerId.length) { + setToast({ + id: Date.now().toString(), + type: "error", + message: "Select a remote server before creating the workspace.", }); + return { success: false }; + } - if (!devcontainerSelection.isCreatable) { - setToast({ - id: Date.now().toString(), - type: "error", - message: "Select a devcontainer configuration before creating the workspace.", + // Build runtime config early (used later for local workspace creation). + // Remote workspace creation intentionally avoids sending the local runtimeConfig to + // prevent leaking local filesystem paths into the remote server. + let runtimeConfig: RuntimeConfig | undefined; + if (!createOnRemote) { + let runtimeSelection = settings.selectedRuntime; + + if (runtimeSelection.mode === RUNTIME_MODE.DEVCONTAINER) { + const devcontainerSelection = resolveDevcontainerSelection({ + selectedRuntime: runtimeSelection, + availabilityState: runtimeAvailabilityState, }); - return { success: false }; - } - // Update selection with resolved config if different (persist the resolved value) - if (devcontainerSelection.configPath !== runtimeSelection.configPath) { - runtimeSelection = { - ...runtimeSelection, - configPath: devcontainerSelection.configPath, - }; - setSelectedRuntime(runtimeSelection); + if (!devcontainerSelection.isCreatable) { + setToast({ + id: Date.now().toString(), + type: "error", + message: "Select a devcontainer configuration before creating the workspace.", + }); + return { success: false }; + } + + // Update selection with resolved config if different (persist the resolved value) + if (devcontainerSelection.configPath !== runtimeSelection.configPath) { + runtimeSelection = { + ...runtimeSelection, + configPath: devcontainerSelection.configPath, + }; + setSelectedRuntime(runtimeSelection); + } } - } - const runtimeConfig: RuntimeConfig | undefined = buildRuntimeConfig(runtimeSelection); + runtimeConfig = buildRuntimeConfig(runtimeSelection); + } setIsSending(true); setToast(null); @@ -427,15 +450,25 @@ export function useCreationWorkspace({ } } - // Create the workspace with the generated name and title - const createResult = await api.workspace.create({ - projectPath, - branchName: identity.name, - trunkBranch: settings.trunkBranch, - title: createTitle, - runtimeConfig, - sectionId: sectionId ?? undefined, - }); + // Create the workspace with the generated name and title. + const createResult = await (createOnRemote + ? api.remoteServers.workspaceCreate({ + serverId: trimmedRemoteServerId, + localProjectPath: projectPath, + branchName: identity.name, + trunkBranch: settings.trunkBranch, + title: createTitle, + // Intentionally omit runtimeConfig when creating on a remote server. + sectionId: sectionId ?? undefined, + }) + : api.workspace.create({ + projectPath, + branchName: identity.name, + trunkBranch: settings.trunkBranch, + title: createTitle, + runtimeConfig, + sectionId: sectionId ?? undefined, + })); if (!createResult.success) { setToast({ @@ -558,6 +591,8 @@ export function useCreationWorkspace({ [ api, isSending, + createOnRemote, + remoteServerId, projectPath, projectScopeId, onWorkspaceCreated, @@ -589,6 +624,10 @@ export function useCreationWorkspace({ setDefaultRuntimeChoice, toast, setToast, + createOnRemote, + setCreateOnRemote, + remoteServerId, + setRemoteServerId, isSending, handleSend, // Workspace name/title state (for CreationControls) diff --git a/src/browser/components/ProjectPage.tsx b/src/browser/components/ProjectPage.tsx index 580b7854d4..7320041a17 100644 --- a/src/browser/components/ProjectPage.tsx +++ b/src/browser/components/ProjectPage.tsx @@ -9,6 +9,9 @@ import type { ChatInputAPI, WorkspaceCreatedOptions } from "./ChatInput/types"; import { ProjectMCPOverview } from "./ProjectMCPOverview"; import { ArchivedWorkspaces } from "./ArchivedWorkspaces"; import { useAPI } from "@/browser/contexts/API"; +import { useExperimentValue } from "@/browser/hooks/useExperiments"; +import { EXPERIMENT_IDS } from "@/common/constants/experiments"; +import { isRemoteWorkspaceId } from "@/common/utils/remoteMuxIds"; import { isWorkspaceArchived } from "@/common/utils/archive"; import { GitInitBanner } from "./GitInitBanner"; import { ConfiguredProvidersBar } from "./ConfiguredProvidersBar"; @@ -79,12 +82,25 @@ export const ProjectPage: React.FC = ({ onWorkspaceCreated, }) => { const { api } = useAPI(); + const remoteMuxServersEnabled = useExperimentValue(EXPERIMENT_IDS.REMOTE_MUX_SERVERS); const chatInputRef = useRef(null); const pendingAgentsInitSendRef = useRef(false); - // Initialize from localStorage cache to avoid flash when archived workspaces appear - const [archivedWorkspaces, setArchivedWorkspaces] = useState(() => - readPersistedState(getArchivedWorkspacesKey(projectPath), []) - ); + // Initialize from localStorage cache to avoid flash when archived workspaces appear. + // + // IMPORTANT: Respect REMOTE_MUX_SERVERS even for cached state so remote archived + // workspaces never flash in the UI when the experiment is disabled. + const [archivedWorkspaces, setArchivedWorkspaces] = useState(() => { + const cached = readPersistedState( + getArchivedWorkspacesKey(projectPath), + [] + ); + + if (remoteMuxServersEnabled) { + return cached; + } + + return cached.filter((w) => !isRemoteWorkspaceId(w.id)); + }); const [showAgentsInitNudge, setShowAgentsInitNudge] = usePersistedState( getAgentsInitNudgeKey(projectPath), false, @@ -139,14 +155,18 @@ export const ProjectPage: React.FC = ({ const archivedMapRef = useRef>(new Map()); const syncArchivedState = useCallback(() => { - const next = Array.from(archivedMapRef.current.values()); + const next = Array.from(archivedMapRef.current.values()).filter((w) => { + if (remoteMuxServersEnabled) return true; + return !isRemoteWorkspaceId(w.id); + }); + setArchivedWorkspaces((prev) => { if (archivedListsEqual(prev, next)) return prev; // Persist to localStorage for optimistic cache on next load updatePersistedState(getArchivedWorkspacesKey(projectPath), next); return next; }); - }, [projectPath]); + }, [projectPath, remoteMuxServersEnabled]); // Fetch archived workspaces for this project on mount useEffect(() => { @@ -157,7 +177,12 @@ export const ProjectPage: React.FC = ({ try { const allArchived = await api.workspace.list({ archived: true }); if (cancelled) return; - const projectArchived = allArchived.filter((w) => w.projectPath === projectPath); + + let projectArchived = allArchived.filter((w) => w.projectPath === projectPath); + if (!remoteMuxServersEnabled) { + projectArchived = projectArchived.filter((w) => !isRemoteWorkspaceId(w.id)); + } + archivedMapRef.current = new Map(projectArchived.map((w) => [w.id, w])); syncArchivedState(); } catch (error) { @@ -169,7 +194,7 @@ export const ProjectPage: React.FC = ({ return () => { cancelled = true; }; - }, [api, projectPath, syncArchivedState]); + }, [api, projectPath, remoteMuxServersEnabled, syncArchivedState]); // Subscribe to metadata events to reactively update archived list useEffect(() => { @@ -182,6 +207,11 @@ export const ProjectPage: React.FC = ({ for await (const event of iterator) { if (controller.signal.aborted) break; + // Hide remote workspaces unless the experiment is explicitly enabled. + if (!remoteMuxServersEnabled && isRemoteWorkspaceId(event.workspaceId)) { + continue; + } + const meta = event.metadata; // Only care about workspaces in this project if (meta && meta.projectPath !== projectPath) continue; @@ -206,7 +236,7 @@ export const ProjectPage: React.FC = ({ })(); return () => controller.abort(); - }, [api, projectPath, syncArchivedState]); + }, [api, projectPath, remoteMuxServersEnabled, syncArchivedState]); const didAutoFocusRef = useRef(false); @@ -356,7 +386,13 @@ export const ProjectPage: React.FC = ({ // Refresh archived list after unarchive/delete if (!api) return; void api.workspace.list({ archived: true }).then((all) => { - setArchivedWorkspaces(all.filter((w) => w.projectPath === projectPath)); + let projectArchived = all.filter((w) => w.projectPath === projectPath); + if (!remoteMuxServersEnabled) { + projectArchived = projectArchived.filter( + (w) => !isRemoteWorkspaceId(w.id) + ); + } + setArchivedWorkspaces(projectArchived); }); }} /> diff --git a/src/browser/components/Settings/SettingsModal.tsx b/src/browser/components/Settings/SettingsModal.tsx index 58555ff85d..384411cfca 100644 --- a/src/browser/components/Settings/SettingsModal.tsx +++ b/src/browser/components/Settings/SettingsModal.tsx @@ -26,6 +26,7 @@ import { GovernorSection } from "./sections/GovernorSection"; import { Button } from "@/browser/components/ui/button"; import { MCPSettingsSection } from "./sections/MCPSettingsSection"; import { SecretsSection } from "./sections/SecretsSection"; +import { RemoteServersSection } from "./sections/RemoteServersSection"; import { LayoutsSection } from "./sections/LayoutsSection"; import { ExperimentsSection } from "./sections/ExperimentsSection"; import { KeybindsSection } from "./sections/KeybindsSection"; @@ -62,6 +63,12 @@ const BASE_SECTIONS: SettingsSection[] = [ icon: , component: SecretsSection, }, + { + id: "remoteServers", + label: "Remote Servers", + icon: , + component: RemoteServersSection, + }, { id: "models", label: "Models", @@ -92,19 +99,33 @@ export function SettingsModal() { const { isOpen, close, activeSection, setActiveSection } = useSettings(); const system1Enabled = useExperimentValue(EXPERIMENT_IDS.SYSTEM_1); const governorEnabled = useExperimentValue(EXPERIMENT_IDS.MUX_GOVERNOR); + const remoteMuxServersEnabled = useExperimentValue(EXPERIMENT_IDS.REMOTE_MUX_SERVERS); // Reset activeSection if the experiment is disabled React.useEffect(() => { + const fallbackSectionId = BASE_SECTIONS[0]?.id ?? "general"; + if (!system1Enabled && activeSection === "system1") { - setActiveSection(BASE_SECTIONS[0]?.id ?? "general"); + setActiveSection(fallbackSectionId); + return; } + if (!governorEnabled && activeSection === "governor") { - setActiveSection(BASE_SECTIONS[0]?.id ?? "general"); + setActiveSection(fallbackSectionId); + return; + } + + if (!remoteMuxServersEnabled && activeSection === "remoteServers") { + setActiveSection(fallbackSectionId); } - }, [activeSection, setActiveSection, system1Enabled, governorEnabled]); + }, [activeSection, setActiveSection, system1Enabled, governorEnabled, remoteMuxServersEnabled]); + + const visibleBaseSections = remoteMuxServersEnabled + ? BASE_SECTIONS + : BASE_SECTIONS.filter((section) => section.id !== "remoteServers"); // Build sections list based on enabled experiments - let sections: SettingsSection[] = BASE_SECTIONS; + let sections: SettingsSection[] = visibleBaseSections; if (system1Enabled) { sections = [ ...sections, diff --git a/src/browser/components/Settings/sections/RemoteServersSection.tsx b/src/browser/components/Settings/sections/RemoteServersSection.tsx new file mode 100644 index 0000000000..1b07ba5f30 --- /dev/null +++ b/src/browser/components/Settings/sections/RemoteServersSection.tsx @@ -0,0 +1,969 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { CheckCircle, Loader2, Pencil, Plus, Trash2, X, XCircle } from "lucide-react"; + +import { useAPI, type APIClient } from "@/browser/contexts/API"; +import { Button } from "@/browser/components/ui/button"; +import { Input } from "@/browser/components/ui/input"; +import { Switch } from "@/browser/components/ui/switch"; +import { cn } from "@/common/lib/utils"; +import type { RemoteMuxServerConfig, RemoteMuxServerProjectMapping } from "@/common/types/project"; + +interface RemoteMuxServerListEntry { + config: RemoteMuxServerConfig; + hasAuthToken: boolean; +} + +interface PingStatus { + status: "idle" | "loading" | "success" | "error"; + message?: string; +} + +interface LoadStatus { + status: "idle" | "loading" | "success" | "error"; + message?: string; +} + +interface Notice { + type: "success" | "error"; + message: string; +} + +type EditorState = { mode: "add" } | { mode: "edit"; id: string }; + +type EditorMode = EditorState["mode"]; + +interface ProjectPathSuggestion { + path: string; + label: string; +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return String(error); +} + +function generateRemoteServerId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + + const random = Math.random().toString(16).slice(2); + return `remote-${Date.now()}-${random}`; +} + +function createBlankMapping(): RemoteMuxServerProjectMapping { + return { localProjectPath: "", remoteProjectPath: "" }; +} + +function ensureAtLeastOneMapping( + mappings: RemoteMuxServerProjectMapping[] +): RemoteMuxServerProjectMapping[] { + if (mappings.length > 0) { + return mappings; + } + + return [createBlankMapping()]; +} + +function createDraftConfig(id: string): RemoteMuxServerConfig { + return { + id, + label: "", + baseUrl: "", + enabled: true, + projectMappings: [createBlankMapping()], + }; +} + +function formatPingPayload(payload: unknown): string { + if (payload === null || payload === undefined) { + return "OK"; + } + + if (typeof payload === "string") { + const trimmed = payload.trim(); + return trimmed ? `OK — ${trimmed}` : "OK"; + } + + if (typeof payload === "object" && !Array.isArray(payload)) { + const record = payload as Record; + const rawVersion = + typeof record.git_describe === "string" + ? record.git_describe.trim() + : typeof record.version === "string" + ? record.version.trim() + : ""; + + if (rawVersion) { + const version = + rawVersion.startsWith("v") || rawVersion.startsWith("V") + ? `v${rawVersion.slice(1)}` + : `v${rawVersion}`; + return `OK — Mux ${version}`; + } + } + + try { + const json = JSON.stringify(payload); + if (json.length <= 200) { + return `OK — ${json}`; + } + + return `OK — ${json.slice(0, 200)}…`; + } catch { + return "OK"; + } +} + +function getPathBasename(filePath: string): string { + const trimmed = filePath.trim().replace(/[/\\]+$/g, ""); + if (!trimmed) { + return ""; + } + + const segments = trimmed.split(/[/\\]/); + const lastSegment = segments.at(-1); + + return lastSegment ?? trimmed; +} + +function createPathSuggestion(filePath: string): ProjectPathSuggestion | null { + const trimmed = filePath.trim(); + if (!trimmed) { + return null; + } + + const label = getPathBasename(trimmed) || trimmed; + return { path: trimmed, label }; +} + +function getRemotePathPlaceholder(mapping: RemoteMuxServerProjectMapping): string { + const localBasename = mapping.localProjectPath.trim() + ? getPathBasename(mapping.localProjectPath) + : ""; + if (!localBasename) { + return "Remote project path"; + } + + return `Remote project path (e.g., /…/${localBasename})`; +} + +export function RemoteServersSection() { + const { api } = useAPI(); + + const [servers, setServers] = useState([]); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const requestIdRef = useRef(0); + + const [pingById, setPingById] = useState>({}); + + const [notice, setNotice] = useState(null); + + const [editorState, setEditorState] = useState(null); + + const [draftConfig, setDraftConfig] = useState(() => + createDraftConfig(generateRemoteServerId()) + ); + const [draftHasAuthToken, setDraftHasAuthToken] = useState(false); + const [authToken, setAuthToken] = useState(""); + const [clearAuthTokenOnSave, setClearAuthTokenOnSave] = useState(false); + const [saving, setSaving] = useState(false); + + const [localProjectSuggestions, setLocalProjectSuggestions] = useState( + [] + ); + const [localProjectsError, setLocalProjectsError] = useState(null); + const localProjectsRequestIdRef = useRef(0); + + const [remoteProjectSuggestions, setRemoteProjectSuggestions] = useState( + [] + ); + const [remoteProjectsStatus, setRemoteProjectsStatus] = useState({ status: "idle" }); + const remoteProjectsRequestIdRef = useRef(0); + + const loadServers = useCallback(async () => { + if (!api) { + setServers([]); + return; + } + + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + + setLoading(true); + setLoadError(null); + + try { + const remoteServersApi: Partial["remoteServers"] = api.remoteServers; + if (!remoteServersApi) { + if (requestIdRef.current !== requestId) { + return; + } + + setServers([]); + return; + } + + const result = (await remoteServersApi.list()) as RemoteMuxServerListEntry[]; + if (requestIdRef.current !== requestId) { + return; + } + + setServers(result); + } catch (error) { + if (requestIdRef.current !== requestId) { + return; + } + + setLoadError(getErrorMessage(error)); + } finally { + if (requestIdRef.current === requestId) { + setLoading(false); + } + } + }, [api]); + + const loadLocalProjects = useCallback(async () => { + if (!api) { + setLocalProjectSuggestions([]); + setLocalProjectsError(null); + return; + } + + const requestId = localProjectsRequestIdRef.current + 1; + localProjectsRequestIdRef.current = requestId; + + try { + const projects = await api.projects.list(); + if (localProjectsRequestIdRef.current !== requestId) { + return; + } + + const suggestions = projects + .map(([projectPath]) => createPathSuggestion(projectPath)) + .filter((entry): entry is ProjectPathSuggestion => entry !== null) + .sort((a, b) => a.label.localeCompare(b.label) || a.path.localeCompare(b.path)); + + setLocalProjectSuggestions(suggestions); + setLocalProjectsError(null); + } catch (error) { + if (localProjectsRequestIdRef.current !== requestId) { + return; + } + + setLocalProjectSuggestions([]); + setLocalProjectsError(getErrorMessage(error)); + } + }, [api]); + + const loadRemoteProjects = useCallback( + async (serverId: string) => { + if (!api) { + setRemoteProjectSuggestions([]); + setRemoteProjectsStatus({ status: "idle" }); + return; + } + + const remoteServersApi: Partial["remoteServers"] = api.remoteServers; + if (!remoteServersApi?.listRemoteProjects) { + setRemoteProjectSuggestions([]); + setRemoteProjectsStatus({ + status: "error", + message: "Remote project suggestions are not supported by this backend.", + }); + return; + } + + const requestId = remoteProjectsRequestIdRef.current + 1; + remoteProjectsRequestIdRef.current = requestId; + + setRemoteProjectsStatus({ status: "loading" }); + + try { + const result = await remoteServersApi.listRemoteProjects({ id: serverId }); + + if (remoteProjectsRequestIdRef.current !== requestId) { + return; + } + + if (!result.success) { + setRemoteProjectSuggestions([]); + setRemoteProjectsStatus({ status: "error", message: result.error }); + return; + } + + const suggestions = result.data + .map((entry) => createPathSuggestion(entry.path)) + .filter((entry): entry is ProjectPathSuggestion => entry !== null) + .sort((a, b) => a.label.localeCompare(b.label) || a.path.localeCompare(b.path)); + + setRemoteProjectSuggestions(suggestions); + setRemoteProjectsStatus({ status: "success" }); + } catch (error) { + if (remoteProjectsRequestIdRef.current !== requestId) { + return; + } + + setRemoteProjectSuggestions([]); + setRemoteProjectsStatus({ status: "error", message: getErrorMessage(error) }); + } + }, + [api] + ); + + useEffect(() => { + void loadServers(); + void loadLocalProjects(); + }, [loadLocalProjects, loadServers]); + + useEffect(() => { + if (editorState?.mode !== "edit") { + remoteProjectsRequestIdRef.current += 1; + setRemoteProjectSuggestions([]); + setRemoteProjectsStatus({ status: "idle" }); + return; + } + + void loadRemoteProjects(editorState.id); + }, [editorState, loadRemoteProjects]); + + const closeEditor = useCallback(() => { + remoteProjectsRequestIdRef.current += 1; + + setEditorState(null); + setDraftConfig(createDraftConfig(generateRemoteServerId())); + setDraftHasAuthToken(false); + setAuthToken(""); + setClearAuthTokenOnSave(false); + setNotice(null); + + setRemoteProjectSuggestions([]); + setRemoteProjectsStatus({ status: "idle" }); + }, []); + + const startAdd = useCallback(() => { + remoteProjectsRequestIdRef.current += 1; + + setEditorState({ mode: "add" }); + setDraftConfig(createDraftConfig(generateRemoteServerId())); + setDraftHasAuthToken(false); + setAuthToken(""); + setClearAuthTokenOnSave(false); + setNotice(null); + + setRemoteProjectSuggestions([]); + setRemoteProjectsStatus({ status: "idle" }); + }, []); + + const startEdit = useCallback((entry: RemoteMuxServerListEntry) => { + remoteProjectsRequestIdRef.current += 1; + + setEditorState({ mode: "edit", id: entry.config.id }); + setDraftConfig({ + ...entry.config, + enabled: entry.config.enabled !== false, + projectMappings: ensureAtLeastOneMapping( + entry.config.projectMappings.map((mapping) => ({ ...mapping })) + ), + }); + setDraftHasAuthToken(entry.hasAuthToken); + setAuthToken(""); + setClearAuthTokenOnSave(false); + setNotice(null); + + setRemoteProjectSuggestions([]); + setRemoteProjectsStatus({ status: "idle" }); + }, []); + + const handleRemove = useCallback( + async (id: string) => { + if (!api) { + return; + } + + const confirmed = window.confirm("Remove this remote server?"); + if (!confirmed) { + return; + } + + setNotice(null); + + const remoteServersApi: Partial["remoteServers"] = api.remoteServers; + if (!remoteServersApi) { + setNotice({ + type: "error", + message: "Remote servers are not supported by this backend.", + }); + return; + } + + try { + const result = await remoteServersApi.remove({ id }); + if (!result.success) { + setNotice({ type: "error", message: result.error }); + return; + } + } catch (error) { + setNotice({ type: "error", message: getErrorMessage(error) }); + return; + } + + if (editorState?.mode === "edit" && editorState.id === id) { + closeEditor(); + } + + await loadServers(); + }, + [api, closeEditor, editorState, loadServers] + ); + + const handlePing = useCallback( + async (id: string) => { + if (!api) { + return; + } + + setPingById((prev) => ({ + ...prev, + [id]: { status: "loading" }, + })); + + const remoteServersApi: Partial["remoteServers"] = api.remoteServers; + if (!remoteServersApi) { + setPingById((prev) => ({ + ...prev, + [id]: { + status: "error", + message: "Remote servers are not supported by this backend.", + }, + })); + return; + } + + try { + const result = await remoteServersApi.ping({ id }); + + if (result.success) { + setPingById((prev) => ({ + ...prev, + [id]: { status: "success", message: formatPingPayload(result.data.version) }, + })); + } else { + setPingById((prev) => ({ + ...prev, + [id]: { status: "error", message: result.error }, + })); + } + } catch (error) { + setPingById((prev) => ({ + ...prev, + [id]: { status: "error", message: getErrorMessage(error) }, + })); + } + }, + [api] + ); + + const handleSave = useCallback(async () => { + if (!api) { + return; + } + + if (!editorState) { + return; + } + + const remoteServersApi: Partial["remoteServers"] = api.remoteServers; + if (!remoteServersApi) { + setNotice({ + type: "error", + message: "Remote servers are not supported by this backend.", + }); + return; + } + + const trimmedLabel = draftConfig.label.trim(); + if (!trimmedLabel) { + setNotice({ type: "error", message: "Label is required." }); + return; + } + + const trimmedBaseUrl = draftConfig.baseUrl.trim(); + if (!trimmedBaseUrl) { + setNotice({ type: "error", message: "Base URL is required." }); + return; + } + + setSaving(true); + setNotice(null); + + const tokenToSend = clearAuthTokenOnSave ? "" : authToken.trim() ? authToken : undefined; + + try { + const result = await remoteServersApi.upsert({ + config: { + ...draftConfig, + label: trimmedLabel, + baseUrl: trimmedBaseUrl, + projectMappings: draftConfig.projectMappings.map((mapping) => ({ ...mapping })), + }, + authToken: tokenToSend, + }); + + if (!result.success) { + setNotice({ type: "error", message: result.error }); + return; + } + + await loadServers(); + closeEditor(); + setNotice({ type: "success", message: editorState.mode === "add" ? "Added." : "Saved." }); + } catch (error) { + setNotice({ type: "error", message: getErrorMessage(error) }); + } finally { + setSaving(false); + } + }, [api, authToken, clearAuthTokenOnSave, closeEditor, draftConfig, editorState, loadServers]); + + const handleMappingChange = useCallback( + (index: number, next: Partial) => { + setDraftConfig((prev) => { + const nextMappings = prev.projectMappings.map((mapping, idx) => + idx === index ? { ...mapping, ...next } : mapping + ); + return { ...prev, projectMappings: nextMappings }; + }); + }, + [] + ); + + const handleAddMapping = useCallback(() => { + setDraftConfig((prev) => ({ + ...prev, + projectMappings: [...prev.projectMappings, createBlankMapping()], + })); + }, []); + + const handleRemoveMapping = useCallback((index: number) => { + setDraftConfig((prev) => { + const next = prev.projectMappings.filter((_, idx) => idx !== index); + return { + ...prev, + projectMappings: ensureAtLeastOneMapping(next), + }; + }); + }, []); + + const editorMode: EditorMode | null = editorState?.mode ?? null; + const editorDatalistIdLocal = `remote-server-local-projects-${draftConfig.id}`; + const editorDatalistIdRemote = `remote-server-remote-projects-${draftConfig.id}`; + + const editorForm = editorMode ? ( +
+
+ + setDraftConfig((prev) => ({ ...prev, label: e.target.value }))} + placeholder="e.g., Work desktop" + /> +
+ +
+ + + setDraftConfig((prev) => ({ + ...prev, + baseUrl: e.target.value, + })) + } + placeholder="https://example.com" + spellCheck={false} + className="font-mono" + /> +
+ +
+
+
Enabled
+
+ Disabled servers are ignored for remote workspaces. +
+
+ + setDraftConfig((prev) => ({ + ...prev, + enabled: checked, + })) + } + aria-label="Toggle remote server enabled" + /> +
+ +
+
+
+ +
+ {editorMode === "edit" && draftHasAuthToken + ? "Token is configured locally. Leave blank to keep the existing token." + : ""} + {editorMode === "edit" && draftHasAuthToken ? " " : ""} + Stored locally on this machine in{" "} + ~/.mux/secrets.json. The token expected by the + remote server is configured on that machine in + ~/.mux/server.lock. + {clearAuthTokenOnSave && ( + Will clear the local token on save. + )} +
+
+ + {editorMode === "edit" && draftHasAuthToken && ( + + )} +
+ + { + setAuthToken(e.target.value); + setClearAuthTokenOnSave(false); + }} + placeholder={editorMode === "edit" ? "Enter new token" : "Enter token"} + spellCheck={false} + autoComplete="off" + /> +
+ +
+
+
+
Project mappings
+
+ Map each local project path to the corresponding path on the remote server. +
+
+ Local suggestions come from your configured projects. + {editorMode === "edit" ? " Remote suggestions come from the remote server." : ""} +
+ {localProjectsError && ( +
{localProjectsError}
+ )} + {editorMode === "edit" && remoteProjectsStatus.status === "loading" && ( +
+ + Loading remote projects… +
+ )} + {editorMode === "edit" && + remoteProjectsStatus.status === "error" && + remoteProjectsStatus.message && ( +
{remoteProjectsStatus.message}
+ )} + {editorMode === "add" && ( +
+ Tip: save the server first to load remote project suggestions. +
+ )} +
+ +
+ +
+ {draftConfig.projectMappings.map((mapping, idx) => ( +
+ + handleMappingChange(idx, { + localProjectPath: e.target.value, + }) + } + placeholder="Local project path" + spellCheck={false} + className="font-mono" + list={editorDatalistIdLocal} + /> + + handleMappingChange(idx, { + remoteProjectPath: e.target.value, + }) + } + placeholder={ + remoteProjectSuggestions.length > 0 + ? "Remote project path" + : getRemotePathPlaceholder(mapping) + } + spellCheck={false} + className="font-mono" + list={editorDatalistIdRemote} + /> + +
+ ))} +
+ + + {localProjectSuggestions.map((suggestion) => ( + + + {remoteProjectSuggestions.map((suggestion) => ( + +
+ +
+ + + {editorMode === "edit" && ( + + )} +
+
+ ) : null; + + return ( +
+
+

Remote servers

+

+ Configure remote mux API servers and map remote project paths to local ones. +

+
+ +
+
+

Configured

+ +
+ + {notice && ( +
+ {notice.type === "success" ? ( + + ) : ( + + )} + {notice.message} +
+ )} + + {editorMode === "add" && ( +
+
+
+
Add server
+
+ ID: {draftConfig.id} +
+
+ + +
+ + {editorForm} +
+ )} + + {loadError && ( +
+ + {loadError} +
+ )} + + {loading ? ( +
+ + Loading… +
+ ) : servers.length === 0 ? ( +
No remote servers configured.
+ ) : ( +
+ {servers.map((entry) => { + const { config } = entry; + const enabled = config.enabled !== false; + const pingStatus = pingById[config.id] ?? { status: "idle" }; + + const isEditingThis = editorState?.mode === "edit" && editorState.id === config.id; + + return ( +
+ {isEditingThis ? ( +
+
+
+
+ Edit {config.label || config.id} +
+
+ ID: {draftConfig.id} +
+
+ + +
+ + {editorForm} +
+ ) : ( +
+
+
+ {config.label || config.id} +
+
+ {config.baseUrl} +
+
+ Enabled: {enabled ? "Yes" : "No"} + Mappings: {config.projectMappings.length} + Token: {entry.hasAuthToken ? "Configured" : "—"} +
+
+ +
+ + + +
+
+ )} + + {pingStatus.status === "success" && pingStatus.message && ( +
+ + {pingStatus.message} +
+ )} + {pingStatus.status === "error" && pingStatus.message && ( +
+ + {pingStatus.message} +
+ )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/src/browser/components/WorkspaceHeader.tsx b/src/browser/components/WorkspaceHeader.tsx index 5ceb37a715..bb88ec626f 100644 --- a/src/browser/components/WorkspaceHeader.tsx +++ b/src/browser/components/WorkspaceHeader.tsx @@ -1,7 +1,8 @@ import React, { useCallback, useEffect, useState } from "react"; -import { Bell, BellOff, Menu, Pencil, Server } from "lucide-react"; +import { Bell, BellOff, Globe, Menu, Pencil, Server } from "lucide-react"; import { CUSTOM_EVENTS } from "@/common/constants/events"; import { cn } from "@/common/lib/utils"; +import { decodeRemoteWorkspaceId } from "@/common/utils/remoteMuxIds"; import { RIGHT_SIDEBAR_COLLAPSED_KEY, @@ -68,6 +69,7 @@ export const WorkspaceHeader: React.FC = ({ const openTerminalPopout = useOpenTerminal(); const openInEditor = useOpenInEditor(); const gitStatus = useGitStatus(workspaceId); + const remoteWorkspaceInfo = decodeRemoteWorkspaceId(workspaceId); const { canInterrupt, isStarting, awaitingUserQuestion, loadedSkills } = useWorkspaceSidebarState(workspaceId); const isWorking = (canInterrupt || isStarting) && !awaitingUserQuestion; @@ -221,6 +223,21 @@ export const WorkspaceHeader: React.FC = ({ )} + {remoteWorkspaceInfo && ( + + + + + + + + Remote workspace (Mux server: {remoteWorkspaceInfo.serverId}) + + + )} = ({ tooltipSide="bottom" /> {projectName} -
- - -
+ {/* Git controls don't work for remote workspaces (no local repo) */} + {!remoteWorkspaceInfo && ( +
+ + +
+ )}
diff --git a/src/browser/components/WorkspaceHoverPreview.tsx b/src/browser/components/WorkspaceHoverPreview.tsx index 52e81a88b5..421030665e 100644 --- a/src/browser/components/WorkspaceHoverPreview.tsx +++ b/src/browser/components/WorkspaceHoverPreview.tsx @@ -1,3 +1,4 @@ +import { Globe } from "lucide-react"; import { cn } from "@/common/lib/utils"; import { RuntimeBadge } from "./RuntimeBadge"; import { BranchSelector } from "./BranchSelector"; @@ -9,6 +10,7 @@ interface WorkspaceHoverPreviewProps { projectName: string; workspaceName: string; namedWorkspacePath: string; + remoteServerId?: string; runtimeConfig?: RuntimeConfig; isWorking: boolean; className?: string; @@ -23,12 +25,18 @@ export function WorkspaceHoverPreview({ projectName, workspaceName, namedWorkspacePath, + remoteServerId, runtimeConfig, isWorking, className, }: WorkspaceHoverPreviewProps) { return (
+ {remoteServerId && ( + + )} ) : ( - - { - if (isDisabled) return; - e.stopPropagation(); - startEditing(); - }} - > - {/* Always render text in same structure; Shimmer just adds animation class */} - + + { + if (isDisabled) return; + e.stopPropagation(); + startEditing(); + }} > - {displayTitle} - - - + {/* Always render text in same structure; Shimmer just adds animation class */} + + {displayTitle} + + + +
+ {remoteWorkspaceInfo && ( +
+ Mux server:{" "} + {remoteWorkspaceInfo.serverId} +
+ )}
)} - {!isCreating && !isEditing && ( + {/* Git controls don't work for remote workspaces (no local repo) */} + {!isCreating && !isEditing && !remoteWorkspaceInfo && (
@@ -835,6 +840,25 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { [navigateToWorkspace, navigateToHome] ); + // Best-effort: when the remote mux servers experiment is disabled, make sure we + // don't stay routed to a remote workspace id (which would otherwise show a + // confusing "workspace not found" state). + useEffect(() => { + if (remoteMuxServersEnabled) { + return; + } + + if (!currentWorkspaceId) { + return; + } + + if (!isRemoteWorkspaceId(currentWorkspaceId)) { + return; + } + + setSelectedWorkspace(null); + }, [currentWorkspaceId, remoteMuxServersEnabled, setSelectedWorkspace]); + // Used by async subscription handlers to safely access the most recent metadata map // without triggering render-phase state updates. const workspaceMetadataRef = useRef(workspaceMetadata); @@ -876,6 +900,8 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { const metadataList = await api.workspace.list(); const metadataMap = new Map(); for (const metadata of metadataList) { + if (!remoteMuxServersEnabled && isRemoteWorkspaceId(metadata.id)) continue; + // Skip archived workspaces - they should not be tracked by the app if (isWorkspaceArchived(metadata.archivedAt, metadata.unarchivedAt)) continue; ensureCreatedAt(metadata); @@ -890,7 +916,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { setWorkspaceMetadata(new Map()); return true; // Still return true - we tried to load, just got empty result } - }, [setWorkspaceMetadata, api]); + }, [setWorkspaceMetadata, api, remoteMuxServersEnabled]); // Load metadata once on mount (and again when api becomes available) useEffect(() => { @@ -979,6 +1005,11 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { for await (const event of iterator) { if (signal.aborted) break; + // Hide remote workspaces unless the experiment is explicitly enabled. + if (!remoteMuxServersEnabled && isRemoteWorkspaceId(event.workspaceId)) { + continue; + } + const meta = event.metadata; // 1. ALWAYS normalize incoming metadata first - this is the critical data update. @@ -1091,7 +1122,14 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { return () => { controller.abort(); }; - }, [navigateToProject, refreshProjects, setSelectedWorkspace, setWorkspaceMetadata, api]); + }, [ + navigateToProject, + refreshProjects, + setSelectedWorkspace, + setWorkspaceMetadata, + api, + remoteMuxServersEnabled, + ]); const createWorkspace = useCallback( async ( diff --git a/src/browser/stories/App.chat.stories.tsx b/src/browser/stories/App.chat.stories.tsx index e463fd1bcf..14d8919e65 100644 --- a/src/browser/stories/App.chat.stories.tsx +++ b/src/browser/stories/App.chat.stories.tsx @@ -25,6 +25,8 @@ import type { WorkspaceChatMessage } from "@/common/orpc/types"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; import { getModelKey } from "@/common/constants/storage"; +import { EXPERIMENT_IDS, getExperimentKey } from "@/common/constants/experiments"; +import { encodeRemoteWorkspaceId } from "@/common/utils/remoteMuxIds"; import { waitForChatMessagesLoaded } from "./storyPlayHelpers.js"; import { setupSimpleChatStory, setupStreamingChatStory, setWorkspaceInput } from "./storyHelpers"; import { within, userEvent, waitFor } from "@storybook/test"; @@ -156,6 +158,70 @@ export const WithSkillCommand: AppStory = { ), }; +/** Remote workspace selected (experiment gated) */ +export const RemoteWorkspaceSelected: AppStory = { + render: () => ( + { + window.localStorage.setItem( + getExperimentKey(EXPERIMENT_IDS.REMOTE_MUX_SERVERS), + JSON.stringify(true) + ); + + const serverId = "server-work"; + const workspaceId = encodeRemoteWorkspaceId(serverId, "ws-remote-456"); + + return setupSimpleChatStory({ + workspaceId, + workspaceName: "feature/remote", + messages: [ + createUserMessage("msg-1", "Show me the workspace view for this remote server.", { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 120000, + }), + createAssistantMessage( + "msg-2", + "Sure — remote workspaces should behave like local ones in the UI.", + { + historySequence: 2, + timestamp: STABLE_TIMESTAMP - 110000, + } + ), + ], + }); + }} + /> + ), + play: async ({ canvasElement }) => { + const storyRoot = document.getElementById("storybook-root") ?? canvasElement; + await waitForChatMessagesLoaded(storyRoot); + + const canvas = within(storyRoot); + const header = await canvas.findByTestId("workspace-header"); + const headerCanvas = within(header); + + const globe = await headerCanvas.findByLabelText( + /Remote workspace \(Mux server: server-work\)/i + ); + + // Hover to open the globe tooltip and keep it open for the Chromatic snapshot. + await userEvent.hover(globe); + + await waitFor( + () => { + const tooltip = document.querySelector('[role="tooltip"]'); + if (!tooltip) throw new Error("Tooltip not visible"); + + const tooltipText = tooltip.textContent ?? ""; + if (!tooltipText.includes("Remote workspace (Mux server: server-work)")) { + throw new Error(`Unexpected tooltip text: ${JSON.stringify(tooltipText)}`); + } + }, + { timeout: 2000, interval: 50 } + ); + }, +}; + /** Basic chat conversation with various message types */ export const Conversation: AppStory = { render: () => ( diff --git a/src/browser/stories/App.settings.stories.tsx b/src/browser/stories/App.settings.stories.tsx index 50a9fc675c..d826a73a50 100644 --- a/src/browser/stories/App.settings.stories.tsx +++ b/src/browser/stories/App.settings.stories.tsx @@ -5,6 +5,7 @@ * - General (theme toggle) * - Agents (task parallelism / nesting) * - Providers (API key configuration) + * - Remote Servers (remote mux server configuration) * - Models (custom model management) * - Modes (per-mode default model / reasoning) * - Experiments @@ -18,7 +19,7 @@ import type { APIClient } from "@/browser/contexts/API"; import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; import { createWorkspace, groupWorkspacesByProject } from "./mockFactory"; import { selectWorkspace } from "./storyHelpers"; -import { createMockORPCClient } from "@/browser/stories/mocks/orpc"; +import { createMockORPCClient, type RemoteMuxServerListEntry } from "@/browser/stories/mocks/orpc"; import { within, userEvent, waitFor } from "@storybook/test"; import { getExperimentKey, @@ -48,6 +49,7 @@ function setupSettingsStory(options: { providersList?: string[]; agentAiDefaults?: AgentAiDefaults; taskSettings?: Partial; + remoteServers?: RemoteMuxServerListEntry[]; /** Pre-set experiment states in localStorage before render */ experiments?: Partial>; }): APIClient { @@ -66,6 +68,7 @@ function setupSettingsStory(options: { return createMockORPCClient({ projects: groupWorkspacesByProject(workspaces), workspaces, + remoteServers: options.remoteServers, providersConfig: options.providersConfig ?? {}, agentAiDefaults: options.agentAiDefaults, providersList: options.providersList ?? ["anthropic", "openai", "xai"], @@ -478,6 +481,89 @@ export const System1: AppStory = { }, }; +/** Remote Servers section - experiment gated */ +export const RemoteServers: AppStory = { + render: () => ( + + setupSettingsStory({ + experiments: { [EXPERIMENT_IDS.REMOTE_MUX_SERVERS]: true }, + remoteServers: [ + { + config: { + id: "server-work", + label: "Work desktop", + baseUrl: "https://mux-work.example.com", + enabled: true, + projectMappings: [ + { + localProjectPath: "/home/user/projects/my-app", + remoteProjectPath: "/Users/alex/projects/my-app", + }, + { + localProjectPath: "/home/user/projects/backend", + remoteProjectPath: "/Users/alex/projects/backend", + }, + ], + }, + hasAuthToken: true, + }, + { + config: { + id: "server-staging", + label: "Staging mux", + baseUrl: "http://staging.mux.internal:9876", + enabled: false, + projectMappings: [ + { + localProjectPath: "/home/user/projects/my-app", + remoteProjectPath: "/srv/my-app", + }, + ], + }, + hasAuthToken: false, + }, + ], + }) + } + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await openSettingsToSection(canvasElement, "remote servers"); + + const body = within(canvasElement.ownerDocument.body); + const dialog = await body.findByRole("dialog"); + const dialogCanvas = within(dialog); + + await dialogCanvas.findByRole("heading", { name: /remote servers/i }); + await dialogCanvas.findByText(/Work desktop/i); + + const editButtons = await dialogCanvas.findAllByRole("button", { name: /^Edit$/i }); + if (editButtons.length < 1) { + throw new Error("Expected at least one remote server Edit button"); + } + + await userEvent.click(editButtons[0]); + + // Editor header includes the server label/id (e.g., "Edit Work desktop"). + await dialogCanvas.findByText(/^Edit Work desktop$/i); + await dialogCanvas.findByRole("button", { name: /^Clear local token$/i }); + + // Verify mappings are loaded into the editor. + await dialogCanvas.findByDisplayValue("/home/user/projects/my-app"); + await dialogCanvas.findByDisplayValue("/Users/alex/projects/my-app"); + + const pingButtons = await dialogCanvas.findAllByRole("button", { name: /^Ping$/i }); + if (pingButtons.length < 1) { + throw new Error("Expected at least one remote server Ping button"); + } + + await userEvent.click(pingButtons[0]); + + await dialogCanvas.findByText(/OK — Mux v0\.0\.0-mock/i); + }, +}; + /** Experiments section - shows available experiments */ export const Experiments: AppStory = { render: () => setupSettingsStory({})} />, diff --git a/src/browser/stories/App.sidebar.stories.tsx b/src/browser/stories/App.sidebar.stories.tsx index 829672b061..c24e1c0dec 100644 --- a/src/browser/stories/App.sidebar.stories.tsx +++ b/src/browser/stories/App.sidebar.stories.tsx @@ -23,6 +23,8 @@ import { setWorkspaceDrafts, } from "./storyHelpers"; import { GIT_STATUS_INDICATOR_MODE_KEY } from "@/common/constants/storage"; +import { EXPERIMENT_IDS, getExperimentKey } from "@/common/constants/experiments"; +import { encodeRemoteWorkspaceId } from "@/common/utils/remoteMuxIds"; import { within, userEvent, waitFor } from "@storybook/test"; import { createMockORPCClient } from "@/browser/stories/mocks/orpc"; @@ -153,6 +155,91 @@ export const SingleProject: AppStory = { ), }; +/** Sidebar showing a remote workspace (experiment gated) */ +export const WithRemoteWorkspace: AppStory = { + render: () => ( + { + window.localStorage.setItem( + getExperimentKey(EXPERIMENT_IDS.REMOTE_MUX_SERVERS), + JSON.stringify(true) + ); + + const projectPath = "/home/user/projects/my-app"; + const serverId = "server-work"; + const remoteWorkspaceId = encodeRemoteWorkspaceId(serverId, "ws-remote-123"); + + const workspaces = [ + createWorkspace({ id: "ws-local", name: "main", projectName: "my-app", projectPath }), + createWorkspace({ + id: remoteWorkspaceId, + name: "feature/remote", + title: "Remote: feature/remote", + projectName: "my-app", + projectPath, + }), + ]; + + expandProjects([projectPath]); + + return createMockORPCClient({ + projects: groupWorkspacesByProject(workspaces), + workspaces, + remoteServers: [ + { + config: { + id: serverId, + label: "Work desktop", + baseUrl: "https://mux-work.example.com", + enabled: true, + projectMappings: [ + { + localProjectPath: projectPath, + remoteProjectPath: "/Users/alex/projects/my-app", + }, + ], + }, + hasAuthToken: true, + }, + ], + }); + }} + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const storyRoot = document.getElementById("storybook-root") ?? canvasElement; + + // Wait for the remote workspace row to appear. + await waitFor( + () => { + const row = storyRoot.querySelector('[data-workspace-id^="remote."]'); + if (!row) throw new Error("Remote workspace row not found"); + }, + { timeout: 5000 } + ); + + const remoteRow = storyRoot.querySelector('[data-workspace-id^="remote."]')!; + const titleSpan = within(remoteRow).getByText("Remote: feature/remote"); + await userEvent.hover(titleSpan); + + // Wait for HoverCard to appear (portaled to body) and verify remote server line. + await waitFor( + () => { + const hoverCard = document.body.querySelector( + "[data-radix-popper-content-wrapper] .bg-modal-bg" + ); + if (!hoverCard) throw new Error("HoverCard not visible"); + + // HoverCard markup splits "Mux server:" and the server id across nested elements, + // so a single getByText(/Mux server:\s*server-work/i) will not match. + within(hoverCard).getByText(/Mux server:/i); + within(hoverCard).getByText(/server-work/i); + }, + { timeout: 5000 } + ); + }, +}; + /** Multiple projects showing sidebar organization */ export const MultipleProjects: AppStory = { render: () => ( diff --git a/src/browser/stories/App.welcome.stories.tsx b/src/browser/stories/App.welcome.stories.tsx index d22b6fdc3e..a831b902a6 100644 --- a/src/browser/stories/App.welcome.stories.tsx +++ b/src/browser/stories/App.welcome.stories.tsx @@ -8,6 +8,7 @@ import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; import { createMockORPCClient, type MockSessionUsage } from "@/browser/stories/mocks/orpc"; import { expandProjects } from "./storyHelpers"; import { createArchivedWorkspace, NOW } from "./mockFactory"; +import { EXPERIMENT_IDS, getExperimentKey } from "@/common/constants/experiments"; import type { ProjectConfig } from "@/node/config"; /** Helper to create session usage data with a specific total cost */ @@ -90,6 +91,167 @@ export const CreateWorkspace: AppStory = { }, }; +const REMOTE_MUX_SERVERS_STORY_PROJECT_PATH = "/Users/dev/my-project"; + +const WORK_REMOTE_MUX_SERVER = { + config: { + id: "server-work", + label: "Work desktop", + baseUrl: "https://mux-work.example.com", + enabled: true, + projectMappings: [ + { + localProjectPath: REMOTE_MUX_SERVERS_STORY_PROJECT_PATH, + remoteProjectPath: "/home/dev/my-project", + }, + ], + }, + hasAuthToken: true, +}; + +function setupCreateWorkspaceWithRemoteMuxServer() { + expandProjects([REMOTE_MUX_SERVERS_STORY_PROJECT_PATH]); + window.localStorage.setItem( + getExperimentKey(EXPERIMENT_IDS.REMOTE_MUX_SERVERS), + JSON.stringify(true) + ); + + return createMockORPCClient({ + projects: new Map([projectWithNoWorkspaces(REMOTE_MUX_SERVERS_STORY_PROJECT_PATH)]), + workspaces: [], + remoteServers: [WORK_REMOTE_MUX_SERVER], + }); +} + +/** Creation view - remote mux server available (experiment gated) */ +export const CreateWorkspaceRemoteServerAvailable: AppStory = { + render: () => , + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const storyRoot = document.getElementById("storybook-root") ?? canvasElement; + await openFirstProjectCreationView(storyRoot); + + const canvas = within(storyRoot); + const body = within(storyRoot.ownerDocument.body); + + // Wait for the create target group to render (remote servers loaded + enabled). + const createOn = await canvas.findByRole("combobox", { name: "Create on" }, { timeout: 10000 }); + + // Switch to Remote so the snapshot captures the remote server selector, + // but keep dropdowns closed. + await userEvent.click(createOn); + const remoteOption = await body.findByRole("option", { name: "Remote" }); + await userEvent.click(remoteOption); + + if (storyRoot.ownerDocument.body.hasAttribute("data-scroll-locked")) { + // Ensure the Radix Select overlay is fully dismissed before we query for the remote server selector. + await userEvent.keyboard("{Escape}"); + } + + // Radix Select updates can take a tick to commit; wait for the trigger to reflect + // the selection before looking for the remote server selector (helps CI/Chromatic stability). + await waitFor(() => { + const createOnAfter = canvas.getByRole("combobox", { name: "Create on" }); + if (!/Remote/i.test(createOnAfter.textContent ?? "")) { + throw new Error("Create on not set to Remote"); + } + if (createOnAfter.getAttribute("aria-expanded") === "true") { + throw new Error("Create on dropdown still open"); + } + }); + + await body.findByRole("combobox", { name: "Remote server" }, { timeout: 10000 }); + + await waitFor(() => { + const createOnAfter = canvas.getByRole("combobox", { name: "Create on" }); + if (createOnAfter.getAttribute("aria-expanded") === "true") { + throw new Error("Create on dropdown still open"); + } + + const remoteServerAfter = body.getByRole("combobox", { name: "Remote server" }); + if (remoteServerAfter.getAttribute("aria-expanded") === "true") { + throw new Error("Remote server dropdown still open"); + } + + if (storyRoot.ownerDocument.body.hasAttribute("data-scroll-locked")) { + throw new Error("Scroll lock still active"); + } + }); + }, +}; + +/** Creation view - remote server dropdown open for Chromatic snapshot */ +export const CreateWorkspaceRemoteServerDropdownOpen: AppStory = { + render: () => , + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const storyRoot = document.getElementById("storybook-root") ?? canvasElement; + await openFirstProjectCreationView(storyRoot); + + const canvas = within(storyRoot); + const body = within(storyRoot.ownerDocument.body); + + // Wait for the create target group to render (remote servers loaded + enabled). + const createOn = await canvas.findByRole("combobox", { name: "Create on" }, { timeout: 10000 }); + + // Select Remote in the "Create on" Radix Select. + await userEvent.click(createOn); + const remoteOption = await body.findByRole("option", { name: "Remote" }); + await userEvent.click(remoteOption); + + if (storyRoot.ownerDocument.body.hasAttribute("data-scroll-locked")) { + // Ensure the Radix Select overlay is fully dismissed before we query for the remote server selector. + await userEvent.keyboard("{Escape}"); + } + + await waitFor( + () => { + const createOnAfter = canvas.getByRole("combobox", { name: "Create on" }); + if (!/Remote/i.test(createOnAfter.textContent ?? "")) { + throw new Error("Create on not set to Remote"); + } + if (createOnAfter.getAttribute("aria-expanded") === "true") { + throw new Error("Create on dropdown still open"); + } + + // Radix Select uses scroll locking / aria-hiding while the dropdown is open. + // Wait for the lock to clear so the remote server control is visible to role queries. + if (storyRoot.ownerDocument.body.hasAttribute("data-scroll-locked")) { + throw new Error("Scroll lock still active"); + } + }, + { timeout: 10000 } + ); + + const remoteServerTrigger = await waitFor( + () => { + const el = storyRoot.querySelector('[aria-label="Remote server"]'); + if (!(el instanceof HTMLElement)) { + throw new Error("Remote server trigger not found"); + } + + return el; + }, + { timeout: 10000 } + ); + + // Open the remote server dropdown and keep it open for the snapshot. + // Use a native click to avoid userEvent's pointer-events checks while Radix is + // toggling scroll-lock / aria-hiding. + remoteServerTrigger.click(); + + await body.findByRole("option", { name: /Work desktop/i }); + + await waitFor(() => { + const remoteServerAfter = storyRoot.querySelector('[aria-label="Remote server"]'); + if (!(remoteServerAfter instanceof HTMLElement)) { + throw new Error("Remote server trigger not found"); + } + if (remoteServerAfter.getAttribute("aria-expanded") !== "true") { + throw new Error("Remote server dropdown not open"); + } + }); + }, +}; + /** Creation view with multiple projects - shows sidebar with projects */ export const CreateWorkspaceMultipleProjects: AppStory = { parameters: { diff --git a/src/browser/stories/meta.tsx b/src/browser/stories/meta.tsx index 9e781d5052..371c468d1c 100644 --- a/src/browser/stories/meta.tsx +++ b/src/browser/stories/meta.tsx @@ -50,6 +50,19 @@ function resetStorybookPersistedStateForStory(): void { if (typeof localStorage !== "undefined") { localStorage.removeItem(SELECTED_WORKSPACE_KEY); localStorage.setItem(UI_THEME_KEY, JSON.stringify("dark")); + + // Experiments are persisted in localStorage (experiment:). Clear them so a story + // enabling an experiment doesn't leak that state into later stories. + const experimentKeys: string[] = []; + for (let i = 0; i < localStorage.length; i += 1) { + const key = localStorage.key(i); + if (key?.startsWith("experiment:")) { + experimentKeys.push(key); + } + } + for (const key of experimentKeys) { + localStorage.removeItem(key); + } } } function getStorybookStoryId(): string | null { diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 5a3327f318..0ecea1dd05 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -11,6 +11,8 @@ import type { import type { AgentSkillDescriptor, AgentSkillIssue } from "@/common/types/agentSkill"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { ProjectConfig } from "@/node/config"; +import type { RemoteMuxServerConfig } from "@/common/types/project"; +import { encodeRemoteWorkspaceId } from "@/common/utils/remoteMuxIds"; import { DEFAULT_LAYOUT_PRESETS_CONFIG, normalizeLayoutPresetsConfig, @@ -83,11 +85,18 @@ export interface MockSessionUsage { version: 1; } +export interface RemoteMuxServerListEntry { + config: RemoteMuxServerConfig; + hasAuthToken: boolean; +} + export interface MockORPCClientOptions { /** Layout presets config for Settings → Layouts stories */ layoutPresets?: LayoutPresetsConfig; projects?: Map; workspaces?: FrontendWorkspaceMetadata[]; + /** Remote mux API servers for Settings → Remote Servers + remote workspace creation */ + remoteServers?: RemoteMuxServerListEntry[]; /** Initial task settings for config.getConfig (e.g., Settings → Tasks section) */ taskSettings?: Partial; /** Initial unified AI defaults for agents (plan/exec/compact + subagents) */ @@ -256,6 +265,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl const { projects = new Map(), workspaces: inputWorkspaces = [], + remoteServers: initialRemoteServers = [], onChat, executeBash, providersConfig = { anthropic: { apiKeySet: true, isConfigured: true } }, @@ -336,6 +346,14 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl let createdWorkspaceCounter = 0; + let remoteServers: RemoteMuxServerListEntry[] = initialRemoteServers.map((entry) => ({ + config: { + ...entry.config, + projectMappings: entry.config.projectMappings.map((mapping) => ({ ...mapping })), + }, + hasAuthToken: entry.hasAuthToken, + })); + const agentDefinitions: AgentDefinitionDescriptor[] = initialAgentDefinitions ?? ([ @@ -979,6 +997,129 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl }, }, }, + remoteServers: { + list: () => Promise.resolve(remoteServers), + upsert: (input: { config: RemoteMuxServerConfig; authToken?: string }) => { + const nextConfig: RemoteMuxServerConfig = { + ...input.config, + projectMappings: input.config.projectMappings.map((mapping) => ({ ...mapping })), + }; + + const token = typeof input.authToken === "string" ? input.authToken.trim() : undefined; + + const existingIndex = remoteServers.findIndex((entry) => entry.config.id === nextConfig.id); + const previous = existingIndex >= 0 ? remoteServers[existingIndex] : null; + + const hasAuthToken = + token === undefined ? (previous?.hasAuthToken ?? false) : token.trim().length > 0; + + const nextEntry: RemoteMuxServerListEntry = { + config: nextConfig, + hasAuthToken, + }; + + if (existingIndex >= 0) { + remoteServers = remoteServers.map((entry, index) => + index === existingIndex ? nextEntry : entry + ); + } else { + remoteServers = [...remoteServers, nextEntry]; + } + + return Promise.resolve({ success: true, data: undefined }); + }, + remove: (input: { id: string }) => { + const existingIndex = remoteServers.findIndex((entry) => entry.config.id === input.id); + if (existingIndex === -1) { + return Promise.resolve({ + success: false, + error: `Remote server not found: ${input.id}`, + }); + } + + remoteServers = remoteServers.filter((entry) => entry.config.id !== input.id); + return Promise.resolve({ success: true, data: undefined }); + }, + clearAuthToken: (input: { id: string }) => { + const existingIndex = remoteServers.findIndex((entry) => entry.config.id === input.id); + if (existingIndex === -1) { + return Promise.resolve({ + success: false, + error: `Remote server not found: ${input.id}`, + }); + } + + remoteServers = remoteServers.map((entry) => + entry.config.id === input.id ? { ...entry, hasAuthToken: false } : entry + ); + + return Promise.resolve({ success: true, data: undefined }); + }, + ping: (input: { id: string }) => { + const server = remoteServers.find((entry) => entry.config.id === input.id) ?? null; + if (!server) { + return Promise.resolve({ + success: false, + error: `Remote server not found: ${input.id}`, + }); + } + + return Promise.resolve({ + success: true, + data: { + version: { + mux: "mock", + version: "0.0.0-mock", + }, + }, + }); + }, + workspaceCreate: (input: { + serverId: string; + localProjectPath: string; + branchName: string; + title?: string; + sectionId?: string; + }) => { + const serverId = input.serverId.trim(); + if (!serverId) { + return Promise.resolve({ success: false, error: "Remote server id is required" }); + } + + const server = remoteServers.find((entry) => entry.config.id === serverId) ?? null; + if (!server) { + return Promise.resolve({ + success: false, + error: `Remote server not found: ${serverId}`, + }); + } + + if (server.config.enabled === false) { + return Promise.resolve({ + success: false, + error: `Remote server is disabled: ${serverId}`, + }); + } + + createdWorkspaceCounter += 1; + const remoteId = `ws-remote-${createdWorkspaceCounter}`; + const encodedId = encodeRemoteWorkspaceId(serverId, remoteId); + + return Promise.resolve({ + success: true, + metadata: { + id: encodedId, + name: input.branchName, + title: input.title, + projectPath: input.localProjectPath, + projectName: input.localProjectPath.split("/").pop() ?? "project", + namedWorkspacePath: `/mock/remote/${serverId}/${input.branchName}`, + runtimeConfig: DEFAULT_RUNTIME_CONFIG, + sectionId: input.sectionId, + }, + }); + }, + }, workspace: { list: (input?: { archived?: boolean }) => { if (input?.archived) { diff --git a/src/browser/utils/openInEditor.ts b/src/browser/utils/openInEditor.ts index 9fe16b4bef..5fa664eb6d 100644 --- a/src/browser/utils/openInEditor.ts +++ b/src/browser/utils/openInEditor.ts @@ -13,6 +13,7 @@ import { } from "@/common/constants/storage"; import type { RuntimeConfig } from "@/common/types/runtime"; import { isSSHRuntime, isDockerRuntime, isDevcontainerRuntime } from "@/common/types/runtime"; +import { isRemoteWorkspaceId } from "@/common/utils/remoteMuxIds"; import type { APIClient } from "@/browser/contexts/API"; export interface OpenInEditorResult { @@ -101,6 +102,16 @@ export async function openInEditor(args: { const isSSH = isSSHRuntime(args.runtimeConfig); const isDocker = isDockerRuntime(args.runtimeConfig); + // Remote workspaces have paths that refer to a different machine. Until we support + // propagating SSH context (or another transport) for remote workspaces, avoid generating + // local editor deep links that point at a non-existent local path. + if (isRemoteWorkspaceId(args.workspaceId) && !isSSH) { + return { + success: false, + error: "Opening remote workspaces in editor is not yet supported.", + }; + } + // For custom editor with no command configured, open settings (if available) if (editorConfig.editor === "custom" && !editorConfig.customCommand) { args.openSettings?.("general"); diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index cf06d17140..0527027b50 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -73,6 +73,7 @@ async function createTestServer(authToken?: string): Promise { updateService: services.updateService, tokenizerService: services.tokenizerService, serverService: services.serverService, + remoteServersService: services.remoteServersService, workspaceMcpOverridesService: services.workspaceMcpOverridesService, mcpConfigService: services.mcpConfigService, mcpOauthService: services.mcpOauthService, diff --git a/src/cli/server.test.ts b/src/cli/server.test.ts index 4c2fa39845..d036e523eb 100644 --- a/src/cli/server.test.ts +++ b/src/cli/server.test.ts @@ -13,6 +13,7 @@ import { describe, test, expect, beforeAll, afterAll } from "bun:test"; import * as os from "os"; import * as path from "path"; import * as fs from "fs/promises"; +import { execSync } from "node:child_process"; import { WebSocket } from "ws"; import { RPCLink as HTTPRPCLink } from "@orpc/client/fetch"; import { RPCLink as WebSocketRPCLink } from "@orpc/client/websocket"; @@ -25,6 +26,7 @@ import { Config } from "@/node/config"; import { ServiceContainer } from "@/node/services/serviceContainer"; import type { RouterClient } from "@orpc/server"; import { createOrpcServer, type OrpcServer } from "@/node/orpc/server"; +import { encodeRemoteWorkspaceId } from "@/common/utils/remoteMuxIds"; // --- Test Server Factory --- @@ -76,6 +78,7 @@ async function createTestServer(): Promise { updateService: services.updateService, tokenizerService: services.tokenizerService, serverService: services.serverService, + remoteServersService: services.remoteServersService, workspaceMcpOverridesService: services.workspaceMcpOverridesService, mcpConfigService: services.mcpConfigService, mcpOauthService: services.mcpOauthService, @@ -372,4 +375,118 @@ describe("oRPC Server Endpoints", () => { } }); }); + + describe("Remote server workspace proxying", () => { + test( + "surfaces remote workspaces via workspace.list and supports remoteServers.workspaceCreate", + async () => { + const localClient = createHttpClient(serverHandle.server.baseUrl); + + const serverId = "remote1"; + let remoteHandle: TestServerHandle | null = null; + let projectPath: string | null = null; + + try { + remoteHandle = await createTestServer(); + const remoteClient = createHttpClient(remoteHandle.server.baseUrl); + + projectPath = await fs.mkdtemp(path.join(os.tmpdir(), "mux-remote-workspace-project-")); + + execSync("git init -b main", { cwd: projectPath, stdio: "ignore" }); + execSync('git config user.email "test@example.com"', { + cwd: projectPath, + stdio: "ignore", + }); + execSync('git config user.name "test"', { cwd: projectPath, stdio: "ignore" }); + // Ensure tests don't hang when developers have global commit signing enabled. + execSync("git config commit.gpgsign false", { cwd: projectPath, stdio: "ignore" }); + await fs.writeFile(path.join(projectPath, "README.md"), "hello\n", "utf-8"); + execSync("git add README.md", { cwd: projectPath, stdio: "ignore" }); + execSync('git commit -m "init"', { cwd: projectPath, stdio: "ignore" }); + + const remoteProjectResult = await remoteClient.projects.create({ projectPath }); + expect(remoteProjectResult.success).toBe(true); + if (!remoteProjectResult.success) { + throw new Error(remoteProjectResult.error); + } + + const remoteBranchA = `remote-branch-${Date.now().toString(36)}-a`; + const remoteWorkspaceA = await remoteClient.workspace.create({ + projectPath, + branchName: remoteBranchA, + trunkBranch: "main", + }); + expect(remoteWorkspaceA.success).toBe(true); + if (!remoteWorkspaceA.success) { + throw new Error(remoteWorkspaceA.error); + } + + const upsertResult = await localClient.remoteServers.upsert({ + config: { + id: serverId, + label: "Remote 1", + baseUrl: remoteHandle.server.baseUrl, + enabled: true, + projectMappings: [{ localProjectPath: projectPath, remoteProjectPath: projectPath }], + }, + authToken: "", + }); + expect(upsertResult.success).toBe(true); + if (!upsertResult.success) { + throw new Error(upsertResult.error); + } + + const listed = await localClient.workspace.list(); + const expectedSurfacedId = encodeRemoteWorkspaceId( + serverId, + remoteWorkspaceA.metadata.id + ); + const surfaced = listed.find((w) => w.id === expectedSurfacedId); + expect(surfaced).toBeDefined(); + expect(surfaced?.projectPath).toBe(projectPath); + + const remoteBranchB = `remote-branch-${Date.now().toString(36)}-b`; + const proxyCreateResult = await localClient.remoteServers.workspaceCreate({ + serverId, + localProjectPath: projectPath, + branchName: remoteBranchB, + trunkBranch: "main", + }); + expect(proxyCreateResult.success).toBe(true); + if (!proxyCreateResult.success) { + throw new Error(proxyCreateResult.error); + } + + expect(proxyCreateResult.metadata.projectPath).toBe(projectPath); + + const remoteWorkspaces = await remoteClient.workspace.list(); + const remoteWorkspaceB = remoteWorkspaces.find((w) => w.name === remoteBranchB); + if (!remoteWorkspaceB) { + throw new Error(`Remote workspace not found after proxy create: ${remoteBranchB}`); + } + + expect(proxyCreateResult.metadata.id).toBe( + encodeRemoteWorkspaceId(serverId, remoteWorkspaceB.id) + ); + + const listedAfter = await localClient.workspace.list(); + expect(listedAfter.some((w) => w.id === proxyCreateResult.metadata.id)).toBe(true); + } finally { + try { + await localClient.remoteServers.remove({ id: serverId }); + } catch { + // Best-effort cleanup. + } + + if (remoteHandle) { + await remoteHandle.close(); + } + if (projectPath) { + await fs.rm(projectPath, { recursive: true, force: true }); + } + } + }, + { timeout: 60_000 } + ); + }); }); diff --git a/src/common/constants/experiments.ts b/src/common/constants/experiments.ts index 264db07005..8c52541633 100644 --- a/src/common/constants/experiments.ts +++ b/src/common/constants/experiments.ts @@ -9,6 +9,7 @@ export const EXPERIMENT_IDS = { PROGRAMMATIC_TOOL_CALLING: "programmatic-tool-calling", PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE: "programmatic-tool-calling-exclusive", CONFIGURABLE_BIND_URL: "configurable-bind-url", + REMOTE_MUX_SERVERS: "remote-mux-servers", SYSTEM_1: "system-1", EXEC_SUBAGENT_HARD_RESTART: "exec-subagent-hard-restart", MUX_GOVERNOR: "mux-governor", @@ -64,6 +65,14 @@ export const EXPERIMENTS: Record = { userOverridable: true, showInSettings: true, }, + [EXPERIMENT_IDS.REMOTE_MUX_SERVERS]: { + id: EXPERIMENT_IDS.REMOTE_MUX_SERVERS, + name: "Remote Servers", + description: "Enable Remote Servers settings + remote workspaces", + enabledByDefault: false, + userOverridable: true, + showInSettings: true, + }, [EXPERIMENT_IDS.SYSTEM_1]: { id: EXPERIMENT_IDS.SYSTEM_1, name: "System 1", diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index 223f4cd32d..02b0ef7aa3 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -193,6 +193,7 @@ export { policy, providers, ProvidersConfigMapSchema, + remoteServers, server, splashScreens, tasks, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 53b3ad9805..509030bd18 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -1362,6 +1362,106 @@ export const server = { }, }; +// Remote mux servers +const RemoteMuxServerProjectMappingSchema = z + .object({ + localProjectPath: z.string(), + remoteProjectPath: z.string(), + }) + .strict(); + +const RemoteMuxServerConfigSchema = z + .object({ + id: z.string(), + label: z.string(), + baseUrl: z.url(), + enabled: z.boolean().optional(), + projectMappings: z.array(RemoteMuxServerProjectMappingSchema), + }) + .strict(); + +const RemoteMuxServerListEntrySchema = z + .object({ + config: RemoteMuxServerConfigSchema, + hasAuthToken: z.boolean(), + }) + .strict(); + +const RemoteMuxServerProjectSuggestionSchema = z + .object({ + path: z.string(), + label: z.string(), + }) + .strict(); + +export const remoteServers = { + list: { + input: z.void(), + output: z.array(RemoteMuxServerListEntrySchema), + }, + upsert: { + input: z + .object({ + config: RemoteMuxServerConfigSchema, + authToken: z.string().optional(), + }) + .strict(), + output: ResultSchema(z.void()), + }, + remove: { + input: z + .object({ + id: z.string(), + }) + .strict(), + output: ResultSchema(z.void()), + }, + clearAuthToken: { + input: z + .object({ + id: z.string(), + }) + .strict(), + output: ResultSchema(z.void()), + }, + ping: { + input: z + .object({ + id: z.string(), + }) + .strict(), + output: ResultSchema( + z + .object({ + version: z.any(), + }) + .strict() + ), + }, + listRemoteProjects: { + input: z + .object({ + id: z.string(), + }) + .strict(), + output: ResultSchema(z.array(RemoteMuxServerProjectSuggestionSchema)), + }, + workspaceCreate: { + input: z + .object({ + serverId: z.string(), + localProjectPath: z.string(), + branchName: z.string(), + trunkBranch: z.string().optional(), + title: z.string().optional(), + runtimeConfig: RuntimeConfigSchema.optional(), + sectionId: z.string().optional(), + }) + .strict(), + output: workspace.create.output, + }, +}; + // Config (global settings) const SubagentAiDefaultsEntrySchema = z .object({ diff --git a/src/common/types/project.ts b/src/common/types/project.ts index a5f7c4bf63..a3bf8bed79 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -21,6 +21,26 @@ export type ProjectConfig = z.infer; export type FeatureFlagOverride = "default" | "on" | "off"; +export interface RemoteMuxServerProjectMapping { + localProjectPath: string; + remoteProjectPath: string; +} + +/** + * RemoteMuxServerConfig - configuration for a remote mux API server. + * + * NOTE: auth tokens are stored in ~/.mux/secrets.json (not in config.json). + */ +export interface RemoteMuxServerConfig { + /** Stable, filesystem-safe identifier (used for secrets lookup). */ + id: string; + label: string; + /** Base URL for the remote mux server (no trailing slash). */ + baseUrl: string; + enabled?: boolean; + projectMappings: RemoteMuxServerProjectMapping[]; +} + export interface ProjectsConfig { projects: Map; /** @@ -52,6 +72,11 @@ export interface ProjectsConfig { mdnsServiceName?: string; /** SSH hostname/alias for this machine (used for editor deep links in browser mode) */ serverSshHost?: string; + /** + * Registry of remote mux API servers (shared via ~/.mux/config.json). + * Auth tokens are stored separately in ~/.mux/secrets.json. + */ + remoteServers?: RemoteMuxServerConfig[]; /** IDs of splash screens that have been viewed */ viewedSplashScreens?: string[]; /** Cross-client feature flag overrides (shared via ~/.mux/config.json). */ diff --git a/src/common/utils/remoteMuxIds.test.ts b/src/common/utils/remoteMuxIds.test.ts new file mode 100644 index 0000000000..8e362e1101 --- /dev/null +++ b/src/common/utils/remoteMuxIds.test.ts @@ -0,0 +1,54 @@ +import { + decodeRemoteWorkspaceId, + encodeRemoteWorkspaceId, + isRemoteWorkspaceId, +} from "./remoteMuxIds"; + +describe("remoteMuxIds", () => { + it("roundtrips serverId + remoteId and is filesystem-safe", () => { + const cases: Array<{ serverId: string; remoteId: string }> = [ + { serverId: "server-1", remoteId: "workspace-123" }, + { serverId: "srv with spaces", remoteId: "remote id with spaces" }, + { serverId: "srv/with/slashes", remoteId: "id\\with\\backslashes" }, + { serverId: "ユニコード", remoteId: "emoji 🚀 + symbols <>:*?" }, + ]; + + for (const { serverId, remoteId } of cases) { + const encoded = encodeRemoteWorkspaceId(serverId, remoteId); + expect(encoded).not.toMatch(/[:/\\]/); + + const decoded = decodeRemoteWorkspaceId(encoded); + expect(decoded).toEqual({ serverId, remoteId }); + + expect(isRemoteWorkspaceId(encoded)).toBe(true); + } + }); + + it("rejects invalid inputs when encoding", () => { + expect(() => encodeRemoteWorkspaceId("", "x")).toThrow(); + expect(() => encodeRemoteWorkspaceId("x", "")).toThrow(); + + // Runtime misuse: defensive assertions should catch non-string inputs. + expect(() => encodeRemoteWorkspaceId(123 as unknown as string, "x")).toThrow(); + expect(() => encodeRemoteWorkspaceId("x", null as unknown as string)).toThrow(); + }); + + it("returns null for non-remote or malformed ids", () => { + expect(decodeRemoteWorkspaceId("a1b2c3d4e5")).toBeNull(); + expect(isRemoteWorkspaceId("a1b2c3d4e5")).toBe(false); + + // Wrong prefix / missing parts + expect(decodeRemoteWorkspaceId("remote")).toBeNull(); + expect(decodeRemoteWorkspaceId("remote.")).toBeNull(); + + // Too many separators + expect(decodeRemoteWorkspaceId("remote.a.b.c")).toBeNull(); + + // Invalid base64url components + expect(decodeRemoteWorkspaceId("remote.!!!!.bbbb")).toBeNull(); + expect(decodeRemoteWorkspaceId("remote.aaaa.****")).toBeNull(); + + // base64url length that can never be valid (len % 4 === 1) + expect(decodeRemoteWorkspaceId("remote.a.bbb")).toBeNull(); + }); +}); diff --git a/src/common/utils/remoteMuxIds.ts b/src/common/utils/remoteMuxIds.ts new file mode 100644 index 0000000000..982901a5b1 --- /dev/null +++ b/src/common/utils/remoteMuxIds.ts @@ -0,0 +1,131 @@ +import assert from "./assert"; + +// Remote workspaces are represented locally via a namespaced workspaceId. +// +// Requirements: +// - Must be filesystem-safe across Windows/macOS/Linux (no path separators) +// - Must be reversible (decode(encode(x)) === x) +// +// We use base64url-encoded UTF-8 components separated by a `.` delimiter. +// base64url only uses [A-Za-z0-9_-], so `.` is a safe separator. +const REMOTE_WORKSPACE_ID_PREFIX = "remote."; + +const base64UrlPattern = /^[A-Za-z0-9_-]+$/; + +function toBase64(bytes: Uint8Array): string { + // Prefer Node's Buffer when available (faster, fewer allocations). + // Guarded for browser bundles. + if (typeof Buffer !== "undefined") { + return Buffer.from(bytes).toString("base64"); + } + + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + + return btoa(binary); +} + +function fromBase64(base64: string): Uint8Array | null { + try { + if (typeof Buffer !== "undefined") { + return new Uint8Array(Buffer.from(base64, "base64")); + } + + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + + return bytes; + } catch { + return null; + } +} + +function toBase64Url(base64: string): string { + // Convert to RFC 4648 base64url (no padding) + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +function fromBase64Url(base64Url: string): string | null { + if (!base64UrlPattern.test(base64Url)) { + return null; + } + + const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); + const remainder = base64.length % 4; + if (remainder === 1) { + // Invalid base64 length. + return null; + } + + const padded = remainder === 0 ? base64 : base64 + "=".repeat(4 - remainder); + return padded; +} + +function encodeComponent(value: string): string { + const bytes = new TextEncoder().encode(value); + return toBase64Url(toBase64(bytes)); +} + +function decodeComponent(value: string): string | null { + const base64 = fromBase64Url(value); + if (base64 === null) return null; + + const bytes = fromBase64(base64); + if (bytes === null) return null; + + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return null; + } +} + +export function encodeRemoteWorkspaceId(serverId: string, remoteId: string): string { + assert(typeof serverId === "string", "encodeRemoteWorkspaceId: serverId must be a string"); + assert(typeof remoteId === "string", "encodeRemoteWorkspaceId: remoteId must be a string"); + assert(serverId.length > 0, "encodeRemoteWorkspaceId: serverId must be non-empty"); + assert(remoteId.length > 0, "encodeRemoteWorkspaceId: remoteId must be non-empty"); + + const encodedServerId = encodeComponent(serverId); + const encodedRemoteId = encodeComponent(remoteId); + + // These should be impossible (base64url never includes '.') but we assert anyway to keep + // the codec safe if the encoding strategy changes. + assert( + encodedServerId.length > 0 && !encodedServerId.includes("."), + "encodeRemoteWorkspaceId: encoded serverId is invalid" + ); + assert( + encodedRemoteId.length > 0 && !encodedRemoteId.includes("."), + "encodeRemoteWorkspaceId: encoded remoteId is invalid" + ); + + return `${REMOTE_WORKSPACE_ID_PREFIX}${encodedServerId}.${encodedRemoteId}`; +} + +export function decodeRemoteWorkspaceId(id: string): { serverId: string; remoteId: string } | null { + if (typeof id !== "string") return null; + if (!id.startsWith(REMOTE_WORKSPACE_ID_PREFIX)) return null; + + const rest = id.slice(REMOTE_WORKSPACE_ID_PREFIX.length); + const parts = rest.split("."); + if (parts.length !== 2) return null; + + const [encodedServerId, encodedRemoteId] = parts; + if (!encodedServerId || !encodedRemoteId) return null; + + const serverId = decodeComponent(encodedServerId); + const remoteId = decodeComponent(encodedRemoteId); + if (serverId === null || remoteId === null) return null; + + return { serverId, remoteId }; +} + +export function isRemoteWorkspaceId(id: string): boolean { + return decodeRemoteWorkspaceId(id) !== null; +} diff --git a/src/node/config.ts b/src/node/config.ts index b6a26442a6..cb6753b7ad 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -11,6 +11,7 @@ import type { ProjectConfig, ProjectsConfig, FeatureFlagOverride, + RemoteMuxServerConfig, } from "@/common/types/project"; import { DEFAULT_TASK_SETTINGS, @@ -25,6 +26,8 @@ import { getMuxHome } from "@/common/constants/paths"; import { PlatformPaths } from "@/common/utils/paths"; import { isValidModelFormat, normalizeGatewayModel } from "@/common/utils/ai/models"; import { stripTrailingSlashes } from "@/node/utils/pathUtils"; +import { AsyncMutex } from "@/node/utils/concurrency/asyncMutex"; +import { isRemoteWorkspaceId } from "@/common/utils/remoteMuxIds"; import { getContainerName as getDockerContainerName } from "@/node/runtime/DockerRuntime"; // Re-export project types from dedicated types file (for preload usage) @@ -131,6 +134,105 @@ function parseOptionalPort(value: unknown): number | undefined { return value; } + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +const REMOTE_MUX_SERVER_ID_RE = /^[a-zA-Z0-9._-]+$/; + +function parseRemoteMuxServerId(value: unknown): string | undefined { + const id = parseOptionalNonEmptyString(value); + if (!id) { + return undefined; + } + + return REMOTE_MUX_SERVER_ID_RE.test(id) ? id : undefined; +} + +function parseRemoteMuxProjectMappings(value: unknown): RemoteMuxServerConfig["projectMappings"] { + if (!Array.isArray(value)) { + return []; + } + + const mappings: RemoteMuxServerConfig["projectMappings"] = []; + for (const item of value) { + if (!isPlainObject(item)) { + continue; + } + + const localProjectPath = parseOptionalNonEmptyString(item.localProjectPath); + const remoteProjectPath = parseOptionalNonEmptyString(item.remoteProjectPath); + if (!localProjectPath || !remoteProjectPath) { + continue; + } + + mappings.push({ localProjectPath, remoteProjectPath }); + } + + return mappings; +} + +function parseRemoteMuxServerConfig(value: unknown): RemoteMuxServerConfig | null { + if (!isPlainObject(value)) { + return null; + } + + const id = parseRemoteMuxServerId(value.id); + const label = parseOptionalNonEmptyString(value.label); + const baseUrlRaw = parseOptionalNonEmptyString(value.baseUrl); + const baseUrl = baseUrlRaw ? stripTrailingSlashes(baseUrlRaw) : undefined; + + if (!id || !label || !baseUrl) { + return null; + } + + // Defensive: avoid persisting invalid base URLs that would later break ORPC output validation. + try { + const url = new URL(baseUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return null; + } + } catch { + return null; + } + + return { + id, + label, + baseUrl, + enabled: parseOptionalBoolean(value.enabled), + projectMappings: parseRemoteMuxProjectMappings(value.projectMappings), + }; +} + +function parseRemoteMuxServerConfigs(value: unknown): RemoteMuxServerConfig[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const servers: RemoteMuxServerConfig[] = []; + const seenIds = new Set(); + + for (let i = 0; i < value.length; i++) { + const parsed = parseRemoteMuxServerConfig(value[i]); + if (!parsed) { + log.warn("Filtering out malformed remote server config", { index: i }); + continue; + } + + if (seenIds.has(parsed.id)) { + log.warn("Filtering out remote server config with duplicate id", { id: parsed.id }); + continue; + } + + seenIds.add(parsed.id); + servers.push(parsed); + } + + return servers.length > 0 ? servers : undefined; +} + export type ProvidersConfig = Record; /** @@ -146,6 +248,7 @@ export class Config { private readonly configFile: string; private readonly providersFile: string; private readonly secretsFile: string; + private readonly editMutex = new AsyncMutex(); constructor(rootDir?: string) { this.rootDir = rootDir ?? getMuxHome(); @@ -168,6 +271,7 @@ export class Config { mdnsAdvertisementEnabled?: unknown; mdnsServiceName?: unknown; serverSshHost?: string; + remoteServers?: unknown; viewedSplashScreens?: string[]; featureFlagOverrides?: Record; layoutPresets?: unknown; @@ -230,6 +334,8 @@ export class Config { ? undefined : layoutPresetsRaw; + const remoteServers = parseRemoteMuxServerConfigs(parsed.remoteServers); + return { projects: projectsMap, apiServerBindHost: parseOptionalNonEmptyString(parsed.apiServerBindHost), @@ -240,6 +346,7 @@ export class Config { mdnsAdvertisementEnabled: parseOptionalBoolean(parsed.mdnsAdvertisementEnabled), mdnsServiceName: parseOptionalNonEmptyString(parsed.mdnsServiceName), serverSshHost: parsed.serverSshHost, + remoteServers, viewedSplashScreens: parsed.viewedSplashScreens, layoutPresets, taskSettings, @@ -286,6 +393,7 @@ export class Config { mdnsAdvertisementEnabled?: boolean; mdnsServiceName?: string; serverSshHost?: string; + remoteServers?: ProjectsConfig["remoteServers"]; viewedSplashScreens?: string[]; layoutPresets?: ProjectsConfig["layoutPresets"]; featureFlagOverrides?: ProjectsConfig["featureFlagOverrides"]; @@ -360,6 +468,11 @@ export class Config { if (config.serverSshHost) { data.serverSshHost = config.serverSshHost; } + + const remoteServers = parseRemoteMuxServerConfigs(config.remoteServers); + if (remoteServers) { + data.remoteServers = remoteServers; + } if (config.featureFlagOverrides) { data.featureFlagOverrides = config.featureFlagOverrides; } @@ -420,6 +533,7 @@ export class Config { * @param fn Function that takes current config and returns modified config */ async editConfig(fn: (config: ProjectsConfig) => ProjectsConfig): Promise { + await using _lock = await this.editMutex.acquire(); const config = this.loadConfigOrDefault(); const newConfig = fn(config); await this.saveConfig(newConfig); @@ -611,6 +725,15 @@ export class Config { * Get the session directory for a specific workspace */ getSessionDir(workspaceId: string): string { + if (isRemoteWorkspaceId(workspaceId)) { + // Guardrail: remote workspaces are served by a separate mux server and should never + // read/write to the local ~/.mux/sessions directory. + throw new Error( + `Config.getSessionDir called with remote workspace ID: ${workspaceId}. ` + + "Remote workspaces do not have local session directories." + ); + } + return path.join(this.sessionsDir, workspaceId); } diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index 61eee9391d..cf188d12a9 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -14,6 +14,7 @@ import type { WindowService } from "@/node/services/windowService"; import type { UpdateService } from "@/node/services/updateService"; import type { TokenizerService } from "@/node/services/tokenizerService"; import type { ServerService } from "@/node/services/serverService"; +import type { RemoteServersService } from "@/node/services/remoteServersService"; import type { MenuEventService } from "@/node/services/menuEventService"; import type { VoiceService } from "@/node/services/voiceService"; import type { MCPConfigService } from "@/node/services/mcpConfigService"; @@ -47,6 +48,7 @@ export interface ORPCContext { updateService: UpdateService; tokenizerService: TokenizerService; serverService: ServerService; + remoteServersService: RemoteServersService; menuEventService: MenuEventService; voiceService: VoiceService; mcpConfigService: MCPConfigService; diff --git a/src/node/orpc/federationMiddleware.test.ts b/src/node/orpc/federationMiddleware.test.ts new file mode 100644 index 0000000000..2212835827 --- /dev/null +++ b/src/node/orpc/federationMiddleware.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, test } from "bun:test"; +import { eventIterator, os } from "@orpc/server"; +import { RPCHandler } from "@orpc/server/node"; +import express from "express"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { z } from "zod"; + +import { encodeRemoteWorkspaceId } from "@/common/utils/remoteMuxIds"; +import { createRemoteClient } from "@/node/remote/remoteOrpcClient"; + +import type { ORPCContext } from "./context"; +import { createFederationMiddleware } from "./federationMiddleware"; + +type RPCHandlerRouter = ConstructorParameters[0]; + +interface TestOrpcServer { + baseUrl: string; + close: () => Promise; +} + +async function createTestOrpcServer(params: { + router: RPCHandlerRouter; + context: ORPCContext; +}): Promise { + const app = express(); + app.use(express.json({ limit: "50mb" })); + app.use(express.urlencoded({ extended: false })); + + const handler = new RPCHandler(params.router); + + app.use("/orpc", async (req, res, next) => { + const { matched } = await handler.handle(req, res, { + prefix: "/orpc", + context: { ...params.context, headers: req.headers }, + }); + + if (matched) { + return; + } + + next(); + }); + + const httpServer = http.createServer(app); + + await new Promise((resolve) => { + httpServer.listen(0, "127.0.0.1", resolve); + }); + + const address = httpServer.address(); + assert(address && typeof address === "object", "createTestOrpcServer: expected a bound port"); + + const baseUrl = `http://127.0.0.1:${address.port}`; + + return { + baseUrl, + close: async () => { + await new Promise((resolve, reject) => { + httpServer.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + }, + }; +} + +interface TestOrpcClient { + workspace: { + getPlanContent: (input: { + workspaceId: string; + }) => Promise<{ workspaceId: string; content: string }>; + backgroundBashes: { + subscribe: ( + input: { workspaceId: string }, + options?: { signal?: AbortSignal } + ) => Promise>; + }; + }; + tasks: { + create: (input: { workspaceId: string }) => Promise<{ workspaceId: string; taskId: string }>; + }; +} + +describe("createFederationMiddleware", () => { + test("proxies requests with remote.* IDs and rewrites returned IDs", async () => { + const remoteServerId = "test-remote"; + const remoteWorkspaceId = "remote-workspace"; + const encodedWorkspaceId = encodeRemoteWorkspaceId(remoteServerId, remoteWorkspaceId); + + const remoteGetPlanInputs: string[] = []; + const remoteSubscribeInputs: string[] = []; + const remoteTaskCreateInputs: string[] = []; + + const workspaceIdInput = z.object({ workspaceId: z.string() }); + const getPlanContentOutput = z.object({ workspaceId: z.string(), content: z.string() }); + const backgroundBashEventOutput = z.object({ workspaceId: z.string(), event: z.string() }); + const tasksCreateOutput = z.object({ workspaceId: z.string(), taskId: z.string() }); + + const remoteT = os.$context(); + + const remoteRouter = remoteT.router({ + workspace: { + getPlanContent: remoteT + .input(workspaceIdInput) + .output(getPlanContentOutput) + .handler(({ input }) => { + remoteGetPlanInputs.push(input.workspaceId); + return { workspaceId: input.workspaceId, content: "remote-plan" }; + }), + backgroundBashes: { + subscribe: remoteT + .input(workspaceIdInput) + .output(eventIterator(backgroundBashEventOutput)) + .handler(async function* ({ input }) { + remoteSubscribeInputs.push(input.workspaceId); + await Promise.resolve(); + yield { workspaceId: input.workspaceId, event: "hello" }; + }), + }, + }, + tasks: { + create: remoteT + .input(workspaceIdInput) + .output(tasksCreateOutput) + .handler(({ input }) => { + remoteTaskCreateInputs.push(input.workspaceId); + return { workspaceId: input.workspaceId, taskId: `task-${input.workspaceId}` }; + }), + }, + }); + + const localT = os.$context().use(createFederationMiddleware()); + + const localRouter = localT.router({ + workspace: { + getPlanContent: localT + .input(workspaceIdInput) + .output(getPlanContentOutput) + .handler(() => { + throw new Error("local workspace.getPlanContent handler should not be invoked"); + }), + backgroundBashes: { + subscribe: localT + .input(workspaceIdInput) + .output(eventIterator(backgroundBashEventOutput)) + .handler(() => { + throw new Error( + "local workspace.backgroundBashes.subscribe handler should not be invoked" + ); + }), + }, + }, + tasks: { + create: localT + .input(workspaceIdInput) + .output(tasksCreateOutput) + .handler(() => { + throw new Error("local tasks.create handler should not be invoked"); + }), + }, + }); + + const remoteContext: Partial = {}; + + let remoteServer: TestOrpcServer | null = null; + let localServer: TestOrpcServer | null = null; + + try { + remoteServer = await createTestOrpcServer({ + router: remoteRouter as unknown as RPCHandlerRouter, + context: remoteContext as ORPCContext, + }); + + const remoteBaseUrl = remoteServer.baseUrl; + + const localContext: Partial = { + config: { + loadConfigOrDefault: () => + ({ + remoteServers: [ + { + id: remoteServerId, + label: "Test remote", + baseUrl: remoteBaseUrl, + projectMappings: [], + }, + ], + }) as unknown, + } as unknown as ORPCContext["config"], + remoteServersService: { + getAuthToken: () => null, + } as unknown as ORPCContext["remoteServersService"], + }; + + localServer = await createTestOrpcServer({ + router: localRouter as unknown as RPCHandlerRouter, + context: localContext as ORPCContext, + }); + + const client = createRemoteClient({ baseUrl: localServer.baseUrl }); + + const plan = await client.workspace.getPlanContent({ workspaceId: encodedWorkspaceId }); + expect(plan).toEqual({ workspaceId: encodedWorkspaceId, content: "remote-plan" }); + + const created = await client.tasks.create({ workspaceId: encodedWorkspaceId }); + expect(created.workspaceId).toBe(encodedWorkspaceId); + expect(created.taskId).toBe( + encodeRemoteWorkspaceId(remoteServerId, `task-${remoteWorkspaceId}`) + ); + + const controller = new AbortController(); + + try { + const iterator = await client.workspace.backgroundBashes.subscribe( + { workspaceId: encodedWorkspaceId }, + { signal: controller.signal } + ); + + const first = await iterator[Symbol.asyncIterator]().next(); + expect(first.done).toBe(false); + expect(first.value).toEqual({ workspaceId: encodedWorkspaceId, event: "hello" }); + } finally { + controller.abort(); + } + + // Remote handlers should have seen *decoded* IDs (no remote.* prefix). + expect(remoteGetPlanInputs).toEqual([remoteWorkspaceId]); + expect(remoteSubscribeInputs).toEqual([remoteWorkspaceId]); + expect(remoteTaskCreateInputs).toEqual([remoteWorkspaceId]); + } finally { + await localServer?.close(); + await remoteServer?.close(); + } + }, 20_000); +}); diff --git a/src/node/orpc/federationMiddleware.ts b/src/node/orpc/federationMiddleware.ts new file mode 100644 index 0000000000..4e8d1bdafa --- /dev/null +++ b/src/node/orpc/federationMiddleware.ts @@ -0,0 +1,560 @@ +import { os } from "@orpc/server"; +import type { ORPCContext } from "./context"; +import { decodeRemoteWorkspaceId, isRemoteWorkspaceId } from "@/common/utils/remoteMuxIds"; +import { createRemoteClient } from "@/node/remote/remoteOrpcClient"; +import { + buildRemoteProjectPathMap, + encodeRemoteIdBestEffort, + rewriteRemoteFrontendWorkspaceMetadataForLocalProject, + rewriteRemoteFrontendWorkspaceMetadataIds, + rewriteRemoteTaskToolPartsInMessage, + rewriteRemoteTaskToolPayloadIds, + rewriteRemoteWorkspaceChatMessageIds, +} from "./remoteMuxProxying"; +import { stripTrailingSlashes } from "@/node/utils/pathUtils"; +import { EXPERIMENT_IDS } from "@/common/constants/experiments"; +import type { + FrontendWorkspaceMetadataSchemaType, + WorkspaceChatMessage, +} from "@/common/orpc/types"; +import type { MuxMessage } from "@/common/types/message"; +import assert from "node:assert/strict"; + +interface AnyOrpcClient { + (input?: unknown, options?: { signal?: AbortSignal; lastEventId?: string }): Promise; + [key: string]: AnyOrpcClient; +} + +const FEDERATION_ID_KEYS = new Set([ + "workspaceId", + "workspaceIds", + "parentWorkspaceId", + "sourceWorkspaceId", + "taskId", + "taskIds", + // Snake_case variants used by task tools. + "task_id", + "task_ids", + "sectionId", + "sessionId", +]); + +function isPlainObject(value: unknown): value is Record { + if (!value || typeof value !== "object") { + return false; + } + + const proto: unknown = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +function isAsyncIterable(value: unknown): value is AsyncIterable { + if (!value || (typeof value !== "object" && typeof value !== "function")) { + return false; + } + + return Symbol.asyncIterator in value; +} + +function resolveRemoteProcedure(client: AnyOrpcClient, path: readonly string[]): AnyOrpcClient { + let current: AnyOrpcClient = client; + for (const segment of path) { + current = current[segment]; + } + return current; +} + +function createLinkedAbortController(signal?: AbortSignal): AbortController { + const controller = new AbortController(); + + if (signal) { + if (signal.aborted) { + controller.abort(); + } else { + signal.addEventListener("abort", () => controller.abort(), { once: true }); + } + } + + return controller; +} + +function wrapAsyncIterable(params: { + iterable: AsyncIterable; + mapValue: (value: unknown) => unknown; + abortController: AbortController; +}): AsyncIteratorObject { + const iterator = params.iterable[Symbol.asyncIterator](); + + const end = async (value: unknown) => { + // Best-effort: abort the underlying HTTP stream if the local subscription ends early. + params.abortController.abort(); + await iterator.return?.(value); + }; + + return { + async next() { + const result = await iterator.next(); + if (result.done) { + return result; + } + + return { done: false as const, value: params.mapValue(result.value) }; + }, + async return(value?: unknown) { + await end(value); + return { done: true as const, value }; + }, + async throw(error?: unknown) { + await end(undefined); + throw error; + }, + async [Symbol.asyncDispose]() { + await end(undefined); + }, + [Symbol.asyncIterator]() { + return this; + }, + }; +} + +function decodeRemoteIdForFederation( + encodedId: string +): { serverId: string; remoteId: string } | null { + const decoded = decodeRemoteWorkspaceId(encodedId); + if (!decoded) { + return null; + } + + const serverId = decoded.serverId.trim(); + const remoteId = decoded.remoteId.trim(); + + assert(serverId.length > 0, "decodeRemoteIdForFederation: serverId must be non-empty"); + assert(remoteId.length > 0, "decodeRemoteIdForFederation: remoteId must be non-empty"); + + return { serverId, remoteId }; +} + +interface FederationInputRewrite { + serverId: string; + rewrittenInput: unknown; + /** Set of raw remote IDs we decoded (useful for rewriting record keys on output). */ + decodedRemoteIds: ReadonlySet; +} + +function rewriteFederationInputIds(input: unknown): FederationInputRewrite | null { + let serverId: string | null = null; + const decodedRemoteIds = new Set(); + + const MAX_DEPTH = 20; + const seen = new WeakSet(); + + const visit = (current: unknown, depth: number): unknown => { + if (depth > MAX_DEPTH) { + return current; + } + + if (Array.isArray(current)) { + let changed = false; + const next = current.map((entry) => { + const rewritten = visit(entry, depth + 1); + if (rewritten !== entry) { + changed = true; + } + return rewritten; + }); + return changed ? next : current; + } + + if (!isPlainObject(current)) { + return current; + } + + if (seen.has(current)) { + return current; + } + + seen.add(current); + + let changed = false; + const next: Record = {}; + + for (const [key, entry] of Object.entries(current)) { + let rewritten = entry; + + if (FEDERATION_ID_KEYS.has(key)) { + if (typeof entry === "string") { + const decoded = decodeRemoteIdForFederation(entry); + if (decoded) { + if (!serverId) { + serverId = decoded.serverId; + } else { + assert( + serverId === decoded.serverId, + "rewriteFederationInputIds: mixed remote server IDs are not supported" + ); + } + + decodedRemoteIds.add(decoded.remoteId); + rewritten = decoded.remoteId; + } + } else if (Array.isArray(entry)) { + let arrayChanged = false; + const nextArray = entry.map((item) => { + if (typeof item === "string") { + const decoded = decodeRemoteIdForFederation(item); + if (decoded) { + if (!serverId) { + serverId = decoded.serverId; + } else { + assert( + serverId === decoded.serverId, + "rewriteFederationInputIds: mixed remote server IDs are not supported" + ); + } + + decodedRemoteIds.add(decoded.remoteId); + arrayChanged = true; + return decoded.remoteId; + } + + return item; + } + + const visited = visit(item, depth + 1); + if (visited !== item) { + arrayChanged = true; + } + return visited; + }); + + rewritten = arrayChanged ? nextArray : entry; + } else { + rewritten = visit(entry, depth + 1); + } + } else { + rewritten = visit(entry, depth + 1); + } + + if (rewritten !== entry) { + changed = true; + } + + next[key] = rewritten; + } + + return changed ? next : current; + }; + + const rewrittenInput = visit(input, 0); + + if (!serverId) { + return null; + } + + return { + serverId, + rewrittenInput, + decodedRemoteIds, + }; +} + +function isFrontendWorkspaceMetadataLike( + value: unknown +): value is FrontendWorkspaceMetadataSchemaType { + if (!isPlainObject(value)) { + return false; + } + + return ( + typeof value.id === "string" && + typeof value.name === "string" && + typeof value.projectPath === "string" + ); +} + +function rewriteRemoteFrontendMetadataBestEffort(params: { + metadata: FrontendWorkspaceMetadataSchemaType; + serverId: string; + remoteProjectPathMap: ReadonlyMap; +}): FrontendWorkspaceMetadataSchemaType { + const rewrittenForLocalProject = rewriteRemoteFrontendWorkspaceMetadataForLocalProject( + params.metadata, + params.serverId, + params.remoteProjectPathMap + ); + + return ( + rewrittenForLocalProject ?? + rewriteRemoteFrontendWorkspaceMetadataIds(params.metadata, params.serverId) + ); +} + +function rewriteFederationOutputValue(params: { + value: unknown; + serverId: string; + remoteProjectPathMap: ReadonlyMap; + decodedRemoteIds: ReadonlySet; +}): unknown { + // Generic rewrite for nested tool payloads (workspaceId/taskId/etc). + // Note: this does NOT rewrite metadata.id, hence the extra metadata pass below. + const rewrittenByKeys = rewriteRemoteTaskToolPayloadIds(params.serverId, params.value); + + const MAX_DEPTH = 20; + const seen = new WeakSet(); + + const visit = (current: unknown, depth: number): unknown => { + if (depth > MAX_DEPTH) { + return current; + } + + if (current === null || current === undefined) { + return current; + } + + // Keep Workspace metadata shape rewriter separate so we can rewrite metadata.id and + // optionally map projectPath (when remote project mappings exist). + if (isFrontendWorkspaceMetadataLike(current)) { + return rewriteRemoteFrontendMetadataBestEffort({ + metadata: current, + serverId: params.serverId, + remoteProjectPathMap: params.remoteProjectPathMap, + }); + } + + if (Array.isArray(current)) { + let changed = false; + const next = current.map((entry) => { + const rewritten = visit(entry, depth + 1); + if (rewritten !== entry) { + changed = true; + } + return rewritten; + }); + + return changed ? next : current; + } + + if (!isPlainObject(current)) { + return current; + } + + if (seen.has(current)) { + return current; + } + + seen.add(current); + + let changed = false; + const next: Record = {}; + + for (const [key, entry] of Object.entries(current)) { + const nextKey = params.decodedRemoteIds.has(key) + ? encodeRemoteIdBestEffort(params.serverId, key) + : key; + + // VALUE rewriting: re-encode string values under known ID keys so that + // output IDs (e.g. sessionId from terminal.create) are encoded for the + // frontend to round-trip back through federation on subsequent calls. + let nextValue: unknown; + if (FEDERATION_ID_KEYS.has(key) && typeof entry === "string" && !isRemoteWorkspaceId(entry)) { + nextValue = encodeRemoteIdBestEffort(params.serverId, entry); + } else { + nextValue = visit(entry, depth + 1); + } + + if (nextKey !== key || nextValue !== entry) { + changed = true; + } + + next[nextKey] = nextValue; + } + + return changed ? next : current; + }; + + return visit(rewrittenByKeys, 0); +} + +function rewriteSubagentTranscriptOutput(params: { value: unknown; serverId: string }): unknown { + if (!isPlainObject(params.value)) { + return params.value; + } + + const messagesValue = params.value.messages; + if (!Array.isArray(messagesValue)) { + return params.value; + } + + let changed = false; + const nextMessages = messagesValue.map((message) => { + const maybeMessage = message as MuxMessage; + const rewritten = rewriteRemoteTaskToolPartsInMessage(maybeMessage, params.serverId); + if (rewritten !== maybeMessage) { + changed = true; + } + return rewritten; + }); + + return changed ? { ...params.value, messages: nextMessages } : params.value; +} + +function isExactPath(path: readonly string[], expected: readonly string[]): boolean { + if (path.length !== expected.length) { + return false; + } + + for (let i = 0; i < path.length; i += 1) { + if (path[i] !== expected[i]) { + return false; + } + } + + return true; +} + +export function createFederationMiddleware() { + return os.$context().middleware(async (options, input, output) => { + // Backend kill-switch for remote server federation. + // Config override "off" → force-disable (admin kill-switch). + // Config override "on" → force-enable (self-hosted without PostHog). + // Config override "default" → check experiment service (PostHog). + const override = options.context.config.getFeatureFlagOverride( + EXPERIMENT_IDS.REMOTE_MUX_SERVERS + ); + if (override === "off") return options.next(); + if ( + override === "default" && + !options.context.experimentsService.isExperimentEnabled(EXPERIMENT_IDS.REMOTE_MUX_SERVERS) + ) { + return options.next(); + } + + // Skip routes that handle their own remote splitting + if (isExactPath(options.path, ["workspace", "getSessionUsageBatch"])) { + return options.next(); + } + + // Skip local-only Electron terminal window operations — these manage + // local windows/processes and must never be proxied to remote servers. + if ( + isExactPath(options.path, ["terminal", "openWindow"]) || + isExactPath(options.path, ["terminal", "closeWindow"]) || + isExactPath(options.path, ["terminal", "openNative"]) + ) { + return options.next(); + } + + const rewrittenInput = rewriteFederationInputIds(input); + if (!rewrittenInput) { + return options.next(); + } + + const config = options.context.config.loadConfigOrDefault(); + const server = + config.remoteServers?.find((entry) => entry.id === rewrittenInput.serverId) ?? null; + if (!server) { + throw new Error(`Remote server not found: ${rewrittenInput.serverId}`); + } + + if (server.enabled === false) { + throw new Error(`Remote server is disabled: ${rewrittenInput.serverId}`); + } + + const authToken = + options.context.remoteServersService.getAuthToken({ id: rewrittenInput.serverId }) ?? + undefined; + + const client = createRemoteClient({ + baseUrl: server.baseUrl, + authToken, + timeoutMs: 30_000, + }); + + const procedure = resolveRemoteProcedure(client, options.path); + + const remoteAbortController = createLinkedAbortController(options.signal); + + const remoteResult = await procedure(rewrittenInput.rewrittenInput, { + signal: remoteAbortController.signal, + lastEventId: options.lastEventId, + }); + + const remoteProjectPathMap = buildRemoteProjectPathMap(server.projectMappings); + + const rewriteValue = (value: unknown): unknown => { + if (isExactPath(options.path, ["workspace", "getFullReplay"]) && Array.isArray(value)) { + return value.map((entry) => + rewriteRemoteWorkspaceChatMessageIds( + entry as WorkspaceChatMessage, + rewrittenInput.serverId + ) + ); + } + + if (isExactPath(options.path, ["workspace", "getSubagentTranscript"])) { + return rewriteSubagentTranscriptOutput({ value, serverId: rewrittenInput.serverId }); + } + + const rewritten = rewriteFederationOutputValue({ + value, + serverId: rewrittenInput.serverId, + remoteProjectPathMap, + decodedRemoteIds: rewrittenInput.decodedRemoteIds, + }); + + // terminal.listSessions returns a bare string[] of session IDs — the + // generic object-key rewriter won't touch plain array elements, so we + // re-encode each element explicitly. + if (isExactPath(options.path, ["terminal", "listSessions"])) { + if (Array.isArray(rewritten)) { + return rewritten.map((id: unknown) => + typeof id === "string" && !isRemoteWorkspaceId(id) + ? encodeRemoteIdBestEffort(rewrittenInput.serverId, id) + : id + ); + } + } + + // workspace.fork returns { metadata, projectPath }. The generic rewriter + // rewrites metadata via duck-typing, but the top-level projectPath is a + // remote filesystem path that needs mapping to its local equivalent. + if (isExactPath(options.path, ["workspace", "fork"])) { + if (typeof rewritten === "object" && rewritten !== null && "projectPath" in rewritten) { + const record = rewritten as Record; + const remotePath = record.projectPath; + if (typeof remotePath === "string") { + const localPath = remoteProjectPathMap.get(stripTrailingSlashes(remotePath.trim())); + if (localPath) { + record.projectPath = localPath; + } + } + } + } + + return rewritten; + }; + + if (isAsyncIterable(remoteResult)) { + const mapValue = (value: unknown) => { + if (isExactPath(options.path, ["workspace", "onChat"])) { + return rewriteRemoteWorkspaceChatMessageIds( + value as WorkspaceChatMessage, + rewrittenInput.serverId + ); + } + + return rewriteValue(value); + }; + + return output( + wrapAsyncIterable({ + iterable: remoteResult, + mapValue, + abortController: remoteAbortController, + }) + ); + } + + return output(rewriteValue(remoteResult)); + }); +} diff --git a/src/node/orpc/remoteMuxProxying.test.ts b/src/node/orpc/remoteMuxProxying.test.ts new file mode 100644 index 0000000000..6709120480 --- /dev/null +++ b/src/node/orpc/remoteMuxProxying.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from "bun:test"; + +import type { + FrontendWorkspaceMetadataSchemaType, + WorkspaceChatMessage, +} from "@/common/orpc/types"; +import { encodeRemoteWorkspaceId } from "@/common/utils/remoteMuxIds"; + +import { + rewriteRemoteFrontendWorkspaceMetadataForLocalProject, + rewriteRemoteWorkspaceChatMessageIds, +} from "./remoteMuxProxying"; + +describe("remoteMuxProxying", () => { + describe("rewriteRemoteWorkspaceChatMessageIds", () => { + test("rewrites tool-call-start.args for task tools", () => { + const serverId = "test-remote"; + + const message: WorkspaceChatMessage = { + type: "tool-call-start", + workspaceId: "workspace-root", + messageId: "message-1", + toolCallId: "tool-call-1", + toolName: "task/task_create", + args: { + workspaceId: "workspace-child", + task_id: "task-child", + unrelated: "leave-me-alone", + }, + tokens: 0, + timestamp: 0, + }; + + const rewritten = rewriteRemoteWorkspaceChatMessageIds(message, serverId); + + expect(rewritten.type).toBe("tool-call-start"); + if (rewritten.type !== "tool-call-start") { + throw new Error(`Expected tool-call-start message but got: ${rewritten.type}`); + } + + expect(rewritten.workspaceId).toBe(encodeRemoteWorkspaceId(serverId, "workspace-root")); + + const argsUnknown: unknown = rewritten.args; + expect(argsUnknown && typeof argsUnknown).toBe("object"); + + const argsRecord = argsUnknown as Record; + expect(argsRecord.workspaceId).toBe(encodeRemoteWorkspaceId(serverId, "workspace-child")); + expect(argsRecord.task_id).toBe(encodeRemoteWorkspaceId(serverId, "task-child")); + expect(argsRecord.unrelated).toBe("leave-me-alone"); + }); + + test("rewrites legacy tool-call-end.result.metadata.id for task tools", () => { + const serverId = "test-remote"; + + const message: WorkspaceChatMessage = { + type: "tool-call-end", + workspaceId: "workspace-root", + messageId: "message-1", + toolCallId: "tool-call-1", + toolName: "task/task_await", + result: { + metadata: { + id: "workspace-child", + }, + workspaceId: "workspace-from-result", + }, + timestamp: 0, + }; + + const rewritten = rewriteRemoteWorkspaceChatMessageIds(message, serverId); + + expect(rewritten.type).toBe("tool-call-end"); + if (rewritten.type !== "tool-call-end") { + throw new Error(`Expected tool-call-end message but got: ${rewritten.type}`); + } + + const resultUnknown: unknown = rewritten.result; + expect(resultUnknown && typeof resultUnknown).toBe("object"); + + const resultRecord = resultUnknown as Record; + expect(resultRecord.workspaceId).toBe( + encodeRemoteWorkspaceId(serverId, "workspace-from-result") + ); + + const metadataUnknown: unknown = resultRecord.metadata; + expect(metadataUnknown && typeof metadataUnknown).toBe("object"); + + const metadataRecord = metadataUnknown as Record; + expect(metadataRecord.id).toBe(encodeRemoteWorkspaceId(serverId, "workspace-child")); + }); + }); + + describe("rewriteRemoteFrontendWorkspaceMetadataForLocalProject", () => { + test("maps runtimeConfig.projectPath (when present) to local projectPath", () => { + const serverId = "test-remote"; + + const remoteProjectPath = "/remote/project"; + const localProjectPath = "/local/project"; + + const remoteProjectPathMap = new Map([[remoteProjectPath, localProjectPath]]); + + const metadata: FrontendWorkspaceMetadataSchemaType = { + id: "workspace-1", + name: "branch-1", + projectName: "project", + projectPath: remoteProjectPath, + runtimeConfig: { + type: "local", + projectPath: remoteProjectPath, + } as unknown as FrontendWorkspaceMetadataSchemaType["runtimeConfig"], + namedWorkspacePath: "/remote/project/.mux/workspace-1", + }; + + const rewritten = rewriteRemoteFrontendWorkspaceMetadataForLocalProject( + metadata, + serverId, + remoteProjectPathMap + ); + + expect(rewritten).not.toBeNull(); + expect(rewritten?.id).toBe(encodeRemoteWorkspaceId(serverId, "workspace-1")); + expect(rewritten?.projectPath).toBe(localProjectPath); + + const runtimeConfigUnknown: unknown = rewritten?.runtimeConfig; + expect(runtimeConfigUnknown && typeof runtimeConfigUnknown).toBe("object"); + + const runtimeConfigRecord = runtimeConfigUnknown as Record; + expect(runtimeConfigRecord.projectPath).toBe(localProjectPath); + }); + }); +}); diff --git a/src/node/orpc/remoteMuxProxying.ts b/src/node/orpc/remoteMuxProxying.ts new file mode 100644 index 0000000000..3bfeb9dae6 --- /dev/null +++ b/src/node/orpc/remoteMuxProxying.ts @@ -0,0 +1,563 @@ +import type { z } from "zod"; +import type * as schemas from "@/common/orpc/schemas"; +import type { + FrontendWorkspaceMetadataSchemaType, + WorkspaceActivitySnapshot, + WorkspaceChatMessage, +} from "@/common/orpc/types"; +import type { MuxMessage } from "@/common/types/message"; +import { decodeRemoteWorkspaceId, encodeRemoteWorkspaceId } from "@/common/utils/remoteMuxIds"; +import type { ORPCContext } from "./context"; +import { createRemoteClient } from "@/node/remote/remoteOrpcClient"; +import { stripTrailingSlashes } from "@/node/utils/pathUtils"; +import { EXPERIMENT_IDS } from "@/common/constants/experiments"; +import assert from "node:assert/strict"; + +// ----------------------------------------------------------------------------- +// Remote workspace proxying +// ----------------------------------------------------------------------------- + +export interface RemoteMuxOrpcClient { + workspace: { + list: ( + input: z.infer + ) => Promise; + create: ( + input: z.infer + ) => Promise>; + onMetadata: ( + input: z.infer, + options?: { signal?: AbortSignal } + ) => Promise< + AsyncIterable<{ + workspaceId: string; + metadata: FrontendWorkspaceMetadataSchemaType | null; + }> + >; + activity: { + list: ( + input: z.infer + ) => Promise>; + subscribe: ( + input: z.infer, + options?: { signal?: AbortSignal } + ) => Promise< + AsyncIterable<{ + workspaceId: string; + activity: WorkspaceActivitySnapshot | null; + }> + >; + }; + onChat: ( + input: z.infer, + options?: { signal?: AbortSignal } + ) => Promise>; + sendMessage: ( + input: z.infer + ) => Promise>; + answerAskUserQuestion: ( + input: z.infer + ) => Promise>; + resumeStream: ( + input: z.infer + ) => Promise>; + interruptStream: ( + input: z.infer + ) => Promise>; + archive: ( + input: z.infer + ) => Promise>; + unarchive: ( + input: z.infer + ) => Promise>; + getInfo: ( + input: z.infer + ) => Promise; + getFullReplay: ( + input: z.infer + ) => Promise; + getSubagentTranscript: ( + input: z.infer + ) => Promise>; + getSessionUsageBatch: ( + input: z.infer + ) => Promise>; + }; + agents: { + list: ( + input: z.infer + ) => Promise>; + get: ( + input: z.infer + ) => Promise>; + }; + agentSkills: { + list: ( + input: z.infer + ) => Promise>; + listDiagnostics: ( + input: z.infer + ) => Promise>; + get: ( + input: z.infer + ) => Promise>; + }; +} + +export interface RemoteWorkspaceProxy { + client: RemoteMuxOrpcClient; + remoteWorkspaceId: string; + serverId: string; +} + +export function resolveRemoteWorkspaceProxy( + context: ORPCContext, + workspaceId: string +): RemoteWorkspaceProxy | null { + const decoded = decodeRemoteWorkspaceId(workspaceId); + if (!decoded) { + return null; + } + + const serverId = decoded.serverId.trim(); + const remoteWorkspaceId = decoded.remoteId.trim(); + + assert(serverId.length > 0, "resolveRemoteWorkspaceProxy: decoded serverId must be non-empty"); + assert( + remoteWorkspaceId.length > 0, + "resolveRemoteWorkspaceProxy: decoded remoteWorkspaceId must be non-empty" + ); + + const config = context.config.loadConfigOrDefault(); + const server = config.remoteServers?.find((entry) => entry.id === serverId) ?? null; + assert(server, `Remote server not found for id: ${serverId}`); + + const authToken = context.remoteServersService.getAuthToken({ id: serverId }) ?? undefined; + const client = createRemoteClient({ baseUrl: server.baseUrl, authToken }); + + return { client, remoteWorkspaceId, serverId }; +} + +export function encodeRemoteIdBestEffort(serverId: string, remoteId: string): string { + assert(typeof serverId === "string", "encodeRemoteIdBestEffort: serverId must be a string"); + assert(typeof remoteId === "string", "encodeRemoteIdBestEffort: remoteId must be a string"); + + // Avoid double-encoding if a remote server ever returns already-encoded IDs. + if (decodeRemoteWorkspaceId(remoteId) !== null) { + return remoteId; + } + + const trimmed = remoteId.trim(); + if (!trimmed) { + // Keep rewriting tolerant/defensive. + return remoteId; + } + + return encodeRemoteWorkspaceId(serverId, trimmed); +} + +export function getRemoteServersForWorkspaceViews(context: ORPCContext) { + const config = context.config.loadConfigOrDefault(); + const servers = config.remoteServers ?? []; + if (servers.length === 0) return []; + + // Backend kill-switch: honor config override, then fall back to experiment service. + // Config override "off" → force-disable (admin kill-switch). + // Config override "on" → force-enable (self-hosted without PostHog). + // Config override "default" → check experiment service (PostHog). + const override = context.config.getFeatureFlagOverride(EXPERIMENT_IDS.REMOTE_MUX_SERVERS); + if (override === "off") return []; + if ( + override === "default" && + !context.experimentsService.isExperimentEnabled(EXPERIMENT_IDS.REMOTE_MUX_SERVERS) + ) { + return []; + } + + return servers.filter((server) => server.enabled !== false && server.projectMappings.length > 0); +} + +export function buildRemoteProjectPathMap( + projectMappings: Array<{ localProjectPath: string; remoteProjectPath: string }> +): Map { + const map = new Map(); + for (const mapping of projectMappings) { + const remoteProjectPath = stripTrailingSlashes(mapping.remoteProjectPath.trim()); + const localProjectPath = stripTrailingSlashes(mapping.localProjectPath.trim()); + + if (!remoteProjectPath || !localProjectPath) { + continue; + } + + map.set(remoteProjectPath, localProjectPath); + } + + return map; +} + +export function rewriteRemoteFrontendWorkspaceMetadataForLocalProject( + metadata: FrontendWorkspaceMetadataSchemaType, + serverId: string, + remoteProjectPathMap: ReadonlyMap +): FrontendWorkspaceMetadataSchemaType | null { + const normalizedProjectPath = stripTrailingSlashes(metadata.projectPath.trim()); + if (!normalizedProjectPath) { + return null; + } + + const localProjectPath = remoteProjectPathMap.get(normalizedProjectPath); + if (!localProjectPath) { + return null; + } + + const rewritten = rewriteRemoteFrontendWorkspaceMetadataIds(metadata, serverId); + + let runtimeConfig = rewritten.runtimeConfig; + + // Backward-compatible: some mux versions redundantly included projectPath inside runtimeConfig. + // When present, keep it aligned with the mapped (local) projectPath so UI code doesn't + // accidentally use the remote path. + const runtimeConfigUnknown: unknown = runtimeConfig; + if (runtimeConfigUnknown && typeof runtimeConfigUnknown === "object") { + const runtimeConfigRecord = runtimeConfigUnknown as Record; + const runtimeProjectPathRaw = runtimeConfigRecord.projectPath; + if (typeof runtimeProjectPathRaw === "string") { + const normalizedRuntimeProjectPath = stripTrailingSlashes(runtimeProjectPathRaw.trim()); + const mappedRuntimeProjectPath = remoteProjectPathMap.get(normalizedRuntimeProjectPath); + + if (mappedRuntimeProjectPath && mappedRuntimeProjectPath !== runtimeProjectPathRaw) { + // Type assertion is safe: runtimeConfig is a Zod-inferred union that permits extra keys. + // We cast through unknown to satisfy TS's non-overlapping union check. + runtimeConfig = { + ...runtimeConfigRecord, + projectPath: mappedRuntimeProjectPath, + } as unknown as FrontendWorkspaceMetadataSchemaType["runtimeConfig"]; + } + } + } + + if (rewritten.projectPath === localProjectPath && runtimeConfig === rewritten.runtimeConfig) { + return rewritten; + } + + return { + ...rewritten, + projectPath: localProjectPath, + runtimeConfig, + }; +} + +export async function sleepMs(ms: number, signal: AbortSignal): Promise { + assert(Number.isFinite(ms) && ms >= 0, "sleepMs: ms must be a non-negative finite number"); + + if (signal.aborted) { + return; + } + + await new Promise((resolve) => { + let timeout: ReturnType | null = null; + + const onAbort = () => { + if (timeout) { + clearTimeout(timeout); + } + resolve(); + }; + + timeout = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +export function rewriteRemoteFrontendWorkspaceMetadataIds( + metadata: FrontendWorkspaceMetadataSchemaType, + serverId: string +): FrontendWorkspaceMetadataSchemaType { + const next: FrontendWorkspaceMetadataSchemaType = { ...metadata }; + + if (typeof next.id === "string") { + next.id = encodeRemoteIdBestEffort(serverId, next.id); + } + + if (typeof next.parentWorkspaceId === "string") { + next.parentWorkspaceId = encodeRemoteIdBestEffort(serverId, next.parentWorkspaceId); + } + + if (typeof next.sectionId === "string") { + next.sectionId = encodeRemoteIdBestEffort(serverId, next.sectionId); + } + + return next; +} + +const TASK_TOOL_RESULT_ID_KEYS = new Set([ + "workspaceId", + "parentWorkspaceId", + "sourceWorkspaceId", + "taskId", + // Snake_case variants used by task tools. + "task_id", + "task_ids", +]); + +function isPlainObject(value: unknown): value is Record { + if (!value || typeof value !== "object") { + return false; + } + + const proto: unknown = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +export function rewriteRemoteTaskToolPayloadIds(serverId: string, value: unknown): unknown { + const seen = new WeakSet(); + const MAX_DEPTH = 20; + + const visit = (current: unknown, depth: number): unknown => { + if (depth > MAX_DEPTH) { + return current; + } + + if (Array.isArray(current)) { + let changed = false; + const next = current.map((entry) => { + const rewritten = visit(entry, depth + 1); + if (rewritten !== entry) { + changed = true; + } + return rewritten; + }); + return changed ? next : current; + } + + if (!isPlainObject(current)) { + return current; + } + + if (seen.has(current)) { + return current; + } + + seen.add(current); + + let changed = false; + const next: Record = {}; + for (const [key, entry] of Object.entries(current)) { + let rewritten = entry; + + if (TASK_TOOL_RESULT_ID_KEYS.has(key)) { + if (typeof entry === "string") { + rewritten = encodeRemoteIdBestEffort(serverId, entry); + } else if (Array.isArray(entry)) { + let arrayChanged = false; + + const nextArray = entry.map((item) => { + if (typeof item === "string") { + const encoded = encodeRemoteIdBestEffort(serverId, item); + if (encoded !== item) { + arrayChanged = true; + } + return encoded; + } + + const visited = visit(item, depth + 1); + if (visited !== item) { + arrayChanged = true; + } + return visited; + }); + + rewritten = arrayChanged ? nextArray : entry; + } else { + rewritten = visit(entry, depth + 1); + } + } else { + rewritten = visit(entry, depth + 1); + } + + if (rewritten !== entry) { + changed = true; + } + + next[key] = rewritten; + } + + return changed ? next : current; + }; + + return visit(value, 0); +} + +export function rewriteRemoteTaskToolPartsInMessage( + message: T, + serverId: string +): T { + let changed = false; + + const nextParts = message.parts.map((part) => { + if (part.type !== "dynamic-tool") { + return part; + } + + const isTaskTool = part.toolName.startsWith("task"); + + const rewrittenInput = isTaskTool + ? rewriteRemoteTaskToolPayloadIds(serverId, part.input) + : part.input; + + const rewrittenOutput = + isTaskTool && part.state === "output-available" + ? rewriteRemoteTaskToolPayloadIds(serverId, part.output) + : part.state === "output-available" + ? part.output + : undefined; + + let nestedChanged = false; + const nextNestedCalls = Array.isArray(part.nestedCalls) + ? part.nestedCalls.map((call) => { + if (typeof call.toolName !== "string" || !call.toolName.startsWith("task")) { + return call; + } + + const nextInput = rewriteRemoteTaskToolPayloadIds(serverId, call.input); + const nextOutput = + call.state === "output-available" && call.output !== undefined + ? rewriteRemoteTaskToolPayloadIds(serverId, call.output) + : call.output; + + if (nextInput === call.input && nextOutput === call.output) { + return call; + } + + nestedChanged = true; + return { + ...call, + input: nextInput, + output: nextOutput, + }; + }) + : part.nestedCalls; + + const outputChanged = + part.state === "output-available" && isTaskTool && rewrittenOutput !== part.output; + + if (rewrittenInput === part.input && !outputChanged && !nestedChanged) { + return part; + } + + changed = true; + + if (part.state === "output-available") { + return { + ...part, + input: rewrittenInput, + output: rewrittenOutput, + nestedCalls: nextNestedCalls, + }; + } + + return { + ...part, + input: rewrittenInput, + nestedCalls: nextNestedCalls, + }; + }); + + if (!changed) { + return message; + } + + // Type assertion is safe: we only replace parts with the same part union type. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return { ...message, parts: nextParts } as T; +} + +export function rewriteRemoteWorkspaceChatMessageIds( + message: WorkspaceChatMessage, + serverId: string +): WorkspaceChatMessage { + if (!message || typeof message !== "object") { + return message; + } + + if (message.type === "message") { + return rewriteRemoteTaskToolPartsInMessage(message, serverId); + } + + let changed = false; + const next: WorkspaceChatMessage = { ...message }; + + if ("workspaceId" in next && typeof next.workspaceId === "string") { + const rewritten = encodeRemoteIdBestEffort(serverId, next.workspaceId); + if (rewritten !== next.workspaceId) { + next.workspaceId = rewritten; + changed = true; + } + } + + if (next.type === "task-created" && typeof next.taskId === "string") { + const rewritten = encodeRemoteIdBestEffort(serverId, next.taskId); + if (rewritten !== next.taskId) { + next.taskId = rewritten; + changed = true; + } + } + + if (next.type === "session-usage-delta" && typeof next.sourceWorkspaceId === "string") { + const rewritten = encodeRemoteIdBestEffort(serverId, next.sourceWorkspaceId); + if (rewritten !== next.sourceWorkspaceId) { + next.sourceWorkspaceId = rewritten; + changed = true; + } + } + + if (next.type === "tool-call-start" && next.toolName.startsWith("task")) { + const rewrittenArgs = rewriteRemoteTaskToolPayloadIds(serverId, next.args); + if (rewrittenArgs !== next.args) { + next.args = rewrittenArgs; + changed = true; + } + } + + // Tools like task/task_await return workspace IDs that must be re-encoded locally. + if (next.type === "tool-call-end" && next.toolName.startsWith("task")) { + let rewrittenResult = rewriteRemoteTaskToolPayloadIds(serverId, next.result); + + // Some legacy tool result shapes wrap IDs inside result.metadata.id. + // Best-effort: rewrite this nested field without over-encoding every `id`. + if (isPlainObject(rewrittenResult)) { + const record = rewrittenResult; + const metadataValue = record.metadata; + + if (isPlainObject(metadataValue)) { + const metadataRecord = metadataValue; + const idValue = metadataRecord.id; + + if (typeof idValue === "string") { + const rewrittenId = encodeRemoteIdBestEffort(serverId, idValue); + + if (rewrittenId !== idValue) { + rewrittenResult = { + ...record, + metadata: { + ...metadataRecord, + id: rewrittenId, + }, + }; + } + } + } + } + + if (rewrittenResult !== next.result) { + next.result = rewrittenResult; + changed = true; + } + } + + return changed ? next : message; +} diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 98c4abcb9d..3d6181f7d2 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1,6 +1,7 @@ import { os } from "@orpc/server"; import * as schemas from "@/common/orpc/schemas"; import type { ORPCContext } from "./context"; +import type { z } from "zod"; import { MUX_GATEWAY_ORIGIN, MUX_GATEWAY_SESSION_EXPIRED_MESSAGE, @@ -17,12 +18,24 @@ import type { } from "@/common/orpc/types"; import type { WorkspaceMetadata } from "@/common/types/workspace"; import { createAuthMiddleware } from "./authMiddleware"; +import { createFederationMiddleware } from "./federationMiddleware"; +import { + buildRemoteProjectPathMap, + encodeRemoteIdBestEffort, + getRemoteServersForWorkspaceViews, + rewriteRemoteFrontendWorkspaceMetadataForLocalProject, + sleepMs, + type RemoteMuxOrpcClient, +} from "./remoteMuxProxying"; import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue"; import { createReplayBufferedStreamMessageRelay } from "./replayBufferedStreamMessageRelay"; +import { decodeRemoteWorkspaceId, encodeRemoteWorkspaceId } from "@/common/utils/remoteMuxIds"; +import { createRemoteClient } from "@/node/remote/remoteOrpcClient"; import { createRuntime, checkRuntimeAvailability } from "@/node/runtime/runtimeFactory"; import { createRuntimeForWorkspace } from "@/node/runtime/runtimeHelpers"; import { hasNonEmptyPlanFile, readPlanFile } from "@/node/utils/runtime/helpers"; +import { stripTrailingSlashes } from "@/node/utils/pathUtils"; import { secretsToRecord } from "@/common/types/secrets"; import { roundToBase2 } from "@/common/telemetry/utils"; import { createAsyncEventQueue } from "@/common/utils/asyncEventIterator"; @@ -270,7 +283,10 @@ async function findSubagentTranscriptEntryByScanningSessions(params: { } export const router = (authToken?: string) => { - const t = os.$context().use(createAuthMiddleware(authToken)); + const t = os + .$context() + .use(createAuthMiddleware(authToken)) + .use(createFederationMiddleware()); return t.router({ tokenizer: { @@ -470,6 +486,235 @@ export const router = (authToken?: string) => { }; }), }, + remoteServers: { + list: t + .input(schemas.remoteServers.list.input) + .output(schemas.remoteServers.list.output) + .handler(({ context }) => { + return context.remoteServersService.list(); + }), + upsert: t + .input(schemas.remoteServers.upsert.input) + .output(schemas.remoteServers.upsert.output) + .handler(async ({ context, input }) => { + try { + await context.remoteServersService.upsert({ + config: input.config, + authToken: input.authToken, + }); + return Ok(undefined); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(message); + } + }), + remove: t + .input(schemas.remoteServers.remove.input) + .output(schemas.remoteServers.remove.output) + .handler(async ({ context, input }) => { + // Guard: refuse removal when the server still has project mappings. + // Project mappings cause remote workspaces to appear in the UI via + // onMetadata subscriptions. If removed, subsequent operations on those + // remote-encoded workspace IDs would hit the assert(server, ...) in + // the federation middleware and crash. + const servers = context.remoteServersService.list(); + const targetServer = servers.find((s) => s.config.id === input.id.trim()); + if (targetServer && targetServer.config.projectMappings.length > 0) { + const label = targetServer.config.label || targetServer.config.id; + const count = targetServer.config.projectMappings.length; + return Err( + `Cannot remove server "${label}": it has ${count} project mapping(s) ` + + `that may reference active workspaces. Remove all project mappings first.` + ); + } + + try { + await context.remoteServersService.remove({ id: input.id }); + return Ok(undefined); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(message); + } + }), + clearAuthToken: t + .input(schemas.remoteServers.clearAuthToken.input) + .output(schemas.remoteServers.clearAuthToken.output) + .handler(async ({ context, input }) => { + try { + await context.remoteServersService.clearAuthToken({ id: input.id }); + return Ok(undefined); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(message); + } + }), + ping: t + .input(schemas.remoteServers.ping.input) + .output(schemas.remoteServers.ping.output) + .handler(async ({ context, input }) => { + try { + const version = await context.remoteServersService.ping({ id: input.id }); + return Ok({ version }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(message); + } + }), + listRemoteProjects: t + .input(schemas.remoteServers.listRemoteProjects.input) + .output(schemas.remoteServers.listRemoteProjects.output) + .handler(async ({ context, input }) => { + const serverId = input.id.trim(); + if (!serverId) { + return Err("Remote server id is required"); + } + + const config = context.config.loadConfigOrDefault(); + const server = config.remoteServers?.find((entry) => entry.id === serverId) ?? null; + if (!server) { + return Err(`Remote server not found: ${serverId}`); + } + + const authToken = + context.remoteServersService.getAuthToken({ id: serverId }) ?? undefined; + + type RemoteProjectsListOutput = z.infer; + interface RemoteMuxProjectsClient { + projects: { + list: () => Promise; + }; + } + + let client: RemoteMuxProjectsClient; + try { + client = createRemoteClient({ + baseUrl: server.baseUrl, + authToken, + timeoutMs: 30_000, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(message); + } + + try { + const projects = await client.projects.list(); + + const suggestions = projects + .map(([projectPath]) => stripTrailingSlashes(projectPath.trim())) + .filter((projectPath) => projectPath.length > 0) + .map((projectPath) => { + const label = projectPath + .replace(/[/\\]+$/g, "") + .split(/[/\\]/) + .slice(-1)[0]; + return { + path: projectPath, + label: label ? label : projectPath, + }; + }) + .sort((a, b) => a.label.localeCompare(b.label) || a.path.localeCompare(b.path)); + + return Ok(suggestions); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(message); + } + }), + workspaceCreate: t + .input(schemas.remoteServers.workspaceCreate.input) + .output(schemas.remoteServers.workspaceCreate.output) + .handler(async ({ context, input }) => { + const serverId = input.serverId.trim(); + if (!serverId) { + return { success: false, error: "Remote server id is required" }; + } + + const config = context.config.loadConfigOrDefault(); + const server = config.remoteServers?.find((entry) => entry.id === serverId) ?? null; + if (!server) { + return { success: false, error: `Remote server not found: ${serverId}` }; + } + + if (server.enabled === false) { + return { success: false, error: `Remote server is disabled: ${serverId}` }; + } + + const localProjectPath = stripTrailingSlashes(input.localProjectPath.trim()); + if (!localProjectPath) { + return { success: false, error: "localProjectPath is required" }; + } + + const mapping = + server.projectMappings.find( + (entry) => stripTrailingSlashes(entry.localProjectPath.trim()) === localProjectPath + ) ?? null; + if (!mapping) { + return { + success: false, + error: `No remote project mapping found for local project: ${localProjectPath}`, + }; + } + + const remoteProjectPath = stripTrailingSlashes(mapping.remoteProjectPath.trim()); + if (!remoteProjectPath) { + return { + success: false, + error: `Remote project mapping for ${localProjectPath} has empty remoteProjectPath`, + }; + } + + const authToken = + context.remoteServersService.getAuthToken({ id: serverId }) ?? undefined; + + let client: RemoteMuxOrpcClient; + try { + client = createRemoteClient({ + baseUrl: server.baseUrl, + authToken, + timeoutMs: 30_000, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: message }; + } + + let result: z.infer; + try { + result = await client.workspace.create({ + projectPath: remoteProjectPath, + branchName: input.branchName, + trunkBranch: input.trunkBranch, + title: input.title, + runtimeConfig: input.runtimeConfig, + sectionId: input.sectionId, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: message }; + } + + if (!result.success) { + return { success: false, error: result.error }; + } + + const remoteProjectPathMap = buildRemoteProjectPathMap(server.projectMappings); + const rewritten = rewriteRemoteFrontendWorkspaceMetadataForLocalProject( + result.metadata, + serverId, + remoteProjectPathMap + ); + + if (!rewritten) { + return { + success: false, + error: `Remote workspace created for unmapped project path: ${result.metadata.projectPath}`, + }; + } + + return { success: true, metadata: rewritten }; + }), + }, features: { getStatsTabState: t .input(schemas.features.getStatsTabState.input) @@ -2261,12 +2506,56 @@ export const router = (authToken?: string) => { .output(schemas.workspace.list.output) .handler(async ({ context, input }) => { const allWorkspaces = await context.workspaceService.list(); + // Filter by archived status (derived from timestamps via shared utility) - if (input?.archived) { - return allWorkspaces.filter((w) => isWorkspaceArchived(w.archivedAt, w.unarchivedAt)); - } - // Default: return non-archived workspaces - return allWorkspaces.filter((w) => !isWorkspaceArchived(w.archivedAt, w.unarchivedAt)); + const localWorkspaces = input?.archived + ? allWorkspaces.filter((w) => isWorkspaceArchived(w.archivedAt, w.unarchivedAt)) + : allWorkspaces.filter((w) => !isWorkspaceArchived(w.archivedAt, w.unarchivedAt)); + + const remoteServers = getRemoteServersForWorkspaceViews(context); + + const remoteWorkspaces = ( + await Promise.all( + remoteServers.map(async (server) => { + const remoteProjectPathMap = buildRemoteProjectPathMap(server.projectMappings); + + try { + const authToken = + context.remoteServersService.getAuthToken({ id: server.id }) ?? undefined; + const client = createRemoteClient({ + baseUrl: server.baseUrl, + authToken, + timeoutMs: 30_000, + }); + + const workspaces = await client.workspace.list(input); + + const mapped: FrontendWorkspaceMetadataSchemaType[] = []; + for (const metadata of workspaces) { + const rewritten = rewriteRemoteFrontendWorkspaceMetadataForLocalProject( + metadata, + server.id, + remoteProjectPathMap + ); + if (rewritten) { + mapped.push(rewritten); + } + } + + return mapped; + } catch (error) { + log.warn("Failed to list workspaces from remote server", { + serverId: server.id, + baseUrl: server.baseUrl, + error: error instanceof Error ? error.message : String(error), + }); + return []; + } + }) + ) + ).flat(); + + return [...localWorkspaces, ...remoteWorkspaces]; }), create: t .input(schemas.workspace.create.input) @@ -2749,6 +3038,8 @@ export const router = (authToken?: string) => { .output(schemas.workspace.onMetadata.output) .handler(async function* ({ context, signal }) { const service = context.workspaceService; + const controller = new AbortController(); + const remoteServers = getRemoteServersForWorkspaceViews(context); interface MetadataEvent { workspaceId: string; @@ -2779,6 +3070,7 @@ export const router = (authToken?: string) => { const onAbort = () => { if (ended) return; ended = true; + controller.abort(); if (resolveNext) { const resolve = resolveNext; @@ -2795,6 +3087,113 @@ export const router = (authToken?: string) => { } } + for (const server of remoteServers) { + const remoteProjectPathMap = buildRemoteProjectPathMap(server.projectMappings); + const visibleWorkspaceIds = new Set(); + + const pumpRemoteMetadata = async () => { + const BASE_BACKOFF_MS = 250; + const MAX_BACKOFF_MS = 5_000; + let backoffMs = BASE_BACKOFF_MS; + + while (!controller.signal.aborted) { + // On reconnect, clear stale workspaces from the previous + // connection so the frontend removes them before the fresh + // stream re-adds any that still exist. + for (const staleId of visibleWorkspaceIds) { + const encodedId = encodeRemoteIdBestEffort(server.id, staleId); + push({ workspaceId: encodedId, metadata: null }); + } + visibleWorkspaceIds.clear(); + + try { + const authToken = + context.remoteServersService.getAuthToken({ id: server.id }) ?? undefined; + const client = createRemoteClient({ + baseUrl: server.baseUrl, + authToken, + }); + + const iterator = await client.workspace.onMetadata(undefined, { + signal: controller.signal, + }); + + backoffMs = BASE_BACKOFF_MS; + + for await (const event of iterator) { + if (controller.signal.aborted) { + break; + } + + const remoteWorkspaceId = event.workspaceId.trim(); + if (!remoteWorkspaceId) { + continue; + } + + const encodedWorkspaceId = encodeRemoteIdBestEffort( + server.id, + remoteWorkspaceId + ); + + if (event.metadata) { + const rewritten = rewriteRemoteFrontendWorkspaceMetadataForLocalProject( + event.metadata, + server.id, + remoteProjectPathMap + ); + + if (rewritten) { + visibleWorkspaceIds.add(remoteWorkspaceId); + const metadata = + rewritten.id === encodedWorkspaceId + ? rewritten + : { ...rewritten, id: encodedWorkspaceId }; + push({ workspaceId: encodedWorkspaceId, metadata }); + } else if (visibleWorkspaceIds.has(remoteWorkspaceId)) { + visibleWorkspaceIds.delete(remoteWorkspaceId); + push({ workspaceId: encodedWorkspaceId, metadata: null }); + } + + continue; + } + + if (!visibleWorkspaceIds.has(remoteWorkspaceId)) { + continue; + } + + visibleWorkspaceIds.delete(remoteWorkspaceId); + push({ workspaceId: encodedWorkspaceId, metadata: null }); + } + } catch (error) { + if (controller.signal.aborted) { + break; + } + + log.warn("Remote workspace.onMetadata subscription failed", { + serverId: server.id, + baseUrl: server.baseUrl, + error: error instanceof Error ? error.message : String(error), + }); + } + + if (controller.signal.aborted) { + break; + } + + await sleepMs(backoffMs, controller.signal); + backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS); + } + }; + + void pumpRemoteMetadata().catch((error) => { + log.error("Remote workspace.onMetadata pump crashed", { + serverId: server.id, + baseUrl: server.baseUrl, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + try { while (!ended) { if (queue.length > 0) { @@ -2815,6 +3214,7 @@ export const router = (authToken?: string) => { } finally { ended = true; signal?.removeEventListener("abort", onAbort); + controller.abort(); service.off("metadata", onMetadata); } }), @@ -2823,13 +3223,105 @@ export const router = (authToken?: string) => { .input(schemas.workspace.activity.list.input) .output(schemas.workspace.activity.list.output) .handler(async ({ context }) => { - return context.workspaceService.getActivityList(); + const localActivity = await context.workspaceService.getActivityList(); + const remoteServers = getRemoteServersForWorkspaceViews(context); + + const remoteActivityRecords = await Promise.all( + remoteServers.map(async (server) => { + const remoteProjectPathMap = buildRemoteProjectPathMap(server.projectMappings); + + try { + const authToken = + context.remoteServersService.getAuthToken({ id: server.id }) ?? undefined; + const client = createRemoteClient({ + baseUrl: server.baseUrl, + authToken, + timeoutMs: 30_000, + }); + + const allowedWorkspaceIds = new Set(); + + try { + const workspaces = await client.workspace.list(undefined); + for (const metadata of workspaces) { + // Normalize to match the keys in remoteProjectPathMap (built via stripTrailingSlashes). + const normalizedPath = stripTrailingSlashes(metadata.projectPath.trim()); + if (remoteProjectPathMap.has(normalizedPath)) { + allowedWorkspaceIds.add(metadata.id); + } + } + } catch (error) { + log.warn("Failed to list workspaces from remote server for activity", { + serverId: server.id, + baseUrl: server.baseUrl, + archived: false, + error: error instanceof Error ? error.message : String(error), + }); + } + + try { + const workspaces = await client.workspace.list({ archived: true }); + for (const metadata of workspaces) { + const normalizedPath = stripTrailingSlashes(metadata.projectPath.trim()); + if (remoteProjectPathMap.has(normalizedPath)) { + allowedWorkspaceIds.add(metadata.id); + } + } + } catch (error) { + log.warn("Failed to list archived workspaces from remote server for activity", { + serverId: server.id, + baseUrl: server.baseUrl, + archived: true, + error: error instanceof Error ? error.message : String(error), + }); + } + + if (allowedWorkspaceIds.size === 0) { + return {}; + } + + const remoteActivity = await client.workspace.activity.list(undefined); + + const mapped: Record = {}; + for (const [remoteWorkspaceIdRaw, activity] of Object.entries(remoteActivity)) { + const remoteWorkspaceId = remoteWorkspaceIdRaw.trim(); + if (!remoteWorkspaceId || !allowedWorkspaceIds.has(remoteWorkspaceId)) { + continue; + } + + const encodedWorkspaceId = encodeRemoteIdBestEffort( + server.id, + remoteWorkspaceId + ); + mapped[encodedWorkspaceId] = activity; + } + + return mapped; + } catch (error) { + log.warn("Failed to list workspace activity from remote server", { + serverId: server.id, + baseUrl: server.baseUrl, + error: error instanceof Error ? error.message : String(error), + }); + return {}; + } + }) + ); + + const merged: Record = { ...localActivity }; + for (const record of remoteActivityRecords) { + Object.assign(merged, record); + } + + return merged; }), subscribe: t .input(schemas.workspace.activity.subscribe.input) .output(schemas.workspace.activity.subscribe.output) .handler(async function* ({ context, signal }) { const service = context.workspaceService; + const controller = new AbortController(); + const remoteServers = getRemoteServersForWorkspaceViews(context); interface ActivityEvent { workspaceId: string; @@ -2860,6 +3352,7 @@ export const router = (authToken?: string) => { const onAbort = () => { if (ended) return; ended = true; + controller.abort(); if (resolveNext) { const resolve = resolveNext; @@ -2876,6 +3369,162 @@ export const router = (authToken?: string) => { } } + for (const server of remoteServers) { + const remoteProjectPathMap = buildRemoteProjectPathMap(server.projectMappings); + + const pumpRemoteActivity = async () => { + const BASE_BACKOFF_MS = 250; + const MAX_BACKOFF_MS = 5_000; + let backoffMs = BASE_BACKOFF_MS; + + while (!controller.signal.aborted) { + try { + const authToken = + context.remoteServersService.getAuthToken({ id: server.id }) ?? undefined; + const client = createRemoteClient({ + baseUrl: server.baseUrl, + authToken, + }); + + const allowedWorkspaceIds = new Set(); + + try { + const workspaces = await client.workspace.list(undefined); + for (const metadata of workspaces) { + // Normalize to match the keys in remoteProjectPathMap (built via stripTrailingSlashes). + const normalizedPath = stripTrailingSlashes(metadata.projectPath.trim()); + if (remoteProjectPathMap.has(normalizedPath)) { + allowedWorkspaceIds.add(metadata.id); + } + } + } catch (error) { + log.warn( + "Failed to list workspaces from remote server for activity subscribe", + { + serverId: server.id, + baseUrl: server.baseUrl, + archived: false, + error: error instanceof Error ? error.message : String(error), + } + ); + } + + try { + const workspaces = await client.workspace.list({ archived: true }); + for (const metadata of workspaces) { + const normalizedPath = stripTrailingSlashes(metadata.projectPath.trim()); + if (remoteProjectPathMap.has(normalizedPath)) { + allowedWorkspaceIds.add(metadata.id); + } + } + } catch (error) { + log.warn( + "Failed to list archived workspaces from remote server for activity subscribe", + { + serverId: server.id, + baseUrl: server.baseUrl, + archived: true, + error: error instanceof Error ? error.message : String(error), + } + ); + } + + if (allowedWorkspaceIds.size === 0) { + await sleepMs(backoffMs, controller.signal); + backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS); + continue; + } + + // Periodically refresh the allowlist so workspaces created + // after this subscription started aren't permanently dropped. + let lastAllowlistRefresh = Date.now(); + const ALLOWLIST_REFRESH_INTERVAL_MS = 30_000; + + const iterator = await client.workspace.activity.subscribe(undefined, { + signal: controller.signal, + }); + + backoffMs = BASE_BACKOFF_MS; + + for await (const event of iterator) { + if (controller.signal.aborted) { + break; + } + + // Best-effort: pick up newly created workspaces by + // re-fetching the workspace list on a timer. We only + // *add* to the set so existing entries are never lost. + if (Date.now() - lastAllowlistRefresh > ALLOWLIST_REFRESH_INTERVAL_MS) { + try { + const freshWorkspaces = await client.workspace.list(undefined); + for (const metadata of freshWorkspaces) { + const normalizedPath = stripTrailingSlashes( + metadata.projectPath.trim() + ); + if (remoteProjectPathMap.has(normalizedPath)) { + allowedWorkspaceIds.add(metadata.id); + } + } + + const freshArchived = await client.workspace.list({ archived: true }); + for (const metadata of freshArchived) { + const normalizedPath = stripTrailingSlashes( + metadata.projectPath.trim() + ); + if (remoteProjectPathMap.has(normalizedPath)) { + allowedWorkspaceIds.add(metadata.id); + } + } + } catch { + // Best-effort refresh — don't disrupt the stream. + } + lastAllowlistRefresh = Date.now(); + } + + const remoteWorkspaceId = event.workspaceId.trim(); + if (!remoteWorkspaceId || !allowedWorkspaceIds.has(remoteWorkspaceId)) { + continue; + } + + const encodedWorkspaceId = encodeRemoteIdBestEffort( + server.id, + remoteWorkspaceId + ); + push({ + workspaceId: encodedWorkspaceId, + activity: event.activity, + }); + } + } catch (error) { + if (controller.signal.aborted) { + break; + } + + log.warn("Remote workspace.activity.subscribe failed", { + serverId: server.id, + baseUrl: server.baseUrl, + error: error instanceof Error ? error.message : String(error), + }); + } + + if (controller.signal.aborted) { + break; + } + + await sleepMs(backoffMs, controller.signal); + backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS); + } + }; + + void pumpRemoteActivity().catch((error) => { + log.error("Remote workspace.activity.subscribe pump crashed", { + serverId: server.id, + baseUrl: server.baseUrl, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + try { while (!ended) { if (queue.length > 0) { @@ -2896,6 +3545,7 @@ export const router = (authToken?: string) => { } finally { ended = true; signal?.removeEventListener("abort", onAbort); + controller.abort(); service.off("activity", onActivity); } }), @@ -3034,7 +3684,82 @@ export const router = (authToken?: string) => { .input(schemas.workspace.getSessionUsageBatch.input) .output(schemas.workspace.getSessionUsageBatch.output) .handler(async ({ context, input }) => { - return context.sessionUsageService.getSessionUsageBatch(input.workspaceIds); + // Partition workspace IDs into local vs per-remote-server buckets. + // Federation middleware is skipped for this route because the input + // is an array that may mix local and multi-server remote IDs. + const localIds: string[] = []; + const remoteServerBuckets = new Map(); + + for (const id of input.workspaceIds) { + const decoded = decodeRemoteWorkspaceId(id); + if (!decoded) { + localIds.push(id); + continue; + } + const bucket = remoteServerBuckets.get(decoded.serverId); + if (bucket) { + bucket.remoteIds.push(decoded.remoteId); + } else { + remoteServerBuckets.set(decoded.serverId, { + serverId: decoded.serverId, + remoteIds: [decoded.remoteId], + }); + } + } + + // Fetch local results. + const localResultsPromise = + localIds.length > 0 + ? context.sessionUsageService.getSessionUsageBatch(localIds) + : Promise.resolve( + {} as Record> + ); + + // Fetch remote results per server. + const config = context.config.loadConfigOrDefault(); + const remoteResultsPromise = Promise.all( + Array.from(remoteServerBuckets.values()).map(async ({ serverId, remoteIds }) => { + const server = config.remoteServers?.find((entry) => entry.id === serverId) ?? null; + assert(server, `Remote server not found for ID: ${serverId}`); + + const authToken = + context.remoteServersService.getAuthToken({ id: serverId }) ?? undefined; + const client = createRemoteClient({ + baseUrl: server.baseUrl, + authToken, + timeoutMs: 30_000, + }); + + const results = await client.workspace.getSessionUsageBatch({ + workspaceIds: remoteIds, + }); + + // Re-key results with encoded IDs so the caller sees the + // original remote-encoded workspace IDs it sent in. + const reKeyed: Record< + string, + z.infer + > = {}; + for (const [remoteId, usage] of Object.entries(results)) { + reKeyed[encodeRemoteWorkspaceId(serverId, remoteId)] = usage; + } + return reKeyed; + }) + ); + + const [localResults, remoteResults] = await Promise.all([ + localResultsPromise, + remoteResultsPromise, + ]); + + // Merge local + all remote result dictionaries. + const merged: Record> = { + ...localResults, + }; + for (const remoteResult of remoteResults) { + Object.assign(merged, remoteResult); + } + return merged; }), stats: { subscribe: t diff --git a/src/node/remote/remoteOrpcClient.ts b/src/node/remote/remoteOrpcClient.ts new file mode 100644 index 0000000000..dd3c608f88 --- /dev/null +++ b/src/node/remote/remoteOrpcClient.ts @@ -0,0 +1,73 @@ +import { createORPCClient } from "@orpc/client"; +import { RPCLink as HTTPRPCLink } from "@orpc/client/fetch"; +import assert from "@/common/utils/assert"; + +export interface CreateRemoteClientOptions { + baseUrl: string; + authToken?: string; + /** + * Optional per-request timeout in milliseconds. When set, each fetch call + * will be aborted via `AbortSignal.timeout()` if it does not complete in time. + * If the caller already provides an `AbortSignal`, the two are composed with + * `AbortSignal.any()` so either can cancel the request. + * + * Do NOT set this for streaming subscriptions (onMetadata, onChat, + * activity.subscribe) — they have their own stall detection. + */ + timeoutMs?: number; +} + +/** + * Creates a typed oRPC client for talking to a remote mux server over HTTP. + */ +export function createRemoteClient({ + baseUrl, + authToken, + timeoutMs, +}: CreateRemoteClientOptions): TClient { + assert(typeof baseUrl === "string", "createRemoteClient: baseUrl must be a string"); + + const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/g, ""); + assert(normalizedBaseUrl.length > 0, "createRemoteClient: baseUrl must be non-empty"); + + if (timeoutMs !== undefined) { + assert( + typeof timeoutMs === "number" && timeoutMs > 0, + "createRemoteClient: timeoutMs must be a positive number" + ); + } + + const orpcUrl = `${normalizedBaseUrl}/orpc`; + + let headers: Record | undefined; + if (authToken !== undefined) { + assert(typeof authToken === "string", "createRemoteClient: authToken must be a string"); + const token = authToken.trim(); + assert(token.length > 0, "createRemoteClient: authToken must be non-empty"); + headers = { Authorization: `Bearer ${token}` }; + } + + // When timeoutMs is set, wrap the global fetch so every request is bounded. + // Compose with any existing signal via AbortSignal.any() to honour caller cancellation too. + let customFetch: + | ((input: RequestInfo | URL, init?: RequestInit) => Promise) + | undefined; + if (timeoutMs !== undefined) { + const timeout = timeoutMs; + customFetch = (input: RequestInfo | URL, init?: RequestInit): Promise => { + const timeoutSignal = AbortSignal.timeout(timeout); + const existingSignal = init?.signal; + const composedSignal = existingSignal + ? AbortSignal.any([existingSignal, timeoutSignal]) + : timeoutSignal; + + return fetch(input, { ...init, signal: composedSignal }); + }; + } + + const link = new HTTPRPCLink({ url: orpcUrl, headers, fetch: customFetch }); + + // Type assertion is safe: createORPCClient returns a runtime client object. The caller chooses + // the type parameter based on the procedures they intend to call. + return createORPCClient(link) as unknown as TClient; +} diff --git a/src/node/services/remoteServersService.ts b/src/node/services/remoteServersService.ts new file mode 100644 index 0000000000..b0448324a9 --- /dev/null +++ b/src/node/services/remoteServersService.ts @@ -0,0 +1,278 @@ +import assert from "@/common/utils/assert"; +import type { RemoteMuxServerConfig } from "@/common/types/project"; +import { secretsToRecord, type Secret, type SecretsConfig } from "@/common/types/secrets"; +import type { Config } from "@/node/config"; +import { stripTrailingSlashes } from "@/node/utils/pathUtils"; +import { log } from "./log"; + +const REMOTE_MUX_SERVER_SECRETS_PREFIX = "__remoteMuxServer:"; +const REMOTE_MUX_SERVER_AUTH_TOKEN_KEY = "authToken"; +const REMOTE_MUX_SERVER_ID_RE = /^[a-zA-Z0-9._-]+$/; + +function normalizeRemoteMuxServerId(value: string): string { + assert(typeof value === "string", "remote server id must be a string"); + const id = value.trim(); + assert(id.length > 0, "remote server id must not be empty"); + assert( + REMOTE_MUX_SERVER_ID_RE.test(id), + "remote server id must be filesystem-safe (letters, numbers, ., _, -)" + ); + return id; +} + +function normalizeRemoteMuxServerBaseUrl(value: string): string { + assert(typeof value === "string", "baseUrl must be a string"); + const trimmed = value.trim(); + assert(trimmed.length > 0, "baseUrl must not be empty"); + + const normalized = stripTrailingSlashes(trimmed); + assert(normalized.length > 0, "baseUrl must not be empty"); + + // Defensive: validate baseUrl is an absolute URL. This avoids later surprises when fetching. + let url: URL; + try { + url = new URL(normalized); + } catch { + throw new Error(`Invalid baseUrl: ${value}`); + } + + assert( + url.protocol === "http:" || url.protocol === "https:", + "baseUrl must start with http:// or https://" + ); + + return normalized; +} + +function normalizeRemoteMuxServerConfig(config: RemoteMuxServerConfig): RemoteMuxServerConfig { + assert(config && typeof config === "object", "config is required"); + + const id = normalizeRemoteMuxServerId(config.id); + + assert(typeof config.label === "string", "config.label must be a string"); + const label = config.label.trim(); + assert(label.length > 0, "config.label must not be empty"); + + const baseUrl = normalizeRemoteMuxServerBaseUrl(config.baseUrl); + + assert(Array.isArray(config.projectMappings), "config.projectMappings must be an array"); + const projectMappings: RemoteMuxServerConfig["projectMappings"] = []; + for (const mapping of config.projectMappings) { + if (!mapping || typeof mapping !== "object") continue; + + const { localProjectPath, remoteProjectPath } = mapping as { + localProjectPath?: unknown; + remoteProjectPath?: unknown; + }; + + if (typeof localProjectPath !== "string" || typeof remoteProjectPath !== "string") { + continue; + } + + const localTrimmed = localProjectPath.trim(); + const remoteTrimmed = remoteProjectPath.trim(); + + if (!localTrimmed || !remoteTrimmed) { + continue; + } + + projectMappings.push({ + localProjectPath: localTrimmed, + remoteProjectPath: remoteTrimmed, + }); + } + + return { + id, + label, + baseUrl, + enabled: config.enabled === true ? true : config.enabled === false ? false : undefined, + projectMappings, + }; +} + +function getRemoteMuxServerSecretsKey(serverId: string): string { + return `${REMOTE_MUX_SERVER_SECRETS_PREFIX}${serverId}`; +} + +function getAuthTokenFromSecrets(secrets: Secret[] | undefined): string | null { + if (!secrets || secrets.length === 0) { + return null; + } + + const record = secretsToRecord(secrets); + const authToken = record[REMOTE_MUX_SERVER_AUTH_TOKEN_KEY]; + + if (typeof authToken !== "string") { + return null; + } + + const trimmed = authToken.trim(); + if (!trimmed) { + return null; + } + + return trimmed; +} + +function hasAuthTokenInSecretsConfig(secretsConfig: SecretsConfig, serverId: string): boolean { + const secretsKey = getRemoteMuxServerSecretsKey(serverId); + return Boolean(getAuthTokenFromSecrets(secretsConfig[secretsKey])); +} + +export interface RemoteMuxServerListEntry { + config: RemoteMuxServerConfig; + hasAuthToken: boolean; +} + +export class RemoteServersService { + constructor(private readonly config: Config) { + assert(config, "RemoteServersService requires a Config instance"); + } + + list(): RemoteMuxServerListEntry[] { + const config = this.config.loadConfigOrDefault(); + const remoteServers = config.remoteServers ?? []; + + const secretsConfig = this.config.loadSecretsConfig(); + + return remoteServers.map((entry) => ({ + config: entry, + hasAuthToken: hasAuthTokenInSecretsConfig(secretsConfig, entry.id), + })); + } + + getAuthToken(params: { id: string }): string | null { + const id = normalizeRemoteMuxServerId(params.id); + const secretsConfig = this.config.loadSecretsConfig(); + const secretsKey = getRemoteMuxServerSecretsKey(id); + return getAuthTokenFromSecrets(secretsConfig[secretsKey]); + } + + hasAuthToken(params: { id: string }): boolean { + return Boolean(this.getAuthToken(params)); + } + async upsert(params: { config: RemoteMuxServerConfig; authToken?: string }): Promise { + const normalizedConfig = normalizeRemoteMuxServerConfig(params.config); + + // Warn when an auth token will be sent over plaintext HTTP. + if (params.authToken && normalizedConfig.baseUrl.startsWith("http://")) { + log.warn( + `Server "${normalizedConfig.label ?? normalizedConfig.id}" uses http:// — ` + + "auth tokens will be transmitted in cleartext. Consider switching to https://." + ); + } + + await this.config.editConfig((config) => { + const existing = config.remoteServers ?? []; + const next = [...existing]; + + const existingIndex = next.findIndex((server) => server.id === normalizedConfig.id); + if (existingIndex === -1) { + next.push(normalizedConfig); + } else { + next[existingIndex] = normalizedConfig; + } + + config.remoteServers = next.length > 0 ? next : undefined; + return config; + }); + + if (params.authToken !== undefined) { + const trimmed = params.authToken.trim(); + if (trimmed) { + await this.setAuthToken({ id: normalizedConfig.id, authToken: trimmed }); + } else { + await this.clearAuthToken({ id: normalizedConfig.id }); + } + } + } + + async remove(params: { id: string }): Promise { + const id = normalizeRemoteMuxServerId(params.id); + + await this.config.editConfig((config) => { + const existing = config.remoteServers ?? []; + const next = existing.filter((server) => server.id !== id); + config.remoteServers = next.length > 0 ? next : undefined; + return config; + }); + + await this.clearAuthToken({ id }); + } + + async clearAuthToken(params: { id: string }): Promise { + const id = normalizeRemoteMuxServerId(params.id); + const secretsKey = getRemoteMuxServerSecretsKey(id); + + const secretsConfig = this.config.loadSecretsConfig(); + const existing = secretsConfig[secretsKey]; + if (!existing) { + return; + } + + const next = existing.filter((secret) => secret.key !== REMOTE_MUX_SERVER_AUTH_TOKEN_KEY); + + if (next.length > 0) { + secretsConfig[secretsKey] = next; + } else { + delete secretsConfig[secretsKey]; + } + + await this.config.saveSecretsConfig(secretsConfig); + } + + async setAuthToken(params: { id: string; authToken: string }): Promise { + const id = normalizeRemoteMuxServerId(params.id); + + assert(typeof params.authToken === "string", "authToken must be a string"); + const trimmed = params.authToken.trim(); + assert(trimmed.length > 0, "authToken must not be empty"); + + const secretsKey = getRemoteMuxServerSecretsKey(id); + + const secretsConfig = this.config.loadSecretsConfig(); + const existing = secretsConfig[secretsKey] ?? []; + + const next = existing.filter((secret) => secret.key !== REMOTE_MUX_SERVER_AUTH_TOKEN_KEY); + next.push({ key: REMOTE_MUX_SERVER_AUTH_TOKEN_KEY, value: trimmed }); + + secretsConfig[secretsKey] = next; + await this.config.saveSecretsConfig(secretsConfig); + } + + async ping(params: { id: string }): Promise { + const id = normalizeRemoteMuxServerId(params.id); + + const config = this.config.loadConfigOrDefault(); + const remoteServers = config.remoteServers ?? []; + const server = remoteServers.find((entry) => entry.id === id); + if (!server) { + throw new Error(`Remote server not found: ${id}`); + } + + const url = `${normalizeRemoteMuxServerBaseUrl(server.baseUrl)}/version`; + + const response = await fetch(url, { + method: "GET", + headers: { + accept: "application/json", + }, + signal: AbortSignal.timeout(5_000), + }); + + if (!response.ok) { + let body = ""; + try { + body = await response.text(); + } catch { + // Ignore + } + + const prefix = body.trim().slice(0, 200); + throw new Error(`Remote /version request failed (HTTP ${response.status}): ${prefix}`); + } + + return response.json() as unknown; + } +} diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 42aac5d315..b8d958ce74 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -24,6 +24,7 @@ import { WindowService } from "@/node/services/windowService"; import { UpdateService } from "@/node/services/updateService"; import { TokenizerService } from "@/node/services/tokenizerService"; import { ServerService } from "@/node/services/serverService"; +import { RemoteServersService } from "@/node/services/remoteServersService"; import { MenuEventService } from "@/node/services/menuEventService"; import { VoiceService } from "@/node/services/voiceService"; import { TelemetryService } from "@/node/services/telemetryService"; @@ -101,6 +102,7 @@ export class ServiceContainer { public readonly updateService: UpdateService; public readonly tokenizerService: TokenizerService; public readonly serverService: ServerService; + public readonly remoteServersService: RemoteServersService; public readonly menuEventService: MenuEventService; public readonly voiceService: VoiceService; public readonly mcpOauthService: McpOauthService; @@ -198,6 +200,7 @@ export class ServiceContainer { this.updateService = new UpdateService(); this.tokenizerService = new TokenizerService(this.sessionUsageService); this.serverService = new ServerService(); + this.remoteServersService = new RemoteServersService(config); this.menuEventService = new MenuEventService(); this.voiceService = new VoiceService(config); this.featureFlagService = new FeatureFlagService(config, this.telemetryService); @@ -397,6 +400,7 @@ export class ServiceContainer { policyService: this.policyService, signingService: this.signingService, coderService: this.coderService, + remoteServersService: this.remoteServersService, }; } diff --git a/tests/ipc/agentSkillsRemoteWorkspace.test.ts b/tests/ipc/agentSkillsRemoteWorkspace.test.ts new file mode 100644 index 0000000000..435088ddfc --- /dev/null +++ b/tests/ipc/agentSkillsRemoteWorkspace.test.ts @@ -0,0 +1,118 @@ +/** + * Regression test: agent discovery endpoints should proxy remote workspaceIds. + * + * When a workspaceId is encoded (remote.*), local agent discovery would attempt to resolve + * metadata via the local AIService and fail ("Workspace metadata not found..."). + * + * The router should instead proxy agentSkills.* and agents.* requests to the remote mux server. + */ + +import { encodeRemoteWorkspaceId } from "@/common/utils/remoteMuxIds"; +import { EXPERIMENT_IDS } from "@/common/constants/experiments"; +import { createOrpcServer, type OrpcServer } from "@/node/orpc/server"; +import { + buildOrpcContext, + cleanupTestEnvironment, + createTestEnvironment, + enableExperimentForTesting, +} from "./setup"; +import { + cleanupTempGitRepo, + createTempGitRepo, + createWorkspace, + generateBranchName, +} from "./helpers"; + +const TEST_TIMEOUT_MS = 60_000; + +test( + "agentSkills.* and agents.* proxy remote workspaceIds", + async () => { + const localEnv = await createTestEnvironment(); + enableExperimentForTesting(localEnv, EXPERIMENT_IDS.REMOTE_MUX_SERVERS); + const remoteEnv = await createTestEnvironment(); + const repoPath = await createTempGitRepo(); + + let remoteServer: OrpcServer | null = null; + + try { + remoteServer = await createOrpcServer({ + context: buildOrpcContext(remoteEnv), + host: "127.0.0.1", + port: 0, + }); + + const serverId = "remote-test"; + + const upsertResult = await localEnv.orpc.remoteServers.upsert({ + config: { + id: serverId, + label: "Remote test", + baseUrl: remoteServer.baseUrl, + projectMappings: [{ localProjectPath: repoPath, remoteProjectPath: repoPath }], + }, + }); + + if (!upsertResult.success) { + throw new Error(upsertResult.error); + } + + const branchName = generateBranchName("remote-agent-skills"); + const remoteCreate = await createWorkspace(remoteEnv, repoPath, branchName); + if (!remoteCreate.success) { + throw new Error(remoteCreate.error); + } + + const encodedWorkspaceId = encodeRemoteWorkspaceId(serverId, remoteCreate.metadata.id); + + const listBefore = await localEnv.orpc.workspace.list(); + expect(listBefore.some((w) => w.id === encodedWorkspaceId)).toBe(true); + + const skills = await localEnv.orpc.agentSkills.list({ workspaceId: encodedWorkspaceId }); + expect(Array.isArray(skills)).toBe(true); + expect(skills.length).toBeGreaterThan(0); + + const diagnostics = await localEnv.orpc.agentSkills.listDiagnostics({ + workspaceId: encodedWorkspaceId, + }); + expect(Array.isArray(diagnostics.skills)).toBe(true); + expect(diagnostics.skills.length).toBeGreaterThan(0); + expect(Array.isArray(diagnostics.invalidSkills)).toBe(true); + + const firstSkill = skills[0]; + expect(firstSkill).toBeTruthy(); + expect(typeof firstSkill.name).toBe("string"); + + const skillPkg = await localEnv.orpc.agentSkills.get({ + workspaceId: encodedWorkspaceId, + skillName: firstSkill.name, + }); + expect(skillPkg.frontmatter.name).toBe(firstSkill.name); + expect(typeof skillPkg.body).toBe("string"); + + const agents = await localEnv.orpc.agents.list({ workspaceId: encodedWorkspaceId }); + expect(Array.isArray(agents)).toBe(true); + expect(agents.length).toBeGreaterThan(0); + + const firstAgent = agents[0]; + expect(firstAgent).toBeTruthy(); + expect(typeof firstAgent.id).toBe("string"); + + const agentPkg = await localEnv.orpc.agents.get({ + workspaceId: encodedWorkspaceId, + agentId: firstAgent.id, + }); + expect(agentPkg.id).toBe(firstAgent.id); + expect(typeof agentPkg.body).toBe("string"); + } finally { + if (remoteServer) { + await remoteServer.close(); + } + + await cleanupTestEnvironment(remoteEnv); + await cleanupTestEnvironment(localEnv); + await cleanupTempGitRepo(repoPath); + } + }, + TEST_TIMEOUT_MS +); diff --git a/tests/ipc/archiveRemoteWorkspace.test.ts b/tests/ipc/archiveRemoteWorkspace.test.ts new file mode 100644 index 0000000000..7137e60d9a --- /dev/null +++ b/tests/ipc/archiveRemoteWorkspace.test.ts @@ -0,0 +1,100 @@ +/** + * Regression test: archiving/unarchiving a remote workspace via an encoded workspaceId should + * proxy the request to the remote mux server instead of failing with "Workspace not found". + */ + +import { encodeRemoteWorkspaceId } from "@/common/utils/remoteMuxIds"; +import { EXPERIMENT_IDS } from "@/common/constants/experiments"; +import { createOrpcServer, type OrpcServer } from "@/node/orpc/server"; +import { + buildOrpcContext, + cleanupTestEnvironment, + createTestEnvironment, + enableExperimentForTesting, +} from "./setup"; +import { + cleanupTempGitRepo, + createTempGitRepo, + createWorkspace, + generateBranchName, +} from "./helpers"; + +const TEST_TIMEOUT_MS = 40_000; + +test( + "workspace.archive + workspace.unarchive proxy remote workspaceIds", + async () => { + const localEnv = await createTestEnvironment(); + enableExperimentForTesting(localEnv, EXPERIMENT_IDS.REMOTE_MUX_SERVERS); + const remoteEnv = await createTestEnvironment(); + const repoPath = await createTempGitRepo(); + + let remoteServer: OrpcServer | null = null; + + try { + remoteServer = await createOrpcServer({ + context: buildOrpcContext(remoteEnv), + host: "127.0.0.1", + port: 0, + }); + + const serverId = "remote-test"; + + const upsertResult = await localEnv.orpc.remoteServers.upsert({ + config: { + id: serverId, + label: "Remote test", + baseUrl: remoteServer.baseUrl, + projectMappings: [{ localProjectPath: repoPath, remoteProjectPath: repoPath }], + }, + }); + + if (!upsertResult.success) { + throw new Error(upsertResult.error); + } + + const branchName = generateBranchName("remote-archive"); + const remoteCreate = await createWorkspace(remoteEnv, repoPath, branchName); + if (!remoteCreate.success) { + throw new Error(remoteCreate.error); + } + + const encodedWorkspaceId = encodeRemoteWorkspaceId(serverId, remoteCreate.metadata.id); + + const listBefore = await localEnv.orpc.workspace.list(); + expect(listBefore.some((w) => w.id === encodedWorkspaceId)).toBe(true); + + const archiveResult = await localEnv.orpc.workspace.archive({ + workspaceId: encodedWorkspaceId, + }); + if (!archiveResult.success) { + throw new Error(archiveResult.error); + } + + const listUnarchived = await localEnv.orpc.workspace.list({ archived: false }); + expect(listUnarchived.some((w) => w.id === encodedWorkspaceId)).toBe(false); + + const listArchived = await localEnv.orpc.workspace.list({ archived: true }); + expect(listArchived.some((w) => w.id === encodedWorkspaceId)).toBe(true); + + const unarchiveResult = await localEnv.orpc.workspace.unarchive({ + workspaceId: encodedWorkspaceId, + }); + if (!unarchiveResult.success) { + throw new Error(unarchiveResult.error); + } + + const listAfter = await localEnv.orpc.workspace.list({ archived: false }); + expect(listAfter.some((w) => w.id === encodedWorkspaceId)).toBe(true); + } finally { + if (remoteServer) { + await remoteServer.close(); + } + + await cleanupTestEnvironment(remoteEnv); + await cleanupTestEnvironment(localEnv); + await cleanupTempGitRepo(repoPath); + } + }, + TEST_TIMEOUT_MS +); diff --git a/tests/ipc/queuedMessages.test.ts b/tests/ipc/queuedMessages.test.ts index 29230062f8..276830d600 100644 --- a/tests/ipc/queuedMessages.test.ts +++ b/tests/ipc/queuedMessages.test.ts @@ -64,8 +64,10 @@ async function waitForQueuedMessageEvent( while (Date.now() - startTime < timeoutMs) { const events = collector.getEvents().filter(isQueuedMessageChanged); if (events.length > currentCount) { - // Return the newest event - return events[events.length - 1]; + // Return the FIRST new event after the baseline count. + // This avoids races where the queue is updated twice before we poll + // (e.g. message queued, then immediately cleared on auto-send). + return events[currentCount]; } await new Promise((resolve) => setTimeout(resolve, 100)); } @@ -95,7 +97,11 @@ describeIntegration("Queued messages", () => { async () => { const { env, workspaceId, cleanup } = await setupWorkspace("anthropic"); try { - // Start initial stream + const collector1 = createStreamCollector(env.orpc, workspaceId); + collector1.start(); + await collector1.waitForSubscription(); + + // Start initial stream (must happen after StreamCollector is subscribed) void sendMessageWithModel( env, workspaceId, @@ -103,11 +109,10 @@ describeIntegration("Queued messages", () => { modelString("anthropic", "claude-sonnet-4-5") ); - const collector1 = createStreamCollector(env.orpc, workspaceId); - collector1.start(); await collector1.waitForEvent("stream-start", 5000); // Queue a message while streaming + const queuedEventPromise = waitForQueuedMessageEvent(collector1); const queueResult = await sendMessageWithModel( env, workspaceId, @@ -117,25 +122,26 @@ describeIntegration("Queued messages", () => { expect(queueResult.success).toBe(true); // Verify message was queued (not sent directly) - const queuedEvent = await waitForQueuedMessageEvent(collector1); + const queuedEvent = await queuedEventPromise; expect(queuedEvent).toBeDefined(); expect(queuedEvent?.queuedMessages).toEqual(["Say 'SECOND' and nothing else"]); expect(queuedEvent?.displayText).toBe("Say 'SECOND' and nothing else"); // Wait for first stream to complete (this triggers auto-send) + const clearEventPromise = waitForQueuedMessageEvent(collector1, 5000); await collector1.waitForEvent("stream-end", 15000); // Wait for queue to be cleared (happens before auto-send starts new stream) // The sendQueuedMessages() clears queue and emits event before sending - const clearEvent = await waitForQueuedMessageEvent(collector1, 5000); + const clearEvent = await clearEventPromise; expect(clearEvent?.queuedMessages).toEqual([]); // Wait for auto-send to emit second user message (happens async after stream-end) // The second stream starts after auto-send - wait for the second stream-start - await collector1.waitForEvent("stream-start", 5000); + await collector1.waitForEventN("stream-start", 2, 10000); // Wait for second stream to complete - await collector1.waitForEvent("stream-end", 15000); + await collector1.waitForEventN("stream-end", 2, 15000); // Verify queue is still empty (check current state) const queuedAfter = await getQueuedMessages(collector1, { wait: false }); @@ -153,7 +159,11 @@ describeIntegration("Queued messages", () => { async () => { const { env, workspaceId, cleanup } = await setupWorkspace("anthropic"); try { - // Start a stream + const collector = createStreamCollector(env.orpc, workspaceId); + collector.start(); + await collector.waitForSubscription(); + + // Start a stream (must happen after StreamCollector is subscribed) void sendMessageWithModel( env, workspaceId, @@ -161,21 +171,22 @@ describeIntegration("Queued messages", () => { modelString("anthropic", "claude-sonnet-4-5") ); - const collector = createStreamCollector(env.orpc, workspaceId); - collector.start(); await collector.waitForEvent("stream-start", 5000); - // Queue a message - await sendMessageWithModel( + // Queue a message (capture the queue-add event deterministically) + const queuedEventPromise = waitForQueuedMessageEvent(collector); + const queueResult = await sendMessageWithModel( env, workspaceId, "This message should be restored", modelString("anthropic", "claude-sonnet-4-5") ); + expect(queueResult.success).toBe(true); - // Verify message was queued - const queued = await getQueuedMessages(collector); - expect(queued).toEqual(["This message should be restored"]); + // Verify message was queued (avoid reading the latest state which may already be cleared) + const queuedEvent = await queuedEventPromise; + expect(queuedEvent).toBeDefined(); + expect(queuedEvent?.queuedMessages).toEqual(["This message should be restored"]); // Capture event count BEFORE interrupt to avoid race condition // (clear event may arrive before or with stream-abort) @@ -225,7 +236,11 @@ describeIntegration("Queued messages", () => { async () => { const { env, workspaceId, cleanup } = await setupWorkspace("anthropic"); try { - // Start a stream + const collector = createStreamCollector(env.orpc, workspaceId); + collector.start(); + await collector.waitForSubscription(); + + // Start a stream (must happen after StreamCollector is subscribed) void sendMessageWithModel( env, workspaceId, @@ -233,21 +248,22 @@ describeIntegration("Queued messages", () => { modelString("anthropic", "claude-sonnet-4-5") ); - const collector = createStreamCollector(env.orpc, workspaceId); - collector.start(); await collector.waitForEvent("stream-start", 5000); - // Queue a message - await sendMessageWithModel( + // Queue a message (capture the queue-add event deterministically) + const queuedEventPromise = waitForQueuedMessageEvent(collector); + const queueResult = await sendMessageWithModel( env, workspaceId, "This message should be sent immediately", modelString("anthropic", "claude-sonnet-4-5") ); + expect(queueResult.success).toBe(true); - // Verify message was queued - const queued = await getQueuedMessages(collector); - expect(queued).toEqual(["This message should be sent immediately"]); + // Verify message was queued (avoid reading the latest state which may already be cleared) + const queuedEvent = await queuedEventPromise; + expect(queuedEvent).toBeDefined(); + expect(queuedEvent?.queuedMessages).toEqual(["This message should be sent immediately"]); // Interrupt the stream with sendQueuedImmediately flag const client = resolveOrpcClient(env); @@ -260,6 +276,10 @@ describeIntegration("Queued messages", () => { // Wait for stream abort await collector.waitForEvent("stream-abort", 5000); + const preAutoSendStreamEndCount = collector + .getEvents() + .filter((e) => "type" in e && e.type === "stream-end").length; + // Should NOT get restore-to-input event (message is sent, not restored) // Instead, we should see the queued message being sent as a new user message const autoSendHappened = await waitFor(() => { @@ -275,8 +295,8 @@ describeIntegration("Queued messages", () => { expect(queuedAfter).toEqual([]); // Wait for the immediately-sent message's stream to start and complete - await collector.waitForEvent("stream-start", 5000); - await collector.waitForEvent("stream-end", 15000); + await collector.waitForEventN("stream-start", 2, 10000); + await collector.waitForEventN("stream-end", preAutoSendStreamEndCount + 1, 15000); collector.stop(); } finally { await cleanup(); @@ -290,7 +310,11 @@ describeIntegration("Queued messages", () => { async () => { const { env, workspaceId, cleanup } = await setupWorkspace("anthropic"); try { - // Start a stream + const collector1 = createStreamCollector(env.orpc, workspaceId); + collector1.start(); + await collector1.waitForSubscription(); + + // Start a stream (must happen after StreamCollector is subscribed) void sendMessageWithModel( env, workspaceId, @@ -298,19 +322,20 @@ describeIntegration("Queued messages", () => { modelString("anthropic", "claude-sonnet-4-5") ); - const collector1 = createStreamCollector(env.orpc, workspaceId); - collector1.start(); await collector1.waitForEvent("stream-start", 5000); // Queue multiple messages, waiting for each queued-message-changed event + const message1Queued = waitForQueuedMessageEvent(collector1); await sendMessage(env, workspaceId, "Message 1"); - await waitForQueuedMessageEvent(collector1); + await message1Queued; + const message2Queued = waitForQueuedMessageEvent(collector1); await sendMessage(env, workspaceId, "Message 2"); - await waitForQueuedMessageEvent(collector1); + await message2Queued; + const message3Queued = waitForQueuedMessageEvent(collector1); await sendMessage(env, workspaceId, "Message 3"); - await waitForQueuedMessageEvent(collector1); + await message3Queued; // Verify all messages queued (check current state, don't wait for new event) const queued = await getQueuedMessages(collector1, { wait: false }); @@ -339,7 +364,11 @@ describeIntegration("Queued messages", () => { async () => { const { env, workspaceId, cleanup } = await setupWorkspace("anthropic"); try { - // Start a stream + const collector1 = createStreamCollector(env.orpc, workspaceId); + collector1.start(); + await collector1.waitForSubscription(); + + // Start a stream (must happen after StreamCollector is subscribed) void sendMessageWithModel( env, workspaceId, @@ -347,32 +376,32 @@ describeIntegration("Queued messages", () => { modelString("anthropic", "claude-sonnet-4-5") ); - const collector1 = createStreamCollector(env.orpc, workspaceId); - collector1.start(); await collector1.waitForEvent("stream-start", 5000); // Queue message with image + const queuedEventPromise = waitForQueuedMessageEvent(collector1); await sendMessage(env, workspaceId, "Describe this image", { model: "anthropic:claude-sonnet-4-5", fileParts: [TEST_IMAGES.RED_PIXEL], }); // Verify queued with image - const queuedEvent = await waitForQueuedMessageEvent(collector1); + const queuedEvent = await queuedEventPromise; expect(queuedEvent?.queuedMessages).toEqual(["Describe this image"]); expect(queuedEvent?.fileParts).toHaveLength(1); expect(queuedEvent?.fileParts?.[0]).toMatchObject(TEST_IMAGES.RED_PIXEL); // Wait for first stream to complete (this triggers auto-send) + const clearEventPromise = waitForQueuedMessageEvent(collector1, 5000); await collector1.waitForEvent("stream-end", 15000); // Wait for queue to be cleared - const clearEvent = await waitForQueuedMessageEvent(collector1, 5000); + const clearEvent = await clearEventPromise; expect(clearEvent?.queuedMessages).toEqual([]); // Wait for auto-send stream to start and complete - await collector1.waitForEvent("stream-start", 5000); - await collector1.waitForEvent("stream-end", 15000); + await collector1.waitForEventN("stream-start", 2, 10000); + await collector1.waitForEventN("stream-end", 2, 15000); // Verify queue is still empty const queuedAfter = await getQueuedMessages(collector1, { wait: false }); @@ -390,7 +419,11 @@ describeIntegration("Queued messages", () => { async () => { const { env, workspaceId, cleanup } = await setupWorkspace("anthropic"); try { - // Start a stream + const collector1 = createStreamCollector(env.orpc, workspaceId); + collector1.start(); + await collector1.waitForSubscription(); + + // Start a stream (must happen after StreamCollector is subscribed) void sendMessageWithModel( env, workspaceId, @@ -398,18 +431,17 @@ describeIntegration("Queued messages", () => { modelString("anthropic", "claude-sonnet-4-5") ); - const collector1 = createStreamCollector(env.orpc, workspaceId); - collector1.start(); await collector1.waitForEvent("stream-start", 5000); // Queue image-only message (empty text) + const queuedEventPromise = waitForQueuedMessageEvent(collector1); await sendMessage(env, workspaceId, "", { model: "anthropic:claude-sonnet-4-5", fileParts: [TEST_IMAGES.RED_PIXEL], }); // Verify queued (no text messages, but has image) - const queuedEvent = await waitForQueuedMessageEvent(collector1); + const queuedEvent = await queuedEventPromise; expect(queuedEvent?.queuedMessages).toEqual([]); expect(queuedEvent?.displayText).toBe(""); expect(queuedEvent?.fileParts).toHaveLength(1); @@ -418,8 +450,8 @@ describeIntegration("Queued messages", () => { await collector1.waitForEvent("stream-end", 15000); // Wait for auto-send stream to start and complete - await collector1.waitForEvent("stream-start", 5000); - await collector1.waitForEvent("stream-end", 15000); + await collector1.waitForEventN("stream-start", 2, 10000); + await collector1.waitForEventN("stream-end", 2, 15000); // Verify queue was cleared after auto-send // Use wait: false since the queue-clearing event already happened @@ -438,7 +470,11 @@ describeIntegration("Queued messages", () => { async () => { const { env, workspaceId, cleanup } = await setupWorkspace("anthropic"); try { - // Start a stream + const collector1 = createStreamCollector(env.orpc, workspaceId); + collector1.start(); + await collector1.waitForSubscription(); + + // Start a stream (must happen after StreamCollector is subscribed) void sendMessageWithModel( env, workspaceId, @@ -446,8 +482,6 @@ describeIntegration("Queued messages", () => { modelString("anthropic", "claude-sonnet-4-5") ); - const collector1 = createStreamCollector(env.orpc, workspaceId); - collector1.start(); await collector1.waitForEvent("stream-start", 5000); // Queue messages with different options @@ -464,12 +498,12 @@ describeIntegration("Queued messages", () => { await collector1.waitForEvent("stream-end", 15000); // Wait for auto-send stream to start (verifies the second stream began) - const streamStart = await collector1.waitForEvent("stream-start", 5000); + const streamStart = await collector1.waitForEventN("stream-start", 2, 10000); if (streamStart && "model" in streamStart) { expect(streamStart.model).toContain("claude-sonnet-4-5"); } - await collector1.waitForEvent("stream-end", 15000); + await collector1.waitForEventN("stream-end", 2, 15000); collector1.stop(); } finally { await cleanup(); @@ -483,7 +517,11 @@ describeIntegration("Queued messages", () => { async () => { const { env, workspaceId, cleanup } = await setupWorkspace("anthropic"); try { - // Start a stream + const collector1 = createStreamCollector(env.orpc, workspaceId); + collector1.start(); + await collector1.waitForSubscription(); + + // Start a stream (must happen after StreamCollector is subscribed) void sendMessageWithModel( env, workspaceId, @@ -491,8 +529,6 @@ describeIntegration("Queued messages", () => { modelString("anthropic", "claude-sonnet-4-5") ); - const collector1 = createStreamCollector(env.orpc, workspaceId); - collector1.start(); await collector1.waitForEvent("stream-start", 5000); // Queue a compaction request @@ -502,25 +538,27 @@ describeIntegration("Queued messages", () => { parsed: { maxOutputTokens: 3000 }, }; + const queuedEventPromise = waitForQueuedMessageEvent(collector1); await sendMessage(env, workspaceId, "Summarize this conversation into a compact form...", { model: "anthropic:claude-sonnet-4-5", muxMetadata: compactionMetadata, }); // Wait for queued-message-changed event - const queuedEvent = await waitForQueuedMessageEvent(collector1); + const queuedEvent = await queuedEventPromise; expect(queuedEvent?.displayText).toBe("/compact -t 3000"); // Wait for first stream to complete (this triggers auto-send) + const clearEventPromise = waitForQueuedMessageEvent(collector1, 5000); await collector1.waitForEvent("stream-end", 15000); // Wait for queue to be cleared - const clearEvent = await waitForQueuedMessageEvent(collector1, 5000); + const clearEvent = await clearEventPromise; expect(clearEvent?.queuedMessages).toEqual([]); // Wait for auto-send stream to start and complete - await collector1.waitForEvent("stream-start", 5000); - await collector1.waitForEvent("stream-end", 15000); + await collector1.waitForEventN("stream-start", 2, 10000); + await collector1.waitForEventN("stream-end", 2, 15000); // Verify queue is still empty const queuedAfter = await getQueuedMessages(collector1, { wait: false }); diff --git a/tests/ipc/sendMessageRemoteWorkspace.test.ts b/tests/ipc/sendMessageRemoteWorkspace.test.ts new file mode 100644 index 0000000000..764226ecc2 --- /dev/null +++ b/tests/ipc/sendMessageRemoteWorkspace.test.ts @@ -0,0 +1,184 @@ +/** + * Regression test: workspace.sendMessage and workspace.interruptStream should proxy + * requests when the workspaceId is a remote-encoded ID, routing them through federation + * to the remote mux server instead of failing with "Workspace not found". + */ + +import { encodeRemoteWorkspaceId } from "@/common/utils/remoteMuxIds"; +import { EXPERIMENT_IDS } from "@/common/constants/experiments"; +import { createOrpcServer, type OrpcServer } from "@/node/orpc/server"; +import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; +import { + buildOrpcContext, + cleanupTestEnvironment, + createTestEnvironment, + enableExperimentForTesting, +} from "./setup"; +import { + cleanupTempGitRepo, + createTempGitRepo, + createWorkspace, + generateBranchName, + HAIKU_MODEL, + readChatHistory, +} from "./helpers"; + +const TEST_TIMEOUT_MS = 40_000; + +test( + "workspace.sendMessage proxies remote workspaceIds", + async () => { + const localEnv = await createTestEnvironment(); + enableExperimentForTesting(localEnv, EXPERIMENT_IDS.REMOTE_MUX_SERVERS); + const remoteEnv = await createTestEnvironment(); + const repoPath = await createTempGitRepo(); + + let remoteServer: OrpcServer | null = null; + + try { + remoteServer = await createOrpcServer({ + context: buildOrpcContext(remoteEnv), + host: "127.0.0.1", + port: 0, + }); + + const serverId = "remote-test"; + + const upsertResult = await localEnv.orpc.remoteServers.upsert({ + config: { + id: serverId, + label: "Remote test", + baseUrl: remoteServer.baseUrl, + projectMappings: [{ localProjectPath: repoPath, remoteProjectPath: repoPath }], + }, + }); + + if (!upsertResult.success) { + throw new Error(upsertResult.error); + } + + const branchName = generateBranchName("remote-send-msg"); + const remoteCreate = await createWorkspace(remoteEnv, repoPath, branchName); + if (!remoteCreate.success) { + throw new Error(remoteCreate.error); + } + + const remoteWorkspaceId = remoteCreate.metadata.id; + const encodedWorkspaceId = encodeRemoteWorkspaceId(serverId, remoteWorkspaceId); + + // Verify the workspace is visible through the local env + const listBefore = await localEnv.orpc.workspace.list(); + expect(listBefore.some((w) => w.id === encodedWorkspaceId)).toBe(true); + + // Send message through the local env — it should be proxied to the remote server. + // With a test-only API key the AI stream will ultimately fail, but the important + // thing is that sendMessage doesn't throw a "Workspace not found" error (which + // would mean federation failed). + const sendResult = await localEnv.orpc.workspace.sendMessage({ + workspaceId: encodedWorkspaceId, + message: "test federation message", + options: { + model: HAIKU_MODEL, + agentId: WORKSPACE_DEFAULTS.agentId, + }, + }); + + // The call should have returned a valid Result, not thrown an exception. + expect(sendResult).toBeDefined(); + expect(typeof sendResult.success).toBe("boolean"); + + // Verify the user message was persisted on the remote workspace. + // The workspace service writes the user message to history before starting the + // AI stream, so it should be readable immediately after sendMessage returns. + const history = await readChatHistory(remoteEnv.tempDir, remoteWorkspaceId); + const userMessages = history.filter((msg) => msg.role === "user"); + expect(userMessages.length).toBeGreaterThanOrEqual(1); + + const lastUserMsg = userMessages[userMessages.length - 1]; + expect(lastUserMsg).toBeDefined(); + const hasTestMessage = lastUserMsg.parts.some( + (part: { type: string; text?: string }) => + part.type === "text" && part.text === "test federation message" + ); + expect(hasTestMessage).toBe(true); + + // Interrupt any in-progress stream to clean up gracefully before teardown. + const interruptResult = await localEnv.orpc.workspace.interruptStream({ + workspaceId: encodedWorkspaceId, + }); + expect(interruptResult).toBeDefined(); + expect(typeof interruptResult.success).toBe("boolean"); + } finally { + if (remoteServer) { + await remoteServer.close(); + } + + await cleanupTestEnvironment(remoteEnv); + await cleanupTestEnvironment(localEnv); + await cleanupTempGitRepo(repoPath); + } + }, + TEST_TIMEOUT_MS +); + +test( + "workspace.interruptStream proxies remote workspaceIds", + async () => { + const localEnv = await createTestEnvironment(); + enableExperimentForTesting(localEnv, EXPERIMENT_IDS.REMOTE_MUX_SERVERS); + const remoteEnv = await createTestEnvironment(); + const repoPath = await createTempGitRepo(); + + let remoteServer: OrpcServer | null = null; + + try { + remoteServer = await createOrpcServer({ + context: buildOrpcContext(remoteEnv), + host: "127.0.0.1", + port: 0, + }); + + const serverId = "remote-test"; + + const upsertResult = await localEnv.orpc.remoteServers.upsert({ + config: { + id: serverId, + label: "Remote test", + baseUrl: remoteServer.baseUrl, + projectMappings: [{ localProjectPath: repoPath, remoteProjectPath: repoPath }], + }, + }); + + if (!upsertResult.success) { + throw new Error(upsertResult.error); + } + + const branchName = generateBranchName("remote-interrupt"); + const remoteCreate = await createWorkspace(remoteEnv, repoPath, branchName); + if (!remoteCreate.success) { + throw new Error(remoteCreate.error); + } + + const encodedWorkspaceId = encodeRemoteWorkspaceId(serverId, remoteCreate.metadata.id); + + // interruptStream on a workspace with no active stream should still succeed + // (no-op interrupt). The key assertion is that it doesn't throw a federation + // or "workspace not found" error. + const interruptResult = await localEnv.orpc.workspace.interruptStream({ + workspaceId: encodedWorkspaceId, + }); + + expect(interruptResult).toBeDefined(); + expect(typeof interruptResult.success).toBe("boolean"); + } finally { + if (remoteServer) { + await remoteServer.close(); + } + + await cleanupTestEnvironment(remoteEnv); + await cleanupTestEnvironment(localEnv); + await cleanupTempGitRepo(repoPath); + } + }, + TEST_TIMEOUT_MS +); diff --git a/tests/ipc/setup.ts b/tests/ipc/setup.ts index 31c5ae7dd7..11bfa5923a 100644 --- a/tests/ipc/setup.ts +++ b/tests/ipc/setup.ts @@ -17,6 +17,7 @@ import type { ORPCContext } from "../../src/node/orpc/context"; import type { RuntimeConfig } from "../../src/common/types/runtime"; import { createOrpcTestClient, type OrpcTestClient } from "./orpcTestClient"; import { shouldRunIntegrationTests, validateApiKeys, getApiKey } from "../testUtils"; +import type { ExperimentId } from "../../src/common/constants/experiments"; export interface TestEnvironment { config: Config; @@ -49,6 +50,46 @@ function createMockBrowserWindow(): BrowserWindow { return mockWindow; } +/** + * Build an ORPCContext from a TestEnvironment. Useful for creating standalone + * oRPC servers (e.g., to simulate a remote mux instance in federation tests). + */ +export function buildOrpcContext(env: TestEnvironment): ORPCContext { + return { + config: env.services.config, + aiService: env.services.aiService, + projectService: env.services.projectService, + workspaceService: env.services.workspaceService, + muxGatewayOauthService: env.services.muxGatewayOauthService, + muxGovernorOauthService: env.services.muxGovernorOauthService, + codexOauthService: env.services.codexOauthService, + copilotOauthService: env.services.copilotOauthService, + taskService: env.services.taskService, + providerService: env.services.providerService, + terminalService: env.services.terminalService, + editorService: env.services.editorService, + windowService: env.services.windowService, + updateService: env.services.updateService, + tokenizerService: env.services.tokenizerService, + serverService: env.services.serverService, + remoteServersService: env.services.remoteServersService, + featureFlagService: env.services.featureFlagService, + workspaceMcpOverridesService: env.services.workspaceMcpOverridesService, + sessionTimingService: env.services.sessionTimingService, + mcpConfigService: env.services.mcpConfigService, + mcpOauthService: env.services.mcpOauthService, + mcpServerManager: env.services.mcpServerManager, + menuEventService: env.services.menuEventService, + voiceService: env.services.voiceService, + experimentsService: env.services.experimentsService, + telemetryService: env.services.telemetryService, + sessionUsageService: env.services.sessionUsageService, + signingService: env.services.signingService, + coderService: env.services.coderService, + policyService: env.services.policyService, + }; +} + /** * Create a test environment with temporary config and service container */ @@ -98,6 +139,7 @@ export async function createTestEnvironment(): Promise { updateService: services.updateService, tokenizerService: services.tokenizerService, serverService: services.serverService, + remoteServersService: services.remoteServersService, featureFlagService: services.featureFlagService, workspaceMcpOverridesService: services.workspaceMcpOverridesService, sessionTimingService: services.sessionTimingService, @@ -154,6 +196,32 @@ export async function cleanupTestEnvironment(env: TestEnvironment): Promise(experimentIds); + + Object.defineProperty(service, "isExperimentEnabled", { + value: (id: ExperimentId): boolean => { + if (enabledSet.has(id)) return true; + return original(id); + }, + writable: true, + configurable: true, + }); +} + /** * Setup provider configuration via IPC */ diff --git a/tests/ipc/terminalRemoteWorkspace.test.ts b/tests/ipc/terminalRemoteWorkspace.test.ts new file mode 100644 index 0000000000..eb993d6a9a --- /dev/null +++ b/tests/ipc/terminalRemoteWorkspace.test.ts @@ -0,0 +1,264 @@ +/** + * Regression test: terminal.listSessions, terminal.create, terminal.sendInput, and + * terminal.close should proxy requests through federation when the workspaceId or + * sessionId is a remote-encoded ID. + * + * The federation middleware rewrites both workspaceId and sessionId (both are in + * FEDERATION_ID_KEYS) so that the remote server receives bare IDs and the local + * caller receives re-encoded IDs. + * + * Tests that spawn real PTY processes (create/sendInput/close) are gated behind + * TEST_INTEGRATION=1 because node-pty throws async ESPIPE errors in bun's + * non-integration test runner environment. + */ + +import { encodeRemoteWorkspaceId } from "@/common/utils/remoteMuxIds"; +import { EXPERIMENT_IDS } from "@/common/constants/experiments"; +import { createOrpcServer, type OrpcServer } from "@/node/orpc/server"; +import { + buildOrpcContext, + cleanupTestEnvironment, + createTestEnvironment, + enableExperimentForTesting, + shouldRunIntegrationTests, +} from "./setup"; +import { + cleanupTempGitRepo, + createTempGitRepo, + createWorkspace, + generateBranchName, +} from "./helpers"; + +const TEST_TIMEOUT_MS = 40_000; + +// terminal.listSessions does NOT spawn PTY processes, so it works in all environments. +test( + "terminal.listSessions proxies remote workspaceIds", + async () => { + const localEnv = await createTestEnvironment(); + enableExperimentForTesting(localEnv, EXPERIMENT_IDS.REMOTE_MUX_SERVERS); + const remoteEnv = await createTestEnvironment(); + const repoPath = await createTempGitRepo(); + + let remoteServer: OrpcServer | null = null; + + try { + remoteServer = await createOrpcServer({ + context: buildOrpcContext(remoteEnv), + host: "127.0.0.1", + port: 0, + }); + + const serverId = "remote-test"; + + const upsertResult = await localEnv.orpc.remoteServers.upsert({ + config: { + id: serverId, + label: "Remote test", + baseUrl: remoteServer.baseUrl, + projectMappings: [{ localProjectPath: repoPath, remoteProjectPath: repoPath }], + }, + }); + + if (!upsertResult.success) { + throw new Error(upsertResult.error); + } + + const branchName = generateBranchName("remote-terminal-list"); + const remoteCreate = await createWorkspace(remoteEnv, repoPath, branchName); + if (!remoteCreate.success) { + throw new Error(remoteCreate.error); + } + + const encodedWorkspaceId = encodeRemoteWorkspaceId(serverId, remoteCreate.metadata.id); + + // Verify workspace is visible through the local env + const listBefore = await localEnv.orpc.workspace.list(); + expect(listBefore.some((w) => w.id === encodedWorkspaceId)).toBe(true); + + // listSessions on a workspace with no active terminals should return an empty + // array. The key assertion is that the call is proxied through federation to + // the remote (not failing with "workspace not found" locally). + const sessions = await localEnv.orpc.terminal.listSessions({ + workspaceId: encodedWorkspaceId, + }); + expect(Array.isArray(sessions)).toBe(true); + expect(sessions.length).toBe(0); + } finally { + if (remoteServer) { + await remoteServer.close(); + } + + await cleanupTestEnvironment(remoteEnv); + await cleanupTestEnvironment(localEnv); + await cleanupTempGitRepo(repoPath); + } + }, + TEST_TIMEOUT_MS +); + +// Tests below require real PTY processes (node-pty), which throw async ESPIPE errors +// in bun's non-integration test runner. Gate on TEST_INTEGRATION=1. +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +describeIntegration("terminal federation (PTY)", () => { + test( + "terminal.create returns remote-encoded sessionId for remote workspaceIds", + async () => { + const localEnv = await createTestEnvironment(); + enableExperimentForTesting(localEnv, EXPERIMENT_IDS.REMOTE_MUX_SERVERS); + const remoteEnv = await createTestEnvironment(); + const repoPath = await createTempGitRepo(); + + let remoteServer: OrpcServer | null = null; + + try { + remoteServer = await createOrpcServer({ + context: buildOrpcContext(remoteEnv), + host: "127.0.0.1", + port: 0, + }); + + const serverId = "remote-test"; + + const upsertResult = await localEnv.orpc.remoteServers.upsert({ + config: { + id: serverId, + label: "Remote test", + baseUrl: remoteServer.baseUrl, + projectMappings: [{ localProjectPath: repoPath, remoteProjectPath: repoPath }], + }, + }); + + if (!upsertResult.success) { + throw new Error(upsertResult.error); + } + + const branchName = generateBranchName("remote-terminal-create"); + const remoteCreate = await createWorkspace(remoteEnv, repoPath, branchName); + if (!remoteCreate.success) { + throw new Error(remoteCreate.error); + } + + const encodedWorkspaceId = encodeRemoteWorkspaceId(serverId, remoteCreate.metadata.id); + + // Create terminal session through local env — should be proxied to remote + const session = await localEnv.orpc.terminal.create({ + workspaceId: encodedWorkspaceId, + cols: 80, + rows: 24, + }); + + // The returned sessionId should be remote-encoded (starts with "remote.") + // because the federation middleware re-encodes all FEDERATION_ID_KEYS in the output. + expect(session.sessionId).toBeDefined(); + expect(session.sessionId.startsWith("remote.")).toBe(true); + + // The returned workspaceId should also be remote-encoded + expect(session.workspaceId).toBeDefined(); + expect(session.workspaceId.startsWith("remote.")).toBe(true); + + // listSessions should include the newly created session (remote-encoded) + const sessions = await localEnv.orpc.terminal.listSessions({ + workspaceId: encodedWorkspaceId, + }); + expect(sessions.length).toBeGreaterThan(0); + expect(sessions.every((id: string) => id.startsWith("remote."))).toBe(true); + expect(sessions).toContain(session.sessionId); + + // Clean up the terminal session + await localEnv.orpc.terminal.close({ sessionId: session.sessionId }); + } finally { + if (remoteServer) { + await remoteServer.close(); + } + + await cleanupTestEnvironment(remoteEnv); + await cleanupTestEnvironment(localEnv); + await cleanupTempGitRepo(repoPath); + } + }, + TEST_TIMEOUT_MS + ); + + test( + "terminal.sendInput and terminal.close proxy remote-encoded sessionIds", + async () => { + const localEnv = await createTestEnvironment(); + enableExperimentForTesting(localEnv, EXPERIMENT_IDS.REMOTE_MUX_SERVERS); + const remoteEnv = await createTestEnvironment(); + const repoPath = await createTempGitRepo(); + + let remoteServer: OrpcServer | null = null; + + try { + remoteServer = await createOrpcServer({ + context: buildOrpcContext(remoteEnv), + host: "127.0.0.1", + port: 0, + }); + + const serverId = "remote-test"; + + const upsertResult = await localEnv.orpc.remoteServers.upsert({ + config: { + id: serverId, + label: "Remote test", + baseUrl: remoteServer.baseUrl, + projectMappings: [{ localProjectPath: repoPath, remoteProjectPath: repoPath }], + }, + }); + + if (!upsertResult.success) { + throw new Error(upsertResult.error); + } + + const branchName = generateBranchName("remote-terminal-io"); + const remoteCreate = await createWorkspace(remoteEnv, repoPath, branchName); + if (!remoteCreate.success) { + throw new Error(remoteCreate.error); + } + + const encodedWorkspaceId = encodeRemoteWorkspaceId(serverId, remoteCreate.metadata.id); + + // Create terminal session + const session = await localEnv.orpc.terminal.create({ + workspaceId: encodedWorkspaceId, + cols: 80, + rows: 24, + }); + + expect(session.sessionId.startsWith("remote.")).toBe(true); + + // sendInput with the remote-encoded sessionId should not throw. + // The federation middleware decodes the sessionId before proxying. + await expect( + localEnv.orpc.terminal.sendInput({ + sessionId: session.sessionId, + data: "echo hello\n", + }) + ).resolves.toBeUndefined(); + + // close with the remote-encoded sessionId should not throw + await expect( + localEnv.orpc.terminal.close({ sessionId: session.sessionId }) + ).resolves.toBeUndefined(); + + // After close, listSessions should not contain the closed session + const sessionsAfterClose = await localEnv.orpc.terminal.listSessions({ + workspaceId: encodedWorkspaceId, + }); + expect(sessionsAfterClose).not.toContain(session.sessionId); + } finally { + if (remoteServer) { + await remoteServer.close(); + } + + await cleanupTestEnvironment(remoteEnv); + await cleanupTestEnvironment(localEnv); + await cleanupTempGitRepo(repoPath); + } + }, + TEST_TIMEOUT_MS + ); +});