From 09967cf38df649f2cda130c7c348c10642c2a8d8 Mon Sep 17 00:00:00 2001 From: Fnine59 <36078040+Fnine59@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:30:31 +0000 Subject: [PATCH 1/2] fix(bash): honor cwd for backgrounded shell commands --- .changeset/steady-shell-cwd.md | 5 + .../src/agent/tools/os/bash/bash.ts | 2 +- .../src/agent/tools/os/bash/bashTool.ts | 29 ++--- .../os/backends/node-local/tools/bash.test.ts | 55 ++++++-- .../src/tools/builtin/shell/bash.ts | 27 ++-- packages/agent-core/test/tools/bash.test.ts | 118 +++++++++++++++--- .../test/tools/shell-quoting.test.ts | 5 +- 7 files changed, 183 insertions(+), 58 deletions(-) create mode 100644 .changeset/steady-shell-cwd.md diff --git a/.changeset/steady-shell-cwd.md b/.changeset/steady-shell-cwd.md new file mode 100644 index 00000000000..b9821b5d514 --- /dev/null +++ b/.changeset/steady-shell-cwd.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Keep Bash scripts in the requested working directory after backgrounded commands while preserving logical symlink paths. diff --git a/packages/agent-core-v2/src/agent/tools/os/bash/bash.ts b/packages/agent-core-v2/src/agent/tools/os/bash/bash.ts index c2157a5805b..9d6065a5a1c 100644 --- a/packages/agent-core-v2/src/agent/tools/os/bash/bash.ts +++ b/packages/agent-core-v2/src/agent/tools/os/bash/bash.ts @@ -2,7 +2,7 @@ * `tools` domain — `IBashTool` contract. * * Public contract of Bash, the model's shell command runner: the command runs - * as `cd && ` inside the session's working directory, with a + * with the requested working directory at the process boundary, with a * manager-owned timeout deadline — a foreground command whose deadline fires * is moved to the background instead of being killed, and background tasks * report completion automatically in a later turn. diff --git a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts index 2454af736f2..ac42a9467c4 100644 --- a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts @@ -3,8 +3,8 @@ * runner. * * Invokes the execution-environment shell (POSIX bash; Git Bash on Windows) - * through the injected `ISessionProcessRunner`. The command runs as - * `cd && ` inside the environment's working directory. + * through the injected `ISessionProcessRunner`, with cwd passed at the process + * boundary rather than injected into the user's shell script. * * Collaborators injected via constructor: * - `runner` — `ISessionProcessRunner`, spawns the shell process @@ -33,8 +33,8 @@ * * Ported from v1. The * v1 `process.env` spread is intentionally dropped: v2's `ISessionProcessRunner.exec` - * already overlays the per-call `env` on `process.env`, so only the - * noninteractive knobs are passed here. + * already overlays the per-call `env` on `process.env`, so only shell-specific + * overrides are passed here. * * Bound at Agent scope; self-registers via `registerAgentToolService(...)` at module * load. @@ -48,6 +48,7 @@ import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionProcessRunner, type IProcess } from '#/session/process/processRunner'; import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import type { ExecutableToolResult, ToolExecution, ToolUpdate } from '#/tool/toolContract'; +import { canonicalizePath } from '#/tool/path-access'; import { type ExecutableToolResultBuilderResult, ToolResultBuilder, @@ -191,20 +192,17 @@ export class BashTool implements IBashTool { private spawn(effectiveCwd: string, command: string): Promise { const shellCwd = this.isWindowsBash ? windowsPathToPosixPath(effectiveCwd) : effectiveCwd; - const shellArgs = [ - this.env.shellPath, - '-c', - `cd ${shellQuote(shellCwd)} && ${command}`, - ]; + const shellArgs = [this.env.shellPath, '-c', command]; - const noninteractiveEnv: Record = { + const shellEnv: Record = { NO_COLOR: '1', TERM: 'dumb', GIT_TERMINAL_PROMPT: process.env['GIT_TERMINAL_PROMPT'] ?? '0', + PWD: shellCwd, SHELL: this.env.shellPath, }; - return this.runner.exec(shellArgs, { env: noninteractiveEnv }); + return this.runner.exec(shellArgs, { cwd: effectiveCwd, env: shellEnv }); } private async execution( @@ -219,7 +217,6 @@ export class BashTool implements IBashTool { const startsInBackground = args.run_in_background === true; const foregroundTimeoutMs = normalizeTimeoutMs(args.timeout, false); const command = this.isWindowsBash ? rewriteWindowsNullRedirect(args.command) : args.command; - const effectiveCwd = args.cwd ?? this.ctx.cwd; const description = startsInBackground ? args.description!.trim() : foregroundDescription(args); const timeoutMs = startsInBackground ? args.disable_timeout @@ -230,6 +227,10 @@ export class BashTool implements IBashTool { const builder = new ToolResultBuilder(); let proc: IProcess; try { + const effectiveCwd = + args.cwd === undefined + ? this.ctx.cwd + : canonicalizePath(args.cwd, this.ctx.cwd, this.env.pathClass); proc = await this.spawn(effectiveCwd, command); } catch (error) { return { @@ -480,10 +481,6 @@ async function killSpawnedProcess(proc: IProcess): Promise { } } -function shellQuote(s: string): string { - return `'${s.replaceAll("'", "'\\''")}'`; -} - function windowsPathToPosixPath(path: string): string { if (path.startsWith('\\\\')) { return path.replaceAll('\\', '/'); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts index 1360d01b2cc..775a29f831a 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts @@ -821,9 +821,11 @@ describe('BashTool', () => { expect(exec).toHaveBeenCalledTimes(1); const [argv, execOptions] = exec.mock.calls[0]!; - expect(argv).toEqual(['/bin/bash', '-c', "cd '/workspace' && printf ok"]); + expect(argv).toEqual(['/bin/bash', '-c', 'printf ok']); + expect(execOptions?.cwd).toBe('/workspace'); expect(execOptions?.env).toMatchObject({ NO_COLOR: '1', + PWD: '/workspace', TERM: 'dumb', }); expect(proc.stdin.end).toHaveBeenCalledTimes(1); @@ -839,7 +841,36 @@ describe('BashTool', () => { await executeTool(tool, context({ command: 'pwd', cwd: '/tmp/project', timeout: 60 })); - expect(exec.mock.calls[0]?.[0]).toEqual(['/bin/bash', '-c', "cd '/tmp/project' && pwd"]); + expect(exec.mock.calls[0]?.[0]).toEqual(['/bin/bash', '-c', 'pwd']); + expect(exec.mock.calls[0]?.[1]?.cwd).toBe('/tmp/project'); + expect(exec.mock.calls[0]?.[1]?.env).toMatchObject({ PWD: '/tmp/project' }); + }); + + it('resolves a relative args.cwd against the session cwd', async () => { + const { runner, exec } = createTestRunner(processWithOutput({ stdout: 'sub\n' })); + const tool = bashTool(runner, posixEnv, createTestCtx('/workspace/project')); + + await executeTool(tool, context({ command: 'pwd', cwd: 'packages/ui', timeout: 60 })); + + expect(exec.mock.calls[0]?.[0]).toEqual(['/bin/bash', '-c', 'pwd']); + expect(exec.mock.calls[0]?.[1]?.cwd).toBe('/workspace/project/packages/ui'); + }); + + it('resolves a relative Windows args.cwd without Git Bash path conversion', async () => { + const { runner, exec } = createTestRunner(processWithOutput({ stdout: 'sub\n' })); + const tool = bashTool(runner, windowsBashEnv, createTestCtx('C:\\Users\\me\\project')); + + await executeTool(tool, context({ command: 'pwd', cwd: 'packages\\ui', timeout: 60 })); + + expect(exec.mock.calls[0]?.[0]).toEqual([ + 'C:\\Program Files\\Git\\bin\\bash.exe', + '-c', + 'pwd', + ]); + expect(exec.mock.calls[0]?.[1]?.cwd).toBe('C:/Users/me/project/packages/ui'); + expect(exec.mock.calls[0]?.[1]?.env).toMatchObject({ + PWD: '/c/Users/me/project/packages/ui', + }); }); it('uses the kaos cwd as the default working directory', async () => { @@ -848,7 +879,8 @@ describe('BashTool', () => { await executeTool(tool, context({ command: 'pwd', timeout: 60 })); - expect(exec.mock.calls[0]?.[0]).toEqual(['/bin/bash', '-c', "cd '/var/app' && pwd"]); + expect(exec.mock.calls[0]?.[0]).toEqual(['/bin/bash', '-c', 'pwd']); + expect(exec.mock.calls[0]?.[1]?.cwd).toBe('/var/app'); }); it('uses Git Bash semantics on Windows', async () => { @@ -863,9 +895,13 @@ describe('BashTool', () => { expect(argv).toEqual([ 'C:\\Program Files\\Git\\bin\\bash.exe', '-c', - "cd '/c/Users/me/project' && echo ok 2>/dev/null", + 'echo ok 2>/dev/null', ]); - expect(execOptions?.env).toMatchObject({ SHELL: 'C:\\Program Files\\Git\\bin\\bash.exe' }); + expect(execOptions?.cwd).toBe('C:\\Users\\me\\project'); + expect(execOptions?.env).toMatchObject({ + PWD: '/c/Users/me/project', + SHELL: 'C:\\Program Files\\Git\\bin\\bash.exe', + }); expect(result).toMatchObject({ output: 'ok\n', isError: false, @@ -1207,7 +1243,8 @@ describe('BashTool', () => { await executeTool(tool, context({ command: 'ls 2>nul', timeout: 60 })); const argv = exec.mock.calls[0]?.[0] as readonly string[]; - expect(argv[2]).toBe("cd '/c/Users/me/project' && ls 2>/dev/null"); + expect(argv[2]).toBe('ls 2>/dev/null'); + expect(exec.mock.calls[0]?.[1]?.cwd).toBe('C:\\Users\\me\\project'); }); it('passes nul-redirect through unchanged on Linux so the argv keeps the literal file target', async () => { @@ -1217,7 +1254,8 @@ describe('BashTool', () => { await executeTool(tool, context({ command: 'ls 2>nul', timeout: 60 })); const argv = exec.mock.calls[0]?.[0] as readonly string[]; - expect(argv[2]).toBe("cd '/workspace' && ls 2>nul"); + expect(argv[2]).toBe('ls 2>nul'); + expect(exec.mock.calls[0]?.[1]?.cwd).toBe('/workspace'); }); it('exposes a shell description that documents /bin/bash, TaskOutput/TaskStop, safety and efficiency sections, and background semantics', () => { @@ -1653,8 +1691,9 @@ describe('BashTool background mode', () => { expect(argv).toEqual([ 'C:\\Program Files\\Git\\bin\\bash.exe', '-c', - "cd '/c/Users/me/project' && echo ok 2>/dev/null", + 'echo ok 2>/dev/null', ]); + expect(execOptions?.cwd).toBe('C:\\Users\\me\\project'); expect(execOptions?.env).toMatchObject({ SHELL: 'C:\\Program Files\\Git\\bin\\bash.exe' }); expect(secondProc.kill).toHaveBeenCalledWith('SIGTERM'); expect(results).toContainEqual(expect.objectContaining({ isError: false })); diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index 46d6d9ab0d0..44489404c99 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -29,6 +29,7 @@ import { ProcessBackgroundTask, type BackgroundManager } from '../../../agent/ba import type { BuiltinTool } from '../../../agent/tool'; import type { ExecutableToolResult, ToolExecution, ToolUpdate } from '../../../loop/types'; import { renderPrompt } from '../../../utils/render-prompt'; +import { canonicalizePath } from '../../policies/path-access'; import { toInputJsonSchema } from '../../support/input-schema'; import { literalRulePattern, matchesGlobRuleSubject } from '../../support/rule-match'; import { @@ -272,13 +273,9 @@ export class BashTool implements BuiltinTool { private spawn(effectiveCwd: string, command: string): Promise { const shellCwd = this.isWindowsBash ? windowsPathToPosixPath(effectiveCwd) : effectiveCwd; - const shellArgs = [ - this.kaos.osEnv.shellPath, - '-c', - `cd ${shellQuote(shellCwd)} && ${command}`, - ]; + const shellArgs = [this.kaos.osEnv.shellPath, '-c', command]; - const noninteractiveEnv: Record = { + const shellEnv: Record = { NO_COLOR: '1', TERM: 'dumb', // Default to '0' so git fails fast on private remotes if a TTY happens @@ -288,13 +285,16 @@ export class BashTool implements BuiltinTool { SHELL: this.kaos.osEnv.shellPath, }; - // Merge ambient env + noninteractive knobs so tools like git / node + // Merge ambient env + shell-specific overrides so tools like git / node // don't open a pager and paints don't colour the stream. const mergedEnv: Record = { ...(process.env as Record), - ...noninteractiveEnv, + ...shellEnv, }; - return this.kaos.execWithEnv(shellArgs, mergedEnv); + return this.kaos + .withCwd(effectiveCwd) + .withEnv({ PWD: shellCwd }) + .execWithEnv(shellArgs, mergedEnv); } /** @@ -319,7 +319,6 @@ export class BashTool implements BuiltinTool { const startsInBackground = args.run_in_background === true; const foregroundTimeoutMs = normalizeForegroundTimeoutMs(args.timeout); const command = this.isWindowsBash ? rewriteWindowsNullRedirect(args.command) : args.command; - const effectiveCwd = args.cwd ?? this.cwd; const description = startsInBackground ? args.description!.trim() : foregroundDescription(args); const timeoutMs = startsInBackground ? args.disable_timeout @@ -330,6 +329,10 @@ export class BashTool implements BuiltinTool { const builder = new ToolResultBuilder(); let proc: KaosProcess; try { + const effectiveCwd = + args.cwd === undefined + ? this.cwd + : canonicalizePath(args.cwd, this.cwd, this.kaos.pathClass()); proc = await this.spawn(effectiveCwd, command); } catch (error) { return { @@ -603,10 +606,6 @@ async function killSpawnedProcess(proc: KaosProcess): Promise { } } -function shellQuote(s: string): string { - return `'${s.replaceAll("'", "'\\''")}'`; -} - function windowsPathToPosixPath(path: string): string { if (path.startsWith('\\\\')) { return path.replaceAll('\\', '/'); diff --git a/packages/agent-core/test/tools/bash.test.ts b/packages/agent-core/test/tools/bash.test.ts index 29be1910eb7..272d1129a95 100644 --- a/packages/agent-core/test/tools/bash.test.ts +++ b/packages/agent-core/test/tools/bash.test.ts @@ -1,14 +1,22 @@ -import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { PassThrough, Readable, type Writable } from 'node:stream'; -import type { Environment, KaosProcess } from '@moonshot-ai/kaos'; +import type { Environment, Kaos, KaosProcess } from '@moonshot-ai/kaos'; import { describe, expect, it, vi } from 'vitest'; import { type BashInput, BashInputSchema, BashTool } from '../../src/tools/builtin/shell/bash'; import { createBackgroundManager, registerProcess } from '../agent/background/helpers'; -import { createFakeKaos } from './fixtures/fake-kaos'; +import { testKaos } from '../fixtures/test-kaos'; +import { createFakeKaos, toolContentString } from './fixtures/fake-kaos'; import { executeTool } from './fixtures/execute-tool'; const posixEnv: Environment = { @@ -441,9 +449,10 @@ describe('BashTool', () => { expect(execWithEnv).toHaveBeenCalledTimes(1); const [argv, env] = execWithEnv.mock.calls[0]!; - expect(argv).toEqual(['/bin/bash', '-c', "cd '/workspace' && printf ok"]); + expect(argv).toEqual(['/bin/bash', '-c', 'printf ok']); expect(env).toMatchObject({ NO_COLOR: '1', + PWD: '/workspace', TERM: 'dumb', }); expect(proc.stdin.end).toHaveBeenCalledTimes(1); @@ -456,17 +465,93 @@ describe('BashTool', () => { it('uses args.cwd when provided', async () => { const execWithEnv = vi.fn().mockResolvedValue(processWithOutput({ stdout: 'sub\n' })); - const tool = bashTool( - createFakeKaos({ execWithEnv, osEnv: posixEnv }), - '/workspace', - createBackgroundManager().manager, - ); + const withCwd = vi.fn().mockReturnValue(createFakeKaos({ execWithEnv, osEnv: posixEnv })); + const kaos: Kaos = { ...createFakeKaos({ osEnv: posixEnv }), withCwd }; + const tool = bashTool(kaos, '/workspace', createBackgroundManager().manager); await executeTool(tool, context({ command: 'pwd', cwd: '/tmp/project', timeout: 60 })); - expect(execWithEnv.mock.calls[0]?.[0]).toEqual(['/bin/bash', '-c', "cd '/tmp/project' && pwd"]); + expect(execWithEnv.mock.calls[0]?.[0]).toEqual(['/bin/bash', '-c', 'pwd']); + expect(withCwd).toHaveBeenCalledWith('/tmp/project'); + expect(execWithEnv.mock.calls[0]?.[1]).toMatchObject({ PWD: '/tmp/project' }); + }); + + it('resolves a relative args.cwd against the session cwd', async () => { + const execWithEnv = vi.fn().mockResolvedValue(processWithOutput({ stdout: 'sub\n' })); + const withCwd = vi.fn().mockReturnValue(createFakeKaos({ execWithEnv, osEnv: posixEnv })); + const kaos: Kaos = { ...createFakeKaos({ osEnv: posixEnv }), withCwd }; + const tool = bashTool(kaos, '/workspace/project', createBackgroundManager().manager); + + await executeTool(tool, context({ command: 'pwd', cwd: 'packages/ui', timeout: 60 })); + + expect(execWithEnv.mock.calls[0]?.[0]).toEqual(['/bin/bash', '-c', 'pwd']); + expect(withCwd).toHaveBeenCalledWith('/workspace/project/packages/ui'); + }); + + it('resolves a relative Windows args.cwd to a native process cwd', async () => { + const execWithEnv = vi.fn().mockResolvedValue(processWithOutput({ stdout: 'sub\n' })); + const withCwd = vi.fn().mockReturnValue(createFakeKaos({ execWithEnv, osEnv: windowsBashEnv })); + const kaos: Kaos = { ...createFakeKaos({ osEnv: windowsBashEnv }), withCwd }; + const tool = bashTool(kaos, 'C:\\Users\\me\\project', createBackgroundManager().manager); + + await executeTool(tool, context({ command: 'pwd', cwd: 'packages\\ui', timeout: 60 })); + + expect(execWithEnv.mock.calls[0]?.[0]).toEqual([ + 'C:\\Program Files\\Git\\bin\\bash.exe', + '-c', + 'pwd', + ]); + expect(withCwd).toHaveBeenCalledWith('C:/Users/me/project/packages/ui'); + expect(execWithEnv.mock.calls[0]?.[1]).toMatchObject({ + PWD: '/c/Users/me/project/packages/ui', + }); }); + it.skipIf(process.platform === 'win32')( + 'keeps later lines in cwd when the first command is backgrounded', + async () => { + const cwd = mkdtempSync(join(tmpdir(), 'kimi-bash-cwd-')); + try { + const tool = bashTool(testKaos, '/workspace'); + + const result = await executeTool( + tool, + context({ + command: 'true &\npwd -P\nwait', + cwd, + timeout: 60, + }), + ); + + expect(result).toMatchObject({ isError: false }); + expect(toolContentString(result).trim()).toBe(realpathSync(cwd)); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'preserves the logical cwd for a symlinked workspace over a stale kaos PWD', + async () => { + const root = mkdtempSync(join(tmpdir(), 'kimi-bash-cwd-')); + const target = join(root, 'target'); + const cwd = join(root, 'workspace'); + mkdirSync(target); + symlinkSync(target, cwd, 'dir'); + + try { + const tool = bashTool(testKaos.withEnv({ PWD: target }), '/workspace'); + const result = await executeTool(tool, context({ command: 'pwd', cwd, timeout: 60 })); + + expect(result).toMatchObject({ isError: false }); + expect(toolContentString(result).trim()).toBe(cwd); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); + it('uses Git Bash semantics on Windows', async () => { const proc = processWithOutput({ stdout: 'ok\n' }); const execWithEnv = vi.fn().mockResolvedValue(proc); @@ -482,9 +567,12 @@ describe('BashTool', () => { expect(argv).toEqual([ 'C:\\Program Files\\Git\\bin\\bash.exe', '-c', - "cd '/c/Users/me/project' && echo ok 2>/dev/null", + 'echo ok 2>/dev/null', ]); - expect(env).toMatchObject({ SHELL: 'C:\\Program Files\\Git\\bin\\bash.exe' }); + expect(env).toMatchObject({ + PWD: '/c/Users/me/project', + SHELL: 'C:\\Program Files\\Git\\bin\\bash.exe', + }); expect(result).toMatchObject({ output: 'ok\n', isError: false, @@ -1073,7 +1161,7 @@ describe('BashTool', () => { expect(argv).toEqual([ 'C:\\Program Files\\Git\\bin\\bash.exe', '-c', - "cd '/c/Users/me/project' && echo ok 2>/dev/null", + 'echo ok 2>/dev/null', ]); expect(env).toMatchObject({ SHELL: 'C:\\Program Files\\Git\\bin\\bash.exe' }); expect(secondProc.kill).toHaveBeenCalledWith('SIGTERM'); @@ -1398,7 +1486,7 @@ describe('BashTool', () => { await executeTool(tool, context({ command: 'ls 2>nul', timeout: 60 })); const argv = execWithEnv.mock.calls[0]?.[0] as readonly string[]; - expect(argv[2]).toBe("cd '/c/Users/me/project' && ls 2>/dev/null"); + expect(argv[2]).toBe('ls 2>/dev/null'); }); it('passes nul-redirect through unchanged on Linux so the argv keeps the literal file target', async () => { @@ -1408,7 +1496,7 @@ describe('BashTool', () => { await executeTool(tool, context({ command: 'ls 2>nul', timeout: 60 })); const argv = execWithEnv.mock.calls[0]?.[0] as readonly string[]; - expect(argv[2]).toBe("cd '/workspace' && ls 2>nul"); + expect(argv[2]).toBe('ls 2>nul'); }); it('exposes a shell description that documents /bin/bash, TaskOutput/TaskStop, safety and efficiency sections, and background semantics', () => { diff --git a/packages/agent-core/test/tools/shell-quoting.test.ts b/packages/agent-core/test/tools/shell-quoting.test.ts index 62a17a7188b..96f4cceb2af 100644 --- a/packages/agent-core/test/tools/shell-quoting.test.ts +++ b/packages/agent-core/test/tools/shell-quoting.test.ts @@ -71,10 +71,7 @@ function captureCommandRewrite( signal, }).then(() => { const argv = execWithEnv.mock.calls[0]?.[0] as readonly string[]; - // The shell wrapper is "cd '' && "; isolate the rewrite. - const wrapped = argv[2]!; - const match = /^cd '[^']+' && (.*)$/.exec(wrapped)!; - return { rewritten: match[1]!, argv }; + return { rewritten: argv[2]!, argv }; }); } From a0e84ad15ca18a8462deb41b16880b0cbd7068b6 Mon Sep 17 00:00:00 2001 From: Fnine59 Date: Mon, 17 Aug 2026 15:50:10 +0800 Subject: [PATCH 2/2] fix(bash): align cwd handling with runtime process API --- .../src/agent/tools/os/bash/bashTool.ts | 93 ++++++------ .../os/backends/node-local/tools/bash.test.ts | 135 +++++++++++------- 2 files changed, 129 insertions(+), 99 deletions(-) diff --git a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts index ac42a9467c4..e1a9cf52d68 100644 --- a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts @@ -3,13 +3,13 @@ * runner. * * Invokes the execution-environment shell (POSIX bash; Git Bash on Windows) - * through the injected `ISessionProcessRunner`, with cwd passed at the process - * boundary rather than injected into the user's shell script. + * through the active runtime's process capability, with cwd passed at the + * process boundary rather than injected into the user's shell script. * * Collaborators injected via constructor: - * - `runner` — `ISessionProcessRunner`, spawns the shell process - * - `env` — `IHostEnvironment`, host OS / shell probe (osKind / shellName / shellPath) + * - `runtime` — `IAgentRuntimeService`, supplies the process capability and shell environment * - `ctx` — `ISessionContext`, session cwd used to render the shell prompt + * - `workspaceCtx` — `ISessionWorkspaceContext`, workspace roots used to resolve cwd * - `tasks` — `IAgentTaskService`, owns foreground/detached task * lifecycle (timeouts, detach, user interrupt) * - `toolPolicy` — `IAgentToolPolicyService`, gates background execution on @@ -17,7 +17,7 @@ * - `config` — `IConfigService`, task config (auto-background on * timeout, detach timeout) * - * Execution goes through `ISessionProcessRunner`, never directly via + * Execution goes through `IHostProcessService`, never directly via * `node:child_process`. * * Hardening: @@ -31,10 +31,8 @@ * - stdout/stderr are captured by `ProcessTask` for task output; * foreground runs pass a callback to collect chunks for this call. * - * Ported from v1. The - * v1 `process.env` spread is intentionally dropped: v2's `ISessionProcessRunner.exec` - * already overlays the per-call `env` on `process.env`, so only shell-specific - * overrides are passed here. + * Ported from v1. The host process backend overlays the per-call `env` on + * `process.env`, so only shell-specific overrides are passed here. * * Bound at Agent scope; self-registers via `registerAgentToolService(...)` at module * load. @@ -43,12 +41,14 @@ import { IAgentTaskService } from '#/agent/task/task'; import { resolveAgentTaskConfig } from '#/agent/task/configSection'; import { IConfigService } from '#/app/config/config'; -import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import type { HostEnvironmentInfo } from '#/os/interface/hostEnvironment'; +import type { IHostProcess, IHostProcessService } from '#/os/interface/hostProcess'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { ISessionProcessRunner, type IProcess } from '#/session/process/processRunner'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { IAgentRuntimeService, inspectAgentRuntime } from '#/agent/runtimeBinding/agentRuntime'; +import { RuntimeWorkspaceView } from '#/runtime/runtimeWorkspaceView'; import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import type { ExecutableToolResult, ToolExecution, ToolUpdate } from '#/tool/toolContract'; -import { canonicalizePath } from '#/tool/path-access'; import { type ExecutableToolResultBuilderResult, ToolResultBuilder, @@ -89,7 +89,7 @@ function normalizeTimeoutMs(timeout: number | undefined, isBackground: boolean): return Math.min(value, timeoutCapS(isBackground)) * MS_PER_SECOND; } -async function disposeProcess(proc: IProcess): Promise { +async function disposeProcess(proc: IHostProcess): Promise { try { await proc.dispose(); } catch { @@ -128,21 +128,14 @@ export class BashTool implements IBashTool { readonly name = 'Bash' as const; readonly parameters: Record = toInputJsonSchema(BashInputSchema); - private readonly isWindowsBash: boolean; - - private readonly renderedDescription: string; - constructor( - @ISessionProcessRunner private readonly runner: ISessionProcessRunner, - @IHostEnvironment private readonly env: IHostEnvironment, + @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, @ISessionContext private readonly ctx: ISessionContext, + @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, @IAgentTaskService private readonly tasks: IAgentTaskService, @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, @IConfigService private readonly config: IConfigService, - ) { - this.isWindowsBash = this.env.osKind === 'Windows'; - this.renderedDescription = renderBashDescription(this.env.shellName); - } + ) {} private allowBackground(): boolean { return ( @@ -163,11 +156,12 @@ export class BashTool implements IBashTool { } get description(): string { - if (!this.allowBackground()) return withoutBackgroundDescription(this.renderedDescription); + const renderedDescription = renderBashDescription(inspectAgentRuntime(this.runtime).environment.shellName); + if (!this.allowBackground()) return withoutBackgroundDescription(renderedDescription); if (!this.autoBackgroundOnTimeout()) { - return withoutAutoBackgroundOnTimeout(this.renderedDescription); + return withoutAutoBackgroundOnTimeout(renderedDescription); } - return this.renderedDescription; + return renderedDescription; } resolveExecution(args: BashInput): ToolExecution { @@ -190,19 +184,22 @@ export class BashTool implements IBashTool { }; } - private spawn(effectiveCwd: string, command: string): Promise { - const shellCwd = this.isWindowsBash ? windowsPathToPosixPath(effectiveCwd) : effectiveCwd; - const shellArgs = [this.env.shellPath, '-c', command]; - + private spawn( + processService: IHostProcessService, + env: HostEnvironmentInfo, + effectiveCwd: string, + command: string, + ): Promise { + const shellCwd = env.osKind === 'Windows' ? windowsPathToPosixPath(effectiveCwd) : effectiveCwd; const shellEnv: Record = { NO_COLOR: '1', TERM: 'dumb', GIT_TERMINAL_PROMPT: process.env['GIT_TERMINAL_PROMPT'] ?? '0', PWD: shellCwd, - SHELL: this.env.shellPath, + SHELL: env.shellPath, }; - return this.runner.exec(shellArgs, { cwd: effectiveCwd, env: shellEnv }); + return processService.spawn(env.shellPath, ['-c', command], { cwd: effectiveCwd, env: shellEnv }); } private async execution( @@ -216,7 +213,11 @@ export class BashTool implements IBashTool { const startsInBackground = args.run_in_background === true; const foregroundTimeoutMs = normalizeTimeoutMs(args.timeout, false); - const command = this.isWindowsBash ? rewriteWindowsNullRedirect(args.command) : args.command; + const lease = this.runtime.acquire(['process']); + const view = new RuntimeWorkspaceView(lease.runtime, this.workspaceCtx); + const env = lease.runtime.environment; + const command = env.osKind === 'Windows' ? rewriteWindowsNullRedirect(args.command) : args.command; + const effectiveCwd = view.resolve(args.cwd ?? view.workDir); const description = startsInBackground ? args.description!.trim() : foregroundDescription(args); const timeoutMs = startsInBackground ? args.disable_timeout @@ -225,14 +226,11 @@ export class BashTool implements IBashTool { : foregroundTimeoutMs; const builder = new ToolResultBuilder(); - let proc: IProcess; + let proc: IHostProcess; try { - const effectiveCwd = - args.cwd === undefined - ? this.ctx.cwd - : canonicalizePath(args.cwd, this.ctx.cwd, this.env.pathClass); - proc = await this.spawn(effectiveCwd, command); + proc = lease.track(await this.spawn(lease.runtime.process!, env, effectiveCwd, command)); } catch (error) { + lease.dispose(); return { isError: true, output: error instanceof Error ? error.message : String(error), @@ -258,7 +256,7 @@ export class BashTool implements IBashTool { let taskId: string; try { taskId = this.tasks.registerTask( - new ProcessTask(proc, command, description, onProcessOutput), + new ProcessTask(proc, command, description, onProcessOutput, () => lease.dispose()), { detached: startsInBackground, timeoutMs, @@ -271,6 +269,7 @@ export class BashTool implements IBashTool { } catch (error) { collectForegroundOutput = false; await killSpawnedProcess(proc); + lease.dispose(); return { isError: true, output: error instanceof Error ? error.message : String(error), @@ -341,7 +340,7 @@ export class BashTool implements IBashTool { private async foregroundCompletionResult( taskId: string, - proc: IProcess, + proc: IHostProcess, builder: ToolResultBuilder, foregroundTimeoutMs: number, ): Promise { @@ -396,7 +395,7 @@ export class BashTool implements IBashTool { private backgroundStartedResult( taskId: string, - proc: IProcess, + proc: IHostProcess, description: string, labels: { title: string; brief: string }, builder = new ToolResultBuilder(), @@ -452,7 +451,11 @@ export class BashTool implements IBashTool { } } -registerAgentToolService(IBashTool, BashTool, { name: 'Bash', domain: 'os/backends' }); +registerAgentToolService(IBashTool, BashTool, { + name: 'Bash', + domain: 'os/backends', + requiredRuntimeCapabilities: ['process'], +}); function formatTimeoutLabel(timeoutMs: number): string { return timeoutMs % 1000 === 0 ? `${String(timeoutMs / 1000)}s` : `${String(timeoutMs)}ms`; @@ -465,14 +468,14 @@ function foregroundDescription(args: BashInput): string { return `Bash: ${preview}`; } -function closeProcessStdin(proc: IProcess): void { +function closeProcessStdin(proc: IHostProcess): void { try { proc.stdin.end(); } catch { } } -async function killSpawnedProcess(proc: IProcess): Promise { +async function killSpawnedProcess(proc: IHostProcess): Promise { try { await proc.kill('SIGTERM'); } catch { diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts index 775a29f831a..e9cf8259587 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts @@ -3,15 +3,15 @@ * * Ported from v1 (`packages/agent-core/test/tools/bash.test.ts`) and adapted * to the v2 constructor `(runner, kaos, background, options)`. Self-contained: - * builds minimal fake `ISessionProcessRunner` / `IProcess`, `IKaos`, and + * builds minimal fake runtime / `IHostProcessService`, `IKaos`, and * `IAgentTaskService` inline so the tool can be exercised without the * composition root. The fake `IAgentTaskService` drives the real * `ProcessTask` so stream observation, timeout and user-interrupt * semantics match production. * * Deviations from v1: - * - v1's `execWithEnv(args, env)` is now `runner.exec(args, { env })`, so - * spawn-call assertions read `options.env` from the second argument. + * - v1's `execWithEnv(args, env)` is now `runner.spawn(command, args, options)`, + * so spawn-call assertions read `options.env` from the third argument. */ import { PassThrough, Readable, type Writable } from 'node:stream'; @@ -32,9 +32,12 @@ import { userCancellationReason } from '#/_base/utils/abort'; import type { IConfigService } from '#/app/config/config'; import { ProcessTask } from '#/agent/tools/os/bash/process-task'; import type { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import type { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; +import { FakeRuntime } from '#/runtime/fakeRuntime'; +import { stubWorkspaceContext } from '../../../../session/workspaceContext/stub-workspace-context'; import type { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { type ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; -import type { IProcess, ISessionProcessRunner } from '#/session/process/processRunner'; +import type { IHostProcess, IHostProcessService } from '#/os/interface/hostProcess'; import { type BashInput, BashInputSchema } from '#/agent/tools/os/bash/bash'; import { BashTool } from '#/agent/tools/os/bash/bashTool'; import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/tool/toolContract'; @@ -72,11 +75,12 @@ function processWithOutput( readonly wait?: () => Promise; readonly kill?: (signal?: NodeJS.Signals) => Promise; } = {}, -): IProcess { +): IHostProcess { const exitCode = options.exitCode ?? 0; const stdout = Readable.from(options.stdout === undefined ? [] : [options.stdout]); const stderr = Readable.from(options.stderr === undefined ? [] : [options.stderr]); return { + _serviceBrand: undefined, stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable, stdout, stderr, @@ -98,7 +102,7 @@ function processWithInterleavedOutput( readonly delayMs: number; }>, exitCode = 0, -): IProcess { +): IHostProcess { const stdout = new PassThrough(); const stderr = new PassThrough(); const lastDelay = Math.max(...events.map((event) => event.delayMs), 0); @@ -117,6 +121,7 @@ function processWithInterleavedOutput( }); return { + _serviceBrand: undefined, stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable, stdout, stderr, @@ -132,7 +137,7 @@ function processWithInterleavedOutput( } function pendingProcess(): { - readonly proc: IProcess; + readonly proc: IHostProcess; readonly finish: (exitCode?: number) => void; } { const stdout = new PassThrough(); @@ -151,6 +156,7 @@ function pendingProcess(): { }; return { proc: { + _serviceBrand: undefined, stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable, stdout, stderr, @@ -161,7 +167,7 @@ function pendingProcess(): { wait: vi.fn(async () => waitPromise), kill: vi.fn(async () => { finish(143); - }) as IProcess['kill'], + }) as IHostProcess['kill'], dispose: vi.fn(async () => {}), }, finish, @@ -169,7 +175,7 @@ function pendingProcess(): { } function processWithVisibleExitBeforeWait(exitCode = 0): { - proc: IProcess; + proc: IHostProcess; finishWait: () => void; markExited: () => void; } { @@ -178,7 +184,8 @@ function processWithVisibleExitBeforeWait(exitCode = 0): { const waitPromise = new Promise((resolve) => { resolveWait = resolve; }); - const proc: IProcess = { + const proc: IHostProcess = { + _serviceBrand: undefined, stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable, stdout: Readable.from([]), stderr: Readable.from([]), @@ -202,10 +209,11 @@ function processWithVisibleExitBeforeWait(exitCode = 0): { }; } -function processThatNeverExits(): IProcess { +function processThatNeverExits(): IHostProcess { const stdout = new PassThrough(); const stderr = new PassThrough(); return { + _serviceBrand: undefined, stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable, stdout, stderr, @@ -224,7 +232,7 @@ function processWithStreamError(options: { readonly stdoutError?: Error; readonly stderrError?: Error; readonly exitCode?: number; -} = {}): IProcess { +} = {}): IHostProcess { const exitCode = options.exitCode ?? 0; const stdout = new PassThrough(); const stderr = new PassThrough(); @@ -244,6 +252,7 @@ function processWithStreamError(options: { }, 1); }); return { + _serviceBrand: undefined, stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable, stdout, stderr, @@ -255,7 +264,7 @@ function processWithStreamError(options: { }; } -function processWithOpenStreamsThatExitOnKill(): IProcess { +function processWithOpenStreamsThatExitOnKill(): IHostProcess { let currentExitCode: number | null = null; let resolveWait: (code: number) => void = () => {}; const waitPromise = new Promise((resolve) => { @@ -265,6 +274,7 @@ function processWithOpenStreamsThatExitOnKill(): IProcess { const stderr = new PassThrough(); return { + _serviceBrand: undefined, stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable, stdout, stderr, @@ -300,9 +310,9 @@ function createTestCtx(cwd = '/workspace'): ISessionContext { } -function createTestRunner(proc: IProcess | ReturnType) { +function createTestRunner(proc: IHostProcess | ReturnType) { const exec = typeof proc === 'function' ? proc : vi.fn().mockResolvedValue(proc); - const runner = { exec } as unknown as ISessionProcessRunner; + const runner = { _serviceBrand: undefined, spawn: exec } as IHostProcessService; return { runner, exec }; } @@ -714,14 +724,36 @@ function stubConfig(values: Record = {}): IConfigService { } function bashTool( - runner: ISessionProcessRunner, + runner: IHostProcessService, env: IHostEnvironment = createTestEnv(), ctx: ISessionContext = createTestCtx(), background: IAgentTaskService = createFakeTaskService().service, toolPolicy: IAgentToolPolicyService = stubToolPolicy(), config: IConfigService = stubConfig(), ): BashTool { - return new BashTool(runner, env, ctx, background, toolPolicy, config); + const processService: IHostProcessService = { + _serviceBrand: undefined, + spawn: async (command, args = [], options) => runner.spawn(command, args, options), + }; + const backend = Object.assign( + new FakeRuntime( + { workspaceId: ctx.workspaceId, runtimeId: 'local', generation: 'test' }, + { capabilities: ['process'], pathClass: env.pathClass }, + ), + { environment: env, process: processService }, + ); + const runtime: IAgentRuntimeService = { + _serviceBrand: undefined, + onDidChange: () => ({ dispose: () => {} }), + isAvailable: () => true, + inspect: () => backend, + acquire: () => ({ + runtime: backend, + track: (resource) => resource, + dispose: () => {}, + }), + }; + return new BashTool(runtime, ctx, stubWorkspaceContext(ctx.cwd), background, toolPolicy, config); } @@ -812,7 +844,7 @@ describe('BashTool', () => { expect(tool.description).toContain('/tasks'); }); - it('runs through runner.exec, injects cwd, noninteractive env, and closes stdin', async () => { + it('runs through runner.spawn, injects cwd, noninteractive env, and closes stdin', async () => { const proc = processWithOutput({ stdout: 'ok\n' }); const { runner, exec } = createTestRunner(proc); const tool = bashTool(runner); @@ -820,8 +852,9 @@ describe('BashTool', () => { const result = await executeTool(tool, context({ command: 'printf ok', timeout: 60 })); expect(exec).toHaveBeenCalledTimes(1); - const [argv, execOptions] = exec.mock.calls[0]!; - expect(argv).toEqual(['/bin/bash', '-c', 'printf ok']); + const [command, args, execOptions] = exec.mock.calls[0]!; + expect(command).toBe('/bin/bash'); + expect(args).toEqual(['-c', 'printf ok']); expect(execOptions?.cwd).toBe('/workspace'); expect(execOptions?.env).toMatchObject({ NO_COLOR: '1', @@ -839,11 +872,12 @@ describe('BashTool', () => { const { runner, exec } = createTestRunner(processWithOutput({ stdout: 'sub\n' })); const tool = bashTool(runner); - await executeTool(tool, context({ command: 'pwd', cwd: '/tmp/project', timeout: 60 })); + await executeTool(tool, context({ command: 'pwd', cwd: '/workspace/project', timeout: 60 })); - expect(exec.mock.calls[0]?.[0]).toEqual(['/bin/bash', '-c', 'pwd']); - expect(exec.mock.calls[0]?.[1]?.cwd).toBe('/tmp/project'); - expect(exec.mock.calls[0]?.[1]?.env).toMatchObject({ PWD: '/tmp/project' }); + expect(exec.mock.calls[0]?.[0]).toBe('/bin/bash'); + expect(exec.mock.calls[0]?.[1]).toEqual(['-c', 'pwd']); + expect(exec.mock.calls[0]?.[2]?.cwd).toBe('/workspace/project'); + expect(exec.mock.calls[0]?.[2]?.env).toMatchObject({ PWD: '/workspace/project' }); }); it('resolves a relative args.cwd against the session cwd', async () => { @@ -852,8 +886,9 @@ describe('BashTool', () => { await executeTool(tool, context({ command: 'pwd', cwd: 'packages/ui', timeout: 60 })); - expect(exec.mock.calls[0]?.[0]).toEqual(['/bin/bash', '-c', 'pwd']); - expect(exec.mock.calls[0]?.[1]?.cwd).toBe('/workspace/project/packages/ui'); + expect(exec.mock.calls[0]?.[0]).toBe('/bin/bash'); + expect(exec.mock.calls[0]?.[1]).toEqual(['-c', 'pwd']); + expect(exec.mock.calls[0]?.[2]?.cwd).toBe('/workspace/project/packages/ui'); }); it('resolves a relative Windows args.cwd without Git Bash path conversion', async () => { @@ -862,13 +897,10 @@ describe('BashTool', () => { await executeTool(tool, context({ command: 'pwd', cwd: 'packages\\ui', timeout: 60 })); - expect(exec.mock.calls[0]?.[0]).toEqual([ - 'C:\\Program Files\\Git\\bin\\bash.exe', - '-c', - 'pwd', - ]); - expect(exec.mock.calls[0]?.[1]?.cwd).toBe('C:/Users/me/project/packages/ui'); - expect(exec.mock.calls[0]?.[1]?.env).toMatchObject({ + expect(exec.mock.calls[0]?.[0]).toBe('C:\\Program Files\\Git\\bin\\bash.exe'); + expect(exec.mock.calls[0]?.[1]).toEqual(['-c', 'pwd']); + expect(exec.mock.calls[0]?.[2]?.cwd).toBe('C:\\Users\\me\\project\\packages\\ui'); + expect(exec.mock.calls[0]?.[2]?.env).toMatchObject({ PWD: '/c/Users/me/project/packages/ui', }); }); @@ -879,8 +911,9 @@ describe('BashTool', () => { await executeTool(tool, context({ command: 'pwd', timeout: 60 })); - expect(exec.mock.calls[0]?.[0]).toEqual(['/bin/bash', '-c', 'pwd']); - expect(exec.mock.calls[0]?.[1]?.cwd).toBe('/var/app'); + expect(exec.mock.calls[0]?.[0]).toBe('/bin/bash'); + expect(exec.mock.calls[0]?.[1]).toEqual(['-c', 'pwd']); + expect(exec.mock.calls[0]?.[2]?.cwd).toBe('/var/app'); }); it('uses Git Bash semantics on Windows', async () => { @@ -891,12 +924,9 @@ describe('BashTool', () => { const result = await executeTool(tool, context({ command: 'echo ok 2>nul', timeout: 60 })); expect(exec).toHaveBeenCalledTimes(1); - const [argv, execOptions] = exec.mock.calls[0]!; - expect(argv).toEqual([ - 'C:\\Program Files\\Git\\bin\\bash.exe', - '-c', - 'echo ok 2>/dev/null', - ]); + const [command, args, execOptions] = exec.mock.calls[0]!; + expect(command).toBe('C:\\Program Files\\Git\\bin\\bash.exe'); + expect(args).toEqual(['-c', 'echo ok 2>/dev/null']); expect(execOptions?.cwd).toBe('C:\\Users\\me\\project'); expect(execOptions?.env).toMatchObject({ PWD: '/c/Users/me/project', @@ -1229,7 +1259,7 @@ describe('BashTool', () => { await executeTool(tool, context({ command: 'true', timeout: 60 })); - const env = exec.mock.calls[0]?.[1]?.env as Record; + const env = exec.mock.calls[0]?.[2]?.env as Record; expect(Object.prototype.hasOwnProperty.call(env, 'GIT_SSH_COMMAND')).toBe(false); } finally { if (previous !== undefined) process.env['GIT_SSH_COMMAND'] = previous; @@ -1242,9 +1272,9 @@ describe('BashTool', () => { await executeTool(tool, context({ command: 'ls 2>nul', timeout: 60 })); - const argv = exec.mock.calls[0]?.[0] as readonly string[]; - expect(argv[2]).toBe('ls 2>/dev/null'); - expect(exec.mock.calls[0]?.[1]?.cwd).toBe('C:\\Users\\me\\project'); + const args = exec.mock.calls[0]?.[1] as readonly string[]; + expect(args[1]).toBe('ls 2>/dev/null'); + expect(exec.mock.calls[0]?.[2]?.cwd).toBe('C:\\Users\\me\\project'); }); it('passes nul-redirect through unchanged on Linux so the argv keeps the literal file target', async () => { @@ -1253,9 +1283,9 @@ describe('BashTool', () => { await executeTool(tool, context({ command: 'ls 2>nul', timeout: 60 })); - const argv = exec.mock.calls[0]?.[0] as readonly string[]; - expect(argv[2]).toBe('ls 2>nul'); - expect(exec.mock.calls[0]?.[1]?.cwd).toBe('/workspace'); + const args = exec.mock.calls[0]?.[1] as readonly string[]; + expect(args[1]).toBe('ls 2>nul'); + expect(exec.mock.calls[0]?.[2]?.cwd).toBe('/workspace'); }); it('exposes a shell description that documents /bin/bash, TaskOutput/TaskStop, safety and efficiency sections, and background semantics', () => { @@ -1687,12 +1717,9 @@ describe('BashTool background mode', () => { const results = await Promise.all([first, second]); expect(exec).toHaveBeenCalledTimes(2); - const [argv, execOptions] = exec.mock.calls[0]!; - expect(argv).toEqual([ - 'C:\\Program Files\\Git\\bin\\bash.exe', - '-c', - 'echo ok 2>/dev/null', - ]); + const [command, args, execOptions] = exec.mock.calls[0]!; + expect(command).toBe('C:\\Program Files\\Git\\bin\\bash.exe'); + expect(args).toEqual(['-c', 'echo ok 2>/dev/null']); expect(execOptions?.cwd).toBe('C:\\Users\\me\\project'); expect(execOptions?.env).toMatchObject({ SHELL: 'C:\\Program Files\\Git\\bin\\bash.exe' }); expect(secondProc.kill).toHaveBeenCalledWith('SIGTERM');