Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
811 changes: 811 additions & 0 deletions docs/plans/2026-04-21-v0.10.0-openclaw-compat-refresh.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@drakon-systems/agent-optimizer",
"version": "0.9.2",
"version": "0.10.0",
"description": "Audit, optimize, and secure OpenClaw AI agent deployments. Token waste detection, security scanning, config drift, fleet SSH audit. Free to install.",
"type": "module",
"bin": {
Expand Down
19 changes: 19 additions & 0 deletions src/auditors/bootstrap-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,25 @@ export function auditBootstrapFiles(config: OpenClawConfig): AuditResult[] {
const perFileMax = (defaults as Record<string, unknown>)?.bootstrapMaxChars as number ?? DEFAULT_PER_FILE_MAX;
const totalMax = (defaults as Record<string, unknown>)?.bootstrapTotalMaxChars as number ?? DEFAULT_TOTAL_MAX;

// MEMORY.md split-brain (v2026.4.23): both MEMORY.md and memory.md present in workspace root.
// OpenClaw 2026.4.23 canonicalizes on MEMORY.md; `openclaw doctor --fix` merges the pair.
// Use directory listing (case-sensitive) so we don't false-positive on case-insensitive FS
// where existsSync("memory.md") returns true when only MEMORY.md is on disk.
try {
const rootEntries = readdirSync(wsPath);
const hasUpper = rootEntries.includes("MEMORY.md");
const hasLower = rootEntries.includes("memory.md");
if (hasUpper && hasLower) {
results.push({
category: "Bootstrap Files",
check: "MEMORY.md split-brain",
status: "warn",
message: "Both MEMORY.md and memory.md exist in workspace root — OpenClaw 2026.4.23 canonicalizes on MEMORY.md and will no longer treat memory.md as a runtime fallback.",
fix: "Run `openclaw doctor --fix` to merge memory.md into MEMORY.md (creates a backup automatically).",
});
}
} catch { /* unreadable workspace — other checks will catch it */ }

let totalChars = 0;
let filesFound = 0;
let filesOverBudget = 0;
Expand Down
81 changes: 81 additions & 0 deletions src/auditors/config-patch-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { AuditResult, OpenClawConfig } from "../types.js";

const CONFIG_MUTATION_PATTERNS = ["config.patch", "config.apply"];

function findMutationReference(value: unknown): string | null {
if (typeof value === "string") {
for (const pattern of CONFIG_MUTATION_PATTERNS) {
if (value.includes(pattern)) return pattern;
}
return null;
}
if (value && typeof value === "object") {
for (const v of Object.values(value as Record<string, unknown>)) {
const hit = findMutationReference(v);
if (hit) return hit;
}
}
return null;
}

const ALLOWLIST_FIX =
"OpenClaw v2026.4.23+ only accepts agent-driven config.patch/apply on allowlisted paths (prompt, model, mention-gating). Remove the reference or migrate the mutation to a build-time config change.";

export function auditConfigPatchUsage(config: OpenClawConfig): AuditResult[] {
const results: AuditResult[] = [];

// Legacy handlers[] (hooks.internal.handlers)
const handlers = config.hooks?.internal?.handlers ?? [];
for (const handler of handlers) {
const module = handler?.module;
const hit = findMutationReference(module);
if (hit) {
results.push({
category: "Config Patch Usage",
check: `Legacy handler references ${hit}`,
status: "warn",
message: `Hook handler module "${module}" references ${hit} — will fail closed on non-allowlisted paths in v2026.4.23+.`,
fix: ALLOWLIST_FIX,
});
}
}

// Keyed entries (hooks.internal.entries)
const entries = config.hooks?.internal?.entries ?? {};
for (const [name, entry] of Object.entries(entries)) {
const hit = findMutationReference(entry);
if (hit) {
results.push({
category: "Config Patch Usage",
check: `Hook entry "${name}" references ${hit}`,
status: "warn",
message: `Hook "${name}" contains a reference to ${hit} — will silently fail on non-allowlisted paths in v2026.4.23+.`,
fix: ALLOWLIST_FIX,
});
}
}

// Agent tool allowlists
const agents = config.agents?.list ?? [];
for (const agent of agents) {
const allowList = agent.tools?.alsoAllow ?? [];
const hits = new Set<string>();
for (const tool of allowList) {
for (const pattern of CONFIG_MUTATION_PATTERNS) {
if (tool.includes(pattern)) hits.add(pattern);
}
}
if (hits.size > 0) {
const hitList = Array.from(hits).join(", ");
results.push({
category: "Config Patch Usage",
check: `Agent "${agent.id}" tool allowlist exposes ${hitList}`,
status: "warn",
message: `Agent "${agent.id}" explicitly allows ${hitList} in tools.alsoAllow — these calls will silently fail on non-allowlisted config paths in v2026.4.23+.`,
fix: ALLOWLIST_FIX,
});
}
}

return results;
}
62 changes: 62 additions & 0 deletions src/auditors/dreaming-cron.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { existsSync, readFileSync } from "fs";
import { expandPath } from "../utils/config.js";
import type { AuditResult, OpenClawConfig } from "../types.js";

const DREAMING_PATTERN = /dreaming/i;

function isMainSession(session: unknown): boolean {
return typeof session === "string" && /(^|:)main:main($|:)/.test(session);
}

function jobMentionsDreaming(job: unknown): boolean {
if (!job || typeof job !== "object") return false;
const j = job as Record<string, unknown>;
return (
DREAMING_PATTERN.test(String(j.label ?? "")) ||
DREAMING_PATTERN.test(String(j.module ?? "")) ||
DREAMING_PATTERN.test(String(j.command ?? ""))
);
}

export function auditDreamingCron(_config: OpenClawConfig): AuditResult[] {
const results: AuditResult[] = [];
const cronPath = expandPath("~/.openclaw/cron/jobs.json");
if (!existsSync(cronPath)) return results;

let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(cronPath, "utf-8"));
} catch {
results.push({
category: "Dreaming Cron",
check: "jobs.json parse",
status: "info",
message: `Could not parse ${cronPath} as JSON — skipping dreaming-cron migration check.`,
});
return results;
}

const jobs = Array.isArray(parsed)
? parsed
: Array.isArray((parsed as Record<string, unknown> | null)?.jobs)
? ((parsed as Record<string, unknown>).jobs as unknown[])
: [];

for (const job of jobs) {
if (!jobMentionsDreaming(job)) continue;
const j = job as Record<string, unknown>;
if (isMainSession(j.session)) {
const id = (j.id as string) ?? (j.label as string) ?? "(unnamed)";
results.push({
category: "Dreaming Cron",
check: `Stale dreaming job "${id}"`,
status: "warn",
message:
"Dreaming cron job is tied to the main agent session — OpenClaw v2026.4.23 runs dreaming as an isolated lightweight agent turn decoupled from heartbeat. Unmigrated jobs still run old-shape.",
fix: "Run `openclaw doctor --fix` to migrate the job to the new shape.",
});
}
}

return results;
}
32 changes: 32 additions & 0 deletions src/auditors/hooks-deprecations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { AuditResult, OpenClawConfig } from "../types.js";

export function auditHooksDeprecations(config: OpenClawConfig): AuditResult[] {
const results: AuditResult[] = [];
const internal = config.hooks?.internal;
if (!internal) return results;

if (Array.isArray(internal.handlers) && internal.handlers.length > 0) {
results.push({
category: "Hooks",
check: "Legacy handlers[] format",
status: "warn",
message: "hooks.internal.handlers[] is deprecated — replaced by directory-based discovery with entries.<name>",
fix: "Migrate handlers to ~/.openclaw/hooks/<name>/ directories and configure via hooks.internal.entries.<name>",
});
}

const entries = internal.entries ?? {};
for (const [name, entry] of Object.entries(entries)) {
if (entry?.event === "before_agent_start") {
results.push({
category: "Hooks",
check: `Deprecated event: ${name}`,
status: "warn",
message: `Hook "${name}" uses before_agent_start — deprecated in favour of before_model_resolve / before_prompt_build`,
fix: "Split the hook into before_model_resolve (for model selection) and before_prompt_build (for prompt changes)",
});
}
}

return results;
}
6 changes: 6 additions & 0 deletions src/auditors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import { auditChannelSecurity } from "./channel-security.js";
import { auditProviderFailover } from "./provider-failover.js";
import { auditMemorySearch } from "./memory-search.js";
import { auditLocalModels } from "./local-models.js";
import { auditHooksDeprecations } from "./hooks-deprecations.js";
import { auditSecurityAdvisories } from "./security-advisories.js";
import { auditConfigPatchUsage } from "./config-patch-usage.js";
import { auditDreamingCron } from "./dreaming-cron.js";

interface AuditorModule {
name: string;
Expand Down Expand Up @@ -61,6 +64,9 @@ export async function runFullAudit(opts: AuditOptions & { silent?: boolean }): P
{ name: "Channel Security", run: () => auditChannelSecurity(config) },
{ name: "Memory Search", run: () => auditMemorySearch(config) },
{ name: "Local Models", run: () => auditLocalModels(config) },
{ name: "Hooks Deprecations", run: () => auditHooksDeprecations(config) },
{ name: "Config Patch Usage", run: () => auditConfigPatchUsage(config) },
{ name: "Dreaming Cron", run: () => auditDreamingCron(config) },
{ name: "Security Advisories", run: () => auditSecurityAdvisories(openclawVersion) },
];

Expand Down
31 changes: 31 additions & 0 deletions src/auditors/memory-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,37 @@ export function auditMemorySearch(config: OpenClawConfig): AuditResult[] {
}
}

// --- Local embedding context size (v2026.4.23 added memorySearch.local.contextSize) ---

const local = memorySearch.local as Record<string, unknown> | undefined;
const contextSize = local?.contextSize as number | undefined;
if (typeof contextSize === "number") {
if (contextSize < 1024) {
results.push({
category: "Memory Search",
check: "Local embedding context size",
status: "warn",
message: `memorySearch.local.contextSize is ${contextSize} — below 1024 truncates most chunks and hurts recall quality. Default is 4096.`,
fix: "Set agents.defaults.memorySearch.local.contextSize to 4096 (or 2048 on severely constrained hosts).",
});
} else if (contextSize > 32768) {
results.push({
category: "Memory Search",
check: "Local embedding context size",
status: "warn",
message: `memorySearch.local.contextSize is ${contextSize} — above 32768 bloats embedding-host memory for no recall benefit on typical chunks. Default is 4096.`,
fix: "Lower agents.defaults.memorySearch.local.contextSize to 4096-16384.",
});
} else {
results.push({
category: "Memory Search",
check: "Local embedding context size",
status: "pass",
message: `Local embedding contextSize: ${contextSize} tokens (default 4096).`,
});
}
}

// --- Fallback provider ---

const fallback = memorySearch.fallback as string | undefined;
Expand Down
25 changes: 25 additions & 0 deletions src/auditors/plugins.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { existsSync, readdirSync } from "fs";
import { homedir } from "os";
import { resolve } from "path";
import type { AuditResult, OpenClawConfig } from "../types.js";

// Bundled plugins that don't require an install entry
const BUNDLED_PLUGINS = [
// Core / messaging
"memory-wiki", "memory-core", "browser", "telegram", "whatsapp",
"discord", "matrix", "imessage", "voice", "dreaming", "active-memory",
// Added in v0.10.0 — newly bundled in OpenClaw v2026.3.14+
"firecrawl", "openrouter", "github-copilot", "openai-codex",
];

export function auditPlugins(config: OpenClawConfig): AuditResult[] {
Expand Down Expand Up @@ -60,5 +66,24 @@ export function auditPlugins(config: OpenClawConfig): AuditResult[] {
}
}

const legacyPath = resolve(homedir(), ".openclaw", "plugins");
const currentPath = resolve(homedir(), ".openclaw", "extensions");
if (existsSync(legacyPath)) {
try {
const entries = readdirSync(legacyPath);
if (entries.length > 0) {
results.push({
category: "Plugins",
check: "Legacy plugin directory",
status: "warn",
message: `Found ${entries.length} item(s) in ~/.openclaw/plugins/ — OpenClaw now uses ~/.openclaw/extensions/`,
fix: `Move contents from ${legacyPath} to ${currentPath} and remove the legacy directory`,
});
}
} catch {
// unreadable — ignore
}
}

return results;
}
8 changes: 8 additions & 0 deletions src/auditors/security-advisories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ const ADVISORIES: SecurityAdvisory[] = [
message: "Feishu webhook transport starts without encryptKey — accepts unauthenticated webhook payloads",
fix: "Upgrade to OpenClaw v2026.4.15+",
},
// v2026.4.23 fixes
{
fixedIn: "2026.4.23",
severity: "warn",
check: "config.patch allowlist lockdown",
message: "Gateway config.patch/config.apply runtime edits rely on a hand-maintained denylist — agents can mutate sensitive keys the denylist missed. Fixed in 2026.4.23 by allowlisting a narrow set of agent-tunable paths (prompt, model, mention-gating) and failing closed on everything else.",
fix: "Upgrade to OpenClaw v2026.4.23+. After upgrade, audit agent cron/hooks for config.patch usage — non-allowlisted mutations now silently fail.",
},
// v2026.4.12 fixes
{
fixedIn: "2026.4.12",
Expand Down
12 changes: 12 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ export interface OpenClawConfig {
entries?: Record<string, { enabled?: boolean; config?: Record<string, unknown> }>;
installs?: Record<string, PluginInstall>;
};
hooks?: {
internal?: {
enabled?: boolean;
handlers?: Array<{ event?: string; module?: string }>; // legacy
entries?: Record<string, {
enabled?: boolean;
event?: string;
env?: Record<string, string>;
}>;
load?: { extraDirs?: string[] };
};
};
gateway?: Record<string, unknown>;
channels?: Record<string, unknown>;
[key: string]: unknown;
Expand Down
Loading