desktop/electron/localShell.test.cjs
Rama main · réplica de lectura del repositorio original.
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const childProcess = require('child_process')
const fs = require('fs')
const os = require('os')
const path = require('path')
const { createLocalShellRuntime, MAX_COMMAND_LENGTH, shellSingleQuote } = require('./localShell.cjs')
function createRuntime(t, extra = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mao-local-shell-'))
// node:test 的 after 按注册顺序执行。删目录必须发生在所有 shell 关闭之后,
// 否则 Windows 上仍把该目录当作 cwd 的 powershell 会让 rm 得到 EPERM。
const closers = []
const track = (runtime) => {
closers.push(runtime)
return runtime
}
const runtime = track(createLocalShellRuntime({
autoCleanup: false,
buildEnv: async () => ({ ...process.env, TERM: 'dumb', PS1: '' }),
refreshToken: (session) => {
const line = process.platform === 'win32'
? "$env:MAO_TOKEN = 'tok'\n"
: "export MAO_TOKEN='tok'\n"
session.writeStdin(line)
},
resolveOutput: (_cid, shellId) => ({
absPath: path.join(dir, `${shellId}.out`),
displayPath: path.join(dir, `${shellId}.out`),
}),
...extra,
}))
t.after(() => {
for (const rt of closers) {
rt.closeAll()
rt.stopCleanup()
}
fs.rmSync(dir, { recursive: true, force: true })
})
return { runtime, dir, track }
}
test('shellSingleQuote escapes embedded quotes', () => {
assert.equal(shellSingleQuote("a'b"), `'a'\\''b'`)
})
test('exec captures stdout, stderr and the real exit code', async (t) => {
const { runtime, dir } = createRuntime(t)
const result = await runtime.handle(
{ command: process.platform === 'win32'
? "[Console]::Error.WriteLine('validation failed'); cmd /c exit 1"
: "printf 'validation failed\\n' >&2; false" },
{ conversationId: 11, workspace: dir, needApproval: false },
)
assert.equal(result.exit_code, 1)
assert.equal(result.completed, true)
assert.match(result.output, /validation failed/)
assert.equal(fs.readFileSync(result.output_file, 'utf8').includes('validation failed'), true)
const listed = await runtime.handle({ action: 'list' }, { conversationId: 11 })
assert.equal(listed.sessions.length, 0)
})
test('keep_session reuses cwd after cd and refreshes current_workdir', async (t) => {
const { runtime, dir } = createRuntime(t)
const sub = path.join(dir, 'sub')
fs.mkdirSync(sub)
const first = await runtime.handle(
{ command: 'cd sub', keep_session: true, session_id: 'sh-keep' },
{ conversationId: 12, workspace: dir, needApproval: false },
)
assert.equal(first.exit_code, 0)
// macOS 上 /var → /private/var,shell 的 pwd 返回解析后的真实路径。
assert.equal(fs.realpathSync(first.current_workdir), fs.realpathSync(sub))
const second = await runtime.handle(
{ command: 'pwd', keep_session: true, session_id: 'sh-keep' },
{ conversationId: 12, workspace: dir, needApproval: false },
)
assert.equal(fs.realpathSync(second.output.trim().split('\n').pop()), fs.realpathSync(sub))
const listed = await runtime.handle({ action: 'list' }, { conversationId: 12 })
assert.equal(listed.sessions.length, 1)
assert.equal(listed.sessions[0].session_id, 'sh-keep')
})
test('reused session honors workdir by cd-ing before the command', async (t) => {
const { runtime, dir } = createRuntime(t)
const sub = path.join(dir, 'nested')
fs.mkdirSync(sub)
await runtime.handle(
{ command: 'true', keep_session: true, session_id: 'sh-cd' },
{ conversationId: 13, workspace: dir, needApproval: false },
)
const moved = await runtime.handle(
{ command: 'pwd', keep_session: true, session_id: 'sh-cd', workdir: sub },
{ conversationId: 13, workspace: dir, needApproval: false },
)
assert.equal(moved.current_workdir, sub)
})
test('write_stdin returns immediately with a marker and refreshes token first', async (t) => {
const { runtime, dir, track } = createRuntime(t)
const writes = []
const withSpy = track(createLocalShellRuntime({
autoCleanup: false,
buildEnv: async () => ({ ...process.env, TERM: 'dumb', PS1: '' }),
refreshToken: (session) => {
writes.push('token')
const line = process.platform === 'win32'
? "$env:MAO_TOKEN = 'tok'\n"
: "export MAO_TOKEN='tok'\n"
session.writeStdin(line)
},
resolveOutput: (_cid, shellId) => ({
absPath: path.join(dir, `${shellId}.out`),
displayPath: path.join(dir, `${shellId}.out`),
}),
}))
await withSpy.handle(
{ command: 'true', keep_session: true, session_id: 'sh-in' },
{ conversationId: 14, workspace: dir, needApproval: false },
)
writes.length = 0
const started = Date.now()
const result = await withSpy.handle(
{ action: 'write_stdin', session_id: 'sh-in', input: 'echo hi' },
{ conversationId: 14, workspace: dir, needApproval: false },
)
assert.ok(Date.now() - started < 4000)
assert.equal(result.completed, true)
assert.match(result.output, /hi/)
assert.equal(writes[0], 'token')
})
test('kills the whole process group when a session closes', async (t) => {
const { runtime, dir } = createRuntime(t)
const command = process.platform === 'win32'
? "$p = Start-Process -FilePath powershell.exe -ArgumentList '-NoProfile','-Command','Start-Sleep 300' -WindowStyle Hidden -PassThru; $p.Id"
: 'sleep 300 & echo $!'
const started = await runtime.handle(
{ command, keep_session: true, session_id: 'sh-tree' },
{ conversationId: 15, workspace: dir, needApproval: false },
)
const childPid = Number(started.output.trim().split('\n').pop())
assert.ok(childPid > 0)
assert.doesNotThrow(() => process.kill(childPid, 0))
await runtime.handle({ action: 'close', session_id: 'sh-tree' }, { conversationId: 15 })
await new Promise((resolve) => setTimeout(resolve, 500))
assert.throws(() => process.kill(childPid, 0))
})
test('enforces per-conversation session limits and command length', async (t) => {
const { runtime, dir } = createRuntime(t, { maxSessionsPerConversation: 1 })
await runtime.handle(
{ command: 'true', keep_session: true, session_id: 'one' },
{ conversationId: 16, workspace: dir, needApproval: false },
)
const tooMany = await runtime.handle(
{ command: 'true', keep_session: true, session_id: 'two' },
{ conversationId: 16, workspace: dir, needApproval: false },
)
assert.match(tooMany.error, /Maximum number of shell sessions/)
const tooLong = await runtime.handle(
{ command: 'x'.repeat(MAX_COMMAND_LENGTH + 1) },
{ conversationId: 17, workspace: dir, needApproval: false },
)
assert.match(tooLong.error, /命令过长/)
})
test('defaults missing action to exec and reports unknown actions', async (t) => {
const { runtime, dir } = createRuntime(t)
const execed = await runtime.handle(
{ command: process.platform === 'win32' ? 'echo ok' : 'printf ok' },
{ conversationId: 18, workspace: dir, needApproval: false },
)
assert.equal(execed.exit_code, 0)
assert.match(execed.output, /ok/)
const unknown = await runtime.handle({ action: 'nope' }, { conversationId: 18 })
assert.match(unknown.error, /未知动作/)
})
test('denies exec when approval callback returns false', async (t) => {
const { runtime, dir } = createRuntime(t)
const result = await runtime.handle(
{ command: 'echo hi' },
{ conversationId: 19, workspace: dir, needApproval: true, approve: async () => false },
)
assert.equal(result.exit_code, -1)
assert.match(result.output, /User denied/)
})
test('exit code from the previous command does not leak into the next output', async (t) => {
const { runtime, dir } = createRuntime(t)
const failed = await runtime.handle(
{ command: process.platform === 'win32' ? 'cmd /c exit 3' : 'bash -c "exit 3"', keep_session: true, session_id: 'sh-exit' },
{ conversationId: 20, workspace: dir, needApproval: false },
)
assert.equal(failed.exit_code, 3)
const ok = await runtime.handle(
{ command: 'echo second', keep_session: true, session_id: 'sh-exit' },
{ conversationId: 20, workspace: dir, needApproval: false },
)
assert.equal(ok.exit_code, 0)
assert.equal(ok.output.trim(), 'second')
})
test('async starts the command before returning session_id and await_async collects output', async (t) => {
const { runtime, dir } = createRuntime(t)
const startedAt = Date.now()
const started = await runtime.handle(
{ command: process.platform === 'win32' ? 'sleep 1; echo async-ok' : 'sleep 1; printf async-ok', async: true, keep_session: true, session_id: 'sh-async' },
{ conversationId: 21, workspace: dir, needApproval: false },
)
assert.ok(Date.now() - startedAt < 800)
assert.equal(started.async, true)
assert.equal(started.session_id, 'sh-async')
const listed = await runtime.handle({ action: 'list' }, { conversationId: 21 })
assert.equal(listed.sessions.length, 1)
const blocked = await runtime.handle(
{ action: 'write_stdin', session_id: 'sh-async', input: 'echo no' },
{ conversationId: 21, workspace: dir, needApproval: false },
)
assert.match(blocked.error, /未完成的异步命令/)
const awaited = await runtime.handle(
{ action: 'await_async', session_id: 'sh-async' },
{ conversationId: 21 },
)
assert.equal(awaited.exit_code, 0)
assert.match(awaited.output, /async-ok/)
})
test('timeout seconds maps to yield_time_ms when yield_time_ms is omitted', async (t) => {
const { runtime, dir } = createRuntime(t)
const startedAt = Date.now()
const result = await runtime.handle(
{ command: 'sleep 2; echo late', timeout: 1 },
{ conversationId: 22, workspace: dir, needApproval: false },
)
const elapsed = Date.now() - startedAt
assert.ok(elapsed < 1800, `expected timeout around 1s, took ${elapsed}ms`)
assert.equal(result.completed, false)
assert.equal(result.exit_code, -1)
})
test('wait_for returns early while the command keeps running, await_async collects the rest', async (t) => {
const { runtime, dir } = createRuntime(t)
const startedAt = Date.now()
const early = await runtime.handle(
{
command: process.platform === 'win32'
? "echo 'Listening on 3000'; sleep 1; echo done"
: "printf 'Listening on 3000\\n'; sleep 1; printf 'done\\n'",
wait_for: 'Listening on',
keep_session: true,
session_id: 'sh-wait',
},
{ conversationId: 23, workspace: dir, needApproval: false },
)
assert.ok(Date.now() - startedAt < 900, 'wait_for should return before the command finishes')
assert.equal(early.completed, false)
assert.equal(early.matched, 'Listening on')
assert.match(early.output, /Listening on 3000/)
assert.match(early.message, /await_async/)
const alive = await runtime.handle({ action: 'list' }, { conversationId: 23 })
assert.equal(alive.sessions.length, 1)
const rest = await runtime.handle(
{ action: 'await_async', session_id: 'sh-wait', yield_time_ms: 5000 },
{ conversationId: 23 },
)
assert.equal(rest.completed, true)
assert.equal(rest.exit_code, 0)
// 提前放行后的输出不会丢,且不会重复交付已返回的部分
assert.match(rest.output, /done/)
assert.equal(/Listening on/.test(rest.output), false)
})
test('wait_for plus an immediate exit completes instead of asking to await a dead session', async (t) => {
const { runtime, dir } = createRuntime(t)
const result = await runtime.handle(
{
command: "printf 'record=SUCCESS tag=v1\\n'; exit 0",
wait_for: 'record=(SUCCESS|FAILURE|STOP)',
keep_session: true,
session_id: 'sh-wait-exit',
},
{ conversationId: 40, workspace: dir, needApproval: false },
)
assert.equal(result.completed, true)
assert.equal(result.exit_code, 0)
assert.equal(result.matched, 'record=SUCCESS')
assert.match(result.output, /record=SUCCESS/)
assert.match(result.message, /已退出/)
const listed = await runtime.handle({ action: 'list' }, { conversationId: 40 })
assert.equal(listed.sessions.length, 0)
})
test('await_async after wait_for still finishes when the shell later exits', async (t) => {
const { runtime, dir } = createRuntime(t)
const early = await runtime.handle(
{
command: process.platform === 'win32'
? "echo 'record=SUCCESS'; Start-Sleep -Milliseconds 500; exit 0"
: "printf 'record=SUCCESS\\n'; sleep 0.5; exit 0",
wait_for: 'record=SUCCESS',
keep_session: true,
session_id: 'sh-delay-exit',
},
{ conversationId: 41, workspace: dir, needApproval: false },
)
assert.equal(early.completed, false)
assert.equal(early.matched, 'record=SUCCESS')
await new Promise((resolve) => setTimeout(resolve, 800))
const rest = await runtime.handle(
{ action: 'await_async', session_id: 'sh-delay-exit', yield_time_ms: 2000 },
{ conversationId: 41 },
)
assert.equal(rest.completed, true)
assert.equal(rest.exit_code, 0)
assert.match(rest.message, /已退出/)
const listed = await runtime.handle({ action: 'list' }, { conversationId: 41 })
assert.equal(listed.sessions.length, 0)
})
test('await_async resumes a timed-out command without losing output', async (t) => {
const { runtime, dir } = createRuntime(t)
const first = await runtime.handle(
{ command: process.platform === 'win32' ? "sleep 1; echo late-line" : "sleep 1; printf 'late-line\\n'", yield_time_ms: 200, session_id: 'sh-resume' },
{ conversationId: 24, workspace: dir, needApproval: false },
)
assert.equal(first.completed, false)
const second = await runtime.handle(
{ action: 'await_async', session_id: 'sh-resume', yield_time_ms: 5000 },
{ conversationId: 24 },
)
assert.equal(second.completed, true)
assert.equal(second.exit_code, 0)
assert.match(second.output, /late-line/)
// keep_session 未开启且命令已结束,此时才回收会话
const listed = await runtime.handle({ action: 'list' }, { conversationId: 24 })
assert.equal(listed.sessions.length, 0)
})
test('write_stdin feeds a running command instead of queueing a new marker', async (t) => {
const { runtime, dir } = createRuntime(t)
// 只回显长度:避免命令把 stdin 里排队的 marker 回显行原样打出来,被误判为命令结束
const started = await runtime.handle(
{
command: process.platform === 'win32'
? "while ($true) { $line = [Console]::In.ReadLine(); if ($null -eq $line) { break }; Write-Output ('len:' + $line.Length) }"
: 'while read -r line; do printf \'len:%s\\n\' "${#line}"; done',
yield_time_ms: 300,
keep_session: true,
session_id: 'sh-stdin',
},
{ conversationId: 25, workspace: dir, needApproval: false },
)
assert.equal(started.completed, false)
const answered = await runtime.handle(
{ action: 'write_stdin', session_id: 'sh-stdin', input: 'hello', yield_time_ms: 800 },
{ conversationId: 25, workspace: dir, needApproval: false },
)
// 输入交给了正在运行的循环,而不是排在它后面等一个新 marker
assert.equal(answered.completed, false)
assert.match(answered.output, /len:5/)
const listed = await runtime.handle({ action: 'list' }, { conversationId: 25 })
assert.equal(listed.sessions.length, 1)
})
test('exec refuses to start while the session still has an unfinished command', async (t) => {
const { runtime, dir } = createRuntime(t)
const pending = await runtime.handle(
{ command: 'sleep 2', yield_time_ms: 150, keep_session: true, session_id: 'sh-busy' },
{ conversationId: 26, workspace: dir, needApproval: false },
)
assert.equal(pending.completed, false)
const rejected = await runtime.handle(
{ command: 'echo nope', keep_session: true, session_id: 'sh-busy' },
{ conversationId: 26, workspace: dir, needApproval: false },
)
assert.match(rejected.error, /未结束的命令/)
})
test('rejects an invalid or over-long wait_for pattern', async (t) => {
const { runtime, dir } = createRuntime(t)
const invalid = await runtime.handle(
{ command: 'true', wait_for: '([' },
{ conversationId: 27, workspace: dir, needApproval: false },
)
assert.match(invalid.error, /wait_for 不是合法正则/)
const tooLong = await runtime.handle(
{ command: 'true', wait_for: 'x'.repeat(201) },
{ conversationId: 27, workspace: dir, needApproval: false },
)
assert.match(tooLong.error, /wait_for 过长/)
})
test('does not leak a marker that arrives split across two chunks', async (t) => {
const spawned = []
const { runtime, dir } = createRuntime(t, {
spawn: (command, args, options) => {
const child = childProcess.spawn(command, args, options)
spawned.push(child)
return child
},
})
const pending = runtime.handle(
{ command: 'sleep 5', yield_time_ms: 300, keep_session: true, session_id: 'sh-split' },
{ conversationId: 28, workspace: dir, needApproval: false },
)
// spawn 发生在 handle 内部的 await 之后,先等到子进程创建
while (spawned.length === 0) await new Promise((resolve) => setImmediate(resolve))
// 结束标记被拆成两个 chunk 到达:前半段不能作为正文交给模型,也不能落盘
spawned[0].stdout.emit('data', 'partial-output\n__CMD')
const early = await pending
assert.equal(early.completed, false)
assert.equal(early.output, 'partial-output\n')
assert.equal(fs.readFileSync(early.output_file, 'utf8'), 'partial-output\n')
await runtime.handle({ action: 'close', session_id: 'sh-split' }, { conversationId: 28 })
})
test('write_stdin on a finished-but-unconsumed command is rejected instead of mis-answered', async (t) => {
const { runtime, dir } = createRuntime(t)
const early = await runtime.handle(
{ command: process.platform === 'win32' ? 'Start-Sleep -Milliseconds 400; echo finished' : 'sleep 0.4; echo finished', yield_time_ms: 150, keep_session: true, session_id: 'sh-stale' },
{ conversationId: 29, workspace: dir, needApproval: false },
)
assert.equal(early.completed, false)
// 命令自然结束后 marker 已在缓冲区但无人消费:输入若被直喂会被 bash 当作新命令,
// 而续读会立刻命中旧 marker,把旧输出误当成输入的应答
await new Promise((resolve) => setTimeout(resolve, 700))
const stale = await runtime.handle(
{ action: 'write_stdin', session_id: 'sh-stale', input: 'echo next', yield_time_ms: 2000 },
{ conversationId: 29, workspace: dir, needApproval: false },
)
assert.match(stale.error, /已结束但结果尚未收取/)
// 输入不能被执行:结果仍可正常收取,且后续输出里没有 next 的痕迹
const collected = await runtime.handle(
{ action: 'await_async', session_id: 'sh-stale', yield_time_ms: 2000 },
{ conversationId: 29, workspace: dir },
)
assert.equal(collected.completed, true)
assert.match(collected.output, /finished/)
const probe = await runtime.handle(
{ command: 'echo probe', keep_session: true, session_id: 'sh-stale', yield_time_ms: 2000 },
{ conversationId: 29, workspace: dir, needApproval: false },
)
assert.equal(probe.output.includes('next'), false)
})
test('double await_async cannot fake success or lose the session', async (t) => {
const { runtime, dir } = createRuntime(t)
const early = await runtime.handle(
{ command: process.platform === 'win32' ? 'Start-Sleep -Milliseconds 400; echo done' : 'sleep 0.4; echo done', yield_time_ms: 150, keep_session: true, session_id: 'sh-race' },
{ conversationId: 30, workspace: dir, needApproval: false },
)
assert.equal(early.completed, false)
await new Promise((resolve) => setTimeout(resolve, 700))
const [first, second] = await Promise.all([
runtime.handle({ action: 'await_async', session_id: 'sh-race', yield_time_ms: 3000 }, { conversationId: 30, workspace: dir }),
runtime.handle({ action: 'await_async', session_id: 'sh-race', yield_time_ms: 3000 }, { conversationId: 30, workspace: dir }),
])
const got = [first, second].find((r) => !r.error)
const other = [first, second].find((r) => r !== got)
assert.equal(got.completed, true)
assert.match(got.output, /done/)
assert.match(other.error, /(没有未结束的命令|已被并行调用收取)/)
// 会话必须仍然可用,不能被假 pending 卡死
const probe = await runtime.handle(
{ command: 'echo alive', keep_session: true, session_id: 'sh-race', yield_time_ms: 2000 },
{ conversationId: 30, workspace: dir, needApproval: false },
)
assert.equal(probe.error, undefined)
assert.match(probe.output, /alive/)
})
test('powershell host executes commands, reports exit code and stderr via injected protocol', async (t) => {
if (process.platform === 'win32') {
// 真实 win32 上 runtime 自身就会用 powershell.exe
const { runtime, dir } = createRuntime(t)
const result = await runtime.handle(
{ command: "Write-Output 'ps-ok'" },
{ conversationId: 41, workspace: dir, needApproval: false },
)
assert.equal(result.exit_code, 0)
assert.match(result.output, /ps-ok/)
return
}
// 非 Windows:用 pwsh 若可用则验证完整协议,否则跳过
let pwsh = null
try { pwsh = require('child_process').execFileSync('which', ['pwsh']).toString().trim() } catch { /* not installed */ }
if (!pwsh) { t.skip('pwsh not available on this platform'); return }
const { runtime, dir } = createRuntime(t, {
protocol: {
command: pwsh,
args: ['-NoProfile', '-NonInteractive', '-Command', '-'],
isPowerShell: true,
commandDone: (m) => `if ($?) { echo "${m} 0" } else { echo "${m} 1" }`,
markerEcho: (m) => `echo ${m}`,
chdir: (d) => "Set-Location -LiteralPath '" + d.replace(/'/g, "''") + "'",
envSet: (n, v) => `$env:${n} = '${String(v).replace(/'/g, "''")}'`,
envUnset: (n) => `Remove-Item Env:${n} -ErrorAction SilentlyContinue`,
initScript: '',
},
})
const result = await runtime.handle(
{ command: "Write-Output 'ps-ok'; Write-Error 'ps-err' -ErrorAction Continue" },
{ conversationId: 41, workspace: dir, needApproval: false },
)
assert.equal(result.completed, true)
assert.match(result.output, /ps-ok/)
assert.match(result.output, /ps-err/)
const fail = await runtime.handle(
{ command: 'cmdlet-that-does-not-exist-xyz' },
{ conversationId: 41, workspace: dir, needApproval: false },
)
assert.equal(fail.exit_code, 1)
})
test('spawn failure surfaces an explicit error instead of silent exit 1', async (t) => {
const { runtime, dir } = createRuntime(t, {
protocol: {
command: 'mao-nonexistent-shell-binary-xyz',
args: ['-c'],
isPowerShell: false,
commandDone: (m) => `echo ${m} $?`,
markerEcho: (m) => `echo ${m}`,
chdir: (d) => 'cd ' + d,
envSet: (n, v) => `export ${n}=${v}`,
envUnset: (n) => `unset ${n}`,
initScript: '',
},
})
const result = await runtime.handle(
{ command: 'echo hi' },
{ conversationId: 42, workspace: dir, needApproval: false },
)
assert.equal(result.completed, true)
assert.match(String(result.error || result.spawn_error || ''), /ENOENT|spawn/i)
assert.notEqual(result.exit_code, 0)
})