feat: implement piped shell command execution and fallback for terminal integration

This commit is contained in:
adnaan
2026-08-21 00:52:34 +08:00
parent 8fed2eaf1a
commit 84c38c077b
9 changed files with 713 additions and 11 deletions
@@ -26,9 +26,6 @@ __adnify_shell_integration_precmd() {
}
__adnify_shell_integration_preexec() {
if [ "${__adnify_suppress_preexec:-0}" -eq 1 ]; then
return 0
fi
__adnify_command_running=1
__adnify_shell_integration_command_line "$1"
__adnify_shell_integration_emit C
@@ -36,19 +33,21 @@ __adnify_shell_integration_preexec() {
case "$ZSH_VERSION" in
'')
# DEBUG is the only pre-command hook available in bash. It fires for
# nested commands and function calls too, so keep only the first boundary
# and never restore this handler recursively.
# DEBUG is the only pre-command hook available in bash. It fires for every
# nested command and function call, so this handler owns the "already
# reported this command" latch: it checks the latch, sets it, and then calls
# preexec. preexec itself must NOT re-check the latch — doing so made the
# two guards interlock, so C/E/D were never emitted at all and every agent
# command fell through to the prompt-recovery path with a null exit code.
__adnify_shell_integration_debug() {
[ "${__adnify_suppress_preexec:-0}" -eq 1 ] && return 0
__adnify_suppress_preexec=1
case "$BASH_COMMAND" in
'__adnify_shell_integration_debug'|'__adnify_shell_integration_precmd'|'__adnify_shell_integration_preexec') ;;
__adnify_shell_integration_*) ;;
*) __adnify_shell_integration_preexec "$BASH_COMMAND" ;;
esac
}
PROMPT_COMMAND="__adnify_shell_integration_precmd${PROMPT_COMMAND:+; $PROMPT_COMMAND}"
trap '__adnify_shell_integration_debug' DEBUG
;;
*)
autoload -Uz add-zsh-hook
@@ -59,5 +58,14 @@ case "$ZSH_VERSION" in
;;
esac
__adnify_suppress_preexec=0
# Latch starts engaged so the remainder of this script cannot be reported as a
# user command, and no command is in flight yet.
__adnify_suppress_preexec=1
__adnify_command_running=0
__adnify_shell_integration_precmd || true
# bash only: install the trap last, after the bootstrap above has run, so
# sourcing this file never emits a spurious command cycle.
case "$ZSH_VERSION" in
'') trap '__adnify_shell_integration_debug' DEBUG ;;
esac
+2
View File
@@ -693,6 +693,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
writeTerminal: (id: string, data: string) => ipcRenderer.invoke('terminal:input', { id, data }),
executeBackground: (params: { command: string; cwd?: string; timeout?: number; shell?: string }) =>
ipcRenderer.invoke('shell:executeBackground', params),
runPiped: (params: { command: string; cwd?: string; timeout?: number; shell?: string; maxOutputChars?: number }) =>
ipcRenderer.invoke('shell:runPiped', params),
onShellOutput: (callback: (event: { command: string; type: 'stdout' | 'stderr'; data: string; timestamp: number }) => void) => {
const handler = (_: IpcRendererEvent, event: { command: string; type: 'stdout' | 'stderr'; data: string; timestamp: number }) => callback(event)
ipcRenderer.on('shell:output', handler)
+227
View File
@@ -0,0 +1,227 @@
/**
* Run a shell command through pipes and report its real result.
*
* `run_command` normally drives an interactive PTY so the user can watch, and
* relies on OSC 633 shell integration to learn where a command starts, where it
* ends, and what it exited with. When those markers never arrive — cmd.exe has
* no integration script, and a user rc-file can replace the hooks — the PTY path
* has no way to distinguish "succeeded quietly" from "never ran", so it reports
* failure with a null exit code even though the output is on screen.
*
* Piped stdio has none of that ambiguity: stdout/stderr are the process's own
* streams and the exit code comes from the process itself. This module is that
* fallback. It deliberately carries no command whitelist — it is reached only
* from the same agent tool call as the PTY path, behind the same approval UI, so
* adding a second policy here would just make identical commands succeed or fail
* depending on which transport happened to be chosen.
*/
import { spawn } from 'child_process'
import { StringDecoder } from 'node:string_decoder'
import * as path from 'path'
import { logger } from '@shared/utils/Logger'
import { toAppError } from '@shared/utils/errorHandler'
export interface PipedShellOptions {
command: string
cwd: string
timeoutMs: number
shell?: string
maxOutputChars: number
onExit?: (pid: number) => void
onSpawn?: (pid: number) => void
}
export interface PipedShellOutcome {
stdout: string
stderr: string
exitCode: number | null
signal: string | null
timedOut: boolean
truncated: boolean
durationMs: number
error?: string
}
type ShellKind = 'powershell' | 'cmd' | 'posix'
function classifyShell(shellPath: string): ShellKind {
const name = path.basename(shellPath).toLowerCase()
if (name === 'powershell.exe' || name === 'powershell' || name === 'pwsh.exe' || name === 'pwsh') {
return 'powershell'
}
if (name === 'cmd.exe' || name === 'cmd') return 'cmd'
return 'posix'
}
export function resolveDefaultShell(): string {
if (process.platform === 'win32') return 'powershell.exe'
return process.env.SHELL || '/bin/bash'
}
/**
* Build the argv that hands `command` to `shellPath` as a single script.
*
* The command string is passed as one argument rather than interpolated into a
* larger script, so quoting inside it is the shell's business and cannot alter
* the surrounding argv.
*/
export function buildShellArgs(shellPath: string, command: string): string[] {
switch (classifyShell(shellPath)) {
case 'powershell':
// -NoProfile keeps a user profile from writing to stdout and polluting the
// captured output. UTF-8 is forced so non-ASCII output survives the pipe.
return [
'-NoProfile',
'-NoLogo',
'-NonInteractive',
'-Command',
'$__adnifyUtf8 = New-Object System.Text.UTF8Encoding($false);'
+ ' $OutputEncoding = $__adnifyUtf8;'
+ ' [Console]::OutputEncoding = $__adnifyUtf8;'
+ ` ${command}`,
]
case 'cmd':
return ['/D', '/S', '/C', `chcp 65001 > nul & ${command}`]
default:
return ['-c', command]
}
}
/** Keep the tail: the end of a long build log is what explains the outcome. */
export function truncateOutput(text: string, maxChars: number): { text: string; truncated: boolean } {
if (text.length <= maxChars) return { text, truncated: false }
return {
text: `[... ${text.length - maxChars} characters truncated ...]\n${text.slice(-maxChars)}`,
truncated: true,
}
}
export function runPipedShellCommand(options: PipedShellOptions): Promise<PipedShellOutcome> {
const { command, cwd, timeoutMs, maxOutputChars } = options
const shellPath = options.shell || resolveDefaultShell()
const startedAt = Date.now()
return new Promise<PipedShellOutcome>((resolve) => {
let child: ReturnType<typeof spawn>
try {
child = spawn(shellPath, buildShellArgs(shellPath, command), {
cwd,
// TERM=dumb stops tools from emitting colour escapes and progress
// redraws, which are noise once the output is text for a model.
env: { ...process.env, TERM: 'dumb' },
windowsHide: true,
})
} catch (error) {
resolve({
stdout: '',
stderr: '',
exitCode: null,
signal: null,
timedOut: false,
truncated: false,
durationMs: Date.now() - startedAt,
error: toAppError(error).message,
})
return
}
if (child.pid) options.onSpawn?.(child.pid)
let stdout = ''
let stderr = ''
let timedOut = false
let settled = false
// A UTF-8 character can be split across two chunks; decoding each chunk
// independently would turn it into U+FFFD.
const stdoutDecoder = new StringDecoder('utf8')
const stderrDecoder = new StringDecoder('utf8')
// Cap while streaming so a runaway process cannot exhaust memory before the
// timeout fires. Keeping 2x the reported budget leaves room for the tail
// slice to still be a clean cut.
const streamCap = maxOutputChars * 2
child.stdout?.on('data', (data: Buffer) => {
stdout += stdoutDecoder.write(data)
if (stdout.length > streamCap) stdout = stdout.slice(-streamCap)
})
child.stderr?.on('data', (data: Buffer) => {
stderr += stderrDecoder.write(data)
if (stderr.length > streamCap) stderr = stderr.slice(-streamCap)
})
const timer = setTimeout(() => {
timedOut = true
killProcessTree(child)
}, timeoutMs)
const finish = (exitCode: number | null, signal: string | null, error?: string) => {
if (settled) return
settled = true
clearTimeout(timer)
if (child.pid) options.onExit?.(child.pid)
stdout += stdoutDecoder.end()
stderr += stderrDecoder.end()
const cleanedStdout = truncateOutput(stripAnsi(stdout), maxOutputChars)
const cleanedStderr = truncateOutput(stripAnsi(stderr), maxOutputChars)
resolve({
stdout: cleanedStdout.text,
stderr: cleanedStderr.text,
exitCode,
signal,
timedOut,
truncated: cleanedStdout.truncated || cleanedStderr.truncated,
durationMs: Date.now() - startedAt,
error,
})
}
child.on('close', (code, signal) => finish(code, signal))
child.on('error', (error) => {
logger.security.error('[PipedShell] Spawn failed:', error)
finish(null, null, toAppError(error).message)
})
})
}
/**
* ANSI escapes still reach us from tools that write them unconditionally.
* Strips CSI/OSC sequences and normalizes CRLF; the text is for a model to read.
*/
export function stripAnsi(text: string): string {
return text
// eslint-disable-next-line no-control-regex
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
// eslint-disable-next-line no-control-regex
.replace(/\x1b[()][0-9A-B]/g, '')
.replace(/\r\n/g, '\n')
}
/**
* Kill the whole tree. A shell that spawned children leaves them running (and
* holding the pipes open) if only the shell itself is signalled.
*/
function killProcessTree(child: ReturnType<typeof spawn>): void {
const pid = child.pid
if (!pid) return
if (process.platform === 'win32') {
spawn('taskkill', ['/F', '/T', '/PID', String(pid)], { windowsHide: true })
.on('error', () => {
try { child.kill('SIGKILL') } catch { /* already gone */ }
})
return
}
try { child.kill('SIGTERM') } catch { /* already gone */ }
// SIGTERM can be ignored; escalate so the promise cannot hang forever.
setTimeout(() => {
try { if (!child.killed) child.kill('SIGKILL') } catch { /* already gone */ }
}, 2000)
}
+118 -2
View File
@@ -15,6 +15,7 @@ import { securityManager, OperationType } from './securityModule'
import { SECURITY_SETTINGS_DEFAULTS } from '@shared/config/securitySettings'
import { safeIpcHandle } from '../ipc/safeHandle'
import { normalizePipeTerminalInput } from './terminalInput'
import { runPipedShellCommand } from './pipedShell'
import { exec as gitExec } from 'dugite'
import { remoteHostTrustService } from '../services/remoteHostTrustService'
@@ -27,6 +28,34 @@ interface SecureShellRequest {
requireConfirm?: boolean
}
/**
* A shell command to run through pipes rather than an interactive PTY.
*
* This is the fallback for `run_command` when terminal shell integration cannot
* report command boundaries (cmd.exe, or a failed OSC 633 handshake). Piped
* stdio gives the real stdout/stderr and the real exit code, so the agent can
* never be told a command failed when it actually succeeded.
*/
interface PipedShellRequest {
command: string
cwd?: string
timeout?: number
shell?: string
maxOutputChars?: number
}
interface PipedShellResult {
success: boolean
stdout: string
stderr: string
exitCode: number | null
signal: string | null
timedOut: boolean
truncated: boolean
durationMs: number
error?: string
}
interface CommandWhitelist {
shell: Set<string>
git: Set<string>
@@ -59,6 +88,8 @@ export function getWhitelist() {
// Terminal instances storage (模块级别,便于清理)
const terminals = new Map<string, any>() // IPty instances
const backgroundProcesses = new Map<number, import('child_process').ChildProcess>() // shell:executeBackground 子进程
/** PIDs of in-flight shell:runPiped children, so app shutdown can reap them. */
const pipedShellPids = new Set<number>()
function getShellIntegrationResourcePath(scriptName: string): string {
return app.isPackaged
@@ -171,6 +202,12 @@ export function cleanupTerminals(): void {
try { child.kill('SIGTERM') } catch { /* ignore */ }
backgroundProcesses.delete(pid)
}
// Piped agent commands are tracked by pid only; the promise that owns each
// child is already gone by the time shutdown runs.
for (const pid of pipedShellPids) {
try { process.kill(pid, 'SIGTERM') } catch { /* already gone */ }
pipedShellPids.delete(pid)
}
logger.security.info(`[Terminal] All terminals and background processes cleaned up`)
}
@@ -320,8 +357,7 @@ class SecureCommandParser {
}
}
function shouldLogGitNonZeroAsWarning(args: string[], stderr: string, stdout: string): boolean {
const gitSubCommand = args.find(arg => !arg.startsWith('-'))?.toLowerCase()
function shouldLogGitNonZeroAsWarning(args: string[], stderr: string, stdout: string): boolean { const gitSubCommand = args.find(arg => !arg.startsWith('-'))?.toLowerCase()
const output = `${stderr}\n${stdout}`.toLowerCase()
if (gitSubCommand === 'notes' && args.includes('show') && output.includes('no note found for object')) {
@@ -1417,6 +1453,86 @@ export function registerSecureTerminalHandlers(
* 使用 child_process.spawn,不依赖 PTY
* 实时推送输出到前端,精确捕获 exit code
*/
/**
* Run an agent command through pipes instead of the interactive PTY.
*
* Reached only when terminal shell integration cannot frame the command, so it
* carries no command whitelist: the caller is the same `run_command` tool call
* that the PTY path serves, gated by the same approval UI. Dangerous-pattern
* detection and the workspace boundary still apply, matching what the PTY path
* enforces.
*/
safeIpcHandle('shell:runPiped', async (
event,
request: PipedShellRequest,
): Promise<PipedShellResult> => {
const { command, cwd, timeout = 120_000, shell: customShell, maxOutputChars = 120_000 } = request || {}
const fail = (error: string): PipedShellResult => ({
success: false,
stdout: '',
stderr: '',
exitCode: null,
signal: null,
timedOut: false,
truncated: false,
durationMs: 0,
error,
})
if (typeof command !== 'string' || !command.trim()) {
return fail('No command provided')
}
const workspace = getWorkspace(event)
const workingDir = cwd || workspace?.roots[0] || process.cwd()
if (workspace && !securityManager.validateWorkspacePath(workingDir, workspace.roots)) {
securityManager.logOperation(OperationType.SHELL_EXECUTE, command, false, {
reason: 'Working directory outside workspace',
source: 'runPiped',
})
return fail('Working directory outside workspace')
}
const dangerousCheck = SecureCommandParser.detectDangerousPatterns(command)
if (!dangerousCheck.safe) {
securityManager.logOperation(OperationType.SHELL_EXECUTE, command, false, {
reason: dangerousCheck.reason,
source: 'runPiped',
})
return fail(dangerousCheck.reason || 'Command rejected')
}
const outcome = await runPipedShellCommand({
command,
cwd: workingDir,
timeoutMs: timeout,
shell: customShell,
maxOutputChars,
onSpawn: pid => pipedShellPids.add(pid),
onExit: pid => pipedShellPids.delete(pid),
})
securityManager.logOperation(OperationType.SHELL_EXECUTE, command, outcome.exitCode === 0, {
source: 'runPiped',
exitCode: outcome.exitCode,
timedOut: outcome.timedOut,
durationMs: outcome.durationMs,
})
return {
success: outcome.exitCode === 0 && !outcome.timedOut,
stdout: outcome.stdout,
stderr: outcome.stderr,
exitCode: outcome.exitCode,
signal: outcome.signal,
timedOut: outcome.timedOut,
truncated: outcome.truncated,
durationMs: outcome.durationMs,
error: outcome.error,
}
})
safeIpcHandle('shell:executeBackground', async (
event,
{ command, cwd, timeout = 30000, shell: customShell }: {
+110
View File
@@ -616,6 +616,89 @@ async function buildRemoteTrustMeta(
return {}
}
/** Matches the PTY path's own output budget so both transports report the same volume. */
const MAX_PIPED_OUTPUT_CHARS = 120_000
/**
* Terminal outcomes that mean "the shell never told us what happened".
*
* These are transport failures, not command failures: the command may well have
* run and printed its output, but without OSC 633 boundaries the PTY path cannot
* read a result, so it reports failure with a null exit code. Re-running through
* pipes is the only way to get a trustworthy answer.
*/
const FALLBACK_TERMINATION_REASONS = new Set([
'shell_integration_missing',
'sentinel_missing_prompt',
'terminal_error',
'terminal_exit',
'user_closed_terminal',
])
/**
* Re-run a command through pipes and shape it like a normal tool result.
*
* Returns null when the piped transport itself could not start, so the caller can
* keep the original PTY diagnostics rather than replacing them with a worse
* message.
*/
async function runCommandViaPipes(
command: string,
cwd: string | undefined,
timeout: number,
reason: string,
): Promise<{ result: ToolExecutionResult } | null> {
let piped: Awaited<ReturnType<typeof api.shell.runPiped>>
try {
piped = await api.shell.runPiped({
command,
cwd,
timeout,
maxOutputChars: MAX_PIPED_OUTPUT_CHARS,
})
} catch (error) {
logger.agent.warn('[run_command] Piped fallback failed to start:', error)
return null
}
if (piped.error && piped.exitCode === null && !piped.stdout && !piped.stderr) {
logger.agent.warn(`[run_command] Piped fallback rejected: ${piped.error}`)
return null
}
const combined = [piped.stdout, piped.stderr].filter(Boolean).join('\n').trim()
let resultText = combined
if (piped.timedOut) {
resultText = combined
? `[Timed out after ${timeout / 1000}s]\n${combined}`
: `Command timed out after ${timeout / 1000}s`
} else if (!resultText) {
resultText = piped.exitCode === 0
? 'Command executed successfully (no output)'
: `Command failed${piped.exitCode !== null ? ` (exit code ${piped.exitCode})` : ''} (no output)`
}
return {
result: {
success: piped.success,
result: resultText,
error: piped.success ? undefined : (piped.error || resultText),
meta: {
command,
cwd,
exitCode: piped.exitCode,
signal: piped.signal,
timedOut: piped.timedOut,
truncated: piped.truncated,
durationMs: piped.durationMs,
executionMode: 'piped-fallback',
fallbackReason: reason,
},
},
}
}
async function runInlineScriptViaTempFile(
command: string,
ctx: ToolExecutionContext,
@@ -1689,6 +1772,33 @@ const rawToolExecutors: Record<string, (args: Record<string, unknown>, ctx: Tool
remoteLink ? undefined : (resolvedCwd || undefined),
)
// The PTY could not frame this command, so its "failure" says nothing
// about the command itself. Re-run through pipes, where stdout and the
// exit code come straight from the process. Remote commands are left
// alone: pipes here would run them on the wrong machine.
if (!remoteLink
&& !commandResult.sentinelMatched
&& commandResult.terminationReason
&& FALLBACK_TERMINATION_REASONS.has(commandResult.terminationReason)) {
logger.agent.info(
`[run_command] Shell integration unusable (${commandResult.terminationReason}); retrying through pipes`,
)
const fallback = await runCommandViaPipes(
command,
resolvedCwd || ctx.workspacePath || undefined,
timeout,
commandResult.terminationReason,
)
if (fallback) {
fallback.result.meta = {
...(fallback.result.meta || {}),
...routeMeta,
terminalId: termId,
}
return fallback.result
}
}
const displayOutput = (commandResult.output || commandResult.partialOutput || '').trim()
let resultText = displayOutput
+1
View File
@@ -204,6 +204,7 @@ function createGroupedAPI() {
shell: {
executeSecure: (request: Parameters<typeof raw.executeSecureCommand>[0]) => raw.executeSecureCommand(request),
executeBackground: (params: Parameters<typeof raw.executeBackground>[0]) => raw.executeBackground(params),
runPiped: (params: Parameters<typeof raw.runPiped>[0]) => raw.runPiped(params),
onOutput: (callback: Parameters<typeof raw.onShellOutput>[0]) => raw.onShellOutput(callback),
},
+21
View File
@@ -519,6 +519,27 @@ export interface ElectronAPI {
success: boolean; output: string; exitCode: number; error?: string
}>
onShellOutput: (callback: (event: { command: string; type: 'stdout' | 'stderr'; data: string; timestamp: number }) => void) => () => void
/**
* Run a command through pipes and get its real stdout/stderr/exit code.
* Fallback for when terminal shell integration cannot frame a command.
*/
runPiped: (params: {
command: string
cwd?: string
timeout?: number
shell?: string
maxOutputChars?: number
}) => Promise<{
success: boolean
stdout: string
stderr: string
exitCode: number | null
signal: string | null
timedOut: boolean
truncated: boolean
durationMs: number
error?: string
}>
executeSecureCommand: (request: SecureCommandRequest) => Promise<{
success: boolean; output?: string; errorOutput?: string; exitCode?: number; error?: string
}>
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest'
import {
buildShellArgs,
runPipedShellCommand,
stripAnsi,
truncateOutput,
} from '@main/security/pipedShell'
const shell = process.platform === 'win32' ? 'powershell.exe' : '/bin/bash'
function run(command: string, overrides: Partial<Parameters<typeof runPipedShellCommand>[0]> = {}) {
return runPipedShellCommand({
command,
cwd: process.cwd(),
timeoutMs: 20_000,
shell,
maxOutputChars: 120_000,
...overrides,
})
}
describe('buildShellArgs', () => {
it('passes the command as a single argument so its quoting stays intact', () => {
const args = buildShellArgs('/bin/bash', 'echo "a b" && echo c')
expect(args).toEqual(['-c', 'echo "a b" && echo c'])
})
it('suppresses the PowerShell profile so it cannot pollute captured output', () => {
const args = buildShellArgs('powershell.exe', 'Write-Output hi')
expect(args).toContain('-NoProfile')
expect(args).toContain('-NonInteractive')
expect(args[args.length - 1]).toContain('Write-Output hi')
})
it('routes cmd.exe through /D /S /C with a UTF-8 codepage', () => {
const args = buildShellArgs('cmd.exe', 'echo hi')
expect(args.slice(0, 3)).toEqual(['/D', '/S', '/C'])
expect(args[3]).toContain('chcp 65001')
})
})
describe('stripAnsi', () => {
it('removes colour codes and OSC sequences but keeps the text', () => {
const text = 'green ]633;Aplain'
expect(stripAnsi(text)).toBe('green plain')
})
it('normalizes CRLF', () => {
expect(stripAnsi('a\r\nb')).toBe('a\nb')
})
})
describe('truncateOutput', () => {
it('keeps the tail, which is where a build failure explains itself', () => {
const result = truncateOutput('abcdefghij', 4)
expect(result.truncated).toBe(true)
expect(result.text).toContain('ghij')
expect(result.text).toContain('truncated')
})
it('leaves short output untouched', () => {
expect(truncateOutput('short', 100)).toEqual({ text: 'short', truncated: false })
})
})
describe('runPipedShellCommand', () => {
it('captures stdout and a zero exit code', async () => {
const result = await run('echo piped-hello')
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('piped-hello')
expect(result.timedOut).toBe(false)
})
// This is the whole point of the fallback: the PTY path reports failure with a
// null exit code when shell integration is missing, even for commands that
// succeeded silently.
it('reports success for a command that produces no output', async () => {
const result = await run(process.platform === 'win32' ? 'exit 0' : 'true')
expect(result.exitCode).toBe(0)
expect(result.stdout.trim()).toBe('')
})
it('reports the real non-zero exit code', async () => {
const result = await run(process.platform === 'win32' ? 'exit 3' : 'exit 3')
expect(result.exitCode).toBe(3)
})
it('separates stderr from stdout', async () => {
const command = process.platform === 'win32'
? '[Console]::Error.WriteLine("to-stderr"); Write-Output "to-stdout"'
: 'echo to-stdout; echo to-stderr 1>&2'
const result = await run(command)
expect(result.stdout).toContain('to-stdout')
expect(result.stderr).toContain('to-stderr')
})
it('times out and kills the process instead of hanging', async () => {
const command = process.platform === 'win32' ? 'Start-Sleep -Seconds 30' : 'sleep 30'
const startedAt = Date.now()
const result = await run(command, { timeoutMs: 1200 })
expect(result.timedOut).toBe(true)
expect(Date.now() - startedAt).toBeLessThan(15_000)
}, 20_000)
it('surfaces a spawn failure rather than pretending the command ran', async () => {
const result = await run('echo nope', { shell: '/definitely/not/a/shell' })
expect(result.exitCode).not.toBe(0)
expect(result.error).toBeTruthy()
})
it('caps very large output while keeping the end of it', async () => {
const command = process.platform === 'win32'
? '1..4000 | ForEach-Object { "line-$_" }'
: 'for i in $(seq 1 4000); do echo "line-$i"; done'
const result = await run(command, { maxOutputChars: 500 })
expect(result.truncated).toBe(true)
expect(result.stdout.length).toBeLessThan(2000)
expect(result.stdout).toContain('line-4000')
}, 30_000)
})
@@ -0,0 +1,95 @@
import { spawnSync } from 'child_process'
import * as path from 'path'
import { describe, expect, it } from 'vitest'
/**
* Drives the real shell-integration script through a real interactive bash and
* reads back the OSC 633 sequences it emitted.
*
* This file exists because the script was silently broken: the DEBUG trap and
* `preexec` both checked the same "already reported" latch, so they interlocked
* and only ever emitted `A` (prompt). Every agent command then fell through to
* the prompt-recovery path and was reported as failed with a null exit code,
* even when it had run fine. Nothing in the suite covered the script, so it
* stayed broken. Assert on the marker stream, not on internals.
*/
const SCRIPT = path.resolve(__dirname, '../../../resources/shell-integration/shellIntegration.sh')
const ESC = String.fromCharCode(27)
const BEL = String.fromCharCode(7)
function hasBash(): boolean {
const probe = spawnSync('bash', ['-c', 'echo ok'], { encoding: 'utf8' })
return probe.status === 0
}
/** Run lines in an interactive bash with the integration loaded; return OSC payloads. */
function collectMarkers(lines: string[]): string[] {
const input = [`export PS1='$ '`, `. '${SCRIPT}'`, ...lines, 'exit'].join('\n') + '\n'
const result = spawnSync('bash', ['--norc', '--noprofile', '-i'], {
input,
encoding: 'latin1',
timeout: 20_000,
})
const output = `${result.stdout || ''}${result.stderr || ''}`
const pattern = new RegExp(`${ESC}\\]633;([^${BEL}]*)${BEL}`, 'g')
return [...output.matchAll(pattern)].map(match => match[1])
}
describe.runIf(hasBash())('bash shell integration', () => {
it('emits a full command lifecycle with the real exit code', () => {
const markers = collectMarkers(['echo MARK_A'])
// E (command line) → C (start) → D;<code> (end) is what the terminal needs
// to frame a command and read its result.
const cycle = markers.slice(markers.indexOf('E;echo MARK_A'))
expect(cycle[0]).toBe('E;echo MARK_A')
expect(cycle[1]).toBe('C')
expect(cycle[2]).toBe('D;0')
})
it('reports a non-zero exit code rather than swallowing it', () => {
const markers = collectMarkers(['false'])
expect(markers).toContain('D;1')
})
it('emits exactly one lifecycle per command, not one per nested call', () => {
// DEBUG fires for every nested command in bash, so a function whose body
// runs two commands must still produce a single boundary pair.
const markers = collectMarkers(['myfn() { echo one; echo two; }', 'myfn'])
const callIndex = markers.indexOf('E;myfn')
expect(callIndex).toBeGreaterThan(-1)
// Exactly one start and one end between the call and the next prompt.
const untilPrompt = markers.slice(callIndex, markers.indexOf('A', callIndex))
expect(untilPrompt.filter(marker => marker === 'C')).toHaveLength(1)
expect(untilPrompt.filter(marker => marker.startsWith('D;'))).toHaveLength(1)
})
it('emits one lifecycle for a loop rather than one per iteration', () => {
const markers = collectMarkers(['for i in 1 2 3; do echo "n=$i"; done'])
const startIndex = markers.findIndex(marker => marker.startsWith('E;for'))
expect(startIndex).toBeGreaterThan(-1)
const untilPrompt = markers.slice(startIndex, markers.indexOf('A', startIndex))
expect(untilPrompt.filter(marker => marker === 'C')).toHaveLength(1)
expect(untilPrompt.filter(marker => marker.startsWith('D;'))).toHaveLength(1)
})
it('does not report sourcing itself as a user command', () => {
// The script must be loaded with the latch engaged; otherwise its own
// bootstrap statements are reported as if the user had typed them.
const markers = collectMarkers([])
const beforeFirstPrompt = markers.slice(0, markers.indexOf('A') + 1)
expect(beforeFirstPrompt.some(marker => marker.startsWith('E;'))).toBe(false)
expect(beforeFirstPrompt.some(marker => marker === 'C')).toBe(false)
})
it('keeps framing commands after one fails', () => {
const markers = collectMarkers(['false', 'echo AFTER'])
const afterIndex = markers.indexOf('E;echo AFTER')
expect(afterIndex).toBeGreaterThan(-1)
expect(markers[afterIndex + 1]).toBe('C')
expect(markers[afterIndex + 2]).toBe('D;0')
})
})