重构仓库目录为统一的 runtime 分层并清理历史 openclaw 残留。

本次迁移将网关/通道/工具/技能/脚本与协议资源集中到新结构,统一路径常量与脚本转发机制,减少顶层噪音并保证运行与测试行为一致。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-25 01:24:23 +08:00
parent ba3836f00f
commit 4a23b715a2
498 changed files with 2760 additions and 2200 deletions

View file

@ -0,0 +1,30 @@
# Webhooks 扩展(中文重写)
对应上游目录:`vendor/oclaw/extensions/webhooks`
## 能力定位
- 提供“认证后的入站 webhook”,把外部自动化请求绑定到 Oclaw TaskFlow。
- 支持多 route 配置,每个 route 对应 path/session/secret/controller。
## 关键入口
- `oclaw.plugin.json`:声明插件 `id=webhooks` 及配置 schema。
- `index.ts`:读取 routes 配置并注册 HTTP 路由。
- `api.ts`:导出插件 SDK 类型入口。
- `runtime-api.ts`:运行时相关公共接口。
## 配置核心(来自 schema)
- `routes.<routeId>.enabled`:是否启用。
- `routes.<routeId>.path`:Webhook 路径。
- `routes.<routeId>.sessionKey`:绑定会话键。
- `routes.<routeId>.secret`:密钥(字符串或 secretRef)。
- `routes.<routeId>.controllerId`:默认控制器标识。
- `routes.<routeId>.description`:可选说明。
## secret 支持类型
- 直接字符串密钥。
- `secretRef` 对象:`source`(env/file/exec)+ `provider` + `id`。
## 运行关注点
- 路由是否注册成功并与 `sessionKey` 对齐。
- secret 解析是否正确(配置路径与 provider 可用性)。
- 外部系统调用失败时的日志可观测性(routeId/path/sessionKey)。

View file

@ -0,0 +1,23 @@
from .api import build_webhooks_plugin_entry
from .config import ConfiguredWebhookRoute, resolve_webhooks_plugin_config
from .http import TaskFlowWebhookTarget, create_taskflow_webhook_request_handler, execute_webhook_action
from .runtime_api import (
WEBHOOK_IN_FLIGHT_DEFAULTS,
WEBHOOK_RATE_LIMIT_DEFAULTS,
normalize_webhook_path,
resolve_configured_secret_input_string,
)
__all__ = [
"ConfiguredWebhookRoute",
"TaskFlowWebhookTarget",
"WEBHOOK_IN_FLIGHT_DEFAULTS",
"WEBHOOK_RATE_LIMIT_DEFAULTS",
"build_webhooks_plugin_entry",
"create_taskflow_webhook_request_handler",
"execute_webhook_action",
"normalize_webhook_path",
"resolve_configured_secret_input_string",
"resolve_webhooks_plugin_config",
]

View file

@ -0,0 +1,17 @@
from __future__ import annotations
from ..api import PluginApi, PluginEntry, define_plugin_entry
from .index import register_webhook_routes
def build_webhooks_plugin_entry() -> PluginEntry:
return define_plugin_entry(
id="webhooks",
name="Webhooks",
description="Authenticated inbound webhooks that bind external automation to Oclaw TaskFlows.",
register=lambda api: register_webhook_routes(api),
)
__all__ = ["PluginApi", "PluginEntry", "build_webhooks_plugin_entry", "define_plugin_entry"]

View file

@ -0,0 +1,68 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .runtime_api import normalize_webhook_path
SecretInput = str | dict[str, str]
@dataclass(frozen=True)
class ConfiguredWebhookRoute:
route_id: str
path: str
session_key: str
secret: SecretInput
controller_id: str
description: str = ""
def _validate_secret(value: Any) -> SecretInput:
if isinstance(value, str) and value.strip():
return value.strip()
if isinstance(value, dict):
source = str(value.get("source") or "").strip()
provider = str(value.get("provider") or "").strip()
sid = str(value.get("id") or "").strip()
if source in {"env", "file", "exec"} and provider and sid:
return {"source": source, "provider": provider, "id": sid}
raise ValueError("invalid webhook secret: must be non-empty string or secretRef dict")
def resolve_webhooks_plugin_config(*, plugin_config: Any) -> list[ConfiguredWebhookRoute]:
cfg = plugin_config if isinstance(plugin_config, dict) else {}
routes = cfg.get("routes")
routes = routes if isinstance(routes, dict) else {}
out: list[ConfiguredWebhookRoute] = []
seen_paths: dict[str, str] = {}
for route_id, raw in routes.items():
rid = str(route_id or "").strip()
if not rid or not isinstance(raw, dict):
continue
enabled = bool(raw.get("enabled", True))
if not enabled:
continue
session_key = str(raw.get("sessionKey") or "").strip()
if not session_key:
raise ValueError(f"webhooks.routes.{rid}.sessionKey is required")
path = normalize_webhook_path(str(raw.get("path") or f"/plugins/webhooks/{rid}"))
if path in seen_paths:
raise ValueError(f"webhooks.routes.{rid}.path conflicts with routes.{seen_paths[path]}.path ({path})")
seen_paths[path] = rid
secret = _validate_secret(raw.get("secret"))
controller_id = str(raw.get("controllerId") or f"webhooks/{rid}").strip()
description = str(raw.get("description") or "").strip()
out.append(
ConfiguredWebhookRoute(
route_id=rid,
path=path,
session_key=session_key,
secret=secret,
controller_id=controller_id,
description=description,
)
)
return out

View file

@ -0,0 +1,338 @@
from __future__ import annotations
import hmac
import json
from dataclasses import dataclass
from typing import Any
from .runtime_api import resolve_configured_secret_input_string
@dataclass(frozen=True)
class TaskFlowWebhookTarget:
route_id: str
path: str
secret_input: str | dict[str, str]
secret_config_path: str
default_controller_id: str
task_flow: Any
def _pick_optional(data: dict[str, Any], keys: tuple[str, ...]) -> dict[str, Any]:
out: dict[str, Any] = {}
for key in keys:
if key in data and data[key] is not None:
out[key] = data[key]
return out
def _to_flow_view(flow: dict[str, Any] | None) -> dict[str, Any] | None:
if not isinstance(flow, dict):
return None
keep = {
"flowId",
"syncMode",
"controllerId",
"revision",
"status",
"notifyPolicy",
"goal",
"currentStep",
"blockedTaskId",
"blockedSummary",
"stateJson",
"waitJson",
"cancelRequestedAt",
"createdAt",
"updatedAt",
"endedAt",
}
return {k: v for k, v in flow.items() if k in keep and v is not None}
def _to_task_view(task: dict[str, Any] | None) -> dict[str, Any] | None:
if not isinstance(task, dict):
return None
keep = {
"taskId",
"runtime",
"sourceId",
"scopeKind",
"childSessionKey",
"parentFlowId",
"parentTaskId",
"agentId",
"runId",
"label",
"task",
"status",
"deliveryStatus",
"notifyPolicy",
"createdAt",
"startedAt",
"endedAt",
"lastEventAt",
"cleanupAfter",
"error",
"progressSummary",
"terminalSummary",
"terminalOutcome",
}
return {k: v for k, v in task.items() if k in keep and v is not None}
def _timing_safe_equals(left: str, right: str) -> bool:
return hmac.compare_digest(left.encode("utf-8"), right.encode("utf-8"))
def _extract_secret(headers: dict[str, str]) -> str:
auth = str(headers.get("authorization") or "")
if auth.lower().startswith("bearer "):
return auth[7:].strip()
return str(headers.get("x-oclaw-webhook-secret") or "").strip()
def _map_mutation_status(result: dict[str, Any]) -> tuple[int, str | None, str | None]:
if bool(result.get("applied")):
return (200, None, None)
code = str(result.get("code") or "").strip()
if code == "not_found":
return (404, "not_found", "TaskFlow not found.")
if code == "not_managed":
return (409, "not_managed", "TaskFlow is not managed by this webhook surface.")
if code == "revision_conflict":
return (409, "revision_conflict", "TaskFlow changed since the caller's expected revision.")
return (409, "mutation_rejected", "TaskFlow mutation was rejected.")
def _map_run_task_status(result: dict[str, Any]) -> tuple[int, str | None, str | None]:
if bool(result.get("created")):
return (200, None, None)
if not bool(result.get("found", True)):
return (404, "not_found", "TaskFlow not found.")
reason = str(result.get("reason") or "").strip()
if reason == "Flow cancellation has already been requested.":
return (409, "cancel_requested", reason)
if reason == "Flow does not accept managed child tasks.":
return (409, "not_managed", reason)
if reason.startswith("Flow is already "):
return (409, "terminal", reason)
return (409, "task_not_created", reason or "TaskFlow task was not created.")
def _map_cancel_status(result: dict[str, Any]) -> tuple[int, str | None, str | None]:
if bool(result.get("cancelled")):
return (200, None, None)
if not bool(result.get("found", True)):
return (404, "not_found", "TaskFlow not found.")
reason = str(result.get("reason") or "").strip()
if reason == "One or more child tasks are still active.":
return (202, "cancel_pending", reason)
if reason == "Flow changed while cancellation was in progress.":
return (409, "revision_conflict", reason)
if reason.startswith("Flow is already "):
return (409, "terminal", reason)
return (409, "cancel_rejected", reason or "TaskFlow cancellation was rejected.")
def _describe_webhook_outcome(action_name: str, result: dict[str, Any]) -> tuple[int, str | None, str | None]:
if action_name in {"set_waiting", "resume_flow", "finish_flow", "fail_flow", "request_cancel"}:
return _map_mutation_status(result)
if action_name == "cancel_flow":
return _map_cancel_status(result)
if action_name == "run_task":
return _map_run_task_status(result)
return (200, None, None)
def _map_flow_mutation_result(result: dict[str, Any]) -> dict[str, Any]:
if bool(result.get("applied")):
flow = _to_flow_view(result.get("flow") if isinstance(result.get("flow"), dict) else None)
return {"applied": True, "flow": flow}
current = result.get("current")
out = {
"applied": False,
"code": str(result.get("code") or ""),
}
if isinstance(current, dict):
out["current"] = _to_flow_view(current)
return out
def execute_webhook_action(*, action: dict[str, Any], target: TaskFlowWebhookTarget, cfg: dict[str, Any]) -> dict[str, Any]:
name = str(action.get("action") or "").strip()
tf = target.task_flow
if name == "create_flow":
flow = tf.create_managed(
controller_id=action.get("controllerId") or target.default_controller_id,
goal=action["goal"],
status=action.get("status"),
notify_policy=action.get("notifyPolicy"),
current_step=action.get("currentStep"),
state_json=action.get("stateJson"),
wait_json=action.get("waitJson"),
)
return {"flow": _to_flow_view(flow)}
if name == "get_flow":
flow = tf.get(action["flowId"])
return {"flow": _to_flow_view(flow)}
if name == "list_flows":
flows = tf.list()
return {"flows": [_to_flow_view(x) for x in flows]}
if name == "find_latest_flow":
flow = tf.find_latest()
return {"flow": _to_flow_view(flow)}
if name == "resolve_flow":
flow = tf.resolve(action["token"])
return {"flow": _to_flow_view(flow)}
if name == "get_task_summary":
return {"summary": tf.get_task_summary(action["flowId"])}
if name == "set_waiting":
raw = tf.set_waiting(
flow_id=action["flowId"],
expected_revision=action["expectedRevision"],
current_step=action.get("currentStep"),
state_json=action.get("stateJson"),
wait_json=action.get("waitJson"),
blocked_task_id=action.get("blockedTaskId"),
blocked_summary=action.get("blockedSummary"),
)
return _map_flow_mutation_result(raw if isinstance(raw, dict) else {})
if name == "resume_flow":
raw = tf.resume(
flow_id=action["flowId"],
expected_revision=action["expectedRevision"],
status=action.get("status"),
current_step=action.get("currentStep"),
state_json=action.get("stateJson"),
)
return _map_flow_mutation_result(raw if isinstance(raw, dict) else {})
if name == "finish_flow":
raw = tf.finish(
flow_id=action["flowId"],
expected_revision=action["expectedRevision"],
state_json=action.get("stateJson"),
)
return _map_flow_mutation_result(raw if isinstance(raw, dict) else {})
if name == "fail_flow":
raw = tf.fail(
flow_id=action["flowId"],
expected_revision=action["expectedRevision"],
state_json=action.get("stateJson"),
blocked_task_id=action.get("blockedTaskId"),
blocked_summary=action.get("blockedSummary"),
)
return _map_flow_mutation_result(raw if isinstance(raw, dict) else {})
if name == "request_cancel":
raw = tf.request_cancel(
flow_id=action["flowId"],
expected_revision=action["expectedRevision"],
)
return _map_flow_mutation_result(raw if isinstance(raw, dict) else {})
if name == "cancel_flow":
raw = tf.cancel(flow_id=action["flowId"], cfg=cfg)
if not isinstance(raw, dict):
return {"found": False, "cancelled": False, "reason": "invalid cancel result"}
out = {
"found": bool(raw.get("found")),
"cancelled": bool(raw.get("cancelled")),
}
if raw.get("reason") is not None:
out["reason"] = str(raw.get("reason"))
flow = _to_flow_view(raw.get("flow") if isinstance(raw.get("flow"), dict) else None)
if flow is not None:
out["flow"] = flow
tasks = raw.get("tasks")
if isinstance(tasks, list):
out["tasks"] = [_to_task_view(t if isinstance(t, dict) else None) for t in tasks]
return out
if name == "run_task":
raw = tf.run_task(
flow_id=action["flowId"],
runtime=action["runtime"],
source_id=action.get("sourceId"),
child_session_key=action.get("childSessionKey"),
parent_task_id=action.get("parentTaskId"),
agent_id=action.get("agentId"),
run_id=action.get("runId"),
label=action.get("label"),
task=action["task"],
prefer_metadata=action.get("preferMetadata"),
notify_policy=action.get("notifyPolicy"),
status=action.get("status"),
started_at=action.get("startedAt"),
last_event_at=action.get("lastEventAt"),
progress_summary=action.get("progressSummary"),
)
if not isinstance(raw, dict):
return {"found": False, "created": False, "reason": "invalid run_task result"}
if bool(raw.get("created")):
return {
"created": True,
"flow": _to_flow_view(raw.get("flow") if isinstance(raw.get("flow"), dict) else None),
"task": _to_task_view(raw.get("task") if isinstance(raw.get("task"), dict) else None),
}
out = {
"found": bool(raw.get("found")),
"created": False,
"reason": str(raw.get("reason") or ""),
}
flow = _to_flow_view(raw.get("flow") if isinstance(raw.get("flow"), dict) else None)
if flow is not None:
out["flow"] = flow
return out
raise ValueError(f"unsupported webhook action: {name}")
def create_taskflow_webhook_request_handler(*, cfg: dict[str, Any], targets_by_path: dict[str, list[TaskFlowWebhookTarget]]):
def handle(request: dict[str, Any]) -> dict[str, Any]:
path = str(request.get("path") or "/")
targets = list(targets_by_path.get(path) or [])
if not targets:
return {"ok": False, "code": "not_found", "error": "route not found"}
headers = request.get("headers")
headers = headers if isinstance(headers, dict) else {}
presented = _extract_secret({str(k).lower(): str(v) for k, v in headers.items()})
if not presented:
return {"ok": False, "code": "unauthorized", "error": "missing webhook secret"}
matched: TaskFlowWebhookTarget | None = None
for target in targets:
resolved = resolve_configured_secret_input_string(value=target.secret_input)
if resolved and _timing_safe_equals(resolved, presented):
matched = target
break
if matched is None:
return {"ok": False, "code": "unauthorized", "error": "invalid webhook secret"}
body = request.get("json")
if isinstance(body, str):
body = json.loads(body)
if not isinstance(body, dict):
return {"ok": False, "code": "invalid_request", "error": "request body must be json object"}
try:
action_name = str(body.get("action") or "").strip()
result = execute_webhook_action(action=body, target=matched, cfg=cfg)
except Exception as exc:
return {
"ok": False,
"routeId": matched.route_id,
"code": "request_rejected",
"error": str(exc),
}
status_code, code, error = _describe_webhook_outcome(action_name, result if isinstance(result, dict) else {})
if status_code < 400:
out = {"ok": True, "routeId": matched.route_id, "statusCode": status_code, "result": result}
if code:
out["code"] = code
return out
out = {
"ok": False,
"routeId": matched.route_id,
"statusCode": status_code,
"code": code or "request_rejected",
"error": error or "request rejected",
"result": result,
}
return out
return handle

View file

@ -0,0 +1,26 @@
from __future__ import annotations
from .config import resolve_webhooks_plugin_config
from .http import TaskFlowWebhookTarget, create_taskflow_webhook_request_handler
def register_webhook_routes(api) -> None:
routes = resolve_webhooks_plugin_config(plugin_config=getattr(api, "plugin_config", {}) or {})
if not routes:
return
targets_by_path: dict[str, list[TaskFlowWebhookTarget]] = {}
handler = create_taskflow_webhook_request_handler(cfg=getattr(api, "config", {}) or {}, targets_by_path=targets_by_path)
for route in routes:
task_flow = api.runtime.task_flow.bind_session(session_key=route.session_key)
target = TaskFlowWebhookTarget(
route_id=route.route_id,
path=route.path,
secret_input=route.secret,
secret_config_path=f"plugins.entries.webhooks.routes.{route.route_id}.secret",
default_controller_id=route.controller_id,
task_flow=task_flow,
)
targets_by_path.setdefault(target.path, []).append(target)
api.register_http_route(path=target.path, auth="plugin", match="exact", replace_existing=True, handler=handler)
api.logger.info(f"[webhooks] registered route {route.route_id} on {route.path} for session {route.session_key}")

View file

@ -0,0 +1,35 @@
from __future__ import annotations
from typing import Any
WEBHOOK_RATE_LIMIT_DEFAULTS: dict[str, int] = {
"window_ms": 60_000,
"max_requests": 120,
"max_tracked_keys": 20_000,
}
WEBHOOK_IN_FLIGHT_DEFAULTS: dict[str, int] = {
"max_in_flight_per_key": 8,
"max_tracked_keys": 20_000,
}
def normalize_webhook_path(path: str) -> str:
p = str(path or "").strip()
if not p:
raise ValueError("webhook path is required")
if not p.startswith("/"):
p = "/" + p
while "//" in p:
p = p.replace("//", "/")
return p.rstrip("/") or "/"
def resolve_configured_secret_input_string(*, value: Any) -> str | None:
# Python 重写版先支持直传字符串;ref 由上层配置系统扩展。
if isinstance(value, str):
s = value.strip()
return s or None
return None