refactor: add hook guards and test helpers

This commit is contained in:
Peter Steinberger
2026-01-18 06:07:20 +00:00
parent 32dd052260
commit 28f8b7bafa
12 changed files with 145 additions and 50 deletions

View File

@@ -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");

View File

@@ -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;

View File

@@ -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,

View File

@@ -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({

View File

@@ -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();

View File

@@ -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: {

View File

@@ -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<string, unknown> | 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<string, unknown>);
const soulConfig = resolveSoulEvilConfigFromHook(hookConfig as Record<string, unknown>, {
warn: (message) => console.warn(`[soul-evil] ${message}`),
});
if (!soulConfig) return;
const workspaceDir = context.workspaceDir;

View File

@@ -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());

View File

@@ -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<AgentBootstrapHookContext> | null;
if (!context || typeof context !== "object") return false;
if (typeof context.workspaceDir !== "string") return false;
return Array.isArray(context.bootstrapFiles);
}

View File

@@ -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<WorkspaceBootstrapFile>) => [
{
@@ -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[] = [
{

View File

@@ -40,6 +40,50 @@ type SoulEvilLog = {
warn?: (message: string) => void;
};
export function resolveSoulEvilConfigFromHook(
entry: Record<string, unknown> | 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));

View File

@@ -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<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
export async function writeWorkspaceFile(params: {
dir: string;
name: string;
content: string;
}): Promise<string> {
const filePath = path.join(params.dir, params.name);
await fs.writeFile(filePath, params.content, "utf-8");
return filePath;
}