fix(runtime): route HUB-owned SSH worktrees through owning runtime (#9994)

This commit is contained in:
OrcaWin 2026-07-22 21:25:05 -04:00 committed by GitHub
parent 3b86405201
commit 41751dd90d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
253 changed files with 16572 additions and 2102 deletions

View File

@ -0,0 +1,32 @@
import { spawnSync } from 'node:child_process'
const result = spawnSync(
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
[
'exec',
'playwright',
'test',
'tests/e2e/nested-runtime-ssh-routing.spec.ts',
'tests/e2e/nested-runtime-ssh-lifecycle.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--workers=1'
],
{
cwd: process.cwd(),
env: {
...process.env,
ORCA_E2E_NESTED_RUNTIME_SSH: '1',
ORCA_E2E_SSH_DOCKER: '1',
ORCA_E2E_WEB_CLIENT: '1'
},
stdio: 'inherit'
}
)
if (result.error) {
throw result.error
}
process.exit(result.status ?? 1)

View File

@ -90,6 +90,7 @@
"test:e2e:terminal-perf:html-report": "node config/scripts/generate-terminal-perf-html-report.mjs",
"test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs",
"test:e2e:ssh-docker-watcher-isolation": "node config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs",
"test:e2e:nested-runtime-ssh": "node config/scripts/run-nested-runtime-ssh-e2e.mjs",
"test:e2e:source-control-scale": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/source-control-large-file-count.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"win-update-e2e": "node tools/win-update-e2e/run.mjs",
"win-crash-survival-e2e": "node tools/win-crash-survival-e2e/run.mjs",

View File

@ -4,7 +4,7 @@
{
"name": "computer-use",
"sourcePath": "skills/computer-use",
"releaseRevision": 6,
"releaseRevision": 8,
"packageDigest": "d1b4850c9a9ee9a32b855176c31cd357608bfedc845319c97e89960296303430",
"gitTreeSha": "2072384f53670cb61d93f4f6264ad2d8f6b5239c",
"files": [
@ -22,7 +22,7 @@
{
"name": "linear-tickets",
"sourcePath": "skills/linear-tickets",
"releaseRevision": 8,
"releaseRevision": 10,
"packageDigest": "cbb9496d069da8a2490343c44967a9086698102806b2312ec9fba313be960bf3",
"gitTreeSha": "1047772e2422647d8c36f850f22d4182f9f87c61",
"files": [
@ -58,7 +58,7 @@
{
"name": "orca-emulator",
"sourcePath": "skills/orca-emulator",
"releaseRevision": 5,
"releaseRevision": 7,
"packageDigest": "cdfb39ffae0cfcab33d57bc279776d3a18fcbf975331dd64cdab757148173a49",
"gitTreeSha": "ad1ecea6dfda6c0c79b06c2b87df290ba97cea2c",
"files": [
@ -76,7 +76,7 @@
{
"name": "orca-emulator-android",
"sourcePath": "skills/orca-emulator-android",
"releaseRevision": 3,
"releaseRevision": 5,
"packageDigest": "cd0b1a4c017e1f98fff073b80396c7f852ab793ecdae96e8ad63f580e2a2ed6e",
"gitTreeSha": "9e270499eef6bc00c1d578f527ab005fc32e18e2",
"files": [
@ -94,7 +94,7 @@
{
"name": "orca-linear",
"sourcePath": "skills/orca-linear",
"releaseRevision": 6,
"releaseRevision": 8,
"packageDigest": "363e10f9fb00616d983fe19905a0d85d60a6a1b522e5313f625a1b1dc801e890",
"gitTreeSha": "091d9bcc279d7ec7f4d3f63929f01f8b9e3db68d",
"files": [
@ -112,7 +112,7 @@
{
"name": "orca-per-workspace-env",
"sourcePath": "skills/orca-per-workspace-env",
"releaseRevision": 3,
"releaseRevision": 5,
"packageDigest": "9c96ed37a89d4959d05ab1565a81fc80d68f00174c2873b2efb81e20daef8e1d",
"gitTreeSha": "942b9397139f9d5b6cd4164339c965c35494985d",
"files": [
@ -130,7 +130,7 @@
{
"name": "orchestration",
"sourcePath": "skills/orchestration",
"releaseRevision": 26,
"releaseRevision": 28,
"packageDigest": "ef5d5a744cdc700c51b4870cd2536b65b0b33d19413dfe238d43efdd01b5d14c",
"gitTreeSha": "9aa26fde93c0592e5983cdca1ccd33b402802255",
"files": [

View File

@ -587,6 +587,32 @@
"orca-per-workspace-env": 2,
"orchestration": 25
}
},
{
"appVersion": "1.4.151-rc.1",
"skills": {
"computer-use": 6,
"linear-tickets": 8,
"orca-cli": 35,
"orca-emulator": 5,
"orca-emulator-android": 3,
"orca-linear": 6,
"orca-per-workspace-env": 3,
"orchestration": 26
}
},
{
"appVersion": "1.4.151",
"skills": {
"computer-use": 7,
"linear-tickets": 9,
"orca-cli": 35,
"orca-emulator": 6,
"orca-emulator-android": 4,
"orca-linear": 7,
"orca-per-workspace-env": 4,
"orchestration": 27
}
}
]
}

View File

@ -979,6 +979,38 @@
"identitySha256": "9ca228137b9a442b98c761aa07adecc2265708132ab175ad7e22b163fdc0bd7f"
}
]
},
{
"releaseRevision": 27,
"packageDigest": "c19171d213e827bdf5364b733b67889566aaa2fb3667ebe029db87044d08f908",
"gitTreeSha": "da346803bccae7fb1fdade31bbe9b4d851b25e38",
"files": [
{
"path": "SKILL.md",
"size": 22676,
"executable": false,
"classification": "text",
"exactSha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305",
"textNormalizedSha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305",
"identitySha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305"
}
]
},
{
"releaseRevision": 28,
"packageDigest": "ef5d5a744cdc700c51b4870cd2536b65b0b33d19413dfe238d43efdd01b5d14c",
"gitTreeSha": "9aa26fde93c0592e5983cdca1ccd33b402802255",
"files": [
{
"path": "SKILL.md",
"size": 4220,
"executable": false,
"classification": "text",
"exactSha256": "9ca228137b9a442b98c761aa07adecc2265708132ab175ad7e22b163fdc0bd7f",
"textNormalizedSha256": "9ca228137b9a442b98c761aa07adecc2265708132ab175ad7e22b163fdc0bd7f",
"identitySha256": "9ca228137b9a442b98c761aa07adecc2265708132ab175ad7e22b163fdc0bd7f"
}
]
}
],
"mobile-fit-debug": [
@ -1095,6 +1127,38 @@
"identitySha256": "c4a11596b7c0338f4c991b24ba7ba453d93fb8dc045c642c517e7ae6d3c88467"
}
]
},
{
"releaseRevision": 7,
"packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68",
"gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344",
"files": [
{
"path": "SKILL.md",
"size": 11241,
"executable": false,
"classification": "text",
"exactSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39",
"textNormalizedSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39",
"identitySha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39"
}
]
},
{
"releaseRevision": 8,
"packageDigest": "d1b4850c9a9ee9a32b855176c31cd357608bfedc845319c97e89960296303430",
"gitTreeSha": "2072384f53670cb61d93f4f6264ad2d8f6b5239c",
"files": [
{
"path": "SKILL.md",
"size": 3667,
"executable": false,
"classification": "text",
"exactSha256": "c4a11596b7c0338f4c991b24ba7ba453d93fb8dc045c642c517e7ae6d3c88467",
"textNormalizedSha256": "c4a11596b7c0338f4c991b24ba7ba453d93fb8dc045c642c517e7ae6d3c88467",
"identitySha256": "c4a11596b7c0338f4c991b24ba7ba453d93fb8dc045c642c517e7ae6d3c88467"
}
]
}
],
"orca-emulator": [
@ -1177,6 +1241,38 @@
"identitySha256": "796f2135824e0ecdfe4f6e8f8bd4690788c1816933df4104b2f9846ffe9a41e0"
}
]
},
{
"releaseRevision": 6,
"packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b",
"gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656",
"files": [
{
"path": "SKILL.md",
"size": 11527,
"executable": false,
"classification": "text",
"exactSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429",
"textNormalizedSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429",
"identitySha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429"
}
]
},
{
"releaseRevision": 7,
"packageDigest": "cdfb39ffae0cfcab33d57bc279776d3a18fcbf975331dd64cdab757148173a49",
"gitTreeSha": "ad1ecea6dfda6c0c79b06c2b87df290ba97cea2c",
"files": [
{
"path": "SKILL.md",
"size": 3724,
"executable": false,
"classification": "text",
"exactSha256": "796f2135824e0ecdfe4f6e8f8bd4690788c1816933df4104b2f9846ffe9a41e0",
"textNormalizedSha256": "796f2135824e0ecdfe4f6e8f8bd4690788c1816933df4104b2f9846ffe9a41e0",
"identitySha256": "796f2135824e0ecdfe4f6e8f8bd4690788c1816933df4104b2f9846ffe9a41e0"
}
]
}
],
"linear-tickets": [
@ -1307,6 +1403,38 @@
"identitySha256": "d2dec89eca8c71c820ee2dbd7bae4fb8528775554dbc6c7a71ed8a3422f53d23"
}
]
},
{
"releaseRevision": 9,
"packageDigest": "ff9f085631f753f059c631d874177ddd4fa847c5eca85a420dc85fb2bece6ff6",
"gitTreeSha": "e35ac3c0c583661983d3fc1352ff3aec74e67e8c",
"files": [
{
"path": "SKILL.md",
"size": 12466,
"executable": false,
"classification": "text",
"exactSha256": "ea2a508c60ab145981f5b16fbed949c4a4c167ec4df16888cf1703fd4c6056c0",
"textNormalizedSha256": "ea2a508c60ab145981f5b16fbed949c4a4c167ec4df16888cf1703fd4c6056c0",
"identitySha256": "ea2a508c60ab145981f5b16fbed949c4a4c167ec4df16888cf1703fd4c6056c0"
}
]
},
{
"releaseRevision": 10,
"packageDigest": "cbb9496d069da8a2490343c44967a9086698102806b2312ec9fba313be960bf3",
"gitTreeSha": "1047772e2422647d8c36f850f22d4182f9f87c61",
"files": [
{
"path": "SKILL.md",
"size": 4148,
"executable": false,
"classification": "text",
"exactSha256": "d2dec89eca8c71c820ee2dbd7bae4fb8528775554dbc6c7a71ed8a3422f53d23",
"textNormalizedSha256": "d2dec89eca8c71c820ee2dbd7bae4fb8528775554dbc6c7a71ed8a3422f53d23",
"identitySha256": "d2dec89eca8c71c820ee2dbd7bae4fb8528775554dbc6c7a71ed8a3422f53d23"
}
]
}
],
"orca-linear": [
@ -1405,6 +1533,38 @@
"identitySha256": "39241e0aa2929344e3b38407215d737fb35de8421b4efb5cf2c767f91d0e7a9b"
}
]
},
{
"releaseRevision": 7,
"packageDigest": "5e9622bd3883c0f53e6bd349758096deafceebd2fa260d3e90d677e64d06416d",
"gitTreeSha": "f3727995a4719fd522119eca6d1b57542cb5fe23",
"files": [
{
"path": "SKILL.md",
"size": 12190,
"executable": false,
"classification": "text",
"exactSha256": "af855a87af929e2da19d51c46e5f2bf156b026c6f3b9cfbf23708a0d53b6a764",
"textNormalizedSha256": "af855a87af929e2da19d51c46e5f2bf156b026c6f3b9cfbf23708a0d53b6a764",
"identitySha256": "af855a87af929e2da19d51c46e5f2bf156b026c6f3b9cfbf23708a0d53b6a764"
}
]
},
{
"releaseRevision": 8,
"packageDigest": "363e10f9fb00616d983fe19905a0d85d60a6a1b522e5313f625a1b1dc801e890",
"gitTreeSha": "091d9bcc279d7ec7f4d3f63929f01f8b9e3db68d",
"files": [
{
"path": "SKILL.md",
"size": 3902,
"executable": false,
"classification": "text",
"exactSha256": "39241e0aa2929344e3b38407215d737fb35de8421b4efb5cf2c767f91d0e7a9b",
"textNormalizedSha256": "39241e0aa2929344e3b38407215d737fb35de8421b4efb5cf2c767f91d0e7a9b",
"identitySha256": "39241e0aa2929344e3b38407215d737fb35de8421b4efb5cf2c767f91d0e7a9b"
}
]
}
],
"orca-emulator-android": [
@ -1455,6 +1615,38 @@
"identitySha256": "41d9cae07abd03a39236733884332058316bcf816e4b5b2d411c01b3a16ac8a6"
}
]
},
{
"releaseRevision": 4,
"packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5",
"gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83",
"files": [
{
"path": "SKILL.md",
"size": 8886,
"executable": false,
"classification": "text",
"exactSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332",
"textNormalizedSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332",
"identitySha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332"
}
]
},
{
"releaseRevision": 5,
"packageDigest": "cd0b1a4c017e1f98fff073b80396c7f852ab793ecdae96e8ad63f580e2a2ed6e",
"gitTreeSha": "9e270499eef6bc00c1d578f527ab005fc32e18e2",
"files": [
{
"path": "SKILL.md",
"size": 3529,
"executable": false,
"classification": "text",
"exactSha256": "41d9cae07abd03a39236733884332058316bcf816e4b5b2d411c01b3a16ac8a6",
"textNormalizedSha256": "41d9cae07abd03a39236733884332058316bcf816e4b5b2d411c01b3a16ac8a6",
"identitySha256": "41d9cae07abd03a39236733884332058316bcf816e4b5b2d411c01b3a16ac8a6"
}
]
}
],
"orca-per-workspace-env": [
@ -1505,6 +1697,38 @@
"identitySha256": "a7ae9a0d22b8bc14a6cb3bdb6fc6ebf1f11cc25ab489d1cc63928bd025d7dddc"
}
]
},
{
"releaseRevision": 4,
"packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d",
"gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650",
"files": [
{
"path": "SKILL.md",
"size": 43769,
"executable": false,
"classification": "text",
"exactSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7",
"textNormalizedSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7",
"identitySha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7"
}
]
},
{
"releaseRevision": 5,
"packageDigest": "9c96ed37a89d4959d05ab1565a81fc80d68f00174c2873b2efb81e20daef8e1d",
"gitTreeSha": "942b9397139f9d5b6cd4164339c965c35494985d",
"files": [
{
"path": "SKILL.md",
"size": 4222,
"executable": false,
"classification": "text",
"exactSha256": "a7ae9a0d22b8bc14a6cb3bdb6fc6ebf1f11cc25ab489d1cc63928bd025d7dddc",
"textNormalizedSha256": "a7ae9a0d22b8bc14a6cb3bdb6fc6ebf1f11cc25ab489d1cc63928bd025d7dddc",
"identitySha256": "a7ae9a0d22b8bc14a6cb3bdb6fc6ebf1f11cc25ab489d1cc63928bd025d7dddc"
}
]
}
]
}

View File

@ -2261,6 +2261,11 @@ app.whenReady().then(async () => {
})
// Why: parallel E2E Electron instances would race the fixed port (EADDRINUSE); port 0 gives each a random OS-assigned port.
const isE2E = Boolean(process.env.ORCA_E2E_USER_DATA_DIR)
const requestedE2EWsPort = process.env.ORCA_E2E_RUNTIME_WS_PORT
const e2eWsPort = requestedE2EWsPort === undefined ? 0 : Number(requestedE2EWsPort)
if (isE2E && (!Number.isInteger(e2eWsPort) || e2eWsPort < 0 || e2eWsPort > 65_535)) {
throw new Error(`Invalid ORCA_E2E_RUNTIME_WS_PORT value: ${requestedE2EWsPort}`)
}
// Why: pin dev to 6769 so `pnpm dev` doesn't race packaged Orca on 6768 and fall back to a random port, breaking deterministic mobile pairing/repro (STA-1511).
const devWsPort = is.dev && !isE2E ? 6769 : undefined
let serveOptions: ServeOptions | null = null
@ -2278,7 +2283,7 @@ app.whenReady().then(async () => {
// Why: mobile pairing needs the stable pre-setName() path (getCanonicalUserDataPath), not a late app.getPath('userData') that drops paired devices across restarts.
userDataPath: getCanonicalUserDataPath(),
enableWebSocket: true,
...(isE2E ? { wsPort: 0 } : {}),
...(isE2E ? { wsPort: e2eWsPort } : {}),
...(devWsPort !== undefined ? { wsPort: devWsPort } : {}),
...(serveOptions?.wsPort !== undefined
? {

View File

@ -30,6 +30,7 @@ import {
removeRuntimeOwnedSshTarget
} from '../ephemeral-vm-runtime-ssh'
import { getRecipeRepo, getRuntimeRecipeContext } from './ephemeral-vm-recipe-context'
import { invalidateRuntimeEnvironmentTransport } from './runtime-environments'
export type EphemeralVmCleanupCommandResult = {
runtimeId: string
@ -192,6 +193,7 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
updateEnvironmentFromPairingCode(userDataPath, runtime.runtimeEnvironmentId, {
pairingCode
})
invalidateRuntimeEnvironmentTransport(runtime.runtimeEnvironmentId)
}
const connection = getEphemeralVmRecipeResultConnection(result.runtime.recipeResult)
if (!result.skipped && connection.type === 'ssh') {

View File

@ -12,14 +12,16 @@ const {
getPathMock,
connectRuntimeOwnedSshTargetMock,
disconnectRuntimeOwnedSshTargetMock,
removeRuntimeOwnedSshTargetMock
removeRuntimeOwnedSshTargetMock,
invalidateRuntimeEnvironmentTransportMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
removeHandlerMock: vi.fn(),
getPathMock: vi.fn(),
connectRuntimeOwnedSshTargetMock: vi.fn(),
disconnectRuntimeOwnedSshTargetMock: vi.fn(),
removeRuntimeOwnedSshTargetMock: vi.fn()
removeRuntimeOwnedSshTargetMock: vi.fn(),
invalidateRuntimeEnvironmentTransportMock: vi.fn()
}))
vi.mock('electron', () => ({
@ -38,6 +40,10 @@ vi.mock('../ephemeral-vm-runtime-ssh', () => ({
removeRuntimeOwnedSshTarget: removeRuntimeOwnedSshTargetMock
}))
vi.mock('./runtime-environments', () => ({
invalidateRuntimeEnvironmentTransport: invalidateRuntimeEnvironmentTransportMock
}))
import { registerEphemeralVmHandlers } from './ephemeral-vm'
const tempDirs: string[] = []
@ -98,6 +104,7 @@ describe('registerEphemeralVmHandlers', () => {
connectRuntimeOwnedSshTargetMock.mockReset()
disconnectRuntimeOwnedSshTargetMock.mockReset()
removeRuntimeOwnedSshTargetMock.mockReset()
invalidateRuntimeEnvironmentTransportMock.mockReset()
connectRuntimeOwnedSshTargetMock.mockResolvedValue({
targetId: 'runtime-ssh-orca-instance-1',
target: {
@ -493,6 +500,10 @@ describe('registerEphemeralVmHandlers', () => {
expect(suspended).toEqual(expect.objectContaining({ status: 'suspended' }))
expect(readFileSync(join(repoPath, 'suspend-mode.txt'), 'utf8')).toBe('suspend')
invalidateRuntimeEnvironmentTransportMock.mockImplementationOnce((environmentId: string) => {
const environment = listEnvironments(userDataPath).find((entry) => entry.id === environmentId)
expect(environment?.endpoints[0]?.endpoint).toBe('wss://resumed.example.com')
})
const resumed = await handlers.get('ephemeralVm:resumeWorkspace')?.(null, {
workspaceId: 'workspace-1'
} as never)
@ -507,6 +518,9 @@ describe('registerEphemeralVmHandlers', () => {
(entry) => entry.id === provisioned.environment.id
)
expect(environment?.endpoints[0]?.endpoint).toBe('wss://resumed.example.com')
expect(invalidateRuntimeEnvironmentTransportMock).toHaveBeenCalledWith(
provisioned.environment.id
)
})
it('returns a copyable cleanup command for a persisted runtime', async () => {

View File

@ -45,7 +45,8 @@ export async function uploadSshImportDirectory(
localDir: string,
remoteDir: string,
rootRealPath: string,
remotePathFlavor: RemotePathFlavor
remotePathFlavor: RemotePathFlavor,
assertCurrent?: () => void
): Promise<void> {
await assertLocalUploadPathInsideRoot(rootRealPath, localDir)
const entries = await readdir(localDir, { withFileTypes: true })
@ -63,6 +64,7 @@ export async function uploadSshImportDirectory(
}
if (statResult.isDirectory()) {
assertCurrent?.()
await provider.createDirNoClobber(remotePath)
await uploadSshImportDirectory(
provider,
@ -70,10 +72,12 @@ export async function uploadSshImportDirectory(
localPath,
remotePath,
rootRealPath,
remotePathFlavor
remotePathFlavor,
assertCurrent
)
continue
}
assertCurrent?.()
await uploadSession.uploadFile(localPath, remotePath, { exclusive: true })
}
}

View File

@ -34,6 +34,10 @@ vi.mock('fs/promises', () => ({
vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock }))
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
import {
advanceSshConnectionGeneration,
resetSshConnectionGenerations
} from '../ssh/ssh-connection-generation'
import {
registerSshFilesystemProvider,
unregisterSshFilesystemProvider
@ -112,7 +116,15 @@ describe('fs:importExternalPaths — SSH operations', () => {
})
}
const invoke = (args: Record<string, unknown>) =>
handlers.get('fs:importExternalPaths')!(null, args) as Promise<{
handlers.get('fs:importExternalPaths')!(null, {
...args,
...(typeof args.connectionId === 'string'
? {
expectedSshTargetId: args.expectedSshTargetId ?? args.connectionId,
expectedSshConnectionGeneration: args.expectedSshConnectionGeneration ?? 0
}
: {})
}) as Promise<{
results: Record<string, unknown>[]
}>
@ -145,6 +157,42 @@ describe('fs:importExternalPaths — SSH operations', () => {
afterEach(() => {
unregisterSshFilesystemProvider(connId)
resetSshConnectionGenerations()
})
it('rejects a staged upload when a restarted HUB reaches the same target counter', async () => {
resetSshConnectionGenerations(71)
const stagedGeneration = advanceSshConnectionGeneration(connId)
const sourcePath = path.resolve('/tmp/dropped/restart.txt')
lstatMock.mockImplementation(async (candidate: string) => {
if (candidate !== sourcePath) {
throw enoent()
}
resetSshConnectionGenerations(72)
advanceSshConnectionGeneration(connId)
return {
size: 12,
ino: 1,
dev: 1,
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false
}
})
const { results } = await invoke({
sourcePaths: [sourcePath],
destDir,
connectionId: connId,
expectedSshTargetId: connId,
expectedSshConnectionGeneration: stagedGeneration
})
expect(results[0]).toMatchObject({
status: 'failed',
reason: 'SSH connection changed; refresh and try again'
})
expect(uploadSession.uploadFile).not.toHaveBeenCalled()
})
it('deconflicts file names via provider stat', async () => {

View File

@ -47,6 +47,7 @@ import {
registerSshFilesystemProvider,
unregisterSshFilesystemProvider
} from '../providers/ssh-filesystem-dispatch'
import { resetSshConnectionGenerations } from '../ssh/ssh-connection-generation'
const store = {
getRepos: () => [
@ -113,12 +114,22 @@ describe('fs:importExternalPaths — SSH routing & connection', () => {
})
}
const invoke = (args: Record<string, unknown>) =>
handlers.get('fs:importExternalPaths')!(null, args) as Promise<{
handlers.get('fs:importExternalPaths')!(
null,
typeof args.connectionId === 'string'
? {
...args,
expectedSshTargetId: args.connectionId,
expectedSshConnectionGeneration: 0
}
: args
) as Promise<{
results: Record<string, unknown>[]
}>
beforeEach(() => {
handlers.clear()
resetSshConnectionGenerations()
;[
handleMock,
lstatMock,

View File

@ -19,7 +19,7 @@ export async function importExternalPathsSsh(
sourcePaths: string[],
destDir: string,
connectionId: string,
options?: { ensureDir?: boolean }
options?: { ensureDir?: boolean; assertCurrent?: () => void }
): Promise<{ results: ImportItemResult[] }> {
if (sourcePaths.length === 0) {
return { results: [] }
@ -45,7 +45,7 @@ export async function importExternalPathsSsh(
// Why: terminal-drop staging needs `${worktree}/.orca/drops` to exist
// before the first upload. .orca/ is reserved as Orca-owned remote state;
// see docs/terminal-drop-ssh.md.
await ensureDropStagingDir(provider, destDir)
await ensureDropStagingDir(provider, destDir, options.assertCurrent)
}
const results: ImportItemResult[] = []
@ -53,6 +53,7 @@ export async function importExternalPathsSsh(
if (!provider.openFileUploadSession) {
throw new Error('Remote file upload is unavailable. Reconnect the SSH target and retry.')
}
options?.assertCurrent?.()
const uploadSession = await provider.openFileUploadSession()
// Why: filename legality follows the remote filesystem, not the client's OS.
const remotePathFlavor: RemotePathFlavor = isWindowsAbsolutePathLike(destDir)
@ -66,7 +67,8 @@ export async function importExternalPathsSsh(
sourcePath,
destDir,
reservedNames,
remotePathFlavor
remotePathFlavor,
options?.assertCurrent
)
results.push(result)
if (result.status === 'imported') {
@ -89,7 +91,8 @@ async function importOneSourceSsh(
sourcePath: string,
destDir: string,
reservedNames: Set<string>,
remotePathFlavor: RemotePathFlavor
remotePathFlavor: RemotePathFlavor,
assertCurrent?: () => void
): Promise<ImportItemResult> {
const resolvedSource = resolve(sourcePath)
@ -145,11 +148,20 @@ async function importOneSourceSsh(
return { sourcePath, status: 'skipped', reason: 'symlink' }
}
const finalName = await deconflictName(provider, destDir, originalName, reservedNames)
// Why: local inspection can outlive a HUB SSH session; revalidate before the first remote write.
assertCurrent?.()
const finalName = await deconflictName(
provider,
destDir,
originalName,
reservedNames,
assertCurrent
)
const destPath = `${destDir}/${finalName}`
const renamed = finalName !== originalName
if (isDir) {
assertCurrent?.()
await provider.createDirNoClobber(destPath)
createdDestDir = destPath
await uploadSshImportDirectory(
@ -158,9 +170,11 @@ async function importOneSourceSsh(
resolvedSource,
destPath,
rootRealPath!,
remotePathFlavor
remotePathFlavor,
assertCurrent
)
} else {
assertCurrent?.()
await uploadSession.uploadFile(resolvedSource, destPath, { exclusive: true })
}
@ -175,7 +189,12 @@ async function importOneSourceSsh(
if (createdDestDir) {
// Why: local directory imports roll back partial output; SSH imports
// should not leave the no-clobber root after a nested upload failure.
await provider.deletePath(createdDestDir, true).catch(() => {})
try {
assertCurrent?.()
await provider.deletePath(createdDestDir, true)
} catch {
// Best effort; a replacement session must never inherit cleanup from the retired owner.
}
}
return {
sourcePath,
@ -189,8 +208,10 @@ async function deconflictName(
provider: IFilesystemProvider,
destDir: string,
originalName: string,
reservedNames: Set<string>
reservedNames: Set<string>,
assertCurrent?: () => void
): Promise<string> {
assertCurrent?.()
if (
!(await remotePathExists(provider, `${destDir}/${originalName}`)) &&
!reservedNames.has(originalName)
@ -204,6 +225,7 @@ async function deconflictName(
const ext = hasMeaningfulExt ? originalName.slice(dotIndex) : ''
let candidate = `${stem} copy${ext}`
assertCurrent?.()
if (
!(await remotePathExists(provider, `${destDir}/${candidate}`)) &&
!reservedNames.has(candidate)
@ -214,6 +236,7 @@ async function deconflictName(
let counter = 2
while (counter < 10000) {
candidate = `${stem} copy ${counter}${ext}`
assertCurrent?.()
if (
!(await remotePathExists(provider, `${destDir}/${candidate}`)) &&
!reservedNames.has(candidate)
@ -228,13 +251,21 @@ async function deconflictName(
)
}
async function ensureDropStagingDir(provider: IFilesystemProvider, destDir: string): Promise<void> {
async function ensureDropStagingDir(
provider: IFilesystemProvider,
destDir: string,
assertCurrent?: () => void
): Promise<void> {
const parent = posix.dirname(destDir)
assertCurrent?.()
await provider.createDir(parent)
const gitignorePath = `${parent}/.gitignore`
assertCurrent?.()
if (!(await remotePathExists(provider, gitignorePath))) {
assertCurrent?.()
await provider.writeFile(gitignorePath, '*\n!.gitignore\n')
}
assertCurrent?.()
await provider.createDir(destDir)
}

View File

@ -32,6 +32,10 @@ import {
registerSshFilesystemProvider,
unregisterSshFilesystemProvider
} from '../providers/ssh-filesystem-dispatch'
import {
resetSshConnectionGenerations,
setSshConnectionGeneration
} from '../ssh/ssh-connection-generation'
// Why: paths are resolved via path.resolve() in production code, so test
// data must use resolved paths to avoid Unix-vs-Windows mismatches.
@ -72,6 +76,7 @@ describe('registerFilesystemMutationHandlers', () => {
renameMock.mockReset()
writeFileMock.mockReset()
realpathMock.mockReset()
resetSshConnectionGenerations()
handleMock.mockImplementation((channel: string, handler: never) => {
handlers.set(channel, handler)
@ -281,7 +286,9 @@ describe('registerFilesystemMutationHandlers', () => {
await handlers.get('fs:rename')!(null, {
oldPath: '/home/me/repo/old.ts',
newPath: '/home/me/repo/new.ts',
connectionId: 'ssh-1'
connectionId: 'ssh-1',
expectedSshTargetId: 'ssh-1',
expectedSshConnectionGeneration: 0
})
} finally {
unregisterSshFilesystemProvider('ssh-1')
@ -300,7 +307,9 @@ describe('registerFilesystemMutationHandlers', () => {
handlers.get('fs:rename')!(null, {
oldPath: '/home/me/repo/old.ts',
newPath: '/home/me/repo/new.ts',
connectionId: 'ssh-1'
connectionId: 'ssh-1',
expectedSshTargetId: 'ssh-1',
expectedSshConnectionGeneration: 0
})
).rejects.toThrow('destination exists')
} finally {
@ -310,6 +319,120 @@ describe('registerFilesystemMutationHandlers', () => {
expect(renameMock).not.toHaveBeenCalled()
})
it('rejects direct SSH rename without target-bound generation provenance', async () => {
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never)
try {
await expect(
handlers.get('fs:rename')!(null, {
oldPath: '/home/me/repo/old.ts',
newPath: '/home/me/repo/new.ts',
connectionId: 'ssh-1'
})
).rejects.toThrow('SSH connection changed')
} finally {
unregisterSshFilesystemProvider('ssh-1')
}
expect(renameNoClobber).not.toHaveBeenCalled()
})
it('rejects equal-generation provenance for another direct SSH target', async () => {
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
registerSshFilesystemProvider('ssh-b', { renameNoClobber } as never)
try {
await expect(
handlers.get('fs:rename')!(null, {
oldPath: '/home/me/repo/old.ts',
newPath: '/home/me/repo/new.ts',
connectionId: 'ssh-b',
expectedSshTargetId: 'ssh-a',
expectedSshConnectionGeneration: 0
})
).rejects.toThrow('SSH connection changed')
} finally {
unregisterSshFilesystemProvider('ssh-b')
}
expect(renameNoClobber).not.toHaveBeenCalled()
})
it('rejects stale generation provenance for a direct SSH target', async () => {
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never)
setSshConnectionGeneration('ssh-1', 8)
try {
await expect(
handlers.get('fs:rename')!(null, {
oldPath: '/home/me/repo/old.ts',
newPath: '/home/me/repo/new.ts',
connectionId: 'ssh-1',
expectedSshTargetId: 'ssh-1',
expectedSshConnectionGeneration: 7
})
).rejects.toThrow('SSH connection changed')
} finally {
unregisterSshFilesystemProvider('ssh-1')
}
expect(renameNoClobber).not.toHaveBeenCalled()
})
it('rejects stale SSH provenance when a direct mutation resolves local', async () => {
await expect(
handlers.get('fs:rename')!(null, {
oldPath: path.resolve('/workspace/repo/old.ts'),
newPath: path.resolve('/workspace/repo/new.ts'),
expectedSshTargetId: 'ssh-1',
expectedSshConnectionGeneration: 0
})
).rejects.toThrow('SSH connection changed')
expect(renameMock).not.toHaveBeenCalled()
})
it.each([
['fs:createFile', { filePath: path.resolve('/workspace/repo/new.ts') }],
['fs:createDir', { dirPath: path.resolve('/workspace/repo/new-dir') }],
[
'fs:rename',
{
oldPath: path.resolve('/workspace/repo/old.ts'),
newPath: path.resolve('/workspace/repo/new.ts')
}
],
[
'fs:copy',
{
sourcePath: path.resolve('/workspace/repo/source.ts'),
destinationPath: path.resolve('/workspace/repo/copy.ts')
}
],
[
'fs:importExternalPaths',
{ sourcePaths: [path.resolve('/tmp/source.ts')], destDir: path.resolve('/workspace/repo') }
],
[
'fs:resolveDroppedPathsForAgent',
{ paths: [path.resolve('/tmp/source.ts')], worktreePath: path.resolve('/workspace/repo') }
]
])(
'rejects %s before local fallback when the expected execution host is SSH',
async (channel, args) => {
await expect(
handlers.get(channel)!(null, { ...args, expectedExecutionHostId: 'ssh:ssh-1' })
).rejects.toThrow('Workspace host changed; refresh and try again')
expect(writeFileMock).not.toHaveBeenCalled()
expect(mkdirMock).not.toHaveBeenCalled()
expect(renameMock).not.toHaveBeenCalled()
expect(copyFileMock).not.toHaveBeenCalled()
}
)
// ── fs:copy ────────────────────────────────────────────────────
it('copies a file without overwriting an existing destination', async () => {
@ -330,7 +453,9 @@ describe('registerFilesystemMutationHandlers', () => {
await handlers.get('fs:copy')!(null, {
sourcePath: '/home/me/repo/source.ts',
destinationPath: '/home/me/repo/source copy.ts',
connectionId: 'ssh-1'
connectionId: 'ssh-1',
expectedSshTargetId: 'ssh-1',
expectedSshConnectionGeneration: 0
})
} finally {
unregisterSshFilesystemProvider('ssh-1')

View File

@ -22,6 +22,8 @@ import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispat
import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution'
import { importExternalPathsSsh } from './filesystem-import-ssh'
import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision'
import type { SshMutationExpectation } from '../../shared/ssh-types'
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
/**
* Re-throw filesystem errors with user-friendly messages.
@ -70,7 +72,16 @@ async function assertNotExists(targetPath: string): Promise<void> {
export function registerFilesystemMutationHandlers(store: Store): void {
ipcMain.handle(
'fs:createFile',
async (_event, args: { filePath: string; connectionId?: string }): Promise<void> => {
async (
_event,
args: { filePath: string; connectionId?: string } & SshMutationExpectation
): Promise<void> => {
assertSshMutationExpectation(
args.connectionId,
args.expectedSshTargetId,
args.expectedSshConnectionGeneration,
args.expectedExecutionHostId
)
if (args.connectionId) {
const provider = requireSshFilesystemProvider(args.connectionId)
return provider.createFile(args.filePath)
@ -88,7 +99,16 @@ export function registerFilesystemMutationHandlers(store: Store): void {
ipcMain.handle(
'fs:createDir',
async (_event, args: { dirPath: string; connectionId?: string }): Promise<void> => {
async (
_event,
args: { dirPath: string; connectionId?: string } & SshMutationExpectation
): Promise<void> => {
assertSshMutationExpectation(
args.connectionId,
args.expectedSshTargetId,
args.expectedSshConnectionGeneration,
args.expectedExecutionHostId
)
if (args.connectionId) {
const provider = requireSshFilesystemProvider(args.connectionId)
return provider.createDir(args.dirPath)
@ -106,8 +126,14 @@ export function registerFilesystemMutationHandlers(store: Store): void {
'fs:rename',
async (
_event,
args: { oldPath: string; newPath: string; connectionId?: string }
args: { oldPath: string; newPath: string; connectionId?: string } & SshMutationExpectation
): Promise<void> => {
assertSshMutationExpectation(
args.connectionId,
args.expectedSshTargetId,
args.expectedSshConnectionGeneration,
args.expectedExecutionHostId
)
if (args.connectionId) {
const provider = requireSshFilesystemProvider(args.connectionId)
return provider.renameNoClobber(args.oldPath, args.newPath)
@ -129,8 +155,18 @@ export function registerFilesystemMutationHandlers(store: Store): void {
'fs:copy',
async (
_event,
args: { sourcePath: string; destinationPath: string; connectionId?: string }
args: {
sourcePath: string
destinationPath: string
connectionId?: string
} & SshMutationExpectation
): Promise<void> => {
assertSshMutationExpectation(
args.connectionId,
args.expectedSshTargetId,
args.expectedSshConnectionGeneration,
args.expectedExecutionHostId
)
if (args.connectionId) {
const provider = requireSshFilesystemProvider(args.connectionId)
return provider.copy(args.sourcePath, args.destinationPath)
@ -152,11 +188,29 @@ export function registerFilesystemMutationHandlers(store: Store): void {
'fs:importExternalPaths',
async (
_event,
args: { sourcePaths: string[]; destDir: string; connectionId?: string; ensureDir?: boolean }
args: {
sourcePaths: string[]
destDir: string
connectionId?: string
ensureDir?: boolean
} & SshMutationExpectation
): Promise<{ results: ImportItemResult[] }> => {
assertSshMutationExpectation(
args.connectionId,
args.expectedSshTargetId,
args.expectedSshConnectionGeneration,
args.expectedExecutionHostId
)
if (args.connectionId) {
return importExternalPathsSsh(args.sourcePaths, args.destDir, args.connectionId, {
ensureDir: args.ensureDir
ensureDir: args.ensureDir,
assertCurrent: () =>
assertSshMutationExpectation(
args.connectionId,
args.expectedSshTargetId,
args.expectedSshConnectionGeneration,
args.expectedExecutionHostId
)
})
}
@ -205,8 +259,18 @@ export function registerFilesystemMutationHandlers(store: Store): void {
'fs:resolveDroppedPathsForAgent',
async (
_event,
args: { paths: string[]; worktreePath: string; connectionId?: string }
args: {
paths: string[]
worktreePath: string
connectionId?: string
} & SshMutationExpectation
): Promise<ResolveDroppedPathsResult> => {
assertSshMutationExpectation(
args.connectionId,
args.expectedSshTargetId,
args.expectedSshConnectionGeneration,
args.expectedExecutionHostId
)
// Why: `== null` (not `!args.connectionId`) so an empty string is
// treated as a renderer error, not silently routed to the local branch.
if (args.connectionId == null) {
@ -219,7 +283,14 @@ export function registerFilesystemMutationHandlers(store: Store): void {
const worktreePath = args.worktreePath.replace(/\/+$/, '')
const destDir = `${worktreePath}/.orca/drops`
const { results } = await importExternalPathsSsh(args.paths, destDir, args.connectionId, {
ensureDir: true
ensureDir: true,
assertCurrent: () =>
assertSshMutationExpectation(
args.connectionId,
args.expectedSshTargetId,
args.expectedSshConnectionGeneration,
args.expectedExecutionHostId
)
})
const resolvedPaths: string[] = []
const skipped: { sourcePath: string; reason: ImportSkipReason }[] = []

View File

@ -1129,6 +1129,24 @@ describe('registerFilesystemHandlers', () => {
expect(writeFileMock).not.toHaveBeenCalled()
})
it.each([
['fs:writeFile', { filePath: path.resolve('/workspace/repo/file.txt'), content: 'data' }],
['fs:deletePath', { targetPath: path.resolve('/workspace/repo/file.txt') }]
])(
'rejects %s before local mutation when the expected execution host is SSH',
async (channel, args) => {
registerFilesystemHandlers(store as never)
await expect(
handlers.get(channel)!(null, { ...args, expectedExecutionHostId: 'ssh:ssh-1' })
).rejects.toThrow('Workspace host changed; refresh and try again')
expect(writeFileMock).not.toHaveBeenCalled()
expect(trashItemMock).not.toHaveBeenCalled()
expect(tryDeleteWslUncPathMock).not.toHaveBeenCalled()
}
)
it.each([
{ ext: 'png', mime: 'image/png', data: [0x89, 0x50, 0x4e, 0x47, 0x00] },
{ ext: 'pdf', mime: 'application/pdf', data: [0x25, 0x50, 0x44, 0x46, 0x00] },

View File

@ -29,6 +29,8 @@ import type {
TuiAgent
} from '../../shared/types'
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
import type { SshMutationExpectation } from '../../shared/ssh-types'
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
import {
buildRgArgs,
createAccumulator,
@ -807,8 +809,14 @@ export function registerFilesystemHandlers(
'fs:writeFile',
async (
_event,
args: { filePath: string; content: string; connectionId?: string }
args: { filePath: string; content: string; connectionId?: string } & SshMutationExpectation
): Promise<void> => {
assertSshMutationExpectation(
args.connectionId,
args.expectedSshTargetId,
args.expectedSshConnectionGeneration,
args.expectedExecutionHostId
)
if (args.connectionId) {
const provider = requireSshFilesystemProvider(args.connectionId)
return provider.writeFile(args.filePath, args.content)
@ -834,8 +842,18 @@ export function registerFilesystemHandlers(
'fs:deletePath',
async (
_event,
args: { targetPath: string; connectionId?: string; recursive?: boolean }
args: {
targetPath: string
connectionId?: string
recursive?: boolean
} & SshMutationExpectation
): Promise<void> => {
assertSshMutationExpectation(
args.connectionId,
args.expectedSshTargetId,
args.expectedSshConnectionGeneration,
args.expectedExecutionHostId
)
if (args.connectionId) {
const provider = requireSshFilesystemProvider(args.connectionId)
return provider.deletePath(args.targetPath, args.recursive)

View File

@ -694,10 +694,14 @@ describe('registerPtyHandlers', () => {
function registerAgentClaimController(): {
spawn: (args: Record<string, unknown>) => Promise<unknown>
write: (ptyId: string, data: string) => boolean
resize: (ptyId: string, cols: number, rows: number) => boolean
} {
let controller:
| {
spawn: (args: Record<string, unknown>) => Promise<unknown>
write: (ptyId: string, data: string) => boolean
resize: (ptyId: string, cols: number, rows: number) => boolean
}
| undefined
const runtime = {
@ -714,6 +718,34 @@ describe('registerPtyHandlers', () => {
return controller
}
it('fails closed instead of routing encoded SSH PTY writes locally after disconnect', () => {
const connectionId = 'ssh-1'
const ptyId = `ssh:${connectionId}@@remote-pty`
const localProvider = createAgentClaimProvider({})
const sshProvider = createAgentClaimProvider({})
setLocalPtyProvider(localProvider as never)
registerSshPtyProvider(connectionId, sshProvider as never)
setPtyOwnership(ptyId, connectionId)
const controller = registerAgentClaimController()
unregisterSshPtyProvider(connectionId)
clearPtyOwnershipForConnection(connectionId)
expect(controller.write(ptyId, 'input')).toBe(false)
expect(controller.resize(ptyId, 100, 40)).toBe(false)
expect(localProvider.write).not.toHaveBeenCalled()
expect(localProvider.resize).not.toHaveBeenCalled()
registerSshPtyProvider(connectionId, sshProvider as never)
expect(controller.write(ptyId, 'reconnected')).toBe(true)
expect(controller.resize(ptyId, 120, 50)).toBe(true)
expect(sshProvider.write).toHaveBeenCalledWith(ptyId, 'reconnected')
expect(sshProvider.resize).toHaveBeenCalledWith(ptyId, 120, 50)
unregisterSshPtyProvider(connectionId)
clearProviderPtyState(ptyId)
})
it('does not dispatch a runtime PTY spawn after its client disconnects', async () => {
const provider = createAgentClaimProvider({})
setLocalPtyProvider(provider as never)
@ -3647,12 +3679,15 @@ describe('registerPtyHandlers', () => {
state: 'attached'
})
)
expect(store.persistPtyBinding).toHaveBeenCalledWith({
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId,
ptyId: 'ssh-pty'
})
expect(store.persistPtyBinding).toHaveBeenCalledWith(
{
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId,
ptyId: 'ssh-pty'
},
'ssh:ssh-1'
)
store.upsertSshRemotePtyLease.mockClear()
store.persistPtyBinding.mockClear()
@ -7103,12 +7138,15 @@ describe('registerPtyHandlers', () => {
state: 'attached'
})
)
expect(store.persistPtyBinding).toHaveBeenCalledWith({
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
ptyId: 'ssh:ssh-1@@relay-pty'
})
expect(store.persistPtyBinding).toHaveBeenCalledWith(
{
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
ptyId: 'ssh:ssh-1@@relay-pty'
},
'ssh:ssh-1'
)
expect(store.persistPtyBinding.mock.invocationCallOrder[0]!).toBeLessThan(
store.upsertSshRemotePtyLease.mock.invocationCallOrder[0]!
)
@ -7252,12 +7290,15 @@ describe('registerPtyHandlers', () => {
persistHostSessionBinding: true
})
expect(store.persistPtyBinding).toHaveBeenCalledWith({
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
ptyId: 'ssh:ssh-reattach-ok@@relay-pty'
})
expect(store.persistPtyBinding).toHaveBeenCalledWith(
{
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
ptyId: 'ssh:ssh-reattach-ok@@relay-pty'
},
'ssh:ssh-reattach-ok'
)
expect(store.upsertSshRemotePtyLease).toHaveBeenCalledWith(
expect.objectContaining({
targetId: 'ssh-reattach-ok',

View File

@ -15,6 +15,7 @@ export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-rea
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type { Store } from '../persistence'
import type { GlobalSettings, TuiAgent } from '../../shared/types'
import { toSshExecutionHostId } from '../../shared/execution-host'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import { terminalOutputBacklogCapChars } from '../../shared/terminal-scrollback-policy'
import type {
@ -462,6 +463,11 @@ function getProvider(connectionId: string | null | undefined): IPtyProvider {
function getProviderForPty(ptyId: string): IPtyProvider {
const connectionId = ptyOwnership.get(ptyId)
if (connectionId === undefined) {
const parsedSshId = parseAppSshPtyId(ptyId)
if (parsedSshId) {
// Why: disconnected SSH PTYs retain their encoded owner and must never fall through to the HUB-local provider.
return getProvider(parsedSshId.connectionId)
}
return localProvider
}
return getProvider(connectionId)
@ -3441,14 +3447,22 @@ export function registerPtyHandlers(
}
if (hostSessionBinding) {
try {
hostSessionBinding.store.persistPtyBinding({
const binding = {
worktreeId: hostSessionBinding.worktreeId,
tabId: hostSessionBinding.tabId,
leafId: hostSessionBinding.leafId,
ptyId: result.id,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
...(cwd ? { startupCwd: cwd } : {})
})
}
if (args.connectionId) {
hostSessionBinding.store.persistPtyBinding(
binding,
toSshExecutionHostId(args.connectionId)
)
} else {
hostSessionBinding.store.persistPtyBinding(binding)
}
} catch (err) {
console.error('[pty] failed to persist runtime PTY binding after spawn:', err)
deletePtyOwnership(result.id)
@ -3562,9 +3576,8 @@ export function registerPtyHandlers(
}
},
write: (ptyId, data) => {
const provider = getProviderForPty(ptyId)
try {
provider.write(ptyId, data)
getProviderForPty(ptyId).write(ptyId, data)
return true
} catch {
return false
@ -4507,14 +4520,19 @@ export function registerPtyHandlers(
validatedLeafId !== null
) {
try {
store.persistPtyBinding({
const binding = {
worktreeId: args.worktreeId,
tabId: args.tabId,
leafId: validatedLeafId,
ptyId: result.id,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
...(cwd ? { startupCwd: cwd } : {})
})
}
if (args.connectionId) {
store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId))
} else {
store.persistPtyBinding(binding)
}
} catch (err) {
console.error('[pty] failed to persist PTY binding after spawn:', err)
if (!result.isReattach) {

View File

@ -99,6 +99,10 @@ export function getRemoteRuntimeSharedControlDiagnostics(
return sharedControlConnections.get(environmentId)?.connection.getDiagnostics() ?? null
}
export function reconnectRemoteRuntimeSharedControlConnection(environmentId: string): void {
sharedControlConnections.get(environmentId)?.connection.reconnectNow()
}
function getSharedControlConnection(
environmentId: string,
pairing: PairingOffer

View File

@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments'
import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard'
const environment = {
id: 'hub-a',
runtimeId: 'runtime-b',
createdAt: 1,
pairingRevision: 20
} as KnownRuntimeEnvironment
describe('runtimeEnvironmentRevisionFailure', () => {
it('fails a queued call when the saved pairing changed under the same environment id', () => {
expect(runtimeEnvironmentRevisionFailure(environment, 10, 'worktree.rm')).toEqual({
id: 'worktree.rm',
ok: false,
error: {
code: 'runtime_environment_changed',
message: 'Runtime environment pairing changed; refresh and try again'
},
_meta: { runtimeId: 'runtime-b' }
})
})
it('preserves mixed-version calls that provide no revision', () => {
expect(runtimeEnvironmentRevisionFailure(environment, undefined, 'repo.list')).toBeNull()
expect(runtimeEnvironmentRevisionFailure(environment, 20, 'repo.list')).toBeNull()
})
})

View File

@ -0,0 +1,24 @@
import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments'
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
export function runtimeEnvironmentRevisionFailure(
environment: KnownRuntimeEnvironment,
expectedPairingRevision: number | undefined,
method: string
): RuntimeRpcResponse<never> | null {
if (
expectedPairingRevision === undefined ||
(environment.pairingRevision ?? environment.createdAt) === expectedPairingRevision
) {
return null
}
return {
id: method,
ok: false,
error: {
code: 'runtime_environment_changed',
message: 'Runtime environment pairing changed; refresh and try again'
},
_meta: { runtimeId: environment.runtimeId }
}
}

View File

@ -0,0 +1,18 @@
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import { withRemoteRuntimeTailscaleHint } from '../../shared/remote-runtime-tailscale-hint'
export function withTailscaleHintForResponse<TResult>(
response: RuntimeRpcResponse<TResult>,
endpoint: string
): RuntimeRpcResponse<TResult> {
if (response.ok === true) {
return response
}
return {
...response,
error: {
...response.error,
message: withRemoteRuntimeTailscaleHint(response.error.message, endpoint)
}
}
}

View File

@ -0,0 +1,12 @@
const generationByEnvironment = new Map<string, number>()
export function getRuntimeEnvironmentTransportGeneration(environmentId: string): number {
return generationByEnvironment.get(environmentId) ?? 0
}
export function advanceRuntimeEnvironmentTransportGeneration(environmentId: string): void {
generationByEnvironment.set(
environmentId,
getRuntimeEnvironmentTransportGeneration(environmentId) + 1
)
}

View File

@ -14,40 +14,22 @@ import {
import { withRemoteRuntimeTailscaleHint } from '../../shared/remote-runtime-tailscale-hint'
import { enqueueRuntimeCall } from './runtime-environment-call-queue'
import {
reconnectRemoteRuntimeSharedControlConnection,
sendRemoteRuntimeConnectionRequest,
sendRemoteRuntimeSharedControlRequest,
subscribeRemoteRuntimeSharedControlRequest
} from './runtime-environment-request-connections'
import { attachRemoteControlDiagnostics } from './runtime-environment-status-diagnostics'
import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard'
import { withTailscaleHintForResponse } from './runtime-environment-tailscale-response'
const DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS = 15_000
const sharedControlSupport = new Map<string, { cacheKey: string; check: Promise<boolean> }>()
export function resetSharedControlSupport(): void {
sharedControlSupport.clear()
}
export const resetSharedControlSupport = (): void => sharedControlSupport.clear()
export function clearSharedControlSupport(environmentId: string): void {
sharedControlSupport.delete(environmentId)
}
// Why: when a remote host is unreachable, point the user at Tailscale as the
// connectivity remedy; the helper no-ops on non-connectivity errors.
function withTailscaleHintForResponse<TResult>(
response: RuntimeRpcResponse<TResult>,
endpoint: string
): RuntimeRpcResponse<TResult> {
if (response.ok === true) {
return response
}
return {
...response,
error: {
...response.error,
message: withRemoteRuntimeTailscaleHint(response.error.message, endpoint)
}
}
}
export const clearSharedControlSupport = (environmentId: string): void =>
void sharedControlSupport.delete(environmentId)
export async function getRuntimeEnvironmentStatus(
userDataPath: string,
@ -85,6 +67,7 @@ export async function getRuntimeEnvironmentStatus(
}
if (response.ok === true) {
markEnvironmentUsed(userDataPath, environment.id, { runtimeId: response._meta.runtimeId })
reconnectRemoteRuntimeSharedControlConnection(environment.id)
}
return attachRemoteControlDiagnostics(
withTailscaleHintForResponse(response, pairing.endpoint),
@ -97,7 +80,8 @@ export async function callRuntimeEnvironment(
selector: string,
method: string,
params: unknown,
timeoutMs?: number
timeoutMs?: number,
expectedEnvironmentPairingRevision?: number
): Promise<RuntimeRpcResponse<unknown>> {
const environment = resolveEnvironment(userDataPath, selector)
// Why: connection failures reject (they don't resolve as ok:false), so the
@ -109,6 +93,14 @@ export async function callRuntimeEnvironment(
try {
return await enqueueRuntimeCall(environment.id, method, async () => {
const currentEnvironment = resolveEnvironment(userDataPath, environment.id)
const revisionFailure = runtimeEnvironmentRevisionFailure(
currentEnvironment,
expectedEnvironmentPairingRevision,
method
)
if (revisionFailure) {
return revisionFailure
}
const pairing = getPreferredPairingOffer(currentEnvironment)
endpoint = pairing.endpoint
const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS
@ -125,6 +117,7 @@ export async function callRuntimeEnvironment(
}
if (
method !== 'status.get' &&
!shouldUseOneShotRequest(method) &&
(await supportsSharedControl(userDataPath, currentEnvironment, pairing, effectiveTimeoutMs))
) {
const response = await sendRemoteRuntimeSharedControlRequest(
@ -245,6 +238,11 @@ function shouldUseCachedRequestConnection(method: string): boolean {
return method === 'terminal.send' || method === 'terminal.updateViewport'
}
function shouldUseOneShotRequest(method: string): boolean {
// Why: snapshot recovery must remain available while a retained shared-control stream is reconnecting after a HUB restart.
return method === 'session.tabs.list' || method === 'session.tabs.listAll'
}
function shouldKeepDedicatedSubscriptionSocket(method: string): boolean {
return method === 'browser.screencast' || method === 'terminal.multiplex'
}

View File

@ -20,6 +20,7 @@ const {
sendRemoteRuntimeSharedControlRequestMock,
subscribeRemoteRuntimeSharedControlRequestMock,
getRemoteRuntimeSharedControlDiagnosticsMock,
reconnectRemoteRuntimeSharedControlConnectionMock,
closeRemoteRuntimeRequestConnectionMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
@ -33,6 +34,7 @@ const {
sendRemoteRuntimeSharedControlRequestMock: vi.fn(),
subscribeRemoteRuntimeSharedControlRequestMock: vi.fn(),
getRemoteRuntimeSharedControlDiagnosticsMock: vi.fn(),
reconnectRemoteRuntimeSharedControlConnectionMock: vi.fn(),
closeRemoteRuntimeRequestConnectionMock: vi.fn()
}))
@ -56,10 +58,14 @@ vi.mock('./runtime-environment-request-connections', () => ({
sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock,
subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock,
getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock,
reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock,
closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock
}))
import { registerRuntimeEnvironmentHandlers } from './runtime-environments'
import {
invalidateRuntimeEnvironmentTransport,
registerRuntimeEnvironmentHandlers
} from './runtime-environments'
function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string {
return encodePairingOffer({
@ -108,6 +114,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
subscribeRemoteRuntimeSharedControlRequestMock.mockReset()
getRemoteRuntimeSharedControlDiagnosticsMock.mockReset()
getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue(null)
reconnectRemoteRuntimeSharedControlConnectionMock.mockReset()
closeRemoteRuntimeRequestConnectionMock.mockReset()
})
@ -267,6 +274,9 @@ describe('registerRuntimeEnvironmentHandlers', () => {
undefined,
50
)
expect(reconnectRemoteRuntimeSharedControlConnectionMock).toHaveBeenCalledWith(
added.environment.id
)
const resolve = handler<{ selector: string }, { id: string; runtimeId: string | null }>(
'runtimeEnvironments:resolve'
@ -708,6 +718,40 @@ describe('registerRuntimeEnvironmentHandlers', () => {
)
})
it('keeps session snapshot recovery on one-shot transport while shared control reconnects', async () => {
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeRequestMock.mockImplementation(async (_pairing, method) => ({
id: method,
ok: true,
result:
method === 'status.get'
? {
runtimeId: 'runtime-remote',
capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY]
}
: { snapshots: [] },
_meta: { runtimeId: 'runtime-remote' }
}))
const add = handler<
{ name: string; pairingCode: string },
{ environment: { id: string; name: string } }
>('runtimeEnvironments:addFromPairingCode')
await add(null, { name: 'desk', pairingCode: pairingCode() })
const call = handler<
{ selector: string; method: string; params?: unknown },
{ ok: true; result: unknown }
>('runtimeEnvironments:call')
await expect(
call(null, { selector: 'desk', method: 'session.tabs.listAll' })
).resolves.toMatchObject({ ok: true, result: { snapshots: [] } })
expect(sendRemoteRuntimeRequestMock.mock.calls.map((entry) => entry[1])).toEqual([
'session.tabs.listAll'
])
expect(sendRemoteRuntimeSharedControlRequestMock).not.toHaveBeenCalled()
})
it('keeps browser and terminal heavy streams on dedicated subscription sockets', async () => {
registerRuntimeEnvironmentHandlers(store as never)
const close = vi.fn()
@ -1536,6 +1580,103 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
})
it.each([
{ method: 'terminal.multiplex', includeExpectedRevision: true },
{ method: 'browser.screencast', includeExpectedRevision: true },
{ method: 'terminal.multiplex', includeExpectedRevision: false },
{ method: 'browser.screencast', includeExpectedRevision: false }
])(
'closes a pending $method subscription after same-id re-pair (expected revision: $includeExpectedRevision)',
async ({ method, includeExpectedRevision }) => {
registerRuntimeEnvironmentHandlers(store as never)
const close = vi.fn()
const sendBinary = vi.fn(() => true)
let emitRemoteBinary: (bytes: Uint8Array<ArrayBufferLike>) => void = () => {}
let resolveSubscribe: (value: {
requestId: string
close: () => void
sendBinary: (bytes: Uint8Array<ArrayBufferLike>) => boolean
}) => void = () => {}
subscribeRemoteRuntimeRequestMock.mockImplementation(
(_pairing, _method, _params, _timeoutMs, callbacks) => {
emitRemoteBinary = callbacks.onBinary
return new Promise((resolve) => {
resolveSubscribe = resolve
})
}
)
const add = handler<
{ name: string; pairingCode: string },
{ environment: { id: string; name: string } }
>('runtimeEnvironments:addFromPairingCode')
const added = await add(null, { name: 'desk', pairingCode: pairingCode() })
const savedEnvironment = environmentStore.resolveEnvironment(
userDataPath,
added.environment.id
)
const pairingRevision = savedEnvironment.pairingRevision ?? savedEnvironment.createdAt
const senderSend = vi.fn()
const subscribe = handler<
{
selector: string
method: string
params?: unknown
subscriptionId: string
expectedEnvironmentPairingRevision?: number
},
{ subscriptionId: string; requestId: string }
>('runtimeEnvironments:subscribe')
const resultPromise = subscribe(
{
sender: {
id: 1,
isDestroyed: () => false,
send: senderSend,
once: vi.fn(),
removeListener: vi.fn()
}
},
{
selector: added.environment.id,
method,
params: {},
subscriptionId: `pending-${method}-${includeExpectedRevision ? 'current' : 'legacy'}`,
...(includeExpectedRevision
? { expectedEnvironmentPairingRevision: pairingRevision }
: {})
}
)
await vi.waitFor(() => expect(subscribeRemoteRuntimeRequestMock).toHaveBeenCalledTimes(1))
environmentStore.updateEnvironmentFromPairingCode(userDataPath, added.environment.id, {
pairingCode: pairingCode('ws://127.0.0.1:7678')
})
invalidateRuntimeEnvironmentTransport(added.environment.id)
emitRemoteBinary(new Uint8Array([1, 2, 3]))
expect(senderSend).not.toHaveBeenCalled()
resolveSubscribe({ requestId: 'retired-stream', close, sendBinary })
await expect(resultPromise).rejects.toThrow(
'Runtime environment pairing changed; refresh and try again'
)
expect(close).toHaveBeenCalledTimes(1)
const binaryListener = onMock.mock.calls.find(
(call) => call[0] === 'runtimeEnvironments:subscriptionBinary'
)?.[1] as (_event: unknown, args: unknown) => void
binaryListener(
{ sender: { id: 1 } },
{
subscriptionId: `pending-${method}-${includeExpectedRevision ? 'current' : 'legacy'}`,
bytes: new Uint8Array([4, 5, 6])
}
)
expect(sendBinary).not.toHaveBeenCalled()
}
)
it('removes the destroyed listener when streaming subscription setup rejects', async () => {
registerRuntimeEnvironmentHandlers(store as never)
subscribeRemoteRuntimeRequestMock.mockRejectedValue(new Error('connect failed'))

View File

@ -16,6 +16,10 @@ import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-clie
import type { Store } from '../persistence'
import { clearActiveRuntimeEnvironmentFocusIfMatches } from '../runtime-environment-focus-self-heal'
import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections'
import {
advanceRuntimeEnvironmentTransportGeneration,
getRuntimeEnvironmentTransportGeneration
} from './runtime-environment-transport-generation'
import {
callRuntimeEnvironment,
clearSharedControlSupport,
@ -42,14 +46,10 @@ type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & {
removeDestroyedListener: () => void
}
const remoteRuntimeSubscriptions = new Map<string, RetainedRemoteRuntimeSubscription>()
function getUserDataPath(): string {
return app.getPath('userData')
}
const getUserDataPath = (): string => app.getPath('userData')
function closeSubscriptionsForEnvironment(environmentId: string): void {
// Why: removing a saved runtime invalidates its streaming WebSockets too;
// otherwise terminal/browser subscriptions stay alive until renderer teardown.
// Why: removed runtimes must not retain terminal/browser WebSockets until renderer teardown.
for (const [subscriptionId, subscription] of remoteRuntimeSubscriptions) {
if (subscription.environmentId !== environmentId) {
continue
@ -58,10 +58,16 @@ function closeSubscriptionsForEnvironment(environmentId: string): void {
subscription.close()
}
}
export function invalidateRuntimeEnvironmentTransport(environmentId: string): void {
// Why: a same-id re-pair must retire every transport that still authenticates as the old peer.
advanceRuntimeEnvironmentTransportGeneration(environmentId)
closeRemoteRuntimeRequestConnection(environmentId)
clearSharedControlSupport(environmentId)
closeSubscriptionsForEnvironment(environmentId)
}
function listPublicRuntimeEnvironments(): PublicKnownRuntimeEnvironment[] {
// Why: `source` is persisted on the env record, so read it directly instead of
// joining the VM store — a corrupt VM store must not break listing all envs.
// Why: a corrupt VM store must not break persisted environment listing.
return listEnvironments(getUserDataPath()).map(redactRuntimeEnvironment)
}
@ -95,14 +101,12 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
'runtimeEnvironments:remove',
(_event, args: { selector: string }): { removed: PublicKnownRuntimeEnvironment } => {
const removed = removeEnvironment(getUserDataPath(), args.selector)
closeRemoteRuntimeRequestConnection(removed.id)
clearSharedControlSupport(removed.id)
invalidateRuntimeEnvironmentTransport(removed.id)
if (args.selector !== removed.id) {
closeRemoteRuntimeRequestConnection(args.selector)
clearSharedControlSupport(args.selector)
}
clearActiveRuntimeEnvironmentFocusIfMatches(store, removed.id)
closeSubscriptionsForEnvironment(removed.id)
return { removed: redactRuntimeEnvironment(removed) }
}
)
@ -112,13 +116,11 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
const environment = resolveEnvironment(getUserDataPath(), args.selector)
// Why: disconnect is intentionally non-destructive; it drops live
// transport state while keeping the paired server available for later.
closeRemoteRuntimeRequestConnection(environment.id)
clearSharedControlSupport(environment.id)
invalidateRuntimeEnvironmentTransport(environment.id)
if (args.selector !== environment.id) {
closeRemoteRuntimeRequestConnection(args.selector)
clearSharedControlSupport(args.selector)
}
closeSubscriptionsForEnvironment(environment.id)
return { disconnected: redactRuntimeEnvironment(environment) }
}
)
@ -135,14 +137,21 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
'runtimeEnvironments:call',
async (
_event,
args: { selector: string; method: string; params?: unknown; timeoutMs?: number }
args: {
selector: string
method: string
params?: unknown
timeoutMs?: number
expectedEnvironmentPairingRevision?: number
}
): Promise<RuntimeRpcResponse<unknown>> => {
return callRuntimeEnvironment(
getUserDataPath(),
args.selector,
args.method,
args.params,
args.timeoutMs
args.timeoutMs,
args.expectedEnvironmentPairingRevision
)
}
)
@ -156,6 +165,7 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
params?: unknown
timeoutMs?: number
subscriptionId?: string
expectedEnvironmentPairingRevision?: number
}
): Promise<{ subscriptionId: string; requestId: string }> => {
const subscriptionId =
@ -166,6 +176,16 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
throw new Error('Runtime environment subscription id already exists')
}
const environment = resolveEnvironment(getUserDataPath(), args.selector)
const pairingRevision = environment.pairingRevision ?? environment.createdAt
if (
args.expectedEnvironmentPairingRevision !== undefined &&
pairingRevision !== args.expectedEnvironmentPairingRevision
) {
throw new Error('Runtime environment pairing changed; refresh and try again')
}
const transportGeneration = getRuntimeEnvironmentTransportGeneration(environment.id)
const transportIsCurrent = (): boolean =>
getRuntimeEnvironmentTransportGeneration(environment.id) === transportGeneration
const sender = event.sender
const ownerWebContentsId = sender.id
let senderDestroyed = sender.isDestroyed()
@ -200,7 +220,7 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
args.timeoutMs,
{
onEvent: (payload) => {
if (!sender.isDestroyed()) {
if (transportIsCurrent() && !sender.isDestroyed()) {
sender.send('runtimeEnvironments:subscriptionEvent', {
subscriptionId,
...payload
@ -218,6 +238,19 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
removeDestroyedListener()
throw error
}
let pairingIsCurrent = false
try {
const currentEnvironment = resolveEnvironment(getUserDataPath(), environment.id)
pairingIsCurrent =
(currentEnvironment.pairingRevision ?? currentEnvironment.createdAt) === pairingRevision
} catch {
pairingIsCurrent = false
}
if (!transportIsCurrent() || !pairingIsCurrent) {
removeDestroyedListener()
subscription.close()
throw new Error('Runtime environment pairing changed; refresh and try again')
}
if (senderDestroyed || sender.isDestroyed()) {
removeDestroyedListener()
subscription.close()

View File

@ -225,6 +225,7 @@ import {
getSshPtyProvider,
getPtyIdsForConnection
} from './pty'
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
describe('SSH IPC handlers', () => {
const handlers = new Map<string, (_event: unknown, args: unknown) => unknown>()
@ -537,6 +538,7 @@ describe('SSH IPC handlers', () => {
status: 'connected',
error: null,
reconnectAttempt: 0,
connectionGeneration: 1,
remotePlatform: 'win32'
})
expect(mockWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', {
@ -546,6 +548,7 @@ describe('SSH IPC handlers', () => {
status: 'connected',
error: null,
reconnectAttempt: 0,
connectionGeneration: 1,
supportsFolderDownload: true,
remotePlatform: 'win32'
}
@ -586,14 +589,16 @@ describe('SSH IPC handlers', () => {
targetId: 'ssh-1',
status: 'reconnecting',
error: 'Relay channel lost. Reconnecting...',
reconnectAttempt: 1
reconnectAttempt: 1,
connectionGeneration: 1
}
})
expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({
targetId: 'ssh-1',
status: 'reconnecting',
error: 'Relay channel lost. Reconnecting...',
reconnectAttempt: 1
reconnectAttempt: 1,
connectionGeneration: 1
})
await vi.advanceTimersByTimeAsync(500)
@ -605,6 +610,7 @@ describe('SSH IPC handlers', () => {
status: 'connected',
error: null,
reconnectAttempt: 0,
connectionGeneration: 1,
supportsFolderDownload: true
}
})
@ -612,13 +618,72 @@ describe('SSH IPC handlers', () => {
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
reconnectAttempt: 0,
connectionGeneration: 1
})
expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', 1)).not.toThrow()
} finally {
vi.useRealTimers()
}
})
it('rejects a staged mutation after the underlying SSH transport reconnects', async () => {
const target: SshTarget = {
id: 'ssh-1',
label: 'Server',
host: 'example.com',
port: 22,
username: 'deploy'
}
const conn = {}
mockSshStore.getTarget.mockReturnValue(target)
mockConnectionManager.connect.mockResolvedValue(conn)
mockConnectionManager.getConnection.mockReturnValue(conn)
mockConnectionManager.getState.mockReturnValue({
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
})
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
const stagedGeneration = 1
const callbacks = mockConnectionManager.callbacksRef.current as {
onStateChange: (targetId: string, state: SshConnectionState) => void
}
callbacks.onStateChange('ssh-1', {
targetId: 'ssh-1',
status: 'reconnecting',
error: null,
reconnectAttempt: 1
})
callbacks.onStateChange('ssh-1', {
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
})
callbacks.onStateChange('ssh-1', {
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
})
expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({
targetId: 'ssh-1',
status: 'reconnecting',
error: 'Relay channel reconnecting...',
reconnectAttempt: 0,
connectionGeneration: 2
})
expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', stagedGeneration)).toThrow(
'SSH connection changed; refresh and try again'
)
expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', 2)).not.toThrow()
})
it('rebuilds instead of reusing a ready session while relay loss is pending', async () => {
vi.useFakeTimers()
const target: SshTarget = {
@ -651,7 +716,8 @@ describe('SSH IPC handlers', () => {
targetId: 'ssh-1',
status: 'reconnecting',
error: 'Relay channel lost. Reconnecting...',
reconnectAttempt: 1
reconnectAttempt: 1,
connectionGeneration: 1
})
mockDeployAndLaunchRelay.mockClear()
@ -661,7 +727,8 @@ describe('SSH IPC handlers', () => {
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
reconnectAttempt: 0,
connectionGeneration: 2
})
expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1')
@ -670,7 +737,8 @@ describe('SSH IPC handlers', () => {
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
reconnectAttempt: 0,
connectionGeneration: 2
})
} finally {
vi.useRealTimers()
@ -709,7 +777,8 @@ describe('SSH IPC handlers', () => {
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
reconnectAttempt: 0,
connectionGeneration: 1
})
}
@ -719,7 +788,8 @@ describe('SSH IPC handlers', () => {
targetId: 'ssh-1',
status: 'error',
error: 'Relay channel kept dropping. Click Reconnect on the SSH target before retrying.',
reconnectAttempt: 0
reconnectAttempt: 0,
connectionGeneration: 1
})
} finally {
vi.useRealTimers()
@ -756,7 +826,8 @@ describe('SSH IPC handlers', () => {
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
reconnectAttempt: 0,
connectionGeneration: 1
})
await vi.advanceTimersByTimeAsync(relayLostStabilizedMs + 1)
@ -767,7 +838,8 @@ describe('SSH IPC handlers', () => {
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
reconnectAttempt: 0,
connectionGeneration: 1
})
expect(mockPortForwardManager.removeAllForwards).not.toHaveBeenCalled()
expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled()
@ -823,7 +895,8 @@ describe('SSH IPC handlers', () => {
const runtime = {
onPtyData: vi.fn(),
onPtyExit: vi.fn(),
notifySshStateChanged: vi.fn()
notifySshStateChanged: vi.fn(),
notifySshRelayReady: vi.fn()
}
registerSshHandlers(mockStore as never, () => mockWindow as never, runtime as never)
const target: SshTarget = {
@ -850,6 +923,7 @@ describe('SSH IPC handlers', () => {
'ssh-1',
expect.objectContaining({ targetId: 'ssh-1', status: 'connected' })
)
expect(runtime.notifySshRelayReady).toHaveBeenCalledWith('ssh-1')
})
it('keeps runtime-owned SSH state off the renderer while invalidating runtime scans', async () => {
@ -970,9 +1044,10 @@ describe('SSH IPC handlers', () => {
mockDeployAndLaunchRelay.mockClear()
mockPortForwardManager.removeAllForwards.mockClear()
await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual(
connectedState
)
await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual({
...connectedState,
connectionGeneration: 1
})
expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled()
expect(mockPortForwardManager.removeAllForwards).not.toHaveBeenCalled()
expect(await handlers.get('ssh:listPortForwards')!(null, { targetId: 'ssh-1' })).toEqual([
@ -1171,7 +1246,8 @@ describe('SSH IPC handlers', () => {
targetId: 'ssh-1',
status: 'error',
error: 'network down',
reconnectAttempt: 0
reconnectAttempt: 0,
connectionGeneration: 1
}
})
expect(secondWindow.webContents.send).toHaveBeenCalledWith(
@ -1569,6 +1645,23 @@ describe('SSH IPC handlers', () => {
error: null,
reconnectAttempt: 0
})
mockConnectionManager.reconnect.mockImplementation(async (targetId: string) => {
const callbacks = mockConnectionManager.callbacksRef.current as {
onStateChange: (id: string, state: SshConnectionState) => void
}
callbacks.onStateChange(targetId, {
targetId,
status: 'reconnecting',
error: null,
reconnectAttempt: 1
})
callbacks.onStateChange(targetId, {
targetId,
status: 'connected',
error: null,
reconnectAttempt: 0
})
})
mockMux.probeLiveness.mockResolvedValue(false)
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
@ -1581,6 +1674,9 @@ describe('SSH IPC handlers', () => {
await vi.waitFor(() => expect(mockConnectionManager.reconnect).toHaveBeenCalledWith('ssh-1'))
// Why: a failed first probe gets one retry before teardown (slow post-wake network).
expect(mockMux.probeLiveness).toHaveBeenCalledTimes(2)
expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toMatchObject({
connectionGeneration: 2
})
})
it('skips reconnect on system resume when the relay link is still alive', async () => {
@ -1729,6 +1825,6 @@ describe('SSH IPC handlers', () => {
mockConnectionManager.getState.mockReturnValue(state)
const result = await handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })
expect(result).toEqual(state)
expect(result).toEqual({ ...state, connectionGeneration: 0 })
})
})

View File

@ -1,5 +1,6 @@
/* oxlint-disable max-lines -- Why: co-locates SSH IPC handlers, port-forward broadcasting, and session lifecycle to keep the data flow obvious. */
import { ipcMain, powerMonitor, type BrowserWindow } from 'electron'
import { appendFileSync } from 'node:fs'
import type { Store } from '../persistence'
import { SshConnectionStore } from '../ssh/ssh-connection-store'
import { SshConnectionManager, type SshConnectionCallbacks } from '../ssh/ssh-connection'
@ -37,6 +38,12 @@ import {
getSshPtyProvider
} from './pty'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import {
advanceSshConnectionGeneration,
getSshConnectionGeneration,
initializeSshConnectionGenerationSession,
resetSshConnectionGenerations
} from '../ssh/ssh-connection-generation'
let sshStore: SshConnectionStore | null = null
let connectionManager: SshConnectionManager | null = null
@ -182,14 +189,14 @@ type ConnectAttempt = {
}
const connectInFlight = new Map<string, ConnectAttempt>()
const connectGenerationByTarget = new Map<string, number>()
const pendingTransportReconnects = new Set<string>()
function currentConnectGeneration(targetId: string): number {
return connectGenerationByTarget.get(targetId) ?? 0
return getSshConnectionGeneration(targetId)
}
function invalidateConnectAttempt(targetId: string): void {
connectGenerationByTarget.set(targetId, currentConnectGeneration(targetId) + 1)
advanceSshConnectionGeneration(targetId)
pendingTransportReconnects.delete(targetId)
connectInFlight.delete(targetId)
credentialRequestedForTarget.delete(targetId)
}
@ -254,7 +261,11 @@ function broadcastSshState(
function withSshRemotePlatform(targetId: string, state: SshConnectionState): SshConnectionState {
const remotePlatform = activeSessions.get(targetId)?.getHostPlatform()?.os
return remotePlatform ? { ...state, remotePlatform } : state
return {
...state,
connectionGeneration: currentConnectGeneration(targetId),
...(remotePlatform ? { remotePlatform } : {})
}
}
function publishRelayOverride(
@ -505,11 +516,35 @@ function createSshConnectionCallbacks(): SshConnectionCallbacks {
// Why: an SSH reconnect must re-deploy the relay and rebuild providers; the guard below fires only for real reconnects, not an explicit connect's 'deploying'.
const session = activeSessions.get(targetId)
const sessionState = session?.getState()
if (
state.status === 'reconnecting' &&
(sessionState === 'ready' || sessionState === 'reconnecting')
) {
pendingTransportReconnects.add(targetId)
} else if (
state.status === 'disconnected' ||
state.status === 'auth-failed' ||
state.status === 'reconnection-failed' ||
state.status === 'error'
) {
pendingTransportReconnects.delete(targetId)
}
const completedTransportReconnect =
state.status === 'connected' && pendingTransportReconnects.delete(targetId)
if (completedTransportReconnect) {
// Why: staged mutations from the replaced SSH transport must fail even if its relay session disappeared before recovery completed.
advanceSshConnectionGeneration(targetId)
}
const shouldReconnectRelay =
session !== undefined &&
state.status === 'connected' &&
completedTransportReconnect &&
state.reconnectAttempt === 0 &&
(sessionState === 'ready' || sessionState === 'reconnecting')
const relayReconnectAlreadyInFlight =
!completedTransportReconnect &&
state.status === 'connected' &&
sessionState === 'reconnecting' &&
relayStateOverrides.has(targetId)
if (shouldReconnectRelay) {
// Why: SSH connects before the relay providers rebuild; keep renderer actions gated until SshRelaySession reaches ready again.
@ -520,6 +555,9 @@ function createSshConnectionCallbacks(): SshConnectionCallbacks {
'Relay channel reconnecting...',
state.reconnectAttempt
)
} else if (relayReconnectAlreadyInFlight) {
// Why: duplicate connected notifications belong to the same socket generation and must not expose providers before relay recovery finishes.
return
} else {
clearRelayStateOverride(targetId)
broadcastSshState(getCurrentMainWindow, targetId, state)
@ -646,6 +684,7 @@ function configureRelaySessionCallbacks(session: SshRelaySession): void {
supportsFolderDownload: connectionSupportsFolderDownload(tid)
})
}
currentRuntime?.notifySshRelayReady?.(tid)
void restorePortForwards(tid, getCurrentMainWindow)
})
}
@ -671,6 +710,7 @@ export function registerSshHandlers(
getMainWindow: () => BrowserWindow | null,
runtime?: OrcaRuntimeService
): { connectionManager: SshConnectionManager; sshStore: SshConnectionStore } {
initializeSshConnectionGenerationSession()
// Why: macOS re-activation re-calls this with a new BrowserWindow; ipcMain.handle() throws on a duplicate channel, so remove prior handlers first.
for (const ch of SSH_IPC_CHANNELS) {
ipcMain.removeHandler(ch)
@ -759,6 +799,11 @@ export function registerSshHandlers(
// ── Connection lifecycle ───────────────────────────────────────────
async function connectTarget(targetId: string): Promise<SshConnectionState> {
const e2eProbePath = process.env.ORCA_E2E_FORBID_LOCAL_SSH_CONNECT_PROBE
if (e2eProbePath) {
appendFileSync(e2eProbePath, `${JSON.stringify(targetId)}\n`)
throw new Error('e2e_forbidden_local_ssh_connect')
}
const observedGeneration = currentConnectGeneration(targetId)
const reset = resetRelayInFlight.get(targetId)
if (reset) {
@ -774,10 +819,9 @@ export function registerSshHandlers(
throw connectCancelledError()
}
const generation = observedGeneration + 1
connectGenerationByTarget.set(targetId, generation)
const promise = doConnect(targetId, generation)
const attempt = { generation, promise }
pendingTransportReconnects.delete(targetId)
const promise = doConnect(targetId)
const attempt = { generation: currentConnectGeneration(targetId), promise }
connectInFlight.set(targetId, attempt)
try {
return await promise
@ -795,7 +839,7 @@ export function registerSshHandlers(
return connectTarget(args.targetId)
})
async function doConnect(targetId: string, generation: number): Promise<SshConnectionState> {
async function doConnect(targetId: string): Promise<SshConnectionState> {
const target = sshStore!.getTarget(targetId)
if (!target) {
throw new Error(`SSH target "${targetId}" not found`)
@ -815,9 +859,10 @@ export function registerSshHandlers(
) {
// Why: BrowserWindow reactivation re-fires ssh:connect for already-live targets; treat as a refresh instead of tearing down the relay and its forwards.
broadcastSshState(getCurrentMainWindow, targetId, existingState)
return existingState
return getPublicSshState(targetId)!
}
const generation = advanceSshConnectionGeneration(targetId)
clearRelayStateOverride(targetId)
let conn
// Why: tear down any existing session first to avoid leaking its multiplexer, providers, and timers (double-connect / reconnect-after-error).
@ -1231,7 +1276,8 @@ export async function resetSshHandlerStateForTests(): Promise<void> {
}
relayStateOverrides.clear()
connectInFlight.clear()
connectGenerationByTarget.clear()
pendingTransportReconnects.clear()
resetSshConnectionGenerations()
resetRelayInFlight.clear()
testingTargets.clear()
credentialRequestedForTarget.clear()

View File

@ -10511,6 +10511,35 @@ describe('Store host-partitioned workspace sessions', () => {
activeRepoId
})
const makeBoundHostSession = (ptyId: string | null): WorkspaceSessionState => ({
...getDefaultWorkspaceSession(),
activeRepoId: 'repo-1',
activeWorktreeId: 'repo-1::/worktree',
activeTabId: 'tab-1',
tabsByWorktree: {
'repo-1::/worktree': [
{
id: 'tab-1',
worktreeId: 'repo-1::/worktree',
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId
}
]
},
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: TEST_LEAF_1 },
activeLeafId: TEST_LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: ptyId ? { [TEST_LEAF_1]: ptyId } : {}
}
}
})
it('migrates a legacy workspaceSession blob into the local partition', async () => {
writeDataFile({
schemaVersion: 1,
@ -10652,6 +10681,81 @@ describe('Store host-partitioned workspace sessions', () => {
).toBe(7)
})
it('persists an SSH PTY binding only in the SSH host partition', async () => {
const store = await createStore()
store.setWorkspaceSession(makeBoundHostSession(null), 'local')
store.setWorkspaceSession(makeBoundHostSession(null), 'ssh:ssh-1')
store.persistPtyBinding(
{
worktreeId: 'repo-1::/worktree',
tabId: 'tab-1',
leafId: TEST_LEAF_1,
ptyId: 'ssh:ssh-1@@remote-pty'
},
'ssh:ssh-1'
)
expect(
store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree['repo-1::/worktree'][0]?.ptyId
).toBe('ssh:ssh-1@@remote-pty')
expect(
store.getWorkspaceSession('local').tabsByWorktree['repo-1::/worktree'][0]?.ptyId
).toBeNull()
})
it('rolls back a failed SSH PTY binding flush in the SSH host partition', async () => {
const store = await createStore()
store.setWorkspaceSession(makeBoundHostSession(null), 'local')
store.setWorkspaceSession(makeBoundHostSession(null), 'ssh:ssh-1')
const flush = vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => {
throw new Error('disk unavailable')
})
expect(() =>
store.persistPtyBinding(
{
worktreeId: 'repo-1::/worktree',
tabId: 'tab-1',
leafId: TEST_LEAF_1,
ptyId: 'ssh:ssh-1@@remote-pty'
},
'ssh:ssh-1'
)
).toThrow('disk unavailable')
flush.mockRestore()
expect(
store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree['repo-1::/worktree'][0]?.ptyId
).toBeNull()
expect(
store.getWorkspaceSession('local').tabsByWorktree['repo-1::/worktree'][0]?.ptyId
).toBeNull()
})
it('clears expired SSH PTY bindings from the SSH partition and legacy local copy', async () => {
const store = await createStore()
const ptyId = 'ssh:ssh-1@@remote-pty'
store.setWorkspaceSession(makeBoundHostSession(ptyId), 'local')
store.setWorkspaceSession(makeBoundHostSession(ptyId), 'ssh:ssh-1')
store.upsertSshRemotePtyLease({
targetId: 'ssh-1',
ptyId: 'remote-pty',
worktreeId: 'repo-1::/worktree',
tabId: 'tab-1',
leafId: TEST_LEAF_1,
state: 'attached'
})
store.markSshRemotePtyLease('ssh-1', ptyId, 'expired')
for (const hostId of ['local', 'ssh:ssh-1']) {
const session = store.getWorkspaceSession(hostId)
expect(session.tabsByWorktree['repo-1::/worktree'][0]?.ptyId).toBeNull()
expect(session.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({})
}
})
it('defaults an omitted hostId to the local partition', async () => {
const store = await createStore()
store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a')

View File

@ -5910,17 +5910,24 @@ export class Store {
}
// Why: sync-flush the pty binding before pty:spawn returns to close the spawn/persist SIGKILL race (Issue #217).
persistPtyBinding(args: {
worktreeId: string
tabId: string
leafId: string
ptyId: string
incarnationId?: string
startupCwd?: string
}): void {
const session = this.state.workspaceSession
if (!session) {
return
persistPtyBinding(
args: {
worktreeId: string
tabId: string
leafId: string
ptyId: string
incarnationId?: string
startupCwd?: string
},
hostId?: string | null
): void {
const resolvedHostId = this.resolveHostId(hostId)
const session = this.getWorkspaceSession(resolvedHostId)
if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) {
this.state.workspaceSessionsByHostId = {
...this.state.workspaceSessionsByHostId,
[resolvedHostId]: session
}
}
const sessionBeforeBinding = cloneWorkspaceSessionState(session)
const paneKey = `${args.tabId}:${args.leafId}`
@ -5937,6 +5944,16 @@ export class Store {
[repoId]: currentRevision + 1
}
}
const restoreSession = (): void => {
if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) {
this.state.workspaceSession = sessionBeforeBinding
} else {
this.state.workspaceSessionsByHostId = {
...this.state.workspaceSessionsByHostId,
[resolvedHostId]: sessionBeforeBinding
}
}
}
if (args.incarnationId) {
session.terminalPtyIncarnationsByPaneKey = {
...session.terminalPtyIncarnationsByPaneKey,
@ -5980,7 +5997,7 @@ export class Store {
try {
this.flushOrThrow()
} catch (err) {
this.state.workspaceSession = sessionBeforeBinding
restoreSession()
throw err
}
return
@ -6028,7 +6045,7 @@ export class Store {
try {
this.flushOrThrow()
} catch (err) {
this.state.workspaceSession = sessionBeforeBinding
restoreSession()
throw err
}
}
@ -6393,54 +6410,61 @@ export class Store {
targetId: string,
leases: SshRemotePtyLease[]
): boolean {
const session = this.state.workspaceSession
if (!leases?.length || !session) {
if (!leases?.length) {
return false
}
let changed = false
for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) {
for (const tab of tabs) {
if (
tab.ptyId &&
leases.some((lease) =>
this.sshRemotePtyLeaseMayReferenceBinding(lease, {
ptyId: tab.ptyId!,
worktreeId,
targetId,
tabId: tab.id
})
)
) {
tab.ptyId = null
changed = true
}
}
}
for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) {
const bindings = layout.ptyIdsByLeafId
if (!bindings) {
continue
}
const worktreeId = Object.entries(session.tabsByWorktree ?? {}).find(([, tabs]) =>
tabs.some((tab) => tab.id === tabId)
)?.[0]
const nextBindings = Object.fromEntries(
Object.entries(bindings).filter(
([leafId, ptyId]) =>
!leases.some((lease) =>
const sessions = new Set(
[
this.state.workspaceSession,
this.state.workspaceSessionsByHostId?.[toSshExecutionHostId(targetId)]
].filter((session): session is WorkspaceSessionState => Boolean(session))
)
for (const session of sessions) {
for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) {
for (const tab of tabs) {
if (
tab.ptyId &&
leases.some((lease) =>
this.sshRemotePtyLeaseMayReferenceBinding(lease, {
ptyId,
targetId,
ptyId: tab.ptyId!,
worktreeId,
tabId,
leafId
targetId,
tabId: tab.id
})
)
) {
tab.ptyId = null
changed = true
}
}
}
for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) {
const bindings = layout.ptyIdsByLeafId
if (!bindings) {
continue
}
const worktreeId = Object.entries(session.tabsByWorktree ?? {}).find(([, tabs]) =>
tabs.some((tab) => tab.id === tabId)
)?.[0]
const nextBindings = Object.fromEntries(
Object.entries(bindings).filter(
([leafId, ptyId]) =>
!leases.some((lease) =>
this.sshRemotePtyLeaseMayReferenceBinding(lease, {
ptyId,
targetId,
worktreeId,
tabId,
leafId
})
)
)
)
)
if (Object.keys(nextBindings).length !== Object.keys(bindings).length) {
layout.ptyIdsByLeafId = nextBindings
changed = true
if (Object.keys(nextBindings).length !== Object.keys(bindings).length) {
layout.ptyIdsByLeafId = nextBindings
changed = true
}
}
}
if (changed) {

View File

@ -0,0 +1,56 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
import type {
SshPtyDataCallback,
SshPtyExitCallback,
SshPtyReplayCallback
} from './ssh-pty-provider-contract'
export type { SshPtyDataCallback, SshPtyExitCallback, SshPtyReplayCallback }
export function subscribeSshPtyNotifications(args: {
mux: SshChannelMultiplexer
toAppPtyId: (id: string) => string
dataListeners: Set<SshPtyDataCallback>
replayListeners: Set<SshPtyReplayCallback>
exitListeners: Set<SshPtyExitCallback>
livePtyIds: Set<string>
recordExit: (relayPtyId: string, incarnationId: unknown) => void
}): () => void {
return args.mux.onNotification((method, params) => {
const id = args.toAppPtyId(params.id as string)
if (method === 'pty.exit') {
args.recordExit(params.id as string, params.incarnationId)
args.livePtyIds.delete(id)
for (const listener of args.exitListeners) {
listener({
id,
code: params.code as number,
...(isPtyIncarnationId(params.incarnationId)
? { incarnationId: params.incarnationId }
: {})
})
}
return
}
if (method !== 'pty.data' && method !== 'pty.replay') {
return
}
args.livePtyIds.add(id)
if (method === 'pty.replay') {
for (const listener of args.replayListeners) {
listener({ id, data: params.data as string })
}
return
}
for (const listener of args.dataListeners) {
listener({
id,
data: params.data as string,
...(typeof params.rawLength === 'number' ? { sequenceChars: params.rawLength } : {}),
...(params.transformed === true ? { transformed: true } : {}),
...(typeof params.seq === 'number' ? { seq: params.seq } : {})
})
}
})
}

View File

@ -268,6 +268,16 @@ describe('SshPtyProvider', () => {
env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' }
})
expect(result).toEqual({ id: scopedPty1 })
expect(provider.hasPty(scopedPty1)).toBe(true)
})
it('keeps a spawned PTY live across an overlapping stale process list', async () => {
mux.request.mockResolvedValueOnce({ id: 'pty-new' }).mockResolvedValueOnce([])
const result = await provider.spawn({ cols: 80, rows: 24 })
await provider.listProcesses()
expect(provider.hasPty(result.id)).toBe(true)
})
it('gates fresh startup intent with the relay ingress capability version', async () => {

View File

@ -8,12 +8,12 @@ import type {
SshPtyExitCallback,
SshPtyReplayCallback
} from './ssh-pty-provider-contract'
import { subscribeSshPtyNotifications } from './ssh-pty-notification-routing'
import { validateClaimedSshSpawn } from './ssh-agent-session-claim-validation'
import {
assertSshAgentSessionCreateResult,
requestSshAgentSessionCreate
} from './ssh-agent-session-create-operation'
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
import { mapSshPtyProcessList } from './ssh-agent-session-process-list'
import {
parseSshPtyAttachResult,
@ -36,6 +36,7 @@ export class SshPtyProvider implements IPtyProvider {
private dataListeners = new Set<SshPtyDataCallback>()
private replayListeners = new Set<SshPtyReplayCallback>()
private exitListeners = new Set<SshPtyExitCallback>()
private livePtyIds = new Set<string>()
// Why: stale notification callbacks must not outlive a disconnected provider.
private unsubscribeNotifications: (() => void) | null = null
readonly getAppliedSize: NonNullable<IPtyProvider['getAppliedSize']>
@ -52,41 +53,15 @@ export class SshPtyProvider implements IPtyProvider {
this.agentSessionCapabilities = new SshAgentSessionCapabilities(mux)
this.getAppliedSize = createSshPtyAppliedSizeReader(mux, connectionId)
this.unsubscribeNotifications = mux.onNotification((method, params) => {
switch (method) {
case 'pty.data':
for (const cb of this.dataListeners) {
cb({
id: this.toAppPtyId(params.id as string),
data: params.data as string,
...(typeof params.rawLength === 'number'
? { sequenceChars: params.rawLength as number }
: {}),
...(params.transformed === true ? { transformed: true } : {}),
...(typeof params.seq === 'number' ? { seq: params.seq as number } : {})
})
}
break
case 'pty.replay':
for (const cb of this.replayListeners) {
cb({ id: this.toAppPtyId(params.id as string), data: params.data as string })
}
break
case 'pty.exit':
this.spawnExitRaces.recordExit(params.id as string, params.incarnationId)
for (const cb of this.exitListeners) {
cb({
id: this.toAppPtyId(params.id as string),
code: params.code as number,
...(isPtyIncarnationId(params.incarnationId)
? { incarnationId: params.incarnationId }
: {})
})
}
break
}
this.unsubscribeNotifications = subscribeSshPtyNotifications({
mux,
toAppPtyId: (id) => this.toAppPtyId(id),
dataListeners: this.dataListeners,
replayListeners: this.replayListeners,
exitListeners: this.exitListeners,
livePtyIds: this.livePtyIds,
recordExit: (relayPtyId, incarnationId) =>
this.spawnExitRaces.recordExit(relayPtyId, incarnationId)
})
}
@ -98,6 +73,7 @@ export class SshPtyProvider implements IPtyProvider {
this.dataListeners.clear()
this.replayListeners.clear()
this.exitListeners.clear()
this.livePtyIds.clear()
}
getConnectionId = (): string => this.connectionId
@ -124,13 +100,15 @@ export class SshPtyProvider implements IPtyProvider {
}
}
if (opts.sessionId) {
return await reattachSshPtySessionWithExitFence({
const result = await reattachSshPtySessionWithExitFence({
mux: this.mux,
connectionId: this.connectionId,
sessionId: opts.sessionId,
options: opts,
exitRaceTracker: this.spawnExitRaces
})
this.livePtyIds.add(result.id)
return result
}
const supportsCreateOperation = opts.agentSessionCreateOperationId
@ -181,9 +159,11 @@ export class SshPtyProvider implements IPtyProvider {
throw new Error(validation.error)
}
}
const id = this.toAppPtyId(spawnResult.id)
this.livePtyIds.add(id)
return {
...spawnResult,
id: this.toAppPtyId(spawnResult.id),
id,
...(claimed
? {
agentSessionEnsure: {
@ -259,6 +239,7 @@ export class SshPtyProvider implements IPtyProvider {
},
relayTimeoutOptions(opts.deadlineMs)
)
this.livePtyIds.delete(id)
}
async sendSignal(id: string, signal: string): Promise<void> {
@ -317,7 +298,15 @@ export class SshPtyProvider implements IPtyProvider {
undefined,
relayTimeoutOptions(opts?.deadlineMs)
)
return mapSshPtyProcessList(result as PtyProcessInfo[], (id) => this.toAppPtyId(id))
const processes = mapSshPtyProcessList(result as PtyProcessInfo[], (id) => this.toAppPtyId(id))
for (const process of processes) {
this.livePtyIds.add(process.id)
}
return processes
}
hasPty(id: string): boolean {
return this.livePtyIds.has(id)
}
async getDefaultShell(): Promise<string> {

View File

@ -686,8 +686,10 @@ describe('graph-sync mobile snapshot gating', () => {
).toBe(true)
// The persisted SSH binding is removed with no renderer-visible change, so
// the renderer resends the unchanged version 1 — the tab must still drop.
// the renderer resends the unchanged version 1 after the bounded HUB-restart
// recovery grace — the tab must still drop.
setSession(makeSession())
vi.advanceTimersByTime(30_001)
sync([makeRendererSnapshot({ version: 1 })])
vi.advanceTimersByTime(60)
expect(
@ -762,9 +764,10 @@ describe('graph-sync mobile snapshot gating', () => {
?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'ssh-tab')
).toBe(true)
// Once the SSH binding disappears from persistence (and no live PTY backs
// it), the next renderer revision must stop preserving it.
// Once the recovery grace expires and the SSH binding disappears from
// persistence (with no live PTY), the next revision must stop preserving it.
setSession(makeSession())
vi.advanceTimersByTime(30_001)
sync([makeRendererSnapshot({ version: 3, title: 'Renamed again' })])
vi.advanceTimersByTime(60)
expect(

View File

@ -190,6 +190,10 @@ function activeTabId(response: Record<string, unknown>): string | null {
return (response.result as RuntimeMobileSessionTabsResult | undefined)?.activeTabId ?? null
}
function snapshotVersion(response: Record<string, unknown>): number {
return (response.result as RuntimeMobileSessionTabsResult | undefined)?.snapshotVersion ?? -1
}
function seedSessionTabs(runtime: OrcaRuntimeService): void {
const tabs = ['host-tab', 'client-a-tab', 'client-a2-tab', 'client-b-tab'].map((id, index) => ({
type: 'terminal' as const,
@ -410,11 +414,17 @@ describe('paired runtime navigation isolation', () => {
notifyClients: false
}
})
expect(activeTabId(await harness.readerA.next('select-a2'))).toBe('client-a2-tab')
const selectA2 = await harness.readerA.next('select-a2')
expect(activeTabId(selectA2)).toBe('client-a2-tab')
harness.runtime.notifyMobileSessionTabsChanged(SESSION_WORKTREE_ID)
const [updateA, updateB] = await Promise.all([
harness.readerA.next('tabs-a', (response) => resultType(response) === 'updated'),
harness.readerA.next(
'tabs-a',
(response) =>
resultType(response) === 'updated' &&
snapshotVersion(response) >= snapshotVersion(selectA2)
),
harness.readerB.next('tabs-b', (response) => resultType(response) === 'updated')
])
expect(activeTabId(updateA)).toBe('client-a2-tab')

View File

@ -99,6 +99,10 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
import { awaitRuntimeFileWatcherUnsubscribes, RuntimeFileCommands } from './orca-runtime-files'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import {
resetSshConnectionGenerations,
setSshConnectionGeneration
} from '../ssh/ssh-connection-generation'
import { SEARCH_TIMEOUT_MS } from '../../shared/text-search'
type MockRuntimeSearchChild = EventEmitter & {
@ -207,6 +211,7 @@ describe('RuntimeFileCommands', () => {
watchMock.mockReset()
checkRgAvailableMock.mockReset()
vi.mocked(getSshFilesystemProvider).mockReset()
resetSshConnectionGenerations()
getLocalGitOptionsForRegisteredWorktreeMock.mockReset()
wslAwareSpawnMock.mockReset()
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({})
@ -367,11 +372,64 @@ describe('RuntimeFileCommands', () => {
const { commands } = createRuntimeFileCommands()
resolveAuthorizedPathMock.mockImplementation(async (p: string) => p)
await commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts')
await commands.renameFileExplorerPath(
'id:wt-1',
'old.ts',
'new.ts',
undefined,
undefined,
'local'
)
expect(renameMock).toHaveBeenCalledWith('/repo/old.ts', '/repo/new.ts')
})
it('rejects legacy paired local mutations before selecting a filesystem provider', async () => {
const { commands } = createRuntimeFileCommands()
await expect(commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts')).rejects.toThrow(
'newer Orca client'
)
expect(getSshFilesystemProvider).not.toHaveBeenCalled()
expect(renameMock).not.toHaveBeenCalled()
})
it('rejects legacy paired SSH mutations before selecting a filesystem provider', async () => {
const { commands, store } = createRuntimeFileCommands()
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
await expect(
commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-1')
).rejects.toThrow('newer Orca client')
expect(getSshFilesystemProvider).not.toHaveBeenCalled()
expect(renameMock).not.toHaveBeenCalled()
})
it('rejects a local expectation when the worktree moved to SSH', async () => {
const { commands, store } = createRuntimeFileCommands()
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
await expect(
commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', undefined, undefined, 'local')
).rejects.toThrow('Workspace host changed')
expect(getSshFilesystemProvider).not.toHaveBeenCalled()
expect(renameMock).not.toHaveBeenCalled()
})
it('rejects an SSH expectation when the worktree moved to HUB-local', async () => {
const { commands } = createRuntimeFileCommands()
await expect(
commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-1', 'ssh:ssh-1')
).rejects.toThrow('Workspace host changed')
expect(getSshFilesystemProvider).not.toHaveBeenCalled()
expect(renameMock).not.toHaveBeenCalled()
})
it('allows runtime-local case-only rename with IPC parity guard behavior', async () => {
const { commands } = createRuntimeFileCommands()
mockLocalPathStats({
@ -379,7 +437,14 @@ describe('RuntimeFileCommands', () => {
'/repo/readme.md': [10, 100]
})
await commands.renameFileExplorerPath('id:wt-1', 'README.md', 'readme.md')
await commands.renameFileExplorerPath(
'id:wt-1',
'README.md',
'readme.md',
undefined,
undefined,
'local'
)
expect(renameMock).toHaveBeenCalledWith('/repo/README.md', '/repo/readme.md')
})
@ -391,9 +456,9 @@ describe('RuntimeFileCommands', () => {
'/repo/new.ts': [11, 111]
})
await expect(commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts')).rejects.toThrow(
"A file or folder named 'new.ts' already exists in this location"
)
await expect(
commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', undefined, undefined, 'local')
).rejects.toThrow("A file or folder named 'new.ts' already exists in this location")
expect(renameMock).not.toHaveBeenCalled()
})
@ -406,7 +471,14 @@ describe('RuntimeFileCommands', () => {
})
await expect(
commands.renameFileExplorerPath('id:wt-1', 'README.md', 'README-hardlink.md')
commands.renameFileExplorerPath(
'id:wt-1',
'README.md',
'README-hardlink.md',
undefined,
undefined,
'local'
)
).rejects.toThrow("A file or folder named 'README-hardlink.md' already exists in this location")
expect(renameMock).not.toHaveBeenCalled()
@ -420,7 +492,14 @@ describe('RuntimeFileCommands', () => {
})
await expect(
commands.renameFileExplorerPath('id:wt-1', 'src/README.md', 'docs/readme.md')
commands.renameFileExplorerPath(
'id:wt-1',
'src/README.md',
'docs/readme.md',
undefined,
undefined,
'local'
)
).rejects.toThrow("A file or folder named 'readme.md' already exists in this location")
expect(renameMock).not.toHaveBeenCalled()
@ -432,9 +511,64 @@ describe('RuntimeFileCommands', () => {
const { commands, store } = createRuntimeFileCommands()
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
await commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts')
await commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-1', 'ssh:ssh-1')
expect(renameNoClobber).toHaveBeenCalledWith('/repo/old.ts', '/repo/new.ts')
expect(store.getRepo).toHaveBeenCalledTimes(1)
expect(renameMock).not.toHaveBeenCalled()
})
it('rejects a mutation captured for an obsolete SSH connection generation', async () => {
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never)
const { commands, store } = createRuntimeFileCommands()
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
setSshConnectionGeneration('ssh-1', 8)
await expect(
commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 7, 'ssh-1', 'ssh:ssh-1')
).rejects.toThrow('SSH connection changed')
expect(renameNoClobber).not.toHaveBeenCalled()
})
it('rejects nested SSH mutations from clients without generation support', async () => {
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never)
const { commands, store } = createRuntimeFileCommands()
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
await expect(
commands.renameFileExplorerPath(
'id:wt-1',
'old.ts',
'new.ts',
undefined,
'ssh-1',
'ssh:ssh-1'
)
).rejects.toThrow('SSH connection changed')
expect(renameNoClobber).not.toHaveBeenCalled()
})
it('rejects an equal-generation mutation captured for another SSH target', async () => {
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never)
const { commands, store } = createRuntimeFileCommands()
store.getRepo.mockReturnValue({ connectionId: 'ssh-b' })
await expect(
commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-a', 'ssh:ssh-a')
).rejects.toThrow('Workspace host changed')
expect(getSshFilesystemProvider).not.toHaveBeenCalled()
expect(renameNoClobber).not.toHaveBeenCalled()
})
it('rejects a stale SSH expectation after the worktree becomes HUB-local', async () => {
const { commands } = createRuntimeFileCommands()
await expect(
commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-1', 'ssh:ssh-1')
).rejects.toThrow('Workspace host changed')
expect(renameMock).not.toHaveBeenCalled()
})
@ -444,9 +578,9 @@ describe('RuntimeFileCommands', () => {
const { commands, store } = createRuntimeFileCommands()
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
await expect(commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts')).rejects.toThrow(
'destination exists'
)
await expect(
commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-1', 'ssh:ssh-1')
).rejects.toThrow('destination exists')
expect(renameMock).not.toHaveBeenCalled()
})

View File

@ -86,6 +86,8 @@ import {
RuntimeMobileFilePathSearchCache
} from './runtime-mobile-file-path-search'
import { beginWatcherInstall } from '../ipc/watcher-removal-gate'
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
import { toSshExecutionHostId } from '../../shared/execution-host'
const MOBILE_FILE_LIST_LIMIT = 5000
const MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT = 20_000
@ -97,8 +99,27 @@ const WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS = 150
export const WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS = 10_000
const TERMINAL_FILE_GRANT_TTL_MS = 10 * 60 * 1000
const OPEN_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0
const RUNTIME_FILE_MUTATION_UPDATE_REQUIRED =
'Remote file changes require a newer Orca client. Update the paired client and try again.'
function assertRuntimeFileMutationExpectation(
connectionId: string | undefined,
expectedExecutionHostId: string | undefined,
expectedSshTargetId: string | undefined,
expectedSshConnectionGeneration: number | undefined
): void {
if (!expectedExecutionHostId) {
throw new Error(RUNTIME_FILE_MUTATION_UPDATE_REQUIRED)
}
const actualExecutionHostId = connectionId ? toSshExecutionHostId(connectionId) : 'local'
if (expectedExecutionHostId !== actualExecutionHostId) {
throw new Error('Workspace host changed; refresh and try again')
}
assertSshMutationExpectation(connectionId, expectedSshTargetId, expectedSshConnectionGeneration)
}
// Why: files.watch cleanup is synchronous RPC; track native Parcel unsubscribes so shutdown can drain them.
const pendingRuntimeFileWatcherUnsubscribes = new Set<Promise<void>>()
type RuntimeFileWatcherLease = {
suspend(): Promise<void>
resume(): Promise<void>
@ -1339,9 +1360,18 @@ export class RuntimeFileCommands {
async writeFileExplorerFile(
worktreeSelector: string,
relativePath: string,
content: string
content: string,
expectedSshConnectionGeneration?: number,
expectedSshTargetId?: string,
expectedExecutionHostId?: string
): Promise<{ ok: true }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
assertRuntimeFileMutationExpectation(
target.connectionId,
expectedExecutionHostId,
expectedSshTargetId,
expectedSshConnectionGeneration
)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
@ -1369,9 +1399,18 @@ export class RuntimeFileCommands {
async writeFileExplorerFileBase64(
worktreeSelector: string,
relativePath: string,
contentBase64: string
contentBase64: string,
expectedSshConnectionGeneration?: number,
expectedSshTargetId?: string,
expectedExecutionHostId?: string
): Promise<{ ok: true }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
assertRuntimeFileMutationExpectation(
target.connectionId,
expectedExecutionHostId,
expectedSshTargetId,
expectedSshConnectionGeneration
)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
const content = Buffer.from(contentBase64, 'base64')
if (target.connectionId) {
@ -1392,9 +1431,18 @@ export class RuntimeFileCommands {
worktreeSelector: string,
relativePath: string,
contentBase64: string,
append: boolean
append: boolean,
expectedSshConnectionGeneration?: number,
expectedSshTargetId?: string,
expectedExecutionHostId?: string
): Promise<{ ok: true }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
assertRuntimeFileMutationExpectation(
target.connectionId,
expectedExecutionHostId,
expectedSshTargetId,
expectedSshConnectionGeneration
)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
const content = Buffer.from(contentBase64, 'base64')
if (target.connectionId) {
@ -1413,9 +1461,18 @@ export class RuntimeFileCommands {
async createFileExplorerFile(
worktreeSelector: string,
relativePath: string
relativePath: string,
expectedSshConnectionGeneration?: number,
expectedSshTargetId?: string,
expectedExecutionHostId?: string
): Promise<{ ok: true }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
assertRuntimeFileMutationExpectation(
target.connectionId,
expectedExecutionHostId,
expectedSshTargetId,
expectedSshConnectionGeneration
)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
@ -1437,9 +1494,18 @@ export class RuntimeFileCommands {
async createFileExplorerDir(
worktreeSelector: string,
relativePath: string
relativePath: string,
expectedSshConnectionGeneration?: number,
expectedSshTargetId?: string,
expectedExecutionHostId?: string
): Promise<{ ok: true }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
assertRuntimeFileMutationExpectation(
target.connectionId,
expectedExecutionHostId,
expectedSshTargetId,
expectedSshConnectionGeneration
)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
@ -1457,9 +1523,18 @@ export class RuntimeFileCommands {
async createFileExplorerDirNoClobber(
worktreeSelector: string,
relativePath: string
relativePath: string,
expectedSshConnectionGeneration?: number,
expectedSshTargetId?: string,
expectedExecutionHostId?: string
): Promise<{ ok: true }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
assertRuntimeFileMutationExpectation(
target.connectionId,
expectedExecutionHostId,
expectedSshTargetId,
expectedSshConnectionGeneration
)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
@ -1477,10 +1552,21 @@ export class RuntimeFileCommands {
async commitFileExplorerUpload(
worktreeSelector: string,
tempRelativePath: string,
finalRelativePath: string
finalRelativePath: string,
expectedSshConnectionGeneration?: number,
expectedSshTargetId?: string,
expectedExecutionHostId?: string
): Promise<{ ok: true }> {
const tempTarget = await this.resolveFileExplorerPath(worktreeSelector, tempRelativePath)
const finalTarget = await this.resolveFileExplorerPath(worktreeSelector, finalRelativePath)
const [tempTarget, finalTarget] = await this.resolveFileExplorerPaths(worktreeSelector, [
tempRelativePath,
finalRelativePath
])
assertRuntimeFileMutationExpectation(
tempTarget.connectionId,
expectedExecutionHostId,
expectedSshTargetId,
expectedSshConnectionGeneration
)
const provider = tempTarget.connectionId
? getSshFilesystemProvider(tempTarget.connectionId)
: null
@ -1505,10 +1591,21 @@ export class RuntimeFileCommands {
async renameFileExplorerPath(
worktreeSelector: string,
oldRelativePath: string,
newRelativePath: string
newRelativePath: string,
expectedSshConnectionGeneration?: number,
expectedSshTargetId?: string,
expectedExecutionHostId?: string
): Promise<{ ok: true }> {
const oldTarget = await this.resolveFileExplorerPath(worktreeSelector, oldRelativePath)
const newTarget = await this.resolveFileExplorerPath(worktreeSelector, newRelativePath)
const [oldTarget, newTarget] = await this.resolveFileExplorerPaths(worktreeSelector, [
oldRelativePath,
newRelativePath
])
assertRuntimeFileMutationExpectation(
oldTarget.connectionId,
expectedExecutionHostId,
expectedSshTargetId,
expectedSshConnectionGeneration
)
const provider = oldTarget.connectionId
? getSshFilesystemProvider(oldTarget.connectionId)
: null
@ -1531,12 +1628,20 @@ export class RuntimeFileCommands {
async copyFileExplorerPath(
worktreeSelector: string,
sourceRelativePath: string,
destinationRelativePath: string
destinationRelativePath: string,
expectedSshConnectionGeneration?: number,
expectedSshTargetId?: string,
expectedExecutionHostId?: string
): Promise<{ ok: true }> {
const sourceTarget = await this.resolveFileExplorerPath(worktreeSelector, sourceRelativePath)
const destinationTarget = await this.resolveFileExplorerPath(
const [sourceTarget, destinationTarget] = await this.resolveFileExplorerPaths(
worktreeSelector,
destinationRelativePath
[sourceRelativePath, destinationRelativePath]
)
assertRuntimeFileMutationExpectation(
sourceTarget.connectionId,
expectedExecutionHostId,
expectedSshTargetId,
expectedSshConnectionGeneration
)
const provider = sourceTarget.connectionId
? getSshFilesystemProvider(sourceTarget.connectionId)
@ -1565,9 +1670,18 @@ export class RuntimeFileCommands {
async deleteFileExplorerPath(
worktreeSelector: string,
relativePath: string,
recursive?: boolean
recursive?: boolean,
expectedSshConnectionGeneration?: number,
expectedSshTargetId?: string,
expectedExecutionHostId?: string
): Promise<{ ok: true }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
assertRuntimeFileMutationExpectation(
target.connectionId,
expectedExecutionHostId,
expectedSshTargetId,
expectedSshConnectionGeneration
)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
@ -1768,13 +1882,23 @@ export class RuntimeFileCommands {
worktreeSelector: string,
relativePath: string
): Promise<{ worktree: ResolvedRuntimeFileWorktree; path: string; connectionId?: string }> {
const [target] = await this.resolveFileExplorerPaths(worktreeSelector, [relativePath])
return target
}
private async resolveFileExplorerPaths(
worktreeSelector: string,
relativePaths: readonly string[]
): Promise<{ worktree: ResolvedRuntimeFileWorktree; path: string; connectionId?: string }[]> {
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
const normalizedRelativePath = normalizeRuntimeRelativePath(relativePath)
return {
return relativePaths.map((relativePath) => ({
worktree: target.worktree,
path: joinWorktreeRelativePath(target.worktree.path, normalizedRelativePath),
path: joinWorktreeRelativePath(
target.worktree.path,
normalizeRuntimeRelativePath(relativePath)
),
connectionId: target.connectionId
}
}))
}
private async listRemoteMobileFiles(

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,9 @@
import type { SshConnectionState } from '../../shared/ssh-types'
export function getPublicSshError(status: SshConnectionState['status']): string {
return status === 'auth-failed' ? 'SSH authentication failed' : 'SSH connection unavailable'
}
export function getPublicSshState(state: SshConnectionState | null): SshConnectionState | null {
return state ? { ...state, error: state.error ? getPublicSshError(state.status) : null } : null
}

View File

@ -1,4 +1,6 @@
import { z } from 'zod'
import { getRegisteredSshState, listRegisteredSshTargets } from '../../../ipc/ssh'
import { getPublicSshState } from '../../public-ssh-state'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
let clientEventSubscriptionSeq = 0
@ -36,7 +38,12 @@ export const CLIENT_EVENT_METHODS: readonly RpcAnyMethod[] = [
for (const event of runtime.getTerminalSleepClientEventSnapshot?.() ?? []) {
emit(event)
}
emit({ type: 'ready', subscriptionId })
const sshStates = listRegisteredSshTargets().flatMap((target) => {
const state = getPublicSshState(getRegisteredSshState(target.id) ?? null)
return state ? [{ targetId: target.id, state }] : []
})
// Why: attaching the listener before snapshotting closes the reload gap without exposing HUB-private target configuration.
emit({ type: 'ready', subscriptionId, snapshot: { sshStates } })
})
}
}),

View File

@ -396,7 +396,7 @@ describe('file RPC methods', () => {
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const response = await dispatcher.dispatch(
await dispatcher.dispatch(
makeRequest('files.writeTerminalArtifact', {
worktree: 'id:wt-1',
absolutePath: '/tmp/result.json',
@ -412,7 +412,6 @@ describe('file RPC methods', () => {
'{}',
undefined
)
expect(response).toMatchObject({ ok: true, result: { ok: true } })
})
it('reads a preview file for a selected worktree', async () => {
@ -775,7 +774,7 @@ describe('file RPC methods', () => {
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const response = await dispatcher.dispatch(
await dispatcher.dispatch(
makeRequest('files.delete', {
worktree: 'id:wt-1',
relativePath: 'src',
@ -784,6 +783,34 @@ describe('file RPC methods', () => {
)
expect(runtime.deleteFileExplorerPath).toHaveBeenCalledWith('id:wt-1', 'src', true)
})
it('forwards the captured SSH target and generation for destructive mutations', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
deleteFileExplorerPath: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('files.delete', {
worktree: 'id:wt-1',
relativePath: 'src',
recursive: true,
expectedExecutionHostId: 'ssh:ssh-1',
expectedSshTargetId: 'ssh-1',
expectedSshConnectionGeneration: 7
})
)
expect(runtime.deleteFileExplorerPath).toHaveBeenCalledWith(
'id:wt-1',
'src',
true,
7,
'ssh-1',
'ssh:ssh-1'
)
expect(response).toMatchObject({ ok: true, result: { ok: true } })
})

View File

@ -12,6 +12,29 @@ function isValidRuntimeFileBase64(value: unknown): value is string {
)
}
type SshMutationParams = {
expectedExecutionHostId?: string
expectedSshTargetId?: string
expectedSshConnectionGeneration?: number
}
function sshMutationArguments(
params: SshMutationParams
): [expectedGeneration?: number, expectedTargetId?: string, expectedExecutionHostId?: string] {
if (
params.expectedExecutionHostId === undefined &&
params.expectedSshTargetId === undefined &&
params.expectedSshConnectionGeneration === undefined
) {
return []
}
return [
params.expectedSshConnectionGeneration,
params.expectedSshTargetId,
params.expectedExecutionHostId
]
}
const WorktreeSelector = z.object({
worktree: z
.unknown()
@ -31,6 +54,12 @@ const FileOpen = WorktreeSelector.extend({
.pipe(z.string().min(1, 'Missing relative path'))
})
const FileMutationOpen = FileOpen.extend({
expectedExecutionHostId: z.string().min(1).optional(),
expectedSshTargetId: z.string().min(1).optional(),
expectedSshConnectionGeneration: z.number().int().nonnegative().optional()
})
const ResolveTerminalPath = WorktreeSelector.extend({
pathText: z
.unknown()
@ -86,13 +115,13 @@ const ServerDirectoryBrowse = z.object({
// Why: write content must be a real string. Coercing a missing/non-string value
// to '' silently truncated the target file to empty instead of erroring. An
// explicit '' is still accepted (writing an empty file is legitimate).
const FileWrite = FileOpen.extend({
const FileWrite = FileMutationOpen.extend({
content: z
.unknown()
.refine((v): v is string => typeof v === 'string', { message: 'Missing file content' })
})
const FileWriteBase64 = FileOpen.extend({
const FileWriteBase64 = FileMutationOpen.extend({
contentBase64: z
.unknown()
.refine((v): v is string => typeof v === 'string', { message: 'Missing file content' })
@ -115,6 +144,9 @@ const FileReadChunk = FileOpen.extend({
})
const FileRename = WorktreeSelector.extend({
expectedExecutionHostId: z.string().min(1).optional(),
expectedSshTargetId: z.string().min(1).optional(),
expectedSshConnectionGeneration: z.number().int().nonnegative().optional(),
oldRelativePath: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
@ -126,6 +158,9 @@ const FileRename = WorktreeSelector.extend({
})
const FileCopy = WorktreeSelector.extend({
expectedExecutionHostId: z.string().min(1).optional(),
expectedSshTargetId: z.string().min(1).optional(),
expectedSshConnectionGeneration: z.number().int().nonnegative().optional(),
sourceRelativePath: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
@ -137,6 +172,9 @@ const FileCopy = WorktreeSelector.extend({
})
const FileCommitUpload = WorktreeSelector.extend({
expectedExecutionHostId: z.string().min(1).optional(),
expectedSshTargetId: z.string().min(1).optional(),
expectedSshConnectionGeneration: z.number().int().nonnegative().optional(),
tempRelativePath: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
@ -147,7 +185,7 @@ const FileCommitUpload = WorktreeSelector.extend({
.pipe(z.string().min(1, 'Missing final path'))
})
const FileDelete = FileOpen.extend({
const FileDelete = FileMutationOpen.extend({
recursive: z.boolean().optional()
})
@ -283,7 +321,12 @@ export const FILE_METHODS: RpcAnyMethod[] = [
name: 'files.write',
params: FileWrite,
handler: async (params, { runtime }) =>
runtime.writeFileExplorerFile(params.worktree, params.relativePath, params.content)
runtime.writeFileExplorerFile(
params.worktree,
params.relativePath,
params.content,
...sshMutationArguments(params)
)
}),
defineMethod({
name: 'files.writeBase64',
@ -292,7 +335,8 @@ export const FILE_METHODS: RpcAnyMethod[] = [
runtime.writeFileExplorerFileBase64(
params.worktree,
params.relativePath,
params.contentBase64
params.contentBase64,
...sshMutationArguments(params)
)
}),
defineMethod({
@ -303,26 +347,39 @@ export const FILE_METHODS: RpcAnyMethod[] = [
params.worktree,
params.relativePath,
params.contentBase64,
params.append === true
params.append === true,
...sshMutationArguments(params)
)
}),
defineMethod({
name: 'files.createFile',
params: FileOpen,
params: FileMutationOpen,
handler: async (params, { runtime }) =>
runtime.createFileExplorerFile(params.worktree, params.relativePath)
runtime.createFileExplorerFile(
params.worktree,
params.relativePath,
...sshMutationArguments(params)
)
}),
defineMethod({
name: 'files.createDir',
params: FileOpen,
params: FileMutationOpen,
handler: async (params, { runtime }) =>
runtime.createFileExplorerDir(params.worktree, params.relativePath)
runtime.createFileExplorerDir(
params.worktree,
params.relativePath,
...sshMutationArguments(params)
)
}),
defineMethod({
name: 'files.createDirNoClobber',
params: FileOpen,
params: FileMutationOpen,
handler: async (params, { runtime }) =>
runtime.createFileExplorerDirNoClobber(params.worktree, params.relativePath)
runtime.createFileExplorerDirNoClobber(
params.worktree,
params.relativePath,
...sshMutationArguments(params)
)
}),
defineMethod({
name: 'files.commitUpload',
@ -331,7 +388,8 @@ export const FILE_METHODS: RpcAnyMethod[] = [
runtime.commitFileExplorerUpload(
params.worktree,
params.tempRelativePath,
params.finalRelativePath
params.finalRelativePath,
...sshMutationArguments(params)
)
}),
defineMethod({
@ -341,7 +399,8 @@ export const FILE_METHODS: RpcAnyMethod[] = [
runtime.renameFileExplorerPath(
params.worktree,
params.oldRelativePath,
params.newRelativePath
params.newRelativePath,
...sshMutationArguments(params)
)
}),
defineMethod({
@ -351,14 +410,20 @@ export const FILE_METHODS: RpcAnyMethod[] = [
runtime.copyFileExplorerPath(
params.worktree,
params.sourceRelativePath,
params.destinationRelativePath
params.destinationRelativePath,
...sshMutationArguments(params)
)
}),
defineMethod({
name: 'files.delete',
params: FileDelete,
handler: async (params, { runtime }) =>
runtime.deleteFileExplorerPath(params.worktree, params.relativePath, params.recursive)
runtime.deleteFileExplorerPath(
params.worktree,
params.relativePath,
params.recursive,
...sshMutationArguments(params)
)
}),
defineMethod({
name: 'files.search',

View File

@ -72,15 +72,89 @@ describe('ssh RPC methods', () => {
expect(response).toMatchObject({ ok: true, result: { state: null } })
})
it('lists the registered SSH targets for paired clients', async () => {
const targets = [{ id: 'ssh-1', label: 'Dev box', host: 'dev', port: 22, username: 'me' }]
it('redacts HUB-private diagnostics from state and connect failures', async () => {
const privateMessage = 'identity /Users/hub/.ssh/private via bastion.internal failed'
getRegisteredSshStateMock.mockReturnValue({
targetId: 'ssh-1',
status: 'auth-failed',
error: privateMessage,
reconnectAttempt: 0
})
connectRegisteredSshTargetMock.mockRejectedValueOnce(new Error(privateMessage))
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
const stateResponse = await dispatcher.dispatch(
makeRequest('ssh.getState', { targetId: 'ssh-1' })
)
const connectResponse = await dispatcher.dispatch(
makeRequest('ssh.connect', { targetId: 'ssh-1' })
)
expect(stateResponse).toMatchObject({
ok: true,
result: { state: { error: 'SSH authentication failed' } }
})
expect(connectResponse).toMatchObject({
ok: false,
error: { message: 'SSH authentication failed' }
})
expect(JSON.stringify([stateResponse, connectResponse])).not.toContain(privateMessage)
})
it('lists redacted SSH target summaries for paired clients', async () => {
const targets = [
{
id: 'ssh-1',
label: 'Dev box',
host: 'dev.internal',
port: 22,
username: 'me',
identityFile: '/secret/key',
jumpHost: 'bastion',
proxyCommand: 'private proxy'
}
]
listRegisteredSshTargetsMock.mockReturnValueOnce(targets)
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
const response = await dispatcher.dispatch(makeRequest('ssh.listTargetSummaries'))
expect(response).toMatchObject({
ok: true,
result: { targets: [{ id: 'ssh-1', label: 'Dev box' }] }
})
expect(JSON.stringify(response)).not.toContain('dev.internal')
expect(JSON.stringify(response)).not.toContain('/secret/key')
expect(JSON.stringify(response)).not.toContain('bastion')
})
it('redacts the legacy target response for older clients', async () => {
const targets = [
{
id: 'ssh-1',
label: 'Dev box',
host: 'dev.internal',
port: 22,
username: 'me',
identityFile: '/secret/key',
jumpHost: 'bastion'
}
]
listRegisteredSshTargetsMock.mockReturnValueOnce(targets)
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
const response = await dispatcher.dispatch(makeRequest('ssh.listTargets'))
expect(response).toMatchObject({ ok: true, result: { targets } })
expect(response).toMatchObject({
ok: true,
result: { targets: [{ id: 'ssh-1', label: 'Dev box' }] }
})
expect(JSON.stringify(response)).not.toContain('dev.internal')
expect(JSON.stringify(response)).not.toContain('/secret/key')
expect(JSON.stringify(response)).not.toContain('bastion')
})
it('lists removed-target labels for ghost-host display on paired clients', async () => {

View File

@ -6,26 +6,47 @@ import {
listRegisteredSshTargets
} from '../../../ipc/ssh'
import { defineMethod, type RpcMethod } from '../core'
import { getPublicSshError, getPublicSshState } from '../../public-ssh-state'
const SshTarget = z.object({
targetId: z.string().min(1)
})
function listRegisteredSshTargetSummaries(): { id: string; label: string }[] {
return listRegisteredSshTargets().map(({ id, label }) => ({ id, label }))
}
export const SSH_METHODS: RpcMethod[] = [
defineMethod({
name: 'ssh.getState',
params: SshTarget,
handler: (params) => ({ state: getRegisteredSshState(params.targetId) ?? null })
handler: (params) => ({
state: getPublicSshState(getRegisteredSshState(params.targetId) ?? null)
})
}),
defineMethod({
name: 'ssh.connect',
params: SshTarget,
handler: async (params) => ({ state: await connectRegisteredSshTarget(params.targetId) })
handler: async (params) => {
try {
return { state: getPublicSshState(await connectRegisteredSshTarget(params.targetId)) }
} catch {
const state = getRegisteredSshState(params.targetId)
throw new Error(getPublicSshError(state?.status ?? 'error'))
}
}
}),
defineMethod({
name: 'ssh.listTargets',
params: null,
handler: () => ({ targets: listRegisteredSshTargets() })
// Why: legacy clients can call this method directly, so it must preserve the same HUB-private secret boundary.
handler: () => ({ targets: listRegisteredSshTargetSummaries() })
}),
defineMethod({
name: 'ssh.listTargetSummaries',
params: null,
// Why: paired clients need display identity only; SSH addresses, jump chains, and credentials remain HUB-private.
handler: () => ({ targets: listRegisteredSshTargetSummaries() })
}),
defineMethod({
name: 'ssh.listRemovedTargetLabels',

View File

@ -839,7 +839,14 @@ const TerminalResolveActive = z.object({
})
const TerminalResolvePane = z.object({
paneKey: requiredString('Missing pane key')
paneKey: requiredString('Missing pane key'),
worktreeId: OptionalString
})
const TerminalRecoverPane = z.object({
paneKey: requiredString('Missing pane key'),
worktreeId: requiredString('Missing worktree ID'),
expectedTerminal: requiredString('Missing expected terminal handle').optional()
})
const TerminalRead = TerminalHandle.extend({
@ -1110,7 +1117,18 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
name: 'terminal.resolvePane',
params: TerminalResolvePane,
handler: async (params, { runtime }) => ({
terminal: runtime.resolveTerminalPane(params.paneKey)
terminal: runtime.resolveTerminalPane(params.paneKey, params.worktreeId)
})
}),
defineMethod({
name: 'terminal.recoverPane',
params: TerminalRecoverPane,
handler: async (params, { runtime }) => ({
terminal: await runtime.recoverTerminalPane(
params.paneKey,
params.worktreeId,
params.expectedTerminal
)
})
}),
defineMethod({

View File

@ -3092,7 +3092,7 @@ describe('OrcaRuntimeRpcServer', () => {
id: 'req_resolve_pane',
authToken: metadata!.authToken,
method: 'terminal.resolvePane',
params: { paneKey: `tab-right:${bottomLeaf}` }
params: { paneKey: `tab-right:${bottomLeaf}`, worktreeId }
})
expect(resolvePaneResponse).toMatchObject({
id: 'req_resolve_pane',
@ -3102,10 +3102,23 @@ describe('OrcaRuntimeRpcServer', () => {
handle: handleByLeaf.get(bottomLeaf),
tabId: 'tab-right',
leafId: bottomLeaf,
ptyId: 'pty-bottom'
ptyId: 'pty-bottom',
worktreeId
}
}
})
const wrongOwnerResponse = await sendRequest(metadata!.transports[0]!.endpoint, {
id: 'req_resolve_pane_wrong_owner',
authToken: metadata!.authToken,
method: 'terminal.resolvePane',
params: { paneKey: `tab-right:${bottomLeaf}`, worktreeId: 'other-worktree' }
})
expect(wrongOwnerResponse).toMatchObject({
id: 'req_resolve_pane_wrong_owner',
ok: false,
error: { message: 'terminal_not_found' }
})
} finally {
await server.stop()
}

View File

@ -341,6 +341,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'ssh.getState',
'ssh.listRemovedTargetLabels',
'ssh.listTargets',
'ssh.listTargetSummaries',
'speech.dictation.cancel',
'speech.dictation.chunk',
'speech.dictation.finish',

View File

@ -0,0 +1,90 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
advanceSshConnectionGeneration,
assertSshMutationExpectation,
getSshConnectionGeneration,
resetSshConnectionGenerations,
setSshConnectionGeneration
} from './ssh-connection-generation'
const SESSION_COUNTER_STRIDE = 2 ** 13
const MAX_SESSION_SCOPE = 2 ** 40 - 1
describe('SSH connection generation session scope', () => {
afterEach(() => resetSshConnectionGenerations())
it('does not reuse a target token when a restarted HUB reaches the same counter', () => {
resetSshConnectionGenerations(41)
const beforeRestart = advanceSshConnectionGeneration('ssh-a')
resetSshConnectionGenerations(42)
const afterRestart = advanceSshConnectionGeneration('ssh-a')
expect(afterRestart).not.toBe(beforeRestart)
expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', beforeRestart)).toThrow(
'SSH connection changed; refresh and try again'
)
expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', afterRestart)).not.toThrow()
})
it('keeps target counters independent within one HUB session', () => {
resetSshConnectionGenerations(7)
expect(advanceSshConnectionGeneration('ssh-a')).toBe(advanceSshConnectionGeneration('ssh-b'))
expect(getSshConnectionGeneration('ssh-a')).toBe(getSshConnectionGeneration('ssh-b'))
})
it('rejects an SSH execution-host expectation when direct IPC resolves locally', () => {
expect(() =>
assertSshMutationExpectation(undefined, undefined, undefined, 'ssh:ssh-a')
).toThrow('Workspace host changed; refresh and try again')
})
it('rejects a local execution-host expectation when direct IPC resolves through SSH', () => {
expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', 0, 'local')).toThrow(
'Workspace host changed; refresh and try again'
)
})
it('rolls the session scope after counter exhaustion and keeps rotating', () => {
resetSshConnectionGenerations(7)
const exhaustedGeneration = 8 * SESSION_COUNTER_STRIDE - 1
setSshConnectionGeneration('ssh-a', exhaustedGeneration)
const rolledGeneration = advanceSshConnectionGeneration('ssh-a')
expect(rolledGeneration).toBe(8 * SESSION_COUNTER_STRIDE + 1)
expect(advanceSshConnectionGeneration('ssh-a')).toBe(rolledGeneration + 1)
expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', exhaustedGeneration)).toThrow(
'SSH connection changed; refresh and try again'
)
})
it('invalidates other targets when exhaustion rolls the session scope', () => {
resetSshConnectionGenerations(11)
const otherTargetGeneration = advanceSshConnectionGeneration('ssh-b')
setSshConnectionGeneration('ssh-a', 12 * SESSION_COUNTER_STRIDE - 1)
const rolledGeneration = advanceSshConnectionGeneration('ssh-a')
expect(getSshConnectionGeneration('ssh-b')).toBe(12 * SESSION_COUNTER_STRIDE)
expect(rolledGeneration).toBe(12 * SESSION_COUNTER_STRIDE + 1)
expect(() => assertSshMutationExpectation('ssh-b', 'ssh-b', otherTargetGeneration)).toThrow(
'SSH connection changed; refresh and try again'
)
expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', rolledGeneration)).not.toThrow()
})
it('wraps the maximum safe numeric scope without reusing it', () => {
resetSshConnectionGenerations(MAX_SESSION_SCOPE)
setSshConnectionGeneration('ssh-a', Number.MAX_SAFE_INTEGER)
const rolledGeneration = advanceSshConnectionGeneration('ssh-a')
expect(rolledGeneration).toBe(1)
expect(Number.isSafeInteger(rolledGeneration)).toBe(true)
expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', Number.MAX_SAFE_INTEGER)).toThrow(
'SSH connection changed; refresh and try again'
)
})
})

View File

@ -0,0 +1,101 @@
import { randomBytes } from 'node:crypto'
import { toSshExecutionHostId } from '../../shared/execution-host'
const SESSION_COUNTER_BITS = 13
const SESSION_COUNTER_STRIDE = 2 ** SESSION_COUNTER_BITS
const MAX_SESSION_SCOPE = 2 ** (53 - SESSION_COUNTER_BITS) - 1
function createSessionScope(): number {
return randomBytes(5).readUIntBE(0, 5)
}
let sessionGenerationBase = 0
let sessionInitialized = false
const connectionGenerationByTarget = new Map<string, number>()
const usedSessionScopes = new Set<number>()
function assertGenerationInCurrentSession(generation: number): void {
if (
!Number.isSafeInteger(generation) ||
generation < sessionGenerationBase ||
generation - sessionGenerationBase >= SESSION_COUNTER_STRIDE
) {
throw new Error('SSH connection generation exhausted for this runtime session')
}
}
export function getSshConnectionGeneration(targetId: string): number {
return connectionGenerationByTarget.get(targetId) ?? sessionGenerationBase
}
export function initializeSshConnectionGenerationSession(): void {
if (sessionInitialized) {
return
}
const sessionScope = createSessionScope()
// Why: randomize the process scope so a replacement HUB does not predictably reuse the prior target/counter token.
sessionGenerationBase = sessionScope * SESSION_COUNTER_STRIDE
usedSessionScopes.add(sessionScope)
sessionInitialized = true
}
export function advanceSshConnectionGeneration(targetId: string): number {
let next = getSshConnectionGeneration(targetId) + 1
if (next - sessionGenerationBase >= SESSION_COUNTER_STRIDE) {
let nextSessionScope =
(sessionGenerationBase / SESSION_COUNTER_STRIDE + 1) % (MAX_SESSION_SCOPE + 1)
while (usedSessionScopes.has(nextSessionScope)) {
nextSessionScope = (nextSessionScope + 1) % (MAX_SESSION_SCOPE + 1)
}
usedSessionScopes.add(nextSessionScope)
sessionGenerationBase = nextSessionScope * SESSION_COUNTER_STRIDE
// Why: changing the scope must revoke tokens for every target, not only the target that exhausted its counter.
connectionGenerationByTarget.clear()
next = sessionGenerationBase + 1
}
assertGenerationInCurrentSession(next)
connectionGenerationByTarget.set(targetId, next)
return next
}
export function setSshConnectionGeneration(targetId: string, generation: number): void {
assertGenerationInCurrentSession(generation)
connectionGenerationByTarget.set(targetId, generation)
}
export function resetSshConnectionGenerations(sessionScope = 0): void {
if (!Number.isSafeInteger(sessionScope) || sessionScope < 0 || sessionScope > MAX_SESSION_SCOPE) {
throw new Error('Invalid SSH connection generation session scope')
}
sessionGenerationBase = sessionScope * SESSION_COUNTER_STRIDE
sessionInitialized = true
connectionGenerationByTarget.clear()
usedSessionScopes.clear()
usedSessionScopes.add(sessionScope)
}
export function assertSshMutationExpectation(
connectionId: string | undefined,
expectedTargetId: string | undefined,
expectedGeneration: number | undefined,
expectedExecutionHostId?: string
): void {
const actualExecutionHostId = connectionId ? toSshExecutionHostId(connectionId) : 'local'
if (expectedExecutionHostId !== undefined && expectedExecutionHostId !== actualExecutionHostId) {
throw new Error('Workspace host changed; refresh and try again')
}
const hasExpectation = expectedTargetId !== undefined || expectedGeneration !== undefined
if (!connectionId) {
if (hasExpectation) {
throw new Error('SSH connection changed; refresh and try again')
}
return
}
if (
expectedTargetId !== connectionId ||
expectedGeneration === undefined ||
expectedGeneration !== getSshConnectionGeneration(connectionId)
) {
throw new Error('SSH connection changed; refresh and try again')
}
}

View File

@ -32,6 +32,7 @@ import type {
} from '../shared/terminal-render-desync-evidence'
import type { MobileRelayStatus } from '../shared/mobile-relay-status'
import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode'
import type { SshMutationExpectation } from '../shared/ssh-types'
import type {
CreateLocalOrcaProfileArgs,
CreateLocalOrcaProfileResult,
@ -2503,20 +2504,32 @@ export type PreloadApi = {
rootPath: string
connectionId?: string
}) => Promise<MarkdownDocument[]>
writeFile: (args: { filePath: string; content: string; connectionId?: string }) => Promise<void>
createFile: (args: { filePath: string; connectionId?: string }) => Promise<void>
createDir: (args: { dirPath: string; connectionId?: string }) => Promise<void>
rename: (args: { oldPath: string; newPath: string; connectionId?: string }) => Promise<void>
copy: (args: {
sourcePath: string
destinationPath: string
connectionId?: string
}) => Promise<void>
deletePath: (args: {
targetPath: string
connectionId?: string
recursive?: boolean
}) => Promise<void>
writeFile: (
args: { filePath: string; content: string; connectionId?: string } & SshMutationExpectation
) => Promise<void>
createFile: (
args: { filePath: string; connectionId?: string } & SshMutationExpectation
) => Promise<void>
createDir: (
args: { dirPath: string; connectionId?: string } & SshMutationExpectation
) => Promise<void>
rename: (
args: { oldPath: string; newPath: string; connectionId?: string } & SshMutationExpectation
) => Promise<void>
copy: (
args: {
sourcePath: string
destinationPath: string
connectionId?: string
} & SshMutationExpectation
) => Promise<void>
deletePath: (
args: {
targetPath: string
connectionId?: string
recursive?: boolean
} & SshMutationExpectation
) => Promise<void>
authorizeExternalPath: (args: { targetPath: string }) => Promise<void>
stat: (args: {
filePath: string
@ -2531,12 +2544,14 @@ export type PreloadApi = {
}) => Promise<string[]>
cancelListFiles: (args: { requestToken: string }) => Promise<void>
search: (args: SearchOptions & { connectionId?: string }) => Promise<SearchResult>
importExternalPaths: (args: {
sourcePaths: string[]
destDir: string
connectionId?: string
ensureDir?: boolean
}) => Promise<{
importExternalPaths: (
args: {
sourcePaths: string[]
destDir: string
connectionId?: string
ensureDir?: boolean
} & SshMutationExpectation
) => Promise<{
results: (
| {
sourcePath: string
@ -2581,11 +2596,13 @@ export type PreloadApi = {
}
)[]
}>
resolveDroppedPathsForAgent: (args: {
paths: string[]
worktreePath: string
connectionId?: string
}) => Promise<{
resolveDroppedPathsForAgent: (
args: {
paths: string[]
worktreePath: string
connectionId?: string
} & SshMutationExpectation
) => Promise<{
resolvedPaths: string[]
skipped: {
sourcePath: string
@ -3077,6 +3094,7 @@ export type PreloadApi = {
method: string
params?: unknown
timeoutMs?: number
expectedEnvironmentPairingRevision?: number
}) => Promise<RuntimeRpcResponse<unknown>>
subscribe: (
args: {
@ -3084,6 +3102,7 @@ export type PreloadApi = {
method: string
params?: unknown
timeoutMs?: number
expectedEnvironmentPairingRevision?: number
},
callbacks: {
onResponse: (response: RuntimeRpcResponse<unknown>) => void

View File

@ -20,6 +20,7 @@ import type {
} from '../shared/agent-session-resume'
import type { MobileRelayStatus } from '../shared/mobile-relay-status'
import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode'
import type { SshMutationExpectation } from '../shared/ssh-types'
import type {
BaseRefSearchResult,
BaseRefDefaultResult,
@ -2862,27 +2863,36 @@ const api = {
connectionId?: string
}): Promise<{ filePath: string; relativePath: string; basename: string; name: string }[]> =>
ipcRenderer.invoke('fs:listMarkdownDocuments', args),
writeFile: (args: {
filePath: string
content: string
connectionId?: string
}): Promise<void> => ipcRenderer.invoke('fs:writeFile', args),
createFile: (args: { filePath: string; connectionId?: string }): Promise<void> =>
ipcRenderer.invoke('fs:createFile', args),
createDir: (args: { dirPath: string; connectionId?: string }): Promise<void> =>
ipcRenderer.invoke('fs:createDir', args),
rename: (args: { oldPath: string; newPath: string; connectionId?: string }): Promise<void> =>
ipcRenderer.invoke('fs:rename', args),
copy: (args: {
sourcePath: string
destinationPath: string
connectionId?: string
}): Promise<void> => ipcRenderer.invoke('fs:copy', args),
deletePath: (args: {
targetPath: string
connectionId?: string
recursive?: boolean
}): Promise<void> => ipcRenderer.invoke('fs:deletePath', args),
writeFile: (
args: {
filePath: string
content: string
connectionId?: string
} & SshMutationExpectation
): Promise<void> => ipcRenderer.invoke('fs:writeFile', args),
createFile: (
args: { filePath: string; connectionId?: string } & SshMutationExpectation
): Promise<void> => ipcRenderer.invoke('fs:createFile', args),
createDir: (
args: { dirPath: string; connectionId?: string } & SshMutationExpectation
): Promise<void> => ipcRenderer.invoke('fs:createDir', args),
rename: (
args: { oldPath: string; newPath: string; connectionId?: string } & SshMutationExpectation
): Promise<void> => ipcRenderer.invoke('fs:rename', args),
copy: (
args: {
sourcePath: string
destinationPath: string
connectionId?: string
} & SshMutationExpectation
): Promise<void> => ipcRenderer.invoke('fs:copy', args),
deletePath: (
args: {
targetPath: string
connectionId?: string
recursive?: boolean
} & SshMutationExpectation
): Promise<void> => ipcRenderer.invoke('fs:deletePath', args),
authorizeExternalPath: (args: { targetPath: string }): Promise<void> =>
ipcRenderer.invoke('fs:authorizeExternalPath', args),
stat: (args: {
@ -2911,12 +2921,14 @@ const api = {
maxResults?: number
connectionId?: string
}): Promise<SearchResult> => ipcRenderer.invoke('fs:search', args),
importExternalPaths: (args: {
sourcePaths: string[]
destDir: string
connectionId?: string
ensureDir?: boolean
}): Promise<{
importExternalPaths: (
args: {
sourcePaths: string[]
destDir: string
connectionId?: string
ensureDir?: boolean
} & SshMutationExpectation
): Promise<{
results: (
| {
sourcePath: string
@ -2963,11 +2975,13 @@ const api = {
}
)[]
}> => ipcRenderer.invoke('fs:stageExternalPathsForRuntimeUpload', args),
resolveDroppedPathsForAgent: (args: {
paths: string[]
worktreePath: string
connectionId?: string
}): Promise<{
resolveDroppedPathsForAgent: (
args: {
paths: string[]
worktreePath: string
connectionId?: string
} & SshMutationExpectation
): Promise<{
resolvedPaths: string[]
skipped: {
sourcePath: string
@ -4068,6 +4082,7 @@ const api = {
method: string
params?: unknown
timeoutMs?: number
expectedEnvironmentPairingRevision?: number
}): Promise<RuntimeRpcResponse<unknown>> =>
ipcRenderer.invoke('runtimeEnvironments:call', args),
subscribe: async (
@ -4076,6 +4091,7 @@ const api = {
method: string
params?: unknown
timeoutMs?: number
expectedEnvironmentPairingRevision?: number
},
callbacks: {
onResponse: (response: RuntimeRpcResponse<unknown>) => void

View File

@ -14,12 +14,12 @@ import { createProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks
import { joinPath } from '@/lib/path'
import { detectLanguage } from '@/lib/language-detect'
import { setWithLRU } from '@/lib/scroll-cache'
import { getConnectionIdForFile } from '@/lib/connection-context'
import { getCombinedDiffSectionConnectionId } from './combined-diff-section-connection'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { selectWorktreeDiffCommentsOrEmpty } from '@/store/worktree-diff-comments-selector'
import { writeRuntimeFile } from '@/runtime/runtime-file-client'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner'
import { formatDiffComments } from '@/lib/diff-comments-format'
import { getDiffCommentLineLabel } from '@/lib/diff-comment-compat'
import {
@ -1185,18 +1185,20 @@ export default function CombinedDiffViewer({
const content = modifiedEditor?.getValue() ?? section.modifiedContent
const absolutePath = joinPath(file.filePath, section.path)
try {
const connectionId = getConnectionIdForFile(file.worktreeId, absolutePath) ?? undefined
const state = useAppStore.getState()
const worktree = file.worktreeId
? findWorktreeById(state.worktreesByRepo, file.worktreeId)
: null
await writeRuntimeFile(
{
settings: settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId),
worktreeId: file.worktreeId,
worktreePath: worktree?.path ?? null,
connectionId
},
getEditorFileOperationContext(
state,
{
worktreeId: file.worktreeId,
runtimeEnvironmentId: file.runtimeEnvironmentId,
operationProvenance: file.operationProvenance
},
worktree?.path ?? null
),
absolutePath,
content
)
@ -1237,7 +1239,7 @@ export default function CombinedDiffViewer({
console.error('Save failed:', err)
}
},
[file.filePath, file.runtimeEnvironmentId, file.worktreeId, sections]
[file.filePath, file.operationProvenance, file.runtimeEnvironmentId, file.worktreeId, sections]
)
const handleSectionSaveRef = useRef(handleSectionSave)

View File

@ -129,7 +129,9 @@ describe('editor autosave changed-on-disk conflict flow', () => {
await vi.advanceTimersByTimeAsync(1500)
expect(writeFile).toHaveBeenCalledWith({
filePath: '/repo/file.ts',
content: 'user edit'
content: 'user edit',
connectionId: undefined,
expectedExecutionHostId: 'local'
})
} finally {
cleanup()
@ -160,7 +162,9 @@ describe('editor autosave changed-on-disk conflict flow', () => {
await vi.advanceTimersByTimeAsync(1500)
expect(writeFile).toHaveBeenCalledWith({
filePath: '/repo/file.ts',
content: 'restored draft'
content: 'restored draft',
connectionId: undefined,
expectedExecutionHostId: 'local'
})
} finally {
cleanup()
@ -179,7 +183,9 @@ describe('editor autosave changed-on-disk conflict flow', () => {
expect(writeFile).toHaveBeenCalledWith({
filePath: '/repo/file.ts',
content: 'user version'
content: 'user version',
connectionId: undefined,
expectedExecutionHostId: 'local'
})
const file = store.getState().openFiles[0]
expect(file?.isDirty).toBe(false)

View File

@ -43,6 +43,16 @@ export function createEditorStore(): StoreApi<AppState> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return createStore<any>()((...args: any[]) => ({
activeWorktreeId: 'wt-1',
repos: [],
worktreesByRepo: {
'repo-1': [{ id: 'wt-1', repoId: 'repo-1', hostId: 'local' }]
},
detectedWorktreesByRepo: {},
runtimeEnvironments: [],
runtimeEnvironmentCatalogHydrated: true,
removedRuntimeEnvironmentIds: new Set(),
sshConnectionStates: {},
sshStateByEnvironment: {},
settings: {
editorAutoSave: true,
editorAutoSaveDelayMs: 1000

View File

@ -52,6 +52,16 @@ function createEditorStore(): StoreApi<AppState> {
editorAutoSave: true,
editorAutoSaveDelayMs: 1000
},
repos: [],
worktreesByRepo: {
'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo', hostId: 'local' }]
},
detectedWorktreesByRepo: {},
runtimeEnvironments: [],
runtimeEnvironmentCatalogHydrated: true,
removedRuntimeEnvironmentIds: new Set(),
sshConnectionStates: new Map(),
sshStateByEnvironment: new Map(),
...createEditorSlice(...(args as Parameters<typeof createEditorSlice>))
})) as unknown as StoreApi<AppState>
}
@ -177,7 +187,9 @@ describe('attachEditorAutosaveController', () => {
expect(writeFile).toHaveBeenCalledWith({
filePath: '/repo/file.ts',
content: 'edited'
content: 'edited',
connectionId: undefined,
expectedExecutionHostId: 'local'
})
expect(store.getState().openFiles[0]?.isDirty).toBe(false)
expect(store.getState().editorDrafts).toEqual({})
@ -204,7 +216,30 @@ describe('attachEditorAutosaveController', () => {
const store = createEditorStore()
const workspaceKey = folderWorkspaceKey('folder-workspace-1')
mocks.getConnectionIdForFile.mockReturnValue('ssh-1')
store.setState({
worktreesByRepo: {
'folder-workspace-1': [
{
id: workspaceKey,
repoId: 'folder-workspace-1',
path: '/home/neil/platform',
hostId: 'ssh:ssh-1'
}
] as never
},
sshConnectionStates: new Map([
[
'ssh-1',
{
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0,
connectionGeneration: 4
}
]
])
})
store.getState().openFile({
filePath: '/home/neil/platform/api/src/file.ts',
relativePath: 'api/src/file.ts',
@ -219,14 +254,13 @@ describe('attachEditorAutosaveController', () => {
try {
await requestDirtyFileSave()
expect(mocks.getConnectionIdForFile).toHaveBeenCalledWith(
workspaceKey,
'/home/neil/platform/api/src/file.ts'
)
expect(writeFile).toHaveBeenCalledWith({
filePath: '/home/neil/platform/api/src/file.ts',
content: 'edited',
connectionId: 'ssh-1'
connectionId: 'ssh-1',
expectedExecutionHostId: 'ssh:ssh-1',
expectedSshTargetId: 'ssh-1',
expectedSshConnectionGeneration: 4
})
expect(store.getState().openFiles[0]?.isDirty).toBe(false)
} finally {
@ -289,7 +323,13 @@ describe('attachEditorAutosaveController', () => {
expect(runtimeCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'files.write',
params: { worktree: 'id:wt-1', relativePath: 'file.ts', content: 'edited' },
expectedEnvironmentPairingRevision: undefined,
params: {
worktree: 'id:wt-1',
relativePath: 'file.ts',
content: 'edited',
expectedExecutionHostId: 'local'
},
timeoutMs: 15_000
})
expect(writeFile).not.toHaveBeenCalled()
@ -333,7 +373,9 @@ describe('attachEditorAutosaveController', () => {
expect(writeFile).toHaveBeenCalledWith({
filePath: '/repo/file.md',
content: 'pending rich edit'
content: 'pending rich edit',
connectionId: undefined,
expectedExecutionHostId: 'local'
})
expect(store.getState().openFiles[0]?.isDirty).toBe(false)
expect(store.getState().editorDrafts).toEqual({})
@ -568,7 +610,9 @@ describe('attachEditorAutosaveController', () => {
await requestEditorFileSave({ fileId: '/repo/file.md' })
expect(writeFile).toHaveBeenCalledWith({
filePath: '/repo/file.md',
content: 'after save'
content: 'after save',
connectionId: undefined,
expectedExecutionHostId: 'local'
})
expect(store.getState().openFiles[0]?.isDirty).toBe(false)
} finally {

View File

@ -6,7 +6,7 @@ import { getConnectionIdForFile } from '@/lib/connection-context'
import { shouldPersistWorkspaceSession } from '@/lib/workspace-session'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { writeRuntimeFile } from '@/runtime/runtime-file-client'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner'
import {
canAutoSaveOpenFile,
getOpenFilesForExternalFileChange,
@ -98,11 +98,11 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void {
}
const contentToSave = state.editorDrafts[file.id] ?? fallbackContent
const connectionId =
getConnectionIdForFile(liveFile.worktreeId, liveFile.filePath) ?? undefined
const worktree = liveFile.worktreeId
? findWorktreeById(state.worktreesByRepo ?? {}, liveFile.worktreeId)
: null
const fileContext = getEditorFileOperationContext(state, liveFile, worktree?.path ?? null)
const connectionId = fileContext.connectionId
// Why: stamp before writing so useEditorExternalWatch ignores our own fs:changed echo (editor-self-write-registry).
recordSelfWrite(
liveFile.filePath,
@ -113,16 +113,7 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void {
: undefined
)
try {
await writeRuntimeFile(
{
settings: settingsForRuntimeOwner(state.settings, liveFile.runtimeEnvironmentId),
worktreeId: liveFile.worktreeId,
worktreePath: worktree?.path ?? null,
connectionId
},
liveFile.filePath,
contentToSave
)
await writeRuntimeFile(fileContext, liveFile.filePath, contentToSave)
} catch (error) {
// Why: the self-write stamp is only valid after a real write; clear on failure so it can't suppress a real update.
clearSelfWrite(liveFile.filePath, liveFile.runtimeEnvironmentId)

View File

@ -8,7 +8,7 @@ vi.mock('@/runtime/runtime-file-client', () => ({
}))
vi.mock('@/lib/connection-context', () => ({
getConnectionId: vi.fn(() => 'ssh-1')
getConnectionId: vi.fn(() => null)
}))
vi.mock('@/store', () => ({

View File

@ -4,7 +4,9 @@ import { dirname, basename } from '@/lib/path'
import { getConnectionId } from '@/lib/connection-context'
import { useAppStore } from '@/store'
import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client'
import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import { captureDirectSshMutationExpectation } from '@/lib/ssh-mutation-expectation'
import { translate } from '@/i18n/i18n'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { extractIpcErrorMessage } from './rich-markdown-ipc-error-message'
@ -29,9 +31,30 @@ export async function insertRichMarkdownImageFromPath({
canInsert
}: RichMarkdownImageInsertArgs): Promise<void> {
try {
const connectionId = getConnectionId(worktreeId) ?? undefined
const settings = settingsForRuntimeOwner(useAppStore.getState().settings, runtimeEnvironmentId)
const state = useAppStore.getState()
const worktreePath = getWorktreePath(worktreeId)
const parsedWorkspace = worktreeId ? parseWorkspaceKey(worktreeId) : null
const resolvedConnectionId = getConnectionId(worktreeId)
if (parsedWorkspace?.type === 'folder' && resolvedConnectionId === undefined) {
throw new Error("Couldn't verify which host owns this file. Reopen the file and try again.")
}
const connectionId = resolvedConnectionId ?? undefined
const fileContext =
worktreeId && parsedWorkspace?.type !== 'folder'
? getEditorFileOperationContext(state, { worktreeId, runtimeEnvironmentId }, worktreePath)
: {
settings: settingsForRuntimeOwner(state.settings, runtimeEnvironmentId),
worktreeId,
worktreePath,
connectionId,
expectedExecutionHostId: connectionId
? (`ssh:${encodeURIComponent(connectionId)}` as const)
: ('local' as const),
...(connectionId
? captureDirectSshMutationExpectation(state, connectionId, runtimeEnvironmentId)
: {})
}
const settings = fileContext.settings
if (settings?.activeRuntimeEnvironmentId?.trim() && !worktreePath) {
toast.error(
translate(
@ -45,12 +68,7 @@ export async function insertRichMarkdownImageFromPath({
// Why: image bytes should live beside the note instead of inside markdown;
// this keeps rich-mode size checks based on document text, not binary data.
const { results } = await importExternalPathsToRuntime(
{
settings,
worktreeId,
worktreePath,
connectionId
},
fileContext,
[sourcePath],
dirname(filePath)
)

View File

@ -1,11 +1,10 @@
import { useCallback, useState } from 'react'
import { getConnectionId } from '@/lib/connection-context'
import { dirname, joinPath } from '@/lib/path'
import { useAppStore } from '@/store'
import type { OpenFile } from '@/store/slices/editor'
import { createRuntimePath, runtimePathExists } from '@/runtime/runtime-file-client'
import { executeOpenEditorPathMove } from '@/lib/execute-open-editor-path-move'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner'
import { requestEditorFileSave, requestEditorSaveQuiesce } from './editor-autosave'
import { getUntitledFileRoot } from './untitled-file-rename-path'
@ -46,16 +45,11 @@ export function useUntitledFileRename({
const oldPath = renameDialogFile.filePath
const worktreeRoot = getUntitledFileRoot(renameDialogFile)
const newPath = joinPath(worktreeRoot, newRelPath)
const connectionId = getConnectionId(renameDialogFile.worktreeId) ?? undefined
const fileContext = {
settings: settingsForRuntimeOwner(
useAppStore.getState().settings,
renameDialogFile.runtimeEnvironmentId
),
worktreeId: renameDialogFile.worktreeId,
worktreePath: worktreeRoot,
connectionId
}
const fileContext = getEditorFileOperationContext(
useAppStore.getState(),
renameDialogFile,
worktreeRoot
)
if (newPath !== oldPath && (await runtimePathExists(fileContext, newPath))) {
setRenameError('A file with that name already exists')

View File

@ -50,6 +50,7 @@ function state(overrides: Partial<AppState> = {}): AppState {
projectGroups: [],
repos: [{ id: 'repo', connectionId: null }],
settings: { activeRuntimeEnvironmentId: null },
sshConnectionStates: new Map(),
tabsByWorktree: {
'wt-1': [terminalTab()]
},
@ -68,10 +69,20 @@ describe('resolveNativeChatAttachmentOwner', () => {
it('resolves an SSH repo worktree to ssh with the worktree path', () => {
expect(
resolveNativeChatAttachmentOwner(
state({ repos: [{ id: 'repo', connectionId: 'conn-1' }] as never }),
state({
repos: [{ id: 'repo', connectionId: 'conn-1' }] as never,
sshConnectionStates: new Map([['conn-1', { connectionGeneration: 4 } as never]])
}),
'tab-1'
)
).toEqual({ kind: 'ssh', connectionId: 'conn-1', worktreePath: '/repo/worktree' })
).toEqual({
kind: 'ssh',
connectionId: 'conn-1',
worktreePath: '/repo/worktree',
expectedExecutionHostId: 'ssh:conn-1',
expectedSshTargetId: 'conn-1',
expectedSshConnectionGeneration: 4
})
})
it('resolves a runtime-owned repo to runtime', () => {
@ -122,7 +133,14 @@ describe('resolveNativeChatAttachmentOwner', () => {
})
describe('uploadNativeChatAttachmentPaths', () => {
const owner = { connectionId: 'conn-1', worktreePath: '/remote/worktree' }
const owner = {
kind: 'ssh' as const,
connectionId: 'conn-1',
worktreePath: '/remote/worktree',
expectedExecutionHostId: 'ssh:conn-1' as const,
expectedSshTargetId: 'conn-1',
expectedSshConnectionGeneration: 4
}
beforeEach(() => {
vi.clearAllMocks()
@ -143,7 +161,10 @@ describe('uploadNativeChatAttachmentPaths', () => {
expect(mocks.resolveDroppedPathsForAgent).toHaveBeenCalledWith({
paths: ['/local/a.txt'],
worktreePath: '/remote/worktree',
connectionId: 'conn-1'
connectionId: 'conn-1',
expectedExecutionHostId: 'ssh:conn-1',
expectedSshTargetId: 'conn-1',
expectedSshConnectionGeneration: 4
})
expect(mocks.toastLoading).toHaveBeenCalledTimes(1)
expect(mocks.toastDismiss).toHaveBeenCalledWith('toast-1')

View File

@ -14,10 +14,20 @@ import {
findTerminalTabWorktreeId,
resolveNativeChatFileLinkContext
} from './native-chat-file-link'
import {
captureDirectSshMutationExpectation,
type DirectSshMutationExpectation
} from '@/lib/ssh-mutation-expectation'
export type NativeChatSshAttachmentOwner = DirectSshMutationExpectation & {
kind: 'ssh'
connectionId: string
worktreePath: string
}
export type NativeChatAttachmentOwner =
| { kind: 'local' }
| { kind: 'ssh'; connectionId: string; worktreePath: string }
| NativeChatSshAttachmentOwner
/** Runtime-owned (`remote:`) panes keep the composer's existing
* local-attachment block; runtime upload support is a separate seam. */
| { kind: 'runtime' }
@ -33,6 +43,7 @@ type NativeChatAttachmentOwnerState = Pick<
| 'projectGroups'
| 'repos'
| 'settings'
| 'sshConnectionStates'
| 'tabsByWorktree'
| 'worktreesByRepo'
>
@ -61,7 +72,12 @@ export function resolveNativeChatAttachmentOwner(
if (!worktreePath) {
return { kind: 'not-ready' }
}
return { kind: 'ssh', connectionId, worktreePath }
return {
kind: 'ssh',
connectionId,
worktreePath,
...captureDirectSshMutationExpectation(state, connectionId)
}
}
export function nativeChatWorktreeNotReadyNotice(): string {
@ -79,7 +95,7 @@ export function nativeChatWorktreeNotReadyNotice(): string {
*/
export async function uploadNativeChatAttachmentPaths(
paths: string[],
owner: { connectionId: string; worktreePath: string }
owner: NativeChatSshAttachmentOwner
): Promise<string[] | null> {
const pending = toast.loading(
translate(
@ -92,7 +108,10 @@ export async function uploadNativeChatAttachmentPaths(
const { resolvedPaths, skipped, failed } = await window.api.fs.resolveDroppedPathsForAgent({
paths,
worktreePath: owner.worktreePath,
connectionId: owner.connectionId
connectionId: owner.connectionId,
expectedExecutionHostId: owner.expectedExecutionHostId,
expectedSshTargetId: owner.expectedSshTargetId,
expectedSshConnectionGeneration: owner.expectedSshConnectionGeneration
})
reportTerminalDropUploadSkipsAndFailures(skipped, failed)
return resolvedPaths

View File

@ -121,7 +121,10 @@ function imagePasteEvent(): {
const sshOwner: NativeChatAttachmentOwner = {
kind: 'ssh',
connectionId: 'conn-1',
worktreePath: '/remote/wt'
worktreePath: '/remote/wt',
expectedExecutionHostId: 'ssh:conn-1',
expectedSshTargetId: 'conn-1',
expectedSshConnectionGeneration: 4
}
afterEach(() => {

View File

@ -104,7 +104,10 @@ describe('useNativeChatExternalAttachments', () => {
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({
kind: 'ssh',
connectionId: 'conn-1',
worktreePath: '/remote/wt'
worktreePath: '/remote/wt',
expectedExecutionHostId: 'ssh:conn-1',
expectedSshTargetId: 'conn-1',
expectedSshConnectionGeneration: 4
})
mocks.uploadNativeChatAttachmentPaths.mockResolvedValue(['/remote/wt/.orca/drops/a.txt'])
const attachResolvedPaths = vi.fn()
@ -115,7 +118,10 @@ describe('useNativeChatExternalAttachments', () => {
expect(mocks.uploadNativeChatAttachmentPaths).toHaveBeenCalledWith(['/local/a.txt'], {
kind: 'ssh',
connectionId: 'conn-1',
worktreePath: '/remote/wt'
worktreePath: '/remote/wt',
expectedExecutionHostId: 'ssh:conn-1',
expectedSshTargetId: 'conn-1',
expectedSshConnectionGeneration: 4
})
expect(attachResolvedPaths).toHaveBeenCalledWith(['/remote/wt/.orca/drops/a.txt'])
})
@ -136,7 +142,10 @@ describe('useNativeChatExternalAttachments', () => {
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({
kind: 'ssh',
connectionId: 'conn-1',
worktreePath: '/remote/wt'
worktreePath: '/remote/wt',
expectedExecutionHostId: 'ssh:conn-1',
expectedSshTargetId: 'conn-1',
expectedSshConnectionGeneration: 4
})
let resolveUpload: (paths: string[]) => void = () => {}
mocks.uploadNativeChatAttachmentPaths.mockReturnValue(

View File

@ -300,7 +300,8 @@ function FileExplorerFiles(): React.JSX.Element {
expanded,
toggleDir,
refreshDir,
scrollRef
scrollRef,
getOperationOwnerForPath: (path) => rowProjection.getRowByPath(path)?.operationOwner
})
const lastResetWorktreePathRef = useRef<string | null>(null)
@ -393,7 +394,8 @@ function FileExplorerFiles(): React.JSX.Element {
refreshTree,
inlineInput,
dragSourcePath,
isNativeDragOver
isNativeDragOver,
operationOwner: rootCache?.operationOwner
})
useFileExplorerImport({
@ -401,7 +403,8 @@ function FileExplorerFiles(): React.JSX.Element {
activeWorktreeId,
refreshDir,
clearNativeDragState,
setSelectedPath: setSingleSelectedPath
setSelectedPath: setSingleSelectedPath,
operationOwner: rootCache?.operationOwner
})
const totalCount = visibleRowCount + (inlineInputIndex >= 0 ? 1 : 0)

View File

@ -79,6 +79,7 @@ export type InlineInput = {
depth: number
existingName?: string
existingPath?: string
operationOwner?: TreeNode['operationOwner']
}
// ─── Inline Input Row ────────────────────────────────────────────

View File

@ -0,0 +1,27 @@
import { translate } from '@/i18n/i18n'
import {
getFileExplorerOperationRoute,
getFileExplorerOwnerUnresolvedMessage
} from './file-explorer-operation-owner'
import type { TreeNode } from './file-explorer-types'
export function needsRemoteDeleteConfirmation(node: TreeNode): boolean {
const owner = node.operationOwner ?? { kind: 'unresolved' as const }
return owner.kind !== 'local' && getFileExplorerOperationRoute(owner) !== null
}
export function isLocalDeleteNode(node: TreeNode): boolean {
return (node.operationOwner ?? { kind: 'unresolved' as const }).kind === 'local'
}
export function getFileDeleteErrorMessage(error: unknown): string | null {
if (!(error instanceof Error)) {
return null
}
return error.message === getFileExplorerOwnerUnresolvedMessage()
? translate(
'auto.components.right.sidebar.useFileDeletion.8b8ee9d22f',
"Couldn't determine which host owns this file. Check the workspace connection and try again."
)
: error.message
}

View File

@ -0,0 +1,166 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { Worktree } from '../../../../shared/types'
import { useAppStore } from '@/store'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import {
captureFileExplorerOperationGuard,
getFileExplorerOperationOwner
} from './file-explorer-operation-owner'
const initialState = useAppStore.getInitialState()
const worktreeId = 'repo-1::/srv/project'
function worktree(hostId: Worktree['hostId'], runtimeOwnerEnvironmentId?: string): Worktree {
return {
id: worktreeId,
repoId: 'repo-1',
path: '/srv/project',
hostId,
runtimeOwnerEnvironmentId
} as Worktree
}
afterEach(() => {
useAppStore.getState().setRuntimeEnvironments([])
useAppStore.setState(initialState, true)
})
describe('file explorer operation generations', () => {
it('invalidates a nested SSH mutation when that target reconnects', () => {
useAppStore.setState({
repos: [],
worktreesByRepo: { 'repo-1': [worktree('ssh:private-target', 'hub-a')] }
})
useAppStore.getState().setEnvironmentSshConnectionState('hub-a', 'private-target', {
targetId: 'private-target',
status: 'connected',
error: null,
reconnectAttempt: 0,
connectionGeneration: 1
})
const owner = getFileExplorerOperationOwner(worktreeId)
const guard = captureFileExplorerOperationGuard(worktreeId, owner)
useAppStore.getState().setEnvironmentSshConnectionState('hub-a', 'private-target', {
targetId: 'private-target',
status: 'disconnected',
error: null,
reconnectAttempt: 0,
connectionGeneration: 2
})
expect(() => guard.assertCurrent()).toThrow("Couldn't determine which host owns")
})
it('invalidates a direct SSH mutation when that target reconnects', () => {
useAppStore.setState({
repos: [],
worktreesByRepo: { 'repo-1': [worktree('ssh:client-target')] }
})
useAppStore.getState().setSshConnectionState('client-target', {
targetId: 'client-target',
status: 'connected',
error: null,
reconnectAttempt: 0,
connectionGeneration: 1
})
const owner = getFileExplorerOperationOwner(worktreeId)
const guard = captureFileExplorerOperationGuard(worktreeId, owner)
useAppStore.getState().setSshConnectionState('client-target', {
targetId: 'client-target',
status: 'disconnected',
error: null,
reconnectAttempt: 0,
connectionGeneration: 2
})
expect(() => guard.assertCurrent()).toThrow("Couldn't determine which host owns")
})
it('invalidates a folder-workspace mutation when its SSH target reconnects', () => {
const folderWorkspaceId = 'folder-1'
const folderWorktreeId = folderWorkspaceKey(folderWorkspaceId)
useAppStore.setState({
folderWorkspaces: [
{
id: folderWorkspaceId,
projectGroupId: 'group-1',
connectionId: 'client-target'
} as never
],
projectGroups: [{ id: 'group-1', connectionId: 'client-target' } as never],
repos: [],
worktreesByRepo: {}
})
useAppStore.getState().setSshConnectionState('client-target', {
targetId: 'client-target',
status: 'connected',
error: null,
reconnectAttempt: 0,
connectionGeneration: 1
})
const owner = getFileExplorerOperationOwner(folderWorktreeId)
const guard = captureFileExplorerOperationGuard(folderWorktreeId, owner)
useAppStore.getState().setSshConnectionState('client-target', {
targetId: 'client-target',
status: 'disconnected',
error: null,
reconnectAttempt: 0,
connectionGeneration: 2
})
expect(() => guard.assertCurrent()).toThrow("Couldn't determine which host owns")
})
it('invalidates a mutation when the saved HUB points at a replacement runtime', () => {
useAppStore.setState({
repos: [],
worktreesByRepo: { 'repo-1': [worktree('local', 'hub-a')] }
})
useAppStore.getState().setRuntimeEnvironmentStatus('hub-a', {
status: { runtimeId: 'runtime-a' } as never,
checkedAt: 1
})
const owner = getFileExplorerOperationOwner(worktreeId)
const guard = captureFileExplorerOperationGuard(worktreeId, owner)
useAppStore.getState().setRuntimeEnvironmentStatus('hub-a', {
status: { runtimeId: 'runtime-b' } as never,
checkedAt: 2
})
expect(() => guard.assertCurrent()).toThrow("Couldn't determine which host owns")
})
it('fails closed when nested SSH ownership has no authoritative generation', () => {
useAppStore.setState({
repos: [],
worktreesByRepo: { 'repo-1': [worktree('ssh:private-target', 'hub-a')] }
})
const owner = getFileExplorerOperationOwner(worktreeId)
expect(() => captureFileExplorerOperationGuard(worktreeId, owner)).toThrow(
"Couldn't determine which host owns"
)
})
it('invalidates a mutation when the same saved HUB id is re-paired', () => {
useAppStore.setState({
repos: [],
worktreesByRepo: { 'repo-1': [worktree('local', 'hub-a')] }
})
useAppStore
.getState()
.setRuntimeEnvironments([{ id: 'hub-a', createdAt: 1, pairingRevision: 1 } as never])
const owner = getFileExplorerOperationOwner(worktreeId)
const guard = captureFileExplorerOperationGuard(worktreeId, owner)
useAppStore
.getState()
.setRuntimeEnvironments([{ id: 'hub-a', createdAt: 1, pairingRevision: 2 } as never])
expect(() => guard.assertCurrent()).toThrow("Couldn't determine which host owns")
})
})

View File

@ -1,12 +1,7 @@
import { getConnectionIdFromState } from '@/lib/connection-context'
import { useAppStore } from '@/store'
import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers'
import type { AppState } from '@/store/types'
import {
getRepoExecutionHostId,
parseExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { translate } from '@/i18n/i18n'
import {
@ -14,13 +9,26 @@ import {
getSettingsForWorktreeRuntimeOwner
} from '@/lib/worktree-runtime-owner'
import type { FileExplorerOperationOwner } from './file-explorer-types'
import {
resolveWorktreeOperationRoute,
type WorktreeOperationRoute
} from '@/lib/worktree-operation-route'
import { captureWorktreeOperationGenerationGuard } from '@/lib/worktree-operation-generation'
export type FileExplorerOperationRoute = {
settings: { activeRuntimeEnvironmentId: string | null }
connectionId?: string
expectedExecutionHostId?: 'local' | `ssh:${string}`
expectedSshTargetId?: string
expectedSshConnectionGeneration?: number
}
type FileExplorerOwnerState = Pick<
export type FileExplorerOperationGuard = {
route: FileExplorerOperationRoute
assertCurrent: () => FileExplorerOperationRoute
}
export type FileExplorerOwnerState = Pick<
AppState,
| 'settings'
| 'repos'
@ -37,21 +45,20 @@ export function getFileExplorerOperationOwnerFromState(
): FileExplorerOperationOwner {
const parsedWorkspace = worktreeId ? parseWorkspaceKey(worktreeId) : null
if (worktreeId && parsedWorkspace?.type !== 'folder') {
const exactHostIds = getExactWorktreeHostIds(state, worktreeId)
if (exactHostIds.size > 1) {
const route = resolveWorktreeOperationRoute(state, worktreeId)
if (!route) {
return { kind: 'unresolved' }
}
const exactHostId = exactHostIds.values().next().value
if (exactHostId) {
return operationOwnerFromHostId(exactHostId)
if (route.runtimeEnvironmentId) {
return {
kind: 'runtime',
environmentId: route.runtimeEnvironmentId,
executionHostId:
route.executionHostId ?? `runtime:${encodeURIComponent(route.runtimeEnvironmentId)}`
}
}
const repoId = getRepoIdFromWorktreeId(worktreeId)
const repoHostIds = new Set(
state.repos.filter((repo) => repo.id === repoId).map(getRepoExecutionHostId)
)
if (repoHostIds.size > 1) {
return { kind: 'unresolved' }
if (route.executionHostId) {
return operationOwnerFromHostId(route.executionHostId)
}
}
@ -70,7 +77,11 @@ export function getFileExplorerOperationOwnerFromState(
? null
: settings.activeRuntimeEnvironmentId?.trim()
if (runtimeEnvironmentId) {
return { kind: 'runtime', environmentId: runtimeEnvironmentId }
return {
kind: 'runtime',
environmentId: runtimeEnvironmentId,
executionHostId: `runtime:${encodeURIComponent(runtimeEnvironmentId)}`
}
}
if (connectionId === undefined) {
return { kind: 'unresolved' }
@ -89,15 +100,142 @@ export function getFileExplorerOperationRoute(
): FileExplorerOperationRoute | null {
switch (owner.kind) {
case 'local':
return { settings: { activeRuntimeEnvironmentId: null } }
return {
settings: { activeRuntimeEnvironmentId: null },
expectedExecutionHostId: 'local'
}
case 'ssh':
return {
settings: { activeRuntimeEnvironmentId: null },
connectionId: owner.connectionId
connectionId: owner.connectionId,
expectedExecutionHostId: `ssh:${encodeURIComponent(owner.connectionId)}`
}
case 'runtime': {
const host = parseExecutionHostId(owner.executionHostId)
return {
settings: { activeRuntimeEnvironmentId: owner.environmentId },
...(host?.kind === 'ssh'
? { expectedExecutionHostId: host.id }
: { expectedExecutionHostId: 'local' as const })
}
}
case 'unresolved':
return null
}
}
export function requireFileExplorerOperationRoute(
worktreeId: string | null | undefined
): FileExplorerOperationRoute {
const route = getFileExplorerOperationRoute(getFileExplorerOperationOwner(worktreeId))
if (!route) {
throw new Error(getFileExplorerOwnerUnresolvedMessage())
}
return route
}
export function requireMatchingFileExplorerOperationRoute(
worktreeId: string | null | undefined,
expectedOwner: FileExplorerOperationOwner | undefined
): FileExplorerOperationRoute {
if (!expectedOwner || expectedOwner.kind === 'unresolved') {
throw new Error(getFileExplorerOwnerUnresolvedMessage())
}
const currentOwner = getFileExplorerOperationOwner(worktreeId)
if (JSON.stringify(currentOwner) !== JSON.stringify(expectedOwner)) {
throw new Error(getFileExplorerOwnerUnresolvedMessage())
}
const route = getFileExplorerOperationRoute(expectedOwner)
if (!route) {
throw new Error(getFileExplorerOwnerUnresolvedMessage())
}
return route
}
export function captureFileExplorerOperationGuard(
worktreeId: string | null | undefined,
expectedOwner: FileExplorerOperationOwner | undefined
): FileExplorerOperationGuard {
if (!worktreeId) {
throw new Error(getFileExplorerOwnerUnresolvedMessage())
}
const route = requireMatchingFileExplorerOperationRoute(worktreeId, expectedOwner)
const operationRoute = getFileExplorerGenerationRoute(expectedOwner)
if (!operationRoute) {
throw new Error(getFileExplorerOwnerUnresolvedMessage())
}
const generationGuard = captureWorktreeOperationGenerationGuard(
useAppStore.getState,
worktreeId,
operationRoute,
() => new Error(getFileExplorerOwnerUnresolvedMessage()),
() => getFileExplorerGenerationRoute(getFileExplorerOperationOwner(worktreeId))
)
const expectedSshConnectionGeneration = getExpectedSshConnectionGeneration(
useAppStore.getState(),
operationRoute
)
const operationHost = parseExecutionHostId(operationRoute.executionHostId)
if (!operationHost) {
throw new Error(getFileExplorerOwnerUnresolvedMessage())
}
if (operationHost?.kind === 'ssh' && expectedSshConnectionGeneration === undefined) {
throw new Error(getFileExplorerOwnerUnresolvedMessage())
}
const guardedRoute: FileExplorerOperationRoute = {
...route,
expectedExecutionHostId: operationHost.kind === 'ssh' ? operationHost.id : 'local',
...(operationHost?.kind === 'ssh' ? { expectedSshTargetId: operationHost.targetId } : {}),
...(expectedSshConnectionGeneration === undefined ? {} : { expectedSshConnectionGeneration })
}
return {
route: guardedRoute,
assertCurrent: () => {
generationGuard.assertCurrent()
if (
getExpectedSshConnectionGeneration(useAppStore.getState(), operationRoute) !==
expectedSshConnectionGeneration
) {
throw new Error(getFileExplorerOwnerUnresolvedMessage())
}
return guardedRoute
}
}
}
function getExpectedSshConnectionGeneration(
state: Pick<AppState, 'sshConnectionStates' | 'sshStateByEnvironment'>,
route: WorktreeOperationRoute
): number | undefined {
const host = parseExecutionHostId(route.executionHostId)
if (host?.kind !== 'ssh') {
return undefined
}
return route.runtimeEnvironmentId
? state.sshStateByEnvironment
.get(route.runtimeEnvironmentId)
?.connectionStates.get(host.targetId)?.connectionGeneration
: state.sshConnectionStates.get(host.targetId)?.connectionGeneration
}
function getFileExplorerGenerationRoute(
owner: FileExplorerOperationOwner | undefined
): WorktreeOperationRoute | null {
switch (owner?.kind) {
case 'local':
return { executionHostId: 'local', runtimeEnvironmentId: null }
case 'ssh':
return {
executionHostId: `ssh:${encodeURIComponent(owner.connectionId)}`,
runtimeEnvironmentId: null
}
case 'runtime':
return { settings: { activeRuntimeEnvironmentId: owner.environmentId } }
return {
executionHostId: owner.executionHostId,
runtimeEnvironmentId: owner.environmentId
}
case 'unresolved':
case undefined:
return null
}
}
@ -109,28 +247,6 @@ export function getFileExplorerOwnerUnresolvedMessage(): string {
)
}
function getExactWorktreeHostIds(
state: Pick<AppState, 'worktreesByRepo' | 'detectedWorktreesByRepo'>,
worktreeId: string
): Set<ExecutionHostId> {
const hostIds = new Set<ExecutionHostId>()
for (const worktrees of Object.values(state.worktreesByRepo)) {
for (const worktree of worktrees) {
if (worktree.id === worktreeId && worktree.hostId) {
hostIds.add(worktree.hostId)
}
}
}
for (const result of Object.values(state.detectedWorktreesByRepo)) {
for (const worktree of result.worktrees) {
if (worktree.id === worktreeId && worktree.hostId) {
hostIds.add(worktree.hostId)
}
}
}
return hostIds
}
function operationOwnerFromHostId(hostId: ExecutionHostId): FileExplorerOperationOwner {
const parsed = parseExecutionHostId(hostId)
switch (parsed?.kind) {
@ -139,7 +255,7 @@ function operationOwnerFromHostId(hostId: ExecutionHostId): FileExplorerOperatio
case 'ssh':
return { kind: 'ssh', connectionId: parsed.targetId }
case 'runtime':
return { kind: 'runtime', environmentId: parsed.environmentId }
return { kind: 'runtime', environmentId: parsed.environmentId, executionHostId: hostId }
case undefined:
return { kind: 'unresolved' }
}

View File

@ -24,7 +24,7 @@ describe('right sidebar file/git runtime ownership boundaries', () => {
const text = source(path)
expect(text).toMatch(
/getRightSidebarWorktreeRuntimeSettings|getSettingsForWorktreeRuntimeOwner|getFileExplorerOperationOwner|getFileExplorerOperationRoute/
/getRightSidebarWorktreeRuntimeSettings|getSettingsForWorktreeRuntimeOwner|getFileExplorerOperationOwner|getFileExplorerOperationRoute|captureFileExplorerOperationGuard|requireFileExplorerOperationRoute|requireMatchingFileExplorerOperationRoute/
)
expect(text).not.toContain('settings: useAppStore.getState().settings')
expect(text).not.toContain('const settings = useAppStore.getState().settings')

View File

@ -1,7 +1,9 @@
import type { ExecutionHostId } from '../../../../shared/execution-host'
export type FileExplorerOperationOwner =
| { kind: 'local' }
| { kind: 'ssh'; connectionId: string }
| { kind: 'runtime'; environmentId: string }
| { kind: 'runtime'; environmentId: string; executionHostId: ExecutionHostId }
| { kind: 'unresolved' }
export type TreeNode = {
@ -18,4 +20,5 @@ export type TreeNode = {
export type DirCache = {
children: TreeNode[]
loading: boolean
operationOwner?: FileExplorerOperationOwner
}

View File

@ -7,7 +7,12 @@ import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import { isPathEqualOrDescendant } from './file-explorer-paths'
import { runBatchDeletion, selectDeletionRoots } from './file-explorer-batch-deletion'
import type { TreeNode } from './file-explorer-types'
import { getFileExplorerOperationRoute } from './file-explorer-operation-owner'
import { captureFileExplorerOperationGuard } from './file-explorer-operation-owner'
import {
getFileDeleteErrorMessage,
isLocalDeleteNode,
needsRemoteDeleteConfirmation
} from './file-explorer-delete-classification'
import {
requestEditorFileSave,
requestEditorSaveQuiesce
@ -39,21 +44,6 @@ type UseFileDeletionResult = {
requestDeleteAll: (nodes: TreeNode[]) => void
}
// Why: gate the batch prompt on the same condition runDelete uses to actually
// show its per-node confirm — a non-local owner with a resolvable route.
// Unresolved owners throw before prompting, so a batch of them must not pop a
// destructive dialog for deletes that provably cannot proceed.
function needsRemoteDeleteConfirmation(node: TreeNode): boolean {
const operationOwner = node.operationOwner ?? { kind: 'unresolved' as const }
return operationOwner.kind !== 'local' && getFileExplorerOperationRoute(operationOwner) !== null
}
// Why: local deletes go to the OS Trash/Recycle Bin and stay recoverable, so a
// mixed batch must not describe every item as a permanent remote delete.
function isLocalDeleteNode(node: TreeNode): boolean {
return (node.operationOwner ?? { kind: 'unresolved' as const }).kind === 'local'
}
export function useFileDeletion({
activeWorktreeId,
openFiles,
@ -64,10 +54,6 @@ export function useFileDeletion({
}: UseFileDeletionParams): UseFileDeletionResult {
const confirm = useConfirmationDialog()
const deleteShortcutLabel = useShortcutLabel('fileExplorer.delete')
const unresolvedDeleteOwnerError = translate(
'auto.components.right.sidebar.useFileDeletion.8b8ee9d22f',
"Couldn't determine which host owns this file. Check the workspace connection and try again."
)
// Why: track in-flight deletes per-path so repeated Del presses on the same
// node don't issue duplicate IPC calls; the map is a ref to avoid re-renders.
const inFlightRef = useRef<Set<string>>(new Set())
@ -80,27 +66,16 @@ export function useFileDeletion({
inFlightRef.current.add(node.path)
const operationOwner = node.operationOwner ?? { kind: 'unresolved' as const }
const operationRoute = getFileExplorerOperationRoute(operationOwner)
// Why: treat every non-local owner (ssh, runtime, unresolved) as remote
// for confirm/error copy, then fail closed below when the route is null
// so an unresolved owner never reaches local filesystem authorization.
const isRemote = operationOwner.kind !== 'local'
try {
if (!operationRoute) {
throw new Error(unresolvedDeleteOwnerError)
}
// Why: cached nodes can outlive host hydration changes; preserve the
// listing-time owner so deletion cannot jump to a same-path file elsewhere.
const state = useAppStore.getState()
const worktree = activeWorktreeId ? state.getKnownWorktreeById(activeWorktreeId) : null
const connectionId = operationRoute.connectionId
const fileContext = {
settings: operationRoute.settings,
worktreeId: activeWorktreeId,
worktreePath: worktree?.path ?? null,
connectionId
}
const operationGuard = captureFileExplorerOperationGuard(
activeWorktreeId,
node.operationOwner
)
// Why: remote deletes bypass OS Trash, and undo cannot recover
// directories or unreadable files. Batch deletes confirm once up
// front instead, so they skip the per-node prompt.
@ -146,6 +121,20 @@ export function useFileDeletion({
// writes cannot recreate the file after it's been trashed.
await Promise.all(filesToClose.map((file) => requestEditorSaveQuiesce({ fileId: file.id })))
// Why: confirmation and autosave can outlive a reconnect or graph replacement; mutations require the owner generation that produced the row.
const operationRoute = operationGuard.assertCurrent()
const state = useAppStore.getState()
const worktree = activeWorktreeId ? state.getKnownWorktreeById(activeWorktreeId) : null
const fileContext = {
settings: operationRoute.settings,
worktreeId: activeWorktreeId,
worktreePath: worktree?.path ?? null,
connectionId: operationRoute.connectionId,
expectedExecutionHostId: operationRoute.expectedExecutionHostId,
expectedSshTargetId: operationRoute.expectedSshTargetId,
expectedSshConnectionGeneration: operationRoute.expectedSshConnectionGeneration
}
const parentDir = dirname(node.path)
// Why: read file content before deleting so undo can restore it.
// We capture content first but only commit the undo entry after the
@ -158,7 +147,7 @@ export function useFileDeletion({
filePath: node.path,
relativePath: node.relativePath,
worktreeId: activeWorktreeId ?? undefined,
connectionId
connectionId: operationRoute.connectionId
})
if (!rf.isBinary) {
undoContent = rf.content
@ -169,16 +158,35 @@ export function useFileDeletion({
}
}
operationGuard.assertCurrent()
await deleteRuntimePath(fileContext, node.path, node.isDirectory)
if (undoContent !== undefined) {
commitFileExplorerOp({
undo: async () => {
await writeRuntimeFile(fileContext, node.path, undoContent)
const currentRoute = operationGuard.assertCurrent()
await writeRuntimeFile(
{
...fileContext,
settings: currentRoute.settings,
connectionId: currentRoute.connectionId
},
node.path,
undoContent
)
await refreshDir(parentDir)
},
redo: async () => {
await deleteRuntimePath(fileContext, node.path, node.isDirectory)
const currentRoute = operationGuard.assertCurrent()
await deleteRuntimePath(
{
...fileContext,
settings: currentRoute.settings,
connectionId: currentRoute.connectionId
},
node.path,
node.isDirectory
)
await refreshDir(parentDir)
}
})
@ -218,9 +226,10 @@ export function useFileDeletion({
return true
} catch (error) {
const action = isRemote ? 'delete' : isWindows ? 'move to Recycle Bin' : 'move to Trash'
const errorMessage = getFileDeleteErrorMessage(error)
toast.error(
error instanceof Error
? error.message
errorMessage
? errorMessage
: translate(
'auto.components.right.sidebar.useFileDeletion.72691dfebc',
"Failed to {{value0}} '{{value1}}'.",
@ -232,15 +241,7 @@ export function useFileDeletion({
inFlightRef.current.delete(node.path)
}
},
[
activeWorktreeId,
closeFile,
confirm,
isWindows,
openFiles,
refreshDir,
unresolvedDeleteOwnerError
]
[activeWorktreeId, closeFile, confirm, isWindows, openFiles, refreshDir]
)
const requestDelete = useCallback(

View File

@ -3,8 +3,7 @@ import { toast } from 'sonner'
import { basename, dirname, joinPath } from '@/lib/path'
import type { TreeNode } from './file-explorer-types'
import { copyRuntimePath, runtimePathExists } from '@/runtime/runtime-file-client'
import { getConnectionId } from '@/lib/connection-context'
import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner'
import { captureFileExplorerOperationGuard } from './file-explorer-operation-owner'
/**
* Electron's ipcRenderer.invoke wraps errors as:
@ -42,11 +41,21 @@ export function useFileDuplicate({
const ext = dotIndex > 0 ? name.slice(dotIndex) : ''
const run = async (): Promise<void> => {
let operationGuard
try {
operationGuard = captureFileExplorerOperationGuard(activeWorktreeId, node.operationOwner)
} catch (err) {
toast.error(extractIpcErrorMessage(err, `Failed to duplicate '${name}'.`))
return
}
const context = {
settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId),
settings: operationGuard.route.settings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId: getConnectionId(activeWorktreeId) ?? undefined
connectionId: operationGuard.route.connectionId,
expectedExecutionHostId: operationGuard.route.expectedExecutionHostId,
expectedSshTargetId: operationGuard.route.expectedSshTargetId,
expectedSshConnectionGeneration: operationGuard.route.expectedSshConnectionGeneration
}
// Why: generate a unique "stem copy.ext", "stem copy 2.ext", … name
// so we never collide with an existing file. pathExists checks are
@ -69,6 +78,7 @@ export function useFileDuplicate({
// eslint-disable-next-line no-constant-condition
while (true) {
try {
operationGuard.assertCurrent()
await copyRuntimePath(context, node.path, candidate)
break
} catch (err) {

View File

@ -6,7 +6,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { basename, dirname, joinPath } from '@/lib/path'
import { getConnectionId } from '@/lib/connection-context'
import {
getWorkspaceFileDragRejectionMessage,
readWorkspaceFileDragPaths,
@ -14,7 +13,8 @@ import {
} from '@/lib/workspace-file-drag'
import { executeOpenEditorPathMove } from '@/lib/execute-open-editor-path-move'
import { commitFileExplorerOp } from './fileExplorerUndoRedo'
import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner'
import type { FileExplorerOperationOwner } from './file-explorer-types'
import { captureFileExplorerOperationGuard } from './file-explorer-operation-owner'
function extractIpcErrorMessage(err: unknown, fallback: string): string {
if (!(err instanceof Error)) {
@ -32,6 +32,7 @@ type UseFileExplorerDragDropParams = {
refreshDir: (dirPath: string) => Promise<void>
// Explorer scroll viewport used to auto-scroll while dragging near top/bottom edges
scrollRef: RefObject<HTMLDivElement | null>
getOperationOwnerForPath: (path: string) => FileExplorerOperationOwner | undefined
}
type UseFileExplorerDragDropResult = {
@ -101,7 +102,8 @@ export function useFileExplorerDragDrop({
expanded,
toggleDir,
refreshDir,
scrollRef
scrollRef,
getOperationOwnerForPath
}: UseFileExplorerDragDropParams): UseFileExplorerDragDropResult {
const [isRootDragOver, setIsRootDragOver] = useState(false)
const rootDragCounterRef = useRef(0)
@ -207,18 +209,22 @@ export function useFileExplorerDragDrop({
}
const newPath = joinPath(destDir, fileName)
const operationOwner = getOperationOwnerForPath(sourcePath)
const run = async (): Promise<void> => {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
const fileContext = {
settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId),
worktreeId: activeWorktreeId,
worktreePath,
connectionId
}
// The coordinator quiesces saves, retargets the open sessions in place,
// and settles the move as one transaction (was: quiesce + rename + remap).
try {
const operationGuard = captureFileExplorerOperationGuard(activeWorktreeId, operationOwner)
const operationRoute = operationGuard.route
const fileContext = {
settings: operationRoute.settings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId: operationRoute.connectionId,
expectedExecutionHostId: operationRoute.expectedExecutionHostId,
expectedSshTargetId: operationRoute.expectedSshTargetId,
expectedSshConnectionGeneration: operationRoute.expectedSshConnectionGeneration
}
operationGuard.assertCurrent()
await executeOpenEditorPathMove({
context: fileContext,
fromPath: sourcePath,
@ -228,6 +234,7 @@ export function useFileExplorerDragDrop({
})
commitFileExplorerOp({
undo: async () => {
operationGuard.assertCurrent()
await executeOpenEditorPathMove({
context: fileContext,
fromPath: newPath,
@ -238,6 +245,7 @@ export function useFileExplorerDragDrop({
await Promise.all([refreshDir(destDir), refreshDir(sourceDir)])
},
redo: async () => {
operationGuard.assertCurrent()
await executeOpenEditorPathMove({
context: fileContext,
fromPath: sourcePath,
@ -256,7 +264,7 @@ export function useFileExplorerDragDrop({
}
void run()
},
[worktreePath, activeWorktreeId, refreshDir]
[worktreePath, activeWorktreeId, refreshDir, getOperationOwnerForPath]
)
const clearNativeDragState = useCallback(() => {

View File

@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import type { TreeNode } from './file-explorer-types'
import { activateFileExplorerNode } from './useFileExplorerHandlers'
@ -16,7 +17,12 @@ describe('activateFileExplorerNode', () => {
relativePath: 'linked-docs',
isDirectory: false,
isSymlink: true,
depth: 0
depth: 0,
operationOwner: {
kind: 'runtime',
environmentId: 'runtime-env-1',
executionHostId: 'runtime:runtime-env-1'
}
}
it('selects filtered folders without mutating persisted expansion', async () => {
@ -68,6 +74,18 @@ describe('activateFileExplorerNode', () => {
it('falls back to opening a symlink as a file when directory loading fails', async () => {
const openFile = vi.fn()
useAppStore.setState({
worktreesByRepo: {
'repo-1': [
{
id: 'wt-1',
repoId: 'repo-1',
path: '/repo',
hostId: 'runtime:runtime-env-1'
} as never
]
}
})
await activateFileExplorerNode({
node: symlinkNode,
@ -100,9 +118,15 @@ describe('activateFileExplorerNode', () => {
path: '/repo/README.md',
relativePath: 'README.md',
isDirectory: false,
depth: 0
depth: 0,
operationOwner: { kind: 'local' }
}
const openFile = vi.fn()
useAppStore.setState({
worktreesByRepo: {
'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo', hostId: 'local' } as never]
}
})
await activateFileExplorerNode({
node: fileNode,

View File

@ -6,6 +6,10 @@ import { toast } from 'sonner'
import type { TreeNode } from './file-explorer-types'
import { FILE_EXPLORER_DRAGGABLE_SELECTOR } from './file-explorer-drag-scroll-marker'
import { translate } from '@/i18n/i18n'
import {
getFileExplorerOwnerUnresolvedMessage,
requireMatchingFileExplorerOperationRoute
} from './file-explorer-operation-owner'
type UseFileExplorerHandlersParams = {
activeWorktreeId: string | null
@ -59,7 +63,6 @@ export async function activateFileExplorerNode(args: {
const {
node,
activeWorktreeId,
runtimeEnvironmentId,
openFile,
toggleDir,
canToggleDirectories = true,
@ -115,12 +118,20 @@ export async function activateFileExplorerNode(args: {
return
}
}
let fileRuntimeEnvironmentId: string | null
try {
const route = requireMatchingFileExplorerOperationRoute(activeWorktreeId, node.operationOwner)
fileRuntimeEnvironmentId = route.settings.activeRuntimeEnvironmentId?.trim() || null
} catch {
toast.error(getFileExplorerOwnerUnresolvedMessage())
return
}
openFile(
{
filePath: node.path,
relativePath: node.relativePath,
worktreeId: activeWorktreeId,
runtimeEnvironmentId: runtimeEnvironmentId ?? undefined,
runtimeEnvironmentId: fileRuntimeEnvironmentId ?? undefined,
language: detectLanguage(node.name),
mode: 'edit'
},
@ -128,7 +139,7 @@ export async function activateFileExplorerNode(args: {
preview: true,
// Why: explicit local opens must not inherit the active runtime, so we
// encode "no runtime owner" via the fallback-suppression option.
suppressActiveRuntimeFallback: runtimeEnvironmentId === null
suppressActiveRuntimeFallback: fileRuntimeEnvironmentId === null
}
)
}

View File

@ -1,10 +1,10 @@
import { useEffect, useRef } from 'react'
import { toast } from 'sonner'
import { getConnectionId } from '@/lib/connection-context'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client'
import { translate } from '@/i18n/i18n'
import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner'
import type { FileExplorerOperationOwner } from './file-explorer-types'
import { captureFileExplorerOperationGuard } from './file-explorer-operation-owner'
type UseFileExplorerImportParams = {
worktreePath: string | null
@ -12,6 +12,7 @@ type UseFileExplorerImportParams = {
refreshDir: (dirPath: string) => Promise<void>
clearNativeDragState: () => void
setSelectedPath: (path: string | null) => void
operationOwner?: FileExplorerOperationOwner
}
/**
@ -28,7 +29,8 @@ export function useFileExplorerImport({
activeWorktreeId,
refreshDir,
clearNativeDragState,
setSelectedPath
setSelectedPath,
operationOwner
}: UseFileExplorerImportParams): void {
// Refs to avoid re-subscribing IPC listener on every render
const worktreePathRef = useRef(worktreePath)
@ -41,6 +43,8 @@ export function useFileExplorerImport({
clearNativeDragStateRef.current = clearNativeDragState
const setSelectedPathRef = useRef(setSelectedPath)
setSelectedPathRef.current = setSelectedPath
const operationOwnerRef = useRef(operationOwner)
operationOwnerRef.current = operationOwner
useEffect(() => {
return window.api.ui.onFileDrop((data) => {
@ -59,19 +63,24 @@ export function useFileExplorerImport({
}
const { paths, destinationDir } = data
const connectionId = getConnectionId(wtId) ?? undefined
void (async () => {
try {
const operationGuard = captureFileExplorerOperationGuard(wtId, operationOwnerRef.current)
operationGuard.assertCurrent()
const { results } = await importExternalPathsToRuntime(
{
settings: getRightSidebarWorktreeRuntimeSettings(wtId),
settings: operationGuard.route.settings,
worktreeId: wtId,
worktreePath: worktreePathRef.current,
connectionId
connectionId: operationGuard.route.connectionId,
expectedExecutionHostId: operationGuard.route.expectedExecutionHostId,
expectedSshTargetId: operationGuard.route.expectedSshTargetId,
expectedSshConnectionGeneration: operationGuard.route.expectedSshConnectionGeneration
},
paths,
destinationDir
destinationDir,
{ assertCurrent: operationGuard.assertCurrent }
)
// Refresh the destination directory once per gesture

View File

@ -4,14 +4,16 @@ import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { detectLanguage } from '@/lib/language-detect'
import { dirname, joinPath } from '@/lib/path'
import { getConnectionId } from '@/lib/connection-context'
import { extractIpcErrorMessage, renameFileOnDisk } from '@/lib/rename-file'
import type { InlineInput } from './FileExplorerRow'
import type { TreeNode } from './file-explorer-types'
import type { FileExplorerRowProjection } from './file-explorer-row-projection'
import { commitFileExplorerOp } from './fileExplorerUndoRedo'
import { createRuntimePath, deleteRuntimePath } from '@/runtime/runtime-file-client'
import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner'
import {
captureFileExplorerOperationGuard,
getFileExplorerOperationOwner
} from './file-explorer-operation-owner'
type UseFileExplorerInlineInputParams = {
activeWorktreeId: string | null
@ -74,7 +76,12 @@ export function useFileExplorerInlineInput({
if (activeWorktreeId && parentPath !== worktreePath && !expanded.has(parentPath)) {
toggleDir(activeWorktreeId, parentPath)
}
setInlineInput({ parentPath, type, depth })
setInlineInput({
parentPath,
type,
depth,
operationOwner: getFileExplorerOperationOwner(activeWorktreeId)
})
},
[activeWorktreeId, worktreePath, expanded, toggleDir]
)
@ -86,7 +93,8 @@ export function useFileExplorerInlineInput({
type: 'rename',
depth: node.depth,
existingName: node.name,
existingPath: node.path
existingPath: node.path,
operationOwner: node.operationOwner
}),
[]
)
@ -109,24 +117,33 @@ export function useFileExplorerInlineInput({
return
}
const run = async (): Promise<void> => {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
const fileContext = {
settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId),
worktreeId: activeWorktreeId,
worktreePath,
connectionId
}
if (inlineInput.type === 'rename' && inlineInput.existingPath) {
await renameFileOnDisk({
oldPath: inlineInput.existingPath,
newName: name,
worktreeId: activeWorktreeId,
worktreePath,
operationOwner: inlineInput.operationOwner,
refreshDir
})
} else {
const fullPath = joinPath(inlineInput.parentPath, name)
try {
const operationGuard = captureFileExplorerOperationGuard(
activeWorktreeId,
inlineInput.operationOwner
)
const operationRoute = operationGuard.route
const fileContext = {
settings: operationRoute.settings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId: operationRoute.connectionId,
expectedExecutionHostId: operationRoute.expectedExecutionHostId,
expectedSshTargetId: operationRoute.expectedSshTargetId,
expectedSshConnectionGeneration: operationRoute.expectedSshConnectionGeneration
}
operationGuard.assertCurrent()
await createRuntimePath(
fileContext,
fullPath,
@ -136,22 +153,57 @@ export function useFileExplorerInlineInput({
if (inlineInput.type === 'folder') {
commitFileExplorerOp({
undo: async () => {
await deleteRuntimePath(fileContext, fullPath, true)
const currentRoute = operationGuard.assertCurrent()
await deleteRuntimePath(
{
...fileContext,
settings: currentRoute.settings,
connectionId: currentRoute.connectionId
},
fullPath,
true
)
await refreshDir(parentForRefresh)
},
redo: async () => {
await createRuntimePath(fileContext, fullPath, 'directory')
const currentRoute = operationGuard.assertCurrent()
await createRuntimePath(
{
...fileContext,
settings: currentRoute.settings,
connectionId: currentRoute.connectionId
},
fullPath,
'directory'
)
await refreshDir(parentForRefresh)
}
})
} else {
commitFileExplorerOp({
undo: async () => {
await deleteRuntimePath(fileContext, fullPath)
const currentRoute = operationGuard.assertCurrent()
await deleteRuntimePath(
{
...fileContext,
settings: currentRoute.settings,
connectionId: currentRoute.connectionId
},
fullPath
)
await refreshDir(parentForRefresh)
},
redo: async () => {
await createRuntimePath(fileContext, fullPath, 'file')
const currentRoute = operationGuard.assertCurrent()
await createRuntimePath(
{
...fileContext,
settings: currentRoute.settings,
connectionId: currentRoute.connectionId
},
fullPath,
'file'
)
await refreshDir(parentForRefresh)
}
})

View File

@ -97,7 +97,8 @@ export async function refreshFileExplorerExpandedDirs({
worktreePath,
listing.operationOwner
),
loading: false
loading: false,
operationOwner: listing.operationOwner
}
}
} catch {
@ -184,7 +185,10 @@ export function useFileExplorerTree(
worktreePath,
listing.operationOwner
)
setDirCache((prev) => ({ ...prev, [dirPath]: { children, loading: false } }))
setDirCache((prev) => ({
...prev,
[dirPath]: { children, loading: false, operationOwner: listing.operationOwner }
}))
return true
} catch (error) {
if (!dirLoadTrackerRef.current.isCurrent(loadToken)) {

View File

@ -202,4 +202,21 @@ describe('getFileExplorerWatchRuntimeEnvironmentId', () => {
)
).toBeNull()
})
it('disables a cached watch when its listing owner no longer matches', () => {
expect(
getFileExplorerWatchRuntimeEnvironmentId(
makeState({
activeRuntimeEnvironmentId: 'focused-runtime',
executionHostId: 'runtime:owner-runtime'
}),
'wt-1',
{
kind: 'runtime',
environmentId: 'old-owner-runtime',
executionHostId: 'runtime:old-owner-runtime'
}
)
).toBeUndefined()
})
})

View File

@ -1,7 +1,6 @@
import { useEffect, useRef } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react'
import type { FsChangedPayload } from '../../../../shared/types'
import type { DirCache } from './file-explorer-types'
import type { DirCache, FileExplorerOperationOwner } from './file-explorer-types'
import type { InlineInput } from './FileExplorerRow'
import { joinPath, normalizeRelativePath, dirname } from '@/lib/path'
import {
@ -16,8 +15,16 @@ import {
} from './file-explorer-watcher-reconcile'
import { useAppStore } from '@/store'
import { subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client'
import type { AppState } from '@/store/types'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import {
getFileExplorerOperationOwnerFromState,
type FileExplorerOwnerState
} from './file-explorer-operation-owner'
type FileExplorerWatchOwnerState = Pick<
FileExplorerOwnerState,
'settings' | 'repos' | 'worktreesByRepo'
> &
Partial<Omit<FileExplorerOwnerState, 'settings' | 'repos' | 'worktreesByRepo'>>
type UseFileExplorerWatchParams = {
worktreePath: string | null
@ -31,6 +38,7 @@ type UseFileExplorerWatchParams = {
inlineInput: InlineInput | null
dragSourcePath: string | null
isNativeDragOver: boolean
operationOwner?: FileExplorerOperationOwner
}
export function getExternalFileChangeRelativePath(
@ -65,10 +73,7 @@ export function canonicalizeFileExplorerWatchPath(
}
function normalizeExplorerAbsolutePath(path: string): string {
if (path === '/' || /^[A-Za-z]:[\\/]$/.test(path)) {
return path
}
return path.replace(/[\\/]+$/, '')
return path === '/' || /^[A-Za-z]:[\\/]$/.test(path) ? path : path.replace(/[\\/]+$/, '')
}
export function payloadRequiresDeferredTreeRefresh(
@ -86,10 +91,29 @@ export function payloadRequiresDeferredTreeRefresh(
}
export function getFileExplorerWatchRuntimeEnvironmentId(
state: Pick<AppState, 'repos' | 'settings' | 'worktreesByRepo'>,
activeWorktreeId: string | null
): string | null {
return getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId)
state: FileExplorerWatchOwnerState,
activeWorktreeId: string | null,
expectedOwner?: FileExplorerOperationOwner
): string | null | undefined {
const ownerState: FileExplorerOwnerState = {
settings: state.settings,
repos: state.repos,
worktreesByRepo: state.worktreesByRepo,
detectedWorktreesByRepo: state.detectedWorktreesByRepo ?? {},
folderWorkspaces: state.folderWorkspaces ?? [],
projectGroups: state.projectGroups ?? [],
restoredRuntimeHostIdByWorkspaceSessionKey:
state.restoredRuntimeHostIdByWorkspaceSessionKey ?? {}
}
const owner = getFileExplorerOperationOwnerFromState(ownerState, activeWorktreeId)
if (expectedOwner && JSON.stringify(owner) !== JSON.stringify(expectedOwner)) {
return undefined
}
return owner.kind === 'runtime'
? owner.environmentId
: owner.kind === 'unresolved'
? undefined
: null
}
/**
@ -108,11 +132,12 @@ export function useFileExplorerWatch({
refreshTree,
inlineInput,
dragSourcePath,
isNativeDragOver
isNativeDragOver,
operationOwner
}: UseFileExplorerWatchParams): void {
// Why: subscriptions follow the selected worktree; host focus is only a legacy default, not an ownership signal.
const activeRuntimeEnvironmentId = useAppStore((s) =>
getFileExplorerWatchRuntimeEnvironmentId(s, activeWorktreeId)
getFileExplorerWatchRuntimeEnvironmentId(s, activeWorktreeId, operationOwner)
)
// Keep refs for handler-accessed values so the IPC listener isn't re-subscribed on every render.
@ -149,7 +174,7 @@ export function useFileExplorerWatch({
// Why: one atomic effect avoids a cleanup-ordering race that drops events on rapid worktree switches (review issue §3).
useEffect(() => {
if (!worktreePath) {
if (!worktreePath || activeRuntimeEnvironmentId === undefined) {
return
}

View File

@ -258,7 +258,10 @@ describe('useGitStatusPolling rerender stability', () => {
await act(async () => {
useAppStore.setState({
settings: { activeRuntimeEnvironmentId: 'env-2' } as AppState['settings']
worktreesByRepo: {
[REPO_ID]: [{ ...worktree, hostId: 'runtime:env-2' }],
[REPO_ID2]: [worktree2]
}
})
})
await flushMicrotasks()

View File

@ -19,6 +19,7 @@ import { McpConfigFileRow, type LoadedMcpConfigInspection } from './McpConfigFil
import { McpMissingConfigList } from './McpMissingConfigList'
import { loadMcpConfigInspections } from './mcp-config-inspection'
import { translate } from '@/i18n/i18n'
import { captureDirectSshMutationExpectation } from '@/lib/ssh-mutation-expectation'
type McpConfigSectionProps = {
repo: Repo
@ -192,9 +193,17 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
const target = joinPath(targetRootPath, '.mcp.json')
try {
const sshExpectation = connectionId
? captureDirectSshMutationExpectation(useAppStore.getState(), connectionId)
: {}
// Why: v1 only creates the root workspace config so we do not need to
// guess per-agent directory layouts or mutate agent-specific files.
await window.api.fs.writeFile({ filePath: target, content: MCP_STARTER_CONFIG, connectionId })
await window.api.fs.writeFile({
filePath: target,
content: MCP_STARTER_CONFIG,
connectionId,
...sshExpectation
})
clearCreateConfirmResetTimer()
if (mountedRef.current) {
setCreateConfirm(false)

View File

@ -69,11 +69,12 @@ afterEach(() => {
useAppStore.setState(useAppStore.getInitialState(), true)
})
function renderSection(repo: Repo): void {
function renderSection(repo: Repo, selectedProjectSetupId?: string): void {
act(() => {
root.render(
React.createElement(RepositoryHostSetupsSection, {
repo,
selectedProjectSetupId,
forceVisible: true,
searchQuery: '',
searchEntries: []
@ -201,12 +202,142 @@ describe('RepositoryHostSetupsSection', () => {
// The single project pane switches host in place — no navigation.
expect(setSettingsProjectHostSelection).toHaveBeenCalledWith(
'github:stablyai/orca',
toSshExecutionHostId('openclaw 2')
toSshExecutionHostId('openclaw 2'),
'remote-repo'
)
expect(openSettingsPage).not.toHaveBeenCalled()
expect(openSettingsTarget).not.toHaveBeenCalled()
})
it('keeps nested SSH setups distinct and derives readiness from their HUB owner', () => {
const remoteRepo = makeRepo({
id: 'remote-repo',
displayName: 'Orca',
path: '/srv/orca',
executionHostId: 'runtime:hub'
})
useAppStore.setState({
repos: [remoteRepo],
projects: [makeProject({ id: 'github:stablyai/orca', sourceRepoIds: ['remote-repo'] })],
projectHostSetups: [
makeSetup({
id: 'direct-setup',
projectId: 'github:stablyai/orca',
repoId: 'remote-repo',
hostId: 'runtime:hub',
executionHostId: 'ssh:direct',
runtimeOwnerEnvironmentId: 'hub',
path: '/srv/orca'
}),
makeSetup({
id: 'jump-setup',
projectId: 'github:stablyai/orca',
repoId: 'remote-repo',
hostId: 'runtime:hub',
executionHostId: 'ssh:jump',
runtimeOwnerEnvironmentId: 'hub',
path: '/srv/orca'
})
],
runtimeStatusByEnvironmentId: new Map([
[
'hub',
{
checkedAt: 1,
appVersion: '1.8.0',
status: {
runtimeId: 'runtime-hub',
rendererGraphEpoch: 1,
graphStatus: 'ready',
authoritativeWindowId: 1,
liveTabCount: 0,
liveLeafCount: 0,
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
minCompatibleRuntimeClientVersion: 1,
capabilities: []
}
}
]
]),
sshStateByEnvironment: new Map([
[
'hub',
{
connectionStates: new Map([
[
'direct',
{
targetId: 'direct',
status: 'connected',
error: null,
reconnectAttempt: 0
}
],
[
'jump',
{
targetId: 'jump',
status: 'disconnected',
error: null,
reconnectAttempt: 0
}
]
]),
targetLabels: new Map([
['direct', 'Direct box'],
['jump', 'Jump box']
]),
removedTargetLabels: new Map(),
targetsHydrated: true
}
]
])
})
renderSection(remoteRepo, 'jump-setup')
expect(container.textContent).toContain('Direct box')
expect(container.textContent).toContain('Jump box')
expect(container.textContent).toContain('Ready')
expect(container.textContent).toContain('Disconnected')
expect(findButton('Open')).toBeTruthy()
const currentSetup = container.querySelector('[data-current="true"]')
expect(currentSetup?.textContent).toContain('Jump box')
expect(currentSetup?.textContent).toContain('Disconnected')
expect(currentSetup?.textContent).not.toContain('Direct box')
expect(currentSetup?.textContent).not.toContain('Ready')
})
it('shows HUB-local setups as disconnected when their owning runtime is unreachable', () => {
const remoteRepo = makeRepo({
id: 'remote-repo',
displayName: 'Orca',
path: '/srv/orca',
executionHostId: 'runtime:hub'
})
useAppStore.setState({
repos: [remoteRepo],
projects: [makeProject({ id: 'github:stablyai/orca', sourceRepoIds: ['remote-repo'] })],
projectHostSetups: [
makeSetup({
id: 'hub-local-setup',
projectId: 'github:stablyai/orca',
repoId: 'remote-repo',
hostId: 'runtime:hub',
executionHostId: 'local',
runtimeOwnerEnvironmentId: 'hub',
path: '/srv/orca'
})
],
runtimeStatusByEnvironmentId: new Map([['hub', { checkedAt: 1, status: null }]])
})
renderSection(remoteRepo)
expect(container.textContent).toContain('Disconnected')
expect(container.textContent).not.toContain('Ready')
})
it('removes independent setup metadata instead of opening an empty repo target', async () => {
const deleteProjectHostSetup = vi.fn().mockResolvedValue({
project: makeProject({ id: 'github:stablyai/orca' }),

View File

@ -1,12 +1,12 @@
import { useMemo, useState } from 'react'
import {
getExecutionHostLabel,
getRepoExecutionHostId,
parseExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
import { buildExecutionHostRegistry } from '../../../../shared/execution-host-registry'
import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides'
import type { Repo } from '../../../../shared/types'
import type { ProjectHostSetup, Repo } from '../../../../shared/types'
import { useAppStore } from '../../store'
import { getProjectHostSetupProjectionFromState } from '../../store/selectors'
import { cn } from '../../lib/utils'
@ -20,16 +20,40 @@ import type { SettingsSearchEntry } from './settings-search'
import { translate } from '@/i18n/i18n'
import { buildSetupHostOptions, getSetupStateLabel } from './repository-host-setup-options'
import { RepositoryHostSetupActions } from './RepositoryHostSetupActions'
import {
selectRuntimeAwareSshStatus,
selectRuntimeAwareSshTargetLabel
} from '@/store/slices/runtime-environment-ssh'
type RepositoryHostSetupsSectionProps = {
repo: Repo
selectedProjectSetupId?: string
forceVisible: boolean
searchQuery: string
searchEntries: SettingsSearchEntry[]
}
function setupsByOwnedExecutionHost(
setups: readonly ProjectHostSetup[],
selectedSetupId: string
): ProjectHostSetup[] {
const byHost = new Map<string, ProjectHostSetup>()
for (const setup of setups) {
const key = JSON.stringify([
setup.hostId,
setup.executionHostId ?? setup.hostId,
setup.runtimeOwnerEnvironmentId ?? null
])
if (!byHost.has(key) || setup.id === selectedSetupId) {
byHost.set(key, setup)
}
}
return [...byHost.values()]
}
export function RepositoryHostSetupsSection({
repo,
selectedProjectSetupId,
forceVisible,
searchQuery,
searchEntries
@ -47,6 +71,9 @@ export function RepositoryHostSetupsSection({
const settings = useAppStore((state) => state.settings)
const runtimeEnvironments = useAppStore((state) => state.runtimeEnvironments)
const runtimeStatusByEnvironmentId = useAppStore((state) => state.runtimeStatusByEnvironmentId)
const sshStateByEnvironment = useAppStore((state) => state.sshStateByEnvironment)
const removedSshTargetLabels = useAppStore((state) => state.removedSshTargetLabels)
const sshTargetsHydrated = useAppStore((state) => state.sshTargetsHydrated)
const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings])
const hostOptions = useMemo(
() =>
@ -72,16 +99,29 @@ export function RepositoryHostSetupsSection({
const projectHostSetupProjection = useAppStore((state) =>
getProjectHostSetupProjectionFromState(state)
)
const selectedHostId = getRepoExecutionHostId(repo)
const selectedProjectHostSetup = projectHostSetupProjection.setups.find(
(setup) => setup.repoId === repo.id && setup.hostId === selectedHostId
const repoProjectHostSetup = projectHostSetupProjection.setups.find(
(setup) => setup.repoId === repo.id
)
const selectedProjectHostSetup =
projectHostSetupProjection.setups.find(
(setup) =>
setup.id === selectedProjectSetupId &&
setup.repoId === repo.id &&
setup.projectId === repoProjectHostSetup?.projectId
) ?? repoProjectHostSetup
const projectHostSetups = selectedProjectHostSetup
? projectHostSetupProjection.setups.filter(
(setup) => setup.projectId === selectedProjectHostSetup.projectId
? setupsByOwnedExecutionHost(
projectHostSetupProjection.setups.filter(
(setup) => setup.projectId === selectedProjectHostSetup.projectId
),
selectedProjectHostSetup.id
)
: []
const openableProjectHostSetups = projectHostSetups.filter((setup) => setup.repoId.trim())
const switchableProjectHostSetups = setupsByOwnedExecutionHost(
openableProjectHostSetups,
selectedProjectHostSetup?.id ?? ''
)
const setupHostOptions = buildSetupHostOptions({
projectHostSetups,
hostOptions
@ -96,6 +136,11 @@ export function RepositoryHostSetupsSection({
setSettingsProjectHostSelection(projectId, hostId)
}
}
const selectSetup = (setup: ProjectHostSetup) => {
if (projectId) {
setSettingsProjectHostSelection(projectId, setup.hostId, setup.id)
}
}
if (
(projectHostSetups.length <= 1 && setupHostOptions.length === 0) ||
(!forceVisible && !matchesSettingsSearch(searchQuery, searchEntries))
@ -119,29 +164,34 @@ export function RepositoryHostSetupsSection({
<Label className="text-sm font-semibold">
{translate('auto.components.settings.RepositoryPane.availableHosts', 'Available Hosts')}
</Label>
{openableProjectHostSetups.length > 1 ? (
{switchableProjectHostSetups.length > 1 ? (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{translate('auto.components.settings.RepositoryPane.viewingHost', 'Viewing host')}
</span>
<Select
value={selectedHostId}
onValueChange={(hostId) => {
if (hostId === selectedHostId) {
value={selectedProjectHostSetup?.id}
onValueChange={(setupId) => {
if (setupId === selectedProjectHostSetup?.id) {
return
}
selectHost(hostId as ExecutionHostId)
const setup = switchableProjectHostSetups.find(
(candidate) => candidate.id === setupId
)
if (setup) {
selectSetup(setup)
}
}}
>
<SelectTrigger className="h-8 w-44 min-w-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{openableProjectHostSetups.map((setup) => (
<SelectItem key={setup.hostId} value={setup.hostId}>
{switchableProjectHostSetups.map((setup) => (
<SelectItem key={setup.id} value={setup.id}>
<span className="block min-w-0 truncate">
{hostOptionById.get(setup.hostId)?.label ??
getExecutionHostLabel(setup.hostId)}
{hostOptionById.get(setup.executionHostId ?? setup.hostId)?.label ??
getExecutionHostLabel(setup.executionHostId ?? setup.hostId)}
</span>
</SelectItem>
))}
@ -159,24 +209,86 @@ export function RepositoryHostSetupsSection({
</div>
<div className="divide-y divide-border rounded-md border border-border">
{projectHostSetups.map((setup) => {
const isCurrentSetup = setup.hostId === selectedHostId
const executionHost = parseExecutionHostId(setup.executionHostId ?? setup.hostId)
const transportHost = parseExecutionHostId(setup.hostId)
const runtimeOwnerEnvironmentId =
setup.runtimeOwnerEnvironmentId?.trim() ||
(transportHost?.kind === 'runtime' ? transportHost.environmentId : null)
const runtimeOwnerReachable =
!runtimeOwnerEnvironmentId ||
Boolean(runtimeStatusByEnvironmentId.get(runtimeOwnerEnvironmentId)?.status)
const nestedSshStatus =
runtimeOwnerEnvironmentId && executionHost?.kind === 'ssh'
? selectRuntimeAwareSshStatus(
{
sshConnectionStates,
sshTargetLabels,
removedSshTargetLabels,
sshTargetsHydrated,
sshStateByEnvironment,
runtimeStatusByEnvironmentId
},
runtimeOwnerEnvironmentId,
executionHost.targetId
)
: undefined
const setupReady =
setup.setupState === 'ready' &&
runtimeOwnerReachable &&
(nestedSshStatus === undefined || nestedSshStatus === 'connected')
const setupStateLabel = !runtimeOwnerReachable
? translate(
'auto.components.settings.RepositoryPane.hostStateDisconnected',
'Disconnected'
)
: nestedSshStatus === null
? translate('auto.components.settings.RepositoryPane.hostStateUnknown', 'Unknown')
: nestedSshStatus !== undefined && nestedSshStatus !== 'connected'
? translate(
'auto.components.settings.RepositoryPane.hostStateDisconnected',
'Disconnected'
)
: getSetupStateLabel(setup.setupState)
const setupHostLabel =
runtimeOwnerEnvironmentId && executionHost?.kind === 'ssh'
? translate(
'auto.components.settings.RepositoryPane.nestedHostLabel',
'{{value0}} via {{value1}}',
{
value0: selectRuntimeAwareSshTargetLabel(
{
sshConnectionStates,
sshTargetLabels,
removedSshTargetLabels,
sshTargetsHydrated,
sshStateByEnvironment,
runtimeStatusByEnvironmentId
},
runtimeOwnerEnvironmentId,
executionHost.targetId
),
value1:
hostOptionById.get(setup.hostId)?.label ?? getExecutionHostLabel(setup.hostId)
}
)
: (hostOptionById.get(setup.hostId)?.label ?? getExecutionHostLabel(setup.hostId))
const isCurrentSetup = setup.id === selectedProjectHostSetup?.id
const canOpenSetup = setup.repoId.trim().length > 0
const canRemoveSetup = !canOpenSetup && deletingSetupId !== setup.id
return (
<div
key={setup.hostId}
key={setup.id}
data-current={isCurrentSetup ? 'true' : undefined}
className={cn(
'flex w-full items-start gap-3 px-3 py-2.5 text-left transition-colors',
isCurrentSetup ? 'bg-muted/30' : ''
isCurrentSetup ? 'bg-accent' : ''
)}
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">
{hostOptionById.get(setup.hostId)?.label ?? getExecutionHostLabel(setup.hostId)}
</span>
<SettingsBadge tone={setup.setupState === 'ready' ? 'accent' : 'muted'}>
{getSetupStateLabel(setup.setupState)}
<span className="truncate text-sm font-medium">{setupHostLabel}</span>
<SettingsBadge tone={setupReady ? 'accent' : 'muted'}>
{setupStateLabel}
</SettingsBadge>
</div>
<p className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground">
@ -198,7 +310,7 @@ export function RepositoryHostSetupsSection({
variant="outline"
size="sm"
onClick={() => {
selectHost(setup.hostId)
selectSetup(setup)
}}
>
{translate('auto.components.settings.RepositoryPane.openSetup', 'Open')}

View File

@ -55,6 +55,7 @@ type RepositoryPaneProps = {
) => void
removeProject: (repoId: string) => void
project?: Project | null
selectedProjectSetupId?: string
isLocalWindowsProject?: boolean
wslAvailable?: boolean
wslDistros?: string[]
@ -74,6 +75,7 @@ export function RepositoryPane({
updateRepo,
removeProject,
project = null,
selectedProjectSetupId,
isLocalWindowsProject = false,
wslAvailable = false,
wslDistros = EMPTY_WSL_DISTROS,
@ -320,6 +322,7 @@ export function RepositoryPane({
<>
<RepositoryHostSetupsSection
repo={repo}
selectedProjectSetupId={selectedProjectSetupId}
forceVisible={forceFullPaneForRepoMatch}
searchQuery={searchQuery}
searchEntries={hostSetupEntries}

View File

@ -293,6 +293,7 @@ function Settings(): React.JSX.Element {
const settingsNavigationTarget = useAppStore((s) => s.settingsNavigationTarget)
const clearSettingsTarget = useAppStore((s) => s.clearSettingsTarget)
const settingsProjectHostSelection = useAppStore((s) => s.settingsProjectHostSelection)
const settingsProjectSetupSelection = useAppStore((s) => s.settingsProjectSetupSelection)
const setSettingsProjectHostSelection = useAppStore((s) => s.setSettingsProjectHostSelection)
const settingsSearchInputQuery = useAppStore((s) => s.settingsSearchInputQuery)
const settingsSearchQuery = useAppStore((s) => s.settingsSearchQuery)
@ -871,14 +872,21 @@ function Settings(): React.JSX.Element {
const repo = getSettingsProjectHostRepo(
settingsProject,
repos,
settingsProjectHostSelection[settingsProject.projectId]
settingsProjectHostSelection[settingsProject.projectId],
settingsProjectSetupSelection[settingsProject.projectId]
)
if (repo) {
reposByHostIdentity.set(getRepoHostIdentity(repo), repo)
}
}
return [...reposByHostIdentity.values()]
}, [neededSectionIds, repos, settingsProjectHostSelection, settingsProjectList])
}, [
neededSectionIds,
repos,
settingsProjectHostSelection,
settingsProjectList,
settingsProjectSetupSelection
])
useEffect(() => {
const repoHostIdentitySet = new Set(repos.map(getRepoHostIdentity))
@ -1720,7 +1728,8 @@ function Settings(): React.JSX.Element {
const repo = getSettingsProjectHostRepo(
settingsProject,
repos,
settingsProjectHostSelection[settingsProject.projectId]
settingsProjectHostSelection[settingsProject.projectId],
settingsProjectSetupSelection[settingsProject.projectId]
)
if (!repo) {
return null
@ -1753,6 +1762,9 @@ function Settings(): React.JSX.Element {
updateRepo={updateRepo}
removeProject={() => void removeProjectAllHosts(settingsProject.setups)}
project={project}
selectedProjectSetupId={
settingsProjectSetupSelection[settingsProject.projectId]
}
isLocalWindowsProject={
getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID &&
isWindowsTerminalHost

View File

@ -211,6 +211,32 @@ describe('deep-link resolution', () => {
getSettingsProjectHostRepo(sameIdProjects[0], sameIdRepos, 'runtime:home-mac')?.path
).toBe('/remote/repo')
})
it('selects a same-transport setup by setup id', () => {
const directRepo = makeRepo({
id: 'direct-repo',
gitRemoteIdentity: gitRemote,
executionHostId: 'runtime:home-mac',
path: '/direct/repo'
})
const jumpRepo = makeRepo({
id: 'jump-repo',
gitRemoteIdentity: gitRemote,
executionHostId: 'runtime:home-mac',
path: '/jump/repo'
})
const sameHubProjects = buildSettingsProjectList([directRepo, jumpRepo])
const jumpSetup = sameHubProjects[0].setups.find((setup) => setup.repoId === 'jump-repo')
expect(
getSettingsProjectHostRepo(
sameHubProjects[0],
[directRepo, jumpRepo],
'runtime:home-mac',
jumpSetup?.id
)?.path
).toBe('/jump/repo')
})
})
describe('removeSettingsProjectFromAllHosts', () => {

View File

@ -174,13 +174,17 @@ export async function removeSettingsProjectFromAllHosts(
export function getSettingsProjectHostRepo(
settingsProject: SettingsProject,
repos: readonly Repo[],
selectedHostId: ExecutionHostId | undefined
selectedHostId: ExecutionHostId | undefined,
selectedSetupId?: string
): Repo | undefined {
const effectiveHostId = resolveEffectiveProjectHost(settingsProject.setups, selectedHostId)
if (!effectiveHostId) {
return undefined
}
const effectiveSetup =
settingsProject.setups.find(
(setup) => setup.id === selectedSetupId && setup.hostId === effectiveHostId
) ??
settingsProject.setups.find((setup) => setup.hostId === effectiveHostId) ??
settingsProject.setups[0]
return (

View File

@ -25,7 +25,7 @@ function unified(
}
function state(overrides: Partial<KillAllTerminalSurfaceState> = {}): KillAllTerminalSurfaceState {
return {
const state = {
activeWorktreeId: null,
tabsByWorktree: {},
unifiedTabsByWorktree: {},
@ -36,6 +36,27 @@ function state(overrides: Partial<KillAllTerminalSurfaceState> = {}): KillAllTer
pendingReconnectPtyIdByTabId: {},
...overrides
}
const worktreeIds = new Set([
...Object.keys(state.tabsByWorktree),
...Object.keys(state.unifiedTabsByWorktree)
])
const runtimeOwnerEnvironmentId = state.settings?.activeRuntimeEnvironmentId ?? undefined
return {
...state,
repos: [],
worktreesByRepo: {
'fixture-repo': [...worktreeIds].map((id) => ({
id,
repoId: 'fixture-repo',
hostId: 'local',
runtimeOwnerEnvironmentId
}))
},
detectedWorktreesByRepo: {},
runtimeEnvironments: [],
runtimeEnvironmentCatalogHydrated: true,
removedRuntimeEnvironmentIds: new Set()
}
}
function removeSurface(current: KillAllTerminalSurfaceState, targetId: string): void {
@ -489,14 +510,14 @@ describe('runKillAllTerminalSurfaces', () => {
runtimeTerminals: [],
cleanupOnlyPtyIds: ['remote:env-1@@terminal-1']
})
expect(killPty).toHaveBeenCalledWith('pty-first')
expect(killPty).toHaveBeenCalledWith('pty-second')
expect(killPty).not.toHaveBeenCalledWith('pty-first')
expect(killPty).not.toHaveBeenCalledWith('pty-second')
expect(killPty).not.toHaveBeenCalledWith('pty-shared-after-yield')
expect(snapshotKillAllTerminalSurfaceIds(current)).toEqual(['survivor'])
expect(summary).toMatchObject({
closeAttemptCount: 3,
absentTargetCount: 3,
exactKillAcceptedCount: 2,
exactKillAcceptedCount: 0,
closeYieldCount: 1
})
})
@ -507,6 +528,11 @@ describe('runKillAllTerminalSurfaces', () => {
const previousState = useAppStore.getState()
useAppStore.setState({
activeWorktreeId: null,
repos: [],
worktreesByRepo: {
'fixture-repo': [{ id: 'wt', repoId: 'fixture-repo', hostId: 'local' }]
} as never,
detectedWorktreesByRepo: {},
tabsByWorktree: { wt: tabs },
unifiedTabsByWorktree: {},
ptyIdsByTabId

View File

@ -14,6 +14,8 @@ let WorktreeCard: typeof WorktreeCardComponent
let sshConnectionStates = new Map<string, { status: string }>()
let sshTargetLabels = new Map<string, string>()
let runtimeStatusByEnvironmentId = new Map<string, { status?: unknown }>()
let sshStateByEnvironment = new Map()
let worktreesByRepo: Record<string, Worktree[]> = {}
let worktreeCardProperties: WorktreeCardProperty[] = ['status']
vi.mock('@/store', () => ({
@ -31,10 +33,14 @@ vi.mock('@/store', () => ({
projectGroups: [],
remoteBranchConflictByWorktreeId: {},
runtimeStatusByEnvironmentId,
removedSshTargetLabels: new Map(),
settings: null,
sshConnectionStates,
sshStateByEnvironment,
sshTargetLabels,
sshTargetsHydrated: true,
updateWorktreeMeta,
worktreesByRepo,
worktreeCardProperties
})
}))
@ -130,6 +136,8 @@ describe('WorktreeCard SSH reconnect prompt', () => {
sshConnectionStates = new Map()
sshTargetLabels = new Map()
runtimeStatusByEnvironmentId = new Map()
sshStateByEnvironment = new Map()
worktreesByRepo = {}
worktreeCardProperties = ['status']
})
@ -174,4 +182,30 @@ describe('WorktreeCard SSH reconnect prompt', () => {
expect(markup).not.toContain('Server disconnected')
expect(markup).toContain('Project on Orca server')
})
it('reads nested SSH readiness from the owning HUB instead of client-local SSH state', () => {
runtimeStatusByEnvironmentId.set('hub-1', { status: { runtimeId: 'hub-runtime' } })
sshConnectionStates.set('ssh-target-1', { status: 'disconnected' })
sshTargetLabels.set('ssh-target-1', 'Misleading client-local target')
sshStateByEnvironment.set('hub-1', {
connectionStates: new Map([['ssh-target-1', { status: 'connected' }]]),
targetLabels: new Map([['ssh-target-1', 'HUB private target']]),
removedTargetLabels: new Map(),
targetsHydrated: true
})
const worktree = {
...makeWorktree(),
hostId: 'ssh:ssh-target-1' as const,
runtimeOwnerEnvironmentId: 'hub-1'
}
worktreesByRepo = { 'repo-1': [worktree] }
const markup = renderToStaticMarkup(
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} />
)
expect(markup).not.toContain('SSH disconnected')
expect(markup).toContain('data-ssh-target-label="HUB private target"')
expect(markup).not.toContain('Misleading client-local target')
})
})

View File

@ -83,6 +83,12 @@ import { recordRendererCrashBreadcrumb } from '@/lib/crash-diagnostics'
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../../shared/execution-host'
import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE } from '../../../../shared/constants'
import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import {
selectRuntimeAwareSshStatus,
selectRuntimeAwareSshTargetLabel
} from '@/store/slices/runtime-environment-ssh'
import { hydrateRuntimeEnvironmentSshState } from '@/runtime/runtime-environment-ssh-state'
type WorktreeRenameRequest = {
worktreeId: string
@ -331,14 +337,21 @@ const WorktreeCard = React.memo(function WorktreeCard({
)
// SSH disconnected state
const sshOwnerEnvironmentId = useAppStore((s) =>
repo?.connectionId ? getExplicitRuntimeEnvironmentIdForWorktree(s, worktree.id) : null
)
const sshStatus = useAppStore((s) => {
// Why: runtime-owned SSH targets suppress their ssh:state-changed broadcasts, so don't show a false "disconnected" chip for them.
if (!repo?.connectionId || isRuntimeOwnedSshTargetId(repo.connectionId)) {
return null
}
const state = s.sshConnectionStates.get(repo.connectionId)
return state?.status ?? 'disconnected'
return selectRuntimeAwareSshStatus(s, sshOwnerEnvironmentId, repo.connectionId)
})
useEffect(() => {
if (sshOwnerEnvironmentId) {
void hydrateRuntimeEnvironmentSshState(sshOwnerEnvironmentId).catch(() => {})
}
}, [sshOwnerEnvironmentId])
const isSshDisconnected = sshStatus != null && sshStatus !== 'connected'
// Why: terminal views have their own reconnect overlay; reserve the blocking dialog for non-terminal views (default to terminal when ambiguous).
const activeViewIsTerminal = useAppStore(
@ -357,9 +370,11 @@ const WorktreeCard = React.memo(function WorktreeCard({
const [showDisconnectedDialog, setShowDisconnectedDialog] = useState(false)
const [titleRenaming, setTitleRenaming] = useState(false)
const [showRenameErrorDialog, setShowRenameErrorDialog] = useState(false)
// Why: read the target label from the store (hydrated in useIpcEvents.ts) instead of a listTargets IPC per card.
// Why: read the target label from its owning host's store instead of exposing HUB-private SSH metadata as client-local state.
const sshTargetLabel = useAppStore((s) =>
repo?.connectionId ? (s.sshTargetLabels.get(repo.connectionId) ?? '') : ''
repo?.connectionId
? selectRuntimeAwareSshTargetLabel(s, sshOwnerEnvironmentId, repo.connectionId)
: ''
)
const gitIdentityDisplay = getWorktreeGitIdentityDisplay(worktree)

View File

@ -23,7 +23,14 @@ const appStoreSnapshot: {
sshConnectionStates: Map<string, { remotePlatform?: NodeJS.Platform }>
worktreesByRepo: Record<
string,
{ id: string; repoId: string; path?: string; projectId?: string }[]
{
id: string
repoId: string
path?: string
projectId?: string
hostId?: 'local' | `runtime:${string}` | `ssh:${string}`
runtimeOwnerEnvironmentId?: string
}[]
>
unifiedTabsByWorktree: Record<string, unknown[]>
activeGroupIdByWorktree: Record<string, string>
@ -562,6 +569,16 @@ describe('TabBar PowerShell launch wiring', () => {
}
})
appStoreSnapshot.activeRuntimeEnvironmentId = 'web-env-1'
appStoreSnapshot.worktreesByRepo = {
fixture: [
{
id: 'wt-1',
repoId: 'fixture',
hostId: 'local',
runtimeOwnerEnvironmentId: 'web-env-1'
}
]
}
const capabilities = await import('@/lib/windows-terminal-capabilities')
await capabilities.loadWindowsTerminalCapabilities({
force: true,
@ -619,6 +636,16 @@ describe('TabBar PowerShell launch wiring', () => {
}
})
appStoreSnapshot.activeRuntimeEnvironmentId = 'desktop-env-1'
appStoreSnapshot.worktreesByRepo = {
fixture: [
{
id: 'wt-1',
repoId: 'fixture',
hostId: 'local',
runtimeOwnerEnvironmentId: 'desktop-env-1'
}
]
}
const capabilities = await import('@/lib/windows-terminal-capabilities')
await capabilities.loadWindowsTerminalCapabilities({
force: true,
@ -864,6 +891,16 @@ describe('TabBar PowerShell launch wiring', () => {
}
})
appStoreSnapshot.activeRuntimeEnvironmentId = 'serve-env-1'
appStoreSnapshot.worktreesByRepo = {
fixture: [
{
id: 'wt-1',
repoId: 'fixture',
hostId: 'local',
runtimeOwnerEnvironmentId: 'serve-env-1'
}
]
}
const capabilities = await import('@/lib/windows-terminal-capabilities')
await capabilities.loadWindowsTerminalCapabilities({
force: true,

View File

@ -1,5 +1,4 @@
import { detectLanguage } from '@/lib/language-detect'
import { getConnectionId } from '@/lib/connection-context'
import { joinPath } from '@/lib/path'
import {
createRuntimePath,
@ -14,7 +13,7 @@ import { useAppStore } from '@/store'
import type { OpenFile } from '@/store/slices/editor'
import type { BrowserTab as BrowserTabState } from '../../../../shared/types'
import type { RuntimeFileListState } from '../quick-open-file-list'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner'
import {
classifyTabEntryQuery,
type TabEntryActionClassification
@ -219,14 +218,11 @@ export async function openTabBarEntry(args: TabCreateEntryArgs): Promise<void> {
if (!worktree) {
throw new Error('No active worktree.')
}
const runtimeContext: RuntimeFileOperationArgs = {
settings: {
activeRuntimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, args.worktreeId)
},
worktreeId: args.worktreeId,
worktreePath: worktree.path,
connectionId: getConnectionId(args.worktreeId) ?? undefined
}
const runtimeContext: RuntimeFileOperationArgs = getEditorFileOperationContext(
state,
{ worktreeId: args.worktreeId },
worktree.path
)
await openTabEntryWithOperations({
query: args.query,
fileList: args.fileList,

View File

@ -35,7 +35,6 @@ import {
selectRuntimeAwareSshTargetRemoved
} from '@/store/slices/runtime-environment-ssh'
import { hydrateRuntimeEnvironmentSshState } from '@/runtime/runtime-environment-ssh-state'
import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome'
import { handleInternalTerminalFileDrop } from './terminal-drop-handler'
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
import {
@ -330,11 +329,8 @@ export default function TerminalPane({
isNativeChatTranscriptLocalReadable(getConnectionIdFromState(store, worktreeId))
)
// Which machine's SSH store this target belongs to: a remote server's per-environment bucket, or null for this machine's local SSH maps.
// Why: paired web clients force null — they mirror their one host through the local maps, not an explicit environment bucket.
const sshReconnectEnvironmentId = useAppStore((store) =>
sshReconnectTargetId && !isPairedWebClientWindow()
? getExplicitRuntimeEnvironmentIdForWorktree(store, worktreeId)
: null
sshReconnectTargetId ? getExplicitRuntimeEnvironmentIdForWorktree(store, worktreeId) : null
)
const sshReconnectStatus = useAppStore((store) =>
sshReconnectTargetId

View File

@ -130,8 +130,12 @@ type StoreState = {
displayName?: string
branch?: string
workspaceStatus?: string
hostId?: string
runtimeOwnerEnvironmentId?: string
}[]
>
runtimeEnvironments?: { id: string }[]
runtimeEnvironmentCatalogHydrated?: boolean
repos: {
id: string
connectionId?: string | null
@ -2566,12 +2570,15 @@ describe('connectPanePty', () => {
it('rebinds a provider replacement without granting fresh-spawn exit protection', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('terminal-old')
let transportPtyId = 'terminal-old'
const transport = createMockTransport(transportPtyId)
transport.getPtyId = vi.fn(() => transportPtyId)
transportFactoryQueue.push(transport)
const manager = createManager(1)
const deps = createDeps()
const pane = createPane(1)
connectPanePty(createPane(1) as never, manager as never, deps as never)
connectPanePty(pane as never, manager as never, deps as never)
const onPtyRebind = createdTransportOptions[0]?.onPtyRebind as
| ((ptyId: string, replacedPtyId: string) => void)
| undefined
@ -2579,7 +2586,11 @@ describe('connectPanePty', () => {
expect(onPtyRebind).toBeTypeOf('function')
expect(onPtyExit).toBeTypeOf('function')
transportPtyId = 'terminal-reconnected'
onPtyRebind?.('terminal-reconnected', 'terminal-old')
expect((transport.getPtyId as unknown as () => string | null)()).toBe('terminal-reconnected')
expect(pane.container.dataset.ptyId).toBe('terminal-reconnected')
onPtyExit?.('terminal-reconnected')
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'terminal-reconnected')
@ -14937,7 +14948,7 @@ describe('connectPanePty', () => {
expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey)
})
it('constructs restored encoded remote PTYs with their owning runtime environment', async () => {
it('does not let a restored encoded PTY override the current worktree owner', async () => {
const { connectPanePty } = await import('./pty-connection')
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createMockTransport()
@ -14948,6 +14959,17 @@ describe('connectPanePty', () => {
tabsByWorktree: {
'wt-1': [{ id: 'tab-1', ptyId: 'remote:env-1@@terminal-1' }]
},
worktreesByRepo: {
repo1: [
{
id: 'wt-1',
repoId: 'repo1',
path: '/tmp/wt-1',
displayName: 'feat/notis',
runtimeOwnerEnvironmentId: 'env-2'
}
]
},
settings: {
...mockStoreState.settings,
activeRuntimeEnvironmentId: 'env-2'
@ -14960,13 +14982,260 @@ describe('connectPanePty', () => {
connectPanePty(pane as never, manager as never, deps as never)
expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('env-1', expect.any(Object))
expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('env-2', expect.any(Object))
expect(transport.attach).toHaveBeenCalledWith(
expect.objectContaining({ existingPtyId: 'remote:env-1@@terminal-1' })
)
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:env-1@@terminal-1')
})
it('routes a paired-web mirrored pane through its session-scoped HUB owner', async () => {
const { connectPanePty } = await import('./pty-connection')
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const { createIpcPtyTransport } = await import('./pty-transport')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
const tabId = 'web-terminal-host-tab'
const ptyId = 'remote:hub-web@@terminal-1'
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: tabId, ptyId }] },
ptyIdsByTabId: { [tabId]: [ptyId] },
worktreesByRepo: {
repo1: [
{
id: 'wt-1',
repoId: 'repo1',
path: '/srv/wt-1',
hostId: 'local'
}
]
},
repos: [{ id: 'repo1', connectionId: null, executionHostId: 'local' }]
} as StoreState
connectPanePty(
createPane(1) as never,
createManager(1) as never,
createDeps({
tabId,
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: ptyId }
}) as never
)
expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('hub-web', expect.any(Object))
expect(createIpcPtyTransport).not.toHaveBeenCalled()
expect(transport.attach).toHaveBeenCalledWith(expect.objectContaining({ existingPtyId: ptyId }))
})
it('uses a paired-web pane owner to disambiguate duplicate HUB projections', async () => {
const { connectPanePty } = await import('./pty-connection')
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
const tabId = 'web-terminal-host-tab'
const ptyId = 'remote:hub-b@@terminal-1'
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: tabId, ptyId }] },
worktreesByRepo: {
repo1: [
{ id: 'wt-1', repoId: 'repo1', path: '/srv/wt-1', hostId: 'local' },
{
id: 'wt-1',
repoId: 'repo1',
path: '/srv/wt-1',
hostId: 'ssh:private-target',
runtimeOwnerEnvironmentId: 'hub-b'
}
]
}
} as StoreState
connectPanePty(
createPane(1) as never,
createManager(1) as never,
createDeps({
tabId,
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: ptyId }
}) as never
)
expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('hub-b', expect.any(Object))
})
it('ignores a stale runtime PTY on an explicitly local non-web pane', async () => {
const { connectPanePty } = await import('./pty-connection')
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const { createIpcPtyTransport } = await import('./pty-transport')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
const ptyId = 'remote:stale-hub@@terminal-1'
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId }] },
worktreesByRepo: {
repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/tmp/wt-1', hostId: 'local' }]
},
repos: [{ id: 'repo1', connectionId: null, executionHostId: 'local' }],
settings: { ...mockStoreState.settings, activeRuntimeEnvironmentId: 'stale-hub' }
} as StoreState
connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never)
expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled()
expect(createIpcPtyTransport).toHaveBeenCalled()
})
it('uses the focused runtime only for ownerless mixed-version publications', async () => {
const { connectPanePty } = await import('./pty-connection')
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
worktreesByRepo: {
repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/srv/wt-1' }]
},
repos: [{ id: 'repo1', connectionId: null }],
settings: { ...mockStoreState.settings, activeRuntimeEnvironmentId: 'legacy-hub' }
} as StoreState
connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never)
expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('legacy-hub', expect.any(Object))
})
it('routes a HUB-owned SSH PTY wake hint through the HUB without direct SSH', async () => {
const { connectPanePty } = await import('./pty-connection')
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
const hostPtyId = 'ssh:hub-private@@pty-2'
mockStoreState = {
...mockStoreState,
tabsByWorktree: {
'wt-1': [{ id: 'tab-1', ptyId: hostPtyId }]
},
ptyIdsByTabId: { 'tab-1': [hostPtyId] },
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF_1 },
activeLeafId: LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_1]: hostPtyId }
}
},
worktreesByRepo: {
repo1: [
{
id: 'wt-1',
repoId: 'repo1',
path: '/srv/wt-1',
hostId: 'ssh:hub-private',
runtimeOwnerEnvironmentId: 'hub-env'
}
]
},
repos: [
{
id: 'repo1',
connectionId: 'hub-private',
executionHostId: 'runtime:hub-env'
}
]
} as StoreState
const deps = createDeps({
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: hostPtyId }
})
connectPanePty(createPane(1) as never, createManager(1) as never, deps as never)
await flushAsyncTicks()
expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('hub-env', expect.any(Object))
expect(transport.connect).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: hostPtyId })
)
expect(transport.attach).not.toHaveBeenCalled()
expect(window.api.ssh.connect).not.toHaveBeenCalled()
expect(window.api.ssh.needsPassphrasePrompt).not.toHaveBeenCalled()
})
it('fails closed when the same SSH worktree id is projected by two HUBs', async () => {
const { connectPanePty } = await import('./pty-connection')
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const { createIpcPtyTransport } = await import('./pty-transport')
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
worktreesByRepo: {
repo1: [
{
id: 'wt-1',
repoId: 'repo1',
path: '/srv/same-worktree',
hostId: 'ssh:same-private-target',
runtimeOwnerEnvironmentId: 'hub-a'
},
{
id: 'wt-1',
repoId: 'repo1',
path: '/srv/same-worktree',
hostId: 'ssh:same-private-target',
runtimeOwnerEnvironmentId: 'hub-b'
}
]
},
settings: {
...mockStoreState.settings,
activeRuntimeEnvironmentId: 'hub-a'
}
} as StoreState
connectPanePty(
createPane(1) as never,
createManager(1) as never,
createDeps({ restoredPtyIdByLeafId: { [LEAF_1]: null } }) as never
)
await flushAsyncTicks()
expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled()
expect(createIpcPtyTransport).not.toHaveBeenCalled()
expect(window.api.ssh.connect).not.toHaveBeenCalled()
expect(window.api.ssh.needsPassphrasePrompt).not.toHaveBeenCalled()
})
it('fails a missing paired-client owner closed instead of creating a local PTY', async () => {
const { connectPanePty } = await import('./pty-connection')
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const { createIpcPtyTransport } = await import('./pty-transport')
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
worktreesByRepo: {
repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/srv/stale-worktree' }]
},
runtimeEnvironments: [{ id: 'hub-a' }, { id: 'hub-b' }],
runtimeEnvironmentCatalogHydrated: true,
settings: { ...mockStoreState.settings, activeRuntimeEnvironmentId: 'hub-a' }
} as StoreState
connectPanePty(
createPane(1) as never,
createManager(1) as never,
createDeps({ restoredPtyIdByLeafId: { [LEAF_1]: null } }) as never
)
await flushAsyncTicks()
expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled()
expect(createIpcPtyTransport).not.toHaveBeenCalled()
expect(window.api.ssh.connect).not.toHaveBeenCalled()
})
it('spawns fresh PTYs through the worktree owner runtime when focus differs', async () => {
const { connectPanePty } = await import('./pty-connection')
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')

View File

@ -34,6 +34,8 @@ import type { PtyTransportRecoveryState } from './pty-transport-types'
import { createIpcPtyTransport } from './pty-transport'
import { createRemoteRuntimePtyTransport } from './remote-runtime-pty-transport'
import { toAgentLaunchPreferences } from '@/runtime/agent-session-create-operation'
import { createUnresolvedOwnerPtyTransport } from './unresolved-owner-pty-transport'
import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route'
import { getConnectionId } from '@/lib/connection-context'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import {
@ -124,8 +126,8 @@ import {
registerPtySerializer,
registerPtyTitleSource
} from './pty-buffer-serializer'
import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection'
import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
import {
discardTerminalOutput,
flushTerminalOutput,
@ -231,8 +233,7 @@ import {
import { resolveHiddenRestoreScrollbackRows } from './terminal-hidden-restore-scrollback'
import {
getExecutionHostIdForWorktree,
getSettingsForWorktreeRuntimeOwner,
getRuntimeEnvironmentIdForWorktree
getSettingsForWorktreeRuntimeOwner
} from '@/lib/worktree-runtime-owner'
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup'
@ -3199,15 +3200,47 @@ export function connectPanePty(
// use the shared resolver instead of only looking up repo-backed worktrees.
const worktree = getWorktreeMapFromState(state).get(deps.worktreeId)
const worktreeConnectionId = getConnectionId(deps.worktreeId)
const connectionId = worktreeConnectionId ?? null
const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find((t) => t.id === deps.tabId)
const restoredPtyIdForTransport =
deps.restoredLeafId && deps.restoredPtyIdByLeafId
? (deps.restoredPtyIdByLeafId[deps.restoredLeafId] ?? null)
: null
const operationRouteResolution = resolveWorktreeOperationRouteResult(state, deps.worktreeId)
const explicitRuntimeEnvironmentId =
operationRouteResolution.kind === 'resolved'
? operationRouteResolution.route.runtimeEnvironmentId
: null
// Why: paired-web worktrees retain HUB execution identity; their runtime-scoped mirrored pane is the session-level transport owner.
const mirroredRuntimeOwners = new Set(
isWebTerminalSurfaceTabId(deps.tabId)
? [restoredPtyIdForTransport, tab?.ptyId]
.map((ptyId) => (ptyId ? getRemoteRuntimePtyEnvironmentId(ptyId) : null))
.filter((environmentId): environmentId is string => Boolean(environmentId))
: []
)
const mirroredRuntimeEnvironmentId = mirroredRuntimeOwners.values().next().value ?? null
const terminalOwnerUnresolved =
mirroredRuntimeOwners.size > 1 ||
(operationRouteResolution.kind !== 'resolved' && !mirroredRuntimeEnvironmentId)
const runtimeEnvironmentId = explicitRuntimeEnvironmentId
? explicitRuntimeEnvironmentId
: mirroredRuntimeEnvironmentId
? mirroredRuntimeEnvironmentId
: null
// Why: an SSH host nested under a HUB is execution identity, not permission for the paired client to dial that host.
const connectionId =
!terminalOwnerUnresolved && runtimeEnvironmentId === null
? (worktreeConnectionId ?? null)
: null
const shellOverride = tab?.shellOverride
// Why: a serve/remote-runtime pane has no SSH connectionId and a Linux cwd, so
// the native-Windows ConPTY heuristic misfires on a Windows client and wrongly
// enables ConPTY synchronized-output protection, which strips an agent's
// transient cursor-show (?25h) and leaves the cursor invisible. The execution
// host is the authoritative signal: only a 'local' host is a local native PTY.
const executionHostId = getExecutionHostIdForWorktree(state, deps.worktreeId)
const executionHostId = terminalOwnerUnresolved
? ('runtime:unresolved-owner' as const)
: getExecutionHostIdForWorktree(state, deps.worktreeId)
const isNativeWindowsConpty = isLocalNativeWindowsConpty({
userAgent: navigator.userAgent,
connectionId,
@ -3258,16 +3291,6 @@ export function connectPanePty(
})
}
const restoredPtyIdForTransport =
deps.restoredLeafId && deps.restoredPtyIdByLeafId
? (deps.restoredPtyIdByLeafId[deps.restoredLeafId] ?? null)
: null
const remoteRuntimeOwnerForTransport =
(restoredPtyIdForTransport
? getRemoteRuntimePtyEnvironmentId(restoredPtyIdForTransport)
: null) ?? (tab?.ptyId ? getRemoteRuntimePtyEnvironmentId(tab.ptyId) : null)
const runtimeEnvironmentId =
remoteRuntimeOwnerForTransport ?? getRuntimeEnvironmentIdForWorktree(state, deps.worktreeId)
const localWindowsTerminalCapabilities = hasCachedWindowsTerminalCapabilities()
? getCachedWindowsTerminalCapabilities()
: null
@ -3372,6 +3395,7 @@ export function connectPanePty(
? undefined
: paneStartup?.startupCommandDelivery,
connectionId,
executionHostId,
worktreeId: deps.worktreeId,
// Why: closes the SIGKILL race documented in INVESTIGATION.md by letting
// main sync-flush the (worktreeId, tabId, leafId → ptyId) binding before
@ -3488,9 +3512,13 @@ export function connectPanePty(
}
: {})
}
const transport = runtimeEnvironmentId
? createRemoteRuntimePtyTransport(runtimeEnvironmentId, transportOptions)
: createIpcPtyTransport(transportOptions)
const transport = terminalOwnerUnresolved
? createUnresolvedOwnerPtyTransport(
'Workspace identity is ambiguous across hosts. Refresh projects and try again.'
)
: runtimeEnvironmentId
? createRemoteRuntimePtyTransport(runtimeEnvironmentId, transportOptions)
: createIpcPtyTransport(transportOptions)
const canSendDesktopQueryReply = (): boolean => {
const ptyId = transport.getPtyId()
return !ptyId || !isPtyLocked(ptyId)
@ -7777,6 +7805,12 @@ export function connectPanePty(
restoredSessionId && restoredSessionId !== detachedLivePtyId
? restoredSessionId
: detachedLivePtyId
const runtimeHostPtyWakeHint =
runtimeEnvironmentId &&
candidateReattachSessionId &&
!isRemoteRuntimePtyId(candidateReattachSessionId)
? candidateReattachSessionId
: null
const sleptRemoteColdRestoreStartup = sleptRemoteRuntimeSessionId
? buildColdRestoreAgentResumeStartup()
: null
@ -7801,12 +7835,13 @@ export function connectPanePty(
// Why: after a daemon crash + cold restore, a stale session-to-tab mapping can make a tab hold a ptyId from another worktree.
// Restoring it would paint the wrong terminal content, so drop the reattach and spawn fresh.
const deferredReattachSessionId =
candidateReattachSessionId &&
runtimeHostPtyWakeHint ??
(candidateReattachSessionId &&
!isRemoteRuntimePtyId(candidateReattachSessionId) &&
!candidateHasEagerBuffer &&
isSessionOwnedByWorktree(candidateReattachSessionId, deps.worktreeId)
? candidateReattachSessionId
: null
: null)
recordPtyConnectDiagnostic(
`pane=${pane.id} tab=${deps.tabId} restored=${restoredPtyId} existing=${existingPtyId} detached=${detachedRemoteLeafPtyId ?? detachedLivePtyId} reattach=${deferredReattachSessionId} hasTransport=${hadExistingPaneTransportAtConnect} pendingKey=${pendingSpawnKey}`
)

View File

@ -12,6 +12,7 @@ import type { ProjectExecutionRuntimeResolution } from '../../../../shared/proje
import type { EventProps } from '../../../../shared/telemetry-events'
import type { TerminalOscColorQueryReplyColors } from '../../../../shared/terminal-osc-color-reply'
import type { TuiAgent } from '../../../../shared/types'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { PtyDataMeta } from './pty-dispatcher'
export type PtyBufferSnapshot = {
@ -152,6 +153,10 @@ export type PtyTransport = {
/** The runtime captured by this transport; legacy remote PTY ids do not
* encode their owner, and current worktree settings may have changed. */
getRuntimeEnvironmentId?: () => string | null
/** Execution host captured at spawn; nested SSH differs from its outer runtime owner. */
getExecutionHostId?: () => ExecutionHostId | null
/** Host platform captured by the PTY owner; paired-client OS is not authoritative. */
getRemotePlatform?: () => NodeJS.Platform | null
getLocalSessionMetadata?: () => LocalPtySessionMetadata | null
/** Drop cross-chunk parser carries (partial OSC-9999 prefix). Called when a
* model-restore marker reports dropped bytes a carry spanning the gap
@ -179,6 +184,7 @@ export type IpcPtyTransportOptions = {
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
connectionId?: string | null
executionHostId?: ExecutionHostId | null
worktreeId?: string
tabId?: string
leafId?: string

View File

@ -23,7 +23,17 @@ describe('remote transport sendInputImmediate (#7329)', () => {
vi.clearAllMocks()
subscriptionCallbacks = null
subscriptionSendBinary.mockReset()
runtimeCall.mockResolvedValue({ ok: true, result: { terminal: { handle: 'terminal-1' } } })
runtimeCall.mockResolvedValue({
ok: true,
result: {
terminal: {
handle: 'terminal-1',
tabId: 'tab-1',
leafId: 'pane:1',
worktreeId: 'wt-1'
}
}
})
runtimeSubscribe.mockImplementation(
async (_args: unknown, callbacks: typeof subscriptionCallbacks) => {
subscriptionCallbacks = callbacks
@ -58,6 +68,7 @@ describe('remote transport sendInputImmediate (#7329)', () => {
rows: 24,
callbacks: {}
})
await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalled())
// Typed input: debounced — nothing sent before the 8ms flush.
expect(transport.sendInput('yes')).toBe(true)
@ -92,6 +103,7 @@ describe('remote transport sendInputImmediate (#7329)', () => {
rows: 24,
callbacks: {}
})
await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalled())
expect(transport.sendInputImmediate('\x1b[3;1R')).toBe(true) // CPR reply
await Promise.resolve()
@ -120,6 +132,7 @@ describe('remote transport sendInputImmediate (#7329)', () => {
rows: 24,
callbacks: {}
})
await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalled())
// A paste above CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS forces the async
// validation path, so its bytes are captured in validationTail, not pending.

View File

@ -32,7 +32,17 @@ describe('remote transport snapshot escape-tail threading (#7329)', () => {
vi.clearAllMocks()
subscriptionCallbacks = null
subscriptionSendBinary.mockReset()
runtimeCall.mockResolvedValue({ ok: true, result: { terminal: { handle: 'terminal-1' } } })
runtimeCall.mockResolvedValue({
ok: true,
result: {
terminal: {
handle: 'terminal-1',
tabId: 'tab-1',
leafId: 'pane:1',
worktreeId: 'wt-1'
}
}
})
runtimeSubscribe.mockImplementation(
async (_args: unknown, callbacks: typeof subscriptionCallbacks) => {
subscriptionCallbacks = callbacks

Some files were not shown because too many files have changed in this diff Show More