From b40ef364b71cd686c1ee87db5499d5cc0deca4ef Mon Sep 17 00:00:00 2001 From: wangchunyue <80630709+openperf@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:10:03 +0800 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + src/agents/subagent-spawn.test.ts | 46 +++++++++++++++++++++++++++++++ src/agents/subagent-spawn.ts | 17 +++++++++++- 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ada3d735ba..d2ab7dcb24f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/subagent-spawn.test.ts b/src/agents/subagent-spawn.test.ts index eee0d3c3591..48c14747722 100644 --- a/src/agents/subagent-spawn.test.ts +++ b/src/agents/subagent-spawn.test.ts @@ -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(); + } + } + }); }); diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 4c92575eca9..00a6be3cca0 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -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[0], ): Promise>> { - 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>): string | undefined {