diff --git a/openclaw.plugin.json b/openclaw.plugin.json index f548ddf..f080b71 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -2,8 +2,23 @@ "id": "clawflow", "name": "ClawFlow", "description": "The n8n for agents. Declarative, AI-native workflow engine — LLM-writable, Cloudflare-portable.", - "version": "0.2.2", + "version": "1.2.0", "skills": ["./skills/flow"], + "contracts": { + "tools": [ + "flow_create", + "flow_delete", + "flow_restore_from_bin", + "flow_run", + "flow_resume", + "flow_send_event", + "flow_status", + "flow_list", + "flow_read", + "flow_publish", + "flow_edit" + ] + }, "configSchema": { "type": "object", "additionalProperties": false, @@ -23,6 +38,16 @@ "flowsDir": { "type": "string" } }, "required": ["port"] + }, + "approval": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "skipSessionPatterns": { "type": "array", "items": { "type": "string" } }, + "timeoutMs": { "type": "number" }, + "timeoutBehavior": { "type": "string", "enum": ["allow", "deny"] } + } } } }, @@ -34,6 +59,7 @@ "memoryDir": { "label": "Memory Directory" }, "stateDir": { "label": "Flow State Directory" }, "maxNodeDurationMs": { "label": "Node Timeout (ms)", "placeholder": "30000" }, - "serve": { "label": "Flow HTTP Server", "help": "Start an HTTP server that runs flows on POST. Requires at least port. Endpoint: POST ///run with the JSON body as the flow's inputs." } + "serve": { "label": "Flow HTTP Server", "help": "Start an HTTP server that runs flows on POST. Requires at least port. Endpoint: POST ///run with the JSON body as the flow's inputs." }, + "approval": { "label": "Approval Gate", "help": "Prompt the user before flow_run executes. Set enabled=false to disable entirely. Add session-key substrings to skipSessionPatterns to bypass for unattended hooks (e.g. \"email\" for inbound-email automation)." } } } diff --git a/package.json b/package.json index ce66ad9..71e14bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@clawnify/clawflow", - "version": "1.1.0", + "version": "1.2.0", "description": "The n8n for agents. A declarative, AI-native workflow format that agents can read, write, and run.", "type": "module", "main": "./dist/index.js", @@ -28,11 +28,11 @@ "./index.ts" ], "compat": { - "pluginApi": ">=2026.3.22", - "minGatewayVersion": ">=2026.3.22" + "pluginApi": ">=2026.5.2", + "minGatewayVersion": ">=2026.5.2" }, "build": { - "openclawVersion": "2026.3.24" + "openclawVersion": "2026.5.12" } }, "files": [ diff --git a/src/core/types.ts b/src/core/types.ts index 747693b..7929710 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -393,6 +393,30 @@ export interface PluginConfig { * test isolation or to run multiple FlowRunners with different step sets. */ customSteps?: import("./custom-steps.js").StepRegistry; + /** + * Approval gate for the `flow_run` tool. Flows can call HTTP, exec, and + * agent tools, so by default the plugin prompts the user before each run. + * Hosts that need unattended automation can disable the gate entirely or + * skip it for specific session contexts. + */ + approval?: ApprovalConfig; +} + +export interface ApprovalConfig { + /** Disable the approval gate entirely. Default: `true` (gate enabled). */ + enabled?: boolean; + /** + * Substrings matched against the current session key. If any substring + * appears in the session key, the approval gate is skipped for that run. + * Useful for hook-driven sessions where no interactive channel is bound + * (e.g. inbound email automation, where the session key looks like + * `agent:main:main:email:`). Default: `[]`. + */ + skipSessionPatterns?: string[]; + /** Override the prompt timeout (ms). Default: `300000` (5 min). */ + timeoutMs?: number; + /** Action on prompt timeout: `"allow"` or `"deny"`. Default: `"deny"`. */ + timeoutBehavior?: "allow" | "deny"; } // ---- Model Shorthands ----------------------------------------------------------- diff --git a/src/index.ts b/src/index.ts index e1dc62f..e685d54 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,10 @@ -// clawflow — core exports +// clawflow — core exports + OpenClaw plugin entry +// +// The default export is the OpenClaw plugin definition (id, register). Named +// exports are the public library surface (FlowRunner, validateFlow, etc.) for +// standalone consumers (e.g. `@clawnify/clawflow` as a Cloudflare runtime). +export { default } from "./plugin/index.js"; + export { FlowRunner, sendEvent } from "./core/runner.js"; export { StateStore } from "./core/store.js"; export { transpileToCloudflare } from "./core/transpile.js"; diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 762a172..e057eba 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -19,15 +19,38 @@ import type { FlowDefinition, FlowNode, PluginConfig, BranchNode, ConditionNode, // flow_publish — publish a draft flow as a numbered version // flow_edit — edit nodes in a flow definition (file or inline) +interface BeforeToolCallEvent { + toolName?: string; + /** Older OpenClaw releases used `tool`; kept for back-compat. */ + tool?: string; + params?: unknown; + context?: { + sessionKey?: string; + sessionId?: string; + agentId?: string; + runId?: string; + [k: string]: unknown; + }; + [k: string]: unknown; +} + +type RequireApprovalDecision = { + title: string; + description: string; + severity?: "info" | "warning" | "critical"; + timeoutMs?: number; + timeoutBehavior?: "allow" | "deny"; +}; + interface PluginApi { registerTool: (def: object, opts?: { optional?: boolean }) => void; registerHook?: ( - name: string, - handler: (event: { tool?: string; params?: unknown; [k: string]: unknown }) => - | { requireApproval?: boolean; prompt?: string; block?: boolean } + events: string | string[], + handler: (event: BeforeToolCallEvent) => + | { requireApproval?: RequireApprovalDecision; block?: boolean; blockReason?: string } | void - | Promise<{ requireApproval?: boolean; prompt?: string; block?: boolean } | void>, - opts?: { priority?: number }, + | Promise<{ requireApproval?: RequireApprovalDecision; block?: boolean; blockReason?: string } | void>, + opts: { name: string; description?: string; priority?: number }, ) => void; config?: { plugins?: { entries?: Record }; @@ -75,21 +98,55 @@ function register(api: PluginApi) { } // ---- Approval gate for flow_run ----------------------------------------------- - // Pause and prompt the user before any flow_run invocation. Flows can have - // side effects (HTTP, exec, agent delegation) so we require explicit consent. - if (api.registerHook) { - api.registerHook("before_tool_call", (event) => { - if (event.tool !== "flow_run") return; - const p = (event.params ?? {}) as { file?: string; flow?: { flow?: string }; version?: number; draft?: boolean }; - const target = p.file ?? p.flow?.flow ?? "inline flow"; - const variant = - p.version != null ? ` v${p.version}` : p.draft ? " (draft)" : ""; - return { - requireApproval: true, - prompt: `Run clawflow "${target}"${variant}?`, - }; - }); - } else { + // Flows can call HTTP, exec, and agent tools, so by default we prompt the user + // before each run. Disable entirely (`approval.enabled: false`) or skip for + // specific session contexts (`approval.skipSessionPatterns`) — useful for + // hook-driven, unattended automation that has no interactive channel to + // approve in. + const approvalCfg = pluginCfg.approval ?? {}; + const approvalEnabled = approvalCfg.enabled !== false; + const skipPatterns = Array.isArray(approvalCfg.skipSessionPatterns) + ? approvalCfg.skipSessionPatterns.filter((p): p is string => typeof p === "string" && p.length > 0) + : []; + const approvalTimeoutMs = + typeof approvalCfg.timeoutMs === "number" && approvalCfg.timeoutMs > 0 + ? approvalCfg.timeoutMs + : 5 * 60_000; + const approvalTimeoutBehavior: "allow" | "deny" = + approvalCfg.timeoutBehavior === "allow" ? "allow" : "deny"; + + if (api.registerHook && approvalEnabled) { + api.registerHook( + "before_tool_call", + (event) => { + const toolName = event.toolName ?? event.tool; + if (toolName !== "flow_run") return; + + const sessionKey = event.context?.sessionKey ?? ""; + if (skipPatterns.some((pattern) => sessionKey.includes(pattern))) { + return; + } + + const p = (event.params ?? {}) as { file?: string; flow?: { flow?: string }; version?: number; draft?: boolean }; + const target = p.file ?? p.flow?.flow ?? "inline flow"; + const variant = + p.version != null ? ` v${p.version}` : p.draft ? " (draft)" : ""; + return { + requireApproval: { + title: `Run clawflow "${target}"${variant}?`, + description: "Flows may call HTTP, exec, and agent tools.", + severity: "warning", + timeoutMs: approvalTimeoutMs, + timeoutBehavior: approvalTimeoutBehavior, + }, + }; + }, + { + name: "clawflow-flow-run-approval", + description: "Request user approval before executing flow_run (skippable via approval config).", + }, + ); + } else if (!api.registerHook) { api.logger?.warn( "clawflow: registerHook unavailable — flow_run will run without approval gate. Update OpenClaw to enable.", ); diff --git a/src/plugin/openclaw.plugin.json b/src/plugin/openclaw.plugin.json deleted file mode 100644 index 8ffc8f5..0000000 --- a/src/plugin/openclaw.plugin.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "id": "clawflow", - "name": "ClawFlow", - "description": "The n8n for agents. Declarative, AI-native workflow engine — LLM-writable, Cloudflare-portable.", - "version": "0.2.2", - "skills": ["./skills/flow"], - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": { - "apiKey": { "type": "string" }, - "defaultModel": { "type": "string" }, - "baseUrl": { "type": "string" }, - "defaultAgent": { "type": "string" }, - "memoryDir": { "type": "string" }, - "stateDir": { "type": "string" }, - "maxNodeDurationMs": { "type": "number" } - } - }, - "uiHints": { - "apiKey": { "label": "Anthropic API Key", "sensitive": true, "help": "Used when ANTHROPIC_API_KEY env var is not set" }, - "defaultModel": { "label": "Default AI Model", "placeholder": "smart" }, - "baseUrl": { "label": "Direct API Base URL", "placeholder": "https://api.anthropic.com" }, - "defaultAgent": { "label": "Agent ID for do:agent nodes", "placeholder": "clawflow", "help": "OpenClaw agent ID to delegate agent tasks to. Uses --local (embedded) if unset." }, - "memoryDir": { "label": "Memory Directory" }, - "stateDir": { "label": "Flow State Directory" }, - "maxNodeDurationMs": { "label": "Node Timeout (ms)", "placeholder": "30000" } - } -}