[codex] fix(ui): guard dreaming wiki plugin calls (#66140)

Merged via squash.

Prepared head SHA: 030562b044
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-04-13 22:01:11 +02:00
committed by GitHub
parent ac00ba1943
commit 305a80ce32
6 changed files with 253 additions and 36 deletions

View File

@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
- Outbound/delivery-queue: persist the originating outbound `session` context on queued delivery entries and replay it during recovery, so write-ahead-queued sends keep their original outbound media policy context after restart instead of evaluating against a missing session. (#66025) Thanks @eleqtrizit.
- Auto-reply/queue: split collect-mode followup drains into contiguous groups by per-message authorization context (sender id, owner status, exec/bash-elevated overrides), so queued items from different senders or exec configs no longer execute under the last queued run's owner-only and exec-approval context. (#66024) Thanks @eleqtrizit.
- Dreaming/memory-core: require a live queued Dreaming cron event before the heartbeat hook runs the sweep, so managed Dreaming no longer replays on later heartbeats after the scheduled run was already consumed. (#66139) Thanks @mbelinky.
- Control UI/Dreaming: stop Imported Insights and Memory Palace from calling optional `memory-wiki` gateway methods when the plugin is off, and refresh config before wiki reloads so the Dreaming tab stops showing misleading unknown-method failures. (#66140) Thanks @mbelinky.
## 2026.4.12

View File

@@ -114,10 +114,11 @@ import {
updateSkillEdit,
updateSkillEnabled,
} from "./controllers/skills.ts";
import "./components/dashboard-header.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts";
import "./components/dashboard-header.ts";
import { icons } from "./icons.ts";
import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts";
import { isPluginEnabledInConfigSnapshot } from "./plugin-activation.ts";
import { agentLogoUrl } from "./views/agents-utils.ts";
import {
resolveAgentConfig,
@@ -236,32 +237,6 @@ function uniquePreserveOrder(values: string[]): string[] {
return output;
}
function isPluginExplicitlyEnabled(
configSnapshot: AppViewState["configSnapshot"],
pluginId: string,
): boolean {
const config = configSnapshot?.config;
if (!config || typeof config !== "object" || Array.isArray(config)) {
return true;
}
const plugins =
"plugins" in config && config.plugins && typeof config.plugins === "object"
? (config.plugins as Record<string, unknown>)
: null;
if (plugins?.enabled === false) {
return false;
}
const entries =
plugins && "entries" in plugins && plugins.entries && typeof plugins.entries === "object"
? (plugins.entries as Record<string, unknown>)
: null;
const entry = entries?.[pluginId];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return true;
}
return (entry as { enabled?: unknown }).enabled !== false;
}
type DismissedUpdateBanner = {
latestVersion: string;
channel: string | null;
@@ -457,12 +432,15 @@ export function renderApp(state: AppViewState) {
const dreamingLoading = state.dreamingStatusLoading || state.dreamingModeSaving;
const dreamingRefreshLoading = state.dreamingStatusLoading || state.dreamDiaryLoading;
const refreshDreaming = () => {
void Promise.all([
loadDreamingStatus(state),
loadDreamDiary(state),
loadWikiImportInsights(state),
loadWikiMemoryPalace(state),
]);
void (async () => {
await loadConfig(state);
await Promise.all([
loadDreamingStatus(state),
loadDreamDiary(state),
loadWikiImportInsights(state),
loadWikiMemoryPalace(state),
]);
})();
};
const openWikiPage = async (lookup: string) => {
if (!state.client || !state.connected) {
@@ -2019,7 +1997,11 @@ export function renderApp(state: AppViewState) {
dreamDiaryError: state.dreamDiaryError,
dreamDiaryPath: state.dreamDiaryPath,
dreamDiaryContent: state.dreamDiaryContent,
memoryWikiEnabled: isPluginExplicitlyEnabled(state.configSnapshot, "memory-wiki"),
memoryWikiEnabled: isPluginEnabledInConfigSnapshot(
state.configSnapshot,
"memory-wiki",
{ enabledByDefault: false },
),
wikiImportInsightsLoading: state.wikiImportInsightsLoading,
wikiImportInsightsError: state.wikiImportInsightsError,
wikiImportInsights: state.wikiImportInsights,
@@ -2028,8 +2010,18 @@ export function renderApp(state: AppViewState) {
wikiMemoryPalace: state.wikiMemoryPalace,
onRefresh: refreshDreaming,
onRefreshDiary: () => loadDreamDiary(state),
onRefreshImports: () => loadWikiImportInsights(state),
onRefreshMemoryPalace: () => loadWikiMemoryPalace(state),
onRefreshImports: () => {
void (async () => {
await loadConfig(state);
await loadWikiImportInsights(state);
})();
},
onRefreshMemoryPalace: () => {
void (async () => {
await loadConfig(state);
await loadWikiMemoryPalace(state);
})();
},
onOpenConfig: () => openConfigFile(state),
onOpenWikiPage: (lookup: string) => openWikiPage(lookup),
onBackfillDiary: () => backfillDreamDiary(state),

View File

@@ -227,6 +227,18 @@ describe("dreaming controller", () => {
it("loads and normalizes wiki import insights", async () => {
const { state, request } = createState();
state.configSnapshot = {
hash: "hash-1",
config: {
plugins: {
entries: {
"memory-wiki": {
enabled: true,
},
},
},
},
};
request.mockResolvedValue({
sourceType: "chatgpt",
totalItems: 2,
@@ -285,8 +297,44 @@ describe("dreaming controller", () => {
expect(state.wikiImportInsightsLoading).toBe(false);
});
it("skips wiki import insights when memory-wiki is not enabled", async () => {
const { state, request } = createState();
state.configSnapshot = {
hash: "hash-1",
config: {
plugins: {},
},
};
state.wikiImportInsights = {
sourceType: "chatgpt",
totalItems: 1,
totalClusters: 1,
clusters: [],
};
state.wikiImportInsightsError = "unknown method: wiki.importInsights";
await loadWikiImportInsights(state);
expect(request).not.toHaveBeenCalled();
expect(state.wikiImportInsights).toBeNull();
expect(state.wikiImportInsightsError).toBeNull();
expect(state.wikiImportInsightsLoading).toBe(false);
});
it("loads and normalizes the wiki memory palace", async () => {
const { state, request } = createState();
state.configSnapshot = {
hash: "hash-1",
config: {
plugins: {
entries: {
"memory-wiki": {
enabled: true,
},
},
},
},
};
request.mockResolvedValue({
totalItems: 2,
totalClaims: 3,
@@ -343,6 +391,31 @@ describe("dreaming controller", () => {
expect(state.wikiMemoryPalaceLoading).toBe(false);
});
it("skips wiki memory palace when memory-wiki is not enabled", async () => {
const { state, request } = createState();
state.configSnapshot = {
hash: "hash-1",
config: {
plugins: {},
},
};
state.wikiMemoryPalace = {
totalItems: 1,
totalClaims: 1,
totalQuestions: 0,
totalContradictions: 0,
clusters: [],
};
state.wikiMemoryPalaceError = "unknown method: wiki.palace";
await loadWikiMemoryPalace(state);
expect(request).not.toHaveBeenCalled();
expect(state.wikiMemoryPalace).toBeNull();
expect(state.wikiMemoryPalaceError).toBeNull();
expect(state.wikiMemoryPalaceLoading).toBe(false);
});
it("patches config to update global dreaming enablement", async () => {
const { state, request } = createState();
state.configSnapshot = {

View File

@@ -1,9 +1,11 @@
import type { GatewayBrowserClient } from "../gateway.ts";
import { isPluginEnabledInConfigSnapshot } from "../plugin-activation.ts";
import type { ConfigSnapshot } from "../types.ts";
export type DreamingPhaseId = "light" | "deep" | "rem";
const DEFAULT_DREAM_DIARY_PATH = "DREAMS.md";
const DEFAULT_DREAMING_PLUGIN_ID = "memory-core";
const MEMORY_WIKI_PLUGIN_ID = "memory-wiki";
type DreamingPhaseStatusBase = {
enabled: boolean;
@@ -228,6 +230,12 @@ function confirmDreamingAction(message: string): boolean {
return globalThis.confirm(message);
}
function isMemoryWikiEnabled(state: DreamingState): boolean {
return isPluginEnabledInConfigSnapshot(state.configSnapshot, MEMORY_WIKI_PLUGIN_ID, {
enabledByDefault: false,
});
}
function buildDreamDiaryActionSuccessMessage(
method:
| "doctor.memory.backfillDreamDiary"
@@ -740,6 +748,11 @@ export async function loadWikiImportInsights(state: DreamingState): Promise<void
if (!state.client || !state.connected || state.wikiImportInsightsLoading) {
return;
}
if (!isMemoryWikiEnabled(state)) {
state.wikiImportInsights = null;
state.wikiImportInsightsError = null;
return;
}
state.wikiImportInsightsLoading = true;
state.wikiImportInsightsError = null;
try {
@@ -759,6 +772,11 @@ export async function loadWikiMemoryPalace(state: DreamingState): Promise<void>
if (!state.client || !state.connected || state.wikiMemoryPalaceLoading) {
return;
}
if (!isMemoryWikiEnabled(state)) {
state.wikiMemoryPalace = null;
state.wikiMemoryPalaceError = null;
return;
}
state.wikiMemoryPalaceLoading = true;
state.wikiMemoryPalaceError = null;
try {

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { isPluginEnabledInConfigSnapshot } from "./plugin-activation.ts";
describe("isPluginEnabledInConfigSnapshot", () => {
it("stays permissive when config has not loaded yet", () => {
expect(
isPluginEnabledInConfigSnapshot({ hash: "hash-1" }, "memory-wiki", {
enabledByDefault: false,
}),
).toBe(true);
});
it("treats bundled default-off plugins as disabled when config is present but silent", () => {
expect(
isPluginEnabledInConfigSnapshot(
{
hash: "hash-1",
config: {
plugins: {},
},
},
"memory-wiki",
{
enabledByDefault: false,
},
),
).toBe(false);
});
it("returns true when the plugin is explicitly enabled", () => {
expect(
isPluginEnabledInConfigSnapshot(
{
hash: "hash-1",
config: {
plugins: {
entries: {
"memory-wiki": {
enabled: true,
},
},
},
},
},
"memory-wiki",
{ enabledByDefault: false },
),
).toBe(true);
});
it("returns false when plugins.allow excludes the plugin", () => {
expect(
isPluginEnabledInConfigSnapshot(
{
hash: "hash-1",
config: {
plugins: {
allow: ["memory-core"],
entries: {
"memory-wiki": {
enabled: true,
},
},
},
},
},
"memory-wiki",
{ enabledByDefault: false },
),
).toBe(false);
});
it("keeps default-on plugins enabled when config is silent", () => {
expect(
isPluginEnabledInConfigSnapshot({ hash: "hash-1" }, "browser", {
enabledByDefault: true,
}),
).toBe(true);
});
});

View File

@@ -0,0 +1,53 @@
import type { ConfigSnapshot } from "./types.ts";
type PluginActivationOptions = {
enabledByDefault?: boolean;
};
export function isPluginEnabledInConfigSnapshot(
configSnapshot: ConfigSnapshot | null | undefined,
pluginId: string,
options?: PluginActivationOptions,
): boolean {
const enabledByDefault = options?.enabledByDefault ?? true;
const config = configSnapshot?.config;
if (!config || typeof config !== "object" || Array.isArray(config)) {
return true;
}
const plugins =
"plugins" in config && config.plugins && typeof config.plugins === "object"
? (config.plugins as Record<string, unknown>)
: null;
if (plugins?.enabled === false) {
return false;
}
const deny =
Array.isArray(plugins?.deny) && plugins.deny.every((entry) => typeof entry === "string")
? plugins.deny
: [];
if (deny.includes(pluginId)) {
return false;
}
const allow =
Array.isArray(plugins?.allow) && plugins.allow.every((entry) => typeof entry === "string")
? plugins.allow
: [];
if (allow.length > 0 && !allow.includes(pluginId)) {
return false;
}
const entries =
plugins && "entries" in plugins && plugins.entries && typeof plugins.entries === "object"
? (plugins.entries as Record<string, unknown>)
: null;
const entry = entries?.[pluginId];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return enabledByDefault;
}
const enabled = (entry as { enabled?: unknown }).enabled;
return typeof enabled === "boolean" ? enabled : enabledByDefault;
}