diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 7fd9b7c84cb..5ffd3ce3c51 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -209,7 +209,7 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.token).toBe("explicit-token"); }); - it("does not attach device identity for local loopback shared-token auth", async () => { + it("keeps device identity enabled for local loopback shared-token auth", async () => { setLocalLoopbackGatewayConfig(); await callGateway({ @@ -219,7 +219,7 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789"); expect(lastClientOptions?.token).toBe("explicit-token"); - expect(lastClientOptions?.deviceIdentity).toBeUndefined(); + expect(lastClientOptions?.deviceIdentity).toBeDefined(); }); it("uses OPENCLAW_GATEWAY_URL env override in remote mode when remote URL is missing", async () => { diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 300391b6047..98793dd4071 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -86,15 +86,12 @@ function shouldAttachDeviceIdentityForGatewayCall(params: { token?: string; password?: string; }): boolean { - if (!(params.token || params.password)) { - return true; - } - try { - const parsed = new URL(params.url); - return !["127.0.0.1", "::1", "localhost"].includes(parsed.hostname); - } catch { - return true; - } + void params; + // Shared-auth local calls used to skip device identity as an optimization, but + // device-less operator connects now have their self-declared scopes stripped. + // Keep identity enabled so local authenticated calls stay device-bound and + // retain their least-privilege scopes. + return true; } export type ExplicitGatewayAuth = { diff --git a/src/gateway/probe.auth.integration.test.ts b/src/gateway/probe.auth.integration.test.ts new file mode 100644 index 00000000000..eed4d1be8ac --- /dev/null +++ b/src/gateway/probe.auth.integration.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { installGatewayTestHooks, testState, withGatewayServer } from "./test-helpers.js"; + +installGatewayTestHooks(); + +const { callGateway } = await import("./call.js"); +const { probeGateway } = await import("./probe.js"); + +describe("probeGateway auth integration", () => { + it("keeps direct local authenticated status RPCs device-bound", async () => { + const token = + typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string" + ? ((testState.gatewayAuth as { token?: string }).token ?? "") + : ""; + expect(token).toBeTruthy(); + + await withGatewayServer(async ({ port }) => { + const status = await callGateway({ + url: `ws://127.0.0.1:${port}`, + token, + method: "status", + timeoutMs: 5_000, + }); + + expect(status).toBeTruthy(); + }); + }); + + it("keeps detail RPCs available for local authenticated probes", async () => { + const token = + typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string" + ? ((testState.gatewayAuth as { token?: string }).token ?? "") + : ""; + expect(token).toBeTruthy(); + + await withGatewayServer(async ({ port }) => { + const result = await probeGateway({ + url: `ws://127.0.0.1:${port}`, + auth: { token }, + timeoutMs: 5_000, + }); + + expect(result.ok).toBe(true); + expect(result.error).toBeNull(); + expect(result.health).not.toBeNull(); + expect(result.status).not.toBeNull(); + expect(result.configSnapshot).not.toBeNull(); + }); + }); +}); diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts index f91dc5148d5..4a2374e17cb 100644 --- a/src/gateway/probe.test.ts +++ b/src/gateway/probe.test.ts @@ -51,7 +51,7 @@ describe("probeGateway", () => { }); expect(gatewayClientState.options?.scopes).toEqual(["operator.read"]); - expect(gatewayClientState.options?.deviceIdentity).toBeNull(); + expect(gatewayClientState.options?.deviceIdentity).toBeUndefined(); expect(gatewayClientState.requests).toEqual([ "health", "status", @@ -71,6 +71,15 @@ describe("probeGateway", () => { expect(gatewayClientState.options?.deviceIdentity).toBeUndefined(); }); + it("keeps device identity disabled for unauthenticated loopback probes", async () => { + await probeGateway({ + url: "ws://127.0.0.1:18789", + timeoutMs: 1_000, + }); + + expect(gatewayClientState.options?.deviceIdentity).toBeNull(); + }); + it("skips detail RPCs for lightweight reachability probes", async () => { const result = await probeGateway({ url: "ws://127.0.0.1:18789", diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index 87a77b8bfef..bbd36639b78 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -44,7 +44,10 @@ export async function probeGateway(opts: { const disableDeviceIdentity = (() => { try { - return isLoopbackHost(new URL(opts.url).hostname); + const hostname = new URL(opts.url).hostname; + // Local authenticated probes should stay device-bound so read/detail RPCs + // are not scope-limited by the shared-auth scope stripping hardening. + return isLoopbackHost(hostname) && !(opts.auth?.token || opts.auth?.password); } catch { return false; }