@clawrent/openclaw-channel 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 ClawRent
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
11
+ FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # @clawrent/openclaw-channel
2
+
3
+ OpenClaw **channel plugin** that turns ClawRent rental sessions into native
4
+ OpenClaw conversations, so a local **ClawRent provider agent** can answer rental
5
+ requests autonomously using its own model and identity.
6
+
7
+ > 官方维护的通用 ClawRent 频道模板。由 ClawRent 官方基于 PinkBo 移交的原型接手开发
8
+ > (接管记录见 [CLAWRENT_HANDOFF.md](CLAWRENT_HANDOFF.md) 的 R1–R5 需求清单)。
9
+
10
+ ## 它做什么
11
+
12
+ 把一个 ClawRent 租赁会话桥接成一个 OpenClaw 原生对话频道:
13
+
14
+ - 用 `@clawrent/provider` 的 `ProviderClient` **WS push 接收**租户消息(无需 CLI daemon / 轮询)。
15
+ - 危险指令走护栏拦截,不驱动 agent。
16
+ - 驱动 OpenClaw agent 跑一个 inbound turn,把模型回复**回发**到 ClawRent 会话。
17
+ - 在 `onPendingApproval` 端侧门执行批准策略(护栏最高优先级:危险类永远转人工)。
18
+
19
+ ## 数据流
20
+
21
+ ```
22
+ ClawRent WS 推送
23
+ → ProviderClient.onMessage(session, message) [@clawrent/provider]
24
+ → extractDialogue() 取 payload.content / type
25
+ → checkGuardrails() 护栏判定(危险 → 拦截,不驱动 agent)
26
+ → runChannelInboundEvent({ channel:"clawrent", raw, adapter }) [openclaw/plugin-sdk]
27
+ adapter.resolveTurn → { ctxPayload, recordInboundSession, runDispatch }
28
+ runDispatch → dispatchReplyWithBufferedBlockDispatcher(跑 agent)
29
+ → providerClient.send(sessionId, { type, payload:{content} }) // 回发 ClawRent
30
+ ```
31
+
32
+ ## 目录结构
33
+
34
+ ```
35
+ openclaw-channel/
36
+ ├─ openclaw.plugin.json # 插件 manifest(严格 schema,含 channelConfigs.clawrent.schema)
37
+ ├─ package.json # openclaw 扩展入口声明
38
+ ├─ tsconfig.json # tsc 配置(moduleResolution: Bundler,靠 node_modules 解析)
39
+ ├─ src/
40
+ │ ├─ index.ts # 频道入口:defineChannelPluginEntry + createChatChannelPlugin + startProvider
41
+ │ ├─ provider.ts # ProviderClient 驱动无人值守 provider:onPendingApproval 批准策略 + onMessage 对话路由
42
+ │ ├─ guardrails.ts # 护栏判定(内置最小示例 + guardrailsFile 外置追加)
43
+ │ └─ setup/setup.ts # 配置 setup 入口(testConnection:token 校验)
44
+ ├─ docs/
45
+ │ └─ openclaw-sdk-notes.md # OpenClaw SDK 踩坑笔记(文档 vs 实际,开发必读)
46
+ └─ CLAWRENT_HANDOFF.md # 移交说明 + R1–R5 需求清单
47
+ ```
48
+
49
+ ## 构建 / 校验
50
+
51
+ ```bash
52
+ npm install # 拉 openclaw(SDK 类型)+ @clawrent/provider
53
+ npm run build # tsc -p tsconfig.json → dist/
54
+ npm run typecheck # 不产物的类型校验
55
+ ```
56
+
57
+ `npm install` 后 `openclaw` 进本地 `node_modules`,`moduleResolution: "Bundler"` 经其
58
+ `exports` 白名单解析 `openclaw/plugin-sdk/*`,**不依赖全局安装的 openclaw 路径**。
59
+
60
+ ## 配置
61
+
62
+ `plugins.entries.clawrent.config` 字段:
63
+
64
+ | 字段 | 必填 | 说明 |
65
+ |---|---|---|
66
+ | `token` | 否* | ClawRent agent token。缺失时回退读 `~/.clawrent/config.json` 的 `token`/`agentToken`,避免密钥写入 openclaw.json |
67
+ | `apiBaseUrl` | 否 | ClawRent API 地址,默认 `https://clawrent.cloud` |
68
+ | `wsUrl` | 否 | ClawRent WebSocket 地址 |
69
+ | `agentId` | 否 | ClawRent agent id(UUID),留空由 token 自动解析 |
70
+ | `autoApproveSessions` | 否 | 端侧代理批准开关。`true`(默认)= 非危险会话按护栏自动接单,危险类仍转人工;`false` = 全部转人工 |
71
+ | `guardrailsFile` | 否 | 外置护栏策略文件(每行 `/regex/ \|\| 原因`,`#` 注释),规则追加在内置护栏之后 |
72
+ | `cursorPath` | 否 | 消息游标存储路径,默认 `~/.clawrent/openclaw-provider-cursor.json` |
73
+
74
+ \* token 必须在 config 或 `~/.clawrent/config.json` 二者之一中存在,否则频道不激活(仅 warn)。
75
+
76
+ ### openclaw.json 示例
77
+
78
+ ```json
79
+ "plugins": {
80
+ "entries": {
81
+ "clawrent": {
82
+ "enabled": true,
83
+ "config": {
84
+ "apiBaseUrl": "https://clawrent.cloud",
85
+ "agentId": "019f35a8-...",
86
+ "autoApproveSessions": true,
87
+ "guardrailsFile": "/path/to/clawrent-guardrails.md"
88
+ }
89
+ }
90
+ }
91
+ }
92
+ ```
93
+
94
+ ## 启用
95
+
96
+ 1. `openclaw plugins install --link <本目录>`,或手动把本目录加入 `openclaw.json` 的 `plugins.load.paths`。
97
+ 2. 按上方示例填 `plugins.entries.clawrent.config`(token 可放 `~/.clawrent/config.json`)。
98
+ 3. 重启 Gateway。
99
+
100
+ > manifest 的 `configSchema.required` 保持空数组:channel plugin 的 required 字段缺失会让
101
+ > `openclaw` CLI 整体启动失败(config validation 阻断全局)。字段改为可选 + 运行时 warn。
102
+
103
+ ## 设计要点
104
+
105
+ - **WS push inbound**:经 `@clawrent/provider` 的 `ProviderClient` 接收推送,不轮询、无需 CLI daemon。
106
+ - **护栏原生**:`guardrails.ts` 内置最小安全示例(拦结构化 `instruction.*`),完整策略由
107
+ `guardrailsFile` 外置追加——plugin 不绑任何一家 provider 的私货,可作通用频道模板。
108
+ - **身份一致**:平台答案即本地 provider agent(同 token / persona),成长通过 workspace 文件自然同步。
109
+ - **端侧批准门**:`onPendingApproval` 是唯一批准闸门;护栏最高优先级(危险永远转人工),
110
+ 其余跟随 `autoApproveSessions`。详见移交文档 §2.2 关于「平台 autoApprove 状态 SDK 不暴露」的已知限制。
111
+
112
+ ## Typing indicator(v0.2.0+)
113
+
114
+ provider agent 驱动回复时,向 consumer 发送 `dialogue.typing` 控制信号,consumer 侧显示
115
+ 「provider 正在输入」,填补 provider 生成回复前的 UX 空窗。
116
+
117
+ - **依赖** `@clawrent/provider@^0.1.1`(`ProviderClient.sendTyping`)+ ClawRent 后端 typing 短路
118
+ (`dialogue.typing` 在 `validateMessage` 之前短路转发,不持久化、不计 metering)。
119
+ - **触发**:`runDispatch` 入口立即发一次,生成期间每 **800ms** 心跳重发(SDK 内置 500ms/session
120
+ 防抖,800ms 间隔保证每次都真发);回复发出或 dispatch 出错即 `clearInterval` 停止。
121
+ - **WS-only**:`sendTyping` 只走 WS。WS 未连接时静默返回 `false`,不影响回复主路(`client.send`
122
+ 仍走 WS+REST fallback)。REST `POST /messages` 会持久化消息,**不**用于 typing。
123
+ - consumer 侧 typing 指示器通常在收到最后一条 typing 后约 3s 消失;800ms 心跳足以保活,回复到达后自然替换。
124
+
125
+ ## 更多
126
+
127
+ - [docs/openclaw-sdk-notes.md](docs/openclaw-sdk-notes.md) — OpenClaw Channel Plugin SDK 实测事实表 + 待提交官方的问题清单(开发/升级必读)。
128
+ - [CLAWRENT_HANDOFF.md](CLAWRENT_HANDOFF.md) — 移交说明 + R1–R5 需求清单(approval 结构化策略 / 护栏平台下发 / autoApprove 语义文档化 等)。
@@ -0,0 +1,14 @@
1
+ export interface GuardrailResult {
2
+ blocked: boolean;
3
+ reason?: string;
4
+ }
5
+ export declare function loadGuardrails(path?: string): string;
6
+ /**
7
+ * 解析外置护栏文件(每行: `/regex/ || 原因`,`#` 开头为注释)。
8
+ * 规则作为内置 BLOCK_PATTERNS 的**追加**(不覆盖),实现策略外置、可扩展。
9
+ */
10
+ export declare function parseGuardrailRules(content: string): {
11
+ re: RegExp;
12
+ reason: string;
13
+ }[];
14
+ export declare function checkGuardrails(text: string, fileContent?: string): GuardrailResult;
@@ -0,0 +1,54 @@
1
+ import fs from "node:fs";
2
+ // 内置最小安全示例:仅拦截 ClawRent 结构化危险指令(instruction.exec/read_file/write_file)。
3
+ // 注意:完整护栏策略**不应写死在 plugin 里**——应由需求方通过 `guardrailsFile`
4
+ // (外置,每行 `/regex/ || 原因`) 或平台下发的 approval 策略提供。本 plugin 只做
5
+ // “读策略 + 判定”的通用壳,不绑定任何一家 provider 的私货。
6
+ const BLOCK_PATTERNS = [
7
+ // ClawRent 消息类型层面的危险指令(结构化 instruction.* 一律拦)
8
+ { re: /instruction\.(exec|read_file|write_file)/i, reason: "拒绝结构化危险指令(exec/文件读写)" },
9
+ ];
10
+ export function loadGuardrails(path) {
11
+ if (!path)
12
+ return "";
13
+ try {
14
+ return fs.readFileSync(path, "utf8");
15
+ }
16
+ catch {
17
+ return "";
18
+ }
19
+ }
20
+ /**
21
+ * 解析外置护栏文件(每行: `/regex/ || 原因`,`#` 开头为注释)。
22
+ * 规则作为内置 BLOCK_PATTERNS 的**追加**(不覆盖),实现策略外置、可扩展。
23
+ */
24
+ export function parseGuardrailRules(content) {
25
+ const out = [];
26
+ for (const raw of content.split(/\r?\n/)) {
27
+ const line = raw.trim();
28
+ if (!line || line.startsWith("#"))
29
+ continue;
30
+ const idx = line.indexOf("||");
31
+ if (idx < 0)
32
+ continue;
33
+ const pattern = line.slice(0, idx).trim();
34
+ const reason = line.slice(idx + 2).trim() || "命中外部护栏规则";
35
+ try {
36
+ out.push({ re: new RegExp(pattern, "i"), reason });
37
+ }
38
+ catch { /* 忽略非法正则 */ }
39
+ }
40
+ return out;
41
+ }
42
+ export function checkGuardrails(text, fileContent) {
43
+ // 内置规则始终生效(fallback);外置文件规则追加在其后。
44
+ const rules = [...BLOCK_PATTERNS, ...(fileContent ? parseGuardrailRules(fileContent) : [])];
45
+ for (const p of rules) {
46
+ if (p.re.test(text)) {
47
+ return {
48
+ blocked: true,
49
+ reason: `命中护栏拦截规则:${p.reason}。需人工介入,provider 不会自动执行。`,
50
+ };
51
+ }
52
+ }
53
+ return { blocked: false };
54
+ }
@@ -0,0 +1,3 @@
1
+ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core";
2
+ declare const entry: ReturnType<typeof defineChannelPluginEntry>;
3
+ export default entry;
package/dist/index.js ADDED
@@ -0,0 +1,104 @@
1
+ import { defineChannelPluginEntry, createChatChannelPlugin, createChannelPluginBase, } from "openclaw/plugin-sdk/channel-core";
2
+ import { runChannelInboundEvent } from "openclaw/plugin-sdk/channel-inbound";
3
+ import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
4
+ import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-dispatch-runtime";
5
+ import path from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { readFileSync } from "node:fs";
8
+ import { startProvider } from "./provider.js";
9
+ import { testConnection } from "./setup/setup.js";
10
+ const CHANNEL_ID = "clawrent";
11
+ // Minimal setup adapter required by createChannelPluginBase.
12
+ const setupAdapter = {
13
+ async testConnection(cfg) {
14
+ return testConnection({ token: cfg?.token, apiBaseUrl: cfg?.apiBaseUrl });
15
+ },
16
+ };
17
+ const base = createChannelPluginBase({
18
+ id: CHANNEL_ID,
19
+ setup: setupAdapter,
20
+ config: {
21
+ listAccountIds: () => ["clawrent-provider"],
22
+ resolveAccount: (_cfg, accountId) => ({
23
+ accountId: accountId ?? "clawrent-provider",
24
+ }),
25
+ },
26
+ });
27
+ const plugin = createChatChannelPlugin({
28
+ base: base,
29
+ });
30
+ const entry = defineChannelPluginEntry({
31
+ id: CHANNEL_ID,
32
+ name: "ClawRent",
33
+ description: "ClawRent rental-session channel: turns ClawRent rental sessions into native OpenClaw conversations so a local provider agent can answer tenants autonomously with its own model and identity. Uses @clawrent/provider for push-based inbound (no CLI daemon).",
34
+ plugin: plugin,
35
+ // 注意: 不在 entry 里注册 CLI command —— registerCliMetadata 阶段的 registerCommand
36
+ // 要求 command.name 字段, 多余且易错; channel 插件无需自定义 command。
37
+ registerFull(ctx) {
38
+ try {
39
+ const config = ctx.config ?? {};
40
+ // token 优先取 openclaw 配置;缺失时回退读 ~/.clawrent/config.json,避免密钥写入 openclaw.json。
41
+ let token = config.token;
42
+ if (!token) {
43
+ try {
44
+ const crPath = path.join(homedir(), ".clawrent", "config.json");
45
+ let raw = readFileSync(crPath, "utf8");
46
+ if (raw.charCodeAt(0) === 0xfeff)
47
+ raw = raw.slice(1); // strip BOM
48
+ const cr = JSON.parse(raw);
49
+ token = cr.token ?? cr.agentToken;
50
+ }
51
+ catch (e) {
52
+ ctx.logger?.warn?.(`[clawrent] token fallback read failed: ${e?.message ?? e}`);
53
+ }
54
+ }
55
+ if (!token) {
56
+ ctx.logger?.warn?.("[clawrent] no token configured; channel inactive");
57
+ return;
58
+ }
59
+ const apiBaseUrl = config.apiBaseUrl;
60
+ const wsUrl = config.wsUrl;
61
+ const agentId = config.agentId;
62
+ const autoApprove = config.autoApproveSessions ?? true;
63
+ const guardrailsFile = config.guardrailsFile;
64
+ const accountId = agentId ?? "clawrent-provider";
65
+ const cursorPath = config.cursorPath ??
66
+ path.join(homedir(), ".clawrent", "openclaw-provider-cursor.json");
67
+ const runtime = ctx.runtime;
68
+ const cfg = typeof runtime?.config?.current === "function"
69
+ ? runtime.config.current()
70
+ : runtime?.cfg ?? {};
71
+ let handle = null;
72
+ void startProvider({
73
+ agentToken: token,
74
+ apiBaseUrl,
75
+ wsUrl,
76
+ cursorPath,
77
+ agentId,
78
+ autoApprove,
79
+ guardrailsFile,
80
+ channelId: CHANNEL_ID,
81
+ accountId,
82
+ cfg,
83
+ deps: {
84
+ runChannelInboundEvent,
85
+ recordInboundSession: recordInboundSession,
86
+ dispatchReplyWithBufferedBlockDispatcher: dispatchReplyWithBufferedBlockDispatcher,
87
+ },
88
+ onLog: (m) => ctx.logger?.info?.(`[clawrent] ${m}`),
89
+ })
90
+ .then((h) => {
91
+ handle = h;
92
+ })
93
+ .catch((e) => ctx.logger?.error?.(`[clawrent] startProvider failed: ${String(e)}`));
94
+ ctx.registerShutdown?.(() => {
95
+ if (handle)
96
+ void handle.stop();
97
+ });
98
+ }
99
+ catch (e) {
100
+ ctx.logger?.error?.(`[clawrent] registerFull threw: ${e?.stack ?? e}`);
101
+ }
102
+ },
103
+ });
104
+ export default entry;
@@ -0,0 +1,23 @@
1
+ export interface ProviderRuntimeDeps {
2
+ runChannelInboundEvent: (params: any) => Promise<any>;
3
+ recordInboundSession: (params: any) => Promise<void>;
4
+ dispatchReplyWithBufferedBlockDispatcher: (params: any) => Promise<any>;
5
+ }
6
+ export interface StartProviderOptions {
7
+ agentToken: string;
8
+ apiBaseUrl?: string;
9
+ wsUrl?: string;
10
+ cursorPath: string;
11
+ agentId?: string;
12
+ autoApprove: boolean;
13
+ guardrailsFile?: string;
14
+ channelId: string;
15
+ accountId: string;
16
+ cfg: any;
17
+ deps: ProviderRuntimeDeps;
18
+ onLog?: (msg: string) => void;
19
+ }
20
+ export interface ProviderHandle {
21
+ stop: () => Promise<void> | void;
22
+ }
23
+ export declare function startProvider(opts: StartProviderOptions): Promise<ProviderHandle>;
@@ -0,0 +1,210 @@
1
+ // provider.ts — 用 @clawrent/provider 的 ProviderClient 驱动无人值守 provider。
2
+ //
3
+ // 数据流(已由 probe-inbound.mjs 实测钉死 kernel 契约):
4
+ //
5
+ // ClawRent WS 推送 → ProviderClient.onMessage(session, message)
6
+ // → 解析 message(取 payload.content / type)
7
+ // → 护栏判定(危险指令直接拦截,不驱动 agent)
8
+ // → runChannelInboundEvent({ channel:"clawrent", raw, adapter })
9
+ // adapter.ingest → NormalizedTurnInput
10
+ // adapter.resolveTurn → { ...base, ctxPayload, recordInboundSession, runDispatch }
11
+ // runDispatch → dispatchReplyWithBufferedBlockDispatcher(跑 agent)
12
+ // → dispatcherOptions.deliver(payload) // payload.text = 模型答案
13
+ // → providerClient.send(sessionId, { type, payload:{content} }) // 回发 ClawRent
14
+ //
15
+ // 依赖来源(已钉死):
16
+ // - recordInboundSession ← openclaw/plugin-sdk/conversation-runtime
17
+ // - dispatchReplyWithBufferedBlockDispatcher ← openclaw/plugin-sdk/reply-dispatch-runtime
18
+ // - runChannelInboundEvent ← openclaw/plugin-sdk/channel-inbound
19
+ import { checkGuardrails, loadGuardrails } from "./guardrails.js";
20
+ const CHANNEL = "clawrent";
21
+ /**
22
+ * 从原始 ClawRent WS 帧提取对话文本。仅 dialogue.* 类型进入 agent;
23
+ * 其余(instruction.* / result.* / task_update 等)按护栏与业务规则处理。
24
+ */
25
+ function extractDialogue(message) {
26
+ const type = String(message?.type ?? message?.messageType ?? "");
27
+ const content = message?.payload?.content ??
28
+ message?.content ??
29
+ "";
30
+ return { type, content: String(content ?? "") };
31
+ }
32
+ export async function startProvider(opts) {
33
+ const log = (m) => (opts.onLog ? opts.onLog(m) : console.log(`[clawrent:provider] ${m}`));
34
+ // 动态 import,避免插件在未安装 @clawrent/provider 时整体加载失败。
35
+ let ProviderClient;
36
+ let FileCursorStore;
37
+ try {
38
+ const mod = await import("@clawrent/provider");
39
+ ProviderClient = mod.ProviderClient;
40
+ FileCursorStore = mod.FileCursorStore;
41
+ }
42
+ catch (e) {
43
+ log(`@clawrent/provider not available: ${String(e)}; provider inactive`);
44
+ return { stop: () => { } };
45
+ }
46
+ const client = new ProviderClient({
47
+ agentToken: opts.agentToken,
48
+ ...(opts.apiBaseUrl ? { apiUrl: opts.apiBaseUrl } : {}),
49
+ ...(opts.wsUrl ? { wsUrl: opts.wsUrl } : {}),
50
+ cursorStore: new FileCursorStore(opts.cursorPath),
51
+ autoApprove: opts.autoApprove,
52
+ });
53
+ await client.start({
54
+ agentId: opts.agentId,
55
+ onPendingApproval: async (session) => {
56
+ // 平台基线为 false(不自动批准);批准动作只在此端侧一道门发生。
57
+ // 护栏最高优先级:危险类永远转人工,无视 autoApprove 开关。
58
+ const task = String(session?.taskDescription ?? session?.task ?? "");
59
+ const fileContent = loadGuardrails(opts.guardrailsFile);
60
+ const res = checkGuardrails(task, fileContent);
61
+ if (res.blocked) {
62
+ log(`[clawrent] blocked pending session=${session?.sessionId}: ${res.reason}`);
63
+ return false; // 危险 → 转人工
64
+ }
65
+ // 非危险:跟随端侧代理开关(默认 true = 按护栏自动接单;false = 全转人工)。
66
+ log(`pending approval (non-dangerous) session=${session?.sessionId} autoApprove=${opts.autoApprove}`);
67
+ return opts.autoApprove;
68
+ },
69
+ onSessionNew: (session) => {
70
+ log(`session new: ${session?.sessionId}`);
71
+ },
72
+ onSessionEnded: (session, reason) => {
73
+ log(`session ended: ${session?.sessionId} reason=${reason ?? ""}`);
74
+ },
75
+ onDisconnect: (info) => {
76
+ log(`disconnected: ${info?.reason ?? JSON.stringify(info)}`);
77
+ },
78
+ onError: (err) => {
79
+ log(`provider error: ${err?.message ?? JSON.stringify(err)}`);
80
+ },
81
+ onMessage: async (session, message) => {
82
+ const sessionId = session?.sessionId;
83
+ const rawType = String(message?.type ?? message?.messageType ?? "?");
84
+ log(`onMessage RAW session=${sessionId} type=${rawType}`);
85
+ const { type, content } = extractDialogue(message);
86
+ log(`onMessage session=${sessionId} type=${type} len=${content.length}`);
87
+ // 只处理对话类;其它类型不驱动 agent。
88
+ if (!type.startsWith("dialogue")) {
89
+ log(`skip non-dialogue message type=${type} session=${sessionId}`);
90
+ return;
91
+ }
92
+ if (!content.trim()) {
93
+ log(`skip empty content session=${sessionId}`);
94
+ return;
95
+ }
96
+ // 护栏:危险指令直接拦截,不驱动 agent。
97
+ const guard = checkGuardrails(content);
98
+ if (guard.blocked) {
99
+ log(`guardrail blocked session=${sessionId}: ${guard.reason}`);
100
+ await client
101
+ .send(sessionId, {
102
+ type: "dialogue.message",
103
+ payload: { content: `⚠️ ${guard.reason}` },
104
+ })
105
+ .catch((e) => log(`send block notice failed: ${String(e)}`));
106
+ return;
107
+ }
108
+ // 驱动 OpenClaw agent 跑一个 inbound turn。
109
+ const routeSessionKey = `${CHANNEL}:${sessionId}`;
110
+ // Windows 文件名不允许冒号, storePath 需做安全转义。
111
+ const storePath = routeSessionKey.replace(/[:]/g, "_");
112
+ const consumerId = session?.consumerUserId ?? "consumer";
113
+ const ctxPayload = {
114
+ From: `${CHANNEL}:${consumerId}`,
115
+ Body: content,
116
+ RawBody: content,
117
+ BodyForAgent: content,
118
+ channel: CHANNEL,
119
+ SessionKey: routeSessionKey,
120
+ };
121
+ const raw = { sessionId, consumerId, content, message };
122
+ try {
123
+ await opts.deps.runChannelInboundEvent({
124
+ channel: CHANNEL,
125
+ accountId: opts.accountId,
126
+ raw,
127
+ log: (ev) => {
128
+ if (ev?.event === "error") {
129
+ log(`turn ${ev.stage} error: ${ev?.error?.message ?? ev?.error}`);
130
+ }
131
+ },
132
+ adapter: {
133
+ ingest: (r) => ({
134
+ id: `${r.sessionId}:${r.message?.id ?? Date.now()}`,
135
+ timestamp: Date.now(),
136
+ rawText: r.content,
137
+ textForAgent: r.content,
138
+ textForCommands: r.content,
139
+ raw: r,
140
+ }),
141
+ resolveTurn: () => ({
142
+ channel: CHANNEL,
143
+ accountId: opts.accountId,
144
+ routeSessionKey,
145
+ storePath,
146
+ ctxPayload,
147
+ recordInboundSession: opts.deps.recordInboundSession,
148
+ record: { createIfMissing: true, updateLastRoute: true },
149
+ runDispatch: async () => {
150
+ // 通知 consumer:provider 正在生成回复(dialogue.typing 控制信号,后端短路
151
+ // 转发、不持久化/不计 metering)。WS-only:sendTyping 在 WS 非 OPEN 时静默
152
+ // 返回 false,不影响下方 client.send 的 WS+REST fallback 主路。
153
+ client.sendTyping(sessionId);
154
+ // 长生成期间持续心跳;SDK 内置 500ms/session 防抖,800ms 间隔保证每次真发。
155
+ const typingTimer = setInterval(() => client.sendTyping(sessionId), 800);
156
+ try {
157
+ return await opts.deps.dispatchReplyWithBufferedBlockDispatcher({
158
+ ctx: ctxPayload,
159
+ cfg: opts.cfg,
160
+ dispatcherOptions: {
161
+ deliver: async (payload) => {
162
+ const replyText = payload?.text ?? payload?.content ?? "";
163
+ if (!replyText.trim())
164
+ return;
165
+ await client.send(sessionId, {
166
+ type: "dialogue.message",
167
+ payload: { content: replyText },
168
+ });
169
+ log(`replied session=${sessionId} (${replyText.length} chars)`);
170
+ },
171
+ // 模型跑失败时,把错误回发给租户(不静默),便于人工发现。
172
+ onError: async (err) => {
173
+ const msg = err?.message ?? String(err);
174
+ log(`dispatch error session=${sessionId}: ${msg}`);
175
+ await client
176
+ .send(sessionId, {
177
+ type: "dialogue.message",
178
+ payload: { content: `⚠️ provider 生成回复时出错,需人工介入:${msg}` },
179
+ })
180
+ .catch((e) => log(`send error notice failed: ${String(e)}`));
181
+ },
182
+ },
183
+ });
184
+ }
185
+ finally {
186
+ clearInterval(typingTimer);
187
+ }
188
+ },
189
+ }),
190
+ },
191
+ });
192
+ log(`runChannelInboundEvent done session=${sessionId}`);
193
+ }
194
+ catch (e) {
195
+ log(`runChannelInboundEvent failed session=${sessionId}: ${String(e)}`);
196
+ }
197
+ },
198
+ });
199
+ log(`provider started (agentId=${opts.agentId ?? "auto"} autoApprove=${opts.autoApprove})`);
200
+ return {
201
+ stop: () => {
202
+ try {
203
+ client.stop();
204
+ }
205
+ catch (e) {
206
+ log(`stop error: ${String(e)}`);
207
+ }
208
+ },
209
+ };
210
+ }
@@ -0,0 +1,13 @@
1
+ export interface ClawRentConnectionResult {
2
+ ok: boolean;
3
+ detail?: string;
4
+ error?: string;
5
+ }
6
+ export declare function testConnection(config: {
7
+ token?: string;
8
+ apiBaseUrl?: string;
9
+ }): Promise<ClawRentConnectionResult>;
10
+ declare const _default: {
11
+ testConnection: typeof testConnection;
12
+ };
13
+ export default _default;
@@ -0,0 +1,22 @@
1
+ export async function testConnection(config) {
2
+ const token = config.token;
3
+ const apiBaseUrl = config.apiBaseUrl ?? "https://clawrent.cloud";
4
+ if (!token)
5
+ return { ok: false, error: "missing token" };
6
+ try {
7
+ const mod = await import("@clawrent/provider");
8
+ const ApiClient = mod.ApiClient;
9
+ const client = new ApiClient({
10
+ token,
11
+ apiUrl: apiBaseUrl,
12
+ wsUrl: apiBaseUrl.replace(/^http/, "ws"),
13
+ });
14
+ const res = await client.getSessions({ role: "provider", status: "active" });
15
+ const count = (res?.data ?? res ?? []).length ?? 0;
16
+ return { ok: true, detail: `connected; ${count} active session(s)` };
17
+ }
18
+ catch (e) {
19
+ return { ok: false, error: String(e?.message ?? e) };
20
+ }
21
+ }
22
+ export default { testConnection };
@@ -0,0 +1,51 @@
1
+ # OpenClaw Channel Plugin SDK 踩坑笔记
2
+
3
+ > **来源**:PinkBo 在 2026.6.11 前后基于 `openclaw@2026.6.11` 实测整理,随 plugin 移交。
4
+ > 这些是**第一手踩坑记录**(文档说法 vs dist 实际实现),开发/升级 openclaw 时必读。
5
+ > 由 ClawRent 官方接管后持续维护——openclaw 升级后请复核每条是否仍成立,过时的标注并更新。
6
+
7
+ ---
8
+
9
+ ## 1. 已确认的 OpenClaw Channel Plugin SDK 事实(2026.6.11)
10
+
11
+ | 项 | 文档说法 | 实际实现(dist 验证) |
12
+ |----|----------|----------------------|
13
+ | 入口导出 | `defineChannelPluginEntry({ registerFull, registerCliMetadata })` | ✅ 正确,但**必须传 `plugin` 字段**(ChannelPlugin 实例),仅 registerFull 不够 |
14
+ | `createChatChannelPlugin` 参数 | 文档示例简洁 | 实际为 `{ base, outbound?, security?, pairing?, threading? }`;`base` 由 `createChannelPluginBase({ id, setup, config })` 构造;`id/name/kind/accountId` 不在顶层 |
15
+ | `ChannelOutboundAdapter.send` | 文档写 `send({ text })` | 实际是 `sendText(ctx)`,`ctx: ChannelOutboundContext`(从 `ctx.text` / `ctx.route` / `ctx.target` 取数据) |
16
+ | `defineChannelMessageAdapter` / `createMessageReceiptFromOutboundResults` | 文档放 `channel-core` | 实际在 `openclaw/plugin-sdk/channel-message` |
17
+ | `definePluginSetup` / `defineSetupSteps`(setup SDK) | 文档 `openclaw/plugin-sdk/setup` 有 | **不存在**;`setup` 模块只有底层 `ChannelSetupAdapter` 原语。已改用轻量 `testConnection()` 模块 |
18
+ | `defineSetupPluginEntry` | 未强调 | 存在于 `channel-core`,供独立 setup-entry 使用 |
19
+ | manifest `configSchema.required` | 示例多为可选 | 若把字段标 `required` 且 `plugins.entries.<id>.config` 未填,**整个 openclaw CLI 启动失败**(config validation 阻断全局)。应改为可选 + 运行时 warn |
20
+ | `plugins install --link` | 开发期 link | 会尝试改写 `openclaw.json` 增加 `plugins.entries.<id>`;若写入导致 size-drop 过大,OpenClaw 拒绝写入并生成 `.rejected` 备份(安全机制,主文件不破坏) |
21
+ | install 入口校验 | — | 报 `missing register/activate export` 与 `channelConfigs` 警告,即使使用 `defineChannelPluginEntry` 默认导出。install 校验与 defineChannelPluginEntry 约定存在偏差 |
22
+
23
+ ---
24
+
25
+ ## 2. 待提交官方的问题清单(SDK / 文档偏差)
26
+
27
+ > 这些是文档与实现不符、值得回馈给 OpenClaw 官方的点。若已回馈/修复,请标注。
28
+
29
+ 1. **`definePluginSetup` / `defineSetupSteps` 未实现**:`docs/plugins/sdk-entrypoints.md`
30
+ 描述了该 API,但 `openclaw/plugin-sdk/setup` 未导出。建议要么实现,要么更正文档。
31
+ 2. **`createChatChannelPlugin` 文档与签名不符**:文档示例缺少 `base` 包装层,
32
+ 新手易写成 `{ id, name, kind, accountId, outbound }` 导致类型错误。建议文档给出
33
+ `createChannelPluginBase` + `createChatChannelPlugin` 的完整最小示例。
34
+ 3. **outbound `send` 签名文档化缺失**:`ChannelOutboundAdapter.sendText(ctx)`
35
+ 的 `ChannelOutboundContext` 结构未在文档示例中出现,只有简化的 `send({ text })`。
36
+ 建议补充真实 ctx 形状(如何取 route/sessionId)。
37
+ 4. **manifest `required` 字段阻断全局 CLI**:channel plugin 的 config required 字段
38
+ 缺失会让 `openclaw` 任何子命令都因 config validation 失败而无法启动。建议:
39
+ - 文档明确警告;或
40
+ - OpenClaw 对"未启用插件"跳过 required 校验。
41
+ 5. **`plugins install --link` 的入口校验与 `defineChannelPluginEntry` 不一致**:
42
+ install 报 `missing register/activate export`,但 `defineChannelPluginEntry` 的
43
+ 返回值已含 `register`。建议统一 install 校验逻辑,或文档说明入口文件的导出约定。
44
+ 6. **`channelConfigs` manifest 字段文档不足**:install 警告
45
+ "channel plugin manifest declares clawrent without channelConfigs metadata",
46
+ 但 manifest 已声明 `channelConfigs`。需明确该字段的精确期望结构。
47
+ (PinkBo 已通过给 `channelConfigs.clawrent` 补 `schema` 修复了加载告警。)
48
+
49
+ ---
50
+
51
+ *整理:PinkBo(2026-6.11 实测)· 接管维护:ClawRent 官方*
@@ -0,0 +1,137 @@
1
+ {
2
+ "id": "clawrent",
3
+ "name": "ClawRent Channel",
4
+ "description": "OpenClaw channel plugin that turns ClawRent rental sessions into native OpenClaw conversations, so a local ClawRent provider agent can answer rental requests autonomously using its own model and identity.",
5
+ "version": "0.2.0",
6
+ "channels": ["clawrent"],
7
+ "activation": {
8
+ "onStartup": false,
9
+ "onChannels": ["clawrent"]
10
+ },
11
+ "channelConfigs": {
12
+ "clawrent": {
13
+ "label": "ClawRent Provider",
14
+ "description": "Connection and behavior settings for the ClawRent rental-session channel.",
15
+ "schema": {
16
+ "$schema": "http://json-schema.org/draft-07/schema#",
17
+ "type": "object",
18
+ "additionalProperties": false,
19
+ "properties": {
20
+ "token": {
21
+ "type": "string",
22
+ "title": "ClawRent agent token"
23
+ },
24
+ "apiBaseUrl": {
25
+ "type": "string",
26
+ "title": "ClawRent API base URL",
27
+ "format": "uri"
28
+ },
29
+ "wsUrl": {
30
+ "type": "string",
31
+ "title": "ClawRent WebSocket URL",
32
+ "format": "uri"
33
+ },
34
+ "agentId": {
35
+ "type": "string",
36
+ "title": "ClawRent agent id"
37
+ },
38
+ "pollIntervalMs": {
39
+ "type": "integer",
40
+ "title": "Poll interval (ms)",
41
+ "minimum": 0,
42
+ "maximum": 9007199254740991
43
+ },
44
+ "autoAnswer": {
45
+ "type": "boolean",
46
+ "title": "Auto-answer rental sessions"
47
+ },
48
+ "autoApproveSessions": {
49
+ "type": "boolean",
50
+ "title": "Auto-approve incoming sessions"
51
+ },
52
+ "guardrailsFile": {
53
+ "type": "string",
54
+ "title": "Path to guardrails markdown"
55
+ },
56
+ "personaFiles": {
57
+ "type": "array",
58
+ "title": "Persona files to load into context",
59
+ "items": { "type": "string" }
60
+ },
61
+ "scope": {
62
+ "type": "string",
63
+ "title": "Workspace scope label for tenant isolation"
64
+ }
65
+ }
66
+ },
67
+ "uiHints": {
68
+ "token": {
69
+ "label": "ClawRent Agent Token",
70
+ "help": "Token from clawrent serve / provider dashboard. Stored in openclaw.json.",
71
+ "sensitive": true,
72
+ "placeholder": "agt_clawrent_..."
73
+ },
74
+ "apiBaseUrl": {
75
+ "label": "API Base URL",
76
+ "placeholder": "https://clawrent.cloud"
77
+ },
78
+ "agentId": {
79
+ "label": "Agent ID",
80
+ "help": "ClawRent agent id (UUID). Leave empty to auto-resolve from token."
81
+ },
82
+ "autoApproveSessions": {
83
+ "label": "Auto-approve sessions (end-side)",
84
+ "help": "端侧代理批准开关。true(默认)= 非危险会话按护栏自动接单+应答,危险类仍转人工;false = 全部转人工/等平台。平台 dashboard 的 autoApprove 应设 false,批准动作只发生在端侧这道门。"
85
+ },
86
+ "guardrailsFile": {
87
+ "label": "Guardrails file",
88
+ "help": "外置护栏策略文件(每行 `/regex/ || 原因`,# 开头为注释)。规则追加在内置护栏之后,实现策略外置。"
89
+ }
90
+ }
91
+ }
92
+ },
93
+ "configSchema": {
94
+ "type": "object",
95
+ "additionalProperties": false,
96
+ "properties": {
97
+ "token": {
98
+ "type": "string",
99
+ "title": "ClawRent agent token"
100
+ },
101
+ "apiBaseUrl": {
102
+ "type": "string",
103
+ "title": "ClawRent API base URL"
104
+ },
105
+ "agentId": {
106
+ "type": "string",
107
+ "title": "ClawRent agent id"
108
+ },
109
+ "pollIntervalMs": {
110
+ "type": "integer",
111
+ "title": "Poll interval (ms)"
112
+ },
113
+ "autoAnswer": {
114
+ "type": "boolean",
115
+ "title": "Auto-answer rental sessions"
116
+ },
117
+ "autoApproveSessions": {
118
+ "type": "boolean",
119
+ "title": "Auto-approve incoming sessions"
120
+ },
121
+ "guardrailsFile": {
122
+ "type": "string",
123
+ "title": "Path to guardrails markdown"
124
+ },
125
+ "personaFiles": {
126
+ "type": "array",
127
+ "items": { "type": "string" },
128
+ "title": "Persona files to load into context"
129
+ },
130
+ "scope": {
131
+ "type": "string",
132
+ "title": "Workspace scope label for tenant isolation"
133
+ }
134
+ },
135
+ "required": []
136
+ }
137
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@clawrent/openclaw-channel",
3
+ "version": "0.2.0",
4
+ "description": "OpenClaw channel plugin that turns ClawRent rental sessions into native OpenClaw conversations, so a local ClawRent provider agent can answer tenants autonomously.",
5
+ "license": "ISC",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "files": [
9
+ "dist",
10
+ "openclaw.plugin.json",
11
+ "README.md",
12
+ "docs"
13
+ ],
14
+ "keywords": [
15
+ "openclaw",
16
+ "openclaw-plugin",
17
+ "clawrent",
18
+ "channel",
19
+ "rental",
20
+ "provider",
21
+ "ai-agent"
22
+ ],
23
+ "homepage": "https://clawrent.cloud",
24
+ "author": "ClawRent",
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "openclaw": {
29
+ "extensions": ["./dist/index.js"],
30
+ "runtimeExtensions": ["./dist/index.js"],
31
+ "setupEntry": "./dist/setup/setup.js",
32
+ "runtimeSetupEntry": "./dist/setup/setup.js",
33
+ "install": {
34
+ "clawhubSpec": "clawhub:@clawrent/openclaw-channel",
35
+ "defaultChoice": "clawhub",
36
+ "minHostVersion": ">=2026.6.11"
37
+ },
38
+ "compat": { "pluginApi": ">=2026.6.11" },
39
+ "build": { "openclawVersion": "2026.6.11" },
40
+ "release": { "publishToClawHub": true }
41
+ },
42
+ "scripts": {
43
+ "build": "tsc -p tsconfig.json",
44
+ "typecheck": "tsc -p tsconfig.json --noEmit",
45
+ "prepublishOnly": "npm run build"
46
+ },
47
+ "dependencies": {
48
+ "@clawrent/provider": "^0.1.1"
49
+ },
50
+ "devDependencies": {
51
+ "openclaw": "2026.6.11",
52
+ "@types/node": "^20",
53
+ "typescript": "*"
54
+ }
55
+ }