Ctrl K
ComunidadGuías…

desktop/electron/localShell.test.cjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
'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)
})