Skip to content

Commit a970642

Browse files
committed
🤖 fix: script runner overflow policy, path handling & stale suggestions
Change-Id: I8223a444f1e46cbc156f0c23a811f65dcea8e126 Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 5d8860a commit a970642

File tree

8 files changed

+317
-56
lines changed

8 files changed

+317
-56
lines changed

src/browser/components/ChatInput/index.tsx

Lines changed: 2 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { useMode } from "@/browser/contexts/ModeContext";
1818
import { ThinkingSliderComponent } from "../ThinkingSlider";
1919
import { ModelSettings } from "../ModelSettings";
2020
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
21+
import { useAvailableScripts } from "@/browser/hooks/useAvailableScripts";
2122
import {
2223
getModelKey,
2324
getInputKey,
@@ -131,9 +132,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
131132
const [showCommandSuggestions, setShowCommandSuggestions] = useState(false);
132133
const [commandSuggestions, setCommandSuggestions] = useState<SlashSuggestion[]>([]);
133134
const [providerNames, setProviderNames] = useState<string[]>([]);
134-
const [availableScripts, setAvailableScripts] = useState<
135-
Array<{ name: string; description?: string }>
136-
>([]);
135+
const availableScripts = useAvailableScripts(workspaceId ?? null);
137136
const [toast, setToast] = useState<Toast | null>(null);
138137
const [imageAttachments, setImageAttachments] = useState<ImageAttachment[]>([]);
139138
const handleToastDismiss = useCallback(() => {
@@ -326,44 +325,6 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
326325
};
327326
}, []);
328327

329-
useEffect(() => {
330-
const currentWorkspaceId = workspaceId;
331-
if (!currentWorkspaceId) {
332-
setAvailableScripts([]);
333-
return;
334-
}
335-
336-
let isMounted = true;
337-
338-
const loadScripts = async () => {
339-
try {
340-
const result = await window.api.workspace.listScripts(currentWorkspaceId);
341-
if (isMounted) {
342-
if (result.success) {
343-
const executableScripts = result.data
344-
.filter((s) => s.isExecutable)
345-
.map((s) => ({ name: s.name, description: s.description }));
346-
setAvailableScripts(executableScripts);
347-
} else {
348-
// Clear scripts if listing fails (e.g. runtime error) to avoid stale suggestions
349-
setAvailableScripts([]);
350-
}
351-
}
352-
} catch (error) {
353-
console.error("Failed to load scripts:", error);
354-
if (isMounted) {
355-
setAvailableScripts([]);
356-
}
357-
}
358-
};
359-
360-
void loadScripts();
361-
362-
return () => {
363-
isMounted = false;
364-
};
365-
}, [workspaceId]);
366-
367328
// Allow external components (e.g., CommandPalette, Queued message edits) to insert text
368329
useEffect(() => {
369330
const handler = (e: Event) => {
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { GlobalWindow } from "happy-dom";
2+
3+
const win = new GlobalWindow();
4+
// @ts-expect-error -- Patching global for test environment
5+
global.window = win;
6+
// @ts-expect-error -- Patching global for test environment
7+
global.document = win.document;
8+
// @ts-expect-error -- Patching global for test environment
9+
global.navigator = win.navigator;
10+
11+
import { describe, it, expect, beforeEach, afterEach, mock, type Mock } from "bun:test";
12+
import { renderHook, waitFor } from "@testing-library/react";
13+
import { useAvailableScripts } from "./useAvailableScripts";
14+
15+
// Define types for our mock
16+
type ListScriptsMock = Mock<() => Promise<{ success: boolean; data?: unknown[]; error?: string }>>;
17+
18+
describe("useAvailableScripts", () => {
19+
const mockListScripts = mock() as unknown as ListScriptsMock;
20+
21+
beforeEach(() => {
22+
// Mock window.api attached to the global window
23+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
24+
(global.window as any).api = {
25+
workspace: {
26+
listScripts: mockListScripts,
27+
},
28+
};
29+
});
30+
31+
afterEach(() => {
32+
mock.restore();
33+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
34+
delete (global.window as any).api;
35+
});
36+
37+
it("should fetch and return executable scripts", async () => {
38+
mockListScripts.mockResolvedValue({
39+
success: true,
40+
data: [
41+
{ name: "script1", isExecutable: true, description: "desc1" },
42+
{ name: "script2", isExecutable: false, description: "desc2" },
43+
],
44+
});
45+
46+
const { result } = renderHook(() => useAvailableScripts("ws-1"));
47+
48+
// Initial state
49+
expect(result.current).toEqual([]);
50+
51+
await waitFor(() => {
52+
expect(result.current).toHaveLength(1);
53+
});
54+
55+
expect(result.current).toEqual([{ name: "script1", description: "desc1" }]);
56+
});
57+
58+
it("should clear scripts immediately when workspaceId changes", async () => {
59+
// First load
60+
mockListScripts.mockResolvedValue({
61+
success: true,
62+
data: [{ name: "script1", isExecutable: true }],
63+
});
64+
65+
const { result, rerender } = renderHook(({ wsId }) => useAvailableScripts(wsId), {
66+
initialProps: { wsId: "ws-1" as string | null },
67+
});
68+
69+
await waitFor(() => {
70+
expect(result.current).toHaveLength(1);
71+
});
72+
73+
// Prepare for second load (hangs to prove clearance)
74+
mockListScripts.mockReturnValue(
75+
new Promise(() => {
76+
// Never resolve
77+
})
78+
);
79+
80+
rerender({ wsId: "ws-2" });
81+
82+
// Should be cleared immediately
83+
expect(result.current).toEqual([]);
84+
});
85+
86+
it("should clear scripts if listing fails", async () => {
87+
mockListScripts.mockResolvedValue({
88+
success: false,
89+
error: "Failed",
90+
});
91+
92+
const { result } = renderHook(() => useAvailableScripts("ws-1"));
93+
94+
await waitFor(() => {
95+
expect(mockListScripts).toHaveBeenCalled();
96+
});
97+
98+
expect(result.current).toEqual([]);
99+
});
100+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { useState, useEffect } from "react";
2+
3+
export interface AvailableScript {
4+
name: string;
5+
description?: string;
6+
}
7+
8+
export function useAvailableScripts(workspaceId: string | null) {
9+
const [availableScripts, setAvailableScripts] = useState<AvailableScript[]>([]);
10+
11+
useEffect(() => {
12+
// Clear scripts immediately to prevent stale suggestions from previous workspace
13+
setAvailableScripts([]);
14+
15+
if (!workspaceId) {
16+
return;
17+
}
18+
19+
let isMounted = true;
20+
21+
const loadScripts = async () => {
22+
try {
23+
const result = await window.api.workspace.listScripts(workspaceId);
24+
if (isMounted) {
25+
if (result.success) {
26+
const executableScripts = result.data
27+
.filter((s) => s.isExecutable)
28+
.map((s) => ({ name: s.name, description: s.description }));
29+
setAvailableScripts(executableScripts);
30+
} else {
31+
// Clear scripts if listing fails
32+
setAvailableScripts([]);
33+
}
34+
}
35+
} catch (error) {
36+
console.error("Failed to load scripts:", error);
37+
if (isMounted) {
38+
setAvailableScripts([]);
39+
}
40+
}
41+
};
42+
43+
void loadScripts();
44+
45+
return () => {
46+
isMounted = false;
47+
};
48+
}, [workspaceId]);
49+
50+
return availableScripts;
51+
}

src/common/utils/tools/tools.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,16 @@ describe("getToolsForModel", () => {
160160
};
161161
const result = await diagnoseTool.execute({ args: [] });
162162

163+
expect(mockRunScript).toHaveBeenCalledWith(
164+
config.runtime,
165+
config.cwd,
166+
"diagnose",
167+
[],
168+
expect.objectContaining({
169+
overflowPolicy: "tmpfile",
170+
})
171+
);
172+
163173
expect(result).toContain("Standard output");
164174
expect(result).toContain("--- MUX_OUTPUT ---\nUser notification");
165175
expect(result).toContain("--- MUX_PROMPT ---\nAgent instruction");

src/common/utils/tools/tools.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,12 @@ export async function getToolsForModel(
103103
config.cwd,
104104
script.name,
105105
args ?? [],
106-
config.env ?? {}, // Extra env
107-
config.secrets ?? {}, // Secrets
108-
300 // timeout
106+
{
107+
env: config.env ?? {},
108+
secrets: config.secrets ?? {},
109+
timeoutSecs: 300,
110+
overflowPolicy: "tmpfile",
111+
}
109112
);
110113

111114
if (!result.success) {

src/node/services/ipcMain.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1357,10 +1357,12 @@ export class IpcMain {
13571357
workspacePath,
13581358
scriptName,
13591359
args,
1360-
{}, // Extra env
1361-
secretsToRecord(projectSecrets),
1362-
300, // timeout
1363-
signal
1360+
{
1361+
env: {},
1362+
secrets: secretsToRecord(projectSecrets),
1363+
timeoutSecs: 300,
1364+
abortSignal: signal,
1365+
}
13641366
);
13651367
const elapsedMs = scriptExecutionStartMs ? Date.now() - scriptExecutionStartMs : 0;
13661368
scriptExecutionStartMs = null;
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { describe, it, expect, mock } from "bun:test";
2+
import { runWorkspaceScript } from "./scriptRunner";
3+
import type { Runtime } from "@/node/runtime/Runtime";
4+
5+
// Mock discovery utils to control paths
6+
void mock.module("@/utils/scripts/discovery", () => ({
7+
getScriptPath: (wsPath: string, scriptName: string) => {
8+
if (wsPath.includes("/")) {
9+
return `${wsPath}/.cmux/scripts/${scriptName}`;
10+
}
11+
return `${wsPath}\\.cmux\\scripts\\${scriptName}`;
12+
},
13+
getScriptsDir: (wsPath: string) => {
14+
if (wsPath.includes("/")) {
15+
return `${wsPath}/.cmux/scripts`;
16+
}
17+
return `${wsPath}\\.cmux\\scripts`;
18+
},
19+
}));
20+
21+
// Mock createBashTool
22+
void mock.module("@/node/services/tools/bash", () => ({
23+
createBashTool: () => ({
24+
execute: mock().mockResolvedValue({
25+
success: true,
26+
exitCode: 0,
27+
output: "mock output",
28+
error: "",
29+
}),
30+
}),
31+
}));
32+
33+
// Mock helpers
34+
void mock.module("@/node/utils/runtime/helpers", () => ({
35+
execBuffered: mock().mockResolvedValue({ exitCode: 0, stdout: "/tmp/mock", stderr: "" }),
36+
writeFileString: mock().mockResolvedValue(undefined),
37+
readFileString: mock().mockResolvedValue(""),
38+
}));
39+
40+
describe("runWorkspaceScript path handling", () => {
41+
const mockRuntime = {
42+
stat: mock().mockResolvedValue({ isDirectory: false }),
43+
normalizePath: mock(),
44+
};
45+
46+
it("should handle POSIX paths correctly (SSH scenario)", async () => {
47+
// SSH runtime semantics: paths are POSIX
48+
const workspacePath = "/home/user/workspace";
49+
const scriptName = "deploy.sh";
50+
51+
// normalizePath just returns the path as-is for POSIX on POSIX
52+
// But key is that it doesn't convert to backslashes
53+
mockRuntime.normalizePath.mockImplementation((p: string) => p);
54+
55+
const result = await runWorkspaceScript(
56+
mockRuntime as unknown as Runtime,
57+
workspacePath,
58+
scriptName,
59+
[]
60+
);
61+
62+
expect(result.success).toBe(true);
63+
expect(mockRuntime.normalizePath).toHaveBeenCalledTimes(2);
64+
// Verify it didn't fail with escape error
65+
});
66+
67+
it("should handle Windows paths correctly", async () => {
68+
const workspacePath = "C:\\Users\\user\\workspace";
69+
const scriptName = "deploy.bat";
70+
71+
mockRuntime.normalizePath.mockImplementation((p: string) => p);
72+
73+
const result = await runWorkspaceScript(
74+
mockRuntime as unknown as Runtime,
75+
workspacePath,
76+
scriptName,
77+
[]
78+
);
79+
80+
expect(result.success).toBe(true);
81+
});
82+
83+
it("should detect path escape attempts", async () => {
84+
const workspacePath = "/home/user/workspace";
85+
const scriptName = "evil.sh";
86+
87+
// Mock getScriptPath returning something outside
88+
// We can't easily mock the module conditionally inside test,
89+
// but we can rely on normalizePath to simulate the escape check failure
90+
91+
// Simulate a case where script path is outside scripts dir
92+
mockRuntime.normalizePath.mockImplementation((p: string, _base: string) => {
93+
if (p.includes("scripts")) {
94+
if (p.endsWith("scripts")) return "/home/user/workspace/.cmux/scripts";
95+
return "/home/user/workspace/evil.sh"; // Escaped!
96+
}
97+
return p;
98+
});
99+
100+
const result = await runWorkspaceScript(
101+
mockRuntime as unknown as Runtime,
102+
workspacePath,
103+
scriptName,
104+
[]
105+
);
106+
107+
expect(result.success).toBe(false);
108+
if (!result.success) {
109+
expect(result.error).toContain("Script path escapes scripts directory");
110+
}
111+
});
112+
});

0 commit comments

Comments
 (0)