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')