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.
This commit is contained in:
Brennan Benson
2026-10-02 04:26:28 -07:00
parent f4bebe01e0
commit 9be1739f80
11 changed files with 82 additions and 55 deletions
@@ -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()
}
+7 -14
View File
@@ -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 }
: {}),
@@ -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
@@ -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()
@@ -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',
+21 -16
View File
@@ -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
})
}
)
})
+6 -1
View File
@@ -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')
}
}
+2 -2
View File
@@ -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')
+24 -2
View File
@@ -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')
+1 -1
View File
@@ -160,7 +160,7 @@ export function carryLaunchPrompt<A extends CarriedPlanArgs, P extends { launchC
return pasteAfterReady()
}
// Why: an agent not measured reading the file would stop on an approval or refuse the path.
if (args.host.paired || !agentReadsLaunchFile(args.agent)) {
if (!args.host.takesLaunchFile || !agentReadsLaunchFile(args.agent)) {
return lineOrPaste()
}
const pointer = carryInLaunchFile(text, false)
@@ -83,7 +83,7 @@ describe('a prompt a Windows shell would damage on the launch line', () => {
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')