Skip to content

Commit fa10fc7

Browse files
authored
🤖 fix: delete plan file on Start Here + read-only exec (#1116)
### What - When clicking **Start Here** on a `propose_plan` tool call, we now delete the plan file on disk (new + legacy paths) and clear plan file tracking state. - The plan file is now **readable in exec mode** via `file_read` (we always pass `planFilePath`, and plan path detection is mode-agnostic). - The plan file is now explicitly **read-only outside plan mode**, including for SSH runtimes. ### Tests - `make static-check` - `bun test src/node/services/tools/file_read.test.ts src/node/services/tools/file_edit_operation.test.ts src/node/runtime/SSHRuntime.test.ts` --- <details> <summary>📋 Implementation Plan</summary> # 🤖 Plan: Delete plan file on “Start Here” + make plan file readable (read-only) in Exec mode ## Problem 1. **Plan file is left behind after using “Start Here” on a `propose_plan` tool call.** - The chat history is replaced, but `~/.mux/plans/<project>/<workspace>.md` remains on disk, confusing later sessions (stale/duplicate plan). 2. **Switching from Plan → Exec currently blocks reading the plan file.** - `file_read` only exempts the plan file from `cwd`-restriction in **plan mode**; in exec mode the plan file path resolves outside the workspace and is rejected. ## Goals - When the user clicks **Start Here** on a `propose_plan` tool call, delete the plan file from disk (new + legacy paths) and clear plan file tracking state. - In **exec mode**, allow the agent to **read** the plan file via `file_read`, but **never write/modify** it (treat as read-only outside plan mode). - Preserve existing safety: agents still can’t read arbitrary files outside the workspace; only the plan file is exempt. - Support **local + SSH runtimes**. ## Non-goals - Changing where plan files are stored or the plan file naming scheme. - Auto-deleting the plan file on every “Start Here” action (only when invoked from `propose_plan`). --- ## Recommended approach (A): Add `deletePlanFile` option to `workspace.replaceChatHistory` **Net LoC (product code): ~120–180** ### Why this approach - Single round-trip: “Start Here” + plan cleanup happens in one backend call. - Keeps deletion scoped to the UI that knows the intent (`propose_plan` Start Here). - Avoids fragile heuristics (e.g., inferring intent from message ID prefix). ### Implementation steps #### 1) Allow plan file *read* in exec mode (but keep edits blocked) 1. **Pass `planFilePath` in tool config in all modes** - File: `src/node/services/aiService.ts` - Change tool config from: - `planFilePath: mode === "plan" ? planFilePath : undefined` - to `planFilePath: planFilePath` (always set) - Rationale: tools need to know the canonical plan path even in exec mode. 2. **Refactor plan path detection helper(s)** - File: `src/node/services/tools/fileCommon.ts` - Add a helper that checks whether `targetPath` equals the configured plan file path **regardless of mode**, using `runtime.resolvePath` for tilde/absolute equivalence. - Keep the existing plan-mode semantics available for edit tools. - Suggested shape: - `isConfiguredPlanFilePath(targetPath, config)` → ignores `config.mode`, requires `config.planFilePath`. - `isPlanFilePathInPlanMode(targetPath, config)` → `config.mode === "plan" && isConfiguredPlanFilePath(...)`. 3. **Update `file_read` to allow reading the plan file in any mode** - File: `src/node/services/tools/file_read.ts` - Replace the current exemption check: - `if (!(await isPlanFilePath(filePath, config))) { validatePathInCwd(...) }` - With: - `if (!(await isConfiguredPlanFilePath(filePath, config))) { validatePathInCwd(...) }` 4. **(Defensive) Make plan file explicitly read-only outside plan mode** - Files: - `src/node/services/tools/file_edit_operation.ts` - `src/node/services/tools/file_edit_insert.ts` - Add an early check: - If `isConfiguredPlanFilePath(filePath, config)` and `config.mode !== "plan"`, return a clear error like: - `Plan file is read-only outside plan mode: <path>` - This prevents future regressions where someone might accidentally add a read-exemption to edits too. #### 2) Delete the plan file when “Start Here” is used on `propose_plan` 5. **Extend ORPC schema for `replaceChatHistory`** - File: `src/common/orpc/schemas/api.ts` - Add an optional flag: - `deletePlanFile: z.boolean().optional()` - Keep default behavior unchanged when omitted. 6. **Plumb the flag through the ORPC router** - File: `src/node/orpc/router.ts` - Pass the flag to the workspace service: - `replaceHistory(workspaceId, summaryMessage, { deletePlanFile })`. 7. **Implement deletion in `WorkspaceService.replaceHistory`** - File: `src/node/services/workspaceService.ts` - Update signature to accept an optional options object. - If `deletePlanFile === true`: - Delete both: - `getPlanFilePath(metadata.name, metadata.projectName)` - `getLegacyPlanFilePath(workspaceId)` - Use the existing local/SSH-safe deletion technique already used in `truncateHistory`: - `rm -f <quotedNewPath> <quotedLegacyPath>` via `runtime.exec(...)` - Clear plan file tracking state: - `this.sessions.get(workspaceId)?.clearFileState()` - **Refactor**: extract the duplicated plan deletion logic currently embedded in `truncateHistory` into a private helper (e.g., `deletePlanFilesForWorkspace(...)`) and call it from both places. 8. **Update frontend “Start Here” call site for plans** - Files: - `src/browser/hooks/useStartHere.ts` - `src/browser/components/tools/ProposePlanToolCall.tsx` - Extend `useStartHere(...)` to accept optional `replaceChatHistory` options. - In `ProposePlanToolCall`, pass `{ deletePlanFile: true }`. - Keep `AssistantMessage` usage unchanged (no flag), so ordinary “Start Here” does not delete the plan. --- ## Alternative approach (B): Add a separate `workspace.deletePlanFile` endpoint **Net LoC (product code): ~140–220** ### Summary - Keep `replaceChatHistory` unchanged. - Add a new endpoint that deletes plan files (new + legacy) + clears file tracking state. - `ProposePlanToolCall` would call `replaceChatHistory(...)` and then `deletePlanFile(...)`. ### Pros / cons - **Pros**: avoids touching an existing API signature. - **Cons**: two round-trips; harder to make the behavior feel atomic; more UI error-handling complexity. --- ## Test plan ### Unit tests 1. **`file_read` can read plan file in exec mode** - File: `src/node/services/tools/file_read.test.ts` - Create a temp plan file outside `cwd`. - Configure tool with `mode: "exec"`, `planFilePath: <planPath>`. - Assert `file_read` succeeds for the plan file and still rejects other outside-cwd paths. 2. **Plan file is not editable outside plan mode** - Files: - `src/node/services/tools/file_edit_operation.test.ts` - (optionally) `src/node/services/tools/file_edit_insert.test.ts` - With `mode: "exec"` and `planFilePath` set, attempt to edit plan file path. - Expect the explicit “read-only outside plan mode” error. ### Integration tests (IPC) 3. **`replaceChatHistory(deletePlanFile: true)` deletes plan file** - Add a test near existing plan tests (e.g., `tests/ipc/planCommands.test.ts` or a new `tests/ipc/startHerePlanCleanup.test.ts`). - Create workspace, create plan file at the expected location, call `replaceChatHistory` with the flag. - Assert: - Plan file no longer exists. - Subsequent `getPlanContent` returns `success: false`. --- ## Manual verification checklist - Plan mode: create/edit plan file, call `propose_plan`. - Click **Start Here** on the plan tool call: - Chat history is replaced. - Plan file is deleted from `~/.mux/plans/<project>/<workspace>.md`. - Switch to exec mode without using Start Here: - `file_read` can read the plan file. - `file_edit_*` attempting to modify the plan file fails (read-only). --- ## Rollout / compatibility notes - This change is backward compatible: the new `deletePlanFile` flag is optional. - Existing “Start Here” on normal assistant messages is unaffected. - Plan file deletion includes both new and legacy paths to handle migrated workspaces. </details> --- _Generated with `mux`_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 0bdcd3b commit fa10fc7

File tree

13 files changed

+218
-44
lines changed

13 files changed

+218
-44
lines changed

src/browser/components/tools/ProposePlanToolCall.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,8 @@ export const ProposePlanToolCall: React.FC<ProposePlanToolCallProps> = (props) =
223223
} = useStartHere(
224224
workspaceId,
225225
startHereContent,
226-
false // Plans are never already compacted
226+
false, // Plans are never already compacted
227+
{ deletePlanFile: true }
227228
);
228229

229230
// Copy to clipboard with feedback

src/browser/hooks/useStartHere.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ import { useAPI } from "@/browser/contexts/API";
1212
* @param workspaceId - Current workspace ID (required for operation)
1313
* @param content - Content to use as the new conversation starting point
1414
* @param isCompacted - Whether the message is already compacted (disables button if true)
15+
* @param options - Optional behavior flags for this Start Here action
1516
*/
1617
export function useStartHere(
1718
workspaceId: string | undefined,
1819
content: string,
19-
isCompacted = false
20+
isCompacted = false,
21+
options?: { deletePlanFile?: boolean }
2022
) {
2123
const { api } = useAPI();
2224
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -52,6 +54,7 @@ export function useStartHere(
5254
const result = await api.workspace.replaceChatHistory({
5355
workspaceId,
5456
summaryMessage,
57+
deletePlanFile: options?.deletePlanFile,
5558
});
5659

5760
if (!result.success) {

src/common/orpc/schemas/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,8 @@ export const workspace = {
272272
input: z.object({
273273
workspaceId: z.string(),
274274
summaryMessage: MuxMessageSchema,
275+
/** When true, delete the plan file (new + legacy paths) and clear plan tracking state. */
276+
deletePlanFile: z.boolean().optional(),
275277
}),
276278
output: ResultSchema(z.void(), z.string()),
277279
},

src/node/orpc/router.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,8 @@ export const router = (authToken?: string) => {
409409
.handler(async ({ context, input }) => {
410410
const result = await context.workspaceService.replaceHistory(
411411
input.workspaceId,
412-
input.summaryMessage
412+
input.summaryMessage,
413+
{ deletePlanFile: input.deletePlanFile }
413414
);
414415
if (!result.success) {
415416
return { success: false, error: result.error };

src/node/runtime/SSHRuntime.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -416,10 +416,15 @@ export class SSHRuntime implements Runtime {
416416
};
417417
}
418418
async resolvePath(filePath: string): Promise<string> {
419-
// Use shell to expand tildes on remote system
420-
// Bash will expand ~ automatically when we echo the unquoted variable
421-
// This works with BusyBox (doesn't require GNU coreutils)
422-
const command = `bash -c 'p=${shescape.quote(filePath)}; echo $p'`;
419+
// Expand tilde on the remote system.
420+
// IMPORTANT: This must not single-quote a "~" path directly, because quoted tildes won't expand.
421+
// We reuse expandTildeForSSH() to produce a "$HOME"-based, bash-safe expression.
422+
//
423+
// Note: This does not attempt to canonicalize relative paths (no filesystem access).
424+
// It only ensures ~ is expanded so callers can compare against absolute paths.
425+
const script = `echo ${expandTildeForSSH(filePath)}`;
426+
const command = `bash -c ${shescape.quote(script)}`;
427+
423428
// Use 10 second timeout for path resolution to allow for slower SSH connections
424429
return this.execSSHCommand(command, 10000);
425430
}

src/node/services/aiService.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1154,9 +1154,11 @@ export class AIService extends EventEmitter {
11541154
),
11551155
runtimeTempDir,
11561156
backgroundProcessManager: this.backgroundProcessManager,
1157-
// Plan mode configuration for path enforcement
1157+
// Plan/exec mode configuration for plan file access.
1158+
// - read: plan file is readable in all modes (useful context)
1159+
// - write: enforced by file_edit_* tools (plan file is read-only outside plan mode)
11581160
mode: mode as UIMode | undefined,
1159-
planFilePath: mode === "plan" ? planFilePath : undefined,
1161+
planFilePath,
11601162
workspaceId,
11611163
// External edit detection callback
11621164
recordFileState,

src/node/services/tools/fileCommon.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,22 +30,25 @@ export function generateDiff(filePath: string, oldContent: string, newContent: s
3030
}
3131

3232
/**
33-
* Check if a file path is the plan file in plan mode.
33+
* Check if a file path is the configured plan file (any mode).
3434
* Uses runtime.resolvePath to properly expand tildes for comparison.
3535
*
36+
* Why mode-agnostic: the plan file is useful context in both plan + exec modes,
37+
* but should only be writable in plan mode.
38+
*
3639
* @param targetPath - The path being accessed (may contain ~ or be absolute)
37-
* @param config - Tool configuration containing mode and planFilePath
38-
* @returns true if this is the plan file in plan mode
40+
* @param config - Tool configuration containing planFilePath
41+
* @returns true if this is the configured plan file
3942
*/
4043
export async function isPlanFilePath(
4144
targetPath: string,
4245
config: ToolConfiguration
4346
): Promise<boolean> {
44-
if (config.mode !== "plan" || !config.planFilePath) {
47+
if (!config.planFilePath) {
4548
return false;
4649
}
47-
// Resolve both paths to absolute form for proper comparison
48-
// This handles cases where one path uses ~ and the other is fully expanded
50+
// Resolve both paths to absolute form for proper comparison.
51+
// This handles cases where one path uses ~ and the other is fully expanded.
4952
const [resolvedTarget, resolvedPlan] = await Promise.all([
5053
config.runtime.resolvePath(targetPath),
5154
config.runtime.resolvePath(config.planFilePath),

src/node/services/tools/file_edit_insert.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ export const createFileEditInsertTool: ToolFactory = (config: ToolConfiguration)
6464
);
6565
file_path = correctedPath;
6666

67+
// Plan file is always read-only outside plan mode.
68+
// This is especially important for SSH runtimes, where cwd validation is intentionally skipped.
69+
if ((await isPlanFilePath(file_path, config)) && config.mode !== "plan") {
70+
return {
71+
success: false,
72+
error: `Plan file is read-only outside plan mode: ${file_path}`,
73+
};
74+
}
6775
const resolvedPath = config.runtime.normalizePath(file_path, config.cwd);
6876

6977
// Plan mode restriction: only allow editing/creating the plan file

src/node/services/tools/file_edit_operation.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,36 @@ describe("executeFileEditOperation plan mode enforcement", () => {
216216
expect(await fs.readFile(testFile, "utf-8")).toBe("const x = 2;\n");
217217
});
218218

219+
test("should block editing the plan file outside plan mode (integration)", async () => {
220+
using tempDir = new TestTempDir("exec-plan-readonly-test");
221+
222+
const planPath = path.join(tempDir.path, "plan.md");
223+
await fs.writeFile(planPath, "# Plan\n");
224+
225+
const workspaceCwd = path.join(tempDir.path, "workspace");
226+
await fs.mkdir(workspaceCwd);
227+
228+
const result = await executeFileEditOperation({
229+
config: {
230+
cwd: workspaceCwd,
231+
runtime: new LocalRuntime(workspaceCwd),
232+
runtimeTempDir: tempDir.path,
233+
mode: "exec",
234+
planFilePath: planPath,
235+
},
236+
filePath: planPath,
237+
operation: () => ({ success: true, newContent: "# Updated\n", metadata: {} }),
238+
});
239+
240+
expect(result.success).toBe(false);
241+
if (!result.success) {
242+
expect(result.error).toContain("read-only outside plan mode");
243+
}
244+
245+
// Verify file was not modified
246+
expect(await fs.readFile(planPath, "utf-8")).toBe("# Plan\n");
247+
});
248+
219249
test("should handle relative path to plan file in plan mode", async () => {
220250
// When user provides a relative path that resolves to the plan file,
221251
// it should still be allowed

src/node/services/tools/file_edit_operation.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ export async function executeFileEditOperation<TMetadata>({
5656
// This ensures path resolution uses runtime-specific semantics instead of Node.js path module
5757
const resolvedPath = config.runtime.normalizePath(filePath, config.cwd);
5858

59+
// Plan file is always read-only outside plan mode.
60+
// This is especially important for SSH runtimes, where cwd validation is intentionally skipped.
61+
if ((await isPlanFilePath(filePath, config)) && config.mode !== "plan") {
62+
return {
63+
success: false,
64+
error: `Plan file is read-only outside plan mode: ${filePath}`,
65+
};
66+
}
67+
5968
// Plan mode restriction: only allow editing the plan file
6069
if (config.mode === "plan" && config.planFilePath) {
6170
if (!(await isPlanFilePath(filePath, config))) {

0 commit comments

Comments
 (0)