From 6016ed70592f584b38a2bbe1e39ca8608af6dbc6 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:50:41 +0200 Subject: [PATCH] Reject missing and non-directory paths when an agent creates a local workspace (#5322) * Reject missing and non-directory paths for local create_workspace The create_workspace MCP tool with isolation local persisted a workspace and project for a path that does not exist or is a regular file. The app's workspace create request already rejects those paths with "Directory not found"; the tool now applies the same check before provisioning. * Make the local create_workspace e2e assertions path-format independent * Rethrow filesystem errors other than a missing path in local create_workspace * Express the local create_workspace e2e test through daemon and workspace helpers * Clean up the offline MCP daemon when its startup fails --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/agent/agent-mcp.e2e.test.ts | 90 +++++++++++++++++++ .../src/server/agent/tools/paseo-tools.ts | 22 ++++- 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts index 0374c4eafc..d1422f5b6b 100644 --- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts +++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts @@ -89,6 +89,75 @@ async function createMcpClient(url: string, authToken?: string): Promise rawClient.close() }; } +interface OfflineMcpDaemon { + client: McpClient; + stop: () => Promise; +} + +async function startOfflineMcpDaemon(): Promise { + const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-")); + const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); + const removeDirectories = async () => { + await rm(paseoHome, { recursive: true, force: true }); + await rm(staticDir, { recursive: true, force: true }); + }; + const port = await getAvailablePort(); + const daemon = await createPaseoDaemon( + { + listen: `127.0.0.1:${port}`, + paseoHome, + corsAllowedOrigins: [], + hostnames: true, + mcpEnabled: true, + staticDir, + mcpDebug: false, + agentClients: createTestAgentClients(), + agentStoragePath: path.join(paseoHome, "agents"), + }, + pino({ level: "silent" }), + ).catch(async (error: unknown) => { + await removeDirectories(); + throw error; + }); + try { + await daemon.start(); + const client = await createMcpClient(`http://127.0.0.1:${port}/mcp/agents`); + return { + client, + stop: async () => { + await client.close(); + await daemon.stop(); + await removeDirectories(); + }, + }; + } catch (error) { + await daemon.stop(); + await removeDirectories(); + throw error; + } +} + +type WorkspaceCreation = { workspaceId: unknown } | { error: unknown }; + +async function createLocalWorkspace(client: McpClient, cwd: string): Promise { + const result = await client.callTool({ + name: "create_workspace", + args: { isolation: "local", path: cwd }, + }); + if (result.isError) { + const content = result.content?.[0]; + return { error: content && "text" in content ? content.text : undefined }; + } + return { workspaceId: getStructuredContent(result)?.workspaceId }; +} + +async function listWorkspaces(client: McpClient): Promise { + const result = await client.callTool({ name: "list_workspaces", args: {} }); + const workspaces = getStructuredContent(result)?.workspaces; + if (!Array.isArray(workspaces)) return []; + return workspaces.map((workspace: StructuredContent) => ({ workspaceId: workspace.workspaceId })); +} + interface LaunchRecorder { recordedLaunches: AgentSessionConfig[]; } @@ -235,6 +304,27 @@ describe("agent MCP end-to-end (offline)", () => { } }, 30_000); + test("create_workspace with local isolation adopts only an existing directory", async () => { + const daemon = await startOfflineMcpDaemon(); + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-local-workspace-")); + const missingPath = path.join(root, "does-not-exist"); + const filePath = path.join(root, "regular-file"); + await writeFile(filePath, "not a directory\n", "utf8"); + try { + expect(await createLocalWorkspace(daemon.client, missingPath)).toEqual({ + error: expect.stringContaining(`Directory not found: ${missingPath}`), + }); + expect(await createLocalWorkspace(daemon.client, filePath)).toEqual({ + error: expect.stringContaining(`Directory not found: ${filePath}`), + }); + const created = await createLocalWorkspace(daemon.client, root); + expect(await listWorkspaces(daemon.client)).toEqual([created]); + } finally { + await daemon.stop(); + await rm(root, { recursive: true, force: true }); + } + }, 30_000); + test("password-protected daemon authorizes the agent MCP via the capability token", async () => { const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-")); const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 2ddadaa1a5..4d72b99401 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -1,3 +1,4 @@ +import { stat } from "node:fs/promises"; import { z } from "zod"; import { ensureValidJson } from "../../json-utils.js"; import type { Logger } from "pino"; @@ -223,6 +224,20 @@ function assertOptionsAbsent( } } +async function isExistingDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch (error) { + if (isMissingPathError(error)) return false; + throw error; + } +} + +function isMissingPathError(error: unknown): boolean { + if (typeof error !== "object" || error === null || !("code" in error)) return false; + return error.code === "ENOENT" || error.code === "ENOTDIR"; +} + function resolveWorkspaceWorktreeTarget(input: WorkspaceWorktreeOptions): WorkspaceWorktreeTarget { switch (input.mode ?? "branch-off") { case "branch-off": @@ -1225,7 +1240,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase path: z .string() .optional() - .describe("Local directory or source checkout. Defaults to your current workspace."), + .describe( + "Local directory or source checkout. Defaults to your current workspace. Local isolation adopts an existing directory and never creates one.", + ), projectId: z.string().optional().describe("Existing project id to own the workspace."), title: z.string().trim().min(1).optional(), mode: z @@ -1277,6 +1294,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase let workspace: PersistedWorkspaceRecord; if (isolation === "local") { const cwd = resolveScopedCwd(path, { required: true }); + if (!(await isExistingDirectory(cwd))) { + throw new Error(`Directory not found: ${cwd}`); + } assertOptionsAbsent( [ ["mode", mode],