diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index 4acc37584..e7a83a4d9 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -51,6 +51,7 @@ export const HookNameSchema = z.enum([ "anthropic-effort", "hashline-read-enhancer", "read-image-resizer", + "openclaw-sender", ]) export type HookName = z.infer diff --git a/src/config/schema/oh-my-opencode-config.ts b/src/config/schema/oh-my-opencode-config.ts index 9f4d70c99..ea7e479c5 100644 --- a/src/config/schema/oh-my-opencode-config.ts +++ b/src/config/schema/oh-my-opencode-config.ts @@ -12,6 +12,7 @@ import { BuiltinCommandNameSchema } from "./commands" import { ExperimentalConfigSchema } from "./experimental" import { GitMasterConfigSchema } from "./git-master" import { NotificationConfigSchema } from "./notification" +import { OpenClawConfigSchema } from "./openclaw" import { RalphLoopConfigSchema } from "./ralph-loop" import { RuntimeFallbackConfigSchema } from "./runtime-fallback" import { SkillsConfigSchema } from "./skills" @@ -55,6 +56,7 @@ export const OhMyOpenCodeConfigSchema = z.object({ runtime_fallback: z.union([z.boolean(), RuntimeFallbackConfigSchema]).optional(), background_task: BackgroundTaskConfigSchema.optional(), notification: NotificationConfigSchema.optional(), + openclaw: OpenClawConfigSchema.optional(), babysitting: BabysittingConfigSchema.optional(), git_master: GitMasterConfigSchema.optional(), browser_automation_engine: BrowserAutomationConfigSchema.optional(), diff --git a/src/config/schema/openclaw.ts b/src/config/schema/openclaw.ts new file mode 100644 index 000000000..aaa015126 --- /dev/null +++ b/src/config/schema/openclaw.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; + +export const OpenClawHookEventSchema = z.enum([ + "session-start", + "session-end", + "session-idle", + "ask-user-question", + "stop", +]); + +export const OpenClawHttpGatewayConfigSchema = z.object({ + type: z.literal("http").optional(), + url: z.string(), // Allow looser URL validation as it might contain placeholders + headers: z.record(z.string(), z.string()).optional(), + method: z.enum(["POST", "PUT"]).optional(), + timeout: z.number().optional(), +}); + +export const OpenClawCommandGatewayConfigSchema = z.object({ + type: z.literal("command"), + command: z.string(), + timeout: z.number().optional(), +}); + +export const OpenClawGatewayConfigSchema = z.union([ + OpenClawHttpGatewayConfigSchema, + OpenClawCommandGatewayConfigSchema, +]); + +export const OpenClawHookMappingSchema = z.object({ + gateway: z.string(), + instruction: z.string(), + enabled: z.boolean(), +}); + +export const OpenClawConfigSchema = z.object({ + enabled: z.boolean(), + gateways: z.record(z.string(), OpenClawGatewayConfigSchema), + hooks: z + .object({ + "session-start": OpenClawHookMappingSchema.optional(), + "session-end": OpenClawHookMappingSchema.optional(), + "session-idle": OpenClawHookMappingSchema.optional(), + "ask-user-question": OpenClawHookMappingSchema.optional(), + stop: OpenClawHookMappingSchema.optional(), + }) + .strict() + .optional(), +}); + +export type OpenClawConfig = z.infer; diff --git a/src/hooks/openclaw-sender/index.ts b/src/hooks/openclaw-sender/index.ts new file mode 100644 index 000000000..6d87f749c --- /dev/null +++ b/src/hooks/openclaw-sender/index.ts @@ -0,0 +1,70 @@ +import { wakeOpenClaw } from "../../openclaw/client"; +import type { OpenClawConfig, OpenClawContext } from "../../openclaw/types"; +import { getMainSessionID } from "../../features/claude-code-session-state"; +import type { PluginContext } from "../../plugin/types"; + +export function createOpenClawSenderHook( + ctx: PluginContext, + config: OpenClawConfig +) { + return { + event: async (input: { + event: { type: string; properties?: Record }; + }) => { + const { type, properties } = input.event; + const info = properties?.info as Record | undefined; + const context: OpenClawContext = { + sessionId: + (properties?.sessionID as string) || + (info?.id as string) || + getMainSessionID(), + projectPath: ctx.directory, + }; + + if (type === "session.created") { + await wakeOpenClaw("session-start", context, config); + } else if (type === "session.idle") { + await wakeOpenClaw("session-idle", context, config); + } else if (type === "session.deleted") { + await wakeOpenClaw("session-end", context, config); + } + }, + + "tool.execute.before": async ( + input: { tool: string; sessionID: string }, + output: { args: Record } + ) => { + const toolName = input.tool.toLowerCase(); + const context: OpenClawContext = { + sessionId: input.sessionID, + projectPath: ctx.directory, + }; + + if ( + toolName === "ask_user_question" || + toolName === "askuserquestion" || + toolName === "question" + ) { + const question = + typeof output.args.question === "string" + ? output.args.question + : undefined; + await wakeOpenClaw( + "ask-user-question", + { + ...context, + question, + }, + config + ); + } else if (toolName === "skill") { + const rawName = + typeof output.args.name === "string" ? output.args.name : undefined; + const command = rawName?.replace(/^\//, "").toLowerCase(); + if (command === "stop-continuation") { + await wakeOpenClaw("stop", context, config); + } + } + }, + }; +} diff --git a/src/openclaw/__tests__/client.test.ts b/src/openclaw/__tests__/client.test.ts new file mode 100644 index 000000000..b260c44e8 --- /dev/null +++ b/src/openclaw/__tests__/client.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "bun:test"; +import { resolveGateway } from "../client"; +import { type OpenClawConfig } from "../types"; + +describe("OpenClaw Client", () => { + describe("resolveGateway", () => { + const config: OpenClawConfig = { + enabled: true, + gateways: { + foo: { type: "command", command: "echo foo" }, + bar: { type: "http", url: "https://example.com" }, + }, + hooks: { + "session-start": { + gateway: "foo", + instruction: "start", + enabled: true, + }, + "session-end": { gateway: "bar", instruction: "end", enabled: true }, + stop: { gateway: "foo", instruction: "stop", enabled: false }, + }, + }; + + it("resolves valid mapping", () => { + const result = resolveGateway(config, "session-start"); + expect(result).not.toBeNull(); + expect(result?.gatewayName).toBe("foo"); + expect(result?.instruction).toBe("start"); + }); + + it("returns null for disabled hook", () => { + const result = resolveGateway(config, "stop"); + expect(result).toBeNull(); + }); + + it("returns null for unmapped event", () => { + const result = resolveGateway(config, "ask-user-question"); + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/openclaw/__tests__/config.test.ts b/src/openclaw/__tests__/config.test.ts new file mode 100644 index 000000000..3d85a9f7c --- /dev/null +++ b/src/openclaw/__tests__/config.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "bun:test"; +import { OpenClawConfigSchema } from "../../config/schema/openclaw"; + +describe("OpenClaw Config Schema", () => { + it("validates correct config", () => { + const raw = { + enabled: true, + gateways: { + foo: { type: "command", command: "echo foo" }, + bar: { type: "http", url: "https://example.com" }, + }, + hooks: { + "session-start": { + gateway: "foo", + instruction: "start", + enabled: true, + }, + }, + }; + const parsed = OpenClawConfigSchema.safeParse(raw); + if (!parsed.success) console.log(parsed.error); + expect(parsed.success).toBe(true); + }); + + it("fails on invalid event", () => { + const raw = { + enabled: true, + gateways: {}, + hooks: { + "invalid-event": { + gateway: "foo", + instruction: "start", + enabled: true, + }, + }, + }; + const parsed = OpenClawConfigSchema.safeParse(raw); + expect(parsed.success).toBe(false); + }); +}); diff --git a/src/openclaw/__tests__/dispatcher.test.ts b/src/openclaw/__tests__/dispatcher.test.ts new file mode 100644 index 000000000..d670092a1 --- /dev/null +++ b/src/openclaw/__tests__/dispatcher.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "bun:test"; +import { + interpolateInstruction, + resolveCommandTimeoutMs, + shellEscapeArg, + validateGatewayUrl, + wakeCommandGateway, +} from "../dispatcher"; +import { type OpenClawCommandGatewayConfig } from "../types"; + +describe("OpenClaw Dispatcher", () => { + describe("validateGatewayUrl", () => { + it("accepts valid https URLs", () => { + expect(validateGatewayUrl("https://example.com")).toBe(true); + }); + + it("rejects http URLs (remote)", () => { + expect(validateGatewayUrl("http://example.com")).toBe(false); + }); + + it("accepts http URLs for localhost", () => { + expect(validateGatewayUrl("http://localhost:3000")).toBe(true); + expect(validateGatewayUrl("http://127.0.0.1:8080")).toBe(true); + }); + }); + + describe("interpolateInstruction", () => { + it("interpolates variables correctly", () => { + const result = interpolateInstruction("Hello {{name}}!", { name: "World" }); + expect(result).toBe("Hello World!"); + }); + + it("handles missing variables", () => { + const result = interpolateInstruction("Hello {{name}}!", {}); + expect(result).toBe("Hello !"); + }); + }); + + describe("shellEscapeArg", () => { + it("escapes simple string", () => { + expect(shellEscapeArg("foo")).toBe("'foo'"); + }); + + it("escapes string with single quotes", () => { + expect(shellEscapeArg("it's")).toBe("'it'\\''s'"); + }); + }); + + describe("resolveCommandTimeoutMs", () => { + it("uses default timeout", () => { + expect(resolveCommandTimeoutMs(undefined, undefined)).toBe(5000); + }); + + it("uses provided timeout", () => { + expect(resolveCommandTimeoutMs(1000, undefined)).toBe(1000); + }); + + it("clamps timeout", () => { + expect(resolveCommandTimeoutMs(10, undefined)).toBe(100); + expect(resolveCommandTimeoutMs(1000000, undefined)).toBe(300000); + }); + }); + + describe("wakeCommandGateway", () => { + it("rejects if disabled via env", async () => { + const oldEnv = process.env.OMX_OPENCLAW_COMMAND; + process.env.OMX_OPENCLAW_COMMAND = "0"; + const config: OpenClawCommandGatewayConfig = { + type: "command", + command: "echo hi", + }; + const result = await wakeCommandGateway("test", config, {}); + expect(result.success).toBe(false); + expect(result.error).toContain("disabled"); + process.env.OMX_OPENCLAW_COMMAND = oldEnv; + }); + }); +}); diff --git a/src/openclaw/client.ts b/src/openclaw/client.ts new file mode 100644 index 000000000..202f905f8 --- /dev/null +++ b/src/openclaw/client.ts @@ -0,0 +1,256 @@ +/** + * OpenClaw Integration - Client + * + * Wakes OpenClaw gateways on hook events. Non-blocking, fire-and-forget. + * + * Usage: + * wakeOpenClaw("session-start", { sessionId, projectPath: directory }, config); + * + * Activation requires OMX_OPENCLAW=1 env var and config in pluginConfig.openclaw. + */ + +import { + type OpenClawConfig, + type OpenClawContext, + type OpenClawHookEvent, + type OpenClawResult, + type OpenClawGatewayConfig, + type OpenClawHttpGatewayConfig, + type OpenClawCommandGatewayConfig, + type OpenClawPayload, +} from "./types"; +import { + interpolateInstruction, + isCommandGateway, + wakeCommandGateway, + wakeGateway, +} from "./dispatcher"; +import { execSync } from "child_process"; +import { basename } from "path"; + +/** Whether debug logging is enabled */ +const DEBUG = process.env.OMX_OPENCLAW_DEBUG === "1"; + +// Helper for tmux session +function getCurrentTmuxSession(): string | undefined { + if (!process.env.TMUX) return undefined; + try { + // tmux display-message -p '#S' + const session = execSync("tmux display-message -p '#S'", { + encoding: "utf-8", + }).trim(); + return session || undefined; + } catch { + return undefined; + } +} + +// Helper for tmux capture +function captureTmuxPane(paneId: string, lines: number): string | undefined { + try { + // tmux capture-pane -p -t {paneId} -S -{lines} + const output = execSync( + `tmux capture-pane -p -t "${paneId}" -S -${lines}`, + { encoding: "utf-8" } + ); + return output || undefined; + } catch { + return undefined; + } +} + +/** + * Build a whitelisted context object from the input context. + * Only known fields are included to prevent accidental data leakage. + */ +function buildWhitelistedContext(context: OpenClawContext): OpenClawContext { + const result: OpenClawContext = {}; + if (context.sessionId !== undefined) result.sessionId = context.sessionId; + if (context.projectPath !== undefined) + result.projectPath = context.projectPath; + if (context.tmuxSession !== undefined) + result.tmuxSession = context.tmuxSession; + if (context.prompt !== undefined) result.prompt = context.prompt; + if (context.contextSummary !== undefined) + result.contextSummary = context.contextSummary; + if (context.reason !== undefined) result.reason = context.reason; + if (context.question !== undefined) result.question = context.question; + if (context.tmuxTail !== undefined) result.tmuxTail = context.tmuxTail; + if (context.replyChannel !== undefined) + result.replyChannel = context.replyChannel; + if (context.replyTarget !== undefined) + result.replyTarget = context.replyTarget; + if (context.replyThread !== undefined) + result.replyThread = context.replyThread; + return result; +} + +/** + * Resolve gateway config for a specific hook event. + * Returns null if the event is not mapped or disabled. + * Returns the gateway name alongside config to avoid O(n) reverse lookup. + */ +export function resolveGateway( + config: OpenClawConfig, + event: OpenClawHookEvent +): { + gatewayName: string; + gateway: OpenClawGatewayConfig; + instruction: string; +} | null { + const mapping = config.hooks?.[event]; + if (!mapping || !mapping.enabled) { + return null; + } + const gateway = config.gateways?.[mapping.gateway]; + if (!gateway) { + return null; + } + // Validate based on gateway type + if (gateway.type === "command") { + if (!gateway.command) return null; + } else { + // HTTP gateway (default when type is absent or "http") + if (!("url" in gateway) || !gateway.url) return null; + } + return { + gatewayName: mapping.gateway, + gateway, + instruction: mapping.instruction, + }; +} + +/** + * Wake the OpenClaw gateway mapped to a hook event. + * + * This is the main entry point called from the notify hook. + * Non-blocking, swallows all errors. Returns null if OpenClaw + * is not configured or the event is not mapped. + * + * @param event - The hook event type + * @param context - Context data for template variable interpolation + * @param config - OpenClaw configuration + * @returns OpenClawResult or null if not configured/mapped + */ +export async function wakeOpenClaw( + event: OpenClawHookEvent, + context: OpenClawContext, + config?: OpenClawConfig +): Promise { + try { + // Activation gate: only active when OMX_OPENCLAW=1 + if (process.env.OMX_OPENCLAW !== "1") { + return null; + } + + if (!config || !config.enabled) return null; + + const resolved = resolveGateway(config, event); + if (!resolved) return null; + + const { gatewayName, gateway, instruction } = resolved; + const now = new Date().toISOString(); + + // Read originating channel context from env vars + const replyChannel = + context.replyChannel ?? process.env.OPENCLAW_REPLY_CHANNEL ?? undefined; + const replyTarget = + context.replyTarget ?? process.env.OPENCLAW_REPLY_TARGET ?? undefined; + const replyThread = + context.replyThread ?? process.env.OPENCLAW_REPLY_THREAD ?? undefined; + + // Merge reply context + const enrichedContext: OpenClawContext = { + ...context, + ...(replyChannel !== undefined && { replyChannel }), + ...(replyTarget !== undefined && { replyTarget }), + ...(replyThread !== undefined && { replyThread }), + }; + + // Auto-detect tmux session + const tmuxSession = + enrichedContext.tmuxSession ?? getCurrentTmuxSession() ?? undefined; + + // Auto-capture tmux pane content + let tmuxTail = enrichedContext.tmuxTail; + if ( + !tmuxTail && + (event === "stop" || event === "session-end") && + process.env.TMUX + ) { + const paneId = process.env.TMUX_PANE; + if (paneId) { + tmuxTail = captureTmuxPane(paneId, 15) ?? undefined; + } + } + + // Build template variables + const variables: Record = { + sessionId: enrichedContext.sessionId, + projectPath: enrichedContext.projectPath, + projectName: enrichedContext.projectPath + ? basename(enrichedContext.projectPath) + : undefined, + tmuxSession, + prompt: enrichedContext.prompt, + contextSummary: enrichedContext.contextSummary, + reason: enrichedContext.reason, + question: enrichedContext.question, + tmuxTail, + event, + timestamp: now, + replyChannel, + replyTarget, + replyThread, + }; + + // Interpolate instruction + const interpolatedInstruction = interpolateInstruction( + instruction, + variables + ); + variables.instruction = interpolatedInstruction; + + let result: OpenClawResult; + + if (isCommandGateway(gateway)) { + result = await wakeCommandGateway(gatewayName, gateway, variables); + } else { + const payload: OpenClawPayload = { + event, + instruction: interpolatedInstruction, + text: interpolatedInstruction, + timestamp: now, + sessionId: enrichedContext.sessionId, + projectPath: enrichedContext.projectPath, + projectName: enrichedContext.projectPath + ? basename(enrichedContext.projectPath) + : undefined, + tmuxSession, + tmuxTail, + ...(replyChannel !== undefined && { channel: replyChannel }), + ...(replyTarget !== undefined && { to: replyTarget }), + ...(replyThread !== undefined && { threadId: replyThread }), + context: buildWhitelistedContext(enrichedContext), + }; + result = await wakeGateway(gatewayName, gateway, payload); + } + + if (DEBUG) { + console.error( + `[openclaw] wake ${event} -> ${gatewayName}: ${ + result.success ? "ok" : result.error + }` + ); + } + return result; + } catch (error) { + if (DEBUG) { + console.error( + `[openclaw] wakeOpenClaw error:`, + error instanceof Error ? error.message : error + ); + } + return null; + } +} diff --git a/src/openclaw/dispatcher.ts b/src/openclaw/dispatcher.ts new file mode 100644 index 000000000..e17bcd918 --- /dev/null +++ b/src/openclaw/dispatcher.ts @@ -0,0 +1,317 @@ +/** + * OpenClaw Gateway Dispatcher + * + * Sends instruction payloads to OpenClaw gateways via HTTP or CLI command. + * All calls are non-blocking with timeouts. Failures are swallowed + * to avoid blocking hooks. + * + * SECURITY: Command gateway requires OMX_OPENCLAW_COMMAND=1 opt-in. + * Command timeout is configurable with safe bounds. + * Prefers execFile for simple commands; falls back to sh -c only for shell metacharacters. + */ + +import { + type OpenClawCommandGatewayConfig, + type OpenClawGatewayConfig, + type OpenClawHttpGatewayConfig, + type OpenClawPayload, + type OpenClawResult, +} from "./types"; +import { exec, execFile } from "child_process"; + +/** Default per-request timeout for HTTP gateways */ +const DEFAULT_HTTP_TIMEOUT_MS = 10_000; +/** Default command gateway timeout (backward-compatible default) */ +const DEFAULT_COMMAND_TIMEOUT_MS = 5_000; +/** + * Command timeout safety bounds. + * - Minimum 100ms: avoids immediate/near-zero timeout misconfiguration. + * - Maximum 300000ms (5 minutes): prevents runaway long-lived command processes. + */ +const MIN_COMMAND_TIMEOUT_MS = 100; +const MAX_COMMAND_TIMEOUT_MS = 300_000; + +/** Shell metacharacters that require sh -c instead of execFile */ +const SHELL_METACHAR_RE = /[|&;><`$()]/; + +/** + * Validate gateway URL. Must be HTTPS, except localhost/127.0.0.1/::1 + * which allows HTTP for local development. + */ +export function validateGatewayUrl(url: string): boolean { + try { + const parsed = new URL(url); + if (parsed.protocol === "https:") return true; + if ( + parsed.protocol === "http:" && + (parsed.hostname === "localhost" || + parsed.hostname === "127.0.0.1" || + parsed.hostname === "::1" || + parsed.hostname === "[::1]") + ) { + return true; + } + return false; + } catch (err) { + process.stderr.write(`[openclaw-dispatcher] operation failed: ${err}\n`); + return false; + } +} + +/** + * Interpolate template variables in an instruction string. + * + * Supported variables (from hook context): + * - {{projectName}} - basename of project directory + * - {{projectPath}} - full project directory path + * - {{sessionId}} - session identifier + * - {{prompt}} - prompt text + * - {{contextSummary}} - context summary (session-end event) + * - {{question}} - question text (ask-user-question event) + * - {{timestamp}} - ISO timestamp + * - {{event}} - hook event name + * - {{instruction}} - interpolated instruction (for command gateway) + * - {{replyChannel}} - originating channel (from OPENCLAW_REPLY_CHANNEL env var) + * - {{replyTarget}} - reply target user/bot (from OPENCLAW_REPLY_TARGET env var) + * - {{replyThread}} - reply thread ID (from OPENCLAW_REPLY_THREAD env var) + * + * Unresolved variables are replaced with empty string. + */ +export function interpolateInstruction( + template: string, + variables: Record +): string { + return template.replace(/\{\{(\w+)\}\}/g, (_match, key) => { + return variables[key] ?? ""; + }); +} + +/** + * Type guard: is this gateway config a command gateway? + */ +export function isCommandGateway( + config: OpenClawGatewayConfig +): config is OpenClawCommandGatewayConfig { + return config.type === "command"; +} + +/** + * Shell-escape a string for safe embedding in a shell command. + * Uses single-quote wrapping with internal quote escaping. + */ +export function shellEscapeArg(value: string): string { + return "'" + value.replace(/'/g, "'\\''") + "'"; +} + +/** + * Resolve command gateway timeout with precedence: + * gateway timeout > OMX_OPENCLAW_COMMAND_TIMEOUT_MS > default. + */ +export function resolveCommandTimeoutMs( + gatewayTimeout?: number, + envTimeoutRaw = process.env.OMX_OPENCLAW_COMMAND_TIMEOUT_MS +): number { + const parseFinite = (value: unknown): number | undefined => { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + return value; + }; + const parseEnv = (value: string | undefined): number | undefined => { + if (!value) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + }; + + const rawTimeout = + parseFinite(gatewayTimeout) ?? + parseEnv(envTimeoutRaw) ?? + DEFAULT_COMMAND_TIMEOUT_MS; + + return Math.min( + MAX_COMMAND_TIMEOUT_MS, + Math.max(MIN_COMMAND_TIMEOUT_MS, Math.trunc(rawTimeout)) + ); +} + +/** + * Wake an HTTP-type OpenClaw gateway with the given payload. + */ +export async function wakeGateway( + gatewayName: string, + gatewayConfig: OpenClawHttpGatewayConfig, + payload: OpenClawPayload +): Promise { + if (!validateGatewayUrl(gatewayConfig.url)) { + return { + gateway: gatewayName, + success: false, + error: "Invalid URL (HTTPS required)", + }; + } + + try { + const headers = { + "Content-Type": "application/json", + ...gatewayConfig.headers, + }; + const timeout = gatewayConfig.timeout ?? DEFAULT_HTTP_TIMEOUT_MS; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + const response = await fetch(gatewayConfig.url, { + method: gatewayConfig.method || "POST", + headers, + body: JSON.stringify(payload), + signal: controller.signal, + }); + clearTimeout(timeoutId); + + if (!response.ok) { + return { + gateway: gatewayName, + success: false, + error: `HTTP ${response.status}`, + statusCode: response.status, + }; + } + + return { gateway: gatewayName, success: true, statusCode: response.status }; + } catch (error) { + return { + gateway: gatewayName, + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } +} + +/** + * Wake a command-type OpenClaw gateway by executing a shell command. + * + * SECURITY REQUIREMENTS: + * - Requires OMX_OPENCLAW_COMMAND=1 opt-in (separate gate from OMX_OPENCLAW) + * - Timeout is configurable via gateway.timeout or OMX_OPENCLAW_COMMAND_TIMEOUT_MS + * with safe clamping bounds and backward-compatible default 5000ms + * - Prefers execFile for simple commands (no metacharacters) + * - Falls back to sh -c only when metacharacters detected + * - detached: false to prevent orphan processes + * - SIGTERM cleanup handler kills child on parent SIGTERM, 1s grace then SIGKILL + * + * The command template supports {{variable}} placeholders. All variable + * values are shell-escaped before interpolation to prevent injection. + */ +export async function wakeCommandGateway( + gatewayName: string, + gatewayConfig: OpenClawCommandGatewayConfig, + variables: Record +): Promise { + // Separate command gateway opt-in gate + if (process.env.OMX_OPENCLAW_COMMAND !== "1") { + return { + gateway: gatewayName, + success: false, + error: "Command gateway disabled (set OMX_OPENCLAW_COMMAND=1 to enable)", + }; + } + + let child: any = null; + let sigtermHandler: (() => void) | null = null; + + try { + const timeout = resolveCommandTimeoutMs(gatewayConfig.timeout); + + // Interpolate variables with shell escaping + const interpolated = gatewayConfig.command.replace( + /\{\{(\w+)\}\}/g, + (match, key) => { + const value = variables[key]; + if (value === undefined) return match; + return shellEscapeArg(value); + } + ); + + // Detect whether the interpolated command contains shell metacharacters + const hasMetachars = SHELL_METACHAR_RE.test(interpolated); + + await new Promise((resolve, reject) => { + const cleanup = (signal: NodeJS.Signals) => { + if (child) { + child.kill(signal); + // 1s grace period then SIGKILL + setTimeout(() => { + try { + child?.kill("SIGKILL"); + } catch (err) { + process.stderr.write( + `[openclaw-dispatcher] operation failed: ${err}\n` + ); + } + }, 1000); + } + }; + + sigtermHandler = () => cleanup("SIGTERM"); + process.once("SIGTERM", sigtermHandler); + + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + if (sigtermHandler) { + process.removeListener("SIGTERM", sigtermHandler); + sigtermHandler = null; + } + + if (signal) { + reject(new Error(`Command killed by signal ${signal}`)); + } else if (code !== 0) { + reject(new Error(`Command exited with code ${code}`)); + } else { + resolve(); + } + }; + + const onError = (err: Error) => { + if (sigtermHandler) { + process.removeListener("SIGTERM", sigtermHandler); + sigtermHandler = null; + } + reject(err); + }; + + if (hasMetachars) { + // Fall back to sh -c for complex commands with metacharacters + child = exec(interpolated, { + timeout, + env: { ...process.env }, + }); + } else { + // Parse simple command: split on whitespace, use execFile + const parts = interpolated.split(/\s+/).filter(Boolean); + const cmd = parts[0]; + const args = parts.slice(1); + child = execFile(cmd, args, { + timeout, + env: { ...process.env }, + }); + } + + // Ensure detached is false (default, but explicit via options above) + if (child) { + child.on("exit", onExit); + child.on("error", onError); + } else { + reject(new Error("Failed to spawn process")); + } + }); + + return { gateway: gatewayName, success: true }; + } catch (error) { + // Ensure SIGTERM handler is cleaned up on error + if (sigtermHandler) { + process.removeListener("SIGTERM", sigtermHandler as () => void); + } + return { + gateway: gatewayName, + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } +} diff --git a/src/openclaw/index.ts b/src/openclaw/index.ts new file mode 100644 index 000000000..b872dfb21 --- /dev/null +++ b/src/openclaw/index.ts @@ -0,0 +1,10 @@ +export { resolveGateway, wakeOpenClaw } from "./client"; +export { + interpolateInstruction, + isCommandGateway, + shellEscapeArg, + validateGatewayUrl, + wakeCommandGateway, + wakeGateway, +} from "./dispatcher"; +export * from "./types"; diff --git a/src/openclaw/types.ts b/src/openclaw/types.ts new file mode 100644 index 000000000..b7e4747f2 --- /dev/null +++ b/src/openclaw/types.ts @@ -0,0 +1,134 @@ +/** + * OpenClaw Gateway Integration Types + * + * Defines types for the OpenClaw gateway waker system. + * Each hook event can be mapped to a gateway with a pre-defined instruction. + */ + +/** Hook events that can trigger OpenClaw gateway calls */ +export type OpenClawHookEvent = + | "session-start" + | "session-end" + | "session-idle" + | "ask-user-question" + | "stop"; + +/** HTTP gateway configuration (default when type is absent or "http") */ +export interface OpenClawHttpGatewayConfig { + /** Gateway type discriminator (optional for backward compat) */ + type?: "http"; + /** Gateway endpoint URL (HTTPS required, HTTP allowed for localhost) */ + url: string; + /** Optional custom headers (e.g., Authorization) */ + headers?: Record; + /** HTTP method (default: POST) */ + method?: "POST" | "PUT"; + /** Per-request timeout in ms (default: 10000) */ + timeout?: number; +} + +/** CLI command gateway configuration */ +export interface OpenClawCommandGatewayConfig { + /** Gateway type discriminator */ + type: "command"; + /** Command template with {{variable}} placeholders. + * Variables are shell-escaped automatically before interpolation. */ + command: string; + /** + * Per-command timeout in ms. + * Precedence: gateway timeout > OMX_OPENCLAW_COMMAND_TIMEOUT_MS > default (5000ms). + * Runtime clamps to safe bounds. + */ + timeout?: number; +} + +/** Gateway configuration — HTTP or CLI command */ +export type OpenClawGatewayConfig = + | OpenClawHttpGatewayConfig + | OpenClawCommandGatewayConfig; + +/** Per-hook-event mapping to a gateway + instruction */ +export interface OpenClawHookMapping { + /** Name of the gateway (key in gateways object) */ + gateway: string; + /** Instruction template with {{variable}} placeholders */ + instruction: string; + /** Whether this hook-event mapping is active */ + enabled: boolean; +} + +/** Top-level config schema for notifications.openclaw key in .omx-config.json */ +export interface OpenClawConfig { + /** Global enable/disable */ + enabled: boolean; + /** Named gateway endpoints */ + gateways: Record; + /** Hook-event to gateway+instruction mappings */ + hooks?: Partial>; +} + +/** Payload sent to an OpenClaw gateway */ +export interface OpenClawPayload { + /** The hook event that triggered this call */ + event: OpenClawHookEvent; + /** Interpolated instruction text */ + instruction: string; + /** Alias of instruction — allows OpenClaw /hooks/wake to consume the payload directly */ + text: string; + /** ISO timestamp */ + timestamp: string; + /** Session identifier (if available) */ + sessionId?: string; + /** Project directory path */ + projectPath?: string; + /** Project basename */ + projectName?: string; + /** Tmux session name (if running inside tmux) */ + tmuxSession?: string; + /** Recent tmux pane output (for stop/session-end events) */ + tmuxTail?: string; + /** Originating channel for reply routing (if OPENCLAW_REPLY_CHANNEL is set) */ + channel?: string; + /** Reply target user/bot (if OPENCLAW_REPLY_TARGET is set) */ + to?: string; + /** Reply thread ID (if OPENCLAW_REPLY_THREAD is set) */ + threadId?: string; + /** Context data from the hook (whitelisted fields only) */ + context: OpenClawContext; +} + +/** + * Context data passed from the hook to OpenClaw for template interpolation. + * + * All fields are explicitly enumerated (no index signature) to prevent + * accidental leakage of sensitive data into gateway payloads. + */ +export interface OpenClawContext { + sessionId?: string; + projectPath?: string; + tmuxSession?: string; + prompt?: string; + contextSummary?: string; + reason?: string; + question?: string; + /** Recent tmux pane output (captured automatically for stop/session-end events) */ + tmuxTail?: string; + /** Originating channel for reply routing (from OPENCLAW_REPLY_CHANNEL env var) */ + replyChannel?: string; + /** Reply target user/bot (from OPENCLAW_REPLY_TARGET env var) */ + replyTarget?: string; + /** Reply thread ID for threaded conversations (from OPENCLAW_REPLY_THREAD env var) */ + replyThread?: string; +} + +/** Result of a gateway wake attempt */ +export interface OpenClawResult { + /** Gateway name */ + gateway: string; + /** Whether the call succeeded */ + success: boolean; + /** Error message if failed */ + error?: string; + /** HTTP status code if available */ + statusCode?: number; +} diff --git a/src/plugin/event.ts b/src/plugin/event.ts index d18ec3691..2b5a83058 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -215,6 +215,7 @@ export function createEventHandler(args: { await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input)); await Promise.resolve(hooks.writeExistingFileGuard?.event?.(input)); await Promise.resolve(hooks.atlasHook?.handler?.(input)); + await Promise.resolve(hooks.openclawSender?.event?.(input)); await Promise.resolve(hooks.autoSlashCommand?.event?.(input)); }; diff --git a/src/plugin/hooks/create-session-hooks.ts b/src/plugin/hooks/create-session-hooks.ts index daa5e4ff5..60ab77db4 100644 --- a/src/plugin/hooks/create-session-hooks.ts +++ b/src/plugin/hooks/create-session-hooks.ts @@ -26,6 +26,7 @@ import { createPreemptiveCompactionHook, createRuntimeFallbackHook, } from "../../hooks" +import { createOpenClawSenderHook } from "../../hooks/openclaw-sender" import { createAnthropicEffortHook } from "../../hooks/anthropic-effort" import { detectExternalNotificationPlugin, @@ -60,6 +61,7 @@ export type SessionHooks = { taskResumeInfo: ReturnType | null anthropicEffort: ReturnType | null runtimeFallback: ReturnType | null + openclawSender: ReturnType | null } export function createSessionHooks(args: { @@ -261,6 +263,11 @@ export function createSessionHooks(args: { pluginConfig, })) : null + + const openclawSender = isHookEnabled("openclaw-sender") && pluginConfig.openclaw?.enabled + ? safeHook("openclaw-sender", () => createOpenClawSenderHook(ctx, pluginConfig.openclaw!)) + : null + return { contextWindowMonitor, preemptiveCompaction, @@ -285,5 +292,6 @@ export function createSessionHooks(args: { taskResumeInfo, anthropicEffort, runtimeFallback, + openclawSender, } } diff --git a/src/plugin/tool-execute-before.ts b/src/plugin/tool-execute-before.ts index 000b121dd..173d7e82d 100644 --- a/src/plugin/tool-execute-before.ts +++ b/src/plugin/tool-execute-before.ts @@ -33,6 +33,7 @@ export function createToolExecuteBeforeHandler(args: { await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output) await hooks.sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output) await hooks.atlasHook?.["tool.execute.before"]?.(input, output) + await hooks.openclawSender?.["tool.execute.before"]?.(input, output) const normalizedToolName = input.tool.toLowerCase() if (