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:
wangchunyue 2026-04-02 22:10:03 +08:00 committed by GitHub
parent 4f692190b4
commit b40ef364b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 63 additions and 1 deletions

View File

@ -63,6 +63,7 @@ Docs: https://docs.openclaw.ai
- ACP/gateway reconnects: keep ACP prompts alive across transient websocket drops while still failing boundedly when reconnect recovery does not complete. (#59473) Thanks @obviyus.
- ACP/gateway reconnects: reject stale pre-ack ACP prompts after reconnect grace expiry so callers fail cleanly instead of hanging indefinitely when the gateway never confirms the run.
- Exec approvals/doctor: report host policy sources from the real approvals file path and ignore malformed host override values when attributing effective policy conflicts. (#59367) Thanks @gumadeiras.
- Agents/subagents: pin admin-only subagent gateway calls to `operator.admin` while keeping `agent` at least privilege, so `sessions_spawn` no longer dies on loopback scope-upgrade pairing with `close(1008) "pairing required"`. (#59555) Thanks @openperf.
- Exec approvals/config: strip invalid `security`, `ask`, and `askFallback` values from `~/.openclaw/exec-approvals.json` during normalization so malformed policy enums fall back cleanly to the documented defaults instead of corrupting runtime policy resolution. (#59112) Thanks @openperf.
- Gateway/session kill: enforce HTTP operator scopes on session kill requests and gate authorization before session lookup so unauthenticated callers cannot probe session existence. (#59128) Thanks @jacobtomlinson.
- MS Teams/logging: format non-`Error` failures with the shared unknown-error helper so logs stop collapsing caught SDK or Axios objects into `[object Object]`. (#59321) Thanks @bradgroux.

View File

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

View File

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