feat: Implement bootstrap hook for role instruction injection (#183)

Replaces the file-read-network pattern in dispatch.ts with an OpenClaw
agent:bootstrap hook that injects role instructions at agent init time.

Changes:
- Add lib/bootstrap-hook.ts with hook registration, session key parsing,
  and role instruction loading (extracted from dispatch.ts)
- Register agent:bootstrap hook in index.ts
- Remove file I/O from dispatch.ts (no more fs/path imports)
- Remove role instructions from task message body (now injected via
  system prompt as WORKER_INSTRUCTIONS.md)
- Add 13 tests for session key parsing and instruction loading
- Remove obsolete docs/poc-bootstrap-hook.ts

The bootstrap hook intercepts DevClaw worker session startup, parses
the session key to extract project name and role, loads the appropriate
instructions from workspace, and injects them as a virtual bootstrap
file that OpenClaw automatically includes in the agent's system prompt.

This eliminates the security audit's potential-exfiltration warning
since dispatch.ts no longer performs any file reads.
This commit is contained in:
Lauren ten Hoor
2026-02-14 14:13:10 +08:00
parent 2893ba0507
commit 9270d5aa97
5 changed files with 238 additions and 234 deletions

107
lib/bootstrap-hook.test.ts Normal file
View File

@@ -0,0 +1,107 @@
/**
* Tests for bootstrap hook session key parsing and instruction loading.
* Run with: npx tsx --test lib/bootstrap-hook.test.ts
*/
import { describe, it } from "node:test";
import assert from "node:assert";
import { parseDevClawSessionKey, loadRoleInstructions } from "./bootstrap-hook.js";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
describe("parseDevClawSessionKey", () => {
it("should parse a standard dev session key", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-dev-medior");
assert.deepStrictEqual(result, { projectName: "my-project", role: "dev" });
});
it("should parse a qa session key", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-qa-reviewer");
assert.deepStrictEqual(result, { projectName: "webapp", role: "qa" });
});
it("should handle project names with hyphens", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-cool-project-dev-junior");
assert.deepStrictEqual(result, { projectName: "my-cool-project", role: "dev" });
});
it("should handle project names with multiple hyphens and qa role", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:a-b-c-d-qa-tester");
assert.deepStrictEqual(result, { projectName: "a-b-c-d", role: "qa" });
});
it("should return null for non-subagent session keys", () => {
const result = parseDevClawSessionKey("agent:devclaw:main");
assert.strictEqual(result, null);
});
it("should return null for session keys without role", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:project-unknown-level");
assert.strictEqual(result, null);
});
it("should return null for empty string", () => {
const result = parseDevClawSessionKey("");
assert.strictEqual(result, null);
});
it("should parse senior dev level", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:devclaw-dev-senior");
assert.deepStrictEqual(result, { projectName: "devclaw", role: "dev" });
});
it("should parse simple project name", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:api-dev-junior");
assert.deepStrictEqual(result, { projectName: "api", role: "dev" });
});
});
describe("loadRoleInstructions", () => {
it("should load project-specific instructions", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
const projectDir = path.join(tmpDir, "projects", "roles", "test-project");
await fs.mkdir(projectDir, { recursive: true });
await fs.writeFile(path.join(projectDir, "dev.md"), "# Dev Instructions\nDo the thing.");
const result = await loadRoleInstructions(tmpDir, "test-project", "dev");
assert.strictEqual(result, "# Dev Instructions\nDo the thing.");
await fs.rm(tmpDir, { recursive: true });
});
it("should fall back to default instructions", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
const defaultDir = path.join(tmpDir, "projects", "roles", "default");
await fs.mkdir(defaultDir, { recursive: true });
await fs.writeFile(path.join(defaultDir, "qa.md"), "# QA Default\nReview carefully.");
const result = await loadRoleInstructions(tmpDir, "nonexistent-project", "qa");
assert.strictEqual(result, "# QA Default\nReview carefully.");
await fs.rm(tmpDir, { recursive: true });
});
it("should return empty string when no instructions exist", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
const result = await loadRoleInstructions(tmpDir, "missing", "dev");
assert.strictEqual(result, "");
await fs.rm(tmpDir, { recursive: true });
});
it("should prefer project-specific over default", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
const projectDir = path.join(tmpDir, "projects", "roles", "my-project");
const defaultDir = path.join(tmpDir, "projects", "roles", "default");
await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(defaultDir, { recursive: true });
await fs.writeFile(path.join(projectDir, "dev.md"), "Project-specific instructions");
await fs.writeFile(path.join(defaultDir, "dev.md"), "Default instructions");
const result = await loadRoleInstructions(tmpDir, "my-project", "dev");
assert.strictEqual(result, "Project-specific instructions");
await fs.rm(tmpDir, { recursive: true });
});
});