From 0ba67e11819e5a35c2a9569a04c41d09893198c1 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 02:09:10 -0700 Subject: [PATCH] fix(mobile): agents for workspaces another runtime owns show why (#27196) * fix(mobile): agents for workspaces another runtime owns show why The paired host no longer lists its own agents for a workspace another Orca server owns: preflight.detectAgents refuses with workspace_on_other_runtime, and the phone shows 'Agents on Another Server - Pair that server directly'. The phone also recognises an unambiguous runtime-owned repo row itself, so an older host is never asked. * fix(mobile): a shared repo id with a runtime-owned row defers to a refusing host Rows on several hosts can share a repo id, and the first row's SSH connection could name the paired host's own target. When any row is runtime-owned the phone now asks a host that advertises preflight.other-runtime-refusal.v1 to decide by worktree, and shows the other-server row on an older host. * fix(mobile): keep the agent-loader change inside the web bundle budget Move the refusal code beside its capability in protocol-version, read the capability in the loader, and test the runtime: prefix directly: importing execution-host into the loader split a web bundle chunk (129 > 128 assets). --------- Co-authored-by: m4air --- .../mobile-new-tab-agent-loader.test.ts | 96 ++++++++++++++++++- .../session/mobile-new-tab-agent-loader.ts | 73 ++++++++++++-- .../session/mobile-session-read-operations.ts | 6 +- .../src/session/mobile-session-route-types.ts | 3 +- mobile/src/session/pr-ai-triage-launch.ts | 11 ++- .../session/use-mobile-session-attachments.ts | 26 ++++- ...use-mobile-session-panel-route-actions.tsx | 55 ++++++----- .../workspace-agent-detection.test.ts | 40 ++++++++ .../preflight/workspace-agent-detection.ts | 12 ++- src/shared/protocol-version.ts | 7 ++ 10 files changed, 286 insertions(+), 43 deletions(-) diff --git a/mobile/src/session/mobile-new-tab-agent-loader.test.ts b/mobile/src/session/mobile-new-tab-agent-loader.test.ts index b9f5d9e174e..2adf94c8b9b 100644 --- a/mobile/src/session/mobile-new-tab-agent-loader.test.ts +++ b/mobile/src/session/mobile-new-tab-agent-loader.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { FLOATING_WORKSPACE_WORKTREE_ID } from './floating-workspace' -import { loadMobileNewTabAgentOptions } from './mobile-new-tab-agent-loader' +import { WORKSPACE_ON_OTHER_RUNTIME } from '../../../src/shared/protocol-version' +import { + loadMobileNewTabAgentOptions, + MobileWorkspaceOnOtherRuntimeError +} from './mobile-new-tab-agent-loader' function createClient( handler: (method: string, params?: unknown) => Promise @@ -69,4 +73,94 @@ describe('mobile new-tab agent loading', () => { 'preflight.detectRemoteAgents' ]) }) + + it('#13752: names another runtime instead of listing the paired host for its workspace', async () => { + const client = createClient(async (method) => { + if (method === 'settings.get') { + return { ok: true, result: { settings: {} } } + } + if (method === 'repo.list') { + return { + ok: true, + result: { + repos: [{ id: 'repo-1', executionHostId: 'runtime:env-b', connectionId: 'b-ssh' }] + } + } + } + throw new Error(`unexpected request: ${method}`) + }) + + await expect( + loadMobileNewTabAgentOptions({ client, worktreeId: 'repo-1::/srv/worktree' }) + ).rejects.toBeInstanceOf(MobileWorkspaceOnOtherRuntimeError) + // Neither the paired host nor its SSH targets are probed for a workspace it does not own. + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'repo.list', + 'settings.get' + ]) + }) + + // Rows on two hosts share the id; the SSH row first, so its connection would name this host's target. + function sharedRepoIdClient(detectAgents: () => unknown) { + return createClient(async (method, params) => { + if (method === 'settings.get') { + return { ok: true, result: { settings: {} } } + } + if (method === 'repo.list') { + return { + ok: true, + result: { + repos: [ + { id: 'repo-1', executionHostId: 'ssh:a-target', connectionId: 'a-target' }, + { id: 'repo-1', executionHostId: 'runtime:env-b' } + ] + } + } + } + if (method === 'preflight.detectAgents') { + expect(params).toEqual({ worktreeId: 'repo-1::/srv/worktree' }) + return detectAgents() + } + throw new Error(`unexpected request: ${method}`) + }) + } + + it('#13752: lets a refusing host decide a shared repo id, and reads its refusal', async () => { + const refused = sharedRepoIdClient(() => ({ + ok: false, + error: { code: 'runtime_error', message: WORKSPACE_ON_OTHER_RUNTIME } + })) + await expect( + loadMobileNewTabAgentOptions({ + client: refused, + worktreeId: 'repo-1::/srv/worktree', + hostRefusesOtherRuntime: true + }) + ).rejects.toBeInstanceOf(MobileWorkspaceOnOtherRuntimeError) + + const answered = sharedRepoIdClient(() => ({ ok: true, result: ['claude'] })) + await expect( + loadMobileNewTabAgentOptions({ + client: answered, + worktreeId: 'repo-1::/srv/worktree', + hostRefusesOtherRuntime: true + }) + ).resolves.toEqual([{ agent: 'claude', label: 'Claude' }]) + expect(answered.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'repo.list', + 'settings.get', + 'preflight.detectAgents' + ]) + }) + + it('#13752: an older host cannot decide a shared repo id, so nothing is probed', async () => { + const client = sharedRepoIdClient(() => ({ ok: true, result: ['codex'] })) + await expect( + loadMobileNewTabAgentOptions({ client, worktreeId: 'repo-1::/srv/worktree' }) + ).rejects.toBeInstanceOf(MobileWorkspaceOnOtherRuntimeError) + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'repo.list', + 'settings.get' + ]) + }) }) diff --git a/mobile/src/session/mobile-new-tab-agent-loader.ts b/mobile/src/session/mobile-new-tab-agent-loader.ts index 596ed118f5f..6b2bef916d6 100644 --- a/mobile/src/session/mobile-new-tab-agent-loader.ts +++ b/mobile/src/session/mobile-new-tab-agent-loader.ts @@ -1,3 +1,7 @@ +import { + PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY, + WORKSPACE_ON_OTHER_RUNTIME +} from '../../../src/shared/protocol-version' import { newTabSettingsRead } from '../transport/settings-read-operations' import { type MobileRuntimeRepoSummary, @@ -15,6 +19,17 @@ import { type MobileNewTabAgentSettings } from './mobile-new-tab-agent-options' +const WORKSPACE_ON_OTHER_RUNTIME_MESSAGE = + 'This workspace runs on another Orca server. Pair that server directly to see its agents.' + +/** The paired host does not own this workspace, so its agents are unverifiable from here. */ +export class MobileWorkspaceOnOtherRuntimeError extends Error { + constructor() { + super(WORKSPACE_ON_OTHER_RUNTIME_MESSAGE) + this.name = 'MobileWorkspaceOnOtherRuntimeError' + } +} + /** What a launch in this workspace can choose from: the host's settings, the agents detected on * the workspace's execution host, and the workspace's repo (absent for the floating workspace). */ export type MobileAgentLaunchContext = { @@ -23,14 +38,26 @@ export type MobileAgentLaunchContext = { repo: MobileRuntimeRepoSummary | null } -export async function loadMobileAgentLaunchContext(args: { +export function hostRefusesOtherRuntimeWorkspace( + hostCapabilities: readonly string[] | null | undefined +): boolean { + return hostCapabilities?.includes(PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY) === true +} + +type MobileAgentLaunchContextArgs = { client: RpcClient worktreeId: string -}): Promise { + /** Whether the host refuses, rather than answers for, a workspace another runtime owns. */ + hostRefusesOtherRuntime?: boolean +} + +export async function loadMobileAgentLaunchContext( + args: MobileAgentLaunchContextArgs +): Promise { const { client, worktreeId } = args // Started before the settings read, not inside the array: the detection request goes on the wire // first, and the recorded sender order is what says so. - const detectedAgentsRequest = loadDetectedAgents(client, worktreeId) + const detectedAgentsRequest = loadDetectedAgents(client, worktreeId, args.hostRefusesOtherRuntime) const [settingsResponse, detectedAgents] = await Promise.all([ newTabSettingsRead.request(client), detectedAgentsRequest @@ -38,14 +65,13 @@ export async function loadMobileAgentLaunchContext(args: { const readSettings = newTabSettingsRead.interpret(settingsResponse) // Interpreted after the group, not inside it: whichever peer failed first must not decide the // error the sheet shows, and main raised the detection refusal only once settings had settled. - const detected = detectedAgents.interpret(detectedAgents.reply) + const detected = interpretDetectedAgents(detectedAgents) return { settings: readSettings(), detectedAgents: detected, repo: detectedAgents.repo } } -export async function loadMobileNewTabAgentOptions(args: { - client: RpcClient - worktreeId: string -}): Promise { +export async function loadMobileNewTabAgentOptions( + args: MobileAgentLaunchContextArgs +): Promise { const context = await loadMobileAgentLaunchContext(args) return buildMobileNewTabAgentOptions( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. @@ -63,7 +89,8 @@ type DetectedAgentsReply = { async function loadDetectedAgents( client: RpcClient, - worktreeId: string + worktreeId: string, + hostRefusesOtherRuntime = false ): Promise { // Why: the floating workspace runs on the paired host, so it has no repo connection to resolve. if (isFloatingWorkspaceWorktreeId(worktreeId)) { @@ -81,6 +108,22 @@ async function loadDetectedAgents( if (!repo) { throw new Error('worktree_repo_not_found') } + // A prefix test, not parseExecutionHostId: importing execution-host here splits a web bundle chunk. + const runtimeOwned = repos + .filter((candidate) => candidate.id === repoId) + .map((candidate) => candidate.executionHostId?.startsWith('runtime:') === true) + if (runtimeOwned.includes(true)) { + // Why: rows on several hosts can share a repo id, and then only a host that refuses another + // runtime's workspace may decide; the first row's connection could name this host's SSH target. + if (!hostRefusesOtherRuntime || !runtimeOwned.includes(false)) { + throw new MobileWorkspaceOnOtherRuntimeError() + } + return { + reply: await preflightDetectAgentsRead.request(client, { worktreeId }), + interpret: preflightDetectAgentsRead.interpret, + repo + } + } const connectionId = repo.connectionId?.trim() || null return connectionId ? { @@ -96,3 +139,15 @@ async function loadDetectedAgents( repo } } + +function interpretDetectedAgents(detected: DetectedAgentsReply): unknown[] { + try { + return detected.interpret(detected.reply) + } catch (error) { + // A host with the refusal answers it for a workspace another runtime owns. + if (error instanceof Error && error.message === WORKSPACE_ON_OTHER_RUNTIME) { + throw new MobileWorkspaceOnOtherRuntimeError() + } + throw error + } +} diff --git a/mobile/src/session/mobile-session-read-operations.ts b/mobile/src/session/mobile-session-read-operations.ts index ee13f5dbad1..3e75554fd1e 100644 --- a/mobile/src/session/mobile-session-read-operations.ts +++ b/mobile/src/session/mobile-session-read-operations.ts @@ -32,7 +32,11 @@ export const sessionTerminalListRead = bindDeferredRpcOperation( }) ) -export type MobileRuntimeRepoSummary = { id: string; connectionId?: string | null } +export type MobileRuntimeRepoSummary = { + id: string + connectionId?: string | null + executionHostId?: string | null +} const repoListReader = rpcResultVariant('runtime-repo-list', runtimeRepoListSchema) diff --git a/mobile/src/session/mobile-session-route-types.ts b/mobile/src/session/mobile-session-route-types.ts index 7a71ee9ab40..3a0df95cb3c 100644 --- a/mobile/src/session/mobile-session-route-types.ts +++ b/mobile/src/session/mobile-session-route-types.ts @@ -144,7 +144,8 @@ export type DirtyMarkdownDraft = { content: string } -export type MobileNewTabAgentLoadState = 'idle' | 'loading' | 'loaded' | 'error' +/** `other-runtime`: another Orca server owns the workspace, so the paired host cannot list its agents. */ +export type MobileNewTabAgentLoadState = 'idle' | 'loading' | 'loaded' | 'error' | 'other-runtime' export type MobileDisplayMode = 'auto' | 'phone' | 'desktop' diff --git a/mobile/src/session/pr-ai-triage-launch.ts b/mobile/src/session/pr-ai-triage-launch.ts index c425cb255cd..9496084397b 100644 --- a/mobile/src/session/pr-ai-triage-launch.ts +++ b/mobile/src/session/pr-ai-triage-launch.ts @@ -6,7 +6,10 @@ import { launchAgentInExistingWorkspace, supportsMobileExistingAgentLaunch } from './mobile-existing-agent-launch' -import { loadMobileAgentLaunchContext } from './mobile-new-tab-agent-loader' +import { + hostRefusesOtherRuntimeWorkspace, + loadMobileAgentLaunchContext +} from './mobile-new-tab-agent-loader' import { resolveMobileSourceControlLaunchAgent } from './mobile-source-control-launch-agent' // Launch path for the phone's AI buttons ("Fix checks with AI", "Resolve conflicts with AI", commit @@ -38,7 +41,11 @@ export async function launchAgentWithPrompt(args: { let resolved try { resolved = resolveMobileSourceControlLaunchAgent( - await loadMobileAgentLaunchContext({ client: args.client, worktreeId: args.worktreeId }), + await loadMobileAgentLaunchContext({ + client: args.client, + worktreeId: args.worktreeId, + hostRefusesOtherRuntime: hostRefusesOtherRuntimeWorkspace(args.hostCapabilities) + }), args.actionId ) } catch (error) { diff --git a/mobile/src/session/use-mobile-session-attachments.ts b/mobile/src/session/use-mobile-session-attachments.ts index 86d351c1c5d..0abf3c97f4d 100644 --- a/mobile/src/session/use-mobile-session-attachments.ts +++ b/mobile/src/session/use-mobile-session-attachments.ts @@ -2,7 +2,11 @@ import { useEffect } from 'react' import { AppState, type AppStateStatus } from 'react-native' import { useClipboardReader } from '../platform/clipboard' import { triggerSelection, triggerError } from '../platform/haptics' -import { loadMobileNewTabAgentOptions } from './mobile-new-tab-agent-loader' +import { + hostRefusesOtherRuntimeWorkspace, + loadMobileNewTabAgentOptions, + MobileWorkspaceOnOtherRuntimeError +} from './mobile-new-tab-agent-loader' import { useMobileSessionImageAttachments } from './use-mobile-session-image-attachments' import { useMobileAttachmentInputLeaseGate } from './use-mobile-attachment-input-lease-gate' import { useMobileTerminalPaste } from './use-mobile-terminal-paste' @@ -13,6 +17,7 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti worktreeId, client, connState, + hostCapabilities, activeHandle, pendingDiffNotesDelivery, showCreateTabDrawer, @@ -120,6 +125,7 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti } }, [clipboardContents, selectModeActive, setCanPaste]) + const hostRefusesOtherRuntime = hostRefusesOtherRuntimeWorkspace(hostCapabilities) useEffect(() => { const shouldLoadAgentOptions = showCreateTabDrawer || pendingDiffNotesDelivery !== null if (!shouldLoadAgentOptions) { @@ -140,24 +146,34 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti void (async () => { const options = await loadMobileNewTabAgentOptions({ client, - worktreeId + worktreeId, + hostRefusesOtherRuntime }) if (stale) { return } setCreateTabAgentOptions(options) setCreateTabAgentLoadState('loaded') - })().catch(() => { + })().catch((error: unknown) => { if (!stale) { setCreateTabAgentOptions([]) - setCreateTabAgentLoadState('error') + setCreateTabAgentLoadState( + error instanceof MobileWorkspaceOnOtherRuntimeError ? 'other-runtime' : 'error' + ) } }) return () => { stale = true } - }, [client, connState, pendingDiffNotesDelivery, showCreateTabDrawer, worktreeId]) + }, [ + client, + connState, + hostRefusesOtherRuntime, + pendingDiffNotesDelivery, + showCreateTabDrawer, + worktreeId + ]) return { handlePaste, flushPendingLiveInputBeforeAttachmentSend, diff --git a/mobile/src/session/use-mobile-session-panel-route-actions.tsx b/mobile/src/session/use-mobile-session-panel-route-actions.tsx index bfdaa017cb8..1c39b0c849e 100644 --- a/mobile/src/session/use-mobile-session-panel-route-actions.tsx +++ b/mobile/src/session/use-mobile-session-panel-route-actions.tsx @@ -37,6 +37,13 @@ export function useMobileSessionPanelRouteActions(scope: MobileSessionPresentati sendDiffNotesToNewAgent, handleCreateTerminal } = scope + const otherRuntimeAgentRow = { + label: 'Agents on Another Server', + hint: 'Pair that server directly', + icon: Bot, + disabled: true, + onPress: () => {} + } const createTabAgentActions = createTabAgentLoadState === 'loading' ? [ @@ -66,17 +73,19 @@ export function useMobileSessionPanelRouteActions(scope: MobileSessionPresentati onPress: () => {} } ] - : createTabAgentLoadState === 'error' - ? [ - { - label: 'Agent Presets Unavailable', - hint: 'Check the host connection', - icon: Bot, - disabled: true, - onPress: () => {} - } - ] - : [] + : createTabAgentLoadState === 'other-runtime' + ? [otherRuntimeAgentRow] + : createTabAgentLoadState === 'error' + ? [ + { + label: 'Agent Presets Unavailable', + hint: 'Check the host connection', + icon: Bot, + disabled: true, + onPress: () => {} + } + ] + : [] const sendDiffNotesAgentActions = pendingDiffNotesDelivery === null ? [] @@ -118,17 +127,19 @@ export function useMobileSessionPanelRouteActions(scope: MobileSessionPresentati onPress: () => {} } ] - : createTabAgentLoadState === 'error' - ? [ - { - label: 'Agent Presets Unavailable', - hint: 'Copy notes instead', - icon: Bot, - disabled: true, - onPress: () => {} - } - ] - : [] + : createTabAgentLoadState === 'other-runtime' + ? [otherRuntimeAgentRow] + : createTabAgentLoadState === 'error' + ? [ + { + label: 'Agent Presets Unavailable', + hint: 'Copy notes instead', + icon: Bot, + disabled: true, + onPress: () => {} + } + ] + : [] // Panel-icon taps route through the dock-vs-push decision (U1): dock-capable rows dock, constrained rows push. const handleSessionContentRowLayout = useCallback((event: LayoutChangeEvent) => { diff --git a/src/main/preflight/workspace-agent-detection.test.ts b/src/main/preflight/workspace-agent-detection.test.ts index ce10e12d9f2..5ec78ffc05d 100644 --- a/src/main/preflight/workspace-agent-detection.test.ts +++ b/src/main/preflight/workspace-agent-detection.test.ts @@ -10,6 +10,11 @@ import type { ProjectGroup } from '../../shared/project-group-types' import type { Repo } from '../../shared/repo-types' import { folderWorkspaceKey, parseWorkspaceKey } from '../../shared/workspace-scope' import { splitWorktreeId } from '../../shared/worktree/id' +import { + PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY, + RUNTIME_CAPABILITIES, + WORKSPACE_ON_OTHER_RUNTIME +} from '../../shared/protocol-version' import { resolveWorkspaceAgentDetectionHost, type WorkspaceAgentDetectionStore @@ -245,6 +250,25 @@ describe('resolveWorkspaceAgentDetectionHost on a Windows host', () => { 'worktree_execution_host_unresolved' ) }) + it('#13752: refuses a worktree or folder another runtime owns instead of probing here', () => { + const s = store({ + repos: [ + repo({ id: 'r1', path: '/srv/r1', executionHostId: 'runtime:env-b' }), + repo({ + id: 'r2', + path: '/srv/r2', + executionHostId: 'runtime:env-b', + connectionId: 'b-ssh', + projectGroupId: 'g1' + }) + ], + projectGroups: [GROUP], + folderWorkspaces: [{ ...FOLDER, folderPath: '/srv', executionHostId: 'runtime:env-b' }] + }) + expect(() => resolveFor(s, 'r1::/srv/r1')).toThrow(WORKSPACE_ON_OTHER_RUNTIME) + expect(() => resolveFor(s, 'r2::/srv/r2')).toThrow(WORKSPACE_ON_OTHER_RUNTIME) + expect(() => resolveFor(s, folderWorkspaceKey('f1'))).toThrow(WORKSPACE_ON_OTHER_RUNTIME) + }) }) describe('preflight.detectAgents resolves the workspace on the host', () => { @@ -296,4 +320,20 @@ describe('preflight.detectAgents resolves the workspace on the host', () => { }) expect(refreshMock).toHaveBeenCalledWith(wslContext('Ubuntu', 'p1', 'global-default')) }) + + it('#13752: a paired client naming a workspace another runtime owns gets a refusal', async () => { + const dispatcher = dispatcherFor( + store({ repos: [repo({ id: 'r1', path: '/srv/r1', executionHostId: 'runtime:env-b' })] }) + ) + const response = await dispatcher.dispatch({ + id: '1', + authToken: 't', + method: 'preflight.detectAgents', + params: { worktreeId: 'r1::/srv/r1' } + }) + expect(detectInstalledMock).not.toHaveBeenCalled() + expect(response).toMatchObject({ ok: false, error: { message: WORKSPACE_ON_OTHER_RUNTIME } }) + // Clients let a host decide a shared repo id only when it advertises this refusal. + expect(RUNTIME_CAPABILITIES).toContain(PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY) + }) }) diff --git a/src/main/preflight/workspace-agent-detection.ts b/src/main/preflight/workspace-agent-detection.ts index 9b700225f8b..ca9eee7b257 100644 --- a/src/main/preflight/workspace-agent-detection.ts +++ b/src/main/preflight/workspace-agent-detection.ts @@ -1,7 +1,9 @@ +import { WORKSPACE_ON_OTHER_RUNTIME } from '../../shared/protocol-version' import { getRepoExecutionHostId, getSshTargetIdForExecutionHost, - LOCAL_EXECUTION_HOST_ID + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId } from '../../shared/execution-host' import { findFolderWorkspaceCandidateRepos, @@ -86,7 +88,10 @@ function resolveWorktree( if (sshTargetId) { return { kind: 'ssh', connectionId: sshTargetId } } - // A worktree another runtime owns is not this host's to probe; keep the host default. + // Why refuse: another runtime's worktree is not this host's to probe, and its list would mislead. + if (parseExecutionHostId(routing.hostId)?.kind === 'runtime') { + throw new Error(WORKSPACE_ON_OTHER_RUNTIME) + } return routing.hostId === LOCAL_EXECUTION_HOST_ID && routing.repo ? { kind: 'local', repo: routing.repo, path: worktree.path } : null @@ -108,6 +113,9 @@ function resolveFolderWorkspace( if (host.kind === 'ambiguous') { throw new Error('worktree_execution_host_unresolved') } + if (host.kind === 'runtime') { + throw new Error(WORKSPACE_ON_OTHER_RUNTIME) + } const folder = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId) const candidates = findFolderWorkspaceCandidateRepos(state, folderWorkspaceId) // Why one candidate: a folder spanning several repos has no single project runtime to pick. diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 8edcebc1d3e..ee05b227cfb 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -324,6 +324,12 @@ export const WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY = // must not read that answer as the workspace's (a WSL project on a Windows host differs). export const PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY = 'preflight.workspace-scoped.v1' as const +// Why: an older host answers its own agents for a workspace another runtime owns; one with this +// refuses with WORKSPACE_ON_OTHER_RUNTIME, so a client may let it decide for a shared repo id. +export const PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY = + 'preflight.other-runtime-refusal.v1' as const +/** What such a host answers, instead of probing itself, for another runtime's workspace. */ +export const WORKSPACE_ON_OTHER_RUNTIME = 'workspace_on_other_runtime' // Hosts without this capability have no workspacePorts.scanHost/killHost; their scan and Stop act // only on the endpoint itself, never on a workspace's SSH host. export const WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY = @@ -434,6 +440,7 @@ export const RUNTIME_CAPABILITIES = [ ...AUTOMATION_RUNTIME_CAPABILITIES, NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY, PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY, + PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY, WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY, ...AGENT_LAUNCH_RUNTIME_CAPABILITIES ] as const