mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-05 14:21:32 +00:00
fix: pin admin-only subagent gateway scopes (#59555) (thanks @openperf)
* fix(agents): pin subagent gateway calls to admin scope to prevent scope-upgrade pairing failures callSubagentGateway forwards params to callGateway without explicit scopes, so callGatewayLeastPrivilege negotiates the minimum scope per method independently. The first connection pairs the device at a lower tier and every subsequent higher-tier call triggers a scope-upgrade handshake that headless gateway-client connections cannot complete interactively (close 1008 "pairing required"). Pin callSubagentGateway to operator.admin so the device is paired at the ceiling scope on the very first (silent, local-loopback) handshake, avoiding any subsequent scope-upgrade negotiation entirely. Fixes #59428 * fix: pin admin-only subagent gateway scopes (#59555) (thanks @openperf) --------- Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
@@ -157,4 +157,50 @@ describe("spawnSubagentDirect seam flow", () => {
|
||||
);
|
||||
expect(operations.indexOf("gateway:agent")).toBeGreaterThan(operations.indexOf("store:update"));
|
||||
});
|
||||
|
||||
it("pins admin-only methods to operator.admin and preserves least-privilege for others (#59428)", async () => {
|
||||
const capturedCalls: Array<{ method?: string; scopes?: string[] }> = [];
|
||||
|
||||
hoisted.callGatewayMock.mockImplementation(
|
||||
async (request: { method?: string; scopes?: string[] }) => {
|
||||
capturedCalls.push({ method: request.method, scopes: request.scopes });
|
||||
if (request.method === "agent") {
|
||||
return { runId: "run-1" };
|
||||
}
|
||||
if (request.method?.startsWith("sessions.")) {
|
||||
return { ok: true };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
);
|
||||
installSessionStoreCaptureMock(hoisted.updateSessionStoreMock);
|
||||
|
||||
const result = await spawnSubagentDirect(
|
||||
{
|
||||
task: "verify per-method scope routing",
|
||||
model: "openai-codex/gpt-5.4",
|
||||
},
|
||||
{
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "discord",
|
||||
agentAccountId: "acct-1",
|
||||
agentTo: "user-1",
|
||||
workspaceDir: "/tmp/requester-workspace",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe("accepted");
|
||||
expect(capturedCalls.length).toBeGreaterThan(0);
|
||||
|
||||
for (const call of capturedCalls) {
|
||||
if (call.method === "sessions.patch" || call.method === "sessions.delete") {
|
||||
// Admin-only methods must be pinned to operator.admin.
|
||||
expect(call.scopes).toEqual(["operator.admin"]);
|
||||
} else {
|
||||
// Non-admin methods (e.g. "agent") must NOT be forced to admin scope
|
||||
// so the gateway preserves least-privilege and senderIsOwner stays false.
|
||||
expect(call.scopes).toBeUndefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { mergeSessionEntry, updateSessionStore } from "../config/sessions.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { ADMIN_SCOPE, isAdminOnlyMethod } from "../gateway/method-scopes.js";
|
||||
import {
|
||||
pruneLegacyStoreKeys,
|
||||
resolveGatewaySessionStoreTarget,
|
||||
@@ -148,7 +149,21 @@ async function updateSubagentSessionStore(
|
||||
async function callSubagentGateway(
|
||||
params: Parameters<typeof callGateway>[0],
|
||||
): Promise<Awaited<ReturnType<typeof callGateway>>> {
|
||||
return await subagentSpawnDeps.callGateway(params);
|
||||
// Subagent lifecycle requires methods spanning multiple scope tiers
|
||||
// (sessions.patch / sessions.delete → admin, agent → write). When each call
|
||||
// independently negotiates least-privilege scopes the first connection pairs
|
||||
// at a lower tier and every subsequent higher-tier call triggers a
|
||||
// scope-upgrade handshake that headless gateway-client connections cannot
|
||||
// complete interactively, causing close(1008) "pairing required" (#59428).
|
||||
//
|
||||
// Only admin-only methods are pinned to ADMIN_SCOPE; other methods (e.g.
|
||||
// "agent" → write) keep their least-privilege scope so that the gateway does
|
||||
// not treat the caller as owner (senderIsOwner) and expose owner-only tools.
|
||||
const scopes = params.scopes ?? (isAdminOnlyMethod(params.method) ? [ADMIN_SCOPE] : undefined);
|
||||
return await subagentSpawnDeps.callGateway({
|
||||
...params,
|
||||
...(scopes != null ? { scopes } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function readGatewayRunId(response: Awaited<ReturnType<typeof callGateway>>): string | undefined {
|
||||
|
||||
Reference in New Issue
Block a user