From 4c86010b0620a1a689bef2e47c7f1b2b00891aa9 Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 13 Feb 2026 01:52:09 +0800 Subject: [PATCH] fix: remove bundled soul-evil hook (closes #8776) (#14757) * fix: remove bundled soul-evil hook (closes #8776) * fix: remove soul-evil docs (#14757) (thanks @Imccccc) --------- Co-authored-by: OpenClaw Bot Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + docs/automation/hooks.md | 39 +-- docs/cli/hooks.md | 15 +- docs/docs.json | 8 - docs/hooks/soul-evil.md | 69 ----- docs/zh-CN/automation/hooks.md | 39 +-- docs/zh-CN/cli/hooks.md | 15 +- docs/zh-CN/hooks/soul-evil.md | 72 ----- package.json | 8 +- src/hooks/bundled/README.md | 15 -- src/hooks/bundled/soul-evil/HOOK.md | 71 ----- src/hooks/bundled/soul-evil/README.md | 11 - src/hooks/bundled/soul-evil/handler.test.ts | 46 ---- src/hooks/bundled/soul-evil/handler.ts | 49 ---- src/hooks/soul-evil.test.ts | 252 ------------------ src/hooks/soul-evil.ts | 280 -------------------- 16 files changed, 9 insertions(+), 981 deletions(-) delete mode 100644 docs/hooks/soul-evil.md delete mode 100644 docs/zh-CN/hooks/soul-evil.md delete mode 100644 src/hooks/bundled/soul-evil/HOOK.md delete mode 100644 src/hooks/bundled/soul-evil/README.md delete mode 100644 src/hooks/bundled/soul-evil/handler.test.ts delete mode 100644 src/hooks/bundled/soul-evil/handler.ts delete mode 100644 src/hooks/soul-evil.test.ts delete mode 100644 src/hooks/soul-evil.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e1d852fd11..271d0098aa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Security: fix unauthenticated Nostr profile API remote config tampering. (#13719) Thanks @coygeek. +- Security: remove bundled soul-evil hook. (#14757) Thanks @Imccccc. - Gateway: raise WS payload/buffer limits so 5,000,000-byte image attachments work reliably. (#14486) Thanks @0xRaini. - Gateway: drain active turns before restart to prevent message loss. (#13931) Thanks @0xRaini. - Gateway: auto-generate auth token during install to prevent launchd restart loops. (#13813) Thanks @cathrynlavery. diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index e842b8c58e7..2030e9aeaf6 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -41,12 +41,11 @@ The hooks system allows you to: ### Bundled Hooks -OpenClaw ships with four bundled hooks that are automatically discovered: +OpenClaw ships with three bundled hooks that are automatically discovered: - **💾 session-memory**: Saves session context to your agent workspace (default `~/.openclaw/workspace/memory/`) when you issue `/new` - **📝 command-logger**: Logs all command events to `~/.openclaw/logs/commands.log` - **🚀 boot-md**: Runs `BOOT.md` when the gateway starts (requires internal hooks enabled) -- **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance List available hooks: @@ -527,42 +526,6 @@ grep '"action":"new"' ~/.openclaw/logs/commands.log | jq . openclaw hooks enable command-logger ``` -### soul-evil - -Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance. - -**Events**: `agent:bootstrap` - -**Docs**: [SOUL Evil Hook](/hooks/soul-evil) - -**Output**: No files written; swaps happen in-memory only. - -**Enable**: - -```bash -openclaw hooks enable soul-evil -``` - -**Config**: - -```json -{ - "hooks": { - "internal": { - "enabled": true, - "entries": { - "soul-evil": { - "enabled": true, - "file": "SOUL_EVIL.md", - "chance": 0.1, - "purge": { "at": "21:00", "duration": "15m" } - } - } - } - } -} -``` - ### boot-md Runs `BOOT.md` when the gateway starts (after channels start). diff --git a/docs/cli/hooks.md b/docs/cli/hooks.md index d7531a02d91..6b4f42143e9 100644 --- a/docs/cli/hooks.md +++ b/docs/cli/hooks.md @@ -32,13 +32,12 @@ List all discovered hooks from workspace, managed, and bundled directories. **Example output:** ``` -Hooks (4/4 ready) +Hooks (3/3 ready) Ready: 🚀 boot-md ✓ - Run BOOT.md on gateway startup 📝 command-logger ✓ - Log all command events to a centralized audit file 💾 session-memory ✓ - Save session context to memory when /new command is issued - 😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance ``` **Example (verbose):** @@ -277,18 +276,6 @@ grep '"action":"new"' ~/.openclaw/logs/commands.log | jq . **See:** [command-logger documentation](/automation/hooks#command-logger) -### soul-evil - -Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance. - -**Enable:** - -```bash -openclaw hooks enable soul-evil -``` - -**See:** [SOUL Evil Hook](/hooks/soul-evil) - ### boot-md Runs `BOOT.md` when the gateway starts (after channels start). diff --git a/docs/docs.json b/docs/docs.json index 0d9831d3054..af750f0bc8e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1003,10 +1003,6 @@ "automation/auth-monitoring" ] }, - { - "group": "Hooks", - "pages": ["hooks/soul-evil"] - }, { "group": "Media and devices", "pages": [ @@ -1523,10 +1519,6 @@ "zh-CN/automation/auth-monitoring" ] }, - { - "group": "Hooks", - "pages": ["zh-CN/hooks/soul-evil"] - }, { "group": "媒体与设备", "pages": [ diff --git a/docs/hooks/soul-evil.md b/docs/hooks/soul-evil.md deleted file mode 100644 index 0b08d54a1c9..00000000000 --- a/docs/hooks/soul-evil.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -summary: "SOUL Evil hook (swap SOUL.md with SOUL_EVIL.md)" -read_when: - - You want to enable or tune the SOUL Evil hook - - You want a purge window or random-chance persona swap -title: "SOUL Evil Hook" ---- - -# SOUL Evil Hook - -The SOUL Evil hook swaps the **injected** `SOUL.md` content with `SOUL_EVIL.md` during -a purge window or by random chance. It does **not** modify files on disk. - -## How It Works - -When `agent:bootstrap` runs, the hook can replace the `SOUL.md` content in memory -before the system prompt is assembled. If `SOUL_EVIL.md` is missing or empty, -OpenClaw logs a warning and keeps the normal `SOUL.md`. - -Sub-agent runs do **not** include `SOUL.md` in their bootstrap files, so this hook -has no effect on sub-agents. - -## Enable - -```bash -openclaw hooks enable soul-evil -``` - -Then set the config: - -```json -{ - "hooks": { - "internal": { - "enabled": true, - "entries": { - "soul-evil": { - "enabled": true, - "file": "SOUL_EVIL.md", - "chance": 0.1, - "purge": { "at": "21:00", "duration": "15m" } - } - } - } - } -} -``` - -Create `SOUL_EVIL.md` in the agent workspace root (next to `SOUL.md`). - -## Options - -- `file` (string): alternate SOUL filename (default: `SOUL_EVIL.md`) -- `chance` (number 0–1): random chance per run to use `SOUL_EVIL.md` -- `purge.at` (HH:mm): daily purge start (24-hour clock) -- `purge.duration` (duration): window length (e.g. `30s`, `10m`, `1h`) - -**Precedence:** purge window wins over chance. - -**Timezone:** uses `agents.defaults.userTimezone` when set; otherwise host timezone. - -## Notes - -- No files are written or modified on disk. -- If `SOUL.md` is not in the bootstrap list, the hook does nothing. - -## See Also - -- [Hooks](/automation/hooks) diff --git a/docs/zh-CN/automation/hooks.md b/docs/zh-CN/automation/hooks.md index d0a2c890c61..61f9e916e15 100644 --- a/docs/zh-CN/automation/hooks.md +++ b/docs/zh-CN/automation/hooks.md @@ -48,12 +48,11 @@ hooks 系统允许你: ### 捆绑的 Hooks -OpenClaw 附带四个自动发现的捆绑 hooks: +OpenClaw 附带三个自动发现的捆绑 hooks: - **💾 session-memory**:当你发出 `/new` 时将会话上下文保存到智能体工作区(默认 `~/.openclaw/workspace/memory/`) - **📝 command-logger**:将所有命令事件记录到 `~/.openclaw/logs/commands.log` - **🚀 boot-md**:当 Gateway 网关启动时运行 `BOOT.md`(需要启用内部 hooks) -- **😈 soul-evil**:在清除窗口期间或随机机会下将注入的 `SOUL.md` 内容替换为 `SOUL_EVIL.md` 列出可用的 hooks: @@ -533,42 +532,6 @@ grep '"action":"new"' ~/.openclaw/logs/commands.log | jq . openclaw hooks enable command-logger ``` -### soul-evil - -在清除窗口期间或随机机会下将注入的 `SOUL.md` 内容替换为 `SOUL_EVIL.md`。 - -**事件**:`agent:bootstrap` - -**文档**:[SOUL Evil Hook](/hooks/soul-evil) - -**输出**:不写入文件;替换仅在内存中发生。 - -**启用**: - -```bash -openclaw hooks enable soul-evil -``` - -**配置**: - -```json -{ - "hooks": { - "internal": { - "enabled": true, - "entries": { - "soul-evil": { - "enabled": true, - "file": "SOUL_EVIL.md", - "chance": 0.1, - "purge": { "at": "21:00", "duration": "15m" } - } - } - } - } -} -``` - ### boot-md 当 Gateway 网关启动时运行 `BOOT.md`(在渠道启动之后)。 diff --git a/docs/zh-CN/cli/hooks.md b/docs/zh-CN/cli/hooks.md index 02c2a62e8d6..015cd02bb3c 100644 --- a/docs/zh-CN/cli/hooks.md +++ b/docs/zh-CN/cli/hooks.md @@ -39,13 +39,12 @@ openclaw hooks list **示例输出:** ``` -Hooks (4/4 ready) +Hooks (3/3 ready) Ready: 🚀 boot-md ✓ - Run BOOT.md on gateway startup 📝 command-logger ✓ - Log all command events to a centralized audit file 💾 session-memory ✓ - Save session context to memory when /new command is issued - 😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance ``` **示例(详细模式):** @@ -284,18 +283,6 @@ grep '"action":"new"' ~/.openclaw/logs/commands.log | jq . **参见:** [command-logger 文档](/automation/hooks#command-logger) -### soul-evil - -在清除窗口期间或随机情况下,将注入的 `SOUL.md` 内容替换为 `SOUL_EVIL.md`。 - -**启用:** - -```bash -openclaw hooks enable soul-evil -``` - -**参见:** [SOUL Evil 钩子](/hooks/soul-evil) - ### boot-md 在 Gateway 网关启动时(渠道启动后)运行 `BOOT.md`。 diff --git a/docs/zh-CN/hooks/soul-evil.md b/docs/zh-CN/hooks/soul-evil.md deleted file mode 100644 index c9401a84544..00000000000 --- a/docs/zh-CN/hooks/soul-evil.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -read_when: - - 你想要启用或调整 SOUL Evil 钩子 - - 你想要设置清除窗口或随机概率的人格替换 -summary: SOUL Evil 钩子(将 SOUL.md 替换为 SOUL_EVIL.md) -title: SOUL Evil 钩子 -x-i18n: - generated_at: "2026-02-01T20:42:18Z" - model: claude-opus-4-5 - provider: pi - source_hash: cc32c1e207f2b6923a6ede8299293f8fc07f3c8d6b2a377775237c0173fe8d1b - source_path: hooks/soul-evil.md - workflow: 14 ---- - -# SOUL Evil 钩子 - -SOUL Evil 钩子在清除窗口期间或随机概率下,将**注入的** `SOUL.md` 内容替换为 `SOUL_EVIL.md`。它**不会**修改磁盘上的文件。 - -## 工作原理 - -当 `agent:bootstrap` 运行时,该钩子可以在系统提示词组装之前,在内存中替换 `SOUL.md` 的内容。如果 `SOUL_EVIL.md` 缺失或为空,OpenClaw 会记录警告并保留正常的 `SOUL.md`。 - -子智能体运行**不会**在其引导文件中包含 `SOUL.md`,因此此钩子对子智能体没有影响。 - -## 启用 - -```bash -openclaw hooks enable soul-evil -``` - -然后设置配置: - -```json -{ - "hooks": { - "internal": { - "enabled": true, - "entries": { - "soul-evil": { - "enabled": true, - "file": "SOUL_EVIL.md", - "chance": 0.1, - "purge": { "at": "21:00", "duration": "15m" } - } - } - } - } -} -``` - -在智能体工作区根目录(`SOUL.md` 旁边)创建 `SOUL_EVIL.md`。 - -## 选项 - -- `file`(字符串):替代的 SOUL 文件名(默认:`SOUL_EVIL.md`) -- `chance`(数字 0–1):每次运行使用 `SOUL_EVIL.md` 的随机概率 -- `purge.at`(HH:mm):每日清除开始时间(24 小时制) -- `purge.duration`(时长):窗口长度(例如 `30s`、`10m`、`1h`) - -**优先级:** 清除窗口优先于随机概率。 - -**时区:** 设置了 `agents.defaults.userTimezone` 时使用该时区;否则使用主机时区。 - -## 注意事项 - -- 不会在磁盘上写入或修改任何文件。 -- 如果 `SOUL.md` 不在引导列表中,该钩子不执行任何操作。 - -## 另请参阅 - -- [钩子](/automation/hooks) diff --git a/package.json b/package.json index 075f80db3ab..674f5105151 100644 --- a/package.json +++ b/package.json @@ -9,15 +9,15 @@ "openclaw": "openclaw.mjs" }, "files": [ - "assets/", "CHANGELOG.md", - "dist/", - "docs/", - "extensions/", "LICENSE", "openclaw.mjs", "README-header.png", "README.md", + "assets/", + "dist/", + "docs/", + "extensions/", "skills/" ], "type": "module", diff --git a/src/hooks/bundled/README.md b/src/hooks/bundled/README.md index b842d7909f3..4587d20a256 100644 --- a/src/hooks/bundled/README.md +++ b/src/hooks/bundled/README.md @@ -32,21 +32,6 @@ Logs all command events to a centralized audit file. openclaw hooks enable command-logger ``` -### 😈 soul-evil - -Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance. - -**Events**: `agent:bootstrap` -**What it does**: Overrides the injected SOUL content before the system prompt is built. -**Output**: No files written; swaps happen in-memory only. -**Docs**: https://docs.openclaw.ai/hooks/soul-evil - -**Enable**: - -```bash -openclaw hooks enable soul-evil -``` - ### 🚀 boot-md Runs `BOOT.md` whenever the gateway starts (after channels start). diff --git a/src/hooks/bundled/soul-evil/HOOK.md b/src/hooks/bundled/soul-evil/HOOK.md deleted file mode 100644 index c3bc81b2ddb..00000000000 --- a/src/hooks/bundled/soul-evil/HOOK.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -name: soul-evil -description: "Swap SOUL.md with SOUL_EVIL.md during a purge window or by random chance" -homepage: https://docs.openclaw.ai/hooks/soul-evil -metadata: - { - "openclaw": - { - "emoji": "😈", - "events": ["agent:bootstrap"], - "requires": { "config": ["hooks.internal.entries.soul-evil.enabled"] }, - "install": [{ "id": "bundled", "kind": "bundled", "label": "Bundled with OpenClaw" }], - }, - } ---- - -# SOUL Evil Hook - -Replaces the injected `SOUL.md` content with `SOUL_EVIL.md` during a daily purge window or by random chance. - -## What It Does - -When enabled and the trigger conditions match, the hook swaps the **injected** `SOUL.md` content before the system prompt is built. It does **not** modify files on disk. - -## Files - -- `SOUL.md` — normal persona (always read) -- `SOUL_EVIL.md` — alternate persona (read only when triggered) - -You can change the filename via hook config. - -## Configuration - -Add this to your config (`~/.openclaw/openclaw.json`): - -```json -{ - "hooks": { - "internal": { - "enabled": true, - "entries": { - "soul-evil": { - "enabled": true, - "file": "SOUL_EVIL.md", - "chance": 0.1, - "purge": { "at": "21:00", "duration": "15m" } - } - } - } - } -} -``` - -### Options - -- `file` (string): alternate SOUL filename (default: `SOUL_EVIL.md`) -- `chance` (number 0–1): random chance per run to swap in SOUL_EVIL -- `purge.at` (HH:mm): daily purge window start time (24h) -- `purge.duration` (duration): window length (e.g. `30s`, `10m`, `1h`) - -**Precedence:** purge window wins over chance. - -## Requirements - -- `hooks.internal.entries.soul-evil.enabled` must be set to `true` - -## Enable - -```bash -openclaw hooks enable soul-evil -``` diff --git a/src/hooks/bundled/soul-evil/README.md b/src/hooks/bundled/soul-evil/README.md deleted file mode 100644 index a90af5c0752..00000000000 --- a/src/hooks/bundled/soul-evil/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# SOUL Evil Hook - -Small persona swap hook for OpenClaw. - -Docs: https://docs.openclaw.ai/hooks/soul-evil - -## Setup - -1. `openclaw hooks enable soul-evil` -2. Create `SOUL_EVIL.md` next to `SOUL.md` in your agent workspace -3. Configure `hooks.internal.entries.soul-evil` (see docs) diff --git a/src/hooks/bundled/soul-evil/handler.test.ts b/src/hooks/bundled/soul-evil/handler.test.ts deleted file mode 100644 index 8cb4be14c49..00000000000 --- a/src/hooks/bundled/soul-evil/handler.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; -import type { AgentBootstrapHookContext } from "../../hooks.js"; -import { makeTempWorkspace, writeWorkspaceFile } from "../../../test-helpers/workspace.js"; -import { createHookEvent } from "../../hooks.js"; -import handler from "./handler.js"; - -describe("soul-evil hook", () => { - it("skips subagent sessions", async () => { - const tempDir = await makeTempWorkspace("openclaw-soul-"); - await writeWorkspaceFile({ - dir: tempDir, - name: "SOUL_EVIL.md", - content: "chaotic", - }); - - const cfg: OpenClawConfig = { - hooks: { - internal: { - entries: { - "soul-evil": { enabled: true, chance: 1 }, - }, - }, - }, - }; - const context: AgentBootstrapHookContext = { - workspaceDir: tempDir, - bootstrapFiles: [ - { - name: "SOUL.md", - path: path.join(tempDir, "SOUL.md"), - content: "friendly", - missing: false, - }, - ], - cfg, - sessionKey: "agent:main:subagent:abc", - }; - - const event = createHookEvent("agent", "bootstrap", "agent:main:subagent:abc", context); - await handler(event); - - expect(context.bootstrapFiles[0]?.content).toBe("friendly"); - }); -}); diff --git a/src/hooks/bundled/soul-evil/handler.ts b/src/hooks/bundled/soul-evil/handler.ts deleted file mode 100644 index 88e5f94a75c..00000000000 --- a/src/hooks/bundled/soul-evil/handler.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { isSubagentSessionKey } from "../../../routing/session-key.js"; -import { resolveHookConfig } from "../../config.js"; -import { isAgentBootstrapEvent, type HookHandler } from "../../hooks.js"; -import { applySoulEvilOverride, resolveSoulEvilConfigFromHook } from "../../soul-evil.js"; - -const HOOK_KEY = "soul-evil"; - -const soulEvilHook: HookHandler = async (event) => { - if (!isAgentBootstrapEvent(event)) { - return; - } - - const context = event.context; - if (context.sessionKey && isSubagentSessionKey(context.sessionKey)) { - return; - } - const cfg = context.cfg; - const hookConfig = resolveHookConfig(cfg, HOOK_KEY); - if (!hookConfig || hookConfig.enabled === false) { - return; - } - - const soulConfig = resolveSoulEvilConfigFromHook(hookConfig as Record, { - warn: (message) => console.warn(`[soul-evil] ${message}`), - }); - if (!soulConfig) { - return; - } - - const workspaceDir = context.workspaceDir; - if (!workspaceDir || !Array.isArray(context.bootstrapFiles)) { - return; - } - - const updated = await applySoulEvilOverride({ - files: context.bootstrapFiles, - workspaceDir, - config: soulConfig, - userTimezone: cfg?.agents?.defaults?.userTimezone, - log: { - warn: (message) => console.warn(`[soul-evil] ${message}`), - debug: (message) => console.debug?.(`[soul-evil] ${message}`), - }, - }); - - context.bootstrapFiles = updated; -}; - -export default soulEvilHook; diff --git a/src/hooks/soul-evil.test.ts b/src/hooks/soul-evil.test.ts deleted file mode 100644 index b6d41904c38..00000000000 --- a/src/hooks/soul-evil.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { DEFAULT_SOUL_FILENAME, type WorkspaceBootstrapFile } from "../agents/workspace.js"; -import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js"; -import { - applySoulEvilOverride, - decideSoulEvil, - DEFAULT_SOUL_EVIL_FILENAME, - resolveSoulEvilConfigFromHook, -} from "./soul-evil.js"; - -const makeFiles = (overrides?: Partial) => [ - { - name: DEFAULT_SOUL_FILENAME, - path: "/tmp/SOUL.md", - content: "friendly", - missing: false, - ...overrides, - }, -]; - -describe("decideSoulEvil", () => { - it("returns false when no config", () => { - const result = decideSoulEvil({}); - expect(result.useEvil).toBe(false); - }); - - it("activates on random chance", () => { - const result = decideSoulEvil({ - config: { chance: 0.5 }, - random: () => 0.2, - }); - expect(result.useEvil).toBe(true); - expect(result.reason).toBe("chance"); - }); - - it("activates during purge window", () => { - const result = decideSoulEvil({ - config: { - purge: { at: "00:00", duration: "10m" }, - }, - userTimezone: "UTC", - now: new Date("2026-01-01T00:05:00Z"), - }); - expect(result.useEvil).toBe(true); - expect(result.reason).toBe("purge"); - }); - - it("prefers purge window over random chance", () => { - const result = decideSoulEvil({ - config: { - chance: 0, - purge: { at: "00:00", duration: "10m" }, - }, - userTimezone: "UTC", - now: new Date("2026-01-01T00:05:00Z"), - random: () => 0, - }); - expect(result.useEvil).toBe(true); - expect(result.reason).toBe("purge"); - }); - - it("skips purge window when outside duration", () => { - const result = decideSoulEvil({ - config: { - purge: { at: "00:00", duration: "10m" }, - }, - userTimezone: "UTC", - now: new Date("2026-01-01T00:30:00Z"), - }); - expect(result.useEvil).toBe(false); - }); - - it("honors sub-minute purge durations", () => { - const config = { - purge: { at: "00:00", duration: "30s" }, - }; - const active = decideSoulEvil({ - config, - userTimezone: "UTC", - now: new Date("2026-01-01T00:00:20Z"), - }); - const inactive = decideSoulEvil({ - config, - userTimezone: "UTC", - now: new Date("2026-01-01T00:00:40Z"), - }); - expect(active.useEvil).toBe(true); - expect(active.reason).toBe("purge"); - expect(inactive.useEvil).toBe(false); - }); - - it("handles purge windows that wrap past midnight", () => { - const result = decideSoulEvil({ - config: { - purge: { at: "23:55", duration: "10m" }, - }, - userTimezone: "UTC", - now: new Date("2026-01-02T00:02:00Z"), - }); - expect(result.useEvil).toBe(true); - expect(result.reason).toBe("purge"); - }); - - it("clamps chance above 1", () => { - const result = decideSoulEvil({ - config: { chance: 2 }, - random: () => 0.5, - }); - expect(result.useEvil).toBe(true); - expect(result.reason).toBe("chance"); - }); -}); - -describe("applySoulEvilOverride", () => { - it("replaces SOUL content when evil is active and file exists", async () => { - const tempDir = await makeTempWorkspace("openclaw-soul-"); - await writeWorkspaceFile({ - dir: tempDir, - name: DEFAULT_SOUL_EVIL_FILENAME, - content: "chaotic", - }); - - const files = makeFiles({ - path: path.join(tempDir, DEFAULT_SOUL_FILENAME), - }); - - const updated = await applySoulEvilOverride({ - files, - workspaceDir: tempDir, - config: { chance: 1 }, - userTimezone: "UTC", - random: () => 0, - }); - - const soul = updated.find((file) => file.name === DEFAULT_SOUL_FILENAME); - expect(soul?.content).toBe("chaotic"); - }); - - it("leaves SOUL content when evil file is missing", async () => { - const tempDir = await makeTempWorkspace("openclaw-soul-"); - const files = makeFiles({ - path: path.join(tempDir, DEFAULT_SOUL_FILENAME), - }); - - const updated = await applySoulEvilOverride({ - files, - workspaceDir: tempDir, - config: { chance: 1 }, - userTimezone: "UTC", - random: () => 0, - }); - - const soul = updated.find((file) => file.name === DEFAULT_SOUL_FILENAME); - expect(soul?.content).toBe("friendly"); - }); - - it("uses custom evil filename when configured", async () => { - const tempDir = await makeTempWorkspace("openclaw-soul-"); - await writeWorkspaceFile({ - dir: tempDir, - name: "SOUL_EVIL_CUSTOM.md", - content: "chaotic", - }); - - const files = makeFiles({ - path: path.join(tempDir, DEFAULT_SOUL_FILENAME), - }); - - const updated = await applySoulEvilOverride({ - files, - workspaceDir: tempDir, - config: { chance: 1, file: "SOUL_EVIL_CUSTOM.md" }, - userTimezone: "UTC", - random: () => 0, - }); - - const soul = updated.find((file) => file.name === DEFAULT_SOUL_FILENAME); - expect(soul?.content).toBe("chaotic"); - }); - - it("warns and skips when evil file is empty", async () => { - const tempDir = await makeTempWorkspace("openclaw-soul-"); - await writeWorkspaceFile({ - dir: tempDir, - name: DEFAULT_SOUL_EVIL_FILENAME, - content: " ", - }); - - const warnings: string[] = []; - const files = makeFiles({ - path: path.join(tempDir, DEFAULT_SOUL_FILENAME), - }); - - const updated = await applySoulEvilOverride({ - files, - workspaceDir: tempDir, - config: { chance: 1 }, - userTimezone: "UTC", - random: () => 0, - log: { warn: (message) => warnings.push(message) }, - }); - - const soul = updated.find((file) => file.name === DEFAULT_SOUL_FILENAME); - expect(soul?.content).toBe("friendly"); - expect(warnings.some((message) => message.includes("file empty"))).toBe(true); - }); - - it("leaves files untouched when SOUL.md is not in bootstrap files", async () => { - const tempDir = await makeTempWorkspace("openclaw-soul-"); - await writeWorkspaceFile({ - dir: tempDir, - name: DEFAULT_SOUL_EVIL_FILENAME, - content: "chaotic", - }); - - const files: WorkspaceBootstrapFile[] = [ - { - name: "AGENTS.md", - path: path.join(tempDir, "AGENTS.md"), - content: "agents", - missing: false, - }, - ]; - - const updated = await applySoulEvilOverride({ - files, - workspaceDir: tempDir, - config: { chance: 1 }, - userTimezone: "UTC", - random: () => 0, - }); - - expect(updated).toEqual(files); - }); -}); - -describe("resolveSoulEvilConfigFromHook", () => { - it("returns null and warns when config is invalid", () => { - const warnings: string[] = []; - const result = resolveSoulEvilConfigFromHook( - { file: 42, chance: "nope", purge: "later" }, - { warn: (message) => warnings.push(message) }, - ); - expect(result).toBeNull(); - expect(warnings).toEqual([ - "soul-evil config: file must be a string", - "soul-evil config: chance must be a number", - "soul-evil config: purge must be an object", - ]); - }); -}); diff --git a/src/hooks/soul-evil.ts b/src/hooks/soul-evil.ts deleted file mode 100644 index fc1591737d2..00000000000 --- a/src/hooks/soul-evil.ts +++ /dev/null @@ -1,280 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import type { WorkspaceBootstrapFile } from "../agents/workspace.js"; -import { resolveUserTimezone } from "../agents/date-time.js"; -import { parseDurationMs } from "../cli/parse-duration.js"; -import { resolveUserPath } from "../utils.js"; - -export const DEFAULT_SOUL_EVIL_FILENAME = "SOUL_EVIL.md"; - -export type SoulEvilConfig = { - /** Alternate SOUL file name (default: SOUL_EVIL.md). */ - file?: string; - /** Random chance (0-1) to use SOUL_EVIL on any message. */ - chance?: number; - /** Daily purge window (static time each day). */ - purge?: { - /** Start time in 24h HH:mm format. */ - at?: string; - /** Duration (e.g. 30s, 10m, 1h). */ - duration?: string; - }; -}; - -type SoulEvilDecision = { - useEvil: boolean; - reason?: "purge" | "chance"; - fileName: string; -}; - -type SoulEvilCheckParams = { - config?: SoulEvilConfig; - userTimezone?: string; - now?: Date; - random?: () => number; -}; - -type SoulEvilLog = { - debug?: (message: string) => void; - warn?: (message: string) => void; -}; - -export function resolveSoulEvilConfigFromHook( - entry: Record | undefined, - log?: SoulEvilLog, -): SoulEvilConfig | null { - if (!entry) { - return null; - } - const file = typeof entry.file === "string" ? entry.file : undefined; - if (entry.file !== undefined && !file) { - log?.warn?.("soul-evil config: file must be a string"); - } - - let chance: number | undefined; - if (entry.chance !== undefined) { - if (typeof entry.chance === "number" && Number.isFinite(entry.chance)) { - chance = entry.chance; - } else { - log?.warn?.("soul-evil config: chance must be a number"); - } - } - - let purge: SoulEvilConfig["purge"]; - if (entry.purge && typeof entry.purge === "object") { - const at = - typeof (entry.purge as { at?: unknown }).at === "string" - ? (entry.purge as { at?: string }).at - : undefined; - const duration = - typeof (entry.purge as { duration?: unknown }).duration === "string" - ? (entry.purge as { duration?: string }).duration - : undefined; - if ((entry.purge as { at?: unknown }).at !== undefined && !at) { - log?.warn?.("soul-evil config: purge.at must be a string"); - } - if ((entry.purge as { duration?: unknown }).duration !== undefined && !duration) { - log?.warn?.("soul-evil config: purge.duration must be a string"); - } - purge = { at, duration }; - } else if (entry.purge !== undefined) { - log?.warn?.("soul-evil config: purge must be an object"); - } - - if (!file && chance === undefined && !purge) { - return null; - } - return { file, chance, purge }; -} - -function clampChance(value?: number): number { - if (typeof value !== "number" || !Number.isFinite(value)) { - return 0; - } - return Math.min(1, Math.max(0, value)); -} - -function parsePurgeAt(raw?: string): number | null { - if (!raw) { - return null; - } - const trimmed = raw.trim(); - const match = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(trimmed); - if (!match) { - return null; - } - const hour = Number.parseInt(match[1] ?? "", 10); - const minute = Number.parseInt(match[2] ?? "", 10); - if (!Number.isFinite(hour) || !Number.isFinite(minute)) { - return null; - } - return hour * 60 + minute; -} - -function timeOfDayMsInTimezone(date: Date, timeZone: string): number | null { - try { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone, - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hourCycle: "h23", - }).formatToParts(date); - const map: Record = {}; - for (const part of parts) { - if (part.type !== "literal") { - map[part.type] = part.value; - } - } - if (!map.hour || !map.minute || !map.second) { - return null; - } - const hour = Number.parseInt(map.hour, 10); - const minute = Number.parseInt(map.minute, 10); - const second = Number.parseInt(map.second, 10); - if (!Number.isFinite(hour) || !Number.isFinite(minute) || !Number.isFinite(second)) { - return null; - } - return (hour * 3600 + minute * 60 + second) * 1000 + date.getMilliseconds(); - } catch { - return null; - } -} - -function isWithinDailyPurgeWindow(params: { - at?: string; - duration?: string; - now: Date; - timeZone: string; -}): boolean { - if (!params.at || !params.duration) { - return false; - } - const startMinutes = parsePurgeAt(params.at); - if (startMinutes === null) { - return false; - } - - let durationMs: number; - try { - durationMs = parseDurationMs(params.duration, { defaultUnit: "m" }); - } catch { - return false; - } - if (!Number.isFinite(durationMs) || durationMs <= 0) { - return false; - } - - const dayMs = 24 * 60 * 60 * 1000; - if (durationMs >= dayMs) { - return true; - } - - const nowMs = timeOfDayMsInTimezone(params.now, params.timeZone); - if (nowMs === null) { - return false; - } - - const startMs = startMinutes * 60 * 1000; - const endMs = startMs + durationMs; - if (endMs < dayMs) { - return nowMs >= startMs && nowMs < endMs; - } - const wrappedEnd = endMs % dayMs; - return nowMs >= startMs || nowMs < wrappedEnd; -} - -export function decideSoulEvil(params: SoulEvilCheckParams): SoulEvilDecision { - const evil = params.config; - const fileName = evil?.file?.trim() || DEFAULT_SOUL_EVIL_FILENAME; - if (!evil) { - return { useEvil: false, fileName }; - } - - const timeZone = resolveUserTimezone(params.userTimezone); - const now = params.now ?? new Date(); - const inPurge = isWithinDailyPurgeWindow({ - at: evil.purge?.at, - duration: evil.purge?.duration, - now, - timeZone, - }); - if (inPurge) { - return { useEvil: true, reason: "purge", fileName }; - } - - const chance = clampChance(evil.chance); - if (chance > 0) { - const random = params.random ?? Math.random; - if (random() < chance) { - return { useEvil: true, reason: "chance", fileName }; - } - } - - return { useEvil: false, fileName }; -} - -export async function applySoulEvilOverride(params: { - files: WorkspaceBootstrapFile[]; - workspaceDir: string; - config?: SoulEvilConfig; - userTimezone?: string; - now?: Date; - random?: () => number; - log?: SoulEvilLog; -}): Promise { - const decision = decideSoulEvil({ - config: params.config, - userTimezone: params.userTimezone, - now: params.now, - random: params.random, - }); - if (!decision.useEvil) { - return params.files; - } - - const workspaceDir = resolveUserPath(params.workspaceDir); - const evilPath = path.join(workspaceDir, decision.fileName); - let evilContent: string; - try { - evilContent = await fs.readFile(evilPath, "utf-8"); - } catch { - params.log?.warn?.( - `SOUL_EVIL active (${decision.reason ?? "unknown"}) but file missing: ${evilPath}`, - ); - return params.files; - } - - if (!evilContent.trim()) { - params.log?.warn?.( - `SOUL_EVIL active (${decision.reason ?? "unknown"}) but file empty: ${evilPath}`, - ); - return params.files; - } - - const hasSoulEntry = params.files.some((file) => file.name === "SOUL.md"); - if (!hasSoulEntry) { - params.log?.warn?.( - `SOUL_EVIL active (${decision.reason ?? "unknown"}) but SOUL.md not in bootstrap files`, - ); - return params.files; - } - - let replaced = false; - const updated = params.files.map((file) => { - if (file.name !== "SOUL.md") { - return file; - } - replaced = true; - return { ...file, content: evilContent, missing: false }; - }); - if (!replaced) { - return params.files; - } - - params.log?.debug?.( - `SOUL_EVIL active (${decision.reason ?? "unknown"}) using ${decision.fileName}`, - ); - - return updated; -}