Skip to content

Commit b40ef36

Browse files
openperfobviyus
andauthored
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 <[email protected]>
1 parent 4f69219 commit b40ef36

3 files changed

Lines changed: 63 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ Docs: https://docs.openclaw.ai
6363
- ACP/gateway reconnects: keep ACP prompts alive across transient websocket drops while still failing boundedly when reconnect recovery does not complete. (#59473) Thanks @obviyus.
6464
- 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.
6565
- 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.
66+
- 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.
6667
- 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.
6768
- 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.
6869
- 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.

src/agents/subagent-spawn.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,4 +157,50 @@ describe("spawnSubagentDirect seam flow", () => {
157157
);
158158
expect(operations.indexOf("gateway:agent")).toBeGreaterThan(operations.indexOf("store:update"));
159159
});
160+
161+
it("pins admin-only methods to operator.admin and preserves least-privilege for others (#59428)", async () => {
162+
const capturedCalls: Array<{ method?: string; scopes?: string[] }> = [];
163+
164+
hoisted.callGatewayMock.mockImplementation(
165+
async (request: { method?: string; scopes?: string[] }) => {
166+
capturedCalls.push({ method: request.method, scopes: request.scopes });
167+
if (request.method === "agent") {
168+
return { runId: "run-1" };
169+
}
170+
if (request.method?.startsWith("sessions.")) {
171+
return { ok: true };
172+
}
173+
return {};
174+
},
175+
);
176+
installSessionStoreCaptureMock(hoisted.updateSessionStoreMock);
177+
178+
const result = await spawnSubagentDirect(
179+
{
180+
task: "verify per-method scope routing",
181+
model: "openai-codex/gpt-5.4",
182+
},
183+
{
184+
agentSessionKey: "agent:main:main",
185+
agentChannel: "discord",
186+
agentAccountId: "acct-1",
187+
agentTo: "user-1",
188+
workspaceDir: "/tmp/requester-workspace",
189+
},
190+
);
191+
192+
expect(result.status).toBe("accepted");
193+
expect(capturedCalls.length).toBeGreaterThan(0);
194+
195+
for (const call of capturedCalls) {
196+
if (call.method === "sessions.patch" || call.method === "sessions.delete") {
197+
// Admin-only methods must be pinned to operator.admin.
198+
expect(call.scopes).toEqual(["operator.admin"]);
199+
} else {
200+
// Non-admin methods (e.g. "agent") must NOT be forced to admin scope
201+
// so the gateway preserves least-privilege and senderIsOwner stays false.
202+
expect(call.scopes).toBeUndefined();
203+
}
204+
}
205+
});
160206
});

src/agents/subagent-spawn.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js";
55
import { loadConfig } from "../config/config.js";
66
import { mergeSessionEntry, updateSessionStore } from "../config/sessions.js";
77
import { callGateway } from "../gateway/call.js";
8+
import { ADMIN_SCOPE, isAdminOnlyMethod } from "../gateway/method-scopes.js";
89
import {
910
pruneLegacyStoreKeys,
1011
resolveGatewaySessionStoreTarget,
@@ -148,7 +149,21 @@ async function updateSubagentSessionStore(
148149
async function callSubagentGateway(
149150
params: Parameters<typeof callGateway>[0],
150151
): Promise<Awaited<ReturnType<typeof callGateway>>> {
151-
return await subagentSpawnDeps.callGateway(params);
152+
// Subagent lifecycle requires methods spanning multiple scope tiers
153+
// (sessions.patch / sessions.delete → admin, agent → write). When each call
154+
// independently negotiates least-privilege scopes the first connection pairs
155+
// at a lower tier and every subsequent higher-tier call triggers a
156+
// scope-upgrade handshake that headless gateway-client connections cannot
157+
// complete interactively, causing close(1008) "pairing required" (#59428).
158+
//
159+
// Only admin-only methods are pinned to ADMIN_SCOPE; other methods (e.g.
160+
// "agent" → write) keep their least-privilege scope so that the gateway does
161+
// not treat the caller as owner (senderIsOwner) and expose owner-only tools.
162+
const scopes = params.scopes ?? (isAdminOnlyMethod(params.method) ? [ADMIN_SCOPE] : undefined);
163+
return await subagentSpawnDeps.callGateway({
164+
...params,
165+
...(scopes != null ? { scopes } : {}),
166+
});
152167
}
153168

154169
function readGatewayRunId(response: Awaited<ReturnType<typeof callGateway>>): string | undefined {

0 commit comments

Comments
 (0)