From 50f6a2f136fed85b58548a38f7a3dbb98d2cd1a0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 23 Mar 2026 09:24:24 -0700 Subject: [PATCH] fix(gateway): require admin for agent session reset --- src/gateway/server-methods/agent.test.ts | 38 ++++++++++++++++++- src/gateway/server-methods/agent.ts | 13 +++++++ ...erver.agent.gateway-server-agent-b.test.ts | 13 +++---- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index f29a9a4c85d..bb3d2be0a85 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -803,7 +803,10 @@ describe("gateway agent handler", () => { sessionKey: "agent:main:main", idempotencyKey: "test-idem-new", }, - { reqId: "4" }, + { + reqId: "4", + client: { connect: { scopes: ["operator.admin"] } } as AgentHandlerArgs["client"], + }, ); await waitForAssertion(() => expect(mocks.agentCommand).toHaveBeenCalled()); @@ -831,7 +834,10 @@ describe("gateway agent handler", () => { sessionKey: "agent:main:main", idempotencyKey: "test-idem-reset-suffix", }, - { reqId: "4b" }, + { + reqId: "4b", + client: { connect: { scopes: ["operator.admin"] } } as AgentHandlerArgs["client"], + }, ); const call = await expectResetCall("[Wed 2026-01-28 20:30 EST] check status"); @@ -861,6 +867,34 @@ describe("gateway agent handler", () => { ); }); + it("rejects /reset for write-scoped gateway callers", async () => { + mockMainSessionEntry({ sessionId: "existing-session-id" }); + mocks.performGatewaySessionReset.mockClear(); + mocks.agentCommand.mockClear(); + + const respond = await invokeAgent( + { + message: "/reset", + sessionKey: "agent:main:main", + idempotencyKey: "test-reset-write-scope", + }, + { + reqId: "4c", + client: { connect: { scopes: ["operator.write"] } } as AgentHandlerArgs["client"], + }, + ); + + expect(mocks.performGatewaySessionReset).not.toHaveBeenCalled(); + expect(mocks.agentCommand).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: "missing scope: operator.admin", + }), + ); + }); + it("rejects malformed session keys in agent.identity.get", async () => { const respond = await invokeAgentIdentityGet( { diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index bd5637fa78f..df928e626fa 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -79,6 +79,10 @@ function resolveAllowModelOverrideFromClient( return resolveSenderIsOwnerFromClient(client) || client?.internal?.allowModelOverride === true; } +function resolveCanResetSessionFromClient(client: GatewayRequestHandlerOptions["client"]): boolean { + return resolveSenderIsOwnerFromClient(client); +} + async function runSessionResetFromAgent(params: { key: string; reason: "new" | "reset"; @@ -240,6 +244,7 @@ export const agentHandlers: GatewayRequestHandlers = { }; const senderIsOwner = resolveSenderIsOwnerFromClient(client); const allowModelOverride = resolveAllowModelOverrideFromClient(client); + const canResetSession = resolveCanResetSessionFromClient(client); const requestedModelOverride = Boolean(request.provider || request.model); if (requestedModelOverride && !allowModelOverride) { respond( @@ -378,6 +383,14 @@ export const agentHandlers: GatewayRequestHandlers = { const resetCommandMatch = message.match(RESET_COMMAND_RE); if (resetCommandMatch && requestedSessionKey) { + if (!canResetSession) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${ADMIN_SCOPE}`), + ); + return; + } const resetReason = resetCommandMatch[1]?.toLowerCase() === "new" ? "new" : "reset"; const resetResult = await runSessionResetFromAgent({ key: requestedSessionKey, diff --git a/src/gateway/server.agent.gateway-server-agent-b.test.ts b/src/gateway/server.agent.gateway-server-agent-b.test.ts index 096687851f3..7fdd191a82f 100644 --- a/src/gateway/server.agent.gateway-server-agent-b.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-b.test.ts @@ -326,7 +326,7 @@ describe("gateway server agent", () => { expect(call.sessionId).not.toBe("sess-main-before-reset"); }); - test("write-scoped callers cannot use sessions.reset directly but can still reset conversations via agent", async () => { + test("write-scoped callers cannot reset conversations via agent", async () => { await withGatewayServer(async ({ port }) => { await useTempSessionStorePath(); const storePath = testState.sessionStorePath; @@ -358,19 +358,16 @@ describe("gateway server agent", () => { sessionKey: "main", idempotencyKey: "idem-agent-write-reset", }); - expect(viaAgent.ok).toBe(true); + expect(viaAgent.ok).toBe(false); + expect(viaAgent.error?.message).toContain("missing scope: operator.admin"); const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< string, { sessionId?: string } >; expect(store["agent:main:main"]?.sessionId).toBeDefined(); - expect(store["agent:main:main"]?.sessionId).not.toBe("sess-main-before-write-reset"); - - await vi.waitFor(() => expect(vi.mocked(agentCommand)).toHaveBeenCalled()); - const call = readAgentCommandCall(); - expect(typeof call.sessionId).toBe("string"); - expect(call.sessionId).not.toBe("sess-main-before-write-reset"); + expect(store["agent:main:main"]?.sessionId).toBe("sess-main-before-write-reset"); + expect(vi.mocked(agentCommand)).not.toHaveBeenCalled(); writeWs.close(); });