From 9be1739f80b6ace076cca7d51de32a0e8b78a3ef Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:26:28 -0700 Subject: [PATCH] fix(launch-prompt): "takes a launch file" is a host fact; such hosts fall back, never refuse An SSH Windows host's relay may run its panes in WSL (its OpenSSH default shell, which this client cannot see), where it writes no launch file, so a multi-line Claude/Codex prompt there was refused where main started the agent. describeLaunchHost now derives takesLaunchFile (false for a paired Orca and an SSH Windows host), and the one rule gives such a host the line or the caller's paste instead of a file. A spawn routed to a daemon older than launch files only attaches to a session that daemon still runs, so it is sent with neither the launch file nor its command instead of being refused. --- .../daemon-launch-file-protocol.test.ts | 24 ++++++------ src/main/daemon/daemon-pty-spawn-request.ts | 21 ++++------- .../full-creation-startup.test.ts | 2 +- src/renderer/src/lib/launch-file-host.test.ts | 2 +- .../lib/launch-work-item-direct-agent.test.ts | 10 ++--- src/shared/launch-host.test.ts | 37 +++++++++++-------- src/shared/launch-host.ts | 7 +++- src/shared/launch-prompt-carrier.test.ts | 4 +- src/shared/launch-prompt-carry.test.ts | 26 ++++++++++++- src/shared/launch-prompt-carry.ts | 2 +- .../windows-shell-prompt-damage.test.ts | 2 +- 11 files changed, 82 insertions(+), 55 deletions(-) diff --git a/src/main/daemon/daemon-launch-file-protocol.test.ts b/src/main/daemon/daemon-launch-file-protocol.test.ts index a15373cbe9e..f8433d0b3e2 100644 --- a/src/main/daemon/daemon-launch-file-protocol.test.ts +++ b/src/main/daemon/daemon-launch-file-protocol.test.ts @@ -31,10 +31,13 @@ describe('a launch file sent to a terminal daemon', () => { expect(PROTOCOL_VERSION).toBeGreaterThanOrEqual(LAUNCH_FILE_DAEMON_PROTOCOL_VERSION) }) - // Why: an older daemon drops `launchFile` and would type a line naming a file nobody wrote. - it('is refused, with the reason, by a daemon older than launch files', async () => { + // Why: only a session an older daemon still runs routes to it, so the spawn attaches; it must never + // type a line naming a file that daemon would not write, and it must not refuse the attach. + it('is withheld, with its command, from a daemon older than launch files', async () => { vi.spyOn(DaemonClient.prototype, 'ensureConnected').mockResolvedValue() - const request = vi.spyOn(DaemonClient.prototype, 'request') + const request = vi + .spyOn(DaemonClient.prototype, 'request') + .mockResolvedValue({ isNew: false, snapshot: null, pid: 1 }) const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, @@ -42,8 +45,8 @@ describe('a launch file sent to a terminal daemon', () => { }) const { prompt, launchFile } = carryInLaunchFile('the whole task', false) try { - await expect( - legacy.spawn({ + await legacy + .spawn({ sessionId: 'legacy-launch', cols: 80, rows: 24, @@ -51,12 +54,11 @@ describe('a launch file sent to a terminal daemon', () => { launchAgent: 'claude', launchFile }) - ).rejects.toThrow(/launch_file_unavailable/) - expect(request).not.toHaveBeenCalledWith( - 'createOrAttach', - expect.anything(), - expect.anything() - ) + .catch(() => undefined) + const createCall = request.mock.calls.find(([method]) => method === 'createOrAttach') + expect(createCall?.[1]).toMatchObject({ sessionId: 'legacy-launch' }) + expect(createCall?.[1]).not.toHaveProperty('launchFile') + expect(createCall?.[1]).toMatchObject({ command: undefined }) } finally { legacy.dispose() } diff --git a/src/main/daemon/daemon-pty-spawn-request.ts b/src/main/daemon/daemon-pty-spawn-request.ts index 3e224c24406..bae027c9540 100644 --- a/src/main/daemon/daemon-pty-spawn-request.ts +++ b/src/main/daemon/daemon-pty-spawn-request.ts @@ -11,7 +11,6 @@ import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION, LAUNCH_FILE_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' -import { LaunchFileUnavailableError } from '../../shared/launch-file-writing' import type { ColdRestoreInfo } from './history-reader' import { NdjsonLineTooLongError } from './ndjson' import { @@ -110,17 +109,11 @@ export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState { if (opts.signal?.aborted) { throw new Error('client_disconnected') } - // Why refuse: an older daemon (a same-id respawn routed to it) would drop the file and type - // a line naming it. The refusal reaches the user with the prompt to copy. Temporary: main - // pastes here, but a paste needs a second, prompt-less plan and a guarded paste at every - // spawn site; only sessions an older daemon still holds route here, and they end when closed. - if ( + // Why no command: only a session an older daemon still runs routes here, so this attaches and + // types nothing; were it gone, a line naming a file that daemon never writes must not run. + const launches = !context.attachOnly && - opts.launchFile && - this.protocolVersion < LAUNCH_FILE_DAEMON_PROTOCOL_VERSION - ) { - throw new LaunchFileUnavailableError("this terminal's daemon is from an older Orca") - } + !(opts.launchFile && this.protocolVersion < LAUNCH_FILE_DAEMON_PROTOCOL_VERSION) const payload = { sessionId: context.sessionId, cols: context.effectiveCols, @@ -128,9 +121,9 @@ export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState { cwd: context.attachOnly ? undefined : context.effectiveCwd, env: context.attachOnly ? undefined : opts.env, envToDelete: context.attachOnly ? undefined : opts.envToDelete, - command: context.attachOnly ? undefined : opts.command, - startupCommandDelivery: context.attachOnly ? undefined : opts.startupCommandDelivery, - ...(!context.attachOnly && opts.launchFile ? { launchFile: opts.launchFile } : {}), + command: launches ? opts.command : undefined, + startupCommandDelivery: launches ? opts.startupCommandDelivery : undefined, + ...(launches && opts.launchFile ? { launchFile: opts.launchFile } : {}), ...(!context.attachOnly && opts.wslLaunchDirectory ? { wslLaunchDirectory: opts.wslLaunchDirectory } : {}), diff --git a/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts b/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts index 33b00e8c2ac..b377610f81d 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts @@ -16,7 +16,7 @@ describe('the full composer’s renderer-spawned startup', () => { cmdOverrides: {}, platform: 'linux', isRemote: true, - host: { paired: false, provesAgentInFront: true }, + host: { paired: false, provesAgentInFront: true, takesLaunchFile: true }, paste: 'when-host-proves-agent' }), prompt diff --git a/src/renderer/src/lib/launch-file-host.test.ts b/src/renderer/src/lib/launch-file-host.test.ts index 4dbdb125d40..53256c7ca09 100644 --- a/src/renderer/src/lib/launch-file-host.test.ts +++ b/src/renderer/src/lib/launch-file-host.test.ts @@ -26,7 +26,7 @@ describe('the quick composer on a paired host', () => { platform: 'linux', shell: null, isRemote: false, - host: { paired: true, provesAgentInFront: true }, + host: { paired: true, provesAgentInFront: true, takesLaunchFile: false }, telemetrySource: 'sidebar' }) expect(startup.backendStartup).toBeUndefined() diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts index b12f61c652f..a834b38c49d 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts @@ -84,7 +84,7 @@ const settings = { describe('buildDirectWorkItemAgentStartupPlan', () => { it('omits native-chat preferences when the new workspace opens in terminal mode', () => { const result = buildDirectWorkItemAgentStartupPlan({ - host: { paired: false, provesAgentInFront: true }, + host: { paired: false, provesAgentInFront: true, takesLaunchFile: true }, agent: 'codex', draftContent: 'Review issue 42', promptDelivery: 'draft', @@ -99,7 +99,7 @@ describe('buildDirectWorkItemAgentStartupPlan', () => { it('applies native-chat preferences when the new workspace opens in chat', () => { const result = buildDirectWorkItemAgentStartupPlan({ - host: { paired: false, provesAgentInFront: true }, + host: { paired: false, provesAgentInFront: true, takesLaunchFile: true }, agent: 'codex', draftContent: 'Review issue 42', promptDelivery: 'draft', @@ -148,7 +148,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => it('resolves the global Agents arguments when the launch names none', () => { const result = buildDirectWorkItemAgentStartupPlan({ - host: { paired: false, provesAgentInFront: true }, + host: { paired: false, provesAgentInFront: true, takesLaunchFile: true }, agent: 'codex', draftContent: 'Fix the broken checks', promptDelivery: 'draft', @@ -162,7 +162,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => it('lets an explicit per-action value win over the global one', () => { const result = buildDirectWorkItemAgentStartupPlan({ - host: { paired: false, provesAgentInFront: true }, + host: { paired: false, provesAgentInFront: true, takesLaunchFile: true }, agent: 'codex', agentArgs: '--model gpt-5', draftContent: 'Fix the broken checks', @@ -180,7 +180,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => describe('buildDirectWorkItemAgentStartupPlan submitted prompts', () => { const submit = (agent: 'claude' | 'gemini', draftContent: string, paired = false) => buildDirectWorkItemAgentStartupPlan({ - host: { paired, provesAgentInFront: true }, + host: { paired, provesAgentInFront: true, takesLaunchFile: !paired }, agent, draftContent, promptDelivery: 'submit-after-ready', diff --git a/src/shared/launch-host.test.ts b/src/shared/launch-host.test.ts index 7eb4de191b4..d7679cef75b 100644 --- a/src/shared/launch-host.test.ts +++ b/src/shared/launch-host.test.ts @@ -1,21 +1,26 @@ import { describe, expect, it } from 'vitest' import { describeLaunchHost } from './launch-host' -describe('whether a launch host can prove its agent is in front before a paste', () => { +describe('what a launch host can do with its prompt', () => { it.each([ - ['a local macOS host', false, false, 'darwin', 'darwin', true], - ['a local Linux host', false, false, 'linux', 'linux', true], - ['a local Windows host', false, false, 'win32', 'win32', false], - // The pane runs in the distro, but the reads run on the Windows host. - ['a local WSL pane', false, false, 'linux', 'win32', false], - ['an SSH Linux host from Windows', true, false, 'linux', 'win32', true], - ['an SSH Windows host', true, false, 'win32', 'darwin', false], - ['a paired Linux Orca from Windows', false, true, 'linux', 'win32', true], - ['a paired Windows Orca from macOS', false, true, 'win32', 'darwin', false] - ] as const)('%s', (_label, isRemote, paired, launchPlatform, hostPlatform, proves) => { - expect(describeLaunchHost({ isRemote, paired, launchPlatform, hostPlatform })).toEqual({ - paired, - provesAgentInFront: proves - }) - }) + ['a local macOS host', false, false, 'darwin', 'darwin', true, true], + ['a local Linux host', false, false, 'linux', 'linux', true, true], + ['a local Windows host', false, false, 'win32', 'win32', false, true], + // The pane runs in the distro, but the reads run on the Windows host; Orca writes into the distro. + ['a local WSL pane', false, false, 'linux', 'win32', false, true], + ['an SSH Linux host from Windows', true, false, 'linux', 'win32', true, true], + // Its relay may run panes in WSL, where it writes no launch file; this client cannot tell. + ['an SSH Windows host', true, false, 'win32', 'darwin', false, false], + ['a paired Linux Orca from Windows', false, true, 'linux', 'win32', true, false], + ['a paired Windows Orca from macOS', false, true, 'win32', 'darwin', false, false] + ] as const)( + '%s', + (_label, isRemote, paired, launchPlatform, hostPlatform, proves, takesLaunchFile) => { + expect(describeLaunchHost({ isRemote, paired, launchPlatform, hostPlatform })).toEqual({ + paired, + provesAgentInFront: proves, + takesLaunchFile + }) + } + ) }) diff --git a/src/shared/launch-host.ts b/src/shared/launch-host.ts index 2666fa39d94..20beb713b6e 100644 --- a/src/shared/launch-host.ts +++ b/src/shared/launch-host.ts @@ -6,6 +6,10 @@ export type LaunchHost = { /** Whether the host can prove the launched agent holds its terminal before a paste * (`launched-agent-foreground`). A Windows host cannot, so #24257's guarded paste is refused. */ provesAgentInFront: boolean + /** Whether the host writes a launch file the agent can read. A paired Orca may be older, and an + * SSH Windows host's relay may run its panes in WSL (its OpenSSH default shell, which this client + * cannot see), where it writes none. Such a host gets the line or the paste instead. */ + takesLaunchFile: boolean } /** @@ -23,6 +27,7 @@ export function describeLaunchHost(args: { const runsElsewhere = args.isRemote || args.paired return { paired: args.paired, - provesAgentInFront: (runsElsewhere ? args.launchPlatform : args.hostPlatform) !== 'win32' + provesAgentInFront: (runsElsewhere ? args.launchPlatform : args.hostPlatform) !== 'win32', + takesLaunchFile: !args.paired && !(args.isRemote && args.launchPlatform === 'win32') } } diff --git a/src/shared/launch-prompt-carrier.test.ts b/src/shared/launch-prompt-carrier.test.ts index 784c6ba3164..ff714410b18 100644 --- a/src/shared/launch-prompt-carrier.test.ts +++ b/src/shared/launch-prompt-carrier.test.ts @@ -25,14 +25,14 @@ describe('the one launch-prompt decision every launch path builds through', () = ...base, prompt: 'fix it', platform: 'linux', - host: { paired: true, provesAgentInFront: true } + host: { paired: true, provesAgentInFront: true, takesLaunchFile: false } }) expect(short?.carry).toBe('on-line') const long = planLaunchForTest({ ...base, prompt: 'fix it\nthen run the tests', platform: 'linux', - host: { paired: true, provesAgentInFront: true } + host: { paired: true, provesAgentInFront: true, takesLaunchFile: false } }) expect(long?.carry).toBe('paste-after-ready') expect(long?.pasteAfterReady).toBe('fix it\nthen run the tests') diff --git a/src/shared/launch-prompt-carry.test.ts b/src/shared/launch-prompt-carry.test.ts index 4454e4854ff..84a614e54d9 100644 --- a/src/shared/launch-prompt-carry.test.ts +++ b/src/shared/launch-prompt-carry.test.ts @@ -190,7 +190,7 @@ describe('a host that types the line raw', () => { // A local WSL pane: a Linux line, on a Windows host that cannot read what holds it. const extra = { platform: 'linux' as const, - host: { paired: false, provesAgentInFront: false } + host: { paired: false, provesAgentInFront: false, takesLaunchFile: true } } const prompt = 'fix the build\nthen run the tests' expect(plan('gemini', prompt, extra)?.carry).toBe('on-line') @@ -203,8 +203,30 @@ describe('a host that types the line raw', () => { expect(plan('gemini', huge, button)?.carry).toBe('paste-after-ready') }) + // Why: its relay may run the pane in WSL, where no launch file is written; main typed the line or + // pasted, so that is the fallback, never a refusal. + it('gives an SSH Windows host no launch file: the line, or the paste main used', () => { + const host = describeLaunchHost({ + launchPlatform: 'win32', + isRemote: true, + hostPlatform: 'darwin', + paired: false + }) + const prompt = 'fix the build\nthen run the tests' + const extra = { platform: 'win32' as const, shell: 'powershell' as const, host } + expect(plan('claude', prompt, extra)?.carry).toBe('on-line') + expect(plan('codex', 'y'.repeat(MAX_LINE_PROMPT_BYTES + 1), extra)?.carry).toBe('on-line') + expect(plan('claude', prompt, { ...extra, paste: 'once-agent-runs' })?.carry).toBe( + 'paste-after-ready' + ) + expect(plan('claude', 'fix it', extra)?.carry).toBe('on-line') + }) + it('pastes on a paired host what its line cannot carry typed, even for Claude', () => { - const extra = { platform: 'linux' as const, host: { paired: true, provesAgentInFront: true } } + const extra = { + platform: 'linux' as const, + host: { paired: true, provesAgentInFront: true, takesLaunchFile: false } + } expect(plan('claude', 'fix it', extra)?.carry).toBe('on-line') expect(plan('claude', 'first line\nsecond line', extra)?.carry).toBe('paste-after-ready') expect(plan('claude', 'z'.repeat(20_000), extra)?.carry).toBe('paste-after-ready') diff --git a/src/shared/launch-prompt-carry.ts b/src/shared/launch-prompt-carry.ts index 5e9fad2c95e..8210b29be0c 100644 --- a/src/shared/launch-prompt-carry.ts +++ b/src/shared/launch-prompt-carry.ts @@ -160,7 +160,7 @@ export function carryLaunchPrompt { cmdOverrides: {}, platform: 'win32', shell: 'powershell', - host: { paired: true, provesAgentInFront: false } + host: { paired: true, provesAgentInFront: false, takesLaunchFile: false } }) expect(startup?.launchFile).toBeUndefined() expect(startup?.launchCommand).toContain('fix the build')