diff --git a/DESIGN-OPENCLAW-PLUGIN.md b/DESIGN-OPENCLAW-PLUGIN.md new file mode 100644 index 000000000..61cf8cc94 --- /dev/null +++ b/DESIGN-OPENCLAW-PLUGIN.md @@ -0,0 +1,265 @@ +# DESIGN: OpenClaw Integration for AO (Revised) + +## Executive Summary +This design is revised to match how OpenClaw actually works and to minimize time-to-value. + +- Phase 0-2 uses existing primitives: + - AO `notifier-openclaw` (outbound) + - OpenClaw built-in webhook ingress (`POST /hooks/agent`) + - OpenClaw agent `exec` tool and plugin commands (`api.registerCommand`) for reverse control +- No new AO `peer` slot in early phases. +- Full structured peer protocol, HMAC, and RBAC are deferred to Phase 3 (cross-network / multi-tenant hardening). + +This ships fast while solving current operational fragility. + +## What Was Correct vs What Changes + +## Keep +- Escalation envelope concept and session-supervision goals are sound. +- Need bidirectional path and human override. +- Need durable session identity, dead-session detection, crash forensics, and send reliability signal. + +## Change +- Do not start with a custom OpenClaw bridge extension for event ingress. +- Do not add AO reverse-command API in Phase 0-2. +- Do not introduce AO `peer` slot yet. + +## Ground Truth Architecture + +## OpenClaw capabilities (used here) +OpenClaw already supports: +- Webhook ingress: `POST /hooks/agent`, `POST /hooks/wake` +- Plugin system: commands, services, gateway methods/handlers, tools, channels, CLI +- Auto-reply command registration: `api.registerCommand(...)` (no AI turn) +- Agent execution path with shell/exec tools + +## AO capabilities (used here) +- Escalation event production via `lifecycle-manager.ts` +- Notifier plugin interface (`notify`) +- Reliable-ish session send path in `session-manager.send(...)` including confirmation heuristics + +## Phase 0 Design (Ship in ~1 day) + +## Flow +1. AO escalation event triggers `notifier-openclaw.notify(...)`. +2. `notifier-openclaw` posts to OpenClaw webhook: + - `POST http://127.0.0.1:18789/hooks/agent` + - `Authorization: Bearer ` +3. OpenClaw runs an agent turn and delivers to chat channel(s). +4. Human replies in chat; OpenClaw agent runs `ao send/ao kill/ao session ...` via exec tools. + +## Request payload (Phase 0) +```json +{ + "message": "[AO Escalation] ao-5 failed CI 5 times on feat/ci-auto-injection. Last error: type mismatch in codex plugin. PR: github.com/ComposioHQ/agent-orchestrator/pull/123. Actions available: retry, skip, kill. Context: {\"sessionId\":\"ao-5\",\"projectId\":\"ao\",\"reason\":\"ci_failed\",\"attempts\":5}", + "name": "AO", + "sessionKey": "hook:ao:ao-5", + "wakeMode": "now", + "deliver": true +} +``` + +## Session key strategy (required) +Use one OpenClaw session per AO session: +- `hook:ao:` (examples: `hook:ao:ao-5`, `hook:ao:ao-12`) + +Benefits: +- Preserves per-session escalation history. +- Enables continuity for retries/human follow-up. +- Avoids cross-session context bleed. + +Security config: +- `hooks.allowRequestSessionKey: true` +- `hooks.allowedSessionKeyPrefixes: ["hook:ao:"]` + +## Why this is the right Phase 0 +- Zero OpenClaw plugin code required. +- Uses stable OpenClaw ingress/auth/session behavior. +- Immediate bidirectional operations through existing agent exec path. + +## Phase 1 Design (Lightweight OpenClaw plugin) + +Add a small OpenClaw plugin focused on UX + ops speed, not transport replacement. + +## Plugin responsibilities +1. Register auto-reply commands (`api.registerCommand`): +- `/ao status ` +- `/ao sessions` +- `/ao retry ` +- `/ao kill ` + +These execute without invoking an AI turn for fast deterministic actions. + +2. Register background service (`api.registerService`): +- Periodic AO health polling (`ao session ls/status`), summarize anomalies. +- Trigger chat updates when dead/stuck sessions detected. + +3. Keep complex tasks on normal agent path: +- For multi-step remediation, let AI run with exec tools (`ao send`, diagnostics, fixes). + +## Phase 2 Design (Structured OpenClaw plugin) + +Add structured AO interactions while keeping Phase 0 compatibility. + +## Additions +- Gateway HTTP handler(s) in plugin for structured AO event ingress (optional alongside `/hooks/agent`). +- Agent tools for AO structured reads/actions (e.g., `ao_session_info`, `ao_session_send`). +- Better supervisor event formatting and correlation across chat threads. + +Still no AO peer slot required. + +## Phase 3 Design (Optional hardened peer protocol) + +Only if needed (cross-host, multi-tenant, compliance): +- Dedicated AO peer abstraction. +- Signed envelopes (HMAC), replay protection, RBAC, strict command API. + +## Escalation Message Contract (Phase 0-2) + +Keep escalation semantics consistent even in text form. + +Canonical logical shape: +```json +{ + "type": "escalation", + "from": "ao-5", + "reason": "ci_failed", + "attempts": 5, + "context": { + "projectId": "ao", + "branch": "feat/ci-auto-injection", + "pr": "https://github.com/.../pull/123", + "lastError": "..." + }, + "actions": ["retry", "skip", "kill"] +} +``` + +In Phase 0 this is embedded in webhook `message` text plus compact JSON context. In Phase 2+ it can be fully structured. + +## Session Supervision Requirements (Operational) + +## 1) Health monitoring +- AO should emit/update session health snapshots periodically. +- OpenClaw Phase 1 service polls and reports dead/stuck sessions. + +Simplest Phase 0 bootstrap: +- AO writes status snapshots to a known file. +- OpenClaw reads during heartbeat cycle and surfaces anomalies. + +## 2) Auto-respawn +- If session dies unexpectedly, workflow should attempt `ao session restore ` first. +- If restore fails, spawn replacement with preserved task metadata and explicit mapping notice. + +## 3) Stable session identity +Persist task identity independent of numeric ID: +- `logicalSessionKey` (task/issue/PR anchored) +- `taskRef` +- `branch` +- `prUrl` + +Respawn/replacement must carry the same logical identity. + +## 4) Crash forensics +On failure detection, capture before cleanup: +- last pane output (`tmux capture-pane` tail), +- AO/agent/runtime error signature, +- known classifiers (e.g. permission/auth/config crash). + +Include this in escalation message. + +## 5) `ao send` delivery confidence +Expose send result confidence in escalations/acks: +- `accepted` +- `confirmed` +- `uncertain` +- `failed` + +`uncertain` must not be shown as success. + +## Graceful Degradation + +If OpenClaw is down/unreachable: +- `notifier-openclaw` fails over per policy to: + - desktop notifier and/or + - webhook/file sink for later replay +- AO must log unsent escalation payloads with retry metadata. + +## Rate Limiting / Debounce + +Avoid chat spam when many sessions fail simultaneously: +- Batch window: e.g. 10-30s aggregation by `projectId/reason`. +- Collapse repeated identical escalations per `sessionId` within cooldown. +- Send summary + top actionable items when burst detected. + +## Security by Phase + +## Phase 0-1 (localhost) +- Loopback transport + OpenClaw `hooks.token` auth is sufficient. +- No HMAC/RBAC requirement initially. + +## Phase 2+ +- Add tighter sender policy for plugin commands. + +## Phase 3 +- HMAC signatures, replay protection, AO-side RBAC for structured API. + +## Reference OpenClaw plugin patterns to follow +When implementing Phase 1/2 plugin, model structure after: +- Voice Call plugin (`@openclaw/voice-call`): command + tool + service + RPC pattern. +- Teams/Matrix channel plugins for robust bidirectional routing patterns. +- Memory plugins for slot/service/tool separation patterns. + +## `agent-orchestrator.yaml` (Phase 0) + +```yaml +defaults: + notifiers: [desktop, openclaw] + +notifiers: + openclaw: + plugin: openclaw + url: "http://127.0.0.1:18789/hooks/agent" + token: "${OPENCLAW_HOOKS_TOKEN}" + retries: 3 + retryDelayMs: 1000 + +notificationRouting: + urgent: [openclaw, desktop] + action: [openclaw] + warning: [openclaw] + info: [openclaw] +``` + +OpenClaw config requirements: +```json5 +{ + hooks: { + enabled: true, + token: "${OPENCLAW_HOOKS_TOKEN}", + allowRequestSessionKey: true, + allowedSessionKeyPrefixes: ["hook:ao:"], + defaultSessionKey: "hook:ao:default" + } +} +``` + +## Required AO changes for Phase 0 +1. Implement `notifier-openclaw` using webhook POST semantics. +2. Add payload formatter producing action-oriented escalation text + compact context. +3. Add burst control (debounce/batch) in notifier path. +4. Add fallback routing when OpenClaw is unavailable. + +## Required AO/OpenClaw changes for Phase 1 +1. OpenClaw plugin with `api.registerCommand` for deterministic `/ao ...` commands. +2. OpenClaw service with `api.registerService` for periodic AO health polling. +3. AO metadata additions for logical session identity and crash forensics fields. + +## Revised rollout plan +- Phase 0: AO notifier -> OpenClaw `/hooks/agent`, per-session `hook:ao:*` keys, agent exec for reverse actions. +- Phase 1: OpenClaw plugin commands + health polling service. +- Phase 2: Structured plugin handlers/tools for AO supervisor events and richer automation. +- Phase 3: Optional dedicated peer protocol/security hardening. + +## Final Recommendation +Use existing mechanisms first: notifier + webhook + exec + plugin commands. This delivers immediate operational value and directly addresses session durability pain. Defer new abstractions until Phase 3, when complexity is justified. diff --git a/packages/cli/package.json b/packages/cli/package.json index 413c59a9b..bee8a1aa6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -40,6 +40,7 @@ "@composio/ao-plugin-agent-opencode": "workspace:*", "@composio/ao-plugin-notifier-composio": "workspace:*", "@composio/ao-plugin-notifier-desktop": "workspace:*", + "@composio/ao-plugin-notifier-openclaw": "workspace:*", "@composio/ao-plugin-notifier-slack": "workspace:*", "@composio/ao-plugin-notifier-webhook": "workspace:*", "@composio/ao-plugin-runtime-process": "workspace:*", diff --git a/packages/core/package.json b/packages/core/package.json index a1c0756bb..cb4c47974 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -14,6 +14,10 @@ "./types": { "types": "./dist/types.d.ts", "import": "./dist/types.js" + }, + "./utils": { + "types": "./dist/utils.d.ts", + "import": "./dist/utils.js" } }, "files": [ diff --git a/packages/core/src/__tests__/plugin-registry.test.ts b/packages/core/src/__tests__/plugin-registry.test.ts index d19a124c4..4d6e0b861 100644 --- a/packages/core/src/__tests__/plugin-registry.test.ts +++ b/packages/core/src/__tests__/plugin-registry.test.ts @@ -185,7 +185,6 @@ describe("loadBuiltins", () => { }); expect(fakeWebhookNotifier.create).toHaveBeenCalledWith({ - plugin: "webhook", url: "http://127.0.0.1:8787/hook", retries: 2, retryDelayMs: 500, @@ -211,11 +210,61 @@ describe("loadBuiltins", () => { }); expect(fakeWebhookNotifier.create).toHaveBeenCalledWith({ - plugin: "webhook", url: "http://127.0.0.1:8787/custom-hook", retries: 4, }); }); + + it("passes notifier config from config.notifiers when loading builtins", async () => { + const registry = createPluginRegistry(); + const fakeOpenClaw = makePlugin("notifier", "openclaw"); + const cfg = makeOrchestratorConfig({ + notifiers: { + openclaw: { + plugin: "openclaw", + url: "http://127.0.0.1:18789/hooks/agent", + token: "tok", + }, + }, + }); + + await registry.loadBuiltins(cfg, async (pkg: string) => { + if (pkg === "@composio/ao-plugin-notifier-openclaw") return fakeOpenClaw; + throw new Error(`Not found: ${pkg}`); + }); + + expect(fakeOpenClaw.create).toHaveBeenCalledWith({ + url: "http://127.0.0.1:18789/hooks/agent", + token: "tok", + }); + }); + + it("does not match notifier key when explicit plugin points to another notifier", async () => { + const registry = createPluginRegistry(); + const fakeOpenClaw = makePlugin("notifier", "openclaw"); + const fakeWebhook = makePlugin("notifier", "webhook"); + const cfg = makeOrchestratorConfig({ + notifiers: { + openclaw: { + plugin: "webhook", + url: "http://127.0.0.1:8787/hook", + retries: 3, + }, + }, + }); + + await registry.loadBuiltins(cfg, async (pkg: string) => { + if (pkg === "@composio/ao-plugin-notifier-openclaw") return fakeOpenClaw; + if (pkg === "@composio/ao-plugin-notifier-webhook") return fakeWebhook; + throw new Error(`Not found: ${pkg}`); + }); + + expect(fakeOpenClaw.create).toHaveBeenCalledWith(undefined); + expect(fakeWebhook.create).toHaveBeenCalledWith({ + url: "http://127.0.0.1:8787/hook", + retries: 3, + }); + }); }); describe("extractPluginConfig (via register with config)", () => { diff --git a/packages/core/src/__tests__/utils.test.ts b/packages/core/src/__tests__/utils.test.ts index e967c5a7d..2c22d6007 100644 --- a/packages/core/src/__tests__/utils.test.ts +++ b/packages/core/src/__tests__/utils.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from "vitest"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { readLastJsonlEntry } from "../utils.js"; +import { isRetryableHttpStatus, normalizeRetryConfig, readLastJsonlEntry } from "../utils.js"; describe("readLastJsonlEntry", () => { let tmpDir: string; @@ -85,3 +85,32 @@ describe("readLastJsonlEntry", () => { expect(result!.modifiedAt).toBeInstanceOf(Date); }); }); + +describe("retry utilities", () => { + it("marks 429 and 5xx statuses as retryable", () => { + expect(isRetryableHttpStatus(429)).toBe(true); + expect(isRetryableHttpStatus(500)).toBe(true); + expect(isRetryableHttpStatus(503)).toBe(true); + }); + + it("marks 4xx statuses (except 429) as non-retryable", () => { + expect(isRetryableHttpStatus(400)).toBe(false); + expect(isRetryableHttpStatus(401)).toBe(false); + expect(isRetryableHttpStatus(404)).toBe(false); + }); + + it("normalizes retry config with defaults", () => { + expect(normalizeRetryConfig(undefined)).toEqual({ retries: 2, retryDelayMs: 1000 }); + }); + + it("normalizes retry config values and clamps invalid input", () => { + expect(normalizeRetryConfig({ retries: 4, retryDelayMs: 250 })).toEqual({ + retries: 4, + retryDelayMs: 250, + }); + expect(normalizeRetryConfig({ retries: -1, retryDelayMs: -50 })).toEqual({ + retries: 0, + retryDelayMs: 1000, + }); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b97f7aa49..a32175092 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -60,7 +60,14 @@ export { generateOrchestratorPrompt } from "./orchestrator-prompt.js"; export type { OrchestratorPromptConfig } from "./orchestrator-prompt.js"; // Shared utilities -export { shellEscape, escapeAppleScript, validateUrl, readLastJsonlEntry } from "./utils.js"; +export { + shellEscape, + escapeAppleScript, + validateUrl, + isRetryableHttpStatus, + normalizeRetryConfig, + readLastJsonlEntry, +} from "./utils.js"; export { asValidOpenCodeSessionId } from "./opencode-session-id.js"; export { normalizeOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js"; export type { NormalizedOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js"; diff --git a/packages/core/src/plugin-registry.ts b/packages/core/src/plugin-registry.ts index e1b655337..9892786f7 100644 --- a/packages/core/src/plugin-registry.ts +++ b/packages/core/src/plugin-registry.ts @@ -43,6 +43,7 @@ const BUILTIN_PLUGINS: Array<{ slot: PluginSlot; name: string; pkg: string }> = // Notifiers { slot: "notifier", name: "composio", pkg: "@composio/ao-plugin-notifier-composio" }, { slot: "notifier", name: "desktop", pkg: "@composio/ao-plugin-notifier-desktop" }, + { slot: "notifier", name: "openclaw", pkg: "@composio/ao-plugin-notifier-openclaw" }, { slot: "notifier", name: "slack", pkg: "@composio/ao-plugin-notifier-slack" }, { slot: "notifier", name: "webhook", pkg: "@composio/ao-plugin-notifier-webhook" }, // Terminals @@ -56,14 +57,19 @@ function extractPluginConfig( name: string, config: OrchestratorConfig, ): Record | undefined { + // Notifiers are configured under config.notifiers.. + // Match by key (e.g. "openclaw") or explicit plugin field. if (slot === "notifier") { - for (const notifierConfig of Object.values(config.notifiers)) { - if ( - notifierConfig && - typeof notifierConfig === "object" && - notifierConfig["plugin"] === name - ) { - return notifierConfig; + for (const [notifierName, notifierConfig] of Object.entries(config.notifiers ?? {})) { + if (!notifierConfig || typeof notifierConfig !== "object") continue; + const configuredPlugin = (notifierConfig as Record)["plugin"]; + const hasExplicitPlugin = typeof configuredPlugin === "string" && configuredPlugin.length > 0; + const matches = hasExplicitPlugin + ? configuredPlugin === name + : notifierName === name; + if (matches) { + const { plugin: _plugin, ...rest } = notifierConfig as Record; + return rest; } } } diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 7085ca1c3..6b3fbe9b3 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -32,6 +32,30 @@ export function validateUrl(url: string, label: string): void { } } +/** + * Returns true if an HTTP status code should be retried. + * Retry only 429 (rate-limit) and 5xx (server) failures. + */ +export function isRetryableHttpStatus(status: number): boolean { + return status === 429 || status >= 500; +} + +/** + * Normalize retry config from plugin config with sane defaults. + */ +export function normalizeRetryConfig( + config: Record | undefined, + defaults: { retries: number; retryDelayMs: number } = { retries: 2, retryDelayMs: 1000 }, +): { retries: number; retryDelayMs: number } { + const rawRetries = config?.retries as number | undefined; + const rawDelay = config?.retryDelayMs as number | undefined; + const retries = Number.isFinite(rawRetries) ? Math.max(0, rawRetries ?? 0) : defaults.retries; + const retryDelayMs = Number.isFinite(rawDelay) && (rawDelay ?? -1) >= 0 + ? (rawDelay as number) + : defaults.retryDelayMs; + return { retries, retryDelayMs }; +} + /** * Read the last line from a file by reading backwards from the end. * Pure Node.js — no external binaries. Handles any file size. @@ -93,7 +117,6 @@ export async function readLastJsonlEntry( try { const [line, fileStat] = await Promise.all([readLastLine(filePath), stat(filePath)]); - if (!line) return null; const parsed: unknown = JSON.parse(line); diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index aee969f66..271d5675e 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -23,6 +23,7 @@ }, "devDependencies": { "@composio/ao-plugin-notifier-desktop": "workspace:*", + "@composio/ao-plugin-notifier-openclaw": "workspace:*", "@composio/ao-plugin-notifier-slack": "workspace:*", "@composio/ao-plugin-notifier-webhook": "workspace:*", "@composio/ao-plugin-notifier-composio": "workspace:*", diff --git a/packages/integration-tests/src/notifier-openclaw.integration.test.ts b/packages/integration-tests/src/notifier-openclaw.integration.test.ts new file mode 100644 index 000000000..2d29a527d --- /dev/null +++ b/packages/integration-tests/src/notifier-openclaw.integration.test.ts @@ -0,0 +1,116 @@ +/** + * Integration tests for notifier-openclaw. + * + * Mocks network boundary only (global fetch + timers). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { NotifyAction } from "@composio/ao-core"; +import openClawPlugin from "@composio/ao-plugin-notifier-openclaw"; +import { makeEvent } from "./helpers/event-factory.js"; + +describe("notifier-openclaw integration", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + delete process.env.OPENCLAW_HOOKS_TOKEN; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("notify posts OpenClaw hooks payload with per-session key", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = openClawPlugin.create({ + url: "http://127.0.0.1:18789/hooks/agent", + token: "tok", + sessionKeyPrefix: "hook:ao:", + }); + + await notifier.notify( + makeEvent({ + type: "reaction.escalated", + priority: "urgent", + sessionId: "ao-12", + message: "CI failed 5 times", + data: { attempts: 5, reason: "ci_failed" }, + }), + ); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toBe("http://127.0.0.1:18789/hooks/agent"); + + const body = JSON.parse(opts.body); + expect(body.name).toBe("AO"); + expect(body.sessionKey).toBe("hook:ao:ao-12"); + expect(body.wakeMode).toBe("now"); + expect(body.deliver).toBe(true); + expect(body.message).toContain("[AO URGENT]"); + expect(body.message).toContain("CI failed 5 times"); + }); + + it("notifyWithActions formats escalation header/context and appends action labels", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const actions: NotifyAction[] = [{ label: "retry" }, { label: "kill" }]; + + const notifier = openClawPlugin.create({ token: "tok" }); + await notifier.notifyWithActions!( + makeEvent({ + priority: "action", + type: "ci.failing", + sessionId: "ao-5", + message: "CI check failed on app-1", + data: { checkName: "lint" }, + }), + actions, + ); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.sessionKey).toBe("hook:ao:ao-5"); + expect(body.message).toContain("[AO ACTION] ao-5 ci.failing"); + expect(body.message).toContain('Context: {"checkName":"lint"}'); + expect(body.message).toContain("Actions available: retry, kill"); + }); + + it("uses explicit deliver=true in hooks payload", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = openClawPlugin.create({ + token: "tok", + deliver: true, + wakeMode: "now", + }); + await notifier.notify(makeEvent({ sessionId: "ao-21" })); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.deliver).toBe(true); + expect(body.wakeMode).toBe("now"); + }); + + it("retries 503 then succeeds", async () => { + vi.useFakeTimers(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ ok: false, status: 503, text: () => Promise.resolve("down") }) + .mockResolvedValueOnce({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = openClawPlugin.create({ token: "tok", retries: 1, retryDelayMs: 100 }); + const promise = notifier.notify(makeEvent()); + + await vi.advanceTimersByTimeAsync(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(100); + expect(fetchMock).toHaveBeenCalledTimes(2); + + await promise; + }); +}); diff --git a/packages/plugins/notifier-openclaw/README.md b/packages/plugins/notifier-openclaw/README.md new file mode 100644 index 000000000..5eff20201 --- /dev/null +++ b/packages/plugins/notifier-openclaw/README.md @@ -0,0 +1,44 @@ +# notifier-openclaw + +OpenClaw notifier plugin for AO escalation events. + +## Required OpenClaw config (`openclaw.json`) + +```json +{ + "hooks": { + "enabled": true, + "token": "", + "allowRequestSessionKey": true, + "allowedSessionKeyPrefixes": ["hook:"] + } +} +``` + +## AO config (`agent-orchestrator.yaml`) + +```yaml +notifiers: + openclaw: + plugin: openclaw + url: http://127.0.0.1:18789/hooks/agent + token: ${OPENCLAW_HOOKS_TOKEN} +``` + +## Behavior + +- Sends `POST /hooks/agent` payloads with per-session key `hook:ao:`. +- Defaults `wakeMode: now` and `deliver: true`. +- Retries on `429` and `5xx` responses with exponential backoff. + +## Token rotation + +1. Rotate `hooks.token` in OpenClaw. +2. Update `OPENCLAW_HOOKS_TOKEN` used by AO. +3. Verify old token returns `401` and new token returns `200`. + +## Known limitation (Phase 0) + +- OpenClaw hook ingest is not idempotent by default. Replayed webhook payloads are processed as separate runs. +- Owner: AO integration. +- Follow-up: add stable event id/idempotency key support. diff --git a/packages/plugins/notifier-openclaw/package.json b/packages/plugins/notifier-openclaw/package.json new file mode 100644 index 000000000..aeedc2cf0 --- /dev/null +++ b/packages/plugins/notifier-openclaw/package.json @@ -0,0 +1,44 @@ +{ + "name": "@composio/ao-plugin-notifier-openclaw", + "version": "0.1.0", + "description": "Notifier plugin: OpenClaw webhook notifications", + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/ComposioHQ/agent-orchestrator.git", + "directory": "packages/plugins/notifier-openclaw" + }, + "homepage": "https://github.com/ComposioHQ/agent-orchestrator", + "bugs": { + "url": "https://github.com/ComposioHQ/agent-orchestrator/issues" + }, + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@composio/ao-core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.2.3", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/packages/plugins/notifier-openclaw/src/index.test.ts b/packages/plugins/notifier-openclaw/src/index.test.ts new file mode 100644 index 000000000..d38db0e99 --- /dev/null +++ b/packages/plugins/notifier-openclaw/src/index.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { NotifyAction, OrchestratorEvent } from "@composio/ao-core"; +import { create, manifest } from "./index.js"; + +function makeEvent(overrides: Partial = {}): OrchestratorEvent { + return { + id: "evt-1", + type: "reaction.escalated", + priority: "urgent", + sessionId: "ao-5", + projectId: "ao", + timestamp: new Date("2026-03-08T12:00:00Z"), + message: "Reaction escalated after retries", + data: { attempts: 5, reason: "ci_failed" }, + ...overrides, + }; +} + +describe("notifier-openclaw", () => { + beforeEach(() => { + vi.restoreAllMocks(); + delete process.env.OPENCLAW_HOOKS_TOKEN; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("has correct manifest", () => { + expect(manifest.name).toBe("openclaw"); + expect(manifest.slot).toBe("notifier"); + }); + + it("uses default OpenClaw hooks endpoint", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok" }); + await notifier.notify(makeEvent()); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:18789/hooks/agent"); + }); + + it("uses token from OPENCLAW_HOOKS_TOKEN env", async () => { + process.env.OPENCLAW_HOOKS_TOKEN = "env-token"; + + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create(); + await notifier.notify(makeEvent()); + + const headers = fetchMock.mock.calls[0][1].headers; + expect(headers["Authorization"]).toBe("Bearer env-token"); + }); + + it("warns and sends without Authorization when token missing", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create(); + await notifier.notify(makeEvent()); + + const headers = fetchMock.mock.calls[0][1].headers as Record; + expect(headers["Authorization"]).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No token configured")); + }); + + it("builds per-session OpenClaw session key", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok", sessionKeyPrefix: "hook:ao:" }); + await notifier.notify(makeEvent({ sessionId: "ao-12" })); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.sessionKey).toBe("hook:ao:ao-12"); + }); + + it("sanitizes invalid characters in session id", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok" }); + await notifier.notify(makeEvent({ sessionId: "ao/12?x" })); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.sessionKey).toBe("hook:ao:ao-12-x"); + }); + + it("notifyWithActions appends action labels", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok" }); + const actions: NotifyAction[] = [{ label: "retry" }, { label: "kill" }]; + await notifier.notifyWithActions!(makeEvent(), actions); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.message).toContain("Actions available: retry, kill"); + }); + + it("post uses context sessionId when provided", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok" }); + await notifier.post!("ready", { sessionId: "ao-77" }); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.sessionKey).toBe("hook:ao:ao-77"); + expect(body.message).toBe("ready"); + }); + + it("defaults wakeMode=now and deliver=true", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok" }); + await notifier.notify(makeEvent()); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.wakeMode).toBe("now"); + expect(body.deliver).toBe(true); + }); + + it("supports wakeMode=next-heartbeat when configured", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok", wakeMode: "next-heartbeat" }); + await notifier.notify(makeEvent()); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.wakeMode).toBe("next-heartbeat"); + }); + + it("retries on 5xx response", async () => { + vi.useFakeTimers(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ ok: false, status: 503, text: () => Promise.resolve("down") }) + .mockResolvedValueOnce({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok", retries: 1, retryDelayMs: 50 }); + const promise = notifier.notify(makeEvent()); + + await vi.advanceTimersByTimeAsync(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(50); + expect(fetchMock).toHaveBeenCalledTimes(2); + + await promise; + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Retry 1/1 for session=ao-5 after HTTP 503"), + ); + }); + + it("does not retry on 4xx response", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve("unauthorized") }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok", retries: 2, retryDelayMs: 1 }); + await expect(notifier.notify(makeEvent())).rejects.toThrow("OpenClaw webhook failed (401)"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/plugins/notifier-openclaw/src/index.ts b/packages/plugins/notifier-openclaw/src/index.ts new file mode 100644 index 000000000..a5b10166b --- /dev/null +++ b/packages/plugins/notifier-openclaw/src/index.ts @@ -0,0 +1,193 @@ +import { + type EventPriority, + type Notifier, + type NotifyAction, + type NotifyContext, + type OrchestratorEvent, + type PluginModule, +} from "@composio/ao-core"; +import { + isRetryableHttpStatus, + normalizeRetryConfig, + validateUrl, +} from "@composio/ao-core/utils"; + +export const manifest = { + name: "openclaw", + slot: "notifier" as const, + description: "Notifier plugin: OpenClaw webhook notifications", + version: "0.1.0", +}; + +type WakeMode = "now" | "next-heartbeat"; + +interface OpenClawWebhookPayload { + message: string; + name?: string; + sessionKey?: string; + wakeMode?: WakeMode; + deliver?: boolean; +} + +async function postWithRetry( + url: string, + payload: OpenClawWebhookPayload, + headers: Record, + retries: number, + retryDelayMs: number, + context: { sessionId: string }, +): Promise { + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(payload), + }); + + if (response.ok) return; + + const body = await response.text(); + lastError = new Error(`OpenClaw webhook failed (${response.status}): ${body}`); + + if (!isRetryableHttpStatus(response.status)) { + throw lastError; + } + + if (attempt < retries) { + console.warn( + `[notifier-openclaw] Retry ${attempt + 1}/${retries} for session=${context.sessionId} after HTTP ${response.status}`, + ); + } + } catch (err) { + if (err === lastError) throw err; + lastError = err instanceof Error ? err : new Error(String(err)); + + if (attempt < retries) { + console.warn( + `[notifier-openclaw] Retry ${attempt + 1}/${retries} for session=${context.sessionId} after network error: ${lastError.message}`, + ); + } + } + + if (attempt < retries) { + const delay = retryDelayMs * 2 ** attempt; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw lastError; +} + +function sanitizeSessionId(id: string): string { + return id.replace(/[^a-zA-Z0-9:_-]/g, "-"); +} + +function eventHeadline(event: OrchestratorEvent): string { + const priorityTag: Record = { + urgent: "URGENT", + action: "ACTION", + warning: "WARNING", + info: "INFO", + }; + return `[AO ${priorityTag[event.priority]}] ${event.sessionId} ${event.type}`; +} + +function stringifyData(data: Record): string { + const entries = Object.entries(data); + if (entries.length === 0) return ""; + return `Context: ${JSON.stringify(data)}`; +} + +function formatEscalationMessage(event: OrchestratorEvent): string { + const parts = [eventHeadline(event), event.message, stringifyData(event.data)].filter(Boolean); + return parts.join("\n"); +} + +function formatActionsLine(actions: NotifyAction[]): string { + if (actions.length === 0) return ""; + const labels = actions.map((a) => a.label).join(", "); + return `Actions available: ${labels}`; +} + +export function create(config?: Record): Notifier { + const url = (typeof config?.url === "string" ? config.url : undefined) ?? + "http://127.0.0.1:18789/hooks/agent"; + const token = + (typeof config?.token === "string" ? config.token : undefined) ?? + process.env.OPENCLAW_HOOKS_TOKEN; + const senderName = typeof config?.name === "string" ? config.name : "AO"; + const sessionKeyPrefix = + typeof config?.sessionKeyPrefix === "string" ? config.sessionKeyPrefix : "hook:ao:"; + const wakeMode: WakeMode = config?.wakeMode === "next-heartbeat" ? "next-heartbeat" : "now"; + const deliver = typeof config?.deliver === "boolean" ? config.deliver : true; + + const { retries, retryDelayMs } = normalizeRetryConfig(config); + + validateUrl(url, "notifier-openclaw"); + + if (!token) { + console.warn( + "[notifier-openclaw] No token configured (token or OPENCLAW_HOOKS_TOKEN). Sending without Authorization header.", + ); + } + + async function sendPayload(payload: OpenClawWebhookPayload): Promise { + const headers: Record = { + "Content-Type": "application/json", + }; + if (token) headers["Authorization"] = `Bearer ${token}`; + + const sessionId = payload.sessionKey?.slice(sessionKeyPrefix.length) ?? "default"; + + await postWithRetry(url, payload, headers, retries, retryDelayMs, { sessionId }); + } + + return { + name: "openclaw", + + async notify(event: OrchestratorEvent): Promise { + const sessionKey = `${sessionKeyPrefix}${sanitizeSessionId(event.sessionId)}`; + await sendPayload({ + message: formatEscalationMessage(event), + name: senderName, + sessionKey, + wakeMode, + deliver, + }); + }, + + async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise { + const sessionKey = `${sessionKeyPrefix}${sanitizeSessionId(event.sessionId)}`; + const actionsLine = formatActionsLine(actions); + const message = [formatEscalationMessage(event), actionsLine].filter(Boolean).join("\n"); + + await sendPayload({ + message, + name: senderName, + sessionKey, + wakeMode, + deliver, + }); + }, + + async post(message: string, context?: NotifyContext): Promise { + const sessionId = context?.sessionId ? sanitizeSessionId(context.sessionId) : "default"; + const sessionKey = `${sessionKeyPrefix}${sessionId}`; + + await sendPayload({ + message, + name: senderName, + sessionKey, + wakeMode, + deliver, + }); + + return null; + }, + }; +} + +export default { manifest, create } satisfies PluginModule; diff --git a/packages/plugins/notifier-openclaw/tsconfig.json b/packages/plugins/notifier-openclaw/tsconfig.json new file mode 100644 index 000000000..e1b71318a --- /dev/null +++ b/packages/plugins/notifier-openclaw/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/plugins/notifier-webhook/src/index.ts b/packages/plugins/notifier-webhook/src/index.ts index c3e8ab47f..4e801dfa1 100644 --- a/packages/plugins/notifier-webhook/src/index.ts +++ b/packages/plugins/notifier-webhook/src/index.ts @@ -1,11 +1,15 @@ import { - validateUrl, type PluginModule, type Notifier, type OrchestratorEvent, type NotifyAction, type NotifyContext, } from "@composio/ao-core"; +import { + isRetryableHttpStatus, + normalizeRetryConfig, + validateUrl, +} from "@composio/ao-core/utils"; export const manifest = { name: "webhook", @@ -35,15 +39,6 @@ interface WebhookPayload { context?: NotifyContext; } -/** - * Returns true if the HTTP status code should be retried. - * Only 429 (Too Many Requests) and 5xx (server errors) are retryable. - * 4xx client errors (400, 401, 403, 404, etc.) are permanent failures. - */ -function isRetryableStatus(status: number): boolean { - return status === 429 || status >= 500; -} - async function postWithRetry( url: string, payload: WebhookPayload, @@ -70,7 +65,7 @@ async function postWithRetry( lastError = new Error(`Webhook POST failed (${response.status}): ${body}`); // Only retry on 429 or 5xx — 4xx client errors are permanent - if (!isRetryableStatus(response.status)) { + if (!isRetryableHttpStatus(response.status)) { throw lastError; } } catch (err) { @@ -110,10 +105,7 @@ export function create(config?: Record): Notifier { if (typeof v === "string") customHeaders[k] = v; } } - const rawRetries = (config?.retries as number) ?? 2; - const rawDelay = (config?.retryDelayMs as number) ?? 1000; - const retries = Number.isFinite(rawRetries) ? Math.max(0, rawRetries) : 2; - const retryDelayMs = Number.isFinite(rawDelay) && rawDelay >= 0 ? rawDelay : 1000; + const { retries, retryDelayMs } = normalizeRetryConfig(config); if (!url) { console.warn("[notifier-webhook] No url configured — notifications will be no-ops"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ee31a9a3..3706f4555 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: '@composio/ao-plugin-notifier-desktop': specifier: workspace:* version: link:../plugins/notifier-desktop + '@composio/ao-plugin-notifier-openclaw': + specifier: workspace:* + version: link:../plugins/notifier-openclaw '@composio/ao-plugin-notifier-slack': specifier: workspace:* version: link:../plugins/notifier-slack @@ -185,6 +188,9 @@ importers: '@composio/ao-plugin-notifier-desktop': specifier: workspace:* version: link:../plugins/notifier-desktop + '@composio/ao-plugin-notifier-openclaw': + specifier: workspace:* + version: link:../plugins/notifier-openclaw '@composio/ao-plugin-notifier-slack': specifier: workspace:* version: link:../plugins/notifier-slack @@ -309,6 +315,22 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + packages/plugins/notifier-openclaw: + dependencies: + '@composio/ao-core': + specifier: workspace:* + version: link:../../core + devDependencies: + '@types/node': + specifier: ^25.2.3 + version: 25.2.3 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + packages/plugins/notifier-slack: dependencies: '@composio/ao-core':