diff --git a/src/agents/bootstrap-files.test.ts b/src/agents/bootstrap-files.test.ts index 59f67d438..272389118 100644 --- a/src/agents/bootstrap-files.test.ts +++ b/src/agents/bootstrap-files.test.ts @@ -1,10 +1,12 @@ -import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { resolveBootstrapContextForRun, resolveBootstrapFilesForRun } from "./bootstrap-files.js"; +import { + resolveBootstrapContextForRun, + resolveBootstrapFilesForRun, +} from "./bootstrap-files.js"; +import { makeTempWorkspace } from "../test-helpers/workspace.js"; import { clearInternalHooks, registerInternalHook, @@ -29,7 +31,7 @@ describe("resolveBootstrapFilesForRun", () => { ]; }); - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-bootstrap-")); + const workspaceDir = await makeTempWorkspace("clawdbot-bootstrap-"); const files = await resolveBootstrapFilesForRun({ workspaceDir }); expect(files.some((file) => file.name === "EXTRA.md")).toBe(true); @@ -54,7 +56,7 @@ describe("resolveBootstrapContextForRun", () => { ]; }); - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-bootstrap-")); + const workspaceDir = await makeTempWorkspace("clawdbot-bootstrap-"); const result = await resolveBootstrapContextForRun({ workspaceDir }); const extra = result.contextFiles.find((file) => file.path === "EXTRA.md"); diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts index 326737e3d..ff512ed0f 100644 --- a/src/agents/bootstrap-files.ts +++ b/src/agents/bootstrap-files.ts @@ -8,6 +8,14 @@ import { import { buildBootstrapContextFiles, resolveBootstrapMaxChars } from "./pi-embedded-helpers.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; +export function makeBootstrapWarn(params: { + sessionLabel: string; + warn?: (message: string) => void; +}): ((message: string) => void) | undefined { + if (!params.warn) return undefined; + return (message: string) => params.warn?.(`${message} (sessionKey=${params.sessionLabel})`); +} + export async function resolveBootstrapFilesForRun(params: { workspaceDir: string; config?: ClawdbotConfig; diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index ddffda56a..e4dce5dba 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -7,7 +7,7 @@ import { createSubsystemLogger } from "../logging.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { resolveUserPath } from "../utils.js"; import { resolveSessionAgentIds } from "./agent-scope.js"; -import { resolveBootstrapContextForRun } from "./bootstrap-files.js"; +import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js"; import { resolveCliBackendConfig } from "./cli-backends.js"; import { appendImagePathsToPrompt, @@ -73,7 +73,7 @@ export async function runCliAgent(params: { config: params.config, sessionKey: params.sessionKey, sessionId: params.sessionId, - warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`), + warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }), }); const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({ sessionKey: params.sessionKey, diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 7fe5d1e09..d06a751ef 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -16,7 +16,7 @@ import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { resolveUserPath } from "../../utils.js"; import { resolveClawdbotAgentDir } from "../agent-paths.js"; import { resolveSessionAgentIds } from "../agent-scope.js"; -import { resolveBootstrapContextForRun } from "../bootstrap-files.js"; +import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js"; import type { ExecElevatedDefaults } from "../bash-tools.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; import { getApiKeyForModel, resolveModelAuthMode } from "../model-auth.js"; @@ -181,7 +181,7 @@ export async function compactEmbeddedPiSession(params: { config: params.config, sessionKey: params.sessionKey, sessionId: params.sessionId, - warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`), + warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }), }); const runAbortController = new AbortController(); const toolsRaw = createClawdbotCodingTools({ diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index bf7590e70..d9fa512aa 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -17,7 +17,7 @@ import { isSubagentSessionKey } from "../../../routing/session-key.js"; import { resolveUserPath } from "../../../utils.js"; import { resolveClawdbotAgentDir } from "../../agent-paths.js"; import { resolveSessionAgentIds } from "../../agent-scope.js"; -import { resolveBootstrapContextForRun } from "../../bootstrap-files.js"; +import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js"; import { resolveModelAuthMode } from "../../model-auth.js"; import { isCloudCodeAssistFormatError, @@ -126,7 +126,7 @@ export async function runEmbeddedAttempt( config: params.config, sessionKey: params.sessionKey, sessionId: params.sessionId, - warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`), + warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }), }); const agentDir = params.agentDir ?? resolveClawdbotAgentDir(); diff --git a/src/hooks/bundled/soul-evil/handler.test.ts b/src/hooks/bundled/soul-evil/handler.test.ts index 1e4674517..efba74e3d 100644 --- a/src/hooks/bundled/soul-evil/handler.test.ts +++ b/src/hooks/bundled/soul-evil/handler.test.ts @@ -1,5 +1,3 @@ -import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -8,11 +6,16 @@ import handler from "./handler.js"; import { createHookEvent } from "../../hooks.js"; import type { AgentBootstrapHookContext } from "../../hooks.js"; import type { ClawdbotConfig } from "../../../config/config.js"; +import { makeTempWorkspace, writeWorkspaceFile } from "../../../test-helpers/workspace.js"; describe("soul-evil hook", () => { it("skips subagent sessions", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-soul-")); - await fs.writeFile(path.join(tempDir, "SOUL_EVIL.md"), "chaotic", "utf-8"); + const tempDir = await makeTempWorkspace("clawdbot-soul-"); + await writeWorkspaceFile({ + dir: tempDir, + name: "SOUL_EVIL.md", + content: "chaotic", + }); const cfg: ClawdbotConfig = { hooks: { diff --git a/src/hooks/bundled/soul-evil/handler.ts b/src/hooks/bundled/soul-evil/handler.ts index cca6694ee..3b3d5446f 100644 --- a/src/hooks/bundled/soul-evil/handler.ts +++ b/src/hooks/bundled/soul-evil/handler.ts @@ -1,42 +1,26 @@ import type { ClawdbotConfig } from "../../../config/config.js"; import { isSubagentSessionKey } from "../../../routing/session-key.js"; import { resolveHookConfig } from "../../config.js"; -import type { AgentBootstrapHookContext, HookHandler } from "../../hooks.js"; -import { applySoulEvilOverride, type SoulEvilConfig } from "../../soul-evil.js"; +import { isAgentBootstrapEvent, type HookHandler } from "../../hooks.js"; +import { + applySoulEvilOverride, + resolveSoulEvilConfigFromHook, +} from "../../soul-evil.js"; const HOOK_KEY = "soul-evil"; -function resolveSoulEvilConfig(entry: Record | undefined): SoulEvilConfig | null { - if (!entry) return null; - const file = typeof entry.file === "string" ? entry.file : undefined; - const chance = typeof entry.chance === "number" ? entry.chance : undefined; - const purge = - entry.purge && typeof entry.purge === "object" - ? { - at: - typeof (entry.purge as { at?: unknown }).at === "string" - ? (entry.purge as { at?: string }).at - : undefined, - duration: - typeof (entry.purge as { duration?: unknown }).duration === "string" - ? (entry.purge as { duration?: string }).duration - : undefined, - } - : undefined; - if (!file && chance === undefined && !purge) return null; - return { file, chance, purge }; -} - const soulEvilHook: HookHandler = async (event) => { - if (event.type !== "agent" || event.action !== "bootstrap") return; + if (!isAgentBootstrapEvent(event)) return; - const context = event.context as AgentBootstrapHookContext; + const context = event.context; if (context.sessionKey && isSubagentSessionKey(context.sessionKey)) return; const cfg = context.cfg as ClawdbotConfig | undefined; const hookConfig = resolveHookConfig(cfg, HOOK_KEY); if (!hookConfig || hookConfig.enabled === false) return; - const soulConfig = resolveSoulEvilConfig(hookConfig as Record); + const soulConfig = resolveSoulEvilConfigFromHook(hookConfig as Record, { + warn: (message) => console.warn(`[soul-evil] ${message}`), + }); if (!soulConfig) return; const workspaceDir = context.workspaceDir; diff --git a/src/hooks/internal-hooks.test.ts b/src/hooks/internal-hooks.test.ts index 4b9d983bd..e01e5bc3c 100644 --- a/src/hooks/internal-hooks.test.ts +++ b/src/hooks/internal-hooks.test.ts @@ -3,9 +3,11 @@ import { clearInternalHooks, createInternalHookEvent, getRegisteredEventKeys, + isAgentBootstrapEvent, registerInternalHook, triggerInternalHook, unregisterInternalHook, + type AgentBootstrapHookContext, type InternalHookEvent, } from "./internal-hooks.js"; @@ -164,6 +166,22 @@ describe("hooks", () => { }); }); + describe("isAgentBootstrapEvent", () => { + it("returns true for agent:bootstrap events with expected context", () => { + const context: AgentBootstrapHookContext = { + workspaceDir: "/tmp", + bootstrapFiles: [], + }; + const event = createInternalHookEvent("agent", "bootstrap", "test-session", context); + expect(isAgentBootstrapEvent(event)).toBe(true); + }); + + it("returns false for non-bootstrap events", () => { + const event = createInternalHookEvent("command", "new", "test-session"); + expect(isAgentBootstrapEvent(event)).toBe(false); + }); + }); + describe("getRegisteredEventKeys", () => { it("should return all registered event keys", () => { registerInternalHook("command:new", vi.fn()); diff --git a/src/hooks/internal-hooks.ts b/src/hooks/internal-hooks.ts index adb652d88..2de74c6a3 100644 --- a/src/hooks/internal-hooks.ts +++ b/src/hooks/internal-hooks.ts @@ -19,6 +19,12 @@ export type AgentBootstrapHookContext = { agentId?: string; }; +export type AgentBootstrapHookEvent = InternalHookEvent & { + type: "agent"; + action: "bootstrap"; + context: AgentBootstrapHookContext; +}; + export interface InternalHookEvent { /** The type of event (command, session, agent, etc.) */ type: InternalHookEventType; @@ -159,3 +165,11 @@ export function createInternalHookEvent( messages: [], }; } + +export function isAgentBootstrapEvent(event: InternalHookEvent): event is AgentBootstrapHookEvent { + if (event.type !== "agent" || event.action !== "bootstrap") return false; + const context = event.context as Partial | null; + if (!context || typeof context !== "object") return false; + if (typeof context.workspaceDir !== "string") return false; + return Array.isArray(context.bootstrapFiles); +} diff --git a/src/hooks/soul-evil.test.ts b/src/hooks/soul-evil.test.ts index f89ecdd23..fa86abd86 100644 --- a/src/hooks/soul-evil.test.ts +++ b/src/hooks/soul-evil.test.ts @@ -1,11 +1,10 @@ -import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { applySoulEvilOverride, decideSoulEvil, DEFAULT_SOUL_EVIL_FILENAME } from "./soul-evil.js"; import { DEFAULT_SOUL_FILENAME, type WorkspaceBootstrapFile } from "../agents/workspace.js"; +import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js"; const makeFiles = (overrides?: Partial) => [ { @@ -91,9 +90,12 @@ describe("decideSoulEvil", () => { describe("applySoulEvilOverride", () => { it("replaces SOUL content when evil is active and file exists", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-soul-")); - const evilPath = path.join(tempDir, DEFAULT_SOUL_EVIL_FILENAME); - await fs.writeFile(evilPath, "chaotic", "utf-8"); + const tempDir = await makeTempWorkspace("clawdbot-soul-"); + await writeWorkspaceFile({ + dir: tempDir, + name: DEFAULT_SOUL_EVIL_FILENAME, + content: "chaotic", + }); const files = makeFiles({ path: path.join(tempDir, DEFAULT_SOUL_FILENAME), @@ -112,7 +114,7 @@ describe("applySoulEvilOverride", () => { }); it("leaves SOUL content when evil file is missing", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-soul-")); + const tempDir = await makeTempWorkspace("clawdbot-soul-"); const files = makeFiles({ path: path.join(tempDir, DEFAULT_SOUL_FILENAME), }); @@ -130,9 +132,12 @@ describe("applySoulEvilOverride", () => { }); it("leaves files untouched when SOUL.md is not in bootstrap files", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-soul-")); - const evilPath = path.join(tempDir, DEFAULT_SOUL_EVIL_FILENAME); - await fs.writeFile(evilPath, "chaotic", "utf-8"); + const tempDir = await makeTempWorkspace("clawdbot-soul-"); + await writeWorkspaceFile({ + dir: tempDir, + name: DEFAULT_SOUL_EVIL_FILENAME, + content: "chaotic", + }); const files: WorkspaceBootstrapFile[] = [ { diff --git a/src/hooks/soul-evil.ts b/src/hooks/soul-evil.ts index 32914f779..934d0a48c 100644 --- a/src/hooks/soul-evil.ts +++ b/src/hooks/soul-evil.ts @@ -40,6 +40,50 @@ type SoulEvilLog = { 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)); diff --git a/src/test-helpers/workspace.ts b/src/test-helpers/workspace.ts new file mode 100644 index 000000000..52f08ad3a --- /dev/null +++ b/src/test-helpers/workspace.ts @@ -0,0 +1,17 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export async function makeTempWorkspace(prefix = "clawdbot-workspace-"): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +export async function writeWorkspaceFile(params: { + dir: string; + name: string; + content: string; +}): Promise { + const filePath = path.join(params.dir, params.name); + await fs.writeFile(filePath, params.content, "utf-8"); + return filePath; +}