From 55fd9fecf5598baa902da8797e18733421c101a2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:27:12 -0700 Subject: [PATCH] fix(launch): stage long or multi-line launch lines so they arrive whole A launch line that is long or has several lines could lose lines or wedge a shell that is still starting, because it was typed into the terminal before the shell could read it whole. The terminal host (local daemon, and the SSH relay) now writes such a line to a script file and types one short line that runs it, for every POSIX shell; a shell that cannot read Orca's quoting runs Orca's own agent lines through /bin/sh. If the script cannot be written, the terminal says so. The SSH background launch path no longer types the line from the renderer; the relay stages it like any other terminal. Bumps the terminal daemon protocol to 40 (main took 39); a v39 daemon keeps its sessions and gets no staged line. Restacked onto #24257 from the version reviewed on top of #23748; carries none of #23748's changes. --- ...n-foreground-confirmation-protocol.test.ts | 3 +- .../daemon/daemon-protocol-version.test.ts | 4 +- src/main/daemon/daemon-protocol-version.ts | 6 +- src/main/daemon/daemon-pty-spawn-result.ts | 27 +- .../daemon-pty-startup-delivery.test.ts | 33 ++- .../daemon/daemon-spawn-result-identity.ts | 13 + src/main/daemon/session.ts | 3 +- .../daemon/terminal-host-session-create.ts | 37 ++- .../terminal-host-startup-staging.test.ts | 123 ++++++++ src/main/daemon/terminal-host.test.ts | 9 +- ...local-pty-provider-shell-readiness.test.ts | 43 +++ .../providers/local-pty-session-activation.ts | 22 +- ...ty-handler-startup-command-staging.test.ts | 114 ++++++++ src/relay/pty-handler.ts | 37 ++- ...ch-agent-background-session-remote.test.ts | 264 +++--------------- .../lib/launch-agent-background-session.ts | 27 +- .../ssh-background-startup-delivery.test.ts | 196 ------------- .../lib/ssh-background-startup-delivery.ts | 196 ------------- .../local-build-compatibility-contract.json | 4 +- .../local-build-compatibility-contract.ts | 4 +- .../startup-command-staging.node-pty.test.ts | 158 +++++++++++ src/shared/startup-command-staging.test.ts | 213 ++++++++++++++ src/shared/startup-command-staging.ts | 184 ++++++++++++ 23 files changed, 1033 insertions(+), 687 deletions(-) create mode 100644 src/main/daemon/daemon-spawn-result-identity.ts create mode 100644 src/main/daemon/terminal-host-startup-staging.test.ts create mode 100644 src/relay/pty-handler-startup-command-staging.test.ts delete mode 100644 src/renderer/src/lib/ssh-background-startup-delivery.test.ts delete mode 100644 src/renderer/src/lib/ssh-background-startup-delivery.ts create mode 100644 src/shared/startup-command-staging.node-pty.test.ts create mode 100644 src/shared/startup-command-staging.test.ts create mode 100644 src/shared/startup-command-staging.ts diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 8a7421b35c1..3d87fa463af 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(39) + expect(PROTOCOL_VERSION).toBe(40) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) @@ -22,5 +22,6 @@ describe('foreground-confirmation daemon protocol', () => { expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(36) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(37) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(38) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(39) }) }) diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts index 15b7cb8068c..1b77b8c20b5 100644 --- a/src/main/daemon/daemon-protocol-version.test.ts +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -22,7 +22,7 @@ import { describe('daemon protocol version', () => { it('ships bounded history transfer after the 2031-unsubscribe fact', () => { - expect(PROTOCOL_VERSION).toBe(39) + expect(PROTOCOL_VERSION).toBe(40) expect(COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION).toBe(38) expect(CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION).toBe(37) expect(CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION).toBe(36) @@ -38,7 +38,7 @@ describe('daemon protocol version', () => { expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26) expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual( - Array.from({ length: 38 }, (_, index) => index + 1) + Array.from({ length: 39 }, (_, index) => index + 1) ) }) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 42f87382d62..6dfec710e8b 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,6 +1,6 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v39 gives plain fish panes Orca's codex function through XDG_DATA_DIRS; older owners stay attachable. -export const PROTOCOL_VERSION = 39 +// v40 stages long startup commands as sourced scripts; older owners stay attachable. +export const PROTOCOL_VERSION = 40 // Why: older daemons reject `setColorQueryReplyColors` as an unknown request type. export const COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION = 38 export const CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION = 37 @@ -33,7 +33,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38 + 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39 ] as const export function supportsColorQueryReplyColors(protocolVersion: number): boolean { diff --git a/src/main/daemon/daemon-pty-spawn-result.ts b/src/main/daemon/daemon-pty-spawn-result.ts index afc7315abcd..876c54caae6 100644 --- a/src/main/daemon/daemon-pty-spawn-result.ts +++ b/src/main/daemon/daemon-pty-spawn-result.ts @@ -1,5 +1,6 @@ import { isAgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority' import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags' +import { daemonSpawnResultIdentity } from './daemon-spawn-result-identity' import { retireUnexpectedAttachOnlySpawn } from './daemon-attach-only-retirement' import { DaemonPtySpawnRequest, type DaemonPtySpawnContext } from './daemon-pty-spawn-request' import { providerSequenceFromCreateOrAttach } from './daemon-pty-provider-sequence' @@ -78,10 +79,6 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { if (result.incarnationId) { this.sessionIncarnations.set(sessionId, result.incarnationId) } - const claimResult = (): Pick | Record => - result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {} - const incarnationResult = (): Pick | Record => - result.incarnationId ? { incarnationId: result.incarnationId } : {} let providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro // Why: explicit null from a current daemon overrides the caller's WSL preference; undefined keeps compatibility with older daemons. wslDistro = providerWslDistro ?? undefined @@ -91,8 +88,6 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { } else if (providerWslDistro === null || result.isNew) { this.wslDistrosBySessionId.delete(sessionId) } - const launchIdentity = (): { launchAgent?: NonNullable } => - result.launchAgent ? { launchAgent: result.launchAgent } : {} if (effectiveCwd) { this.initialCwds.set(sessionId, effectiveCwd) @@ -116,10 +111,8 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { } return finalizeSpawnResult({ id: sessionId, - ...incarnationResult(), + ...daemonSpawnResultIdentity(result), pid, - ...claimResult(), - ...launchIdentity(), coldRestore: cachedRestore, ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), ...(!result.isNew ? { isReattach: true } : {}) @@ -210,10 +203,8 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { this.coldRestoreCache.set(sessionId, coldRestore) return finalizeSpawnResult({ id: sessionId, - ...incarnationResult(), + ...daemonSpawnResultIdentity(result), pid, - ...claimResult(), - ...launchIdentity(), coldRestore, ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), ...(providerSequence ? { providerSequence } : {}), @@ -222,10 +213,8 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { } return finalizeSpawnResult({ id: sessionId, - ...incarnationResult(), + ...daemonSpawnResultIdentity(result), pid, - ...claimResult(), - ...launchIdentity(), ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), ...(providerSequence ? { providerSequence } : {}) }) @@ -270,10 +259,8 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { if (!isReattach || !result.snapshot) { return finalizeSpawnResult({ id: sessionId, - ...incarnationResult(), + ...daemonSpawnResultIdentity(result), pid, - ...claimResult(), - ...launchIdentity(), ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), ...(providerSequence ? { providerSequence } : {}), ...(isReattach ? { isReattach: true } : {}) @@ -297,10 +284,8 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { ) return finalizeSpawnResult({ id: sessionId, - ...incarnationResult(), + ...daemonSpawnResultIdentity(result), pid, - ...claimResult(), - ...launchIdentity(), ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), snapshot: snapshotPayload, snapshotCols: reattachSnapshot.cols, diff --git a/src/main/daemon/daemon-pty-startup-delivery.test.ts b/src/main/daemon/daemon-pty-startup-delivery.test.ts index 4b72070bb42..0039bccbce2 100644 --- a/src/main/daemon/daemon-pty-startup-delivery.test.ts +++ b/src/main/daemon/daemon-pty-startup-delivery.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { rmSync } from 'node:fs' +import { mkdirSync, readdirSync, rmSync } from 'node:fs' import { basename, join } from 'node:path' import * as localPtyUtils from '../providers/local-pty-utils' import { @@ -18,12 +18,17 @@ describe('DaemonPtyAdapter startup delivery', () => { let dir: string let lastSubprocess: ReturnType let lastSpawnOpts: Parameters[0] | null + let nextShellPath: string | undefined beforeEach(async () => { lastSpawnOpts = null + nextShellPath = undefined harness = await startDaemonAdapterHarness((opts) => { lastSpawnOpts = opts - lastSubprocess = createMockSubprocess() + lastSubprocess = Object.assign( + createMockSubprocess(), + nextShellPath ? { shellPath: nextShellPath } : {} + ) return lastSubprocess }) adapter = harness.adapter @@ -104,4 +109,28 @@ describe('DaemonPtyAdapter startup delivery', () => { await waitFor(() => vi.mocked(lastSubprocess.write).mock.calls.length > 0) expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith(`${startup.command}\n`) }) + + itOnPosix('types only the short line that sources a staged launch line', async () => { + const stagingDir = join(dir, 'tmp') + mkdirSync(stagingDir) + vi.stubEnv('TMPDIR', stagingDir) + nextShellPath = '/bin/zsh' + try { + const command = `claude '${'x'.repeat(600)}'` + await adapter.spawn({ + cols: 80, + rows: 24, + command, + env: { SHELL: '/bin/zsh' } + }) + lastSubprocess._simulateData('\x1b]777;orca-shell-ready\x07\r\nuser@host $ ') + await waitFor(() => vi.mocked(lastSubprocess.write).mock.calls.length > 0) + const [script] = readdirSync(stagingDir) + expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith( + `. '${join(stagingDir, script)}'\n` + ) + } finally { + vi.unstubAllEnvs() + } + }) }) diff --git a/src/main/daemon/daemon-spawn-result-identity.ts b/src/main/daemon/daemon-spawn-result-identity.ts new file mode 100644 index 00000000000..d6efeaf9b2e --- /dev/null +++ b/src/main/daemon/daemon-spawn-result-identity.ts @@ -0,0 +1,13 @@ +import type { PtySpawnResult } from '../providers/types' +import type { CreateOrAttachResult } from './types' + +/** The daemon's identity and launch facts every spawn result carries forward from its reply. */ +export function daemonSpawnResultIdentity( + result: CreateOrAttachResult +): Pick { + return { + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), + ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}), + ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}) + } +} diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 7c63dbd1d3f..f74e3c1236d 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -32,7 +32,8 @@ export class Session { private readonly producerPause: SessionProducerPause private readonly shellReady: SessionShellReadyBarrier private readonly termination: SessionTerminationController - private readonly startupIngress: PtyStartupIngress + /** Public so the creating host can print its own notice as terminal output. */ + readonly startupIngress: PtyStartupIngress private readonly recoveryBarrier: TerminalShellRecoveryBarrier constructor(opts: SessionOptions) { diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index 15e86a70a72..cba1be962a7 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -1,4 +1,10 @@ import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' +import { + discardStagedStartupCommand, + stageStartupCommand, + startupStagingFailureNotice, + type StartupCommandStaging +} from '../../shared/startup-command-staging' import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result' import { enumerateDirectoryOnce } from './directory-enumeration-probe' @@ -131,6 +137,7 @@ async function spawnAndPublishSession( ...(opts.cancelSignal ? { cancelSignal: opts.cancelSignal } : {}) }) + let staging: StartupCommandStaging | undefined // Why: a fallback shell does not emit the preferred shell's ready marker; // retaining the stale capability would indefinitely queue its first command. const shellReadySupported = @@ -156,7 +163,8 @@ async function spawnAndPublishSession( onExit: createSessionExitHandler( deps.onSessionExit, opts.sessionId, - opts.agentSessionGeneration + opts.agentSessionGeneration, + () => discardStagedStartupCommand(staging) ), ...(deps.reportReadinessEvent ? { reportReadinessEvent: deps.reportReadinessEvent } : {}), ...(opts.shellReadyTimeoutMs !== undefined @@ -197,10 +205,27 @@ async function spawnAndPublishSession( // Diagnostics must never turn a live PTY into a failed create. } if (startupCommandWritten && opts.command) { + staging = stageStartupCommand({ + command: opts.command, + shellPath: subprocess.shellPath, + orcaBuiltLine: opts.launchAgent !== undefined + }) + const notice = startupStagingFailureNotice(staging) + if (notice) { + session.startupIngress.accept(notice) + try { + deps.reportReadinessEvent?.('startup-command-stage-failed', { + sessionId: opts.sessionId, + reason: staging.failure + }) + } catch { + // Diagnostics must never turn a live PTY into a failed create. + } + } const submit = process.platform === 'win32' ? '\r' : '\n' // Why: only Orca-wrapped shells advertise the paste-safe startup barrier. session.write( - buildStartupCommandSubmission(opts.command, { + buildStartupCommandSubmission(staging.command, { submit, bracketedPasteSafe: shellReadySupported }) @@ -222,9 +247,13 @@ async function spawnAndPublishSession( function createSessionExitHandler( onSessionExit: TerminalHostSessionCreateDependencies['onSessionExit'], sessionId: string, - generation: string | undefined + generation: string | undefined, + discardStagedCommand: () => void ): () => void { - return () => onSessionExit(sessionId, generation) + return () => { + discardStagedCommand() + onSessionExit(sessionId, generation) + } } // Why enumeration: a shell's cwd listing is what TCC withholds, and it can withhold it while diff --git a/src/main/daemon/terminal-host-startup-staging.test.ts b/src/main/daemon/terminal-host-startup-staging.test.ts new file mode 100644 index 00000000000..f7ae1d5b786 --- /dev/null +++ b/src/main/daemon/terminal-host-startup-staging.test.ts @@ -0,0 +1,123 @@ +import './mock-descendant-sweep' +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TerminalHost } from './terminal-host' +import type { SubprocessHandle } from './session-subprocess-handle' + +let tempDir: string +let exitSubprocess: ((code: number) => void) | undefined + +function mockSubprocess(shellPath: string): SubprocessHandle { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handle stubs only what session creation calls. + return { + pid: 1, + shellPath, + getForegroundProcess: vi.fn(() => null), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + terminateOwnedTree: () => 'unavailable' as const, + forceKill: vi.fn(), + signal: vi.fn(), + onData: () => {}, + onExit: (callback: (code: number) => void) => { + exitSubprocess = callback + }, + dispose: vi.fn() + } as SubprocessHandle +} + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-daemon-staging-')) + vi.stubEnv('TMPDIR', tempDir) + exitSubprocess = undefined +}) + +afterEach(() => { + vi.unstubAllEnvs() + rmSync(tempDir, { recursive: true, force: true }) +}) + +const describePosix = process.platform === 'win32' ? describe.skip : describe + +describePosix('daemon startup command staging', () => { + async function create(command: string, shellPath = '/bin/zsh') { + const sub = mockSubprocess(shellPath) + const host = new TerminalHost({ spawnSubprocess: () => sub }) + const result = await host.createOrAttach({ + sessionId: `s-${command.length}`, + cols: 80, + rows: 24, + command, + shellReadySupported: false, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + return { sub, result } + } + + it('types a short launch line and reports it typed', async () => { + const { sub, result } = await create(`claude 'fix it'`) + expect(sub.write).toHaveBeenCalledWith(`claude 'fix it'\n`) + expect(result.isNew).toBe(true) + }) + + it('types only a sourcing line for a launch past 512 bytes', async () => { + const command = `claude '${'x'.repeat(600)}'` + const { sub, result } = await create(command) + const [script] = readdirSync(tempDir) + expect(script).toMatch(/^orca-launch-[0-9a-f]+\.sh$/) + const scriptPath = join(tempDir, script) + expect(sub.write).toHaveBeenCalledWith(`. '${scriptPath}'\n`) + expect(readFileSync(scriptPath, 'utf8').split('\n')[1]).toBe(command) + expect(result).not.toHaveProperty('startupDelivery') + }) + + it('stages a multi-line launch instead of bracket-pasting it', async () => { + const { sub } = await create(`claude 'one\ntwo'`) + expect(vi.mocked(sub.write).mock.calls[0][0]).toMatch(/^\. '.*orca-launch-[0-9a-f]+\.sh'\n$/) + }) + + it('deletes a script the shell never sourced when the session exits', async () => { + await create(`claude '${'x'.repeat(600)}'`) + const scriptPath = join(tempDir, readdirSync(tempDir)[0]) + exitSubprocess?.(0) + expect(existsSync(scriptPath)).toBe(false) + }) + + it('types nothing when there is no startup command', async () => { + const sub = mockSubprocess('/bin/zsh') + const host = new TerminalHost({ spawnSubprocess: () => sub }) + await host.createOrAttach({ + sessionId: 's-none', + cols: 80, + rows: 24, + shellReadySupported: false, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + expect(sub.write).not.toHaveBeenCalled() + }) + + it('prints a notice in the terminal when it types a line it could not stage', async () => { + vi.stubEnv('TMPDIR', join(tempDir, 'missing')) + const command = `claude '${'x'.repeat(600)}'` + const onData = vi.fn() + const sub = mockSubprocess('/bin/zsh') + const host = new TerminalHost({ spawnSubprocess: () => sub }) + await host.createOrAttach({ + sessionId: 's-stage-failed', + cols: 80, + rows: 24, + command, + shellReadySupported: false, + streamClient: { onData, onExit: vi.fn() } + }) + expect(sub.write).toHaveBeenCalledWith(`${command}\n`) + await vi.waitFor(() => { + expect(onData.mock.calls.map(([data]) => String(data)).join('')).toContain( + '[orca] Could not stage the launch command (ENOENT' + ) + }) + }) +}) diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index 7ffaadec06d..b2737142a73 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { readFileSync, rmSync } from 'node:fs' import { Session } from './session' import { IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS } from './session-termination-controller' import type { SubprocessHandle } from './session-subprocess-handle' @@ -247,7 +248,7 @@ describe('TerminalHost', () => { ) }) - it('does not bracketed-paste-wrap multiline commands for a fallback shell without paste mode', async () => { + it('stages multiline commands for a fallback shell without paste mode', async () => { spawnFn = vi.fn(() => { const sub = createMockSubprocess({ shellPath: '/bin/sh' }) as ReturnType< typeof createMockSubprocess @@ -271,8 +272,10 @@ describe('TerminalHost', () => { }) const written = (lastSubprocess.write as ReturnType).mock.calls[0]?.[0] - expect(written).not.toContain('\x1b[200~') - expect(written).toContain('line one\nline two') + const scriptPath = /^\. '(.*orca-launch-[0-9a-f]+\.sh)'\n$/.exec(written)?.[1] + expect(scriptPath).toBeDefined() + expect(readFileSync(scriptPath!, 'utf8')).toContain('claude "line one\nline two"\n') + rmSync(scriptPath!, { force: true }) }) it('keeps the shell-ready barrier when the spawned shell supports the marker', async () => { diff --git a/src/main/providers/local-pty-provider-shell-readiness.test.ts b/src/main/providers/local-pty-provider-shell-readiness.test.ts index 6a46261e793..168bc309a02 100644 --- a/src/main/providers/local-pty-provider-shell-readiness.test.ts +++ b/src/main/providers/local-pty-provider-shell-readiness.test.ts @@ -223,6 +223,49 @@ describe('LocalPtyProvider', () => { } }) + it('stages a long startup command and types only the line that sources it', async () => { + vi.useFakeTimers() + try { + process.env.SHELL = '/bin/sh' + const command = `claude '${'x'.repeat(600)}'` + + const result = await provider.spawn({ cols: 80, rows: 24, command }) + + expect(result).not.toHaveProperty('startupDelivery') + const staged = writeFileSyncMock.mock.calls.find(([path]) => + String(path).includes('orca-launch-') + ) + expect(staged?.[1]).toContain(`\n${command}\n`) + await vi.advanceTimersByTimeAsync(200) + expect(mockProc.write).toHaveBeenCalledWith(`. '${staged?.[0]}'\n`) + } finally { + vi.useRealTimers() + } + }) + + it('prints a notice in the terminal when it types a line it could not stage', async () => { + vi.useFakeTimers() + try { + process.env.SHELL = '/bin/sh' + const received: string[] = [] + provider.configure({ onData: (_id, data) => received.push(data) }) + writeFileSyncMock.mockImplementationOnce(() => { + throw new Error('ENOSPC: no space left on device') + }) + const command = `claude '${'x'.repeat(600)}'` + + await provider.spawn({ cols: 80, rows: 24, command }) + + expect(received.join('')).toContain( + '[orca] Could not stage the launch command (ENOSPC: no space left on device)' + ) + await vi.advanceTimersByTimeAsync(200) + expect(mockProc.write).toHaveBeenCalledWith(`${command}\n`) + } finally { + vi.useRealTimers() + } + }) + it('verifies shell identity against the exact spawn PATH', async () => { provider.configure({ buildSpawnEnv: (_id, env) => ({ ...env, PATH: '/post-hook/bin' }) diff --git a/src/main/providers/local-pty-session-activation.ts b/src/main/providers/local-pty-session-activation.ts index e89f0c43316..8b8abd54fd0 100644 --- a/src/main/providers/local-pty-session-activation.ts +++ b/src/main/providers/local-pty-session-activation.ts @@ -1,5 +1,11 @@ import type * as pty from 'node-pty' import { isBracketedPasteSafeShell } from '../../shared/startup-command-submission' +import { + discardStagedStartupCommand, + stageStartupCommand, + startupStagingFailureNotice, + type StartupCommandStaging +} from '../../shared/startup-command-staging' import { PtyStartupIngress, type PtyIngressEmission } from '../../shared/pty-startup-ingress' import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' import { resolveProcessExitCause } from '../../shared/terminal-exit-cause' @@ -125,8 +131,10 @@ export function activateLocalPtySession(args: { ptyDisposables.set(id, disposables) let exitedBeforeSpawnReply = false + let staging: StartupCommandStaging | undefined const onExitDisposable = proc.onExit(({ exitCode, signal }) => { exitedBeforeSpawnReply = true + discardStagedStartupCommand(staging) // Why: node-pty reports a signalled death as {exitCode: 0, signal: N}; the // cause is built here, where the signal and the spawn's trustworthiness // are both still in hand. @@ -178,10 +186,22 @@ export function activateLocalPtySession(args: { shellName: spawnedShellName, waitsForShellReady: plan.shellReadyLaunch?.supportsReadyMarker === true }) + staging = stageStartupCommand({ + command: spawn.command, + shellPath: plan.shellPath, + orcaBuiltLine: spawn.launchAgent !== undefined + }) + const notice = startupStagingFailureNotice(staging) + if (notice) { + startupIngress.accept(notice) + console.warn(`[pty] Could not stage startup command for ${id}; typing it in full`, { + reason: staging.failure + }) + } writeStartupCommandWhenShellReady( readiness.shellReadyPromise, proc, - spawn.command, + staging.command, (cleanup) => { readiness.setStartupCommandCleanup(cleanup) }, diff --git a/src/relay/pty-handler-startup-command-staging.test.ts b/src/relay/pty-handler-startup-command-staging.test.ts new file mode 100644 index 00000000000..a058dfb7d72 --- /dev/null +++ b/src/relay/pty-handler-startup-command-staging.test.ts @@ -0,0 +1,114 @@ +import './mock-descendant-sweep' +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ + mockPtySpawn: vi.fn(), + mockCreateShellPromptReadinessProbe: vi.fn(), + mockPtyInstance: { + pid: process.pid, + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + clear: vi.fn(), + pause: vi.fn(), + resume: vi.fn() + } +})) + +vi.mock('node-pty', () => ({ + spawn: mockPtySpawn +})) + +vi.mock('../main/pty/posix-pty-process-groups', () => ({ + forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) +})) + +vi.mock('../main/shell-prompt-readiness-probe', () => ({ + createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe +})) + +import type { PtyHandler } from './pty-handler' +import { beginPtyHandlerTest, endPtyHandlerTest } from './pty-handler-test-harness' +import type { MockDispatcher } from './pty-handler-test-harness' + +const describePosix = process.platform === 'win32' ? describe.skip : describe + +describePosix('relay startup command staging', () => { + let dispatcher: MockDispatcher + let handler: PtyHandler + let originalPlatform: PropertyDescriptor | undefined + let stagingDir: string + + beforeEach(() => { + stagingDir = mkdtempSync(join(tmpdir(), 'orca-relay-staging-')) + vi.stubEnv('TMPDIR', stagingDir) + ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ + mockPtySpawn, + mockPtyInstance, + mockCreateShellPromptReadinessProbe + })) + }) + + afterEach(async () => { + await endPtyHandlerTest(handler, originalPlatform) + vi.unstubAllEnvs() + rmSync(stagingDir, { recursive: true, force: true }) + }) + + async function spawn(command: string): Promise { + return await dispatcher.callRequest('pty.spawn', { + command, + commandDelivery: 'provider', + env: { SHELL: '/bin/zsh' } + }) + } + + it('types a short provider-delivered command as is', async () => { + await spawn('echo short') + await vi.advanceTimersByTimeAsync(50) + expect(mockPtySpawn.mock.results[0]?.value.write).toHaveBeenCalledWith('echo short\n') + }) + + it('stages a long provider-delivered command and types only the sourcing line', async () => { + const command = `claude '${'x'.repeat(600)}'` + await spawn(command) + const [script] = readdirSync(stagingDir) + const scriptPath = join(stagingDir, script) + expect(readFileSync(scriptPath, 'utf8').split('\n')[1]).toBe(command) + await vi.advanceTimersByTimeAsync(50) + expect(mockPtySpawn.mock.results[0]?.value.write).toHaveBeenCalledWith(`. '${scriptPath}'\n`) + }) + + it('deletes a script the shell never sourced when the PTY exits', async () => { + await spawn(`claude '${'x'.repeat(600)}'`) + const scriptPath = join(stagingDir, readdirSync(stagingDir)[0]) + mockPtyInstance.onExit.mock.calls.at(-1)?.[0]?.({ exitCode: 0 }) + expect(existsSync(scriptPath)).toBe(false) + }) + + it('stages nothing for a renderer-delivered command it only holds as a hint', async () => { + await dispatcher.callRequest('pty.spawn', { + command: `claude '${'x'.repeat(600)}'`, + env: { SHELL: '/bin/zsh' } + }) + expect(readdirSync(stagingDir)).toEqual([]) + }) + + it('prints a notice in the terminal when it types a line it could not stage', async () => { + vi.stubEnv('TMPDIR', join(stagingDir, 'missing')) + const command = `claude '${'x'.repeat(600)}'` + await spawn(command) + await vi.advanceTimersByTimeAsync(50) + expect(mockPtySpawn.mock.results[0]?.value.write).toHaveBeenCalledWith(`${command}\n`) + const output = dispatcher._notifications + .filter((notification) => notification.method === 'pty.data') + .map((notification) => String(notification.params?.data)) + .join('') + expect(output).toContain('[orca] Could not stage the launch command (ENOENT') + }) +}) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index cb88537de44..5210e9ba50f 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -33,6 +33,12 @@ import { SHELL_STARTUP_FEATURE_ENV } from '../main/shell-startup-features' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' import { shouldUseShellReadyStartupDelivery } from '../shared/codex-startup-delivery' import { buildStartupCommandSubmission } from '../shared/startup-command-submission' +import { + discardStagedStartupCommand, + stageStartupCommand, + startupStagingFailureNotice, + type StartupCommandStaging +} from '../shared/startup-command-staging' import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' import { isPathInsideOrEqual, @@ -249,6 +255,8 @@ type ManagedPty = { gitCredentialPromptGuarded: boolean historyIsolationEnabled?: boolean startupCommand?: ManagedStartupCommand + /** Kept past delivery: the typed line may never run if the shell dies first. */ + stagedStartupCommand?: StartupCommandStaging /** Whether this host armed the shell-ready marker for a renderer-delivered startup command. * Kept off `startupCommand`, which is dropped once delivered; the client reads it from the * spawn reply to skip waiting for a marker that will never come (fish, sh, Windows). */ @@ -328,6 +336,7 @@ function disposeManagedPty(managed: ManagedPty): void { return } managed.disposed = true + discardStagedStartupCommand(managed.stagedStartupCommand) // Why: clear the SIGKILL fallback timer so it can't fire pty.kill on an already-disposed instance. if (managed.killTimer) { clearTimeout(managed.killTimer) @@ -957,10 +966,13 @@ export class PtyHandler { } const submit = process.platform === 'win32' ? '\r' : '\n' // Why: only the shell-ready wrapper arms bracketed-paste; other shells use raw submit so ESC[200~ markers aren't echoed. - const payload = buildStartupCommandSubmission(startup.command, { - submit, - bracketedPasteSafe: startup.waitForShellReady - }) + const payload = buildStartupCommandSubmission( + managed.stagedStartupCommand?.command ?? startup.command, + { + submit, + bracketedPasteSafe: startup.waitForShellReady + } + ) managed.startupCommand = undefined managed.pty.write(payload) } @@ -2128,11 +2140,28 @@ export class PtyHandler { } : {}) } + if (managed.startupCommand?.providerDelivery && managed.startupCommand.command) { + managed.stagedStartupCommand = stageStartupCommand({ + command: managed.startupCommand.command, + shellPath: shell, + orcaBuiltLine: launchAgent !== undefined + }) + if (managed.stagedStartupCommand.failure) { + process.stderr.write( + `[pty-handler] Could not stage startup command for ${id}; typing it in full: ${managed.stagedStartupCommand.failure}\n` + ) + } + } this.retiredIncarnations.delete(id) this.sourcePublication?.activate(id, managed.incarnationId, context) const sourceActivation = context && this.sourcePublication?.receivingActivation?.(id, context.clientId) this.wireAndStore(managed) + const stagingNotice = + managed.stagedStartupCommand && startupStagingFailureNotice(managed.stagedStartupCommand) + if (stagingNotice) { + managed.startupIngress?.accept(stagingNotice) + } if (context?.isStale() && !params.agentSessionEnsure && !params.agentSessionCreateOperationId) { // Why: if the client reconnected while pty.spawn was in flight, the // response is discarded and no renderer can own this PTY. Shut it down diff --git a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts index 77dfc46c349..e6480d197f7 100644 --- a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts @@ -153,242 +153,50 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', ) }) - it('injects fast startup commands into SSH background sessions after shell output arrives', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + it.each(['claude', 'codex'] as const)( + 'has the relay type the %s launch line instead of writing it from the renderer', + async (agent) => { + vi.useFakeTimers() + try { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - await launchAgentBackgroundSession({ - agent: 'claude', - worktreeId: 'wt-1', - prompt: 'run the automation', - title: 'Nightly audit' - }) - - expect(mockSpawn.mock.calls[0]?.[0]?.command).toBe( - "claude '--dangerously-skip-permissions' 'run the automation'" - ) - expect(mockSpawn.mock.calls[0]?.[0]?.startupCommandDelivery).toBeUndefined() - const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void - dataSidecar('user@remote repo % ') - vi.advanceTimersByTime(50) - - expect(mockWrite).toHaveBeenCalledWith( - 'pty-1', - "claude '--dangerously-skip-permissions' 'run the automation'\r", - 'launch' - ) - } finally { - vi.useRealTimers() - } - }) - - it('waits for shell-ready before injecting payload-bearing SSH background commands', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await launchAgentBackgroundSession({ - agent: 'codex', - worktreeId: 'wt-1', - prompt: 'run the automation', - title: 'Nightly audit' - }) - - expect(mockSpawn.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ - command: "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'", - startupCommandDelivery: 'shell-ready' + await launchAgentBackgroundSession({ + agent, + worktreeId: 'wt-1', + prompt: 'run the automation' }) - ) - const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void - dataSidecar('user@remote repo % ') - vi.advanceTimersByTime(50) - expect(mockWrite).not.toHaveBeenCalled() - dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') - vi.advanceTimersByTime(50) - - expect(mockWrite).toHaveBeenCalledWith( - 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r", - 'launch' - ) - } finally { - vi.useRealTimers() - } - }) - - // #18767: a plain Codex launch carries no shell-ready hint, but the remote host - // still arms the marker for it, so writing early would display the launch twice. - it('waits for shell-ready for a promptless SSH background Codex launch', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await launchAgentBackgroundSession({ agent: 'codex', worktreeId: 'wt-1' }) - const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void - dataSidecar('user@remote repo % ') - vi.advanceTimersByTime(1_400) - - expect(mockWrite).not.toHaveBeenCalled() - - dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') - vi.advanceTimersByTime(50) - - expect(mockWrite).toHaveBeenCalledWith( - 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox'\r", - 'launch' - ) - } finally { - vi.useRealTimers() - } - }) - - it('skips the shell-ready wait when the host reports it did not arm the marker', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - mockSpawn.mockResolvedValue({ id: 'pty-1', shellReadyArmed: false }) - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await launchAgentBackgroundSession({ agent: 'codex', worktreeId: 'wt-1' }) - const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void - dataSidecar('user@remote repo % ') - vi.advanceTimersByTime(50) - - expect(mockWrite).toHaveBeenCalledWith( - 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox'\r", - 'launch' - ) - } finally { - vi.useRealTimers() - } - }) - - it('falls back when an SSH shell produces no observable startup data', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await launchAgentBackgroundSession({ - agent: 'codex', - worktreeId: 'wt-1', - prompt: 'run the automation' - }) - // Why the longer wait: a shell that has emitted nothing is still booting, - // so the fallback holds off rather than pasting before readline arms. - vi.advanceTimersByTime(1_550) - expect(mockWrite).not.toHaveBeenCalled() - vi.advanceTimersByTime(15_050) - - expect(mockWrite).toHaveBeenCalledWith( - 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r", - 'launch' - ) - } finally { - vi.useRealTimers() - } - }) - - it('keeps the short fallback for an SSH shell that talks but cannot emit the marker', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await launchAgentBackgroundSession({ - agent: 'codex', - worktreeId: 'wt-1', - prompt: 'run the automation' - }) - const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void - dataSidecar('user@remote repo % ') - vi.advanceTimersByTime(1_550) - - expect(mockWrite).toHaveBeenCalledWith( - 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r", - 'launch' - ) - } finally { - vi.useRealTimers() - } - }) - - it('waits for shell-ready for SSH background Codex native prefill commands without a hint', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - state.settings = { - agentCmdOverrides: { codex: "codex --prefill 'draft from override'" }, - activeRuntimeEnvironmentId: null, - terminalMainSideEffectAuthority: undefined + expect(mockSpawn.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + command: expect.stringContaining("'run the automation'"), + connectionId: 'ssh-1', + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready' + }) + ) + const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') + vi.advanceTimersByTime(20_000) + expect(mockWrite).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() } - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await launchAgentBackgroundSession({ - agent: 'codex', - worktreeId: 'wt-1', - title: 'Nightly audit' - }) - - expect(mockSpawn.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ - command: - "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'" - }) - ) - expect(mockSpawn.mock.calls[0]?.[0]).not.toHaveProperty('startupCommandDelivery') - const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void - dataSidecar('user@remote repo % ') - vi.advanceTimersByTime(50) - expect(mockWrite).not.toHaveBeenCalled() - - dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') - vi.advanceTimersByTime(50) - - expect(mockWrite).toHaveBeenCalledWith( - 'pty-1', - "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'\r", - 'launch' - ) - } finally { - vi.useRealTimers() } - }) + ) - it('does not rearm SSH background startup delivery after exit cleanup', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + it('has the relay type a promptless SSH background launch too', async () => { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - await launchAgentBackgroundSession({ - agent: 'codex', - worktreeId: 'wt-1', - prompt: 'run the automation', - title: 'Nightly audit' + await launchAgentBackgroundSession({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(mockSpawn.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready' }) - - const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void - const exitSidecar = mockSubscribeToPtyExit.mock.calls[0]?.[1] as (code: number) => void - exitSidecar(0) - - dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') - vi.advanceTimersByTime(50) - - expect(mockWrite).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } + ) }) it.each([true, false])( diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 35960f749d1..3e997d32e51 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -28,10 +28,6 @@ import { subscribeToRuntimeTerminalData, toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' -import { - createSshBackgroundStartupDelivery, - sshBackgroundLaunchWaitsForShellReady -} from '@/lib/ssh-background-startup-delivery' import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler' import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' import { runBestEffortAgentBackgroundCleanups } from '@/lib/agent-background-session-cleanup' @@ -102,12 +98,6 @@ export async function launchAgentBackgroundSession( }) let paneKey = makePaneKey(reservedTabId, leafId) const sshConnectionId = launchHost.connectionId - const sshStartupDelivery = createSshBackgroundStartupDelivery({ - command: sshConnectionId ? startupPlan.launchCommand : null, - waitForShellReady: - Boolean(sshConnectionId) && sshBackgroundLaunchWaitsForShellReady(startupPlan), - write: (ptyId, data) => window.api.pty.write(ptyId, data, 'launch') - }) // Route by the worktree's owner host, not the focused runtime. const runtimeTarget = getActiveRuntimeTarget( getSettingsForWorktreeRuntimeOwner(store, worktreeId) @@ -130,7 +120,6 @@ export async function launchAgentBackgroundSession( exitHandled = true unsubscribeExit() unsubscribeData() - sshStartupDelivery.clear() if (tab) { settleTabPtyBinding(tab.id, exitPtyId, code) } @@ -153,9 +142,7 @@ export async function launchAgentBackgroundSession( onAgentStatus }) const handleData = (data: string): void => { - data = sshStartupDelivery.handleData(data) onData?.(data) - sshStartupDelivery.schedule(ptyId) agentStatusConsumer.consume(data) } try { @@ -190,9 +177,12 @@ export async function launchAgentBackgroundSession( cwd: worktree.path, command: startupPlan.launchCommand, ...(!sshConnectionId && isWslUncPath(worktree.path) ? { shellOverride: 'wsl.exe' } : {}), - ...(!startupPlan.startupCommandDelivery - ? {} - : { startupCommandDelivery: startupPlan.startupCommandDelivery }), + // Why: the relay types, waits for the shell and stages long lines on the host that owns the PTY. + ...(sshConnectionId + ? { commandDelivery: 'provider' as const, startupCommandDelivery: 'shell-ready' as const } + : startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}), env: paneEnv, launchConfig: startupPlan.launchConfig, launchToken, @@ -209,7 +199,6 @@ export async function launchAgentBackgroundSession( }) ptyId = result.id spawned = result - sshStartupDelivery.applyHostShellReadyArmed(result.shellReadyArmed) } const adopted = await adoptAgentBackgroundSessionTab({ store, @@ -223,7 +212,6 @@ export async function launchAgentBackgroundSession( runtimeTerminalHandle, onRetire: () => { exitHandled = true - sshStartupDelivery.clear() store.clearAgentLaunchConfig(paneKey) }, ...(title ? { title } : {}) @@ -279,8 +267,6 @@ export async function launchAgentBackgroundSession( // alive regardless of whether the tab is hidden or mounted. unsubscribeExit = subscribeToPtyExit(ptyId, (code) => handleExit(ptyId, code)) } - sshStartupDelivery.armFallback(ptyId) - // Why: bind the explicit PTY and ownership before mount; an earlier mount // can double-spawn, while later tracking can miss user takeover. requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] }) @@ -298,7 +284,6 @@ export async function launchAgentBackgroundSession( const createdTab = tab runBestEffortAgentBackgroundCleanups(unsubscribeExit, unsubscribeData) runBestEffortAgentBackgroundCleanups(() => eagerPtyBuffer?.dispose()) - runBestEffortAgentBackgroundCleanups(() => sshStartupDelivery.clear()) if (createdTab) { runBestEffortAgentBackgroundCleanups(() => store.clearTabPtyId(createdTab.id, ptyId)) } diff --git a/src/renderer/src/lib/ssh-background-startup-delivery.test.ts b/src/renderer/src/lib/ssh-background-startup-delivery.test.ts deleted file mode 100644 index 427d1dbdb8f..00000000000 --- a/src/renderer/src/lib/ssh-background-startup-delivery.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createSshBackgroundStartupDelivery } from './ssh-background-startup-delivery' - -const SHELL_READY = '\x1b]777;orca-shell-ready\x07' - -function createDelivery(): { - delivery: ReturnType - write: ReturnType -} { - const write = vi.fn() - return { - delivery: createSshBackgroundStartupDelivery({ - command: 'codex "run the automation"', - waitForShellReady: true, - write - }), - write - } -} - -// Bracketed paste only wraps multiline submissions, so the marker's effect on it -// is only observable through a command that carries a newline. -const MULTILINE_COMMAND = 'codex "run the\nautomation"' - -function createMultilineDelivery(waitForShellReady: boolean): { - delivery: ReturnType - write: ReturnType -} { - const write = vi.fn() - return { - delivery: createSshBackgroundStartupDelivery({ - command: MULTILINE_COMMAND, - waitForShellReady, - write - }), - write - } -} - -beforeEach(() => { - vi.useFakeTimers() -}) - -afterEach(() => { - vi.useRealTimers() -}) - -describe('createSshBackgroundStartupDelivery shell-ready fallback', () => { - it('does not force delivery at the short deadline while the remote shell is still silent', () => { - const { delivery, write } = createDelivery() - - // Armed at spawn, before any byte arrives (launch-agent-background-session). - delivery.armFallback('pty-1') - // A cold host sourcing /etc/profile plus nvm/pyenv has not prompted yet. - vi.advanceTimersByTime(3_000) - - expect(write).not.toHaveBeenCalled() - - // The prompt finally lands with the marker; delivery follows normally. - delivery.handleData(`${SHELL_READY}user@remote repo % `) - vi.advanceTimersByTime(50) - - expect(write).toHaveBeenCalledTimes(1) - expect(write.mock.calls[0]?.[1]).toContain('codex "run the automation"') - }) - - it('still delivers eventually when a shell can never emit the marker, and not before 15s', () => { - const { delivery, write } = createDelivery() - - delivery.armFallback('pty-1') - // Pin the boundary: asserting only eventual delivery would let the budget - // silently shrink back toward the short deadline this fix moved off. - vi.advanceTimersByTime(14_999) - - expect(write).not.toHaveBeenCalled() - - vi.advanceTimersByTime(1) - vi.advanceTimersByTime(50) - - expect(write).toHaveBeenCalledTimes(1) - }) - - it('keeps the short post-output deadline once the shell has started talking', () => { - const { delivery, write } = createDelivery() - - delivery.armFallback('pty-1') - // Output without the marker: the shell is alive but cannot emit it. - delivery.handleData('user@remote repo % ') - vi.advanceTimersByTime(1_550) - vi.advanceTimersByTime(50) - - expect(write).toHaveBeenCalledTimes(1) - }) - - // The long budget exists to protect the bracketed paste from landing before - // readline arms it. 'fast' delivery waits for no marker and pastes nothing - // prompt-sensitive, so stretching it there is latency with nothing bought. - it('keeps the short deadline for fast delivery, which waits for no marker', () => { - const write = vi.fn() - const delivery = createSshBackgroundStartupDelivery({ - command: 'codex "run the automation"', - waitForShellReady: false, - write - }) - - delivery.armFallback('pty-1') - vi.advanceTimersByTime(1_550) - - expect(write).toHaveBeenCalledTimes(1) - }) - - // #18767: the marker is what proves the host wrapped the shell and armed - // bracketed paste. A fallback release means it never did. - it('uses bracketed paste only after the marker actually arrived', () => { - const { delivery, write } = createMultilineDelivery(true) - - delivery.armFallback('pty-1') - delivery.handleData(`${SHELL_READY}user@remote repo % `) - vi.advanceTimersByTime(50) - - expect(write.mock.calls[0]?.[1]).toContain('\x1b[200~') - }) - - // The host answers in the spawn reply whether it armed the marker. `false` means - // none will ever come, so the pre-#18796 fast path applies; `true` keeps the wait; - // absent is an older host and leaves the client-side prediction alone. - describe('host shell-ready verdict', () => { - it('delivers immediately and raw when the host reports the marker was not armed', () => { - const { delivery, write } = createMultilineDelivery(true) - - delivery.applyHostShellReadyArmed(false) - delivery.armFallback('pty-1') - // The launch flow schedules on every data chunk (launch-agent-background-session). - delivery.handleData('user@remote repo % ') - delivery.schedule('pty-1') - vi.advanceTimersByTime(50) - - expect(write).toHaveBeenCalledTimes(1) - expect(write.mock.calls[0]?.[1]).not.toContain('\x1b[200~') - }) - - it('keeps the short silent-shell budget once the host says no marker is coming', () => { - const { delivery, write } = createDelivery() - - delivery.applyHostShellReadyArmed(false) - delivery.armFallback('pty-1') - vi.advanceTimersByTime(1_550) - - expect(write).toHaveBeenCalledTimes(1) - }) - - it('still waits for the marker when the host reports it armed one', () => { - const { delivery, write } = createDelivery() - - delivery.applyHostShellReadyArmed(true) - delivery.armFallback('pty-1') - delivery.handleData('user@remote repo % ') - vi.advanceTimersByTime(1_400) - - expect(write).not.toHaveBeenCalled() - - delivery.handleData(`${SHELL_READY}user@remote repo % `) - vi.advanceTimersByTime(50) - - expect(write).toHaveBeenCalledTimes(1) - }) - - it('keeps the client-side prediction when an older host omits the verdict', () => { - const { delivery, write } = createDelivery() - - delivery.applyHostShellReadyArmed(undefined) - delivery.armFallback('pty-1') - delivery.handleData('user@remote repo % ') - vi.advanceTimersByTime(1_400) - - expect(write).not.toHaveBeenCalled() - - vi.advanceTimersByTime(150) - vi.advanceTimersByTime(50) - - expect(write).toHaveBeenCalledTimes(1) - }) - }) - - it('submits raw when the wait ends at the fallback instead of the marker', () => { - const { delivery, write } = createMultilineDelivery(true) - - delivery.armFallback('pty-1') - delivery.handleData('user@remote repo % ') - vi.advanceTimersByTime(1_550) - vi.advanceTimersByTime(50) - - expect(write).toHaveBeenCalledTimes(1) - expect(write.mock.calls[0]?.[1]).not.toContain('\x1b[200~') - }) -}) diff --git a/src/renderer/src/lib/ssh-background-startup-delivery.ts b/src/renderer/src/lib/ssh-background-startup-delivery.ts deleted file mode 100644 index 0e1c08c70ff..00000000000 --- a/src/renderer/src/lib/ssh-background-startup-delivery.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { - createShellReadyMarkerScanState, - scanForShellReadyMarker -} from '@/components/terminal-pane/shell-ready-marker-scan' -import { - isCodexStartupCommand, - shouldUseShellReadyStartupDelivery, - type StartupCommandDelivery -} from '../../../shared/codex-startup-delivery' -import { buildStartupCommandSubmission } from '../../../shared/startup-command-submission' - -/** - * Why every Codex launch waits and not only the prompt-carrying ones: the remote - * shell is the host's to know, and it arms the ready marker for plain Codex too - * (#18767). On such a host the wait ends at the prompt and costs nothing. On one - * that never publishes a marker -- fish, sh, Windows, or a host predating #18767 -- - * the fallback below releases instead, at the same price prompt-carrying Codex - * already paid there. - */ -export function sshBackgroundLaunchWaitsForShellReady(startupPlan: { - launchCommand: string | null | undefined - startupCommandDelivery?: StartupCommandDelivery -}): boolean { - return ( - isCodexStartupCommand(startupPlan.launchCommand) || - shouldUseShellReadyStartupDelivery({ - command: startupPlan.launchCommand, - startupCommandDelivery: startupPlan.startupCommandDelivery - }) - ) -} - -const SSH_SHELL_READY_STARTUP_FALLBACK_MS = 1500 -// Why: a remote shell that has not emitted a single byte is still booting — -// /etc/profile plus nvm/conda/pyenv over a cold link routinely needs more than -// the post-output deadline. Force-delivering there writes the bracketed-paste -// command before readline arms it, so a silent-since-spawn shell gets a longer -// budget; the short deadline applies once output proves the shell is talking. -const SSH_SHELL_READY_NO_OUTPUT_FALLBACK_MS = 15_000 - -type SshBackgroundStartupDeliveryOptions = { - command: string | null - waitForShellReady: boolean - write: (ptyId: string, data: string) => void -} - -export type SshBackgroundStartupDelivery = { - handleData(data: string): string - armFallback(ptyId: string): void - schedule(ptyId: string): void - /** - * The host's verdict from the spawn reply, which lands after this delivery was built. - * `false` releases the wait: no marker will ever come. `true` keeps it. `undefined` is - * a host that predates the field, so the constructor-time prediction stands. - */ - applyHostShellReadyArmed(armed: boolean | undefined): void - clear(): void -} - -export function createSshBackgroundStartupDelivery( - options: SshBackgroundStartupDeliveryOptions -): SshBackgroundStartupDelivery { - let pendingCommand = options.command - let lastPtyId: string | null = null - let waitForShellReady = options.waitForShellReady - let startupShellReady = !waitForShellReady - // Why tracked apart from `startupShellReady`: only an observed marker proves the - // host wrapped the shell and armed bracketed paste. A fallback release means the - // host shell never published one, so the raw submit is the only safe form. - let markerObserved = false - let markerScan = waitForShellReady ? createShellReadyMarkerScanState() : null - let injectTimer: ReturnType | null = null - let fallbackTimer: ReturnType | null = null - let sawOutput = false - - const clearInjectTimer = (): void => { - if (injectTimer !== null) { - clearTimeout(injectTimer) - injectTimer = null - } - } - const clearFallbackTimer = (): void => { - if (fallbackTimer !== null) { - clearTimeout(fallbackTimer) - fallbackTimer = null - } - } - function markShellReady(): void { - if (startupShellReady) { - return - } - startupShellReady = true - markerObserved = true - clearFallbackTimer() - if (pendingCommand && lastPtyId) { - schedule(lastPtyId) - } - } - - const armFallback = (ptyId: string): void => { - lastPtyId = ptyId - if (!pendingCommand || fallbackTimer !== null) { - return - } - // The long budget only buys time for the shell-ready marker; the fast path - // pastes nothing prompt-sensitive, so delaying it there is pure latency. - const waitingForSilentShell = waitForShellReady && !sawOutput - fallbackTimer = setTimeout( - () => { - fallbackTimer = null - startupShellReady = true - schedule(ptyId) - }, - waitingForSilentShell - ? SSH_SHELL_READY_NO_OUTPUT_FALLBACK_MS - : SSH_SHELL_READY_STARTUP_FALLBACK_MS - ) - } - - const schedule = (ptyId: string): void => { - lastPtyId = ptyId - if (!pendingCommand) { - return - } - if (!startupShellReady) { - armFallback(ptyId) - return - } - clearFallbackTimer() - clearInjectTimer() - injectTimer = setTimeout(() => { - injectTimer = null - const command = pendingCommand - if (!command) { - return - } - pendingCommand = null - // Why: the SSH relay treats spawn.command as metadata for interactive - // PTYs; hidden automation tabs still submit the command themselves. - // Why bracketed paste: multiline prompts are pasted literally only when we - // synchronized on the Orca shell-ready marker — that is the bash/zsh overlay - // with bracketed-paste mode armed. Submit with CR since the relay drives a - // remote shell. - options.write( - ptyId, - buildStartupCommandSubmission(command, { - submit: '\r', - bracketedPasteSafe: markerObserved - }) - ) - }, 50) - } - - return { - handleData(data) { - // First byte proves the shell is talking, so the spawn-time long budget - // collapses back to the original post-output deadline. - if (!sawOutput && data.length > 0) { - sawOutput = true - if (fallbackTimer !== null && !startupShellReady && lastPtyId) { - clearFallbackTimer() - armFallback(lastPtyId) - } - } - if (!markerScan) { - return data - } - const scanned = scanForShellReadyMarker(markerScan, data) - if (scanned.matched) { - markShellReady() - } - return scanned.output - }, - armFallback, - schedule, - applyHostShellReadyArmed(armed) { - if (armed !== false || !waitForShellReady) { - return - } - // Not a marker sighting: bracketed paste stays unproven, so the submit stays raw. - waitForShellReady = false - startupShellReady = true - markerScan = null - clearFallbackTimer() - if (lastPtyId) { - schedule(lastPtyId) - } - }, - clear() { - clearInjectTimer() - clearFallbackTimer() - pendingCommand = null - lastPtyId = null - } - } -} diff --git a/src/shared/local-build-compatibility-contract.json b/src/shared/local-build-compatibility-contract.json index 205171615ca..0e90a402914 100644 --- a/src/shared/local-build-compatibility-contract.json +++ b/src/shared/local-build-compatibility-contract.json @@ -3,9 +3,9 @@ "appId": "com.stablyai.orca", "stateSchemaVersion": 1, "readableStateSchemaVersions": [1], - "daemonProtocolVersion": 39, + "daemonProtocolVersion": 40, "attachableDaemonProtocolVersions": [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39 + 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 ] } diff --git a/src/shared/local-build-compatibility-contract.ts b/src/shared/local-build-compatibility-contract.ts index 441dba09c4b..75a414b2a5f 100644 --- a/src/shared/local-build-compatibility-contract.ts +++ b/src/shared/local-build-compatibility-contract.ts @@ -3,9 +3,9 @@ export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = { appId: 'com.stablyai.orca', stateSchemaVersion: 1, readableStateSchemaVersions: [1], - daemonProtocolVersion: 39, + daemonProtocolVersion: 40, attachableDaemonProtocolVersions: [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39 + 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 ] } as const diff --git a/src/shared/startup-command-staging.node-pty.test.ts b/src/shared/startup-command-staging.node-pty.test.ts new file mode 100644 index 00000000000..72f41c58a66 --- /dev/null +++ b/src/shared/startup-command-staging.node-pty.test.ts @@ -0,0 +1,158 @@ +/** + * Real shells in real PTYs: the line a host types for a long or multi-line launch + * must reach the agent's argv byte for byte, and leave no script behind. + * + * The line is written the moment the shell spawns, while the TTY is still in + * canonical mode — the window where a typed line past MAX_CANON is truncated + * and a raw newline submits early. + */ +import { execFileSync } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as pty from 'node-pty' +import { afterAll, describe, expect, it } from 'vitest' +import { resolveFishBinary } from './fish-binary-requirement' +import { stageStartupCommand } from './startup-command-staging' +import { buildStartupCommandSubmission } from './startup-command-submission' +import { quoteStartupArg } from './tui-agent-startup-shell' + +type LiveShell = { name: string; path: string; args: string[] } + +function discoverShells(): LiveShell[] { + if (process.platform === 'win32') { + return [] + } + const shells: LiveShell[] = [] + const add = (name: string, path: string, args: string[]): void => { + if (existsSync(path) && !shells.some((shell) => shell.name === name)) { + shells.push({ name, path, args }) + } + } + add('bash', '/bin/bash', ['--noprofile', '--norc', '-i']) + add('zsh', '/bin/zsh', ['-f', '-i']) + add('dash', '/bin/dash', ['-i']) + add('dash', '/usr/bin/dash', ['-i']) + add('sh', '/bin/sh', ['-i']) + add('ksh', '/bin/ksh', ['-i']) + // Why tcsh: no staging shell; its line runs the POSIX script through /bin/sh. + add('tcsh', '/bin/tcsh', ['-f', '-i']) + const fish = resolveFishBinary(3) + if (fish.available) { + // Why absolute: the sandbox PATH would not resolve a bare `fish`. + const fishPath = fish.path.includes('/') + ? fish.path + : execFileSync('/bin/sh', ['-c', `command -v ${fish.path}`], { encoding: 'utf8' }).trim() + add('fish', fishPath, ['--no-config', '-i']) + } + return shells +} + +const SANDBOX = mkdtempSync(join(tmpdir(), 'orca-staging-pty-')) +const AGENT = join(SANDBOX, 'agent') +writeFileSync( + AGENT, + `#!/bin/sh\nfor arg in "$@"; do printf '%s\\0' "$arg"; done > "$ORCA_TEST_CAPTURE"\n` +) +chmodSync(AGENT, 0o755) + +afterAll(() => { + rmSync(SANDBOX, { recursive: true, force: true }) +}) + +const HOSTILE = `it's "quoted" $HOME \`id\` $(id) \\\\server\\share %PATH% !! #` + +const PROMPTS: [string, string][] = [ + ['a 600-byte prompt', `${HOSTILE} ${'x'.repeat(600)}`], + // Why 5 KB: past Linux's 4096-byte canonical buffer as well as macOS's 1024. + ['a 5 KB prompt', `${HOSTILE} ${'y'.repeat(5000)}`], + ['a multi-line prompt with a trailing newline', `first line\n${HOSTILE}\n\nlast line\n`], + // Why: typed raw, a line editor reads the TAB as completion and mangles the argument. + ['a prompt with a tab', 'before\tafter'] +] + +async function launchInRealShell(shell: LiveShell, prompt: string): Promise { + const caseDir = mkdtempSync(join(SANDBOX, `${shell.name}-`)) + const capture = join(caseDir, 'argv') + const command = `${quoteStartupArg(AGENT, 'posix')} ${quoteStartupArg(prompt, 'posix')}` + const staging = stageStartupCommand({ + command, + shellPath: shell.path, + orcaBuiltLine: true, + directory: caseDir + }) + const proc = pty.spawn(shell.path, shell.args, { + name: 'xterm-256color', + cols: 120, + rows: 30, + cwd: caseDir, + env: { + PATH: '/usr/bin:/bin:/usr/sbin:/sbin', + HOME: caseDir, + TERM: 'xterm-256color', + XDG_CONFIG_HOME: join(caseDir, 'config'), + XDG_DATA_HOME: join(caseDir, 'data'), + ORCA_TEST_CAPTURE: capture + } + }) + const typeLine = (): void => + proc.write( + buildStartupCommandSubmission(staging.command, { submit: '\n', bracketedPasteSafe: false }) + ) + // Why fish differs: it blocks on terminal queries and drops input typed before its prompt, which + // is why hosts wait for its ready marker; the other shells get the harsher at-spawn write. + let typed = false + const onData = proc.onData((data) => { + if (shell.name !== 'fish') { + return + } + proc.write('\x1b[?62;22c'.repeat(data.split('\x1b[0c').length - 1)) + proc.write('\x1b[1;1R'.repeat(data.split('\x1b[6n').length - 1)) + if (!typed && data.includes('\x1b]133;A')) { + typed = true + typeLine() + } + }) + try { + if (shell.name !== 'fish') { + typeLine() + } + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + if (existsSync(capture)) { + // Why a beat: the agent's redirect creates the file before printf fills it. + await new Promise((resolve) => setTimeout(resolve, 100)) + return readFileSync(capture, 'utf8') + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + return null + } finally { + onData.dispose() + proc.kill() + expect(readdirSync(caseDir).filter((name) => name.startsWith('orca-launch-'))).toEqual([]) + } +} + +const SHELLS = discoverShells() +const describeShells = SHELLS.length > 0 ? describe : describe.skip + +describeShells('a staged launch line in a real shell', () => { + for (const shell of SHELLS) { + it.each(PROMPTS)( + `reaches the agent byte for byte in ${shell.name}: %s`, + async (_, prompt) => { + expect(await launchInRealShell(shell, prompt)).toBe(`${prompt}\0`) + }, + 20_000 + ) + } +}) diff --git a/src/shared/startup-command-staging.test.ts b/src/shared/startup-command-staging.test.ts new file mode 100644 index 00000000000..8a7568c17d1 --- /dev/null +++ b/src/shared/startup-command-staging.test.ts @@ -0,0 +1,213 @@ +import { + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + STAGED_STARTUP_COMMAND_STALE_MS, + discardStagedStartupCommand, + stageStartupCommand, + startupStagingFailureNotice, + sweepStaleStagedStartupCommands +} from './startup-command-staging' + +let directory: string + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'orca-staging-test-')) +}) + +afterEach(() => { + rmSync(directory, { recursive: true, force: true }) +}) + +function stage(command: string, shellPath = '/bin/zsh', platform: NodeJS.Platform = 'darwin') { + return stageStartupCommand({ command, shellPath, platform, directory }) +} + +describe('stageStartupCommand', () => { + it('types a short single-line command as is', () => { + expect(stage(`claude 'fix it'`)).toEqual({ command: `claude 'fix it'`, delivery: 'typed' }) + expect(readdirSync(directory)).toEqual([]) + }) + + it('types exactly 512 bytes, stages 513', () => { + expect(stage('a'.repeat(512)).delivery).toBe('typed') + expect(stage('a'.repeat(513)).delivery).toBe('staged') + }) + + it('measures bytes, not characters', () => { + expect(stage('é'.repeat(257)).delivery).toBe('staged') + }) + + it('ignores a trailing submit byte when measuring', () => { + expect(stage(`${'a'.repeat(512)}\n`).delivery).toBe('typed') + expect(stage(`${'a'.repeat(512)}\r\n`).delivery).toBe('typed') + }) + + it.each([['\n'], ['\r'], ['\t'], ['\x1b'], ['\x03'], ['\x7f']])( + 'stages a short line carrying control byte %j', + (byte) => { + expect(stage(`claude 'a${byte}b'`).delivery).toBe('staged') + } + ) + + it('writes a 0600 script whose body is exactly the command, and types a sourcing line', () => { + const command = `claude '${'x'.repeat(600)}'` + const staged = stage(`${command}\n`) + expect(staged.delivery).toBe('staged') + expect(staged.scriptPath).toBeDefined() + const scriptPath = staged.scriptPath! + expect(staged.command).toBe(`. '${scriptPath}'`) + expect(statSync(scriptPath).mode & 0o777).toBe(0o600) + expect(readFileSync(scriptPath, 'utf8')).toBe(`command rm -f -- '${scriptPath}'\n${command}\n`) + }) + + it('sources with `source` in fish', () => { + const staged = stage('a'.repeat(600), '/opt/homebrew/bin/fish') + expect(staged.command).toBe(`source '${staged.scriptPath}'`) + }) + + it('recognizes a login shell name', () => { + expect(stage('a'.repeat(600), '-zsh').delivery).toBe('staged') + }) + + it('sources the script in ksh like sh', () => { + const staged = stage('a'.repeat(600), '/bin/ksh') + expect(staged.command).toBe(`. '${staged.scriptPath}'`) + }) + + // Why: typed raw, tcsh runs a prompt's second line as a command and MAX_CANON cuts a long one. + it.each([['/usr/bin/nu'], ['/bin/tcsh'], ['/usr/local/bin/pwsh'], [undefined]])( + 'runs an Orca-built launch line through /bin/sh for a shell that cannot source it (%s)', + (shellPath) => { + const staged = stageStartupCommand({ + command: `claude 'one\ntwo'`, + shellPath, + orcaBuiltLine: true, + platform: 'darwin', + directory + }) + expect(staged.delivery).toBe('staged') + expect(staged.command).toBe(`/bin/sh '${staged.scriptPath}'`) + } + ) + + // Measured: tcsh typed `'fix C:'"\\"'dir'` as `fix C:\\dir` and expanded `!!` in `'wow!! great'`. + it.each([['/bin/tcsh'], ['/usr/bin/nu'], [undefined]])( + 'stages even a short Orca-built line for a shell that cannot source it (%s)', + (shellPath) => { + const command = `claude 'wow!! great'` + const staged = stageStartupCommand({ + command, + shellPath, + orcaBuiltLine: true, + platform: 'darwin', + directory + }) + expect(staged.command).toBe(`/bin/sh '${staged.scriptPath}'`) + expect(readFileSync(staged.scriptPath!, 'utf8')).toContain(`\n${command}\n`) + } + ) + + it('types a short Orca-built line in a shell that reads its quoting literally', () => { + const command = `claude 'wow!! great'` + expect( + stageStartupCommand({ command, shellPath: '/bin/zsh', orcaBuiltLine: true, directory }) + ).toEqual({ command, delivery: 'typed' }) + }) + + // Why: a quick command or --command written for tcsh or nu is not sh syntax. + it.each([['/bin/tcsh'], ['/usr/bin/nu'], [undefined]])( + 'types a command the user wrote as it always was in a shell that cannot source it (%s)', + (shellPath) => { + const command = `foreach f (*.log)\n echo $f\nend` + const staged = stageStartupCommand({ command, shellPath, platform: 'darwin', directory }) + expect(staged).toEqual({ command, delivery: 'typed' }) + expect(readdirSync(directory)).toEqual([]) + } + ) + + it('leaves Windows hosts alone', () => { + expect(stage('a'.repeat(600), 'C:\\Program Files\\Git\\bin\\bash.exe', 'win32').delivery).toBe( + 'typed' + ) + expect(stage('a'.repeat(600), '/bin/bash', 'win32').delivery).toBe('typed') + }) + + it('types the full command and says so when the script cannot be written', () => { + const command = 'a'.repeat(600) + const staged = stageStartupCommand({ + command, + shellPath: '/bin/bash', + platform: 'linux', + directory: join(directory, 'missing') + }) + expect(staged.command).toBe(command) + expect(staged.delivery).toBe('typed-after-stage-failed') + expect(staged.failure).toMatch(/ENOENT/) + expect(staged.scriptPath).toBeUndefined() + }) + + it('gives every launch its own script', () => { + const first = stage('a'.repeat(600)) + const second = stage('a'.repeat(600)) + expect(first.scriptPath).not.toBe(second.scriptPath) + }) +}) + +describe('discardStagedStartupCommand', () => { + it('removes a script the shell never sourced', () => { + const staged = stage('a'.repeat(600)) + discardStagedStartupCommand(staged) + expect(readdirSync(directory)).toEqual([]) + }) + + it('is a no-op once the script deleted itself', () => { + const staged = stage('a'.repeat(600)) + rmSync(staged.scriptPath!) + expect(() => discardStagedStartupCommand(staged)).not.toThrow() + }) +}) + +describe('sweepStaleStagedStartupCommands', () => { + it('removes only staged scripts older than an hour', () => { + const now = Date.now() + const stale = join(directory, 'orca-launch-aaaa.sh') + const fresh = join(directory, 'orca-launch-bbbb.sh') + const unrelated = join(directory, 'something-else.sh') + for (const path of [stale, fresh, unrelated]) { + writeFileSync(path, '') + } + const staleSeconds = (now - STAGED_STARTUP_COMMAND_STALE_MS - 1000) / 1000 + utimesSync(stale, staleSeconds, staleSeconds) + utimesSync(unrelated, staleSeconds, staleSeconds) + sweepStaleStagedStartupCommands({ directory, now }) + expect(readdirSync(directory).sort()).toEqual(['orca-launch-bbbb.sh', 'something-else.sh']) + }) +}) + +describe('startupStagingFailureNotice', () => { + it('says nothing when staging did not fail', () => { + expect(startupStagingFailureNotice(stage(`claude 'fix it'`))).toBeNull() + expect(startupStagingFailureNotice(stage(`claude '${'x'.repeat(600)}'`))).toBeNull() + }) + + it('prints one line naming the failure, with no control bytes from the reason', () => { + const notice = startupStagingFailureNotice({ + command: 'x', + delivery: 'typed-after-stage-failed', + failure: 'EACCES: permission denied\nsecond line' + }) + expect(notice).toBe( + '\r\n[orca] Could not stage the launch command (EACCES: permission denied second line); typed it in full. If the shell shows quote> or waits for more input, press Ctrl-C and launch again.\r\n' + ) + }) +}) diff --git a/src/shared/startup-command-staging.ts b/src/shared/startup-command-staging.ts new file mode 100644 index 00000000000..18f78fa8444 --- /dev/null +++ b/src/shared/startup-command-staging.ts @@ -0,0 +1,184 @@ +/** + * Stages a long or multi-line startup command as a self-deleting script so the + * host types only a short line that sources it. + * + * Why: the shell-ready marker fires before the line editor takes the TTY out of + * canonical mode, so a typed line past MAX_CANON (1024 bytes on macOS) is + * silently truncated, and macOS bash 3.2 reads each raw newline as Enter. Run + * by the host that owns the PTY, at the moment it accepts the spawn. + */ +import { randomBytes } from 'node:crypto' +import { readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { quoteStartupArg } from './tui-agent-startup-shell' + +/** Half of macOS MAX_CANON: a typed line this long survived every canonical-mode write measured. */ +export const TYPED_STARTUP_COMMAND_BUDGET_BYTES = 512 + +export const STAGED_STARTUP_COMMAND_PREFIX = 'orca-launch-' + +/** Which delivery ran for a spawn's typed startup line, decided by the host that owns the PTY. */ +export type StartupLineDelivery = 'typed' | 'staged' | 'typed-after-stage-failed' + +/** Age past which a staged script is a crash leftover rather than another host's in-flight launch. */ +export const STAGED_STARTUP_COMMAND_STALE_MS = 60 * 60 * 1000 + +export type StartupCommandStaging = { + /** What the host types: the command itself, or a short line sourcing the staged script. */ + command: string + delivery: StartupLineDelivery + /** Present while the script exists; the host unlinks it if the PTY exits before sourcing it. */ + scriptPath?: string + /** Why staging failed, for the host's log. */ + failure?: string +} + +// Why only these source the script: the staged body is Orca's portable quoting, verified literal in +// these. Any other shell (tcsh, nu, xonsh, pwsh...) runs an agent launch line Orca built through +// `/bin/sh` instead; a command the user wrote for that shell is typed as it always was. +const STAGING_SHELLS = new Set(['bash', 'zsh', 'sh', 'dash', 'fish', 'ksh', 'mksh']) + +const encoder = new TextEncoder() + +export function stagingShellName(shellPath: string | undefined): string | null { + const name = shellPath?.split('/').pop()?.replace(/^-/, '').toLowerCase() + return name && STAGING_SHELLS.has(name) ? name : null +} + +/** Any C0 byte or DEL: no quoter escapes them, so a line editor reads them as keys. */ +function hasControlByte(line: string): boolean { + for (let i = 0; i < line.length; i += 1) { + const code = line.charCodeAt(i) + if (code < 0x20 || code === 0x7f) { + return true + } + } + return false +} + +function stripSubmitTerminator(command: string): string { + return command.replace(/(\r\n|\r|\n)$/, '') +} + +export function shouldStageStartupCommand(args: { + command: string + shellPath: string | undefined + platform: NodeJS.Platform + /** An agent launch line Orca built with POSIX quoting, which sh can run in any shell. */ + orcaBuiltLine?: boolean +}): boolean { + if (args.platform === 'win32') { + return false + } + if (stagingShellName(args.shellPath) === null) { + // Why every length: Orca's POSIX quoting is literal only in the shells above (tcsh doubles a + // quoted backslash and expands `!!`), so another shell only ever sees the script's inert path. + return args.orcaBuiltLine === true + } + const body = stripSubmitTerminator(args.command) + return ( + hasControlByte(body) || encoder.encode(body).byteLength > TYPED_STARTUP_COMMAND_BUDGET_BYTES + ) +} + +let staleSweepStarted = false + +export function stageStartupCommand(args: { + command: string + shellPath: string | undefined + orcaBuiltLine?: boolean + platform?: NodeJS.Platform + directory?: string +}): StartupCommandStaging { + const platform = args.platform ?? process.platform + if (!shouldStageStartupCommand({ ...args, platform })) { + return { command: args.command, delivery: 'typed' } + } + const directory = args.directory ?? tmpdir() + if (!staleSweepStarted) { + staleSweepStarted = true + // Why deferred: the sweep is crash recovery and must never delay this launch. + setTimeout(() => sweepStaleStagedStartupCommands({ directory }), 0).unref?.() + } + const shellName = stagingShellName(args.shellPath) + const scriptPath = join( + directory, + `${STAGED_STARTUP_COMMAND_PREFIX}${randomBytes(8).toString('hex')}.sh` + ) + const quotedPath = quoteStartupArg(scriptPath, 'posix') + try { + // Why rm first: the shell keeps reading the open file, so the prompt-bearing script is gone + // before the agent starts, however long it runs. + writeFileSync( + scriptPath, + `command rm -f -- ${quotedPath}\n${stripSubmitTerminator(args.command)}\n`, + { mode: 0o600, flag: 'wx' } + ) + } catch (error) { + return { + command: args.command, + delivery: 'typed-after-stage-failed', + failure: error instanceof Error ? error.message : String(error) + } + } + return { + command: + shellName === null + ? `/bin/sh ${quotedPath}` + : `${shellName === 'fish' ? 'source' : '.'} ${quotedPath}`, + delivery: 'staged', + scriptPath + } +} + +/** + * The line the host prints to the terminal when staging failed: the full line it types instead may + * leave the shell waiting for more input, and nothing else would tell the user why. + */ +export function startupStagingFailureNotice(staging: StartupCommandStaging): string | null { + if (staging.failure === undefined) { + return null + } + const reason = [...staging.failure] + .map((char) => (char < ' ' || char === '\x7f' ? ' ' : char)) + .join('') + return `\r\n[orca] Could not stage the launch command (${reason}); typed it in full. If the shell shows quote> or waits for more input, press Ctrl-C and launch again.\r\n` +} + +/** For a PTY that exited, or was never typed into, before it sourced its script. */ +export function discardStagedStartupCommand(staging: StartupCommandStaging | undefined): void { + if (!staging?.scriptPath) { + return + } + try { + rmSync(staging.scriptPath, { force: true }) + } catch { + // The age-gated sweep is the fallback. + } +} + +/** Removes scripts a crashed host left behind; age-gated so another instance's fresh launch survives. */ +export function sweepStaleStagedStartupCommands(args: { directory?: string; now?: number }): void { + const directory = args.directory ?? tmpdir() + const now = args.now ?? Date.now() + let names: string[] + try { + names = readdirSync(directory) + } catch { + return + } + for (const name of names) { + if (!name.startsWith(STAGED_STARTUP_COMMAND_PREFIX) || !name.endsWith('.sh')) { + continue + } + const path = join(directory, name) + try { + if (now - statSync(path).mtimeMs > STAGED_STARTUP_COMMAND_STALE_MS) { + rmSync(path, { force: true }) + } + } catch { + // Raced another instance's sweep. + } + } +}