diff --git a/CHANGELOG.md b/CHANGELOG.md index 883e16fe948..a001e0e65b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -210,6 +210,7 @@ Docs: https://docs.openclaw.ai - Compute plugin callback authorization dynamically [AI]. (#78866) Thanks @pgondhi987. - fix(active-memory): require admin scope for global toggles [AI]. (#78863) Thanks @pgondhi987. - Honor owner enforcement for native commands [AI]. (#78864) Thanks @pgondhi987. +- Gateway/auth: allow `gateway.auth.mode: "none"` loopback backend RPC clients to skip device identity only for local non-browser backend connections, restoring subagent spawns and gateway tools without opening remote or browser-origin bypasses. Fixes #75780. Thanks @yozakura-ava. - Tavily: resolve dedicated `tavily_search` and `tavily_extract` tool credentials from the active runtime config snapshot, so `exec` SecretRef-backed API keys do not reach the tools unresolved. (#78610) Thanks @VACInc. - Gateway/sessions: clear cached skills snapshots during `/new` and `sessions.reset` so long-lived channel sessions rebuild the visible skill list after skills change. (#78873) Thanks @Evizero. - fix(auto-reply): gate inline skill tool dispatch [AI]. (#78517) Thanks @pgondhi987. @@ -1204,7 +1205,6 @@ Docs: https://docs.openclaw.ai - Agents/replies: defer implicit image model discovery and keep OAuth auth-store adoption on persisted profiles during reply startup, cutting OCM MarCodex warm prep to sub-second in live checks. Thanks @shakkernerd. - Plugins/tools: enforce `contracts.tools` as the manifest ownership contract for plugin tool registration, rejecting undeclared runtime tool names and adding bundled plugin drift coverage. Thanks @shakkernerd. - Agents/Codex: stop prompting message-tool-only source turns to finish with `NO_REPLY`, so quiet turns are represented by not calling the visible message tool instead of conflicting final-text instructions. Thanks @pashpashpash. - - Gateway/config: report failed backup restores as failed in logs and config observe audit records instead of marking them valid. (#70515) Thanks @davidangularme. - Compaction: use the active session model fallback chain for implicit summarization failures without persisting fallback model selection, so Azure content-filter 400s can recover. Fixes #64960. (#74470) Thanks @jalehman and @OpenCodeEngineer. - Gateway/config: allow `gateway config.patch` to update documented subagent thinking defaults. Fixes #75764. (#75802) Thanks @kAIborg24. diff --git a/src/gateway/server.auth.compat-baseline.test.ts b/src/gateway/server.auth.compat-baseline.test.ts index fffa089bc3e..699f567eead 100644 --- a/src/gateway/server.auth.compat-baseline.test.ts +++ b/src/gateway/server.auth.compat-baseline.test.ts @@ -333,6 +333,52 @@ describe("gateway auth compatibility baseline", () => { } }); + test("allows auth-none local backend connects without device identity", async () => { + const ws = await openWs(port); + try { + const res = await connectReq(ws, { + skipDefaultAuth: true, + client: { ...BACKEND_GATEWAY_CLIENT }, + scopes: ["operator.admin"], + device: null, + }); + expect(res.ok, JSON.stringify(res)).toBe(true); + + const helloOk = res.payload as + | { + auth?: { + scopes?: unknown; + }; + } + | undefined; + expect(helloOk?.auth?.scopes).toEqual(["operator.admin"]); + + const adminRes = await rpcReq(ws, "set-heartbeats", { enabled: false }); + expect(adminRes.ok).toBe(true); + } finally { + ws.close(); + } + }); + + test("rejects auth-none browser-origin backend connects without device identity", async () => { + const ws = await openWs(port, { origin: originForPort(port) }); + try { + const res = await connectReq(ws, { + skipDefaultAuth: true, + client: { ...BACKEND_GATEWAY_CLIENT }, + scopes: ["operator.admin"], + device: null, + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("device identity required"); + expect((res.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED, + ); + } finally { + ws.close(); + } + }); + test("keeps auth-none control ui first-connect token absence unchanged", async () => { const ws = await openWs(port, { origin: originForPort(port) }); try { diff --git a/src/gateway/server/ws-connection/auth-context.state.test.ts b/src/gateway/server/ws-connection/auth-context.state.test.ts index e94e1e5c852..d5de1c76e7b 100644 --- a/src/gateway/server/ws-connection/auth-context.state.test.ts +++ b/src/gateway/server/ws-connection/auth-context.state.test.ts @@ -82,6 +82,7 @@ describe("resolveConnectAuthDecision", () => { const state = await resolveConnectAuthState({ resolvedAuth: { mode: "none", + allowTailscale: false, } satisfies ResolvedGatewayAuth, connectAuth: {}, hasDeviceIdentity: false, diff --git a/src/gateway/server/ws-connection/connect-policy.test.ts b/src/gateway/server/ws-connection/connect-policy.test.ts index 49f1fdbce7d..fb3a2def5e2 100644 --- a/src/gateway/server/ws-connection/connect-policy.test.ts +++ b/src/gateway/server/ws-connection/connect-policy.test.ts @@ -128,6 +128,36 @@ describe("ws connect policy", () => { }).kind, ).toBe("allow"); + expect( + evaluateMissingDeviceIdentity({ + hasDeviceIdentity: false, + role: "operator", + isControlUi: false, + controlUiAuthPolicy: policy, + trustedProxyAuthOk: false, + localBackendSelfPairingOk: true, + sharedAuthOk: false, + authOk: true, + hasSharedAuth: false, + isLocalClient: true, + }).kind, + ).toBe("allow"); + + expect( + evaluateMissingDeviceIdentity({ + hasDeviceIdentity: false, + role: "node", + isControlUi: false, + controlUiAuthPolicy: policy, + trustedProxyAuthOk: false, + localBackendSelfPairingOk: true, + sharedAuthOk: false, + authOk: true, + hasSharedAuth: false, + isLocalClient: true, + }).kind, + ).toBe("reject-device-required"); + expect( evaluateMissingDeviceIdentity({ hasDeviceIdentity: false, diff --git a/src/gateway/server/ws-connection/connect-policy.ts b/src/gateway/server/ws-connection/connect-policy.ts index 27284609174..dfb06ab0ece 100644 --- a/src/gateway/server/ws-connection/connect-policy.ts +++ b/src/gateway/server/ws-connection/connect-policy.ts @@ -111,6 +111,7 @@ export function evaluateMissingDeviceIdentity(params: { isControlUi: boolean; controlUiAuthPolicy: ControlUiAuthPolicy; trustedProxyAuthOk?: boolean; + localBackendSelfPairingOk?: boolean; sharedAuthOk: boolean; authOk: boolean; hasSharedAuth: boolean; @@ -130,6 +131,9 @@ export function evaluateMissingDeviceIdentity(params: { // registrations (see #45405 review). return { kind: "allow" }; } + if (params.localBackendSelfPairingOk && params.role === "operator") { + return { kind: "allow" }; + } if (params.isControlUi && !params.controlUiAuthPolicy.allowBypass) { // Allow localhost Control UI connections when allowInsecureAuth is configured. // Localhost has no network interception risk, and browser SubtleCrypto diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 5a0e45abe27..ca3bcb3fbfc 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -673,6 +673,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar isControlUi, controlUiAuthPolicy, trustedProxyAuthOk, + localBackendSelfPairingOk: skipLocalBackendSelfPairing, sharedAuthOk, authOk, hasSharedAuth,