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:
Cameron Beeley
2026-06-24 01:22:31 +01:00
committed by Ayaan Zaidi
parent 201eb9cd29
commit 2deb696ef4
8 changed files with 477 additions and 7 deletions

View 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);
});
});

View 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();
}

View File

@@ -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")),

View File

@@ -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;

View File

@@ -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 },

View File

@@ -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,

View 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");
});
});

View 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) });
},
};