@coclaw/openclaw-coclaw 0.10.0 → 0.11.2
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/README.md +0 -9
- package/index.js +41 -47
- package/package.json +1 -5
- package/src/common/gateway-notify.js +25 -8
- package/src/common/messages.js +0 -10
- package/src/config.js +2 -2
- package/src/plugin-version.js +18 -0
- package/src/realtime-bridge.js +40 -6
- package/src/settings.js +79 -0
- package/src/utils/atomic-write.js +2 -0
- package/src/{ndc-preloader.js → webrtc/ndc-preloader.js} +1 -1
- package/src/{webrtc-peer.js → webrtc/webrtc-peer.js} +3 -3
- package/src/cli.js +0 -128
- /package/src/{utils → webrtc}/dc-chunking.js +0 -0
package/README.md
CHANGED
|
@@ -88,15 +88,6 @@ openclaw coclaw enroll [--server <url>]
|
|
|
88
88
|
|
|
89
89
|
需要 gateway 运行中。
|
|
90
90
|
|
|
91
|
-
### 方式三:独立 CLI(遗留)
|
|
92
|
-
|
|
93
|
-
```bash
|
|
94
|
-
node ~/.openclaw/extensions/coclaw/src/cli.js bind <binding-code> --server <url>
|
|
95
|
-
node ~/.openclaw/extensions/coclaw/src/cli.js unbind --server <url>
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
> 注意:独立 CLI 不走 gateway RPC,直接在 CLI 进程中执行 bind/unbind 并通过 `coclaw.refreshBridge`/`coclaw.stopBridge` 通知 gateway。此路径不具备瘦 CLI 的架构保证(所有 config 操作在同一进程内)。推荐使用方式一。
|
|
99
|
-
|
|
100
91
|
## 配置存储
|
|
101
92
|
|
|
102
93
|
绑定信息存储在 `~/.openclaw/coclaw/bindings.json`(通过 `resolveStateDir()` + channel ID 组合路径),**不存储在 `openclaw.json` 中**。
|
package/index.js
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
|
-
import nodePath from 'node:path';
|
|
3
|
-
|
|
4
1
|
import { bindBot, unbindBot, enrollBot, waitForClaimAndSave } from './src/common/bot-binding.js';
|
|
5
2
|
import { registerCoclawCli } from './src/cli-registrar.js';
|
|
6
3
|
import { resolveErrorMessage } from './src/common/errors.js';
|
|
7
4
|
import { notBound, bindOk, unbindOk, claimCodeCreated } from './src/common/messages.js';
|
|
8
5
|
import { coclawChannelPlugin } from './src/channel-plugin.js';
|
|
9
|
-
import { ensureAgentSession, gatewayAgentRpc, restartRealtimeBridge, stopRealtimeBridge, waitForSessionsReady } from './src/realtime-bridge.js';
|
|
6
|
+
import { ensureAgentSession, gatewayAgentRpc, restartRealtimeBridge, stopRealtimeBridge, waitForSessionsReady, broadcastPluginEvent } from './src/realtime-bridge.js';
|
|
7
|
+
import { getHostName, readSettings, writeName, MAX_NAME_LENGTH } from './src/settings.js';
|
|
10
8
|
import { setRuntime } from './src/runtime.js';
|
|
11
9
|
import { createSessionManager } from './src/session-manager/manager.js';
|
|
12
10
|
import { TopicManager } from './src/topic-manager/manager.js';
|
|
@@ -16,27 +14,12 @@ import { AutoUpgradeScheduler } from './src/auto-upgrade/updater.js';
|
|
|
16
14
|
import { getPackageInfo } from './src/auto-upgrade/updater-check.js';
|
|
17
15
|
import { createFileHandler } from './src/file-manager/handler.js';
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
export async function getPluginVersion() {
|
|
22
|
-
if (__pluginVersion) return __pluginVersion;
|
|
23
|
-
try {
|
|
24
|
-
const pkgPath = nodePath.resolve(import.meta.dirname, 'package.json');
|
|
25
|
-
const raw = await fs.readFile(pkgPath, 'utf8');
|
|
26
|
-
__pluginVersion = JSON.parse(raw).version ?? 'unknown';
|
|
27
|
-
} catch {
|
|
28
|
-
return 'unknown';
|
|
29
|
-
}
|
|
30
|
-
return __pluginVersion;
|
|
31
|
-
}
|
|
32
|
-
// 测试用:重置缓存
|
|
33
|
-
export function __resetPluginVersion() { __pluginVersion = null; }
|
|
34
|
-
|
|
17
|
+
import { getPluginVersion, __resetPluginVersion } from './src/plugin-version.js';
|
|
18
|
+
export { getPluginVersion, __resetPluginVersion };
|
|
35
19
|
|
|
20
|
+
/* c8 ignore start */
|
|
36
21
|
function parseCommandArgs(args) {
|
|
37
|
-
/* c8 ignore next */
|
|
38
22
|
const tokens = (args ?? '').split(/\s+/).filter(Boolean);
|
|
39
|
-
/* c8 ignore next */
|
|
40
23
|
const action = tokens[0] ?? 'help';
|
|
41
24
|
const options = {};
|
|
42
25
|
const positionals = [];
|
|
@@ -67,7 +50,6 @@ function buildHelpText() {
|
|
|
67
50
|
function respondError(respond, err) {
|
|
68
51
|
respond(false, undefined, {
|
|
69
52
|
code: err?.code ?? 'INTERNAL_ERROR',
|
|
70
|
-
/* c8 ignore next */
|
|
71
53
|
message: String(err?.message ?? err),
|
|
72
54
|
});
|
|
73
55
|
}
|
|
@@ -75,8 +57,6 @@ function respondError(respond, err) {
|
|
|
75
57
|
function respondInvalid(respond, message) {
|
|
76
58
|
respond(false, undefined, { code: 'INVALID_INPUT', message });
|
|
77
59
|
}
|
|
78
|
-
|
|
79
|
-
/* c8 ignore start */
|
|
80
60
|
const plugin = {
|
|
81
61
|
id: 'openclaw-coclaw',
|
|
82
62
|
name: 'CoClaw',
|
|
@@ -130,26 +110,6 @@ const plugin = {
|
|
|
130
110
|
},
|
|
131
111
|
});
|
|
132
112
|
|
|
133
|
-
api.registerGatewayMethod('coclaw.refreshBridge', async ({ respond }) => {
|
|
134
|
-
try {
|
|
135
|
-
await restartRealtimeBridge({ logger, pluginConfig: api.pluginConfig });
|
|
136
|
-
respond(true, { status: 'refreshed' });
|
|
137
|
-
}
|
|
138
|
-
catch (err) {
|
|
139
|
-
respondError(respond, err);
|
|
140
|
-
}
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
api.registerGatewayMethod('coclaw.stopBridge', async ({ respond }) => {
|
|
144
|
-
try {
|
|
145
|
-
await stopRealtimeBridge();
|
|
146
|
-
respond(true, { status: 'stopped' });
|
|
147
|
-
}
|
|
148
|
-
catch (err) {
|
|
149
|
-
respondError(respond, err);
|
|
150
|
-
}
|
|
151
|
-
});
|
|
152
|
-
|
|
153
113
|
// --- bind/unbind 共享逻辑(RPC handler + 斜杠命令共用) ---
|
|
154
114
|
|
|
155
115
|
async function doBind({ code, serverUrl }) {
|
|
@@ -290,14 +250,48 @@ const plugin = {
|
|
|
290
250
|
}
|
|
291
251
|
});
|
|
292
252
|
|
|
293
|
-
|
|
253
|
+
async function handleInfoGet({ respond }) {
|
|
294
254
|
try {
|
|
295
255
|
await waitForSessionsReady();
|
|
296
256
|
const version = await getPluginVersion();
|
|
297
257
|
const rawClawVersion = api.runtime?.version;
|
|
298
258
|
// OpenClaw 打包后 resolveVersion() 路径失配导致返回 'unknown',此时不传该字段
|
|
299
259
|
const clawVersion = (rawClawVersion && rawClawVersion !== 'unknown') ? rawClawVersion : undefined;
|
|
300
|
-
|
|
260
|
+
const settings = await readSettings();
|
|
261
|
+
const name = settings.name ?? null;
|
|
262
|
+
const hostName = getHostName();
|
|
263
|
+
respond(true, { version, clawVersion, capabilities: ['topics', 'chatHistory'], name, hostName });
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
respondError(respond, err);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
api.registerGatewayMethod('coclaw.info', handleInfoGet);
|
|
271
|
+
api.registerGatewayMethod('coclaw.info.get', handleInfoGet);
|
|
272
|
+
|
|
273
|
+
api.registerGatewayMethod('coclaw.info.patch', async ({ params, respond }) => {
|
|
274
|
+
try {
|
|
275
|
+
const rawName = params?.name;
|
|
276
|
+
if (rawName === undefined) {
|
|
277
|
+
respondInvalid(respond, 'name field is required');
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (rawName !== null && typeof rawName !== 'string') {
|
|
281
|
+
respondInvalid(respond, 'name must be a string or null');
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const trimmed = typeof rawName === 'string' ? rawName.trim() : '';
|
|
285
|
+
if (trimmed.length > MAX_NAME_LENGTH) {
|
|
286
|
+
respondInvalid(respond, `name exceeds maximum length of ${MAX_NAME_LENGTH} characters`);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const nameToSave = trimmed || null;
|
|
290
|
+
await writeName(nameToSave);
|
|
291
|
+
const hostName = getHostName();
|
|
292
|
+
respond(true, { name: nameToSave, hostName });
|
|
293
|
+
// 异步广播变更事件到 server 和其他 UI 实例
|
|
294
|
+
broadcastPluginEvent('coclaw.info.updated', { name: nameToSave, hostName });
|
|
301
295
|
}
|
|
302
296
|
catch (err) {
|
|
303
297
|
respondError(respond, err);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coclaw/openclaw-coclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"description": "OpenClaw CoClaw channel plugin for remote chat",
|
|
@@ -41,11 +41,7 @@
|
|
|
41
41
|
"./index.js"
|
|
42
42
|
]
|
|
43
43
|
},
|
|
44
|
-
"bin": {
|
|
45
|
-
"coclaw": "src/cli.js"
|
|
46
|
-
},
|
|
47
44
|
"scripts": {
|
|
48
|
-
"dev": "node src/cli.js --help",
|
|
49
45
|
"build": "echo 'No build step needed (pure ES modules)'",
|
|
50
46
|
"lint": "eslint \"**/*.{js,mjs,cjs}\" --no-error-on-unmatched-pattern",
|
|
51
47
|
"typecheck": "echo \"No typecheck configured yet (coclaw openclaw plugin)\"",
|
|
@@ -15,16 +15,33 @@ export function escapeJsonForCmd(json) {
|
|
|
15
15
|
/**
|
|
16
16
|
* 通过 spawn 调用 `openclaw gateway call <method> --json`
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
* 进程不会自然退出。execSync 会一直阻塞直到超时,导致误报失败。
|
|
18
|
+
* ## 设计背景
|
|
20
19
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* 3. 检测到输出后延迟 KILL_DELAY_MS 再 kill(给进程自然退出的机会)
|
|
25
|
-
* 4. 无论成功失败,最终都主动 kill 子进程
|
|
20
|
+
* openclaw CLI 完成 gateway RPC 后,因 GatewayClient(WebSocket)handle 未完全销毁,
|
|
21
|
+
* 事件循环仍活跃,进程不会自然退出。早期使用 execSync 会阻塞等待进程退出而非输出完成,
|
|
22
|
+
* 导致 RPC 实际在 ~2s 内成功,但 10s 超时后误报 100% 失败。
|
|
26
23
|
*
|
|
27
|
-
*
|
|
24
|
+
* ## 策略
|
|
25
|
+
*
|
|
26
|
+
* 1. spawn 子进程执行 `openclaw gateway call <method> --json`
|
|
27
|
+
* 2. 监听 stdout,解析 JSON 输出判断 RPC 成功/失败
|
|
28
|
+
* 3. 检测到完整 JSON 后启动 KILL_DELAY_MS grace period 等待自然退出
|
|
29
|
+
* 4. 总超时默认 NOTIFY_TIMEOUT_MS(注册 CLI 路径覆盖为 30s)
|
|
30
|
+
* 5. 无论成功失败,最终都主动 kill 子进程
|
|
31
|
+
*
|
|
32
|
+
* grace period 设计:openclaw 进程因 WS handle 滞留可能 10s+ 才退出,
|
|
33
|
+
* 延迟 kill 是为兼容未来 OpenClaw 修复 WS 清理后进程能优雅退出的场景。
|
|
34
|
+
*
|
|
35
|
+
* ## stdout 判断策略
|
|
36
|
+
*
|
|
37
|
+
* `openclaw gateway call --json` 直接输出 method 的 result payload(respond 第二参数),
|
|
38
|
+
* 而非 gateway 协议层 { ok, result, error } 包装:
|
|
39
|
+
* - 有 stdout + 可解析 JSON → RPC 成功
|
|
40
|
+
* - 有 stdout + 非 JSON → 也视为成功(兜底)
|
|
41
|
+
* - 无 stdout + 非零退出码 → RPC 失败
|
|
42
|
+
* - 无 stdout + 超时 → RPC 失败
|
|
43
|
+
*
|
|
44
|
+
* @param {string} method - gateway method 名(如 coclaw.bind)
|
|
28
45
|
* @param {Function} [spawnFn] - 可注入的 spawn 函数(测试用)
|
|
29
46
|
* @param {object} [opts] - 可选配置(测试用)
|
|
30
47
|
* @param {number} [opts.timeoutMs] - 总超时毫秒数
|
package/src/common/messages.js
CHANGED
|
@@ -17,16 +17,6 @@ export function notBound() {
|
|
|
17
17
|
return 'Not bound. Nothing to unbind.';
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
export function gatewayNotified(action) {
|
|
21
|
-
return action === 'refresh'
|
|
22
|
-
? 'Bridge connection refreshed.'
|
|
23
|
-
: 'Bridge connection stopped.';
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function gatewayNotifyFailed() {
|
|
27
|
-
return 'Note: could not notify the running gateway. If it is running, restart it manually.';
|
|
28
|
-
}
|
|
29
|
-
|
|
30
20
|
export function claimCodeCreated({ code, appUrl, expiresMinutes }) {
|
|
31
21
|
return [
|
|
32
22
|
`Claim code: ${code}`,
|
package/src/config.js
CHANGED
|
@@ -7,10 +7,10 @@ import { atomicWriteJsonFile } from './utils/atomic-write.js';
|
|
|
7
7
|
import { createMutex } from './utils/mutex.js';
|
|
8
8
|
|
|
9
9
|
export const DEFAULT_ACCOUNT_ID = 'default';
|
|
10
|
-
const CHANNEL_ID = 'coclaw';
|
|
10
|
+
export const CHANNEL_ID = 'coclaw';
|
|
11
11
|
const BINDINGS_FILENAME = 'bindings.json';
|
|
12
12
|
|
|
13
|
-
function resolveStateDir() {
|
|
13
|
+
export function resolveStateDir() {
|
|
14
14
|
const rt = getRuntime();
|
|
15
15
|
if (rt?.state?.resolveStateDir) {
|
|
16
16
|
return rt.state.resolveStateDir();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import nodePath from 'node:path';
|
|
3
|
+
|
|
4
|
+
// 延迟读取 + 缓存:避免模块加载时 package.json 损坏导致插件整体无法注册
|
|
5
|
+
let __pluginVersion = null;
|
|
6
|
+
export async function getPluginVersion() {
|
|
7
|
+
if (__pluginVersion) return __pluginVersion;
|
|
8
|
+
try {
|
|
9
|
+
const pkgPath = nodePath.resolve(import.meta.dirname, '..', 'package.json');
|
|
10
|
+
const raw = await fs.readFile(pkgPath, 'utf8');
|
|
11
|
+
__pluginVersion = JSON.parse(raw).version ?? 'unknown';
|
|
12
|
+
} catch {
|
|
13
|
+
return 'unknown';
|
|
14
|
+
}
|
|
15
|
+
return __pluginVersion;
|
|
16
|
+
}
|
|
17
|
+
// 测试用:重置缓存
|
|
18
|
+
export function __resetPluginVersion() { __pluginVersion = null; }
|
package/src/realtime-bridge.js
CHANGED
|
@@ -3,6 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import nodePath from 'node:path';
|
|
4
4
|
|
|
5
5
|
import { clearConfig, getBindingsPath, readConfig } from './config.js';
|
|
6
|
+
import { getHostName, readSettings } from './settings.js';
|
|
6
7
|
import {
|
|
7
8
|
loadOrCreateDeviceIdentity,
|
|
8
9
|
signDevicePayload,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
} from './device-identity.js';
|
|
12
13
|
import { getRuntime } from './runtime.js';
|
|
13
14
|
import { setSender as setRemoteLogSender, remoteLog } from './remote-log.js';
|
|
15
|
+
import { getPluginVersion } from './plugin-version.js';
|
|
14
16
|
|
|
15
17
|
const DEFAULT_GATEWAY_WS_URL = `ws://127.0.0.1:${process.env.OPENCLAW_GATEWAY_PORT || '18789'}`;
|
|
16
18
|
const RECONNECT_MS = 10_000;
|
|
@@ -214,7 +216,7 @@ export class RealtimeBridge {
|
|
|
214
216
|
throw new Error('No WebRTC implementation available');
|
|
215
217
|
}
|
|
216
218
|
|
|
217
|
-
const { WebRtcPeer } = await import('./webrtc-peer.js');
|
|
219
|
+
const { WebRtcPeer } = await import('./webrtc/webrtc-peer.js');
|
|
218
220
|
const { createFileHandler } = await import('./file-manager/handler.js');
|
|
219
221
|
this.__fileHandler = createFileHandler({
|
|
220
222
|
resolveWorkspace: (agentId) => this.__resolveWorkspace(agentId),
|
|
@@ -423,6 +425,20 @@ export class RealtimeBridge {
|
|
|
423
425
|
}
|
|
424
426
|
}
|
|
425
427
|
|
|
428
|
+
/** 推送实例名到 server 和已连接的 UI */
|
|
429
|
+
async __pushInstanceName() {
|
|
430
|
+
try {
|
|
431
|
+
const settings = await readSettings();
|
|
432
|
+
const name = settings.name ?? null;
|
|
433
|
+
const hostName = getHostName();
|
|
434
|
+
broadcastPluginEvent('coclaw.info.updated', { name, hostName });
|
|
435
|
+
}
|
|
436
|
+
catch (err) {
|
|
437
|
+
/* c8 ignore next 2 -- 防御性兜底 */
|
|
438
|
+
this.logger.warn?.(`[coclaw] pushInstanceName failed: ${String(err?.message ?? err)}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
426
442
|
/* c8 ignore start -- 仅通过 WebRTC 路径调用,依赖 gateway 连接,集成测试覆盖 */
|
|
427
443
|
/**
|
|
428
444
|
* 通过 gateway RPC 获取指定 agent 的 workspace 绝对路径
|
|
@@ -519,7 +535,7 @@ export class RealtimeBridge {
|
|
|
519
535
|
maxProtocol: 3,
|
|
520
536
|
client: {
|
|
521
537
|
id: 'gateway-client',
|
|
522
|
-
version: '
|
|
538
|
+
version: this.__pluginVersion ?? 'unknown',
|
|
523
539
|
platform: process.platform,
|
|
524
540
|
mode: 'backend',
|
|
525
541
|
},
|
|
@@ -580,6 +596,7 @@ export class RealtimeBridge {
|
|
|
580
596
|
this.__logDebug(`gateway connect ok <- id=${payload.id}`);
|
|
581
597
|
this.gatewayConnectReqId = null;
|
|
582
598
|
this.__ensureSessionsPromise = this.__ensureAllAgentSessions();
|
|
599
|
+
this.__pushInstanceName();
|
|
583
600
|
}
|
|
584
601
|
else {
|
|
585
602
|
this.gatewayReady = false;
|
|
@@ -912,13 +929,18 @@ export class RealtimeBridge {
|
|
|
912
929
|
this.started = true;
|
|
913
930
|
// 先完成 WebRTC 实现加载,再建立连接,避免 UI 发来 offer 时 RTC 包未就绪
|
|
914
931
|
const preloadFn = this.__preloadNdc
|
|
915
|
-
?? (await import('./ndc-preloader.js')).preloadNdc;
|
|
916
|
-
|
|
917
|
-
|
|
932
|
+
?? (await import('./webrtc/ndc-preloader.js')).preloadNdc;
|
|
933
|
+
// 版本预热与 preload 并行,供 gateway connect 请求同步使用
|
|
934
|
+
const [preloadResult] = await Promise.all([
|
|
935
|
+
preloadFn().catch((err) => {
|
|
918
936
|
// preloadNdc 设计上永不 throw,此 catch 为纯防御性兜底
|
|
919
937
|
this.logger.warn?.(`[coclaw] ndc preload unexpected failure: ${err?.message}`);
|
|
920
938
|
return { PeerConnection: null, cleanup: null, impl: 'none' };
|
|
921
|
-
})
|
|
939
|
+
}),
|
|
940
|
+
getPluginVersion()
|
|
941
|
+
.then((v) => { this.__pluginVersion = v; })
|
|
942
|
+
.catch(() => { this.__pluginVersion = 'unknown'; }),
|
|
943
|
+
]);
|
|
922
944
|
// 竞态保护:若 preload 期间 stop() 已执行,不再赋值,立即释放 cleanup
|
|
923
945
|
if (!this.started) {
|
|
924
946
|
if (preloadResult.cleanup) {
|
|
@@ -1048,3 +1070,15 @@ export async function gatewayAgentRpc(method, params, options) {
|
|
|
1048
1070
|
}
|
|
1049
1071
|
return singleton.__gatewayAgentRpc(method, params, options);
|
|
1050
1072
|
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* 广播插件自发事件(推送到 server + 广播到所有 UI DC)
|
|
1076
|
+
* @param {string} event - 事件名(如 'coclaw.info.updated')
|
|
1077
|
+
* @param {object} [payload]
|
|
1078
|
+
*/
|
|
1079
|
+
export function broadcastPluginEvent(event, payload) {
|
|
1080
|
+
if (!singleton) return;
|
|
1081
|
+
const frame = { type: 'event', event, payload };
|
|
1082
|
+
singleton.__forwardToServer(frame);
|
|
1083
|
+
singleton.webrtcPeer?.broadcast(frame);
|
|
1084
|
+
}
|
package/src/settings.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import nodePath from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { resolveStateDir, CHANNEL_ID } from './config.js';
|
|
6
|
+
import { atomicWriteJsonFile } from './utils/atomic-write.js';
|
|
7
|
+
import { createMutex } from './utils/mutex.js';
|
|
8
|
+
|
|
9
|
+
const SETTINGS_FILENAME = 'settings.json';
|
|
10
|
+
export const MAX_NAME_LENGTH = 63;
|
|
11
|
+
|
|
12
|
+
const settingsMutex = createMutex();
|
|
13
|
+
|
|
14
|
+
function getSettingsPath() {
|
|
15
|
+
return nodePath.join(resolveStateDir(), CHANNEL_ID, SETTINGS_FILENAME);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function readJsonSafe(filePath) {
|
|
19
|
+
let raw;
|
|
20
|
+
try {
|
|
21
|
+
raw = await fs.readFile(filePath, 'utf8');
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
if (err?.code === 'ENOENT') return {};
|
|
25
|
+
/* c8 ignore next 2 */
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
if (!String(raw).trim()) return {};
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(raw);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// 文件损坏,删除后当空对象处理
|
|
34
|
+
/* c8 ignore next 2 -- ?./?? fallback */
|
|
35
|
+
console.warn?.(`[coclaw] corrupt settings file deleted: ${filePath}`);
|
|
36
|
+
await fs.unlink(filePath).catch(() => {});
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 读取插件设置
|
|
43
|
+
* @returns {Promise<{ name?: string }>}
|
|
44
|
+
*/
|
|
45
|
+
export async function readSettings() {
|
|
46
|
+
const data = await readJsonSafe(getSettingsPath());
|
|
47
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 写入 claw name
|
|
52
|
+
* @param {string|null} name - 名称;null/空字符串/纯空白 → 清除
|
|
53
|
+
*/
|
|
54
|
+
export async function writeName(name) {
|
|
55
|
+
const trimmed = typeof name === 'string' ? name.trim() : '';
|
|
56
|
+
if (trimmed && trimmed.length > MAX_NAME_LENGTH) {
|
|
57
|
+
throw new Error(`Name exceeds maximum length of ${MAX_NAME_LENGTH} characters`);
|
|
58
|
+
}
|
|
59
|
+
return settingsMutex.withLock(async () => {
|
|
60
|
+
const settingsPath = getSettingsPath();
|
|
61
|
+
const data = await readJsonSafe(settingsPath);
|
|
62
|
+
const settings = data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
|
63
|
+
if (trimmed) {
|
|
64
|
+
settings.name = trimmed;
|
|
65
|
+
} else {
|
|
66
|
+
delete settings.name;
|
|
67
|
+
}
|
|
68
|
+
await atomicWriteJsonFile(settingsPath, settings);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 获取 OS 主机名(去 .local 后缀)
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
export function getHostName() {
|
|
77
|
+
const raw = os.hostname().trim();
|
|
78
|
+
return raw.replace(/\.local$/i, '') || 'openclaw';
|
|
79
|
+
}
|
|
@@ -34,8 +34,10 @@ async function atomicWriteFile(filePath, content, opts) {
|
|
|
34
34
|
try {
|
|
35
35
|
await fs.writeFile(tmp, content, { encoding, mode });
|
|
36
36
|
// best-effort chmod(部分平台 writeFile 的 mode 可能不生效)
|
|
37
|
+
/* c8 ignore next -- chmod 在正常文件系统上不会失败 */
|
|
37
38
|
try { await fs.chmod(tmp, mode); } catch { /* ignore */ }
|
|
38
39
|
await fs.rename(tmp, filePath);
|
|
40
|
+
/* c8 ignore next -- chmod 在正常文件系统上不会失败 */
|
|
39
41
|
try { await fs.chmod(filePath, mode); } catch { /* ignore */ }
|
|
40
42
|
} finally {
|
|
41
43
|
// 确保临时文件不残留
|
|
@@ -2,7 +2,7 @@ import { createRequire } from 'module';
|
|
|
2
2
|
import nodePath from 'path';
|
|
3
3
|
import fsSync from 'fs';
|
|
4
4
|
import fsPromises from 'fs/promises';
|
|
5
|
-
import { remoteLog as defaultRemoteLog } from '
|
|
5
|
+
import { remoteLog as defaultRemoteLog } from '../remote-log.js';
|
|
6
6
|
|
|
7
7
|
const SUPPORTED_PLATFORMS = new Set([
|
|
8
8
|
'linux-x64',
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { chunkAndSend, createReassembler } from './
|
|
2
|
-
import { remoteLog } from '
|
|
1
|
+
import { chunkAndSend, createReassembler } from './dc-chunking.js';
|
|
2
|
+
import { remoteLog } from '../remote-log.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* 管理多个 WebRTC PeerConnection(以 connId 为粒度)。
|
|
@@ -124,7 +124,7 @@ export class WebRtcPeer {
|
|
|
124
124
|
const { urls, username, credential } = msg.turnCreds;
|
|
125
125
|
for (const url of urls) {
|
|
126
126
|
const server = { urls: url };
|
|
127
|
-
if (url.startsWith('turn:')) {
|
|
127
|
+
if (url.startsWith('turn:') || url.startsWith('turns:')) {
|
|
128
128
|
server.username = username;
|
|
129
129
|
server.credential = credential;
|
|
130
130
|
}
|
package/src/cli.js
DELETED
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import nodePath from 'node:path';
|
|
3
|
-
import process from 'node:process';
|
|
4
|
-
import { pathToFileURL } from 'node:url';
|
|
5
|
-
|
|
6
|
-
import { bindBot, unbindBot } from './common/bot-binding.js';
|
|
7
|
-
import { resolveErrorMessage } from './common/errors.js';
|
|
8
|
-
import { callGatewayMethod } from './common/gateway-notify.js';
|
|
9
|
-
import {
|
|
10
|
-
notBound, bindOk, unbindOk,
|
|
11
|
-
gatewayNotified, gatewayNotifyFailed,
|
|
12
|
-
} from './common/messages.js';
|
|
13
|
-
|
|
14
|
-
function parseArgs(argv) {
|
|
15
|
-
const [command, ...rest] = argv;
|
|
16
|
-
const options = {};
|
|
17
|
-
const positionals = [];
|
|
18
|
-
|
|
19
|
-
for (let i = 0; i < rest.length; i += 1) {
|
|
20
|
-
const token = rest[i];
|
|
21
|
-
if (token === '--server' && i + 1 < rest.length) {
|
|
22
|
-
options.server = rest[i + 1];
|
|
23
|
-
i += 1;
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
|
-
positionals.push(token);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
return {
|
|
30
|
-
command,
|
|
31
|
-
positionals,
|
|
32
|
-
options,
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function printHelp() {
|
|
37
|
-
console.log('Usage: coclaw <bind|unbind> [args] [--server <url>]');
|
|
38
|
-
console.log('');
|
|
39
|
-
console.log('Commands:');
|
|
40
|
-
console.log(' bind <binding-code>');
|
|
41
|
-
console.log(' unbind');
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function notifyGateway(method, deps) {
|
|
45
|
-
const action = method.endsWith('refreshBridge') ? 'refresh' : 'stop';
|
|
46
|
-
try {
|
|
47
|
-
const result = await callGatewayMethod(method, deps.spawn);
|
|
48
|
-
if (result.ok) {
|
|
49
|
-
console.log(gatewayNotified(action));
|
|
50
|
-
} else {
|
|
51
|
-
console.warn(gatewayNotifyFailed());
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
/* c8 ignore next 3 -- callGatewayMethod 已内部兜底,此处纯防御 */
|
|
55
|
-
catch {
|
|
56
|
-
console.warn(gatewayNotifyFailed());
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
61
|
-
const { command, positionals, options } = parseArgs(argv);
|
|
62
|
-
|
|
63
|
-
if (!command || command === '--help' || command === '-h') {
|
|
64
|
-
printHelp();
|
|
65
|
-
return 0;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
if (command === 'bind') {
|
|
69
|
-
// 先断开 bridge,避免 unbindWithServer 触发的 bot.unbound 竞态
|
|
70
|
-
await notifyGateway('coclaw.stopBridge', deps);
|
|
71
|
-
const result = await bindBot({
|
|
72
|
-
code: positionals[0],
|
|
73
|
-
serverUrl: options.server,
|
|
74
|
-
});
|
|
75
|
-
/* c8 ignore next */
|
|
76
|
-
console.log(bindOk(result));
|
|
77
|
-
await notifyGateway('coclaw.refreshBridge', deps);
|
|
78
|
-
return 0;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (command === 'unbind') {
|
|
82
|
-
try {
|
|
83
|
-
const result = await unbindBot({
|
|
84
|
-
serverUrl: options.server,
|
|
85
|
-
});
|
|
86
|
-
/* c8 ignore next */
|
|
87
|
-
console.log(unbindOk(result));
|
|
88
|
-
await notifyGateway('coclaw.stopBridge', deps);
|
|
89
|
-
return 0;
|
|
90
|
-
} catch (err) {
|
|
91
|
-
if (err.code === 'NOT_BOUND') {
|
|
92
|
-
console.error(notBound());
|
|
93
|
-
return 1;
|
|
94
|
-
}
|
|
95
|
-
/* c8 ignore start -- 防御性兜底,unbindBot 主要抛 NOT_BOUND,也可能抛网络/HTTP 错误 */
|
|
96
|
-
throw err;
|
|
97
|
-
}
|
|
98
|
-
/* c8 ignore stop */
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
throw new Error(`unknown command: ${command}`);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/* c8 ignore start */
|
|
105
|
-
function isCliEntrypoint() {
|
|
106
|
-
const argvPath = process.argv[1];
|
|
107
|
-
if (!argvPath) {
|
|
108
|
-
return false;
|
|
109
|
-
}
|
|
110
|
-
return import.meta.url === pathToFileURL(nodePath.resolve(argvPath)).href;
|
|
111
|
-
}
|
|
112
|
-
/* c8 ignore stop */
|
|
113
|
-
|
|
114
|
-
/* c8 ignore start */
|
|
115
|
-
if (isCliEntrypoint()) {
|
|
116
|
-
process.on('uncaughtException', (err) => {
|
|
117
|
-
console.error('[coclaw] uncaughtException:', err?.stack ?? err);
|
|
118
|
-
});
|
|
119
|
-
process.on('unhandledRejection', (err) => {
|
|
120
|
-
console.error('[coclaw] unhandledRejection:', err?.stack ?? err);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
main().catch((err) => {
|
|
124
|
-
console.error(`[coclaw] ${resolveErrorMessage(err)}`);
|
|
125
|
-
process.exitCode = 1;
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
/* c8 ignore stop */
|
|
File without changes
|