From 2cbd381fdc05770e630ed6fbb50d237f0f732f66 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 6 May 2026 12:55:49 +0000 Subject: [PATCH 1/7] Show other agent-browser sessions --- .../BrowserTab/BrowserTab.test.tsx | 71 +++++++- .../RightSidebar/BrowserTab/BrowserTab.tsx | 165 +++++++++++++----- .../BrowserTab/BrowserToolbar.test.tsx | 15 ++ .../BrowserTab/BrowserToolbar.tsx | 2 + .../BrowserTab/browserBridgeTypes.ts | 8 + .../useBrowserBridgeConnection.test.tsx | 15 ++ .../BrowserTab/useBrowserBridgeConnection.ts | 9 +- src/common/orpc/schemas/api.ts | 8 + src/node/orpc/router.ts | 20 ++- ...gentBrowserSessionDiscoveryService.test.ts | 104 +++++++++++ .../AgentBrowserSessionDiscoveryService.ts | 85 +++++---- .../browser/BrowserBridgeServer.test.ts | 47 ++++- .../services/browser/BrowserBridgeServer.ts | 3 +- .../browser/BrowserBridgeTokenManager.test.ts | 33 +++- .../browser/BrowserBridgeTokenManager.ts | 24 ++- .../browser/BrowserControlService.test.ts | 49 +++++- .../services/browser/BrowserControlService.ts | 19 +- 17 files changed, 576 insertions(+), 101 deletions(-) diff --git a/src/browser/features/RightSidebar/BrowserTab/BrowserTab.test.tsx b/src/browser/features/RightSidebar/BrowserTab/BrowserTab.test.tsx index 412c2a06e4..09602eef73 100644 --- a/src/browser/features/RightSidebar/BrowserTab/BrowserTab.test.tsx +++ b/src/browser/features/RightSidebar/BrowserTab/BrowserTab.test.tsx @@ -1,12 +1,19 @@ -import { cleanup, render, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { GlobalWindow } from "happy-dom"; import { useState } from "react"; -import type { BrowserDiscoveredSession, BrowserSession } from "./browserBridgeTypes"; +import type { + BrowserDiscoveredOtherSession, + BrowserDiscoveredSession, + BrowserSession, +} from "./browserBridgeTypes"; const listSessionsMock = mock(() => - Promise.resolve({ sessions: [] as BrowserDiscoveredSession[] }) + Promise.resolve({ + sessions: [] as BrowserDiscoveredSession[], + otherSessions: [] as BrowserDiscoveredOtherSession[], + }) ); const connectMock = mock(() => undefined); const disconnectMock = mock(() => undefined); @@ -88,7 +95,7 @@ describe("BrowserTab", () => { globalThis.document = globalThis.window.document; listSessionsMock.mockReset(); - listSessionsMock.mockResolvedValue({ sessions: [] }); + listSessionsMock.mockResolvedValue({ sessions: [], otherSessions: [] }); connectMock.mockReset(); disconnectMock.mockReset(); setPendingUrlMock.mockReset(); @@ -106,6 +113,7 @@ describe("BrowserTab", () => { test("connects to missing_stream sessions while showing the activating state", async () => { listSessionsMock.mockResolvedValue({ sessions: [createDiscoveredSession({ status: "missing_stream" })], + otherSessions: [], }); const view = render(); @@ -120,9 +128,64 @@ describe("BrowserTab", () => { expect(view.queryByText(/AGENT_BROWSER_STREAM_PORT/)).toBeNull(); }); + test("shows other running sessions in the session picker without auto-attaching", async () => { + listSessionsMock.mockResolvedValue({ + sessions: [], + otherSessions: [ + { + sessionName: "other-alpha", + status: "attachable", + cwd: "/tmp/other-project", + }, + ], + }); + + const view = render(); + + await waitFor(() => { + expect(view.getByText("Select session")).toBeTruthy(); + }); + expect(view.queryByText("Other running sessions")).toBeNull(); + + fireEvent.click(view.getByText("Select session")); + + expect(view.getByText("other-alpha")).toBeTruthy(); + expect(view.getByText("/tmp/other-project")).toBeTruthy(); + expect(connectMock).not.toHaveBeenCalled(); + }); + + test("attaches to an other running session only after selecting it from the picker", async () => { + listSessionsMock.mockResolvedValue({ + sessions: [], + otherSessions: [ + { + sessionName: "other-alpha", + status: "attachable", + cwd: "/tmp/other-project", + }, + ], + }); + + const view = render(); + + await waitFor(() => { + expect(view.getByText("Select session")).toBeTruthy(); + }); + + fireEvent.click(view.getByText("Select session")); + fireEvent.click(view.getByTestId("browser-other-session-other-alpha")); + + await waitFor(() => { + expect(connectMock).toHaveBeenCalledWith("other-alpha", { + allowOtherWorkspaceSession: true, + }); + }); + }); + test("renders the navigation toolbar with the active session URL", async () => { listSessionsMock.mockResolvedValue({ sessions: [createDiscoveredSession()], + otherSessions: [], }); mockSession = createSession({ currentUrl: "https://current.example.com", diff --git a/src/browser/features/RightSidebar/BrowserTab/BrowserTab.tsx b/src/browser/features/RightSidebar/BrowserTab/BrowserTab.tsx index fc51b679b5..260c4e2700 100644 --- a/src/browser/features/RightSidebar/BrowserTab/BrowserTab.tsx +++ b/src/browser/features/RightSidebar/BrowserTab/BrowserTab.tsx @@ -5,6 +5,7 @@ import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { getBrowserSelectedSessionKey } from "@/common/constants/storage"; import { cn } from "@/common/lib/utils"; import type { + BrowserDiscoveredOtherSession, BrowserDiscoveredSession, BrowserDiscoveredSessionStatus, BrowserSession, @@ -112,18 +113,25 @@ export function BrowserTab(props: BrowserTabProps) { const discoveryRefreshInFlightRef = useRef(false); const { api } = useAPI(); const [discoveredSessions, setDiscoveredSessions] = useState([]); - const [selectedSessionName, setSelectedSessionName] = usePersistedState( - getBrowserSelectedSessionKey(props.projectPath), - null, - { listener: true } - ); + const [otherDiscoveredSessions, setOtherDiscoveredSessions] = useState< + BrowserDiscoveredOtherSession[] + >([]); + const [selectedCurrentSessionName, setSelectedCurrentSessionName] = usePersistedState< + string | null + >(getBrowserSelectedSessionKey(props.projectPath), null, { listener: true }); + const [explicitOtherSessionName, setExplicitOtherSessionName] = useState(null); const [discoveryError, setDiscoveryError] = useState(null); const { session, connect, disconnect, sendInput, setPendingUrl } = useBrowserBridgeConnection( props.workspaceId ); - const selectedDiscoveredSession = - discoveredSessions.find((candidate) => candidate.sessionName === selectedSessionName) ?? null; + const selectedSessionName = explicitOtherSessionName ?? selectedCurrentSessionName; + const selectedSessionAllowsOther = explicitOtherSessionName != null; + const selectedDiscoveredSession = selectedSessionAllowsOther + ? (otherDiscoveredSessions.find((candidate) => candidate.sessionName === selectedSessionName) ?? + null) + : (discoveredSessions.find((candidate) => candidate.sessionName === selectedSessionName) ?? + null); const isStarting = session?.status === "starting"; const screenshotSrc = @@ -141,6 +149,7 @@ export function BrowserTab(props: BrowserTabProps) { if (api == null) { setDiscoveryError("Browser API client is unavailable."); setDiscoveredSessions([]); + setOtherDiscoveredSessions([]); return; } @@ -160,7 +169,14 @@ export function BrowserTab(props: BrowserTabProps) { setDiscoveryError(null); setDiscoveredSessions(result.sessions); - setSelectedSessionName((currentSessionName) => + setOtherDiscoveredSessions(result.otherSessions); + setExplicitOtherSessionName((currentSessionName) => + currentSessionName != null && + result.otherSessions.some((session) => session.sessionName === currentSessionName) + ? currentSessionName + : null + ); + setSelectedCurrentSessionName((currentSessionName) => chooseSelectedSession(currentSessionName, result.sessions) ); } catch (error) { @@ -188,7 +204,7 @@ export function BrowserTab(props: BrowserTabProps) { cancelled = true; clearInterval(refreshTimer); }; - }, [api, props.workspaceId, setSelectedSessionName]); + }, [api, props.workspaceId, setSelectedCurrentSessionName]); useEffect(() => { if (api == null || selectedSessionName == null || selectedDiscoveredSession == null) { @@ -233,13 +249,18 @@ export function BrowserTab(props: BrowserTabProps) { sessionName: selectedSessionName, attemptedAtMs: now, }; - connect(selectedSessionName); + if (selectedSessionAllowsOther) { + connect(selectedSessionName, { allowOtherWorkspaceSession: true }); + } else { + connect(selectedSessionName); + } }, [ api, connect, disconnect, selectedDiscoveredSession, selectedSessionName, + selectedSessionAllowsOther, session, visibleError, ]); @@ -255,11 +276,17 @@ export function BrowserTab(props: BrowserTabProps) { {headerBadge && } - {discoveredSessions.length > 0 && selectedSessionName != null && ( + {discoveredSessions.length + otherDiscoveredSessions.length > 0 && ( { + setExplicitOtherSessionName(null); + setSelectedCurrentSessionName(sessionName); + }} + onSelectOther={setExplicitOtherSessionName} /> )} @@ -267,6 +294,7 @@ export function BrowserTab(props: BrowserTabProps) { 0} hasDiscoveredSessions={discoveredSessions.length > 0} /> } @@ -321,9 +350,12 @@ function BrowserHeaderBadge(props: { badge: { label: string; className: string } } function BrowserSessionPicker(props: { - sessions: BrowserDiscoveredSession[]; - selectedSessionName: string; - onChange: (sessionName: string) => void; + currentSessions: BrowserDiscoveredSession[]; + otherSessions: BrowserDiscoveredOtherSession[]; + selectedSessionName: string | null; + selectedSessionAllowsOther: boolean; + onSelectCurrent: (sessionName: string) => void; + onSelectOther: (sessionName: string) => void; }) { const [isOpen, setIsOpen] = useState(false); const containerRef = useRef(null); @@ -354,7 +386,7 @@ function BrowserSessionPicker(props: { aria-haspopup="listbox" onClick={() => setIsOpen((current) => !current)} > - {props.selectedSessionName} + {props.selectedSessionName ?? "Select session"} @@ -363,33 +395,43 @@ function BrowserSessionPicker(props: {
- {props.sessions.map((session) => ( - + /> + ))} + {props.otherSessions.length > 0 && ( +
+ Other sessions +
+ )} + {props.otherSessions.map((session) => ( + { + props.onSelectOther(session.sessionName); + setIsOpen(false); + }} + /> ))}
@@ -398,10 +440,46 @@ function BrowserSessionPicker(props: { ); } +function BrowserSessionPickerOption(props: { + session: BrowserDiscoveredSession; + isSelected: boolean; + testId: string; + cwd?: string; + onSelect: () => void; +}) { + return ( + + ); +} + function BrowserViewerState(props: { sessionStatus: BrowserSessionStatus | null; isStarting: boolean; selectedSession: BrowserDiscoveredSession | null; + hasOtherSessions: boolean; hasDiscoveredSessions: boolean; }) { const content = (() => { @@ -434,6 +512,13 @@ function BrowserViewerState(props: { }; } + if (props.hasOtherSessions) { + return { + title: "Choose a browser session", + description: "Other running sessions require explicit attachment before Mux connects.", + }; + } + return { title: "Waiting for browser preview", description: diff --git a/src/browser/features/RightSidebar/BrowserTab/BrowserToolbar.test.tsx b/src/browser/features/RightSidebar/BrowserTab/BrowserToolbar.test.tsx index ce4109347a..853e61b83a 100644 --- a/src/browser/features/RightSidebar/BrowserTab/BrowserToolbar.test.tsx +++ b/src/browser/features/RightSidebar/BrowserTab/BrowserToolbar.test.tsx @@ -191,6 +191,21 @@ describe("BrowserToolbar", () => { }); }); + test("sends explicit other-workspace scope with browser control commands", async () => { + const view = renderToolbar({ allowOtherWorkspaceSession: true }); + + fireEvent.click(view.getByLabelText("Reload")); + + await waitFor(() => { + expect(controlMock).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + sessionName: "session-a", + action: "reload", + allowOtherWorkspaceSession: true, + }); + }); + }); + test("runs browser navigation shortcuts when the URL input is not focused", async () => { let keyDownHandler: ((event: KeyboardEvent) => void) | null = null; const originalAddEventListener = window.addEventListener.bind(window); diff --git a/src/browser/features/RightSidebar/BrowserTab/BrowserToolbar.tsx b/src/browser/features/RightSidebar/BrowserTab/BrowserToolbar.tsx index e4c627c8e9..4bcad13098 100644 --- a/src/browser/features/RightSidebar/BrowserTab/BrowserToolbar.tsx +++ b/src/browser/features/RightSidebar/BrowserTab/BrowserToolbar.tsx @@ -16,6 +16,7 @@ import assert from "@/common/utils/assert"; interface BrowserToolbarProps { workspaceId: string; sessionName: string | null; + allowOtherWorkspaceSession?: boolean; currentUrl: string | null; pendingUrl: string | null; isPageLoading: boolean; @@ -141,6 +142,7 @@ export function BrowserToolbar(props: BrowserToolbarProps) { sessionName: targetSession, action, ...(url != null ? { url } : {}), + ...(props.allowOtherWorkspaceSession === true ? { allowOtherWorkspaceSession: true } : {}), }); if (currentSessionNameRef.current !== targetSession) { return; diff --git a/src/browser/features/RightSidebar/BrowserTab/browserBridgeTypes.ts b/src/browser/features/RightSidebar/BrowserTab/browserBridgeTypes.ts index 51b1162a75..0346fc46be 100644 --- a/src/browser/features/RightSidebar/BrowserTab/browserBridgeTypes.ts +++ b/src/browser/features/RightSidebar/BrowserTab/browserBridgeTypes.ts @@ -17,6 +17,14 @@ export interface BrowserDiscoveredSession { status: BrowserDiscoveredSessionStatus; } +export interface BrowserDiscoveredOtherSession extends BrowserDiscoveredSession { + cwd: string; +} + +export interface BrowserSessionAttachOptions { + allowOtherWorkspaceSession?: boolean; +} + export type PageStateSource = "bootstrap" | "command" | "poll"; export interface BrowserSession { diff --git a/src/browser/features/RightSidebar/BrowserTab/useBrowserBridgeConnection.test.tsx b/src/browser/features/RightSidebar/BrowserTab/useBrowserBridgeConnection.test.tsx index 31c76594d4..ba0b922d1f 100644 --- a/src/browser/features/RightSidebar/BrowserTab/useBrowserBridgeConnection.test.tsx +++ b/src/browser/features/RightSidebar/BrowserTab/useBrowserBridgeConnection.test.tsx @@ -173,6 +173,21 @@ describe("useBrowserBridgeConnection", () => { expect(result.current.session?.frameMetadata?.deviceWidth).toBe(1280); }); + test("passes explicit other-workspace scope to browser bootstrap", async () => { + const result = renderHook(() => useBrowserBridgeConnection("workspace-1")); + + act(() => { + result.result.current.connect("session-a", { allowOtherWorkspaceSession: true }); + }); + await flushAsyncWork(); + + expect(getBootstrapMock).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + sessionName: "session-a", + allowOtherWorkspaceSession: true, + }); + }); + test("initializes new sessions with page state defaults", async () => { const { result } = await connectHook(); diff --git a/src/browser/features/RightSidebar/BrowserTab/useBrowserBridgeConnection.ts b/src/browser/features/RightSidebar/BrowserTab/useBrowserBridgeConnection.ts index 049d158263..f0ead80454 100644 --- a/src/browser/features/RightSidebar/BrowserTab/useBrowserBridgeConnection.ts +++ b/src/browser/features/RightSidebar/BrowserTab/useBrowserBridgeConnection.ts @@ -5,6 +5,7 @@ import type { BrowserViewportMetadata, BrowserInputEvent, BrowserSession, + BrowserSessionAttachOptions, BrowserStreamState, } from "./browserBridgeTypes"; @@ -174,7 +175,7 @@ function createSession( export function useBrowserBridgeConnection(workspaceId: string): { session: BrowserSession | null; - connect: (sessionName: string) => void; + connect: (sessionName: string, options?: BrowserSessionAttachOptions) => void; disconnect: () => void; sendInput: (input: BrowserInputEvent) => void; setPendingUrl: (url: string | null) => void; @@ -216,11 +217,10 @@ export function useBrowserBridgeConnection(workspaceId: string): { disconnectSocket(null, { intentionalCloseGeneration: generation }); }; - const connect = (sessionName: string) => { + const connect = (sessionName: string, options?: BrowserSessionAttachOptions) => { if (sessionName.trim().length === 0) { throw new Error("Browser bridge connection requires a non-empty sessionName"); } - void (async () => { const generation = generationRef.current + 1; generationRef.current = generation; @@ -248,6 +248,9 @@ export function useBrowserBridgeConnection(workspaceId: string): { const bootstrap = await api.browser.getBootstrap({ workspaceId, sessionName, + ...(options?.allowOtherWorkspaceSession === true + ? { allowOtherWorkspaceSession: true } + : {}), }); if (generationRef.current !== generation) { return; diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index cf6669549c..1cc0d50737 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -2077,6 +2077,10 @@ const BrowserDiscoveredSessionSchema = z.object({ status: z.enum(["attachable", "missing_stream"]), }); +const BrowserDiscoveredOtherSessionSchema = BrowserDiscoveredSessionSchema.extend({ + cwd: z.string(), +}); + const BrowserControlActionSchema = z.enum(["open", "back", "forward", "reload"]); export const browser = { @@ -2088,6 +2092,7 @@ export const browser = { .strict(), output: z.object({ sessions: z.array(BrowserDiscoveredSessionSchema), + otherSessions: z.array(BrowserDiscoveredOtherSessionSchema), }), }, getBootstrap: { @@ -2095,6 +2100,7 @@ export const browser = { .object({ workspaceId: z.string(), sessionName: z.string(), + allowOtherWorkspaceSession: z.boolean().nullish(), }) .strict(), output: z.object({ @@ -2110,6 +2116,7 @@ export const browser = { sessionName: z.string(), action: BrowserControlActionSchema, url: z.string().nullish(), + allowOtherWorkspaceSession: z.boolean().nullish(), }) .strict(), output: z.object({ @@ -2122,6 +2129,7 @@ export const browser = { .object({ workspaceId: z.string(), sessionName: z.string(), + allowOtherWorkspaceSession: z.boolean().nullish(), }) .strict(), output: z.object({ diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 00ab529213..4a38e19f88 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1181,14 +1181,19 @@ export const router = (authToken?: string) => { .input(schemas.browser.listSessions.input) .output(schemas.browser.listSessions.output) .handler(async ({ context, input }) => { - const sessions = await context.browserSessionDiscoveryService.listSessions( + const sessionGroups = await context.browserSessionDiscoveryService.listSessionGroups( input.workspaceId ); return { - sessions: sessions.map((session) => ({ + sessions: sessionGroups.sessions.map((session) => ({ sessionName: session.sessionName, status: session.status, })), + otherSessions: sessionGroups.otherSessions.map((session) => ({ + sessionName: session.sessionName, + status: session.status, + cwd: session.cwd, + })), }; }), getBootstrap: t @@ -1200,15 +1205,18 @@ export const router = (authToken?: string) => { throw new Error("Browser bridge bootstrap failed: API server unavailable"); } + const allowOtherWorkspaceSession = input.allowOtherWorkspaceSession === true; const connection = await context.browserSessionDiscoveryService.ensureSessionAttachable( input.workspaceId, - input.sessionName + input.sessionName, + { allowOtherWorkspaceSession } ); const token = context.browserBridgeTokenManager.mint( input.workspaceId, connection.sessionName, - connection.streamPort + connection.streamPort, + { allowOtherWorkspaceSession } ); return { @@ -1277,7 +1285,9 @@ export const router = (authToken?: string) => { .input(schemas.browser.getUrl.input) .output(schemas.browser.getUrl.output) .handler(async ({ context, input }) => { - return await context.browserControlService.getUrl(input.workspaceId, input.sessionName); + return await context.browserControlService.getUrl(input.workspaceId, input.sessionName, { + allowOtherWorkspaceSession: input.allowOtherWorkspaceSession === true, + }); }), }, uiLayouts: { diff --git a/src/node/services/browser/AgentBrowserSessionDiscoveryService.test.ts b/src/node/services/browser/AgentBrowserSessionDiscoveryService.test.ts index 10d514f10f..eb2115d22e 100644 --- a/src/node/services/browser/AgentBrowserSessionDiscoveryService.test.ts +++ b/src/node/services/browser/AgentBrowserSessionDiscoveryService.test.ts @@ -341,6 +341,110 @@ describe("AgentBrowserSessionDiscoveryService", () => { expect(await service.listSessions("workspace-1")).toEqual([]); }); + test("groups sessions from the current workspace separately from other live sessions", async () => { + const projectPath = path.join(tempDir, "project"); + const otherProjectPath = path.join(tempDir, "different-project"); + await mkdir(projectPath, { recursive: true }); + await mkdir(otherProjectPath, { recursive: true }); + await writeSessionFiles(socketDir, "current", { pid: "100", streamPort: "9100" }); + await writeSessionFiles(socketDir, "other", { pid: "200", streamPort: "9200" }); + + const service = createService({ + listSessionNamesFn: () => Promise.resolve(["other", "current"]), + resolveCandidatePaths: () => Promise.resolve([projectPath]), + resolveProcessCwdFn: (pid) => Promise.resolve(pid === 100 ? projectPath : otherProjectPath), + }); + + expect(await service.listSessionGroups("workspace-1")).toEqual({ + sessions: [ + { + sessionName: "current", + pid: 100, + cwd: projectPath, + status: "attachable", + streamPort: 9100, + }, + ], + otherSessions: [ + { + sessionName: "other", + pid: 200, + cwd: otherProjectPath, + status: "attachable", + streamPort: 9200, + }, + ], + }); + }); + + test("keeps other missing_stream sessions discoverable without adding them to current sessions", async () => { + const projectPath = path.join(tempDir, "project"); + const otherProjectPath = path.join(tempDir, "different-project"); + await mkdir(projectPath, { recursive: true }); + await mkdir(otherProjectPath, { recursive: true }); + await writeSessionFiles(socketDir, "other-nostream", { pid: "201" }); + + const service = createService({ + listSessionNamesFn: () => Promise.resolve(["other-nostream"]), + resolveCandidatePaths: () => Promise.resolve([projectPath]), + resolveProcessCwdFn: () => Promise.resolve(otherProjectPath), + }); + + expect(await service.listSessions("workspace-1")).toEqual([]); + expect(await service.listSessionGroups("workspace-1")).toEqual({ + sessions: [], + otherSessions: [ + { + sessionName: "other-nostream", + pid: 201, + cwd: otherProjectPath, + status: "missing_stream", + }, + ], + }); + }); + + test("explicitly allowed other sessions can be made attachable", async () => { + const projectPath = path.join(tempDir, "project"); + const otherProjectPath = path.join(tempDir, "different-project"); + await mkdir(projectPath, { recursive: true }); + await mkdir(otherProjectPath, { recursive: true }); + await writeSessionFiles(socketDir, "other-nostream", { pid: "202" }); + + const statuses = [ + { enabled: false, port: null }, + { enabled: true, port: 12345 }, + ]; + const getSessionStreamStatusFn = mock(() => Promise.resolve(statuses.shift() ?? null)); + const enableSessionStreamingFn = mock(() => Promise.resolve({ port: 12345 })); + const service = createService({ + listSessionNamesFn: () => Promise.resolve(["other-nostream"]), + getSessionStreamStatusFn, + enableSessionStreamingFn, + resolveCandidatePaths: () => Promise.resolve([projectPath]), + resolveProcessCwdFn: () => Promise.resolve(otherProjectPath), + }); + + // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime + await expect(service.ensureSessionAttachable("workspace-1", "other-nostream")).rejects.toThrow( + 'Session "other-nostream" is unavailable (no sessions discovered for workspace "workspace-1")' + ); + + expect( + await service.ensureSessionAttachable("workspace-1", "other-nostream", { + allowOtherWorkspaceSession: true, + }) + ).toEqual({ + sessionName: "other-nostream", + pid: 202, + cwd: otherProjectPath, + status: "attachable", + streamPort: 12345, + }); + expect(getSessionStreamStatusFn).toHaveBeenCalledTimes(2); + expect(enableSessionStreamingFn).toHaveBeenCalledTimes(1); + }); + test("skips dead pid sessions when cwd cannot be resolved", async () => { await writeSessionFiles(socketDir, "dead", { pid: "404", streamPort: "9300" }); diff --git a/src/node/services/browser/AgentBrowserSessionDiscoveryService.ts b/src/node/services/browser/AgentBrowserSessionDiscoveryService.ts index c859595605..c6498d3d36 100644 --- a/src/node/services/browser/AgentBrowserSessionDiscoveryService.ts +++ b/src/node/services/browser/AgentBrowserSessionDiscoveryService.ts @@ -203,6 +203,15 @@ export type AgentBrowserDiscoveredSession = | AgentBrowserDiscoveredSessionConnection | AgentBrowserMissingStreamSession; +export interface AgentBrowserSessionGroups { + sessions: AgentBrowserDiscoveredSession[]; + otherSessions: AgentBrowserDiscoveredSession[]; +} + +export interface AgentBrowserSessionLookupOptions { + allowOtherWorkspaceSession?: boolean; +} + interface StreamStatusResult { enabled: boolean; port: number | null; @@ -564,23 +573,31 @@ export class AgentBrowserSessionDiscoveryService { async listSessions(workspaceId: string): Promise { assert(workspaceId.trim().length > 0, "listSessions requires a non-empty workspaceId"); - return await this.discoverSessions(workspaceId); + return (await this.discoverSessionGroups(workspaceId)).sessions; + } + + async listSessionGroups(workspaceId: string): Promise { + assert(workspaceId.trim().length > 0, "listSessionGroups requires a non-empty workspaceId"); + return await this.discoverSessionGroups(workspaceId); } async getSessionConnection( workspaceId: string, - sessionName: string + sessionName: string, + options?: AgentBrowserSessionLookupOptions ): Promise { assert(workspaceId.trim().length > 0, "getSessionConnection requires a non-empty workspaceId"); assert(sessionName.trim().length > 0, "getSessionConnection requires a non-empty sessionName"); - const sessions = await this.discoverSessions(workspaceId); + const sessionGroups = await this.discoverSessionGroups(workspaceId); + const sessions = this.getLookupSessions(sessionGroups, options); const session = sessions.find((candidate) => candidate.sessionName === sessionName) ?? null; return session?.status === "attachable" ? session : null; } async ensureSessionAttachable( workspaceId: string, - sessionName: string + sessionName: string, + options?: AgentBrowserSessionLookupOptions ): Promise { assert( workspaceId.trim().length > 0, @@ -591,7 +608,8 @@ export class AgentBrowserSessionDiscoveryService { "ensureSessionAttachable requires a non-empty sessionName" ); - const sessions = await this.discoverSessions(workspaceId); + const sessionGroups = await this.discoverSessionGroups(workspaceId); + const sessions = this.getLookupSessions(sessionGroups, options); const session = sessions.find((candidate) => candidate.sessionName === sessionName) ?? null; if (session == null) { if (sessions.length === 0) { @@ -648,16 +666,17 @@ export class AgentBrowserSessionDiscoveryService { }; } - private async discoverSessions(workspaceId: string): Promise { + private async discoverSessionGroups(workspaceId: string): Promise { const candidatePaths = await this.resolveWorkspaceCandidatePathsFn(workspaceId); const comparableCandidatePaths = await this.resolveComparableCandidatePaths(candidatePaths); if (comparableCandidatePaths.length === 0) { - return []; + return { sessions: [], otherSessions: [] }; } const socketDir = getAgentBrowserSocketDir(this.env); const sessionNames = await this.listSessionNamesFn(); const sessions: AgentBrowserDiscoveredSession[] = []; + const otherSessions: AgentBrowserDiscoveredSession[] = []; for (const sessionName of sessionNames) { const pid = await readPositiveIntegerFile( @@ -673,36 +692,42 @@ export class AgentBrowserSessionDiscoveryService { continue; } - const comparableCwd = await this.resolveComparablePath(cwd); - if ( - !comparableCandidatePaths.some((candidatePath) => - isPathInsideDir(candidatePath, comparableCwd) - ) - ) { - continue; - } - const streamPort = await readPositiveIntegerFile( this.readFileFn, path.join(socketDir, `${sessionName}.stream`) ); - if (streamPort == null) { - sessions.push({ sessionName, pid, cwd, status: "missing_stream" }); - continue; - } + const discoveredSession: AgentBrowserDiscoveredSession = + streamPort == null + ? { sessionName, pid, cwd, status: "missing_stream" } + : { sessionName, pid, cwd, status: "attachable", streamPort }; - const attachableSession: AgentBrowserDiscoveredSessionConnection = { - sessionName, - pid, - cwd, - status: "attachable", - streamPort, - }; - sessions.push(attachableSession); + const comparableCwd = await this.resolveComparablePath(cwd); + const isCurrentWorkspaceSession = comparableCandidatePaths.some((candidatePath) => + isPathInsideDir(candidatePath, comparableCwd) + ); + if (isCurrentWorkspaceSession) { + sessions.push(discoveredSession); + } else { + otherSessions.push(discoveredSession); + } } - sessions.sort((a, b) => a.sessionName.localeCompare(b.sessionName)); - return sessions; + const compareBySessionName = ( + a: AgentBrowserDiscoveredSession, + b: AgentBrowserDiscoveredSession + ) => a.sessionName.localeCompare(b.sessionName); + sessions.sort(compareBySessionName); + otherSessions.sort(compareBySessionName); + return { sessions, otherSessions }; + } + + private getLookupSessions( + sessionGroups: AgentBrowserSessionGroups, + options?: AgentBrowserSessionLookupOptions + ): AgentBrowserDiscoveredSession[] { + return options?.allowOtherWorkspaceSession === true + ? [...sessionGroups.sessions, ...sessionGroups.otherSessions] + : sessionGroups.sessions; } private async resolveComparableCandidatePaths(candidatePaths: string[]): Promise { diff --git a/src/node/services/browser/BrowserBridgeServer.test.ts b/src/node/services/browser/BrowserBridgeServer.test.ts index 9b53a462ef..7133f92df1 100644 --- a/src/node/services/browser/BrowserBridgeServer.test.ts +++ b/src/node/services/browser/BrowserBridgeServer.test.ts @@ -50,12 +50,16 @@ function createAttachableConnection(sessionName: string, streamPort: number) { function createBridgeServer( options: { - validate?: ( - token: string - ) => { workspaceId: string; sessionName: string; streamPort: number } | null; + validate?: (token: string) => { + workspaceId: string; + sessionName: string; + streamPort: number; + allowOtherWorkspaceSession: boolean; + } | null; getSessionConnection?: ( workspaceId: string, - sessionName: string + sessionName: string, + options?: { allowOtherWorkspaceSession?: boolean } ) => Promise<{ sessionName: string; pid: number; @@ -80,6 +84,7 @@ function createBridgeServer( workspaceId: VALID_WORKSPACE_ID, sessionName: VALID_SESSION_NAME, streamPort: VALID_STREAM_PORT, + allowOtherWorkspaceSession: false, } : null ), @@ -260,6 +265,7 @@ describe("BrowserBridgeServer", () => { workspaceId: VALID_WORKSPACE_ID, sessionName: VALID_SESSION_NAME, streamPort: upstreamHarness.port, + allowOtherWorkspaceSession: false, } : null ), @@ -307,6 +313,35 @@ describe("BrowserBridgeServer", () => { } }); + test("revalidates explicit other-workspace tokens with the same session scope", async () => { + const getSessionConnection = mock(() => Promise.resolve(null)); + const bridgeServer = createBridgeServer({ + validate: mock(() => ({ + workspaceId: VALID_WORKSPACE_ID, + sessionName: VALID_SESSION_NAME, + streamPort: VALID_STREAM_PORT, + allowOtherWorkspaceSession: true, + })), + getSessionConnection, + }); + + try { + const ws = createMockClientSocket(); + const bridgeServerPrivate = bridgeServer as unknown as BrowserBridgeServerPrivate; + await bridgeServerPrivate.handleUpgradedConnection( + ws as unknown as WebSocket, + { url: `/?token=${VALID_TOKEN}` } as IncomingMessage + ); + + expect(getSessionConnection).toHaveBeenCalledWith(VALID_WORKSPACE_ID, VALID_SESSION_NAME, { + allowOtherWorkspaceSession: true, + }); + expect(ws.close).toHaveBeenCalledWith(4002, "session unavailable"); + } finally { + await bridgeServer.stop(); + } + }); + test("closes with 4002 when the live session is missing or mismatched", async () => { for (const liveSession of [null, createAttachableConnection(VALID_SESSION_NAME, 9999)]) { const bridgeServer = createBridgeServer({ @@ -314,6 +349,7 @@ describe("BrowserBridgeServer", () => { workspaceId: VALID_WORKSPACE_ID, sessionName: VALID_SESSION_NAME, streamPort: VALID_STREAM_PORT, + allowOtherWorkspaceSession: false, })), getSessionConnection: mock(() => Promise.resolve(liveSession)), }); @@ -360,6 +396,7 @@ describe("BrowserBridgeServer", () => { workspaceId: VALID_WORKSPACE_ID, sessionName: VALID_SESSION_NAME, streamPort: upstreamHarness.port, + allowOtherWorkspaceSession: false, } : null ), @@ -409,6 +446,7 @@ describe("BrowserBridgeServer", () => { workspaceId: VALID_WORKSPACE_ID, sessionName: VALID_SESSION_NAME, streamPort: upstreamHarness.port, + allowOtherWorkspaceSession: false, } : null ), @@ -468,6 +506,7 @@ describe("BrowserBridgeServer", () => { workspaceId: VALID_WORKSPACE_ID, sessionName: VALID_SESSION_NAME, streamPort: upstreamHarness.port, + allowOtherWorkspaceSession: false, } : null ), diff --git a/src/node/services/browser/BrowserBridgeServer.ts b/src/node/services/browser/BrowserBridgeServer.ts index 28c4d19c4d..5ca20f374b 100644 --- a/src/node/services/browser/BrowserBridgeServer.ts +++ b/src/node/services/browser/BrowserBridgeServer.ts @@ -271,7 +271,8 @@ export class BrowserBridgeServer { const liveSession = await this.browserSessionDiscoveryService.getSessionConnection( payload.workspaceId, - payload.sessionName + payload.sessionName, + { allowOtherWorkspaceSession: payload.allowOtherWorkspaceSession } ); if ( !liveSession || diff --git a/src/node/services/browser/BrowserBridgeTokenManager.test.ts b/src/node/services/browser/BrowserBridgeTokenManager.test.ts index 3218317cc1..7c3db96b98 100644 --- a/src/node/services/browser/BrowserBridgeTokenManager.test.ts +++ b/src/node/services/browser/BrowserBridgeTokenManager.test.ts @@ -1,15 +1,10 @@ -import { afterEach, describe, expect, it, setSystemTime, vi } from "bun:test"; +import { describe, expect, it } from "bun:test"; import { AssertionError } from "@/common/utils/assert"; import { BrowserBridgeTokenManager } from "./BrowserBridgeTokenManager"; const TOKEN_TTL_MS = 30_000; describe("BrowserBridgeTokenManager", () => { - afterEach(() => { - setSystemTime(); - vi.useRealTimers(); - }); - it("mints a 64-character hex token", () => { const manager = new BrowserBridgeTokenManager(); @@ -42,6 +37,7 @@ describe("BrowserBridgeTokenManager", () => { workspaceId: "workspace-1", sessionName: "session-a", streamPort: 9222, + allowOtherWorkspaceSession: false, }); expect(manager.validate(token)).toBeNull(); } finally { @@ -49,16 +45,37 @@ describe("BrowserBridgeTokenManager", () => { } }); + it("preserves explicit other-workspace session scope", () => { + const manager = new BrowserBridgeTokenManager(); + + try { + const token = manager.mint("workspace-1", "session-a", 9222, { + allowOtherWorkspaceSession: true, + }); + expect(manager.validate(token)).toEqual({ + workspaceId: "workspace-1", + sessionName: "session-a", + streamPort: 9222, + allowOtherWorkspaceSession: true, + }); + } finally { + manager.dispose(); + } + }); + it("returns null for expired tokens", () => { - setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const originalNow = Date.now; + let nowMs = new Date("2026-01-01T00:00:00.000Z").getTime(); + Date.now = () => nowMs; const manager = new BrowserBridgeTokenManager(); try { const token = manager.mint("workspace-1", "session-a", 9222); - setSystemTime(Date.now() + TOKEN_TTL_MS + 1); + nowMs += TOKEN_TTL_MS + 1; expect(manager.validate(token)).toBeNull(); } finally { manager.dispose(); + Date.now = originalNow; } }); }); diff --git a/src/node/services/browser/BrowserBridgeTokenManager.ts b/src/node/services/browser/BrowserBridgeTokenManager.ts index 7d991e17cf..89d52d8b48 100644 --- a/src/node/services/browser/BrowserBridgeTokenManager.ts +++ b/src/node/services/browser/BrowserBridgeTokenManager.ts @@ -6,9 +6,21 @@ interface TokenRecord { workspaceId: string; sessionName: string; streamPort: number; + allowOtherWorkspaceSession: boolean; expiresAtMs: number; } +interface BrowserBridgeTokenMintOptions { + allowOtherWorkspaceSession?: boolean; +} + +export interface BrowserBridgeTokenPayload { + workspaceId: string; + sessionName: string; + streamPort: number; + allowOtherWorkspaceSession: boolean; +} + const BROWSER_BRIDGE_TOKEN_TTL_MS = 30_000; const CLEANUP_INTERVAL_MS = 60_000; @@ -21,7 +33,12 @@ export class BrowserBridgeTokenManager { this.cleanupTimer.unref?.(); } - mint(workspaceId: string, sessionName: string, streamPort: number): string { + mint( + workspaceId: string, + sessionName: string, + streamPort: number, + options?: BrowserBridgeTokenMintOptions + ): string { assert(workspaceId.length > 0, "BrowserBridgeTokenManager.mint requires non-empty workspaceId"); assert(sessionName.length > 0, "BrowserBridgeTokenManager.mint requires non-empty sessionName"); assert( @@ -29,7 +46,6 @@ export class BrowserBridgeTokenManager { "BrowserBridgeTokenManager.mint requires integer streamPort" ); assert(streamPort > 0, "BrowserBridgeTokenManager.mint requires positive streamPort"); - let token = ""; do { token = randomBytes(32).toString("hex"); @@ -39,13 +55,14 @@ export class BrowserBridgeTokenManager { workspaceId, sessionName, streamPort, + allowOtherWorkspaceSession: options?.allowOtherWorkspaceSession === true, expiresAtMs: Date.now() + BROWSER_BRIDGE_TOKEN_TTL_MS, }); return token; } - validate(token: string): { workspaceId: string; sessionName: string; streamPort: number } | null { + validate(token: string): BrowserBridgeTokenPayload | null { const record = this.tokens.get(token); if (!record) { return null; @@ -62,6 +79,7 @@ export class BrowserBridgeTokenManager { workspaceId: record.workspaceId, sessionName: record.sessionName, streamPort: record.streamPort, + allowOtherWorkspaceSession: record.allowOtherWorkspaceSession, }; } diff --git a/src/node/services/browser/BrowserControlService.test.ts b/src/node/services/browser/BrowserControlService.test.ts index ab94573241..8261fcac92 100644 --- a/src/node/services/browser/BrowserControlService.test.ts +++ b/src/node/services/browser/BrowserControlService.test.ts @@ -68,7 +68,8 @@ type SpawnFn = (command: string, args: string[], options: SpawnOptions) => Child function createService(options?: { getSessionConnection?: ( workspaceId: string, - sessionName: string + sessionName: string, + options?: { allowOtherWorkspaceSession?: boolean } ) => Promise | null>; resolveSessionEnvFn?: (workspaceId: string) => Promise; spawnFn?: SpawnFn; @@ -255,6 +256,52 @@ describe("BrowserControlService", () => { expect(spawnFn).not.toHaveBeenCalled(); }); + test("executeControl validates explicitly allowed other sessions with matching scope", async () => { + const child = new MockChildProcess(); + const { spawnFn, waitForSpawn } = createSpawnHarness(child); + const getSessionConnection = mock(() => Promise.resolve(createAttachableSession())); + const service = createService({ + getSessionConnection, + spawnFn, + }); + + const executionPromise = service.executeControl({ + workspaceId: WORKSPACE_ID, + sessionName: SESSION_NAME, + action: "reload", + allowOtherWorkspaceSession: true, + }); + await waitForSpawn(); + child.close(); + + expect(await executionPromise).toEqual({ success: true }); + expect(getSessionConnection).toHaveBeenCalledWith(WORKSPACE_ID, SESSION_NAME, { + allowOtherWorkspaceSession: true, + }); + }); + + test("getUrl validates explicitly allowed other sessions with matching scope", async () => { + const child = new MockChildProcess(); + const { spawnFn, waitForSpawn } = createSpawnHarness(child); + const getSessionConnection = mock(() => Promise.resolve(createAttachableSession())); + const service = createService({ + getSessionConnection, + spawnFn, + }); + + const resultPromise = service.getUrl(WORKSPACE_ID, SESSION_NAME, { + allowOtherWorkspaceSession: true, + }); + await waitForSpawn(); + child.writeStdout("https://example.com/current\n"); + child.close(); + + expect(await resultPromise).toEqual({ url: "https://example.com/current" }); + expect(getSessionConnection).toHaveBeenCalledWith(WORKSPACE_ID, SESSION_NAME, { + allowOtherWorkspaceSession: true, + }); + }); + test("getUrl parses stdout from the CLI", async () => { const child = new MockChildProcess(); const { spawnCalls, spawnFn, waitForSpawn } = createSpawnHarness(child); diff --git a/src/node/services/browser/BrowserControlService.ts b/src/node/services/browser/BrowserControlService.ts index ca573f6aea..f1a3603b98 100644 --- a/src/node/services/browser/BrowserControlService.ts +++ b/src/node/services/browser/BrowserControlService.ts @@ -16,6 +16,7 @@ export interface BrowserControlParams { sessionName: string; action: BrowserControlAction; url?: string | null; + allowOtherWorkspaceSession?: boolean | null; } export interface BrowserControlResult { @@ -30,6 +31,7 @@ export interface BrowserGetUrlResult { export interface BrowserGetUrlOptions { skipSessionValidation?: boolean; + allowOtherWorkspaceSession?: boolean | null; } interface BrowserCommandExecutionResult { @@ -70,7 +72,8 @@ export class BrowserControlService { try { const connection = await this.browserSessionDiscoveryService.getSessionConnection( params.workspaceId, - params.sessionName + params.sessionName, + { allowOtherWorkspaceSession: params.allowOtherWorkspaceSession === true } ); if (connection == null) { return { @@ -119,12 +122,18 @@ export class BrowserControlService { options?.skipSessionValidation == null || typeof options.skipSessionValidation === "boolean", "BrowserControlService getUrl skipSessionValidation must be a boolean when provided" ); + assert( + options?.allowOtherWorkspaceSession == null || + typeof options.allowOtherWorkspaceSession === "boolean", + "BrowserControlService getUrl allowOtherWorkspaceSession must be a boolean when provided" + ); try { if (!options?.skipSessionValidation) { const connection = await this.browserSessionDiscoveryService.getSessionConnection( workspaceId, - sessionName + sessionName, + { allowOtherWorkspaceSession: options?.allowOtherWorkspaceSession === true } ); if (connection == null) { return { @@ -168,6 +177,12 @@ export class BrowserControlService { `Unsupported browser control action: ${String(params.action)}` ); + assert( + params.allowOtherWorkspaceSession == null || + typeof params.allowOtherWorkspaceSession === "boolean", + "BrowserControlService allowOtherWorkspaceSession must be a boolean when provided" + ); + if (params.action === "open") { assert(typeof params.url === "string", 'BrowserControlService "open" requires a url'); assert(params.url.trim().length > 0, 'BrowserControlService "open" requires a non-empty url'); From 2119fc934ab76e98d824a9470ad28cba8e847e2c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 6 May 2026 14:51:30 +0000 Subject: [PATCH 2/7] Address browser session review feedback --- .../BrowserTab/BrowserTab.test.tsx | 47 +++++++++++++- .../RightSidebar/BrowserTab/BrowserTab.tsx | 61 +++++++++++-------- ...gentBrowserSessionDiscoveryService.test.ts | 27 ++++++++ .../AgentBrowserSessionDiscoveryService.ts | 1 + .../browser/BrowserBridgeServer.test.ts | 40 ++++++++++-- .../browser/BrowserBridgeTokenManager.test.ts | 13 ++-- .../browser/BrowserBridgeTokenManager.ts | 1 + 7 files changed, 153 insertions(+), 37 deletions(-) diff --git a/src/browser/features/RightSidebar/BrowserTab/BrowserTab.test.tsx b/src/browser/features/RightSidebar/BrowserTab/BrowserTab.test.tsx index 09602eef73..1bac0b8d54 100644 --- a/src/browser/features/RightSidebar/BrowserTab/BrowserTab.test.tsx +++ b/src/browser/features/RightSidebar/BrowserTab/BrowserTab.test.tsx @@ -52,6 +52,7 @@ void mock.module("./useBrowserBridgeConnection", () => ({ import { BROWSER_PREVIEW_RETRY_INTERVAL_MS, BrowserTab, + chooseExplicitOtherSession, shouldBackOffBrowserReconnect, } from "./BrowserTab"; @@ -145,7 +146,9 @@ describe("BrowserTab", () => { await waitFor(() => { expect(view.getByText("Select session")).toBeTruthy(); }); - expect(view.queryByText("Other running sessions")).toBeNull(); + expect(view.queryByText("Other sessions")).toBeNull(); + expect(view.getByText("Choose a browser session")).toBeTruthy(); + expect(view.getByText("Select an other session from the picker to connect.")).toBeTruthy(); fireEvent.click(view.getByText("Select session")); @@ -154,6 +157,30 @@ describe("BrowserTab", () => { expect(connectMock).not.toHaveBeenCalled(); }); + test("auto-selects current sessions while still listing other sessions in the picker", async () => { + listSessionsMock.mockResolvedValue({ + sessions: [createDiscoveredSession({ sessionName: "current-alpha" })], + otherSessions: [ + { + sessionName: "other-alpha", + status: "attachable", + cwd: "/tmp/other-project", + }, + ], + }); + + const view = render(); + + await waitFor(() => { + expect(connectMock).toHaveBeenCalledWith("current-alpha"); + }); + + fireEvent.click(view.getByText("current-alpha")); + + expect(view.getByTestId("browser-session-current-alpha")).toBeTruthy(); + expect(view.getByTestId("browser-other-session-other-alpha")).toBeTruthy(); + }); + test("attaches to an other running session only after selecting it from the picker", async () => { listSessionsMock.mockResolvedValue({ sessions: [], @@ -175,6 +202,10 @@ describe("BrowserTab", () => { fireEvent.click(view.getByText("Select session")); fireEvent.click(view.getByTestId("browser-other-session-other-alpha")); + await waitFor(() => { + expect(view.getByText("Waiting for browser frames")).toBeTruthy(); + }); + await waitFor(() => { expect(connectMock).toHaveBeenCalledWith("other-alpha", { allowOtherWorkspaceSession: true, @@ -208,6 +239,20 @@ describe("BrowserTab", () => { }); }); +describe("chooseExplicitOtherSession", () => { + test("preserves an explicitly selected other session while it is still discovered", () => { + expect( + chooseExplicitOtherSession("other-alpha", [ + { sessionName: "other-alpha", status: "attachable", cwd: "/tmp/other-project" }, + ]) + ).toBe("other-alpha"); + }); + + test("clears an explicitly selected other session after discovery loses it", () => { + expect(chooseExplicitOtherSession("other-alpha", [])).toBeNull(); + }); +}); + describe("shouldBackOffBrowserReconnect", () => { test("backs off retryable reconnects for the same session inside the retry window", () => { expect( diff --git a/src/browser/features/RightSidebar/BrowserTab/BrowserTab.tsx b/src/browser/features/RightSidebar/BrowserTab/BrowserTab.tsx index 260c4e2700..b93f8d1ab9 100644 --- a/src/browser/features/RightSidebar/BrowserTab/BrowserTab.tsx +++ b/src/browser/features/RightSidebar/BrowserTab/BrowserTab.tsx @@ -15,6 +15,10 @@ import { BrowserToolbar } from "./BrowserToolbar"; import { BrowserViewport } from "./BrowserViewport"; import { useBrowserBridgeConnection } from "./useBrowserBridgeConnection"; +type BrowserSelectedSession = + | { sessionName: string; source: "current" } + | { sessionName: string; source: "other" }; + interface BrowserTabProps { workspaceId: string; projectPath: string; @@ -104,6 +108,16 @@ function chooseSelectedSession( return sessions[0]?.sessionName ?? null; } +export function chooseExplicitOtherSession( + currentSessionName: string | null, + otherSessions: BrowserDiscoveredOtherSession[] +): string | null { + return currentSessionName != null && + otherSessions.some((otherSession) => otherSession.sessionName === currentSessionName) + ? currentSessionName + : null; +} + export function BrowserTab(props: BrowserTabProps) { if (props.workspaceId.trim().length === 0) { throw new Error("Browser tab requires a workspaceId"); @@ -125,9 +139,15 @@ export function BrowserTab(props: BrowserTabProps) { props.workspaceId ); - const selectedSessionName = explicitOtherSessionName ?? selectedCurrentSessionName; - const selectedSessionAllowsOther = explicitOtherSessionName != null; - const selectedDiscoveredSession = selectedSessionAllowsOther + const selectedSession: BrowserSelectedSession | null = + explicitOtherSessionName != null + ? { sessionName: explicitOtherSessionName, source: "other" } + : selectedCurrentSessionName != null + ? { sessionName: selectedCurrentSessionName, source: "current" } + : null; + const selectedSessionName = selectedSession?.sessionName ?? null; + const isOtherSessionSelected = selectedSession?.source === "other"; + const selectedDiscoveredSession = isOtherSessionSelected ? (otherDiscoveredSessions.find((candidate) => candidate.sessionName === selectedSessionName) ?? null) : (discoveredSessions.find((candidate) => candidate.sessionName === selectedSessionName) ?? @@ -171,10 +191,7 @@ export function BrowserTab(props: BrowserTabProps) { setDiscoveredSessions(result.sessions); setOtherDiscoveredSessions(result.otherSessions); setExplicitOtherSessionName((currentSessionName) => - currentSessionName != null && - result.otherSessions.some((session) => session.sessionName === currentSessionName) - ? currentSessionName - : null + chooseExplicitOtherSession(currentSessionName, result.otherSessions) ); setSelectedCurrentSessionName((currentSessionName) => chooseSelectedSession(currentSessionName, result.sessions) @@ -249,7 +266,7 @@ export function BrowserTab(props: BrowserTabProps) { sessionName: selectedSessionName, attemptedAtMs: now, }; - if (selectedSessionAllowsOther) { + if (isOtherSessionSelected) { connect(selectedSessionName, { allowOtherWorkspaceSession: true }); } else { connect(selectedSessionName); @@ -260,7 +277,7 @@ export function BrowserTab(props: BrowserTabProps) { disconnect, selectedDiscoveredSession, selectedSessionName, - selectedSessionAllowsOther, + isOtherSessionSelected, session, visibleError, ]); @@ -281,7 +298,7 @@ export function BrowserTab(props: BrowserTabProps) { currentSessions={discoveredSessions} otherSessions={otherDiscoveredSessions} selectedSessionName={selectedSessionName} - selectedSessionAllowsOther={selectedSessionAllowsOther} + isOtherSessionSelected={isOtherSessionSelected} onSelectCurrent={(sessionName) => { setExplicitOtherSessionName(null); setSelectedCurrentSessionName(sessionName); @@ -294,7 +311,7 @@ export function BrowserTab(props: BrowserTabProps) { void; onSelectOther: (sessionName: string) => void; }) { @@ -402,8 +419,7 @@ function BrowserSessionPicker(props: { key={session.sessionName} session={session} isSelected={ - !props.selectedSessionAllowsOther && - session.sessionName === props.selectedSessionName + !props.isOtherSessionSelected && session.sessionName === props.selectedSessionName } testId={`browser-session-${session.sessionName}`} onSelect={() => { @@ -422,11 +438,9 @@ function BrowserSessionPicker(props: { key={session.sessionName} session={session} isSelected={ - props.selectedSessionAllowsOther && - session.sessionName === props.selectedSessionName + props.isOtherSessionSelected && session.sessionName === props.selectedSessionName } testId={`browser-other-session-${session.sessionName}`} - cwd={session.cwd} onSelect={() => { props.onSelectOther(session.sessionName); setIsOpen(false); @@ -441,10 +455,9 @@ function BrowserSessionPicker(props: { } function BrowserSessionPickerOption(props: { - session: BrowserDiscoveredSession; + session: BrowserDiscoveredSession | BrowserDiscoveredOtherSession; isSelected: boolean; testId: string; - cwd?: string; onSelect: () => void; }) { return ( @@ -462,9 +475,9 @@ function BrowserSessionPickerOption(props: { /> {props.session.sessionName} - {props.cwd != null && ( - - {props.cwd} + {"cwd" in props.session && ( + + {props.session.cwd} )} @@ -505,7 +518,7 @@ function BrowserViewerState(props: { }; } - if (props.hasDiscoveredSessions) { + if (props.selectedSession != null || props.hasDiscoveredSessions) { return { title: "Waiting for browser frames", description: "Mux found a browser session and is waiting for live preview frames.", @@ -515,7 +528,7 @@ function BrowserViewerState(props: { if (props.hasOtherSessions) { return { title: "Choose a browser session", - description: "Other running sessions require explicit attachment before Mux connects.", + description: "Select an other session from the picker to connect.", }; } diff --git a/src/node/services/browser/AgentBrowserSessionDiscoveryService.test.ts b/src/node/services/browser/AgentBrowserSessionDiscoveryService.test.ts index eb2115d22e..55e36ca9e9 100644 --- a/src/node/services/browser/AgentBrowserSessionDiscoveryService.test.ts +++ b/src/node/services/browser/AgentBrowserSessionDiscoveryService.test.ts @@ -377,6 +377,33 @@ describe("AgentBrowserSessionDiscoveryService", () => { }); }); + test("getSessionConnection remains strict unless other sessions are explicitly allowed", async () => { + const projectPath = path.join(tempDir, "project"); + const otherProjectPath = path.join(tempDir, "different-project"); + await mkdir(projectPath, { recursive: true }); + await mkdir(otherProjectPath, { recursive: true }); + await writeSessionFiles(socketDir, "other", { pid: "203", streamPort: "9203" }); + + const service = createService({ + listSessionNamesFn: () => Promise.resolve(["other"]), + resolveCandidatePaths: () => Promise.resolve([projectPath]), + resolveProcessCwdFn: () => Promise.resolve(otherProjectPath), + }); + + expect(await service.getSessionConnection("workspace-1", "other")).toBeNull(); + expect( + await service.getSessionConnection("workspace-1", "other", { + allowOtherWorkspaceSession: true, + }) + ).toEqual({ + sessionName: "other", + pid: 203, + cwd: otherProjectPath, + status: "attachable", + streamPort: 9203, + }); + }); + test("keeps other missing_stream sessions discoverable without adding them to current sessions", async () => { const projectPath = path.join(tempDir, "project"); const otherProjectPath = path.join(tempDir, "different-project"); diff --git a/src/node/services/browser/AgentBrowserSessionDiscoveryService.ts b/src/node/services/browser/AgentBrowserSessionDiscoveryService.ts index c6498d3d36..cce2c9d3ba 100644 --- a/src/node/services/browser/AgentBrowserSessionDiscoveryService.ts +++ b/src/node/services/browser/AgentBrowserSessionDiscoveryService.ts @@ -203,6 +203,7 @@ export type AgentBrowserDiscoveredSession = | AgentBrowserDiscoveredSessionConnection | AgentBrowserMissingStreamSession; +/** Sessions split by whether their process CWD is inside the active workspace paths. */ export interface AgentBrowserSessionGroups { sessions: AgentBrowserDiscoveredSession[]; otherSessions: AgentBrowserDiscoveredSession[]; diff --git a/src/node/services/browser/BrowserBridgeServer.test.ts b/src/node/services/browser/BrowserBridgeServer.test.ts index 7133f92df1..5e1b208af7 100644 --- a/src/node/services/browser/BrowserBridgeServer.test.ts +++ b/src/node/services/browser/BrowserBridgeServer.test.ts @@ -4,6 +4,7 @@ import { EventEmitter } from "node:events"; import { describe, expect, mock, test } from "bun:test"; import { WebSocket, WebSocketServer, type RawData } from "ws"; import { BrowserBridgeServer } from "./BrowserBridgeServer"; +import type { BrowserBridgeTokenPayload } from "./BrowserBridgeTokenManager"; import type { PageState } from "./BrowserSessionStateHub"; const VALID_TOKEN = "valid-token"; @@ -50,12 +51,7 @@ function createAttachableConnection(sessionName: string, streamPort: number) { function createBridgeServer( options: { - validate?: (token: string) => { - workspaceId: string; - sessionName: string; - streamPort: number; - allowOtherWorkspaceSession: boolean; - } | null; + validate?: (token: string) => BrowserBridgeTokenPayload | null; getSessionConnection?: ( workspaceId: string, sessionName: string, @@ -292,6 +288,38 @@ describe("BrowserBridgeServer", () => { } }); + test("bridges explicit other-workspace tokens on the success path", async () => { + const upstreamHarness = await listenUpstreamServer(); + const getSessionConnection = mock(() => + Promise.resolve(createAttachableConnection(VALID_SESSION_NAME, upstreamHarness.port)) + ); + const bridgeServer = createBridgeServer({ + getSessionConnection, + validate: mock(() => ({ + workspaceId: VALID_WORKSPACE_ID, + sessionName: VALID_SESSION_NAME, + streamPort: upstreamHarness.port, + allowOtherWorkspaceSession: true, + })), + }); + const upgradeHarness = await listenUpgradeServer(bridgeServer); + + const ws = new WebSocket(`ws://127.0.0.1:${upgradeHarness.port}/?token=${VALID_TOKEN}`); + try { + await waitForWebSocketOpen(ws); + await upstreamHarness.connectionPromise; + + expect(getSessionConnection).toHaveBeenCalledWith(VALID_WORKSPACE_ID, VALID_SESSION_NAME, { + allowOtherWorkspaceSession: true, + }); + } finally { + ws.terminate(); + await upgradeHarness.close(); + await bridgeServer.stop(); + await upstreamHarness.close(); + } + }); + test("closes with 4001 for invalid or missing tokens", async () => { const bridgeServer = createBridgeServer({ validate: mock(() => null), diff --git a/src/node/services/browser/BrowserBridgeTokenManager.test.ts b/src/node/services/browser/BrowserBridgeTokenManager.test.ts index 7c3db96b98..6a76216113 100644 --- a/src/node/services/browser/BrowserBridgeTokenManager.test.ts +++ b/src/node/services/browser/BrowserBridgeTokenManager.test.ts @@ -1,9 +1,13 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, setSystemTime } from "bun:test"; import { AssertionError } from "@/common/utils/assert"; import { BrowserBridgeTokenManager } from "./BrowserBridgeTokenManager"; const TOKEN_TTL_MS = 30_000; +afterEach(() => { + setSystemTime(); +}); + describe("BrowserBridgeTokenManager", () => { it("mints a 64-character hex token", () => { const manager = new BrowserBridgeTokenManager(); @@ -64,18 +68,15 @@ describe("BrowserBridgeTokenManager", () => { }); it("returns null for expired tokens", () => { - const originalNow = Date.now; - let nowMs = new Date("2026-01-01T00:00:00.000Z").getTime(); - Date.now = () => nowMs; + setSystemTime(new Date("2026-01-01T00:00:00.000Z")); const manager = new BrowserBridgeTokenManager(); try { const token = manager.mint("workspace-1", "session-a", 9222); - nowMs += TOKEN_TTL_MS + 1; + setSystemTime(Date.now() + TOKEN_TTL_MS + 1); expect(manager.validate(token)).toBeNull(); } finally { manager.dispose(); - Date.now = originalNow; } }); }); diff --git a/src/node/services/browser/BrowserBridgeTokenManager.ts b/src/node/services/browser/BrowserBridgeTokenManager.ts index 89d52d8b48..6678be4880 100644 --- a/src/node/services/browser/BrowserBridgeTokenManager.ts +++ b/src/node/services/browser/BrowserBridgeTokenManager.ts @@ -46,6 +46,7 @@ export class BrowserBridgeTokenManager { "BrowserBridgeTokenManager.mint requires integer streamPort" ); assert(streamPort > 0, "BrowserBridgeTokenManager.mint requires positive streamPort"); + let token = ""; do { token = randomBytes(32).toString("hex"); From 723a5c058434e2f9e06552013a3641f822949852 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 6 May 2026 17:04:04 +0000 Subject: [PATCH 3/7] Address remaining browser review notes --- .../features/RightSidebar/BrowserTab/BrowserTab.tsx | 7 +------ src/node/orpc/router.ts | 2 ++ .../browser/AgentBrowserSessionDiscoveryService.ts | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/browser/features/RightSidebar/BrowserTab/BrowserTab.tsx b/src/browser/features/RightSidebar/BrowserTab/BrowserTab.tsx index b93f8d1ab9..1fbf63078e 100644 --- a/src/browser/features/RightSidebar/BrowserTab/BrowserTab.tsx +++ b/src/browser/features/RightSidebar/BrowserTab/BrowserTab.tsx @@ -101,10 +101,6 @@ function chooseSelectedSession( return currentSessionName; } - if (currentSessionName != null && sessions.length === 0) { - return currentSessionName; - } - return sessions[0]?.sessionName ?? null; } @@ -513,8 +509,7 @@ function BrowserViewerState(props: { if (props.sessionStatus === "error") { return { title: "Browser preview unavailable", - description: - "Mux will keep retrying while a discovered browser session is available for this project.", + description: "Mux will keep retrying while the selected browser session is available.", }; } diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 4a38e19f88..601782effd 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1237,6 +1237,7 @@ export const router = (authToken?: string) => { try { const result = await context.browserControlService.executeControl(input); if (result.success) { + // executeControl already validated the selected session with the explicit scope flag. const urlResult = await context.browserControlService.getUrl( input.workspaceId, input.sessionName, @@ -1259,6 +1260,7 @@ export const router = (authToken?: string) => { return result; } catch (error) { try { + // executeControl already validated the selected session with the explicit scope flag. const urlResult = await context.browserControlService.getUrl( input.workspaceId, input.sessionName, diff --git a/src/node/services/browser/AgentBrowserSessionDiscoveryService.ts b/src/node/services/browser/AgentBrowserSessionDiscoveryService.ts index cce2c9d3ba..0d6d815e66 100644 --- a/src/node/services/browser/AgentBrowserSessionDiscoveryService.ts +++ b/src/node/services/browser/AgentBrowserSessionDiscoveryService.ts @@ -613,7 +613,7 @@ export class AgentBrowserSessionDiscoveryService { const sessions = this.getLookupSessions(sessionGroups, options); const session = sessions.find((candidate) => candidate.sessionName === sessionName) ?? null; if (session == null) { - if (sessions.length === 0) { + if (sessionGroups.sessions.length + sessionGroups.otherSessions.length === 0) { throw new Error( `Session "${sessionName}" is unavailable (no sessions discovered for workspace "${workspaceId}")` ); From 76cbefb266b46bfc9eb1fc47a2f9c49b5ad37c2b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 6 May 2026 17:05:14 +0000 Subject: [PATCH 4/7] Update browser discovery test expectation --- .../browser/AgentBrowserSessionDiscoveryService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/node/services/browser/AgentBrowserSessionDiscoveryService.test.ts b/src/node/services/browser/AgentBrowserSessionDiscoveryService.test.ts index 55e36ca9e9..9cca7d725f 100644 --- a/src/node/services/browser/AgentBrowserSessionDiscoveryService.test.ts +++ b/src/node/services/browser/AgentBrowserSessionDiscoveryService.test.ts @@ -454,7 +454,7 @@ describe("AgentBrowserSessionDiscoveryService", () => { // eslint-disable-next-line @typescript-eslint/await-thenable -- Bun's expect().rejects.toThrow() is thenable at runtime await expect(service.ensureSessionAttachable("workspace-1", "other-nostream")).rejects.toThrow( - 'Session "other-nostream" is unavailable (no sessions discovered for workspace "workspace-1")' + 'Session "other-nostream" not found for workspace "workspace-1"' ); expect( From 1dee3c362b375a1f2ef23449322afa589f9b9f88 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 7 May 2026 07:28:47 +0000 Subject: [PATCH 5/7] Address browser picker review comments --- .../features/RightSidebar/BrowserTab/BrowserTab.test.tsx | 4 ++-- src/browser/features/RightSidebar/BrowserTab/BrowserTab.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/browser/features/RightSidebar/BrowserTab/BrowserTab.test.tsx b/src/browser/features/RightSidebar/BrowserTab/BrowserTab.test.tsx index 1bac0b8d54..7a651f5e8d 100644 --- a/src/browser/features/RightSidebar/BrowserTab/BrowserTab.test.tsx +++ b/src/browser/features/RightSidebar/BrowserTab/BrowserTab.test.tsx @@ -146,12 +146,12 @@ describe("BrowserTab", () => { await waitFor(() => { expect(view.getByText("Select session")).toBeTruthy(); }); - expect(view.queryByText("Other sessions")).toBeNull(); expect(view.getByText("Choose a browser session")).toBeTruthy(); - expect(view.getByText("Select an other session from the picker to connect.")).toBeTruthy(); + expect(view.getByText("Select another session from the picker to connect.")).toBeTruthy(); fireEvent.click(view.getByText("Select session")); + expect(view.getByText("Other sessions")).toBeTruthy(); expect(view.getByText("other-alpha")).toBeTruthy(); expect(view.getByText("/tmp/other-project")).toBeTruthy(); expect(connectMock).not.toHaveBeenCalled(); diff --git a/src/browser/features/RightSidebar/BrowserTab/BrowserTab.tsx b/src/browser/features/RightSidebar/BrowserTab/BrowserTab.tsx index 1fbf63078e..f5245319cb 100644 --- a/src/browser/features/RightSidebar/BrowserTab/BrowserTab.tsx +++ b/src/browser/features/RightSidebar/BrowserTab/BrowserTab.tsx @@ -523,7 +523,7 @@ function BrowserViewerState(props: { if (props.hasOtherSessions) { return { title: "Choose a browser session", - description: "Select an other session from the picker to connect.", + description: "Select another session from the picker to connect.", }; } From ba317f111c2b112a9b345853b1759e99bd3a915e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 7 May 2026 07:52:51 +0000 Subject: [PATCH 6/7] Cover browser picker switch-back behavior --- .../BrowserTab/BrowserTab.test.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/browser/features/RightSidebar/BrowserTab/BrowserTab.test.tsx b/src/browser/features/RightSidebar/BrowserTab/BrowserTab.test.tsx index 7a651f5e8d..cd2eb6edf0 100644 --- a/src/browser/features/RightSidebar/BrowserTab/BrowserTab.test.tsx +++ b/src/browser/features/RightSidebar/BrowserTab/BrowserTab.test.tsx @@ -181,6 +181,41 @@ describe("BrowserTab", () => { expect(view.getByTestId("browser-other-session-other-alpha")).toBeTruthy(); }); + test("can switch from an explicitly selected other session back to a current session", async () => { + listSessionsMock.mockResolvedValue({ + sessions: [createDiscoveredSession({ sessionName: "current-alpha" })], + otherSessions: [ + { + sessionName: "other-alpha", + status: "attachable", + cwd: "/tmp/other-project", + }, + ], + }); + + const view = render(); + + await waitFor(() => { + expect(connectMock).toHaveBeenCalledWith("current-alpha"); + }); + + fireEvent.click(view.getByText("current-alpha")); + fireEvent.click(view.getByTestId("browser-other-session-other-alpha")); + + await waitFor(() => { + expect(connectMock).toHaveBeenCalledWith("other-alpha", { + allowOtherWorkspaceSession: true, + }); + }); + + fireEvent.click(view.getByText("other-alpha")); + fireEvent.click(view.getByTestId("browser-session-current-alpha")); + + await waitFor(() => { + expect(connectMock.mock.calls.at(-1)).toEqual(["current-alpha"]); + }); + }); + test("attaches to an other running session only after selecting it from the picker", async () => { listSessionsMock.mockResolvedValue({ sessions: [], @@ -248,6 +283,14 @@ describe("chooseExplicitOtherSession", () => { ).toBe("other-alpha"); }); + test("clears an explicitly selected other session when only a different other session exists", () => { + expect( + chooseExplicitOtherSession("other-alpha", [ + { sessionName: "other-beta", status: "attachable", cwd: "/tmp/other-project" }, + ]) + ).toBeNull(); + }); + test("clears an explicitly selected other session after discovery loses it", () => { expect(chooseExplicitOtherSession("other-alpha", [])).toBeNull(); }); From be40e555b99ec8932e801be3bede7ec6fb4bfaf8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 7 May 2026 08:04:32 +0000 Subject: [PATCH 7/7] Fix browser picker test matcher --- .../features/RightSidebar/BrowserTab/BrowserTab.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/features/RightSidebar/BrowserTab/BrowserTab.test.tsx b/src/browser/features/RightSidebar/BrowserTab/BrowserTab.test.tsx index cd2eb6edf0..f09da7b336 100644 --- a/src/browser/features/RightSidebar/BrowserTab/BrowserTab.test.tsx +++ b/src/browser/features/RightSidebar/BrowserTab/BrowserTab.test.tsx @@ -212,7 +212,7 @@ describe("BrowserTab", () => { fireEvent.click(view.getByTestId("browser-session-current-alpha")); await waitFor(() => { - expect(connectMock.mock.calls.at(-1)).toEqual(["current-alpha"]); + expect(connectMock).toHaveBeenLastCalledWith("current-alpha"); }); });