Skip to content

Commit c08b7a8

Browse files
authored
Fix bash tool to use runtime-appropriate temp directory (#433)
## Summary Fixes a bug where the bash tool wasn't using the Runtime to store bash overflow files properly, causing issues with SSH runtimes. ## The Problem The bash tool was using `config.tempDir` (set to `os.tmpdir()`, a LOCAL path like `/tmp`) to construct overflow file paths, then passing those paths to `config.runtime.writeFile()`. This failed for SSH runtimes because: 1. `os.tmpdir()` returns a local filesystem path (e.g., `/tmp` on the local machine) 2. `SSHRuntime.writeFile()` expects a path on the REMOTE filesystem 3. The path construction was local-only, breaking remote execution ## The Solution Changed the bash tool to write overflow files to a **runtime-agnostic** location: `{cwd}/.cmux/tmp/bash-{id}.txt` **Key changes:** - `bash.ts`: Changed from `path.join(config.tempDir, ...)` to `path.join(config.cwd, ".cmux", "tmp", ...)` - `bash.test.ts`: Updated test to verify files are created in the new location ## Why This Works - `config.cwd` is already runtime-appropriate (local path for LocalRuntime, remote path for SSHRuntime) - Both `LocalRuntime.writeFile()` and `SSHRuntime.writeFile()` automatically create parent directories - The temp file is now workspace-relative, making it accessible in both local and SSH contexts ## Testing ✅ All 46 tests pass, including the test that specifically verifies tmpfile policy behavior.
1 parent 3aff87e commit c08b7a8

File tree

13 files changed

+116
-84
lines changed

13 files changed

+116
-84
lines changed

src/services/aiService.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,7 @@ export class AIService extends EventEmitter {
426426
const earlyAllTools = await getToolsForModel(modelString, {
427427
cwd: process.cwd(),
428428
runtime: earlyRuntime,
429-
tempDir: os.tmpdir(),
429+
runtimeTempDir: os.tmpdir(),
430430
secrets: {},
431431
});
432432
const earlyTools = applyToolPolicy(earlyAllTools, toolPolicy);
@@ -530,14 +530,14 @@ export class AIService extends EventEmitter {
530530

531531
// Generate stream token and create temp directory for tools
532532
const streamToken = this.streamManager.generateStreamToken();
533-
const tempDir = this.streamManager.createTempDirForStream(streamToken);
533+
const runtimeTempDir = await this.streamManager.createTempDirForStream(streamToken, runtime);
534534

535535
// Get model-specific tools with workspace path (correct for local or remote)
536536
const allTools = await getToolsForModel(modelString, {
537537
cwd: workspacePath,
538538
runtime,
539539
secrets: secretsToRecord(projectSecrets),
540-
tempDir,
540+
runtimeTempDir,
541541
});
542542

543543
// Apply tool policy to filter tools (if policy provided)
@@ -702,6 +702,7 @@ export class AIService extends EventEmitter {
702702
modelString,
703703
historySequence,
704704
systemMessage,
705+
runtime,
705706
abortSignal,
706707
tools,
707708
{

src/services/ipcMain.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -900,7 +900,7 @@ export class IpcMain {
900900
runtime,
901901
secrets: secretsToRecord(projectSecrets),
902902
niceness: options?.niceness,
903-
tempDir: tempDir.path,
903+
runtimeTempDir: tempDir.path,
904904
overflow_policy: "truncate",
905905
});
906906

src/services/streamManager.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { HistoryService } from "./historyService";
44
import type { PartialService } from "./partialService";
55
import { createAnthropic } from "@ai-sdk/anthropic";
66
import { shouldRunIntegrationTests, validateApiKeys } from "../../tests/testUtils";
7+
import { createRuntime } from "@/runtime/runtimeFactory";
78

89
// Skip integration tests if TEST_INTEGRATION is not set
910
const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip;
@@ -38,6 +39,7 @@ describe("StreamManager - Concurrent Stream Prevention", () => {
3839
let streamManager: StreamManager;
3940
let mockHistoryService: HistoryService;
4041
let mockPartialService: PartialService;
42+
const runtime = createRuntime({ type: "local", srcBaseDir: "/tmp" });
4143

4244
beforeEach(() => {
4345
mockHistoryService = createMockHistoryService();
@@ -85,6 +87,7 @@ describe("StreamManager - Concurrent Stream Prevention", () => {
8587
"anthropic:claude-sonnet-4-5",
8688
1,
8789
"You are a helpful assistant",
90+
runtime,
8891
undefined,
8992
{}
9093
);
@@ -102,6 +105,7 @@ describe("StreamManager - Concurrent Stream Prevention", () => {
102105
"anthropic:claude-sonnet-4-5",
103106
2,
104107
"You are a helpful assistant",
108+
runtime,
105109
undefined,
106110
{}
107111
);
@@ -273,6 +277,7 @@ describe("StreamManager - Concurrent Stream Prevention", () => {
273277
"anthropic:claude-sonnet-4-5",
274278
1,
275279
"system",
280+
runtime,
276281
undefined,
277282
{}
278283
),
@@ -283,6 +288,7 @@ describe("StreamManager - Concurrent Stream Prevention", () => {
283288
"anthropic:claude-sonnet-4-5",
284289
2,
285290
"system",
291+
runtime,
286292
undefined,
287293
{}
288294
),
@@ -293,6 +299,7 @@ describe("StreamManager - Concurrent Stream Prevention", () => {
293299
"anthropic:claude-sonnet-4-5",
294300
3,
295301
"system",
302+
runtime,
296303
undefined,
297304
{}
298305
),

src/services/streamManager.ts

Lines changed: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
11
import { EventEmitter } from "events";
2-
import * as fs from "fs";
3-
import * as path from "path";
4-
import * as os from "os";
52
import {
63
streamText,
74
stepCountIs,
@@ -31,6 +28,8 @@ import type { HistoryService } from "./historyService";
3128
import { AsyncMutex } from "@/utils/concurrency/asyncMutex";
3229
import type { ToolPolicy } from "@/utils/tools/toolPolicy";
3330
import { StreamingTokenTracker } from "@/utils/main/StreamingTokenTracker";
31+
import type { Runtime } from "@/runtime/Runtime";
32+
import { execBuffered } from "@/utils/runtime/helpers";
3433

3534
// Type definitions for stream parts with extended properties
3635
interface ReasoningDeltaPart {
@@ -107,7 +106,9 @@ interface WorkspaceStreamInfo {
107106
// Track background processing promise for guaranteed cleanup
108107
processingPromise: Promise<void>;
109108
// Temporary directory for tool outputs (auto-cleaned when stream ends)
110-
tempDir: string;
109+
runtimeTempDir: string;
110+
// Runtime for temp directory cleanup
111+
runtime: Runtime;
111112
}
112113

113114
/**
@@ -242,12 +243,26 @@ export class StreamManager extends EventEmitter {
242243
* - Agent mistakes when copying/manipulating paths
243244
* - Harder to read in tool outputs
244245
* - Potential path length issues on some systems
246+
*
247+
* Uses the Runtime abstraction so temp directories work for both local and SSH runtimes.
245248
*/
246-
public createTempDirForStream(streamToken: StreamToken): string {
247-
const homeDir = os.homedir();
248-
const tempDir = path.join(homeDir, ".cmux-tmp", streamToken);
249-
fs.mkdirSync(tempDir, { recursive: true, mode: 0o700 });
250-
return tempDir;
249+
public async createTempDirForStream(streamToken: StreamToken, runtime: Runtime): Promise<string> {
250+
// Create directory and get absolute path (works for both local and remote)
251+
// Use 'cd' + 'pwd' to resolve ~ to absolute path
252+
const command = `mkdir -p ~/.cmux-tmp/${streamToken} && cd ~/.cmux-tmp/${streamToken} && pwd`;
253+
const result = await execBuffered(runtime, command, {
254+
cwd: "/",
255+
timeout: 10,
256+
});
257+
258+
if (result.exitCode !== 0) {
259+
throw new Error(
260+
`Failed to create temp directory ~/.cmux-tmp/${streamToken}: exit code ${result.exitCode}`
261+
);
262+
}
263+
264+
// Return absolute path (e.g., "/home/user/.cmux-tmp/abc123")
265+
return result.stdout.trim();
251266
}
252267

253268
/**
@@ -429,7 +444,8 @@ export class StreamManager extends EventEmitter {
429444
private createStreamAtomically(
430445
workspaceId: WorkspaceId,
431446
streamToken: StreamToken,
432-
tempDir: string,
447+
runtimeTempDir: string,
448+
runtime: Runtime,
433449
messages: ModelMessage[],
434450
model: LanguageModel,
435451
modelString: string,
@@ -508,7 +524,8 @@ export class StreamManager extends EventEmitter {
508524
lastPartialWriteTime: 0, // Initialize to 0 to allow immediate first write
509525
partialWritePromise: undefined, // No write in flight initially
510526
processingPromise: Promise.resolve(), // Placeholder, overwritten in startStream
511-
tempDir, // Stream-scoped temp directory for tool outputs
527+
runtimeTempDir, // Stream-scoped temp directory for tool outputs
528+
runtime, // Runtime for temp directory cleanup
512529
};
513530

514531
// Atomically register the stream
@@ -961,13 +978,17 @@ export class StreamManager extends EventEmitter {
961978
streamInfo.partialWriteTimer = undefined;
962979
}
963980

964-
// Clean up stream temp directory
965-
if (streamInfo.tempDir && fs.existsSync(streamInfo.tempDir)) {
981+
// Clean up stream temp directory using runtime
982+
if (streamInfo.runtimeTempDir) {
966983
try {
967-
fs.rmSync(streamInfo.tempDir, { recursive: true, force: true });
968-
log.debug(`Cleaned up temp dir: ${streamInfo.tempDir}`);
984+
const result = await streamInfo.runtime.exec(`rm -rf "${streamInfo.runtimeTempDir}"`, {
985+
cwd: "~",
986+
timeout: 10,
987+
});
988+
await result.exitCode; // Wait for completion
989+
log.debug(`Cleaned up temp dir: ${streamInfo.runtimeTempDir}`);
969990
} catch (error) {
970-
log.error(`Failed to cleanup temp dir ${streamInfo.tempDir}:`, error);
991+
log.error(`Failed to cleanup temp dir ${streamInfo.runtimeTempDir}:`, error);
971992
// Don't throw - cleanup is best-effort
972993
}
973994
}
@@ -1090,6 +1111,7 @@ export class StreamManager extends EventEmitter {
10901111
modelString: string,
10911112
historySequence: number,
10921113
system: string,
1114+
runtime: Runtime,
10931115
abortSignal?: AbortSignal,
10941116
tools?: Record<string, Tool>,
10951117
initialMetadata?: Partial<CmuxMetadata>,
@@ -1123,18 +1145,16 @@ export class StreamManager extends EventEmitter {
11231145
// Step 2: Use provided stream token or generate a new one
11241146
const streamToken = providedStreamToken ?? this.generateStreamToken();
11251147

1126-
// Step 3: Create temp directory for this stream
1127-
// If token was provided, temp dir might already exist - that's fine
1128-
const tempDir = path.join(os.homedir(), ".cmux-tmp", streamToken);
1129-
if (!fs.existsSync(tempDir)) {
1130-
fs.mkdirSync(tempDir, { recursive: true, mode: 0o700 });
1131-
}
1148+
// Step 3: Create temp directory for this stream using runtime
1149+
// If token was provided, temp dir might already exist - mkdir -p handles this
1150+
const runtimeTempDir = await this.createTempDirForStream(streamToken, runtime);
11321151

11331152
// Step 4: Atomic stream creation and registration
11341153
const streamInfo = this.createStreamAtomically(
11351154
typedWorkspaceId,
11361155
streamToken,
1137-
tempDir,
1156+
runtimeTempDir,
1157+
runtime,
11381158
messages,
11391159
model,
11401160
modelString,

src/services/tools/bash.test.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ function createTestBashTool(options?: { niceness?: number }) {
2222
const tool = createBashTool({
2323
cwd: process.cwd(),
2424
runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }),
25-
tempDir: tempDir.path,
25+
runtimeTempDir: tempDir.path,
2626
...options,
2727
});
2828

@@ -164,7 +164,7 @@ describe("bash tool", () => {
164164
const tool = createBashTool({
165165
cwd: process.cwd(),
166166
runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }),
167-
tempDir: tempDir.path,
167+
runtimeTempDir: tempDir.path,
168168
overflow_policy: "truncate",
169169
});
170170

@@ -203,7 +203,7 @@ describe("bash tool", () => {
203203
const tool = createBashTool({
204204
cwd: process.cwd(),
205205
runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }),
206-
tempDir: tempDir.path,
206+
runtimeTempDir: tempDir.path,
207207
overflow_policy: "truncate",
208208
});
209209

@@ -235,7 +235,7 @@ describe("bash tool", () => {
235235
const tool = createBashTool({
236236
cwd: process.cwd(),
237237
runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }),
238-
tempDir: tempDir.path,
238+
runtimeTempDir: tempDir.path,
239239
overflow_policy: "truncate",
240240
});
241241

@@ -271,7 +271,7 @@ describe("bash tool", () => {
271271
const tool = createBashTool({
272272
cwd: process.cwd(),
273273
runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }),
274-
tempDir: tempDir.path,
274+
runtimeTempDir: tempDir.path,
275275
// overflow_policy not specified - should default to tmpfile
276276
});
277277

@@ -289,7 +289,8 @@ describe("bash tool", () => {
289289
expect(result.error).toContain("saved to");
290290
expect(result.error).not.toContain("[OUTPUT TRUNCATED");
291291

292-
// Verify temp file was created
292+
// Verify temp file was created in runtimeTempDir
293+
expect(fs.existsSync(tempDir.path)).toBe(true);
293294
const files = fs.readdirSync(tempDir.path);
294295
const bashFiles = files.filter((f) => f.startsWith("bash-"));
295296
expect(bashFiles.length).toBe(1);
@@ -303,7 +304,7 @@ describe("bash tool", () => {
303304
const tool = createBashTool({
304305
cwd: process.cwd(),
305306
runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }),
306-
tempDir: tempDir.path,
307+
runtimeTempDir: tempDir.path,
307308
});
308309

309310
// Generate ~50KB of output (well over 16KB display limit, under 100KB file limit)
@@ -355,7 +356,7 @@ describe("bash tool", () => {
355356
const tool = createBashTool({
356357
cwd: process.cwd(),
357358
runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }),
358-
tempDir: tempDir.path,
359+
runtimeTempDir: tempDir.path,
359360
});
360361

361362
// Generate ~150KB of output (exceeds 100KB file limit)
@@ -398,7 +399,7 @@ describe("bash tool", () => {
398399
const tool = createBashTool({
399400
cwd: process.cwd(),
400401
runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }),
401-
tempDir: tempDir.path,
402+
runtimeTempDir: tempDir.path,
402403
});
403404

404405
// Generate output that exceeds display limit but not file limit
@@ -440,7 +441,7 @@ describe("bash tool", () => {
440441
const tool = createBashTool({
441442
cwd: process.cwd(),
442443
runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }),
443-
tempDir: tempDir.path,
444+
runtimeTempDir: tempDir.path,
444445
});
445446

446447
// Generate a single line exceeding 1KB limit, then try to output more
@@ -480,7 +481,7 @@ describe("bash tool", () => {
480481
const tool = createBashTool({
481482
cwd: process.cwd(),
482483
runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }),
483-
tempDir: tempDir.path,
484+
runtimeTempDir: tempDir.path,
484485
});
485486

486487
// Generate ~15KB of output (just under 16KB display limit)
@@ -510,7 +511,7 @@ describe("bash tool", () => {
510511
const tool = createBashTool({
511512
cwd: process.cwd(),
512513
runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }),
513-
tempDir: tempDir.path,
514+
runtimeTempDir: tempDir.path,
514515
});
515516

516517
// Generate exactly 300 lines (hits line limit exactly)
@@ -1250,7 +1251,7 @@ describe("SSH runtime redundant cd detection", () => {
12501251
const tool = createBashTool({
12511252
cwd,
12521253
runtime: sshRuntime,
1253-
tempDir: tempDir.path,
1254+
runtimeTempDir: tempDir.path,
12541255
});
12551256

12561257
return {

src/services/tools/bash.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,10 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {
419419
try {
420420
// Use 8 hex characters for short, memorable temp file IDs
421421
const fileId = Math.random().toString(16).substring(2, 10);
422-
const overflowPath = path.join(config.tempDir, `bash-${fileId}.txt`);
422+
// Write to runtime temp directory (managed by StreamManager)
423+
// Use path.posix.join to preserve forward slashes for SSH runtime
424+
// (config.runtimeTempDir is always a POSIX path like /home/user/.cmux-tmp/token)
425+
const overflowPath = path.posix.join(config.runtimeTempDir, `bash-${fileId}.txt`);
423426
const fullOutput = lines.join("\n");
424427

425428
// Use runtime.writeFile() for SSH support

src/services/tools/file_edit_insert.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ function createTestFileEditInsertTool(options?: { cwd?: string }) {
2121
const tool = createFileEditInsertTool({
2222
cwd: options?.cwd ?? process.cwd(),
2323
runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }),
24-
tempDir: tempDir.path,
24+
runtimeTempDir: tempDir.path,
2525
});
2626

2727
return {
@@ -214,7 +214,7 @@ describe("file_edit_insert tool", () => {
214214
const tool = createFileEditInsertTool({
215215
cwd: testDir,
216216
runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }),
217-
tempDir: "/tmp",
217+
runtimeTempDir: "/tmp",
218218
});
219219
const args: FileEditInsertToolArgs = {
220220
file_path: nonExistentPath,
@@ -240,7 +240,7 @@ describe("file_edit_insert tool", () => {
240240
const tool = createFileEditInsertTool({
241241
cwd: testDir,
242242
runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }),
243-
tempDir: "/tmp",
243+
runtimeTempDir: "/tmp",
244244
});
245245
const args: FileEditInsertToolArgs = {
246246
file_path: nestedPath,
@@ -267,7 +267,7 @@ describe("file_edit_insert tool", () => {
267267
const tool = createFileEditInsertTool({
268268
cwd: testDir,
269269
runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }),
270-
tempDir: "/tmp",
270+
runtimeTempDir: "/tmp",
271271
});
272272
const args: FileEditInsertToolArgs = {
273273
file_path: testFilePath,

0 commit comments

Comments
 (0)