mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
fix(pty): deliver multiline agent-launch prompts via bracketed paste
Multiline agent-launch prompts (claude/codex/opencode argv injection) were mangled when Orca typed the startup command into the interactive shell. The command is single-quoted, but its literal embedded newlines survive quoting; bash readline / zsh zle read every raw LF as accept-line (Enter), so the first newline submits an unterminated single-quoted command, drops the shell into PS2 (>) continuation, and the rest executes piecemeal — backticks/$ evaluate, quotes go unbalanced, and the agent never receives the intact prompt. Short single-line prompts worked because they have no embedded newline. Fix: when a startup command contains a newline, wrap the payload in bracketed-paste markers (ESC[200~ … ESC[201~) before the trailing submit CR/LF so the line editor inserts the multiline text literally and only the trailing byte submits it. The single-line fast path is unchanged. Gated on the target line editor having bracketed-paste mode active (Orca-wrapped bash/zsh) so shells without it never echo the markers as garbage; Orca's bash rc wrappers now force `enable-bracketed-paste on` (zsh has it on by default). Applied consistently across every startup-command delivery path: - src/main/providers/local-pty-shell-ready.ts (in-process / degraded local) - src/main/daemon/terminal-host.ts (daemon host — primary local) - src/relay/pty-handler.ts (SSH relay, remote host) - src/renderer/src/lib/ssh-background-startup-delivery.ts (hidden SSH tab) All share src/shared/startup-command-submission.ts. Windows cmd.exe and other shells keep the current CR submit path (no regression); PSReadLine/POSIX bash/zsh get the fix.
This commit is contained in:
@@ -95,6 +95,11 @@ elif [[ -f "$HOME/.bash_login" ]]; then
|
||||
elif [[ -f "$HOME/.profile" ]]; then
|
||||
source "$HOME/.profile"
|
||||
fi
|
||||
# Why: enable bracketed paste so Orca can deliver a multiline startup prompt as
|
||||
# a single literal paste (ESC[200~…ESC[201~); without it, older readline builds
|
||||
# treat each embedded newline as Enter and mangle the prompt into PS2
|
||||
# continuation. Modern readline defaults this on; force it for the rest.
|
||||
[[ $- == *i* ]] && bind 'set enable-bracketed-paste on' 2>/dev/null
|
||||
__orca_restore_attribution_path() {
|
||||
[[ -n "\${ORCA_ATTRIBUTION_SHIM_DIR:-}" ]] || return 0
|
||||
case "$PATH" in
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Session, type SubprocessHandle } from './session'
|
||||
import { normalizePtySize } from './daemon-pty-size'
|
||||
import { resolveProcessCwd } from '../providers/process-cwd'
|
||||
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
|
||||
import { buildStartupCommandSubmission } from '../../shared/startup-command-submission'
|
||||
import type {
|
||||
SessionInfo,
|
||||
TakePendingOutputResult,
|
||||
@@ -163,8 +164,15 @@ export class TerminalHost {
|
||||
// the user would need to press Enter after Orca launches the agent or
|
||||
// setup script. POSIX shells accept CR as Enter under ICRNL.
|
||||
const submit = process.platform === 'win32' ? '\r' : '\n'
|
||||
const endsWithSubmit = opts.command.endsWith('\r') || opts.command.endsWith('\n')
|
||||
session.write(endsWithSubmit ? opts.command : `${opts.command}${submit}`)
|
||||
// Why: multiline startup prompts are pasted literally via bracketed paste
|
||||
// only for Orca-wrapped bash/zsh, which is exactly when the shell-ready
|
||||
// barrier is supported; other shells keep the raw submit path.
|
||||
session.write(
|
||||
buildStartupCommandSubmission(opts.command, {
|
||||
submit,
|
||||
bracketedPasteSafe: opts.shellReadySupported ?? false
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -805,9 +805,21 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
ptyDisposables.set(id, disposables)
|
||||
|
||||
if (args.command && !startupCommandDeliveredInShellArgs) {
|
||||
writeStartupCommandWhenShellReady(shellReadyPromise, proc, args.command, (cleanup) => {
|
||||
startupCommandCleanup = cleanup
|
||||
})
|
||||
// Why: only Orca-wrapped POSIX bash/zsh have bracketed-paste mode armed
|
||||
// (bash via `bind`, zsh on by default), so multiline startup prompts can
|
||||
// be pasted literally there; other shells keep the raw submit path.
|
||||
const spawnedShellName = getSpawnedShellName(shellPath).toLowerCase()
|
||||
const bracketedPasteSafe =
|
||||
process.platform !== 'win32' && (spawnedShellName === 'bash' || spawnedShellName === 'zsh')
|
||||
writeStartupCommandWhenShellReady(
|
||||
shellReadyPromise,
|
||||
proc,
|
||||
args.command,
|
||||
(cleanup) => {
|
||||
startupCommandCleanup = cleanup
|
||||
},
|
||||
{ bracketedPasteSafe }
|
||||
)
|
||||
}
|
||||
|
||||
// Why: publish the OS pid so ipc/pty can register the PTY with the memory
|
||||
|
||||
@@ -165,6 +165,61 @@ describe('writeStartupCommandWhenShellReady', () => {
|
||||
await Promise.resolve()
|
||||
expect(proc._writes).toEqual(['codex\n'])
|
||||
})
|
||||
|
||||
// Why: regression for the multiline agent-prompt bug. A startup command with
|
||||
// embedded newlines must be wrapped in bracketed paste (ESC[200~ … ESC[201~)
|
||||
// followed by a single submit byte, so bash readline / zsh zle insert the
|
||||
// whole prompt literally instead of reading each LF as Enter and mangling it
|
||||
// into PS2 continuation.
|
||||
it('wraps a multiline startup command in bracketed paste when the shell supports it', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
||||
const proc = createMockProc()
|
||||
const ready = Promise.resolve()
|
||||
const command = "claude '--dangerously-skip-permissions' 'line one\nline two'"
|
||||
writeStartupCommandWhenShellReady(ready, proc, command, () => {}, {
|
||||
bracketedPasteSafe: true
|
||||
})
|
||||
|
||||
await ready
|
||||
proc._emitData('\r\nuser@host % ')
|
||||
vi.advanceTimersByTime(30)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(proc._writes).toEqual([`\x1b[200~${command}\x1b[201~\n`])
|
||||
})
|
||||
|
||||
it('leaves a single-line command on the raw submit path even when bracketed paste is safe', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
||||
const proc = createMockProc()
|
||||
const ready = Promise.resolve()
|
||||
writeStartupCommandWhenShellReady(ready, proc, 'claude', () => {}, {
|
||||
bracketedPasteSafe: true
|
||||
})
|
||||
|
||||
await ready
|
||||
proc._emitData('\r\nuser@host % ')
|
||||
vi.advanceTimersByTime(30)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(proc._writes).toEqual(['claude\n'])
|
||||
})
|
||||
|
||||
it('does not bracket-wrap a multiline command when the shell lacks bracketed paste', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
||||
const proc = createMockProc()
|
||||
const ready = Promise.resolve()
|
||||
const command = 'echo one\necho two'
|
||||
// Default options: bracketedPasteSafe is false, so the raw path is kept to
|
||||
// avoid echoing the ESC[200~ markers on shells without bracketed paste.
|
||||
writeStartupCommandWhenShellReady(ready, proc, command, () => {})
|
||||
|
||||
await ready
|
||||
proc._emitData('\r\nuser@host % ')
|
||||
vi.advanceTimersByTime(30)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(proc._writes).toEqual([`${command}\n`])
|
||||
})
|
||||
})
|
||||
|
||||
describe('scanForShellReady', () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
isPowerShellExecutableName
|
||||
} from '../powershell-osc133-bootstrap'
|
||||
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
|
||||
import { buildStartupCommandSubmission } from '../../shared/startup-command-submission'
|
||||
import {
|
||||
getZshEnvTemplate,
|
||||
getZshFinalZdotdirRestoreBlock,
|
||||
@@ -118,6 +119,11 @@ elif [[ -f "$HOME/.bash_login" ]]; then
|
||||
elif [[ -f "$HOME/.profile" ]]; then
|
||||
source "$HOME/.profile"
|
||||
fi
|
||||
# Why: enable bracketed paste so Orca can deliver a multiline startup prompt as
|
||||
# a single literal paste (ESC[200~…ESC[201~). Without it, older readline builds
|
||||
# treat each embedded newline as Enter and mangle the prompt into PS2
|
||||
# continuation. Modern readline defaults this on; force it for the rest.
|
||||
[[ $- == *i* ]] && bind 'set enable-bracketed-paste on' 2>/dev/null
|
||||
# Why: preserve bash's normal login-shell contract. Many users already source
|
||||
# ~/.bashrc from ~/.bash_profile; forcing ~/.bashrc again here would duplicate
|
||||
# PATH edits, hooks, and prompt init in Orca startup-command shells.
|
||||
@@ -432,7 +438,11 @@ export function writeStartupCommandWhenShellReady(
|
||||
readyPromise: Promise<void | ShellReadySignal>,
|
||||
proc: pty.IPty,
|
||||
startupCommand: string,
|
||||
onExit: (cleanup: () => void) => void
|
||||
onExit: (cleanup: () => void) => void,
|
||||
// Why: only Orca-wrapped bash/zsh have bracketed-paste mode active, so
|
||||
// multiline startup commands are wrapped in ESC[200~/ESC[201~ only there;
|
||||
// other shells keep the raw submit path to avoid echoing the markers.
|
||||
options: { bracketedPasteSafe?: boolean } = {}
|
||||
): void {
|
||||
let sent = false
|
||||
let postReadyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -470,12 +480,17 @@ export function writeStartupCommandWhenShellReady(
|
||||
// Enter under ICRNL, so CR works there too, but this code path is reached
|
||||
// on Windows as well as POSIX via writeStartupCommandWhenShellReady.
|
||||
const submit = process.platform === 'win32' ? '\r' : '\n'
|
||||
const endsWithSubmit = startupCommand.endsWith('\r') || startupCommand.endsWith('\n')
|
||||
const payload = endsWithSubmit ? startupCommand : `${startupCommand}${submit}`
|
||||
// Why: startup commands are usually long, quoted agent launches. Writing
|
||||
// them in one PTY call after the shell-ready barrier avoids the incremental
|
||||
// paste behavior that still dropped characters in practice.
|
||||
proc.write(payload)
|
||||
// paste behavior that still dropped characters in practice. Multiline
|
||||
// prompts are additionally wrapped in bracketed paste (see the helper) so
|
||||
// embedded newlines are inserted literally instead of submitting early.
|
||||
proc.write(
|
||||
buildStartupCommandSubmission(startupCommand, {
|
||||
submit,
|
||||
bracketedPasteSafe: options.bracketedPasteSafe === true
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const schedulePostReadyFlush = (): void => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { getRelayShellLaunchConfig } from './pty-shell-launch'
|
||||
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 { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing'
|
||||
import {
|
||||
createShellReadyScanState,
|
||||
@@ -323,8 +324,14 @@ export class PtyHandler {
|
||||
}
|
||||
}
|
||||
const submit = process.platform === 'win32' ? '\r' : '\n'
|
||||
const endsWithSubmit = startup.command.endsWith('\r') || startup.command.endsWith('\n')
|
||||
const payload = endsWithSubmit ? startup.command : `${startup.command}${submit}`
|
||||
// Why: a multiline startup prompt is pasted literally via bracketed paste
|
||||
// only when the Orca shell-ready wrapper is active (waitForShellReady) —
|
||||
// that is the bash/zsh overlay that arms bracketed-paste mode. Other remote
|
||||
// shells keep the raw submit path so the ESC[200~ markers are not echoed.
|
||||
const payload = buildStartupCommandSubmission(startup.command, {
|
||||
submit,
|
||||
bracketedPasteSafe: startup.waitForShellReady
|
||||
})
|
||||
managed.startupCommand = undefined
|
||||
managed.pty.write(payload)
|
||||
}
|
||||
|
||||
@@ -141,6 +141,11 @@ elif [[ -f "$HOME/.bash_login" ]]; then
|
||||
elif [[ -f "$HOME/.profile" ]]; then
|
||||
source "$HOME/.profile"
|
||||
fi
|
||||
# Why: enable bracketed paste so Orca can deliver a multiline startup prompt as
|
||||
# a single literal paste (ESC[200~…ESC[201~); without it, older readline builds
|
||||
# treat each embedded newline as Enter and mangle the prompt into PS2
|
||||
# continuation. Modern readline defaults this on; force it for the rest.
|
||||
[[ $- == *i* ]] && bind 'set enable-bracketed-paste on' 2>/dev/null
|
||||
# Why: remote startup files can re-export user defaults after relay spawn.
|
||||
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
createShellReadyMarkerScanState,
|
||||
scanForShellReadyMarker
|
||||
} from '@/components/terminal-pane/shell-ready-marker-scan'
|
||||
import { buildStartupCommandSubmission } from '../../../shared/startup-command-submission'
|
||||
|
||||
const SSH_SHELL_READY_STARTUP_FALLBACK_MS = 1500
|
||||
|
||||
@@ -76,9 +77,17 @@ export function createSshBackgroundStartupDelivery(
|
||||
pendingCommand = null
|
||||
// Why: the SSH relay treats spawn.command as metadata for interactive
|
||||
// PTYs; hidden automation tabs still submit the command themselves.
|
||||
const submittedCommand =
|
||||
command.endsWith('\r') || command.endsWith('\n') ? command : `${command}\r`
|
||||
options.write(ptyId, submittedCommand)
|
||||
// Why bracketed paste: multiline prompts are pasted literally only when we
|
||||
// synchronized on the Orca shell-ready marker (waitForShellReady) — 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: options.waitForShellReady
|
||||
})
|
||||
)
|
||||
}, 50)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildStartupCommandSubmission } from './startup-command-submission'
|
||||
|
||||
describe('buildStartupCommandSubmission', () => {
|
||||
it('appends the submit byte to a single-line command unchanged', () => {
|
||||
expect(
|
||||
buildStartupCommandSubmission('claude', { submit: '\n', bracketedPasteSafe: true })
|
||||
).toBe('claude\n')
|
||||
expect(
|
||||
buildStartupCommandSubmission('claude', { submit: '\r', bracketedPasteSafe: true })
|
||||
).toBe('claude\r')
|
||||
})
|
||||
|
||||
it('preserves a caller-supplied trailing submit byte on single-line commands', () => {
|
||||
expect(
|
||||
buildStartupCommandSubmission('claude\n', { submit: '\r', bracketedPasteSafe: true })
|
||||
).toBe('claude\n')
|
||||
})
|
||||
|
||||
it('wraps a multiline command in bracketed paste with a trailing submit byte', () => {
|
||||
const command = "claude 'first\nsecond'"
|
||||
expect(buildStartupCommandSubmission(command, { submit: '\n', bracketedPasteSafe: true })).toBe(
|
||||
`\x1b[200~${command}\x1b[201~\n`
|
||||
)
|
||||
})
|
||||
|
||||
it('strips a trailing submit byte before bracket-wrapping the multiline body', () => {
|
||||
const body = "claude 'first\nsecond'"
|
||||
expect(
|
||||
buildStartupCommandSubmission(`${body}\n`, { submit: '\r', bracketedPasteSafe: true })
|
||||
).toBe(`\x1b[200~${body}\x1b[201~\r`)
|
||||
})
|
||||
|
||||
it('keeps the raw path for multiline commands when bracketed paste is unsafe', () => {
|
||||
const command = 'echo one\necho two'
|
||||
expect(
|
||||
buildStartupCommandSubmission(command, { submit: '\n', bracketedPasteSafe: false })
|
||||
).toBe(`${command}\n`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Builds the exact bytes Orca writes into an interactive shell to deliver and
|
||||
* submit a startup command (agent launch, setup script, etc.).
|
||||
*
|
||||
* Why bracketed paste: agent launch prompts are single-quoted, but their
|
||||
* literal embedded newlines survive quoting. bash readline / zsh zle read every
|
||||
* raw LF as accept-line (Enter), so the first newline inside a multiline prompt
|
||||
* submits an unterminated single-quoted command and drops the shell into PS2
|
||||
* continuation — the prompt is executed piecemeal and mangled. Wrapping the
|
||||
* payload in bracketed-paste markers (ESC[200~ … ESC[201~) tells the line
|
||||
* editor to insert the whole multiline text literally; only the trailing CR/LF
|
||||
* written after the end marker submits it. Single-line commands keep the proven
|
||||
* raw-write path unchanged so the fast path never regresses.
|
||||
*/
|
||||
|
||||
// DEC 2004 bracketed-paste bracket sequences.
|
||||
const BRACKETED_PASTE_START = '\x1b[200~'
|
||||
const BRACKETED_PASTE_END = '\x1b[201~'
|
||||
|
||||
export type StartupCommandSubmissionOptions = {
|
||||
/** Byte that submits the line: CR on Windows (PSReadLine/cmd.exe), LF on
|
||||
* POSIX; SSH relays remote shells with CR. A caller-supplied trailing submit
|
||||
* byte on `command` is preserved as-is. */
|
||||
submit: string
|
||||
/** Whether the target line editor has bracketed-paste mode active (Orca's
|
||||
* wrapped bash/zsh). Only wrap multiline payloads when true — a shell without
|
||||
* bracketed paste would echo the ESC[200~ markers as literal garbage. */
|
||||
bracketedPasteSafe: boolean
|
||||
}
|
||||
|
||||
export function buildStartupCommandSubmission(
|
||||
command: string,
|
||||
{ submit, bracketedPasteSafe }: StartupCommandSubmissionOptions
|
||||
): string {
|
||||
const endsWithSubmit = command.endsWith('\r') || command.endsWith('\n')
|
||||
const body = endsWithSubmit ? command.slice(0, -1) : command
|
||||
if (bracketedPasteSafe && (body.includes('\n') || body.includes('\r'))) {
|
||||
return `${BRACKETED_PASTE_START}${body}${BRACKETED_PASTE_END}${submit}`
|
||||
}
|
||||
return endsWithSubmit ? command : `${command}${submit}`
|
||||
}
|
||||
Reference in New Issue
Block a user