From 9b85ca91b13dc8d90cb780ab717840960a22983a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 16 Jan 2026 09:18:36 +0100 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A4=96=20fix:=20use=20resolved=20plan?= =?UTF-8?q?=20file=20path=20for=20tool=20access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I13a056f4c8e08d539025f115249f890d27605a40 Signed-off-by: Thomas Kosiewski --- src/node/services/aiService.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index e6026748fc..e3d0ebdb42 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -78,7 +78,6 @@ import type { QuickJSRuntimeFactory } from "@/node/services/ptc/quickjsRuntime"; import type { ToolBridge } from "@/node/services/ptc/toolBridge"; import { MockAiStreamPlayer } from "./mock/mockAiStreamPlayer"; import { EnvHttpProxyAgent, type Dispatcher } from "undici"; -import { getPlanFilePath } from "@/common/utils/planStorage"; import { getPlanFileHint, getPlanModeInstruction } from "@/common/utils/ui/modeUtils"; import type { AgentMode } from "@/common/types/mode"; import { MUX_APP_ATTRIBUTION_TITLE, MUX_APP_ATTRIBUTION_URL } from "@/constants/appAttribution"; @@ -1322,8 +1321,6 @@ export class AIService extends EventEmitter { // Construct plan mode instruction if in plan mode // This is done backend-side because we have access to the plan file path let effectiveAdditionalInstructions = additionalSystemInstructions; - const muxHome = runtime.getMuxHome(); - const planFilePath = getPlanFilePath(metadata.name, metadata.projectName, muxHome); // Read plan file (handles legacy migration transparently) const planResult = await readPlanFile( @@ -1333,6 +1330,10 @@ export class AIService extends EventEmitter { workspaceId ); + // Always use the canonical resolved plan path for both prompt context and tool enforcement. + // This prevents mismatches when models convert `~/.mux/...` into an OS-home absolute path. + const planFilePath = planResult.path; + if (effectiveMode === "plan") { const planModeInstruction = getPlanModeInstruction(planFilePath, planResult.exists); effectiveAdditionalInstructions = additionalSystemInstructions From 7e64a047079064a4cf4f6c6c9b8f447b35341464 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 16 Jan 2026 10:03:41 +0100 Subject: [PATCH 2/3] tests: cover plan file path matching across runtimes Change-Id: Idaad68c25245509a2fe7797a542a5049d58dfefe Signed-off-by: Thomas Kosiewski --- src/node/services/tools/fileCommon.test.ts | 106 ++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/src/node/services/tools/fileCommon.test.ts b/src/node/services/tools/fileCommon.test.ts index 139835a7e4..259fc06a90 100644 --- a/src/node/services/tools/fileCommon.test.ts +++ b/src/node/services/tools/fileCommon.test.ts @@ -1,9 +1,16 @@ import { describe, it, expect } from "bun:test"; -import type { FileStat } from "@/node/runtime/Runtime"; +import * as fsPromises from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import type { FileStat, Runtime } from "@/node/runtime/Runtime"; +import type { ToolConfiguration } from "@/common/utils/tools/tools"; +import { LocalRuntime } from "@/node/runtime/LocalRuntime"; +import { DockerRuntime } from "@/node/runtime/DockerRuntime"; import { validatePathInCwd, validateFileSize, validateNoRedundantPrefix, + isPlanFilePath, MAX_FILE_SIZE, } from "./fileCommon"; import { createRuntime } from "@/node/runtime/runtimeFactory"; @@ -235,3 +242,100 @@ describe("fileCommon", () => { }); }); }); + +describe("isPlanFilePath", () => { + it("should match canonical absolute plan path against ~/.mux alias in local runtimes", async () => { + const previousMuxRoot = process.env.MUX_ROOT; + + const muxHome = await fsPromises.mkdtemp(path.join(os.tmpdir(), "mux-home-")); + process.env.MUX_ROOT = muxHome; + + try { + const runtime = new LocalRuntime("/workspace/project"); + + // Canonical (already-resolved) path under mux home. + const canonicalPlanPath = path.join(muxHome, "plans", "proj", "branch.md"); + + const config: ToolConfiguration = { + cwd: "/workspace/project", + runtime, + runtimeTempDir: "/tmp", + planFilePath: canonicalPlanPath, + }; + + // Absolute path should match exactly. + expect(await isPlanFilePath(canonicalPlanPath, config)).toBe(true); + + // Tilde + ~/.mux prefix should resolve to mux home (which may be MUX_ROOT or ~/.mux-dev). + expect(await isPlanFilePath("~/.mux/plans/proj/branch.md", config)).toBe(true); + + // If the caller uses the OS-home absolute path for ~/.mux, that may not match mux home + // when mux is configured to use a different root (e.g. MUX_ROOT or NODE_ENV=development). + const osHomeMuxPath = path.join(os.homedir(), ".mux", "plans", "proj", "branch.md"); + expect(await isPlanFilePath(osHomeMuxPath, config)).toBe(false); + } finally { + if (previousMuxRoot === undefined) { + delete process.env.MUX_ROOT; + } else { + process.env.MUX_ROOT = previousMuxRoot; + } + await fsPromises.rm(muxHome, { recursive: true, force: true }); + } + }); + + it("should use DockerRuntime.resolvePath semantics when matching plan file", async () => { + const runtime = new DockerRuntime({ image: "ubuntu:22.04" }); + + const config: ToolConfiguration = { + cwd: "/src", + runtime, + runtimeTempDir: "/tmp", + planFilePath: "/var/mux/plans/proj/branch.md", + }; + + // Docker uses /var/mux for mux home (not ~/.mux), so only that absolute path matches. + expect(await isPlanFilePath("/var/mux/plans/proj/branch.md", config)).toBe(true); + + // In Docker, ~ expands to the container user's home (e.g. /root), so this should not match. + expect(await isPlanFilePath("~/.mux/plans/proj/branch.md", config)).toBe(false); + + // Non-absolute paths are resolved relative to /src (workspace), so this should not match either. + expect(await isPlanFilePath("var/mux/plans/proj/branch.md", config)).toBe(false); + }); + + it("should handle SSH-like resolution (~/, absolute, and relative) consistently", async () => { + const fakeSshRuntime: Runtime = { + // Minimal subset needed for isPlanFilePath() + resolvePath: (filePath: string) => { + const home = "/home/test"; + const pwd = "/home/test/work"; + + let resolved: string; + if (filePath === "~") { + resolved = home; + } else if (filePath.startsWith("~/")) { + resolved = path.posix.join(home, filePath.slice(2)); + } else if (filePath.startsWith("/")) { + resolved = filePath; + } else { + resolved = path.posix.join(pwd, filePath); + } + + return Promise.resolve(resolved); + }, + } as unknown as Runtime; + + const config: ToolConfiguration = { + cwd: "/home/test/work", + runtime: fakeSshRuntime, + runtimeTempDir: "/tmp", + planFilePath: "/home/test/.mux/plans/proj/branch.md", + }; + + expect(await isPlanFilePath("/home/test/.mux/plans/proj/branch.md", config)).toBe(true); + expect(await isPlanFilePath("~/.mux/plans/proj/branch.md", config)).toBe(true); + + // Relative path is resolved from PWD (not mux home), so it should not match. + expect(await isPlanFilePath(".mux/plans/proj/branch.md", config)).toBe(false); + }); +}); From b8bc9575e74e8f9ce4233cc899b31835d73d5d57 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 16 Jan 2026 10:10:12 +0100 Subject: [PATCH 3/3] tests: allow reading plan file via ~/.mux path Change-Id: I6e3c679cffd4f1a29418efbc0fe5a61bedb584c6 Signed-off-by: Thomas Kosiewski --- src/node/services/tools/file_read.test.ts | 39 +++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/node/services/tools/file_read.test.ts b/src/node/services/tools/file_read.test.ts index 4b44a994fb..286ef6ab0b 100644 --- a/src/node/services/tools/file_read.test.ts +++ b/src/node/services/tools/file_read.test.ts @@ -447,6 +447,45 @@ describe("file_read tool", () => { } }); + it("should allow reading the configured plan file via ~/.mux path when planFilePath is canonical", async () => { + const previousMuxRoot = process.env.MUX_ROOT; + const muxHome = await fs.mkdtemp(path.join(os.tmpdir(), "planFile-muxHome-")); + + try { + process.env.MUX_ROOT = muxHome; + + const planPath = path.join(muxHome, "plans", "proj", "plan.md"); + await fs.mkdir(path.dirname(planPath), { recursive: true }); + await fs.writeFile(planPath, "# Plan\n\n- Step 1\n"); + + const tool = createFileReadTool({ + ...getTestDeps(), + cwd: testDir, + runtime: new LocalRuntime(testDir), + runtimeTempDir: testDir, + mode: "exec", + planFilePath: planPath, + }); + + const result = (await tool.execute!( + { filePath: "~/.mux/plans/proj/plan.md" }, + mockToolCallOptions + )) as FileReadToolResult; + + expect(result.success).toBe(true); + if (result.success) { + expect(result.content).toContain("# Plan"); + } + } finally { + if (previousMuxRoot === undefined) { + delete process.env.MUX_ROOT; + } else { + process.env.MUX_ROOT = previousMuxRoot; + } + await fs.rm(muxHome, { recursive: true, force: true }); + } + }); + it("should allow reading the configured plan file outside cwd in exec mode", async () => { const planDir = await fs.mkdtemp(path.join(os.tmpdir(), "planFile-exec-read-")); const planPath = path.join(planDir, "plan.md");