diff --git a/docs/poc-bootstrap-hook.ts b/docs/poc-bootstrap-hook.ts deleted file mode 100644 index e1ce01b..0000000 --- a/docs/poc-bootstrap-hook.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * Proof of Concept: Bootstrap Hook for Role Instruction Injection - * - * This demonstrates how devclaw can use OpenClaw's agent:bootstrap hook - * to inject role instructions without triggering file-read-network audit patterns. - */ - -// NOTE: This is a PoC snippet. In production, this would go in index.ts register() - -import type { InternalHookEvent } from "openclaw/hooks/internal-hooks"; -import type { WorkspaceBootstrapFile } from "openclaw/agents/workspace"; -import fs from "node:fs/promises"; -import path from "node:path"; - -/** - * Helper: Check if hook event is an agent bootstrap event - */ -function isAgentBootstrapEvent(event: InternalHookEvent): boolean { - if (event.type !== "agent" || event.action !== "bootstrap") { - return false; - } - const context = event.context as Record; - return ( - typeof context.workspaceDir === "string" && - Array.isArray(context.bootstrapFiles) - ); -} - -/** - * Helper: Parse DevClaw worker sessionKey - * Format: subagent:agentId/projectName/role/level - * Example: subagent:devclaw/my-app/dev/medior - */ -function parseWorkerSession(sessionKey?: string): { - projectName: string; - role: "dev" | "qa"; -} | null { - if (!sessionKey) return null; - - const match = sessionKey.match(/^subagent:[^/]+\/([^/]+)\/(dev|qa)/); - if (!match) return null; - - const [_, projectName, role] = match; - return { projectName, role: role as "dev" | "qa" }; -} - -/** - * Load role instructions (same logic as current dispatch.ts) - */ -async function loadRoleInstructions( - workspaceDir: string, - projectName: string, - role: "dev" | "qa" -): Promise { - // Try project-specific instructions first - const projectFile = path.join( - workspaceDir, - "projects", - "roles", - projectName, - `${role}.md` - ); - - try { - return await fs.readFile(projectFile, "utf-8"); - } catch { - // Fallback to default instructions - const defaultFile = path.join( - workspaceDir, - "projects", - "roles", - "default", - `${role}.md` - ); - - try { - return await fs.readFile(defaultFile, "utf-8"); - } catch { - return ""; // No instructions found - } - } -} - -/** - * Bootstrap hook handler for DevClaw role instruction injection - * - * This runs during agent initialization (system prompt construction). - * It injects role-specific instructions as a virtual workspace file. - * - * Benefits: - * - No file-read-network pattern in dispatch code - * - Instructions appear in system prompt automatically - * - Session-aware dynamic injection - * - Zero changes to OpenClaw core - */ -export async function devclawBootstrapHook(event: InternalHookEvent): Promise { - // Validate event type - if (!isAgentBootstrapEvent(event)) { - return; - } - - const context = event.context as { - workspaceDir: string; - bootstrapFiles: WorkspaceBootstrapFile[]; - sessionKey?: string; - sessionId?: string; - agentId?: string; - }; - - const { workspaceDir, bootstrapFiles, sessionKey } = context; - - // Check if this is a DevClaw worker session - const parsed = parseWorkerSession(sessionKey); - if (!parsed) { - // Not a DevClaw worker, skip - return; - } - - const { projectName, role } = parsed; - - // Load role instructions - const instructions = await loadRoleInstructions(workspaceDir, projectName, role); - - if (!instructions) { - // No instructions found (not an error, just no custom instructions) - console.warn( - `[DevClaw] No role instructions found for ${projectName}/${role} ` + - `(checked projects/roles/${projectName}/${role}.md and default/${role}.md)` - ); - return; - } - - // Inject as virtual workspace file - bootstrapFiles.push({ - name: "WORKER_INSTRUCTIONS.md", - path: ``, // Virtual path marker - content: instructions, - missing: false, - }); - - console.log( - `[DevClaw] ✅ Injected ${instructions.length} chars of ${role.toUpperCase()} ` + - `instructions for project "${projectName}" via bootstrap hook` - ); -} - -// ============================================================================ -// USAGE EXAMPLE (in index.ts) -// ============================================================================ - -/* -import { registerInternalHook } from "openclaw/hooks/internal-hooks"; -import { devclawBootstrapHook } from "./lib/bootstrap-hook.js"; - -export default { - id: "devclaw", - name: "DevClaw", - // ... config ... - - register(api: OpenClawPluginApi) { - // ... existing tool/CLI/service registration ... - - // Register bootstrap hook for role instruction injection - registerInternalHook("agent:bootstrap", devclawBootstrapHook); - - api.logger.info( - "DevClaw plugin registered (11 tools, 1 CLI, 1 service, 1 hook)" - ); - }, -}; -*/ - -// ============================================================================ -// TESTING -// ============================================================================ - -/* -# 1. Start a DevClaw worker session -openclaw session create subagent:devclaw/my-app/dev/medior --model claude-sonnet-4 - -# 2. Check system prompt includes instructions -openclaw session context subagent:devclaw/my-app/dev/medior - -# 3. Verify WORKER_INSTRUCTIONS.md appears in workspace files section - -# 4. Dispatch a task and verify worker behavior unchanged -devclaw work start --project my-app --issue 42 --role dev --level medior -*/ - -// ============================================================================ -// MIGRATION CHECKLIST -// ============================================================================ - -/* -[ ] Create lib/bootstrap-hook.ts with devclawBootstrapHook -[ ] Register hook in index.ts register() -[ ] Remove loadRoleInstructions from lib/dispatch.ts -[ ] Remove roleInstructions from buildTaskMessage -[ ] Update tests to verify hook injection -[ ] Run security audit to confirm no false positive -[ ] Update AGENTS.md to document injection mechanism -[ ] Test dev + qa workflows end-to-end -*/ diff --git a/index.ts b/index.ts index 46131e9..9bff56e 100644 --- a/index.ts +++ b/index.ts @@ -12,6 +12,7 @@ import { createOnboardTool } from "./lib/tools/onboard.js"; import { createAutoConfigureModelsTool } from "./lib/tools/autoconfigure-models.js"; import { registerCli } from "./lib/cli.js"; import { registerHeartbeatService } from "./lib/services/heartbeat.js"; +import { registerBootstrapHook } from "./lib/bootstrap-hook.js"; import { initRunCommand } from "./lib/run-command.js"; const plugin = { @@ -119,8 +120,11 @@ const plugin = { // Services registerHeartbeatService(api); + // Bootstrap hook for worker instruction injection + registerBootstrapHook(api); + api.logger.info( - "DevClaw plugin registered (11 tools, 1 CLI command group, 1 service)", + "DevClaw plugin registered (11 tools, 1 CLI command group, 1 service, 1 hook)", ); }, }; diff --git a/lib/bootstrap-hook.test.ts b/lib/bootstrap-hook.test.ts new file mode 100644 index 0000000..008dd99 --- /dev/null +++ b/lib/bootstrap-hook.test.ts @@ -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 }); + }); +}); diff --git a/lib/bootstrap-hook.ts b/lib/bootstrap-hook.ts new file mode 100644 index 0000000..c50955e --- /dev/null +++ b/lib/bootstrap-hook.ts @@ -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 { + 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: ``, + content: instructions.trim(), + missing: false, + }); + + api.logger.info( + `Bootstrap hook: injected ${parsed.role} instructions for project "${parsed.projectName}"`, + ); + }); +} diff --git a/lib/dispatch.ts b/lib/dispatch.ts index db53b7c..1c01cb2 100644 --- a/lib/dispatch.ts +++ b/lib/dispatch.ts @@ -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//.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 { 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 { - 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.