Files
orca/src/shared/tui-agent-startup-shell.test.ts
T
2026-09-25 23:13:31 -07:00

157 lines
5.8 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
buildShellCommandFromArgv,
clearEnvCommand,
commandSeparator,
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('powershell startup argument quoting', () => {
it('doubles typographic single quotes, which PowerShell also treats as delimiters', () => {
expect(quoteStartupArg('it\u2019s', 'powershell')).toBe("'it\u2019\u2019s'")
})
})
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')
})
})