From 133ac35552fd4fe4457450b0c80052096c7d0bbd Mon Sep 17 00:00:00 2001 From: Mariano Belinky Date: Thu, 9 Apr 2026 20:02:55 +0200 Subject: [PATCH] Dreaming: require admin for gateway persistence --- .../memory-core/src/dreaming-command.test.ts | 48 ++++++++++++++++++- .../memory-core/src/dreaming-command.ts | 7 +++ .../chat.directive-tags.test.ts | 21 +++++++- src/gateway/server-methods/chat.ts | 2 +- 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/extensions/memory-core/src/dreaming-command.test.ts b/extensions/memory-core/src/dreaming-command.test.ts index b1a18f9318b..69b5fdb30bb 100644 --- a/extensions/memory-core/src/dreaming-command.test.ts +++ b/extensions/memory-core/src/dreaming-command.test.ts @@ -52,13 +52,17 @@ function createHarness(initialConfig: OpenClawConfig = {}) { }; } -function createCommandContext(args?: string): PluginCommandContext { +function createCommandContext( + args?: string, + overrides?: Partial>, +): PluginCommandContext { return { channel: "webchat", isAuthorizedSender: true, commandBody: args ? `/dreaming ${args}` : "/dreaming", args, config: {}, + gatewayClientScopes: overrides?.gatewayClientScopes, requestConversationBinding: async () => ({ status: "error", message: "unsupported" }), detachConversationBinding: async () => ({ removed: false }), getCurrentConversationBinding: async () => null, @@ -115,6 +119,48 @@ describe("memory-core /dreaming command", () => { expect(result.text).toContain("Dreaming disabled."); }); + it("blocks unscoped gateway callers from persisting dreaming config", async () => { + const { command, runtime } = createHarness(); + + const result = await command.handler( + createCommandContext("off", { + gatewayClientScopes: [], + }), + ); + + expect(result.text).toContain("requires operator.admin"); + expect(runtime.config.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("blocks write-scoped gateway callers from persisting dreaming config", async () => { + const { command, runtime } = createHarness(); + + const result = await command.handler( + createCommandContext("off", { + gatewayClientScopes: ["operator.write"], + }), + ); + + expect(result.text).toContain("requires operator.admin"); + expect(runtime.config.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("allows admin-scoped gateway callers to persist dreaming config", async () => { + const { command, runtime, getRuntimeConfig } = createHarness(); + + const result = await command.handler( + createCommandContext("on", { + gatewayClientScopes: ["operator.admin"], + }), + ); + + expect(runtime.config.writeConfigFile).toHaveBeenCalledTimes(1); + expect(resolveStoredDreaming(getRuntimeConfig())).toMatchObject({ + enabled: true, + }); + expect(result.text).toContain("Dreaming enabled."); + }); + it("returns status without mutating config", async () => { const { command, runtime } = createHarness({ plugins: { diff --git a/extensions/memory-core/src/dreaming-command.ts b/extensions/memory-core/src/dreaming-command.ts index 34b29dd237c..2f202b3e10c 100644 --- a/extensions/memory-core/src/dreaming-command.ts +++ b/extensions/memory-core/src/dreaming-command.ts @@ -75,6 +75,10 @@ function formatUsage(includeStatus: string): string { ].join("\n"); } +function requiresAdminToMutateDreaming(gatewayClientScopes?: readonly string[]): boolean { + return Array.isArray(gatewayClientScopes) && !gatewayClientScopes.includes("operator.admin"); +} + export function registerDreamingCommand(api: OpenClawPluginApi): void { api.registerCommand({ name: "dreaming", @@ -102,6 +106,9 @@ export function registerDreamingCommand(api: OpenClawPluginApi): void { } if (firstToken === "on" || firstToken === "off") { + if (requiresAdminToMutateDreaming(ctx.gatewayClientScopes)) { + return { text: "⚠️ /dreaming on|off requires operator.admin for gateway clients." }; + } const enabled = firstToken === "on"; const nextConfig = updateDreamingEnabledInConfig(currentConfig, enabled); await api.runtime.config.writeConfigFile(nextConfig); diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 6c5ea1b9ab0..0dac80f3162 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -207,7 +207,7 @@ function extractFirstTextBlock(payload: unknown): string | undefined { } function createScopedCliClient( - scopes: string[], + scopes?: string[], client: Partial<{ id: string; mode: string; @@ -1414,6 +1414,25 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(mockState.lastDispatchCtx?.CommandBody).toBe("/scopecheck"); }); + it("normalizes missing gateway caller scopes to an empty array before dispatch", async () => { + createTranscriptFixture("openclaw-chat-send-missing-gateway-client-scopes-"); + mockState.finalText = "ok"; + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-gateway-client-scopes-missing", + message: "/scopecheck", + client: createScopedCliClient(), + expectBroadcast: false, + }); + + expect(mockState.lastDispatchCtx?.GatewayClientScopes).toEqual([]); + expect(mockState.lastDispatchCtx?.CommandBody).toBe("/scopecheck"); + }); + it("injects ACP system provenance into the agent-visible body", async () => { createTranscriptFixture("openclaw-chat-send-system-provenance-acp-"); mockState.finalText = "ok"; diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 0cfedd78cf1..9d6ac799a04 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -1671,7 +1671,7 @@ export const chatHandlers: GatewayRequestHandlers = { SenderId: clientInfo?.id, SenderName: clientInfo?.displayName, SenderUsername: clientInfo?.displayName, - GatewayClientScopes: client?.connect?.scopes, + GatewayClientScopes: client?.connect?.scopes ?? [], }; const agentId = resolveSessionAgentId({