diff --git a/src/main/ipc/ssh/plain-ssh-terminal-handlers.ts b/src/main/ipc/ssh/plain-ssh-terminal-handlers.ts new file mode 100644 index 00000000000..460b1e5669e --- /dev/null +++ b/src/main/ipc/ssh/plain-ssh-terminal-handlers.ts @@ -0,0 +1,98 @@ +/** IPC for plain SSH terminals; the renderer names a saved target id and never any argv. */ +import { homedir } from 'node:os' +import { ipcMain, type WebContents } from 'electron' +import { + PLAIN_SSH_TERMINAL_CHANNELS, + type PlainSshTerminalEvent +} from '../../../shared/plain-ssh-terminal' +import { isSshHostMoveBlocked } from '../../../shared/ssh-host-move-blocked' +import { mayUserSshConfigClaimAlias } from '../../ssh/config/ssh-config-alias-claim' +import { getSshHostServerStatus } from '../../ssh/ssh-host-server-status' +import { getSshTargetRegistryStore } from '../../ssh/targets/ssh-target-registry' +import { findSystemSsh } from '../../ssh/system-transport/system-ssh-binary' +import { ensureNodePtySpawnHelperExecutable } from '../../providers/local-pty-utils' +import { + ackPlainSshTerminal, + closePlainSshTerminal, + closePlainSshTerminalsOwnedBy, + openPlainSshTerminal, + resizePlainSshTerminal, + writePlainSshTerminal, + type PlainSshTerminalDeps +} from '../../ssh/plain-ssh-terminal/plain-ssh-terminal-sessions' + +const deps: PlainSshTerminalDeps = { + getTarget: (targetId) => getSshTargetRegistryStore()?.getTarget(targetId), + isMoveBlocked: (targetId) => isSshHostMoveBlocked(getSshHostServerStatus(targetId)), + findSsh: findSystemSsh, + mayConfigClaimAlias: mayUserSshConfigClaimAlias, + loadPtySpawn: async () => { + // Why: a packaged macOS build can lose node-pty's spawn-helper exec bit; the pane paths repair it too. + ensureNodePtySpawnHelperExecutable() + return (await import('node-pty')).spawn + }, + homeDir: homedir, + platform: process.platform +} + +const watchedOwners = new Set() + +/** A reload, a crash or a closed window can't show its sessions again, so they end with it. */ +function endSessionsWithOwner(sender: WebContents): void { + if (watchedOwners.has(sender.id)) { + return + } + watchedOwners.add(sender.id) + const owner = sender.id + const end = (): void => closePlainSshTerminalsOwnedBy(owner) + sender.on('did-start-navigation', (details) => { + if (details.isMainFrame && !details.isSameDocument) { + end() + } + }) + sender.on('render-process-gone', end) + sender.once('destroyed', () => { + watchedOwners.delete(owner) + end() + }) +} + +export function registerPlainSshTerminalHandlers(): void { + const { open, write, resize, close, ack, event } = PLAIN_SSH_TERMINAL_CHANNELS + ipcMain.removeHandler(open) + ipcMain.removeAllListeners(write) + ipcMain.removeAllListeners(resize) + ipcMain.removeAllListeners(close) + ipcMain.removeAllListeners(ack) + + ipcMain.handle( + open, + async (ipcEvent, args: { targetId?: unknown; cols?: unknown; rows?: unknown }) => { + const sender = ipcEvent.sender + endSessionsWithOwner(sender) + return openPlainSshTerminal( + deps, + { targetId: args?.targetId, cols: args?.cols, rows: args?.rows, owner: sender.id }, + (payload: PlainSshTerminalEvent) => { + if (sender.isDestroyed()) { + return false + } + sender.send(event, payload) + return true + } + ) + } + ) + ipcMain.on(write, (ipcEvent, args: { sessionId?: unknown; data?: unknown }) => { + writePlainSshTerminal(args?.sessionId, ipcEvent.sender.id, args?.data) + }) + ipcMain.on(resize, (ipcEvent, args: { sessionId?: unknown; cols?: unknown; rows?: unknown }) => { + resizePlainSshTerminal(args?.sessionId, ipcEvent.sender.id, args?.cols, args?.rows) + }) + ipcMain.on(close, (ipcEvent, args: { sessionId?: unknown }) => { + closePlainSshTerminal(args?.sessionId, ipcEvent.sender.id) + }) + ipcMain.on(ack, (ipcEvent, args: { sessionId?: unknown; chars?: unknown }) => { + ackPlainSshTerminal(args?.sessionId, ipcEvent.sender.id, args?.chars) + }) +} diff --git a/src/main/ipc/ssh/ssh-shutdown-drain.ts b/src/main/ipc/ssh/ssh-shutdown-drain.ts index d9c6393be7b..62c5530d747 100644 --- a/src/main/ipc/ssh/ssh-shutdown-drain.ts +++ b/src/main/ipc/ssh/ssh-shutdown-drain.ts @@ -5,6 +5,7 @@ import { } from './ssh-connect-attempt-registry' import { connectionManager, portForwardManager } from './ssh-ipc-context' import { shutdownOrcadManagedTunnels } from '../../managed-server/lifecycle/orcad-managed-tunnel' +import { closeAllPlainSshTerminals } from '../../ssh/plain-ssh-terminal/plain-ssh-terminal-sessions' // Why one budget for the whole sequence rather than one per phase: an invalidated connect only // observes its cancellation at the next checkpoint, and one blocked in the transport handshake can @@ -70,6 +71,11 @@ function sshShutdownTasks(): SshShutdownTask[] { // its local port, so the next launch cannot bind a managed tunnel's fixed port. targetId: '*forwards', promise: Promise.all([portForwardManager?.closeAll(), shutdownOrcadManagedTunnels()]) + }, + { + // Why: nothing restores a plain SSH terminal, so none may outlive Orca. + targetId: '*plain-ssh', + promise: closeAllPlainSshTerminals() } ] } diff --git a/src/main/ipc/ssh/ssh.ts b/src/main/ipc/ssh/ssh.ts index 6206a11a1e4..b9fc5972b61 100644 --- a/src/main/ipc/ssh/ssh.ts +++ b/src/main/ipc/ssh/ssh.ts @@ -32,6 +32,7 @@ import { createSshConnectionCallbacks } from './ssh-connection-state-callbacks' import { resetSshCredentialDeclinesForTests } from '../../ssh/ssh-credential-decline' import { resetSshUserDisconnectsForTests } from '../../ssh/ssh-user-disconnect' import { registerSshConnectionHandlers } from './ssh-connection-handlers' +import { registerPlainSshTerminalHandlers } from './plain-ssh-terminal-handlers' import { registerPowerMonitorReconnect, unregisterPowerMonitorReconnect @@ -135,6 +136,7 @@ export function registerSshHandlers( registerSshTargetCrudHandlers() registerSshConnectionHandlers() registerSshPortForwardHandlers() + registerPlainSshTerminalHandlers() // COMPAT(legacy-relay-bridge): terminals an older Orca's relay still runs. registerLegacyRelayBridgeIpc({ store, diff --git a/src/main/providers/local-pty-spawn-environment.ts b/src/main/providers/local-pty-spawn-environment.ts index 0d705f5c2ef..06e4c53dc7b 100644 --- a/src/main/providers/local-pty-spawn-environment.ts +++ b/src/main/providers/local-pty-spawn-environment.ts @@ -24,34 +24,7 @@ export function buildLocalPtySpawnEnvironment(args: { plan: LocalPtyLaunchPlan }): Record | Promise> { const { id, spawn, getOptions, plan } = args - const inheritedEnv = stripInheritedBuildModeEnv(process.env) - restoreManagedDataAccountEnvironment(inheritedEnv) - const spawnEnv: Record = { - ...mergeGitConfigEnvProtocol(inheritedEnv, spawn.env), - TERM: 'xterm-256color', - COLORTERM: 'truecolor', - TERM_PROGRAM: 'Orca', - // Why: TUIs feature-gate on TERM_PROGRAM_VERSION; the fallback keeps tests and non-Electron runs working. - TERM_PROGRAM_VERSION: process.env.ORCA_APP_VERSION ?? '0.0.0-dev', - // Why: supports-hyperlinks rejects TERM_PROGRAM=Orca, so tools drop OSC 8 links; force it since xterm.js parses them. - FORCE_HYPERLINK: '1', - [ORCA_IMAGE_PROTOCOL_ENV]: ORCA_IMAGE_PROTOCOL_VALUE - } satisfies Record - removeUnspecifiedPaneIdentityEnv(spawnEnv, spawn.env) - stripPiProcessOwnerEnv(spawnEnv) - removeAppImageRuntimeEnv(spawnEnv) - removeChromiumDisabledSessionBus(spawnEnv) - removeInheritedNoColor(spawnEnv) - for (const key of spawn.envToDelete ?? []) { - delete spawnEnv[key] - } - if (spawn.env?.TERM) { - spawnEnv.TERM = spawn.env.TERM - } - // Why after the strips and deletes: an alias must never outlive the value it mirrors. - applyScrubSafeAgentEnvAliases(spawnEnv) - - spawnEnv.LANG ??= 'en_US.UTF-8' + const spawnEnv = buildInheritedLocalPtyEnvironment(spawn.env, spawn.envToDelete) // Why: on Windows LANG doesn't set the console code page; PYTHONUTF8=1 forces Python UTF-8 stdio to avoid garbled CJK. if (process.platform === 'win32') { @@ -77,6 +50,42 @@ export function buildLocalPtySpawnEnvironment(args: { }) } +/** What any local PTY inherits from Orca: its terminal identity, minus build and pane leftovers. */ +export function buildInheritedLocalPtyEnvironment( + explicitEnv: Record | undefined, + envToDelete: readonly string[] = [] +): Record { + const inheritedEnv = stripInheritedBuildModeEnv(process.env) + restoreManagedDataAccountEnvironment(inheritedEnv) + const spawnEnv: Record = { + ...mergeGitConfigEnvProtocol(inheritedEnv, explicitEnv), + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + TERM_PROGRAM: 'Orca', + // Why: TUIs feature-gate on TERM_PROGRAM_VERSION; the fallback keeps tests and non-Electron runs working. + TERM_PROGRAM_VERSION: process.env.ORCA_APP_VERSION ?? '0.0.0-dev', + // Why: supports-hyperlinks rejects TERM_PROGRAM=Orca, so tools drop OSC 8 links; force it since xterm.js parses them. + FORCE_HYPERLINK: '1', + [ORCA_IMAGE_PROTOCOL_ENV]: ORCA_IMAGE_PROTOCOL_VALUE + } satisfies Record + removeUnspecifiedPaneIdentityEnv(spawnEnv, explicitEnv) + stripPiProcessOwnerEnv(spawnEnv) + removeAppImageRuntimeEnv(spawnEnv) + removeChromiumDisabledSessionBus(spawnEnv) + removeInheritedNoColor(spawnEnv) + for (const key of envToDelete) { + delete spawnEnv[key] + } + if (explicitEnv?.TERM) { + spawnEnv.TERM = explicitEnv.TERM + } + // Why after the strips and deletes: an alias must never outlive the value it mirrors. + applyScrubSafeAgentEnvAliases(spawnEnv) + + spawnEnv.LANG ??= 'en_US.UTF-8' + return spawnEnv +} + /** App-level env builders can reintroduce deleted keys; enforce isolation after they finish. */ export function enforceLocalPtySpawnEnvironmentOverrides( spawn: PtySpawnOptions, diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 5bdd4567d1f..6fac3f4e73b 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -212,6 +212,24 @@ function spawnWindowsFallbackChain( return null } +export type PtyExecutableSpawnParams = Pick< + ShellSpawnParams, + 'termName' | 'cols' | 'rows' | 'cwd' | 'env' | 'ptySpawn' +> & { file: string; args: string[] } + +/** One executable with an argv array, no shell and no fallback; throws when it can't start. */ +export function spawnPtyExecutable(params: PtyExecutableSpawnParams): pty.IPty { + const { file, args, termName = 'xterm-256color', cols, rows, cwd, env, ptySpawn } = params + return ptySpawn(file, args, { + name: termName, + cols, + rows, + cwd, + env, + ...windowsConptyDllOptions() + }) +} + /** * Attempt to spawn a PTY shell. If the primary shell fails, try fallback shells * (Unix: zsh/bash/sh; Windows: the PowerShell -> cmd.exe chain) before giving up. @@ -239,13 +257,15 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu try { const wrapped = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, env) return { - process: ptySpawn(wrapped.file, wrapped.args, { - name: termName, + process: spawnPtyExecutable({ + file: wrapped.file, + args: wrapped.args, + termName, cols, rows, cwd, env, - ...windowsConptyDllOptions() + ptySpawn }), shellPath, reportsChildExitStatus: hostReportsChildExitStatus(wrapped.file) diff --git a/src/main/ssh/plain-ssh-terminal/plain-ssh-terminal-args.test.ts b/src/main/ssh/plain-ssh-terminal/plain-ssh-terminal-args.test.ts new file mode 100644 index 00000000000..ced22e7bc12 --- /dev/null +++ b/src/main/ssh/plain-ssh-terminal/plain-ssh-terminal-args.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import type { SshTarget } from '../../../shared/ssh-types' +import { buildPlainSshTerminalArgs } from './plain-ssh-terminal-args' + +const manual: SshTarget = { + id: 't1', + label: 'lambda', + source: 'manual', + host: '203.0.113.7', + port: 2222, + username: 'ubuntu', + identityFile: '/home/me/.ssh/lambda key', + jumpHost: 'bastion.example.com' +} + +describe('plain SSH terminal argv', () => { + it('logs in interactively: no -T, BatchMode or Orca multiplexing', () => { + const args = buildPlainSshTerminalArgs(manual) + expect(args).not.toContain('-T') + expect(args.join(' ')).not.toMatch(/BatchMode|ControlMaster|ControlPath|ControlPersist|-S/) + expect(args.join(' ')).not.toMatch(/StrictHostKeyChecking|UserKnownHostsFile/) + }) + + it('states a manual target’s endpoint and ends with -- user@host', () => { + expect(buildPlainSshTerminalArgs(manual)).toEqual([ + '-p', + '2222', + '-i', + '/home/me/.ssh/lambda key', + '-J', + 'bastion.example.com', + '--', + 'ubuntu@203.0.113.7' + ]) + }) + + it('keeps a config alias bare, so Include, Match and ProxyJump in ssh_config apply', () => { + const alias: SshTarget = { + ...manual, + source: 'ssh-config', + configHost: 'lambda', + host: 'lambda' + } + expect(buildPlainSshTerminalArgs(alias)).toEqual(['--', 'lambda']) + }) + + it('restores the stored endpoint only when no config block claims the alias', () => { + const alias: SshTarget = { + ...manual, + source: 'ssh-config', + configHost: 'lambda', + host: '203.0.113.7' + } + expect(buildPlainSshTerminalArgs(alias, { aliasClaimedByConfig: false })).toEqual([ + '-o', + 'Hostname=203.0.113.7', + '-p', + '2222', + '-l', + 'ubuntu', + '--', + 'lambda' + ]) + }) + + it('passes Windows paths and option-like hosts as single argv elements, after --', () => { + const windows: SshTarget = { + ...manual, + host: '-oProxyCommand=calc', + username: '', + identityFile: 'C:\\Users\\Me\\.ssh\\id ed25519', + jumpHost: undefined, + port: 22 + } + const args = buildPlainSshTerminalArgs(windows) + expect(args).toEqual(['-i', 'C:\\Users\\Me\\.ssh\\id ed25519', '--', '-oProxyCommand=calc']) + expect(args.indexOf('--')).toBe(args.length - 2) + }) +}) diff --git a/src/main/ssh/plain-ssh-terminal/plain-ssh-terminal-args.ts b/src/main/ssh/plain-ssh-terminal/plain-ssh-terminal-args.ts new file mode 100644 index 00000000000..599d8b4c955 --- /dev/null +++ b/src/main/ssh/plain-ssh-terminal/plain-ssh-terminal-args.ts @@ -0,0 +1,20 @@ +/** + * argv for an interactive, unmanaged `ssh` login to a saved target. Only the target's identity is + * shared with Orca's transport: no `-T`, no BatchMode and no Orca ControlMaster, so OpenSSH + * allocates a TTY, prompts for passphrases, OTPs and host keys in the terminal, and applies the + * user's own ssh_config (Include, Match, ProxyJump, ControlMaster) to the saved alias. + */ +import type { SshTarget } from '../../../shared/ssh-types' +import { + appendSshTargetDestinationArgs, + type SystemSshBuildArgsOptions +} from '../system-transport/system-ssh-args' + +export function buildPlainSshTerminalArgs( + target: SshTarget, + options?: Pick +): string[] { + const args: string[] = [] + appendSshTargetDestinationArgs(args, target, options) + return args +} diff --git a/src/main/ssh/plain-ssh-terminal/plain-ssh-terminal-sessions.test.ts b/src/main/ssh/plain-ssh-terminal/plain-ssh-terminal-sessions.test.ts new file mode 100644 index 00000000000..5ac5cde95ad --- /dev/null +++ b/src/main/ssh/plain-ssh-terminal/plain-ssh-terminal-sessions.test.ts @@ -0,0 +1,295 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as pty from 'node-pty' +import type { SshTarget } from '../../../shared/ssh-types' +import type { PlainSshTerminalEvent } from '../../../shared/plain-ssh-terminal' +import { + ackPlainSshTerminal, + closeAllPlainSshTerminals, + closePlainSshTerminal, + closePlainSshTerminalsOwnedBy, + openPlainSshTerminal, + resetPlainSshTerminalsForTests, + resizePlainSshTerminal, + writePlainSshTerminal, + type PlainSshTerminalDeps +} from './plain-ssh-terminal-sessions' + +const lambda: SshTarget = { + id: 'lambda', + label: 'lambda', + source: 'ssh-config', + configHost: 'lambda', + host: 'lambda', + port: 22, + username: '' +} + +type FakePty = { + file: string + args: string[] + options: pty.IPtyForkOptions + write: ReturnType + resize: ReturnType + kill: ReturnType + pause: ReturnType + resume: ReturnType + emitData: (data: string) => void + emitExit: (exitCode: number, signal?: number) => void +} + +const spawned: FakePty[] = [] + +function fakeSpawn(file: string, args: string[] | string, options: pty.IPtyForkOptions): pty.IPty { + let onData: ((data: string) => void) | null = null + let onExit: ((event: { exitCode: number; signal?: number }) => void) | null = null + const fake: FakePty = { + file, + args: Array.isArray(args) ? args : [args], + options, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => fake.emitExit(129)), + pause: vi.fn(), + resume: vi.fn(), + emitData: (data) => onData?.(data), + emitExit: (exitCode, signal) => onExit?.({ exitCode, signal }) + } + spawned.push(fake) + const proc = { + cols: 80, + rows: 24, + write: fake.write, + resize: fake.resize, + kill: fake.kill, + pause: fake.pause, + resume: fake.resume, + destroy: vi.fn(), + onData: (listener: (data: string) => void) => { + onData = listener + return { dispose: () => {} } + }, + onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => { + onExit = listener + return { dispose: () => {} } + } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sessions module only calls the IPty members this fake implements. + return proc as unknown as pty.IPty +} + +function deps(overrides: Partial = {}): PlainSshTerminalDeps { + return { + getTarget: (id) => (id === lambda.id ? lambda : undefined), + isMoveBlocked: () => true, + findSsh: () => '/usr/bin/ssh', + mayConfigClaimAlias: () => true, + loadPtySpawn: async () => fakeSpawn, + homeDir: () => '/home/me', + platform: 'linux', + ...overrides + } +} + +function open( + d: PlainSshTerminalDeps, + targetId: unknown = 'lambda', + owner = 1, + events: PlainSshTerminalEvent[] = [] +): ReturnType { + return openPlainSshTerminal(d, { targetId, cols: 100, rows: 30, owner }, (event) => { + events.push(event) + return true + }) +} + +afterEach(async () => { + await closeAllPlainSshTerminals() + resetPlainSshTerminalsForTests() + spawned.length = 0 +}) + +describe('plain SSH terminal sessions', () => { + it('spawns ssh itself with an argv array in the home directory, never a shell', async () => { + const result = await open(deps()) + expect(result).toMatchObject({ ok: true, label: 'lambda' }) + expect(spawned).toHaveLength(1) + expect(spawned[0]).toMatchObject({ file: '/usr/bin/ssh', args: ['--', 'lambda'] }) + expect(spawned[0].options).toMatchObject({ cols: 100, rows: 30, cwd: '/home/me' }) + }) + + it('refuses an unknown, internal or non-string target without spawning', async () => { + const internal: SshTarget = { + ...lambda, + id: 'hop', + owner: { type: 'on-demand-runtime', runtimeId: 'r1' } + } + const d = deps({ getTarget: (id) => (id === 'hop' ? internal : undefined) }) + expect(await open(d, 'nope')).toEqual({ ok: false, reason: 'unknown-target' }) + expect(await open(d, 'hop')).toEqual({ ok: false, reason: 'unknown-target' }) + expect(await open(d, { id: 'lambda' })).toEqual({ ok: false, reason: 'unknown-target' }) + expect(spawned).toHaveLength(0) + }) + + it('refuses a host whose move isn’t blocked: Orca’s own terminals serve it', async () => { + expect(await open(deps({ isMoveBlocked: () => false }))).toEqual({ + ok: false, + reason: 'not-blocked' + }) + expect(spawned).toHaveLength(0) + }) + + it('names OpenSSH Client when Windows has no ssh.exe, with no shell workaround', async () => { + const result = await open(deps({ findSsh: () => null, platform: 'win32' })) + expect(result).toMatchObject({ ok: false, reason: 'ssh-missing' }) + expect(result.ok ? '' : 'message' in result ? result.message : '').toContain('OpenSSH Client') + }) + + it('keeps one session per host across windows', async () => { + await open(deps(), 'lambda', 1) + expect(await open(deps(), 'lambda', 2)).toEqual({ ok: false, reason: 'already-open' }) + expect(spawned).toHaveLength(1) + }) + + it('claims the host before awaiting, so concurrent opens never spawn twice', async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + const slow = deps({ + loadPtySpawn: async () => { + await gate + return fakeSpawn + } + }) + const first = open(slow, 'lambda', 1) + const other = await open(slow, 'lambda', 2) + // The same window replacing its own pending open (e.g. a remount) wins; the older one yields. + const again = open(slow, 'lambda', 1) + release() + expect(other).toEqual({ ok: false, reason: 'already-open' }) + expect(await first).toEqual({ ok: false, reason: 'cancelled' }) + expect(await again).toMatchObject({ ok: true }) + expect(spawned).toHaveLength(1) + }) + + it('spawns nothing for an open whose window reloaded while it was pending', async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + const pending = open( + deps({ + loadPtySpawn: async () => { + await gate + return fakeSpawn + } + }), + 'lambda', + 3 + ) + closePlainSshTerminalsOwnedBy(3) + release() + expect(await pending).toEqual({ ok: false, reason: 'cancelled' }) + expect(spawned).toHaveLength(0) + expect(await open(deps(), 'lambda', 4)).toMatchObject({ ok: true }) + }) + + it('frees the host as soon as a close is asked, before ssh has exited', async () => { + const first = await open(deps(), 'lambda', 1) + spawned[0].kill.mockImplementation(() => {}) + closePlainSshTerminal(first.ok ? first.sessionId : '', 1) + expect(await open(deps(), 'lambda', 2)).toMatchObject({ ok: true }) + spawned[0].emitExit(129) + }) + + it('pauses ssh while the window has too much unrendered output, and resumes on ack', async () => { + const result = await open(deps(), 'lambda', 1) + const sessionId = result.ok ? result.sessionId : '' + const chunk = 'x'.repeat(64 * 1024) + for (let i = 0; i < 8; i++) { + spawned[0].emitData(chunk) + } + expect(spawned[0].pause).not.toHaveBeenCalled() + spawned[0].emitData(chunk) + expect(spawned[0].pause).toHaveBeenCalledTimes(1) + // Another window's ack is ignored. + ackPlainSshTerminal(sessionId, 2, 9 * chunk.length) + expect(spawned[0].resume).not.toHaveBeenCalled() + ackPlainSshTerminal(sessionId, 1, 6 * chunk.length) + expect(spawned[0].resume).not.toHaveBeenCalled() + ackPlainSshTerminal(sessionId, 1, 2 * chunk.length) + expect(spawned[0].resume).toHaveBeenCalledTimes(1) + }) + + it('resumes a paused ssh before hanging up, or Windows would never report its exit', async () => { + const result = await open(deps(), 'lambda', 1) + const order: string[] = [] + spawned[0].resume.mockImplementation(() => order.push('resume')) + spawned[0].kill.mockImplementation(() => order.push('kill')) + spawned[0].emitData('x'.repeat(600 * 1024)) + expect(spawned[0].pause).toHaveBeenCalledTimes(1) + closePlainSshTerminal(result.ok ? result.sessionId : '', 1) + expect(order).toEqual(['resume', 'kill']) + spawned[0].emitExit(129) + }) + + it('swallows native calls on a pty that exited but hasn’t reported it, as Windows does', async () => { + const result = await open(deps(), 'lambda', 1) + const sessionId = result.ok ? result.sessionId : '' + const exited = (): never => { + throw new Error('Cannot resize a pty that has already exited') + } + spawned[0].resize.mockImplementation(exited) + spawned[0].write.mockImplementation(exited) + expect(() => resizePlainSshTerminal(sessionId, 1, 100, 30)).not.toThrow() + expect(() => writePlainSshTerminal(sessionId, 1, 'ls\r')).not.toThrow() + }) + + it('reports a signal kill, which is not a clean logout', async () => { + const events: PlainSshTerminalEvent[] = [] + const result = await open(deps(), 'lambda', 1, events) + spawned[0].emitExit(0, 9) + expect(events).toEqual([ + { kind: 'exit', sessionId: result.ok ? result.sessionId : '', exitCode: 0, signal: 9 } + ]) + }) + + it('forwards output and exit, and only its owner can write, resize or close it', async () => { + const events: PlainSshTerminalEvent[] = [] + const result = await open(deps(), 'lambda', 7, events) + const sessionId = result.ok ? result.sessionId : '' + spawned[0].emitData('Welcome to lambda\r\n') + writePlainSshTerminal(sessionId, 8, 'rm -rf /\r') + resizePlainSshTerminal(sessionId, 8, 10, 10) + closePlainSshTerminal(sessionId, 8) + expect(spawned[0].write).not.toHaveBeenCalled() + expect(spawned[0].resize).not.toHaveBeenCalled() + expect(spawned[0].kill).not.toHaveBeenCalled() + writePlainSshTerminal(sessionId, 7, 'ls\r') + resizePlainSshTerminal(sessionId, 7, 120, 40) + expect(spawned[0].write).toHaveBeenCalledWith('ls\r') + expect(spawned[0].resize).toHaveBeenCalledWith(120, 40) + closePlainSshTerminal(sessionId, 7) + expect(events).toEqual([ + { kind: 'data', sessionId, data: 'Welcome to lambda\r\n' }, + { kind: 'exit', sessionId, exitCode: 129 } + ]) + // Ended: a new one may open. + expect(await open(deps(), 'lambda', 7)).toMatchObject({ ok: true }) + }) + + it('on quit, hangs up every session, waits for it to exit, and opens no more', async () => { + await open(deps()) + spawned[0].kill.mockImplementation(() => {}) + let done = false + const quit = closeAllPlainSshTerminals().then(() => { + done = true + }) + expect(spawned[0].kill).toHaveBeenCalledTimes(1) + await Promise.resolve() + expect(done).toBe(false) + spawned[0].emitExit(129) + await quit + expect(await open(deps(), 'lambda', 9)).toEqual({ ok: false, reason: 'cancelled' }) + }) +}) diff --git a/src/main/ssh/plain-ssh-terminal/plain-ssh-terminal-sessions.ts b/src/main/ssh/plain-ssh-terminal/plain-ssh-terminal-sessions.ts new file mode 100644 index 00000000000..7f27b9d1b4b --- /dev/null +++ b/src/main/ssh/plain-ssh-terminal/plain-ssh-terminal-sessions.ts @@ -0,0 +1,310 @@ +/** + * Plain SSH terminals: an unmanaged `ssh` login to a host whose move to its Orca server is blocked, + * so the user can keep working there meanwhile. A session is not an Orca workspace or terminal: + * nothing persists or restores it, no agent or status runs in it, and it never marks the host + * connected. One per host; it ends when ssh exits, its window closes or reloads, or Orca quits. + */ +import { randomUUID } from 'node:crypto' +import type * as pty from 'node-pty' +import type { SshTarget } from '../../../shared/ssh-types' +import type { + PlainSshTerminalEvent, + PlainSshTerminalOpenResult +} from '../../../shared/plain-ssh-terminal' +import { buildInheritedLocalPtyEnvironment } from '../../providers/local-pty-spawn-environment' +import { spawnPtyExecutable } from '../../providers/local-pty-utils' +import { destroyPtyProcess } from '../../providers/local-pty-termination' +import { buildPlainSshTerminalArgs } from './plain-ssh-terminal-args' + +export type PlainSshTerminalDeps = { + getTarget: (targetId: string) => SshTarget | undefined + /** True only while the host's move to its Orca server is blocked. */ + isMoveBlocked: (targetId: string) => boolean + findSsh: () => string | null + mayConfigClaimAlias: (alias: string) => boolean + loadPtySpawn: () => Promise + homeDir: () => string + platform: NodeJS.Platform +} + +/** Where a session's output and exit go; false once that window is gone. */ +export type PlainSshTerminalSink = (event: PlainSshTerminalEvent) => boolean + +type Session = { + id: string + owner: number + proc: pty.IPty + exited: Promise + hungUp: boolean + /** Output sent but not yet rendered by the window, in UTF-16 units. */ + unacked: number + paused: boolean +} + +/** A host's one claim, taken before the first await so concurrent opens can't both spawn. */ +type Slot = { owner: number; token: symbol; session: Session | null } + +const FORCE_KILL_AFTER_MS = 5_000 +const QUIT_FORCE_KILL_AFTER_MS = 1_000 +// Why: a flood (cat of a big file) must wait for the window to render, not pile up in IPC. +const PAUSE_ABOVE_UNACKED = 512 * 1024 +const RESUME_BELOW_UNACKED = 128 * 1024 +const MIN_SIZE = 2 +const MAX_SIZE = 1_000 + +const slots = new Map() +/** Every process not yet exited, including ones already hung up. */ +const sessions = new Map() +let quitting = false + +function sshMissingMessage(platform: NodeJS.Platform): string { + return platform === 'win32' + ? 'Orca couldn’t find OpenSSH (ssh.exe) on this computer. Install “OpenSSH Client” in Windows Settings > System > Optional features, then try again.' + : 'Orca couldn’t find ssh on this computer. Install the OpenSSH client, then try again.' +} + +function clampSize(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) + ? Math.min(MAX_SIZE, Math.max(MIN_SIZE, Math.floor(value))) + : fallback +} + +/** Frees a host's claim; a live session in it is hung up. */ +function releaseSlot(targetId: string, slot: Slot): void { + if (slots.get(targetId) === slot) { + slots.delete(targetId) + } + if (slot.session) { + hangUp(slot.session, FORCE_KILL_AFTER_MS) + } +} + +/** + * Why: on Windows node-pty records the exit before it emits one, and a native call in that gap + * throws ("already exited"). From an IPC listener that would reach the fatal main-process guard. + */ +function onLivePty(session: Session, call: (proc: pty.IPty) => void): void { + try { + call(session.proc) + } catch { + // Exited; its exit event follows. + } +} + +function hangUp(session: Session, forceAfterMs: number): void { + if (!session.hungUp) { + session.hungUp = true + if (session.paused) { + // Why: a paused Windows pty never reads its socket's end, so it would never report exit. + session.paused = false + onLivePty(session, (proc) => proc.resume()) + } + try { + session.proc.kill() + } catch { + // Already gone; its exit cleans up. + } + } + // Why armed again when already hung up: quit's shorter deadline must apply to it too. + const forceKill = setTimeout(() => { + if (sessions.has(session.id)) { + try { + // Why: Windows rejects a signal name; its kill() above already terminated the console. + session.proc.kill('SIGKILL') + } catch { + // Already gone. + } + } + }, forceAfterMs) + forceKill.unref?.() + void session.exited.then(() => clearTimeout(forceKill)) +} + +export async function openPlainSshTerminal( + deps: PlainSshTerminalDeps, + request: { targetId: unknown; cols: unknown; rows: unknown; owner: number }, + sink: PlainSshTerminalSink +): Promise { + const targetId = typeof request.targetId === 'string' ? request.targetId : '' + const target = targetId ? deps.getTarget(targetId) : undefined + // Why `owner`: internal targets (a server's own SSH hops) are never the user's host. + if (!target || target.owner) { + return { ok: false, reason: 'unknown-target' } + } + if (!deps.isMoveBlocked(target.id)) { + return { ok: false, reason: 'not-blocked' } + } + if (quitting) { + return { ok: false, reason: 'cancelled' } + } + const held = slots.get(target.id) + if (held && held.owner !== request.owner) { + return { ok: false, reason: 'already-open' } + } + // Why the same window may replace its own: it shows one per host, so an older one is stale. + if (held) { + releaseSlot(target.id, held) + } + const slot: Slot = { owner: request.owner, token: Symbol(target.id), session: null } + slots.set(target.id, slot) + const stillClaimed = (): boolean => !quitting && slots.get(target.id)?.token === slot.token + + const sshPath = deps.findSsh() + if (!sshPath) { + releaseSlot(target.id, slot) + return { ok: false, reason: 'ssh-missing', message: sshMissingMessage(deps.platform) } + } + const args = buildPlainSshTerminalArgs( + target, + target.configHost && !deps.mayConfigClaimAlias(target.configHost) + ? { aliasClaimedByConfig: false } + : undefined + ) + let proc: pty.IPty + try { + const ptySpawn = await deps.loadPtySpawn() + // A reload, a newer open or quit during the await took the claim; spawn nothing. + if (!stillClaimed()) { + return { ok: false, reason: 'cancelled' } + } + proc = spawnPtyExecutable({ + file: sshPath, + args, + cols: clampSize(request.cols, 80), + rows: clampSize(request.rows, 24), + cwd: deps.homeDir(), + env: buildInheritedLocalPtyEnvironment(undefined), + ptySpawn + }) + } catch (error) { + releaseSlot(target.id, slot) + return { + ok: false, + reason: 'spawn-failed', + message: error instanceof Error ? error.message : String(error) + } + } + let markExited: () => void = () => {} + const session: Session = { + id: randomUUID(), + owner: request.owner, + proc, + exited: new Promise((resolve) => { + markExited = resolve + }), + hungUp: false, + unacked: 0, + paused: false + } + sessions.set(session.id, session) + slot.session = session + proc.onData((data) => { + if (session.hungUp) { + return + } + if (!sink({ kind: 'data', sessionId: session.id, data })) { + releaseSlot(target.id, slot) + return + } + session.unacked += data.length + if (!session.paused && session.unacked > PAUSE_ABOVE_UNACKED) { + session.paused = true + onLivePty(session, (live) => live.pause()) + } + }) + proc.onExit(({ exitCode, signal }) => { + sessions.delete(session.id) + if (slots.get(target.id) === slot) { + slots.delete(target.id) + } + destroyPtyProcess(proc, { alreadyKilled: true }) + markExited() + sink({ + kind: 'exit', + sessionId: session.id, + exitCode, + ...(signal ? { signal } : {}) + }) + }) + return { ok: true, sessionId: session.id, label: target.label } +} + +function ownedSession(sessionId: unknown, owner: number): Session | null { + const session = typeof sessionId === 'string' ? sessions.get(sessionId) : undefined + return session && session.owner === owner && !session.hungUp ? session : null +} + +export function writePlainSshTerminal(sessionId: unknown, owner: number, data: unknown): void { + const session = ownedSession(sessionId, owner) + if (session && typeof data === 'string') { + onLivePty(session, (proc) => proc.write(data)) + } +} + +export function resizePlainSshTerminal( + sessionId: unknown, + owner: number, + cols: unknown, + rows: unknown +): void { + const session = ownedSession(sessionId, owner) + if (session) { + onLivePty(session, (proc) => + proc.resize(clampSize(cols, proc.cols), clampSize(rows, proc.rows)) + ) + } +} + +export function ackPlainSshTerminal(sessionId: unknown, owner: number, chars: unknown): void { + const session = ownedSession(sessionId, owner) + if (!session || typeof chars !== 'number' || !Number.isFinite(chars) || chars <= 0) { + return + } + session.unacked = Math.max(0, session.unacked - chars) + if (session.paused && session.unacked < RESUME_BELOW_UNACKED) { + session.paused = false + onLivePty(session, (proc) => proc.resume()) + } +} + +/** Hangs up ssh and frees its host at once; a stuck process is killed outright later. */ +export function closePlainSshTerminal(sessionId: unknown, owner: number): void { + const session = ownedSession(sessionId, owner) + if (!session) { + return + } + for (const [targetId, slot] of slots) { + if (slot.session === session) { + releaseSlot(targetId, slot) + return + } + } + hangUp(session, FORCE_KILL_AFTER_MS) +} + +/** Every session a window owns, opened or still opening, e.g. when it closes or reloads. */ +export function closePlainSshTerminalsOwnedBy(owner: number): void { + for (const [targetId, slot] of slots) { + if (slot.owner === owner) { + releaseSlot(targetId, slot) + } + } +} + +/** On quit: nothing restores these, so none may outlive Orca. Resolves once all have exited. */ +export async function closeAllPlainSshTerminals(): Promise { + quitting = true + slots.clear() + const pending = [...sessions.values()] + for (const session of pending) { + hangUp(session, QUIT_FORCE_KILL_AFTER_MS) + } + await Promise.all(pending.map((session) => session.exited)) +} + +/** Test seam: quit is one-way in a real app. */ +export function resetPlainSshTerminalsForTests(): void { + quitting = false + slots.clear() + sessions.clear() +} diff --git a/src/main/ssh/system-transport/system-ssh-args.ts b/src/main/ssh/system-transport/system-ssh-args.ts index 3c72d3919a0..5b0c04fdfc9 100644 --- a/src/main/ssh/system-transport/system-ssh-args.ts +++ b/src/main/ssh/system-transport/system-ssh-args.ts @@ -63,6 +63,19 @@ export function buildSshArgs(target: SshTarget, options?: SystemSshBuildArgsOpti args.push('-o', 'ServerAliveCountMax=3') } + appendSshTargetDestinationArgs(args, target, options) + return args +} + +/** + * Who to log in as and how to reach the host, ending `-- destination`: the part of a dial that + * belongs to the saved target rather than to Orca's transport (multiplexing, batch mode, `-T`). + */ +export function appendSshTargetDestinationArgs( + args: string[], + target: SshTarget, + options?: Pick +): void { const useConfigHost = shouldUseOpenSshConfigHost(target) // Why: a wildcard `Host *` block supplies ProxyCommand/ProxyJump for every alias, so an alias @@ -109,8 +122,6 @@ export function buildSshArgs(target: SshTarget, options?: SystemSshBuildArgsOpti // must not override a fresh wildcard, Include, or Match result. const userHost = useConfigHost ? host : target.username ? `${target.username}@${host}` : host args.push('--', userHost) - - return args } /** diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 31cfafbdde6..3a93dacf8cb 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -58,6 +58,7 @@ import type { KeybindingsApi, SettingsApi } from './api/settings-api' import type { ShellApi } from './api/shell-api' import type { SpeechApi } from './api/speech-api' import type { SshApi } from './api/ssh-api' +import type { PlainSshTerminalApi } from './api/plain-ssh-terminal-bridge' import type { LegacyRelayBridgeApi } from './api/legacy-relay-bridge-api' import type { DiagnosticsApi, MemoryApi, StatsApi, TelemetryApi } from './api/telemetry-api' import type { UiCommandEventApi } from './api/ui-command-event-api' @@ -153,6 +154,8 @@ export type PreloadApi = { grokAccounts: GrokAccountsApi cursorAccounts: CursorAccountsApi ssh: SshApi + /** Desktop only: an unmanaged ssh login to a host whose move is blocked. */ + plainSsh?: PlainSshTerminalApi /** COMPAT(legacy-relay-bridge): desktop-only, revisit 2027-01-15. */ legacyRelayBridge?: LegacyRelayBridgeApi automations: AutomationsApi diff --git a/src/preload/api/plain-ssh-terminal-bridge.ts b/src/preload/api/plain-ssh-terminal-bridge.ts new file mode 100644 index 00000000000..f9f6aa4856e --- /dev/null +++ b/src/preload/api/plain-ssh-terminal-bridge.ts @@ -0,0 +1,33 @@ +import { ipcRenderer } from 'electron' +import { + PLAIN_SSH_TERMINAL_CHANNELS, + type PlainSshTerminalEvent, + type PlainSshTerminalOpenResult +} from '../../shared/plain-ssh-terminal' + +export type PlainSshTerminalApi = { + open: (args: { + targetId: string + cols: number + rows: number + }) => Promise + write: (args: { sessionId: string; data: string }) => void + resize: (args: { sessionId: string; cols: number; rows: number }) => void + close: (args: { sessionId: string }) => void + ack: (args: { sessionId: string; chars: number }) => void + onEvent: (callback: (event: PlainSshTerminalEvent) => void) => () => void +} + +export const plainSshTerminalApi: PlainSshTerminalApi = { + open: (args) => ipcRenderer.invoke(PLAIN_SSH_TERMINAL_CHANNELS.open, args), + write: (args) => ipcRenderer.send(PLAIN_SSH_TERMINAL_CHANNELS.write, args), + resize: (args) => ipcRenderer.send(PLAIN_SSH_TERMINAL_CHANNELS.resize, args), + close: (args) => ipcRenderer.send(PLAIN_SSH_TERMINAL_CHANNELS.close, args), + ack: (args) => ipcRenderer.send(PLAIN_SSH_TERMINAL_CHANNELS.ack, args), + onEvent: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, payload: PlainSshTerminalEvent): void => + callback(payload) + ipcRenderer.on(PLAIN_SSH_TERMINAL_CHANNELS.event, listener) + return () => ipcRenderer.removeListener(PLAIN_SSH_TERMINAL_CHANNELS.event, listener) + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index c395e6ab123..968a299d330 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -78,6 +78,7 @@ import { zcodePlanCredentialsApi } from './api/zcode-plan-credentials-bridge' import { grokAccountsApi } from './api/grok-accounts-bridge' import { cursorAccountsApi } from './api/cursor-accounts-bridge' import { sshApi } from './api/ssh-bridge' +import { plainSshTerminalApi } from './api/plain-ssh-terminal-bridge' import { legacyRelayBridgeApi } from './api/legacy-relay-bridge-api' import { automationsApi } from './api/automations-bridge' import { e2eApi } from './api/e2e-bridge' @@ -179,6 +180,7 @@ const api = { grokAccounts: grokAccountsApi, cursorAccounts: cursorAccountsApi, ssh: sshApi, + plainSsh: plainSshTerminalApi, legacyRelayBridge: legacyRelayBridgeApi, automations: automationsApi, e2e: e2eApi, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 071fcb7f2d3..ca067990ad8 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -35,6 +35,7 @@ import { usePersistedUIWriter } from './app-shell/use-persisted-ui-writer' import { useRuntimeGraphSync } from './app-shell/use-runtime-graph-sync' import { useWindowVisibilityEffects } from './app-shell/use-window-visibility-effects' import { SpaceWindowStatePublisher } from './components/workspace-space/SpaceWindowStatePublisher' +import { PlainSshTerminalPanels } from './components/plain-ssh-terminal/PlainSshTerminalPanels' import { installActiveWorktreeRemovalSuccessor } from './lib/active-worktree-removal-successor' function App(): React.JSX.Element { @@ -110,6 +111,7 @@ function App(): React.JSX.Element { + diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx index 2193424b91b..4b73a2709a2 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx @@ -1,9 +1,6 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { Terminal, type ITheme } from '@xterm/xterm' +import type { ITheme, Terminal } from '@xterm/xterm' import { useShallow } from 'zustand/react/shallow' -import '@xterm/xterm/css/xterm.css' -import { getShortcutPlatform } from '@/lib/shortcut-platform' -import { subscribeToTerminalUserInput } from '@/components/terminal-pane/terminal-user-input-signal' import { composeActiveTerminalTheme } from '@/components/terminal-pane/terminal-appearance' import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark' import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' @@ -15,21 +12,17 @@ import { previewAdvertisesKittyKeyboard } from './preview-terminal-options' import { syncPreviewTerminalLigatures } from './preview-terminal-ligatures' -import { installPreviewTerminalCompatibility } from './preview-terminal-compatibility' -import { createPreviewClipboardPaster } from './preview-terminal-paste' -import { installPreviewImeBridge, type PreviewImeBridge } from './preview-terminal-ime-bridge' import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot' import { terminalPreviewUnavailableMessage } from './terminal-preview-unavailable-message' import { getBuiltinTheme, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' import { cn } from '@/lib/utils' import { useAppStore } from '@/store' -import { installPreviewTerminalKeyHandler } from './preview-terminal-key-handler' import { createPreviewGridClaim } from './preview-grid-claim' import { createPreviewBoxFit } from './preview-terminal-box-fit' -import { installPreviewTerminalAppMenuClipboard } from './preview-terminal-app-menu-clipboard' -import { installPreviewTerminalRightClickPaste } from './preview-terminal-right-click-paste' -import { installTerminalNativeCopyGutterTrim } from '@/components/terminal-pane/terminal-native-copy-gutter' -import { isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers' +import { + openStandaloneTerminal, + type StandaloneTerminal +} from '../standalone-terminal/open-standalone-terminal' import type { TerminalPreviewDataPayload } from '../../../../shared/terminal-preview' import { terminalPreviewApiFor } from './terminal-preview-api' @@ -110,13 +103,9 @@ export function AgentTerminalPreview({ return } let disposed = false + let standalone: StandaloneTerminal | null = null let terminal: Terminal | null = null let offData: (() => void) | null = null - let userInputDisposable: { dispose: () => void } | null = null - let imeBridge: PreviewImeBridge | null = null - let disposeKeyHandler: (() => void) | null = null - let disposeNativeCopyGutterTrim: (() => void) | null = null - let disposeTerminalCompatibility: (() => void) | null = null const previewApi = terminalPreviewApiFor(ptyId) // Why one read: the xterm's advertisement and its mirror must never disagree. const mountTerminalInput = terminalInputRef.current @@ -150,20 +139,13 @@ export function AgentTerminalPreview({ } boxResizeObserver?.observe(container) - let replayDepth = 0 const writeReplayed = (chunk: string, onDone?: () => void, live = false): void => { - // Why: a redelivered snapshot repeats the TUI's one-time kitty push, so - // replayed bytes must apply as idempotent sets (see the tracker's docs). - if (live) { - kittyKeyboardModes.scan(chunk) - } else { - kittyKeyboardModes.scanReplay(chunk) - } - replayDepth++ - terminal?.write(chunk, () => { - replayDepth-- - boxFit.schedule() - onDone?.() + standalone?.write(chunk, { + live, + onDone: () => { + boxFit.schedule() + onDone?.() + } }) } @@ -183,72 +165,6 @@ export function AgentTerminalPreview({ ) } - const pasteClipboardText = createPreviewClipboardPaster({ - ptyId, - container, - getTerminal: () => terminal, - getTerminalInput: () => terminalInputRef.current, - isDisposed: () => disposed - }) - - const disposeImeNativeTextBridge = (): void => { - imeBridge?.dispose() - imeBridge = null - } - - const installImeNativeTextBridge = (): void => { - if (terminal) { - // Why a live getter: kitty state can change between keydown and commit, - // and the tracker outlives every reconnect inside this effect. - imeBridge = installPreviewImeBridge(terminal, { - getKittyKeyboardFlags: () => kittyKeyboardModes.flags - }) - } - } - - const installKeyHandler = (): void => { - if (!terminal) { - return - } - disposeKeyHandler = installPreviewTerminalKeyHandler({ - terminal, - claimImeKeyEvent: (event) => imeBridge?.claimKeyEvent(event) ?? false, - pasteClipboardText: (activeElement, source) => - void pasteClipboardText(activeElement, source), - // Why: route through terminal.input so the chord's bytes carry core's user-input signal, like typed keys. - sendInput: (data) => terminal?.input(data), - getShortcutContext: () => ({ - clientPlatform: getShortcutPlatform(), - macOptionAsAlt: macOptionAsAltRef.current, - keybindings: useAppStore.getState().keybindings, - terminalInput: terminalInputRef.current, - getKittyKeyboardFlags: () => kittyKeyboardModes.flags, - terminalShortcutPolicy: settingsRef.current?.terminalShortcutPolicy - }) - }) - } - - const installInputRouting = (): void => { - if (!terminal) { - return - } - let pendingUserInputSignals = 0 - userInputDisposable = subscribeToTerminalUserInput(terminal, () => { - pendingUserInputSignals = Math.min(32, pendingUserInputSignals + 1) - }) - terminal.onData((data) => { - const signaledUserInput = pendingUserInputSignals > 0 - if (signaledUserInput) { - pendingUserInputSignals-- - } - // Why: core's signal distinguishes real input from parser replies, so typing survives live replay without forwarding synthetic CPR/DA bytes. - if (userInputDisposable ? !signaledUserInput : replayDepth > 0) { - return - } - void previewApi.input(ptyId, data) - }) - } - const replayConnection = ( connection: Awaited>, replaceExisting: boolean, @@ -256,8 +172,9 @@ export function AgentTerminalPreview({ ): void => { const snap = connection.snapshot! if (!terminal) { - terminal = new Terminal( - buildPreviewTerminalOptions({ + standalone = openStandaloneTerminal({ + container, + options: buildPreviewTerminalOptions({ settings: settingsRef.current, terminalInput: mountTerminalInput, macOptionIsMeta: macOptionAsAltRef.current === 'true', @@ -266,23 +183,20 @@ export function AgentTerminalPreview({ cols: clamp(snap.cols ?? FALLBACK_COLS, 2, 500), rows: clamp(snap.rows ?? FALLBACK_ROWS, 2, 200), scrollback: PREVIEW_SCROLLBACK_BUFFER_ROWS - }) - ) - try { - terminal.open(container) - } catch { - terminal.dispose() - terminal = null + }), + kittyKeyboardModes, + sessionKey: ptyId, + sendInput: (data) => previewApi.input(ptyId, data), + answersQueries: false, + getSettings: () => settingsRef.current, + getMacOptionAsAlt: () => macOptionAsAltRef.current, + getTerminalInput: () => terminalInputRef.current + }) + if (!standalone) { return } + terminal = standalone.terminal terminalRef.current = terminal - disposeTerminalCompatibility = installPreviewTerminalCompatibility(terminal, { - getSettings: () => settingsRef.current - }) - disposeNativeCopyGutterTrim = installTerminalNativeCopyGutterTrim(terminal).dispose - installInputRouting() - installImeNativeTextBridge() - installKeyHandler() } else if (replaceExisting) { // Why: keep the old frame visible during capture, then atomically replace it once the authoritative snapshot arrives. terminal.resize( @@ -325,16 +239,8 @@ export function AgentTerminalPreview({ const releaseTerminal = (): void => { offData?.() offData = null - userInputDisposable?.dispose() - userInputDisposable = null - disposeImeNativeTextBridge() - disposeTerminalCompatibility?.() - disposeTerminalCompatibility = null - disposeKeyHandler?.() - disposeKeyHandler = null - disposeNativeCopyGutterTrim?.() - disposeNativeCopyGutterTrim = null - terminal?.dispose() + standalone?.dispose() + standalone = null terminal = null terminalRef.current = null void previewApi.unsubscribe(ptyId) @@ -367,20 +273,6 @@ export function AgentTerminalPreview({ replayConnection(connection, replaceExisting, () => void setup(true)) } - const disposeAppMenuClipboard = installPreviewTerminalAppMenuClipboard({ - container, - getTerminal: () => terminal, - pasteClipboardText: (activeElement, source) => void pasteClipboardText(activeElement, source) - }) - const disposeRightClickPaste = installPreviewTerminalRightClickPaste({ - container, - getTerminal: () => terminal, - // Same default as the pane: Windows users expect terminal-style right-click. - isRightClickToPasteEnabled: () => - settingsRef.current?.terminalRightClickToPaste ?? isWindowsUserAgent(), - pasteClipboardText: (activeElement, source) => void pasteClipboardText(activeElement, source) - }) - offData = previewApi.onData((payload) => { if (payload.ptyId !== ptyId) { return @@ -402,8 +294,6 @@ export function AgentTerminalPreview({ } gridClaim.dispose() boxResizeObserver?.disconnect() - disposeAppMenuClipboard() - disposeRightClickPaste() releaseTerminal() } }, [ diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-paste.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-paste.ts index 4ff536910e7..f312a86f3b3 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-paste.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-paste.ts @@ -6,17 +6,19 @@ import { resolveTerminalPasteRuntime } from '@/components/terminal-pane/terminal import { TERMINAL_PASTE_MAX_BYTES } from '@/components/terminal-pane/terminal-paste-limits' import { pasteTerminalText } from '@/components/terminal-pane/terminal-bracketed-paste' import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot' -import { terminalPreviewApiFor } from './terminal-preview-api' export type PreviewTerminalPasteSource = 'keyboard' | 'app-menu' | 'right-click' /** - * Clipboard paste for the preview terminal, on the pane's coordinator: large + * Clipboard paste for a standalone terminal, on the pane's coordinator: large * pastes stream as bounded IPC payloads, and the plan re-checks that the same * terminal still owns focus between chunks. */ export function createPreviewClipboardPaster(deps: { + /** Names the session for paste planning; a remote-runtime pty id routes as remote. */ ptyId: string + /** Where pasted bytes go when they bypass xterm, e.g. a bracketed paste's closing marker. */ + writeInput: (data: string) => boolean | Promise container: HTMLElement getTerminal: () => Terminal | null getTerminalInput: () => DashboardCardTerminalInput | null @@ -61,7 +63,7 @@ export function createPreviewClipboardPaster(deps: { await executeTerminalPastePlan(plan, { // Why: stream large pastes so the renderer never emits one huge IPC payload. pasteText: (text, options) => pasteTerminalText(pasteTerminal, text, options), - writePty: (data) => terminalPreviewApiFor(deps.ptyId).input(deps.ptyId, data), + writePty: (data) => deps.writeInput(data), isTargetCurrent: targetIsCurrent, // Why: if focus changes mid-bracketed paste, the closing marker must still reach the live PTY. canContinue: () => true diff --git a/src/renderer/src/components/plain-ssh-terminal/OpenPlainSshTerminalButton.tsx b/src/renderer/src/components/plain-ssh-terminal/OpenPlainSshTerminalButton.tsx new file mode 100644 index 00000000000..75f6197a195 --- /dev/null +++ b/src/renderer/src/components/plain-ssh-terminal/OpenPlainSshTerminalButton.tsx @@ -0,0 +1,35 @@ +import type { SshManagedServerStatus } from '../../../../shared/ssh-types' +import { isSshHostMoveBlocked } from '../../../../shared/ssh-host-move-blocked' +import { translate } from '@/i18n/i18n' +import { Button } from '@/components/ui/button' +import { showPlainSshPanel } from '@/ssh/plain-ssh-terminal-panels' + +/** + * The way to keep working on a host whose move is blocked. Only offered then, and only for a host + * this desktop dials itself: a remote Orca server's host is that server's to reach. + */ +export function OpenPlainSshTerminalButton({ + targetId, + targetLabel, + status, + ownerEnvironmentId = null +}: { + targetId: string + targetLabel: string + status: SshManagedServerStatus | undefined + ownerEnvironmentId?: string | null +}): React.JSX.Element | null { + if (ownerEnvironmentId || !isSshHostMoveBlocked(status) || !window.api.plainSsh) { + return null + } + return ( + + ) +} diff --git a/src/renderer/src/components/plain-ssh-terminal/PlainSshTerminalPanels.test.tsx b/src/renderer/src/components/plain-ssh-terminal/PlainSshTerminalPanels.test.tsx new file mode 100644 index 00000000000..c653b30619c --- /dev/null +++ b/src/renderer/src/components/plain-ssh-terminal/PlainSshTerminalPanels.test.tsx @@ -0,0 +1,256 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshManagedServerStatus } from '../../../../shared/ssh-types' +import type { + PlainSshTerminalEvent, + PlainSshTerminalOpenResult +} from '../../../../shared/plain-ssh-terminal' +import { OpenPlainSshTerminalButton } from './OpenPlainSshTerminalButton' +import { PlainSshTerminalPanels } from './PlainSshTerminalPanels' +import { removePlainSshPanel } from '@/ssh/plain-ssh-terminal-panels' +import { TooltipProvider } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import { getDefaultSettings } from '../../../../shared/constants' + +const written: string[] = [] + +// The standalone terminal is the preview's, covered by its tests; this stands in for xterm. +vi.mock('@/components/standalone-terminal/open-standalone-terminal', () => ({ + openStandaloneTerminal: () => ({ + terminal: { + options: {}, + cols: 80, + rows: 24, + loadAddon: () => {}, + focus: () => {}, + onResize: () => ({ dispose: () => {} }) + }, + write: (chunk: string, options: { onDone?: () => void }) => { + written.push(chunk) + options.onDone?.() + }, + dispose: () => {} + }) +})) +const fit = vi.fn() +vi.mock('@xterm/addon-fit', () => ({ + FitAddon: class { + fit(): void { + fit() + } + } +})) + +const blocked: SshManagedServerStatus = { + kind: 'relay', + reason: 'refused', + detail: 'This SSH host cannot move yet: automations (2).' +} + +let emit: (event: PlainSshTerminalEvent) => void = () => {} +const open = vi.fn<(args: unknown) => Promise>() +const close = vi.fn() +const ack = vi.fn() + +beforeEach(() => { + written.length = 0 + open.mockReset() + close.mockReset() + ack.mockReset() + Object.defineProperty(window, 'ResizeObserver', { + configurable: true, + // Full surface: a hovered header button's Tooltip positions itself with unobserve too. + value: class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + plainSsh: { + open, + close, + ack, + write: vi.fn(), + resize: vi.fn(), + onEvent: (callback: (event: PlainSshTerminalEvent) => void) => { + emit = callback + return () => {} + } + } + } + }) +}) + +afterEach(() => { + act(() => removePlainSshPanel('lambda')) + cleanup() +}) + +/** The open reply arrives when the test says, so nothing races it. */ +function deferOpen(): (result: PlainSshTerminalOpenResult) => void { + let resolve: (result: PlainSshTerminalOpenResult) => void = () => {} + open.mockImplementation( + () => + new Promise((done) => { + resolve = done + }) + ) + return (result) => resolve(result) +} + +function Harness({ status }: { status: SshManagedServerStatus | undefined }): React.JSX.Element { + return ( + + + + + ) +} + +describe('plain SSH terminal', () => { + it('is offered only while the host’s move is blocked, and only for this desktop’s host', () => { + const { rerender } = render() + expect(screen.queryByRole('button', { name: 'Open plain SSH terminal' })).toBeNull() + rerender() + expect(screen.queryByRole('button', { name: 'Open plain SSH terminal' })).toBeNull() + rerender() + expect(screen.queryByRole('button', { name: 'Open plain SSH terminal' })).toBeNull() + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Open plain SSH terminal' })).toBeNull() + }) + + it('opens a labelled unmanaged window, shows early output, and closes when ssh exits cleanly', async () => { + let resolveOpen: (result: PlainSshTerminalOpenResult) => void = () => {} + open.mockImplementation( + () => + new Promise((resolve) => { + resolveOpen = resolve + }) + ) + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Open plain SSH terminal' })) + + expect(screen.getByText('Plain SSH · lambda')).toBeInTheDocument() + expect(screen.getByText(/Plain SSH — unmanaged\. Not an Orca workspace/)).toBeInTheDocument() + expect(open).toHaveBeenCalledWith({ targetId: 'lambda', cols: 80, rows: 24 }) + + // ssh can print before the open reply arrives. + act(() => emit({ kind: 'data', sessionId: 's1', data: 'Enter passphrase: ' })) + await act(async () => resolveOpen({ ok: true, sessionId: 's1', label: 'lambda' })) + expect(written).toEqual(['Enter passphrase: ']) + // Rendered output is acked, so main resumes a paused ssh. + expect(ack).toHaveBeenCalledWith({ sessionId: 's1', chars: 'Enter passphrase: '.length }) + + act(() => emit({ kind: 'exit', sessionId: 's1', exitCode: 0 })) + await waitFor(() => expect(screen.queryByText('Plain SSH · lambda')).toBeNull()) + }) + + it('keeps output when ssh is killed by a signal, even with exit code 0', async () => { + const resolveOpen = deferOpen() + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Open plain SSH terminal' })) + await waitFor(() => expect(open).toHaveBeenCalled()) + await act(async () => resolveOpen({ ok: true, sessionId: 's4', label: 'lambda' })) + act(() => emit({ kind: 'exit', sessionId: 's4', exitCode: 0, signal: 9 })) + expect(await screen.findByText(/ssh was stopped \(signal 9\)/)).toBeInTheDocument() + }) + + it('still ends a session whose exit arrives before its open reply', async () => { + let resolveOpen: (result: PlainSshTerminalOpenResult) => void = () => {} + open.mockImplementation( + () => + new Promise((resolve) => { + resolveOpen = resolve + }) + ) + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Open plain SSH terminal' })) + act(() => { + // A flood from another window's session must not crowd out this session's exit. + emit({ kind: 'data', sessionId: 'other', data: 'y'.repeat(300 * 1024) }) + emit({ kind: 'data', sessionId: 's5', data: 'Permission denied (publickey).\r\n' }) + emit({ kind: 'exit', sessionId: 's5', exitCode: 255 }) + }) + await act(async () => resolveOpen({ ok: true, sessionId: 's5', label: 'lambda' })) + expect(await screen.findByText(/ssh exited with code 255/)).toBeInTheDocument() + expect(written).toEqual(['Permission denied (publickey).\r\n']) + }) + + it('keeps a failed login readable, with no shell to fall back to', async () => { + const resolveOpen = deferOpen() + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Open plain SSH terminal' })) + await waitFor(() => expect(open).toHaveBeenCalled()) + await act(async () => resolveOpen({ ok: true, sessionId: 's2', label: 'lambda' })) + act(() => emit({ kind: 'exit', sessionId: 's2', exitCode: 255 })) + expect(await screen.findByText(/ssh exited with code 255/)).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Close and end session' })) + expect(screen.queryByText('Plain SSH · lambda')).toBeNull() + }) + + it('never refits a minimized window, which would shrink the remote terminal to 2x1', async () => { + const width = vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(600) + const height = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(400) + try { + const resolveOpen = deferOpen() + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Open plain SSH terminal' })) + await act(async () => resolveOpen({ ok: true, sessionId: 's6', label: 'lambda' })) + await user.click(screen.getByRole('button', { name: 'Minimize' })) + fit.mockClear() + act(() => + useAppStore.setState({ + settings: { ...getDefaultSettings('/home/me'), terminalFontSize: 18 } + }) + ) + expect(fit).not.toHaveBeenCalled() + } finally { + width.mockRestore() + height.mockRestore() + } + }) + + it('shows why it can’t open, e.g. no OpenSSH on Windows', async () => { + open.mockResolvedValue({ + ok: false, + reason: 'ssh-missing', + message: 'Orca couldn’t find OpenSSH (ssh.exe) on this computer.' + }) + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Open plain SSH terminal' })) + expect(await screen.findByText(/couldn’t find OpenSSH/)).toBeInTheDocument() + }) + + it('hangs up when its window closes', async () => { + const resolveOpen = deferOpen() + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Open plain SSH terminal' })) + await waitFor(() => expect(open).toHaveBeenCalled()) + await act(async () => resolveOpen({ ok: true, sessionId: 's3', label: 'lambda' })) + await user.click(screen.getByRole('button', { name: 'Close and end session' })) + expect(close).toHaveBeenCalledWith({ sessionId: 's3' }) + }) +}) diff --git a/src/renderer/src/components/plain-ssh-terminal/PlainSshTerminalPanels.tsx b/src/renderer/src/components/plain-ssh-terminal/PlainSshTerminalPanels.tsx new file mode 100644 index 00000000000..638efb15489 --- /dev/null +++ b/src/renderer/src/components/plain-ssh-terminal/PlainSshTerminalPanels.tsx @@ -0,0 +1,150 @@ +import { useState } from 'react' +import { Minus, SquareTerminal, X } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + minimizePlainSshPanel, + removePlainSshPanel, + showPlainSshPanel, + usePlainSshPanels, + type PlainSshPanel +} from '@/ssh/plain-ssh-terminal-panels' +import { PlainSshTerminalView, type PlainSshTerminalPhase } from './PlainSshTerminalView' + +export function plainSshUnmanagedNotice(): string { + return translate( + 'auto.components.plainSshTerminal.unmanaged', + 'Plain SSH — unmanaged. Not an Orca workspace: agents and tabs here aren’t tracked, and this terminal isn’t restored. Closing it ends the SSH session.' + ) +} + +function phaseText(phase: PlainSshTerminalPhase): string | null { + switch (phase.kind) { + case 'opening': + case 'open': + return null + case 'failed': + return phase.message + case 'ended': + return phase.signal + ? translate( + 'auto.components.plainSshTerminal.endedBySignal', + 'ssh was stopped (signal {{signal}}). Close this window when you’re done reading.', + { signal: phase.signal } + ) + : translate( + 'auto.components.plainSshTerminal.ended', + 'ssh exited with code {{code}}. Close this window when you’re done reading.', + { code: phase.exitCode } + ) + } +} + +function HeaderIconButton({ + label, + onClick, + children +}: { + label: string + onClick: () => void + children: React.ReactNode +}): React.JSX.Element { + return ( + + + + + {label} + + ) +} + +/** A non-modal window over Orca; minimized windows wait in a strip at the bottom right. */ +function PlainSshTerminalCard({ panel }: { panel: PlainSshPanel }): React.JSX.Element { + const [phase, setPhase] = useState({ kind: 'opening' }) + const message = phaseText(phase) + return ( +
+
+ +
+ {translate('auto.components.plainSshTerminal.title', 'Plain SSH · {{host}}', { + host: panel.label + })} +
+ minimizePlainSshPanel(panel.targetId)} + > + + + removePlainSshPanel(panel.targetId)} + > + + +
+

+ {plainSshUnmanagedNotice()} +

+ {message ? ( +

{message}

+ ) : null} +
+
+
+ ) +} + +export function PlainSshTerminalPanels(): React.JSX.Element | null { + const panels = usePlainSshPanels() + if (panels.length === 0) { + return null + } + const minimized = panels.filter((panel) => panel.minimized) + return ( +
+ {panels.map((panel) => ( + + ))} + {minimized.length > 0 ? ( +
+ {minimized.map((panel) => ( + + ))} +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/plain-ssh-terminal/PlainSshTerminalView.tsx b/src/renderer/src/components/plain-ssh-terminal/PlainSshTerminalView.tsx new file mode 100644 index 00000000000..ed6930a51e9 --- /dev/null +++ b/src/renderer/src/components/plain-ssh-terminal/PlainSshTerminalView.tsx @@ -0,0 +1,318 @@ +import { useEffect, useLayoutEffect, useRef } from 'react' +import { FitAddon } from '@xterm/addon-fit' +import type { ITheme, Terminal } from '@xterm/xterm' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { + PlainSshTerminalEvent, + PlainSshTerminalOpenResult +} from '../../../../shared/plain-ssh-terminal' +import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { useEffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/use-effective-mac-option-as-alt' +import { composeActiveTerminalTheme } from '@/components/terminal-pane/terminal-appearance' +import { getBuiltinTheme, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' +import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark' +import { resolveTerminalMinimumContrastRatio } from '@/lib/terminal-contrast-correction' +import { syncPreviewTerminalLigatures } from '@/components/dashboard-popout/preview-terminal-ligatures' +import { + buildPreviewAppearanceOptions, + buildPreviewTerminalOptions +} from '@/components/dashboard-popout/preview-terminal-options' +import { openStandaloneTerminal } from '@/components/standalone-terminal/open-standalone-terminal' +import { removePlainSshPanel } from '@/ssh/plain-ssh-terminal-panels' + +export type PlainSshTerminalPhase = + | { kind: 'opening' } + | { kind: 'open' } + | { kind: 'failed'; message: string } + | { kind: 'ended'; exitCode: number; signal?: number } + +/** Output held while the open reply is in flight; a login banner or prompt is far smaller. */ +const EARLY_DATA_CHAR_LIMIT = 256 * 1024 +const SCROLLBACK_ROWS = 5_000 + +export function plainSshOpenFailureText( + result: Extract +): string { + switch (result.reason) { + case 'unknown-target': + return translate( + 'auto.components.plainSshTerminal.unknownTarget', + 'This SSH host is no longer saved in Orca.' + ) + case 'not-blocked': + return translate( + 'auto.components.plainSshTerminal.notBlocked', + 'Orca can reach this host again, so use its terminals instead. Plain SSH is only for while Orca can’t set up on the host.' + ) + case 'already-open': + return translate( + 'auto.components.plainSshTerminal.alreadyOpen', + 'Another Orca window already has a plain SSH terminal open to this host.' + ) + case 'cancelled': + return translate( + 'auto.components.plainSshTerminal.cancelled', + 'This plain SSH terminal was closed before it started.' + ) + case 'ssh-missing': + case 'spawn-failed': + return result.message + } +} + +function resolveThemeColors( + settings: GlobalSettings | null, + systemPrefersDark: boolean +): { theme: ITheme | null; mode: 'dark' | 'light' } { + if (!settings) { + return { theme: null, mode: 'dark' } + } + const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark) + return { + theme: composeActiveTerminalTheme( + appearance.theme ?? getBuiltinTheme(appearance.themeName), + settings + ), + mode: appearance.mode + } +} + +/** One plain SSH session on Orca's standalone terminal; it starts on mount and hangs up on unmount. */ +export function PlainSshTerminalView({ + targetId, + hidden, + onPhase +}: { + targetId: string + hidden: boolean + onPhase: (phase: PlainSshTerminalPhase) => void +}): React.JSX.Element { + const containerRef = useRef(null) + const terminalRef = useRef(null) + const fitRef = useRef(null) + const settings = useAppStore((s) => s.settings) + const systemPrefersDark = useSystemPrefersDark() + const macOptionAsAlt = useEffectiveMacOptionAsAlt(settings?.terminalMacOptionAsAlt) + // Why refs: keys and appearance read live values; a remount would hang up the SSH session. + const settingsRef = useRef(settings) + const systemPrefersDarkRef = useRef(systemPrefersDark) + const macOptionAsAltRef = useRef(macOptionAsAlt) + const onPhaseRef = useRef(onPhase) + useLayoutEffect(() => { + settingsRef.current = settings + systemPrefersDarkRef.current = systemPrefersDark + macOptionAsAltRef.current = macOptionAsAlt + onPhaseRef.current = onPhase + }, [settings, systemPrefersDark, macOptionAsAlt, onPhase]) + + useEffect(() => { + const container = containerRef.current + const api = window.api.plainSsh + if (!container || !api) { + return + } + const mountSettings = settingsRef.current + const { theme, mode } = resolveThemeColors(mountSettings, systemPrefersDarkRef.current) + const kittyKeyboardModes = new TerminalKittyKeyboardModeTracker({ kittyKeyboard: true }) + let sessionId: string | null = null + const standalone = openStandaloneTerminal({ + container, + options: buildPreviewTerminalOptions({ + settings: mountSettings, + terminalInput: null, + macOptionIsMeta: macOptionAsAltRef.current === 'true', + theme, + themeMode: mode, + cols: 80, + rows: 24, + scrollback: SCROLLBACK_ROWS + }), + kittyKeyboardModes, + sessionKey: `plain-ssh:${targetId}`, + sendInput: (data) => { + if (!sessionId) { + return false + } + api.write({ sessionId, data }) + return true + }, + // Why: this xterm is the SSH session's only emulator, so remote programs wait on its replies. + answersQueries: true, + getSettings: () => settingsRef.current, + getMacOptionAsAlt: () => macOptionAsAltRef.current, + getTerminalInput: () => null + }) + if (!standalone) { + onPhaseRef.current({ + kind: 'failed', + message: translate( + 'auto.components.plainSshTerminal.terminalUnavailable', + 'Orca couldn’t open a terminal here.' + ) + }) + return + } + const { terminal } = standalone + const fit = new FitAddon() + terminal.loadAddon(fit) + terminalRef.current = terminal + fitRef.current = fit + fit.fit() + + let phase: 'opening' | 'open' | 'done' = 'opening' + let disposed = false + const early: PlainSshTerminalEvent[] = [] + let earlyDataChars = 0 + // Why: output dropped past the early cap was never rendered, but main still counts it unacked. + const droppedEarlyChars = new Map() + let unsubscribe: () => void = () => {} + const finish = (next: PlainSshTerminalPhase | null): void => { + phase = 'done' + sessionId = null + early.length = 0 + earlyDataChars = 0 + droppedEarlyChars.clear() + unsubscribe() + if (next) { + onPhaseRef.current(next) + } + } + const handle = (event: PlainSshTerminalEvent, id: string): void => { + if (event.kind === 'data') { + standalone.write(event.data, { + live: true, + onDone: () => { + if (!disposed) { + api.ack({ sessionId: id, chars: event.data.length }) + } + } + }) + return + } + // A clean logout closes the window; anything else keeps its output, with no shell after it. + const clean = event.exitCode === 0 && !event.signal + finish(clean ? null : { kind: 'ended', exitCode: event.exitCode, signal: event.signal }) + if (clean) { + removePlainSshPanel(targetId) + } + } + unsubscribe = api.onEvent((event) => { + if (phase === 'opening') { + // Exits are always kept: a session that ended before its id arrived must still end here. + if (event.kind === 'exit') { + early.push(event) + } else if ( + // Why: once a session drops output, later chunks would leave a hole mid escape sequence. + !droppedEarlyChars.has(event.sessionId) && + earlyDataChars + event.data.length <= EARLY_DATA_CHAR_LIMIT + ) { + earlyDataChars += event.data.length + early.push(event) + } else { + droppedEarlyChars.set( + event.sessionId, + (droppedEarlyChars.get(event.sessionId) ?? 0) + event.data.length + ) + } + } else if (phase === 'open' && sessionId && event.sessionId === sessionId) { + handle(event, sessionId) + } + }) + void api + .open({ targetId, cols: terminal.cols, rows: terminal.rows }) + .then((result) => { + if (disposed) { + if (result.ok) { + api.close({ sessionId: result.sessionId }) + } + return + } + if (!result.ok) { + finish({ kind: 'failed', message: plainSshOpenFailureText(result) }) + return + } + const id = result.sessionId + sessionId = id + phase = 'open' + onPhaseRef.current({ kind: 'open' }) + const dropped = droppedEarlyChars.get(id) + droppedEarlyChars.clear() + if (dropped) { + api.ack({ sessionId: id, chars: dropped }) + } + earlyDataChars = 0 + for (const event of early.splice(0)) { + if (phase === 'open' && event.sessionId === id) { + handle(event, id) + } + } + terminal.focus() + }) + .catch((error: unknown) => { + if (!disposed) { + finish({ + kind: 'failed', + message: error instanceof Error ? error.message : String(error) + }) + } + }) + const resizeListener = terminal.onResize(({ cols, rows }) => { + if (sessionId) { + api.resize({ sessionId, cols, rows }) + } + }) + const observer = new ResizeObserver(() => { + if (container.offsetWidth > 0 && container.offsetHeight > 0) { + fit.fit() + } + }) + observer.observe(container) + return () => { + disposed = true + observer.disconnect() + resizeListener.dispose() + unsubscribe() + if (sessionId) { + api.close({ sessionId }) + } + standalone.dispose() + terminalRef.current = null + fitRef.current = null + } + }, [targetId]) + + // Why: appearance and Option-as-Alt changes must land live; reopening would hang up ssh. + useEffect(() => { + const terminal = terminalRef.current + if (!terminal) { + return + } + const { theme, mode } = resolveThemeColors(settings, systemPrefersDark) + Object.assign(terminal.options, { + ...buildPreviewAppearanceOptions(settings, macOptionAsAlt === 'true'), + theme: theme ?? undefined, + minimumContrastRatio: resolveTerminalMinimumContrastRatio( + theme?.background, + mode, + settings?.terminalMinimumContrastRatio + ) + }) + syncPreviewTerminalLigatures(terminal, settings) + // Why the guard: a minimized panel measures 0x0, and fitting would shrink the remote pty to 2x1. + const container = containerRef.current + if (!hidden && container && container.offsetWidth > 0 && container.offsetHeight > 0) { + fitRef.current?.fit() + } + }, [settings, systemPrefersDark, macOptionAsAlt, hidden]) + + useEffect(() => { + if (!hidden) { + fitRef.current?.fit() + terminalRef.current?.focus() + } + }, [hidden]) + + return
+} diff --git a/src/renderer/src/components/settings/SshTargetServerStatus.tsx b/src/renderer/src/components/settings/SshTargetServerStatus.tsx index af6710ed915..e35622b2ca4 100644 --- a/src/renderer/src/components/settings/SshTargetServerStatus.tsx +++ b/src/renderer/src/components/settings/SshTargetServerStatus.tsx @@ -10,6 +10,7 @@ import { } from './SshHostKeepServerVersionDialog' import { SshHostDeltaMoveDialog } from './SshHostDeltaMoveDialog' import { SshHostNodeMirrorAction } from './SshHostNodeMirrorAction' +import { OpenPlainSshTerminalButton } from '../plain-ssh-terminal/OpenPlainSshTerminalButton' import { canMoveSshHostAnyway, SshHostMoveAnywayDialog } from './SshHostMoveAnywayAction' import { canRestartSshHostServer, SshHostRestartServerDialog } from './SshHostRestartServerAction' import { getManagedOrcadApi } from './use-managed-server-catalog' @@ -48,6 +49,11 @@ export function SshTargetServerStatusLine({ {line.hint ?

{line.hint}

: null} {/* Why inline, not in the menu: it is the way out of this failure. */} +
) } diff --git a/src/renderer/src/components/standalone-terminal/open-standalone-terminal.test.ts b/src/renderer/src/components/standalone-terminal/open-standalone-terminal.test.ts new file mode 100644 index 00000000000..442e7e4a442 --- /dev/null +++ b/src/renderer/src/components/standalone-terminal/open-standalone-terminal.test.ts @@ -0,0 +1,86 @@ +// @vitest-environment happy-dom + +import { describe, expect, it, vi } from 'vitest' +import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' +import { openStandaloneTerminal } from './open-standalone-terminal' + +let emitData: (data: string) => void = () => {} +let signalUserInput: () => void = () => {} + +vi.mock('@xterm/xterm', () => ({ + Terminal: class { + open(): void {} + dispose(): void {} + write(_data: string, done?: () => void): void { + done?.() + } + onData(listener: (data: string) => void): { dispose: () => void } { + emitData = listener + return { dispose: () => {} } + } + } +})) +vi.mock('@xterm/xterm/css/xterm.css', () => ({})) +vi.mock('@/components/terminal-pane/terminal-user-input-signal', () => ({ + subscribeToTerminalUserInput: (_terminal: unknown, listener: () => void) => { + signalUserInput = listener + return { dispose: () => {} } + } +})) +vi.mock('@/components/terminal-pane/terminal-native-copy-gutter', () => ({ + installTerminalNativeCopyGutterTrim: () => ({ dispose: () => {} }) +})) +vi.mock('../dashboard-popout/preview-terminal-compatibility', () => ({ + installPreviewTerminalCompatibility: () => () => {} +})) +vi.mock('../dashboard-popout/preview-terminal-ime-bridge', () => ({ + installPreviewImeBridge: () => ({ claimKeyEvent: () => false, dispose: () => {} }) +})) +vi.mock('../dashboard-popout/preview-terminal-key-handler', () => ({ + installPreviewTerminalKeyHandler: () => () => {} +})) +vi.mock('../dashboard-popout/preview-terminal-app-menu-clipboard', () => ({ + installPreviewTerminalAppMenuClipboard: () => () => {} +})) +vi.mock('../dashboard-popout/preview-terminal-right-click-paste', () => ({ + installPreviewTerminalRightClickPaste: () => () => {} +})) + +function open(answersQueries: boolean): ReturnType { + const sendInput = vi.fn(() => true) + openStandaloneTerminal({ + container: document.createElement('div'), + options: {}, + kittyKeyboardModes: new TerminalKittyKeyboardModeTracker(), + sessionKey: 's', + sendInput, + answersQueries, + getSettings: () => null, + getMacOptionAsAlt: () => 'false', + getTerminalInput: () => null + }) + return sendInput +} + +const CURSOR_POSITION_REPLY = '\u001b[5;1R' + +describe('standalone terminal input', () => { + it('forwards the user’s typing either way', () => { + const sendInput = open(false) + signalUserInput() + emitData('ls\r') + expect(sendInput).toHaveBeenCalledWith('ls\r') + }) + + it('leaves query replies to the real pane when it only mirrors one', () => { + const sendInput = open(false) + emitData(CURSOR_POSITION_REPLY) + expect(sendInput).not.toHaveBeenCalled() + }) + + it('answers queries when it is the session’s only emulator', () => { + const sendInput = open(true) + emitData(CURSOR_POSITION_REPLY) + expect(sendInput).toHaveBeenCalledWith(CURSOR_POSITION_REPLY) + }) +}) diff --git a/src/renderer/src/components/standalone-terminal/open-standalone-terminal.ts b/src/renderer/src/components/standalone-terminal/open-standalone-terminal.ts new file mode 100644 index 00000000000..0606ebac0a6 --- /dev/null +++ b/src/renderer/src/components/standalone-terminal/open-standalone-terminal.ts @@ -0,0 +1,161 @@ +/** + * One xterm outside the pane system, with everything a typed-into terminal needs: Orca's Unicode + * widths, links and IME handling, the pane's copy/paste and shortcut policy, kitty keyboard flags, + * and input that never forwards xterm's own replies to replayed output. Callers own the transport + * (where output comes from, where input goes, and the backpressure ack in `write`'s callback). + * Used by the agent dashboard preview and the plain SSH terminal. + */ +import { Terminal, type ITerminalInitOnlyOptions, type ITerminalOptions } from '@xterm/xterm' +import '@xterm/xterm/css/xterm.css' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot' +import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' +import { getShortcutPlatform } from '@/lib/shortcut-platform' +import { useAppStore } from '@/store' +import type { MacOptionAsAlt } from '@/components/terminal-pane/terminal-shortcut-policy' +import { subscribeToTerminalUserInput } from '@/components/terminal-pane/terminal-user-input-signal' +import { installTerminalNativeCopyGutterTrim } from '@/components/terminal-pane/terminal-native-copy-gutter' +import { isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers' +import { installPreviewTerminalCompatibility } from '../dashboard-popout/preview-terminal-compatibility' +import { createPreviewClipboardPaster } from '../dashboard-popout/preview-terminal-paste' +import { installPreviewImeBridge } from '../dashboard-popout/preview-terminal-ime-bridge' +import { installPreviewTerminalKeyHandler } from '../dashboard-popout/preview-terminal-key-handler' +import { installPreviewTerminalAppMenuClipboard } from '../dashboard-popout/preview-terminal-app-menu-clipboard' +import { installPreviewTerminalRightClickPaste } from '../dashboard-popout/preview-terminal-right-click-paste' + +export type StandaloneTerminalArgs = { + container: HTMLElement + options: ITerminalOptions & ITerminalInitOnlyOptions + /** Owned by the caller so it can outlive a terminal the caller recreates. */ + kittyKeyboardModes: TerminalKittyKeyboardModeTracker + /** Names the session for paste planning; a remote-runtime pty id routes as remote. */ + sessionKey: string + /** The user's keystrokes and pastes, plus xterm's replies to output when `answersQueries`. */ + sendInput: (data: string) => boolean | Promise + /** + * True when this xterm is the session's only emulator, so programs wait on its replies to + * cursor, device and keyboard queries. A mirror (the preview) leaves them to the real pane. + */ + answersQueries: boolean + getSettings: () => GlobalSettings | null + getMacOptionAsAlt: () => MacOptionAsAlt + getTerminalInput: () => DashboardCardTerminalInput | null +} + +export type StandaloneTerminal = { + terminal: Terminal + /** + * `live`: new output, scanned for kitty pushes as they happen; otherwise replayed bytes, applied + * idempotently. `onDone` runs once xterm has parsed the chunk, which is when to ack backpressure. + */ + write: (chunk: string, options: { live: boolean; onDone?: () => void }) => void + dispose: () => void +} + +/** Null when xterm can't open in the container. */ +export function openStandaloneTerminal(args: StandaloneTerminalArgs): StandaloneTerminal | null { + const { container, kittyKeyboardModes } = args + const terminal = new Terminal(args.options) + try { + terminal.open(container) + } catch { + terminal.dispose() + return null + } + let disposed = false + let replayDepth = 0 + + const disposeCompatibility = installPreviewTerminalCompatibility(terminal, { + getSettings: args.getSettings + }) + const disposeNativeCopyGutterTrim = installTerminalNativeCopyGutterTrim(terminal).dispose + + let pendingUserInputSignals = 0 + const userInputDisposable = subscribeToTerminalUserInput(terminal, () => { + pendingUserInputSignals = Math.min(32, pendingUserInputSignals + 1) + }) + terminal.onData((data) => { + const signaledUserInput = pendingUserInputSignals > 0 + if (signaledUserInput) { + pendingUserInputSignals-- + } + // Why: core's signal distinguishes real input from parser replies, so typing survives live replay without forwarding synthetic CPR/DA bytes. + if (!args.answersQueries && (userInputDisposable ? !signaledUserInput : replayDepth > 0)) { + return + } + void args.sendInput(data) + }) + + // Why a live getter: kitty state can change between keydown and commit. + const imeBridge = installPreviewImeBridge(terminal, { + getKittyKeyboardFlags: () => kittyKeyboardModes.flags + }) + const pasteClipboardText = createPreviewClipboardPaster({ + ptyId: args.sessionKey, + writeInput: args.sendInput, + container, + getTerminal: () => (disposed ? null : terminal), + getTerminalInput: args.getTerminalInput, + isDisposed: () => disposed + }) + const disposeKeyHandler = installPreviewTerminalKeyHandler({ + terminal, + claimImeKeyEvent: (event) => imeBridge?.claimKeyEvent(event) ?? false, + pasteClipboardText: (activeElement, source) => void pasteClipboardText(activeElement, source), + // Why: route through terminal.input so the chord's bytes carry core's user-input signal, like typed keys. + sendInput: (data) => terminal.input(data), + getShortcutContext: () => ({ + clientPlatform: getShortcutPlatform(), + macOptionAsAlt: args.getMacOptionAsAlt(), + keybindings: useAppStore.getState().keybindings, + terminalInput: args.getTerminalInput(), + getKittyKeyboardFlags: () => kittyKeyboardModes.flags, + terminalShortcutPolicy: args.getSettings()?.terminalShortcutPolicy + }) + }) + const disposeAppMenuClipboard = installPreviewTerminalAppMenuClipboard({ + container, + getTerminal: () => (disposed ? null : terminal), + pasteClipboardText: (activeElement, source) => void pasteClipboardText(activeElement, source) + }) + const disposeRightClickPaste = installPreviewTerminalRightClickPaste({ + container, + getTerminal: () => (disposed ? null : terminal), + // Same default as the pane: Windows users expect terminal-style right-click. + isRightClickToPasteEnabled: () => + args.getSettings()?.terminalRightClickToPaste ?? isWindowsUserAgent(), + pasteClipboardText: (activeElement, source) => void pasteClipboardText(activeElement, source) + }) + + return { + terminal, + write: (chunk, { live, onDone }) => { + // Why: a redelivered snapshot repeats the TUI's one-time kitty push, so + // replayed bytes must apply as idempotent sets (see the tracker's docs). + if (live) { + kittyKeyboardModes.scan(chunk) + } else { + kittyKeyboardModes.scanReplay(chunk) + } + replayDepth++ + terminal.write(chunk, () => { + replayDepth-- + onDone?.() + }) + }, + dispose: () => { + if (disposed) { + return + } + disposed = true + userInputDisposable?.dispose() + imeBridge?.dispose() + disposeCompatibility() + disposeKeyHandler() + disposeNativeCopyGutterTrim() + disposeAppMenuClipboard() + disposeRightClickPaste() + terminal.dispose() + } + } +} diff --git a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx index 97a550f71ad..0eebaea2496 100644 --- a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx @@ -4,6 +4,7 @@ import { toastSshConnectFailure } from '@/ssh/ssh-connect-failure-toast' import { Button } from '@/components/ui/button' import { SshHostNodeMirrorAction } from '@/components/settings/SshHostNodeMirrorAction' import { SshHostMoveAnywayButton } from '@/components/settings/SshHostMoveAnywayAction' +import { OpenPlainSshTerminalButton } from '@/components/plain-ssh-terminal/OpenPlainSshTerminalButton' import { useAppStore } from '@/store' import type { SshConnectionStatus } from '../../../../shared/ssh-types' import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host' @@ -220,6 +221,12 @@ export function TerminalSshReconnectOverlay({ disabled={isConnecting} /> )} + ) : null} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 37cd0338523..7f5a2b06097 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18585,6 +18585,20 @@ "copyFailed": "Could not copy the link.", "signingIn": "Signing in…", "cancel": "Cancel" + }, + "plainSshTerminal": { + "open": "Open plain SSH terminal", + "unmanaged": "Plain SSH — unmanaged. Not an Orca workspace: agents and tabs here aren’t tracked, and this terminal isn’t restored. Closing it ends the SSH session.", + "ended": "ssh exited with code {{code}}. Close this window when you’re done reading.", + "title": "Plain SSH · {{host}}", + "minimize": "Minimize", + "close": "Close and end session", + "unknownTarget": "This SSH host is no longer saved in Orca.", + "notBlocked": "Orca can reach this host again, so use its terminals instead. Plain SSH is only for while Orca can’t set up on the host.", + "alreadyOpen": "Another Orca window already has a plain SSH terminal open to this host.", + "endedBySignal": "ssh was stopped (signal {{signal}}). Close this window when you’re done reading.", + "cancelled": "This plain SSH terminal was closed before it started.", + "terminalUnavailable": "Orca couldn’t open a terminal here." } }, "i18n": { diff --git a/src/renderer/src/ssh/plain-ssh-terminal-panels.ts b/src/renderer/src/ssh/plain-ssh-terminal-panels.ts new file mode 100644 index 00000000000..6338e622c48 --- /dev/null +++ b/src/renderer/src/ssh/plain-ssh-terminal-panels.ts @@ -0,0 +1,45 @@ +/** + * Which plain SSH terminal windows are open, one per host. Deliberately outside the app store and + * the workspace session: these windows are never persisted, restored or synced. + */ +import { useSyncExternalStore } from 'react' + +export type PlainSshPanel = { targetId: string; label: string; minimized: boolean } + +let panels: readonly PlainSshPanel[] = [] +const listeners = new Set<() => void>() + +function publish(next: readonly PlainSshPanel[]): void { + panels = next + for (const listener of listeners) { + listener() + } +} + +/** Shows the host's window, opening it if needed; only one is expanded at a time. */ +export function showPlainSshPanel(targetId: string, label: string): void { + const rest = panels + .filter((panel) => panel.targetId !== targetId) + .map((panel) => (panel.minimized ? panel : { ...panel, minimized: true })) + const current = panels.find((panel) => panel.targetId === targetId) + publish([...rest, { targetId, label: current?.label ?? label, minimized: false }]) +} + +export function minimizePlainSshPanel(targetId: string): void { + publish( + panels.map((panel) => (panel.targetId === targetId ? { ...panel, minimized: true } : panel)) + ) +} + +export function removePlainSshPanel(targetId: string): void { + publish(panels.filter((panel) => panel.targetId !== targetId)) +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function usePlainSshPanels(): readonly PlainSshPanel[] { + return useSyncExternalStore(subscribe, () => panels) +} diff --git a/src/shared/plain-ssh-terminal.ts b/src/shared/plain-ssh-terminal.ts new file mode 100644 index 00000000000..b97552a32bc --- /dev/null +++ b/src/shared/plain-ssh-terminal.ts @@ -0,0 +1,23 @@ +/** The plain SSH terminal's IPC contract: open by saved target id, then talk by session id. */ +export const PLAIN_SSH_TERMINAL_CHANNELS = { + open: 'plainSsh:open', + write: 'plainSsh:write', + resize: 'plainSsh:resize', + close: 'plainSsh:close', + /** Output the window has rendered, in UTF-16 units of `data`; ssh is paused while too much is unrendered. */ + ack: 'plainSsh:ack', + event: 'plainSsh:event' +} as const + +export type PlainSshTerminalOpenResult = + | { ok: true; sessionId: string; label: string } + /** `already-open`: another window holds this host's one session. */ + | { ok: false; reason: 'unknown-target' | 'not-blocked' | 'already-open' } + /** A newer open from the same window, a reload or quit took this one's place. */ + | { ok: false; reason: 'cancelled' } + | { ok: false; reason: 'ssh-missing' | 'spawn-failed'; message: string } + +export type PlainSshTerminalEvent = + | { kind: 'data'; sessionId: string; data: string } + /** `signal`: set when ssh was killed, so a zero `exitCode` is not a clean logout. */ + | { kind: 'exit'; sessionId: string; exitCode: number; signal?: number } diff --git a/src/shared/ssh-host-move-blocked.ts b/src/shared/ssh-host-move-blocked.ts new file mode 100644 index 00000000000..4719e902b8f --- /dev/null +++ b/src/shared/ssh-host-move-blocked.ts @@ -0,0 +1,9 @@ +import type { SshManagedServerStatus } from './ssh-types' + +/** + * A host its Orca server doesn't serve, so this desktop can't open Orca terminals there. A bridged + * host stays connected for its old terminals, so it isn't blocked. + */ +export function isSshHostMoveBlocked(status: SshManagedServerStatus | undefined): boolean { + return status?.kind === 'relay' && !status.bridged +}