diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c8ea25bce2..6b9f1213ec7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Onboarding/non-interactive: preserve existing gateway auth tokens during re-onboard so active local gateway clients are not disconnected by an implicit token rotation. (#67821) Thanks @BKF-Gitty. +- Gateway/hello-ok: always report negotiated auth metadata for successful shared-auth handshakes, including control-ui bypass coverage when no device token is issued. (#67810) Thanks @BunsDev. - OpenAI Codex/Responses: unify native Responses API capability detection so Codex OAuth requests emit the required `store: false` field on the native Responses path. (#67918) Thanks @obviyus. - WhatsApp/setup: guard personal-phone and allowlist prompt values so setup fails with clear validation errors instead of crashing on undefined prompt text. (#67895) Thanks @lawrence3699. - Models/config: preserve an existing `models.json` provider `baseUrl` during merge-mode regeneration so custom endpoints do not get reset on restart. (#67893) Thanks @lawrence3699. diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index e27d7de48dc..c3d712073ec 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -89,7 +89,21 @@ Gateway → Client: ``` `server`, `features`, `snapshot`, and `policy` are all required by the schema -(`src/gateway/protocol/schema/frames.ts`). `auth` and `canvasHostUrl` are optional. +(`src/gateway/protocol/schema/frames.ts`). `canvasHostUrl` is optional. `auth` +reports the negotiated role/scopes when available, and includes `deviceToken` +when the gateway issues one. + +When no device token is issued, `hello-ok.auth` can still report the negotiated +permissions: + +```json +{ + "auth": { + "role": "operator", + "scopes": ["operator.read", "operator.write"] + } +} +``` When a device token is issued, `hello-ok` also includes: diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts index eb133a5ea4e..8b9bebe5069 100644 --- a/src/agents/skills.test.ts +++ b/src/agents/skills.test.ts @@ -6,6 +6,8 @@ import { setRuntimeConfigSnapshot, type OpenClawConfig, } from "../config/config.js"; +import { clearPluginDiscoveryCache } from "../plugins/discovery.js"; +import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js"; import { withPathResolutionEnv } from "../test-utils/env.js"; import { createFixtureSuite } from "../test-utils/fixture-suite.js"; import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js"; @@ -127,6 +129,8 @@ afterAll(async () => { afterEach(() => { clearRuntimeConfigSnapshot(); + clearPluginDiscoveryCache(); + clearPluginManifestRegistryCache(); }); describe("buildWorkspaceSkillCommandSpecs", () => { diff --git a/src/gateway/protocol/schema/frames.ts b/src/gateway/protocol/schema/frames.ts index 3a4c671f5a4..ff041fe7024 100644 --- a/src/gateway/protocol/schema/frames.ts +++ b/src/gateway/protocol/schema/frames.ts @@ -92,7 +92,7 @@ export const HelloOkSchema = Type.Object( auth: Type.Optional( Type.Object( { - deviceToken: NonEmptyString, + deviceToken: Type.Optional(NonEmptyString), role: NonEmptyString, scopes: Type.Array(NonEmptyString), issuedAtMs: Type.Optional(Type.Integer({ minimum: 0 })), diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 8be4b79c62f..234396082f0 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -421,7 +421,18 @@ export function registerControlUiAndPairingSuite(): void { }, }); expect(res.ok).toBe(true); - expect((res.payload as { auth?: unknown } | undefined)?.auth).toBeUndefined(); + const helloOk = res.payload as + | { + auth?: { + role?: unknown; + scopes?: unknown; + deviceToken?: unknown; + }; + } + | undefined; + expect(helloOk?.auth?.role).toBe("operator"); + expect(helloOk?.auth?.scopes).toEqual(["operator.read"]); + expect(helloOk?.auth?.deviceToken).toBeUndefined(); const health = await rpcReq(staleDeviceWs, "health"); expect(health.ok).toBe(true); staleDeviceWs.close(); @@ -435,6 +446,18 @@ export function registerControlUiAndPairingSuite(): void { }, }); expect(scopedRes.ok, "requested scope bypass").toBe(true); + const scopedHelloOk = scopedRes.payload as + | { + auth?: { + role?: unknown; + scopes?: unknown; + deviceToken?: unknown; + }; + } + | undefined; + expect(scopedHelloOk?.auth?.role).toBe("operator"); + expect(scopedHelloOk?.auth?.scopes).toEqual(["operator.read"]); + expect(scopedHelloOk?.auth?.deviceToken).toBeUndefined(); const scopedHealth = await rpcReq(scopedWs, "health"); expect(scopedHealth.ok).toBe(true); diff --git a/src/gateway/server.auth.default-token.suite.ts b/src/gateway/server.auth.default-token.suite.ts index 8e35eaeac33..d9295db68cf 100644 --- a/src/gateway/server.auth.default-token.suite.ts +++ b/src/gateway/server.auth.default-token.suite.ts @@ -248,6 +248,28 @@ export function registerDefaultAuthTokenSuite(): void { } }); + test("hello-ok reports granted auth metadata for device-less shared token auth", async () => { + const ws = await openWs(port); + try { + const res = await connectReq(ws, { scopes: ["operator.read"], device: null }); + expect(res.ok).toBe(true); + const helloOk = res.payload as + | { + auth?: { + role?: unknown; + scopes?: unknown; + deviceToken?: unknown; + }; + } + | undefined; + expect(helloOk?.auth?.role).toBe("operator"); + expect(helloOk?.auth?.scopes).toEqual([]); + expect(helloOk?.auth?.deviceToken).toBeUndefined(); + } finally { + ws.close(); + } + }); + test("does not grant admin when scopes are omitted", async () => { const ws = await openWs(port); const token = resolveGatewayTokenOrEnv(); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 2d20756f65e..aba91e72161 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -1227,17 +1227,19 @@ export function attachGatewayWsMessageHandler(params: { features: { methods: gatewayMethods, events }, snapshot, canvasHostUrl: scopedCanvasHostUrl, - auth: deviceToken - ? { - deviceToken: deviceToken.token, - role: deviceToken.role, - scopes: deviceToken.scopes, - issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs, - ...(bootstrapDeviceTokens.length > 1 - ? { deviceTokens: bootstrapDeviceTokens.slice(1) } - : {}), - } - : undefined, + auth: { + role, + scopes, + ...(deviceToken + ? { + deviceToken: deviceToken.token, + issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs, + ...(bootstrapDeviceTokens.length > 1 + ? { deviceTokens: bootstrapDeviceTokens.slice(1) } + : {}), + } + : {}), + }, policy: { maxPayload: MAX_PAYLOAD_BYTES, maxBufferedBytes: MAX_BUFFERED_BYTES,