mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(gateway): expose OAuth-backed tools to CLI agents (#119166)
Carry prepared OAuth and model context through authenticated CLI loopback grants. Revoke cached credential-bearing tools synchronously while preserving the global cache cap.
This commit is contained in:
@@ -109,7 +109,7 @@ function isEnvOnlyAuthProfileRuntime(): boolean {
|
||||
return authProfileRuntimeMode.getStore()?.kind === "env-only";
|
||||
}
|
||||
|
||||
function resolveRuntimeAuthProfileAgentDir(agentDir?: string): string | undefined {
|
||||
export function resolveRuntimeAuthProfileAgentDir(agentDir?: string): string | undefined {
|
||||
const mode = authProfileRuntimeMode.getStore();
|
||||
return mode?.kind === "agent-dir" ? mode.agentDir : agentDir;
|
||||
}
|
||||
|
||||
@@ -2733,8 +2733,15 @@ describe("prepareCliRunContext", () => {
|
||||
const grantContext = mintMcpLoopbackClientGrant.mock.calls.at(-1)?.[0]?.context;
|
||||
expect(projected).toBeDefined();
|
||||
expect(grantContext).toBeDefined();
|
||||
const { cfg: projectedConfig, ...projectedContext } = projected ?? {};
|
||||
const {
|
||||
cfg: projectedConfig,
|
||||
authProfileStore,
|
||||
authProfileStoreAgentDir,
|
||||
...projectedContext
|
||||
} = projected ?? {};
|
||||
expect(projectedConfig).toEqual(expect.any(Object));
|
||||
expect(authProfileStore).toMatchObject({ version: 1, profiles: {} });
|
||||
expect(authProfileStoreAgentDir).toEqual(expect.any(String));
|
||||
expect(projectedContext).toEqual(grantContext);
|
||||
expect(projectedContext).toMatchObject({
|
||||
sessionKey: "agent:worker:main",
|
||||
@@ -2981,6 +2988,10 @@ describe("prepareCliRunContext", () => {
|
||||
spawnedBy: "agent:main:telegram:group:parent",
|
||||
},
|
||||
runtimeOwnerToken: "loopback-owner-token",
|
||||
toolAuth: {
|
||||
agentDir: expect.any(String),
|
||||
store: expect.objectContaining({ version: 1, profiles: {} }),
|
||||
},
|
||||
});
|
||||
context.preparedBackend.mcpClientGrantCapture?.activate("capture-test");
|
||||
context.preparedBackend.mcpClientGrantCapture?.deactivate("capture-test");
|
||||
@@ -3626,10 +3637,14 @@ describe("prepareCliRunContext", () => {
|
||||
const {
|
||||
cfg: _projectedConfig,
|
||||
toolsAllow: projectedPolicy,
|
||||
authProfileStore,
|
||||
authProfileStoreAgentDir,
|
||||
...projectedTrustedContext
|
||||
} = projected ?? {};
|
||||
const { toolsAllow: grantedTools, ...grantTrustedContext } = grantContext ?? {};
|
||||
expect(projectedPolicy).toEqual(["write"]);
|
||||
expect(authProfileStore).toMatchObject({ version: 1, profiles: {} });
|
||||
expect(authProfileStoreAgentDir).toEqual(expect.any(String));
|
||||
expect(grantedTools).toEqual(["write", "apply_patch"]);
|
||||
expect(projectedTrustedContext).toEqual(grantTrustedContext);
|
||||
} finally {
|
||||
|
||||
@@ -58,7 +58,10 @@ import {
|
||||
import { buildOAuthRefreshFailureLoginCommand } from "../auth-profiles/oauth-refresh-failure.js";
|
||||
import { resolveApiKeyForProfile } from "../auth-profiles/oauth.js";
|
||||
import { resolveAuthProfileOrder } from "../auth-profiles/order.js";
|
||||
import { loadAuthProfileStoreForRuntime } from "../auth-profiles/store.js";
|
||||
import {
|
||||
loadAuthProfileStoreForRuntime,
|
||||
resolveRuntimeAuthProfileAgentDir,
|
||||
} from "../auth-profiles/store.js";
|
||||
import type { AuthProfileCredential, AuthProfileStore } from "../auth-profiles/types.js";
|
||||
import {
|
||||
buildBootstrapBudgetState,
|
||||
@@ -936,6 +939,15 @@ export async function prepareCliRunContext(
|
||||
modelId,
|
||||
})
|
||||
: undefined;
|
||||
const mcpToolAuthAgentDir = mcpContextBase
|
||||
? resolveRuntimeAuthProfileAgentDir(agentDir)
|
||||
: undefined;
|
||||
const mcpToolAuth = mcpContextBase
|
||||
? {
|
||||
...(mcpToolAuthAgentDir ? { agentDir: mcpToolAuthAgentDir } : {}),
|
||||
store: authStore ?? loadScopedAuthStore(),
|
||||
}
|
||||
: undefined;
|
||||
const requestedLoopbackToolsAllow =
|
||||
runtimeToolsAllowPolicy ?? params.cliToolAvailability?.openClaw;
|
||||
const mcpProjectionContext =
|
||||
@@ -948,7 +960,12 @@ export async function prepareCliRunContext(
|
||||
: prepareDeps.resolveMcpLoopbackScopedTools;
|
||||
const projectedToolsBeforePromptBuild =
|
||||
(bundleMcpEnabled || shouldMaterializeRuntimePolicy) && mcpProjectionContext
|
||||
? resolveProjectedTools({ cfg: runtimeConfig, ...mcpProjectionContext }).tools
|
||||
? resolveProjectedTools({
|
||||
cfg: runtimeConfig,
|
||||
...mcpProjectionContext,
|
||||
...(mcpToolAuth ? { authProfileStore: mcpToolAuth.store } : {}),
|
||||
...(mcpToolAuth?.agentDir ? { authProfileStoreAgentDir: mcpToolAuth.agentDir } : {}),
|
||||
}).tools
|
||||
: [];
|
||||
const hookFilteredProjectedTools = applyEmbeddedAttemptToolsAllow(
|
||||
projectedToolsBeforePromptBuild,
|
||||
@@ -1047,6 +1064,7 @@ export async function prepareCliRunContext(
|
||||
? prepareDeps.mintMcpLoopbackClientGrant({
|
||||
context: mcpGrantContext,
|
||||
runtimeOwnerToken: mcpLoopbackRuntime.ownerToken,
|
||||
...(mcpToolAuth ? { toolAuth: mcpToolAuth } : {}),
|
||||
})
|
||||
: undefined;
|
||||
const mcpClientGrantCapture =
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
deactivateMcpLoopbackClientGrantCapture,
|
||||
mintAttachGrant,
|
||||
mintMcpLoopbackClientGrant,
|
||||
registerMcpLoopbackClientGrantRevocationListener,
|
||||
resolveAttachGrant,
|
||||
resolveMcpLoopbackClientGrant,
|
||||
revokeAttachGrant,
|
||||
@@ -205,6 +206,40 @@ describe("mcp-grant-store", () => {
|
||||
expect(revokeMcpLoopbackClientGrant(successor.token)).toBe(false);
|
||||
});
|
||||
|
||||
it("notifies revocation listeners for single and runtime-wide cleanup", () => {
|
||||
const events: Array<{ token: string; runtimeOwnerToken: string }> = [];
|
||||
const unregister = registerMcpLoopbackClientGrantRevocationListener((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
try {
|
||||
const first = mintMcpLoopbackClientGrant({
|
||||
context: { sessionKey: "agent:main:first", senderIsOwner: false },
|
||||
runtimeOwnerToken: "runtime-one",
|
||||
});
|
||||
const second = mintMcpLoopbackClientGrant({
|
||||
context: { sessionKey: "agent:main:second", senderIsOwner: false },
|
||||
runtimeOwnerToken: "runtime-one",
|
||||
});
|
||||
|
||||
expect(revokeMcpLoopbackClientGrant(first.token)).toBe(true);
|
||||
expect(revokeMcpLoopbackClientGrant(first.token)).toBe(false);
|
||||
expect(revokeMcpLoopbackClientGrantsForRuntime("runtime-one")).toBe(1);
|
||||
expect(events).toEqual([
|
||||
{ token: first.token, runtimeOwnerToken: "runtime-one" },
|
||||
{ token: second.token, runtimeOwnerToken: "runtime-one" },
|
||||
]);
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
|
||||
const afterUnregister = mintMcpLoopbackClientGrant({
|
||||
context: { sessionKey: "agent:main:later", senderIsOwner: false },
|
||||
runtimeOwnerToken: "runtime-one",
|
||||
});
|
||||
expect(revokeMcpLoopbackClientGrant(afterUnregister.token)).toBe(true);
|
||||
expect(events).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("requires a session key for loopback client grants", () => {
|
||||
expect(() =>
|
||||
mintMcpLoopbackClientGrant({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
|
||||
import type { ExecElevatedDefaults } from "../agents/bash-tools.exec-types.js";
|
||||
import type { ExecPolicyOverrides, ExecSessionDefaults } from "../agents/exec-defaults.js";
|
||||
import type { ScheduledToolPolicyContext } from "../agents/scheduled-tool-policy.js";
|
||||
@@ -79,11 +80,24 @@ interface McpLoopbackClientGrant {
|
||||
readonly context: McpLoopbackRequestContext;
|
||||
}
|
||||
|
||||
type McpLoopbackToolAuth = {
|
||||
agentDir?: string;
|
||||
store: AuthProfileStore;
|
||||
};
|
||||
|
||||
type StoredMcpLoopbackClientGrant = McpLoopbackClientGrant & {
|
||||
runtimeOwnerToken: string;
|
||||
activeCaptureKey?: string;
|
||||
toolAuth?: McpLoopbackToolAuth;
|
||||
};
|
||||
|
||||
type McpLoopbackClientGrantRevocation = {
|
||||
token: string;
|
||||
runtimeOwnerToken: string;
|
||||
};
|
||||
|
||||
const clientGrantRevocationListeners = new Set<(event: McpLoopbackClientGrantRevocation) => void>();
|
||||
|
||||
const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1h
|
||||
const MAX_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
@@ -171,6 +185,7 @@ function sweepExpiredAttachGrants(nowMs: number = Date.now()): number {
|
||||
export function mintMcpLoopbackClientGrant(params: {
|
||||
context: McpLoopbackRequestContext;
|
||||
runtimeOwnerToken: string;
|
||||
toolAuth?: McpLoopbackToolAuth;
|
||||
}): McpLoopbackClientGrant {
|
||||
const sessionKey = params.context.sessionKey.trim();
|
||||
if (!sessionKey) {
|
||||
@@ -184,6 +199,7 @@ export function mintMcpLoopbackClientGrant(params: {
|
||||
token: crypto.randomBytes(32).toString("hex"),
|
||||
context: structuredClone({ ...params.context, sessionKey }),
|
||||
runtimeOwnerToken,
|
||||
...(params.toolAuth ? { toolAuth: structuredClone(params.toolAuth) } : {}),
|
||||
};
|
||||
clientGrantsByToken.set(grant.token, grant);
|
||||
return structuredClone({
|
||||
@@ -233,7 +249,13 @@ export function resolveMcpLoopbackClientGrant(params: {
|
||||
token: string;
|
||||
runtimeOwnerToken: string;
|
||||
captureKey: string;
|
||||
}): { context: McpLoopbackRequestContext; captureKey: string } | undefined {
|
||||
}):
|
||||
| {
|
||||
context: McpLoopbackRequestContext;
|
||||
captureKey: string;
|
||||
toolAuth?: McpLoopbackToolAuth;
|
||||
}
|
||||
| undefined {
|
||||
const grant = clientGrantsByToken.get(params.token);
|
||||
if (
|
||||
!grant ||
|
||||
@@ -243,19 +265,41 @@ export function resolveMcpLoopbackClientGrant(params: {
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return structuredClone({ context: grant.context, captureKey: grant.activeCaptureKey });
|
||||
// Cached tools and OAuth refreshes must share the prepared store for this
|
||||
// grant; cloning on each request would discard refreshed credentials.
|
||||
return {
|
||||
context: structuredClone(grant.context),
|
||||
captureKey: grant.activeCaptureKey,
|
||||
...(grant.toolAuth ? { toolAuth: grant.toolAuth } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Registers cleanup tied to the exact lifetime of loopback client grants. */
|
||||
export function registerMcpLoopbackClientGrantRevocationListener(
|
||||
listener: (event: McpLoopbackClientGrantRevocation) => void,
|
||||
): () => void {
|
||||
clientGrantRevocationListeners.add(listener);
|
||||
return () => clientGrantRevocationListeners.delete(listener);
|
||||
}
|
||||
|
||||
export function revokeMcpLoopbackClientGrant(token: string): boolean {
|
||||
return clientGrantsByToken.delete(token);
|
||||
const grant = clientGrantsByToken.get(token);
|
||||
if (!grant || !clientGrantsByToken.delete(token)) {
|
||||
return false;
|
||||
}
|
||||
// Revocation must also release server-owned projections whose closures retain
|
||||
// this grant's prepared credentials.
|
||||
for (const listener of clientGrantRevocationListeners) {
|
||||
listener({ token, runtimeOwnerToken: grant.runtimeOwnerToken });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function revokeMcpLoopbackClientGrantsForRuntime(runtimeOwnerToken: string): number {
|
||||
let removed = 0;
|
||||
for (const [token, grant] of clientGrantsByToken) {
|
||||
if (grant.runtimeOwnerToken === runtimeOwnerToken) {
|
||||
clientGrantsByToken.delete(token);
|
||||
removed += 1;
|
||||
removed += revokeMcpLoopbackClientGrant(token) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Type } from "typebox";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { jsonResult } from "../agents/tools/common.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveMcpLoopbackScopedTools } from "./mcp-http.runtime.js";
|
||||
|
||||
const pluginTools = vi.hoisted(() => ({
|
||||
resolve: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/tools.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../plugins/tools.js")>()),
|
||||
resolvePluginTools: (params: unknown) => pluginTools.resolve(params),
|
||||
}));
|
||||
|
||||
describe("MCP loopback OAuth tools", () => {
|
||||
it("exposes a plugin tool backed by a prepared OAuth profile", () => {
|
||||
pluginTools.resolve.mockImplementation(
|
||||
(params: {
|
||||
context?: { activeModel?: { provider?: string; modelId?: string } };
|
||||
hasAuthForProvider?: (providerId: string) => boolean;
|
||||
}) =>
|
||||
params.hasAuthForProvider?.("xai") &&
|
||||
params.context?.activeModel?.provider === "anthropic" &&
|
||||
params.context.activeModel.modelId === "claude-haiku-4-5"
|
||||
? [
|
||||
{
|
||||
name: "x_search",
|
||||
label: "X search",
|
||||
description: "X search",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => jsonResult({ ok: true }),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
const result = resolveMcpLoopbackScopedTools({
|
||||
cfg: {
|
||||
auth: { order: { xai: ["xai:oauth"] } },
|
||||
plugins: { allow: ["xai"] },
|
||||
} as OpenClawConfig,
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-haiku-4-5",
|
||||
senderIsOwner: true,
|
||||
authProfileStore: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"xai:oauth": {
|
||||
type: "oauth",
|
||||
provider: "xai",
|
||||
access: "xai-access-token",
|
||||
refresh: "xai-refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tools.map((tool) => tool.name)).toContain("x_search");
|
||||
});
|
||||
});
|
||||
@@ -201,6 +201,42 @@ describe("McpLoopbackToolCache", () => {
|
||||
expect(resolveGatewayScopedTools).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("evicts only the revoked grant's cached tool closures", () => {
|
||||
const cache = new McpLoopbackToolCache();
|
||||
const cfg = {} as OpenClawConfig;
|
||||
|
||||
cache.resolve(scopeParams({ cfg, grantToken: "grant-a" }));
|
||||
cache.resolve(scopeParams({ cfg, grantToken: "grant-b" }));
|
||||
cache.resolve(scopeParams({ cfg, grantToken: "grant-a" }));
|
||||
cache.resolve(scopeParams({ cfg, grantToken: "grant-b" }));
|
||||
expect(resolveGatewayScopedTools).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(cache.evictGrant("grant-a")).toBe(true);
|
||||
cache.resolve(scopeParams({ cfg, grantToken: "grant-a" }));
|
||||
cache.resolve(scopeParams({ cfg, grantToken: "grant-b" }));
|
||||
|
||||
expect(resolveGatewayScopedTools).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("preserves the global 256-entry cache cap across grants", () => {
|
||||
const cache = new McpLoopbackToolCache();
|
||||
const cfg = {} as OpenClawConfig;
|
||||
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
cache.resolve(
|
||||
scopeParams({ cfg, grantToken: "grant-a", currentMessageId: `message-${index}` }),
|
||||
);
|
||||
}
|
||||
cache.resolve(scopeParams({ cfg, grantToken: "grant-b", currentMessageId: "message-b" }));
|
||||
expect(resolveGatewayScopedTools).toHaveBeenCalledTimes(257);
|
||||
|
||||
cache.resolve(scopeParams({ cfg, grantToken: "grant-a", currentMessageId: "message-0" }));
|
||||
expect(resolveGatewayScopedTools).toHaveBeenCalledTimes(258);
|
||||
|
||||
cache.resolve(scopeParams({ cfg, grantToken: "grant-b", currentMessageId: "message-b" }));
|
||||
expect(resolveGatewayScopedTools).toHaveBeenCalledTimes(258);
|
||||
});
|
||||
|
||||
it("never reuses ordinary private-mode tools for a source-reply-only grant", () => {
|
||||
const cache = new McpLoopbackToolCache();
|
||||
const cfg = {} as OpenClawConfig;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
|
||||
// MCP loopback runtime scope cache.
|
||||
// Resolves Gateway-visible tools for MCP clients with short-lived schema caching.
|
||||
import { applyEmbeddedAttemptToolsAllow } from "../agents/embedded-agent-runner/run/attempt-tool-construction-plan.js";
|
||||
@@ -33,6 +34,9 @@ type CachedScopedTools = {
|
||||
|
||||
type McpLoopbackScopeParams = Omit<McpLoopbackRequestContext, "senderIsOwner"> & {
|
||||
cfg: OpenClawConfig;
|
||||
authProfileStore?: AuthProfileStore;
|
||||
authProfileStoreAgentDir?: string;
|
||||
grantToken?: string;
|
||||
senderIsOwner: boolean | undefined;
|
||||
yieldContextCacheKey?: string;
|
||||
onYield?: (message: string) => Promise<void> | void;
|
||||
@@ -84,9 +88,15 @@ function resolveMcpLoopbackTools(
|
||||
if (includeNodeExecTool) {
|
||||
excludeToolNames.delete("exec");
|
||||
}
|
||||
const { toolsAllow: _toolsAllow, ...scopeParams } = params;
|
||||
const {
|
||||
toolsAllow: _toolsAllow,
|
||||
authProfileStoreAgentDir,
|
||||
grantToken: _grantToken,
|
||||
...scopeParams
|
||||
} = params;
|
||||
const scoped = resolveGatewayScopedTools({
|
||||
...scopeParams,
|
||||
agentDir: authProfileStoreAgentDir,
|
||||
conversationReadOrigin: "delegated",
|
||||
surface: "loopback",
|
||||
excludeToolNames,
|
||||
@@ -161,11 +171,14 @@ function applyPolicyToolsAllow(
|
||||
/** Short-lived cache for loopback tool lists keyed by session/channel context. */
|
||||
export class McpLoopbackToolCache {
|
||||
#entries = new DirectoryCache<CachedScopedTools>(TOOL_CACHE_TTL_MS, TOOL_CACHE_MAX_ENTRIES);
|
||||
// Revocation needs the config scopes where one grant may have cached tools.
|
||||
#grantConfigScopes = new Map<string, Set<OpenClawConfig>>();
|
||||
|
||||
resolve(params: McpLoopbackScopeParams): CachedScopedTools {
|
||||
// Callers differing only in capabilities must not share cached tool lists.
|
||||
const clientCapsCacheKey = [...new Set(params.clientCaps ?? [])].toSorted().join(",");
|
||||
const cacheKey = [
|
||||
params.grantToken ?? "",
|
||||
params.sessionKey,
|
||||
params.runtimePolicySessionKey ?? "",
|
||||
params.agentId ?? "",
|
||||
@@ -241,6 +254,29 @@ export class McpLoopbackToolCache {
|
||||
toolSchema: buildMcpToolSchema(next.tools),
|
||||
};
|
||||
this.#entries.set(cacheKey, nextEntry, params.cfg);
|
||||
if (params.grantToken) {
|
||||
const scopes = this.#grantConfigScopes.get(params.grantToken) ?? new Set<OpenClawConfig>();
|
||||
scopes.add(params.cfg);
|
||||
this.#grantConfigScopes.set(params.grantToken, scopes);
|
||||
}
|
||||
return nextEntry;
|
||||
}
|
||||
|
||||
evictGrant(token: string): boolean {
|
||||
const scopes = this.#grantConfigScopes.get(token);
|
||||
if (!scopes) {
|
||||
return false;
|
||||
}
|
||||
const cacheKeyPrefix = `${token}\u0000`;
|
||||
for (const cfg of scopes) {
|
||||
this.#entries.clearMatching((cacheKey) => cacheKey.startsWith(cacheKeyPrefix), cfg);
|
||||
}
|
||||
this.#grantConfigScopes.delete(token);
|
||||
return true;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.#entries.clear();
|
||||
this.#grantConfigScopes.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { request } from "node:http";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { runBeforeToolCallHook } from "../agents/agent-tools.before-tool-call.js";
|
||||
import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
|
||||
import type { AnyAgentTool } from "../agents/tools/common.js";
|
||||
import { getFreePortBlockWithPermissionFallback } from "../test-utils/ports.js";
|
||||
import type { McpLoopbackRequestContext } from "./mcp-grant-store.js";
|
||||
@@ -1296,6 +1297,59 @@ describe("mcp loopback server", () => {
|
||||
expect(getBeforeToolCallHookInput(0).ctx).toHaveProperty("loopDetection");
|
||||
});
|
||||
|
||||
it("keeps prepared auth stores isolated between CLI grants", async () => {
|
||||
const { runtime } = await startLoopbackServerForTest();
|
||||
const firstStore: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"xai:first": { type: "token", provider: "xai", token: "first-token" },
|
||||
},
|
||||
};
|
||||
const secondStore: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"xai:second": { type: "token", provider: "xai", token: "second-token" },
|
||||
},
|
||||
};
|
||||
const mintGrant = (agentDir: string, store: AuthProfileStore) =>
|
||||
mintMcpLoopbackClientGrant({
|
||||
context: { sessionKey: "agent:main:main", senderIsOwner: true },
|
||||
runtimeOwnerToken: runtime.ownerToken,
|
||||
toolAuth: { agentDir, store },
|
||||
});
|
||||
const firstGrant = mintGrant("/agents/first", firstStore);
|
||||
const secondGrant = mintGrant("/agents/second", secondStore);
|
||||
const listForGrant = async (token: string, captureKey: string) => {
|
||||
expect(
|
||||
activateMcpLoopbackClientGrantCapture({
|
||||
token,
|
||||
runtimeOwnerToken: runtime.ownerToken,
|
||||
captureKey,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await sendLoopbackToolsList({
|
||||
token,
|
||||
headers: { "x-openclaw-cli-capture-key": captureKey },
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
};
|
||||
|
||||
await listForGrant(firstGrant.token, "capture-first");
|
||||
await listForGrant(secondGrant.token, "capture-second");
|
||||
|
||||
expect(getScopedToolsCall(0)).toMatchObject({
|
||||
agentDir: "/agents/first",
|
||||
authProfileStore: firstStore,
|
||||
});
|
||||
expect(getScopedToolsCall(1)).toMatchObject({
|
||||
agentDir: "/agents/second",
|
||||
authProfileStore: secondStore,
|
||||
});
|
||||
});
|
||||
|
||||
it("carries the resolved workspace dir into the before-tool-call hook context", async () => {
|
||||
resolveGatewayScopedToolsMock.mockReturnValue({
|
||||
agentId: "main",
|
||||
@@ -1427,6 +1481,68 @@ describe("mcp loopback server", () => {
|
||||
).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects a slow tools request revoked after header admission", async () => {
|
||||
const captureKey = "slow-revoked-grant";
|
||||
let resolveRequestStarted: (() => void) | undefined;
|
||||
const requestStarted = new Promise<void>((resolve) => {
|
||||
resolveRequestStarted = resolve;
|
||||
});
|
||||
beginMcpLoopbackToolCallCapture({
|
||||
captureKey,
|
||||
onRequestStart: () => resolveRequestStarted?.(),
|
||||
onRequestClassified: vi.fn(),
|
||||
onToolCallResult: vi.fn(),
|
||||
});
|
||||
const { runtime, port } = await startLoopbackServerForTest();
|
||||
const grant = mintMcpLoopbackClientGrant({
|
||||
context: { sessionKey: "agent:main:slow-revoked", senderIsOwner: true },
|
||||
runtimeOwnerToken: runtime.ownerToken,
|
||||
toolAuth: { store: { version: 1, profiles: {} } },
|
||||
});
|
||||
expect(
|
||||
activateMcpLoopbackClientGrantCapture({
|
||||
token: grant.token,
|
||||
runtimeOwnerToken: runtime.ownerToken,
|
||||
captureKey,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
let revoked = false;
|
||||
const responsePromise = new Promise<{ status: number | undefined }>((resolve, reject) => {
|
||||
const req = request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
path: "/mcp",
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${grant.token}`,
|
||||
"content-type": "application/json",
|
||||
"transfer-encoding": "chunked",
|
||||
"x-openclaw-cli-capture-key": captureKey,
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
res.resume();
|
||||
res.on("end", () => resolve({ status: res.statusCode }));
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.flushHeaders();
|
||||
void requestStarted.then(() => {
|
||||
revoked = revokeMcpLoopbackClientGrant(grant.token);
|
||||
req.end(mcpToolsListBody());
|
||||
});
|
||||
});
|
||||
|
||||
await requestStarted;
|
||||
const resolverCallsBeforeBody = resolveGatewayScopedToolsMock.mock.calls.length;
|
||||
await expect(responsePromise).resolves.toEqual({ status: 401 });
|
||||
expect(revoked).toBe(true);
|
||||
expect(resolveGatewayScopedToolsMock).toHaveBeenCalledTimes(resolverCallsBeforeBody);
|
||||
clearMcpLoopbackToolCallCapture(captureKey);
|
||||
});
|
||||
|
||||
it("routes sessions_yield to the current CLI capture", async () => {
|
||||
resolveGatewayScopedToolsMock.mockImplementation((input): MockGatewayScopedTools => {
|
||||
const call = input as ScopedToolsCall;
|
||||
|
||||
+31
-1
@@ -20,6 +20,7 @@ import {
|
||||
isAgentHarnessSessionStoreEntryProtected,
|
||||
} from "../sessions/agent-harness-session-key.js";
|
||||
import {
|
||||
registerMcpLoopbackClientGrantRevocationListener,
|
||||
resolveMcpLoopbackClientGrant,
|
||||
revokeMcpLoopbackClientGrantsForRuntime,
|
||||
} from "./mcp-grant-store.js";
|
||||
@@ -247,9 +248,22 @@ async function startMcpLoopbackServer(port = 0): Promise<{
|
||||
});
|
||||
});
|
||||
markMcpLoopbackRequestClassified(cliRequestCaptureHandle);
|
||||
const { boundGrantToken, boundCaptureKey } = auth;
|
||||
const activeBoundGrant =
|
||||
boundGrantToken && boundCaptureKey
|
||||
? resolveMcpLoopbackClientGrant({
|
||||
token: boundGrantToken,
|
||||
runtimeOwnerToken: ownerToken,
|
||||
captureKey: boundCaptureKey,
|
||||
})
|
||||
: undefined;
|
||||
if (boundGrantToken && !activeBoundGrant) {
|
||||
res.writeHead(401, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "unauthorized" }));
|
||||
return;
|
||||
}
|
||||
const cfg = getRuntimeConfig();
|
||||
const requestContext = resolveMcpRequestContext(req, cfg, auth);
|
||||
const { boundGrantToken, boundCaptureKey } = auth;
|
||||
const authorizeToolCall =
|
||||
boundGrantToken && boundCaptureKey
|
||||
? () =>
|
||||
@@ -300,6 +314,15 @@ async function startMcpLoopbackServer(port = 0): Promise<{
|
||||
cwd: requestContext.cwd,
|
||||
modelProvider: requestContext.modelProvider,
|
||||
modelId: requestContext.modelId,
|
||||
...(activeBoundGrant?.toolAuth
|
||||
? {
|
||||
authProfileStore: activeBoundGrant.toolAuth.store,
|
||||
...(activeBoundGrant.toolAuth.agentDir
|
||||
? { authProfileStoreAgentDir: activeBoundGrant.toolAuth.agentDir }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
...(boundGrantToken ? { grantToken: boundGrantToken } : {}),
|
||||
yieldContextCacheKey: yieldContext?.cacheKey,
|
||||
onYield: yieldContext?.onYield,
|
||||
messageProvider: requestContext.messageProvider,
|
||||
@@ -476,6 +499,11 @@ async function startMcpLoopbackServer(port = 0): Promise<{
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("mcp loopback did not bind to a TCP port");
|
||||
}
|
||||
const unregisterGrantRevocation = registerMcpLoopbackClientGrantRevocationListener((event) => {
|
||||
if (event.runtimeOwnerToken === ownerToken) {
|
||||
toolCache.evictGrant(event.token);
|
||||
}
|
||||
});
|
||||
// Register tokens only after the TCP listener is live so clients never learn
|
||||
// a bearer token for a server that failed to bind.
|
||||
setActiveMcpLoopbackRuntime({ port: address.port, ownerToken, nonOwnerToken });
|
||||
@@ -488,6 +516,8 @@ async function startMcpLoopbackServer(port = 0): Promise<{
|
||||
// requests. A delayed old-server close cannot revoke a successor runtime.
|
||||
clearActiveMcpLoopbackRuntimeByOwnerToken(ownerToken);
|
||||
revokeMcpLoopbackClientGrantsForRuntime(ownerToken);
|
||||
unregisterGrantRevocation();
|
||||
toolCache.clear();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
httpServer.close((error) => {
|
||||
if (!error) {
|
||||
|
||||
@@ -70,6 +70,26 @@ describe("resolveGatewayScopedTools", () => {
|
||||
expect(result.tools.some((tool) => tool.name === "message")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps default-agent credentials out of unbound gateway calls", () => {
|
||||
const cfg = {
|
||||
agents: { defaults: { imageModel: { primary: "openai/gpt-5.4-mini" } } },
|
||||
} as OpenClawConfig;
|
||||
const unbound = resolveGatewayScopedTools({
|
||||
cfg,
|
||||
sessionKey: "agent:main:main",
|
||||
surface: "loopback",
|
||||
});
|
||||
const grantBound = resolveGatewayScopedTools({
|
||||
cfg,
|
||||
agentDir: "/agents/cli",
|
||||
sessionKey: "agent:main:main",
|
||||
surface: "loopback",
|
||||
});
|
||||
|
||||
expect(unbound.tools.some((tool) => tool.name === "image")).toBe(false);
|
||||
expect(grantBound.tools.some((tool) => tool.name === "image")).toBe(true);
|
||||
});
|
||||
|
||||
it("materializes an executable write tool on the mediated CLI surface", async () => {
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mediated-write-"));
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent
|
||||
import { createOpenClawCodingTools } from "../agents/agent-tools.js";
|
||||
import { filterToolsByMessageProvider } from "../agents/agent-tools.message-provider-policy.js";
|
||||
import { resolveEffectiveToolPolicy } from "../agents/agent-tools.policy.js";
|
||||
import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
|
||||
import type { ExecElevatedDefaults } from "../agents/bash-tools.exec-types.js";
|
||||
import { nodeExecSchema } from "../agents/bash-tools.schemas.js";
|
||||
import {
|
||||
@@ -57,6 +58,8 @@ type GatewayScopedToolSurface = "http" | "loopback";
|
||||
/** Resolve the tools visible to a gateway caller after agent, channel, and surface policy. */
|
||||
export function resolveGatewayScopedTools(params: {
|
||||
cfg: OpenClawConfig;
|
||||
authProfileStore?: AuthProfileStore;
|
||||
agentDir?: string;
|
||||
sessionKey: string;
|
||||
runtimePolicySessionKey?: string;
|
||||
agentId?: string;
|
||||
@@ -272,6 +275,10 @@ export function resolveGatewayScopedTools(params: {
|
||||
disablePluginTools: params.disablePluginTools,
|
||||
wrapBeforeToolCallHook: false,
|
||||
config: params.cfg,
|
||||
agentDir: params.agentDir,
|
||||
authProfileStore: params.authProfileStore,
|
||||
modelProvider: params.modelProvider,
|
||||
modelId: params.modelId,
|
||||
clientCaps: params.clientCaps,
|
||||
workspaceDir,
|
||||
sandboxed: sandboxRuntime.sandboxed,
|
||||
|
||||
Reference in New Issue
Block a user