Files
orca/src/shared/tui-agent-startup-shell.test.ts
T

210 lines
7.6 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
buildShellCommandFromArgv,
clearEnvCommand,
commandSeparator,
isCmdQuotingPowerShellSafe,
isPosixStartupShell,
quoteStartupArg,
tokenizeStartupCommand
} from './tui-agent-startup-shell'
import { buildAgentDraftLaunchPlan } from './tui-agent-startup'
function expectSpansCoverTokens(source: string, shell: 'powershell' | 'cmd'): string[] {
const result = tokenizeStartupCommand(source, shell)
expect(result.ok).toBe(true)
if (!result.ok) {
return []
}
expect(result.spans).toHaveLength(result.tokens.length)
let previousEnd = 0
for (const [index, { start, end }] of result.spans.entries()) {
expect(start).toBeGreaterThanOrEqual(previousEnd)
expect(end).toBeGreaterThan(start)
// Every raw span must re-tokenize to exactly its own token.
const slice = tokenizeStartupCommand(source.slice(start, end), shell)
expect(slice.ok && slice.tokens).toEqual([result.tokens[index]])
previousEnd = end
}
return result.spans.map(({ start, end }) => source.slice(start, end))
}
describe('tokenizeStartupCommand spans (windows shells)', () => {
it('covers plain and quoted tokens on powershell', () => {
const source = "claude --msg 'hello world'"
const result = tokenizeStartupCommand(source, 'powershell')
expect(result).toEqual({
ok: true,
tokens: ['claude', '--msg', 'hello world'],
spans: [
{ start: 0, end: 6, divergesFromShell: false },
{ start: 7, end: 12, divergesFromShell: false },
{ start: 13, end: 26, divergesFromShell: false }
]
})
})
it('starts a span at a token-leading escape character', () => {
expect(expectSpansCoverTokens('claude ^&literal next', 'cmd')).toEqual([
'claude',
'^&literal',
'next'
])
expect(expectSpansCoverTokens('claude `x tail', 'powershell')).toEqual(['claude', '`x', 'tail'])
})
it('spans a powershell doubled-quote token as one raw range', () => {
expect(expectSpansCoverTokens("claude 'a''b' end", 'powershell')).toEqual([
'claude',
"'a''b'",
'end'
])
})
it('spans a token opened by a quote at end of input', () => {
expect(expectSpansCoverTokens('claude ""', 'cmd')).toEqual(['claude', '""'])
})
})
describe('one Unix startup dialect', () => {
it('clears variables with a self-contained branch, not a per-shell builtin', () => {
// Why not `unset`/`set -e` alone, and why not a wrapper-defined helper:
// Orca only wraps zsh/bash/fish, so an `sh`/`dash`/`ksh` login shell — and
// any shell the user pastes copied text into — would not have the helper.
// startup-shell-portability.live-shell.test.ts proves this form works in
// real sh/bash/zsh/dash/ksh/fish.
expect(clearEnvCommand('CODEX_HOME', 'posix')).toBe(
`command test -n "$fish_pid" && set --erase -g CODEX_HOME; command test -z "$fish_pid" && unset CODEX_HOME; true`
)
expect(clearEnvCommand(['A', 'B'], 'posix')).toBe(
`command test -n "$fish_pid" && set --erase -g A B; command test -z "$fish_pid" && unset A B; true`
)
expect(clearEnvCommand('CODEX_HOME', 'cmd')).toBe('set "CODEX_HOME="')
expect(clearEnvCommand('CODEX_HOME', 'powershell')).toBe(
'Remove-Item Env:CODEX_HOME -ErrorAction SilentlyContinue'
)
})
it.each([
'FOO; rm -rf /tmp/x',
'FOO BAR',
'',
'1LEADING_DIGIT',
'has-dash',
'$FOO',
'PATH\nHOME'
])('refuses %j as an environment variable name', (bad) => {
// Why: the name is interpolated straight into a shell line. Anything but an
// identifier is a command injection, and in fish `set --erase -g` would
// really delete whatever it names — PATH or HOME included.
expect(() => clearEnvCommand(bad, 'posix')).toThrow(/not an environment variable name/)
expect(() => clearEnvCommand(['OK', bad], 'posix')).toThrow(/not an environment variable name/)
})
it('quotes so fish reads back the same bytes sh does', () => {
// fish single quotes are NOT literal — `\\` and `\'` are escapes inside them —
// so the sh `'\''` idiom would halve these backslashes when fish read them,
// and a trailing backslash would be a hard syntax error.
expect(quoteStartupArg("it's", 'posix')).toBe(`'it'"'"'s'`)
expect(quoteStartupArg(String.raw`\\server\share`, 'posix')).toBe(
`"\\\\""\\\\"'server'"\\\\"'share'`
)
expect(quoteStartupArg('ends\\', 'posix')).toBe(`'ends'"\\\\"`)
expect(quoteStartupArg('', 'posix')).toBe(`''`)
})
it('keeps the sh-family grammar claims that do hold for fish', () => {
expect(commandSeparator('posix')).toBe('; ')
expect(isPosixStartupShell('posix')).toBe(true)
expect(isPosixStartupShell('powershell')).toBe(false)
expect(buildShellCommandFromArgv(['codex', 'resume', 'a b'], 'posix')).toBe(
`'codex' 'resume' 'a b'`
)
})
it('round-trips an adversarial argument through quote then tokenize', () => {
for (const value of [
String.raw`use \d+ and \\server\share`,
"it's mine",
'ends\\',
'a "b" c',
'$PATH *.ts'
]) {
const tokenized = tokenizeStartupCommand(quoteStartupArg(value, 'posix'), 'posix')
expect(tokenized.ok && tokenized.tokens).toEqual([value])
}
})
it('clears an agent draft prefill variable with the portable teardown', () => {
const plan = buildAgentDraftLaunchPlan({
agent: 'pi',
draft: 'hello',
cmdOverrides: {},
platform: 'darwin'
})
expect(plan?.launchCommand).toBe(
`pi; command test -n "$fish_pid" && set --erase -g ORCA_PI_PREFILL; command test -z "$fish_pid" && unset ORCA_PI_PREFILL; true`
)
expect(plan?.env?.ORCA_PI_PREFILL).toBe('hello')
})
})
describe('isCmdQuotingPowerShellSafe', () => {
it('accepts tokens cmd quotes as a bare "token" (safe in cmd AND PowerShell)', () => {
// Every id-based resume argv token: binary names, flags and UUIDs.
for (const token of [
'codex',
'resume',
'a1b2c3d4-0000-4000-8000-000000000000',
'--dangerously-bypass-approvals-and-sandbox',
'--resume=abc',
'C:\\Users\\neil\\repo'
]) {
expect(isCmdQuotingPowerShellSafe(token)).toBe(true)
// Contract: a safe token quotes to the same bare "token" in both shells.
expect(quoteStartupArg(token, 'cmd')).toBe(`"${token}"`)
}
})
it('rejects tokens that cmd would ^-escape, which corrupts them in a PowerShell pane', () => {
for (const token of [
'C:\\Users\\neil\\AppData (x86)\\omp\\session.jsonl',
'a&b',
'pct%VALUE%',
'a|b',
'a<b>c',
'has"quote'
]) {
expect(isCmdQuotingPowerShellSafe(token)).toBe(false)
}
})
it('rejects tokens PowerShell expands inside double quotes ($ and backtick) even though cmd keeps them literal', () => {
// `"$env"` / "a`b" parse literally in cmd but expand/escape in PowerShell.
for (const token of ['$env', 'a$b', 'has`backtick', '`n']) {
expect(isCmdQuotingPowerShellSafe(token)).toBe(false)
}
})
it('rejects a trailing backslash, which would escape the appended closing quote', () => {
// `"C:\dir\"` → the child CommandLineToArgvW sees \" as a literal quote.
expect(isCmdQuotingPowerShellSafe('C:\\dir\\')).toBe(false)
// A backslash NOT at the end (a normal Windows path) stays safe.
expect(isCmdQuotingPowerShellSafe('C:\\dir\\file.jsonl')).toBe(true)
})
})
describe('ambiguous Windows resume token quoting', () => {
it.each([
'',
'line\nbreak',
'line\rbreak',
'tab\tvalue',
'nul\0value',
'a\u201cb',
'a\u201db',
'a\u201eb'
])('rejects an unsafe token %j', (value) => expect(isCmdQuotingPowerShellSafe(value)).toBe(false))
})