Dreaming: require admin for gateway persistence

This commit is contained in:
Mariano Belinky
2026-04-09 20:02:55 +02:00
parent b83726d13e
commit 133ac35552
4 changed files with 75 additions and 3 deletions

View File

@@ -52,13 +52,17 @@ function createHarness(initialConfig: OpenClawConfig = {}) {
};
}
function createCommandContext(args?: string): PluginCommandContext {
function createCommandContext(
args?: string,
overrides?: Partial<Pick<PluginCommandContext, "gatewayClientScopes">>,
): 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: {

View File

@@ -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);

View File

@@ -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";

View File

@@ -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({