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
3 changed files with 63 additions and 1 deletions

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 {