From 3369a9e685cbc625080b9f9df6589b5fbc94e045 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 6 Mar 2026 08:17:42 -0600 Subject: [PATCH] feat(openclaw): add adapter hire-approved hooks --- packages/adapter-utils/src/index.ts | 2 + packages/adapter-utils/src/types.ts | 29 +++ packages/adapters/openclaw/src/index.ts | 6 + .../adapters/openclaw/src/server/hire-hook.ts | 77 ++++++++ .../adapters/openclaw/src/server/index.ts | 1 + server/src/__tests__/hire-hook.test.ts | 180 ++++++++++++++++++ server/src/__tests__/openclaw-adapter.test.ts | 77 +++++++- server/src/adapters/registry.ts | 2 + server/src/routes/access.ts | 12 +- server/src/services/approvals.ts | 15 +- server/src/services/hire-hook.ts | 113 +++++++++++ server/src/services/index.ts | 1 + 12 files changed, 512 insertions(+), 3 deletions(-) create mode 100644 packages/adapters/openclaw/src/server/hire-hook.ts create mode 100644 server/src/__tests__/hire-hook.test.ts create mode 100644 server/src/services/hire-hook.ts diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index cfc1bc8d..83605307 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -13,6 +13,8 @@ export type { AdapterEnvironmentTestContext, AdapterSessionCodec, AdapterModel, + HireApprovedPayload, + HireApprovedHookResult, ServerAdapterModule, TranscriptEntry, StdoutLineParser, diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 5170d9cd..bf9b7748 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -119,6 +119,27 @@ export interface AdapterEnvironmentTestContext { }; } +/** Payload for the onHireApproved adapter lifecycle hook (e.g. join-request or hire_agent approval). */ +export interface HireApprovedPayload { + companyId: string; + agentId: string; + agentName: string; + adapterType: string; + /** "join_request" | "approval" */ + source: "join_request" | "approval"; + sourceId: string; + approvedAt: string; + /** Canonical operator-facing message for cloud adapters to show the user. */ + message: string; +} + +/** Result of onHireApproved hook; failures are non-fatal to the approval flow. */ +export interface HireApprovedHookResult { + ok: boolean; + error?: string; + detail?: Record; +} + export interface ServerAdapterModule { type: string; execute(ctx: AdapterExecutionContext): Promise; @@ -128,6 +149,14 @@ export interface ServerAdapterModule { models?: AdapterModel[]; listModels?: () => Promise; agentConfigurationDoc?: string; + /** + * Optional lifecycle hook when an agent is approved/hired (join-request or hire_agent approval). + * adapterConfig is the agent's adapter config so the adapter can e.g. send a callback to a configured URL. + */ + onHireApproved?: ( + payload: HireApprovedPayload, + adapterConfig: Record, + ) => Promise; } // --------------------------------------------------------------------------- diff --git a/packages/adapters/openclaw/src/index.ts b/packages/adapters/openclaw/src/index.ts index 88968ee1..96e70a1d 100644 --- a/packages/adapters/openclaw/src/index.ts +++ b/packages/adapters/openclaw/src/index.ts @@ -29,4 +29,10 @@ Session routing fields: Operational fields: - timeoutSec (number, optional): SSE request timeout in seconds (default 0 = no adapter timeout) + +Hire-approved callback fields (optional): +- hireApprovedCallbackUrl (string): callback endpoint invoked when this agent is approved/hired +- hireApprovedCallbackMethod (string): HTTP method for the callback (default POST) +- hireApprovedCallbackAuthHeader (string): Authorization header value for callback requests +- hireApprovedCallbackHeaders (object): extra headers merged into callback requests `; diff --git a/packages/adapters/openclaw/src/server/hire-hook.ts b/packages/adapters/openclaw/src/server/hire-hook.ts new file mode 100644 index 00000000..2b6262c9 --- /dev/null +++ b/packages/adapters/openclaw/src/server/hire-hook.ts @@ -0,0 +1,77 @@ +import type { HireApprovedPayload, HireApprovedHookResult } from "@paperclipai/adapter-utils"; +import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils"; + +const HIRE_CALLBACK_TIMEOUT_MS = 10_000; + +function nonEmpty(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +/** + * OpenClaw adapter lifecycle hook: when an agent is approved/hired, POST the payload to a + * configured callback URL so the cloud operator can notify the user (e.g. "you're hired"). + * Best-effort; failures are non-fatal to the approval flow. + */ +export async function onHireApproved( + payload: HireApprovedPayload, + adapterConfig: Record, +): Promise { + const config = parseObject(adapterConfig); + const url = nonEmpty(config.hireApprovedCallbackUrl); + if (!url) { + return { ok: true }; + } + + const method = (asString(config.hireApprovedCallbackMethod, "POST").trim().toUpperCase()) || "POST"; + const authHeader = nonEmpty(config.hireApprovedCallbackAuthHeader) ?? nonEmpty(config.webhookAuthHeader); + + const headers: Record = { + "content-type": "application/json", + }; + if (authHeader && !headers.authorization && !headers.Authorization) { + headers.Authorization = authHeader; + } + const extraHeaders = parseObject(config.hireApprovedCallbackHeaders) as Record; + for (const [key, value] of Object.entries(extraHeaders)) { + if (typeof value === "string" && value.trim().length > 0) { + headers[key] = value; + } + } + + const body = JSON.stringify({ + ...payload, + event: "hire_approved", + }); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), HIRE_CALLBACK_TIMEOUT_MS); + + try { + const response = await fetch(url, { + method, + headers, + body, + signal: controller.signal, + }); + clearTimeout(timeout); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + return { + ok: false, + error: `HTTP ${response.status} ${response.statusText}`, + detail: { status: response.status, statusText: response.statusText, body: text.slice(0, 500) }, + }; + } + return { ok: true }; + } catch (err) { + clearTimeout(timeout); + const message = err instanceof Error ? err.message : String(err); + const cause = err instanceof Error ? err.cause : undefined; + return { + ok: false, + error: message, + detail: cause != null ? { cause: String(cause) } : undefined, + }; + } +} diff --git a/packages/adapters/openclaw/src/server/index.ts b/packages/adapters/openclaw/src/server/index.ts index b44c258b..05c4b355 100644 --- a/packages/adapters/openclaw/src/server/index.ts +++ b/packages/adapters/openclaw/src/server/index.ts @@ -1,3 +1,4 @@ export { execute } from "./execute.js"; export { testEnvironment } from "./test.js"; export { parseOpenClawResponse, isOpenClawUnknownSessionError } from "./parse.js"; +export { onHireApproved } from "./hire-hook.js"; diff --git a/server/src/__tests__/hire-hook.test.ts b/server/src/__tests__/hire-hook.test.ts new file mode 100644 index 00000000..3161949a --- /dev/null +++ b/server/src/__tests__/hire-hook.test.ts @@ -0,0 +1,180 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Db } from "@paperclipai/db"; +import { notifyHireApproved } from "../services/hire-hook.js"; + +// Mock the registry so we control whether the adapter has onHireApproved and what it does. +vi.mock("../adapters/registry.js", () => ({ + findServerAdapter: vi.fn(), +})); + +vi.mock("../services/activity-log.js", () => ({ + logActivity: vi.fn().mockResolvedValue(undefined), +})); + +const { findServerAdapter } = await import("../adapters/registry.js"); +const { logActivity } = await import("../services/activity-log.js"); + +function mockDbWithAgent(agent: { id: string; companyId: string; name: string; adapterType: string; adapterConfig?: Record }): Db { + return { + select: () => ({ + from: () => ({ + where: () => + Promise.resolve([ + { + id: agent.id, + companyId: agent.companyId, + name: agent.name, + adapterType: agent.adapterType, + adapterConfig: agent.adapterConfig ?? {}, + }, + ]), + }), + }), + } as unknown as Db; +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("notifyHireApproved", () => { + it("writes success activity when adapter hook returns ok", async () => { + vi.mocked(findServerAdapter).mockReturnValue({ + type: "openclaw", + onHireApproved: vi.fn().mockResolvedValue({ ok: true }), + } as any); + + const db = mockDbWithAgent({ + id: "a1", + companyId: "c1", + name: "OpenClaw Agent", + adapterType: "openclaw", + }); + + await expect( + notifyHireApproved(db, { + companyId: "c1", + agentId: "a1", + source: "approval", + sourceId: "ap1", + }), + ).resolves.toBeUndefined(); + + expect(logActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "hire_hook.succeeded", + entityId: "a1", + details: expect.objectContaining({ source: "approval", sourceId: "ap1", adapterType: "openclaw" }), + }), + ); + }); + + it("does nothing when agent is not found", async () => { + const db = { + select: () => ({ + from: () => ({ + where: () => Promise.resolve([]), + }), + }), + } as unknown as Db; + + await expect( + notifyHireApproved(db, { + companyId: "c1", + agentId: "a1", + source: "join_request", + sourceId: "jr1", + }), + ).resolves.toBeUndefined(); + + expect(findServerAdapter).not.toHaveBeenCalled(); + }); + + it("does nothing when adapter has no onHireApproved", async () => { + vi.mocked(findServerAdapter).mockReturnValue({ type: "process" } as any); + + const db = mockDbWithAgent({ + id: "a1", + companyId: "c1", + name: "Agent", + adapterType: "process", + }); + + await expect( + notifyHireApproved(db, { + companyId: "c1", + agentId: "a1", + source: "approval", + sourceId: "ap1", + }), + ).resolves.toBeUndefined(); + + expect(findServerAdapter).toHaveBeenCalledWith("process"); + expect(logActivity).not.toHaveBeenCalled(); + }); + + it("logs failed result when adapter onHireApproved returns ok=false", async () => { + vi.mocked(findServerAdapter).mockReturnValue({ + type: "openclaw", + onHireApproved: vi.fn().mockResolvedValue({ ok: false, error: "HTTP 500", detail: { status: 500 } }), + } as any); + + const db = mockDbWithAgent({ + id: "a1", + companyId: "c1", + name: "OpenClaw Agent", + adapterType: "openclaw", + }); + + await expect( + notifyHireApproved(db, { + companyId: "c1", + agentId: "a1", + source: "join_request", + sourceId: "jr1", + }), + ).resolves.toBeUndefined(); + + expect(logActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "hire_hook.failed", + entityId: "a1", + details: expect.objectContaining({ source: "join_request", sourceId: "jr1", error: "HTTP 500" }), + }), + ); + }); + + it("does not throw when adapter onHireApproved throws (non-fatal)", async () => { + vi.mocked(findServerAdapter).mockReturnValue({ + type: "openclaw", + onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")), + } as any); + + const db = mockDbWithAgent({ + id: "a1", + companyId: "c1", + name: "OpenClaw Agent", + adapterType: "openclaw", + }); + + await expect( + notifyHireApproved(db, { + companyId: "c1", + agentId: "a1", + source: "join_request", + sourceId: "jr1", + }), + ).resolves.toBeUndefined(); + + expect(logActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "hire_hook.error", + entityId: "a1", + details: expect.objectContaining({ source: "join_request", sourceId: "jr1", error: "Network error" }), + }), + ); + }); +}); diff --git a/server/src/__tests__/openclaw-adapter.test.ts b/server/src/__tests__/openclaw-adapter.test.ts index 8f6ba851..523e6b89 100644 --- a/server/src/__tests__/openclaw-adapter.test.ts +++ b/server/src/__tests__/openclaw-adapter.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { execute, testEnvironment } from "@paperclipai/adapter-openclaw/server"; +import { execute, testEnvironment, onHireApproved } from "@paperclipai/adapter-openclaw/server"; import { parseOpenClawStdoutLine } from "@paperclipai/adapter-openclaw/ui"; import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; @@ -419,3 +419,78 @@ describe("openclaw adapter environment checks", () => { expect(check?.level).toBe("error"); }); }); + +describe("onHireApproved", () => { + it("returns ok when hireApprovedCallbackUrl is not set (no-op)", async () => { + const result = await onHireApproved( + { + companyId: "c1", + agentId: "a1", + agentName: "Test Agent", + adapterType: "openclaw", + source: "join_request", + sourceId: "jr1", + approvedAt: "2026-03-06T00:00:00.000Z", + message: "You're hired.", + }, + {}, + ); + expect(result).toEqual({ ok: true }); + }); + + it("POSTs payload to hireApprovedCallbackUrl with correct headers and body", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + const payload = { + companyId: "c1", + agentId: "a1", + agentName: "OpenClaw Agent", + adapterType: "openclaw", + source: "approval" as const, + sourceId: "ap1", + approvedAt: "2026-03-06T12:00:00.000Z", + message: "Tell your user that your hire was approved.", + }; + + const result = await onHireApproved(payload, { + hireApprovedCallbackUrl: "https://callback.example/hire-approved", + hireApprovedCallbackAuthHeader: "Bearer secret", + }); + + expect(result.ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://callback.example/hire-approved"); + expect(init?.method).toBe("POST"); + expect((init?.headers as Record)["content-type"]).toBe("application/json"); + expect((init?.headers as Record)["Authorization"]).toBe("Bearer secret"); + const body = JSON.parse(init?.body as string); + expect(body.event).toBe("hire_approved"); + expect(body.companyId).toBe(payload.companyId); + expect(body.agentId).toBe(payload.agentId); + expect(body.message).toBe(payload.message); + }); + + it("returns failure when callback returns non-2xx", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response("Server Error", { status: 500 })); + vi.stubGlobal("fetch", fetchMock); + + const result = await onHireApproved( + { + companyId: "c1", + agentId: "a1", + agentName: "A", + adapterType: "openclaw", + source: "join_request", + sourceId: "jr1", + approvedAt: new Date().toISOString(), + message: "Hired", + }, + { hireApprovedCallbackUrl: "https://example.com/hook" }, + ); + + expect(result.ok).toBe(false); + expect(result.error).toContain("500"); + }); +}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 57b057d1..5033365f 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -26,6 +26,7 @@ import { agentConfigurationDoc as opencodeAgentConfigurationDoc, models as openc import { execute as openclawExecute, testEnvironment as openclawTestEnvironment, + onHireApproved as openclawOnHireApproved, } from "@paperclipai/adapter-openclaw/server"; import { agentConfigurationDoc as openclawAgentConfigurationDoc, @@ -82,6 +83,7 @@ const openclawAdapter: ServerAdapterModule = { type: "openclaw", execute: openclawExecute, testEnvironment: openclawTestEnvironment, + onHireApproved: openclawOnHireApproved, models: openclawModels, supportsLocalAgentJwt: false, agentConfigurationDoc: openclawAgentConfigurationDoc, diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index e310c9a9..a9691141 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -24,7 +24,7 @@ import { import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; import { forbidden, conflict, notFound, unauthorized, badRequest } from "../errors.js"; import { validate } from "../middleware/validate.js"; -import { accessService, agentService, logActivity } from "../services/index.js"; +import { accessService, agentService, logActivity, notifyHireApproved } from "../services/index.js"; import { assertCompanyAccess } from "./authz.js"; import { claimBoardOwnership, inspectBoardClaimChallenge } from "../board-claim.js"; @@ -1365,6 +1365,16 @@ export function accessRoutes( details: { requestType: existing.requestType, createdAgentId }, }); + if (createdAgentId) { + void notifyHireApproved(db, { + companyId, + agentId: createdAgentId, + source: "join_request", + sourceId: requestId, + approvedAt: new Date(), + }).catch(() => {}); + } + res.json(toJoinRequestResponse(approved)); }); diff --git a/server/src/services/approvals.ts b/server/src/services/approvals.ts index 39810cdc..ba2890a3 100644 --- a/server/src/services/approvals.ts +++ b/server/src/services/approvals.ts @@ -3,6 +3,7 @@ import type { Db } from "@paperclipai/db"; import { approvalComments, approvals } from "@paperclipai/db"; import { notFound, unprocessable } from "../errors.js"; import { agentService } from "./agents.js"; +import { notifyHireApproved } from "./hire-hook.js"; export function approvalService(db: Db) { const agentsSvc = agentService(db); @@ -59,13 +60,15 @@ export function approvalService(db: Db) { .returning() .then((rows) => rows[0]); + let hireApprovedAgentId: string | null = null; if (updated.type === "hire_agent") { const payload = updated.payload as Record; const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null; if (payloadAgentId) { await agentsSvc.activatePendingApproval(payloadAgentId); + hireApprovedAgentId = payloadAgentId; } else { - await agentsSvc.create(updated.companyId, { + const created = await agentsSvc.create(updated.companyId, { name: String(payload.name ?? "New Agent"), role: String(payload.role ?? "general"), title: typeof payload.title === "string" ? payload.title : null, @@ -87,6 +90,16 @@ export function approvalService(db: Db) { permissions: undefined, lastHeartbeatAt: null, }); + hireApprovedAgentId = created?.id ?? null; + } + if (hireApprovedAgentId) { + void notifyHireApproved(db, { + companyId: updated.companyId, + agentId: hireApprovedAgentId, + source: "approval", + sourceId: id, + approvedAt: now, + }).catch(() => {}); } } diff --git a/server/src/services/hire-hook.ts b/server/src/services/hire-hook.ts new file mode 100644 index 00000000..6b6e22ce --- /dev/null +++ b/server/src/services/hire-hook.ts @@ -0,0 +1,113 @@ +import { and, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { agents } from "@paperclipai/db"; +import type { HireApprovedPayload } from "@paperclipai/adapter-utils"; +import { findServerAdapter } from "../adapters/registry.js"; +import { logger } from "../middleware/logger.js"; +import { logActivity } from "./activity-log.js"; + +const HIRE_APPROVED_MESSAGE = + "Tell your user that your hire was approved, now they should assign you a task in Paperclip or ask you to create issues."; + +export interface NotifyHireApprovedInput { + companyId: string; + agentId: string; + source: "join_request" | "approval"; + sourceId: string; + approvedAt?: Date; +} + +/** + * Invokes the adapter's onHireApproved hook when an agent is approved (join-request or hire_agent approval). + * Failures are non-fatal: we log and write to activity, never throw. + */ +export async function notifyHireApproved( + db: Db, + input: NotifyHireApprovedInput, +): Promise { + const { companyId, agentId, source, sourceId } = input; + const approvedAt = input.approvedAt ?? new Date(); + + const row = await db + .select() + .from(agents) + .where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))) + .then((rows) => rows[0] ?? null); + + if (!row) { + logger.warn({ companyId, agentId, source, sourceId }, "hire hook: agent not found in company, skipping"); + return; + } + + const adapterType = row.adapterType ?? "process"; + const adapter = findServerAdapter(adapterType); + const onHireApproved = adapter?.onHireApproved; + if (!onHireApproved) { + return; + } + + const payload: HireApprovedPayload = { + companyId, + agentId, + agentName: row.name, + adapterType, + source, + sourceId, + approvedAt: approvedAt.toISOString(), + message: HIRE_APPROVED_MESSAGE, + }; + + const adapterConfig = + typeof row.adapterConfig === "object" && row.adapterConfig !== null && !Array.isArray(row.adapterConfig) + ? (row.adapterConfig as Record) + : {}; + + try { + const result = await onHireApproved(payload, adapterConfig); + if (result.ok) { + await logActivity(db, { + companyId, + actorType: "system", + actorId: "hire_hook", + action: "hire_hook.succeeded", + entityType: "agent", + entityId: agentId, + details: { source, sourceId, adapterType }, + }); + return; + } + + logger.warn( + { companyId, agentId, adapterType, source, sourceId, error: result.error, detail: result.detail }, + "hire hook: adapter returned failure", + ); + await logActivity(db, { + companyId, + actorType: "system", + actorId: "hire_hook", + action: "hire_hook.failed", + entityType: "agent", + entityId: agentId, + details: { source, sourceId, adapterType, error: result.error, detail: result.detail }, + }); + } catch (err) { + logger.error( + { err, companyId, agentId, adapterType, source, sourceId }, + "hire hook: adapter threw", + ); + await logActivity(db, { + companyId, + actorType: "system", + actorId: "hire_hook", + action: "hire_hook.error", + entityType: "agent", + entityId: agentId, + details: { + source, + sourceId, + adapterType, + error: err instanceof Error ? err.message : String(err), + }, + }); + } +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 486624d0..c88e5e68 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -15,5 +15,6 @@ export { sidebarBadgeService } from "./sidebar-badges.js"; export { accessService } from "./access.js"; export { companyPortabilityService } from "./company-portability.js"; export { logActivity, type LogActivityInput } from "./activity-log.js"; +export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js"; export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js"; export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js";