diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index dfbee5a47..3b765b3ab 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -992,6 +992,47 @@ test("createAgent injects paseo MCP server only into provider launch config", as }); }); +test("createAgent injects the MCP auth token as a bearer header into the launch config", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class CaptureClient extends TestAgentClient { + lastConfig: AgentSessionConfig | null = null; + + override async createSession(config: AgentSessionConfig): Promise { + this.lastConfig = config; + return new TestAgentSession(config); + } + } + + const client = new CaptureClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + mcpAuthToken: "cap-token", + idFactory: () => "00000000-0000-4000-8000-000000000104", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + expect(manager.getMcpAuthToken()).toBe("cap-token"); + expect(client.lastConfig?.mcpServers?.paseo).toEqual({ + type: "http", + url: `http://127.0.0.1:6767/mcp/agents?callerAgentId=${snapshot.id}`, + headers: { Authorization: "Bearer cap-token" }, + }); + + rmSync(workdir, { recursive: true, force: true }); +}); + test("resumeAgentFromPersistence replaces stored internal paseo MCP with current runtime URL", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 5855af4e0..13156ad13 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -196,6 +196,7 @@ export interface AgentManagerOptions { durableTimelineStore?: AgentTimelineStore; terminalManager?: TerminalManager | null; mcpBaseUrl?: string; + mcpAuthToken?: string; appendSystemPrompt?: string; agentStreamCoalesceWindowMs?: number; rescueTimeouts?: AgentManagerRescueTimeouts; @@ -482,6 +483,7 @@ export class AgentManager { private readonly backgroundTasks = new Set>(); private readonly agentStreamCoalescer: AgentStreamCoalescer; private mcpBaseUrl: string | null; + private readonly mcpAuthToken: string | null; private appendSystemPrompt: string; private onAgentAttention?: AgentAttentionCallback; private onAgentArchived?: AgentArchivedCallback; @@ -496,6 +498,7 @@ export class AgentManager { this.onAgentAttention = options?.onAgentAttention; this.onWorkspaceStateMayHaveChanged = options?.onWorkspaceStateMayHaveChanged; this.mcpBaseUrl = options?.mcpBaseUrl ?? null; + this.mcpAuthToken = options?.mcpAuthToken ?? null; this.appendSystemPrompt = options.appendSystemPrompt ?? ""; this.logger = options.logger.child({ module: "agent", component: "agent-manager" }); this.rescueTimeouts = { @@ -554,6 +557,16 @@ export class AgentManager { this.mcpBaseUrl = url; } + /** + * Capability token the daemon's own MCP clients must present to the Agent MCP + * endpoint when a daemon password is configured. Read by the per-client + * session to authenticate its own MCP connection. Stays in the daemon — never + * sent to remote clients. + */ + getMcpAuthToken(): string | null { + return this.mcpAuthToken; + } + setAppendSystemPrompt(prompt: string | null | undefined): void { this.appendSystemPrompt = prompt ?? ""; } @@ -3567,6 +3580,7 @@ export class AgentManager { config: storedConfig, agentId, mcpBaseUrl: this.mcpBaseUrl, + mcpAuthToken: this.mcpAuthToken, }), ); return { storedConfig, launchConfig }; 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 a1b639a8c..933d625f9 100644 --- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts +++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts @@ -9,6 +9,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import pino from "pino"; import { withTimeout } from "../../utils/promise-timeout.js"; +import { hashDaemonPassword } from "../auth.js"; import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js"; import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import type { @@ -78,8 +79,11 @@ function getStructuredContent(result: McpToolResult): StructuredContent | null { return null; } -async function createMcpClient(url: string): Promise { - const transport = new StreamableHTTPClientTransport(new URL(url)); +async function createMcpClient(url: string, authToken?: string): Promise { + const transport = new StreamableHTTPClientTransport( + new URL(url), + authToken ? { requestInit: { headers: { Authorization: `Bearer ${authToken}` } } } : undefined, + ); const rawClient = await experimental_createMCPClient({ transport }); const boundCallTool: McpClient["callTool"] = Reflect.get(rawClient, "callTool").bind(rawClient); return { callTool: boundCallTool, close: () => rawClient.close() }; @@ -177,6 +181,76 @@ describe("agent MCP end-to-end (offline)", () => { } }, 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-")); + const agentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-cwd-")); + const port = await getAvailablePort(); + + const daemonConfig: PaseoDaemonConfig = { + listen: `127.0.0.1:${port}`, + paseoHome, + corsAllowedOrigins: [], + hostnames: true, + mcpEnabled: true, + staticDir, + mcpDebug: false, + agentClients: createTestAgentClients(), + agentStoragePath: path.join(paseoHome, "agents"), + auth: { password: hashDaemonPassword("daemon-secret") }, + }; + + const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" })); + await daemon.start(); + + const mcpUrl = `http://127.0.0.1:${port}/mcp/agents`; + const capabilityToken = daemon.agentManager.getMcpAuthToken(); + expect(typeof capabilityToken).toBe("string"); + + let agentId: string | null = null; + let client: McpClient | null = null; + try { + // Remote auth is not weakened: a request without credentials is rejected + // before any MCP processing. + const unauthorized = await fetch(mcpUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + expect(unauthorized.status).toBe(401); + + // The injected capability token authenticates the full MCP handshake: + // creating (and connecting) the client and driving a tool call both go + // through the password-gated /mcp/agents route. (The exact bearer header + // injected into a child agent's config is covered by the + // runtime-mcp-config unit test.) + client = await createMcpClient(mcpUrl, capabilityToken!); + const result = await client.callTool({ + name: "create_agent", + args: { + cwd: agentCwd, + title: "Password MCP", + provider: "claude/claude-test-model", + mode: "bypassPermissions", + initialPrompt: "reply with done and stop", + background: true, + }, + }); + const payload = getStructuredContent(result); + agentId = typeof payload?.agentId === "string" ? payload.agentId : null; + expect(agentId).toBeTruthy(); + } finally { + if (agentId) { + await client?.callTool({ name: "kill_agent", args: { agentId } }); + } + await client?.close(); + await daemon.stop(); + await rm(paseoHome, { recursive: true, force: true }); + await rm(staticDir, { recursive: true, force: true }); + await rm(agentCwd, { recursive: true, force: true }); + } + }, 30_000); + test("create_agent auto-injects paseo MCP by default and can be disabled", 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/runtime-mcp-config.test.ts b/packages/server/src/server/agent/runtime-mcp-config.test.ts new file mode 100644 index 000000000..4704d131b --- /dev/null +++ b/packages/server/src/server/agent/runtime-mcp-config.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "vitest"; + +import type { AgentSessionConfig } from "./agent-sdk-types.js"; +import { withRuntimePaseoMcpServer } from "./runtime-mcp-config.js"; + +const BASE_CONFIG: AgentSessionConfig = { + provider: "claude", + cwd: "/tmp/agent", +}; + +describe("withRuntimePaseoMcpServer", () => { + test("injects the paseo MCP server with a bearer header when a token is provided", () => { + const result = withRuntimePaseoMcpServer({ + config: BASE_CONFIG, + agentId: "agent-1", + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + mcpAuthToken: "cap-token", + }); + + expect(result.mcpServers?.paseo).toEqual({ + type: "http", + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1", + headers: { Authorization: "Bearer cap-token" }, + }); + }); + + test("omits the header when no token is available", () => { + const result = withRuntimePaseoMcpServer({ + config: BASE_CONFIG, + agentId: "agent-1", + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + mcpAuthToken: null, + }); + + expect(result.mcpServers?.paseo).toEqual({ + type: "http", + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1", + }); + }); + + test("does not inject when no MCP base URL is configured", () => { + const result = withRuntimePaseoMcpServer({ + config: BASE_CONFIG, + agentId: "agent-1", + mcpBaseUrl: null, + mcpAuthToken: "cap-token", + }); + + expect(result.mcpServers).toBeUndefined(); + }); +}); diff --git a/packages/server/src/server/agent/runtime-mcp-config.ts b/packages/server/src/server/agent/runtime-mcp-config.ts index 8df9ebabb..af04e8021 100644 --- a/packages/server/src/server/agent/runtime-mcp-config.ts +++ b/packages/server/src/server/agent/runtime-mcp-config.ts @@ -30,6 +30,12 @@ export function withRuntimePaseoMcpServer(params: { config: AgentSessionConfig; agentId: string; mcpBaseUrl: string | null; + /** + * Capability token authenticating the injected connection to the daemon's + * Agent MCP endpoint. The daemon password is gated off this route, so without + * this header the agent's MCP requests are rejected when a password is set. + */ + mcpAuthToken: string | null; }): AgentSessionConfig { const storedConfig = stripInternalPaseoMcpServer(params.config); if (!params.mcpBaseUrl || storedConfig.mcpServers?.[PASEO_MCP_SERVER_NAME]) { @@ -42,6 +48,9 @@ export function withRuntimePaseoMcpServer(params: { [PASEO_MCP_SERVER_NAME]: { type: "http", url: `${params.mcpBaseUrl}?callerAgentId=${params.agentId}`, + ...(params.mcpAuthToken + ? { headers: { Authorization: `Bearer ${params.mcpAuthToken}` } } + : {}), }, ...storedConfig.mcpServers, }, diff --git a/packages/server/src/server/auth.test.ts b/packages/server/src/server/auth.test.ts index 1bcf05392..f53ce43a6 100644 --- a/packages/server/src/server/auth.test.ts +++ b/packages/server/src/server/auth.test.ts @@ -5,6 +5,7 @@ import { extractWsBearerProtocol, extractWsBearerToken, hashDaemonPassword, + isAgentMcpRequestAuthorized, isBearerTokenValidAsync, isBearerTokenValid, shouldBypassBearerAuth, @@ -61,8 +62,62 @@ describe("daemon bearer validator", () => { expect(shouldBypassBearerAuth("GET", "/api/health")).toBe(true); // Guarded by its own single-use download token, not the daemon password. expect(shouldBypassBearerAuth("GET", "/api/files/download")).toBe(true); + // Guarded by its own per-daemon-run capability token (see + // isAgentMcpRequestAuthorized), not the daemon password. + expect(shouldBypassBearerAuth("POST", "/mcp/agents")).toBe(true); // Everything else stays behind the daemon password. expect(shouldBypassBearerAuth("GET", "/api/status")).toBe(false); expect(shouldBypassBearerAuth("POST", "/api/files/upload")).toBe(false); }); }); + +describe("agent MCP request authorizer", () => { + const CAPABILITY_TOKEN = "cap-token-abc123"; + + test("allows any request when no daemon password is configured", async () => { + expect( + await isAgentMcpRequestAuthorized({ + password: undefined, + capabilityToken: CAPABILITY_TOKEN, + authorizationHeader: undefined, + }), + ).toBe(true); + }); + + test("accepts the injected capability token", async () => { + expect( + await isAgentMcpRequestAuthorized({ + password: CORRECT_PASSWORD_HASH, + capabilityToken: CAPABILITY_TOKEN, + authorizationHeader: `Bearer ${CAPABILITY_TOKEN}`, + }), + ).toBe(true); + }); + + test("still accepts a valid daemon-password bearer", async () => { + expect( + await isAgentMcpRequestAuthorized({ + password: CORRECT_PASSWORD_HASH, + capabilityToken: CAPABILITY_TOKEN, + authorizationHeader: "Bearer correct-password", + }), + ).toBe(true); + }); + + test("rejects requests presenting neither the token nor a valid password", async () => { + expect( + await isAgentMcpRequestAuthorized({ + password: CORRECT_PASSWORD_HASH, + capabilityToken: CAPABILITY_TOKEN, + authorizationHeader: undefined, + }), + ).toBe(false); + expect( + await isAgentMcpRequestAuthorized({ + password: CORRECT_PASSWORD_HASH, + capabilityToken: CAPABILITY_TOKEN, + authorizationHeader: "Bearer wrong-token", + }), + ).toBe(false); + }); +}); diff --git a/packages/server/src/server/auth.ts b/packages/server/src/server/auth.ts index 4e2e7ee7f..718920d05 100644 --- a/packages/server/src/server/auth.ts +++ b/packages/server/src/server/auth.ts @@ -1,4 +1,5 @@ import { compare, compareSync, hashSync } from "bcryptjs"; +import { timingSafeEqual } from "node:crypto"; import type { RequestHandler } from "express"; export const DAEMON_PASSWORD_BCRYPT_COST = 12; @@ -132,6 +133,16 @@ const BEARER_AUTH_BYPASS_PATHS = new Set([ // rejects requests without a valid token (400/403), so dropping the bearer // here does not make the route unauthenticated. "/api/files/download", + // The daemon injects its own agents' Paseo MCP connections at this endpoint + // (and connects its own per-client MCP client here). Those connections cannot + // carry the daemon password — it is only known in plaintext when set via env, + // never when set via the app — so the route authenticates them with a + // per-daemon-run capability token instead (see isAgentMcpRequestAuthorized). + // The token is injected only into local agent configs/sessions and never sent + // to remote clients, and the route still rejects callers presenting neither + // the token nor a valid daemon password, so dropping the global bearer here + // does not make the endpoint unauthenticated. + "/mcp/agents", ]); export function shouldBypassBearerAuth(method: string, path: string): boolean { @@ -140,3 +151,32 @@ export function shouldBypassBearerAuth(method: string, path: string): boolean { } return BEARER_AUTH_BYPASS_PATHS.has(path); } + +/** + * Authorizes a request to the Agent MCP endpoint (/mcp/agents), which is exempt + * from the global daemon-password middleware. Accepts either the per-daemon-run + * capability token the daemon injects into its own agents' configs and MCP + * client, or a valid daemon-password bearer (so existing password-authenticated + * callers keep working). When no daemon password is configured the endpoint is + * open, matching the global middleware's behavior. + */ +export async function isAgentMcpRequestAuthorized(input: { + password: string | undefined; + capabilityToken: string | null; + authorizationHeader: string | undefined; +}): Promise { + if (!input.password) { + return true; + } + const token = extractHttpBearerToken(input.authorizationHeader); + if (input.capabilityToken !== null && token !== null) { + // Constant-time compare; length-guard first because timingSafeEqual throws + // on differing buffer lengths. + const provided = Buffer.from(token); + const expected = Buffer.from(input.capabilityToken); + if (provided.length === expected.length && timingSafeEqual(provided, expected)) { + return true; + } + } + return isBearerTokenValidAsync({ password: input.password, token }); +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 47932dbc9..d19a3b259 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -131,7 +131,11 @@ import { ScriptHealthMonitor } from "./script-health-monitor.js"; import { createScriptStatusEmitter } from "./script-status-projection.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.js"; -import { createRequireBearerMiddleware, type DaemonAuthConfig } from "./auth.js"; +import { + createRequireBearerMiddleware, + isAgentMcpRequestAuthorized, + type DaemonAuthConfig, +} from "./auth.js"; type AgentMcpTransportMap = Map; @@ -325,6 +329,15 @@ export async function createPaseoDaemon( ttlMs: downloadTokenTtlMs, }); + // Capability token authenticating the daemon's own agents to the loopback + // Agent MCP endpoint (/mcp/agents). Random per daemon run, injected only into + // local agent configs and the daemon's own MCP client — never sent to remote + // clients — so it cannot be replayed off-box. This lets the injected MCP + // authenticate even when the daemon password is set via the app (hash only, + // no plaintext available). Mirrors the /api/files/download capability-token + // pattern. + const agentMcpAuthToken = randomUUID(); + const listenTarget = parseListenString(config.listen); const app = express(); @@ -550,6 +563,7 @@ export async function createPaseoDaemon( onWorkspaceStateMayHaveChanged: ({ cwd }) => { workspaceGitService.onWorkspaceStateMayHaveChanged(cwd); }, + mcpAuthToken: agentMcpAuthToken, logger, }); @@ -800,6 +814,20 @@ export async function createPaseoDaemon( req: express.Request, res: express.Response, ): Promise => { + // This route is exempt from the global daemon-password middleware, so it + // authenticates here using the injected capability token (or a valid + // daemon password). Without this, a password-protected daemon would be + // wide open on its agent control plane. + if ( + !(await isAgentMcpRequestAuthorized({ + password: config.auth?.password, + capabilityToken: agentMcpAuthToken, + authorizationHeader: req.header("authorization"), + })) + ) { + res.status(401).json({ error: "Unauthorized" }); + return; + } if (config.mcpDebug) { logger.debug( { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index b7eee2636..db84704fb 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1220,7 +1220,13 @@ export class Session { ); return; } - const transport = new StreamableHTTPClientTransport(new URL(this.mcpBaseUrl)); + const authToken = this.agentManager.getMcpAuthToken(); + const transport = new StreamableHTTPClientTransport( + new URL(this.mcpBaseUrl), + authToken + ? { requestInit: { headers: { Authorization: `Bearer ${authToken}` } } } + : undefined, + ); this.agentMcpClient = await experimental_createMCPClient({ transport,