From 896f111a95fb2cea6f93b5af3183bddc65eac94c Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Thu, 12 Mar 2026 18:42:11 -0700 Subject: [PATCH] Gateway: preserve operator scopes without device identity --- src/gateway/server.auth.compat-baseline.test.ts | 12 ++++++------ src/gateway/server/ws-connection/message-handler.ts | 2 +- src/gateway/test-helpers.mocks.ts | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/gateway/server.auth.compat-baseline.test.ts b/src/gateway/server.auth.compat-baseline.test.ts index 8c6ea06978c..009668e1321 100644 --- a/src/gateway/server.auth.compat-baseline.test.ts +++ b/src/gateway/server.auth.compat-baseline.test.ts @@ -63,7 +63,7 @@ describe("gateway auth compatibility baseline", () => { } }); - test("clears client-declared scopes for shared-token operator connects", async () => { + test("keeps requested scopes for shared-token operator connects without device identity", async () => { const ws = await openWs(port); try { const res = await connectReq(ws, { @@ -74,8 +74,8 @@ describe("gateway auth compatibility baseline", () => { expect(res.ok).toBe(true); const adminRes = await rpcReq(ws, "set-heartbeats", { enabled: false }); - expect(adminRes.ok).toBe(false); - expect(adminRes.error?.message).toBe("missing scope: operator.admin"); + expect(adminRes.ok).toBe(true); + expect((adminRes.payload as { enabled?: boolean } | undefined)?.enabled).toBe(false); } finally { ws.close(); } @@ -183,7 +183,7 @@ describe("gateway auth compatibility baseline", () => { } }); - test("clears client-declared scopes for shared-password operator connects", async () => { + test("keeps requested scopes for shared-password operator connects without device identity", async () => { const ws = await openWs(port); try { const res = await connectReq(ws, { @@ -194,8 +194,8 @@ describe("gateway auth compatibility baseline", () => { expect(res.ok).toBe(true); const adminRes = await rpcReq(ws, "set-heartbeats", { enabled: false }); - expect(adminRes.ok).toBe(false); - expect(adminRes.error?.message).toBe("missing scope: operator.admin"); + expect(adminRes.ok).toBe(true); + expect((adminRes.payload as { enabled?: boolean } | undefined)?.enabled).toBe(false); } finally { ws.close(); } diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index d3d98da461f..4a3ae558146 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -526,7 +526,7 @@ export function attachGatewayWsMessageHandler(params: { hasSharedAuth, isLocalClient, }); - if (!device && (!isControlUi || decision.kind !== "allow")) { + if (!device && decision.kind !== "allow" && !isControlUi) { clearUnboundScopes(); } if (decision.kind === "allow") { diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index d8dfdcbbe84..98d4ba60b39 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -580,11 +580,11 @@ vi.mock("../channels/web/index.js", async () => { }; }); vi.mock("../commands/agent.js", () => ({ - agentCommand, - agentCommandFromIngress: agentCommand, + agentCommand: hoisted.agentCommand, + agentCommandFromIngress: hoisted.agentCommand, })); vi.mock("../auto-reply/reply.js", () => ({ - getReplyFromConfig, + getReplyFromConfig: hoisted.getReplyFromConfig, })); vi.mock("../cli/deps.js", async () => { const actual = await vi.importActual("../cli/deps.js");