mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
Reverts #14615. Its premise does not reproduce, it does not reach the failure that does, and the correction it installs can misattribute status on a path that worked before. 1. PREMISE FALSE. #14615 asserts Claude Code >= 2.1.206 hosts TUI sessions under a shared daemon. On 2.1.233 `claude daemon status` reports "not running" with 69 live interactive sessions, and every client is a direct child of its own pane's shell. Measured across the fleet: 68 distinct pane keys, zero collisions. Foreground attribution was never broken. 2. DOES NOT FIX THE REAL BUG. The failure in #9236 is real but scoped to BACKGROUNDED sessions, whose workers inherit the dispatching pane's whole ORCA_* set. #14615 mints a binding only for launches Orca constructs, so a typed `claude --bg` produces none. Fixed properly in #15304. 3. INTRODUCES A MISATTRIBUTION. Bindings are removed only on PTY death, and a user who exits Claude keeps the pane's PTY. Resuming that session in another pane does not rebind (`--resume` is a session selector, so the pin declines), and resolveBoundPaneOverride then rewrites paneKey and tabId onto the ORIGINAL pane despite a correct posted key. Demonstrated with a failing test against main; causation isolated to resolveBoundPaneOverride. Kept #14706's observations.rebind() in the conflicting hunk — it postdates #14615 and is not part of this revert.
389 lines
16 KiB
TypeScript
389 lines
16 KiB
TypeScript
import { tokenizeCustomCommandTemplate, type CommandTokenSpan } from './commit-message-prompt'
|
|
|
|
/**
|
|
* `'posix'` covers every Unix shell Orca can type into, fish included — not
|
|
* because they agree, but because everything this module emits for it is built
|
|
* to be correct in all of them (see quoteStartupArg and clearEnvCommand). There
|
|
* is deliberately no fish member: a dialect Orca has to detect is a dialect it
|
|
* can get wrong, and it cannot detect one reliably for a remote or WSL host.
|
|
*/
|
|
export type AgentStartupShell = 'posix' | 'powershell' | 'cmd'
|
|
|
|
type WindowsStartupShell = Extract<AgentStartupShell, 'powershell' | 'cmd'>
|
|
|
|
/** True for the sh-family grammar: `NAME=value cmd` prefixes, `sh -c` wrapping
|
|
* and `;` chaining. fish parses all three, so this holds for fish too. */
|
|
export function isPosixStartupShell(shell: AgentStartupShell): boolean {
|
|
return shell === 'posix'
|
|
}
|
|
|
|
function isWindowsStartupShell(shell: AgentStartupShell): shell is WindowsStartupShell {
|
|
return shell === 'powershell' || shell === 'cmd'
|
|
}
|
|
|
|
export type StartupCommandTokens =
|
|
| { ok: true; tokens: string[]; spans: CommandTokenSpan[] }
|
|
| { ok: false; error: string }
|
|
|
|
/** True when an odd run of backslashes precedes this quote, which makes it a
|
|
* literal byte to the child's CommandLineToArgvW parser rather than a
|
|
* delimiter — so cmd's word boundaries stop matching this tokenizer's. */
|
|
function hasOddBackslashRun(value: string, quoteIndex: number): boolean {
|
|
let backslashes = 0
|
|
while (value[quoteIndex - 1 - backslashes] === '\\') {
|
|
backslashes += 1
|
|
}
|
|
return backslashes % 2 === 1
|
|
}
|
|
|
|
function tokenizeWindowsStartupCommand(
|
|
value: string,
|
|
shell: WindowsStartupShell
|
|
): StartupCommandTokens {
|
|
const tokens: string[] = []
|
|
const spans: CommandTokenSpan[] = []
|
|
let token = ''
|
|
let tokenStart = 0
|
|
let divergesFromShell = false
|
|
let quote: "'" | '"' | null = null
|
|
let tokenStarted = false
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
const char = value[index]
|
|
const escape = shell === 'cmd' ? '^' : '`'
|
|
if (char === escape && index + 1 < value.length) {
|
|
// Why: cmd strips `^` and hands the bare byte to the child's parser,
|
|
// which re-splits on whitespace and reopens a quote, and keeps the caret
|
|
// literal inside double quotes — either way the token stops matching
|
|
// argv. PowerShell folds the backtick into the token except for LF line
|
|
// continuations, verbatim single quotes, escape sequences, and a
|
|
// token-leading backtick before whitespace, which it drops entirely.
|
|
// A bare CR is treated as unmodelable rather than folded: pwsh 7 keeps
|
|
// it in the token, Windows PowerShell 5.1 is unverified, and failing
|
|
// open there costs nothing.
|
|
divergesFromShell ||=
|
|
(shell === 'cmd' ? /[\s"]/.test(value[index + 1]) : /[\n\r]/.test(value[index + 1])) ||
|
|
(shell === 'cmd' && quote === '"') ||
|
|
(shell === 'powershell' && quote === "'") ||
|
|
// A token-leading backtick before whitespace is dropped with the
|
|
// whitespace, emitting no token at all rather than the one built here.
|
|
(shell === 'powershell' && !tokenStarted && /\s/.test(value[index + 1])) ||
|
|
// PowerShell expands these escape sequences in quoted AND bare
|
|
// arguments, so the token value this branch builds is not argv's.
|
|
(shell === 'powershell' && '0abefnrtuv'.includes(value[index + 1]))
|
|
token += value[index + 1]
|
|
if (!tokenStarted) {
|
|
tokenStart = index
|
|
}
|
|
tokenStarted = true
|
|
index += 1
|
|
continue
|
|
}
|
|
if (quote) {
|
|
// Why: see the posix tokenizer — a `"` inside $(…) re-opens a nested
|
|
// quoting context that this tokenizer does not model.
|
|
divergesFromShell ||=
|
|
shell === 'powershell' &&
|
|
quote === '"' &&
|
|
char === '$' &&
|
|
(value[index + 1] === '(' || value[index + 1] === '{')
|
|
if (char === quote) {
|
|
divergesFromShell ||= shell === 'cmd' && char === '"' && hasOddBackslashRun(value, index)
|
|
if (shell === 'powershell' && quote === "'" && value[index + 1] === "'") {
|
|
token += "'"
|
|
index += 1
|
|
} else {
|
|
quote = null
|
|
}
|
|
} else {
|
|
token += char
|
|
}
|
|
tokenStarted = true
|
|
continue
|
|
}
|
|
if (char === "'" || char === '"') {
|
|
divergesFromShell ||= shell === 'cmd' && char === '"' && hasOddBackslashRun(value, index)
|
|
quote = char
|
|
// Why: cmd.exe has no single-quote syntax, so this tokenizer's grouping
|
|
// of a single-quoted region diverges from what cmd actually parses;
|
|
// flag the token so consumers treat it as unmodelable.
|
|
divergesFromShell ||= shell === 'cmd' && char === "'"
|
|
if (!tokenStarted) {
|
|
tokenStart = index
|
|
}
|
|
tokenStarted = true
|
|
} else if (/\s/.test(char)) {
|
|
if (tokenStarted) {
|
|
tokens.push(token)
|
|
spans.push({ start: tokenStart, end: index, divergesFromShell })
|
|
token = ''
|
|
tokenStarted = false
|
|
divergesFromShell = false
|
|
}
|
|
} else {
|
|
if (!tokenStarted) {
|
|
tokenStart = index
|
|
}
|
|
// Why: see the posix tokenizer — a trailing unpaired escape would
|
|
// swallow the separator before anything appended to the base.
|
|
divergesFromShell ||= char === escape && index + 1 >= value.length
|
|
divergesFromShell ||=
|
|
';&|<>'.includes(char) ||
|
|
(shell === 'powershell' &&
|
|
// Why: bare (…) is evaluated and {…} is a script block in argument
|
|
// position, so both are live syntax the span splice cannot model.
|
|
('(){}'.includes(char) ||
|
|
(char === '#' && !tokenStarted) ||
|
|
(char === '$' && (value[index + 1] === '(' || value[index + 1] === '{'))))
|
|
token += char
|
|
tokenStarted = true
|
|
}
|
|
}
|
|
if (quote) {
|
|
return { ok: false, error: 'Unclosed quote in command template.' }
|
|
}
|
|
if (tokenStarted) {
|
|
tokens.push(token)
|
|
spans.push({ start: tokenStart, end: value.length, divergesFromShell })
|
|
}
|
|
return { ok: true, tokens, spans }
|
|
}
|
|
|
|
export function tokenizeStartupCommand(
|
|
value: string,
|
|
shell: AgentStartupShell
|
|
): StartupCommandTokens {
|
|
// Why one Unix parse: the input is a string the user typed into an Orca
|
|
// settings field, and the shell never parses it — every token is re-quoted by
|
|
// quoteStartupArg before the line is built. Parsing it differently per shell
|
|
// would make the same setting mean different things in different workspaces.
|
|
// (Windows is genuinely different: cmd/PowerShell re-parse the built line.)
|
|
return isWindowsStartupShell(shell)
|
|
? tokenizeWindowsStartupCommand(value, shell)
|
|
: tokenizeCustomCommandTemplate(value)
|
|
}
|
|
|
|
export function resolveStartupShell(
|
|
platform: NodeJS.Platform,
|
|
shell?: AgentStartupShell
|
|
): AgentStartupShell {
|
|
return shell ?? (platform === 'win32' ? 'powershell' : 'posix')
|
|
}
|
|
|
|
/**
|
|
* Quotes one argument so the SAME text is literal in every Unix shell Orca can
|
|
* be typing into — sh, bash, zsh, dash and fish.
|
|
*
|
|
* Why not plain sh quoting: fish's single quotes are not literal. Inside `'…'`
|
|
* it treats `\\` and `\'` as escapes, so the sh `'\''` idiom collapses every
|
|
* backslash (a `\d+` regex, a `\\server\share` UNC path) and a trailing
|
|
* backslash is a hard syntax error that kills the launch outright.
|
|
*
|
|
* Both shell families agree on two things: a single-quoted run is literal apart
|
|
* from those escapes, and `"\\"` is one backslash. So emitting backslashes as
|
|
* `"\\"` and apostrophes as `"'"` between single-quoted runs round-trips in all
|
|
* of them, and Orca never has to know which shell will read the line.
|
|
*
|
|
* Verified against sh, bash, zsh, dash and fish 4.7.1 over regex escapes, UNC
|
|
* and drive paths, trailing/lone backslashes, mixed quotes, `$`/backtick/`$()`
|
|
* expansions, globs, braces, operators, comments, newlines, tabs and non-BMP
|
|
* unicode — see fish-startup-arg-quoting.live-fish.test.ts.
|
|
*/
|
|
function quotePortableUnixArg(value: string): string {
|
|
if (!value) {
|
|
return "''"
|
|
}
|
|
const parts: string[] = []
|
|
let literal = ''
|
|
const flushLiteral = (): void => {
|
|
if (literal) {
|
|
parts.push(`'${literal}'`)
|
|
literal = ''
|
|
}
|
|
}
|
|
for (const char of value) {
|
|
if (char === "'") {
|
|
flushLiteral()
|
|
parts.push(`"'"`)
|
|
} else if (char === '\\') {
|
|
flushLiteral()
|
|
parts.push(`"\\\\"`)
|
|
} else {
|
|
literal += char
|
|
}
|
|
}
|
|
flushLiteral()
|
|
return parts.join('')
|
|
}
|
|
|
|
export function quoteStartupArg(value: string, shell: AgentStartupShell): string {
|
|
if (shell === 'powershell') {
|
|
return `'${value.replace(/'/g, "''")}'`
|
|
}
|
|
if (shell === 'cmd') {
|
|
return `"${value.replace(/([\^&|<>()%!"])/g, '^$1')}"`
|
|
}
|
|
return quotePortableUnixArg(value)
|
|
}
|
|
|
|
export function buildShellCommandFromArgv(
|
|
args: readonly string[],
|
|
shell: AgentStartupShell
|
|
): string {
|
|
const command = args.map((arg) => quoteStartupArg(arg, shell)).join(' ')
|
|
if (shell === 'powershell' && command) {
|
|
return `& ${command}`
|
|
}
|
|
return command
|
|
}
|
|
|
|
/**
|
|
* Clears one or more environment variables, in a form correct in every shell a
|
|
* queued command line can land in.
|
|
*
|
|
* Why it carries its own fish/sh branch rather than a single builtin: `unset`
|
|
* does not exist in fish, and `set -e` in bash enables errexit rather than
|
|
* clearing anything, so neither spelling is safe alone.
|
|
*
|
|
* Why not a helper function defined by Orca's shell wrappers: Orca only wraps
|
|
* zsh, bash and fish. A login shell of `sh`, `dash` or `ksh` launches
|
|
* UNWRAPPED, and this text is also copied to the clipboard and pasted into
|
|
* shells Orca never spawned — in all of those a helper would be `command not
|
|
* found`, which is the exact failure this exists to avoid.
|
|
*
|
|
* Why two statements rather than `A && B || C`: in fish, `set -e` on a variable
|
|
* that is already unset returns non-zero, so an `||` fallback would run the sh
|
|
* branch too and print `Unknown command: unset`. Each branch is guarded by its
|
|
* own test, so exactly one runs and the other is only parsed.
|
|
*
|
|
* The trailing `true` pins the exit status at 0: the guard that does NOT fire
|
|
* leaves a non-zero status behind, and this is the last statement of a launch
|
|
* line, so that status is what the user's prompt would render.
|
|
*
|
|
* `command test` rather than `test`, because an interactive shell expands
|
|
* aliases and `alias test=...` would otherwise skip both branches silently.
|
|
*
|
|
* `set --erase` rather than `set -e`, because `$fish_pid` is a heuristic: any
|
|
* non-empty value takes the fish branch. `set -e NAME` in the sh family enables
|
|
* errexit and replaces the positional parameters, silently changing the
|
|
* semantics of everything after it. `set --erase NAME` is the same erase in
|
|
* fish, but in sh `--` ends option parsing, so a misfire is a usage message on
|
|
* stderr and nothing else.
|
|
*
|
|
* `$fish_pid` is set by fish 3.0+ and by nothing in the sh family, and fish
|
|
* does not export it, so a fish parent cannot make a bash child misread itself.
|
|
*
|
|
* `-g` is not optional: it scopes the erase to fish's GLOBAL scope, which is
|
|
* where an inherited environment variable lands. Without it, a name that exists
|
|
* only as a UNIVERSAL — `set -Ux CODEX_HOME …`, a perfectly normal thing for a
|
|
* fish user to have — is permanently deleted from every future session. That is
|
|
* real data loss to undo one launch's injection, and it is reachable from the
|
|
* clipboard command, which a user may run with no Orca-injected value at all.
|
|
* With `-g`, a universal shadowed by an injected global is revealed again
|
|
* instead, which is the wanted outcome.
|
|
*
|
|
* KNOWN LIMIT: under `set -u` the sh side aborts on the unset `$fish_pid` and
|
|
* the variable survives. `${fish_pid-}` would be nounset-safe but is a fish
|
|
* parse error, and so is `set +u`.
|
|
*
|
|
* The one spelling that does satisfy both is a fish-builtin probe — e.g.
|
|
* `math 1 >/dev/null 2>&1 && … || …` — because it references no variable at
|
|
* all. Rejected deliberately: it EXECUTES whatever that name resolves to on
|
|
* the user's PATH, twice. `math` is a real binary (Wolfram Mathematica ships
|
|
* `/usr/local/bin/math`); measured with one on PATH, clearing a single variable
|
|
* took 10.4s and then did not clear it, because a probe that exits 0 is an
|
|
* unconditional false positive. Trading a rare uncleared prefill for an
|
|
* arbitrary program execution on the launch path is the wrong direction.
|
|
*
|
|
* Verified to clear the variables, write nothing to stderr and exit 0 — whether
|
|
* they were set or already unset — in sh, bash, zsh, dash, ksh and fish.
|
|
*/
|
|
const ENV_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
|
|
export function clearEnvCommand(
|
|
name: string | readonly string[],
|
|
shell: AgentStartupShell
|
|
): string {
|
|
const names = typeof name === 'string' ? [name] : [...name]
|
|
// Why assert rather than escape: these names are interpolated straight into a
|
|
// shell line, so anything but an identifier is both a command injection and —
|
|
// in fish, where `-g` erases for real — a way to delete `PATH`/`HOME` out of
|
|
// the user's session. Every caller passes a literal from a fixed table, so
|
|
// this cannot fire today; it exists so it stays that way.
|
|
for (const each of names) {
|
|
if (!ENV_VAR_NAME.test(each)) {
|
|
throw new Error(
|
|
`clearEnvCommand: ${JSON.stringify(each)} is not an environment variable name`
|
|
)
|
|
}
|
|
}
|
|
if (shell === 'powershell') {
|
|
return names.map((each) => `Remove-Item Env:${each} -ErrorAction SilentlyContinue`).join('; ')
|
|
}
|
|
if (shell === 'cmd') {
|
|
return names.map((each) => `set "${each}="`).join(' & ')
|
|
}
|
|
const joined = names.join(' ')
|
|
return (
|
|
`command test -n "$fish_pid" && set --erase -g ${joined}; ` +
|
|
`command test -z "$fish_pid" && unset ${joined}; true`
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Prefix that runs `command` with `names` removed from its environment.
|
|
*
|
|
* Why this and not `clearEnvCommand` for a copied command: `clearEnvCommand`
|
|
* mutates the *calling* shell, so it needs a shell-specific branch, and every
|
|
* spelling of that branch touches `$fish_pid` — an unbound expansion that aborts
|
|
* the whole line under `set -u`, taking the agent launch with it. This prefix
|
|
* only has to change the CHILD's environment, which `env -u` does with no shell
|
|
* syntax and no variable expansion at all. Verified identical in sh, bash, zsh,
|
|
* dash, ksh and fish, including under `set -u`.
|
|
*/
|
|
export function withoutEnvCommand(
|
|
names: readonly string[],
|
|
command: string,
|
|
shell: AgentStartupShell
|
|
): string {
|
|
if (names.length === 0) {
|
|
return command
|
|
}
|
|
if (isWindowsStartupShell(shell)) {
|
|
// Windows has no `env -u`; those shells clear in-place, and neither has a
|
|
// nounset mode that could abort the line.
|
|
return `${clearEnvCommand(names, shell)}${commandSeparator(shell)}${command}`
|
|
}
|
|
for (const name of names) {
|
|
if (!ENV_VAR_NAME.test(name)) {
|
|
throw new Error(
|
|
`withoutEnvCommand: ${JSON.stringify(name)} is not an environment variable name`
|
|
)
|
|
}
|
|
}
|
|
return `env ${names.map((name) => `-u ${name}`).join(' ')} ${command}`
|
|
}
|
|
|
|
export function commandSeparator(shell: AgentStartupShell): string {
|
|
return shell === 'cmd' ? ' & ' : '; '
|
|
}
|
|
|
|
export type AgentCliArgsPlan = { ok: true; suffix: string } | { ok: false; error: string }
|
|
|
|
export function planAgentCliArgsSuffix(
|
|
agentArgs: string | null | undefined,
|
|
shell: AgentStartupShell
|
|
): AgentCliArgsPlan {
|
|
const trimmed = agentArgs?.trim()
|
|
if (!trimmed) {
|
|
return { ok: true, suffix: '' }
|
|
}
|
|
const tokenized = tokenizeStartupCommand(trimmed, shell)
|
|
if (!tokenized.ok) {
|
|
return { ok: false, error: `CLI arguments are invalid: ${tokenized.error}` }
|
|
}
|
|
return {
|
|
ok: true,
|
|
suffix: tokenized.tokens.map((token) => quoteStartupArg(token, shell)).join(' ')
|
|
}
|
|
}
|