mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(terminal): recover readiness after startup exec (#14027)
This commit is contained in:
@@ -239,7 +239,8 @@ jobs:
|
||||
|
||||
- name: Test real shell contracts
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 \
|
||||
src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
|
||||
src/main/daemon/shell-ready.test.ts \
|
||||
src/main/daemon/node-pty-fd-leak.test.ts \
|
||||
src/main/providers/local-pty-shell-ready.test.ts \
|
||||
@@ -275,6 +276,7 @@ jobs:
|
||||
- name: Test shard
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
|
||||
--exclude=src/main/daemon/shell-ready.test.ts \
|
||||
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
|
||||
--exclude=src/main/providers/local-pty-shell-ready.test.ts \
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-08-10",
|
||||
"updatedAt": "2026-08-12",
|
||||
"policy": {
|
||||
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
|
||||
"blockingPromotion": {
|
||||
@@ -10,6 +10,103 @@
|
||||
}
|
||||
},
|
||||
"gates": [
|
||||
{
|
||||
"id": "terminal-session.shell-ready-exec-prompt-fallback",
|
||||
"title": "Startup exec falls back to the identified shell's line editor",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-session-readiness",
|
||||
"layer": "posix-pty-and-shell-startup",
|
||||
"surfaces": ["queued terminal startup commands", "zsh and bash startup files"],
|
||||
"platforms": ["macos", "linux"],
|
||||
"providers": ["local", "local-daemon", "ssh-daemon", "ssh-relay", "paired-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "local-daemon", "ssh-relay"],
|
||||
"coverageNotes": "Real macOS node-pty integration covers zsh 5.9 .zshenv/.zprofile and bash 3.2 profile replacement shells, including exec -a names, same-shell and child-process silent reads, a blocking zle-line-init hook, non-shell readline replacements, and a non-shell image renamed zsh. An Ubuntu 20.04 container confirms compatible zsh/bash termios. Deterministic owner contracts cover local, daemon, and SSH relay identity stripping plus provider- and renderer-owned delivery. Renderer-owned relay fallback publishes the existing ready marker, so mixed client/host versions need no new wire capability. Live SSH, paired-runtime, and WSL remain gaps.",
|
||||
"motivatingLinks": ["https://github.com/stablyai/orca/issues/13767"],
|
||||
"invariant": "Before user startup files run, the wrapper publishes its shell PID. If a later exec discards the ready marker, prompt fallback releases queued startup input only after the launched shell's actual executable image with that exact PID owns the PTY foreground, emits its post-hook line-editor enable sequence, and exposes zle/readline termios; startup reads, line-init hooks, child foreground processes, and non-shell readline replacements cannot trigger early fallback release.",
|
||||
"oracle": "Run the real generated wrappers under node-pty with disposable zsh and bash startup files. Plain and exec -a replacement shells must receive one queued command at their first completed line editor without waiting for the 15-second timeout. The identical oracle must time out without the startup identity and prompt fallback. Same-shell reads, child-process reads, a blocking zle-line-init hook, sqlite3, and sqlite3 renamed zsh must not trigger prompt fallback. Relay owner coverage must strip the identity and release through the same prompt contract.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready.test.ts src/main/providers/local-pty-provider.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/relay/pty-handler.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts",
|
||||
"src/main/line-editor-ready-output-scanner.test.ts",
|
||||
"src/main/shell-startup-identity-scanner.test.ts",
|
||||
"src/main/shell-prompt-readiness-probe.test.ts",
|
||||
"src/shared/pty-slave-line-discipline-echo.test.ts",
|
||||
"src/shared/shell-process-readiness.test.ts",
|
||||
"src/main/daemon/session.test.ts",
|
||||
"src/main/daemon/shell-ready.test.ts",
|
||||
"src/main/providers/local-pty-shell-ready.test.ts",
|
||||
"src/main/providers/local-pty-provider.test.ts",
|
||||
"src/relay/pty-shell-launch.test.ts",
|
||||
"src/relay/pty-handler.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts",
|
||||
"assertions": [
|
||||
"real zsh .zshenv/.zprofile and bash profile replacement shells receive queued input exactly once",
|
||||
"same-shell raw silent reads retain queued input",
|
||||
"child-process raw silent reads retain queued input while the identified shell is not foreground",
|
||||
"a blocking zle-line-init read retains queued input until the hook completes",
|
||||
"sqlite3 and a sqlite3 image renamed zsh cannot impersonate the launched shell executable path",
|
||||
"zsh configurations without a line-editor enable sequence retain the existing timeout backstop"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/relay/pty-handler.test.ts",
|
||||
"assertions": [
|
||||
"the SSH relay owner strips startup identity and releases provider-delivered input through prompt readiness",
|
||||
"renderer-owned SSH delivery receives the existing ready marker after authoritative relay prompt readiness",
|
||||
"normal renderer-owned ready markers pass through unchanged and retain no readiness state"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-12",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready.test.ts src/main/providers/local-pty-provider.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 4,
|
||||
"summary": "All real exec, silent-read, line-init-hook, renamed-image, disabled-bracketed-paste, and relay-owner oracles plus identity, foreground, executable-image, protocol, and line-editor contracts passed."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 6,
|
||||
"scope": "real local zsh/bash exec and silent-read integration gate"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "Fresh deterministic local runs pass; CI and soak history have not started."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "Oracle SHA-256 b13b08006a38b2adc2acb51ae9342909514e55ab4822fac4fbb76cd49b344cc6 is byte-identical across runs. Latest main 09ec516ae5 and the candidate with its production implementation reverted both failed exactly the three zsh .zprofile/.zshenv and bash exec cases by deterministic 5-second timeout; the zle-line-init oracle also exposed unsafe baseline delivery, while all ten other safety controls passed. The candidate passed all 14 on macOS."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "The normal ready-marker path cancels before external probing. A missing marker triggers a debounced stty read only after a line-editor enable sequence, followed by narrow PID status and executable-image inspection only when termios matches. Slow ordinary startup output triggers zero probes; rejected protocol emissions are capped at four probes per startup. There is no per-command hook or steady-state polling."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect Linux, SSH-daemon, and paired-runtime live evidence.",
|
||||
"Collect CI and soak history without unexplained flakes.",
|
||||
"Keep same-shell, child-process, line-init-hook, and non-shell readline contracts green."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The fallback requires a readable POSIX PTY slave; WSL through wsl.exe, ConPTY, and native Windows retain the existing timeout backstop.",
|
||||
"An exec integration that forks a replacement shell instead of retaining the identified shell PID retains the existing timeout backstop.",
|
||||
"A zsh configuration that disables bracketed-paste output emits no safe post-hook protocol signal and retains the existing timeout backstop.",
|
||||
"The pre-existing timeout backstop can still deliver queued input to a non-shell replacement after it expires; this fallback prevents indefinite startup hangs and is unchanged by the prompt fallback.",
|
||||
"The fallback restores safe startup-command delivery but cannot recreate OSC 133 hooks or overlay restores already discarded by exec.",
|
||||
"Startup code running in the launched shell can deliberately spoof the public line-editor enable sequence after configuring matching termios; the existing timeout path already trusts a public OSC marker from the same startup code."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote if prompt fallback enters a normal, child, or line-init read; a non-shell executable triggers prompt fallback; the identified shell PID is not foreground; delivery duplicates; ordinary startup output spawns probes; probe attempts exceed four; or the focused gate flakes without an identified harness defect."
|
||||
},
|
||||
{
|
||||
"id": "editor.restored-sibling-owner-reparent",
|
||||
"title": "Restored sibling tabs migrate filesystem authority before becoming editable",
|
||||
|
||||
@@ -8,6 +8,7 @@ const dependencyAction = parse(
|
||||
)
|
||||
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
|
||||
const shellContractFiles = [
|
||||
'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts',
|
||||
'src/main/daemon/shell-ready.test.ts',
|
||||
'src/main/providers/local-pty-shell-ready.test.ts',
|
||||
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
|
||||
@@ -75,6 +76,7 @@ describe('PR workflow parallelism', () => {
|
||||
|
||||
expect(shellStep).toBeDefined()
|
||||
expect(shellInstall).toBeDefined()
|
||||
expect(shellStep.run.split(/\s+/)).toContain('--maxWorkers=1')
|
||||
// Why the whole workflow, not just the general shards: any other lane installing
|
||||
// these shells would silently start running the real-shell tests twice.
|
||||
expect(jobsInstallingPackages).toEqual(['shell_contracts'])
|
||||
|
||||
@@ -1062,6 +1062,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
return {
|
||||
pid: proc.pid,
|
||||
shellPath,
|
||||
shellCwd: spawnCwd,
|
||||
shellPathEnv: env.PATH,
|
||||
...(slavePath ? { slavePath } : {}),
|
||||
...(startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}),
|
||||
getForegroundProcess: () => {
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createPtySubprocess } from './pty-subprocess'
|
||||
import { Session } from './session'
|
||||
|
||||
const describePosix = process.platform === 'win32' ? describe.skip : describe
|
||||
const hasZsh = process.platform !== 'win32' && spawnSync('/bin/zsh', ['--version']).status === 0
|
||||
const hasBash = process.platform !== 'win32' && spawnSync('/bin/bash', ['--version']).status === 0
|
||||
const COMMAND_OUTPUT = 'ORCA_STARTUP_COMMAND_RAN'
|
||||
const READ_STARTED_FILE = '.orca-read-started'
|
||||
|
||||
type ShellFixture = {
|
||||
name: string
|
||||
shellPath: string
|
||||
startupFile: string
|
||||
replacement: string
|
||||
command: string
|
||||
instrumentationOutput: string
|
||||
secretRead: string
|
||||
childRead: string
|
||||
}
|
||||
|
||||
const FIXTURES: ShellFixture[] = [
|
||||
{
|
||||
name: 'zsh profile',
|
||||
shellPath: '/bin/zsh',
|
||||
startupFile: '.zprofile',
|
||||
replacement: 'exec -a kiro-cli-term /bin/zsh -o noglobalrcs -l -i',
|
||||
command: `printf 'ORCA_STARTUP_%s:PF=%s\\n' COMMAND_RAN "\${(j:,:)precmd_functions}"\r`,
|
||||
instrumentationOutput: `${COMMAND_OUTPUT}:PF=`,
|
||||
secretRead: `: > "$HOME/${READ_STARTED_FILE}"; read -sk 1\n`,
|
||||
childRead: `/bin/zsh -fc ': > "$HOME/${READ_STARTED_FILE}"; read -sk 1'\n`
|
||||
},
|
||||
{
|
||||
name: 'zsh environment',
|
||||
shellPath: '/bin/zsh',
|
||||
startupFile: '.zshenv',
|
||||
replacement: 'exec /bin/zsh -o noglobalrcs -l -i',
|
||||
command: `printf 'ORCA_STARTUP_%s:PF=%s\\n' COMMAND_RAN "\${(j:,:)precmd_functions}"\r`,
|
||||
instrumentationOutput: `${COMMAND_OUTPUT}:PF=`,
|
||||
secretRead: `: > "$HOME/${READ_STARTED_FILE}"; read -sk 1\n`,
|
||||
childRead: `/bin/zsh -fc ': > "$HOME/${READ_STARTED_FILE}"; read -sk 1'\n`
|
||||
},
|
||||
{
|
||||
name: 'bash profile',
|
||||
shellPath: '/bin/bash',
|
||||
startupFile: '.bash_profile',
|
||||
replacement: 'exec -a figterm-test /bin/bash --noprofile --norc -l -i',
|
||||
command: `printf 'ORCA_STARTUP_%s:PC=%s\\n' COMMAND_RAN "$PROMPT_COMMAND"\r`,
|
||||
instrumentationOutput: `${COMMAND_OUTPUT}:PC=`,
|
||||
secretRead: `: > "$HOME/${READ_STARTED_FILE}"; read -s -n 1\n`,
|
||||
childRead: `/bin/bash --noprofile --norc -c ': > "$HOME/${READ_STARTED_FILE}"; read -s -n 1'\n`
|
||||
}
|
||||
]
|
||||
|
||||
function count(text: string, needle: string): number {
|
||||
return text.split(needle).length - 1
|
||||
}
|
||||
|
||||
function waitForOutput(
|
||||
subscribe: (settle: () => void) => void,
|
||||
isDone: () => boolean,
|
||||
timeoutMs = 5_000
|
||||
): Promise<void> {
|
||||
if (isDone()) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const deadline = setTimeout(
|
||||
() => reject(new Error('Timed out waiting for PTY output')),
|
||||
timeoutMs
|
||||
)
|
||||
const settle = (): void => {
|
||||
if (!isDone()) {
|
||||
return
|
||||
}
|
||||
clearTimeout(deadline)
|
||||
resolve()
|
||||
}
|
||||
subscribe(settle)
|
||||
settle()
|
||||
})
|
||||
}
|
||||
|
||||
function waitForCondition(isDone: () => boolean, timeoutMs = 5_000): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const interval = setInterval(() => {
|
||||
if (!isDone()) {
|
||||
return
|
||||
}
|
||||
clearInterval(interval)
|
||||
clearTimeout(deadline)
|
||||
resolve()
|
||||
}, 10)
|
||||
const deadline = setTimeout(() => {
|
||||
clearInterval(interval)
|
||||
reject(new Error('Timed out waiting for fixture state'))
|
||||
}, timeoutMs)
|
||||
})
|
||||
}
|
||||
|
||||
function runCleanupActions(...actions: (() => void)[]): void {
|
||||
for (const action of actions) {
|
||||
try {
|
||||
action()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
type RunningFixture = {
|
||||
session: Session
|
||||
subprocess: ReturnType<typeof createPtySubprocess>
|
||||
output: () => string
|
||||
readStarted: () => boolean
|
||||
subscribe: (settle: () => void) => void
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
function startFixture(
|
||||
fixture: ShellFixture,
|
||||
startupContent: string,
|
||||
extraFiles: Record<string, string> = {}
|
||||
): RunningFixture {
|
||||
const tempHome = mkdtempSync(join(tmpdir(), 'orca-shell-ready-exec-'))
|
||||
const previousHome = process.env.HOME
|
||||
const previousZdotdir = process.env.ZDOTDIR
|
||||
const previousOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR
|
||||
let subprocess: ReturnType<typeof createPtySubprocess> | undefined
|
||||
let session: Session | undefined
|
||||
let consoleWarnSpy: { mockRestore: () => void } | undefined
|
||||
try {
|
||||
writeFileSync(join(tempHome, fixture.startupFile), startupContent)
|
||||
for (const [fileName, content] of Object.entries(extraFiles)) {
|
||||
writeFileSync(join(tempHome, fileName), content)
|
||||
}
|
||||
process.env.HOME = tempHome
|
||||
delete process.env.ZDOTDIR
|
||||
delete process.env.ORCA_ORIG_ZDOTDIR
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
subprocess = createPtySubprocess({
|
||||
sessionId: `repro-13767-${fixture.startupFile}`,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: tempHome,
|
||||
command: fixture.command,
|
||||
shellOverride: fixture.shellPath,
|
||||
env: {
|
||||
HOME: tempHome,
|
||||
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
||||
SHELL: fixture.shellPath,
|
||||
TERM: 'xterm-256color'
|
||||
},
|
||||
envToDelete: ['ORCA_EXEC_REPRO_DONE', 'ORCA_ORIG_ZDOTDIR', 'ZDOTDIR']
|
||||
})
|
||||
session = new Session({
|
||||
sessionId: `repro-13767-${fixture.startupFile}`,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
subprocess,
|
||||
shellReadySupported: true
|
||||
})
|
||||
let output = ''
|
||||
let onOutput = (): void => {}
|
||||
session.attachClient({
|
||||
onData: (data) => {
|
||||
output += data
|
||||
onOutput()
|
||||
},
|
||||
onExit: () => {}
|
||||
})
|
||||
session.write(fixture.command)
|
||||
const runningSubprocess = subprocess
|
||||
const runningSession = session
|
||||
const activeConsoleWarnSpy = consoleWarnSpy
|
||||
|
||||
return {
|
||||
session: runningSession,
|
||||
subprocess: runningSubprocess,
|
||||
output: () => output,
|
||||
readStarted: () => existsSync(join(tempHome, READ_STARTED_FILE)),
|
||||
subscribe: (settle) => {
|
||||
onOutput = settle
|
||||
},
|
||||
cleanup: async () => {
|
||||
if (runningSession.isAlive) {
|
||||
try {
|
||||
await runningSession.forceKillAndWaitForExit(3_000)
|
||||
} catch {
|
||||
runCleanupActions(() => runningSubprocess.forceKill())
|
||||
}
|
||||
}
|
||||
runCleanupActions(
|
||||
() => runningSession.dispose(),
|
||||
() => activeConsoleWarnSpy.mockRestore(),
|
||||
() => restoreEnvironment(previousHome, previousZdotdir, previousOrigZdotdir),
|
||||
() => rmSync(tempHome, { recursive: true, force: true })
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
runCleanupActions(
|
||||
() => subprocess?.forceKill(),
|
||||
() => session?.dispose(),
|
||||
() => !session && subprocess?.dispose(),
|
||||
() => consoleWarnSpy?.mockRestore(),
|
||||
() => restoreEnvironment(previousHome, previousZdotdir, previousOrigZdotdir),
|
||||
() => rmSync(tempHome, { recursive: true, force: true })
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function restoreEnvironment(
|
||||
home: string | undefined,
|
||||
zdotdir: string | undefined,
|
||||
originalZdotdir: string | undefined
|
||||
): void {
|
||||
setEnvironmentValue('HOME', home)
|
||||
setEnvironmentValue('ZDOTDIR', zdotdir)
|
||||
setEnvironmentValue('ORCA_ORIG_ZDOTDIR', originalZdotdir)
|
||||
}
|
||||
|
||||
function setEnvironmentValue(key: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
async function runExecOracle(fixture: ShellFixture): Promise<void> {
|
||||
const running = startFixture(
|
||||
fixture,
|
||||
`if [[ -z "\${ORCA_EXEC_REPRO_DONE:-}" ]]; then
|
||||
export ORCA_EXEC_REPRO_DONE=1
|
||||
${fixture.replacement}
|
||||
fi
|
||||
`
|
||||
)
|
||||
try {
|
||||
await waitForOutput(running.subscribe, () => running.output().includes(COMMAND_OUTPUT))
|
||||
expect(running.session.shellState).toBe('ready')
|
||||
expect(count(running.output(), COMMAND_OUTPUT)).toBe(1)
|
||||
expect(running.output()).toContain(fixture.instrumentationOutput)
|
||||
expect(running.output()).not.toContain('orca-shell-start')
|
||||
} finally {
|
||||
await running.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
async function runReadOracle(fixture: ShellFixture, child: boolean): Promise<void> {
|
||||
const running = startFixture(fixture, child ? fixture.childRead : fixture.secretRead)
|
||||
try {
|
||||
await waitForCondition(running.readStarted)
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
expect(running.session.shellState).toBe('pending')
|
||||
expect(running.output()).not.toContain(COMMAND_OUTPUT)
|
||||
|
||||
running.subprocess.write('x\r')
|
||||
await waitForOutput(running.subscribe, () => running.output().includes(COMMAND_OUTPUT))
|
||||
expect(count(running.output(), COMMAND_OUTPUT)).toBe(1)
|
||||
expect(running.output()).not.toContain('orca-shell-start')
|
||||
} finally {
|
||||
await running.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
describePosix('#13767 shell-ready marker loss across exec', () => {
|
||||
it('continues setup cleanup after force-kill failure', () => {
|
||||
const dispose = vi.fn()
|
||||
const restore = vi.fn()
|
||||
runCleanupActions(
|
||||
() => {
|
||||
throw new Error('force kill failed')
|
||||
},
|
||||
dispose,
|
||||
restore
|
||||
)
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
expect(restore).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
for (const fixture of FIXTURES) {
|
||||
const runnable = fixture.shellPath.endsWith('zsh') ? hasZsh : hasBash
|
||||
const testCase = runnable ? it : it.skip
|
||||
testCase(
|
||||
`releases at the real ${fixture.name} prompt after exec`,
|
||||
() => runExecOracle(fixture),
|
||||
10_000
|
||||
)
|
||||
testCase(
|
||||
`keeps queued input out of a real ${fixture.name} silent read`,
|
||||
() => runReadOracle(fixture, false),
|
||||
10_000
|
||||
)
|
||||
testCase(
|
||||
`keeps queued input out of a child read during ${fixture.name} startup`,
|
||||
() => runReadOracle(fixture, true),
|
||||
10_000
|
||||
)
|
||||
}
|
||||
|
||||
const zshFixture = FIXTURES[0] as ShellFixture
|
||||
const zshTest = hasZsh ? it : it.skip
|
||||
zshTest(
|
||||
'keeps queued input out of a zle-line-init read after exec',
|
||||
async () => {
|
||||
const running = startFixture(
|
||||
zshFixture,
|
||||
`if [[ -z "\${ORCA_EXEC_REPRO_DONE:-}" ]]; then
|
||||
export ORCA_EXEC_REPRO_DONE=1
|
||||
exec env ZDOTDIR="$HOME" /bin/zsh -o noglobalrcs -l -i
|
||||
fi
|
||||
`,
|
||||
{
|
||||
'.zshrc': `zle-line-init() {
|
||||
print -n 'HOOK_SECRET> '
|
||||
read -sk 1
|
||||
print -r -- HOOK_DONE
|
||||
}
|
||||
zle -N zle-line-init
|
||||
`
|
||||
}
|
||||
)
|
||||
try {
|
||||
try {
|
||||
await waitForOutput(running.subscribe, () => running.output().includes('HOOK_SECRET> '))
|
||||
} catch (error) {
|
||||
throw new Error(`ZLE hook startup output: ${JSON.stringify(running.output())}`, {
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
expect(running.session.shellState).toBe('pending')
|
||||
expect(running.output()).not.toContain(COMMAND_OUTPUT)
|
||||
|
||||
running.subprocess.write('x')
|
||||
try {
|
||||
await waitForOutput(running.subscribe, () => running.output().includes(COMMAND_OUTPUT))
|
||||
} catch (error) {
|
||||
throw new Error(`ZLE hook output: ${JSON.stringify(running.output())}`, { cause: error })
|
||||
}
|
||||
expect(running.output()).toContain('HOOK_DONE')
|
||||
expect(count(running.output(), COMMAND_OUTPUT)).toBe(1)
|
||||
} finally {
|
||||
await running.cleanup()
|
||||
}
|
||||
},
|
||||
10_000
|
||||
)
|
||||
|
||||
const sqliteTest = hasZsh && existsSync('/usr/bin/sqlite3') ? it : it.skip
|
||||
sqliteTest(
|
||||
'does not treat an exec-replaced readline program as the shell prompt',
|
||||
async () => {
|
||||
const running = startFixture(zshFixture, 'exec /usr/bin/sqlite3\n')
|
||||
try {
|
||||
await waitForOutput(running.subscribe, () => running.output().includes('sqlite> '))
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
expect(running.session.shellState).toBe('pending')
|
||||
expect(running.output()).not.toContain(COMMAND_OUTPUT)
|
||||
} finally {
|
||||
await running.cleanup()
|
||||
}
|
||||
},
|
||||
10_000
|
||||
)
|
||||
|
||||
sqliteTest(
|
||||
'does not trust a non-shell executable renamed to the shell basename',
|
||||
async () => {
|
||||
const running = startFixture(
|
||||
zshFixture,
|
||||
'ln -s /usr/bin/sqlite3 "$HOME/zsh" && exec "$HOME/zsh"\n'
|
||||
)
|
||||
try {
|
||||
await waitForOutput(running.subscribe, () => running.output().includes('sqlite> '))
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
expect(running.session.shellState).toBe('pending')
|
||||
expect(running.output()).not.toContain(COMMAND_OUTPUT)
|
||||
} finally {
|
||||
await running.cleanup()
|
||||
}
|
||||
},
|
||||
10_000
|
||||
)
|
||||
|
||||
zshTest(
|
||||
'retains the timeout backstop when zsh disables bracketed paste',
|
||||
async () => {
|
||||
const running = startFixture(
|
||||
zshFixture,
|
||||
`if [[ -z "\${ORCA_EXEC_REPRO_DONE:-}" ]]; then
|
||||
export ORCA_EXEC_REPRO_DONE=1
|
||||
exec env ZDOTDIR="$HOME" /bin/zsh -o noglobalrcs -l -i
|
||||
fi
|
||||
`,
|
||||
{
|
||||
'.zshrc': "zmodload zsh/zle\nunset zle_bracketed_paste\nPS1='NO_BRACKET_PROMPT> '\n"
|
||||
}
|
||||
)
|
||||
try {
|
||||
await waitForOutput(running.subscribe, () =>
|
||||
running.output().includes('NO_BRACKET_PROMPT> ')
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
expect(running.session.shellState).toBe('pending')
|
||||
expect(running.output()).not.toContain(COMMAND_OUTPUT)
|
||||
} finally {
|
||||
await running.cleanup()
|
||||
}
|
||||
},
|
||||
10_000
|
||||
)
|
||||
})
|
||||
@@ -13,6 +13,16 @@ import {
|
||||
scanForShellReady,
|
||||
type ShellReadyScanState
|
||||
} from '../shell-ready-marker-scanner'
|
||||
import {
|
||||
createShellStartupIdentityScanState,
|
||||
drainShellStartupIdentityHeldBytes,
|
||||
scanForShellStartupIdentity,
|
||||
type ShellStartupIdentityScanState
|
||||
} from '../shell-startup-identity-scanner'
|
||||
import {
|
||||
createShellPromptReadinessProbe,
|
||||
type ShellPromptReadinessProbe
|
||||
} from '../shell-prompt-readiness-probe'
|
||||
import { isPowerShellProcess } from '../../shared/shell-process-detection'
|
||||
import { killWithDescendantSweep } from '../pty-descendant-termination'
|
||||
import type { TuiAgent } from '../../shared/types'
|
||||
@@ -60,6 +70,8 @@ export type SubprocessHandle = {
|
||||
/** Shell the subprocess actually spawned, after fallbacks. The host reconciles the caller's shell-ready
|
||||
* assumption against it so a fallback shell without a ready marker never gates startup commands. */
|
||||
shellPath?: string
|
||||
shellCwd?: string
|
||||
shellPathEnv?: string
|
||||
/** Slave device path, so startup replies can read the line discipline's ECHO bit before
|
||||
* writing. Absent on handles with no POSIX slave to read (ConPTY, tests). */
|
||||
slavePath?: string
|
||||
@@ -125,6 +137,9 @@ export class Session {
|
||||
private releaseStartupDeviceAttributesResponder: (() => void) | null = null
|
||||
private startupDeviceAttributesQueryFilter: StartupDeviceAttributesQueryFilter | null = null
|
||||
private shellReadyScanState: ShellReadyScanState | null = null
|
||||
private shellStartupIdentityScanState: ShellStartupIdentityScanState | null = null
|
||||
private shellStartupPid: number | null = null
|
||||
private shellPromptReadinessProbe: ShellPromptReadinessProbe | null = null
|
||||
private shellReadyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private killTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private postReadyFlushGate: PostReadyFlushGate
|
||||
@@ -170,6 +185,7 @@ export class Session {
|
||||
if (opts.shellReadySupported) {
|
||||
this._shellState = 'pending'
|
||||
this.shellReadyScanState = createShellReadyScanState()
|
||||
this.shellStartupIdentityScanState = createShellStartupIdentityScanState()
|
||||
// Why: `write` queues everything until the ready marker, including the renderer's DA1
|
||||
// reply — and a shell that withholds its first prompt until DA1 is answered (fish) then
|
||||
// never emits the marker that would release it. Answer from the daemon, past the queue.
|
||||
@@ -195,6 +211,16 @@ export class Session {
|
||||
onEmission: (emission) => this.emitSubprocessOutput(emission),
|
||||
...(echoProbe ? { echoProbe } : {})
|
||||
})
|
||||
if (this._shellState === 'pending') {
|
||||
this.shellPromptReadinessProbe = createShellPromptReadinessProbe({
|
||||
slavePath: this.subprocess.slavePath,
|
||||
shellPath: this.subprocess.shellPath,
|
||||
shellCwd: this.subprocess.shellCwd,
|
||||
shellPathEnv: this.subprocess.shellPathEnv,
|
||||
getShellPid: () => this.shellStartupPid,
|
||||
onPromptReady: () => this.onShellPromptReady()
|
||||
})
|
||||
}
|
||||
this.subprocess.onData((data) => this.handleSubprocessData(data))
|
||||
this.subprocess.onExit((code) => this.handleSubprocessExit(code))
|
||||
}
|
||||
@@ -603,6 +629,8 @@ export class Session {
|
||||
clearTimeout(this.shellReadyTimer)
|
||||
this.shellReadyTimer = null
|
||||
}
|
||||
this.shellPromptReadinessProbe?.dispose()
|
||||
this.shellPromptReadinessProbe = null
|
||||
this.shellReadyScanState = null
|
||||
this.preReadyStdinQueue = []
|
||||
this.postReadyFlushGate.clear()
|
||||
@@ -649,6 +677,14 @@ export class Session {
|
||||
}
|
||||
|
||||
let releaseStartupDeviceAttributes = false
|
||||
if (this._shellState === 'pending' && this.shellStartupIdentityScanState) {
|
||||
const scanned = scanForShellStartupIdentity(this.shellStartupIdentityScanState, data)
|
||||
data = scanned.output
|
||||
if (scanned.shellPid) {
|
||||
this.shellStartupPid = scanned.shellPid
|
||||
this.shellStartupIdentityScanState = null
|
||||
}
|
||||
}
|
||||
if (this._shellState === 'pending' && this.shellReadyScanState) {
|
||||
const scanned = scanForShellReady(this.shellReadyScanState, data)
|
||||
data = scanned.output
|
||||
@@ -661,6 +697,9 @@ export class Session {
|
||||
}
|
||||
|
||||
this.startupIngress.accept(data)
|
||||
if (this._shellState === 'pending' && data.length > 0) {
|
||||
this.shellPromptReadinessProbe?.notifyOutput(data)
|
||||
}
|
||||
if (releaseStartupDeviceAttributes) {
|
||||
this.releaseStartupDeviceAttributes()
|
||||
}
|
||||
@@ -696,6 +735,8 @@ export class Session {
|
||||
}
|
||||
|
||||
this.releaseStartupDeviceAttributes()
|
||||
this.shellPromptReadinessProbe?.dispose()
|
||||
this.shellPromptReadinessProbe = null
|
||||
this.releaseHeldShellReadyBytes()
|
||||
this.startupIngress.drainAndClose()
|
||||
this._exitCode = code
|
||||
@@ -727,10 +768,19 @@ export class Session {
|
||||
}
|
||||
|
||||
private releaseHeldShellReadyBytes(): string {
|
||||
if (!this.shellReadyScanState) {
|
||||
if (!this.shellReadyScanState && !this.shellStartupIdentityScanState) {
|
||||
return ''
|
||||
}
|
||||
const heldBytes = drainShellReadyHeldBytes(this.shellReadyScanState)
|
||||
let heldBytes = this.shellStartupIdentityScanState
|
||||
? drainShellStartupIdentityHeldBytes(this.shellStartupIdentityScanState)
|
||||
: ''
|
||||
this.shellStartupIdentityScanState = null
|
||||
if (this.shellReadyScanState && heldBytes) {
|
||||
heldBytes = scanForShellReady(this.shellReadyScanState, heldBytes).output
|
||||
}
|
||||
if (this.shellReadyScanState) {
|
||||
heldBytes += drainShellReadyHeldBytes(this.shellReadyScanState)
|
||||
}
|
||||
this.shellReadyScanState = null
|
||||
// Why: scanning strips marker bytes before fan-out; if readiness never completes, release any held prefix before timeout/exit discards it.
|
||||
this.startupIngress.accept(heldBytes)
|
||||
@@ -759,6 +809,9 @@ export class Session {
|
||||
private transitionToReady(postMarkerBytesObserved = false): void {
|
||||
this._shellState = 'ready'
|
||||
this.shellReadyScanState = null
|
||||
this.shellStartupIdentityScanState = null
|
||||
this.shellPromptReadinessProbe?.dispose()
|
||||
this.shellPromptReadinessProbe = null
|
||||
if (this.shellReadyTimer) {
|
||||
clearTimeout(this.shellReadyTimer)
|
||||
this.shellReadyTimer = null
|
||||
@@ -775,11 +828,25 @@ export class Session {
|
||||
return
|
||||
}
|
||||
this._shellState = 'timed_out'
|
||||
this.shellPromptReadinessProbe?.dispose()
|
||||
this.shellPromptReadinessProbe = null
|
||||
this.releaseStartupDeviceAttributes()
|
||||
this.releaseHeldShellReadyBytes()
|
||||
this.flushPreReadyQueue()
|
||||
}
|
||||
|
||||
private onShellPromptReady(): void {
|
||||
if (this._shellState !== 'pending') {
|
||||
return
|
||||
}
|
||||
console.warn(
|
||||
`[Session] ${this.sessionId}: shell-ready wrapper was replaced before its marker; releasing at the identified shell prompt. OSC 133 integration may be unavailable.`
|
||||
)
|
||||
this.releaseHeldShellReadyBytes()
|
||||
this.transitionToReady(true)
|
||||
this.releaseStartupDeviceAttributes()
|
||||
}
|
||||
|
||||
private flushPreReadyQueue(): void {
|
||||
const queued = this.preReadyStdinQueue
|
||||
this.preReadyStdinQueue = []
|
||||
|
||||
@@ -462,6 +462,7 @@ describePosix('daemon shell-ready launch config', () => {
|
||||
const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8')
|
||||
const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8')
|
||||
expect(zshenv).toContain('_orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"')
|
||||
expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"')
|
||||
expect(zshenv).toContain('*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;;')
|
||||
expect(zshenv).toContain('""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;')
|
||||
expectZdotdirSourceContext(zprofile, '.zprofile')
|
||||
@@ -650,6 +651,7 @@ describePosix('daemon shell-ready launch config', () => {
|
||||
const bashRc = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8')
|
||||
|
||||
expect(bashRc).toContain('printf "\\033]133;D;%s\\007"')
|
||||
expect(bashRc).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"')
|
||||
expect(bashRc).toContain('printf "\\033]133;C\\007"')
|
||||
// precmd is prepended (captures $? first), epilogue appended last, so a framework needing last position stays between them.
|
||||
expect(bashRc).toContain(
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
getZshEnvTemplate,
|
||||
getZshFinalZdotdirRestoreBlock,
|
||||
getZshShellReadyMarkerRegistrationBlock,
|
||||
SHELL_STARTUP_IDENTITY_MARKER_BLOCK,
|
||||
getZshStartupFileSourceBlock
|
||||
} from '../shell-templates'
|
||||
|
||||
@@ -88,6 +89,7 @@ function shellReadyWrappersExist(): boolean {
|
||||
|
||||
export function getDaemonBashShellReadyRcfileContent(): string {
|
||||
return `# Orca daemon bash shell-ready wrapper
|
||||
${SHELL_STARTUP_IDENTITY_MARKER_BLOCK}
|
||||
[[ -f /etc/profile ]] && source /etc/profile
|
||||
if [[ -f "$HOME/.bash_profile" ]]; then
|
||||
source "$HOME/.bash_profile"
|
||||
@@ -384,7 +386,8 @@ function getWrappedShellLaunchConfig(
|
||||
ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(),
|
||||
ORCA_ZSHENV_SOURCE_DIR: resolveOriginalZshenvSourceDir(),
|
||||
ZDOTDIR: join(root, 'zsh'),
|
||||
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0'
|
||||
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0',
|
||||
ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0'
|
||||
},
|
||||
supportsReadyMarker: options.emitReadyMarker
|
||||
}
|
||||
@@ -396,7 +399,8 @@ function getWrappedShellLaunchConfig(
|
||||
return {
|
||||
args: ['--rcfile', join(root, 'bash', 'rcfile')],
|
||||
env: {
|
||||
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0'
|
||||
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0',
|
||||
ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0'
|
||||
},
|
||||
supportsReadyMarker: options.emitReadyMarker
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createLineEditorReadyOutputScanState,
|
||||
scanForLineEditorReadyOutput
|
||||
} from './line-editor-ready-output-scanner'
|
||||
|
||||
describe('line editor ready output scanner', () => {
|
||||
it.each(['\x1b[?2004h', '\x1b[?1034h'])('detects %j across chunks', (sequence) => {
|
||||
const state = createLineEditorReadyOutputScanState()
|
||||
expect(scanForLineEditorReadyOutput(state, `prompt${sequence.slice(0, 4)}`)).toBe(false)
|
||||
expect(scanForLineEditorReadyOutput(state, sequence.slice(4))).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores ordinary prompt output', () => {
|
||||
expect(
|
||||
scanForLineEditorReadyOutput(createLineEditorReadyOutputScanState(), 'user@host % ')
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
const LINE_EDITOR_READY_SEQUENCES = ['\x1b[?2004h', '\x1b[?1034h'] as const
|
||||
const MAX_SEQUENCE_LENGTH = Math.max(...LINE_EDITOR_READY_SEQUENCES.map((value) => value.length))
|
||||
|
||||
export type LineEditorReadyOutputScanState = {
|
||||
tail: string
|
||||
}
|
||||
|
||||
export function createLineEditorReadyOutputScanState(): LineEditorReadyOutputScanState {
|
||||
return { tail: '' }
|
||||
}
|
||||
|
||||
export function scanForLineEditorReadyOutput(
|
||||
state: LineEditorReadyOutputScanState,
|
||||
data: string
|
||||
): boolean {
|
||||
const combined = state.tail + data
|
||||
state.tail = combined.slice(-(MAX_SEQUENCE_LENGTH - 1))
|
||||
return LINE_EDITOR_READY_SEQUENCES.some((sequence) => combined.includes(sequence))
|
||||
}
|
||||
@@ -62,6 +62,8 @@ export async function shellScriptTest(
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...config.env,
|
||||
// Why: these examples inspect startup-file discovery, not the PTY-owner protocol stream.
|
||||
ORCA_SHELL_STARTUP_IDENTITY: '0',
|
||||
// Why: the framework creates user startup files under testHome after
|
||||
// computing the wrapper config; route wrapper discovery to that fixture.
|
||||
HOME: testHome,
|
||||
|
||||
@@ -15,7 +15,8 @@ const {
|
||||
readWindowsConptyProcessIdsMock,
|
||||
killWithDescendantSweepMock,
|
||||
isWslAvailableAsyncMock,
|
||||
wslUncDirectoryExistsMock
|
||||
wslUncDirectoryExistsMock,
|
||||
createShellPromptReadinessProbeMock
|
||||
} = vi.hoisted(() => ({
|
||||
existsSyncMock: vi.fn(),
|
||||
statSyncMock: vi.fn(),
|
||||
@@ -28,7 +29,8 @@ const {
|
||||
readWindowsConptyProcessIdsMock: vi.fn(),
|
||||
killWithDescendantSweepMock: vi.fn(),
|
||||
isWslAvailableAsyncMock: vi.fn(),
|
||||
wslUncDirectoryExistsMock: vi.fn()
|
||||
wslUncDirectoryExistsMock: vi.fn(),
|
||||
createShellPromptReadinessProbeMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
@@ -107,6 +109,10 @@ vi.mock('../wsl', () => ({
|
||||
wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args)
|
||||
}))
|
||||
|
||||
vi.mock('../shell-prompt-readiness-probe', () => ({
|
||||
createShellPromptReadinessProbe: createShellPromptReadinessProbeMock
|
||||
}))
|
||||
|
||||
import {
|
||||
_resetLocalPtyProviderStateForTest,
|
||||
LOCAL_PTY_FORCE_KILL_RETRY_MS,
|
||||
@@ -174,6 +180,7 @@ describe('LocalPtyProvider', () => {
|
||||
isWslAvailableAsyncMock.mockResolvedValue(true)
|
||||
wslUncDirectoryExistsMock.mockReset()
|
||||
wslUncDirectoryExistsMock.mockReturnValue(true)
|
||||
createShellPromptReadinessProbeMock.mockReset()
|
||||
|
||||
exitCb = undefined
|
||||
mockProc = {
|
||||
@@ -534,6 +541,24 @@ describe('LocalPtyProvider', () => {
|
||||
expect(spawnCall[2].env.CUSTOM_VAR).toBe('custom-value')
|
||||
})
|
||||
|
||||
it('verifies shell identity against the exact spawn PATH', async () => {
|
||||
provider.configure({
|
||||
buildSpawnEnv: (_id, env) => ({ ...env, PATH: '/post-hook/bin' })
|
||||
})
|
||||
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
command: 'printf ready',
|
||||
env: { PATH: '/pre-hook/bin' }
|
||||
})
|
||||
|
||||
expect(spawnMock.mock.calls.at(-1)?.[2].env.PATH).toBe('/post-hook/bin')
|
||||
expect(createShellPromptReadinessProbeMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ shellPathEnv: '/post-hook/bin' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not inherit NODE_ENV from the Orca process env', async () => {
|
||||
// Why: NODE_ENV in Orca's process is Orca's build mode (electron-vite sets
|
||||
// `development` in dev runs); leaking it breaks `next build` and Vitest.
|
||||
|
||||
@@ -75,6 +75,15 @@ import {
|
||||
createPtySlaveEchoProbe,
|
||||
readPtySlavePath
|
||||
} from '../../shared/pty-slave-line-discipline-echo'
|
||||
import {
|
||||
createShellStartupIdentityScanState,
|
||||
drainShellStartupIdentityHeldBytes,
|
||||
scanForShellStartupIdentity
|
||||
} from '../shell-startup-identity-scanner'
|
||||
import {
|
||||
createShellPromptReadinessProbe,
|
||||
type ShellPromptReadinessProbe
|
||||
} from '../shell-prompt-readiness-probe'
|
||||
import {
|
||||
expandWindowsEnvironmentVariables,
|
||||
expandWindowsPathEnvironmentVariables
|
||||
@@ -951,6 +960,11 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
// Shell-ready startup command support
|
||||
let resolveShellReady: ((signal: ShellReadySignal) => void) | null = null
|
||||
let shellReadyTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let shellStartupPid: number | null = null
|
||||
let shellPromptReadinessProbe: ShellPromptReadinessProbe | null = null
|
||||
let shellStartupIdentityScanState = shellReadyLaunch?.supportsReadyMarker
|
||||
? createShellStartupIdentityScanState()
|
||||
: null
|
||||
const shellReadyScanState = shellReadyLaunch?.supportsReadyMarker
|
||||
? createShellReadyScanState()
|
||||
: null
|
||||
@@ -967,6 +981,8 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
clearTimeout(shellReadyTimeout)
|
||||
shellReadyTimeout = null
|
||||
}
|
||||
shellPromptReadinessProbe?.dispose()
|
||||
shellPromptReadinessProbe = null
|
||||
const resolve = resolveShellReady
|
||||
resolveShellReady = null
|
||||
resolve(signal)
|
||||
@@ -975,12 +991,35 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
if (!shellReadyScanState) {
|
||||
return
|
||||
}
|
||||
const heldBytes = drainShellReadyHeldBytes(shellReadyScanState)
|
||||
let heldBytes = shellStartupIdentityScanState
|
||||
? drainShellStartupIdentityHeldBytes(shellStartupIdentityScanState)
|
||||
: ''
|
||||
shellStartupIdentityScanState = null
|
||||
if (heldBytes) {
|
||||
heldBytes = scanForShellReady(shellReadyScanState, heldBytes).output
|
||||
}
|
||||
heldBytes += drainShellReadyHeldBytes(shellReadyScanState)
|
||||
if (heldBytes.length === 0) {
|
||||
return
|
||||
}
|
||||
startupIngress.accept(heldBytes)
|
||||
}
|
||||
if (shellReadyScanState) {
|
||||
shellPromptReadinessProbe = createShellPromptReadinessProbe({
|
||||
slavePath: readPtySlavePath(proc),
|
||||
shellPath,
|
||||
shellCwd: effectiveCwd,
|
||||
shellPathEnv: finalEnv.PATH,
|
||||
getShellPid: () => shellStartupPid,
|
||||
onPromptReady: () => {
|
||||
console.warn(
|
||||
`[pty] ${id}: shell-ready wrapper was replaced before its marker; releasing at the identified shell prompt. OSC 133 integration may be unavailable.`
|
||||
)
|
||||
releaseHeldShellReadyBytes()
|
||||
finishShellReady({ postMarkerBytesObserved: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
if (args.command) {
|
||||
if (shellReadyLaunch?.supportsReadyMarker) {
|
||||
shellReadyTimeout = setTimeout(() => {
|
||||
@@ -1002,20 +1041,33 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
startupCommandCleanup?.()
|
||||
startupCommandCleanup = null
|
||||
resolveShellReady = null
|
||||
shellPromptReadinessProbe?.dispose()
|
||||
shellPromptReadinessProbe = null
|
||||
})
|
||||
}
|
||||
|
||||
const disposables: { dispose: () => void }[] = []
|
||||
const onDataDisposable = proc.onData((rawData) => {
|
||||
let data = rawData
|
||||
if (shellStartupIdentityScanState && resolveShellReady) {
|
||||
const scanned = scanForShellStartupIdentity(shellStartupIdentityScanState, data)
|
||||
data = scanned.output
|
||||
if (scanned.shellPid) {
|
||||
shellStartupPid = scanned.shellPid
|
||||
shellStartupIdentityScanState = null
|
||||
}
|
||||
}
|
||||
if (shellReadyScanState && resolveShellReady) {
|
||||
const scanned = scanForShellReady(shellReadyScanState, rawData)
|
||||
const scanned = scanForShellReady(shellReadyScanState, data)
|
||||
data = scanned.output
|
||||
if (scanned.matched) {
|
||||
finishShellReady({ postMarkerBytesObserved: scanned.postMarkerBytesObserved })
|
||||
}
|
||||
}
|
||||
startupIngress.accept(data)
|
||||
if (resolveShellReady && data.length > 0) {
|
||||
shellPromptReadinessProbe?.notifyOutput(data)
|
||||
}
|
||||
})
|
||||
if (onDataDisposable) {
|
||||
disposables.push(onDataDisposable)
|
||||
@@ -1033,6 +1085,8 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
shellReadyTimeout = null
|
||||
}
|
||||
startupCommandCleanup?.()
|
||||
shellPromptReadinessProbe?.dispose()
|
||||
shellPromptReadinessProbe = null
|
||||
clearPtyState(id)
|
||||
startupIngress.drainAndClose()
|
||||
startupIngressByPty.delete(id)
|
||||
|
||||
@@ -485,6 +485,7 @@ describePosix('local PTY shell-ready launch config', () => {
|
||||
const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8')
|
||||
const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8')
|
||||
expect(zshenv).toContain('_orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"')
|
||||
expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"')
|
||||
expect(zshenv).toContain('*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;;')
|
||||
expect(zshenv).toContain('""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;')
|
||||
expectZdotdirSourceContext(zprofile, '.zprofile')
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getZshEnvTemplate,
|
||||
getZshFinalZdotdirRestoreBlock,
|
||||
getZshShellReadyMarkerRegistrationBlock,
|
||||
SHELL_STARTUP_IDENTITY_MARKER_BLOCK,
|
||||
getZshStartupFileSourceBlock
|
||||
} from '../shell-templates'
|
||||
export {
|
||||
@@ -92,6 +93,7 @@ function resolveOriginalZshenvSourceDir(): string {
|
||||
|
||||
export function getBashShellReadyRcfileContent(): string {
|
||||
return `# Orca bash shell-ready wrapper
|
||||
${SHELL_STARTUP_IDENTITY_MARKER_BLOCK}
|
||||
[[ -f /etc/profile ]] && source /etc/profile
|
||||
if [[ -f "$HOME/.bash_profile" ]]; then
|
||||
source "$HOME/.bash_profile"
|
||||
@@ -368,7 +370,8 @@ function getWrappedShellLaunchConfig(
|
||||
ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(),
|
||||
ORCA_ZSHENV_SOURCE_DIR: resolveOriginalZshenvSourceDir(),
|
||||
ZDOTDIR: `${getShellReadyWrapperRoot()}/zsh`,
|
||||
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0'
|
||||
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0',
|
||||
ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0'
|
||||
},
|
||||
supportsReadyMarker: options.emitReadyMarker
|
||||
}
|
||||
@@ -379,7 +382,8 @@ function getWrappedShellLaunchConfig(
|
||||
return {
|
||||
args: ['--rcfile', `${getShellReadyWrapperRoot()}/bash/rcfile`],
|
||||
env: {
|
||||
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0'
|
||||
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0',
|
||||
ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0'
|
||||
},
|
||||
supportsReadyMarker: options.emitReadyMarker
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const lineEditorProbe = vi.hoisted(() => vi.fn())
|
||||
const processReadinessProbe = vi.hoisted(() => vi.fn())
|
||||
const resolveExecutablePath = vi.hoisted(() => vi.fn((value: string) => Promise.resolve(value)))
|
||||
vi.mock('../shared/pty-slave-line-discipline-echo', () => ({
|
||||
createPtySlaveLineEditorProbe: () => lineEditorProbe
|
||||
}))
|
||||
vi.mock('../shared/shell-process-readiness', () => ({
|
||||
readShellProcessReadiness: processReadinessProbe,
|
||||
resolveShellExecutablePath: resolveExecutablePath
|
||||
}))
|
||||
|
||||
import { createShellPromptReadinessProbe } from './shell-prompt-readiness-probe'
|
||||
|
||||
describe('shell prompt readiness probe', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
lineEditorProbe.mockReset()
|
||||
processReadinessProbe.mockReset()
|
||||
resolveExecutablePath.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('accepts only the identified shell pid in line-editor mode and foreground', async () => {
|
||||
lineEditorProbe.mockResolvedValue('line-editor')
|
||||
processReadinessProbe.mockResolvedValue({ executablePath: '/bin/zsh', foreground: true })
|
||||
const onPromptReady = vi.fn()
|
||||
const probe = createShellPromptReadinessProbe({
|
||||
slavePath: '/dev/ttys048',
|
||||
shellPath: '/bin/zsh',
|
||||
getShellPid: () => 42,
|
||||
onPromptReady,
|
||||
settleMs: 10
|
||||
})
|
||||
|
||||
probe?.notifyOutput('\x1b[?2004h')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
expect(onPromptReady).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('preserves an unset child PATH for executable resolution', async () => {
|
||||
lineEditorProbe.mockResolvedValue('line-editor')
|
||||
processReadinessProbe.mockResolvedValue({ executablePath: '/bin/zsh', foreground: true })
|
||||
const probe = createShellPromptReadinessProbe({
|
||||
slavePath: '/dev/ttys048',
|
||||
shellPath: '/bin/zsh',
|
||||
getShellPid: () => 42,
|
||||
onPromptReady: vi.fn(),
|
||||
settleMs: 10
|
||||
})
|
||||
|
||||
probe?.notifyOutput('\x1b[?2004h')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
expect(resolveExecutablePath).toHaveBeenCalledWith('/bin/zsh', process.cwd(), undefined)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['silent read', 'other', { executablePath: '/bin/zsh', foreground: true }],
|
||||
['background shell', 'line-editor', { executablePath: '/bin/zsh', foreground: false }],
|
||||
['different foreground process', 'line-editor', null],
|
||||
[
|
||||
'non-shell replacement image',
|
||||
'line-editor',
|
||||
{ executablePath: '/usr/bin/sqlite3', foreground: true }
|
||||
],
|
||||
[
|
||||
'non-shell image with the shell basename',
|
||||
'line-editor',
|
||||
{ executablePath: '/tmp/zsh', foreground: true }
|
||||
]
|
||||
])('rejects %s', async (_name, terminalState, rows) => {
|
||||
lineEditorProbe.mockResolvedValue(terminalState)
|
||||
processReadinessProbe.mockResolvedValue(rows)
|
||||
const onPromptReady = vi.fn()
|
||||
const probe = createShellPromptReadinessProbe({
|
||||
slavePath: '/dev/ttys048',
|
||||
shellPath: '/bin/zsh',
|
||||
getShellPid: () => 42,
|
||||
onPromptReady,
|
||||
settleMs: 10
|
||||
})
|
||||
|
||||
probe?.notifyOutput('\x1b[?2004h')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
expect(onPromptReady).not.toHaveBeenCalled()
|
||||
if (terminalState === 'other') {
|
||||
expect(processReadinessProbe).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('does no external work when the ready marker cancels the settle window', async () => {
|
||||
const probe = createShellPromptReadinessProbe({
|
||||
slavePath: '/dev/ttys048',
|
||||
shellPath: '/bin/zsh',
|
||||
getShellPid: () => 42,
|
||||
onPromptReady: vi.fn(),
|
||||
settleMs: 10
|
||||
})
|
||||
|
||||
probe?.notifyOutput('\x1b[?2004h')
|
||||
probe?.dispose()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
expect(lineEditorProbe).not.toHaveBeenCalled()
|
||||
expect(processReadinessProbe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invalidates an in-flight result when newer output arrives', async () => {
|
||||
const pending: { resolve?: (value: string) => void } = {}
|
||||
lineEditorProbe.mockImplementation(
|
||||
() => new Promise((resolve) => (pending.resolve = resolve as (value: string) => void))
|
||||
)
|
||||
processReadinessProbe.mockResolvedValue({ executablePath: '/bin/zsh', foreground: true })
|
||||
const onPromptReady = vi.fn()
|
||||
const probe = createShellPromptReadinessProbe({
|
||||
slavePath: '/dev/ttys048',
|
||||
shellPath: '/bin/zsh',
|
||||
getShellPid: () => 42,
|
||||
onPromptReady,
|
||||
settleMs: 10
|
||||
})
|
||||
|
||||
probe?.notifyOutput('\x1b[?2004h')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
probe?.notifyOutput('\x1b[?2004h')
|
||||
pending.resolve?.('line-editor')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(onPromptReady).not.toHaveBeenCalled()
|
||||
expect(processReadinessProbe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not inspect a process after disposal during a line-editor probe', async () => {
|
||||
const pending: { resolve?: (value: string) => void } = {}
|
||||
lineEditorProbe.mockImplementation(
|
||||
() => new Promise((resolve) => (pending.resolve = resolve as (value: string) => void))
|
||||
)
|
||||
const probe = createShellPromptReadinessProbe({
|
||||
slavePath: '/dev/ttys048',
|
||||
shellPath: '/bin/zsh',
|
||||
getShellPid: () => 42,
|
||||
onPromptReady: vi.fn(),
|
||||
settleMs: 10
|
||||
})
|
||||
|
||||
probe?.notifyOutput('\x1b[?2004h')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
probe?.dispose()
|
||||
pending.resolve?.('line-editor')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(processReadinessProbe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invalidates process readiness that resolves after disposal', async () => {
|
||||
const pending: { resolve?: (value: { executablePath: string; foreground: boolean }) => void } =
|
||||
{}
|
||||
lineEditorProbe.mockResolvedValue('line-editor')
|
||||
processReadinessProbe.mockImplementation(
|
||||
() => new Promise((resolve) => (pending.resolve = resolve))
|
||||
)
|
||||
const onPromptReady = vi.fn()
|
||||
const probe = createShellPromptReadinessProbe({
|
||||
slavePath: '/dev/ttys048',
|
||||
shellPath: '/bin/zsh',
|
||||
getShellPid: () => 42,
|
||||
onPromptReady,
|
||||
settleMs: 10
|
||||
})
|
||||
|
||||
probe?.notifyOutput('\x1b[?2004h')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
probe?.dispose()
|
||||
pending.resolve?.({ executablePath: '/bin/zsh', foreground: true })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(onPromptReady).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores slow startup output until the line editor enables its protocol', async () => {
|
||||
const probe = createShellPromptReadinessProbe({
|
||||
slavePath: '/dev/ttys048',
|
||||
shellPath: '/bin/zsh',
|
||||
getShellPid: () => 42,
|
||||
onPromptReady: vi.fn(),
|
||||
settleMs: 10
|
||||
})
|
||||
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
probe?.notifyOutput(`startup ${index}\n`)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
}
|
||||
|
||||
expect(lineEditorProbe).not.toHaveBeenCalled()
|
||||
expect(processReadinessProbe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bounds rejected line-editor retries', async () => {
|
||||
lineEditorProbe.mockResolvedValue('line-editor')
|
||||
processReadinessProbe.mockResolvedValue({
|
||||
executablePath: '/usr/bin/sqlite3',
|
||||
foreground: true
|
||||
})
|
||||
const probe = createShellPromptReadinessProbe({
|
||||
slavePath: '/dev/ttys048',
|
||||
shellPath: '/bin/zsh',
|
||||
getShellPid: () => 42,
|
||||
onPromptReady: vi.fn(),
|
||||
settleMs: 10
|
||||
})
|
||||
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
probe?.notifyOutput('\x1b[?2004h')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
}
|
||||
|
||||
expect(lineEditorProbe).toHaveBeenCalledTimes(4)
|
||||
expect(processReadinessProbe).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { createPtySlaveLineEditorProbe } from '../shared/pty-slave-line-discipline-echo'
|
||||
import {
|
||||
readShellProcessReadiness,
|
||||
resolveShellExecutablePath
|
||||
} from '../shared/shell-process-readiness'
|
||||
import {
|
||||
createLineEditorReadyOutputScanState,
|
||||
scanForLineEditorReadyOutput
|
||||
} from './line-editor-ready-output-scanner'
|
||||
import { basename } from 'node:path'
|
||||
|
||||
export const SHELL_PROMPT_PROBE_SETTLE_MS = 50
|
||||
export const MAX_SHELL_PROMPT_PROBES = 4
|
||||
|
||||
export type ShellPromptReadinessProbe = {
|
||||
notifyOutput(data: string): void
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export function createShellPromptReadinessProbe(options: {
|
||||
slavePath: string | undefined
|
||||
getShellPid: () => number | null
|
||||
shellPath: string | undefined
|
||||
shellCwd?: string
|
||||
shellPathEnv?: string
|
||||
onPromptReady: () => void
|
||||
settleMs?: number
|
||||
}): ShellPromptReadinessProbe | null {
|
||||
const lineEditorProbe = createPtySlaveLineEditorProbe(options.slavePath)
|
||||
if (!lineEditorProbe) {
|
||||
return null
|
||||
}
|
||||
const settleMs = options.settleMs ?? SHELL_PROMPT_PROBE_SETTLE_MS
|
||||
const expectedShellName = options.shellPath ? basename(options.shellPath).toLowerCase() : null
|
||||
const outputScanState = createLineEditorReadyOutputScanState()
|
||||
let disposed = false
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let generation = 0
|
||||
let probesStarted = 0
|
||||
|
||||
const probe = async (scheduledGeneration: number): Promise<void> => {
|
||||
if (disposed || scheduledGeneration !== generation) {
|
||||
return
|
||||
}
|
||||
const shellPid = options.getShellPid()
|
||||
if (!shellPid || (await lineEditorProbe()) !== 'line-editor') {
|
||||
return
|
||||
}
|
||||
if (disposed || scheduledGeneration !== generation) {
|
||||
return
|
||||
}
|
||||
const [shell, expectedPath] = await Promise.all([
|
||||
readShellProcessReadiness(shellPid),
|
||||
options.shellPath
|
||||
? resolveShellExecutablePath(
|
||||
options.shellPath,
|
||||
options.shellCwd ?? process.cwd(),
|
||||
options.shellPathEnv
|
||||
)
|
||||
: Promise.resolve(null)
|
||||
])
|
||||
if (disposed || scheduledGeneration !== generation) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!shell?.foreground ||
|
||||
!expectedShellName ||
|
||||
!expectedPath ||
|
||||
basename(shell.executablePath).toLowerCase() !== expectedShellName ||
|
||||
shell.executablePath !== expectedPath
|
||||
) {
|
||||
return
|
||||
}
|
||||
disposed = true
|
||||
options.onPromptReady()
|
||||
}
|
||||
|
||||
return {
|
||||
notifyOutput(data: string): void {
|
||||
if (
|
||||
disposed ||
|
||||
probesStarted >= MAX_SHELL_PROMPT_PROBES ||
|
||||
!scanForLineEditorReadyOutput(outputScanState, data)
|
||||
) {
|
||||
return
|
||||
}
|
||||
generation += 1
|
||||
const scheduledGeneration = generation
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
probesStarted += 1
|
||||
void probe(scheduledGeneration).catch(() => {})
|
||||
}, settleMs)
|
||||
},
|
||||
dispose(): void {
|
||||
disposed = true
|
||||
generation += 1
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createShellStartupIdentityScanState,
|
||||
drainShellStartupIdentityHeldBytes,
|
||||
scanForShellStartupIdentity
|
||||
} from './shell-startup-identity-scanner'
|
||||
|
||||
describe('shell startup identity scanner', () => {
|
||||
it('strips a split identity marker and returns its shell pid', () => {
|
||||
const state = createShellStartupIdentityScanState()
|
||||
expect(scanForShellStartupIdentity(state, 'before\x1b]777;orca-shell-st')).toEqual({
|
||||
output: 'before',
|
||||
shellPid: null
|
||||
})
|
||||
expect(scanForShellStartupIdentity(state, 'art:12345\x07after')).toEqual({
|
||||
output: 'after',
|
||||
shellPid: 12345
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards lookalikes unchanged', () => {
|
||||
const state = createShellStartupIdentityScanState()
|
||||
const input = 'a\x1b]777;orca-shell-start:nope\x07b'
|
||||
expect(scanForShellStartupIdentity(state, input)).toEqual({ output: input, shellPid: null })
|
||||
})
|
||||
|
||||
it('forwards an unrelated OSC ending in digits', () => {
|
||||
const state = createShellStartupIdentityScanState()
|
||||
const input = '\x1b]1337;remote-session:12345'
|
||||
expect(scanForShellStartupIdentity(state, input)).toEqual({ output: input, shellPid: null })
|
||||
expect(state.heldBytes).toBe('')
|
||||
})
|
||||
|
||||
it('releases an incomplete marker on teardown', () => {
|
||||
const state = createShellStartupIdentityScanState()
|
||||
scanForShellStartupIdentity(state, '\x1b]777;orca-shell-start:12')
|
||||
expect(drainShellStartupIdentityHeldBytes(state)).toBe('\x1b]777;orca-shell-start:12')
|
||||
})
|
||||
|
||||
it('does not retain an unbounded digit stream', () => {
|
||||
const state = createShellStartupIdentityScanState()
|
||||
const input = `\x1b]777;orca-shell-start:${'1'.repeat(100)}`
|
||||
expect(scanForShellStartupIdentity(state, input).output).toBe(input)
|
||||
expect(state.heldBytes).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
export const SHELL_STARTUP_IDENTITY_PREFIX = '\x1b]777;orca-shell-start:'
|
||||
const POSSIBLE_PID_SUFFIX = /^\d{0,20}$/
|
||||
|
||||
export type ShellStartupIdentityScanState = {
|
||||
heldBytes: string
|
||||
}
|
||||
|
||||
export type ShellStartupIdentityScanResult = {
|
||||
output: string
|
||||
shellPid: number | null
|
||||
}
|
||||
|
||||
export function createShellStartupIdentityScanState(): ShellStartupIdentityScanState {
|
||||
return { heldBytes: '' }
|
||||
}
|
||||
|
||||
export function drainShellStartupIdentityHeldBytes(state: ShellStartupIdentityScanState): string {
|
||||
const heldBytes = state.heldBytes
|
||||
state.heldBytes = ''
|
||||
return heldBytes
|
||||
}
|
||||
|
||||
function isPossibleMarker(candidate: string): boolean {
|
||||
if (candidate.length <= SHELL_STARTUP_IDENTITY_PREFIX.length) {
|
||||
return SHELL_STARTUP_IDENTITY_PREFIX.startsWith(candidate)
|
||||
}
|
||||
if (!candidate.startsWith(SHELL_STARTUP_IDENTITY_PREFIX)) {
|
||||
return false
|
||||
}
|
||||
const suffix = candidate.slice(SHELL_STARTUP_IDENTITY_PREFIX.length)
|
||||
return POSSIBLE_PID_SUFFIX.test(suffix)
|
||||
}
|
||||
|
||||
export function scanForShellStartupIdentity(
|
||||
state: ShellStartupIdentityScanState,
|
||||
data: string
|
||||
): ShellStartupIdentityScanResult {
|
||||
let pending = state.heldBytes + data
|
||||
let output = ''
|
||||
state.heldBytes = ''
|
||||
|
||||
while (pending.length > 0) {
|
||||
const start = pending.indexOf(SHELL_STARTUP_IDENTITY_PREFIX[0] as string)
|
||||
if (start === -1) {
|
||||
output += pending
|
||||
break
|
||||
}
|
||||
output += pending.slice(0, start)
|
||||
const candidate = pending.slice(start)
|
||||
if (isPossibleMarker(candidate)) {
|
||||
state.heldBytes = candidate
|
||||
break
|
||||
}
|
||||
if (candidate.startsWith(SHELL_STARTUP_IDENTITY_PREFIX)) {
|
||||
const suffix = candidate.slice(SHELL_STARTUP_IDENTITY_PREFIX.length)
|
||||
const terminator = suffix.indexOf('\x07')
|
||||
const pidText = terminator === -1 ? '' : suffix.slice(0, terminator)
|
||||
if (/^\d+$/.test(pidText)) {
|
||||
const shellPid = Number(pidText)
|
||||
const markerLength = SHELL_STARTUP_IDENTITY_PREFIX.length + terminator + 1
|
||||
return {
|
||||
output: output + candidate.slice(markerLength),
|
||||
shellPid: Number.isSafeInteger(shellPid) && shellPid > 0 ? shellPid : null
|
||||
}
|
||||
}
|
||||
}
|
||||
output += candidate[0]
|
||||
pending = candidate.slice(1)
|
||||
}
|
||||
|
||||
return { output, shellPid: null }
|
||||
}
|
||||
@@ -5,11 +5,17 @@ function quotePosixSingle(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
export const SHELL_STARTUP_IDENTITY_MARKER_BLOCK = `if [[ "\${ORCA_SHELL_STARTUP_IDENTITY:-0}" == "1" ]]; then
|
||||
unset ORCA_SHELL_STARTUP_IDENTITY
|
||||
printf "\\033]777;orca-shell-start:%s\\007" "$$"
|
||||
fi`
|
||||
|
||||
export function getZshEnvTemplate(zshDir: string, headerPrefix = ''): string {
|
||||
const header = headerPrefix
|
||||
? `Orca ${headerPrefix} zsh shell-ready wrapper`
|
||||
: 'Orca zsh shell-ready wrapper'
|
||||
return `# ${header}
|
||||
${SHELL_STARTUP_IDENTITY_MARKER_BLOCK}
|
||||
# Why: capture the runtime wrapper dir before it is unset below. On WSL this
|
||||
# file is generated with a Windows path but sourced via /mnt/c, so the baked
|
||||
# literal is unusable there and ZDOTDIR must be restored from this value.
|
||||
|
||||
@@ -12,8 +12,9 @@ import {
|
||||
} from '../shared/setup-agent-sequencing'
|
||||
import { PTY_STARTUP_INGRESS_VERSION } from '../shared/pty-startup-ingress'
|
||||
|
||||
const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({
|
||||
const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({
|
||||
mockPtySpawn: vi.fn(),
|
||||
mockCreateShellPromptReadinessProbe: vi.fn(),
|
||||
mockPtyInstance: {
|
||||
// Why: attach now proves the backing pid is alive before replaying, so the
|
||||
// default managed PTY must report a live pid. Reuse the test runner's own
|
||||
@@ -38,6 +39,10 @@ vi.mock('../main/pty/posix-pty-process-groups', () => ({
|
||||
forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback())
|
||||
}))
|
||||
|
||||
vi.mock('../main/shell-prompt-readiness-probe', () => ({
|
||||
createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe
|
||||
}))
|
||||
|
||||
import {
|
||||
IMMEDIATE_PTY_EXIT_TIMEOUT_MS,
|
||||
MAX_RELAY_PTY_SESSIONS,
|
||||
@@ -139,6 +144,11 @@ describe('PtyHandler', () => {
|
||||
mockPtyInstance.clear.mockReset()
|
||||
mockPtyInstance.pause.mockReset()
|
||||
mockPtyInstance.resume.mockReset()
|
||||
mockCreateShellPromptReadinessProbe.mockReset()
|
||||
mockCreateShellPromptReadinessProbe.mockReturnValue({
|
||||
notifyOutput: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
})
|
||||
vi.spyOn(ptyShellUtils, 'processHasChildren').mockResolvedValue(false)
|
||||
|
||||
mockPtySpawn.mockReturnValue({ ...mockPtyInstance })
|
||||
@@ -829,6 +839,7 @@ describe('PtyHandler', () => {
|
||||
vi.advanceTimersByTime(49)
|
||||
const term = mockPtySpawn.mock.results[0]?.value
|
||||
expect(handler.retainedStartupCommandCount).toBe(1)
|
||||
expect(handler.retainedStartupCommandBytes).toBe('echo provider-owned'.length)
|
||||
expect(term.write).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
@@ -870,6 +881,9 @@ describe('PtyHandler', () => {
|
||||
| { env?: Record<string, string> }
|
||||
| undefined
|
||||
expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1')
|
||||
expect(handler.retainedStartupCommandCount).toBe(1)
|
||||
expect(handler.retainedStartupCommandBytes).toBe(0)
|
||||
vi.advanceTimersByTime(15_000)
|
||||
expect(handler.retainedStartupCommandCount).toBe(0)
|
||||
}
|
||||
)
|
||||
@@ -906,7 +920,7 @@ describe('PtyHandler', () => {
|
||||
| { env?: Record<string, string> }
|
||||
| undefined
|
||||
expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1')
|
||||
expect(handler.retainedStartupCommandCount).toBe(0)
|
||||
expect(handler.retainedStartupCommandCount).toBe(1)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1043,6 +1057,224 @@ describe('PtyHandler', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'recovers provider delivery when startup exec replaces the relay wrapper',
|
||||
async () => {
|
||||
let dataCallback: ((data: string) => void) | undefined
|
||||
const term = {
|
||||
...mockPtyInstance,
|
||||
onData: vi.fn((cb: (data: string) => void) => {
|
||||
dataCallback = cb
|
||||
}),
|
||||
onExit: vi.fn()
|
||||
}
|
||||
mockPtySpawn.mockReturnValue(term)
|
||||
const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-exec-spawn-'))
|
||||
const oldShell = process.env.SHELL
|
||||
process.env.SHELL = '/bin/bash'
|
||||
try {
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
env: { HOME: homeDir },
|
||||
command: 'echo after-exec',
|
||||
commandDelivery: 'provider',
|
||||
startupCommandDelivery: 'shell-ready'
|
||||
})
|
||||
} finally {
|
||||
if (oldShell === undefined) {
|
||||
delete process.env.SHELL
|
||||
} else {
|
||||
process.env.SHELL = oldShell
|
||||
}
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
dataCallback?.(`\x1b]777;orca-shell-start:${process.pid}\x07\x1b[?2004hremote $ `)
|
||||
await vi.advanceTimersByTimeAsync(8)
|
||||
|
||||
const promptOptions = mockCreateShellPromptReadinessProbe.mock.calls[0]?.[0] as {
|
||||
onPromptReady: () => void
|
||||
}
|
||||
expect(
|
||||
mockCreateShellPromptReadinessProbe.mock.results[0]?.value.notifyOutput
|
||||
).toHaveBeenCalledWith('\x1b[?2004hremote $ ')
|
||||
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
|
||||
id: 'pty-1',
|
||||
data: '\x1b[?2004hremote $ '
|
||||
})
|
||||
promptOptions.onPromptReady()
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
expect(term.write).toHaveBeenCalledWith('echo after-exec\n')
|
||||
expect(handler.retainedStartupCommandCount).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'signals renderer delivery when startup exec replaces the relay wrapper',
|
||||
async () => {
|
||||
let dataCallback: ((data: string) => void) | undefined
|
||||
const term = {
|
||||
...mockPtyInstance,
|
||||
onData: vi.fn((cb: (data: string) => void) => {
|
||||
dataCallback = cb
|
||||
}),
|
||||
onExit: vi.fn()
|
||||
}
|
||||
mockPtySpawn.mockReturnValue(term)
|
||||
const homeDir = mkdtempSync(join(tmpdir(), 'relay-renderer-exec-spawn-'))
|
||||
const oldShell = process.env.SHELL
|
||||
process.env.SHELL = '/bin/bash'
|
||||
try {
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
env: { HOME: homeDir },
|
||||
command: 'echo after-exec',
|
||||
startupCommandDelivery: 'shell-ready'
|
||||
})
|
||||
} finally {
|
||||
if (oldShell === undefined) {
|
||||
delete process.env.SHELL
|
||||
} else {
|
||||
process.env.SHELL = oldShell
|
||||
}
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
dataCallback?.(`\x1b]777;orca-shell-start:${process.pid}\x07\x1b[?2004hremote $ `)
|
||||
await vi.advanceTimersByTimeAsync(8)
|
||||
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
|
||||
id: 'pty-1',
|
||||
data: '\x1b[?2004hremote $ '
|
||||
})
|
||||
|
||||
const promptOptions = mockCreateShellPromptReadinessProbe.mock.calls[0]?.[0] as {
|
||||
onPromptReady: () => void
|
||||
}
|
||||
promptOptions.onPromptReady()
|
||||
await vi.advanceTimersByTimeAsync(8)
|
||||
|
||||
expect(term.write).not.toHaveBeenCalled()
|
||||
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
|
||||
id: 'pty-1',
|
||||
data: '\x1b]777;orca-shell-ready\x07'
|
||||
})
|
||||
expect(handler.retainedStartupCommandCount).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'forwards the supported ready marker to renderer delivery',
|
||||
async () => {
|
||||
let dataCallback: ((data: string) => void) | undefined
|
||||
const term = {
|
||||
...mockPtyInstance,
|
||||
onData: vi.fn((cb: (data: string) => void) => {
|
||||
dataCallback = cb
|
||||
}),
|
||||
onExit: vi.fn()
|
||||
}
|
||||
mockPtySpawn.mockReturnValue(term)
|
||||
const homeDir = mkdtempSync(join(tmpdir(), 'relay-renderer-ready-spawn-'))
|
||||
const oldShell = process.env.SHELL
|
||||
process.env.SHELL = '/bin/bash'
|
||||
try {
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
env: { HOME: homeDir },
|
||||
command: 'echo after-ready',
|
||||
startupCommandDelivery: 'shell-ready'
|
||||
})
|
||||
} finally {
|
||||
if (oldShell === undefined) {
|
||||
delete process.env.SHELL
|
||||
} else {
|
||||
process.env.SHELL = oldShell
|
||||
}
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
dataCallback?.(
|
||||
`\x1b]777;orca-shell-start:${process.pid}\x07\x1b]777;orca-shell-ready\x07remote $ `
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(8)
|
||||
|
||||
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
|
||||
id: 'pty-1',
|
||||
data: '\x1b]777;orca-shell-ready\x07remote $ '
|
||||
})
|
||||
expect(handler.retainedStartupCommandCount).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'releases split renderer readiness through one completed path',
|
||||
async () => {
|
||||
let dataCallback: ((data: string) => void) | undefined
|
||||
const term = {
|
||||
...mockPtyInstance,
|
||||
onData: vi.fn((cb: (data: string) => void) => {
|
||||
dataCallback = cb
|
||||
}),
|
||||
onExit: vi.fn()
|
||||
}
|
||||
mockPtySpawn.mockReturnValue(term)
|
||||
const homeDir = mkdtempSync(join(tmpdir(), 'relay-renderer-split-ready-spawn-'))
|
||||
const oldShell = process.env.SHELL
|
||||
process.env.SHELL = '/bin/bash'
|
||||
try {
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
env: { HOME: homeDir },
|
||||
command: 'echo after-ready',
|
||||
startupCommandDelivery: 'shell-ready'
|
||||
})
|
||||
} finally {
|
||||
if (oldShell === undefined) {
|
||||
delete process.env.SHELL
|
||||
} else {
|
||||
process.env.SHELL = oldShell
|
||||
}
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
dataCallback?.(`\x1b]777;orca-shell-start:${process.pid}\x07\x1b]777;orca-shell-ready`)
|
||||
dataCallback?.('\x07remote $ ')
|
||||
await vi.advanceTimersByTimeAsync(8)
|
||||
|
||||
const probe = mockCreateShellPromptReadinessProbe.mock.results[0]?.value
|
||||
expect(probe.notifyOutput).not.toHaveBeenCalled()
|
||||
expect(probe.dispose).toHaveBeenCalledOnce()
|
||||
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
|
||||
id: 'pty-1',
|
||||
data: '\x1b]777;orca-shell-ready\x07remote $ '
|
||||
})
|
||||
expect(handler.retainedStartupCommandCount).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'does not retain renderer readiness state for unsupported shells',
|
||||
async () => {
|
||||
const oldShell = process.env.SHELL
|
||||
process.env.SHELL = '/bin/sh'
|
||||
try {
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
command: 'x'.repeat(256 * 1024),
|
||||
startupCommandDelivery: 'shell-ready'
|
||||
})
|
||||
} finally {
|
||||
if (oldShell === undefined) {
|
||||
delete process.env.SHELL
|
||||
} else {
|
||||
process.env.SHELL = oldShell
|
||||
}
|
||||
}
|
||||
|
||||
const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as
|
||||
| { env?: Record<string, string> }
|
||||
| undefined
|
||||
expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('0')
|
||||
expect(handler.retainedStartupCommandCount).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'flushes held shell-ready marker bytes when provider delivery falls back',
|
||||
async () => {
|
||||
@@ -1142,6 +1374,50 @@ describe('PtyHandler', () => {
|
||||
).rejects.toThrow('PTY "pty-1" not found')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'releases renderer readiness state when attach reaps a dead shell',
|
||||
async () => {
|
||||
const oldShell = process.env.SHELL
|
||||
const oldHome = process.env.HOME
|
||||
const homeDir = mkdtempSync(join(tmpdir(), 'relay-dead-shell-ready-spawn-'))
|
||||
process.env.SHELL = '/bin/bash'
|
||||
process.env.HOME = homeDir
|
||||
try {
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
env: { HOME: homeDir },
|
||||
command: 'x'.repeat(256 * 1024),
|
||||
startupCommandDelivery: 'shell-ready'
|
||||
})
|
||||
} finally {
|
||||
if (oldShell === undefined) {
|
||||
delete process.env.SHELL
|
||||
} else {
|
||||
process.env.SHELL = oldShell
|
||||
}
|
||||
if (oldHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = oldHome
|
||||
}
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
}
|
||||
expect(handler.retainedStartupCommandCount).toBe(1)
|
||||
|
||||
const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false)
|
||||
try {
|
||||
await expect(dispatcher.callRequest('pty.attach', { id: 'pty-1' })).rejects.toThrow(
|
||||
'PTY "pty-1" not found'
|
||||
)
|
||||
} finally {
|
||||
aliveSpy.mockRestore()
|
||||
}
|
||||
|
||||
expect(handler.retainedStartupCommandCount).toBe(0)
|
||||
vi.advanceTimersByTime(15_000)
|
||||
expect(handler.retainedStartupCommandCount).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it('settles concurrent immediate shutdown when attach proves the shell exited', async () => {
|
||||
const mockKill = vi.fn()
|
||||
mockPtySpawn.mockReturnValue({
|
||||
@@ -2734,9 +3010,16 @@ describe('PtyHandler', () => {
|
||||
ORCA_AGENT_HOOK_TOKEN: 'abc-uuid'
|
||||
}))
|
||||
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
|
||||
const oldStartupIdentity = process.env.ORCA_SHELL_STARTUP_IDENTITY
|
||||
process.env.ORCA_SHELL_STARTUP_IDENTITY = '1'
|
||||
try {
|
||||
await dispatcher.callRequest('pty.revive', { state })
|
||||
} finally {
|
||||
if (oldStartupIdentity === undefined) {
|
||||
delete process.env.ORCA_SHELL_STARTUP_IDENTITY
|
||||
} else {
|
||||
process.env.ORCA_SHELL_STARTUP_IDENTITY = oldStartupIdentity
|
||||
}
|
||||
killSpy.mockRestore()
|
||||
}
|
||||
|
||||
@@ -2749,6 +3032,8 @@ describe('PtyHandler', () => {
|
||||
expect(callArgs.env.ORCA_AGENT_HOOK_TOKEN).toBe('abc-uuid')
|
||||
expect(callArgs.env.TERM).toBe('xterm-256color')
|
||||
expect(callArgs.env.TERM_PROGRAM).toBe('Orca')
|
||||
expect(callArgs.env.ORCA_SHELL_READY_MARKER).toBe('0')
|
||||
expect(callArgs.env.ORCA_SHELL_STARTUP_IDENTITY).toBe('0')
|
||||
})
|
||||
|
||||
it('fences both revived worktree identity and cwd with rollback', async () => {
|
||||
|
||||
+141
-24
@@ -31,8 +31,19 @@ import {
|
||||
createShellReadyScanState,
|
||||
drainShellReadyHeldBytes,
|
||||
scanForShellReady,
|
||||
SHELL_READY_MARKER_PREFIX,
|
||||
type ShellReadyScanState
|
||||
} from '../main/shell-ready-marker-scanner'
|
||||
import {
|
||||
createShellStartupIdentityScanState,
|
||||
drainShellStartupIdentityHeldBytes,
|
||||
scanForShellStartupIdentity,
|
||||
type ShellStartupIdentityScanState
|
||||
} from '../main/shell-startup-identity-scanner'
|
||||
import {
|
||||
createShellPromptReadinessProbe,
|
||||
type ShellPromptReadinessProbe
|
||||
} from '../main/shell-prompt-readiness-probe'
|
||||
import { applyTerminalGitCredentialPromptGuard } from '../shared/terminal-git-credential-guard'
|
||||
import {
|
||||
gitCredentialPromptGuardEnv,
|
||||
@@ -147,6 +158,9 @@ type ManagedPty = {
|
||||
worktreeId?: string
|
||||
terminalHandle?: string
|
||||
explicitTerm?: string
|
||||
shellPath?: string
|
||||
shellCwd?: string
|
||||
shellPathEnv?: string
|
||||
envToDelete: string[]
|
||||
gitCredentialPromptGuarded: boolean
|
||||
startupCommand?: ManagedStartupCommand
|
||||
@@ -178,10 +192,14 @@ type PendingPtyOutput = RelayPtySourceOutput & {
|
||||
}
|
||||
|
||||
type ManagedStartupCommand = {
|
||||
command: string
|
||||
command: string | null
|
||||
providerDelivery: boolean
|
||||
delivered: boolean
|
||||
waitForShellReady: boolean
|
||||
scanState: ShellReadyScanState | null
|
||||
identityScanState: ShellStartupIdentityScanState | null
|
||||
shellPid: number | null
|
||||
promptProbe: ShellPromptReadinessProbe | null
|
||||
timer: ReturnType<typeof setTimeout> | null
|
||||
}
|
||||
|
||||
@@ -245,6 +263,7 @@ const INTERACTIVE_REDRAW_MAX_CHARS = PTY_OUTPUT_FLUSH_CHUNK_CHARS
|
||||
const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024
|
||||
const STARTUP_COMMAND_WRITE_DELAY_MS = 50
|
||||
const STARTUP_COMMAND_SHELL_READY_FALLBACK_MS = 1500
|
||||
const RENDERER_SHELL_READY_RETENTION_MS = 15_000
|
||||
const PTY_FORCE_KILL_RETRY_DELAY_MS = 250
|
||||
const PTY_FORCE_KILL_MAX_ATTEMPTS = 2
|
||||
const ALLOWED_SIGNALS = new Set([
|
||||
@@ -634,10 +653,26 @@ export class PtyHandler {
|
||||
|
||||
private releaseStartupCommand(managed: ManagedPty): void {
|
||||
this.clearStartupCommandTimer(managed)
|
||||
managed.startupCommand?.promptProbe?.dispose()
|
||||
managed.startupCommand = undefined
|
||||
}
|
||||
|
||||
private scheduleStartupCommandDelivery(managed: ManagedPty, delayMs: number): void {
|
||||
private drainStartupScanBytes(startup: ManagedStartupCommand): string {
|
||||
let heldBytes = startup.identityScanState
|
||||
? drainShellStartupIdentityHeldBytes(startup.identityScanState)
|
||||
: ''
|
||||
startup.identityScanState = null
|
||||
if (startup.scanState && heldBytes) {
|
||||
heldBytes = scanForShellReady(startup.scanState, heldBytes).output
|
||||
}
|
||||
if (startup.scanState) {
|
||||
heldBytes += drainShellReadyHeldBytes(startup.scanState)
|
||||
startup.scanState = null
|
||||
}
|
||||
return heldBytes
|
||||
}
|
||||
|
||||
private scheduleStartupCommandResolution(managed: ManagedPty, delayMs: number): void {
|
||||
const startup = managed.startupCommand
|
||||
if (!startup || startup.delivered || managed.disposed) {
|
||||
return
|
||||
@@ -645,22 +680,25 @@ export class PtyHandler {
|
||||
this.clearStartupCommandTimer(managed)
|
||||
startup.timer = setTimeout(() => {
|
||||
startup.timer = null
|
||||
this.deliverStartupCommand(managed)
|
||||
if (startup.providerDelivery) {
|
||||
this.deliverStartupCommand(managed)
|
||||
} else {
|
||||
this.signalRendererShellReady(managed)
|
||||
}
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
private deliverStartupCommand(managed: ManagedPty): void {
|
||||
const startup = managed.startupCommand
|
||||
if (!startup || startup.delivered || managed.disposed) {
|
||||
if (!startup?.providerDelivery || !startup.command || startup.delivered || managed.disposed) {
|
||||
return
|
||||
}
|
||||
startup.delivered = true
|
||||
this.clearStartupCommandTimer(managed)
|
||||
if (startup.scanState) {
|
||||
const heldBytes = drainShellReadyHeldBytes(startup.scanState)
|
||||
if (heldBytes) {
|
||||
managed.startupIngress?.accept(heldBytes)
|
||||
}
|
||||
startup.promptProbe?.dispose()
|
||||
const heldBytes = this.drainStartupScanBytes(startup)
|
||||
if (heldBytes) {
|
||||
managed.startupIngress?.accept(heldBytes)
|
||||
}
|
||||
const submit = process.platform === 'win32' ? '\r' : '\n'
|
||||
// Why: only the shell-ready wrapper arms bracketed-paste; other shells use raw submit so ESC[200~ markers aren't echoed.
|
||||
@@ -672,6 +710,19 @@ export class PtyHandler {
|
||||
managed.pty.write(payload)
|
||||
}
|
||||
|
||||
private signalRendererShellReady(managed: ManagedPty): void {
|
||||
const startup = managed.startupCommand
|
||||
if (!startup || startup.providerDelivery || startup.delivered || managed.disposed) {
|
||||
return
|
||||
}
|
||||
startup.delivered = true
|
||||
this.clearStartupCommandTimer(managed)
|
||||
startup.promptProbe?.dispose()
|
||||
managed.startupIngress?.accept(this.drainStartupScanBytes(startup))
|
||||
managed.startupIngress?.accept(`${SHELL_READY_MARKER_PREFIX}\x07`)
|
||||
managed.startupCommand = undefined
|
||||
}
|
||||
|
||||
/** Wire onData/onExit listeners for a managed PTY and store it. */
|
||||
private wireAndStore(managed: ManagedPty): void {
|
||||
managed.physicalExit = new PhysicalExitTracker()
|
||||
@@ -697,16 +748,48 @@ export class PtyHandler {
|
||||
onEmission: emitIngressData,
|
||||
...(echoProbe ? { echoProbe } : {})
|
||||
})
|
||||
const startup = managed.startupCommand
|
||||
if (startup?.waitForShellReady) {
|
||||
startup.promptProbe = createShellPromptReadinessProbe({
|
||||
slavePath: readPtySlavePath(managed.pty),
|
||||
shellPath: managed.shellPath,
|
||||
shellCwd: managed.shellCwd,
|
||||
shellPathEnv: managed.shellPathEnv,
|
||||
getShellPid: () => startup.shellPid,
|
||||
onPromptReady: () => {
|
||||
if (startup.providerDelivery) {
|
||||
this.scheduleStartupCommandResolution(managed, STARTUP_COMMAND_WRITE_DELAY_MS)
|
||||
} else {
|
||||
this.signalRendererShellReady(managed)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
managed.pty.onData((data: string) => {
|
||||
const startup = managed.startupCommand
|
||||
if (startup?.identityScanState && !startup.delivered) {
|
||||
const scanned = scanForShellStartupIdentity(startup.identityScanState, data)
|
||||
data = scanned.output
|
||||
if (scanned.shellPid) {
|
||||
startup.shellPid = scanned.shellPid
|
||||
startup.identityScanState = null
|
||||
}
|
||||
}
|
||||
if (startup?.waitForShellReady && startup.scanState && !startup.delivered) {
|
||||
const scanned = scanForShellReady(startup.scanState, data)
|
||||
data = scanned.output
|
||||
if (scanned.matched) {
|
||||
this.scheduleStartupCommandDelivery(managed, STARTUP_COMMAND_WRITE_DELAY_MS)
|
||||
if (startup.providerDelivery) {
|
||||
this.scheduleStartupCommandResolution(managed, STARTUP_COMMAND_WRITE_DELAY_MS)
|
||||
} else {
|
||||
this.signalRendererShellReady(managed)
|
||||
}
|
||||
}
|
||||
}
|
||||
managed.startupIngress?.accept(data)
|
||||
if (startup && !startup.delivered && data.length > 0) {
|
||||
startup.promptProbe?.notifyOutput(data)
|
||||
}
|
||||
})
|
||||
managed.pty.onExit(({ exitCode }: { exitCode: number }) => {
|
||||
managed.physicalExit?.markExited()
|
||||
@@ -744,11 +827,11 @@ export class PtyHandler {
|
||||
|
||||
private releaseRelayIngress(managed: ManagedPty): void {
|
||||
const startupCommand = managed.startupCommand
|
||||
const scanState = startupCommand?.scanState
|
||||
if (scanState) {
|
||||
const held = drainShellReadyHeldBytes(scanState)
|
||||
startupCommand.scanState = null
|
||||
managed.startupIngress?.accept(held)
|
||||
if (startupCommand) {
|
||||
this.clearStartupCommandTimer(managed)
|
||||
startupCommand.promptProbe?.dispose()
|
||||
managed.startupIngress?.accept(this.drainStartupScanBytes(startupCommand))
|
||||
managed.startupCommand = undefined
|
||||
}
|
||||
managed.startupIngress?.drainAndClose()
|
||||
}
|
||||
@@ -1471,11 +1554,15 @@ export class PtyHandler {
|
||||
startupCommandDelivery:
|
||||
params.startupCommandDelivery === 'shell-ready' ? 'shell-ready' : undefined
|
||||
})
|
||||
const managedStartupCommand = shouldProviderDeliverCommand ? command : launchCommandHint
|
||||
// Why: both renderer- and provider-delivered startup commands use this marker; the delivering side strips it from output.
|
||||
const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, {
|
||||
terminalWindowsWslDistro,
|
||||
emitReadyMarker: shouldEmitShellReadyMarker
|
||||
emitReadyMarker: shouldEmitShellReadyMarker,
|
||||
emitStartupIdentity: shouldEmitShellReadyMarker
|
||||
})
|
||||
const rendererShellReadySupported =
|
||||
!shouldProviderDeliverCommand && shellLaunch.env.ORCA_SHELL_READY_MARKER === '1'
|
||||
|
||||
if (context?.signal?.aborted || context?.isStale()) {
|
||||
// Why: cancellation remains side-effect-free until the exact native spawn seam.
|
||||
@@ -1496,7 +1583,12 @@ export class PtyHandler {
|
||||
rows,
|
||||
cwd,
|
||||
// Why: relay shells inherit process.env; don't let an ambient Orca marker enable shell-ready unless requested.
|
||||
env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env }
|
||||
env: {
|
||||
...spawnEnv,
|
||||
ORCA_SHELL_READY_MARKER: '0',
|
||||
ORCA_SHELL_STARTUP_IDENTITY: '0',
|
||||
...shellLaunch.env
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
// Why: Windows loads conpty.node only on first spawn, so handle that late binding failure here.
|
||||
@@ -1535,6 +1627,9 @@ export class PtyHandler {
|
||||
...(explicitTerm !== undefined ? { explicitTerm } : {}),
|
||||
envToDelete,
|
||||
gitCredentialPromptGuarded,
|
||||
shellPath: shell,
|
||||
shellCwd: cwd,
|
||||
shellPathEnv: spawnEnv.PATH,
|
||||
ownerBackend: resolvePtyOwnerBackend({
|
||||
platform: process.platform,
|
||||
shellPath: shell,
|
||||
@@ -1542,16 +1637,23 @@ export class PtyHandler {
|
||||
}),
|
||||
...(startupIngressIntent ? { startupIngressIntent } : {}),
|
||||
...(terminalHandle ? { terminalHandle } : {}),
|
||||
...(shouldProviderDeliverCommand
|
||||
...(managedStartupCommand && (shouldProviderDeliverCommand || rendererShellReadySupported)
|
||||
? {
|
||||
startupCommand: {
|
||||
command,
|
||||
command: shouldProviderDeliverCommand ? managedStartupCommand : null,
|
||||
providerDelivery: shouldProviderDeliverCommand,
|
||||
delivered: false,
|
||||
waitForShellReady: shellLaunch.env.ORCA_SHELL_READY_MARKER === '1',
|
||||
scanState:
|
||||
shellLaunch.env.ORCA_SHELL_READY_MARKER === '1'
|
||||
? createShellReadyScanState()
|
||||
: null,
|
||||
identityScanState:
|
||||
shellLaunch.env.ORCA_SHELL_READY_MARKER === '1'
|
||||
? createShellStartupIdentityScanState()
|
||||
: null,
|
||||
shellPid: null,
|
||||
promptProbe: null,
|
||||
timer: null
|
||||
}
|
||||
}
|
||||
@@ -1568,11 +1670,13 @@ export class PtyHandler {
|
||||
this.releaseStartupCommand(managed)
|
||||
this.requestGracefulKill(managed, 'terminate stale')
|
||||
} else if (managed.startupCommand) {
|
||||
this.scheduleStartupCommandDelivery(
|
||||
this.scheduleStartupCommandResolution(
|
||||
managed,
|
||||
managed.startupCommand.waitForShellReady
|
||||
? STARTUP_COMMAND_SHELL_READY_FALLBACK_MS
|
||||
: STARTUP_COMMAND_WRITE_DELAY_MS
|
||||
managed.startupCommand.providerDelivery
|
||||
? managed.startupCommand.waitForShellReady
|
||||
? STARTUP_COMMAND_SHELL_READY_FALLBACK_MS
|
||||
: STARTUP_COMMAND_WRITE_DELAY_MS
|
||||
: RENDERER_SHELL_READY_RETENTION_MS
|
||||
)
|
||||
}
|
||||
return {
|
||||
@@ -2029,7 +2133,12 @@ export class PtyHandler {
|
||||
rows: entry.rows,
|
||||
cwd: entry.cwd,
|
||||
// Why: no provider-delivered command is waiting for a ready marker.
|
||||
env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env }
|
||||
env: {
|
||||
...spawnEnv,
|
||||
ORCA_SHELL_READY_MARKER: '0',
|
||||
ORCA_SHELL_STARTUP_IDENTITY: '0',
|
||||
...shellLaunch.env
|
||||
}
|
||||
})
|
||||
this.wireAndStore({
|
||||
id: entry.id,
|
||||
@@ -2208,6 +2317,14 @@ export class PtyHandler {
|
||||
return count
|
||||
}
|
||||
|
||||
get retainedStartupCommandBytes(): number {
|
||||
let bytes = 0
|
||||
for (const managed of this.ptys.values()) {
|
||||
bytes += managed.startupCommand?.command?.length ?? 0
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
get graceTimerActive(): boolean {
|
||||
return this.graceTimer !== null
|
||||
}
|
||||
|
||||
@@ -181,6 +181,12 @@ describe('getRelayShellLaunchConfig', () => {
|
||||
expect(config.args).toEqual(['-l'])
|
||||
expect(config.env.ZDOTDIR).toBe(zshRoot)
|
||||
expect(config.env.ORCA_SHELL_READY_MARKER).toBe('1')
|
||||
expect(readFileSync(join(zshRoot, '.zshenv'), 'utf8')).toContain(
|
||||
'printf "\\033]777;orca-shell-start:%s\\007" "$$"'
|
||||
)
|
||||
expect(readFileSync(join(zshRoot, '.zshenv'), 'utf8')).toContain(
|
||||
'unset ORCA_SHELL_STARTUP_IDENTITY'
|
||||
)
|
||||
expect(zlogin).toContain('zle -N zle-line-init __orca_prompt_mark')
|
||||
expect(zlogin).toContain('printf "\\033]777;orca-shell-ready\\007"')
|
||||
}
|
||||
@@ -195,11 +201,26 @@ describe('getRelayShellLaunchConfig', () => {
|
||||
const bashRc = readFileSync(config.args[1] as string, 'utf8')
|
||||
|
||||
expect(config.env.ORCA_SHELL_READY_MARKER).toBe('1')
|
||||
expect(bashRc).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"')
|
||||
expect(bashRc).toContain('unset ORCA_SHELL_STARTUP_IDENTITY')
|
||||
expect(bashRc).toContain('__orca_append_prompt_command "__orca_prompt_mark"')
|
||||
expect(bashRc).toContain('printf "\\033]777;orca-shell-ready\\007"')
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'wraps zsh when only startup identity emission is requested',
|
||||
() => {
|
||||
const config = getRelayShellLaunchConfig('/bin/zsh', { HOME: homeDir }, 'linux', {
|
||||
emitStartupIdentity: true
|
||||
})
|
||||
|
||||
expect(config.env.ZDOTDIR).toBe(join(homeDir, '.orca-relay', 'shell-ready', 'zsh'))
|
||||
expect(config.env.ORCA_SHELL_STARTUP_IDENTITY).toBe('1')
|
||||
expect(config.env.ORCA_SHELL_READY_MARKER).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
itWithBash('runs the relay bash wrapper without fake C/D markers before the first prompt', () => {
|
||||
const config = getRelayShellLaunchConfig('/bin/bash', { HOME: homeDir })
|
||||
const output = runInteractiveBashRcfile(config.args[1] as string, homeDir)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper'
|
||||
import {
|
||||
getZshFinalZdotdirRestoreBlock,
|
||||
getZshShellReadyMarkerRegistrationBlock,
|
||||
SHELL_STARTUP_IDENTITY_MARKER_BLOCK,
|
||||
getZshStartupFileSourceBlock
|
||||
} from '../main/shell-templates'
|
||||
|
||||
@@ -84,6 +85,7 @@ function ensureOverlayRestoreWrappers(root: string): void {
|
||||
const bashDir = join(root, 'bash')
|
||||
|
||||
const zshEnv = `# Orca relay zsh overlay wrapper
|
||||
${SHELL_STARTUP_IDENTITY_MARKER_BLOCK}
|
||||
export ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
|
||||
case "\${ORCA_ORIG_ZDOTDIR%/}" in
|
||||
*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;
|
||||
@@ -133,6 +135,7 @@ ${getZshFinalZdotdirRestoreBlock('"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HO
|
||||
${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER_ESCAPED)}
|
||||
`
|
||||
const bashRc = `# Orca relay bash overlay wrapper
|
||||
${SHELL_STARTUP_IDENTITY_MARKER_BLOCK}
|
||||
[[ -f /etc/profile ]] && source /etc/profile
|
||||
if [[ -f "$HOME/.bash_profile" ]]; then
|
||||
source "$HOME/.bash_profile"
|
||||
@@ -264,10 +267,15 @@ export function getRelayShellLaunchConfig(
|
||||
shellPath: string,
|
||||
env: Record<string, string>,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: { emitReadyMarker?: boolean; terminalWindowsWslDistro?: string | null } = {}
|
||||
options: {
|
||||
emitReadyMarker?: boolean
|
||||
emitStartupIdentity?: boolean
|
||||
terminalWindowsWslDistro?: string | null
|
||||
} = {}
|
||||
): RelayShellLaunchConfig {
|
||||
const shellName = shellBasename(shellPath)
|
||||
const emitReadyMarker = options.emitReadyMarker === true
|
||||
const emitStartupIdentity = options.emitStartupIdentity === true
|
||||
if (platform === 'win32') {
|
||||
// Why: pwsh also exists on POSIX remotes; Windows-specific shell args must
|
||||
// only apply when the relay itself is running on native Windows.
|
||||
@@ -283,9 +291,9 @@ export function getRelayShellLaunchConfig(
|
||||
if (shellName !== 'zsh' && shellName !== 'bash') {
|
||||
return { args: POSIX_LOGIN_ARGS, env: {} }
|
||||
}
|
||||
// Why: preserve plain zsh startup fast path; only force wrappers when
|
||||
// shell-ready or overlay env restoration is requested.
|
||||
if (shellName === 'zsh' && !hasOverlayRestoreEnv(env) && !emitReadyMarker) {
|
||||
// Why: preserve plain zsh startup fast path unless markers or overlay restoration are requested.
|
||||
const requiresZshWrapper = hasOverlayRestoreEnv(env) || emitReadyMarker || emitStartupIdentity
|
||||
if (shellName === 'zsh' && !requiresZshWrapper) {
|
||||
return { args: POSIX_LOGIN_ARGS, env: {} }
|
||||
}
|
||||
|
||||
@@ -298,13 +306,17 @@ export function getRelayShellLaunchConfig(
|
||||
env: {
|
||||
ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(env),
|
||||
ZDOTDIR: join(root, 'zsh'),
|
||||
...(emitReadyMarker ? { ORCA_SHELL_READY_MARKER: '1' } : {})
|
||||
...(emitReadyMarker ? { ORCA_SHELL_READY_MARKER: '1' } : {}),
|
||||
...(emitStartupIdentity ? { ORCA_SHELL_STARTUP_IDENTITY: '1' } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
args: ['--rcfile', join(root, 'bash', 'rcfile')],
|
||||
env: emitReadyMarker ? { ORCA_SHELL_READY_MARKER: '1' } : {}
|
||||
env: {
|
||||
...(emitReadyMarker ? { ORCA_SHELL_READY_MARKER: '1' } : {}),
|
||||
...(emitStartupIdentity ? { ORCA_SHELL_STARTUP_IDENTITY: '1' } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
const execFileMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
|
||||
|
||||
import { createPtySlaveEchoProbe, readPtySlavePath } from './pty-slave-line-discipline-echo'
|
||||
import {
|
||||
createPtySlaveEchoProbe,
|
||||
createPtySlaveLineEditorProbe,
|
||||
readPtySlavePath
|
||||
} from './pty-slave-line-discipline-echo'
|
||||
|
||||
/** Replies to the next stty call with the given output, or an error when `output` is null. */
|
||||
function answerStty(output: string | null): void {
|
||||
@@ -14,6 +18,7 @@ function answerStty(output: string | null): void {
|
||||
|
||||
const COOKED = 'speed 38400 baud;\nlflags: icanon isig iexten echo echoe echok echoctl\n'
|
||||
const RAW = 'speed 38400 baud;\nlflags: -icanon -isig -iexten -echo -echoe -echok -echoctl\n'
|
||||
const LINE_EDITOR = `${RAW}cchars: lnext = <undef>; min = 1; time = 0;\n`
|
||||
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset()
|
||||
@@ -132,3 +137,21 @@ describe('createPtySlaveEchoProbe', () => {
|
||||
expect(execFileMock.mock.calls[1]?.[1]).toEqual(['-a', '-F', '/dev/pts/3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('createPtySlaveLineEditorProbe', () => {
|
||||
it('requires raw quiet mode with the line editor disabling literal-next', async () => {
|
||||
const probe = createPtySlaveLineEditorProbe('/dev/ttys048', 'darwin')
|
||||
answerStty(LINE_EDITOR)
|
||||
await expect(probe?.()).resolves.toBe('line-editor')
|
||||
answerStty(`${RAW}cchars: lnext = ^V; min = 1; time = 0;\n`)
|
||||
await expect(probe?.()).resolves.toBe('other')
|
||||
answerStty(COOKED)
|
||||
await expect(probe?.()).resolves.toBe('other')
|
||||
})
|
||||
|
||||
it('fails closed when the terminal state is incomplete', async () => {
|
||||
const probe = createPtySlaveLineEditorProbe('/dev/ttys048', 'darwin')
|
||||
answerStty('lflags: -echo\ncchars: lnext = <undef>;\n')
|
||||
await expect(probe?.()).resolves.toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,10 +10,16 @@ export type PtySlaveLineDisciplineEcho = 'echoing' | 'quiet' | 'unknown'
|
||||
|
||||
export type PtySlaveEchoProbe = () => Promise<PtySlaveLineDisciplineEcho>
|
||||
|
||||
export type PtySlaveLineEditorState = 'line-editor' | 'other' | 'unknown'
|
||||
|
||||
export type PtySlaveLineEditorProbe = () => Promise<PtySlaveLineEditorState>
|
||||
|
||||
const STTY_TIMEOUT_MS = 2_000
|
||||
// `stty -a` prints the lflags as a space-separated list where a disabled flag is
|
||||
// prefixed with `-`, so `echo` and `-echo` are the two tokens that matter.
|
||||
const ECHO_FLAG = /(?:^|\s)(-?)echo(?:\s|$)/
|
||||
const ICANON_FLAG = /(?:^|\s)(-?)icanon(?:\s|$)/
|
||||
const LNEXT_UNDEFINED = /(?:^|[;\s])lnext\s*=\s*<undef>(?:;|\s|$)/
|
||||
|
||||
function sttyArgs(ptsName: string, platform: NodeJS.Platform): readonly string[] {
|
||||
// BSD/macOS take `-f`; Linux (GNU coreutils) takes `-F`.
|
||||
@@ -30,7 +36,18 @@ function parseEchoFlag(sttyOutput: string): PtySlaveLineDisciplineEcho {
|
||||
return match[1] === '-' ? 'quiet' : 'echoing'
|
||||
}
|
||||
|
||||
type SttyProbeResult = { state: PtySlaveLineDisciplineEcho; permanent: boolean }
|
||||
function parseLineEditorState(sttyOutput: string): PtySlaveLineEditorState {
|
||||
const echo = ECHO_FLAG.exec(sttyOutput)
|
||||
const icanon = ICANON_FLAG.exec(sttyOutput)
|
||||
if (!echo || !icanon) {
|
||||
return 'unknown'
|
||||
}
|
||||
return echo[1] === '-' && icanon[1] === '-' && LNEXT_UNDEFINED.test(sttyOutput)
|
||||
? 'line-editor'
|
||||
: 'other'
|
||||
}
|
||||
|
||||
type SttyProbeResult = { stdout: string | null; permanent: boolean }
|
||||
|
||||
/**
|
||||
* A spawn that never ran (`stty` absent) or a device that answered non-zero (reaped,
|
||||
@@ -54,8 +71,8 @@ function runStty(ptsName: string, platform: NodeJS.Platform): Promise<SttyProbeR
|
||||
(error, stdout) => {
|
||||
resolve(
|
||||
error
|
||||
? { state: 'unknown', permanent: isPermanentSttyFailure(error) }
|
||||
: { state: parseEchoFlag(stdout), permanent: false }
|
||||
? { stdout: null, permanent: isPermanentSttyFailure(error) }
|
||||
: { stdout, permanent: false }
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -83,6 +100,21 @@ export function createPtySlaveEchoProbe(
|
||||
ptsName: string | undefined,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): PtySlaveEchoProbe | undefined {
|
||||
return createSttyProbe(ptsName, platform, parseEchoFlag)
|
||||
}
|
||||
|
||||
export function createPtySlaveLineEditorProbe(
|
||||
ptsName: string | undefined,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): PtySlaveLineEditorProbe | undefined {
|
||||
return createSttyProbe(ptsName, platform, parseLineEditorState)
|
||||
}
|
||||
|
||||
function createSttyProbe<T extends string>(
|
||||
ptsName: string | undefined,
|
||||
platform: NodeJS.Platform,
|
||||
parse: (output: string) => T | 'unknown'
|
||||
): (() => Promise<T | 'unknown'>) | undefined {
|
||||
if (platform === 'win32' || !ptsName) {
|
||||
return undefined
|
||||
}
|
||||
@@ -101,6 +133,6 @@ export function createPtySlaveEchoProbe(
|
||||
})
|
||||
const result = await inFlight
|
||||
unavailable = result.permanent
|
||||
return result.state
|
||||
return result.stdout === null ? 'unknown' : parse(result.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseDarwinExecutablePath, resolveShellExecutablePath } from './shell-process-readiness'
|
||||
|
||||
describe('shell process readiness', () => {
|
||||
it('extracts the primary text image from macOS lsof output', () => {
|
||||
expect(parseDarwinExecutablePath('p42\nftxt\nn/bin/zsh\nftxt\nn/usr/lib/zsh/zle.so\n')).toBe(
|
||||
'/bin/zsh'
|
||||
)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'resolves bare shell commands through the spawn PATH',
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-shell-path-'))
|
||||
const link = join(root, 'shell-name')
|
||||
await symlink(process.execPath, link)
|
||||
try {
|
||||
await expect(resolveShellExecutablePath('shell-name', dirname(root), root)).resolves.toBe(
|
||||
await resolveShellExecutablePath(process.execPath, dirname(root), root)
|
||||
)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'uses the POSIX exec default when PATH is unset',
|
||||
async () => {
|
||||
await expect(resolveShellExecutablePath('sh', process.cwd(), undefined)).resolves.toBe(
|
||||
await resolveShellExecutablePath('/bin/sh', process.cwd(), '')
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'resolves relative shell paths against the PTY cwd',
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-relative-shell-'))
|
||||
const bin = join(root, 'bin')
|
||||
await symlink(dirname(process.execPath), bin)
|
||||
try {
|
||||
await expect(
|
||||
resolveShellExecutablePath(`./bin/${basename(process.execPath)}`, root, '')
|
||||
).resolves.toBe(await resolveShellExecutablePath(process.execPath, root, ''))
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'skips searchable directories that shadow a later PATH executable',
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-shadowed-shell-'))
|
||||
const first = join(root, 'first')
|
||||
const second = join(root, 'second')
|
||||
await mkdir(join(first, 'shell-name'), { recursive: true })
|
||||
await mkdir(second)
|
||||
await symlink(process.execPath, join(second, 'shell-name'))
|
||||
try {
|
||||
await expect(
|
||||
resolveShellExecutablePath('shell-name', root, `${first}:${second}`)
|
||||
).resolves.toBe(await resolveShellExecutablePath(process.execPath, root, ''))
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { execFile as execFileCallback } from 'node:child_process'
|
||||
import { constants } from 'node:fs'
|
||||
import { access, readlink, realpath, stat } from 'node:fs/promises'
|
||||
import { delimiter, isAbsolute, resolve } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
const execFile = promisify(execFileCallback)
|
||||
const PROCESS_READINESS_TIMEOUT_MS = 3000
|
||||
const DEFAULT_POSIX_EXEC_PATH = '/usr/bin:/bin'
|
||||
|
||||
export type ShellProcessReadiness = {
|
||||
executablePath: string
|
||||
foreground: boolean
|
||||
}
|
||||
|
||||
export function parseDarwinExecutablePath(stdout: string): string | null {
|
||||
const lines = stdout.split(/\r?\n/)
|
||||
const textIndex = lines.indexOf('ftxt')
|
||||
const pathLine = textIndex === -1 ? undefined : lines[textIndex + 1]
|
||||
return pathLine?.startsWith('n') ? pathLine.slice(1) : null
|
||||
}
|
||||
|
||||
async function readExecutablePath(pid: number): Promise<string | null> {
|
||||
if (process.platform === 'linux') {
|
||||
return readlink(`/proc/${pid}/exe`)
|
||||
}
|
||||
if (process.platform !== 'darwin') {
|
||||
return null
|
||||
}
|
||||
const { stdout } = await execFile(
|
||||
'/usr/sbin/lsof',
|
||||
['-a', '-p', String(pid), '-d', 'txt', '-Fn'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: PROCESS_READINESS_TIMEOUT_MS
|
||||
}
|
||||
)
|
||||
return parseDarwinExecutablePath(stdout)
|
||||
}
|
||||
|
||||
export async function readShellProcessReadiness(
|
||||
pid: number
|
||||
): Promise<ShellProcessReadiness | null> {
|
||||
const [executablePath, { stdout }] = await Promise.all([
|
||||
readExecutablePath(pid),
|
||||
execFile('ps', ['-p', String(pid), '-o', 'stat='], {
|
||||
encoding: 'utf8',
|
||||
timeout: PROCESS_READINESS_TIMEOUT_MS
|
||||
})
|
||||
])
|
||||
const status = stdout.trim()
|
||||
return status && executablePath
|
||||
? { executablePath: await realpath(executablePath), foreground: status.includes('+') }
|
||||
: null
|
||||
}
|
||||
|
||||
export async function resolveShellExecutablePath(
|
||||
shellPath: string,
|
||||
cwd: string,
|
||||
pathEnv: string | undefined
|
||||
): Promise<string | null> {
|
||||
const candidates = shellPath.includes('/')
|
||||
? [isAbsolute(shellPath) ? shellPath : resolve(cwd, shellPath)]
|
||||
: (
|
||||
pathEnv ??
|
||||
(process.platform === 'win32' ? (process.env.PATH ?? '') : DEFAULT_POSIX_EXEC_PATH)
|
||||
)
|
||||
.split(delimiter)
|
||||
.map((entry) => resolve(isAbsolute(entry) ? entry : resolve(cwd, entry), shellPath))
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await access(candidate, constants.X_OK)
|
||||
const canonicalPath = await realpath(candidate)
|
||||
if ((await stat(canonicalPath)).isFile()) {
|
||||
return canonicalPath
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user