From d4a55f583e880b6bbbbca11faa0a45d88646d3a6 Mon Sep 17 00:00:00 2001
From: OrcaWin
Date: Sun, 11 Oct 2026 21:53:57 -0700
Subject: [PATCH] fix(ssh): end an older Orca's relay terminals from the
connect refusal (STA-10166) (#27748)
* fix(ssh): end an older Orca's relay terminals from the refusal, and name the command
* test: type confirmation mocks
* fix(ssh): match old relay daemons by argv position; owner-check the Windows command
* fix(ssh): select Windows relay daemons by argv position in the manual command
* fix(ssh): keep the relay daemon marker local so census mocks stay intact
---------
Co-authored-by: m4air
---
src/main/ipc/ssh-connection-handlers.ts | 5 +
.../ipc/ssh-old-relay-terminals-handler.ts | 43 ++++
src/main/ipc/ssh.ts | 1 +
.../ssh/ssh-host-relay-census-for-target.ts | 11 +
.../ssh/ssh-host-relay-endpoint-census.ts | 8 +-
...sh-host-relay-terminals-on-connect.test.ts | 11 +
.../ssh-host-relay-terminals-on-connect.ts | 70 ++++--
.../ssh/ssh-host-server-on-connect.test.ts | 11 +
src/main/ssh/ssh-host-server-on-connect.ts | 7 +-
src/main/ssh/ssh-host-unserved-status.test.ts | 30 +++
src/main/ssh/ssh-host-unserved-status.ts | 16 +-
...ld-relay-daemons-shell.integration.test.ts | 201 ++++++++++++++++++
src/main/ssh/ssh-old-relay-daemons.test.ts | 53 +++++
src/main/ssh/ssh-old-relay-daemons.ts | 201 ++++++++++++++++++
.../ssh/ssh-old-relay-terminals-end.test.ts | 83 ++++++++
src/main/ssh/ssh-old-relay-terminals-end.ts | 56 +++++
src/preload/api/ssh-api.ts | 3 +
src/preload/api/ssh-bridge.ts | 4 +
.../src/components/settings/SshPane.tsx | 13 +-
.../settings/SshTargetServerStatus.tsx | 28 +++
.../sidebar/WorktreeCardSshHostControl.tsx | 23 +-
.../status-bar/SshTargetStatusRow.tsx | 15 +-
.../TerminalSshReconnectOverlay.tsx | 73 ++++---
src/renderer/src/i18n/locales/en.json | 13 +-
src/renderer/src/i18n/locales/es.json | 13 +-
src/renderer/src/i18n/locales/fr.json | 13 +-
src/renderer/src/i18n/locales/ja.json | 13 +-
src/renderer/src/i18n/locales/ko.json | 13 +-
src/renderer/src/i18n/locales/zh.json | 13 +-
.../ssh/ssh-host-server-status-copy.test.ts | 50 ++++-
.../src/ssh/ssh-host-server-status-copy.ts | 45 +++-
.../ssh-old-relay-terminals-action.test.tsx | 171 +++++++++++++++
.../src/ssh/ssh-old-relay-terminals-action.ts | 135 ++++++++++++
.../ssh-old-relay-processes-admission.ts | 18 ++
src/shared/ssh-old-relay-stop-command.test.ts | 41 ++++
src/shared/ssh-old-relay-stop-command.ts | 27 +++
.../ssh-retained-payload-admission.test.ts | 19 ++
src/shared/ssh-retained-payload-admission.ts | 4 +-
src/shared/ssh-types.ts | 12 ++
39 files changed, 1480 insertions(+), 86 deletions(-)
create mode 100644 src/main/ipc/ssh-old-relay-terminals-handler.ts
create mode 100644 src/main/ssh/ssh-host-unserved-status.test.ts
create mode 100644 src/main/ssh/ssh-old-relay-daemons-shell.integration.test.ts
create mode 100644 src/main/ssh/ssh-old-relay-daemons.test.ts
create mode 100644 src/main/ssh/ssh-old-relay-daemons.ts
create mode 100644 src/main/ssh/ssh-old-relay-terminals-end.test.ts
create mode 100644 src/main/ssh/ssh-old-relay-terminals-end.ts
create mode 100644 src/renderer/src/ssh/ssh-old-relay-terminals-action.test.tsx
create mode 100644 src/renderer/src/ssh/ssh-old-relay-terminals-action.ts
create mode 100644 src/shared/ssh-old-relay-processes-admission.ts
create mode 100644 src/shared/ssh-old-relay-stop-command.test.ts
create mode 100644 src/shared/ssh-old-relay-stop-command.ts
diff --git a/src/main/ipc/ssh-connection-handlers.ts b/src/main/ipc/ssh-connection-handlers.ts
index dce8bf94c76..06512edc87e 100644
--- a/src/main/ipc/ssh-connection-handlers.ts
+++ b/src/main/ipc/ssh-connection-handlers.ts
@@ -10,6 +10,7 @@ import { connectionManager } from './ssh-ipc-context'
import { getPublicSshState } from './ssh-renderer-broadcast'
import { disconnectRegisteredSshTarget } from './ssh-session-teardown'
import { errorMessage } from '../../shared/error-message'
+import { endOldRelayTerminalsForTarget } from './ssh-old-relay-terminals-handler'
export function registerSshConnectionHandlers(): void {
setSshTargetRegistryHandlers({
@@ -21,6 +22,10 @@ export function registerSshConnectionHandlers(): void {
return connectTarget(args.targetId)
})
+ ipcMain.handle('ssh:endOldRelayTerminals', (_event, args: { targetId: string }) =>
+ endOldRelayTerminalsForTarget(args.targetId)
+ )
+
ipcMain.handle('ssh:disconnect', async (_event, args: { targetId: string }) => {
await disconnectRegisteredSshTarget(args.targetId)
})
diff --git a/src/main/ipc/ssh-old-relay-terminals-handler.ts b/src/main/ipc/ssh-old-relay-terminals-handler.ts
new file mode 100644
index 00000000000..d4fc221b78b
--- /dev/null
+++ b/src/main/ipc/ssh-old-relay-terminals-handler.ts
@@ -0,0 +1,43 @@
+/** `ssh:endOldRelayTerminals`: stop an older Orca's relay terminals on a host, then reconnect. */
+import type { SshEndOldRelayTerminalsResult } from '../../shared/ssh-types'
+import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry'
+import { assertSshConnectsNotFenced, connectInFlight } from './ssh-connect-attempt-registry'
+import { connectTarget } from './ssh-connect-flow'
+
+const endInFlight = new Map>()
+
+export function endOldRelayTerminalsForTarget(
+ targetId: string
+): Promise {
+ const existing = endInFlight.get(targetId)
+ if (existing) {
+ return existing
+ }
+ const target = getSshTargetRegistryStore()!.getTarget(targetId)
+ if (!target) {
+ return Promise.reject(new Error(`SSH target "${targetId}" not found`))
+ }
+ const run = (async () => {
+ // A connect still deciding owns the transport; its refusal is what this action answers.
+ await connectInFlight.get(targetId)?.promise.catch(() => {})
+ assertSshConnectsNotFenced()
+ // Why lazy: the census graph loads only when a host needs it, as on connect.
+ const [census, transport, daemons, action] = await Promise.all([
+ import('../ssh/ssh-host-relay-terminals-on-connect'),
+ import('../ssh/ssh-host-relay-census-for-target'),
+ import('../ssh/ssh-old-relay-daemons'),
+ import('../ssh/ssh-old-relay-terminals-end')
+ ])
+ return action.endOldRelayTerminals({
+ onHost: (runOnHost) => transport.runOnSshHostCensusTransport(target, runOnHost),
+ census: (conn) => census.censusSshHostRelaysBeforeSession(conn, targetId),
+ remoteHome: async (conn) => (await census.readCensusHost(conn))?.remoteHome ?? null,
+ stop: (conn, remoteHome, pids) => daemons.endOldRelayDaemons(conn, remoteHome, pids),
+ connect: () => connectTarget(targetId)
+ })
+ })().finally(() => {
+ endInFlight.delete(targetId)
+ })
+ endInFlight.set(targetId, run)
+ return run
+}
diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts
index 2dcbcc046e8..812519eb5f6 100644
--- a/src/main/ipc/ssh.ts
+++ b/src/main/ipc/ssh.ts
@@ -68,6 +68,7 @@ const SSH_IPC_CHANNELS = [
'ssh:getState',
'ssh:needsPassphrasePrompt',
'ssh:testConnection',
+ 'ssh:endOldRelayTerminals',
'ssh:addPortForward',
'ssh:updatePortForward',
'ssh:removePortForward',
diff --git a/src/main/ssh/ssh-host-relay-census-for-target.ts b/src/main/ssh/ssh-host-relay-census-for-target.ts
index a8bd0a78ba6..6ec767cb36b 100644
--- a/src/main/ssh/ssh-host-relay-census-for-target.ts
+++ b/src/main/ssh/ssh-host-relay-census-for-target.ts
@@ -1,4 +1,5 @@
import type { SshTarget } from '../../shared/ssh-types'
+import type { SshConnection } from './ssh-connection'
import type { CensusHostRelayTerminals } from './orcad-migration-terminal-gate'
import { requireManagedOrcadInfrastructure } from './orcad-managed-runtime-context'
import {
@@ -24,6 +25,16 @@ export function censusHostRelayTerminalsFor(target: SshTarget): CensusHostRelayT
return () => (currentSshOwner() ? census() : censusOutsideConnect(target, census))
}
+/** Runs on a transport to the host that is closed afterwards unless a connect adopted it. */
+export function runOnSshHostCensusTransport(
+ target: SshTarget,
+ run: (conn: SshConnection) => Promise
+): Promise {
+ return censusOutsideConnect(target, async () =>
+ run(await requireManagedOrcadInfrastructure().connectionManager.connect(target))
+ )
+}
+
async function censusOutsideConnect(target: SshTarget, census: () => Promise): Promise {
const owner = Symbol(target.id)
const end = beginSshHostCensus(target.id)
diff --git a/src/main/ssh/ssh-host-relay-endpoint-census.ts b/src/main/ssh/ssh-host-relay-endpoint-census.ts
index 48d52ee2045..3d9d54e9f93 100644
--- a/src/main/ssh/ssh-host-relay-endpoint-census.ts
+++ b/src/main/ssh/ssh-host-relay-endpoint-census.ts
@@ -6,6 +6,7 @@
* Loss of contact is never evidence of exit (docs/reference/ssh-execution-boundary.md): a listing
* that failed, ran out of room, or an endpoint the probe could not classify is `unverifiable`.
*/
+import type { SshOldRelayProcesses } from '../../shared/ssh-types'
import type { SshConnection } from './ssh-connection'
import { shellEscape } from './ssh-connection-utils'
import { RELAY_REMOTE_DIR } from './relay-protocol'
@@ -20,7 +21,12 @@ import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platf
/** `unenumerable`: Windows named pipes cannot be listed, so the caller keeps today's path. */
export type HostRelayEndpointVerdict = 'none' | 'idle' | 'live' | 'unverifiable' | 'unenumerable'
-export type HostRelayEndpointCensus = { verdict: HostRelayEndpointVerdict; count: number }
+export type HostRelayEndpointCensus = {
+ verdict: HostRelayEndpointVerdict
+ count: number
+ /** Set when the census blocks: the older Orca's relay processes, for the stop action. */
+ oldRelays?: SshOldRelayProcesses
+}
const MAX_CENSUS_ENDPOINTS = 32
diff --git a/src/main/ssh/ssh-host-relay-terminals-on-connect.test.ts b/src/main/ssh/ssh-host-relay-terminals-on-connect.test.ts
index 79c3d1e334d..2151670dc73 100644
--- a/src/main/ssh/ssh-host-relay-terminals-on-connect.test.ts
+++ b/src/main/ssh/ssh-host-relay-terminals-on-connect.test.ts
@@ -53,6 +53,17 @@ describe('the connect-time relay terminal verdict', () => {
await expect(decide(census).verdict).resolves.toMatchObject({ verdict: 'unverifiable' })
})
+ it('passes the old relay processes a blocking census found to the verdict', async () => {
+ const oldRelays = { hostOs: 'posix' as const, pids: [4101, 4102] }
+ await expect(decide({ verdict: 'live', count: 2, oldRelays }).verdict).resolves.toMatchObject({
+ verdict: 'live',
+ oldRelays
+ })
+ await expect(
+ decide({ verdict: 'unverifiable', count: 1, oldRelays }).verdict
+ ).resolves.toMatchObject({ verdict: 'unverifiable', oldRelays })
+ })
+
// A lease an older build left proves nothing; the host census decides.
it('lets an idle host census prove a lease an older build left', async () => {
const { censusHost, verdict } = decide(
diff --git a/src/main/ssh/ssh-host-relay-terminals-on-connect.ts b/src/main/ssh/ssh-host-relay-terminals-on-connect.ts
index d304b2d6fab..2e989f9f8b2 100644
--- a/src/main/ssh/ssh-host-relay-terminals-on-connect.ts
+++ b/src/main/ssh/ssh-host-relay-terminals-on-connect.ts
@@ -14,7 +14,13 @@ import { execCommand } from './ssh-relay-deploy-helpers'
import { readRemoteHomeCommand } from './ssh-remote-commands'
import { resolveRemoteNodePath } from './ssh-remote-node-resolution'
import { detectRemoteHostPlatform } from './ssh-remote-platform-detection'
-import { isWindowsRemoteHost, normalizeRemoteHome, validateRemoteHome } from './ssh-remote-platform'
+import {
+ isWindowsRemoteHost,
+ normalizeRemoteHome,
+ validateRemoteHome,
+ type RemoteHostPlatform
+} from './ssh-remote-platform'
+import { listOldRelayDaemonPids } from './ssh-old-relay-daemons'
import {
assessOrcadMigrationTerminals,
terminalsRunElsewhere,
@@ -26,15 +32,19 @@ export async function relayTerminalsOnConnect(args: {
targetId: string
censusHost: () => Promise
}): Promise {
- const proof = await assessOrcadMigrationTerminals(args.store, args.targetId, async () =>
- hostTerminalProofFromCensus(await args.censusHost())
- )
+ let oldRelays: HostRelayEndpointCensus['oldRelays']
+ const proof = await assessOrcadMigrationTerminals(args.store, args.targetId, async () => {
+ const census = await args.censusHost()
+ oldRelays = census.oldRelays
+ return hostTerminalProofFromCensus(census)
+ })
return proof.verdict === 'exited'
? { verdict: 'exited', count: 0 }
: {
verdict: proof.verdict,
count: proof.ptyIds.length || (proof.hostTerminals ?? 0),
- ...(terminalsRunElsewhere(proof) ? { elsewhere: true } : {})
+ ...(terminalsRunElsewhere(proof) ? { elsewhere: true } : {}),
+ ...(oldRelays ? { oldRelays } : {})
}
}
@@ -48,37 +58,61 @@ export function hostTerminalProofFromCensus(
return { verdict: census.verdict === 'live' ? 'live' : 'unverifiable', count: census.count }
}
+/** The host's platform and home, read the way every census reads them; null when unproven. */
+export async function readCensusHost(
+ conn: SshConnection,
+ signal?: AbortSignal
+): Promise<{ host: RemoteHostPlatform; remoteHome: string } | null> {
+ const host = await detectRemoteHostPlatform(conn, { signal })
+ if (!host) {
+ return null
+ }
+ const remoteHome = normalizeRemoteHome(
+ await execCommand(conn, readRemoteHomeCommand(host), {
+ signal,
+ wrapCommand: !isWindowsRemoteHost(host)
+ }),
+ host
+ )
+ return validateRemoteHome(remoteHome, host) ? { host, remoteHome } : null
+}
+
/** The census over the connect's bootstrap connection, before any relay session exists. */
export async function censusSshHostRelaysBeforeSession(
conn: SshConnection,
targetId: string,
signal?: AbortSignal
): Promise {
- const host = await detectRemoteHostPlatform(conn, { signal })
- if (!host) {
+ const read = await readCensusHost(conn, signal)
+ if (!read) {
return { verdict: 'unverifiable', count: 0 }
}
- const windows = isWindowsRemoteHost(host)
- const remoteHome = normalizeRemoteHome(
- await execCommand(conn, readRemoteHomeCommand(host), { signal, wrapCommand: !windows }),
- host
- )
- if (!validateRemoteHome(remoteHome, host)) {
- return { verdict: 'unverifiable', count: 0 }
- }
- if (windows) {
- return censusWindowsHostRelays(conn, {
+ const { host, remoteHome } = read
+ if (isWindowsRemoteHost(host)) {
+ const census = await censusWindowsHostRelays(conn, {
host,
remoteHome,
targetId,
nodePath: () => windowsCensusNodePath(conn, host, remoteHome, signal),
signal
})
+ return blocksConversion(census)
+ ? { ...census, oldRelays: { hostOs: 'windows', pids: [] } }
+ : census
}
- return censusHostRelayEndpoints(conn, {
+ const census = await censusHostRelayEndpoints(conn, {
host,
remoteHome,
fallbackNodePath: () => resolveRemoteNodePath(conn, host, { signal }),
signal
})
+ if (!blocksConversion(census)) {
+ return census
+ }
+ const pids = await listOldRelayDaemonPids(conn, remoteHome, signal)
+ return { ...census, oldRelays: { hostOs: 'posix', pids: pids ?? [] } }
+}
+
+function blocksConversion(census: HostRelayEndpointCensus): boolean {
+ return census.verdict !== 'none' && census.verdict !== 'idle'
}
diff --git a/src/main/ssh/ssh-host-server-on-connect.test.ts b/src/main/ssh/ssh-host-server-on-connect.test.ts
index 67fb21e49e3..e18a227727a 100644
--- a/src/main/ssh/ssh-host-server-on-connect.test.ts
+++ b/src/main/ssh/ssh-host-server-on-connect.test.ts
@@ -96,6 +96,17 @@ describe('which server an SSH host runs on connect', () => {
})
})
+ it('carries the old relay processes the census found into the refusal', async () => {
+ const oldRelays = { hostOs: 'posix' as const, pids: [4101] }
+ const d = deps({ relayTerminals: async () => ({ verdict: 'live', count: 1, oldRelays }) })
+ await expect(resolveHostServerOnConnect(target, d)).resolves.toEqual({
+ route: 'relay',
+ reason: 'relay_terminals_live',
+ terminals: 1,
+ oldRelays
+ })
+ })
+
it('surfaces a refusal for any other reason, naming it', async () => {
const d = deps({
convert: async () => ({
diff --git a/src/main/ssh/ssh-host-server-on-connect.ts b/src/main/ssh/ssh-host-server-on-connect.ts
index 016899e067d..c602ad21ec1 100644
--- a/src/main/ssh/ssh-host-server-on-connect.ts
+++ b/src/main/ssh/ssh-host-server-on-connect.ts
@@ -17,6 +17,7 @@ import type {
SshManagedServerRelayReason,
SshManagedServerServingNote,
SshManagedServerUpdateNote,
+ SshOldRelayProcesses,
SshTarget
} from '../../shared/ssh-types'
import type { OrcadManagedServing } from './orcad-managed-serving'
@@ -58,6 +59,7 @@ export type HostServerOnConnectResult =
detail?: string
terminals?: number
terminalsElsewhere?: boolean
+ oldRelays?: SshOldRelayProcesses
}
export type HostServerTerminalVerdict = {
@@ -66,6 +68,8 @@ export type HostServerTerminalVerdict = {
count: number
/** Counted only by the host-wide census: another desktop or session runs them. */
elsewhere?: boolean
+ /** The older Orca's relay processes the census found, for the stop action. */
+ oldRelays?: SshOldRelayProcesses
}
export type HostServerOnConnectDeps = {
@@ -208,7 +212,8 @@ async function convertHost(
route: 'relay',
reason: terminalReason(terminals.verdict),
terminals: terminals.count,
- ...(terminals.elsewhere ? { terminalsElsewhere: true } : {})
+ ...(terminals.elsewhere ? { terminalsElsewhere: true } : {}),
+ ...(terminals.oldRelays ? { oldRelays: terminals.oldRelays } : {})
}
}
trace.path = 'convert'
diff --git a/src/main/ssh/ssh-host-unserved-status.test.ts b/src/main/ssh/ssh-host-unserved-status.test.ts
new file mode 100644
index 00000000000..07aab4b0ae1
--- /dev/null
+++ b/src/main/ssh/ssh-host-unserved-status.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest'
+import { unservedHostServerMessage, unservedServerStatus } from './ssh-host-unserved-status'
+
+describe('an unserved host’s untranslated message', () => {
+ it('names the scoped stop command and the census pids for old relay terminals', () => {
+ const decision = {
+ route: 'relay' as const,
+ reason: 'relay_terminals_live' as const,
+ terminals: 2,
+ oldRelays: { hostOs: 'posix' as const, pids: [4101, 4102] }
+ }
+ const message = unservedHostServerMessage(decision)
+ expect(message).toContain('(process IDs 4101, 4102)')
+ expect(message).toContain(
+ 'pkill -u "$(id -u)" -f "^([^ ]*/)?node[^ /]* relay\\.js --detached .*--sock-path'
+ )
+ expect(message).not.toContain('pkill -f relay.js')
+ expect(unservedServerStatus(decision).oldRelays).toEqual(decision.oldRelays)
+ })
+
+ it('still names the command when the census could not prove them', () => {
+ const message = unservedHostServerMessage({
+ route: 'relay',
+ reason: 'relay_terminals_unverifiable',
+ oldRelays: { hostOs: 'windows', pids: [] }
+ })
+ expect(message).toContain('Get-CimInstance Win32_Process')
+ expect(message).not.toContain('process IDs')
+ })
+})
diff --git a/src/main/ssh/ssh-host-unserved-status.ts b/src/main/ssh/ssh-host-unserved-status.ts
index feada869c7e..0fc115bc536 100644
--- a/src/main/ssh/ssh-host-unserved-status.ts
+++ b/src/main/ssh/ssh-host-unserved-status.ts
@@ -1,5 +1,6 @@
/** What a connect publishes when the managed server doesn't serve the host; there is no fallback. */
-import type { SshManagedServerStatus } from '../../shared/ssh-types'
+import { oldRelayStopCommand } from '../../shared/ssh-old-relay-stop-command'
+import type { SshManagedServerStatus, SshOldRelayProcesses } from '../../shared/ssh-types'
import type { HostServerOnConnectResult } from './ssh-host-server-on-connect'
type UnservedDecision = Extract
@@ -13,7 +14,8 @@ export function unservedServerStatus(
reason: decision.reason,
...(decision.detail ? { detail: decision.detail } : {}),
...(decision.terminals !== undefined ? { terminals: decision.terminals } : {}),
- ...(decision.terminalsElsewhere ? { terminalsElsewhere: true } : {})
+ ...(decision.terminalsElsewhere ? { terminalsElsewhere: true } : {}),
+ ...(decision.oldRelays ? { oldRelays: decision.oldRelays } : {})
}
}
@@ -24,7 +26,7 @@ export function unservedHostServerMessage(decision: UnservedDecision): string {
return 'This SSH host isn’t supported by this version of Orca. To keep using it, install an older version of Orca.'
case 'relay_terminals_live':
case 'relay_terminals_unverifiable':
- return 'Terminals started by an older version of Orca are still running on this host, and they don’t stop on their own. Close them in that version of Orca or end them on the host, then reconnect.'
+ return `Terminals started by an older version of Orca are still running on this host, and they don’t stop on their own. Close them in that version of Orca or end them on the host, then reconnect.${oldRelayStopHint(decision.oldRelays)}`
case 'refused':
return decision.detail ?? 'Orca couldn’t set up its server on this host.'
case 'source_changed':
@@ -34,3 +36,11 @@ export function unservedHostServerMessage(decision: UnservedDecision): string {
return 'Orca couldn’t start its server on this host. Try connecting again.'
}
}
+
+function oldRelayStopHint(oldRelays: SshOldRelayProcesses | undefined): string {
+ if (!oldRelays) {
+ return ''
+ }
+ const pids = oldRelays.pids.length > 0 ? ` (process IDs ${oldRelays.pids.join(', ')})` : ''
+ return ` To end them on the host${pids}, run: ${oldRelayStopCommand(oldRelays.hostOs)}`
+}
diff --git a/src/main/ssh/ssh-old-relay-daemons-shell.integration.test.ts b/src/main/ssh/ssh-old-relay-daemons-shell.integration.test.ts
new file mode 100644
index 00000000000..00caf00b48c
--- /dev/null
+++ b/src/main/ssh/ssh-old-relay-daemons-shell.integration.test.ts
@@ -0,0 +1,201 @@
+/**
+ * The old-relay stop runs on someone else's machine and decides which pids are signalled, so the
+ * listing and the stop script run here through /bin/sh against real processes laid out the way
+ * an older Orca installed its relay.
+ */
+import { execFile, spawn, type ChildProcess } from 'node:child_process'
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
+import {
+ endOldRelayDaemonsCommand,
+ OLD_RELAY_DAEMON_LIST_COMMAND,
+ parseEndOldRelayDaemonsOutput,
+ parseOldRelayDaemonPids
+} from './ssh-old-relay-daemons'
+import { oldRelayStopCommand } from '../../shared/ssh-old-relay-stop-command'
+
+const posixOnly = process.platform === 'win32' ? describe.skip : describe
+
+const IDLE_SOURCE = `
+if (process.argv.includes('--ignore-term')) process.on('SIGTERM', () => {})
+setInterval(() => {}, 1000)
+setTimeout(() => process.exit(0), 60000)
+process.stdout.write('READY\\n')
+`
+
+function sh(script: string, env?: NodeJS.ProcessEnv): Promise {
+ return new Promise((resolve, reject) => {
+ execFile('/bin/sh', ['-c', script], { timeout: 30_000, env }, (error, stdout) => {
+ if (error) {
+ reject(error)
+ return
+ }
+ resolve(stdout)
+ })
+ })
+}
+
+let workDir: string
+let home: string
+const running: ChildProcess[] = []
+
+function startProcess(cwd: string, script: string, args: string[]): Promise {
+ const child = spawn(process.execPath, [script, ...args], {
+ cwd,
+ stdio: ['ignore', 'pipe', 'ignore']
+ })
+ running.push(child)
+ return new Promise((resolve, reject) => {
+ child.once('error', reject)
+ child.stdout?.once('data', () => resolve(child))
+ })
+}
+
+function isAlive(child: ChildProcess): boolean {
+ try {
+ process.kill(child.pid!, 0)
+ return true
+ } catch {
+ return false
+ }
+}
+
+async function waitForExit(child: ChildProcess): Promise {
+ if (child.exitCode !== null || child.signalCode !== null) {
+ return
+ }
+ await new Promise((resolve) => child.once('exit', resolve))
+}
+
+/** An install dir laid out like an older Orca's, with the daemon started from inside it. */
+function installRelay(version: string, file = 'relay.js'): string {
+ const dir = join(home, '.orca-remote', `relay-${version}`)
+ mkdirSync(dir, { recursive: true })
+ writeFileSync(join(dir, file), IDLE_SOURCE)
+ return dir
+}
+
+function startRelay(dir: string, sockPath: string, extra: string[] = []): Promise {
+ return startProcess(dir, 'relay.js', [
+ '--detached',
+ '--grace-time',
+ '0',
+ '--sock-path',
+ sockPath,
+ ...extra
+ ])
+}
+
+posixOnly('old relay daemons on a POSIX host', () => {
+ beforeAll(() => {
+ workDir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-old-relay-')))
+ home = join(workDir, 'home')
+ })
+
+ afterEach(() => {
+ for (const child of running.splice(0)) {
+ child.kill('SIGKILL')
+ }
+ })
+
+ afterAll(() => {
+ rmSync(workDir, { recursive: true, force: true })
+ })
+
+ it('lists and ends only relay daemons under the install dir, escalating to KILL', async () => {
+ const dir = installRelay('1.4.224')
+ const relay = await startRelay(dir, join(dir, 'relay-a.sock'))
+ const stubborn = await startRelay(dir, join(dir, 'relay-b.sock'), ['--ignore-term'])
+ // Decoys: a relay.js outside the install dir, a managed server under it, a bridge client.
+ const outsideDir = join(workDir, 'elsewhere')
+ mkdirSync(outsideDir, { recursive: true })
+ writeFileSync(join(outsideDir, 'relay.js'), IDLE_SOURCE)
+ const outside = await startRelay(outsideDir, join(outsideDir, 'relay-c.sock'))
+ const orcadDir = installRelay('orcad', 'orcad.js')
+ const orcad = await startProcess(orcadDir, 'orcad.js', [
+ '--sock-path',
+ join(dir, 'relay-a.sock')
+ ])
+ const bridge = await startProcess(dir, 'relay.js', [
+ '--connect',
+ '--sock-path',
+ join(dir, 'relay-a.sock')
+ ])
+
+ // A same-account shell whose script text mentions a relay launch, running unrelated work.
+ const shellDecoy = spawn(
+ '/bin/sh',
+ [
+ '-c',
+ `echo READY; : node relay.js --detached --sock-path ${join(dir, 'relay-a.sock')}; sleep 60; true`
+ ],
+ { stdio: ['ignore', 'pipe', 'ignore'] }
+ )
+ running.push(shellDecoy)
+ await new Promise((resolve) => shellDecoy.stdout?.once('data', resolve))
+
+ const listing = await sh(OLD_RELAY_DAEMON_LIST_COMMAND)
+ // The decoy is in the raw listing; only argv position keeps it out.
+ expect(listing).toMatch(new RegExp(`^\\s*${shellDecoy.pid}\\s`, 'm'))
+ const pids = parseOldRelayDaemonPids(listing, home)
+ expect(pids.sort()).toEqual([relay.pid, stubborn.pid].sort())
+ // The manual command the error text names matches the same processes (pgrep: list only).
+ const manual = oldRelayStopCommand('posix').replace(/^pkill /, 'pgrep ')
+ const manualPids = (await sh(`${manual} || true`, { ...process.env, HOME: home }))
+ .split('\n')
+ .filter(Boolean)
+ .map(Number)
+ expect(manualPids.sort()).toEqual([relay.pid, stubborn.pid].sort())
+
+ const result = parseEndOldRelayDaemonsOutput(
+ await sh(endOldRelayDaemonsCommand(home, pids)),
+ pids
+ )
+ expect(result.ended.sort()).toEqual([relay.pid, stubborn.pid].sort())
+ expect(result.stillRunning).toEqual([])
+ await waitForExit(relay)
+ await waitForExit(stubborn)
+ expect(isAlive(outside)).toBe(true)
+ expect(isAlive(orcad)).toBe(true)
+ expect(isAlive(bridge)).toBe(true)
+ expect(isAlive(shellDecoy)).toBe(true)
+ }, 30_000)
+
+ it('skips a shell whose script text names a relay launch, even when handed its pid', async () => {
+ const dir = installRelay('1.4.224')
+ const decoy = spawn(
+ '/bin/sh',
+ [
+ '-c',
+ `echo READY; : node relay.js --detached --sock-path ${join(dir, 'relay-z.sock')}; sleep 60; true`
+ ],
+ { stdio: ['ignore', 'pipe', 'ignore'] }
+ )
+ running.push(decoy)
+ await new Promise((resolve) => decoy.stdout?.once('data', resolve))
+
+ const result = parseEndOldRelayDaemonsOutput(
+ await sh(endOldRelayDaemonsCommand(home, [decoy.pid!])),
+ [decoy.pid!]
+ )
+ expect(result).toEqual({ ended: [], stillRunning: [], skipped: [decoy.pid] })
+ expect(isAlive(decoy)).toBe(true)
+ })
+
+ it('skips a pid that no longer runs an old relay instead of signalling it', async () => {
+ const decoyDir = join(workDir, 'decoy')
+ mkdirSync(decoyDir, { recursive: true })
+ writeFileSync(join(decoyDir, 'relay.js'), IDLE_SOURCE)
+ // Same argv shape, but its socket is not under this account's relay install dirs.
+ const decoy = await startRelay(decoyDir, join(decoyDir, 'relay-a.sock'))
+
+ const result = parseEndOldRelayDaemonsOutput(
+ await sh(endOldRelayDaemonsCommand(home, [decoy.pid!])),
+ [decoy.pid!]
+ )
+ expect(result).toEqual({ ended: [], stillRunning: [], skipped: [decoy.pid] })
+ expect(isAlive(decoy)).toBe(true)
+ })
+})
diff --git a/src/main/ssh/ssh-old-relay-daemons.test.ts b/src/main/ssh/ssh-old-relay-daemons.test.ts
new file mode 100644
index 00000000000..a713b5fbdab
--- /dev/null
+++ b/src/main/ssh/ssh-old-relay-daemons.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest'
+import {
+ endOldRelayDaemonsCommand,
+ parseEndOldRelayDaemonsOutput,
+ parseOldRelayDaemonPids
+} from './ssh-old-relay-daemons'
+
+const home = '/home/me'
+
+describe('old relay daemon pids', () => {
+ it('keeps only detached relays whose socket is under this account’s relay install dirs', () => {
+ const listing = [
+ ' 4101 /usr/bin/node relay.js --detached --grace-time 0 --sock-path /home/me/.orca-remote/relay-1.4.224/relay-ab.sock --credential-file x',
+ ' 4102 /home/me/.orca-remote/node/bin/node relay.js --detached --grace-time 0 --sock-path /tmp/.orca-relay-1000/relay-9f/relay-ab.sock',
+ // Another user's install, a relay outside the dirs, a bridge client, orcad, a traversal.
+ ' 4103 node relay.js --detached --sock-path /home/other/.orca-remote/relay-1/relay.sock',
+ ' 4104 node relay.js --detached --sock-path /srv/relay-1/relay.sock',
+ ' 4105 node relay.js --connect --sock-path /home/me/.orca-remote/relay-1/relay.sock',
+ ' 4106 node orcad.js --detached --sock-path /home/me/.orca-remote/relay-1/relay.sock',
+ ' 4107 node relay.js --detached --sock-path /home/me/.orca-remote/relay-1/../../x.sock',
+ ' 4108 grep -F -- relay.js --detached',
+ // A shell whose script text mentions a relay launch is not the daemon.
+ ' 4109 sh -c node relay.js --detached --sock-path /home/me/.orca-remote/relay-1/relay.sock & backup; wait',
+ ' 4110 /usr/bin/python3 relay.js --detached --sock-path /home/me/.orca-remote/relay-1/relay.sock',
+ ' 1 node relay.js --detached --sock-path /home/me/.orca-remote/relay-1/relay.sock'
+ ].join('\n')
+ expect(parseOldRelayDaemonPids(listing, home)).toEqual([4101, 4102])
+ })
+
+ it('re-verifies owner and argv before each signal and escalates TERM to KILL', () => {
+ const command = endOldRelayDaemonsCommand(home, [4101])
+ expect(command).toContain(`base='/home/me/.orca-remote'`)
+ expect(command).toContain('[ -n "$uid" ] && [ "$owner" = "$uid" ] || return 1')
+ expect(command).toContain('[ "$2" = relay.js ] && [ "$3" = --detached ] || return 1')
+ expect(command).toContain('kill -TERM "$pid"')
+ expect(command).toContain('is_old_relay "$pid" && kill -KILL "$pid"')
+ expect(command).not.toMatch(/pkill|killall|kill -9 -1/)
+ })
+
+ it('claims an end only for pids the host reported gone', () => {
+ expect(
+ parseEndOldRelayDaemonsOutput(
+ 'GONE 4101\nLIVE 4102\nSKIP 4103\nGONE 999\nDONE\n',
+ [4101, 4102, 4103]
+ )
+ ).toEqual({ ended: [4101], stillRunning: [4102], skipped: [4103] })
+ expect(parseEndOldRelayDaemonsOutput('GONE 4101\n', [4101])).toEqual({
+ ended: [],
+ stillRunning: [4101],
+ skipped: []
+ })
+ })
+})
diff --git a/src/main/ssh/ssh-old-relay-daemons.ts b/src/main/ssh/ssh-old-relay-daemons.ts
new file mode 100644
index 00000000000..ca59021bc2d
--- /dev/null
+++ b/src/main/ssh/ssh-old-relay-daemons.ts
@@ -0,0 +1,201 @@
+/**
+ * The relay daemons an older Orca left running on a POSIX SSH host, found and stopped by argv.
+ *
+ * A relay launches as ` relay.js --detached ... --sock-path ...` from its install
+ * directory, binding `` under `~/.orca-remote/relay-/` or, on long-`$HOME` hosts,
+ * `/tmp/.orca-relay-/relay-/`. Only a process of this account whose argv names
+ * both is ever signalled, and the host re-checks that identity right before each signal, so a
+ * recycled pid or any other process (orcad included) is left alone.
+ */
+import type { SshConnection } from './ssh-connection'
+import { shellEscape } from './ssh-connection-utils'
+import { RELAY_REMOTE_DIR } from './relay-protocol'
+import { SHORT_RELAY_SOCKET_DIR_PREFIX } from './relay-socket-path-limit'
+import { execCommand } from './ssh-relay-deploy-helpers'
+
+// Same marker the runtime census lists daemons by (ssh-relay-endpoint-runtime.ts).
+const RELAY_DAEMON_MARKER = ' relay.js --detached '
+const MAX_OLD_RELAY_PIDS = 64
+const TERM_GRACE_POLLS = 20
+const KILL_CONFIRM_POLLS = 8
+const POLL_SECONDS = '0.25'
+
+/** Same listing as the runtime census, with each daemon's pid. */
+export const OLD_RELAY_DAEMON_LIST_COMMAND =
+ `{ ps -eww -o pid= -o args= 2>/dev/null || ps -o pid= -o args= 2>/dev/null; } | ` +
+ `grep -F -- '${RELAY_DAEMON_MARKER.trim()}' || true`
+
+function isRelayInstallSocket(sockPath: string, remoteHome: string): boolean {
+ if (sockPath.split('/').includes('..')) {
+ return false
+ }
+ return (
+ sockPath.startsWith(`${remoteHome}/${RELAY_REMOTE_DIR}/relay-`) ||
+ new RegExp(`^${SHORT_RELAY_SOCKET_DIR_PREFIX.replace(/\./g, '\\.')}\\d+/relay-`).test(sockPath)
+ )
+}
+
+/**
+ * The daemon's own argv, by position: ` relay.js --detached ... --sock-path `. A shell
+ * whose command text merely mentions such a launch (`sh -c '...'`) has a different argv[1].
+ */
+function relayDaemonSocket(args: string[]): string | null {
+ const [node, script, mode] = args
+ if (
+ !node?.split('/').pop()?.startsWith('node') ||
+ script !== 'relay.js' ||
+ mode !== '--detached'
+ ) {
+ return null
+ }
+ const index = args.indexOf('--sock-path', 3)
+ return index > 0 ? (args[index + 1] ?? null) : null
+}
+
+/** Pids of relay daemons whose socket lives in this account's relay install directories. */
+export function parseOldRelayDaemonPids(output: string, remoteHome: string): number[] {
+ const pids: number[] = []
+ for (const line of output.split('\n')) {
+ const [rawPid, ...args] = line.trim().split(/\s+/)
+ const pid = Number.parseInt(rawPid ?? '', 10)
+ const sock = relayDaemonSocket(args)
+ if (pid > 1 && sock && isRelayInstallSocket(sock, remoteHome) && !pids.includes(pid)) {
+ pids.push(pid)
+ }
+ }
+ return pids.slice(0, MAX_OLD_RELAY_PIDS)
+}
+
+/** Null when the host could not be listed: no pids is then unknown, not none. */
+export async function listOldRelayDaemonPids(
+ conn: SshConnection,
+ remoteHome: string,
+ signal?: AbortSignal
+): Promise {
+ try {
+ const output = await execCommand(conn, OLD_RELAY_DAEMON_LIST_COMMAND, {
+ wrapCommand: true,
+ signal
+ })
+ return parseOldRelayDaemonPids(output, remoteHome)
+ } catch {
+ return null
+ }
+}
+
+/**
+ * TERM each verified relay, give its shutdown (which ends its shells) a grace period, then KILL
+ * the ones still there. Identity — owner uid, a ` relay.js --detached` argv, and a socket
+ * under the install dirs — is re-read from the host before every signal. Prints `GONE`/`LIVE`/`SKIP `.
+ */
+export function endOldRelayDaemonsCommand(remoteHome: string, pids: readonly number[]): string {
+ return [
+ `base=${shellEscape(`${remoteHome}/${RELAY_REMOTE_DIR}`)}`,
+ 'uid=$(id -u 2>/dev/null)',
+ '[ -n "$uid" ] || exit 0',
+ `short_base="${SHORT_RELAY_SOCKET_DIR_PREFIX}$uid"`,
+ "nl='",
+ "'",
+ // Why by argv position: a shell whose script text mentions a relay launch must never match.
+ 'is_old_relay() {',
+ ' if [ -r "/proc/$1/cmdline" ]; then',
+ ` args=$(tr '\\000' '\\n' < "/proc/$1/cmdline" 2>/dev/null)`,
+ ' owner=$(stat -c %u "/proc/$1" 2>/dev/null)',
+ ' split=$nl',
+ ' else',
+ ' args=$(ps -ww -o args= -p "$1" 2>/dev/null | tr "\\n" " ")',
+ ' owner=$(ps -o uid= -p "$1" 2>/dev/null | tr -d " \\n")',
+ " split=' '",
+ ' fi',
+ ' [ -n "$uid" ] && [ "$owner" = "$uid" ] || return 1',
+ ' old_ifs=$IFS; IFS=$split; set -f',
+ ' set -- $args',
+ ' set +f; IFS=$old_ifs',
+ ' case "${1##*/}" in node*) ;; *) return 1 ;; esac',
+ ' [ "$2" = relay.js ] && [ "$3" = --detached ] || return 1',
+ ' shift 3',
+ ' while [ $# -gt 1 ]; do',
+ ' if [ "$1" = --sock-path ]; then',
+ ' case "$2" in */../*) return 1 ;; "$base"/relay-*|"$short_base"/relay-*) return 0 ;; esac',
+ ' return 1',
+ ' fi',
+ ' shift',
+ ' done',
+ ' return 1',
+ '}',
+ 'owned=""',
+ `for pid in ${pids.map((pid) => shellEscape(String(pid))).join(' ')}; do`,
+ ' if is_old_relay "$pid"; then',
+ ' kill -TERM "$pid" 2>/dev/null',
+ ' owned="$owned $pid"',
+ ' else',
+ ' printf \'SKIP %s\\n\' "$pid"',
+ ' fi',
+ 'done',
+ 'still_running() {',
+ ' alive=""',
+ ' for pid in $owned; do kill -0 "$pid" 2>/dev/null && alive="$alive $pid"; done',
+ '}',
+ 'i=0',
+ 'still_running',
+ `while [ -n "$alive" ] && [ $i -lt ${TERM_GRACE_POLLS} ]; do`,
+ ` sleep ${POLL_SECONDS}`,
+ ' i=$((i+1))',
+ ' still_running',
+ 'done',
+ 'for pid in $alive; do',
+ ' is_old_relay "$pid" && kill -KILL "$pid" 2>/dev/null',
+ 'done',
+ 'i=0',
+ 'still_running',
+ `while [ -n "$alive" ] && [ $i -lt ${KILL_CONFIRM_POLLS} ]; do`,
+ ` sleep ${POLL_SECONDS}`,
+ ' i=$((i+1))',
+ ' still_running',
+ 'done',
+ 'for pid in $owned; do',
+ ' if kill -0 "$pid" 2>/dev/null; then printf \'LIVE %s\\n\' "$pid"; else printf \'GONE %s\\n\' "$pid"; fi',
+ 'done',
+ "printf 'DONE\\n'"
+ ].join('\n')
+}
+
+/** `stillRunning` includes every pid when the host never confirmed the run finished. */
+export type OldRelayStopResult = { ended: number[]; stillRunning: number[]; skipped: number[] }
+
+export function parseEndOldRelayDaemonsOutput(
+ output: string,
+ pids: readonly number[]
+): OldRelayStopResult {
+ const lines = output.split('\n').map((line) => line.trim())
+ if (!lines.includes('DONE')) {
+ return { ended: [], stillRunning: [...pids], skipped: [] }
+ }
+ const read = (state: string): number[] =>
+ lines
+ .filter((line) => line.startsWith(`${state} `))
+ .map((line) => Number.parseInt(line.slice(state.length + 1), 10))
+ .filter((pid) => pids.includes(pid))
+ return { ended: read('GONE'), stillRunning: read('LIVE'), skipped: read('SKIP') }
+}
+
+export async function endOldRelayDaemons(
+ conn: SshConnection,
+ remoteHome: string,
+ pids: readonly number[],
+ signal?: AbortSignal
+): Promise {
+ if (pids.length === 0) {
+ return { ended: [], stillRunning: [], skipped: [] }
+ }
+ try {
+ const output = await execCommand(conn, endOldRelayDaemonsCommand(remoteHome, pids), {
+ wrapCommand: true,
+ signal
+ })
+ return parseEndOldRelayDaemonsOutput(output, pids)
+ } catch {
+ // Loss of contact proves nothing ended; the reconnect's census decides.
+ return { ended: [], stillRunning: [...pids], skipped: [] }
+ }
+}
diff --git a/src/main/ssh/ssh-old-relay-terminals-end.test.ts b/src/main/ssh/ssh-old-relay-terminals-end.test.ts
new file mode 100644
index 00000000000..4fb8f1f6e28
--- /dev/null
+++ b/src/main/ssh/ssh-old-relay-terminals-end.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { SshConnection } from './ssh-connection'
+import type { HostRelayEndpointCensus } from './ssh-host-relay-endpoint-census'
+import { endOldRelayTerminals, type EndOldRelayTerminalsDeps } from './ssh-old-relay-terminals-end'
+
+// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deps never touch the connection; it is an opaque token here.
+const conn = {} as SshConnection
+
+function deps(
+ census: HostRelayEndpointCensus | (() => Promise),
+ connect: () => Promise = async () => null
+): EndOldRelayTerminalsDeps & {
+ stop: ReturnType>
+ connect: ReturnType>
+} {
+ return {
+ onHost: (run) => run(conn),
+ census: typeof census === 'function' ? census : async () => census,
+ remoteHome: async () => '/home/me',
+ stop: vi.fn(async (_conn, _home, pids) => ({
+ ended: pids,
+ stillRunning: [],
+ skipped: []
+ })),
+ connect: vi.fn(connect)
+ }
+}
+
+describe('endOldRelayTerminals', () => {
+ it('ends exactly the relay pids a live census found, then reconnects', async () => {
+ const d = deps({
+ verdict: 'live',
+ count: 2,
+ oldRelays: { hostOs: 'posix', pids: [4101, 4102] }
+ })
+
+ await expect(endOldRelayTerminals(d)).resolves.toEqual({ ended: 2, connected: true })
+ expect(d.stop).toHaveBeenCalledWith(conn, '/home/me', [4101, 4102])
+ expect(d.connect).toHaveBeenCalledTimes(1)
+ expect(d.stop.mock.invocationCallOrder[0]).toBeLessThan(d.connect.mock.invocationCallOrder[0])
+ })
+
+ it('stops nothing when the census is unverifiable; the reconnect republishes the refusal', async () => {
+ const d = deps(
+ { verdict: 'unverifiable', count: 1, oldRelays: { hostOs: 'posix', pids: [4101] } },
+ async () => {
+ throw new Error('Terminals started by an older version of Orca are still running')
+ }
+ )
+
+ await expect(endOldRelayTerminals(d)).resolves.toEqual({
+ ended: 0,
+ connected: false,
+ error: 'Terminals started by an older version of Orca are still running'
+ })
+ expect(d.stop).not.toHaveBeenCalled()
+ expect(d.connect).toHaveBeenCalledTimes(1)
+ })
+
+ it('never signals a Windows host', async () => {
+ const d = deps({ verdict: 'live', count: 1, oldRelays: { hostOs: 'windows', pids: [] } })
+
+ await endOldRelayTerminals(d)
+ expect(d.stop).not.toHaveBeenCalled()
+ })
+
+ it('stops nothing when the live census could not name any relay process', async () => {
+ const d = deps({ verdict: 'live', count: 1, oldRelays: { hostOs: 'posix', pids: [] } })
+
+ await endOldRelayTerminals(d)
+ expect(d.stop).not.toHaveBeenCalled()
+ })
+
+ it('still reconnects when the census itself fails', async () => {
+ const d = deps(async () => {
+ throw new Error('transport closed')
+ })
+
+ await expect(endOldRelayTerminals(d)).resolves.toEqual({ ended: 0, connected: true })
+ expect(d.stop).not.toHaveBeenCalled()
+ expect(d.connect).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/main/ssh/ssh-old-relay-terminals-end.ts b/src/main/ssh/ssh-old-relay-terminals-end.ts
new file mode 100644
index 00000000000..19496e00acf
--- /dev/null
+++ b/src/main/ssh/ssh-old-relay-terminals-end.ts
@@ -0,0 +1,56 @@
+/**
+ * The user's "End old terminals on this host": stop the relay daemons an older Orca left running,
+ * then reconnect so the connect's own census proves them gone and converts the host.
+ *
+ * Only a fresh census that proves relay terminals `live` authorizes the stop; `unverifiable`
+ * stops nothing and the reconnect republishes its refusal with the manual command
+ * (docs/reference/ssh-execution-boundary.md). Windows hosts are never signalled from here.
+ */
+import type { SshEndOldRelayTerminalsResult } from '../../shared/ssh-types'
+import { errorMessage } from '../../shared/error-message'
+import type { SshConnection } from './ssh-connection'
+import type { HostRelayEndpointCensus } from './ssh-host-relay-endpoint-census'
+import type { OldRelayStopResult } from './ssh-old-relay-daemons'
+
+export type EndOldRelayTerminalsDeps = {
+ onHost: (run: (conn: SshConnection) => Promise) => Promise
+ census: (conn: SshConnection) => Promise
+ remoteHome: (conn: SshConnection) => Promise
+ stop: (conn: SshConnection, remoteHome: string, pids: number[]) => Promise
+ /** The normal connect, which re-runs the census and converts or republishes the refusal. */
+ connect: () => Promise
+}
+
+export async function endOldRelayTerminals(
+ deps: EndOldRelayTerminalsDeps
+): Promise {
+ const ended = await deps
+ .onHost(async (conn) => {
+ const census = await deps.census(conn)
+ const oldRelays = census.oldRelays
+ if (census.verdict !== 'live' || oldRelays?.hostOs !== 'posix' || !oldRelays.pids.length) {
+ return 0
+ }
+ const remoteHome = await deps.remoteHome(conn)
+ if (!remoteHome) {
+ return 0
+ }
+ const result = await deps.stop(conn, remoteHome, oldRelays.pids)
+ console.log(
+ `[ssh] Ended old relay processes: ended=${result.ended.join(',') || 'none'} ` +
+ `stillRunning=${result.stillRunning.join(',') || 'none'} ` +
+ `skipped=${result.skipped.join(',') || 'none'}`
+ )
+ return result.ended.length
+ })
+ .catch((error: unknown) => {
+ console.warn('[ssh] Could not end old relay terminals; the reconnect decides:', error)
+ return 0
+ })
+ try {
+ await deps.connect()
+ return { ended, connected: true }
+ } catch (error) {
+ return { ended, connected: false, error: errorMessage(error) }
+ }
+}
diff --git a/src/preload/api/ssh-api.ts b/src/preload/api/ssh-api.ts
index 97ada992ae8..c2a99d72728 100644
--- a/src/preload/api/ssh-api.ts
+++ b/src/preload/api/ssh-api.ts
@@ -6,6 +6,7 @@ import type {
SshConfigHostResolution,
SshConfigImportResult,
SshConnectionState,
+ SshEndOldRelayTerminalsResult,
SshTarget,
SshTargetAddResult,
SshTargetCreateInput,
@@ -25,6 +26,8 @@ export type SshApi = {
resolveConfigHost: (args: { alias: string }) => Promise
connect: (args: { targetId: string }) => Promise
disconnect: (args: { targetId: string }) => Promise
+ /** Ends an older Orca's relay terminals on the host, then reconnects; absent on the web client. */
+ endOldRelayTerminals?: (args: { targetId: string }) => Promise
getState: (args: { targetId: string }) => Promise
needsPassphrasePrompt: (args: { targetId: string }) => Promise
testConnection: (args: {
diff --git a/src/preload/api/ssh-bridge.ts b/src/preload/api/ssh-bridge.ts
index 637894cffa3..701524ddacc 100644
--- a/src/preload/api/ssh-bridge.ts
+++ b/src/preload/api/ssh-bridge.ts
@@ -1,6 +1,7 @@
import { ipcRenderer } from 'electron'
import type {
SshConnectionState,
+ SshEndOldRelayTerminalsResult,
SshConfigHostListArgs,
SshConfigHostListResult,
SshConfigHostResolution,
@@ -51,6 +52,9 @@ export const sshApi = {
disconnect: (args: { targetId: string }): Promise =>
ipcRenderer.invoke('ssh:disconnect', args),
+ endOldRelayTerminals: (args: { targetId: string }): Promise =>
+ ipcRenderer.invoke('ssh:endOldRelayTerminals', args),
+
getState: async (args: { targetId: string }): Promise => {
const state: unknown = await ipcRenderer.invoke('ssh:getState', args)
return state ? admitSshConnectionStateForAuthorityReconciliation(state, args.targetId) : null
diff --git a/src/renderer/src/components/settings/SshPane.tsx b/src/renderer/src/components/settings/SshPane.tsx
index 47796b49a53..65317b0bcae 100644
--- a/src/renderer/src/components/settings/SshPane.tsx
+++ b/src/renderer/src/components/settings/SshPane.tsx
@@ -3,7 +3,7 @@ import { toast } from 'sonner'
import { Plus, Upload } from 'lucide-react'
import type { SshTarget } from '../../../../shared/ssh-types'
import { useAppStore } from '@/store'
-import { sshTargetConnectFailureText } from '@/ssh/ssh-host-server-status-copy'
+import { useSshConnectFailureToast } from '@/ssh/ssh-old-relay-terminals-action'
import { useMountedRef } from '@/hooks/useMountedRef'
import { Button } from '../ui/button'
import { SshTargetCard } from './SshTargetCard'
@@ -191,17 +191,16 @@ export function SshPane({ addTargetIntentSignal }: SshPaneProps): React.JSX.Elem
setShowForm(true)
}
+ const showConnectFailure = useSshConnectFailureToast()
const handleConnect = async (targetId: string): Promise => {
try {
await window.api.ssh.connect({ targetId })
recordFeatureInteraction('ssh')
} catch (err) {
- toast.error(
- sshTargetConnectFailureText(
- targetId,
- err,
- translate('auto.components.settings.SshPane.e95d5ae10e', 'Connection failed')
- )
+ showConnectFailure(
+ targetId,
+ err,
+ translate('auto.components.settings.SshPane.e95d5ae10e', 'Connection failed')
)
}
}
diff --git a/src/renderer/src/components/settings/SshTargetServerStatus.tsx b/src/renderer/src/components/settings/SshTargetServerStatus.tsx
index 6b970e48949..8d8cb1da05b 100644
--- a/src/renderer/src/components/settings/SshTargetServerStatus.tsx
+++ b/src/renderer/src/components/settings/SshTargetServerStatus.tsx
@@ -6,6 +6,11 @@ import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
import { Button } from '../ui/button'
import { sshHostServerStatusLine } from '@/ssh/ssh-host-server-status-copy'
+import {
+ endOldRelayTerminalsLabel,
+ useEndOldRelayTerminalsAction
+} from '@/ssh/ssh-old-relay-terminals-action'
+import { useSshConnectInFlight } from '@/ssh/ssh-connect-in-flight'
import { SshHostChangedActions } from './SshHostChangedActions'
/** Whether this SSH host's managed Orca server serves it, and why not when it doesn't. */
@@ -18,6 +23,8 @@ export function SshTargetServerStatus({
}): React.JSX.Element | null {
const state = useAppStore((s) => s.sshConnectionStates.get(target.id))
const line = sshHostServerStatusLine(target, state)
+ const endOldTerminals = useEndOldRelayTerminalsAction(target.id)
+ const connectInFlight = useSshConnectInFlight(target.id)
if (!line) {
return null
}
@@ -33,6 +40,27 @@ export function SshTargetServerStatus({
>
{line.text}
+ {line.stopHint ? (
+
+
{line.stopHint.lead}
+
+ {line.stopHint.command}
+
+
+ ) : null}
+ {endOldTerminals ? (
+
+
+
+ ) : null}
{line.detail ? (
diff --git a/src/renderer/src/components/sidebar/WorktreeCardSshHostControl.tsx b/src/renderer/src/components/sidebar/WorktreeCardSshHostControl.tsx
index df9fb142092..d8ea1df93b4 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardSshHostControl.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardSshHostControl.tsx
@@ -1,7 +1,6 @@
import { useCallback } from 'react'
import { Loader2, Server, ServerOff } from 'lucide-react'
-import { toast } from 'sonner'
-import { sshTargetConnectFailureText } from '@/ssh/ssh-host-server-status-copy'
+import { useSshConnectFailureToast } from '@/ssh/ssh-old-relay-terminals-action'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
@@ -84,6 +83,7 @@ export function WorktreeCardSshHostControl({
// this host dial the same connection, and the store status lags a click by one IPC hop.
const inFlight = useSshConnectInFlight(targetId)
+ const showConnectFailure = useSshConnectFailureToast()
const handleConnect = useCallback(async () => {
if (isSshConnectInFlight(targetId) || isConnectingSshStatus(status)) {
return
@@ -110,15 +110,14 @@ export function WorktreeCardSshHostControl({
}
}
} catch (err) {
- toast.error(
- sshTargetConnectFailureText(
- targetId,
- err,
- translate(
- 'auto.components.sidebar.WorktreeCardSshHostControl.connectFailed',
- 'SSH connection failed'
- )
- )
+ showConnectFailure(
+ targetId,
+ err,
+ translate(
+ 'auto.components.sidebar.WorktreeCardSshHostControl.connectFailed',
+ 'SSH connection failed'
+ ),
+ { local: !sshOwnerEnvironmentId }
)
// Why: a failed connect usually means the renderer's target metadata is stale
// (target removed, or re-added under a new id). Resync so the control converges to
@@ -135,7 +134,7 @@ export function WorktreeCardSshHostControl({
})().catch(() => {})
}
}
- }, [setSshConnectionState, sshOwnerEnvironmentId, status, targetId])
+ }, [setSshConnectionState, showConnectFailure, sshOwnerEnvironmentId, status, targetId])
// A live connection outranks a stale removal tombstone. A null status is a runtime-owned
// target: no renderer-reachable connect, and the card has always shown the plain host glyph
diff --git a/src/renderer/src/components/status-bar/SshTargetStatusRow.tsx b/src/renderer/src/components/status-bar/SshTargetStatusRow.tsx
index eb6c8f9a95f..42850702e74 100644
--- a/src/renderer/src/components/status-bar/SshTargetStatusRow.tsx
+++ b/src/renderer/src/components/status-bar/SshTargetStatusRow.tsx
@@ -1,7 +1,7 @@
import { useCallback, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { toast } from 'sonner'
-import { sshTargetConnectFailureText } from '@/ssh/ssh-host-server-status-copy'
+import { useSshConnectFailureToast } from '@/ssh/ssh-old-relay-terminals-action'
import { translate } from '@/i18n/i18n'
import { useMountedRef } from '@/hooks/useMountedRef'
import { useAppStore } from '../../store'
@@ -32,6 +32,7 @@ export function SshTargetStatusRow({
// must disable those too, in the window before main broadcasts 'connecting'.
const connectInFlight = useSshConnectInFlight(targetId)
+ const showConnectFailure = useSshConnectFailureToast()
const handleConnect = useCallback(async () => {
if (isSshConnectInFlight(targetId)) {
return
@@ -42,12 +43,10 @@ export function SshTargetStatusRow({
await window.api.ssh.connect({ targetId })
recordFeatureInteraction('ssh')
} catch (err) {
- toast.error(
- sshTargetConnectFailureText(
- targetId,
- err,
- translate('auto.components.status.bar.SshStatusSegment.2c29e2de68', 'Connection failed')
- )
+ showConnectFailure(
+ targetId,
+ err,
+ translate('auto.components.status.bar.SshStatusSegment.2c29e2de68', 'Connection failed')
)
} finally {
endSshConnect(targetId)
@@ -55,7 +54,7 @@ export function SshTargetStatusRow({
setBusy(false)
}
}
- }, [mountedRef, recordFeatureInteraction, targetId])
+ }, [mountedRef, recordFeatureInteraction, showConnectFailure, targetId])
const handleDisconnect = useCallback(async () => {
setBusy(true)
diff --git a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx
index ee50b410e91..2ed3aaef04d 100644
--- a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx
@@ -1,7 +1,10 @@
import { useCallback } from 'react'
import { Loader2, Server, ServerOff } from 'lucide-react'
-import { toast } from 'sonner'
-import { sshTargetConnectFailureText } from '@/ssh/ssh-host-server-status-copy'
+import {
+ endOldRelayTerminalsLabel,
+ useEndOldRelayTerminalsAction,
+ useSshConnectFailureToast
+} from '@/ssh/ssh-old-relay-terminals-action'
import { Button } from '@/components/ui/button'
import { useAppStore } from '@/store'
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
@@ -96,6 +99,10 @@ export function TerminalSshReconnectOverlay({
? toRuntimeExecutionHostId(sshOwnerEnvironmentId)
: toSshExecutionHostId(targetId)
+ const showConnectFailure = useSshConnectFailureToast()
+ const endOldTerminals = useEndOldRelayTerminalsAction(targetId, {
+ local: !sshOwnerEnvironmentId
+ })
const handleConnect = useCallback(async () => {
if (isSshConnectInFlight(targetId) || isConnectingSshStatus(status)) {
return
@@ -122,15 +129,14 @@ export function TerminalSshReconnectOverlay({
}
}
} catch (err) {
- toast.error(
- sshTargetConnectFailureText(
- targetId,
- err,
- translate(
- 'auto.components.terminal.pane.TerminalSshReconnectOverlay.connectFailed',
- 'SSH connection failed'
- )
- )
+ showConnectFailure(
+ targetId,
+ err,
+ translate(
+ 'auto.components.terminal.pane.TerminalSshReconnectOverlay.connectFailed',
+ 'SSH connection failed'
+ ),
+ { local: !sshOwnerEnvironmentId }
)
// Why: a failed connect usually means the renderer's target metadata is
// stale (target removed, or re-added under a new id). Resync it so the
@@ -148,7 +154,7 @@ export function TerminalSshReconnectOverlay({
})().catch(() => {})
}
}
- }, [setSshConnectionState, sshOwnerEnvironmentId, status, targetId])
+ }, [setSshConnectionState, showConnectFailure, sshOwnerEnvironmentId, status, targetId])
// Why: z-40 clears pane-local chrome (focus rim z-30); bg-card is fully opaque so terminal text cannot paint through.
return (
@@ -220,21 +226,34 @@ export function TerminalSshReconnectOverlay({
)}
) : (
-
+ <>
+ {endOldTerminals && !targetRemoved ? (
+
+ ) : null}
+
+ >
)}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 2f8812801ce..ff3eafb225c 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -12609,7 +12609,18 @@
"olderTerminalsUnverifiable": "Orca couldn’t confirm that terminals started by an older version of Orca have exited. Reconnect to try again.",
"olderTerminals_one": "{{count}} terminal started by an older version of Orca is still running on this host, and it doesn’t stop on its own. Close it in that version of Orca or end it on the host, then reconnect.",
"olderTerminals_other": "{{count}} terminals started by an older version of Orca are still running on this host, and they don’t stop on their own. Close them in that version of Orca or end them on the host, then reconnect.",
- "olderTerminals": "{{count}} terminals started by an older version of Orca are still running on this host, and they don’t stop on their own. Close them in that version of Orca or end them on the host, then reconnect."
+ "olderTerminals": "{{count}} terminals started by an older version of Orca are still running on this host, and they don’t stop on their own. Close them in that version of Orca or end them on the host, then reconnect.",
+ "oldTerminalsStopLead": "To end them on the host, run:",
+ "oldTerminalsStopLeadPids": "To end them on the host (process IDs {{pids}}), run:",
+ "endOldTerminals": {
+ "action": "End old terminals on this host",
+ "title": "End old terminals on this host?",
+ "description": "This ends every terminal an older version of Orca left running on this host for this account, including ones opened from other computers. The shells and agents running in them stop. Agents that support resume can be resumed afterwards; plain shells can’t be brought back.",
+ "confirm": "End terminals",
+ "running": "Ending old terminals…",
+ "connected": "Old terminals ended. The host is connected.",
+ "failed": "Orca couldn’t end the old terminals."
+ }
},
"ZcodeCliSetupSection": {
"title": "Use GLM with ZCode",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 2c7e4f05eda..b33ab16ff5a 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -12382,7 +12382,18 @@
"olderTerminalsUnverifiable": "Orca no pudo confirmar que los terminales iniciados por una versión anterior de Orca hayan terminado. Vuelve a conectarte para intentarlo de nuevo.",
"olderTerminals_one": "{{count}} terminal iniciado por una versión anterior de Orca sigue ejecutándose en este host y no se detiene solo. Ciérralo en esa versión de Orca o termínalo en el host y vuelve a conectarte.",
"olderTerminals_other": "{{count}} terminales iniciados por una versión anterior de Orca siguen ejecutándose en este host y no se detienen solos. Ciérralos en esa versión de Orca o termínalos en el host y vuelve a conectarte.",
- "olderTerminals": "{{count}} terminales iniciados por una versión anterior de Orca siguen ejecutándose en este host y no se detienen solos. Ciérralos en esa versión de Orca o termínalos en el host y vuelve a conectarte."
+ "olderTerminals": "{{count}} terminales iniciados por una versión anterior de Orca siguen ejecutándose en este host y no se detienen solos. Ciérralos en esa versión de Orca o termínalos en el host y vuelve a conectarte.",
+ "oldTerminalsStopLead": "Para terminarlos en el host, ejecuta:",
+ "oldTerminalsStopLeadPids": "Para terminarlos en el host (ID de proceso {{pids}}), ejecuta:",
+ "endOldTerminals": {
+ "action": "Terminar los terminales antiguos en este host",
+ "title": "¿Terminar los terminales antiguos en este host?",
+ "description": "Esto termina todos los terminales que una versión anterior de Orca dejó en ejecución en este host para esta cuenta, incluidos los abiertos desde otros equipos. Las shells y los agentes que se ejecutan en ellos se detienen. Los agentes compatibles con la reanudación se pueden reanudar después; las shells normales no se pueden recuperar.",
+ "confirm": "Terminar terminales",
+ "running": "Terminando los terminales antiguos…",
+ "connected": "Se terminaron los terminales antiguos. El host está conectado.",
+ "failed": "Orca no pudo terminar los terminales antiguos."
+ }
},
"deltaMove": {
"action": "Mover los proyectos nuevos…",
diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json
index 04f622973ab..4401216d603 100644
--- a/src/renderer/src/i18n/locales/fr.json
+++ b/src/renderer/src/i18n/locales/fr.json
@@ -12488,7 +12488,18 @@
"olderTerminalsUnverifiable": "Orca n’a pas pu confirmer que les terminaux lancés par une version antérieure d’Orca sont terminés. Reconnectez-vous pour réessayer.",
"olderTerminals_one": "{{count}} terminal lancé par une version antérieure d’Orca est toujours en cours d’exécution sur cet hôte et ne s’arrête pas de lui-même. Fermez-le dans cette version d’Orca ou arrêtez-le sur l’hôte, puis reconnectez-vous.",
"olderTerminals_other": "{{count}} terminaux lancés par une version antérieure d’Orca sont toujours en cours d’exécution sur cet hôte et ne s’arrêtent pas d’eux-mêmes. Fermez-les dans cette version d’Orca ou arrêtez-les sur l’hôte, puis reconnectez-vous.",
- "olderTerminals": "{{count}} terminaux lancés par une version antérieure d’Orca sont toujours en cours d’exécution sur cet hôte et ne s’arrêtent pas d’eux-mêmes. Fermez-les dans cette version d’Orca ou arrêtez-les sur l’hôte, puis reconnectez-vous."
+ "olderTerminals": "{{count}} terminaux lancés par une version antérieure d’Orca sont toujours en cours d’exécution sur cet hôte et ne s’arrêtent pas d’eux-mêmes. Fermez-les dans cette version d’Orca ou arrêtez-les sur l’hôte, puis reconnectez-vous.",
+ "oldTerminalsStopLead": "Pour les arrêter sur l’hôte, exécutez :",
+ "oldTerminalsStopLeadPids": "Pour les arrêter sur l’hôte (ID de processus {{pids}}), exécutez :",
+ "endOldTerminals": {
+ "action": "Arrêter les anciens terminaux sur cet hôte",
+ "title": "Arrêter les anciens terminaux sur cet hôte ?",
+ "description": "Cela arrête tous les terminaux qu’une version antérieure d’Orca a laissés en cours d’exécution sur cet hôte pour ce compte, y compris ceux ouverts depuis d’autres ordinateurs. Les shells et les agents qui s’y exécutent s’arrêtent. Les agents qui prennent en charge la reprise peuvent être repris ensuite ; les shells simples ne peuvent pas être restaurés.",
+ "confirm": "Arrêter les terminaux",
+ "running": "Arrêt des anciens terminaux…",
+ "connected": "Anciens terminaux arrêtés. L’hôte est connecté.",
+ "failed": "Orca n’a pas pu arrêter les anciens terminaux."
+ }
},
"deltaMove": {
"action": "Déplacer les nouveaux projets…",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index a632ba19b24..d4d121ddfa1 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -12382,7 +12382,18 @@
"olderTerminalsUnverifiable": "以前のバージョンの Orca で開始されたターミナルが終了したことを確認できませんでした。再接続してもう一度お試しください。",
"olderTerminals_one": "以前のバージョンの Orca で開始された {{count}} 個のターミナルがこのホストでまだ実行中で、自動では終了しません。そのバージョンの Orca で閉じるか、ホスト上で終了してから再接続してください。",
"olderTerminals_other": "以前のバージョンの Orca で開始された {{count}} 個のターミナルがこのホストでまだ実行中で、自動では終了しません。そのバージョンの Orca で閉じるか、ホスト上で終了してから再接続してください。",
- "olderTerminals": "以前のバージョンの Orca で開始された {{count}} 個のターミナルがこのホストでまだ実行中で、自動では終了しません。そのバージョンの Orca で閉じるか、ホスト上で終了してから再接続してください。"
+ "olderTerminals": "以前のバージョンの Orca で開始された {{count}} 個のターミナルがこのホストでまだ実行中で、自動では終了しません。そのバージョンの Orca で閉じるか、ホスト上で終了してから再接続してください。",
+ "oldTerminalsStopLead": "ホスト上で終了するには、次を実行してください:",
+ "oldTerminalsStopLeadPids": "ホスト上で終了するには (プロセス ID {{pids}})、次を実行してください:",
+ "endOldTerminals": {
+ "action": "このホストの古いターミナルを終了",
+ "title": "このホストの古いターミナルを終了しますか?",
+ "description": "以前のバージョンの Orca がこのアカウントでこのホスト上に実行したまま残したターミナルをすべて終了します。他のコンピュータから開いたものも含まれます。その中で実行中のシェルと Agent は停止します。再開に対応した Agent は後で再開できますが、通常のシェルは元に戻せません。",
+ "confirm": "ターミナルを終了",
+ "running": "古いターミナルを終了しています…",
+ "connected": "古いターミナルを終了しました。ホストに接続しています。",
+ "failed": "Orca は古いターミナルを終了できませんでした。"
+ }
},
"deltaMove": {
"action": "新規プロジェクトを移動…",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 4e9aac6cd48..5ddbda7da00 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -12382,7 +12382,18 @@
"olderTerminalsUnverifiable": "이전 버전의 Orca에서 시작한 터미널이 종료되었는지 확인할 수 없습니다. 다시 연결하여 다시 시도하세요.",
"olderTerminals_one": "이전 버전의 Orca에서 시작한 터미널 {{count}}개가 이 호스트에서 아직 실행 중이며 저절로 종료되지 않습니다. 해당 버전의 Orca에서 닫거나 호스트에서 종료한 후 다시 연결하세요.",
"olderTerminals_other": "이전 버전의 Orca에서 시작한 터미널 {{count}}개가 이 호스트에서 아직 실행 중이며 저절로 종료되지 않습니다. 해당 버전의 Orca에서 닫거나 호스트에서 종료한 후 다시 연결하세요.",
- "olderTerminals": "이전 버전의 Orca에서 시작한 터미널 {{count}}개가 이 호스트에서 아직 실행 중이며 저절로 종료되지 않습니다. 해당 버전의 Orca에서 닫거나 호스트에서 종료한 후 다시 연결하세요."
+ "olderTerminals": "이전 버전의 Orca에서 시작한 터미널 {{count}}개가 이 호스트에서 아직 실행 중이며 저절로 종료되지 않습니다. 해당 버전의 Orca에서 닫거나 호스트에서 종료한 후 다시 연결하세요.",
+ "oldTerminalsStopLead": "호스트에서 종료하려면 다음을 실행하세요:",
+ "oldTerminalsStopLeadPids": "호스트에서 종료하려면(프로세스 ID {{pids}}) 다음을 실행하세요:",
+ "endOldTerminals": {
+ "action": "이 호스트의 이전 터미널 종료",
+ "title": "이 호스트의 이전 터미널을 종료할까요?",
+ "description": "이전 버전의 Orca가 이 계정으로 이 호스트에서 실행 중인 상태로 남겨 둔 모든 터미널을 종료합니다. 다른 컴퓨터에서 연 터미널도 포함됩니다. 그 안에서 실행 중인 셸과 에이전트가 중지됩니다. 재개를 지원하는 에이전트는 나중에 재개할 수 있지만 일반 셸은 되돌릴 수 없습니다.",
+ "confirm": "터미널 종료",
+ "running": "이전 터미널을 종료하는 중…",
+ "connected": "이전 터미널을 종료했습니다. 호스트가 연결되었습니다.",
+ "failed": "Orca가 이전 터미널을 종료하지 못했습니다."
+ }
},
"deltaMove": {
"action": "새 프로젝트 이동…",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 0592f647928..bb956359785 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -12396,7 +12396,18 @@
"olderTerminalsUnverifiable": "Orca 无法确认由旧版本 Orca 启动的终端是否已退出。请重新连接后重试。",
"olderTerminals_one": "由旧版本 Orca 启动的 {{count}} 个终端仍在此主机上运行,且不会自行停止。请在该版本的 Orca 中关闭它们,或在主机上结束它们,然后重新连接。",
"olderTerminals_other": "由旧版本 Orca 启动的 {{count}} 个终端仍在此主机上运行,且不会自行停止。请在该版本的 Orca 中关闭它们,或在主机上结束它们,然后重新连接。",
- "olderTerminals": "由旧版本 Orca 启动的 {{count}} 个终端仍在此主机上运行,且不会自行停止。请在该版本的 Orca 中关闭它们,或在主机上结束它们,然后重新连接。"
+ "olderTerminals": "由旧版本 Orca 启动的 {{count}} 个终端仍在此主机上运行,且不会自行停止。请在该版本的 Orca 中关闭它们,或在主机上结束它们,然后重新连接。",
+ "oldTerminalsStopLead": "要在主机上结束它们,请运行:",
+ "oldTerminalsStopLeadPids": "要在主机上结束它们(进程 ID {{pids}}),请运行:",
+ "endOldTerminals": {
+ "action": "结束此主机上的旧终端",
+ "title": "结束此主机上的旧终端?",
+ "description": "这会结束旧版本 Orca 以此账户在此主机上留下并仍在运行的所有终端,包括从其他计算机打开的终端。其中运行的 shell 和代理都会停止。支持恢复的代理之后可以恢复;普通 shell 无法恢复。",
+ "confirm": "结束终端",
+ "running": "正在结束旧终端…",
+ "connected": "旧终端已结束。主机已连接。",
+ "failed": "Orca 无法结束旧终端。"
+ }
},
"deltaMove": {
"action": "迁移新项目…",
diff --git a/src/renderer/src/ssh/ssh-host-server-status-copy.test.ts b/src/renderer/src/ssh/ssh-host-server-status-copy.test.ts
index 5ede05ed2e8..cd4db6262bb 100644
--- a/src/renderer/src/ssh/ssh-host-server-status-copy.test.ts
+++ b/src/renderer/src/ssh/ssh-host-server-status-copy.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
-import { sshConnectFailureText, sshHostServerStatusLine } from './ssh-host-server-status-copy'
+import {
+ canEndOldRelayTerminals,
+ sshConnectFailureText,
+ sshHostServerStatusLine
+} from './ssh-host-server-status-copy'
const plain = {}
@@ -176,4 +180,48 @@ describe('SSH host server status line', () => {
expect(text).not.toContain('relay')
}
})
+
+ it('names the exact scoped command and the census pids when old relay terminals block', () => {
+ const live = {
+ managedServer: {
+ kind: 'relay' as const,
+ reason: 'relay_terminals_live' as const,
+ terminals: 2,
+ oldRelays: { hostOs: 'posix' as const, pids: [4101, 4102] }
+ }
+ }
+ const text = sshConnectFailureText(live, 'raw')
+ expect(text).toContain('process IDs 4101, 4102')
+ expect(text).toContain(
+ 'pkill -u "$(id -u)" -f "^([^ ]*/)?node[^ /]* relay\\.js --detached .*--sock-path ($HOME/\\.orca-remote|/tmp/\\.orca-relay-$(id -u))/relay-"'
+ )
+ expect(sshHostServerStatusLine(plain, live)?.stopHint?.command).toMatch(/^pkill -u /)
+ expect(canEndOldRelayTerminals(live)).toBe(true)
+ })
+
+ it('refuses the in-app stop when the census is unverifiable, keeping the manual command', () => {
+ const unverifiable = {
+ managedServer: {
+ kind: 'relay' as const,
+ reason: 'relay_terminals_unverifiable' as const,
+ oldRelays: { hostOs: 'posix' as const, pids: [] }
+ }
+ }
+ expect(canEndOldRelayTerminals(unverifiable)).toBe(false)
+ expect(sshConnectFailureText(unverifiable, 'raw')).toContain(
+ 'To end them on the host, run: pkill'
+ )
+ })
+
+ it('gives a Windows host its PowerShell command and no in-app stop', () => {
+ const windows = {
+ managedServer: {
+ kind: 'relay' as const,
+ reason: 'relay_terminals_live' as const,
+ oldRelays: { hostOs: 'windows' as const, pids: [] }
+ }
+ }
+ expect(canEndOldRelayTerminals(windows)).toBe(false)
+ expect(sshConnectFailureText(windows, 'raw')).toContain('Get-CimInstance Win32_Process')
+ })
})
diff --git a/src/renderer/src/ssh/ssh-host-server-status-copy.ts b/src/renderer/src/ssh/ssh-host-server-status-copy.ts
index 8e6f517d774..0ae737025a8 100644
--- a/src/renderer/src/ssh/ssh-host-server-status-copy.ts
+++ b/src/renderer/src/ssh/ssh-host-server-status-copy.ts
@@ -3,8 +3,10 @@ import type {
SSH_MANAGED_SERVER_PHASES,
SshConnectionState,
SshManagedServerUpdateNote,
+ SshOldRelayProcesses,
SshTarget
} from '../../../shared/ssh-types'
+import { oldRelayStopCommand } from '../../../shared/ssh-old-relay-stop-command'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
@@ -13,6 +15,8 @@ export type SshHostServerStatusLine = {
tone: 'muted' | 'warning' | 'destructive'
/** Main's untranslated cause, with the host's orcad.log tail when there is one; shown on request. */
detail?: string
+ /** How to end an older Orca's terminals on the host by hand: a lead-in and the exact command. */
+ stopHint?: { lead: string; command: string }
}
export function sshHostServerStatusLine(
@@ -58,7 +62,24 @@ export function sshConnectFailureText(
fallback: string
): string {
const status = state?.managedServer
- return status?.kind === 'relay' ? unservedLine(status).text : fallback
+ if (status?.kind !== 'relay') {
+ return fallback
+ }
+ const line = unservedLine(status)
+ return line.stopHint ? `${line.text} ${line.stopHint.lead} ${line.stopHint.command}` : line.text
+}
+
+/** Orca can end them itself only on a POSIX host whose census proved them live and named them. */
+export function canEndOldRelayTerminals(
+ state: Pick | undefined
+): boolean {
+ const status = state?.managedServer
+ return (
+ status?.kind === 'relay' &&
+ status.reason === 'relay_terminals_live' &&
+ status.oldRelays?.hostOs === 'posix' &&
+ status.oldRelays.pids.length > 0
+ )
}
export function sshTargetConnectFailureText(
@@ -236,6 +257,7 @@ function unservedLine(
case 'relay_terminals_live':
return {
tone: 'warning',
+ ...oldRelayStopHint(status.oldRelays),
// Why: an absent count is unreported, never zero open terminals.
text: status.terminals
? translate(
@@ -251,6 +273,7 @@ function unservedLine(
case 'relay_terminals_unverifiable':
return {
tone: 'warning',
+ ...oldRelayStopHint(status.oldRelays),
text: translate(
'auto.components.settings.sshHostServer.olderTerminalsUnverifiable',
'Orca couldn’t confirm that terminals started by an older version of Orca have exited. Reconnect to try again.'
@@ -276,6 +299,26 @@ function unservedLine(
}
}
+function oldRelayStopHint(
+ oldRelays: SshOldRelayProcesses | undefined
+): Pick {
+ if (!oldRelays) {
+ return {}
+ }
+ const lead =
+ oldRelays.pids.length > 0
+ ? translate(
+ 'auto.components.settings.sshHostServer.oldTerminalsStopLeadPids',
+ 'To end them on the host (process IDs {{pids}}), run:',
+ { pids: oldRelays.pids.join(', ') }
+ )
+ : translate(
+ 'auto.components.settings.sshHostServer.oldTerminalsStopLead',
+ 'To end them on the host, run:'
+ )
+ return { stopHint: { lead, command: oldRelayStopCommand(oldRelays.hostOs) } }
+}
+
function withDetail(detail: string | undefined): Pick {
return detail ? { detail } : {}
}
diff --git a/src/renderer/src/ssh/ssh-old-relay-terminals-action.test.tsx b/src/renderer/src/ssh/ssh-old-relay-terminals-action.test.tsx
new file mode 100644
index 00000000000..71da9556652
--- /dev/null
+++ b/src/renderer/src/ssh/ssh-old-relay-terminals-action.test.tsx
@@ -0,0 +1,171 @@
+// @vitest-environment happy-dom
+
+import '@testing-library/jest-dom/vitest'
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { TerminalSshReconnectOverlay } from '@/components/terminal-pane/TerminalSshReconnectOverlay'
+import {
+ ConfirmationDialogContext,
+ type ConfirmationDialogContextValue
+} from '@/components/confirmation-dialog-context'
+import { useAppStore } from '@/store'
+import { resetSshConnectInFlightForTests } from './ssh-connect-in-flight'
+import type { SshConnectionState, SshManagedServerStatus } from '../../../shared/ssh-types'
+import type * as I18nModule from '@/i18n/i18n'
+
+const toastMocks = vi.hoisted(() => ({
+ error: vi.fn(),
+ loading: vi.fn(() => 'toast-1'),
+ success: vi.fn()
+}))
+
+vi.mock('sonner', () => ({ toast: toastMocks }))
+
+vi.mock('@/i18n/i18n', async (importOriginal) => ({
+ ...(await importOriginal()),
+ translate: (_key: string, fallback: string, values?: Record) =>
+ fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => values?.[name] ?? '')
+}))
+
+const targetId = 'ssh-1'
+
+function seed(managedServer: SshManagedServerStatus): void {
+ const state: SshConnectionState = {
+ targetId,
+ status: 'error',
+ error: 'refused',
+ reconnectAttempt: 0,
+ managedServer
+ }
+ useAppStore.setState({ sshConnectionStates: new Map([[targetId, state]]) })
+}
+
+function installApi(api: Record>): void {
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: {
+ ssh: {
+ listTargets: vi.fn().mockResolvedValue([]),
+ listRemovedTargetLabels: vi.fn().mockResolvedValue({}),
+ ...api
+ }
+ }
+ })
+}
+
+function renderOverlay(
+ confirm: ConfirmationDialogContextValue,
+ environmentId: string | null = null
+) {
+ return render(
+
+
+
+ )
+}
+
+const live: SshManagedServerStatus = {
+ kind: 'relay',
+ reason: 'relay_terminals_live',
+ terminals: 2,
+ oldRelays: { hostOs: 'posix', pids: [4101, 4102] }
+}
+
+describe('End old terminals on this host', () => {
+ beforeEach(() => {
+ useAppStore.setState(useAppStore.getInitialState(), true)
+ resetSshConnectInFlightForTests()
+ for (const mock of Object.values(toastMocks)) {
+ mock.mockClear()
+ }
+ })
+
+ afterEach(() => {
+ cleanup()
+ })
+
+ it('is offered when the census proves old relay terminals live, and ends them after confirmation', async () => {
+ seed(live)
+ const endOldRelayTerminals = vi.fn().mockResolvedValue({ ended: 2, connected: true })
+ installApi({ connect: vi.fn(), endOldRelayTerminals })
+ const confirm = vi.fn().mockResolvedValue(true)
+ const user = userEvent.setup()
+ renderOverlay(confirm)
+
+ await user.click(screen.getByRole('button', { name: 'End old terminals on this host' }))
+
+ expect(confirm).toHaveBeenCalledWith(
+ expect.objectContaining({
+ confirmVariant: 'destructive',
+ description: expect.stringContaining(
+ 'The shells and agents running in them stop. Agents that support resume can be resumed afterwards; plain shells can’t be brought back.'
+ )
+ })
+ )
+ await waitFor(() => expect(endOldRelayTerminals).toHaveBeenCalledWith({ targetId }))
+ await waitFor(() =>
+ expect(toastMocks.success).toHaveBeenCalledWith(
+ 'Old terminals ended. The host is connected.',
+ { id: 'toast-1' }
+ )
+ )
+ })
+
+ it('ends nothing when the confirmation is cancelled', async () => {
+ seed(live)
+ const endOldRelayTerminals = vi.fn()
+ installApi({ connect: vi.fn(), endOldRelayTerminals })
+ const user = userEvent.setup()
+ renderOverlay(vi.fn().mockResolvedValue(false))
+
+ await user.click(screen.getByRole('button', { name: 'End old terminals on this host' }))
+ expect(endOldRelayTerminals).not.toHaveBeenCalled()
+ })
+
+ it('is not offered when the census is unverifiable; the error names the command instead', () => {
+ seed({
+ kind: 'relay',
+ reason: 'relay_terminals_unverifiable',
+ oldRelays: { hostOs: 'posix', pids: [] }
+ })
+ installApi({ connect: vi.fn(), endOldRelayTerminals: vi.fn() })
+ renderOverlay(vi.fn())
+
+ expect(screen.queryByRole('button', { name: 'End old terminals on this host' })).toBeNull()
+ })
+
+ it('is not offered for a host a remote Orca server owns', () => {
+ seed(live)
+ installApi({ connect: vi.fn(), endOldRelayTerminals: vi.fn() })
+ renderOverlay(vi.fn(), 'env-1')
+
+ expect(screen.queryByRole('button', { name: 'End old terminals on this host' })).toBeNull()
+ })
+
+ it('puts the action on the failed-connect toast', async () => {
+ seed(live)
+ installApi({
+ connect: vi.fn().mockRejectedValue(new Error('refused')),
+ endOldRelayTerminals: vi.fn()
+ })
+ const user = userEvent.setup()
+ renderOverlay(vi.fn())
+
+ await user.click(screen.getByRole('button', { name: 'Retry' }))
+ await waitFor(() =>
+ expect(toastMocks.error).toHaveBeenCalledWith(
+ expect.stringContaining('(process IDs 4101, 4102), run: pkill -u'),
+ expect.objectContaining({
+ action: expect.objectContaining({ label: 'End old terminals on this host' })
+ })
+ )
+ )
+ })
+})
diff --git a/src/renderer/src/ssh/ssh-old-relay-terminals-action.ts b/src/renderer/src/ssh/ssh-old-relay-terminals-action.ts
new file mode 100644
index 00000000000..542eaa58b38
--- /dev/null
+++ b/src/renderer/src/ssh/ssh-old-relay-terminals-action.ts
@@ -0,0 +1,135 @@
+/** "End old terminals on this host": the confirmation, the call, and the connect-failure toast that offers it. */
+import { useCallback, useContext } from 'react'
+import { toast } from 'sonner'
+import {
+ ConfirmationDialogContext,
+ type ConfirmationDialogContextValue
+} from '@/components/confirmation-dialog-context'
+import { translate } from '@/i18n/i18n'
+import { useAppStore } from '@/store'
+import { beginSshConnect, endSshConnect, isSshConnectInFlight } from './ssh-connect-in-flight'
+import { canEndOldRelayTerminals, sshTargetConnectFailureText } from './ssh-host-server-status-copy'
+
+export function endOldRelayTerminalsLabel(): string {
+ return translate(
+ 'auto.components.settings.sshHostServer.endOldTerminals.action',
+ 'End old terminals on this host'
+ )
+}
+
+export async function confirmAndEndOldRelayTerminals(
+ targetId: string,
+ confirm: ConfirmationDialogContextValue
+): Promise {
+ const confirmed = await confirm({
+ title: translate(
+ 'auto.components.settings.sshHostServer.endOldTerminals.title',
+ 'End old terminals on this host?'
+ ),
+ description: translate(
+ 'auto.components.settings.sshHostServer.endOldTerminals.description',
+ 'This ends every terminal an older version of Orca left running on this host for this account, including ones opened from other computers. The shells and agents running in them stop. Agents that support resume can be resumed afterwards; plain shells can’t be brought back.'
+ ),
+ confirmLabel: translate(
+ 'auto.components.settings.sshHostServer.endOldTerminals.confirm',
+ 'End terminals'
+ ),
+ confirmVariant: 'destructive'
+ })
+ // Why the connect lock: the action reconnects, so every Connect button on this host waits.
+ if (!confirmed || isSshConnectInFlight(targetId)) {
+ return
+ }
+ beginSshConnect(targetId)
+ const toastId = toast.loading(
+ translate(
+ 'auto.components.settings.sshHostServer.endOldTerminals.running',
+ 'Ending old terminals…'
+ )
+ )
+ try {
+ const end = window.api.ssh.endOldRelayTerminals
+ if (!end) {
+ throw new Error('Ending old terminals is unavailable here.')
+ }
+ const result = await end({ targetId })
+ if (result.connected) {
+ toast.success(
+ translate(
+ 'auto.components.settings.sshHostServer.endOldTerminals.connected',
+ 'Old terminals ended. The host is connected.'
+ ),
+ { id: toastId }
+ )
+ return
+ }
+ toast.error(
+ sshTargetConnectFailureText(
+ targetId,
+ new Error(result.error ?? ''),
+ translate(
+ 'auto.components.settings.sshHostServer.endOldTerminals.failed',
+ 'Orca couldn’t end the old terminals.'
+ )
+ ),
+ { id: toastId }
+ )
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : String(error), { id: toastId })
+ } finally {
+ endSshConnect(targetId)
+ }
+}
+
+/**
+ * The connect-failure toast for a local SSH host. When the failure is old relay terminals Orca can
+ * end, the toast carries the action; without a confirmation provider it stays plain.
+ */
+export function useSshConnectFailureToast(): (
+ targetId: string,
+ error: unknown,
+ fallback: string,
+ options?: { local?: boolean }
+) => void {
+ const confirm = useContext(ConfirmationDialogContext)
+ return useCallback(
+ (targetId, error, fallback, options) => {
+ const text = sshTargetConnectFailureText(targetId, error, fallback)
+ if (
+ confirm === null ||
+ options?.local === false ||
+ window.api.ssh.endOldRelayTerminals === undefined ||
+ !canEndOldRelayTerminals(useAppStore.getState().sshConnectionStates.get(targetId))
+ ) {
+ toast.error(text)
+ return
+ }
+ toast.error(text, {
+ duration: Infinity,
+ action: {
+ label: endOldRelayTerminalsLabel(),
+ onClick: () => void confirmAndEndOldRelayTerminals(targetId, confirm)
+ }
+ })
+ },
+ [confirm]
+ )
+}
+
+/** The action for a surface that shows the refusal; null when Orca can't end them itself. */
+export function useEndOldRelayTerminalsAction(
+ targetId: string,
+ options?: { local?: boolean }
+): (() => void) | null {
+ const confirm = useContext(ConfirmationDialogContext)
+ const endable = useAppStore((s) => canEndOldRelayTerminals(s.sshConnectionStates.get(targetId)))
+ if (
+ !confirm ||
+ !endable ||
+ options?.local === false ||
+ window.api.ssh.endOldRelayTerminals === undefined
+ ) {
+ return null
+ }
+ return () => void confirmAndEndOldRelayTerminals(targetId, confirm)
+}
diff --git a/src/shared/ssh-old-relay-processes-admission.ts b/src/shared/ssh-old-relay-processes-admission.ts
new file mode 100644
index 00000000000..54f4ef447cb
--- /dev/null
+++ b/src/shared/ssh-old-relay-processes-admission.ts
@@ -0,0 +1,18 @@
+import type { SshOldRelayProcesses } from './ssh-types'
+
+const MAX_OLD_RELAY_PIDS = 64
+
+// Why alone: a malformed list drops without hiding the refusal it explains.
+export function admitSshOldRelayProcesses(value: unknown): { oldRelays?: SshOldRelayProcesses } {
+ if (!value || typeof value !== 'object' || !('hostOs' in value) || !('pids' in value)) {
+ return {}
+ }
+ const hostOs = value.hostOs === 'posix' || value.hostOs === 'windows' ? value.hostOs : null
+ const pids = Array.isArray(value.pids)
+ ? value.pids.filter((pid): pid is number => Number.isSafeInteger(pid) && pid > 1)
+ : null
+ if (!hostOs || !pids) {
+ return {}
+ }
+ return { oldRelays: { hostOs, pids: pids.slice(0, MAX_OLD_RELAY_PIDS) } }
+}
diff --git a/src/shared/ssh-old-relay-stop-command.test.ts b/src/shared/ssh-old-relay-stop-command.test.ts
new file mode 100644
index 00000000000..25217ca497b
--- /dev/null
+++ b/src/shared/ssh-old-relay-stop-command.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest'
+import { oldRelayStopCommand } from './ssh-old-relay-stop-command'
+
+/** The PowerShell `-match` pattern for one profile dir, as `[regex]::Escape` would build it. */
+function windowsSelector(home: string): RegExp {
+ const match = /-match \('(.*?)' \+ \$h \+ '(.*?)'\)/.exec(oldRelayStopCommand('windows'))
+ if (!match) {
+ throw new Error('selector not found')
+ }
+ const escaped = home.replace(/[\\.^$|?*+()[\]{}#\s]/g, (char) => `\\${char}`)
+ // PowerShell's -match is case-insensitive.
+ return new RegExp(`${match[1]}${escaped}${match[2]}`, 'i')
+}
+
+describe('the Windows manual stop command', () => {
+ const selector = windowsSelector('C:\\Users\\alice')
+
+ it.each([
+ '"C:\\Users\\alice\\.orca-remote\\node\\node.exe" "C:\\Users\\alice\\.orca-remote\\relay-1.4.224\\relay.js" --detached --grace-time 0 --sock-path "\\\\.\\pipe\\orca-relay-0123" --credential-file "C:\\Users\\alice\\.orca-remote\\relay-1.4.224\\a.credential"',
+ 'C:\\nodejs\\node.exe relay.js --detached --grace-time 0 --credential-file C:\\Users\\alice\\.orca-remote\\relay-1\\a.credential',
+ '"C:\\Program Files\\nodejs\\node.exe" C:\\Users\\alice\\.orca-remote\\relay-1\\relay.js --detached --credential-file "C:\\Users\\alice\\.orca-remote\\relay-1\\a.credential"'
+ ])('selects this account’s relay daemon: %s', (commandLine) => {
+ expect(selector.test(commandLine)).toBe(true)
+ })
+
+ it.each([
+ 'node.exe -e "setInterval(()=>{},1000)" "relay.js --detached C:\\Users\\alice\\.orca-remote\\relay-demo"',
+ 'node.exe relay.js --connect --credential-file C:\\Users\\alice\\.orca-remote\\relay-1\\a.credential',
+ 'node.exe relay.js --detached --credential-file C:\\Users\\bob\\.orca-remote\\relay-1\\a.credential',
+ 'node.exe orcad.js --detached --credential-file C:\\Users\\alice\\.orca-remote\\relay-1\\a.credential',
+ 'C:\\a\\node.exe s.js C:\\b\\node relay.js --detached --credential-file C:\\Users\\alice\\.orca-remote\\relay-1\\a'
+ ])('never selects anything else: %s', (commandLine) => {
+ expect(selector.test(commandLine)).toBe(false)
+ })
+
+ it('also requires the process to belong to the current account', () => {
+ expect(oldRelayStopCommand('windows')).toContain(
+ '($o = Invoke-CimMethod -InputObject $_ -MethodName GetOwner).User -eq $env:USERNAME -and $o.Domain -eq $env:USERDOMAIN'
+ )
+ })
+})
diff --git a/src/shared/ssh-old-relay-stop-command.ts b/src/shared/ssh-old-relay-stop-command.ts
new file mode 100644
index 00000000000..0087baf8796
--- /dev/null
+++ b/src/shared/ssh-old-relay-stop-command.ts
@@ -0,0 +1,27 @@
+/**
+ * The command a user runs on an SSH host to stop the relay daemons an older Orca left running.
+ * Scoped like the in-app action: only ` relay.js --detached` processes of this account whose
+ * socket (POSIX) or credential file (Windows) lives under its Orca relay install directories.
+ */
+import type { SshOldRelayProcesses } from './ssh-types'
+
+// Anchored at argv[0] so a shell whose script text mentions a relay launch never matches.
+const POSIX_STOP_COMMAND =
+ 'pkill -u "$(id -u)" -f "^([^ ]*/)?node[^ /]* relay\\.js --detached .*--sock-path ($HOME/\\.orca-remote|/tmp/\\.orca-relay-$(id -u))/relay-"'
+
+// Windows daemon argv, by position: ` --detached ... --credential-file
+// <%USERPROFILE%/.orca-remote/relay-...>`, each path quoted or not.
+const WINDOWS_RELAY_ARGV_REGEX = String.raw`^(?:"(?:[^"]*\\)?node(?:\.exe)?"|(?:\S*\\)?node(?:\.exe)?)\s+(?:"(?:[^"]*\\)?relay\.js"|(?:\S*\\)?relay\.js)\s+--detached\s(?:.*\s)?--credential-file\s+"?`
+
+// Owner-checked too: an elevated shell would otherwise reach other accounts' relays.
+const WINDOWS_RELAY_DIR_REGEX = String.raw`\\\.orca-remote\\relay-`
+const WINDOWS_STOP_COMMAND =
+ `$h = [regex]::Escape($env:USERPROFILE); ` +
+ `Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" | ` +
+ `Where-Object { $_.CommandLine -match ('${WINDOWS_RELAY_ARGV_REGEX}' + $h + '${WINDOWS_RELAY_DIR_REGEX}') -and ` +
+ `($o = Invoke-CimMethod -InputObject $_ -MethodName GetOwner).User -eq $env:USERNAME -and $o.Domain -eq $env:USERDOMAIN } | ` +
+ `ForEach-Object { Stop-Process -Id $_.ProcessId }`
+
+export function oldRelayStopCommand(hostOs: SshOldRelayProcesses['hostOs']): string {
+ return hostOs === 'windows' ? WINDOWS_STOP_COMMAND : POSIX_STOP_COMMAND
+}
diff --git a/src/shared/ssh-retained-payload-admission.test.ts b/src/shared/ssh-retained-payload-admission.test.ts
index d55aa3b36c3..92632cc1849 100644
--- a/src/shared/ssh-retained-payload-admission.test.ts
+++ b/src/shared/ssh-retained-payload-admission.test.ts
@@ -123,6 +123,25 @@ describe('SSH retained payload admission', () => {
).toEqual(relay)
})
+ it('admits the old relay processes a refusal names, dropping only a malformed list', () => {
+ const state = { targetId: 'ssh-a', status: 'error', error: null, reconnectAttempt: 0 }
+ const relay = { kind: 'relay', reason: 'relay_terminals_live', terminals: 2 }
+ const admit = (oldRelays: unknown): unknown =>
+ admitSshConnectionState({ ...state, managedServer: { ...relay, oldRelays } }, 'ssh-a')
+ ?.managedServer
+
+ expect(admit({ hostOs: 'posix', pids: [4101, 4102] })).toEqual({
+ ...relay,
+ oldRelays: { hostOs: 'posix', pids: [4101, 4102] }
+ })
+ expect(admit({ hostOs: 'posix', pids: [4101, -1, 1, 'x', 2.5] })).toEqual({
+ ...relay,
+ oldRelays: { hostOs: 'posix', pids: [4101] }
+ })
+ expect(admit({ hostOs: 'beos', pids: [] })).toEqual(relay)
+ expect(admit({ hostOs: 'windows' })).toEqual(relay)
+ })
+
it('rejects partial and malformed provider authority', () => {
const state = {
targetId: 'ssh-a',
diff --git a/src/shared/ssh-retained-payload-admission.ts b/src/shared/ssh-retained-payload-admission.ts
index 5bbea400393..1fad70862c0 100644
--- a/src/shared/ssh-retained-payload-admission.ts
+++ b/src/shared/ssh-retained-payload-admission.ts
@@ -8,6 +8,7 @@ import type {
SshPlainSshMode,
SshProviderEpoch
} from './ssh-types'
+import { admitSshOldRelayProcesses } from './ssh-old-relay-processes-admission'
import {
SSH_MANAGED_SERVER_PHASES,
SSH_MANAGED_SERVER_RELAY_REASONS,
@@ -161,7 +162,8 @@ function admitSshManagedServerStatus(value: unknown): { managedServer?: SshManag
...('offerMove' in value && value.offerMove === true ? { offerMove: true } : {}),
...('terminalsElsewhere' in value && value.terminalsElsewhere === true
? { terminalsElsewhere: true }
- : {})
+ : {}),
+ ...admitSshOldRelayProcesses('oldRelays' in value ? value.oldRelays : undefined)
}
}
}
diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts
index 592f0cd7beb..f905f2cb1f9 100644
--- a/src/shared/ssh-types.ts
+++ b/src/shared/ssh-types.ts
@@ -318,8 +318,20 @@ export type SshManagedServerStatus =
offerMove?: boolean
/** Those terminals belong to another Orca desktop or session, so this one offers no move. */
terminalsElsewhere?: boolean
+ /** The older Orca's relay processes the census found, for the stop action and instructions. */
+ oldRelays?: SshOldRelayProcesses
}
+/** `pids` are this account's relay daemons found by argv; empty when the host could not list them. */
+export type SshOldRelayProcesses = { hostOs: 'posix' | 'windows'; pids: number[] }
+
+/** `ended`: relay processes proven gone; `connected`: the follow-up connect succeeded. */
+export type SshEndOldRelayTerminalsResult = {
+ ended: number
+ connected: boolean
+ error?: string
+}
+
/** Plain SSH terminals and SFTP browsing only; `reason` is the ladder's classified cause. */
export type SshPlainSshMode = {
reason: string