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. + } + } +}