mirror of
https://github.com/adnaan-worker/adnify.git
synced 2026-08-29 03:50:51 +08:00
feat: implement piped shell command execution and fallback for terminal integration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }: {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
|
||||
Vendored
+21
@@ -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
|
||||
}>
|
||||
|
||||
@@ -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 = '[32mgreen[0m ]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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user