/* oxlint-disable max-lines */ // Why: consolidates all relay lifecycle state (multiplexer, providers, abort // controller, initialization flag) into a single class per SSH target. // Previously this state was scattered across 5 module-level Maps/Sets in // ssh.ts and ssh-relay-helpers.ts, with 3 separate code paths for initial // connect, network-blip reconnect, and cleanup — each partially duplicating // provider registration/teardown logic. This class is the single authority // for relay session state, eliminating the class of bugs where one path // forgets a step that another path handles. import type { BrowserWindow } from 'electron' import { deployAndLaunchRelay } from './ssh-relay-deploy' import { execCommand } from './ssh-relay-deploy-helpers' import { isRelayVersionMismatchError } from './ssh-relay-version-mismatch-error' import type { RelayVersionMismatchError } from './ssh-relay-version-mismatch-error' import { SshChannelMultiplexer } from './ssh-channel-multiplexer' import { SshPtyProvider, isSshPtyIdentityMismatchError, isSshPtyNotFoundError } from '../providers/ssh-pty-provider' import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' import { SshGitProvider } from '../providers/ssh-git-provider' import { agentHookServer } from '../agent-hooks/server' import { installRemoteManagedAgentHooks } from '../agent-hooks/remote-managed-hook-installers' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD, AGENT_HOOK_NOTIFICATION_METHOD, AGENT_HOOK_REQUEST_REPLAY_METHOD, isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay' import { _internals as openCodeInternals } from '../opencode/hook-service' import { getPiAgentStatusExtensionSource } from '../pi/agent-status-extension-source' import { registerSshPtyProvider, unregisterSshPtyProvider, getSshPtyProvider, getPtyIdsForConnection, clearPtyOwnershipForConnection, clearProviderPtyState, deletePtyOwnership, setPtyOwnership, answerStartupTerminalColorQueriesForPty } from '../ipc/pty' import { recordHiddenRendererPtyDataDrop, shouldDropHiddenRendererPtyData } from '../ipc/pty-hidden-delivery-gate' import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker' import { registerSshFilesystemProvider, unregisterSshFilesystemProvider, getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' import { notifyRemoteWorkspaceHandlers } from '../ipc/remote-workspace-events' import { PortScanner } from './ssh-port-scanner' import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility' import type { SshPortForwardManager } from './ssh-port-forward' import type { SshConnection } from './ssh-connection' import { joinRemotePath, isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' import { makeRemoteDirectoryCommand, makeRemoteExecutableCommand } from './ssh-remote-commands' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, type DetectedPort, MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../../shared/ssh-types' import type { Store } from '../persistence' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' import { isValidTerminalTabId } from '../../shared/terminal-tab-id' import { shellEscape } from './ssh-connection-utils' export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed' type RemoteCliBridgeEnv = { remoteHome: string binDir: string relayDir: string nodePath: string sockPath: string hostPlatform: RemoteHostPlatform pathDelimiter?: ':' | ';' } type ExpectedPtyIdentity = { paneKey?: string; tabId?: string } const REMOTE_GROK_HOME_MAX_LENGTH = 4096 const REMOTE_GROK_HOME_PROBE_TIMEOUT_MS = 8_000 function defaultRemoteGrokHome(remoteHome: string): string { const home = remoteHome.replace(/\/+$/, '') || remoteHome return `${home}/.grok` } function normalizeRemoteGrokHome(candidate: string): string | null { if ( candidate.length === 0 || candidate.length > REMOTE_GROK_HOME_MAX_LENGTH || candidate !== candidate.trim() || !candidate.startsWith('/') || candidate.includes('\\') || hasControlCharacter(candidate) ) { return null } return candidate.replace(/\/+$/, '') || '/' } function hasControlCharacter(value: string): boolean { return Array.from(value).some((character) => { const code = character.charCodeAt(0) return code <= 0x1f || code === 0x7f }) } function loginShellCommand(shell: string, command: string): string { const shellName = shell.split('/').at(-1) const mode = shellName === 'sh' || shellName === 'dash' ? '-c' : '-lc' return `${shellEscape(shell)} ${mode} ${shellEscape(command)}` } async function resolveRemoteGrokHome( connection: SshConnection, remoteHome: string ): Promise { const fallback = defaultRemoteGrokHome(remoteHome) try { // Why: remote PTYs start login shells, so probe the same profile-derived // environment instead of the relay service or local Electron environment. const shellOutput = await execCommand(connection, "printenv SHELL || printf '/bin/sh\\n'", { timeoutMs: REMOTE_GROK_HOME_PROBE_TIMEOUT_MS }) const shell = shellOutput.trim().split(/\r?\n/, 1)[0] if (!shell?.startsWith('/') || hasControlCharacter(shell)) { return fallback } // Why: this runs inside the user's actual login shell, which may be fish or // tcsh. External commands avoid shell-specific variable/conditional syntax. const probe = `printenv GROK_HOME | head -c ${REMOTE_GROK_HOME_MAX_LENGTH + 1}` const output = await execCommand(connection, loginShellCommand(shell, probe), { wrapCommand: false, timeoutMs: REMOTE_GROK_HOME_PROBE_TIMEOUT_MS }) return normalizeRemoteGrokHome(output.split(/\r?\n/, 1)[0] ?? '') ?? fallback } catch { return fallback } } function expectedIdentityForLease(lease: { tabId?: string leafId?: string }): ExpectedPtyIdentity | null { if (typeof lease.tabId !== 'string' || lease.tabId.length === 0) { return null } const paneKey = isValidTerminalTabId(lease.tabId) && typeof lease.leafId === 'string' && isTerminalLeafId(lease.leafId) ? makePaneKey(lease.tabId, lease.leafId) : undefined return { ...(paneKey ? { paneKey } : {}), tabId: lease.tabId } } type ForwardedReplayFingerprint = { fingerprint: string deliveredAt: number } export type SshRelayAiVaultHostInfo = { targetId: string executionHostId: ExecutionHostId remoteHome: string hostPlatform: RemoteHostPlatform } const RECONNECT_REPLAY_DUPLICATE_WINDOW_MS = 1000 const REPLAY_FINGERPRINT_EDGE_CHARS = 128 function normalizeRelayGracePeriodSeconds(graceTimeSeconds: number | undefined): number { const raw = graceTimeSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS const requested = Number.isFinite(raw) ? Math.floor(raw) : DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS return requested === 0 ? 0 : Math.max( MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, Math.min(MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, requested) ) } export class SshRelaySession { private _state: RelaySessionState = 'idle' private mux: SshChannelMultiplexer | null = null private abortController: AbortController | null = null private muxDisposeCleanup: (() => void) | null = null // Why: store the notification-handler disposer so teardownProviders can // release it on reconnect/shutdown. Symmetric with muxDisposeCleanup; while // the old mux's handler array is GC'd along with the mux today, holding the // disposer is cheap insurance against future code that retains the old mux. private muxNotificationCleanup: (() => void) | null = null // Why: when the relay exec channel closes but the SSH connection stays // up, the onStateChange reconnect path never fires. This callback lets // ssh.ts wire up relay-level reconnect from outside the session. private _onRelayLost: ((targetId: string) => void) | null = null // Why: a wire-handshake mismatch is terminal — the daemon and client are at // different versions, no amount of backoff retry will reconcile them. This // separate callback lets ssh.ts surface the failure to the user and skip // the relay-lost backoff loop entirely. Distinct from _onRelayLost because // _onRelayLost expects a recoverable transport drop. private _onTerminalRelayError: | ((targetId: string, err: RelayVersionMismatchError) => void) | null = null private _onReady: ((targetId: string) => void) | null = null private portScanner: PortScanner | null = null private currentConnection: SshConnection | null = null private hostPlatform: RemoteHostPlatform | null = null private remoteCliBridgeEnv: RemoteCliBridgeEnv | null = null private forwardedReattachReplayByPty = new Map() constructor( readonly targetId: string, private getMainWindow: () => BrowserWindow | null, private store: Store, private portForwardManager: SshPortForwardManager, private runtime?: OrcaRuntimeService, private onDetectedPortsChanged?: ( targetId: string, ports: DetectedPort[], platform: string ) => void ) {} refreshEnvironment( getMainWindow: () => BrowserWindow | null, store: Store, portForwardManager: SshPortForwardManager, runtime?: OrcaRuntimeService, onDetectedPortsChanged?: (targetId: string, ports: DetectedPort[], platform: string) => void ): void { this.getMainWindow = getMainWindow this.store = store this.portForwardManager = portForwardManager this.runtime = runtime this.onDetectedPortsChanged = onDetectedPortsChanged } setOnRelayLost(cb: (targetId: string) => void): void { this._onRelayLost = cb } setOnTerminalRelayError(cb: (targetId: string, err: RelayVersionMismatchError) => void): void { this._onTerminalRelayError = cb } setOnReady(cb: (targetId: string) => void): void { this._onReady = cb } getState(): RelaySessionState { return this._state } // Why: TypeScript narrows _state after control-flow checks and then // rejects comparisons like `this._state === 'disposed'` inside async // methods where it "knows" the state was e.g. 'deploying'. But dispose() // can mutate _state from another call stack between await points. This // helper defeats narrowing so the disposed checks compile correctly. private isDisposed(): boolean { return (this._state as RelaySessionState) === 'disposed' } private requireReadyConnection(): SshConnection { if (!this.currentConnection) { throw new Error('SSH connection is not active') } return this.currentConnection } getMux(): SshChannelMultiplexer | null { return this.mux } getHostPlatform(): RemoteHostPlatform | null { return this.remoteCliBridgeEnv?.hostPlatform ?? this.hostPlatform } getAiVaultHostInfo(): SshRelayAiVaultHostInfo | null { const env = this.remoteCliBridgeEnv if (!env) { return null } return { targetId: this.targetId, executionHostId: toSshExecutionHostId(this.targetId), remoteHome: env.remoteHome, hostPlatform: env.hostPlatform } } getPortScanner(): PortScanner | null { return this.portScanner } prepareForHostSleep(): void { const mux = this.mux if (!mux || mux.isDisposed() || this.isDisposed()) { return } mux.notify(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { graceTimeSeconds: 0 }) } // Why: single entry point for relay setup — used by both initial connect // and app-restart reconnect. Having one path eliminates the risk of // forgetting a registration step. async establish(conn: SshConnection, graceTimeSeconds?: number): Promise { if (this._state !== 'idle') { throw new Error(`Cannot establish relay session in state: ${this._state}`) } this._state = 'deploying' this.currentConnection = conn try { const { transport, remoteHome, remoteRelayDir, nodePath, sockPath, hostPlatform } = await deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId) this.hostPlatform = hostPlatform ?? null this.remoteCliBridgeEnv = remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform ? { remoteHome, binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'), relayDir: remoteRelayDir, nodePath, sockPath, hostPlatform, pathDelimiter: hostPlatform.pathDelimiter } : null // Why: dispose() can fire during the await above (e.g. user clicks // disconnect while relay is deploying). If so, the session is already // cleaned up — creating a mux and registering providers would leak // resources with no owner to dispose them. if (this.isDisposed()) { const orphanMux = new SshChannelMultiplexer(transport) orphanMux.dispose() throw new Error('Session disposed during establish') } const mux = new SshChannelMultiplexer(transport) this.mux = mux const ownsAttempt = (): boolean => this.mux === mux && !this.isDisposed() // Why: verify the relay is actually responsive before registering // providers. In --connect mode the bridge may have already closed // (e.g. the grace-period relay exited because it had no PTYs), and // registerRelayRoots would silently swallow all mux errors, leaving // the session in 'ready' state with a dead mux. A round-trip request // here fails fast so doConnect() can report the real error. await mux.request('session.resolveHome', { path: '~' }) const registered = await this.registerProviders(mux, ownsAttempt) if (!registered) { if (!mux.isDisposed()) { mux.dispose() } throw new Error('Session disposed during establish') } // Why: the mux's transport can close during registerProviders (e.g. // the --connect bridge exits). registerRelayRoots swallows mux errors // (notifications no-op when disposed, git.listWorktrees requests are // try/caught), so establish would otherwise reach 'ready' with a dead // mux. Checking isDisposed catches this silent failure. if (mux.isDisposed()) { throw new Error('Relay connection lost during provider registration') } if (this.isDisposed()) { this.teardownProviders('connection_lost') throw new Error('Session disposed during establish') } // Why: explicit disconnect keeps PTY ownership so a later manual connect // must reattach those remote PTYs through the fresh relay connection. await this.reattachKnownPtys(ownsAttempt) if (!ownsAttempt()) { throw new Error('Session disposed during establish') } this.configureRelayGraceTime(mux, graceTimeSeconds) this.watchMuxForRelayLoss(mux) this._state = 'ready' this.startPortScanning() this._onReady?.(this.targetId) } catch (err) { // Why: if deployAndLaunchRelay succeeded but registerProviders threw // partway through, the mux is live and some providers may be partially // registered. teardownProviders cleans up everything so a subsequent // establish() call starts from a clean slate. if (!this.isDisposed()) { this.teardownProviders('connection_lost') this._state = 'idle' } // Why: a wire-handshake mismatch on the FIRST connect is also terminal // — the deployed relay binary on disk does not match a still-running // daemon (typically because a legacy daemon from before the // versioned-dir change is still alive). Notify the terminal-error // callback so ssh.ts surfaces an actionable message and the caller's // catch path doesn't conflate this with a transient deploy failure. // We still rethrow so doConnect's existing failure path runs (clean up // the SSH connection); ssh.ts's handler is idempotent. if (isRelayVersionMismatchError(err)) { console.warn( `[ssh-relay-session] Terminal relay version mismatch on initial connect for ${this.targetId}: ${err.message}` ) this._onTerminalRelayError?.(this.targetId, err) } throw err } } // Why: network-blip reconnect path. Tears down the old provider stack, // deploys a fresh relay, and re-attaches any PTYs that survived the // relay's grace window. Guarded by an AbortController so overlapping // reconnect attempts (fast connection flaps) cancel the stale one. async reconnect(conn: SshConnection, graceTimeSeconds?: number): Promise { // Why: only allow reconnect from 'ready' or 'reconnecting'. Calling // reconnect from 'deploying' would tear down a mux that establish() is // concurrently using. 'idle' means no session was established yet. if (this._state !== 'ready' && this._state !== 'reconnecting') { return } // Cancel any in-flight reconnect this.abortController?.abort() const abortController = new AbortController() this.abortController = abortController this._state = 'reconnecting' this.currentConnection = conn // Why: stop scanning before teardownProviders so the polling timer doesn't // fire against a disposed multiplexer. this.stopPortScanning() await this.portForwardManager.removeAllForwards(this.targetId) this.broadcastEmptyLists() this.teardownProviders('connection_lost') try { const { transport, remoteHome, remoteRelayDir, nodePath, sockPath, hostPlatform } = await deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId) this.hostPlatform = hostPlatform ?? null this.remoteCliBridgeEnv = remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform ? { remoteHome, binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'), relayDir: remoteRelayDir, nodePath, sockPath, hostPlatform, pathDelimiter: hostPlatform.pathDelimiter } : null if (abortController.signal.aborted || this.isDisposed()) { // Why: the relay is already running on the remote. Creating a temporary // multiplexer and immediately disposing it sends a clean shutdown to the // relay process so it doesn't linger until its grace timer expires. const orphanMux = new SshChannelMultiplexer(transport) orphanMux.dispose() return } const mux = new SshChannelMultiplexer(transport) this.mux = mux const ownsAttempt = (): boolean => this.abortController === abortController && !abortController.signal.aborted && !this.isDisposed() // Why: same health check as establish() — verify the relay is // responsive before registering providers so a dead --connect bridge // fails fast instead of silently producing a dead mux. await mux.request('session.resolveHome', { path: '~' }) if (!ownsAttempt()) { if (!mux.isDisposed()) { mux.dispose() } return } const registered = await this.registerProviders(mux, ownsAttempt) if (!registered) { if (!mux.isDisposed()) { mux.dispose() } return } if (mux.isDisposed()) { throw new Error('Relay connection lost during provider registration') } // Why: dispose() can fire during registerProviders or the attach loop // below. If it did, providers and mux were already cleaned up by // dispose() — but this.mux was reassigned above, so the new mux // would leak. Clean it up and bail. if (!ownsAttempt()) { if (this.mux === mux) { this.teardownProviders('shutdown') } else if (!mux.isDisposed()) { mux.dispose() } return } await this.reattachKnownPtys(ownsAttempt) if (!ownsAttempt()) { return } this.configureRelayGraceTime(mux, graceTimeSeconds) this.watchMuxForRelayLoss(mux) this._state = 'ready' this.startPortScanning() this._onReady?.(this.targetId) } catch (err) { // Why: clean up the mux if it was created but registration failed // partway through. Without this, the mux's keepalive/timeout timers // continue running on a half-initialized session. if (this.abortController === abortController && !this.isDisposed()) { this.teardownProviders('connection_lost') } // Why: a version-mismatch is terminal. Fire the typed callback so // ssh.ts can surface a "please reconnect manually" notice and skip the // relay-lost backoff loop entirely. We do NOT keep state at // 'reconnecting' — there's no transient drop to recover from. if (isRelayVersionMismatchError(err)) { console.warn( `[ssh-relay-session] Terminal relay version mismatch for ${this.targetId}: ${err.message}` ) if (this.abortController === abortController && !this.isDisposed()) { this._state = 'idle' } this._onTerminalRelayError?.(this.targetId, err) return } // Why: stay in 'reconnecting' rather than reverting to 'ready', because // the provider stack is already torn down. The SSH connection manager // will fire another onStateChange when it reconnects again. console.warn( `[ssh-relay-session] Failed to re-establish relay for ${this.targetId}: ${err instanceof Error ? err.message : String(err)}` ) if (this.abortController === abortController && !this.isDisposed()) { // Why: non-not-found PTY attach failures are usually transient mux or // relay transport failures. Treat them like relay loss so ssh.ts's // bounded backoff retries instead of stranding the session forever in // reconnecting. this._onRelayLost?.(this.targetId) } } finally { if (this.abortController === abortController) { this.abortController = null } } } dispose(): void { if (this._state === 'disposed') { return } this.abortController?.abort() this.stopPortScanning() // Why: fire-and-forget — nothing rebinds after dispose, so we don't // need to wait for the OS to release ports. void this.portForwardManager.removeAllForwards(this.targetId) this.broadcastEmptyLists() this.teardownProviders('shutdown') this.store.markSshRemotePtyLeases(this.targetId, 'terminated') this.currentConnection = null this._state = 'disposed' } detach(): void { if (this._state === 'disposed') { return } this.abortController?.abort() this.stopPortScanning() this.broadcastEmptyLists() // Why: app/window disconnect is non-destructive for remote PTYs. The relay // owns the grace timer, so Orca must unregister local providers without // clearing PTY ownership needed for reattach. this.teardownProviders('connection_lost') this.store.markSshRemotePtyLeases(this.targetId, 'detached') this.currentConnection = null this._state = 'disposed' } // ── Private ─────────────────────────────────────────────────────── // Why: when the relay exec channel closes (e.g. --connect bridge exits, // relay process crashes) but the SSH connection stays up, there is no // automatic recovery — onStateChange only fires on SSH-level reconnects. // This watcher detects relay-level channel loss and fires onRelayLost // so ssh.ts can trigger session.reconnect() with the still-live SSH conn. private watchMuxForRelayLoss(mux: SshChannelMultiplexer): void { this.muxDisposeCleanup?.() this.muxDisposeCleanup = mux.onDispose((reason) => { if (reason === 'connection_lost' && this.mux === mux && !this.isDisposed()) { console.warn( `[ssh-relay-session] Relay channel lost for ${this.targetId}, triggering reconnect` ) this._onRelayLost?.(this.targetId) } }) } // Why: shared by establish() and reconnect() — the exact same provider // registration sequence, eliminating the duplication that caused bugs. private async registerProviders( mux: SshChannelMultiplexer, shouldContinue?: () => boolean ): Promise { await this.registerRelayRoots(mux) if (shouldContinue && !shouldContinue()) { return false } await this.installManagedHooksOnRemote(mux) if (shouldContinue && !shouldContinue()) { return false } await this.installPluginsOnRelay(mux) if (shouldContinue && !shouldContinue()) { return false } try { await this.installRemoteOrcaCliShim() } catch (error) { // Why: the remote `orca` CLI shim is a convenience bridge. On session- // limited remotes (MaxSessions=1) the relay bridge holds the only slot, // so this raw-connection install can fail — that must not fail the // whole connection, matching the managed-hook install above. console.warn( `[ssh-relay-session] remote orca CLI shim install failed for ${this.targetId}: ${ error instanceof Error ? error.message : String(error) }` ) } if (shouldContinue && !shouldContinue()) { return false } this.wireUpRemoteOrcaCli(mux) let relayInstanceId: string | undefined try { const status = (await mux.request('relay.status', {})) as { instanceId?: unknown } relayInstanceId = typeof status.instanceId === 'string' && status.instanceId.length > 0 ? status.instanceId : undefined } catch { // Why: older relays lack boot identity. Attach/shutdown identity checks // remain the compatibility guard until the versioned deploy upgrades it. } const ptyProvider = new SshPtyProvider( this.targetId, mux, this.remoteCliBridgeEnv ?? undefined, relayInstanceId ) registerSshPtyProvider(this.targetId, ptyProvider) const fsProvider = new SshFilesystemProvider( this.targetId, mux, () => this.requireReadyConnection().sftp(), { downloadFile: (sourcePath, destinationPath) => this.requireReadyConnection().downloadFile(sourcePath, destinationPath, { hostPlatform: this.remoteCliBridgeEnv?.hostPlatform }), openFileUploadSession: () => this.requireReadyConnection().openFileUploadSession({ hostPlatform: this.remoteCliBridgeEnv?.hostPlatform }), writeBuffer: (remotePath, contents, options) => this.requireReadyConnection().writeBuffer(remotePath, contents, { hostPlatform: this.remoteCliBridgeEnv?.hostPlatform, append: options.append, exclusive: options.exclusive }) } ) registerSshFilesystemProvider(this.targetId, fsProvider) const gitProvider = new SshGitProvider( this.targetId, mux, this.remoteCliBridgeEnv?.hostPlatform ?? null ) registerSshGitProvider(this.targetId, gitProvider) this.wireUpPtyEvents(ptyProvider) this.wireUpAgentHookEvents(mux) this.wireUpRemoteWorkspaceEvents(mux) return true } private configureRelayGraceTime( mux: SshChannelMultiplexer, graceTimeSeconds: number | undefined ): void { mux.notify(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { graceTimeSeconds: normalizeRelayGracePeriodSeconds(graceTimeSeconds) }) } // Why: the relay can inject ORCA_AGENT_HOOK_* env into SSH PTYs, but // hook-script agents (Claude/Codex/Gemini/etc.) still need their config // files on the remote host to call Orca's managed script. Install those // configs before registering the PTY provider so newly spawned agent panes // report status from their first prompt. private async installManagedHooksOnRemote(mux: SshChannelMultiplexer): Promise { if (!isRemoteAgentHooksEnabled() || !this.areAgentStatusHooksEnabled()) { return } if ( this.remoteCliBridgeEnv?.hostPlatform && isWindowsRemoteHost(this.remoteCliBridgeEnv.hostPlatform) ) { // Why: managed hook installers currently emit POSIX hook scripts and paths. // Windows remotes still get relay-injected env plus plugin overlays. return } let remoteHome: string try { const result = (await mux.request('session.resolveHome', { path: '~' })) as { resolvedPath?: unknown } if (typeof result.resolvedPath !== 'string' || result.resolvedPath.length === 0) { console.warn( `[ssh-relay-session] skipped remote managed hook install for ${this.targetId}: could not resolve remote home` ) return } remoteHome = result.resolvedPath } catch (error) { console.warn( `[ssh-relay-session] skipped remote managed hook install for ${this.targetId}: ${ error instanceof Error ? error.message : String(error) }` ) return } let sftp: Awaited> | null = null try { const connection = this.requireReadyConnection() const remoteGrokHome = await resolveRemoteGrokHome(connection, remoteHome) sftp = await connection.sftp() await installRemoteManagedAgentHooks(sftp, remoteHome, { grokHomeDir: remoteGrokHome }) } catch (error) { console.warn( `[ssh-relay-session] remote managed hook install failed for ${this.targetId}: ${ error instanceof Error ? error.message : String(error) }` ) } finally { ;(sftp as { end?: () => void } | null)?.end?.() } } private async installRemoteOrcaCliShim(): Promise { if (!this.remoteCliBridgeEnv) { return } const { binDir, hostPlatform } = this.remoteCliBridgeEnv const shim = buildRemoteCliShim(this.remoteCliBridgeEnv) const conn = this.requireReadyConnection() await execCommand(conn, makeRemoteDirectoryCommand(hostPlatform, binDir), { wrapCommand: !isWindowsRemoteHost(hostPlatform) }) if (typeof conn.writeFile === 'function') { await conn.writeFile(shim.path, shim.contents, { hostPlatform }) } else { const sftp = await conn.sftp() try { await new Promise((resolve, reject) => { const ws = sftp.createWriteStream(shim.path) sftp.once('error', reject) ws.once('close', resolve) ws.once('error', reject) ws.end(shim.contents) }) } finally { sftp.end() } } if (!isWindowsRemoteHost(hostPlatform)) { await execCommand(conn, makeRemoteExecutableCommand(hostPlatform, shim.path)) } } private wireUpRemoteOrcaCli(mux: SshChannelMultiplexer): void { mux.onRequest('orca.cli', async (params) => { if (!this.runtime) { throw new Error('Orca runtime is unavailable') } const argv = Array.isArray(params.argv) ? params.argv.filter((item): item is string => typeof item === 'string') : [] const cwd = typeof params.cwd === 'string' && params.cwd.length > 0 ? params.cwd : '/' const rawEnv = params.env const env = rawEnv && typeof rawEnv === 'object' && !Array.isArray(rawEnv) ? Object.fromEntries( Object.entries(rawEnv).filter( (entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string' ) ) : {} const stdin = typeof params.stdin === 'string' ? params.stdin : undefined return await runRemoteOrcaCli(this.runtime, { argv, cwd, env, ...(stdin !== undefined ? { stdin } : {}) }) }) } // Why: ship the OpenCode plugin / Pi extension source bodies to the relay // so it can materialize overlay dirs and inject OPENCODE_CONFIG_DIR // / PI_CODING_AGENT_DIR into spawn env. The strings change as we add agent // events (recent additions: cursor, pi); pinning them to the relay binary // would force a relay redeploy on every Orca update. See // docs/design/agent-status-over-ssh.md §4 + §8 (commit #7). // // Best-effort: a -32601 from an older relay (no handler installed) is // swallowed; the user just doesn't get OpenCode/Pi status reporting until // they upgrade. Hook-script-based agents use a separate explicit remote // installer flow because that mutates user-owned agent config files. private async installPluginsOnRelay(mux: SshChannelMultiplexer): Promise { if (!isRemoteAgentHooksEnabled() || !this.areAgentStatusHooksEnabled()) { return } try { await mux.request(AGENT_HOOK_INSTALL_PLUGINS_METHOD, { opencodePluginSource: openCodeInternals.getOpenCodePluginSource(), piExtensionSource: getPiAgentStatusExtensionSource('pi'), ompExtensionSource: getPiAgentStatusExtensionSource('omp') }) } catch (err) { // Why: -32601 = older relay without the handler (treat as soft skip). // CONNECTION_LOST / DISPOSED come from the multiplexer when it tears // down mid-flight (routine on session shutdown / reconnect race) — not // a real failure to surface; suppress to avoid log spam on every clean // disconnect. const code = (err as { code?: unknown })?.code if (code === -32601 || code === 'CONNECTION_LOST' || code === 'DISPOSED') { return } if (mux.isDisposed()) { return } console.warn( `[ssh-relay-session] agent_hook.installPlugins failed for ${this.targetId}: ${ err instanceof Error ? err.message : String(err) }` ) } } private areAgentStatusHooksEnabled(): boolean { const store = this.store as { getSettings?: Store['getSettings'] } return isAgentStatusHooksEnabled(store.getSettings?.()) } private wireUpRemoteWorkspaceEvents(mux: SshChannelMultiplexer): void { mux.onNotification((method, params) => { notifyRemoteWorkspaceHandlers(this.targetId, method, params) }) } // Why: route the relay's `agent.hook` JSON-RPC notification into Orca's // shared `agentHookServer` via `ingestRemote`. The wire envelope carries // `connectionId: null` (the relay does not know Orca's local handle); we // stamp the real value here from `this.targetId` so the renderer can drop // in-flight events for connections that have torn down. After wiring is // in place we kick off a request-driven replay so any cached payload from // before the channel was up survives the reconnect — see §5 Path 3. // // The Orca-side mux's `notificationHandlers` is a flat array — each // handler must filter by method name itself. private wireUpAgentHookEvents(mux: SshChannelMultiplexer): void { if (!isRemoteAgentHooksEnabled()) { return } // Why: capture the disposer so teardownProviders can release the // notification handler symmetrically with muxDisposeCleanup. Even though // the disposed mux's handler array is GC'd along with it today, retaining // the disposer makes "registerProviders called twice on the same mux" // safe by future-proofing against duplicate handler registration. this.muxNotificationCleanup?.() this.muxNotificationCleanup = mux.onNotification((method, params) => { if (method !== AGENT_HOOK_NOTIFICATION_METHOD) { return } const envelope = params as { paneKey?: unknown launchToken?: unknown tabId?: unknown worktreeId?: unknown env?: unknown version?: unknown hasExplicitPrompt?: unknown promptInteractionKey?: unknown hookEventName?: unknown toolUseId?: unknown toolAgentId?: unknown toolAgentType?: unknown isReplay?: unknown providerSession?: unknown payload?: unknown } if (typeof envelope.paneKey !== 'string') { return } // Why: forward env/version verbatim so Orca's warn-once cross-build / // dev-vs-prod diagnostics fire on remote events the same as on local // ones — see docs/design/agent-status-over-ssh.md §3 ("Replay / // version mismatch") and the relay's wire envelope at // src/shared/agent-hook-relay.ts. agentHookServer.ingestRemote( { paneKey: envelope.paneKey, launchToken: typeof envelope.launchToken === 'string' ? envelope.launchToken : undefined, tabId: typeof envelope.tabId === 'string' ? envelope.tabId : undefined, worktreeId: typeof envelope.worktreeId === 'string' ? envelope.worktreeId : undefined, env: typeof envelope.env === 'string' ? envelope.env : undefined, version: typeof envelope.version === 'string' ? envelope.version : undefined, hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined, promptInteractionKey: typeof envelope.promptInteractionKey === 'string' ? envelope.promptInteractionKey : undefined, hookEventName: typeof envelope.hookEventName === 'string' ? envelope.hookEventName : undefined, toolUseId: typeof envelope.toolUseId === 'string' ? envelope.toolUseId : undefined, toolAgentId: typeof envelope.toolAgentId === 'string' ? envelope.toolAgentId : undefined, toolAgentType: typeof envelope.toolAgentType === 'string' ? envelope.toolAgentType : undefined, isReplay: envelope.isReplay === true ? true : undefined, providerSession: envelope.providerSession, payload: envelope.payload }, this.targetId ) }) // Why: ask the relay to replay every cached paneKey it remembers. Issued // *after* the handler is wired so the request-driven replay shape // strictly trails our subscription on the dispatcher's single write // callback. Best-effort: a relay that does not know the method // (e.g. older relay binary) returns -32601; CONNECTION_LOST / DISPOSED // arise from mux teardown mid-flight on routine reconnect/shutdown. void mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD).catch((err) => { const code = (err as { code?: unknown })?.code if (code === -32601 || code === 'CONNECTION_LOST' || code === 'DISPOSED') { return } if (mux.isDisposed()) { return } // Why: a normal disconnect/teardown rejects the in-flight request with // "Multiplexer disposed"; suppress the warn for that path so reconnect // cycles aren't noisy. if (mux.isDisposed()) { return } console.warn( `[ssh-relay-session] agent_hook.requestReplay failed for ${this.targetId}: ${ err instanceof Error ? err.message : String(err) }` ) }) } private teardownProviders(reason: 'shutdown' | 'connection_lost'): void { this.muxDisposeCleanup?.() this.muxDisposeCleanup = null this.muxNotificationCleanup?.() this.muxNotificationCleanup = null if (this.mux && !this.mux.isDisposed()) { this.mux.dispose(reason) } this.mux = null if (reason === 'shutdown') { clearPtyOwnershipForConnection(this.targetId) } const ptyProvider = getSshPtyProvider(this.targetId) if (ptyProvider && 'dispose' in ptyProvider) { ;(ptyProvider as { dispose: () => void }).dispose() } const fsProvider = getSshFilesystemProvider(this.targetId) if (fsProvider && 'dispose' in fsProvider) { ;(fsProvider as { dispose: () => void }).dispose() } unregisterSshPtyProvider(this.targetId) unregisterSshFilesystemProvider(this.targetId) unregisterSshGitProvider(this.targetId) } // Why: kept for back-compat with old relay binaries during the upgrade // window — those still gate FS ops on registered roots. New relays no-op // these notifications. Tracked for removal once the relay-version floor // moves past the cutover (see docs/relay-fs-allowlist-removal.md). private async registerRelayRoots(mux: SshChannelMultiplexer): Promise { const remoteRepos = this.store.getRepos().filter((r) => r.connectionId === this.targetId) for (const repo of remoteRepos) { mux.notify('session.registerRoot', { rootPath: repo.path }) } // Why: git.listWorktrees requires the repo root to be registered first. await Promise.all( remoteRepos.map(async (repo) => { try { const worktrees = (await mux.request('git.listWorktrees', { repoPath: repo.path })) as { path: string }[] for (const wt of worktrees) { if (wt.path !== repo.path) { mux.notify('session.registerRoot', { rootPath: wt.path }) } } } catch { // git worktree list may fail for folder-mode repos — not fatal } }) ) } // Why: extracted so establish() and reconnect() share exactly the same // event wiring. Previously forgetting to wire onReplay on one path // caused silent terminal blanking after reconnect. private broadcastEmptyLists(): void { const win = this.getMainWindow() if (!win || win.isDestroyed()) { return } win.webContents.send('ssh:port-forwards-changed', { targetId: this.targetId, forwards: [] }) win.webContents.send('ssh:detected-ports-changed', { targetId: this.targetId, ports: [] }) } private startPortScanning(): void { if (!this.mux || this.isDisposed()) { return } // Why: each scan walks /proc/*/fd on the remote host, so the scanner skips // ticks entirely while no window can show the results (hidden to tray or // minimized overnight) and rescans immediately when the window returns. const scanner = new PortScanner({ isWindowVisible: () => isMainWindowVisible(this.getMainWindow()), onWindowBecameVisible: onMainWindowBecameVisible }) this.portScanner = scanner // Why: capture the scanner instance so that a late ports.detect callback // from a previous relay session (before reconnect replaced it) is silently // discarded instead of publishing stale results into the new session. scanner.startScanning(this.targetId, this.mux, (targetId, ports, platform) => { if (this.portScanner !== scanner) { return } this.onDetectedPortsChanged?.(targetId, ports, platform) }) } private stopPortScanning(): void { if (this.portScanner) { this.portScanner.stopScanning(this.targetId) this.portScanner = null } } private wireUpPtyEvents(ptyProvider: SshPtyProvider): void { ptyProvider.onData((payload) => { const seq = this.runtime?.onPtyData(payload.id, payload.data, Date.now()) const rendererData = answerStartupTerminalColorQueriesForPty(payload.id, payload.data) const win = this.getMainWindow() if (!win || win.isDestroyed()) { return } // Why: hidden-delivery gate parity with ipc/pty.ts — runtime ingestion // above already consumed the chunk; gated renderer delivery is dropped // and one out-of-band pty:modelRestoreNeeded signal latches // model-restore-needed for reveal. Never an in-band pty:data sentinel: // OSC-9999-only chunks legitimately strip to empty in the renderer. const store = this.store as { getSettings?: Store['getSettings'] } if (shouldDropHiddenRendererPtyData(payload.id, store.getSettings?.())) { const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length) if (drop.shouldEmitRestoreMarker) { win.webContents.send('pty:modelRestoreNeeded', { id: payload.id, reason: 'hidden-drop', ...(typeof seq === 'number' ? { markerSeq: seq } : {}) } satisfies PtyModelRestoreNeededEvent) } return } // Why: startup color-query answering can strip query-only chunks to // empty; skip empty sends and only attach seq metadata when the chunk // reaches the renderer unmodified (seq tracks raw stream offsets). if (rendererData.length > 0) { win.webContents.send('pty:data', { ...payload, data: rendererData, ...(rendererData === payload.data && typeof seq === 'number' ? { seq, rawLength: payload.data.length } : {}) }) } }) ptyProvider.onReplay((payload) => { const win = this.getMainWindow() if (win && !win.isDestroyed()) { win.webContents.send('pty:replay', payload) } }) ptyProvider.onExit((payload) => { clearProviderPtyState(payload.id) deletePtyOwnership(payload.id) this.forwardedReattachReplayByPty.delete(payload.id) this.store.markSshRemotePtyLease(this.targetId, payload.id, 'terminated') this.runtime?.onPtyExit(payload.id, payload.code) const win = this.getMainWindow() if (win && !win.isDestroyed()) { win.webContents.send('pty:exit', payload) } }) } private replayFingerprint(data: string): string { const head = data.slice(0, REPLAY_FINGERPRINT_EDGE_CHARS) const tail = data.slice(-REPLAY_FINGERPRINT_EDGE_CHARS) return `${data.length}:${head}:${tail}` } private shouldForwardReattachReplay(appPtyId: string, data: string): boolean { const now = Date.now() const fingerprint = this.replayFingerprint(data) const previous = this.forwardedReattachReplayByPty.get(appPtyId) this.forwardedReattachReplayByPty.set(appPtyId, { fingerprint, deliveredAt: now }) return ( !previous || previous.fingerprint !== fingerprint || now - previous.deliveredAt > RECONNECT_REPLAY_DUPLICATE_WINDOW_MS ) } private forwardReattachReplay(appPtyId: string, data: string): void { if (!data || !this.shouldForwardReattachReplay(appPtyId, data)) { return } const win = this.getMainWindow() if (win && !win.isDestroyed()) { win.webContents.send('pty:replay', { id: appPtyId, data }) } } private expireReattachPty(_relayPtyId: string, appPtyId: string): void { clearProviderPtyState(appPtyId) deletePtyOwnership(appPtyId) this.forwardedReattachReplayByPty.delete(appPtyId) this.store.markSshRemotePtyLease(this.targetId, appPtyId, 'expired') const win = this.getMainWindow() if (win && !win.isDestroyed()) { win.webContents.send('pty:exit', { id: appPtyId, code: -1 }) } } private async reattachKnownPtys(shouldContinue: () => boolean): Promise { const ptyProvider = getSshPtyProvider(this.targetId) as SshPtyProvider | undefined if (!ptyProvider) { return } const relayInstanceId = ptyProvider.getRelayInstanceId?.() const activeLeases = this.store .getSshRemotePtyLeases(this.targetId) .filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired') const attachableLeases = activeLeases.filter((lease) => { if (!lease.relayInstanceId || !relayInstanceId || lease.relayInstanceId === relayInstanceId) { return true } const staleAppPtyId = toAppSshPtyId(this.targetId, lease.ptyId, lease.relayInstanceId) console.warn( `[ssh-relay-session] Expiring stale PTY ${lease.ptyId} for ${this.targetId} after relay generation changed` ) this.expireReattachPty(lease.ptyId, staleAppPtyId) return false }) const leasedPtyIds = attachableLeases.map((lease) => lease.ptyId) // Why: carry each lease's pane identity into the attach so the relay can // reject cross-generation id collisions even between split panes in one tab. // Older leases may lack leafId, so tabId remains the compatibility fallback. const expectedIdentityByPtyId = new Map( attachableLeases .map((lease): [string, ExpectedPtyIdentity] | null => { const expected = expectedIdentityForLease(lease) return expected ? [lease.ptyId, expected] : null }) .filter((entry): entry is [string, ExpectedPtyIdentity] => entry !== null) ) // Why: after app restart, ptyOwnership is empty but durable SSH leases // still describe remote PTYs that survived in the relay grace window. const ownedPtyIds = getPtyIdsForConnection(this.targetId).flatMap((appPtyId) => { const parsed = parseAppSshPtyId(appPtyId) if ( parsed?.relayInstanceId && relayInstanceId && parsed.relayInstanceId !== relayInstanceId ) { console.warn( `[ssh-relay-session] Expiring stale PTY ${parsed.relayPtyId} for ${this.targetId} after relay generation changed` ) this.expireReattachPty(parsed.relayPtyId, appPtyId) return [] } return [toRelaySshPtyId(this.targetId, appPtyId)] }) const ptyIds = Array.from(new Set([...ownedPtyIds, ...leasedPtyIds])) for (const ptyId of ptyIds) { if (!shouldContinue()) { return } try { const expectedIdentity = expectedIdentityByPtyId.get(ptyId) const attachResult = (expectedIdentity ? await ptyProvider.attachForReconnect(ptyId, expectedIdentity) : await ptyProvider.attachForReconnect(ptyId)) ?? {} if (!shouldContinue()) { return } const appPtyId = toAppSshPtyId(this.targetId, ptyId, relayInstanceId) setPtyOwnership(appPtyId, this.targetId) this.store.markSshRemotePtyLease(this.targetId, appPtyId, 'attached') this.forwardReattachReplay(appPtyId, attachResult.replay ?? '') } catch (err) { if (!isSshPtyNotFoundError(err)) { throw err } const appPtyId = toAppSshPtyId(this.targetId, ptyId, relayInstanceId) if (isSshPtyIdentityMismatchError(err)) { console.warn( `[ssh-relay-session] Ignoring stale PTY ${ptyId} for ${this.targetId} after relay identity mismatch: ${ err instanceof Error ? err.message : String(err) }` ) continue } console.warn( `[ssh-relay-session] Dropping stale PTY ${ptyId} for ${this.targetId} after relay reattach failed: ${ err instanceof Error ? err.message : String(err) }` ) clearProviderPtyState(appPtyId) deletePtyOwnership(appPtyId) this.forwardedReattachReplayByPty.delete(appPtyId) this.store.markSshRemotePtyLease(this.targetId, appPtyId, 'expired') // Why: if the new relay cannot reattach this id, the remote backing // process is gone. Tell the renderer so it clears stale pane bindings // instead of keeping a cursor-only terminal. const win = this.getMainWindow() if (win && !win.isDestroyed()) { win.webContents.send('pty:exit', { id: appPtyId, code: -1 }) } } } } } function quoteSh(value: string): string { return `'${value.replaceAll("'", `'\\''`)}'` } function buildRemoteCliShim(env: RemoteCliBridgeEnv): { path: string contents: string } { if (isWindowsRemoteHost(env.hostPlatform)) { const shimPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca.cmd') return { path: shimPath, contents: [ '@echo off', 'setlocal', `if not defined ORCA_RELAY_NODE_PATH set "ORCA_RELAY_NODE_PATH=${env.nodePath}"`, `if not defined ORCA_RELAY_DIR set "ORCA_RELAY_DIR=${env.relayDir}"`, `if not defined ORCA_RELAY_SOCKET_PATH set "ORCA_RELAY_SOCKET_PATH=${env.sockPath}"`, '"%ORCA_RELAY_NODE_PATH%" "%ORCA_RELAY_DIR%/relay.js" --sock-path "%ORCA_RELAY_SOCKET_PATH%" --orca-cli %*', 'exit /b %ERRORLEVEL%', '' ].join('\r\n') } } const shimPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca') return { path: shimPath, contents: [ '#!/usr/bin/env sh', 'set -eu', `ORCA_RELAY_NODE_PATH=\${ORCA_RELAY_NODE_PATH:-${quoteSh(env.nodePath)}}`, `ORCA_RELAY_DIR=\${ORCA_RELAY_DIR:-${quoteSh(env.relayDir)}}`, `ORCA_RELAY_SOCKET_PATH=\${ORCA_RELAY_SOCKET_PATH:-${quoteSh(env.sockPath)}}`, 'if [ ! -S "$ORCA_RELAY_SOCKET_PATH" ]; then', ' echo "Orca SSH CLI bridge cannot find the relay socket: $ORCA_RELAY_SOCKET_PATH" >&2', ' exit 1', 'fi', 'exec "$ORCA_RELAY_NODE_PATH" "$ORCA_RELAY_DIR/relay.js" --sock-path "$ORCA_RELAY_SOCKET_PATH" --orca-cli "$@"', '' ].join('\n') } }