fix(active-memory): require admin scope for global toggles [AI] (#78863)

* fix: gate active-memory global writes by admin scope

* addressing claude review

* docs: add changelog entry for PR merge
This commit is contained in:
Pavan Kumar Gondhi
2026-05-07 15:35:30 +05:30
committed by GitHub
parent f4b2a08c85
commit 5852f5d15c
3 changed files with 115 additions and 0 deletions

View File

@@ -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.

View File

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

View File

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