fix(gateway): require admin for agent session reset

This commit is contained in:
Vincent Koc
2026-03-23 09:24:24 -07:00
parent 041c47419f
commit 50f6a2f136
3 changed files with 54 additions and 10 deletions

View File

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

View File

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

View File

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