diff --git a/.changeset/bash-cwd-background-operator.md b/.changeset/bash-cwd-background-operator.md new file mode 100644 index 0000000000..1c38165aaa --- /dev/null +++ b/.changeset/bash-cwd-background-operator.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kimi-code-sdk": patch +--- + +Keep the Bash tool's `cwd` applied to the whole command. The shell invocation was built as `cd && `, which binds the `cd` into the command's first AND-list — so a command containing `&` (`npm run dev & curl localhost`) ran everything after the `&` in the session's original directory instead of the requested one. diff --git a/packages/acp-server/src/acp-fs/acpConnection.ts b/packages/acp-server/src/acp-fs/acpConnection.ts index 9d12773ec1..34d2c08c47 100644 --- a/packages/acp-server/src/acp-fs/acpConnection.ts +++ b/packages/acp-server/src/acp-fs/acpConnection.ts @@ -79,7 +79,7 @@ export interface AcpTerminalCreatedEvent { readonly sessionId: string; /** * The full shell invocation string (the `-c` payload of the exec call, - * `cd && `), used to match the tool call whose + * `cd || exit 1\n`), used to match the tool call whose * `args.command` it ends with. */ readonly shellCommand: string; diff --git a/packages/acp-server/src/session.ts b/packages/acp-server/src/session.ts index 66741fe42b..6489040ba4 100644 --- a/packages/acp-server/src/session.ts +++ b/packages/acp-server/src/session.ts @@ -851,7 +851,7 @@ export class AcpSession { * Correlate a freshly-created client terminal with the in-flight Bash tool * call whose command it runs, then attach a `{type: 'terminal'}` content * entry to that call's card. Match key: the runner reports the full shell - * invocation (`cd && `), which ends with the model's + * invocation (`cd || exit 1\n`), which ends with the model's * `args.command`. Terminals with no matching call (e.g. a subagent's — * this session only follows the main agent's events) stay unattached. */ 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 4668f87694..04ffe1e23d 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 @@ -151,7 +151,9 @@ export class BashTool implements IBashTool { command: string, ): Promise { const shellCwd = env.osKind === 'Windows' ? windowsPathToPosixPath(effectiveCwd) : effectiveCwd; - const shellCommand = `cd ${shellQuote(shellCwd)} && ${command}`; + // `cd … && ` binds the cd into the first AND-list, so `cd /d && a & b` + // parses as `{ cd /d && a } & b` and runs `b` in the original directory. + const shellCommand = `cd ${shellQuote(shellCwd)} || exit 1\n${command}`; const noninteractiveEnv: Record = { NO_COLOR: '1', TERM: 'dumb', 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 9bfb59676e..3a3dd46b84 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 @@ -832,7 +832,7 @@ describe('BashTool', () => { expect(exec).toHaveBeenCalledTimes(1); const [command, args, execOptions] = exec.mock.calls[0]!; expect(command).toBe('/bin/bash'); - expect(args).toEqual(['-c', "cd '/workspace' && printf ok"]); + expect(args).toEqual(['-c', "cd '/workspace' || exit 1\nprintf ok"]); expect(execOptions?.env).toMatchObject({ NO_COLOR: '1', TERM: 'dumb', @@ -851,7 +851,20 @@ describe('BashTool', () => { await executeTool(tool, context({ command: 'pwd', cwd: '/workspace/project', timeout: 60 })); expect(exec.mock.calls[0]?.[0]).toBe('/bin/bash'); - expect(exec.mock.calls[0]?.[1]).toEqual(['-c', "cd '/workspace/project' && pwd"]); + expect(exec.mock.calls[0]?.[1]).toEqual(['-c', "cd '/workspace/project' || exit 1\npwd"]); + }); + + it('keeps cwd applied to commands containing a background operator', async () => { + const { runner, exec } = createTestRunner(processWithOutput({ stdout: '' })); + const tool = bashTool(runner); + + await executeTool(tool, context({ command: 'sleep 1 & pwd', timeout: 60 })); + + // `cd /d && sleep 1 & pwd` parses as `{ cd /d && sleep 1 } & pwd`, which + // leaves `pwd` running in the shell's original directory. + const shellCommand = exec.mock.calls[0]?.[1]?.[1] ?? ''; + expect(shellCommand).toBe("cd '/workspace' || exit 1\nsleep 1 & pwd"); + expect(shellCommand).not.toContain('&& sleep 1 & pwd'); }); it('uses the kaos cwd as the default working directory', async () => { @@ -861,7 +874,7 @@ describe('BashTool', () => { await executeTool(tool, context({ command: 'pwd', timeout: 60 })); expect(exec.mock.calls[0]?.[0]).toBe('/bin/bash'); - expect(exec.mock.calls[0]?.[1]).toEqual(['-c', "cd '/var/app' && pwd"]); + expect(exec.mock.calls[0]?.[1]).toEqual(['-c', "cd '/var/app' || exit 1\npwd"]); }); it('uses Git Bash semantics on Windows', async () => { @@ -874,7 +887,7 @@ describe('BashTool', () => { expect(exec).toHaveBeenCalledTimes(1); const [command, args, execOptions] = exec.mock.calls[0]!; expect(command).toBe('C:\\Program Files\\Git\\bin\\bash.exe'); - expect(args).toEqual(['-c', "cd '/c/Users/me/project' && echo ok 2>/dev/null"]); + expect(args).toEqual(['-c', "cd '/c/Users/me/project' || exit 1\necho ok 2>/dev/null"]); expect(execOptions?.env).toMatchObject({ SHELL: 'C:\\Program Files\\Git\\bin\\bash.exe' }); expect(result).toMatchObject({ output: 'ok\n', @@ -1217,7 +1230,7 @@ describe('BashTool', () => { await executeTool(tool, context({ command: 'ls 2>nul', timeout: 60 })); const args = exec.mock.calls[0]?.[1] as readonly string[]; - expect(args[1]).toBe("cd '/c/Users/me/project' && ls 2>/dev/null"); + expect(args[1]).toBe("cd '/c/Users/me/project' || exit 1\nls 2>/dev/null"); }); it('passes nul-redirect through unchanged on Linux so the argv keeps the literal file target', async () => { @@ -1227,7 +1240,7 @@ describe('BashTool', () => { await executeTool(tool, context({ command: 'ls 2>nul', timeout: 60 })); const args = exec.mock.calls[0]?.[1] as readonly string[]; - expect(args[1]).toBe("cd '/workspace' && ls 2>nul"); + expect(args[1]).toBe("cd '/workspace' || exit 1\nls 2>nul"); }); it('exposes a shell description that documents /bin/bash, TaskOutput/TaskStop, safety and efficiency sections, and background semantics', () => { @@ -1661,7 +1674,7 @@ describe('BashTool background mode', () => { expect(exec).toHaveBeenCalledTimes(2); const [command, args, execOptions] = exec.mock.calls[0]!; expect(command).toBe('C:\\Program Files\\Git\\bin\\bash.exe'); - expect(args).toEqual(['-c', "cd '/c/Users/me/project' && echo ok 2>/dev/null"]); + expect(args).toEqual(['-c', "cd '/c/Users/me/project' || exit 1\necho ok 2>/dev/null"]); 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 46d6d9ab0d..50c0beb4a0 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -275,7 +275,9 @@ export class BashTool implements BuiltinTool { const shellArgs = [ this.kaos.osEnv.shellPath, '-c', - `cd ${shellQuote(shellCwd)} && ${command}`, + // `cd … && ` binds the cd into the first AND-list, so `cd /d && a & b` + // parses as `{ cd /d && a } & b` and runs `b` in the original directory. + `cd ${shellQuote(shellCwd)} || exit 1\n${command}`, ]; const noninteractiveEnv: Record = { diff --git a/packages/agent-core/test/tools/bash.test.ts b/packages/agent-core/test/tools/bash.test.ts index 29be1910eb..767eb6e759 100644 --- a/packages/agent-core/test/tools/bash.test.ts +++ b/packages/agent-core/test/tools/bash.test.ts @@ -441,7 +441,7 @@ 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', "cd '/workspace' || exit 1\nprintf ok"]); expect(env).toMatchObject({ NO_COLOR: '1', TERM: 'dumb', @@ -464,7 +464,7 @@ describe('BashTool', () => { 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', "cd '/tmp/project' || exit 1\npwd"]); }); it('uses Git Bash semantics on Windows', async () => { @@ -482,7 +482,7 @@ describe('BashTool', () => { expect(argv).toEqual([ 'C:\\Program Files\\Git\\bin\\bash.exe', '-c', - "cd '/c/Users/me/project' && echo ok 2>/dev/null", + "cd '/c/Users/me/project' || exit 1\necho ok 2>/dev/null", ]); expect(env).toMatchObject({ SHELL: 'C:\\Program Files\\Git\\bin\\bash.exe' }); expect(result).toMatchObject({ @@ -1073,7 +1073,7 @@ describe('BashTool', () => { expect(argv).toEqual([ 'C:\\Program Files\\Git\\bin\\bash.exe', '-c', - "cd '/c/Users/me/project' && echo ok 2>/dev/null", + "cd '/c/Users/me/project' || exit 1\necho ok 2>/dev/null", ]); expect(env).toMatchObject({ SHELL: 'C:\\Program Files\\Git\\bin\\bash.exe' }); expect(secondProc.kill).toHaveBeenCalledWith('SIGTERM'); @@ -1398,7 +1398,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("cd '/c/Users/me/project' || exit 1\nls 2>/dev/null"); }); it('passes nul-redirect through unchanged on Linux so the argv keeps the literal file target', async () => { @@ -1408,7 +1408,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("cd '/workspace' || exit 1\nls 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 62a17a7188..3450352a7c 100644 --- a/packages/agent-core/test/tools/shell-quoting.test.ts +++ b/packages/agent-core/test/tools/shell-quoting.test.ts @@ -71,9 +71,9 @@ function captureCommandRewrite( signal, }).then(() => { const argv = execWithEnv.mock.calls[0]?.[0] as readonly string[]; - // The shell wrapper is "cd '' && "; isolate the rewrite. + // The shell wrapper is "cd '' || exit 1\n"; isolate the rewrite. const wrapped = argv[2]!; - const match = /^cd '[^']+' && (.*)$/.exec(wrapped)!; + const match = /^cd '[^']+' \|\| exit 1\n([\s\S]*)$/.exec(wrapped)!; return { rewritten: match[1]!, argv }; }); }