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 });
});
});

117
lib/bootstrap-hook.ts Normal file
View File

@@ -0,0 +1,117 @@
/**
* bootstrap-hook.ts — Agent bootstrap hook for injecting role instructions.
*
* Registers an `agent:bootstrap` hook that intercepts DevClaw worker session
* startup and injects role-specific instructions as a virtual workspace file.
*
* This eliminates the file-read-network-send pattern in dispatch.ts that
* triggered the security auditor's potential-exfiltration warning.
*/
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
/**
* Parse a DevClaw subagent session key to extract project name and role.
*
* Session key format: `agent:{agentId}:subagent:{projectName}-{role}-{level}`
* Examples:
* - `agent:devclaw:subagent:my-project-dev-medior` → { projectName: "my-project", role: "dev" }
* - `agent:devclaw:subagent:webapp-qa-reviewer` → { projectName: "webapp", role: "qa" }
*
* Note: projectName may contain hyphens, so we match role from the end.
*/
export function parseDevClawSessionKey(
sessionKey: string,
): { projectName: string; role: "dev" | "qa" } | null {
// Match `:subagent:` prefix, then capture everything up to the last `-dev-` or `-qa-`
const match = sessionKey.match(/:subagent:(.+)-(dev|qa)-[^-]+$/);
if (!match) return null;
return { projectName: match[1], role: match[2] as "dev" | "qa" };
}
/**
* Load role-specific instructions from workspace.
* Tries project-specific file first, then falls back to default.
*
* This is the same logic previously in dispatch.ts loadRoleInstructions(),
* now called from the bootstrap hook instead of during dispatch.
*/
export async function loadRoleInstructions(
workspaceDir: string,
projectName: string,
role: "dev" | "qa",
): Promise<string> {
const projectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`);
try {
return await fs.readFile(projectFile, "utf-8");
} catch {
/* not found — try default */
}
const defaultFile = path.join(workspaceDir, "projects", "roles", "default", `${role}.md`);
try {
return await fs.readFile(defaultFile, "utf-8");
} catch {
/* not found */
}
return "";
}
/**
* Register the agent:bootstrap hook for DevClaw worker instruction injection.
*
* When a DevClaw worker session starts, this hook:
* 1. Detects it's a DevClaw subagent via session key pattern
* 2. Extracts project name and role
* 3. Loads role-specific instructions from workspace
* 4. Injects them as a virtual workspace file (WORKER_INSTRUCTIONS.md)
*
* OpenClaw automatically includes bootstrap files in the agent's system prompt,
* so workers receive their instructions without any file-read in dispatch.ts.
*/
export function registerBootstrapHook(api: OpenClawPluginApi): void {
api.registerHook("agent:bootstrap", async (event) => {
const sessionKey = event.sessionKey;
if (!sessionKey) return;
const parsed = parseDevClawSessionKey(sessionKey);
if (!parsed) return;
const context = event.context as {
workspaceDir?: string;
bootstrapFiles?: Array<{
name: string;
path: string;
content?: string;
missing: boolean;
}>;
};
const workspaceDir = context.workspaceDir;
if (!workspaceDir || typeof workspaceDir !== "string") return;
const bootstrapFiles = context.bootstrapFiles;
if (!Array.isArray(bootstrapFiles)) return;
const instructions = await loadRoleInstructions(
workspaceDir,
parsed.projectName,
parsed.role,
);
if (!instructions) return;
// Inject as a virtual bootstrap file. OpenClaw includes these in the
// agent's system prompt automatically (via buildBootstrapContextFiles).
bootstrapFiles.push({
name: "WORKER_INSTRUCTIONS.md" as any,
path: `<devclaw:${parsed.projectName}:${parsed.role}>`,
content: instructions.trim(),
missing: false,
});
api.logger.info(
`Bootstrap hook: injected ${parsed.role} instructions for project "${parsed.projectName}"`,
);
});
}

View File

@@ -4,8 +4,6 @@
* Handles: session lookup, spawn/reuse via Gateway RPC, task dispatch via CLI,
* state update (activateWorker), and audit logging.
*/
import fs from "node:fs/promises";
import path from "node:path";
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { log as auditLog } from "./audit.js";
import { runCommand } from "./run-command.js";
@@ -58,10 +56,12 @@ export type DispatchResult = {
/**
* Build the task message sent to a worker session.
* Reads role-specific instructions from workspace/projects/roles/<project>/<role>.md (falls back to projects/roles/default/).
*
* Role-specific instructions are no longer included in the message body.
* They are injected via the agent:bootstrap hook (see bootstrap-hook.ts)
* into the worker's system prompt as WORKER_INSTRUCTIONS.md.
*/
export async function buildTaskMessage(opts: {
workspaceDir: string;
export function buildTaskMessage(opts: {
projectName: string;
role: "dev" | "qa";
issueId: number;
@@ -72,14 +72,12 @@ export async function buildTaskMessage(opts: {
baseBranch: string;
groupId: string;
comments?: Array<{ author: string; body: string; created_at: string }>;
}): Promise<string> {
}): string {
const {
workspaceDir, projectName, role, issueId, issueTitle,
projectName, role, issueId, issueTitle,
issueDescription, issueUrl, repo, baseBranch, groupId,
} = opts;
const roleInstructions = await loadRoleInstructions(workspaceDir, projectName, role);
const availableResults =
role === "dev"
? '"done" (completed successfully) or "blocked" (cannot complete, need help)'
@@ -109,10 +107,6 @@ export async function buildTaskMessage(opts: {
`Project group ID: ${groupId}`,
);
if (roleInstructions) {
parts.push(``, `---`, ``, roleInstructions.trim());
}
parts.push(
``, `---`, ``,
`## MANDATORY: Task Completion`,
@@ -166,8 +160,8 @@ export async function dispatchTask(
// Fetch comments to include in task context
const comments = await provider.listComments(issueId);
const taskMessage = await buildTaskMessage({
workspaceDir, projectName: project.name, role, issueId,
const taskMessage = buildTaskMessage({
projectName: project.name, role, issueId,
issueTitle, issueDescription, issueUrl,
repo: project.repo, baseBranch: project.baseBranch, groupId,
comments,
@@ -240,21 +234,6 @@ export async function dispatchTask(
// Private helpers — exist so dispatchTask reads as a sequence of steps
// ---------------------------------------------------------------------------
/**
* Load role-specific instructions from workspace and include them in the task message.
* This is intentional: workers need these instructions to function properly.
* (Not data exfiltration — just standard task dispatch context.)
*/
async function loadRoleInstructions(
workspaceDir: string, projectName: string, role: "dev" | "qa",
): Promise<string> {
const projectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`);
try { return await fs.readFile(projectFile, "utf-8"); } catch { /* none */ }
const defaultFile = path.join(workspaceDir, "projects", "roles", "default", `${role}.md`);
try { return await fs.readFile(defaultFile, "utf-8"); } catch { /* none */ }
return "";
}
/**
* Fire-and-forget session creation/update.
* Session key is deterministic, so we don't need to wait for confirmation.