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
56 changes: 56 additions & 0 deletions src/node/runtime/DevcontainerRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,62 @@ describe("DevcontainerRuntime.stat", () => {
});
});

interface DevcontainerRuntimeWithHomeValidation {
remoteHomeDir?: string;
remoteUser?: string;
verifyRemoteMuxHomeWritable(abortSignal?: AbortSignal): Promise<void>;
}

class HomeValidationDevcontainerRuntime extends DevcontainerRuntime {
commands: string[] = [];
exitCode = 0;

override exec(command: string, _options: ExecOptions): Promise<ExecStream> {
this.commands.push(command);
return Promise.resolve({
stdout: createTextStream(""),
stderr: createTextStream(""),
stdin: createSinkStream(),
exitCode: Promise.resolve(this.exitCode),
duration: Promise.resolve(0),
});
}
}

describe("DevcontainerRuntime remote Mux home validation", () => {
it("checks the configured remote user's Mux home without creating it", async () => {
const runtime = new HomeValidationDevcontainerRuntime({
srcBaseDir: "/tmp/mux",
configPath: ".devcontainer/devcontainer.json",
});
const internals = runtime as unknown as DevcontainerRuntimeWithHomeValidation;
internals.remoteUser = "node";
internals.remoteHomeDir = "/home/node";

await internals.verifyRemoteMuxHomeWritable();

expect(runtime.commands).toEqual([
'if [ -e "$HOME/.mux" ]; then test -w "$HOME/.mux"; else test -w "$HOME"; fi',
]);
});

it("reports an actionable error when its Mux home is not writable", async () => {
const runtime = new HomeValidationDevcontainerRuntime({
srcBaseDir: "/tmp/mux",
configPath: ".devcontainer/devcontainer.json",
});
runtime.exitCode = 1;
const internals = runtime as unknown as DevcontainerRuntimeWithHomeValidation;
internals.remoteUser = "node";
internals.remoteHomeDir = "/home/node";

// eslint-disable-next-line @typescript-eslint/await-thenable -- bun:test expect().rejects requires await
await expect(internals.verifyRemoteMuxHomeWritable()).rejects.toThrow(
"Devcontainer Mux home is not writable; verify the remote user's HOME permissions."
);
});
});

describe("DevcontainerRuntime.resolvePath", () => {
it("resolves ~ to cached remoteHomeDir", async () => {
const runtime = createRuntime({ remoteHomeDir: "/home/coder" });
Expand Down
27 changes: 27 additions & 0 deletions src/node/runtime/DevcontainerRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,31 @@ export class DevcontainerRuntime extends LocalBaseRuntime {
}
}

/**
* Verify that Plan Mode can create its runtime-local Mux home without assuming
* the host user's ownership. This keeps a misconfigured remoteUser actionable
* instead of failing later from an unrelated file tool invocation.
*/
private async verifyRemoteMuxHomeWritable(abortSignal?: AbortSignal): Promise<void> {
const stream = await this.exec(
'if [ -e "$HOME/.mux" ]; then test -w "$HOME/.mux"; else test -w "$HOME"; fi',
{
cwd: this.getContainerBasePath(),
timeout: 10,
abortSignal,
}
);
await stream.stdin.close();
const exitCode = await stream.exitCode;

if (exitCode !== 0) {
throw new RuntimeError(
"Devcontainer Mux home is not writable; verify the remote user's HOME permissions.",
"file_io"
);
}
}

private async fetchRemoteHome(abortSignal?: AbortSignal): Promise<void> {
if (!this.currentWorkspacePath) return;
if (abortSignal?.aborted) return;
Expand Down Expand Up @@ -549,6 +574,7 @@ export class DevcontainerRuntime extends LocalBaseRuntime {
this.remoteUser = result.remoteUser;
this.currentWorkspacePath = workspacePath;
await this.fetchRemoteHome(abortSignal);
await this.verifyRemoteMuxHomeWritable(abortSignal);

await this.setupCredentials(env, abortSignal);

Expand Down Expand Up @@ -851,6 +877,7 @@ export class DevcontainerRuntime extends LocalBaseRuntime {
this.remoteWorkspaceFolder = result.remoteWorkspaceFolder;
this.remoteUser = result.remoteUser;
await this.fetchRemoteHome(options?.signal);
await this.verifyRemoteMuxHomeWritable(options?.signal);

await this.setupCredentials(this.lastCredentialEnv, options?.signal);

Expand Down
50 changes: 44 additions & 6 deletions src/node/services/tools/fileCommon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
MAX_FILE_SIZE,
} from "./fileCommon";
import type { createRuntime as CreateRuntimeFn } from "@/node/runtime/runtimeFactory";
import { DevcontainerRuntime } from "@/node/runtime/DevcontainerRuntime";

/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment */
const {
Expand Down Expand Up @@ -205,28 +206,65 @@ describe("fileCommon", () => {
const cwd = "/workspace/project";
const runtime = createRuntime({ type: "local", srcBaseDir: cwd });

it("keeps resolving configured plan files outside cwd", () => {
it("keeps resolving configured plan files outside cwd", async () => {
const planFilePath = "/home/user/.mux/plans/plan.md";
const result = resolvePathWithinCwd(planFilePath, cwd, runtime);
const result = await resolvePathWithinCwd(planFilePath, cwd, runtime);

expect(result.correctedPath).toBe(planFilePath);
expect(result.resolvedPath).toBe(planFilePath);
});

it("keeps resolving unrelated absolute paths outside cwd", () => {
it("keeps resolving unrelated absolute paths outside cwd", async () => {
const otherPath = "/home/user/.mux/plans/other.md";
const result = resolvePathWithinCwd(otherPath, cwd, runtime);
const result = await resolvePathWithinCwd(otherPath, cwd, runtime);

expect(result.correctedPath).toBe(otherPath);
expect(result.resolvedPath).toBe(otherPath);
});

it("resolves relative paths that traverse outside cwd", () => {
const result = resolvePathWithinCwd("../plans/ancestor.md", cwd, runtime);
it("resolves relative paths that traverse outside cwd", async () => {
const result = await resolvePathWithinCwd("../plans/ancestor.md", cwd, runtime);

expect(result.correctedPath).toBe("../plans/ancestor.md");
expect(result.resolvedPath).toBe("/workspace/plans/ancestor.md");
});

it("resolves tilde paths using the Dev Container remote home", async () => {
const runtime = new DevcontainerRuntime({
srcBaseDir: "/tmp/mux",
configPath: ".devcontainer/devcontainer.json",
});
const runtimeState = runtime as unknown as { remoteHomeDir?: string };
runtimeState.remoteHomeDir = "/home/node";

const result = await resolvePathWithinCwd(
"~/.mux/plans/test-project/devcontainer-plan.md",
cwd,
runtime
);

expect(result.resolvedPath).toBe("/home/node/.mux/plans/test-project/devcontainer-plan.md");
expect(result.resolvedPath).not.toBe(
"/home/host-user/.mux/plans/test-project/devcontainer-plan.md"
);
});

it("uses the Dev Container remote-user fallback before its home is cached", async () => {
const runtime = new DevcontainerRuntime({
srcBaseDir: "/tmp/mux",
configPath: ".devcontainer/devcontainer.json",
});
const runtimeState = runtime as unknown as { remoteUser?: string };
runtimeState.remoteUser = "node";

const result = await resolvePathWithinCwd(
"~/.mux/plans/test-project/devcontainer-plan.md",
cwd,
runtime
);

expect(result.resolvedPath).toBe("/home/node/.mux/plans/test-project/devcontainer-plan.md");
});
});

describe("validateNoRedundantPrefix", () => {
Expand Down
16 changes: 13 additions & 3 deletions src/node/services/tools/fileCommon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,16 +278,26 @@ export function validatePathInCwd(
* the exact path the user asked us to touch instead of imposing a stricter
* workspace-only rule.
*/
export function resolvePathWithinCwd(
export async function resolvePathWithinCwd(
filePath: string,
cwd: string,
runtime: Runtime
): { correctedPath: string; resolvedPath: string; warning?: string } {
): Promise<{ correctedPath: string; resolvedPath: string; warning?: string }> {
const redundantPrefixResult = validateNoRedundantPrefix(filePath, cwd, runtime);
const correctedPath = redundantPrefixResult?.correctedPath ?? filePath;
const trimmedPath = correctedPath.trim();

// A Dev Container's home belongs to its remote user, not the Mux host process.
// Resolve tilde paths through the runtime so a tool never leaks host HOME into a
// command that will execute inside the container.
const resolvedPath =
trimmedPath === "~" || trimmedPath.startsWith("~/")
? await runtime.resolvePath(correctedPath)
: runtime.normalizePath(correctedPath, cwd);

return {
correctedPath,
resolvedPath: runtime.normalizePath(correctedPath, cwd),
resolvedPath,
warning: redundantPrefixResult?.warning,
};
}
44 changes: 44 additions & 0 deletions src/node/services/tools/file_edit_insert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createFileEditInsertTool } from "./file_edit_insert";
import type { FileEditInsertToolArgs, FileEditInsertToolResult } from "@/common/types/tools";
import type { ToolExecutionOptions } from "ai";
import { createRuntime } from "@/node/runtime/runtimeFactory";
import { DevcontainerRuntime } from "@/node/runtime/DevcontainerRuntime";
import { getTestDeps } from "./testHelpers";

const mockToolCallOptions: ToolExecutionOptions<unknown> = {
Expand Down Expand Up @@ -172,6 +173,49 @@ describe("file_edit_insert tool", () => {
});
});

class RecordingDevcontainerRuntime extends DevcontainerRuntime {
writtenPaths: string[] = [];

override stat(): Promise<never> {
return Promise.reject(new Error("file does not exist"));
}

override writeFile(filePath: string): WritableStream<Uint8Array> {
this.writtenPaths.push(filePath);
return new WritableStream<Uint8Array>();
}
}

it("creates plan files in the Dev Container user's home", async () => {
const planPath = "~/.mux/plans/test-project/devcontainer-plan.md";
const runtime = new RecordingDevcontainerRuntime({
srcBaseDir: "/tmp/mux",
configPath: ".devcontainer/devcontainer.json",
});
const runtimeState = runtime as unknown as { remoteUser?: string };
runtimeState.remoteUser = "node";

const tool = createFileEditInsertTool({
...getTestDeps(),
cwd: "/workspace/project",
runtime,
runtimeTempDir: "/tmp",
planFileOnly: true,
planFilePath: planPath,
});

const result = (await tool.execute!(
{ path: planPath, content: "# Plan\n" },
mockToolCallOptions
)) as FileEditInsertToolResult;

expect(result.success).toBe(true);
expect(runtime.writtenPaths).toEqual(["/home/node/.mux/plans/test-project/devcontainer-plan.md"]);
expect(runtime.writtenPaths).not.toContain(
"/home/host-user/.mux/plans/test-project/devcontainer-plan.md"
);
});

describe("file_edit_insert outside-cwd access", () => {
it("allows traversal outside cwd", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "file-edit-insert-outside-"));
Expand Down
2 changes: 1 addition & 1 deletion src/node/services/tools/file_edit_insert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const createFileEditInsertTool: ToolFactory = (config: ToolConfiguration)
correctedPath,
warning: pathWarning,
resolvedPath,
} = resolvePathWithinCwd(path, config.cwd, config.runtime);
} = await resolvePathWithinCwd(path, config.cwd, config.runtime);
path = correctedPath;

// Validate plan mode access restrictions
Expand Down
48 changes: 48 additions & 0 deletions src/node/services/tools/file_edit_operation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,54 @@ describe("executeFileEditOperation", () => {
expect(normalizeCallForFilePath.basePath).toBe(testCwd);
}
});

test("uses the runtime home for Dev Container tilde edits", async () => {
const planPath = "~/.mux/plans/test-project/devcontainer-plan.md";
const resolvedPlanPath = "/home/node/.mux/plans/test-project/devcontainer-plan.md";
const writtenPaths: string[] = [];
const runtime = {
normalizePath: jest.fn<(targetPath: string, basePath: string) => string>(
() => "/home/host-user/.mux/plans/test-project/devcontainer-plan.md"
),
resolvePath: jest.fn<(targetPath: string) => Promise<string>>(() =>
Promise.resolve(resolvedPlanPath)
),
stat: jest
.fn<() => Promise<{ size: number; modifiedTime: Date; isDirectory: boolean }>>()
.mockResolvedValue({ size: 5, modifiedTime: new Date(), isDirectory: false }),
readFile: jest.fn<() => ReadableStream<Uint8Array>>(
() =>
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("old\n"));
controller.close();
},
})
),
writeFile: jest.fn<(filePath: string) => WritableStream<Uint8Array>>((filePath: string) => {
writtenPaths.push(filePath);
return new WritableStream<Uint8Array>();
}),
} as unknown as Runtime;

const result = await executeFileEditOperation({
config: {
cwd: "/workspace/project",
runtime,
runtimeTempDir: "/tmp",
...getTestDeps(),
},
filePath: planPath,
operation: () => ({ success: true, newContent: "updated\n", metadata: {} }),
});

expect(result.success).toBe(true);
// eslint-disable-next-line @typescript-eslint/unbound-method -- asserting calls on a Jest mock
expect(runtime.resolvePath).toHaveBeenCalledWith(planPath);
// eslint-disable-next-line @typescript-eslint/unbound-method -- asserting calls on a Jest mock
expect(runtime.normalizePath).not.toHaveBeenCalled();
expect(writtenPaths).toEqual([resolvedPlanPath]);
});
});

describe("executeFileEditOperation outside-cwd access", () => {
Expand Down
2 changes: 1 addition & 1 deletion src/node/services/tools/file_edit_operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export async function executeFileEditOperation<TMetadata>({
correctedPath: validatedPath,
warning: pathWarning,
resolvedPath,
} = resolvePathWithinCwd(filePath, config.cwd, config.runtime);
} = await resolvePathWithinCwd(filePath, config.cwd, config.runtime);
filePath = validatedPath;

// Validate plan mode access restrictions
Expand Down
Loading