'use strict' const { execFileSync, spawn } = require('child_process') const crypto = require('crypto') const fs = require('fs') const os = require('os') const path = require('path') const MAX_COMMAND_LENGTH = 20000 const MARKER_PREFIX = '__CMD_DONE_' const MARKER_SUFFIX = '__' const WORKDIR_TIMEOUT_MS = 5000 const DEFAULT_EXEC_YIELD_MS = 300_000 const DEFAULT_STDIN_YIELD_MS = 5000 const DEFAULT_AWAIT_YIELD_MS = 60_000 const MAX_PREVIEW_LINES = 100 const MAX_PREVIEW_CHARS = 10000 const MAX_WAIT_FOR_LENGTH = 200 const DEFAULT_MAX_SESSIONS = 30 const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000 const DEFAULT_MAX_LIFETIME_MS = 2 * 60 * 60 * 1000 const EXIT_STATUS_PATTERN = /^[ \t]*(-?\d+)[ \t]*\r?\n?/ /** 缓冲区上限;超限时把最旧的一段丢出内存,输出文件仍然完整。 */ const MAX_BUFFER_CHARS = 262_144 const BUFFER_KEEP_TAIL_CHARS = 8192 /** 轮询上限:有新输出会立即唤醒,这里只兜底超时判定。 */ const WAIT_SLICE_MS = 200 function isWindowsPlatform() { return process.platform === 'win32' } /** PowerShell 单引号字面量:内部单引号用双写转义。 */ function psSingleQuote(value) { return "'" + String(value).replace(/'/g, "''") + "'" } /** * Windows 宿主没有 bash;Git Bash 的工作目录/路径语义与 cmd 不一致,按需求走 PowerShell。 * 命令经 stdin 逐条喂给常驻宿主,与 bash --norc --noprofile 等价。 */ function resolveShellProtocol() { if (isWindowsPlatform()) { return { command: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-Command', '-'], isPowerShell: true, /** 命令执行完打印「结束标记 + 退出码」;标记与退出码必须合成单个字符串参数,否则会被各输出一行。 */ commandDone: (marker) => `if ($?) { echo "${marker} 0" } elseif ($LASTEXITCODE) { echo "${marker} $LASTEXITCODE" } else { echo "${marker} 1" }`, markerEcho: (marker) => `echo ${marker}`, chdir: (dir) => 'Set-Location -LiteralPath ' + psSingleQuote(dir), envSet: (name, value) => `$env:${name} = ${psSingleQuote(value)}`, envUnset: (name) => `Remove-Item Env:${name} -ErrorAction SilentlyContinue`, /** 重定向输出下宿主默认用 OEM 代码页写 stdout(中文系统常为 GBK),先切 UTF-8。 */ initScript: '[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); ' + '$OutputEncoding = New-Object System.Text.UTF8Encoding($false)', } } return { command: 'bash', args: ['-c', 'exec 2>&1; exec bash --norc --noprofile'], isPowerShell: false, commandDone: (marker) => `echo ${marker} $?`, markerEcho: (marker) => `echo ${marker}`, chdir: (dir) => 'cd ' + shellSingleQuote(dir), envSet: (name, value) => `export ${name}=${shellSingleQuote(value)}`, envUnset: (name) => `unset ${name}`, initScript: '', } } /** * 缓冲区末尾与 marker 前缀重叠的长度:这段可能是刚到一半的结束标记, * 既不能当正文交给模型也不能落盘,等剩余字节到达再判定。 */ function pendingMarkerTail(buffer, marker) { const max = Math.min(marker.length - 1, buffer.length) for (let k = max; k > 0; k--) { if (buffer.endsWith(marker.slice(0, k))) return k } return 0 } function shellSingleQuote(value) { return "'" + String(value).replace(/'/g, "'\\''") + "'" } function asText(value) { if (value == null) return null if (typeof value === 'string') return value if (typeof value === 'number' || typeof value === 'boolean') return String(value) return null } function asInt(value, fallback = 0) { if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value) if (typeof value === 'string' && value.trim() !== '') { const n = Number(value) if (Number.isFinite(n)) return Math.trunc(n) } return fallback } function expandHome(filePath) { if (!filePath) return filePath if (filePath === '~') return os.homedir() if (filePath.startsWith('~/')) return path.join(os.homedir(), filePath.slice(2)) return filePath } function newMarker() { return MARKER_PREFIX + crypto.randomBytes(6).toString('hex') + MARKER_SUFFIX } function preview(full) { const lines = full.split('\n') let truncated = false let text = full if (lines.length > MAX_PREVIEW_LINES) { text = lines.slice(-MAX_PREVIEW_LINES).join('\n') truncated = true } if (text.length > MAX_PREVIEW_CHARS) { text = text.slice(text.length - MAX_PREVIEW_CHARS) truncated = true } return { text, truncated } } function resolveYieldTimeMs(args, defaultMs) { if (args.yield_time_ms != null) return asInt(args.yield_time_ms, defaultMs) if (args.timeout != null) { const sec = asInt(args.timeout, 0) if (sec > 0) return sec * 1000 } return defaultMs } function resolveExitCode(result) { if (result.exitCode != null) return result.exitCode return result.completed ? 0 : -1 } class LocalShellSession { constructor(sessionId, conversationId, child, workdir, outputFile, displayPath) { this.sessionId = sessionId this.conversationId = conversationId this.process = child this.currentWorkdir = workdir this.outputFile = outputFile this.displayPath = displayPath this.commandCount = 0 this.createdAt = Date.now() this.lastActiveAt = Date.now() this.alive = true this.buffer = '' this.persistedUpTo = 0 this.emittedUpTo = 0 this.bufferTrimmed = false this.waiters = [] /** stdout 'end' 之后才不会再有输出;进程 exit 可能早于管道排空。 */ this.stdoutEnded = false /** 已写入 stdin 但尚未读到结束标记的命令:{ marker, keepSession, persist, background } */ this.pendingCommand = null /** 最近一次被 finishCommand 消费的 marker:防止过期调用方把它重新登记成永不出现的假 pending。 */ this.lastConsumedMarker = null this.process.stdout.setEncoding('utf8') this.process.stderr.setEncoding('utf8') // stderr 并入同一缓冲区,等价 bash 宿主里的 exec 2>&1(bash 路径下该管道本就为空) this.process.stderr.on('data', (data) => this.onData(data)) // 常驻读取:没有监听者时 Node 会直接丢弃 stdout 数据, // 提前放行(wait_for 命中 / 超时)后剩余输出与结束标记就再也读不到了。 this.process.stdout.on('data', (data) => this.onData(data)) this.process.stdout.on('end', () => { this.stdoutEnded = true this.wake() }) this.process.on('exit', () => this.wake()) // spawn ENOENT 等启动失败只触发 error(exit 可能不触发):必须显式标记,否则会话假活、模型只能看到空输出 this.spawnError = null this.process.on('error', (e) => { this.spawnError = e.message this.alive = false this.wake() }) } touch() { this.lastActiveAt = Date.now() } isAlive() { return this.alive && this.process.exitCode == null && !this.process.killed } /** 进程已退出时的状态码:正常退出用 bash 的 exit code;被信号杀掉或启动失败则为 -1。 */ processExitStatus() { if (this.process.exitCode != null) return this.process.exitCode if (this.process.signalCode || !this.isAlive()) return -1 return null } isIdleTimeout(timeoutMs) { return Date.now() - this.lastActiveAt > timeoutMs } isExpired(maxLifetimeMs) { return Date.now() - this.createdAt > maxLifetimeMs } incrementCommandCount() { this.commandCount++ } setCurrentWorkdir(workdir) { this.currentWorkdir = workdir } writeStdin(text) { this.process.stdin.write(text) } /** * persist=false 用于 cd/pwd 等协议命令,其输出不进落盘文件。 * background=true 表示命令由 async 提交,读取权归 await_async。 */ beginCommand(marker, keepSession, persist = true, background = false) { this.pendingCommand = { marker, keepSession, persist, background } } peekBuffer() { return this.buffer } emittedBoundary() { return this.emittedUpTo } markEmitted(end) { if (end > this.emittedUpTo) this.emittedUpTo = end } wasBufferTrimmed() { return this.bufferTrimmed } finishCommand(consumedEnd) { if (this.pendingCommand) this.lastConsumedMarker = this.pendingCommand.marker this.flushPersist(true) this.buffer = this.buffer.slice(consumedEnd) this.persistedUpTo = 0 this.emittedUpTo = 0 this.bufferTrimmed = false this.pendingCommand = null } async waitForOutput(timeoutMs) { if (timeoutMs <= 0 || !this.isAlive()) return await new Promise((resolve) => { let done = false const finish = () => { if (done) return done = true clearTimeout(timer) this.waiters = this.waiters.filter((w) => w !== finish) resolve() } const timer = setTimeout(finish, timeoutMs) this.waiters.push(finish) }) } /** 进程已退出后仍可能有未读完的 stdout;短等管道排空。 */ async waitForStdoutDrain(timeoutMs) { if (timeoutMs <= 0 || this.stdoutEnded) return await new Promise((resolve) => { let done = false const finish = () => { if (done) return done = true clearTimeout(timer) this.waiters = this.waiters.filter((w) => w !== finish) resolve() } const timer = setTimeout(finish, timeoutMs) this.waiters.push(finish) }) } onData(data) { this.buffer += typeof data === 'string' ? data : data.toString('utf8') // 有输出即视为活跃,否则只输出不被读取的常驻命令会被空闲清理杀掉 this.lastActiveAt = Date.now() this.flushPersist(false) this.trimBuffer() this.wake() } wake() { const waiters = this.waiters this.waiters = [] for (const waiter of waiters) waiter() } /** 结束标记本身与其后的退出码不能写进输出文件,未见到标记时按尾部实际重叠长度回退,避免标记被切两半。 */ flushPersist(final) { const pending = this.pendingCommand if (pending && !pending.persist) return const marker = pending?.marker let visibleEnd if (!marker) { visibleEnd = this.buffer.length } else { const idx = this.buffer.indexOf(marker) visibleEnd = idx >= 0 ? idx : final ? this.buffer.length : this.buffer.length - pendingMarkerTail(this.buffer, marker) } if (visibleEnd <= this.persistedUpTo) return try { fs.appendFileSync(this.outputFile, this.buffer.slice(this.persistedUpTo, visibleEnd)) this.persistedUpTo = visibleEnd } catch { /* ignore */ } } trimBuffer() { if (this.buffer.length <= MAX_BUFFER_CHARS) return const dropTo = Math.min(this.persistedUpTo, this.buffer.length - BUFFER_KEEP_TAIL_CHARS) if (dropTo <= 0) return this.buffer = this.buffer.slice(dropTo) this.persistedUpTo -= dropTo this.emittedUpTo = Math.max(0, this.emittedUpTo - dropTo) this.bufferTrimmed = true } close() { if (!this.alive) return this.alive = false const pid = this.process.pid if (pid != null && process.platform === 'win32') { // process.kill 在 Windows 上立刻返回,PowerShell 仍占着工作目录,随后删除目录会 EPERM。 // taskkill /T /F 会等到进程树退出再返回。 try { execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, timeout: 5000, stdio: 'ignore', }) } catch { /* 进程已退出 */ } } else if (pid != null) { try { process.kill(-pid, 'SIGKILL') } catch { /* group already gone or unsupported */ } try { this.process.kill('SIGKILL') } catch { /* ignore */ } } try { this.process.stdin.end() } catch { /* ignore */ } // 立刻唤醒等待者,否则读取者会空等到 yield 超时才发现会话已关闭 this.wake() } } function createLocalShellRuntime(options = {}) { const buildEnv = options.buildEnv || (async () => ({ ...process.env, TERM: 'dumb', PS1: '' })) const protocol = options.protocol || resolveShellProtocol() const refreshToken = options.refreshToken || (() => {}) const resolveOutput = options.resolveOutput const maxSessions = options.maxSessionsPerConversation ?? DEFAULT_MAX_SESSIONS const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS const maxLifetimeMs = options.maxLifetimeMs ?? DEFAULT_MAX_LIFETIME_MS const spawnFn = options.spawn || spawn const sessions = new Map() const conversationSessions = new Map() let cleanupTimer = null function outputFor(conversationId, shellId) { if (resolveOutput) return resolveOutput(conversationId, shellId) const dir = path.join(os.tmpdir(), 'mao-local-shell', String(conversationId ?? 0)) fs.mkdirSync(dir, { recursive: true }) const absPath = path.join(dir, `${shellId}.out`) return { absPath, displayPath: absPath } } function pruneConversation(conversationId) { const conv = conversationSessions.get(conversationId) if (!conv) return for (const id of [...conv]) { const session = sessions.get(id) if (session?.isAlive()) continue conv.delete(id) sessions.delete(id) session?.close() } if (conv.size === 0) conversationSessions.delete(conversationId) } function removeSession(sessionId) { const session = sessions.get(sessionId) sessions.delete(sessionId) if (!session) return session.close() if (session.conversationId != null) { conversationSessions.get(session.conversationId)?.delete(sessionId) } } async function createSession(shellId, conversationId, workdir) { const { absPath, displayPath } = outputFor(conversationId, shellId) fs.mkdirSync(path.dirname(absPath), { recursive: true }) fs.writeFileSync(absPath, '') const env = { ...(await buildEnv()), TERM: 'dumb', PS1: '' } const child = spawnFn(protocol.command, protocol.args, { cwd: workdir || undefined, env, stdio: ['pipe', 'pipe', 'pipe'], // 非 Windows 上 detached 用于按进程组回收整棵命令树;Windows 下 powershell 不支持组 kill,改用直接 kill detached: !isWindowsPlatform(), windowsHide: isWindowsPlatform(), }) // spawn ENOENT 等错误只会异步触发 error 事件:不接住的话模型只能看到空输出 + exit 1,无法定位。 // LocalShellSession 内部也会挂 error 监听并记录 spawnError,这里的空监听只防 unhandled error 崩溃。 child.on('error', () => {}) // PowerShell 重定向输出默认走 OEM 代码页(中文系统常为 GBK),启动后先切 UTF-8 if (protocol.initScript) { child.stdin.write(protocol.initScript + '\n') } return new LocalShellSession(shellId, conversationId, child, workdir || '', absPath, displayPath) } async function getOrCreate(conversationId, shellSessionId, workdir) { if (shellSessionId && sessions.has(shellSessionId)) { const existing = sessions.get(shellSessionId) if (existing.isAlive()) { existing.touch() return existing } removeSession(shellSessionId) } pruneConversation(conversationId) const conv = conversationSessions.get(conversationId) ?? new Set() conversationSessions.set(conversationId, conv) if (conv.size >= maxSessions) { throw new Error(`Maximum number of shell sessions (${maxSessions}) reached for conversation ${conversationId}. Close existing sessions first.`) } if (!shellSessionId) { shellSessionId = `sh-${conversationId}-${Date.now()}` } const session = await createSession(shellSessionId, conversationId, workdir) sessions.set(shellSessionId, session) conv.add(shellSessionId) return session } /** * 读到结束标记、waitFor 命中正文、进程退出或超时为止。 * 输出取自会话常驻缓冲区,因此提前返回后剩余输出不会丢失,可再次调用继续读。 */ async function readUntilMarker(session, marker, timeoutMs, waitFor = null) { const start = Date.now() const deadline = start + timeoutMs // 直接调用(协议命令、测试)没有登记 pending,按默认落盘补登记; // marker 已被其他调用方消费过(finishCommand 置空了 pending)时绝不能重新登记, // 否则等于造出一条永不结束的假命令,把会话永久卡死。 if (session.pendingCommand?.marker !== marker) { if (session.lastConsumedMarker === marker) { return { output: '', truncated: false, completed: true, elapsedMs: 0, exitCode: null, matched: null, } } session.beginCommand(marker, true) } let matched = null let markerIndex = -1 for (;;) { const buffer = session.peekBuffer() markerIndex = buffer.indexOf(marker) if (markerIndex >= 0) break if (waitFor) { // 只在尚未返回给调用方的部分里找,避免续等时被上一次已交付的输出立刻命中。 const hit = waitFor.exec(buffer.slice(session.emittedBoundary())) if (hit) { matched = hit[0] // printf 命中后紧跟 exit 很常见:stdout 结束会先于 exitCode 到达, // 必须在宽限期内等到进程真正退出,否则会误报「命令仍在运行」。 const graceDeadline = Date.now() + WAIT_SLICE_MS while (session.isAlive()) { markerIndex = session.peekBuffer().indexOf(marker) if (markerIndex >= 0) break const remain = graceDeadline - Date.now() if (remain <= 0) break await session.waitForOutput(remain) } if (markerIndex < 0) markerIndex = session.peekBuffer().indexOf(marker) break } } if (!session.isAlive()) { // exit 可能早于 stdout:宽限期内把管道排空,再回头匹配 wait_for / 结束标记。 if (!session.stdoutEnded) { const drainDeadline = Date.now() + WAIT_SLICE_MS while (!session.stdoutEnded) { const remain = drainDeadline - Date.now() if (remain <= 0) break await session.waitForStdoutDrain(remain) } } const drained = session.peekBuffer() markerIndex = drained.indexOf(marker) if (markerIndex < 0 && waitFor && matched == null) { const hit = waitFor.exec(drained.slice(session.emittedBoundary())) if (hit) matched = hit[0] } break } const remain = deadline - Date.now() if (remain <= 0) break await session.waitForOutput(Math.min(remain, WAIT_SLICE_MS)) } const buffer = session.peekBuffer() if (markerIndex < 0) markerIndex = buffer.indexOf(marker) const emitted = session.emittedBoundary() if (markerIndex < 0) { // 尾部可能是被切成两半的结束标记,留到下一次读取再判定,否则标记会漏进正文; // 进程已退出时不会再有后续字节,按命令结束交付,避免 Agent 去续等已死会话。 const dead = !session.isAlive() const tail = dead ? 0 : pendingMarkerTail(buffer, marker) const end = Math.max(emitted, buffer.length - tail) const shown = preview(buffer.slice(emitted, end)) const trimmed = session.wasBufferTrimmed() session.markEmitted(end) if (dead) { session.finishCommand(buffer.length) return { output: shown.text, truncated: shown.truncated || trimmed, completed: true, elapsedMs: Date.now() - start, exitCode: session.processExitStatus(), matched, shellExited: true, } } return { output: shown.text, truncated: shown.truncated || trimmed, completed: false, elapsedMs: Date.now() - start, exitCode: null, matched, } } let consumed = markerIndex + marker.length let exitCode = null const status = EXIT_STATUS_PATTERN.exec(buffer.slice(consumed)) if (status) { exitCode = Number(status[1]) consumed += status[0].length } else { const newline = /^\r?\n/.exec(buffer.slice(consumed)) if (newline) consumed += newline[0].length } const shown = preview(buffer.slice(emitted, markerIndex)) const trimmed = session.wasBufferTrimmed() session.finishCommand(consumed) return { output: shown.text, truncated: shown.truncated || trimmed, completed: true, elapsedMs: Date.now() - start, exitCode, matched, ...(!session.isAlive() ? { shellExited: true } : {}), } } /** cd 等协议命令:输出不落盘,也不参与 keep_session 判定。回显退出码供调用方判定 cd 失败。 */ async function executeWithMarker(session, command, timeoutMs) { const marker = newMarker() session.beginCommand(marker, true, false) // 与云端 executeWithMarker 同口径补 $?(PowerShell 先清 $LASTEXITCODE,cmdlet 失败才报得出 1) const prelude = protocol.isPowerShell ? '$LASTEXITCODE = 0\n' : '' session.writeStdin(prelude + command + '\n' + protocol.commandDone(marker) + '\n') return readUntilMarker(session, marker, timeoutMs) } function injectToken(session) { try { refreshToken(session) } catch (e) { console.warn('[shell] Failed to refresh MAO_TOKEN:', e.message) } } function resolveWorkdir(workdirArg, workspace) { if (!workdirArg) return null const expanded = expandHome(workdirArg) if (path.isAbsolute(expanded)) return expanded return path.join(workspace || process.cwd(), expanded) } async function resolveCurrentWorkdir(session, result) { if (!result.completed || !session.isAlive()) return session.currentWorkdir try { const marker = newMarker() // pwd 属协议命令,输出不进落盘文件 session.beginCommand(marker, true, false) session.writeStdin('pwd\n' + protocol.markerEcho(marker) + '\n') const pwd = await readUntilMarker(session, marker, WORKDIR_TIMEOUT_MS) if (pwd.completed) { const lines = pwd.output.split('\n').map((l) => l.trim()).filter((l) => l !== '') const last = lines[lines.length - 1] if (last && (last.startsWith('/') || /^[A-Za-z]:[\\/]/.test(last))) { session.setCurrentWorkdir(last) return last } } } catch (e) { console.warn('[shell] Failed to refresh workdir:', e.message) } return session.currentWorkdir } async function ensureApproved(ctx, description) { if (!ctx.needApproval) return true if (typeof ctx.approve !== 'function') return true return ctx.approve(description) } function formatExecResult(session, result, currentWorkdir) { const payload = { exit_code: resolveExitCode(result), session_id: session.sessionId, output: result.output, truncated: result.truncated, completed: result.completed, current_workdir: currentWorkdir ?? session.currentWorkdir, output_file: session.displayPath, } if (session.spawnError) { // 宿主进程启动失败(如 ENOENT):把根因透传给模型,而不是留一条空输出 + exit 1 的悬案 payload.error = `shell 宿主启动失败:${session.spawnError}` payload.spawn_error = session.spawnError } if (result.matched != null) payload.matched = result.matched if (result.shellExited) { payload.message = 'shell 进程已退出,会话已关闭。常驻 bash 里的 exit/exec 会结束整个会话。' } else if (!result.completed) { payload.message = result.matched != null ? `wait_for 已命中,命令仍在运行。用 action:'await_async' + session_id:'${session.sessionId}' 继续等待。` : `等待超时,命令仍在运行。用 action:'await_async' + session_id:'${session.sessionId}' 继续等待。` } return payload } /** wait_for 由模型提供,非法正则要作为参数错误反馈而不是抛异常。 */ function parseWaitFor(args) { const raw = asText(args.wait_for) if (!raw || raw === '') return null if (raw.length > MAX_WAIT_FOR_LENGTH) return `wait_for 过长(最多 ${MAX_WAIT_FOR_LENGTH} 个字符)` try { return new RegExp(raw) } catch (e) { return `wait_for 不是合法正则:${e.message}` } } /** 写入命令并登记为等待中;提前返回后仍能凭 marker 继续读。 */ function writeCommand(session, command, marker, keepSession, background) { session.beginCommand(marker, keepSession, true, background) // cmdlet 失败不会改写 $LASTEXITCODE。先清掉上一条原生命令留下的码,失败且没有新码时才回报 1。 const prelude = protocol.isPowerShell ? '$LASTEXITCODE = 0\n' : '' session.writeStdin(prelude + command + '\n' + protocol.commandDone(marker) + '\n') session.incrementCommandCount() session.touch() } /** * 命令已结束才按 keep_session 决定是否回收;仍在运行时必须保留会话, * 否则关闭会 SIGKILL 掉进程组,模型再也拿不到剩余输出。 * bash 已退出时 keep_session 也无法复用,必须回收。 */ function settleSession(session, result, keepSession) { if (!session.isAlive() || (result.completed && !keepSession)) removeSession(session.sessionId) } async function handleExec(args, ctx) { const command = asText(args.command) if (!command || command.trim() === '') return { error: 'exec 动作必须提供 command' } if (command.length > MAX_COMMAND_LENGTH) { return { error: `命令过长(最多 ${MAX_COMMAND_LENGTH} 个字符)` } } const waitFor = parseWaitFor(args) if (typeof waitFor === 'string') return { error: waitFor } const approved = await ensureApproved(ctx, command) if (!approved) { return { exit_code: -1, output: 'User denied command execution.', session_id: asText(args.session_id) } } const keepSession = args.keep_session === true const yieldTimeMs = resolveYieldTimeMs(args, DEFAULT_EXEC_YIELD_MS) const workdirArg = asText(args.workdir) const workdir = resolveWorkdir(workdirArg, ctx.workspace) || ctx.workspace || '' const conversationId = ctx.conversationId ?? 0 let session try { session = await getOrCreate(conversationId, asText(args.session_id), workdir) } catch (e) { return { error: e.message } } // 上一条命令未结束时再写新命令会让两条命令的输出交织,且新命令要排在它之后才执行 if (session.pendingCommand) { return { error: (session.pendingCommand.background ? '会话仍有未完成的异步命令' : '会话仍有未结束的命令') + `,请先用 action:'await_async' 收取结果:${session.sessionId}`, } } injectToken(session) if (workdirArg && workdir) { // 与云端 handleExec 同口径:cd 结果必须判定——失败/超时都不得让命令静默落在旧目录里执行 const cd = await executeWithMarker(session, protocol.chdir(workdir), WORKDIR_TIMEOUT_MS) if (!cd.completed) { // cd 超时:会话里残留未结束的协议命令、状态未知,关闭回收, // 避免残留 pending 被后续命令覆盖后把 cd 输出写进别人的 output_file removeSession(session.sessionId) return { error: `工作目录切换超时:${workdir}` } } if (cd.exitCode != null && cd.exitCode != 0) { return { error: `工作目录切换失败:${workdir}${cd.output.trim() ? ':' + cd.output.trim() : ''}` } } } const marker = newMarker() if (args.async === true) { // 输出留在会话缓冲区,由 await_async 领取;此处不能挂 promise,否则读取者退出后输出会丢 writeCommand(session, command, marker, keepSession, true) return { async: true, session_id: session.sessionId, output_file: session.displayPath, message: '命令已提交到后台执行。', } } writeCommand(session, command, marker, keepSession, false) const result = await readUntilMarker(session, marker, yieldTimeMs, waitFor) const currentWorkdir = await resolveCurrentWorkdir(session, result) const payload = formatExecResult(session, result, currentWorkdir) settleSession(session, result, keepSession) return payload } /** 继续等待会话中未结束的命令:凭 pendingCommand 的 marker 从常驻缓冲区续读。 */ async function handleAwaitAsync(args) { const shellId = asText(args.session_id) if (!shellId) return { error: 'await_async 必须提供 session_id' } const waitFor = parseWaitFor(args) if (typeof waitFor === 'string') return { error: waitFor } const session = sessions.get(shellId) if (!session) return { error: '会话不存在或已关闭:' + shellId } const pending = session.pendingCommand if (!pending) return { error: '该会话没有未结束的命令:' + shellId } const yieldTimeMs = resolveYieldTimeMs(args, DEFAULT_AWAIT_YIELD_MS) let result try { result = await readUntilMarker(session, pending.marker, yieldTimeMs, waitFor) } finally { // 读取权交还会话,否则命令未结束时 write_stdin 会被永久挡住 if (session.pendingCommand) session.pendingCommand.background = false } if (!result.completed && !session.pendingCommand) { // 等待期间命令的结果已被并行调用方收取:按错误返回,避免把空输出误报成「仍在运行」 return { error: '该命令的结果已被并行调用收取:' + shellId } } session.touch() const currentWorkdir = await resolveCurrentWorkdir(session, result) const payload = formatExecResult(session, result, currentWorkdir) settleSession(session, result, pending.keepSession) return payload } async function handleWriteStdin(args, ctx) { const shellId = asText(args.session_id) const input = asText(args.input) ?? '' if (!shellId) return { error: 'write_stdin 必须提供 session_id' } const waitFor = parseWaitFor(args) if (typeof waitFor === 'string') return { error: waitFor } const session = sessions.get(shellId) if (!session?.isAlive()) return { error: '会话不存在或已关闭:' + shellId } const pending = session.pendingCommand if (pending?.background) { return { error: `会话仍有未完成的异步命令,请先用 action:'await_async' 收取结果:${shellId}` } } const approved = await ensureApproved(ctx, input) if (!approved) { return { error: 'User denied command execution.', session_id: shellId } } const yieldTimeMs = resolveYieldTimeMs(args, DEFAULT_STDIN_YIELD_MS) // 审批可能等待很久,进审批前的快照可能已过期,必须以审批后的实时状态为准 const livePending = session.pendingCommand if (livePending) { if (session.peekBuffer().includes(livePending.marker)) { // 命令已结束但结果尚未被收取:此时写入的输入会被 bash 当作新命令执行, // 而续读会立刻命中旧 marker,把旧输出误当成输入的应答 return { error: `上一条命令已结束但结果尚未收取:${shellId}。请先 action:'await_async' 收取结果,再发送新输入。`, session_id: shellId } } // 有命令正在运行:输入交给它,不能再插入 marker(marker 只会排在该命令之后被执行) session.writeStdin(input.endsWith('\n') ? input : input + '\n') session.touch() const answered = await readUntilMarker(session, livePending.marker, yieldTimeMs, waitFor) const workdirNow = await resolveCurrentWorkdir(session, answered) const payload = formatExecResult(session, answered, workdirNow) settleSession(session, answered, livePending.keepSession) return payload } injectToken(session) // 输入本身不带结束标记,必须额外回显 marker,否则只能空等到超时 const marker = newMarker() writeCommand(session, input, marker, true, false) const result = await readUntilMarker(session, marker, yieldTimeMs, waitFor) const currentWorkdir = await resolveCurrentWorkdir(session, result) // 与 exec / pending 分支一致:输入含 exit 让 bash 退出时必须回收会话, // 否则返回文案说「会话已关闭」而死会话仍占着会话数配额(云端同分支已修,同口径) settleSession(session, result, true) return formatExecResult(session, result, currentWorkdir) } function handleClose(args) { const shellId = asText(args.session_id) if (!shellId) return { error: 'close 必须提供 session_id' } removeSession(shellId) return { success: true, session_id: shellId } } function handleList(ctx) { const conversationId = ctx.conversationId ?? 0 pruneConversation(conversationId) const conv = conversationSessions.get(conversationId) const list = [] if (conv) { for (const id of conv) { const session = sessions.get(id) if (session?.isAlive()) { list.push({ session_id: session.sessionId, current_workdir: session.currentWorkdir, alive: true, }) } } } return { sessions: list } } async function handle(args, ctx = {}) { try { let action = asText(args?.action) ?? 'exec' if (action.trim() === '') action = 'exec' switch (action) { case 'exec': return await handleExec(args || {}, ctx) case 'write_stdin': return await handleWriteStdin(args || {}, ctx) case 'await_async': return await handleAwaitAsync(args || {}) case 'close': return handleClose(args || {}) case 'list': return handleList(ctx) default: return { error: '未知动作:' + action } } } catch (e) { // 审批已经自行回了 tool_error(远端超时 / 拒绝 / 断连)。再包成结果会多发一条 tool_result。 if (e && e.name === 'ToolOutcomeSent') throw e console.error('[shell] execution failed:', e) return { error: '错误:' + e.message } } } function cleanupExpiredSessions() { let cleaned = 0 for (const [sessionId, session] of [...sessions.entries()]) { const dead = !session.isAlive() // 刚因 exit 退出、结果尚未收取的会话留给下一次 await_async if (dead && session.pendingCommand && !session.isIdleTimeout(idleTimeoutMs) && !session.isExpired(maxLifetimeMs)) { continue } if (dead || session.isIdleTimeout(idleTimeoutMs) || session.isExpired(maxLifetimeMs)) { removeSession(sessionId) cleaned++ } } if (cleaned > 0) { console.log(`[shell] Cleaned up ${cleaned} expired shell sessions`) } } function startCleanup(intervalMs = 60_000) { if (cleanupTimer) return cleanupTimer = setInterval(() => cleanupExpiredSessions(), intervalMs) if (typeof cleanupTimer.unref === 'function') cleanupTimer.unref() } function stopCleanup() { if (!cleanupTimer) return clearInterval(cleanupTimer) cleanupTimer = null } function closeAll() { for (const sessionId of [...sessions.keys()]) { removeSession(sessionId) } conversationSessions.clear() } function getActiveSessionCount() { return sessions.size } if (options.autoCleanup !== false) startCleanup() return { handle, closeAll, startCleanup, stopCleanup, cleanupExpiredSessions, getActiveSessionCount, } } module.exports = { createLocalShellRuntime, shellSingleQuote, psSingleQuote, resolveShellProtocol, MAX_COMMAND_LENGTH, DEFAULT_EXEC_YIELD_MS, }