From 2deb696ef498d800e1053d7b8af30b9c574723e2 Mon Sep 17 00:00:00 2001 From: Cameron Beeley Date: Wed, 24 Jun 2026 01:22:31 +0100 Subject: [PATCH] feat(gateway): scoped attach grants for external MCP loopback clients Per-session, TTL-bounded, revocable bearer grants (mcp-grant-store) let an external/interactive harness reach the gateway's scoped MCP loopback tools without the cli-backend's process-global token. A grant is a lower-trust boundary: it binds the session server-side AND fail-closes on every caller-supplied context header (x-session-key plus message-channel/account/current-channel/thread/ source-reply/event-kind), so a grant holder can neither scope-shop the session nor spoof delivery/action context into scoped tools or the message tool. New attach.grant/attach.revoke operator methods mint/revoke grants and return the loopback MCP config. Owner/non-owner cli-backend path unchanged. --- src/gateway/mcp-grant-store.test.ts | 91 +++++++++++++++ src/gateway/mcp-grant-store.ts | 128 ++++++++++++++++++++++ src/gateway/mcp-http.request.ts | 45 ++++++-- src/gateway/mcp-http.test.ts | 42 +++++++ src/gateway/methods/core-descriptors.ts | 5 + src/gateway/server-methods.ts | 8 ++ src/gateway/server-methods/attach.test.ts | 99 +++++++++++++++++ src/gateway/server-methods/attach.ts | 66 +++++++++++ 8 files changed, 477 insertions(+), 7 deletions(-) create mode 100644 src/gateway/mcp-grant-store.test.ts create mode 100644 src/gateway/mcp-grant-store.ts create mode 100644 src/gateway/server-methods/attach.test.ts create mode 100644 src/gateway/server-methods/attach.ts diff --git a/src/gateway/mcp-grant-store.test.ts b/src/gateway/mcp-grant-store.test.ts new file mode 100644 index 000000000000..740838fc2f95 --- /dev/null +++ b/src/gateway/mcp-grant-store.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + resetAttachGrantsForTest, + attachGrantStoreSize, + mintAttachGrant, + resolveAttachGrant, + revokeAttachGrant, + revokeAttachGrantsForSession, + sweepExpiredAttachGrants, +} from "./mcp-grant-store.js"; + +const T0 = 1_000_000_000_000; // fixed epoch for deterministic TTL tests + +describe("mcp-grant-store", () => { + beforeEach(() => resetAttachGrantsForTest()); + + it("mints a grant bound to the sessionKey with a token and a TTL window", () => { + const g = mintAttachGrant({ sessionKey: "agent:main:main", ttlMs: 60_000, nowMs: T0 }); + expect(g.sessionKey).toBe("agent:main:main"); + expect(g.token).toMatch(/^[0-9a-f]{64}$/); + expect(g.issuedAtMs).toBe(T0); + expect(g.expiresAtMs).toBe(T0 + 60_000); + }); + + it("requires a non-empty sessionKey", () => { + expect(() => mintAttachGrant({ sessionKey: " ", nowMs: T0 })).toThrow(); + }); + + it("resolves a live grant and drops it once expired (TTL)", () => { + const g = mintAttachGrant({ sessionKey: "agent:main:x", ttlMs: 1_000, nowMs: T0 }); + expect(resolveAttachGrant(g.token, T0)?.sessionKey).toBe("agent:main:x"); + expect(resolveAttachGrant(g.token, T0 + 999)?.sessionKey).toBe("agent:main:x"); + // at/after expiry → undefined, and the lookup self-sweeps it + expect(resolveAttachGrant(g.token, T0 + 1_000)).toBeUndefined(); + expect(resolveAttachGrant(g.token, T0 + 1_001)).toBeUndefined(); + expect(attachGrantStoreSize()).toBe(0); + }); + + it("returns undefined for an unknown token (no scope without a grant)", () => { + expect(resolveAttachGrant("deadbeef", T0)).toBeUndefined(); + }); + + it("binds the sessionKey to the grant (token carries scope identity, not the caller)", () => { + const a = mintAttachGrant({ sessionKey: "agent:main:telegram:1", nowMs: T0 }); + const b = mintAttachGrant({ sessionKey: "agent:main:telegram:2", nowMs: T0 }); + // each token resolves only to its own bound session — the basis for header-spoof resistance + expect(resolveAttachGrant(a.token, T0)?.sessionKey).toBe("agent:main:telegram:1"); + expect(resolveAttachGrant(b.token, T0)?.sessionKey).toBe("agent:main:telegram:2"); + expect(a.token).not.toBe(b.token); + }); + + it("revokes by token", () => { + const g = mintAttachGrant({ sessionKey: "agent:main:x", nowMs: T0 }); + expect(revokeAttachGrant(g.token)).toBe(true); + expect(resolveAttachGrant(g.token, T0)).toBeUndefined(); + expect(revokeAttachGrant(g.token)).toBe(false); + }); + + it("revokes all grants for a session", () => { + mintAttachGrant({ sessionKey: "agent:main:x", nowMs: T0 }); + mintAttachGrant({ sessionKey: "agent:main:x", nowMs: T0 }); + mintAttachGrant({ sessionKey: "agent:main:y", nowMs: T0 }); + expect(revokeAttachGrantsForSession("agent:main:x")).toBe(2); + expect(attachGrantStoreSize()).toBe(1); + }); + + it("clamps TTL: default for non-positive, ceiling at 12h", () => { + const def = mintAttachGrant({ sessionKey: "s", nowMs: T0 }); + expect(def.expiresAtMs).toBe(T0 + 60 * 60 * 1000); + const zero = mintAttachGrant({ sessionKey: "s", ttlMs: 0, nowMs: T0 }); + expect(zero.expiresAtMs).toBe(T0 + 60 * 60 * 1000); + const huge = mintAttachGrant({ sessionKey: "s", ttlMs: 999 * 60 * 60 * 1000, nowMs: T0 }); + expect(huge.expiresAtMs).toBe(T0 + 12 * 60 * 60 * 1000); + }); + + it("sweeps expired grants", () => { + mintAttachGrant({ sessionKey: "s", ttlMs: 1_000, nowMs: T0 }); + mintAttachGrant({ sessionKey: "s", ttlMs: 5_000, nowMs: T0 }); + expect(sweepExpiredAttachGrants(T0 + 2_000)).toBe(1); + expect(attachGrantStoreSize()).toBe(1); + }); + + it("evicts expired grants on mint, bounding the store (no accumulation)", () => { + mintAttachGrant({ sessionKey: "s", ttlMs: 1_000, nowMs: T0 }); + expect(attachGrantStoreSize()).toBe(1); + // a later mint past the first's expiry sweeps the stale entry before inserting — without + // sweep-on-mint the never-looked-up grant would linger and the size would be 2. + mintAttachGrant({ sessionKey: "s", ttlMs: 1_000, nowMs: T0 + 5_000 }); + expect(attachGrantStoreSize()).toBe(1); + }); +}); diff --git a/src/gateway/mcp-grant-store.ts b/src/gateway/mcp-grant-store.ts new file mode 100644 index 000000000000..c227b702b4e5 --- /dev/null +++ b/src/gateway/mcp-grant-store.ts @@ -0,0 +1,128 @@ +/** + * Per-session MCP loopback **attach grants**. + * + * The loopback MCP server (`mcp-http.ts`) authenticates the gateway-spawned cli-backend with two + * process-global bearer tokens (owner / non-owner) and scopes tools from client-supplied headers — + * adequate because that client is cooperative and gateway-launched. An **attach** grant is the + * primitive for a *less-trusted* external/interactive harness (Claude Code, OpenCode, or a harness + * reached via a node/companion app over its existing gateway connection): a short-lived, revocable + * bearer whose **scope is bound to the grant**, not to the request headers. + * + * Security properties: + * - The bound `sessionKey` comes from the grant, so an attach caller cannot scope-shop by setting + * `x-session-key` (the request layer ignores the header when a grant matches). + * - Grants are always treated as **non-owner** (`senderIsOwner=false`) — the loopback surface + * already excludes the destructive native tools (`read/write/edit/apply_patch/exec/process`). + * - TTL + explicit revoke bound the blast radius of a leaked token. + * + * Transport-independent: the same grant is presented whether the harness reaches the loopback over + * 127.0.0.1 (gateway host) or tunnelled in over a node/app's existing authenticated channel. + */ +import crypto from "node:crypto"; + +export interface McpAttachGrant { + /** Opaque bearer presented as `Authorization: Bearer `. */ + readonly token: string; + /** The openclaw session this grant is bound to; tool scope is resolved for this key. */ + readonly sessionKey: string; + /** Absolute expiry (ms epoch). */ + readonly expiresAtMs: number; + /** Absolute mint time (ms epoch). */ + readonly issuedAtMs: number; +} + +const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1h +const MAX_TTL_MS = 12 * 60 * 60 * 1000; // hard ceiling so a caller can't request a forever-grant + +const grantsByToken = new Map(); + +function clampTtlMs(ttlMs: number | undefined): number { + if (!Number.isFinite(ttlMs) || (ttlMs as number) <= 0) { + return DEFAULT_TTL_MS; + } + return Math.min(ttlMs as number, MAX_TTL_MS); +} + +/** Mint a grant bound to `sessionKey`. Returns the grant (the caller hands `token` to the harness). */ +export function mintAttachGrant(params: { + sessionKey: string; + ttlMs?: number; + nowMs?: number; +}): McpAttachGrant { + const sessionKey = params.sessionKey.trim(); + if (!sessionKey) { + throw new Error("mintAttachGrant: sessionKey is required"); + } + const nowMs = params.nowMs ?? Date.now(); + // Sweep on mint so grants that are minted but never looked up again (harness never connects) + // don't accumulate — lookup self-sweeps only the token it touches, so this bounds the map. + sweepExpiredAttachGrants(nowMs); + const grant: McpAttachGrant = { + token: crypto.randomBytes(32).toString("hex"), + sessionKey, + issuedAtMs: nowMs, + expiresAtMs: nowMs + clampTtlMs(params.ttlMs), + }; + grantsByToken.set(grant.token, grant); + return grant; +} + +/** + * Resolve a bearer token to a live grant, or `undefined` if unknown/expired. An expired grant is + * dropped on lookup (lazy sweep). Lookup is by full-token map key: a caller must already hold the + * complete 256-bit token to get a hit, so there is no partial-match timing oracle to defend. + */ +export function resolveAttachGrant( + token: string, + nowMs: number = Date.now(), +): McpAttachGrant | undefined { + const grant = grantsByToken.get(token); + if (!grant) { + return undefined; + } + if (nowMs >= grant.expiresAtMs) { + grantsByToken.delete(token); + return undefined; + } + return grant; +} + +/** Revoke a grant by token. Returns true if a grant was removed. */ +export function revokeAttachGrant(token: string): boolean { + return grantsByToken.delete(token); +} + +/** Revoke every live grant for a session (e.g. on session teardown). Returns the count removed. */ +export function revokeAttachGrantsForSession(sessionKey: string): number { + const key = sessionKey.trim(); + let removed = 0; + for (const [token, grant] of grantsByToken) { + if (grant.sessionKey === key) { + grantsByToken.delete(token); + removed += 1; + } + } + return removed; +} + +/** Drop expired grants. Returns the count swept. Call opportunistically; lookup also self-sweeps. */ +export function sweepExpiredAttachGrants(nowMs: number = Date.now()): number { + let removed = 0; + for (const [token, grant] of grantsByToken) { + if (nowMs >= grant.expiresAtMs) { + grantsByToken.delete(token); + removed += 1; + } + } + return removed; +} + +/** Number of entries currently held (test/diagnostics). Does not sweep; reflects raw store size. */ +export function attachGrantStoreSize(): number { + return grantsByToken.size; +} + +/** Clear all grants (test isolation only). */ +export function resetAttachGrantsForTest(): void { + grantsByToken.clear(); +} diff --git a/src/gateway/mcp-http.request.ts b/src/gateway/mcp-http.request.ts index 9e32ae7c1451..3443de0c87d7 100644 --- a/src/gateway/mcp-http.request.ts +++ b/src/gateway/mcp-http.request.ts @@ -10,6 +10,7 @@ import { isTruthyEnvValue } from "../infra/env.js"; import { safeEqualSecret } from "../security/secret-equal.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import { getHeader } from "./http-utils.js"; +import { resolveAttachGrant } from "./mcp-grant-store.js"; import { isLoopbackAddress } from "./net.js"; import { checkBrowserOrigin } from "./origin-check.js"; @@ -112,14 +113,22 @@ function resolveMcpSender(params: { req: IncomingMessage; ownerToken: string; nonOwnerToken: string; -}): { senderIsOwner: boolean } | undefined { +}): { senderIsOwner: boolean; boundSessionKey?: string } | undefined { const authHeader = getHeader(params.req, "authorization") ?? ""; const ownerTokenMatched = safeEqualSecret(authHeader, `Bearer ${params.ownerToken}`); const nonOwnerTokenMatched = safeEqualSecret(authHeader, `Bearer ${params.nonOwnerToken}`); - if (!ownerTokenMatched && !nonOwnerTokenMatched) { - return undefined; + if (ownerTokenMatched || nonOwnerTokenMatched) { + return { senderIsOwner: ownerTokenMatched }; } - return { senderIsOwner: ownerTokenMatched }; + // Attach grant: an external/interactive harness presents a per-session grant token (mcp-grant-store). + // Always non-owner, and its scope is bound to the grant's sessionKey so a grant holder cannot widen + // scope via the x-session-key header — resolveMcpRequestContext honors boundSessionKey instead. + const grantToken = authHeader.startsWith("Bearer ") ? authHeader.slice("Bearer ".length) : ""; + const grant = grantToken ? resolveAttachGrant(grantToken) : undefined; + if (grant) { + return { senderIsOwner: false, boundSessionKey: grant.sessionKey }; + } + return undefined; } export function validateMcpLoopbackRequest(params: { @@ -128,7 +137,7 @@ export function validateMcpLoopbackRequest(params: { ownerToken: string; nonOwnerToken: string; onSseResponse?: (res: ServerResponse) => void; -}): { senderIsOwner: boolean } | null { +}): { senderIsOwner: boolean; boundSessionKey?: string } | null { let url: URL; try { url = new URL(params.req.url ?? "/", `http://${params.req.headers.host ?? "localhost"}`); @@ -244,7 +253,7 @@ export function validateMcpLoopbackRequest(params: { return null; } - return { senderIsOwner: sender.senderIsOwner }; + return { senderIsOwner: sender.senderIsOwner, boundSessionKey: sender.boundSessionKey }; } export async function readMcpHttpBody( @@ -357,8 +366,30 @@ export function resolveMcpCliCaptureKey(req: IncomingMessage): string | undefine export function resolveMcpRequestContext( req: IncomingMessage, cfg: OpenClawConfig, - auth: { senderIsOwner: boolean }, + auth: { senderIsOwner: boolean; boundSessionKey?: string }, ): McpRequestContext { + // An attach grant is a lower-trust boundary: bind the session server-side AND ignore every + // caller-supplied delivery/action context header (message channel, account, current channel/ + // thread/message, inbound-audio, event-kind, source-reply mode, explicit-target). Those headers + // feed scoped tools and the message tool, so a grant holder must not be able to spoof them — + // only the grant's pinned sessionKey is trusted. Owner/non-owner (the cooperative, gateway- + // launched cli-backend) keep header-driven context. + if (auth.boundSessionKey) { + return { + sessionKey: auth.boundSessionKey, + sessionId: undefined, + messageProvider: undefined, + currentChannelId: undefined, + currentThreadTs: undefined, + currentMessageId: undefined, + currentInboundAudio: undefined, + accountId: undefined, + inboundEventKind: undefined, + sourceReplyDeliveryMode: undefined, + requireExplicitMessageTarget: undefined, + senderIsOwner: auth.senderIsOwner, + }; + } return { sessionKey: resolveScopedSessionKey(cfg, getHeader(req, "x-session-key")), sessionId: normalizeOptionalString(getHeader(req, "x-openclaw-session-id")), diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts index 09b88691514f..de6fef3cbc83 100644 --- a/src/gateway/mcp-http.test.ts +++ b/src/gateway/mcp-http.test.ts @@ -102,6 +102,7 @@ vi.mock("./tool-resolution.js", () => ({ resolveGatewayScopedToolsMock(...args), })); +import { resetAttachGrantsForTest, mintAttachGrant } from "./mcp-grant-store.js"; import { createMcpLoopbackServerConfig, closeMcpLoopbackServer, @@ -724,6 +725,47 @@ describe("mcp loopback server", () => { ]); }); + it("binds an attach grant's session and ignores ALL spoofed context headers (no scope-shop)", async () => { + resetAttachGrantsForTest(); + const grant = mintAttachGrant({ sessionKey: "agent:main:attach-host" }); + const port = await getFreePortBlockWithPermissionFallback({ + offsets: [0], + fallbackBase: 53_000, + }); + const { port: serverPort } = await startLoopbackServerForTest(port); + + const response = await sendRaw({ + port: serverPort, + token: grant.token, + headers: jsonHeaders({ + // spoof the full delivery/action context, not just the session key + "x-session-key": "agent:main:SPOOFED-other-session", + "x-openclaw-message-channel": "telegram", + "x-openclaw-account-id": "victim-account", + "x-openclaw-current-channel-id": "telegram:victim-chat", + "x-openclaw-current-thread-ts": "999", + "x-openclaw-source-reply-delivery-mode": "automatic", + "x-openclaw-inbound-event-kind": "room_event", + }), + body: mcpToolsListBody(), + }); + + expect(response.status).toBe(200); + const call = getScopedToolsCall(0); + // session is grant-bound, NOT the spoofed header + expect(call.sessionKey).toBe("agent:main:attach-host"); + expect(call.senderIsOwner).toBe(false); + expect(call.surface).toBe("loopback"); + // a grant is a lower-trust boundary: every other caller-supplied context header is ignored + // (fail-closed), so a grant holder cannot spoof delivery/action context into scoped tools. + expect(call.messageProvider).toBeUndefined(); + expect(call.accountId).toBeUndefined(); + expect(call.currentChannelId).toBeUndefined(); + expect(call.currentThreadTs).toBeUndefined(); + expect(call.sourceReplyDeliveryMode).toBeUndefined(); + expect(call.inboundEventKind).toBeUndefined(); + }); + it("routes sessions_yield to the current CLI capture", async () => { resolveGatewayScopedToolsMock.mockImplementation((input): MockGatewayScopedTools => { const call = input as ScopedToolsCall; diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 10ed1f5a9b1c..f5138fbb55e6 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -218,6 +218,11 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "poll", scope: "operator.write", advertise: false }, { name: "sessions.steer", scope: "operator.write", advertise: false }, { name: "push.test", scope: "operator.write", advertise: false }, + // Appended at the end of the advertised methods to preserve the legacy advertised order + // (server-methods-list.test.ts locks the prefix/middle). grant mints process-global state so it + // is control-plane rate-limited; revoke is intentionally not (cheap, idempotent, frees memory). + { name: "attach.grant", scope: "operator.admin", controlPlaneWrite: true }, + { name: "attach.revoke", scope: "operator.admin" }, { name: "push.web.vapidPublicKey", scope: "operator.write", advertise: false }, { name: "push.web.subscribe", scope: "operator.write", advertise: false }, { name: "push.web.unsubscribe", scope: "operator.write", advertise: false }, diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 57ae4e38c74a..d0c5b1fbdd5e 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -74,6 +74,10 @@ const loadArtifactsHandlers = lazyHandlerModule( () => import("./server-methods/artifacts.js"), (module) => module.artifactsHandlers, ); +const loadAttachHandlers = lazyHandlerModule( + () => import("./server-methods/attach.js"), + (module) => module.attachHandlers, +); const loadChannelsHandlers = lazyHandlerModule( () => import("./server-methods/channels.js"), (module) => module.channelsHandlers, @@ -271,6 +275,10 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { methods: ["connect"], loadHandlers: loadConnectHandlers, }), + ...createLazyCoreHandlers({ + methods: ["attach.grant", "attach.revoke"], + loadHandlers: loadAttachHandlers, + }), ...createLazyCoreHandlers({ methods: ["logs.tail"], loadHandlers: loadLogsHandlers, diff --git a/src/gateway/server-methods/attach.test.ts b/src/gateway/server-methods/attach.test.ts new file mode 100644 index 000000000000..1ec91f98c302 --- /dev/null +++ b/src/gateway/server-methods/attach.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resetAttachGrantsForTest, resolveAttachGrant } from "../mcp-grant-store.js"; +import { closeMcpLoopbackServer } from "../mcp-http.js"; +import { attachHandlers } from "./attach.js"; +import type { GatewayRequestHandlerOptions } from "./types.js"; + +const grantOpts = (sessionKey: string, respond: ReturnType) => + ({ + params: { sessionKey }, + respond, + context: { getRuntimeConfig: () => ({}) }, + }) as unknown as GatewayRequestHandlerOptions; + +describe("attach gateway methods", () => { + beforeEach(() => resetAttachGrantsForTest()); + afterEach(async () => { + resetAttachGrantsForTest(); + // attach.grant lazily starts the loopback singleton; close it so it doesn't leak across files. + await closeMcpLoopbackServer(); + }); + + it("attach.grant mints a session-bound grant and returns loopback config + token env", async () => { + const respond = vi.fn(); + await attachHandlers["attach.grant"](grantOpts("agent:main:attach-method", respond)); + + expect(respond).toHaveBeenCalledTimes(1); + const [ok, payload] = respond.mock.calls[0]; + expect(ok).toBe(true); + const body = payload as { + token: string; + sessionKey: string; + mcpConfig: unknown; + env: Record; + }; + expect(body.sessionKey).toBe("agent:main:attach-method"); + expect(body.token).toMatch(/^[0-9a-f]{64}$/); + expect(body.mcpConfig).toBeTruthy(); + expect(body.env.OPENCLAW_MCP_TOKEN).toBe(body.token); + expect(body.env.OPENCLAW_MCP_SESSION_KEY).toBe("agent:main:attach-method"); + // the minted token resolves to the bound session in the shared store + expect(resolveAttachGrant(body.token)?.sessionKey).toBe("agent:main:attach-method"); + }); + + it("attach.revoke removes a grant; missing token is an INVALID_REQUEST", async () => { + const grantRespond = vi.fn(); + await attachHandlers["attach.grant"](grantOpts("agent:main:revoke-me", grantRespond)); + const token = (grantRespond.mock.calls[0][1] as { token: string }).token; + + const revokeRespond = vi.fn(); + await attachHandlers["attach.revoke"]({ + params: { token }, + respond: revokeRespond, + } as unknown as GatewayRequestHandlerOptions); + expect(revokeRespond).toHaveBeenCalledWith(true, { revoked: true }); + expect(resolveAttachGrant(token)).toBeUndefined(); + + const errRespond = vi.fn(); + await attachHandlers["attach.revoke"]({ + params: {}, + respond: errRespond, + } as unknown as GatewayRequestHandlerOptions); + const [errOk, , err] = errRespond.mock.calls[0]; + expect(errOk).toBe(false); + expect((err as { code: string }).code).toBe("INVALID_REQUEST"); + }); + + it("applies a positive ttlMs and falls back to the default for an invalid one", async () => { + const r1 = vi.fn(); + await attachHandlers["attach.grant"]({ + params: { sessionKey: "agent:main:ttl", ttlMs: 30_000 }, + respond: r1, + context: { getRuntimeConfig: () => ({}) }, + } as unknown as GatewayRequestHandlerOptions); + const now1 = Date.now(); + const b1 = r1.mock.calls[0][1] as { expiresAtMs: number }; + expect(b1.expiresAtMs).toBeGreaterThan(now1 + 20_000); + expect(b1.expiresAtMs).toBeLessThan(now1 + 40_000); // honored 30s ttl, not the 1h default + + const r2 = vi.fn(); + await attachHandlers["attach.grant"]({ + params: { sessionKey: "agent:main:ttl2", ttlMs: -5 }, // non-positive → default ttl + respond: r2, + context: { getRuntimeConfig: () => ({}) }, + } as unknown as GatewayRequestHandlerOptions); + const b2 = r2.mock.calls[0][1] as { expiresAtMs: number }; + expect(b2.expiresAtMs).toBeGreaterThan(Date.now() + 50 * 60_000); // ~1h default window + }); + + it("attach.revoke treats non-object params as a missing token (INVALID_REQUEST)", async () => { + const respond = vi.fn(); + await attachHandlers["attach.revoke"]({ + params: null, + respond, + } as unknown as GatewayRequestHandlerOptions); + const [ok, , err] = respond.mock.calls[0]; + expect(ok).toBe(false); + expect((err as { code: string }).code).toBe("INVALID_REQUEST"); + }); +}); diff --git a/src/gateway/server-methods/attach.ts b/src/gateway/server-methods/attach.ts new file mode 100644 index 000000000000..943c5927ccd3 --- /dev/null +++ b/src/gateway/server-methods/attach.ts @@ -0,0 +1,66 @@ +// Gateway RPC handlers for attach grants: mint a per-session, scoped, revocable MCP loopback grant +// so an external/interactive harness can reach the gateway's scoped tools, and revoke it on detach. +import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; +import { resolveMainSessionKey } from "../../config/sessions.js"; +import { mintAttachGrant, revokeAttachGrant } from "../mcp-grant-store.js"; +import { ensureMcpLoopbackServer } from "../mcp-http.js"; +import { + createMcpLoopbackServerConfig, + getActiveMcpLoopbackRuntime, +} from "../mcp-http.loopback-runtime.js"; +import type { GatewayRequestHandlers } from "./types.js"; + +function paramRecord(params: unknown): Record { + return params && typeof params === "object" ? (params as Record) : {}; +} + +function readString(params: unknown, key: string): string | undefined { + const value = paramRecord(params)[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readPositiveNumber(params: unknown, key: string): number | undefined { + const value = paramRecord(params)[key]; + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; +} + +export const attachHandlers: GatewayRequestHandlers = { + // Mint a grant bound to a session, returning the loopback MCP config + the token env the harness + // needs. ensureMcpLoopbackServer lazily brings the singleton up if no cli-backend turn started it. + "attach.grant": async ({ params, respond, context }) => { + await ensureMcpLoopbackServer(); + const runtime = getActiveMcpLoopbackRuntime(); + if (!runtime) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "mcp loopback server unavailable"), + ); + return; + } + const sessionKey = + readString(params, "sessionKey") ?? resolveMainSessionKey(context.getRuntimeConfig()); + const grant = mintAttachGrant({ sessionKey, ttlMs: readPositiveNumber(params, "ttlMs") }); + respond(true, { + sessionKey: grant.sessionKey, + token: grant.token, + expiresAtMs: grant.expiresAtMs, + // The harness writes mcpConfig to its MCP client config and sets env so the ${...} placeholders + // resolve. Loopback today; node/app conduits reuse the same client config over their channel. + mcpConfig: createMcpLoopbackServerConfig(runtime.port), + env: { + OPENCLAW_MCP_TOKEN: grant.token, + OPENCLAW_MCP_SESSION_KEY: grant.sessionKey, + }, + }); + }, + // Revoke a previously minted grant. Idempotent: an unknown/already-expired token reports revoked=false. + "attach.revoke": async ({ params, respond }) => { + const token = readString(params, "token"); + if (!token) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "token is required")); + return; + } + respond(true, { revoked: revokeAttachGrant(token) }); + }, +};