Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 4 additions & 3 deletions src/node/services/aiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down
106 changes: 105 additions & 1 deletion src/node/services/tools/fileCommon.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
});
});
39 changes: 39 additions & 0 deletions src/node/services/tools/file_read.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading