fix(terminals): automation runs, CLI tabs and folder-workspace browser tabs follow the owner (#27246)

* fix(terminals): automation runs, CLI tabs and folder-workspace browser tabs follow the owner

- An automation run on a paired server opens its mirrored pane, or asks the
  server to focus it when the mirror has not loaded it yet, instead of
  reporting "Run terminal is unavailable." while the server still runs it.
- This desktop's CLI browser tab create/close/profile requests are refused
  only for a workspace a paired server owns, not whenever a paired server is
  the Active Server.
- browser.tabCreate resolves folder workspaces for server-hosted pages, as it
  already did for client-hosted ones.

* fix(terminals): refuse ambiguous browser owners and name a failed run reveal

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-10 06:03:42 -07:00
committed by GitHub
co-authored by m4air
parent 5fae0fa327
commit b9502e5a14
19 changed files with 600 additions and 103 deletions
@@ -0,0 +1,59 @@
/**
* A server-hosted browser tab for a folder workspace (#21353). Only the client-hosted branch used
* the folder-aware resolver, so `id:folder:…` failed with selector_not_found on the server path.
*/
import { describe, expect, it, vi } from 'vitest'
import type { RuntimeBrowserCommandHost } from './orca-runtime-browser'
vi.mock('electron', () => ({
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
webContents: { fromId: vi.fn() }
}))
vi.mock('../ipc/browser-tab-registration-wait', () => ({
waitForTabRegistration: vi.fn(),
waitForWorktreeTabRegistration: vi.fn()
}))
vi.mock('../browser/browser-session-registry', () => ({
browserSessionRegistry: {
getDefaultProfile: () => ({ id: 'default', partition: 'persist:orca-browser' }),
getProfile: () => ({ id: 'default', partition: 'persist:orca-browser' }),
resolveKnownPartition: () => 'persist:orca-browser'
}
}))
const FOLDER = 'folder:folder-1'
function createHost(overrides: Record<string, unknown>): RuntimeBrowserCommandHost {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test host implements only the members browserTabCreate reads.
return {
resolveWorktreeSelector: vi.fn(async () => {
throw new Error('selector_not_found')
}),
resolveBrowserWorkspace: vi.fn(async () => ({ id: FOLDER })),
getAgentBrowserBridge: () => null,
getAvailableAuthoritativeWindow: vi.fn(() => null),
...overrides
} as unknown as RuntimeBrowserCommandHost
}
describe('browser.tabCreate on a folder workspace', () => {
it('creates a server-hosted page for the folder workspace', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const createTab = vi.fn(async () => ({ browserPageId: 'page-folder' }))
const host = createHost({
getOffscreenBrowserBackend: vi.fn(() => ({ createTab }))
})
await expect(
new RuntimeBrowserCommands(host).browserTabCreate(
{ worktree: `id:${FOLDER}`, url: 'about:blank' },
{ pairedDeviceId: 'device-a', clientKind: 'runtime' }
)
).resolves.toEqual({ browserPageId: 'page-folder' })
expect(host.resolveBrowserWorkspace).toHaveBeenCalledWith(`id:${FOLDER}`)
expect(createTab).toHaveBeenCalledWith(expect.objectContaining({ worktreeId: FOLDER }))
})
})
@@ -39,10 +39,9 @@ export class RuntimeBrowserCommandsWithBrowserTabCreate extends RuntimeBrowserCo
navigation: params.navigation,
clientKind: caller?.clientKind
})
// Why: every placement must accept folder workspaces, not only client-hosted pages (#21353).
const worktree = params.worktree
? params.placement?.kind === 'client'
? await this.host.resolveBrowserWorkspace(params.worktree)
: await this.host.resolveWorktreeSelector(params.worktree)
? await this.host.resolveBrowserWorkspace(params.worktree)
: undefined
const worktreeId = worktree?.id
const sessionPartition = browserSessionRegistry.resolveKnownPartition(params.profileId)
@@ -3,7 +3,6 @@ import type { AutomationRun } from '../../../../shared/automations-types'
import {
automationRunMatchesPaneKey,
buildAutomationRunOpenLayout,
canOpenAutomationRunOpenTarget,
resolveAutomationRunOpenTarget
} from './automation-run-open-target'
@@ -17,6 +16,8 @@ const runLeafLayout = {
expandedLeafId: null
}
const livePtyIds = ['pty-run']
const canOpen = (args: Parameters<typeof resolveAutomationRunOpenTarget>[0]): boolean =>
resolveAutomationRunOpenTarget(args) !== null
function run(overrides: Partial<AutomationRun> = {}): AutomationRun {
return {
@@ -55,7 +56,7 @@ describe('automation run open target', () => {
it('requires exact pane identity before treating a run terminal as openable', () => {
expect(
canOpenAutomationRunOpenTarget({
canOpen({
run: run({ terminalPaneKey: null, terminalPtyId: null }),
terminalTabExists: true,
currentLayout: runLeafLayout,
@@ -63,7 +64,7 @@ describe('automation run open target', () => {
})
).toBe(false)
expect(
canOpenAutomationRunOpenTarget({
canOpen({
run: run({ terminalPaneKey: `other-tab:${leafId}` }),
terminalTabExists: true,
currentLayout: runLeafLayout,
@@ -71,7 +72,7 @@ describe('automation run open target', () => {
})
).toBe(true)
expect(
canOpenAutomationRunOpenTarget({
canOpen({
run: run(),
terminalTabExists: true,
currentLayout: runLeafLayout,
@@ -82,7 +83,7 @@ describe('automation run open target', () => {
it('requires the run PTY to be live for View run', () => {
expect(
canOpenAutomationRunOpenTarget({
canOpen({
run: run(),
terminalTabExists: true,
currentLayout: runLeafLayout,
@@ -90,7 +91,7 @@ describe('automation run open target', () => {
})
).toBe(true)
expect(
canOpenAutomationRunOpenTarget({
canOpen({
run: run(),
terminalTabExists: true,
currentLayout: runLeafLayout,
@@ -115,7 +116,7 @@ describe('automation run open target', () => {
it('rejects a run without an exact PTY identity', () => {
expect(
canOpenAutomationRunOpenTarget({
canOpen({
run: run({ terminalPtyId: null }),
terminalTabExists: true,
currentLayout: runLeafLayout,
@@ -1,7 +1,15 @@
import type { AutomationRun } from '../../../../shared/automations-types'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id'
import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id'
import {
isWebTerminalSurfaceTabId,
toWebTerminalSurfaceTabId
} from '../../../../shared/terminal-surface-id'
import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types'
import { terminalLayoutContainsLeaf } from '../../../../shared/workspace-session-pane-ownership'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { resolveOwner } from '@/lib/resolve-owner'
import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route'
export type AutomationRunPaneTarget = {
tabId: string
@@ -10,9 +18,7 @@ export type AutomationRunPaneTarget = {
ptyId: string
}
export function getAutomationRunOpenTabId(
run: Pick<AutomationRun, 'terminalPaneKey'>
): string | null {
function getAutomationRunOpenTabId(run: Pick<AutomationRun, 'terminalPaneKey'>): string | null {
return parsePaneKey(run.terminalPaneKey ?? '')?.tabId ?? null
}
@@ -56,13 +62,56 @@ export function resolveAutomationRunOpenTarget({
}
}
export function canOpenAutomationRunOpenTarget(args: {
run: AutomationRun
terminalTabExists: boolean
currentLayout: TerminalLayoutSnapshot | null | undefined
livePtyIds: readonly string[]
}): boolean {
return resolveAutomationRunOpenTarget(args) !== null
export type AutomationRunTerminalState = {
hasTerminalTab: (tabId: string) => boolean
terminalLayoutsByTabId: Readonly<Record<string, TerminalLayoutSnapshot | undefined>>
ptyIdsByTabId: Readonly<Record<string, readonly string[] | undefined>>
}
/**
* The run's pane in this client. A run on a paired server names the server's tab, pane and PTY;
* this client shows that pane as a mirrored tab whose PTY id is the server's terminal handle, so
* the server's PTY id cannot be compared and the mirrored pane must carry a PTY from that server.
*/
export function resolveAutomationRunTerminalTarget(
run: AutomationRun,
terminals: AutomationRunTerminalState,
ownerEnvironmentId: string | null
): AutomationRunPaneTarget | null {
const tabId = getAutomationRunOpenTabId(run)
if (!tabId) {
return null
}
const local = resolveAutomationRunOpenTarget({
run,
terminalTabExists: terminals.hasTerminalTab(tabId),
currentLayout: terminals.terminalLayoutsByTabId[tabId],
livePtyIds: terminals.ptyIdsByTabId[tabId] ?? []
})
const parsed = parsePaneKey(run.terminalPaneKey ?? '')
if (local || !ownerEnvironmentId || !parsed || isWebTerminalSurfaceTabId(tabId)) {
return local
}
const mirroredTabId = toWebTerminalSurfaceTabId(tabId)
const layout = terminals.terminalLayoutsByTabId[mirroredTabId]
const mirroredPtyId = layout?.ptyIdsByLeafId?.[parsed.leafId]
if (
!run.terminalPtyId ||
!terminals.hasTerminalTab(mirroredTabId) ||
!layout?.root ||
!mirroredPtyId ||
!terminalLayoutContainsLeaf(layout.root, parsed.leafId) ||
parseRemoteRuntimePtyId(mirroredPtyId)?.environmentId !== ownerEnvironmentId ||
!(terminals.ptyIdsByTabId[mirroredTabId] ?? []).includes(mirroredPtyId)
) {
return null
}
return {
tabId: mirroredTabId,
paneKey: makePaneKey(mirroredTabId, parsed.leafId),
leafId: parsed.leafId,
ptyId: mirroredPtyId
}
}
export function buildAutomationRunOpenLayout({
@@ -82,3 +131,18 @@ export function buildAutomationRunOpenLayout({
}
}
}
/** The paired server that owns the run's workspace, or null when this client runs it itself. */
export function getAutomationRunOwnerEnvironmentId(
state: WorktreeOperationRouteState,
workspaceId: string | null | undefined,
hostId?: ExecutionHostId
): string | null {
if (!workspaceId) {
return null
}
const owner = resolveOwner(state, { workspaceId, ...(hostId ? { hostId } : {}) })
return owner.kind === 'resolved' && owner.owner.endpoint.kind === 'environment'
? owner.owner.endpoint.environmentId
: null
}
@@ -51,11 +51,14 @@ export function canRerunAutomationRun({
export function getAutomationRunViewState({
run,
workspaceExists,
terminalTargetExists
terminalTargetExists,
terminalOnPairedServer = false
}: {
run: AutomationRun
workspaceExists: boolean
terminalTargetExists: boolean
/** The workspace's paired server holds the terminal; only it can say whether it still lives. */
terminalOnPairedServer?: boolean
}): AutomationRunViewState {
const hasTerminalIdentity = Boolean(run.terminalPaneKey && run.terminalPtyId)
if (run.workspaceId && workspaceExists && terminalTargetExists) {
@@ -67,6 +70,15 @@ export function getAutomationRunViewState({
}
}
if (run.workspaceId && workspaceExists && hasTerminalIdentity && terminalOnPairedServer) {
return {
availability: 'terminal',
actionLabel: 'View run',
statusLabel: 'Run terminal is on the paired server.',
canOpen: true
}
}
if (run.workspaceId && workspaceExists && hasTerminalIdentity) {
return {
availability: 'terminal',
@@ -0,0 +1,145 @@
/**
* A run that executed on a paired server (#21213) names the server's tab, pane and PTY. This client
* shows that pane as a mirrored tab keyed by the server's terminal handle, so matching the server's
* PTY id against local state always failed and the run read "Run terminal is unavailable.".
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AutomationRun } from '../../../../shared/automations-types'
import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id'
import { toWebTerminalSurfaceTabId } from '../../../../shared/terminal-surface-id'
const mocks = vi.hoisted(() => {
const state: Record<string, unknown> = {}
return {
state,
toastError: vi.fn(),
toastMessage: vi.fn(),
activateAndRevealWorktree: vi.fn(() => true),
activateWebRuntimeSessionTab: vi.fn(async () => true),
activateTerminalTabOnOwner: vi.fn()
}
})
vi.mock('sonner', () => ({ toast: { error: mocks.toastError, message: mocks.toastMessage } }))
vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } }))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: mocks.activateAndRevealWorktree
}))
vi.mock('@/runtime/web-runtime-session', () => ({
activateWebRuntimeSessionTab: mocks.activateWebRuntimeSessionTab
}))
vi.mock('@/lib/terminal-tab-owner-activation', () => ({
activateTerminalTabOnOwner: mocks.activateTerminalTabOnOwner
}))
import { createAutomationRunWorkspaceAction } from './automation-run-workspace-action'
import type { AutomationsPageActionContext } from './automations-page-action-context'
const ENV = 'env-a'
const WT = 'repo-a::/srv/app'
const LEAF = '11111111-1111-4111-8111-111111111111'
const HOST_TAB = 'host-tab-1'
const MIRRORED_TAB = toWebTerminalSurfaceTabId(HOST_TAB)
const MIRRORED_PTY = toRemoteRuntimePtyId('term-handle-1', ENV)
const worktree = { id: WT, repoId: 'repo-a', hostId: `runtime:${ENV}` as const }
const run: AutomationRun = {
id: 'run-1',
automationId: 'automation-1',
title: 'Run 1',
scheduledFor: 1,
status: 'completed',
trigger: 'manual',
workspaceId: WT,
workspaceDisplayName: 'app',
sessionKind: 'terminal',
chatSessionId: null,
terminalSessionId: HOST_TAB,
terminalPaneKey: `${HOST_TAB}:${LEAF}`,
// The server's own PTY id, which this client never sees.
terminalPtyId: 'pty-on-server-7',
outputSnapshot: null,
precheckResult: null,
usage: null,
error: null,
startedAt: 1,
dispatchedAt: 1,
createdAt: 1
}
function storeState(mirrored: boolean): Record<string, unknown> {
return {
// Focus is on this computer: owner routing must not depend on it.
settings: { activeRuntimeEnvironmentId: null },
activeWorktreeId: null,
repos: [{ id: 'repo-a', executionHostId: `runtime:${ENV}` }],
worktreesByRepo: { 'repo-a': [{ ...worktree, runtimeOwnerEnvironmentId: ENV }] },
getTab: (tabId: string) => (mirrored && tabId === MIRRORED_TAB ? { id: tabId } : undefined),
terminalLayoutsByTabId: mirrored
? {
[MIRRORED_TAB]: {
root: { type: 'leaf', leafId: LEAF },
activeLeafId: LEAF,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF]: MIRRORED_PTY }
}
}
: {},
ptyIdsByTabId: mirrored ? { [MIRRORED_TAB]: [MIRRORED_PTY] } : {},
unifiedTabsByWorktree: {},
groupsByWorktree: {},
activeGroupIdByWorktree: {},
setTabLayout: vi.fn(),
setActiveTab: vi.fn(),
setActiveTabType: vi.fn()
}
}
function openRun(): void {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the action reads only these members.
const context = {
store: { repoForRow: () => undefined, worktreeForRow: () => worktree },
list: { selectedRow: { key: 'row' } }
} as unknown as AutomationsPageActionContext
createAutomationRunWorkspaceAction(context)(run)
}
describe('opening a paired-server automation run', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('opens the mirrored pane of the run', () => {
mocks.state = storeState(true)
openRun()
expect(mocks.toastError).not.toHaveBeenCalled()
expect(mocks.state.setActiveTab).toHaveBeenCalledWith(MIRRORED_TAB)
expect(mocks.activateTerminalTabOnOwner).toHaveBeenCalledWith(WT, MIRRORED_TAB, LEAF)
})
it('asks the server to open the pane when the mirror has not loaded it yet', async () => {
mocks.state = storeState(false)
openRun()
await vi.waitFor(() => expect(mocks.activateWebRuntimeSessionTab).toHaveBeenCalled())
expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith(WT)
expect(mocks.activateWebRuntimeSessionTab).toHaveBeenCalledWith(
expect.objectContaining({ worktreeId: WT, tabId: HOST_TAB, environmentId: ENV, leafId: LEAF })
)
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('says the terminal is unavailable only when the server has no such pane', async () => {
mocks.state = storeState(false)
mocks.activateWebRuntimeSessionTab.mockResolvedValueOnce(false)
openRun()
await vi.waitFor(() =>
expect(mocks.toastError).toHaveBeenCalledWith('Run terminal is unavailable.')
)
})
})
@@ -3,10 +3,14 @@ import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { useAppStore } from '@/store'
import { activateTerminalTabOnOwner } from '@/lib/terminal-tab-owner-activation'
import { activateWebRuntimeSessionTab } from '@/runtime/web-runtime-session'
import { resolveWebSessionVisibleTabId } from '@/runtime/web-session-focus-intent'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import {
buildAutomationRunOpenLayout,
getAutomationRunOpenTabId,
resolveAutomationRunOpenTarget
getAutomationRunOwnerEnvironmentId,
resolveAutomationRunTerminalTarget
} from './automation-run-open-target'
import { getAutomationRunViewState } from './automation-run-view-state'
import type { AutomationsPageActionContext } from './automations-page-action-context'
@@ -21,27 +25,43 @@ export function createAutomationRunWorkspaceAction({ store, list }: AutomationsP
? (worktreeForRow(selectedRow, repoForRow(selectedRow), run.workspaceId) ?? null)
: null
const appStore = useAppStore.getState()
const openTabId = getAutomationRunOpenTabId(run)
const terminalTabExists = openTabId ? Boolean(appStore.getTab(openTabId)) : false
const currentLayout = openTabId ? appStore.terminalLayoutsByTabId[openTabId] : null
const livePtyIds = openTabId ? (appStore.ptyIdsByTabId[openTabId] ?? []) : []
const terminalTarget = resolveAutomationRunOpenTarget({
const ownerEnvironmentId = runWorktree
? getAutomationRunOwnerEnvironmentId(appStore, run.workspaceId, runWorktree.hostId)
: null
const terminalTarget = resolveAutomationRunTerminalTarget(
run,
terminalTabExists,
currentLayout,
livePtyIds
})
{
hasTerminalTab: (tabId) => Boolean(appStore.getTab(tabId)),
terminalLayoutsByTabId: appStore.terminalLayoutsByTabId,
ptyIdsByTabId: appStore.ptyIdsByTabId
},
ownerEnvironmentId
)
const currentLayout = terminalTarget
? appStore.terminalLayoutsByTabId[terminalTarget.tabId]
: null
const runViewState = getAutomationRunViewState({
run,
workspaceExists: Boolean(runWorktree),
terminalTargetExists: terminalTarget !== null
terminalTargetExists: terminalTarget !== null,
terminalOnPairedServer: ownerEnvironmentId !== null
})
if (!run.workspaceId || !runWorktree || !runViewState.canOpen) {
toast.error(runViewState.statusLabel)
return
}
const paneRef = parsePaneKey(run.terminalPaneKey ?? '')
if (runViewState.availability === 'terminal' && !terminalTarget) {
toast.error(runViewState.statusLabel)
if (!ownerEnvironmentId || !paneRef) {
toast.error(runViewState.statusLabel)
return
}
if (!activateAndRevealWorktree(run.workspaceId)) {
toast.error(workspaceUnavailableMessage())
return
}
// Why: the mirror may not hold the pane yet; the server focuses it when it arrives.
void openRunTerminalOnOwner(run.workspaceId, ownerEnvironmentId, paneRef)
return
}
if (terminalTarget && currentLayout) {
@@ -52,20 +72,42 @@ export function createAutomationRunWorkspaceAction({ store, list }: AutomationsP
if (activateAndRevealWorktree(run.workspaceId)) {
appStore.setActiveTab(terminalTarget.tabId)
appStore.setActiveTabType('terminal', run.workspaceId)
activateTerminalTabOnOwner(run.workspaceId, terminalTarget.tabId, terminalTarget.leafId)
return
}
}
if (!activateAndRevealWorktree(run.workspaceId)) {
toast.error(
translate(
'auto.components.automations.AutomationsPage.e1bf9b1512',
'Workspace is not available.'
)
)
toast.error(workspaceUnavailableMessage())
return
}
toast.message(runViewState.statusLabel)
}
}
function workspaceUnavailableMessage(): string {
return translate(
'auto.components.automations.AutomationsPage.e1bf9b1512',
'Workspace is not available.'
)
}
async function openRunTerminalOnOwner(
worktreeId: string,
environmentId: string,
paneRef: { tabId: string; leafId: string }
): Promise<void> {
const opened = await activateWebRuntimeSessionTab({
worktreeId,
tabId: paneRef.tabId,
environmentId,
leafId: paneRef.leafId,
expectedCurrentLocalTabId: resolveWebSessionVisibleTabId(useAppStore.getState(), worktreeId)
})
if (!opened) {
toast.error(
translate('components.automations.runTerminalUnavailable', 'Run terminal is unavailable.')
)
}
}
export type AutomationRunWorkspaceAction = ReturnType<typeof createAutomationRunWorkspaceAction>
@@ -2,9 +2,10 @@ import { useEffect, useMemo } from 'react'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { getAutomationHostTargetKey, getAutomationTargetFromHostId } from './automation-host-client'
import { useAppStore } from '@/store'
import {
canOpenAutomationRunOpenTarget,
getAutomationRunOpenTabId
getAutomationRunOwnerEnvironmentId,
resolveAutomationRunTerminalTarget
} from './automation-run-open-target'
import { canRerunAutomationRun, getAutomationRunViewState } from './automation-run-view-state'
import { getAutomationRunWorkspaceDisplay } from './automation-run-workspace-display'
@@ -213,25 +214,30 @@ export function useAutomationRunPageState({
worktree: selectedAutomationRunPageWorktree
})
: null
const selectedAutomationRunPageOpenTabId = selectedAutomationRunPage
? getAutomationRunOpenTabId(selectedAutomationRunPage)
: null
const selectedAutomationRunPageOwnerEnvironmentId = useAppStore((state) =>
selectedAutomationRunPageWorktree
? getAutomationRunOwnerEnvironmentId(
state,
selectedAutomationRunPage?.workspaceId,
selectedAutomationRunPageWorktree.hostId
)
: null
)
const selectedAutomationRunPageViewState = selectedAutomationRunPage
? getAutomationRunViewState({
run: selectedAutomationRunPage,
workspaceExists: Boolean(selectedAutomationRunPageWorktree),
terminalTargetExists: canOpenAutomationRunOpenTarget({
run: selectedAutomationRunPage,
terminalTabExists: selectedAutomationRunPageOpenTabId
? activeTerminalTabIds.has(selectedAutomationRunPageOpenTabId)
: false,
currentLayout: selectedAutomationRunPageOpenTabId
? terminalLayoutsByTabId[selectedAutomationRunPageOpenTabId]
: null,
livePtyIds: selectedAutomationRunPageOpenTabId
? (ptyIdsByTabId[selectedAutomationRunPageOpenTabId] ?? [])
: []
})
terminalTargetExists:
resolveAutomationRunTerminalTarget(
selectedAutomationRunPage,
{
hasTerminalTab: (tabId) => activeTerminalTabIds.has(tabId),
terminalLayoutsByTabId,
ptyIdsByTabId
},
selectedAutomationRunPageOwnerEnvironmentId
) !== null,
terminalOnPairedServer: selectedAutomationRunPageOwnerEnvironmentId !== null
})
: null
const canRerunSelectedAutomationRunPage =
@@ -126,7 +126,7 @@ export function installAppLifetimeIpcEvents(
registerUpdaterStatusIpcBridge(unsubs)
registerBrowserStateIpcBridge(unsubs, isRuntimeEnvironmentActive)
registerContentCreationIpcBridge(unsubs, isRuntimeEnvironmentActive)
registerBrowserRequestIpcBridge(unsubs, isRuntimeEnvironmentActive)
registerBrowserRequestIpcBridge(unsubs)
registerTabLifecycleIpcBridge(unsubs)
registerRateLimitIpcBridge(unsubs)
registerDirectSshStateIpcBridge(unsubs, directSshRuntime)
@@ -0,0 +1,135 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
type CreateRequest = { requestId: string; worktreeId?: string; url: string }
type CloseRequest = { requestId: string; tabId?: string | null; worktreeId?: string }
const mocks = vi.hoisted(() => {
const listeners: {
createListener: ((data: CreateRequest) => void) | null
closeListener: ((data: CloseRequest) => void) | null
} = { createListener: null, closeListener: null }
return {
...listeners,
getState: vi.fn(),
replyTabCreate: vi.fn(),
replyTabClose: vi.fn(),
createBrowserTab: vi.fn(),
closeBrowserTab: vi.fn()
}
})
vi.mock('@/components/browser-pane/host-guest/webview-registry', () => ({
destroyPersistentWebview: vi.fn()
}))
vi.mock('../../store', () => ({ useAppStore: { getState: mocks.getState } }))
vi.mock('./browser-automation-bootstrap-lease', () => ({
acquireBrowserAutomationBootstrapLease: vi.fn()
}))
vi.mock('../../store/pinned-tab-close-guard', () => ({
guardPinnedTabClose: ({ onClose }: { onClose: () => void }) => onClose(),
isUnifiedTabPinned: vi.fn(() => false),
resolvePinnedTabLabel: vi.fn()
}))
import { registerBrowserRequestIpcBridge } from './browser-request-ipc-bridge'
const LOCAL_WT = 'repo-local::/Users/me/app'
const REMOTE_WT = 'repo-remote::/srv/app'
function storeState(activeWorktreeId: string) {
return {
// The Active Server is a paired server; neither request below may read it.
settings: { activeRuntimeEnvironmentId: 'env-remote' },
activeWorktreeId,
repos: [
{ id: 'repo-local', executionHostId: 'local' },
{ id: 'repo-remote', executionHostId: 'runtime:env-remote' }
],
worktreesByRepo: {
'repo-local': [{ id: LOCAL_WT, repoId: 'repo-local', hostId: 'local' }],
'repo-remote': [
{
id: REMOTE_WT,
repoId: 'repo-remote',
hostId: 'runtime:env-remote',
runtimeOwnerEnvironmentId: 'env-remote'
}
]
},
browserTabsByWorktree: {
[LOCAL_WT]: [{ id: 'ws-local' }],
[REMOTE_WT]: [{ id: 'ws-remote' }]
},
browserPagesByWorkspace: {
'ws-local': [{ id: 'page-local' }],
'ws-remote': [{ id: 'page-remote' }]
},
activeBrowserTabIdByWorktree: {},
unifiedTabsByWorktree: {},
createBrowserTab: mocks.createBrowserTab.mockImplementation(() => ({ id: 'ws-new' })),
closeBrowserTab: mocks.closeBrowserTab,
closeBrowserPage: vi.fn()
}
}
describe('browser requests from this desktop follow the workspace owner (#22275)', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(window, 'api', {
configurable: true,
value: {
ui: {
onRequestTabCreate: (listener: typeof mocks.createListener) => {
mocks.createListener = listener
return () => {}
},
replyTabCreate: mocks.replyTabCreate,
onRequestTabSetProfile: () => () => {},
replyTabSetProfile: vi.fn(),
onRequestTabClose: (listener: typeof mocks.closeListener) => {
mocks.closeListener = listener
return () => {}
},
replyTabClose: mocks.replyTabClose
}
}
})
registerBrowserRequestIpcBridge([])
})
it('creates a tab for an explicit local worktree while a paired server is the Active Server', () => {
mocks.getState.mockReturnValue(storeState(REMOTE_WT))
mocks.createListener?.({ requestId: 'r1', worktreeId: LOCAL_WT, url: 'about:blank' })
expect(mocks.createBrowserTab).toHaveBeenCalledWith(LOCAL_WT, 'about:blank', expect.anything())
expect(mocks.replyTabCreate).toHaveBeenCalledWith({ requestId: 'r1', browserPageId: 'ws-new' })
})
it("refuses to fall back to a paired server's active workspace", () => {
mocks.getState.mockReturnValue(storeState(REMOTE_WT))
mocks.createListener?.({ requestId: 'r2', url: 'about:blank' })
expect(mocks.createBrowserTab).not.toHaveBeenCalled()
expect(mocks.replyTabCreate).toHaveBeenCalledWith({
requestId: 'r2',
error: expect.stringContaining('paired server')
})
})
it("closes a local page but refuses a paired server's page", () => {
mocks.getState.mockReturnValue(storeState(LOCAL_WT))
mocks.closeListener?.({ requestId: 'c1', tabId: 'ws-local', worktreeId: LOCAL_WT })
mocks.closeListener?.({ requestId: 'c2', tabId: 'page-remote' })
expect(mocks.closeBrowserTab).toHaveBeenCalledExactlyOnceWith('ws-local')
expect(mocks.replyTabClose).toHaveBeenCalledWith({ requestId: 'c1' })
expect(mocks.replyTabClose).toHaveBeenCalledWith({
requestId: 'c2',
error: expect.stringContaining('paired server')
})
})
})
@@ -75,7 +75,7 @@ describe('browser profile request teardown', () => {
})
it('keeps document-preview guests while rebuilding URL siblings for a profile change', () => {
registerBrowserRequestIpcBridge([], () => false)
registerBrowserRequestIpcBridge([])
mocks.profileListener?.({
requestId: 'request-1',
@@ -7,25 +7,24 @@ import {
import { translate } from '@/i18n/i18n'
import { useAppStore } from '../../store'
import { acquireBrowserAutomationBootstrapLease } from './browser-automation-bootstrap-lease'
import {
findBrowserTabWorktreeId,
isBrowserWorkspaceOwnedByPairedServer
} from './browser-request-workspace-owner'
export function registerBrowserRequestIpcBridge(
unsubs: (() => void)[],
isRuntimeEnvironmentActive: () => boolean
): void {
function pairedServerWorkspaceError(): string {
return translate(
'browser.request.pairedServerWorkspace',
"This workspace runs on a paired server. Use that server's browser tabs."
)
}
// Why owner, not the Active Server setting: these requests come from this desktop's own runtime,
// which only names workspaces it runs, so focus on a paired server must not refuse them (#22275).
export function registerBrowserRequestIpcBridge(unsubs: (() => void)[]): void {
unsubs.push(
window.api.ui.onRequestTabCreate((data) => {
try {
if (isRuntimeEnvironmentActive()) {
// Why: browser automation targets client-local Electron webviews that runtime agents can't see or control.
window.api.ui.replyTabCreate({
requestId: data.requestId,
error: translate(
'auto.hooks.useIpcEvents.291c8ed902',
'Browser tabs are unavailable while a remote runtime is active'
)
})
return
}
const store = useAppStore.getState()
const worktreeId = data.worktreeId ?? store.activeWorktreeId
if (!worktreeId) {
@@ -35,6 +34,14 @@ export function registerBrowserRequestIpcBridge(
})
return
}
// Why only the fallback: main resolved an explicit id against its own catalog already.
if (!data.worktreeId && isBrowserWorkspaceOwnedByPairedServer(store, worktreeId)) {
window.api.ui.replyTabCreate({
requestId: data.requestId,
error: pairedServerWorkspaceError()
})
return
}
// Why: CLI-created tabs should land in the active browser tab's group, not the terminal's UI-active group.
const activeBrowserTabId = store.activeBrowserTabIdByWorktree[worktreeId]
const activeBrowserUnifiedTab = activeBrowserTabId
@@ -72,17 +79,15 @@ export function registerBrowserRequestIpcBridge(
unsubs.push(
window.api.ui.onRequestTabSetProfile((data) => {
try {
if (isRuntimeEnvironmentActive()) {
const store = useAppStore.getState()
const pageWorktreeId = findBrowserTabWorktreeId(store, data.browserPageId)
if (pageWorktreeId && isBrowserWorkspaceOwnedByPairedServer(store, pageWorktreeId)) {
window.api.ui.replyTabSetProfile({
requestId: data.requestId,
error: translate(
'auto.hooks.useIpcEvents.f45fa2b03c',
'Browser profiles are unavailable while a remote runtime is active'
)
error: pairedServerWorkspaceError()
})
return
}
const store = useAppStore.getState()
const owningWorkspace = Object.values(store.browserTabsByWorktree)
.flat()
.find((workspace) => {
@@ -129,16 +134,6 @@ export function registerBrowserRequestIpcBridge(
unsubs.push(
window.api.ui.onRequestTabClose((data) => {
try {
if (isRuntimeEnvironmentActive()) {
window.api.ui.replyTabClose({
requestId: data.requestId,
error: translate(
'auto.hooks.useIpcEvents.291c8ed902',
'Browser tabs are unavailable while a remote runtime is active'
)
})
return
}
const store = useAppStore.getState()
const explicitTargetId = data.tabId ?? null
const replyBrowserTabNotFound = (tabId: string): void => {
@@ -186,6 +181,14 @@ export function registerBrowserRequestIpcBridge(
})
return
}
const closeWorktreeId = findBrowserTabWorktreeId(store, tabToClose)
if (closeWorktreeId && isBrowserWorkspaceOwnedByPairedServer(store, closeWorktreeId)) {
window.api.ui.replyTabClose({
requestId: data.requestId,
error: pairedServerWorkspaceError()
})
return
}
// Why: the bridge keys tabs by browserPageId, but closeBrowserTab expects a workspace id.
// Per the CLI's `tab close --page` contract, close only that page unless it is the last in its workspace.
const isWorkspaceId = Object.values(store.browserTabsByWorktree)
@@ -0,0 +1,37 @@
import { resolveOwner } from '@/lib/resolve-owner'
import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route'
import type { AppState } from '../../store/types'
/**
* Whether a paired server owns (or may own) `worktreeId`. This desktop's runtime must not create or close browser
* tabs there: that server's own page registry and snapshot are authoritative for them.
*/
export function isBrowserWorkspaceOwnedByPairedServer(
state: WorktreeOperationRouteState,
worktreeId: string
): boolean {
const owner = resolveOwner(state, { workspaceId: worktreeId })
// Why ambiguous refuses: rows on several hosts cannot prove this desktop owns the tab.
return (
owner.kind === 'ambiguous' ||
(owner.kind === 'resolved' && owner.owner.endpoint.kind === 'environment')
)
}
/** The workspace holding a browser tab, addressed by its tab (workspace) id or one of its page ids. */
export function findBrowserTabWorktreeId(
state: Pick<AppState, 'browserTabsByWorktree' | 'browserPagesByWorkspace'>,
tabOrPageId: string
): string | null {
for (const [worktreeId, tabs] of Object.entries(state.browserTabsByWorktree)) {
for (const tab of tabs) {
if (
tab.id === tabOrPageId ||
(state.browserPagesByWorkspace[tab.id] ?? []).some((page) => page.id === tabOrPageId)
) {
return worktreeId
}
}
}
return null
}
+6 -2
View File
@@ -154,6 +154,9 @@
"description": "Per-profile user-agent settings were removed. Choose Cleaned or Native in Browser settings; changes take effect after a restart.",
"title": "Browser identity is now app-wide",
"openSettings": "Open Settings"
},
"request": {
"pairedServerWorkspace": "This workspace runs on a paired server. Use that server's browser tabs."
}
},
"editor": {
@@ -1205,8 +1208,6 @@
"0e3cf53060": "Browser tab {{value0}} not found",
"2f6637fe6c": "Browser tab {{value0}} is pinned",
"a8d2bf8e9e": "No active browser tab to close",
"291c8ed902": "Browser tabs are unavailable while a remote runtime is active",
"f45fa2b03c": "Browser profiles are unavailable while a remote runtime is active",
"f000b2ff76": "No active worktree",
"56d3ec4203": "Failed to create untitled markdown file.",
"f6300deb8b": "New Browser Tab",
@@ -19079,6 +19080,9 @@
"important": "Important",
"warning": "Warning",
"caution": "Caution"
},
"automations": {
"runTerminalUnavailable": "Run terminal is unavailable."
}
},
"dashboardPopout": {
-2
View File
@@ -1037,8 +1037,6 @@
"0e3cf53060": "Pestaña del navegador {{value0}} no encontrada",
"2f6637fe6c": "La pestaña del navegador {{value0}} está fijada",
"a8d2bf8e9e": "No hay ninguna pestaña activa del navegador para cerrar",
"291c8ed902": "Las pestañas del navegador no están disponibles mientras haya un host remoto activo",
"f45fa2b03c": "Los perfiles del navegador no están disponibles mientras haya un host remoto activo",
"f000b2ff76": "No hay ningún worktree activo",
"56d3ec4203": "No se pudo crear el archivo Markdown sin título.",
"f6300deb8b": "Nueva pestaña del navegador",
-2
View File
@@ -1138,8 +1138,6 @@
"0e3cf53060": "Onglet de navigateur {{value0}} introuvable",
"2f6637fe6c": "L'onglet de navigateur {{value0}} est épinglé",
"a8d2bf8e9e": "Aucun onglet de navigateur actif à fermer",
"291c8ed902": "Les onglets de navigateur sont indisponibles tant qu'un runtime distant est actif",
"f45fa2b03c": "Les profils de navigateur sont indisponibles tant qu'un runtime distant est actif",
"f000b2ff76": "Aucun worktree actif",
"56d3ec4203": "Échec de la création du fichier markdown sans titre.",
"f6300deb8b": "Nouvel onglet de navigateur",
-2
View File
@@ -1037,8 +1037,6 @@
"0e3cf53060": "ブラウザタブ {{value0}} が見つかりません",
"2f6637fe6c": "ブラウザタブ {{value0}} はピン留めされています",
"a8d2bf8e9e": "閉じるためのアクティブなブラウザタブがありません",
"291c8ed902": "リモートランタイムがアクティブな間はブラウザタブは使用できません",
"f45fa2b03c": "リモートランタイムがアクティブな間はブラウザプロファイルを使用できません",
"f000b2ff76": "アクティブなワークツリーがありません",
"56d3ec4203": "無題の markdown ファイルの作成に失敗しました。",
"f6300deb8b": "新規ブラウザタブ",
-2
View File
@@ -1037,8 +1037,6 @@
"0e3cf53060": "브라우저 탭 {{value0}}을(를) 찾을 수 없습니다",
"2f6637fe6c": "브라우저 탭 {{value0}}이(가) 고정되어 있습니다",
"a8d2bf8e9e": "닫을 활성 브라우저 탭이 없습니다.",
"291c8ed902": "원격 런타임이 활성화된 동안에는 브라우저 탭을 사용할 수 없습니다.",
"f45fa2b03c": "원격 런타임이 활성화된 동안에는 브라우저 프로필을 사용할 수 없습니다.",
"f000b2ff76": "활성 작업 트리 없음",
"56d3ec4203": "제목 없는 markdown 파일을 생성하지 못했습니다.",
"f6300deb8b": "새 브라우저 탭",
-2
View File
@@ -1037,8 +1037,6 @@
"0e3cf53060": "未找到浏览器选项卡 {{value0}}",
"2f6637fe6c": "浏览器选项卡 {{value0}} 已固定",
"a8d2bf8e9e": "没有可关闭的活动浏览器选项卡",
"291c8ed902": "当远程运行时处于活动状态时,浏览器选项卡不可用",
"f45fa2b03c": "当远程运行时处于活动状态时,浏览器配置文件不可用",
"f000b2ff76": "没有活动的工作树",
"56d3ec4203": "无法创建无标题 Markdown 文件。",
"f6300deb8b": "新浏览器选项卡",