diff --git a/CHANGELOG.md b/CHANGELOG.md index 9086378b42b..15f338cf515 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Exec/Windows: include Windows-compatible env override keys like `ProgramFiles(x86)` in system-run approval binding so changed approved values are rejected instead of silently passing unbound. (#59182) Thanks @pgondhi987. - ACP/Windows spawn: fail closed on unresolved `.cmd` and `.bat` OpenClaw wrappers unless a caller explicitly opts into shell fallback, so Windows ACP launches do not silently drop into shell-mediated execution when wrapper unwrapping fails. (#58436) Thanks @eleqtrizit. - Exec/Windows: prefer strict-inline-eval denial over generic allowlist prompts for interpreter carriers, while keeping persisted Windows allow-always approvals argv-bound. (#59780) Thanks @luoyanglang. +- Gateway/connect: omit admin-scoped config and auth metadata from lower-privilege `hello-ok` snapshots while preserving those fields for admin reconnects. (#58469) Thanks @eleqtrizit. ## 2026.4.2-beta.1 diff --git a/src/gateway/server.auth.browser-hardening.test.ts b/src/gateway/server.auth.browser-hardening.test.ts index 595513ff3e9..414640286ff 100644 --- a/src/gateway/server.auth.browser-hardening.test.ts +++ b/src/gateway/server.auth.browser-hardening.test.ts @@ -277,6 +277,38 @@ describe("gateway auth browser hardening", () => { }); }); + test("omits sensitive gateway paths from low-privilege hello-ok snapshots", async () => { + testState.gatewayAuth = { mode: "token", token: "secret" }; + await withGatewayServer(async ({ port }) => { + const ws = await openWs(port, { origin: originForPort(port) }); + try { + const payload = (await connectOk(ws, { + token: "secret", + scopes: ["operator.read"], + device: null, + })) as { + type: "hello-ok"; + snapshot?: { + configPath?: unknown; + stateDir?: unknown; + authMode?: unknown; + }; + }; + // connectReq scopes are evaluated after auth and unbound-scope clearing, so this assertion + // verifies the effective low-privilege session view rather than self-declared client scopes. + const snapshot = payload.snapshot as + | { configPath?: unknown; stateDir?: unknown; authMode?: unknown } + | undefined; + expect(snapshot).toBeDefined(); + expect(snapshot?.configPath).toBeUndefined(); + expect(snapshot?.stateDir).toBeUndefined(); + expect(snapshot?.authMode).toBeUndefined(); + } finally { + ws.close(); + } + }); + }); + test("does not silently auto-pair non-control-ui browser clients on loopback", async () => { const { listDevicePairing } = await import("../infra/device-pairing.js"); testState.gatewayAuth = { mode: "token", token: "secret" }; diff --git a/src/gateway/server.auth.compat-baseline.test.ts b/src/gateway/server.auth.compat-baseline.test.ts index b780472a564..5a0a8435f29 100644 --- a/src/gateway/server.auth.compat-baseline.test.ts +++ b/src/gateway/server.auth.compat-baseline.test.ts @@ -190,7 +190,22 @@ describe("gateway auth compatibility baseline", () => { scopes: ["operator.admin"], }); expect(res.ok).toBe(true); - expect((res.payload as { type?: string } | undefined)?.type).toBe("hello-ok"); + const payload = res.payload as + | { + type?: string; + snapshot?: { + configPath?: string; + stateDir?: string; + authMode?: string; + }; + } + | undefined; + expect(payload?.type).toBe("hello-ok"); + expect(typeof payload?.snapshot?.configPath).toBe("string"); + expect((payload?.snapshot?.configPath ?? "").length).toBeGreaterThan(0); + expect(typeof payload?.snapshot?.stateDir).toBe("string"); + expect((payload?.snapshot?.stateDir ?? "").length).toBeGreaterThan(0); + expect(payload?.snapshot?.authMode).toBe("token"); } finally { ws.close(); } diff --git a/src/gateway/server/health-state.ts b/src/gateway/server/health-state.ts index 0c14d6e0ad9..47491f94199 100644 --- a/src/gateway/server/health-state.ts +++ b/src/gateway/server/health-state.ts @@ -14,36 +14,38 @@ let healthCache: HealthSummary | null = null; let healthRefresh: Promise | null = null; let broadcastHealthUpdate: ((snap: HealthSummary) => void) | null = null; -export function buildGatewaySnapshot(): Snapshot { +export function buildGatewaySnapshot(opts?: { includeSensitive?: boolean }): Snapshot { const cfg = loadConfig(); - const configPath = createConfigIO().configPath; const defaultAgentId = resolveDefaultAgentId(cfg); const mainKey = normalizeMainKey(cfg.session?.mainKey); const mainSessionKey = resolveMainSessionKey(cfg); const scope = cfg.session?.scope ?? "per-sender"; const presence = listSystemPresence(); const uptimeMs = Math.round(process.uptime() * 1000); - const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env }); const updateAvailable = getUpdateAvailable() ?? undefined; // Health is async; caller should await getHealthSnapshot and replace later if needed. const emptyHealth: unknown = {}; - return { + const snapshot: Snapshot = { presence, health: emptyHealth, stateVersion: { presence: presenceVersion, health: healthVersion }, uptimeMs, - // Surface resolved paths so UIs can display the true config location. - configPath, - stateDir: STATE_DIR, sessionDefaults: { defaultAgentId, mainKey, mainSessionKey, scope, }, - authMode: auth.mode, updateAvailable, }; + if (opts?.includeSensitive === true) { + const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env }); + // Surface resolved paths only to admin callers that already have broader gateway access. + snapshot.configPath = createConfigIO().configPath; + snapshot.stateDir = STATE_DIR; + snapshot.authMode = auth.mode; + } + return snapshot; } export function getHealthCache(): HealthSummary | null { diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 532c07f11af..639e58fadb4 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -48,6 +48,7 @@ import { mintCanvasCapabilityToken, } from "../../canvas-capability.js"; import { normalizeDeviceMetadataForAuth } from "../../device-auth.js"; +import { ADMIN_SCOPE } from "../../method-scopes.js"; import { isLocalishHost, isLoopbackAddress, @@ -1048,7 +1049,9 @@ export function attachGatewayWsMessageHandler(params: { incrementPresenceVersion(); } - const snapshot = buildGatewaySnapshot(); + const snapshot = buildGatewaySnapshot({ + includeSensitive: scopes.includes(ADMIN_SCOPE), + }); const cachedHealth = getHealthCache(); if (cachedHealth) { snapshot.health = cachedHealth;