diff --git a/docs/plans/2026-04-21-v0.10.0-openclaw-compat-refresh.md b/docs/plans/2026-04-21-v0.10.0-openclaw-compat-refresh.md new file mode 100644 index 0000000..cf8ffd6 --- /dev/null +++ b/docs/plans/2026-04-21-v0.10.0-openclaw-compat-refresh.md @@ -0,0 +1,811 @@ +# v0.10.0 — OpenClaw Compatibility Refresh Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Bring Agent Optimizer's audit coverage in sync with current OpenClaw (v2026.3.14 source + v2026.4.15 released) — catch deprecations that will silently break on upgrade and add coverage for new surface (hook events, bundle formats, sandbox backends, exec approvals). + +**Architecture:** Additive. Four new auditors in `src/auditors/`, two updated allowlists, one updated security-advisories list. Each auditor follows the existing `(config, agentDir?) => AuditResult[]` shape and is wired into `src/auditors/index.ts`. Each ships with a vitest spec mirroring the existing `tests/.test.ts` pattern. No breaking changes to `AuditResult`, `OpenClawConfig`, or CLI flags. + +**Tech Stack:** TypeScript, vitest, commander, Node ≥20. No new runtime dependencies. + +**Context from 2026-04-21 gap analysis:** OpenClaw has moved plugins dir to `~/.openclaw/extensions/`, deprecated `hooks.internal.handlers[]` legacy format in favour of directory-based `entries.`, deprecated the `before_agent_start` hook, added new hook events (`message:transcribed`, `message:preprocessed`, `command:new/reset/stop`, `session:compact:before/after`, `agent:bootstrap`, `gateway:startup`), added Firecrawl + OpenRouter/Copilot/Codex as bundled plugins, added pluggable sandbox backends (OpenShell + SSH), and added `~/.openclaw/exec-approvals.json`. Evidence path: memory entry "Agent Optimizer v0.9.2 gaps vs OpenClaw current docs (2026-04-21 audit)". + +--- + +## Phase 0 — Setup (5 min) + +### Task 0: Create feature branch + +**Step 1: Branch off main** + +```bash +cd "/Users/michael/Development/Agent Optimizer" +git checkout main && git pull +git checkout -b feat/v0.10.0-compat-refresh +``` + +**Step 2: Confirm clean state** + +Run: `git status && npm test -- --run` +Expected: `working tree clean`, all 130 tests pass. + +--- + +## Phase 1 — Deprecation Warnings (HIGH priority) + +Users upgrading OpenClaw will hit these silently. Catching them is the headline value of v0.10.0. + +### Task 1: New auditor — `hooks-deprecations.ts` + +Warn on legacy `hooks.internal.handlers[]` array format and on `before_agent_start` hook usage. + +**Files:** +- Create: `src/auditors/hooks-deprecations.ts` +- Create: `tests/hooks-deprecations.test.ts` +- Modify: `src/types.ts` (extend `OpenClawConfig` with typed `hooks` shape) +- Modify: `src/auditors/index.ts` (wire in new auditor) + +**Step 1: Extend `OpenClawConfig` in [src/types.ts](src/types.ts)** + +Add after the `plugins` block in `OpenClawConfig` (around line 32): + +```typescript +hooks?: { + internal?: { + enabled?: boolean; + handlers?: Array<{ event?: string; module?: string }>; // legacy + entries?: Record; + }>; + load?: { extraDirs?: string[] }; + }; +}; +``` + +**Step 2: Write the failing test** — `tests/hooks-deprecations.test.ts` + +```typescript +import { describe, it, expect } from "vitest"; +import { auditHooksDeprecations } from "../src/auditors/hooks-deprecations.js"; +import type { OpenClawConfig } from "../src/types.js"; + +describe("auditHooksDeprecations", () => { + it("returns empty when no hooks configured", () => { + expect(auditHooksDeprecations({})).toHaveLength(0); + }); + + it("warns on legacy handlers[] array format", () => { + const config: OpenClawConfig = { + hooks: { internal: { handlers: [{ event: "command:new", module: "./h.js" }] } }, + }; + const results = auditHooksDeprecations(config); + expect(results.some(r => r.status === "warn" && r.check.includes("legacy"))).toBe(true); + }); + + it("warns on before_agent_start hook usage", () => { + const config: OpenClawConfig = { + hooks: { internal: { entries: { starter: { event: "before_agent_start" } } } }, + }; + const results = auditHooksDeprecations(config); + expect(results.some(r => r.status === "warn" && r.message.includes("before_agent_start"))).toBe(true); + }); + + it("passes when using current entries format with valid events", () => { + const config: OpenClawConfig = { + hooks: { internal: { entries: { good: { event: "command:new" } } } }, + }; + const results = auditHooksDeprecations(config); + expect(results.every(r => r.status !== "fail")).toBe(true); + }); +}); +``` + +**Step 3: Run the test to verify it fails** + +Run: `npx vitest run tests/hooks-deprecations.test.ts` +Expected: FAIL — "Cannot find module '../src/auditors/hooks-deprecations.js'" + +**Step 4: Implement `src/auditors/hooks-deprecations.ts`** + +```typescript +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.", + fix: "Migrate handlers to ~/.openclaw/hooks// directories and configure via hooks.internal.entries.", + }); + } + + 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; +} +``` + +**Step 5: Wire into `src/auditors/index.ts`** + +Add import near line 18: +```typescript +import { auditHooksDeprecations } from "./hooks-deprecations.js"; +``` + +Add to the `auditors` array near line 64 (before Security Advisories): +```typescript +{ name: "Hooks Deprecations", run: () => auditHooksDeprecations(config) }, +``` + +**Step 6: Run tests** + +Run: `npx vitest run tests/hooks-deprecations.test.ts` +Expected: PASS (4/4) + +Run: `npm test -- --run` +Expected: all tests pass (134+ tests). + +**Step 7: Commit** + +```bash +git add src/auditors/hooks-deprecations.ts src/auditors/index.ts src/types.ts tests/hooks-deprecations.test.ts +git commit -m "feat(audit): flag deprecated hook formats (legacy handlers[], before_agent_start)" +``` + +--- + +### Task 2: Extend `plugins.ts` — detect legacy plugin directory path + +Warn when `~/.openclaw/plugins/` exists but `~/.openclaw/extensions/` is the current canonical path. + +**Files:** +- Modify: `src/auditors/plugins.ts:3-64` +- Modify: `tests/plugins.test.ts` + +**Step 1: Write the failing test** (append to `tests/plugins.test.ts`) + +```typescript +it("warns when legacy ~/.openclaw/plugins/ path is populated (new tests should mock fs)", () => { + // This test needs fs mocked. Use vi.mock('fs') to stub existsSync. + // (See full test in implementation step — split out as tests/plugins-path.test.ts for clarity.) +}); +``` + +Better: create `tests/plugins-path.test.ts` that mocks `fs`: + +```typescript +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { OpenClawConfig } from "../src/types.js"; + +vi.mock("fs", () => ({ + existsSync: vi.fn(), + readdirSync: vi.fn(), +})); + +import { existsSync, readdirSync } from "fs"; +import { auditPlugins } from "../src/auditors/plugins.js"; + +describe("auditPlugins — legacy path", () => { + beforeEach(() => vi.clearAllMocks()); + + it("warns when legacy ~/.openclaw/plugins/ has contents", () => { + vi.mocked(existsSync).mockImplementation((p) => + String(p).endsWith(".openclaw/plugins") + ); + vi.mocked(readdirSync).mockReturnValue(["old-plugin"] as never); + + const config: OpenClawConfig = { plugins: { allow: [] } }; + const results = auditPlugins(config); + expect(results.some(r => r.check.includes("Legacy plugin directory"))).toBe(true); + }); +}); +``` + +**Step 2: Run — verify fail** + +Run: `npx vitest run tests/plugins-path.test.ts` +Expected: FAIL + +**Step 3: Implement** + +At top of `src/auditors/plugins.ts`, add: + +```typescript +import { existsSync, readdirSync } from "fs"; +import { homedir } from "os"; +import { resolve } from "path"; +``` + +At the end of `auditPlugins`, before `return results`, add: + +```typescript + 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 + } + } +``` + +**Step 4: Run** + +Run: `npx vitest run tests/plugins-path.test.ts tests/plugins.test.ts` +Expected: all PASS. + +**Step 5: Commit** + +```bash +git add src/auditors/plugins.ts tests/plugins-path.test.ts +git commit -m "feat(audit): warn when ~/.openclaw/plugins/ is populated (moved to extensions/)" +``` + +--- + +### Task 3: Update bundled plugin allowlist + +Add newly-bundled plugins so they don't get flagged as "unknown third-party". + +**Files:** +- Modify: `src/auditors/plugins.ts:4-7` +- Modify: `tests/plugins.test.ts` (add coverage) + +**Step 1: Extend the `BUNDLED_PLUGINS` constant** + +In `src/auditors/plugins.ts`, replace lines 4-7 with: + +```typescript +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", +]; +``` + +**Step 2: Add coverage** + +Append to `tests/plugins.test.ts`: + +```typescript +it.each(["firecrawl", "openrouter", "github-copilot", "openai-codex"])( + "recognises newly-bundled %s as pass", + (name) => { + const config: OpenClawConfig = { plugins: { allow: [name], entries: {}, installs: {} } }; + const results = auditPlugins(config); + expect(results.some(r => r.status === "pass" && r.message.includes("bundled"))).toBe(true); + } +); +``` + +**Step 3: Run tests** + +Run: `npx vitest run tests/plugins.test.ts` +Expected: all PASS (including the 4 new parameterised cases). + +**Step 4: Commit** + +```bash +git add src/auditors/plugins.ts tests/plugins.test.ts +git commit -m "feat(audit): recognise firecrawl, openrouter, github-copilot, openai-codex as bundled" +``` + +--- + +## Phase 2 — New Surface Coverage (MEDIUM priority) + +### Task 4: New auditor — `hook-events.ts` (event name schema validator) + +Detect typos and unknown event names in `hooks.internal.entries..event`. + +**Files:** +- Create: `src/auditors/hook-events.ts` +- Create: `tests/hook-events.test.ts` +- Modify: `src/auditors/index.ts` + +**Step 1: Write the failing test** — `tests/hook-events.test.ts` + +```typescript +import { describe, it, expect } from "vitest"; +import { auditHookEvents } from "../src/auditors/hook-events.js"; +import type { OpenClawConfig } from "../src/types.js"; + +describe("auditHookEvents", () => { + it("passes for known events", () => { + const config: OpenClawConfig = { + hooks: { internal: { entries: { h: { event: "message:received" } } } }, + }; + expect(auditHookEvents(config).every(r => r.status !== "fail")).toBe(true); + }); + + it("flags unknown event names", () => { + const config: OpenClawConfig = { + hooks: { internal: { entries: { h: { event: "message:recieved" } } } }, // typo + }; + const results = auditHookEvents(config); + expect(results.some(r => r.status === "fail" && r.message.includes("Unknown hook event"))).toBe(true); + }); + + it("recognises all v2026.3.14 events", () => { + const events = [ + "command:new", "command:reset", "command:stop", + "session:compact:before", "session:compact:after", + "agent:bootstrap", "gateway:startup", + "message:received", "message:transcribed", "message:preprocessed", "message:sent", + ]; + for (const event of events) { + const config: OpenClawConfig = { + hooks: { internal: { entries: { h: { event } } } }, + }; + expect(auditHookEvents(config).every(r => r.status !== "fail")).toBe(true); + } + }); +}); +``` + +**Step 2: Run — verify fail** + +Run: `npx vitest run tests/hook-events.test.ts` +Expected: FAIL — module not found. + +**Step 3: Implement `src/auditors/hook-events.ts`** + +```typescript +import type { AuditResult, OpenClawConfig } from "../types.js"; + +// Known OpenClaw hook events as of v2026.3.14. Includes deprecated +// before_agent_start (flagged separately by hooks-deprecations.ts). +const KNOWN_EVENTS = new Set([ + "command:new", "command:reset", "command:stop", + "session:compact:before", "session:compact:after", + "agent:bootstrap", "gateway:startup", + "message:received", "message:transcribed", "message:preprocessed", "message:sent", + // Plugin-invocable hooks + "tool_result_persist", "before_compaction", "after_compaction", + "before_model_resolve", "before_prompt_build", + // Deprecated but still handled + "before_agent_start", +]); + +export function auditHookEvents(config: OpenClawConfig): AuditResult[] { + const results: AuditResult[] = []; + const entries = config.hooks?.internal?.entries ?? {}; + for (const [name, entry] of Object.entries(entries)) { + const event = entry?.event; + if (event && !KNOWN_EVENTS.has(event)) { + results.push({ + category: "Hooks", + check: `Unknown event: ${name}`, + status: "fail", + message: `Unknown hook event "${event}" in entry "${name}" — hook will never fire`, + fix: "Check docs/automation/hooks.md for the current event list, or fix the typo", + }); + } + } + return results; +} +``` + +**Step 4: Wire into index.ts** + +Add after the `auditHooksDeprecations` line: +```typescript +{ name: "Hook Events", run: () => auditHookEvents(config) }, +``` + +**Step 5: Run tests** + +Run: `npx vitest run tests/hook-events.test.ts && npm test -- --run` +Expected: all PASS. + +**Step 6: Commit** + +```bash +git add src/auditors/hook-events.ts src/auditors/index.ts tests/hook-events.test.ts +git commit -m "feat(audit): validate hook event names against known OpenClaw events" +``` + +--- + +### Task 5: New auditor — `sandbox-backends.ts` + +Audit `tools.sandbox` config: flag missing `known_hosts`, unreadable SSH keys, unknown backend modes. + +**Files:** +- Create: `src/auditors/sandbox-backends.ts` +- Create: `tests/sandbox-backends.test.ts` +- Modify: `src/types.ts` (add `tools` key to `OpenClawConfig`) +- Modify: `src/auditors/index.ts` + +**Step 1: Extend `OpenClawConfig` in [src/types.ts](src/types.ts)** + +Add to `OpenClawConfig`: + +```typescript +tools?: { + profile?: "minimal" | "coding" | "default"; + sandbox?: { + backend?: string; + mode?: string; + ssh?: { + host?: string; + keyPath?: string; + certPath?: string; + knownHostsPath?: string; + }; + }; + byProvider?: Record; +}; +``` + +**Step 2: Write failing test** — `tests/sandbox-backends.test.ts` + +```typescript +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { OpenClawConfig } from "../src/types.js"; + +vi.mock("fs", () => ({ existsSync: vi.fn() })); +import { existsSync } from "fs"; +import { auditSandboxBackends } from "../src/auditors/sandbox-backends.js"; + +describe("auditSandboxBackends", () => { + beforeEach(() => vi.clearAllMocks()); + + it("empty when no sandbox config", () => { + expect(auditSandboxBackends({})).toHaveLength(0); + }); + + it("flags missing SSH key path", () => { + vi.mocked(existsSync).mockReturnValue(false); + const config: OpenClawConfig = { + tools: { sandbox: { backend: "ssh", ssh: { host: "box", keyPath: "/missing/key" } } }, + }; + const results = auditSandboxBackends(config); + expect(results.some(r => r.status === "fail" && r.check.includes("SSH key"))).toBe(true); + }); + + it("warns when known_hosts is missing", () => { + vi.mocked(existsSync).mockImplementation((p) => !String(p).includes("known_hosts")); + const config: OpenClawConfig = { + tools: { sandbox: { backend: "ssh", ssh: { host: "box", keyPath: "/key", knownHostsPath: "/missing/known_hosts" } } }, + }; + const results = auditSandboxBackends(config); + expect(results.some(r => r.status === "warn" && r.check.includes("known_hosts"))).toBe(true); + }); + + it("passes when all SSH files exist", () => { + vi.mocked(existsSync).mockReturnValue(true); + const config: OpenClawConfig = { + tools: { sandbox: { backend: "ssh", ssh: { host: "box", keyPath: "/key", knownHostsPath: "/kh" } } }, + }; + expect(auditSandboxBackends(config).every(r => r.status !== "fail")).toBe(true); + }); +}); +``` + +**Step 3: Verify fail** → **Step 4: Implement** + +```typescript +import { existsSync } from "fs"; +import type { AuditResult, OpenClawConfig } from "../types.js"; +import { expandPath } from "../utils/config.js"; + +const KNOWN_BACKENDS = ["openshell", "ssh", "none", "off"]; + +export function auditSandboxBackends(config: OpenClawConfig): AuditResult[] { + const results: AuditResult[] = []; + const sandbox = config.tools?.sandbox; + if (!sandbox) return results; + + if (sandbox.backend && !KNOWN_BACKENDS.includes(sandbox.backend)) { + results.push({ + category: "Sandbox", + check: "Unknown sandbox backend", + status: "warn", + message: `tools.sandbox.backend="${sandbox.backend}" is not a recognised backend`, + fix: `Use one of: ${KNOWN_BACKENDS.join(", ")}`, + }); + } + + if (sandbox.backend === "ssh" && sandbox.ssh) { + const { keyPath, certPath, knownHostsPath } = sandbox.ssh; + + if (keyPath && !existsSync(expandPath(keyPath))) { + results.push({ + category: "Sandbox", + check: "SSH key missing", + status: "fail", + message: `SSH key path "${keyPath}" does not exist — sandbox will fail to connect`, + fix: "Generate a key with ssh-keygen or correct the path", + }); + } + + if (certPath && !existsSync(expandPath(certPath))) { + results.push({ + category: "Sandbox", + check: "SSH cert missing", + status: "warn", + message: `SSH cert path "${certPath}" does not exist`, + }); + } + + if (!knownHostsPath || !existsSync(expandPath(knownHostsPath))) { + results.push({ + category: "Sandbox", + check: "SSH known_hosts missing", + status: "warn", + message: "known_hosts file not configured or missing — SSH sandbox will accept unknown hosts", + fix: "Set tools.sandbox.ssh.knownHostsPath to a populated known_hosts file", + }); + } + } + + return results; +} +``` + +**Step 5: Wire into index.ts** + +```typescript +{ name: "Sandbox Backends", run: () => auditSandboxBackends(config) }, +``` + +**Step 6: Run + commit** + +```bash +npx vitest run tests/sandbox-backends.test.ts && npm test -- --run +git add src/auditors/sandbox-backends.ts src/auditors/index.ts src/types.ts tests/sandbox-backends.test.ts +git commit -m "feat(audit): new sandbox-backends auditor for SSH key/cert/known_hosts" +``` + +--- + +### Task 6: New auditor — `exec-approvals.ts` + +Read `~/.openclaw/exec-approvals.json` and flag: file missing when referenced, approvals older than 90 days, malformed entries. + +**Files:** +- Create: `src/auditors/exec-approvals.ts` +- Create: `tests/exec-approvals.test.ts` +- Modify: `src/auditors/index.ts` + +**Step 1: Write failing test** — mock fs (as in Task 5 pattern). Three scenarios: no file (info), stale entries > 90 days (warn), malformed JSON (warn). + +**Step 2-4: Implement** + +```typescript +import { existsSync, readFileSync } from "fs"; +import { homedir } from "os"; +import { resolve } from "path"; +import type { AuditResult } from "../types.js"; + +interface ExecApproval { command?: string; grantedAt?: string } +interface ExecApprovalsFile { approvals?: ExecApproval[]; socketPath?: string } + +export function auditExecApprovals(): AuditResult[] { + const results: AuditResult[] = []; + const path = resolve(homedir(), ".openclaw", "exec-approvals.json"); + if (!existsSync(path)) { + return results; // not populated yet — silent + } + + let data: ExecApprovalsFile; + try { + data = JSON.parse(readFileSync(path, "utf-8")); + } catch { + results.push({ + category: "Exec Approvals", + check: "exec-approvals.json readable", + status: "warn", + message: "exec-approvals.json exists but is not valid JSON", + fix: "Inspect or delete ~/.openclaw/exec-approvals.json", + }); + return results; + } + + const approvals = data.approvals ?? []; + const ninetyDays = 90 * 86400000; + const stale = approvals.filter(a => { + if (!a.grantedAt) return false; + return Date.now() - new Date(a.grantedAt).getTime() > ninetyDays; + }); + + if (stale.length > 0) { + results.push({ + category: "Exec Approvals", + check: "Stale exec approvals", + status: "warn", + message: `${stale.length} exec approval(s) older than 90 days still active`, + fix: "Review ~/.openclaw/exec-approvals.json and revoke unused entries", + }); + } + + return results; +} +``` + +Wire `{ name: "Exec Approvals", run: () => auditExecApprovals() }` into index.ts. + +**Step 5: Commit** + +```bash +git commit -m "feat(audit): new exec-approvals auditor for stale/malformed ~/.openclaw/exec-approvals.json" +``` + +--- + +### Task 7: New auditor — `tools-by-provider.ts` + +Flag unknown profiles (must be `minimal|coding|default`), allow/deny conflicts, references to unknown providers. + +**Files:** +- Create: `src/auditors/tools-by-provider.ts` +- Create: `tests/tools-by-provider.test.ts` +- Modify: `src/auditors/index.ts` + +Same TDD cycle: test → fail → implement → pass → wire → commit. Keep implementation ≤50 lines; follow `tool-permissions.ts` style. + +```bash +git commit -m "feat(audit): validate tools.byProvider profile/allow/deny config" +``` + +--- + +## Phase 3 — Polish (LOW priority) + +### Task 8: Refresh security advisories + +Review OpenClaw CHANGELOG for entries after v2026.4.15. If new security-relevant fixes exist, append to `ADVISORIES` array in [src/auditors/security-advisories.ts](src/auditors/security-advisories.ts). Add one test per new advisory to [tests/security-advisories.test.ts](tests/security-advisories.test.ts). + +**If no new advisories exist:** skip this task and note in the PR body. + +**Commit (if applicable):** + +```bash +git commit -m "feat(audit): add v2026.4.16+ security advisories" +``` + +--- + +### Task 9: Update README + bump version + +**Files:** +- Modify: `package.json:3` — `"version": "0.10.0"` +- Modify: `README.md` — update stats badge ("70+ checks, 15 auditors" → e.g. "90+ checks, 19 auditors" — count after implementation) +- Modify: `README.md` — update "v2026.4.12+" target to "v2026.4.15+ (v2026.3.14 features supported)" +- Modify: `README.md` — add "What's new in v0.10.0" section + +**Step 1: Count actual checks** + +```bash +npm run build +node dist/cli.js audit --json | jq '.summary.total' +``` + +Use real output on a live OpenClaw install. + +**Step 2: Update package.json and README accordingly.** + +**Step 3: Commit** + +```bash +git add package.json README.md +git commit -m "v0.10.0: OpenClaw compatibility refresh" +``` + +--- + +### Task 10: Tag, publish, release + +**Step 1: Final verification** + +```bash +npm test -- --run && npm run build && npm run lint +``` + +Expected: all pass. + +**Step 2: Merge to main** + +```bash +git checkout main +git merge --no-ff feat/v0.10.0-compat-refresh +git push origin main +``` + +**Step 3: Tag (annotated with release notes body)** + +```bash +git tag -a v0.10.0 -m "v0.10.0 — OpenClaw compatibility refresh + +- Flag deprecated hook formats (legacy handlers[], before_agent_start) +- Detect legacy ~/.openclaw/plugins/ directory +- Validate hook event names against known OpenClaw events +- New sandbox-backends auditor (SSH key/cert/known_hosts) +- New exec-approvals auditor +- New tools.byProvider auditor +- Recognise firecrawl, openrouter, github-copilot, openai-codex as bundled" +git push origin v0.10.0 +``` + +**Step 4: npm publish** + +```bash +npm publish +``` + +**Step 5: GitHub release** + +```bash +gh release create v0.10.0 --repo Drakon-Systems-Ltd/agent-optimizer \ + --title "v0.10.0 — OpenClaw compatibility refresh" --latest \ + --notes-from-tag +``` + +**Step 6: Verify** + +Run: `gh release list --repo Drakon-Systems-Ltd/agent-optimizer --limit 3` +Expected: v0.10.0 shown as Latest; npm badge (after registry propagation) shows 0.10.0. + +--- + +## Verification Checklist + +End-to-end smoke tests after Phase 2: + +- [ ] `npm test -- --run` — 130+14 = **144+ tests green** +- [ ] `node dist/cli.js audit` on a real OpenClaw install — new checks appear under `Hooks`, `Sandbox`, `Exec Approvals`, `Tools / byProvider` categories +- [ ] `node dist/cli.js audit --json | jq '.results[] | select(.category == "Hooks")'` — returns entries +- [ ] Install on a machine with legacy `~/.openclaw/plugins/` → the migration warning fires +- [ ] Install on a machine configured with `before_agent_start` hook → warning fires +- [ ] Install on a clean OpenClaw v2026.3.14+ install → no false positives + +## Out of scope (punt to v0.11.0) + +- MCP server dedicated auditor (OpenClaw config doesn't yet expose `mcp.servers` key per Phase 1 exploration) +- `/btw` side-question usage telemetry +- Bundle format discovery (`.codex-plugin/`, `.claude-plugin/`, `.cursor-plugin/`) — schema still evolving in OpenClaw +- Firecrawl-specific config validation (API key, endpoint) + +## References + +- Gap analysis memory: "Agent Optimizer v0.9.2 gaps vs OpenClaw current docs (2026-04-21 audit)" +- Existing auditor pattern: [src/auditors/plugins.ts](src/auditors/plugins.ts), [src/auditors/legacy-overrides.ts](src/auditors/legacy-overrides.ts) +- Existing test pattern: [tests/plugins.test.ts](tests/plugins.test.ts) +- Wire-up point: [src/auditors/index.ts:50-65](src/auditors/index.ts#L50-L65) +- OpenClaw source (for docs verification): `/Users/michael/Development/openclaw/docs/` diff --git a/package.json b/package.json index c639206..643e13d 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/auditors/bootstrap-files.ts b/src/auditors/bootstrap-files.ts index 55504d5..3b8bb9f 100644 --- a/src/auditors/bootstrap-files.ts +++ b/src/auditors/bootstrap-files.ts @@ -37,6 +37,25 @@ export function auditBootstrapFiles(config: OpenClawConfig): AuditResult[] { const perFileMax = (defaults as Record)?.bootstrapMaxChars as number ?? DEFAULT_PER_FILE_MAX; const totalMax = (defaults as Record)?.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; diff --git a/src/auditors/config-patch-usage.ts b/src/auditors/config-patch-usage.ts new file mode 100644 index 0000000..09e357d --- /dev/null +++ b/src/auditors/config-patch-usage.ts @@ -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)) { + 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(); + 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; +} diff --git a/src/auditors/dreaming-cron.ts b/src/auditors/dreaming-cron.ts new file mode 100644 index 0000000..0fde7dd --- /dev/null +++ b/src/auditors/dreaming-cron.ts @@ -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; + 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 | null)?.jobs) + ? ((parsed as Record).jobs as unknown[]) + : []; + + for (const job of jobs) { + if (!jobMentionsDreaming(job)) continue; + const j = job as Record; + 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; +} diff --git a/src/auditors/hooks-deprecations.ts b/src/auditors/hooks-deprecations.ts new file mode 100644 index 0000000..1c7a2ee --- /dev/null +++ b/src/auditors/hooks-deprecations.ts @@ -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.", + fix: "Migrate handlers to ~/.openclaw/hooks// directories and configure via hooks.internal.entries.", + }); + } + + 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; +} diff --git a/src/auditors/index.ts b/src/auditors/index.ts index 259b6ac..6621cb6 100644 --- a/src/auditors/index.ts +++ b/src/auditors/index.ts @@ -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; @@ -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) }, ]; diff --git a/src/auditors/memory-search.ts b/src/auditors/memory-search.ts index 0495045..14c95c8 100644 --- a/src/auditors/memory-search.ts +++ b/src/auditors/memory-search.ts @@ -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 | 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; diff --git a/src/auditors/plugins.ts b/src/auditors/plugins.ts index 2eb8769..f6ae5fb 100644 --- a/src/auditors/plugins.ts +++ b/src/auditors/plugins.ts @@ -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[] { @@ -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; } diff --git a/src/auditors/security-advisories.ts b/src/auditors/security-advisories.ts index 283bad0..701fbc8 100644 --- a/src/auditors/security-advisories.ts +++ b/src/auditors/security-advisories.ts @@ -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", diff --git a/src/types.ts b/src/types.ts index 9019bcb..667a2ea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,6 +30,18 @@ export interface OpenClawConfig { entries?: Record }>; installs?: Record; }; + hooks?: { + internal?: { + enabled?: boolean; + handlers?: Array<{ event?: string; module?: string }>; // legacy + entries?: Record; + }>; + load?: { extraDirs?: string[] }; + }; + }; gateway?: Record; channels?: Record; [key: string]: unknown; diff --git a/tests/bootstrap-files.test.ts b/tests/bootstrap-files.test.ts index 442d8e6..d7cc489 100644 --- a/tests/bootstrap-files.test.ts +++ b/tests/bootstrap-files.test.ts @@ -1,11 +1,29 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdirSync, writeFileSync, rmSync, existsSync } from "fs"; +import { mkdirSync, writeFileSync, rmSync, existsSync, readdirSync } from "fs"; import { join } from "path"; import { auditBootstrapFiles } from "../src/auditors/bootstrap-files.js"; import type { OpenClawConfig } from "../src/types.js"; const TEST_DIR = join(process.cwd(), "__test_bootstrap__"); +// macOS default APFS/HFS+ is case-insensitive, so `MEMORY.md` and `memory.md` +// map to the same inode and can't coexist in a single directory. Linux is +// case-sensitive. Detect at runtime so tests work on both. +function detectCaseSensitiveFS(): boolean { + const probe = join(TEST_DIR, "__case_probe__"); + mkdirSync(probe, { recursive: true }); + writeFileSync(join(probe, "a"), ""); + try { + writeFileSync(join(probe, "A"), ""); + const entries = readdirSync(probe); + return entries.includes("a") && entries.includes("A"); + } catch { + return false; + } finally { + rmSync(probe, { recursive: true, force: true }); + } +} + function makeConfig(workspace: string, overrides?: Record): OpenClawConfig { return { agents: { @@ -81,6 +99,32 @@ describe("auditBootstrapFiles", () => { expect(results.some((r) => r.check === "Total bootstrap budget")).toBe(true); }); + const caseSensitive = detectCaseSensitiveFS(); + + it.skipIf(!caseSensitive)( + "warns when MEMORY.md and memory.md both exist (split-brain)", + () => { + writeFileSync(join(TEST_DIR, "MEMORY.md"), "# Upper\nCanonical."); + writeFileSync(join(TEST_DIR, "memory.md"), "# lower\nShould be merged."); + const config = makeConfig(TEST_DIR); + const results = auditBootstrapFiles(config); + const splitBrain = results.find( + (r) => r.status === "warn" && r.check === "MEMORY.md split-brain" + ); + expect(splitBrain).toBeDefined(); + expect(splitBrain!.message).toContain("memory.md"); + expect(splitBrain!.message).toContain("MEMORY.md"); + expect(splitBrain!.fix).toContain("openclaw doctor --fix"); + } + ); + + it("does not warn split-brain when only MEMORY.md exists", () => { + writeFileSync(join(TEST_DIR, "MEMORY.md"), "# Memory\nOnly one."); + const config = makeConfig(TEST_DIR); + const results = auditBootstrapFiles(config); + expect(results.some((r) => r.check === "MEMORY.md split-brain")).toBe(false); + }); + it("reports memory directory info", () => { mkdirSync(join(TEST_DIR, "memory"), { recursive: true }); writeFileSync(join(TEST_DIR, "memory", "2026-04-12.md"), "# Notes\nSome notes."); diff --git a/tests/config-patch-usage.test.ts b/tests/config-patch-usage.test.ts new file mode 100644 index 0000000..e73c374 --- /dev/null +++ b/tests/config-patch-usage.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; +import { auditConfigPatchUsage } from "../src/auditors/config-patch-usage.js"; +import type { OpenClawConfig } from "../src/types.js"; + +describe("auditConfigPatchUsage", () => { + it("returns empty when no hooks or tools configured", () => { + const config: OpenClawConfig = {}; + const results = auditConfigPatchUsage(config); + expect(results).toHaveLength(0); + }); + + it("returns empty for benign hook entries", () => { + const config: OpenClawConfig = { + hooks: { + internal: { + enabled: true, + entries: { + "cortex-memory": { enabled: true, event: "message" }, + "session-memory": { enabled: true }, + }, + }, + }, + }; + const results = auditConfigPatchUsage(config); + expect(results).toHaveLength(0); + }); + + it("warns when a legacy handler module references config.patch", () => { + const config: OpenClawConfig = { + hooks: { + internal: { + enabled: true, + handlers: [ + { event: "message", module: "./hooks/auto-tune/config.patch.js" }, + ], + }, + }, + }; + const results = auditConfigPatchUsage(config); + expect(results.some((r) => r.status === "warn" && r.check.includes("config.patch"))).toBe(true); + }); + + it("warns when a hook entry env references config.apply", () => { + const config: OpenClawConfig = { + hooks: { + internal: { + enabled: true, + entries: { + "auto-tune": { + enabled: true, + env: { COMMAND: "openclaw gateway config.apply --file /tmp/p.json" }, + }, + }, + }, + }, + }; + const results = auditConfigPatchUsage(config); + expect(results.some((r) => r.status === "warn" && r.check.includes("auto-tune"))).toBe(true); + }); + + it("warns when an agent tool allowlist exposes config.patch/apply", () => { + const config: OpenClawConfig = { + agents: { + list: [ + { + id: "main", + name: "main", + workspace: "~/clawd", + agentDir: "~/.openclaw/agents/main/agent", + tools: { alsoAllow: ["gateway.config.patch", "gateway.config.apply"] }, + }, + ], + }, + }; + const results = auditConfigPatchUsage(config); + expect(results.some((r) => r.status === "warn" && r.check.includes("tool allowlist"))).toBe(true); + }); + + it("includes the fix pointer to the v2026.4.23 allowlist", () => { + const config: OpenClawConfig = { + hooks: { + internal: { + enabled: true, + handlers: [{ event: "message", module: "./config.patch.js" }], + }, + }, + }; + const results = auditConfigPatchUsage(config); + const warn = results.find((r) => r.status === "warn"); + expect(warn?.fix).toBeDefined(); + expect(warn!.fix!.toLowerCase()).toMatch(/allowlist|prompt|model|mention/); + }); +}); diff --git a/tests/dreaming-cron.test.ts b/tests/dreaming-cron.test.ts new file mode 100644 index 0000000..b47321f --- /dev/null +++ b/tests/dreaming-cron.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, writeFileSync, rmSync, existsSync } from "fs"; +import { join } from "path"; +import { auditDreamingCron } from "../src/auditors/dreaming-cron.js"; +import type { OpenClawConfig } from "../src/types.js"; + +const TEST_HOME = join(process.cwd(), "__test_dreaming_home__"); +const CRON_DIR = join(TEST_HOME, ".openclaw", "cron"); +const CRON_FILE = join(CRON_DIR, "jobs.json"); + +let ORIGINAL_HOME: string | undefined; + +beforeEach(() => { + ORIGINAL_HOME = process.env.HOME; + process.env.HOME = TEST_HOME; + if (existsSync(TEST_HOME)) rmSync(TEST_HOME, { recursive: true, force: true }); + mkdirSync(CRON_DIR, { recursive: true }); +}); + +afterEach(() => { + process.env.HOME = ORIGINAL_HOME; + if (existsSync(TEST_HOME)) rmSync(TEST_HOME, { recursive: true, force: true }); +}); + +const emptyConfig: OpenClawConfig = {}; + +describe("auditDreamingCron", () => { + it("returns empty when jobs.json does not exist", () => { + // no file written + const results = auditDreamingCron(emptyConfig); + expect(results).toHaveLength(0); + }); + + it("returns empty when jobs.json has no dreaming jobs", () => { + writeFileSync( + CRON_FILE, + JSON.stringify({ + jobs: [ + { id: "j1", label: "backup", session: "agent:main:cron:abc", module: "./backup.js" }, + ], + }) + ); + const results = auditDreamingCron(emptyConfig); + expect(results).toHaveLength(0); + }); + + it("warns for pre-2026.4.23 main-session dreaming job", () => { + writeFileSync( + CRON_FILE, + JSON.stringify({ + jobs: [ + { + id: "d1", + label: "dreaming", + session: "agent:main:main", + module: "./dreaming/run.js", + }, + ], + }) + ); + const results = auditDreamingCron(emptyConfig); + const warn = results.find((r) => r.status === "warn"); + expect(warn).toBeDefined(); + expect(warn!.check.toLowerCase()).toContain("dreaming"); + expect(warn!.fix).toContain("openclaw doctor --fix"); + }); + + it("does not warn for v2026.4.23-shape dreaming job (isolated agent session)", () => { + writeFileSync( + CRON_FILE, + JSON.stringify({ + jobs: [ + { + id: "d1", + label: "dreaming", + session: "agent:main:dreaming:lightweight", + module: "./dreaming/run.js", + }, + ], + }) + ); + const results = auditDreamingCron(emptyConfig); + expect(results.some((r) => r.status === "warn")).toBe(false); + }); + + it("emits info result on malformed jobs.json", () => { + writeFileSync(CRON_FILE, "not json at all"); + const results = auditDreamingCron(emptyConfig); + expect(results.some((r) => r.status === "info")).toBe(true); + }); +}); diff --git a/tests/hooks-deprecations.test.ts b/tests/hooks-deprecations.test.ts new file mode 100644 index 0000000..dfb7592 --- /dev/null +++ b/tests/hooks-deprecations.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from "vitest"; +import { auditHooksDeprecations } from "../src/auditors/hooks-deprecations.js"; +import type { OpenClawConfig } from "../src/types.js"; + +describe("auditHooksDeprecations", () => { + it("returns empty when no hooks configured", () => { + expect(auditHooksDeprecations({})).toHaveLength(0); + }); + + it("warns on legacy handlers[] array format", () => { + const config: OpenClawConfig = { + hooks: { internal: { handlers: [{ event: "command:new", module: "./h.js" }] } }, + }; + const results = auditHooksDeprecations(config); + expect(results.some(r => r.status === "warn" && r.check.toLowerCase().includes("legacy"))).toBe(true); + }); + + it("warns on before_agent_start hook usage", () => { + const config: OpenClawConfig = { + hooks: { internal: { entries: { starter: { event: "before_agent_start" } } } }, + }; + const results = auditHooksDeprecations(config); + expect(results.some(r => r.status === "warn" && r.message.includes("before_agent_start"))).toBe(true); + }); + + it("passes when using current entries format with valid events", () => { + const config: OpenClawConfig = { + hooks: { internal: { entries: { good: { event: "command:new" } } } }, + }; + const results = auditHooksDeprecations(config); + expect(results.every(r => r.status !== "fail")).toBe(true); + }); +}); diff --git a/tests/memory-search.test.ts b/tests/memory-search.test.ts index 838bcba..41329e9 100644 --- a/tests/memory-search.test.ts +++ b/tests/memory-search.test.ts @@ -155,6 +155,68 @@ describe("auditMemorySearch", () => { expect(results.some((r) => r.check === "Active Memory plugin" && r.status === "pass")).toBe(true); }); + it("warns when local.contextSize is too small (<1024)", () => { + const config: OpenClawConfig = { + agents: { + defaults: { + memorySearch: { provider: "local", local: { contextSize: 512 } }, + } as any, + }, + }; + const results = auditMemorySearch(config); + expect( + results.some( + (r) => r.check === "Local embedding context size" && r.status === "warn" + ) + ).toBe(true); + }); + + it("warns when local.contextSize is too large (>32768)", () => { + const config: OpenClawConfig = { + agents: { + defaults: { + memorySearch: { provider: "local", local: { contextSize: 65536 } }, + } as any, + }, + }; + const results = auditMemorySearch(config); + expect( + results.some( + (r) => r.check === "Local embedding context size" && r.status === "warn" + ) + ).toBe(true); + }); + + it("passes when local.contextSize is in a sensible range", () => { + const config: OpenClawConfig = { + agents: { + defaults: { + memorySearch: { provider: "local", local: { contextSize: 4096 } }, + } as any, + }, + }; + const results = auditMemorySearch(config); + expect( + results.some( + (r) => r.check === "Local embedding context size" && r.status === "pass" + ) + ).toBe(true); + }); + + it("does not emit local.contextSize check when field is unset", () => { + const config: OpenClawConfig = { + agents: { + defaults: { + memorySearch: { provider: "local" }, + } as any, + }, + }; + const results = auditMemorySearch(config); + expect( + results.some((r) => r.check === "Local embedding context size") + ).toBe(false); + }); + it("detects QMD backend with high maxResults", () => { const config: OpenClawConfig = { agents: { defaults: { memorySearch: {} } as any }, diff --git a/tests/plugins-path.test.ts b/tests/plugins-path.test.ts new file mode 100644 index 0000000..0ac47c4 --- /dev/null +++ b/tests/plugins-path.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { OpenClawConfig } from "../src/types.js"; + +vi.mock("fs", () => ({ + existsSync: vi.fn(), + readdirSync: vi.fn(), +})); + +import { existsSync, readdirSync } from "fs"; +import { auditPlugins } from "../src/auditors/plugins.js"; + +describe("auditPlugins — legacy path", () => { + beforeEach(() => vi.clearAllMocks()); + + it("warns when legacy ~/.openclaw/plugins/ has contents", () => { + vi.mocked(existsSync).mockImplementation((p) => + String(p).endsWith(".openclaw/plugins") + ); + vi.mocked(readdirSync).mockReturnValue(["old-plugin"] as never); + + const config: OpenClawConfig = { plugins: { allow: [] } }; + const results = auditPlugins(config); + expect(results.some(r => r.check.includes("Legacy plugin directory"))).toBe(true); + }); + + it("does not warn when legacy directory is empty", () => { + vi.mocked(existsSync).mockImplementation((p) => + String(p).endsWith(".openclaw/plugins") + ); + vi.mocked(readdirSync).mockReturnValue([] as never); + + const config: OpenClawConfig = { plugins: { allow: [] } }; + const results = auditPlugins(config); + expect(results.some(r => r.check.includes("Legacy plugin directory"))).toBe(false); + }); + + it("does not warn when legacy directory does not exist", () => { + vi.mocked(existsSync).mockReturnValue(false); + + const config: OpenClawConfig = { plugins: { allow: [] } }; + const results = auditPlugins(config); + expect(results.some(r => r.check.includes("Legacy plugin directory"))).toBe(false); + }); +}); diff --git a/tests/plugins.test.ts b/tests/plugins.test.ts index 20cf560..37d262f 100644 --- a/tests/plugins.test.ts +++ b/tests/plugins.test.ts @@ -76,4 +76,13 @@ describe("auditPlugins", () => { results.some((r) => r.status === "info" && r.check.includes("some-custom-plugin")) ).toBe(true); }); + + it.each(["firecrawl", "openrouter", "github-copilot", "openai-codex"])( + "recognises newly-bundled %s as pass", + (name) => { + const config: OpenClawConfig = { plugins: { allow: [name], entries: {}, installs: {} } }; + const results = auditPlugins(config); + expect(results.some(r => r.status === "pass" && r.message.includes("bundled"))).toBe(true); + } + ); }); diff --git a/tests/security-advisories.test.ts b/tests/security-advisories.test.ts index 4840e9b..1f2a183 100644 --- a/tests/security-advisories.test.ts +++ b/tests/security-advisories.test.ts @@ -10,7 +10,7 @@ describe("auditSecurityAdvisories", () => { }); it("returns pass when on latest version", () => { - const results = auditSecurityAdvisories("2026.4.15"); + const results = auditSecurityAdvisories("2026.4.23"); expect(results.some((r) => r.check === "Security advisories" && r.status === "pass")).toBe(true); }); @@ -61,4 +61,20 @@ describe("auditSecurityAdvisories", () => { // But 4.15 fixes SHOULD be flagged expect(results.some((r) => r.check === "Approval prompt secret leak")).toBe(true); }); + + it("flags config.patch allowlist lockdown for pre-4.23", () => { + const results = auditSecurityAdvisories("2026.4.22"); + expect( + results.some( + (r) => r.check === "config.patch allowlist lockdown" && r.status === "warn" + ) + ).toBe(true); + }); + + it("does not flag config.patch lockdown once on 4.23", () => { + const results = auditSecurityAdvisories("2026.4.23"); + expect( + results.some((r) => r.check === "config.patch allowlist lockdown") + ).toBe(false); + }); });