Authenticate injected agent MCP when daemon password is set
Fixes #914 Fixes #690
This commit is contained in:
@@ -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<AgentSession> {
|
||||
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");
|
||||
|
||||
@@ -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<Promise<void>>();
|
||||
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 };
|
||||
|
||||
@@ -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<McpClient> {
|
||||
const transport = new StreamableHTTPClientTransport(new URL(url));
|
||||
async function createMcpClient(url: string, authToken?: string): Promise<McpClient> {
|
||||
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-"));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<boolean> {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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<string, StreamableHTTPServerTransport>;
|
||||
|
||||
@@ -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<void> => {
|
||||
// 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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user