fix(mobile): detect New Workspace agents in the selected repo runtime

Pass the selected repository to New Workspace agent detection through the host-authoritative runtime resolver, including WSL paths and Windows project preferences. Preserve old-client request shape, SSH routing, and strict ownership/refusal behavior.

This merge credits @welldoingk’s original WSL agent-detection investigation and community PR #20101, adapted to the current host-resolved contract.

Co-authored-by: welldoingk <thkwon96@gmail.com>

Fixes #20060
This commit is contained in:
Neil
2026-10-21 17:24:31 -07:00
committed by GitHub
co-authored by welldoingk
parent 718fb9fdc4
commit 50da78aa85
15 changed files with 829 additions and 383 deletions
@@ -4,12 +4,12 @@
"family": "tasks.workspace-ssh-local",
"namedDeltas": [],
"values": {
"11405363c62d": {
"name": "workspaceAgent",
"ordinal": 1,
"value": "claude"
"057cfe2200b5": {
"name": "preflight.detectAgents#1",
"ordinal": 5,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\",\"params\":{\"repoId\":\"repo-1\"}}"
},
"1feb12bfbf62": {
"110ef4b3eb5b": {
"name": "preflight.detectAgents#1",
"ordinal": 4,
"args": [
@@ -19,9 +19,239 @@
},
{
"name": "params",
"value": {
"repoId": "repo-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "rejected",
"startedAt": 0,
"settledAt": 0,
"error": {
"category": "Error",
"message": "transport failure",
"isRpcDeliveryUnknown": true
}
}
},
"11405363c62d": {
"name": "workspaceAgent",
"ordinal": 1,
"value": "claude"
},
"12f65d55aa1a": {
"name": "preflight.detectAgents#1",
"ordinal": 4,
"args": [
{
"name": "method",
"value": "preflight.detectAgents"
},
{
"name": "params",
"value": {
"repoId": "repo-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "refused",
"message": "outer refused"
},
"id": "frame-1",
"ok": false
}
}
},
"34cbfeeb5c49": {
"name": "preflight.detectAgents#1",
"ordinal": 4,
"args": [
{
"name": "method",
"value": "preflight.detectAgents"
},
{
"name": "params",
"value": {
"repoId": "repo-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "rejected",
"startedAt": 0,
"settledAt": 0,
"error": {
"category": "Error",
"message": "",
"isRpcDeliveryUnknown": true
}
}
},
"43111f57966a": {
"name": "preflight.detectAgents#1",
"ordinal": 4,
"args": [
{
"name": "method",
"value": "preflight.detectAgents"
},
{
"name": "params",
"value": {
"repoId": "repo-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"error": "refused"
}
}
}
},
"5786215332fb": {
"name": "preflight.detectAgents#1",
"ordinal": 4,
"args": [
{
"name": "method",
"value": "preflight.detectAgents"
},
{
"name": "params",
"value": {
"repoId": "repo-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"$rpc": "null"
}
}
}
},
"630e2f27face": {
"name": "workspaceDetectedAgentIds",
"ordinal": 6,
"value": []
},
"63751bc69233": {
"name": "preflight.detectAgents#1",
"ordinal": 4,
"args": [
{
"name": "method",
"value": "preflight.detectAgents"
},
{
"name": "params",
"value": {
"repoId": "repo-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "refused",
"message": ""
},
"id": "frame-1",
"ok": false
}
}
},
"71225024ccf5": {
"agent": "claude",
"connecting": false,
"detected": [],
"setup": "unresolved",
"ssh": {
"$rpc": "null"
}
},
"7400f4eebe66": {
"agent": "claude",
"connecting": false,
"detected": ["codex", "claude"],
"setup": "unresolved",
"ssh": {
"$rpc": "null"
}
},
"8172e74bc69e": {
"name": "preflight.detectAgents#1",
"ordinal": 4,
"args": [
{
"name": "method",
"value": "preflight.detectAgents"
},
{
"name": "params",
"value": {
"repoId": "repo-1"
}
},
{
"name": "options",
@@ -44,7 +274,7 @@
}
}
},
"24a0184fc2f5": {
"8f4b7427ef27": {
"name": "preflight.detectAgents#1",
"ordinal": 4,
"args": [
@@ -55,7 +285,7 @@
{
"name": "params",
"value": {
"$rpc": "absent"
"repoId": "repo-1"
}
},
{
@@ -81,35 +311,14 @@
}
}
},
"5da628eecd2f": {
"name": "preflight.detectAgents#1",
"ordinal": 5,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\"}"
},
"630e2f27face": {
"9637248df9cb": {
"name": "workspaceDetectedAgentIds",
"ordinal": 6,
"value": []
},
"71225024ccf5": {
"agent": "claude",
"connecting": false,
"detected": [],
"setup": "unresolved",
"ssh": {
"ordinal": 3,
"value": {
"$rpc": "null"
}
},
"7400f4eebe66": {
"agent": "claude",
"connecting": false,
"detected": ["codex", "claude"],
"setup": "unresolved",
"ssh": {
"$rpc": "null"
}
},
"78870501ce99": {
"a3447a38f180": {
"name": "preflight.detectAgents#1",
"ordinal": 4,
"args": [
@@ -120,7 +329,7 @@
{
"name": "params",
"value": {
"$rpc": "absent"
"repoId": "repo-1"
}
},
{
@@ -144,7 +353,12 @@
}
}
},
"90ac803be31a": {
"a9ec92743b9e": {
"name": "workspaceAgentOverridden",
"ordinal": 2,
"value": false
},
"c988ed31a0bf": {
"name": "preflight.detectAgents#1",
"ordinal": 4,
"args": [
@@ -155,78 +369,7 @@
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "rejected",
"startedAt": 0,
"settledAt": 0,
"error": {
"category": "Error",
"message": "transport failure",
"isRpcDeliveryUnknown": true
}
}
},
"9637248df9cb": {
"name": "workspaceDetectedAgentIds",
"ordinal": 3,
"value": {
"$rpc": "null"
}
},
"9a93035b19e1": {
"name": "preflight.detectAgents#1",
"ordinal": 4,
"args": [
{
"name": "method",
"value": "preflight.detectAgents"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "rejected",
"startedAt": 0,
"settledAt": 0,
"error": {
"category": "Error",
"message": "",
"isRpcDeliveryUnknown": true
}
}
},
"9bee832e6c84": {
"name": "preflight.detectAgents#1",
"ordinal": 4,
"args": [
{
"name": "method",
"value": "preflight.detectAgents"
},
{
"name": "params",
"value": {
"$rpc": "absent"
"repoId": "repo-1"
}
},
{
@@ -241,16 +384,25 @@
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "refused",
"message": ""
},
"id": "frame-1",
"ok": false
"ok": true
}
}
},
"9fdd4a87eb0b": {
"d68cba3b57e1": {
"name": "workspaceDetectedAgentIds",
"ordinal": 6,
"value": ["codex", "claude"]
},
"eb79a9b3682a": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"$rpc": "undefined"
}
},
"ebcf6c6d8d86": {
"name": "preflight.detectAgents#1",
"ordinal": 4,
"args": [
@@ -261,7 +413,7 @@
{
"name": "params",
"value": {
"$rpc": "absent"
"repoId": "repo-1"
}
},
{
@@ -281,158 +433,6 @@
"result": ["codex", "claude"]
}
}
},
"a2765967bb49": {
"name": "preflight.detectAgents#1",
"ordinal": 4,
"args": [
{
"name": "method",
"value": "preflight.detectAgents"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"error": "refused"
}
}
}
},
"a9ec92743b9e": {
"name": "workspaceAgentOverridden",
"ordinal": 2,
"value": false
},
"c1f59c7b2afa": {
"name": "preflight.detectAgents#1",
"ordinal": 4,
"args": [
{
"name": "method",
"value": "preflight.detectAgents"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"$rpc": "null"
}
}
}
},
"c8028d1dd40a": {
"name": "preflight.detectAgents#1",
"ordinal": 4,
"args": [
{
"name": "method",
"value": "preflight.detectAgents"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "refused",
"message": "outer refused"
},
"id": "frame-1",
"ok": false
}
}
},
"d68cba3b57e1": {
"name": "workspaceDetectedAgentIds",
"ordinal": 6,
"value": ["codex", "claude"]
},
"eb79a9b3682a": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"$rpc": "undefined"
}
},
"f8eb8a4a9917": {
"name": "preflight.detectAgents#1",
"ordinal": 4,
"args": [
{
"name": "method",
"value": "preflight.detectAgents"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true
}
}
}
},
"recording": {
@@ -441,8 +441,8 @@
{
"id": "tw-workspace-ssh-local-agents.normal:local-agents-detected",
"observation": {
"sender": ["9fdd4a87eb0b"],
"payloads": ["5da628eecd2f"],
"sender": ["ebcf6c6d8d86"],
"payloads": ["057cfe2200b5"],
"settlements": {
"mount": "eb79a9b3682a"
},
@@ -453,8 +453,8 @@
{
"id": "tw-workspace-ssh-local-agents.result-absent:local-agents-detected",
"observation": {
"sender": ["f8eb8a4a9917"],
"payloads": ["5da628eecd2f"],
"sender": ["c988ed31a0bf"],
"payloads": ["057cfe2200b5"],
"settlements": {
"mount": "eb79a9b3682a"
},
@@ -465,8 +465,8 @@
{
"id": "tw-workspace-ssh-local-agents.result-null:local-agents-detected",
"observation": {
"sender": ["c1f59c7b2afa"],
"payloads": ["5da628eecd2f"],
"sender": ["5786215332fb"],
"payloads": ["057cfe2200b5"],
"settlements": {
"mount": "eb79a9b3682a"
},
@@ -477,8 +477,8 @@
{
"id": "tw-workspace-ssh-local-agents.inner-ok-missing:local-agents-detected",
"observation": {
"sender": ["a2765967bb49"],
"payloads": ["5da628eecd2f"],
"sender": ["43111f57966a"],
"payloads": ["057cfe2200b5"],
"settlements": {
"mount": "eb79a9b3682a"
},
@@ -489,8 +489,8 @@
{
"id": "tw-workspace-ssh-local-agents.inner-false-string-error:local-agents-detected",
"observation": {
"sender": ["78870501ce99"],
"payloads": ["5da628eecd2f"],
"sender": ["a3447a38f180"],
"payloads": ["057cfe2200b5"],
"settlements": {
"mount": "eb79a9b3682a"
},
@@ -501,8 +501,8 @@
{
"id": "tw-workspace-ssh-local-agents.inner-false-object-error:local-agents-detected",
"observation": {
"sender": ["24a0184fc2f5"],
"payloads": ["5da628eecd2f"],
"sender": ["8f4b7427ef27"],
"payloads": ["057cfe2200b5"],
"settlements": {
"mount": "eb79a9b3682a"
},
@@ -513,8 +513,8 @@
{
"id": "tw-workspace-ssh-local-agents.outer-refused:local-agents-detected",
"observation": {
"sender": ["c8028d1dd40a"],
"payloads": ["5da628eecd2f"],
"sender": ["12f65d55aa1a"],
"payloads": ["057cfe2200b5"],
"settlements": {
"mount": "eb79a9b3682a"
},
@@ -525,8 +525,8 @@
{
"id": "tw-workspace-ssh-local-agents.outer-refused-no-message:local-agents-detected",
"observation": {
"sender": ["9bee832e6c84"],
"payloads": ["5da628eecd2f"],
"sender": ["63751bc69233"],
"payloads": ["057cfe2200b5"],
"settlements": {
"mount": "eb79a9b3682a"
},
@@ -537,8 +537,8 @@
{
"id": "tw-workspace-ssh-local-agents.method-not-found:local-agents-detected",
"observation": {
"sender": ["1feb12bfbf62"],
"payloads": ["5da628eecd2f"],
"sender": ["8172e74bc69e"],
"payloads": ["057cfe2200b5"],
"settlements": {
"mount": "eb79a9b3682a"
},
@@ -549,8 +549,8 @@
{
"id": "tw-workspace-ssh-local-agents.transport-rejection:local-agents-detected",
"observation": {
"sender": ["90ac803be31a"],
"payloads": ["5da628eecd2f"],
"sender": ["110ef4b3eb5b"],
"payloads": ["057cfe2200b5"],
"settlements": {
"mount": "eb79a9b3682a"
},
@@ -561,8 +561,8 @@
{
"id": "tw-workspace-ssh-local-agents.transport-rejection-no-message:local-agents-detected",
"observation": {
"sender": ["9a93035b19e1"],
"payloads": ["5da628eecd2f"],
"sender": ["34cbfeeb5c49"],
"payloads": ["057cfe2200b5"],
"settlements": {
"mount": "eb79a9b3682a"
},
@@ -4,16 +4,16 @@
"family": "tasks.workspace-ssh-local",
"namedDeltas": [],
"values": {
"057cfe2200b5": {
"name": "preflight.detectAgents#1",
"ordinal": 5,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\",\"params\":{\"repoId\":\"repo-1\"}}"
},
"11405363c62d": {
"name": "workspaceAgent",
"ordinal": 1,
"value": "claude"
},
"5da628eecd2f": {
"name": "preflight.detectAgents#1",
"ordinal": 5,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\"}"
},
"7400f4eebe66": {
"agent": "claude",
"connecting": false,
@@ -30,38 +30,6 @@
"$rpc": "null"
}
},
"9fdd4a87eb0b": {
"name": "preflight.detectAgents#1",
"ordinal": 4,
"args": [
{
"name": "method",
"value": "preflight.detectAgents"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": ["codex", "claude"]
}
}
},
"a9ec92743b9e": {
"name": "workspaceAgentOverridden",
"ordinal": 2,
@@ -79,6 +47,38 @@
"value": {
"$rpc": "undefined"
}
},
"ebcf6c6d8d86": {
"name": "preflight.detectAgents#1",
"ordinal": 4,
"args": [
{
"name": "method",
"value": "preflight.detectAgents"
},
{
"name": "params",
"value": {
"repoId": "repo-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": ["codex", "claude"]
}
}
}
},
"recording": {
@@ -87,8 +87,8 @@
{
"id": "local-agents-detected",
"observation": {
"sender": ["9fdd4a87eb0b"],
"payloads": ["5da628eecd2f"],
"sender": ["ebcf6c6d8d86"],
"payloads": ["057cfe2200b5"],
"settlements": {
"mount": "eb79a9b3682a"
},
+1 -1
View File
@@ -7219,7 +7219,7 @@
{
"complete": "preflight.detectAgents#1",
"params": {
"$undefined": true
"repoId": "repo-1"
},
"reply": {
"ok": true,
@@ -323,7 +323,7 @@ describe('NewWorktreeModal project targets', () => {
await flushUpdates()
expect(renderer.root.findAll((node) => node.props.label === 'Workspace name')).toHaveLength(1)
expect(sendRequest).toHaveBeenCalledWith('preflight.detectAgents')
expect(sendRequest).toHaveBeenCalledWith('preflight.detectAgents', { repoId: 'folder-1' })
expect(sendRequest).not.toHaveBeenCalledWith('ssh.getState', expect.anything())
expect(sendRequest.mock.calls.map(([method]) => method)).not.toContain(
'worktree.prefetchCreateBase'
@@ -100,6 +100,7 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
const executionTarget = useNewWorkspaceExecutionTarget({
client,
connectionId: selectedRepoConnectionId,
repoId: selectedRepo?.id,
visible
})
const setupScript = useNewWorkspaceSetupScript({ client, selectedRepo })
@@ -0,0 +1,161 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it } from 'vitest'
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
import { useNewWorkspaceExecutionTarget } from './use-new-workspace-execution-target'
const mounted: ReactTestRenderer[] = []
afterEach(() => {
for (const renderer of mounted.splice(0)) {
act(() => renderer.unmount())
}
})
describe('New Workspace agent detection', () => {
it('targets the picked repo and clears the previous repo agents while switching', async () => {
const client = new FakeSession('connected')
let resolveNext: ((response: { id: string; ok: true; result: string[] }) => void) | undefined
client.sendRequest.mockImplementation(async (_method: string, params?: unknown) => {
if (JSON.stringify(params) === JSON.stringify({ repoId: 'wsl-repo' })) {
return { id: 'reply', ok: true, result: ['claude'] }
}
return new Promise((resolve) => {
resolveNext = resolve
})
})
let detected: Set<string> | null = null
function Harness({
repoId,
selectedClient = client
}: {
repoId: string
selectedClient?: RpcClient
}) {
detected = useNewWorkspaceExecutionTarget({
client: selectedClient,
repoId,
connectionId: null,
visible: true
}).detectedAgentIds
return null
}
let renderer: ReactTestRenderer | undefined
await act(async () => {
renderer = create(createElement(Harness, { repoId: 'wsl-repo' }))
mounted.push(renderer)
})
expect(client.sendRequest).toHaveBeenCalledWith('preflight.detectAgents', {
repoId: 'wsl-repo'
})
expect(detected).toEqual(new Set(['claude']))
await act(async () => {
renderer?.update(createElement(Harness, { repoId: 'host-repo' }))
})
expect(detected).toBeNull()
expect(client.sendRequest).toHaveBeenLastCalledWith('preflight.detectAgents', {
repoId: 'host-repo'
})
await act(async () => {
resolveNext?.({ id: 'reply', ok: true, result: ['codex'] })
})
expect(detected).toEqual(new Set(['codex']))
const otherHost = new FakeSession('connected')
otherHost.sendRequest.mockImplementation(
() =>
new Promise((resolve) => {
resolveNext = resolve
})
)
await act(async () => {
renderer?.update(createElement(Harness, { repoId: 'host-repo', selectedClient: otherHost }))
})
expect(detected).toBeNull()
await act(async () => {
resolveNext?.({ id: 'reply', ok: true, result: ['gemini'] })
})
expect(detected).toEqual(new Set(['gemini']))
})
it.each([
{ switchHost: false, rejectLate: false },
{ switchHost: false, rejectLate: true },
{ switchHost: true, rejectLate: false },
{ switchHost: true, rejectLate: true }
])(
'ignores late results after switching ($switchHost host, $rejectLate rejection)',
async ({ switchHost, rejectLate }) => {
const originalClient = new FakeSession('connected')
const currentClient = switchHost ? new FakeSession('connected') : originalClient
let settleLate: (() => void) | undefined
originalClient.sendRequest.mockImplementationOnce(
() =>
new Promise<RpcResponse>((resolve, reject) => {
settleLate = () =>
rejectLate
? reject(new Error('old host disconnected'))
: resolve({ id: 'late', ok: true, result: ['claude'] })
})
)
currentClient.sendRequest.mockResolvedValue({ id: 'current', ok: true, result: ['codex'] })
let detected: Set<string> | null = null
function Harness({ client, repoId }: { client: RpcClient; repoId: string }) {
detected = useNewWorkspaceExecutionTarget({
client,
repoId,
connectionId: null,
visible: true
}).detectedAgentIds
return null
}
let renderer: ReactTestRenderer | undefined
await act(async () => {
renderer = create(createElement(Harness, { client: originalClient, repoId: 'repo-1' }))
mounted.push(renderer)
})
expect(detected).toBeNull()
await act(async () => {
renderer?.update(
createElement(Harness, {
client: currentClient,
repoId: switchHost ? 'repo-1' : 'repo-2'
})
)
})
expect(detected).toEqual(new Set(['codex']))
await act(async () => settleLate?.())
expect(detected).toEqual(new Set(['codex']))
}
)
it('keeps the SSH request and connection gate', async () => {
const client = new FakeSession('connected')
client.sendRequest.mockImplementation(async (method: string) => ({
id: 'reply',
ok: true,
result:
method === 'ssh.getState'
? { state: { targetId: 'ssh-1', status: 'connected', error: null, reconnectAttempt: 0 } }
: ['codex']
}))
function Harness() {
useNewWorkspaceExecutionTarget({
client,
repoId: 'remote-repo',
connectionId: 'ssh-1',
visible: true
})
return null
}
await act(async () => {
mounted.push(create(createElement(Harness)))
})
expect(client.sendRequest).toHaveBeenCalledWith('preflight.detectRemoteAgents', {
connectionId: 'ssh-1'
})
expect(
client.sendRequest.mock.calls.some(([method]) => method === 'preflight.detectAgents')
).toBe(false)
})
})
@@ -13,7 +13,9 @@ import {
} from '../tasks/workspace-ssh-gate'
type DetectedAgentIdsState = {
client: RpcClient
connectionId: string | null
repoId: string | null
ids: Set<string>
}
@@ -29,12 +31,13 @@ export function useNewWorkspaceExecutionTarget(args: {
client: RpcClient | null
connectionId: string | null
visible: boolean
repoId?: string | null
}): {
sshGate: WorkspaceSshGate
detectedAgentIds: Set<string> | null
connect: () => Promise<void>
} {
const { client, connectionId, visible } = args
const { client, connectionId, visible, repoId = null } = args
const [sshState, setSshState] = useState<WorkspaceSshRecord | null>(null)
const [connectingTargetId, setConnectingTargetId] = useState<string | null>(null)
const [detectedAgentIdsState, setDetectedAgentIdsState] = useState<DetectedAgentIdsState | null>(
@@ -46,7 +49,9 @@ export function useNewWorkspaceExecutionTarget(args: {
connecting: connectingTargetId === connectionId
})
const detectedAgentIds =
detectedAgentIdsState?.connectionId === connectionId &&
detectedAgentIdsState?.client === client &&
detectedAgentIdsState.connectionId === connectionId &&
detectedAgentIdsState.repoId === repoId &&
(connectionId === null || sshGate.status === 'connected')
? detectedAgentIdsState.ids
: null
@@ -92,23 +97,29 @@ export function useNewWorkspaceExecutionTarget(args: {
? remoteAgentDetectionRead.interpret(
await remoteAgentDetectionRead.request(client, { connectionId })
)
: localAgentDetectionRead.interpret(await localAgentDetectionRead.request(client))
: localAgentDetectionRead.interpret(
await (repoId
? localAgentDetectionRead.request(client, { repoId })
: localAgentDetectionRead.request(client))
)
if (!stale) {
setDetectedAgentIdsState({
client,
connectionId,
repoId,
ids: detected.accepted ? new Set(detected.value) : new Set()
})
}
} catch {
if (!stale) {
setDetectedAgentIdsState({ connectionId, ids: new Set() })
setDetectedAgentIdsState({ client, connectionId, repoId, ids: new Set() })
}
}
})()
return () => {
stale = true
}
}, [client, connectionId, sshGate.status, visible])
}, [client, connectionId, repoId, sshGate.status, visible])
async function connect(): Promise<void> {
if (!client || !connectionId) {
@@ -25,7 +25,7 @@ const { useMobileTasksWorkspaceSshState } = await import('./use-mobile-tasks-wor
const { useMobileTasksWorkspaceCreateActions } =
await import('./use-mobile-tasks-workspace-create-actions')
type Sent = { method: string; executionHost?: string }
type Sent = { method: string; params?: unknown; executionHost?: string }
const sentBy = new WeakMap<FakeSession, Sent[]>()
/** Records where each call ran, then answers it with `answer`. */
@@ -38,10 +38,10 @@ function recording(
client.sendRequest.mockImplementation(
async (
method: string,
_params?: unknown,
params?: unknown,
options?: { envelope?: { executionHost?: string } }
) => {
sent.push({ method, executionHost: options?.envelope?.executionHost })
sent.push({ method, params, executionHost: options?.envelope?.executionHost })
return answer(method, options?.envelope?.executionHost)
}
)
@@ -165,6 +165,9 @@ describe('creating a workspace from a task on a server repo', () => {
const { client, pushed } = await createFrom(SERVER_REPO)
expect(ranOn(client, 'worktree.create')).toEqual(['runtime:vm'])
expect(ranOn(client, 'preflight.detectAgents')).toEqual(['runtime:vm'])
expect(
sentBy.get(client)?.find((sent) => sent.method === 'preflight.detectAgents')?.params
).toEqual({ repoId: SERVER_REPO.id })
expect(pushed).toHaveLength(1)
expect(new URL(pushed[0]!, 'orca://x').searchParams.get('executionHost')).toBe('runtime:vm')
})
@@ -173,6 +176,9 @@ describe('creating a workspace from a task on a server repo', () => {
const { client, pushed } = await createFrom(DESKTOP_REPO)
expect(ranOn(client, 'worktree.create')).toEqual(['desktop'])
expect(ranOn(client, 'preflight.detectAgents')).toEqual(['desktop'])
expect(
sentBy.get(client)?.find((sent) => sent.method === 'preflight.detectAgents')?.params
).toEqual({ repoId: DESKTOP_REPO.id })
expect(new URL(pushed[0]!, 'orca://x').searchParams.get('executionHost')).toBeNull()
})
})
@@ -123,7 +123,12 @@ export function useMobileTasksWorkspaceSshState(model: WorkspaceSparseActionsMod
connectionId: workspaceCreateTargetRepo.connectionId
})
}
: { operation: localAgentDetectionRead, reply: localAgentDetectionRead.request(repoClient) }
: {
operation: localAgentDetectionRead,
reply: localAgentDetectionRead.request(repoClient, {
repoId: workspaceCreateTargetRepo.id
})
}
void detection.reply
.then((reply) => {
if (stale) {
@@ -18,15 +18,16 @@ vi.mock('electron', () => electronMocks)
import { OrcaRuntimeService } from './orca-runtime'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
import type { Repo } from '../../shared/repo-types'
import type { Project } from '../../shared/project-types'
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
const originalPlatform = process.platform
function makeRuntime(repos: Repo[]) {
function makeRuntime(repos: Repo[], projects: Project[] = []) {
const store = {
getRepo: (id: string) => repos.find((repo) => repo.id === id),
getRepos: () => repos,
getProjects: () => [],
getProjects: () => projects,
getSettings: () => ({ localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } })
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: detection resolution reads only the repo, project and settings members supplied here.
@@ -79,6 +80,165 @@ describe('OrcaRuntimeService.resolveAgentDetectionHost', () => {
)
})
it('resolves the selected repo before a workspace exists (#20060)', async () => {
const wslRepo: Repo = { ...local, path: '\\\\wsl.localhost\\Debian\\home\\neil\\repo' }
const { runtime, selector } = makeRuntime([wslRepo])
expect(await runtime.resolveAgentDetectionHost(undefined, local.id)).toEqual({
kind: 'local',
context: {
projectRuntime: {
status: 'resolved',
runtime: expect.objectContaining({ kind: 'wsl', distro: 'Debian' })
}
}
})
expect(selector).not.toHaveBeenCalled()
})
it('refuses a repository target without a store while retaining the untargeted default', async () => {
const runtime = new OrcaRuntimeService(null)
await expect(runtime.resolveAgentDetectionHost(undefined, local.id)).rejects.toThrow(
'repo_not_found'
)
expect(await runtime.resolveAgentDetectionHost(undefined)).toEqual({ kind: 'local' })
expect(
await runtime.resolveAgentDetectionHost(FLOATING_TERMINAL_WORKTREE_ID, local.id)
).toEqual({
kind: 'local'
})
})
it.each(['removed', 'path', 'owner', 'connection', 'duplicate'] as const)(
'refuses a selected repository %s change across the selector await',
async (change) => {
const selected = { ...local, path: '\\\\wsl$\\Old\\home\\repo' }
const repos: Repo[] = [selected]
const { runtime } = makeRuntime(repos)
const pending = runtime.resolveAgentDetectionHost(undefined, local.id)
repos.splice(
0,
1,
...(change === 'removed'
? []
: change === 'duplicate'
? [selected, { ...selected }]
: [
{
...selected,
...(change === 'path' ? { path: '\\\\wsl$\\Current\\home\\repo' } : {}),
...(change === 'owner' ? { executionHostId: 'ssh:build-host' as const } : {}),
...(change === 'connection' ? { connectionId: 'build-host' } : {})
}
])
)
await expect(pending).rejects.toThrow('repo_target_changed')
}
)
it('keeps repo detection on its configured runtime for a Windows path', async () => {
const { runtime } = makeRuntime([local])
expect(await runtime.resolveAgentDetectionHost(undefined, local.id)).toMatchObject({
kind: 'local',
context: { projectRuntime: { runtime: { kind: 'wsl', distro: 'Ubuntu' } } }
})
})
it.each(['darwin', 'linux', 'win32'])(
'matches existing workspace runtime resolution on %s',
async (platform) => {
Object.defineProperty(process, 'platform', { value: platform })
const selected = { ...local, path: platform === 'win32' ? local.path : '/src/r1' }
const { runtime, selector } = makeRuntime([selected])
selector.mockResolvedValue(
worktree({ repoId: selected.id, path: selected.path, hostId: 'local' })
)
const detected = await runtime.resolveAgentDetectionHost(undefined, selected.id)
expect(detected).toEqual(
await runtime.resolveAgentDetectionHost(`${selected.id}::${selected.path}`)
)
expect(detected).toMatchObject({
kind: 'local',
context: {
projectRuntime: { runtime: { kind: platform === 'win32' ? 'wsl' : 'local-host' } }
}
})
}
)
it('keeps project overrides above a selected folder repo WSL path', async () => {
const folderRepo: Repo = {
...local,
kind: 'folder',
path: '\\\\wsl$\\Debian\\home\\neil\\notes'
}
const project: Project = {
id: 'project-1',
displayName: 'Notes',
badgeColor: 'blue',
sourceRepoIds: [local.id],
localWindowsRuntimePreference: { kind: 'windows-host' },
createdAt: 1,
updatedAt: 1
}
const { runtime } = makeRuntime([folderRepo], [project])
expect(await runtime.resolveAgentDetectionHost(undefined, local.id)).toMatchObject({
kind: 'local',
context: { projectRuntime: { runtime: { kind: 'windows-host', reason: 'project-override' } } }
})
})
it.each([{ connectionId: 'build-host' }, { executionHostId: 'ssh:build-host' }] as const)(
'preserves selected repo SSH ownership: %j',
async (ownership) => {
const { runtime } = makeRuntime([
{ ...local, executionHostId: undefined, ...ownership, path: '/srv/r1' }
])
expect(await runtime.resolveAgentDetectionHost(undefined, local.id)).toEqual({
kind: 'ssh',
connectionId: 'build-host'
})
}
)
it('keeps an explicit workspace authoritative when a repo target is also sent', async () => {
const { runtime, selector } = makeRuntime([local])
selector.mockResolvedValue(worktree({ repoId: local.id, path: local.path, hostId: 'local' }))
const workspaceId = `${local.id}::${local.path}`
expect(await runtime.resolveAgentDetectionHost(workspaceId, 'missing')).toEqual(
await runtime.resolveAgentDetectionHost(workspaceId)
)
expect(
await runtime.resolveAgentDetectionHost(FLOATING_TERMINAL_WORKTREE_ID, 'missing')
).toEqual({ kind: 'local' })
})
it('refuses an unknown or ambiguous repo instead of probing the host default', async () => {
const { runtime } = makeRuntime([local, { ...local, executionHostId: 'runtime:other' }])
await expect(runtime.resolveAgentDetectionHost(undefined, local.id)).rejects.toThrow(
'selector_ambiguous'
)
await expect(runtime.resolveAgentDetectionHost(undefined, 'missing')).rejects.toThrow(
'repo_not_found'
)
})
it.each(['runtime:%broken', 'ssh:%broken'] as const)(
'refuses a selected repository with malformed owner %s',
async (executionHostId) => {
const { runtime } = makeRuntime([{ ...local, executionHostId }])
await expect(runtime.resolveAgentDetectionHost(undefined, local.id)).rejects.toThrow(
'worktree_execution_host_unresolved'
)
}
)
it('refuses a repo owned by another runtime', async () => {
const { runtime } = makeRuntime([{ ...local, executionHostId: 'runtime:other' }])
await expect(runtime.resolveAgentDetectionHost(undefined, local.id)).rejects.toThrow(
'workspace_on_other_runtime'
)
})
it("routes by the host's own worktree record", async () => {
const ssh: Repo = { ...local, path: '/srv/r1', executionHostId: 'ssh:build-host' }
const { runtime, selector } = makeRuntime([ssh, local])
+54 -2
View File
@@ -68,8 +68,8 @@ describe('preflight RPC methods', () => {
const refreshed = await dispatcher.dispatch(makeRequest('preflight.refreshAgents', null))
// An older client sends no workspace and keeps the host-default probe.
expect(resolveAgentDetectionHost).toHaveBeenNthCalledWith(1, undefined)
expect(resolveAgentDetectionHost).toHaveBeenNthCalledWith(2, undefined)
expect(resolveAgentDetectionHost).toHaveBeenNthCalledWith(1, undefined, undefined)
expect(resolveAgentDetectionHost).toHaveBeenNthCalledWith(2, undefined, undefined)
expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalledWith(undefined)
expect(refreshShellPathAndDetectAgentsMock).toHaveBeenCalledWith(undefined)
expect(detected).toMatchObject({ ok: true, result: ['codex'] })
@@ -118,6 +118,58 @@ describe('preflight RPC methods', () => {
expect(ssh).toMatchObject({ ok: false, error: { message: SSH_HOST_NOT_SERVED_MESSAGE } })
})
it('forwards a repo target before workspace creation and rejects malformed ids', async () => {
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValueOnce(['codex'])
const resolveAgentDetectionHost = vi.fn(() => ({ kind: 'local' }) as const)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher reads only these two runtime members.
const runtime = {
getRuntimeId: () => 'runtime',
resolveAgentDetectionHost
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: PREFLIGHT_METHODS })
const response = await dispatcher.dispatch(
makeRequest('preflight.detectAgents', {
repoId: 'r1',
path: '/client-controlled',
wslDistro: 'client-controlled',
executionHostId: 'local'
})
)
expect(resolveAgentDetectionHost).toHaveBeenCalledWith(undefined, 'r1')
expect(response).toMatchObject({ ok: true, result: ['codex'] })
resolveAgentDetectionHost.mockClear()
for (const repoId of ['', null, 1, { path: '/client-controlled' }]) {
const malformed = await dispatcher.dispatch(makeRequest('preflight.detectAgents', { repoId }))
expect(malformed).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
}
expect(resolveAgentDetectionHost).not.toHaveBeenCalled()
})
it.each(['preflight.detectAgents', 'preflight.refreshAgents'])(
'%s never probes locally after repository ownership refuses',
async (method) => {
detectInstalledAgentsWithShellPathHydrationMock.mockClear()
refreshShellPathAndDetectAgentsMock.mockClear()
const resolveAgentDetectionHost = vi
.fn()
.mockRejectedValue(new Error('workspace_on_other_runtime'))
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher reads only these runtime members.
const runtime = {
getRuntimeId: () => 'runtime',
resolveAgentDetectionHost
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: PREFLIGHT_METHODS })
const response = await dispatcher.dispatch(makeRequest(method, { repoId: 'foreign-repo' }))
expect(resolveAgentDetectionHost).toHaveBeenCalledWith(undefined, 'foreign-repo')
expect(response).toMatchObject({
ok: false,
error: { message: 'workspace_on_other_runtime' }
})
expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled()
expect(refreshShellPathAndDetectAgentsMock).not.toHaveBeenCalled()
}
)
it('refuses agent detection on an SSH target this host does not serve', async () => {
detectInstalledAgentsWithShellPathHydrationMock.mockClear()
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
+2 -2
View File
@@ -27,7 +27,7 @@ export const PREFLIGHT_METHODS = [
params: PreflightAgentDetection,
// Why the host resolves: only it knows the workspace's project runtime, including WSL.
handler: async (params, { runtime }) =>
detectAgentsOnHost(await runtime.resolveAgentDetectionHost(params.worktreeId))
detectAgentsOnHost(await runtime.resolveAgentDetectionHost(params.worktreeId, params.repoId))
}),
defineMethod({
name: 'preflight.detectRemoteAgents',
@@ -47,6 +47,6 @@ export const PREFLIGHT_METHODS = [
permission: 'workspace',
params: PreflightAgentDetection,
handler: async (params, { runtime }) =>
refreshAgentsOnHost(await runtime.resolveAgentDetectionHost(params.worktreeId))
refreshAgentsOnHost(await runtime.resolveAgentDetectionHost(params.worktreeId, params.repoId))
})
]
@@ -0,0 +1,75 @@
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
import { getRepoRoutingConnectionId } from '../../shared/execution-host'
import type { Repo } from '../../shared/repo-types'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
import {
HOST_DEFAULT_AGENT_DETECTION,
resolveWorkspaceAgentDetectionHost,
type AgentDetectionHost,
type WorkspaceAgentDetectionStore
} from '../preflight/workspace-agent-detection'
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
type AgentDetectionContext = {
store: WorkspaceAgentDetectionStore | null | undefined
resolveRepo: (selector: string) => Promise<Repo>
resolveWorktree: (selector: string) => Promise<ResolvedWorktree>
}
/** Resolve from the host's records, including the repo before a workspace exists. */
export async function resolveRuntimeAgentDetectionHost(
context: AgentDetectionContext,
worktreeId: string | null | undefined,
repoId?: string
): Promise<AgentDetectionHost> {
const { store } = context
if (!store) {
if (!worktreeId && repoId) {
throw new Error('repo_not_found')
}
return HOST_DEFAULT_AGENT_DETECTION
}
if (!worktreeId && repoId) {
const repo = await context.resolveRepo(`id:${repoId}`)
const current = store.getRepos?.().filter((entry) => entry.id === repo.id) ?? []
if (
current.length !== 1 ||
current[0].path !== repo.path ||
current[0].executionHostId !== repo.executionHostId ||
getRepoRoutingConnectionId(current[0]) !== getRepoRoutingConnectionId(repo)
) {
throw new Error('repo_target_changed')
}
return resolveWorkspaceAgentDetectionHost(store, {
kind: 'worktree',
repoId: repo.id,
path: repo.path
})
}
if (!worktreeId || worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
return HOST_DEFAULT_AGENT_DETECTION
}
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type === 'folder') {
return resolveWorkspaceAgentDetectionHost(store, {
kind: 'folder',
folderWorkspaceId: scope.folderWorkspaceId
})
}
let worktree: ResolvedWorktree
try {
worktree = await context.resolveWorktree(`id:${worktreeId}`)
} catch (error) {
// Only unknown workspaces retain the legacy default; an ambiguous owner must refuse.
if (error instanceof Error && error.message === 'selector_not_found') {
return HOST_DEFAULT_AGENT_DETECTION
}
throw error
}
return resolveWorkspaceAgentDetectionHost(store, {
kind: 'worktree',
repoId: worktree.repoId,
path: worktree.path,
hostId: worktree.hostId
})
}
@@ -14,14 +14,8 @@ import { RECENT_PTY_OUTPUT_LIMIT, RecentPtyOutputBuffer } from '../../recent-pty
import { appendRecentPtyPathCandidates } from '../../terminal-output-path-candidates'
import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime'
import { resolveLocalProjectRuntimeForWorktreeId } from '../../../local-project-runtime-resolution'
import {
HOST_DEFAULT_AGENT_DETECTION,
resolveWorkspaceAgentDetectionHost,
type AgentDetectionHost
} from '../../../preflight/workspace-agent-detection'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import type { ResolvedWorktree } from '../../runtime-worktree-path-identity'
import type { AgentDetectionHost } from '../../../preflight/workspace-agent-detection'
import { resolveRuntimeAgentDetectionHost } from '../../runtime-agent-detection-host'
import type { RuntimePtyWorktreeRecord } from '../../runtime-terminal-state-records'
import {
localOrchestrationCliCommand,
@@ -263,39 +257,19 @@ export abstract class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends O
: undefined
}
// Why the host's own worktree record: it carries the owning host and the real path, where the
// client's id alone could pick the wrong row for a repo id shared across hosts.
async resolveAgentDetectionHost(
worktreeId: string | null | undefined
worktreeId: string | null | undefined,
repoId?: string
): Promise<AgentDetectionHost> {
if (!this.store || !worktreeId || worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
return HOST_DEFAULT_AGENT_DETECTION
}
const store = this.requireStore()
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type === 'folder') {
return resolveWorkspaceAgentDetectionHost(store, {
kind: 'folder',
folderWorkspaceId: scope.folderWorkspaceId
})
}
let worktree: ResolvedWorktree
try {
worktree = await this.resolveWorktreeSelector(`id:${worktreeId}`)
} catch (error) {
// Only an unknown workspace keeps the answer older clients always got; an ambiguous one
// has no single host, and probing this one would list another machine's agents.
if (error instanceof Error && error.message === 'selector_not_found') {
return HOST_DEFAULT_AGENT_DETECTION
}
throw error
}
return resolveWorkspaceAgentDetectionHost(store, {
kind: 'worktree',
repoId: worktree.repoId,
path: worktree.path,
hostId: worktree.hostId
})
return resolveRuntimeAgentDetectionHost(
{
store: this.store,
resolveRepo: (selector) => this.resolveRepoSelector(selector),
resolveWorktree: (selector) => this.resolveWorktreeSelector(selector)
},
worktreeId,
repoId
)
}
getOrchestrationFleetAgentStatusSnapshot() {
+2 -1
View File
@@ -14,5 +14,6 @@ export const PreflightDetectRemoteWindowsTerminalCapabilities = z.object({
/** Why optional: a client that predates it sends nothing and keeps the host-default probe. */
export const PreflightAgentDetection = z.object({
worktreeId: z.string().min(1).optional()
worktreeId: z.string().min(1).optional(),
repoId: z.string().min(1).optional()
})