Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
09967cf
fix(bash): honor cwd for backgrounded shell commands
Fnine59 Aug 14, 2026
a0e84ad
fix(bash): align cwd handling with runtime process API
Fnine59 Aug 17, 2026
e2703da
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 17, 2026
143e63e
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 17, 2026
942bf9c
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 17, 2026
095e1e2
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 18, 2026
6dfc915
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 20, 2026
a15587e
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 20, 2026
ff09408
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 21, 2026
56df547
Merge branch 'main' into contrib/kimi-bash-cwd-2890
Fnine59 Aug 21, 2026
7eea275
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 23, 2026
608e98f
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 23, 2026
53c6d9e
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 26, 2026
5163b21
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 26, 2026
94cc567
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 26, 2026
2bc23da
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 26, 2026
cc964b2
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 26, 2026
7a9acfb
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 26, 2026
21193d1
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 27, 2026
6576f91
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 27, 2026
a555daa
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 27, 2026
6009336
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 27, 2026
f149a1b
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 27, 2026
54f297a
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 28, 2026
a5a45d5
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 28, 2026
b68fb50
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 29, 2026
2a1ccef
Merge upstream/main into contrib/kimi-bash-cwd-2890
Fnine59 Aug 30, 2026
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
5 changes: 5 additions & 0 deletions .changeset/steady-shell-cwd.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 3 additions & 7 deletions packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,15 +161,15 @@ export class BashTool implements IBashTool {
command: string,
): Promise<IHostProcess> {
const shellCwd = getShellPathBridge(env).toShellPath(effectiveCwd);
const shellCommand = `cd ${shellQuote(shellCwd)} && ${command}`;
const noninteractiveEnv: Record<string, string> = {
const shellEnv: Record<string, string> = {
NO_COLOR: '1',
TERM: 'dumb',
GIT_TERMINAL_PROMPT: process.env['GIT_TERMINAL_PROMPT'] ?? '0',
PWD: shellCwd,
SHELL: env.shellPath,
};

return processService.spawn(env.shellPath, ['-c', shellCommand], { env: noninteractiveEnv });
return processService.spawn(env.shellPath, ['-c', command], { cwd: effectiveCwd, env: shellEnv });
}

private async execution(
Expand Down Expand Up @@ -469,10 +469,6 @@ async function killSpawnedProcess(proc: IHostProcess): Promise<void> {
}
}

function shellQuote(s: string): string {
return `'${s.replaceAll("'", "'\\''")}'`;
}

const WINDOWS_NUL_REDIRECT = /(\d?&?>+\s*)[Nn][Uu][Ll](?=\s|$|[|&;)\n])/g;

function rewriteWindowsNullRedirect(command: string): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -837,9 +837,11 @@ 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', 'printf ok']);
expect(execOptions?.cwd).toBe('/workspace');
expect(execOptions?.env).toMatchObject({
NO_COLOR: '1',
PWD: '/workspace',
TERM: 'dumb',
});
expect(proc.stdin.end).toHaveBeenCalledTimes(1);
Expand All @@ -856,7 +858,34 @@ 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', '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 () => {
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]).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 () => {
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]).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',
});
});

it('uses the kaos cwd as the default working directory', async () => {
Expand All @@ -866,7 +895,8 @@ 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', 'pwd']);
expect(exec.mock.calls[0]?.[2]?.cwd).toBe('/var/app');
});

it('uses Git Bash semantics on Windows', async () => {
Expand All @@ -879,8 +909,12 @@ 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(execOptions?.env).toMatchObject({ SHELL: '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',
SHELL: 'C:\\Program Files\\Git\\bin\\bash.exe',
});
expect(result).toMatchObject({
output: 'ok\n',
isError: false,
Expand Down Expand Up @@ -1283,7 +1317,8 @@ 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('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 () => {
Expand All @@ -1293,7 +1328,8 @@ 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('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', () => {
Expand Down Expand Up @@ -1715,7 +1751,8 @@ 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', '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 }));
Expand Down
27 changes: 13 additions & 14 deletions packages/agent-core/src/tools/builtin/shell/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -272,13 +273,9 @@ export class BashTool implements BuiltinTool<BashInput> {

private spawn(effectiveCwd: string, command: string): Promise<KaosProcess> {
const shellCwd = getShellPathBridge(this.kaos.osEnv).toShellPath(effectiveCwd);
const shellArgs = [
this.kaos.osEnv.shellPath,
'-c',
`cd ${shellQuote(shellCwd)} && ${command}`,
];
const shellArgs = [this.kaos.osEnv.shellPath, '-c', command];

const noninteractiveEnv: Record<string, string> = {
const shellEnv: Record<string, string> = {
NO_COLOR: '1',
TERM: 'dumb',
// Default to '0' so git fails fast on private remotes if a TTY happens
Expand All @@ -288,13 +285,16 @@ export class BashTool implements BuiltinTool<BashInput> {
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<string, string> = {
...(process.env as Record<string, string>),
...noninteractiveEnv,
...shellEnv,
};
return this.kaos.execWithEnv(shellArgs, mergedEnv);
return this.kaos
.withCwd(effectiveCwd)
.withEnv({ PWD: shellCwd })
.execWithEnv(shellArgs, mergedEnv);
}

/**
Expand All @@ -319,7 +319,6 @@ export class BashTool implements BuiltinTool<BashInput> {
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
Expand All @@ -330,6 +329,10 @@ export class BashTool implements BuiltinTool<BashInput> {
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 {
Expand Down Expand Up @@ -603,10 +606,6 @@ async function killSpawnedProcess(proc: KaosProcess): Promise<void> {
}
}

function shellQuote(s: string): string {
return `'${s.replaceAll("'", "'\\''")}'`;
}

const WINDOWS_NUL_REDIRECT = /(\d?&?>+\s*)[Nn][Uu][Ll](?=\s|$|[|&;)\n])/g;

function rewriteWindowsNullRedirect(command: string): string {
Expand Down
118 changes: 103 additions & 15 deletions packages/agent-core/test/tools/bash.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -411,9 +419,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);
Expand All @@ -426,17 +435,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);
Expand All @@ -452,9 +537,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,
Expand Down Expand Up @@ -1028,7 +1116,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');
Expand Down Expand Up @@ -1353,7 +1441,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 () => {
Expand All @@ -1363,7 +1451,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', () => {
Expand Down
Loading