diff --git a/CHANGELOG.md b/CHANGELOG.md index f0db7729ed2..fb6fb05a77d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -144,6 +144,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- fix(active-memory): require admin scope for global toggles [AI]. (#78863) Thanks @pgondhi987. - Honor owner enforcement for native commands [AI]. (#78864) Thanks @pgondhi987. - 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. diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index 1736b403ff9..64fb3cd1e9c 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -440,6 +440,108 @@ describe("active-memory plugin", () => { expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); }); + it("blocks gateway callers without admin scope from changing global active-memory config", async () => { + const command = registeredCommands["active-memory"]; + + for (const { args, gatewayClientScopes } of [ + { args: "off --global", gatewayClientScopes: ["operator.write"] }, + { args: "on --global", gatewayClientScopes: ["operator.write"] }, + { args: "disable --global", gatewayClientScopes: ["operator.write"] }, + { args: "enable --global", gatewayClientScopes: ["operator.write"] }, + { args: "disabled --global", gatewayClientScopes: ["operator.write"] }, + { args: "enabled --global", gatewayClientScopes: ["operator.write"] }, + { args: "off --global", gatewayClientScopes: [] }, + ]) { + const result = await command.handler({ + channel: "gateway", + isAuthorizedSender: true, + gatewayClientScopes, + args, + commandBody: `/active-memory ${args}`, + config: {}, + requestConversationBinding: async () => ({ status: "error", message: "unsupported" }), + detachConversationBinding: async () => ({ removed: false }), + getCurrentConversationBinding: async () => null, + }); + + expect(result.text).toContain("global enable/disable changes require operator.admin"); + } + + expect(api.runtime.config.replaceConfigFile).not.toHaveBeenCalled(); + }); + + it("allows admin-scoped gateway callers to change global active-memory config", async () => { + const command = registeredCommands["active-memory"]; + + const result = await command.handler({ + channel: "gateway", + isAuthorizedSender: true, + gatewayClientScopes: ["operator.admin"], + args: "off --global", + commandBody: "/active-memory off --global", + config: {}, + requestConversationBinding: async () => ({ status: "error", message: "unsupported" }), + detachConversationBinding: async () => ({ removed: false }), + getCurrentConversationBinding: async () => null, + }); + + expect(result.text).toBe("Active Memory: off globally."); + expect(api.runtime.config.replaceConfigFile).toHaveBeenCalledTimes(1); + expect(configFile).toMatchObject({ + plugins: { + entries: { + "active-memory": { + enabled: true, + config: { + enabled: false, + agents: ["main"], + }, + }, + }, + }, + }); + }); + + it("keeps write-scoped gateway callers on non-global-write active-memory paths", async () => { + const command = registeredCommands["active-memory"]; + const sessionKey = "agent:main:write-scoped-active-memory"; + hoisted.sessionStore[sessionKey] = { + sessionId: "s-write-scoped-active-memory", + updatedAt: 0, + }; + + const globalStatusResult = await command.handler({ + channel: "gateway", + isAuthorizedSender: true, + gatewayClientScopes: ["operator.write"], + args: "status --global", + commandBody: "/active-memory status --global", + config: {}, + requestConversationBinding: async () => ({ status: "error", message: "unsupported" }), + detachConversationBinding: async () => ({ removed: false }), + getCurrentConversationBinding: async () => null, + }); + + expect(globalStatusResult.text).toBe("Active Memory: on globally."); + expect(api.runtime.config.replaceConfigFile).not.toHaveBeenCalled(); + + const sessionOffResult = await command.handler({ + channel: "gateway", + isAuthorizedSender: true, + gatewayClientScopes: ["operator.write"], + sessionKey, + args: "off", + commandBody: "/active-memory off", + config: {}, + requestConversationBinding: async () => ({ status: "error", message: "unsupported" }), + detachConversationBinding: async () => ({ removed: false }), + getCurrentConversationBinding: async () => null, + }); + + expect(sessionOffResult.text).toBe("Active Memory: off for this session."); + expect(api.runtime.config.replaceConfigFile).not.toHaveBeenCalled(); + }); + it("uses live runtime config for before_prompt_build enablement", async () => { configFile = { plugins: { diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index 53292a29114..eb1158f9aaf 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -782,6 +782,13 @@ function updateActiveMemoryGlobalEnabledInConfig( }; } +function requiresAdminToMutateActiveMemoryGlobal(gatewayClientScopes?: readonly string[]): boolean { + return Array.isArray(gatewayClientScopes) && !gatewayClientScopes.includes("operator.admin"); +} + +const ACTIVE_MEMORY_GLOBAL_MUTATION_ADMIN_REQUIRED_TEXT = + "⚠️ /active-memory global enable/disable changes require operator.admin for gateway clients."; + function normalizePluginConfig(pluginConfig: unknown): ResolvedActiveRecallPluginConfig { const raw = ( pluginConfig && typeof pluginConfig === "object" ? pluginConfig : {} @@ -2819,6 +2826,11 @@ export default definePluginEntry({ text: `Active Memory: ${isActiveMemoryGloballyEnabled(currentConfig) ? "on" : "off"} globally.`, }; } + if (requiresAdminToMutateActiveMemoryGlobal(ctx.gatewayClientScopes)) { + return { + text: ACTIVE_MEMORY_GLOBAL_MUTATION_ADMIN_REQUIRED_TEXT, + }; + } if (action === "on" || action === "enable" || action === "enabled") { const nextConfig = updateActiveMemoryGlobalEnabledInConfig(currentConfig, true); await api.runtime.config.replaceConfigFile({