diff --git a/app/src/agent-tools.ts b/app/src/agent-tools.ts index 94e8a08..0d18216 100644 --- a/app/src/agent-tools.ts +++ b/app/src/agent-tools.ts @@ -818,7 +818,7 @@ export async function runCommand( if (dangerReason) throw new Error(dangerReason); const cwd = resolveSafePath(workspaceRoot, relativeCwd); - const wrappedCommand = applySandbox(command, { workspaceRoot, allowNetwork: network }); + const wrappedCommand = applySandbox(command, { workspaceRoot, allowNetwork: network, cwd }); const settings = settingsStore.getSettings(); let stopMonitor = () => {}; try { @@ -913,7 +913,7 @@ export function startBackgroundCommand( } const cwd = resolveSafePath(workspaceRoot, relativeCwd); - const wrappedCommand = applySandbox(command, { workspaceRoot, allowNetwork: network }); + const wrappedCommand = applySandbox(command, { workspaceRoot, allowNetwork: network, cwd }); // detached so the shell becomes its own process group leader — lets // killProcessTree() below signal the whole group (shell + whatever it // spawned, e.g. `npm run dev` spawning `node`) instead of just the shell diff --git a/app/src/command-sandbox.test.ts b/app/src/command-sandbox.test.ts index 1a1a8de..f4b8371 100644 --- a/app/src/command-sandbox.test.ts +++ b/app/src/command-sandbox.test.ts @@ -79,6 +79,29 @@ describe("wrapCommand", () => { expect(wrapped?.args[1]).toContain("(allow network*)"); expect(wrapped?.args[1]).not.toContain("(deny network*)"); }); + + // --chdir takes precedence over the working directory bwrap inherits from + // the spawning process, so pinning it to the workspace root silently + // overrode the subdirectory the caller had already resolved. + it("chdirs to the requested subdirectory, not just the workspace root", () => { + const wrapped = wrapCommand( + "npm test", + { workspaceRoot: "/home/user/project", allowNetwork: false, cwd: "/home/user/project/packages/api" }, + "linux", + has(["bwrap"]) + ); + const chdirIndex = wrapped!.args.indexOf("--chdir"); + expect(chdirIndex).toBeGreaterThan(-1); + expect(wrapped!.args[chdirIndex + 1]).toBe("/home/user/project/packages/api"); + // The workspace root is still what gets bound writable. + expect(wrapped!.args).toEqual(expect.arrayContaining(["--bind", "/home/user/project", "/home/user/project"])); + }); + + it("falls back to the workspace root when no cwd is given", () => { + const wrapped = wrapCommand("npm test", { workspaceRoot: "/home/user/project", allowNetwork: false }, "linux", has(["bwrap"])); + const chdirIndex = wrapped!.args.indexOf("--chdir"); + expect(wrapped!.args[chdirIndex + 1]).toBe("/home/user/project"); + }); }); describe("applySandbox", () => { diff --git a/app/src/command-sandbox.ts b/app/src/command-sandbox.ts index e604ca2..79d2543 100644 --- a/app/src/command-sandbox.ts +++ b/app/src/command-sandbox.ts @@ -73,6 +73,14 @@ export function detectSandboxCapabilities( export interface WrapCommandOptions { workspaceRoot: string; allowNetwork: boolean; + // Directory the command should start in. Defaults to the workspace root. + // bubblewrap is passed --chdir, which takes precedence over the working + // directory inherited from the spawning process, so a caller that resolved + // a subdirectory has to pass it through here — setting it only on the + // spawn options would be silently overridden. Callers are responsible for + // confining this to the workspace (they go through resolveSafePath); it is + // not re-validated here. + cwd?: string; } export interface WrappedCommand { @@ -92,6 +100,7 @@ export function wrapCommand( ): WrappedCommand | null { const caps = detectSandboxCapabilities(platform, hasCommand); const root = path.resolve(opts.workspaceRoot); + const startDir = opts.cwd ? path.resolve(opts.cwd) : root; if (caps.mechanism === "bubblewrap") { const args = [ @@ -113,7 +122,7 @@ export function wrapCommand( root, root, "--chdir", - root, + startDir, "--die-with-parent", "--unshare-all", ]; diff --git a/app/src/terminal-manager.ts b/app/src/terminal-manager.ts index 74abc12..e78c19d 100644 --- a/app/src/terminal-manager.ts +++ b/app/src/terminal-manager.ts @@ -59,7 +59,7 @@ export function createTerminal( // ordinary shell use (curl, ping, package managers) confusingly fail. // Filesystem confinement to the workspace still applies where the // platform supports it. - const wrapped = wrapCommand(shell, { workspaceRoot, allowNetwork: true }); + const wrapped = wrapCommand(shell, { workspaceRoot, allowNetwork: true, cwd }); const ptyOptions = { name: "xterm-256color", cols: 80, rows: 24, cwd, env: process.env }; const ptyProcess = wrapped ? pty.spawn(wrapped.command, wrapped.args, ptyOptions) : pty.spawn(shell, [], ptyOptions);