mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-14 05:36:03 +00:00
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.
This commit is contained in:
committed by
Ayaan Zaidi
parent
201eb9cd29
commit
2deb696ef4
91
src/gateway/mcp-grant-store.test.ts
Normal file
91
src/gateway/mcp-grant-store.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
128
src/gateway/mcp-grant-store.ts
Normal file
128
src/gateway/mcp-grant-store.ts
Normal file
@@ -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 <token>`. */
|
||||
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<string, McpAttachGrant>();
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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")),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
99
src/gateway/server-methods/attach.test.ts
Normal file
99
src/gateway/server-methods/attach.test.ts
Normal file
@@ -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<typeof vi.fn>) =>
|
||||
({
|
||||
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<string, string>;
|
||||
};
|
||||
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");
|
||||
});
|
||||
});
|
||||
66
src/gateway/server-methods/attach.ts
Normal file
66
src/gateway/server-methods/attach.ts
Normal file
@@ -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<string, unknown> {
|
||||
return params && typeof params === "object" ? (params as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
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) });
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user