Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/bash-cwd-background-operator.md
Original file line number Diff line number Diff line change
@@ -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 <cwd> && <command>`, 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.
2 changes: 1 addition & 1 deletion packages/acp-server/src/acp-fs/acpConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export interface AcpTerminalCreatedEvent {
readonly sessionId: string;
/**
* The full shell invocation string (the `-c` payload of the exec call,
* `cd <cwd> && <command>`), used to match the tool call whose
* `cd <cwd> || exit 1\n<command>`), used to match the tool call whose
* `args.command` it ends with.
*/
readonly shellCommand: string;
Expand Down
2 changes: 1 addition & 1 deletion packages/acp-server/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cwd> && <command>`), which ends with the model's
* invocation (`cd <cwd> || exit 1\n<command>`), 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.
*/
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,9 @@ export class BashTool implements IBashTool {
command: string,
): Promise<IHostProcess> {
const shellCwd = env.osKind === 'Windows' ? windowsPathToPosixPath(effectiveCwd) : effectiveCwd;
const shellCommand = `cd ${shellQuote(shellCwd)} && ${command}`;
// `cd … && <cmd>` 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<string, string> = {
NO_COLOR: '1',
TERM: 'dumb',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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',
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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 }));
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-core/src/tools/builtin/shell/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,9 @@ export class BashTool implements BuiltinTool<BashInput> {
const shellArgs = [
this.kaos.osEnv.shellPath,
'-c',
`cd ${shellQuote(shellCwd)} && ${command}`,
// `cd … && <cmd>` 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<string, string> = {
Expand Down
12 changes: 6 additions & 6 deletions packages/agent-core/test/tools/bash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 () => {
Expand All @@ -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({
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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', () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-core/test/tools/shell-quoting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ function captureCommandRewrite(
signal,
}).then(() => {
const argv = execWithEnv.mock.calls[0]?.[0] as readonly string[];
// The shell wrapper is "cd '<cwd>' && <rewritten>"; isolate the rewrite.
// The shell wrapper is "cd '<cwd>' || exit 1\n<rewritten>"; 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 };
});
}
Expand Down