Merge pull request #2607 from code-yeongyu/feat/openclaw-integration
feat: implement OpenClaw integration
This commit is contained in:
@@ -51,6 +51,7 @@ export const HookNameSchema = z.enum([
|
||||
"anthropic-effort",
|
||||
"hashline-read-enhancer",
|
||||
"read-image-resizer",
|
||||
"openclaw-sender",
|
||||
])
|
||||
|
||||
export type HookName = z.infer<typeof HookNameSchema>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
51
src/config/schema/openclaw.ts
Normal file
51
src/config/schema/openclaw.ts
Normal file
@@ -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<typeof OpenClawConfigSchema>;
|
||||
70
src/hooks/openclaw-sender/index.ts
Normal file
70
src/hooks/openclaw-sender/index.ts
Normal file
@@ -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<string, unknown> };
|
||||
}) => {
|
||||
const { type, properties } = input.event;
|
||||
const info = properties?.info as Record<string, unknown> | 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<string, unknown> }
|
||||
) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
41
src/openclaw/__tests__/client.test.ts
Normal file
41
src/openclaw/__tests__/client.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
40
src/openclaw/__tests__/config.test.ts
Normal file
40
src/openclaw/__tests__/config.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
78
src/openclaw/__tests__/dispatcher.test.ts
Normal file
78
src/openclaw/__tests__/dispatcher.test.ts
Normal file
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
256
src/openclaw/client.ts
Normal file
256
src/openclaw/client.ts
Normal file
@@ -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<OpenClawResult | null> {
|
||||
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<string, string | undefined> = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
317
src/openclaw/dispatcher.ts
Normal file
317
src/openclaw/dispatcher.ts
Normal file
@@ -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, string | undefined>
|
||||
): 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<OpenClawResult> {
|
||||
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<string, string | undefined>
|
||||
): Promise<OpenClawResult> {
|
||||
// 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<void>((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",
|
||||
};
|
||||
}
|
||||
}
|
||||
10
src/openclaw/index.ts
Normal file
10
src/openclaw/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { resolveGateway, wakeOpenClaw } from "./client";
|
||||
export {
|
||||
interpolateInstruction,
|
||||
isCommandGateway,
|
||||
shellEscapeArg,
|
||||
validateGatewayUrl,
|
||||
wakeCommandGateway,
|
||||
wakeGateway,
|
||||
} from "./dispatcher";
|
||||
export * from "./types";
|
||||
134
src/openclaw/types.ts
Normal file
134
src/openclaw/types.ts
Normal file
@@ -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<string, string>;
|
||||
/** 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<string, OpenClawGatewayConfig>;
|
||||
/** Hook-event to gateway+instruction mappings */
|
||||
hooks?: Partial<Record<OpenClawHookEvent, OpenClawHookMapping>>;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
|
||||
@@ -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<typeof createTaskResumeInfoHook> | null
|
||||
anthropicEffort: ReturnType<typeof createAnthropicEffortHook> | null
|
||||
runtimeFallback: ReturnType<typeof createRuntimeFallbackHook> | null
|
||||
openclawSender: ReturnType<typeof createOpenClawSenderHook> | 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user