@coclaw/openclaw-coclaw 0.5.1 → 0.6.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/README.md +4 -1
- package/index.js +117 -14
- package/package.json +1 -1
- package/src/api.js +19 -0
- package/src/cli-registrar.js +92 -0
- package/src/common/bot-binding.js +79 -1
- package/src/common/gateway-notify.js +22 -4
- package/src/common/messages.js +10 -0
- package/src/realtime-bridge.js +1 -1
package/README.md
CHANGED
|
@@ -69,9 +69,11 @@ pnpm run release:versions # 显示所有已发布版本
|
|
|
69
69
|
```bash
|
|
70
70
|
openclaw coclaw bind <binding-code> [--server <url>]
|
|
71
71
|
openclaw coclaw unbind [--server <url>]
|
|
72
|
+
openclaw coclaw enroll [--server <url>]
|
|
72
73
|
```
|
|
73
74
|
|
|
74
|
-
bind/unbind 成功后会通过 gateway RPC 通知插件刷新/停止 bridge 连接(无需重启 gateway)。若 gateway 未运行,通知会失败但不影响绑定结果。
|
|
75
|
+
- bind/unbind 成功后会通过 gateway RPC 通知插件刷新/停止 bridge 连接(无需重启 gateway)。若 gateway 未运行,通知会失败但不影响绑定结果。
|
|
76
|
+
- enroll 由 OpenClaw 侧主动发起,生成认领码和链接供用户点击完成绑定。已绑定时需先 unbind 再发起。
|
|
75
77
|
|
|
76
78
|
### 方式二:IM 渠道命令
|
|
77
79
|
|
|
@@ -80,6 +82,7 @@ bind/unbind 成功后会通过 gateway RPC 通知插件刷新/停止 bridge 连
|
|
|
80
82
|
```
|
|
81
83
|
/coclaw bind <binding-code> [--server <url>]
|
|
82
84
|
/coclaw unbind [--server <url>]
|
|
85
|
+
/coclaw enroll [--server <url>]
|
|
83
86
|
```
|
|
84
87
|
|
|
85
88
|
需要 gateway 运行中。
|
package/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import nodePath from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { bindBot, unbindBot } from './src/common/bot-binding.js';
|
|
4
|
+
import { bindBot, unbindBot, enrollBot, waitForClaimAndSave } from './src/common/bot-binding.js';
|
|
5
5
|
import { registerCoclawCli } from './src/cli-registrar.js';
|
|
6
6
|
import { resolveErrorMessage } from './src/common/errors.js';
|
|
7
|
-
import { notBound, bindOk, unbindOk } from './src/common/messages.js';
|
|
7
|
+
import { notBound, bindOk, unbindOk, claimCodeCreated } from './src/common/messages.js';
|
|
8
8
|
import { coclawChannelPlugin } from './src/channel-plugin.js';
|
|
9
9
|
import { ensureAgentSession, gatewayAgentRpc, restartRealtimeBridge, stopRealtimeBridge, waitForSessionsReady } from './src/realtime-bridge.js';
|
|
10
10
|
import { setRuntime } from './src/runtime.js';
|
|
@@ -59,16 +59,22 @@ function buildHelpText() {
|
|
|
59
59
|
'',
|
|
60
60
|
'/coclaw bind <binding-code> [--server <url>]',
|
|
61
61
|
'/coclaw unbind [--server <url>]',
|
|
62
|
+
'/coclaw enroll [--server <url>]',
|
|
62
63
|
].join('\n');
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
function respondError(respond, err) {
|
|
66
|
-
respond(false, {
|
|
67
|
+
respond(false, undefined, {
|
|
68
|
+
code: err?.code ?? 'INTERNAL_ERROR',
|
|
67
69
|
/* c8 ignore next */
|
|
68
|
-
|
|
70
|
+
message: String(err?.message ?? err),
|
|
69
71
|
});
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
function respondInvalid(respond, message) {
|
|
75
|
+
respond(false, undefined, { code: 'INVALID_INPUT', message });
|
|
76
|
+
}
|
|
77
|
+
|
|
72
78
|
/* c8 ignore start */
|
|
73
79
|
const plugin = {
|
|
74
80
|
id: 'openclaw-coclaw',
|
|
@@ -143,6 +149,60 @@ const plugin = {
|
|
|
143
149
|
}
|
|
144
150
|
});
|
|
145
151
|
|
|
152
|
+
// enroll 并发控制:同一时刻只允许一个活跃 enroll
|
|
153
|
+
let activeEnrollAbort = null;
|
|
154
|
+
|
|
155
|
+
api.registerGatewayMethod('coclaw.enroll', async ({ params, respond }) => {
|
|
156
|
+
try {
|
|
157
|
+
// 取消前一个 enroll
|
|
158
|
+
if (activeEnrollAbort) {
|
|
159
|
+
activeEnrollAbort.abort();
|
|
160
|
+
}
|
|
161
|
+
const abortController = new AbortController();
|
|
162
|
+
activeEnrollAbort = abortController;
|
|
163
|
+
|
|
164
|
+
const serverUrl = params?.serverUrl ?? api.pluginConfig?.serverUrl;
|
|
165
|
+
const result = await enrollBot({ serverUrl });
|
|
166
|
+
|
|
167
|
+
const rawMinutes = Math.round(
|
|
168
|
+
(new Date(result.expiresAt).getTime() - Date.now()) / 60_000,
|
|
169
|
+
);
|
|
170
|
+
const expiresMinutes = Number.isFinite(rawMinutes) ? rawMinutes : 30;
|
|
171
|
+
|
|
172
|
+
// 立即返回认领码给 CLI
|
|
173
|
+
respond(true, {
|
|
174
|
+
status: {
|
|
175
|
+
code: result.code,
|
|
176
|
+
appUrl: result.appUrl,
|
|
177
|
+
expiresAt: result.expiresAt,
|
|
178
|
+
expiresMinutes,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// 后台 fire-and-forget:等待认领并保存 config + 启 bridge
|
|
183
|
+
waitForClaimAndSave({
|
|
184
|
+
serverUrl: result.serverUrl,
|
|
185
|
+
code: result.code,
|
|
186
|
+
waitToken: result.waitToken,
|
|
187
|
+
signal: abortController.signal,
|
|
188
|
+
}).then(async () => {
|
|
189
|
+
if (abortController.signal.aborted) return;
|
|
190
|
+
await restartRealtimeBridge({ logger, pluginConfig: api.pluginConfig });
|
|
191
|
+
logger.info?.('[coclaw] enroll completed, bridge restarted');
|
|
192
|
+
}).catch((err) => {
|
|
193
|
+
if (abortController.signal.aborted) return;
|
|
194
|
+
logger.warn?.(`[coclaw] enroll wait failed: ${String(err?.message ?? err)}`);
|
|
195
|
+
}).finally(() => {
|
|
196
|
+
if (activeEnrollAbort === abortController) {
|
|
197
|
+
activeEnrollAbort = null;
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
respondError(respond, err);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
146
206
|
api.registerGatewayMethod('nativeui.sessions.listAll', async ({ params, respond }) => {
|
|
147
207
|
try {
|
|
148
208
|
const agentId = params?.agentId?.trim?.() || 'main';
|
|
@@ -208,7 +268,7 @@ const plugin = {
|
|
|
208
268
|
try {
|
|
209
269
|
const topicId = params?.topicId?.trim?.();
|
|
210
270
|
if (!topicId) {
|
|
211
|
-
respond
|
|
271
|
+
respondInvalid(respond, 'topicId required');
|
|
212
272
|
return;
|
|
213
273
|
}
|
|
214
274
|
respond(true, topicManager.get({ topicId }));
|
|
@@ -222,7 +282,7 @@ const plugin = {
|
|
|
222
282
|
try {
|
|
223
283
|
const topicId = params?.topicId?.trim?.();
|
|
224
284
|
if (!topicId) {
|
|
225
|
-
respond
|
|
285
|
+
respondInvalid(respond, 'topicId required');
|
|
226
286
|
return;
|
|
227
287
|
}
|
|
228
288
|
const agentId = params?.agentId?.trim?.() || 'main';
|
|
@@ -238,23 +298,23 @@ const plugin = {
|
|
|
238
298
|
try {
|
|
239
299
|
const topicId = params?.topicId?.trim?.();
|
|
240
300
|
if (!topicId) {
|
|
241
|
-
respond
|
|
301
|
+
respondInvalid(respond, 'topicId required');
|
|
242
302
|
return;
|
|
243
303
|
}
|
|
244
304
|
const changes = params?.changes;
|
|
245
305
|
if (!changes || typeof changes !== 'object') {
|
|
246
|
-
respond
|
|
306
|
+
respondInvalid(respond, 'changes required');
|
|
247
307
|
return;
|
|
248
308
|
}
|
|
249
309
|
// 当前版本仅处理 title
|
|
250
310
|
if (typeof changes.title !== 'string') {
|
|
251
|
-
respond
|
|
311
|
+
respondInvalid(respond, 'No valid change field provided (supported: title)');
|
|
252
312
|
return;
|
|
253
313
|
}
|
|
254
314
|
await topicManager.updateTitle({ topicId, title: changes.title });
|
|
255
315
|
const { topic } = topicManager.get({ topicId });
|
|
256
316
|
if (!topic) {
|
|
257
|
-
respond
|
|
317
|
+
respondInvalid(respond, `Topic not found: ${topicId}`);
|
|
258
318
|
return;
|
|
259
319
|
}
|
|
260
320
|
respond(true, { topic });
|
|
@@ -268,7 +328,7 @@ const plugin = {
|
|
|
268
328
|
try {
|
|
269
329
|
const topicId = params?.topicId?.trim?.();
|
|
270
330
|
if (!topicId) {
|
|
271
|
-
respond
|
|
331
|
+
respondInvalid(respond, 'topicId required');
|
|
272
332
|
return;
|
|
273
333
|
}
|
|
274
334
|
const result = await generateTitle({
|
|
@@ -288,7 +348,7 @@ const plugin = {
|
|
|
288
348
|
try {
|
|
289
349
|
const topicId = params?.topicId?.trim?.();
|
|
290
350
|
if (!topicId) {
|
|
291
|
-
respond
|
|
351
|
+
respondInvalid(respond, 'topicId required');
|
|
292
352
|
return;
|
|
293
353
|
}
|
|
294
354
|
const result = await topicManager.delete({ topicId });
|
|
@@ -304,7 +364,7 @@ const plugin = {
|
|
|
304
364
|
const agentId = params?.agentId?.trim?.() || 'main';
|
|
305
365
|
const sessionKey = params?.sessionKey?.trim?.();
|
|
306
366
|
if (!sessionKey) {
|
|
307
|
-
respond
|
|
367
|
+
respondInvalid(respond, 'sessionKey required');
|
|
308
368
|
return;
|
|
309
369
|
}
|
|
310
370
|
if (!chatHistoryManager.__cache.has(agentId)) {
|
|
@@ -323,7 +383,7 @@ const plugin = {
|
|
|
323
383
|
try {
|
|
324
384
|
const sessionId = params?.sessionId?.trim?.();
|
|
325
385
|
if (!sessionId) {
|
|
326
|
-
respond
|
|
386
|
+
respondInvalid(respond, 'sessionId required');
|
|
327
387
|
return;
|
|
328
388
|
}
|
|
329
389
|
const agentId = params?.agentId?.trim?.() || 'main';
|
|
@@ -376,6 +436,49 @@ const plugin = {
|
|
|
376
436
|
return { text: bindOk(result) };
|
|
377
437
|
}
|
|
378
438
|
|
|
439
|
+
if (action === 'enroll') {
|
|
440
|
+
// 并发控制:取消前一个 enroll(与 RPC 路径共享)
|
|
441
|
+
if (activeEnrollAbort) {
|
|
442
|
+
activeEnrollAbort.abort();
|
|
443
|
+
}
|
|
444
|
+
const abortController = new AbortController();
|
|
445
|
+
activeEnrollAbort = abortController;
|
|
446
|
+
|
|
447
|
+
const serverUrl = options.server ?? api.pluginConfig?.serverUrl;
|
|
448
|
+
const result = await enrollBot({ serverUrl });
|
|
449
|
+
const rawMinutes = Math.round(
|
|
450
|
+
(new Date(result.expiresAt).getTime() - Date.now()) / 60_000,
|
|
451
|
+
);
|
|
452
|
+
const expiresMinutes = Number.isFinite(rawMinutes) ? rawMinutes : 30;
|
|
453
|
+
|
|
454
|
+
// 后台 fire-and-forget:等待认领完成后写 config + 启 bridge
|
|
455
|
+
waitForClaimAndSave({
|
|
456
|
+
serverUrl: result.serverUrl,
|
|
457
|
+
code: result.code,
|
|
458
|
+
waitToken: result.waitToken,
|
|
459
|
+
signal: abortController.signal,
|
|
460
|
+
}).then(async () => {
|
|
461
|
+
if (abortController.signal.aborted) return;
|
|
462
|
+
await restartRealtimeBridge({ logger, pluginConfig: api.pluginConfig });
|
|
463
|
+
logger.info?.('[coclaw] enroll completed via slash command, bridge restarted');
|
|
464
|
+
}).catch((err) => {
|
|
465
|
+
if (abortController.signal.aborted) return;
|
|
466
|
+
logger.warn?.(`[coclaw] enroll wait failed: ${String(err?.message ?? err)}`);
|
|
467
|
+
}).finally(() => {
|
|
468
|
+
if (activeEnrollAbort === abortController) {
|
|
469
|
+
activeEnrollAbort = null;
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
text: claimCodeCreated({
|
|
475
|
+
code: result.code,
|
|
476
|
+
appUrl: result.appUrl,
|
|
477
|
+
expiresMinutes,
|
|
478
|
+
}),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
379
482
|
if (action === 'unbind') {
|
|
380
483
|
const result = await unbindBot({ serverUrl: options.server });
|
|
381
484
|
await stopRealtimeBridge();
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -27,6 +27,8 @@ async function requestJson(baseUrl, path, { method = 'GET', headers, body, timeo
|
|
|
27
27
|
|
|
28
28
|
const BIND_TIMEOUT = 30_000;
|
|
29
29
|
const UNBIND_TIMEOUT = 15_000;
|
|
30
|
+
const CLAIM_CODE_TIMEOUT = 15_000;
|
|
31
|
+
const CLAIM_WAIT_TIMEOUT = 30_000;
|
|
30
32
|
|
|
31
33
|
export async function bindWithServer({ baseUrl, code, name }) {
|
|
32
34
|
return requestJson(baseUrl, '/api/v1/bots/bind', {
|
|
@@ -47,3 +49,20 @@ export async function unbindWithServer({ baseUrl, token, timeout = UNBIND_TIMEOU
|
|
|
47
49
|
});
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
export async function createClaimCodeOnServer({ baseUrl, timeout = CLAIM_CODE_TIMEOUT }) {
|
|
53
|
+
return requestJson(baseUrl, '/api/v1/claws/claim-codes', {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'content-type': 'application/json' },
|
|
56
|
+
timeout,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function waitClaimCodeOnServer({ baseUrl, code, waitToken, timeout = CLAIM_WAIT_TIMEOUT }) {
|
|
61
|
+
return requestJson(baseUrl, '/api/v1/claws/claim-codes/wait', {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { 'content-type': 'application/json' },
|
|
64
|
+
body: { code, waitToken },
|
|
65
|
+
timeout,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
package/src/cli-registrar.js
CHANGED
|
@@ -4,8 +4,45 @@ import { callGatewayMethod } from './common/gateway-notify.js';
|
|
|
4
4
|
import {
|
|
5
5
|
notBound, bindOk, unbindOk,
|
|
6
6
|
gatewayNotified, gatewayNotifyFailed,
|
|
7
|
+
claimCodeCreated,
|
|
7
8
|
} from './common/messages.js';
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* 从 `openclaw gateway call` stderr 中提取核心错误信息
|
|
12
|
+
* stderr 格式:`Gateway call failed: GatewayClientRequestError: <message>`
|
|
13
|
+
*/
|
|
14
|
+
function extractRpcErrorMessage(raw) {
|
|
15
|
+
if (!raw) return '';
|
|
16
|
+
const match = raw.match(/GatewayClientRequestError:\s*(.+)/);
|
|
17
|
+
return match ? match[1].trim() : raw;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const GATEWAY_UNAVAILABLE_ERRORS = new Set([
|
|
21
|
+
'spawn_error', 'spawn_failed', 'timeout', 'empty_output',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
function isGatewayUnavailable(result) {
|
|
25
|
+
// exit_code_* 不视为 gateway 不可用:进程已启动成功,非零退出通常是业务错误
|
|
26
|
+
return !result.ok && GATEWAY_UNAVAILABLE_ERRORS.has(result.error);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* c8 ignore start -- 集成级函数,测试通过 deps.restartGateway 注入替代 */
|
|
30
|
+
async function restartGatewayProcess(spawnFn) {
|
|
31
|
+
const { spawn: nodeSpawn } = await import('node:child_process');
|
|
32
|
+
const doSpawn = spawnFn ?? nodeSpawn;
|
|
33
|
+
await new Promise((resolve, reject) => {
|
|
34
|
+
const child = doSpawn('openclaw', ['gateway', 'restart'], {
|
|
35
|
+
stdio: 'ignore',
|
|
36
|
+
shell: process.platform === 'win32',
|
|
37
|
+
});
|
|
38
|
+
child.on('close', (exitCode) => exitCode === 0 ? resolve() : reject(new Error(`exit ${exitCode}`)));
|
|
39
|
+
child.on('error', reject);
|
|
40
|
+
});
|
|
41
|
+
// 等待 gateway 就绪
|
|
42
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
43
|
+
}
|
|
44
|
+
/* c8 ignore stop */
|
|
45
|
+
|
|
9
46
|
function resolveServerUrl(opts, config) {
|
|
10
47
|
return opts?.server
|
|
11
48
|
?? config?.plugins?.entries?.['openclaw-coclaw']?.config?.serverUrl
|
|
@@ -60,6 +97,61 @@ export function registerCoclawCli({ program, config, logger }, deps = {}) {
|
|
|
60
97
|
}
|
|
61
98
|
});
|
|
62
99
|
|
|
100
|
+
coclaw
|
|
101
|
+
.command('enroll')
|
|
102
|
+
.description('Enroll this OpenClaw instance with CoClaw (generate a claim code for the user)')
|
|
103
|
+
.option('--server <url>', 'CoClaw server URL')
|
|
104
|
+
.action(async (opts) => {
|
|
105
|
+
try {
|
|
106
|
+
const rpcOpts = opts?.server ? { params: { serverUrl: opts.server } } : undefined;
|
|
107
|
+
const callRpc = () => callGatewayMethod('coclaw.enroll', deps.spawn, rpcOpts);
|
|
108
|
+
|
|
109
|
+
let result = await callRpc();
|
|
110
|
+
|
|
111
|
+
// 仅在 gateway 不可用时重启重试,业务错误不重启
|
|
112
|
+
if (isGatewayUnavailable(result)) {
|
|
113
|
+
logger.info?.('[coclaw] enroll RPC failed, attempting gateway restart...');
|
|
114
|
+
const restartFn = deps.restartGateway ?? restartGatewayProcess;
|
|
115
|
+
try {
|
|
116
|
+
await restartFn(deps.spawn);
|
|
117
|
+
} catch {
|
|
118
|
+
// 重启失败,仍然尝试再次 RPC
|
|
119
|
+
}
|
|
120
|
+
result = await callRpc();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!result.ok) {
|
|
124
|
+
if (isGatewayUnavailable(result)) {
|
|
125
|
+
console.error('Error: Could not reach gateway. Ensure OpenClaw gateway is running.');
|
|
126
|
+
console.error(' Try: openclaw gateway start');
|
|
127
|
+
} else {
|
|
128
|
+
// 业务错误(如已绑定):输出 gateway 返回的错误信息
|
|
129
|
+
console.error(`Error: ${extractRpcErrorMessage(result.message) || 'enroll failed'}`);
|
|
130
|
+
}
|
|
131
|
+
process.exitCode = 1;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// RPC 成功:输出认领码信息
|
|
136
|
+
// gateway method 的 respond 数据包含 status 字段
|
|
137
|
+
const data = result.status;
|
|
138
|
+
if (data?.code && data?.appUrl) {
|
|
139
|
+
console.log(claimCodeCreated({
|
|
140
|
+
code: data.code,
|
|
141
|
+
appUrl: data.appUrl,
|
|
142
|
+
expiresMinutes: data.expiresMinutes ?? 30,
|
|
143
|
+
}));
|
|
144
|
+
} else {
|
|
145
|
+
console.log('Enroll request sent to gateway.');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/* c8 ignore next 4 -- callGatewayMethod 不会抛异常,纯防御 */
|
|
149
|
+
catch (err) {
|
|
150
|
+
console.error(`Error: ${resolveErrorMessage(err)}`);
|
|
151
|
+
process.exitCode = 1;
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
63
155
|
coclaw
|
|
64
156
|
.command('unbind')
|
|
65
157
|
.description('Unbind this OpenClaw instance from CoClaw')
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import { bindWithServer, unbindWithServer } from '../api.js';
|
|
1
|
+
import { bindWithServer, unbindWithServer, createClaimCodeOnServer, waitClaimCodeOnServer } from '../api.js';
|
|
2
2
|
import { clearConfig, readConfig, writeConfig } from '../config.js';
|
|
3
3
|
|
|
4
4
|
const DEFAULT_SERVER_URL = 'https://im.coclaw.net';
|
|
5
5
|
|
|
6
|
+
function resolveServerUrl(serverUrl) {
|
|
7
|
+
/* c8 ignore next */
|
|
8
|
+
return serverUrl ?? process.env.COCLAW_SERVER_URL ?? DEFAULT_SERVER_URL;
|
|
9
|
+
}
|
|
10
|
+
|
|
6
11
|
export async function bindBot({ code, serverUrl }) {
|
|
7
12
|
if (!code) {
|
|
8
13
|
throw new Error('binding code is required');
|
|
@@ -50,6 +55,79 @@ export async function bindBot({ code, serverUrl }) {
|
|
|
50
55
|
};
|
|
51
56
|
}
|
|
52
57
|
|
|
58
|
+
export async function enrollBot({ serverUrl }, deps = {}) {
|
|
59
|
+
const { createClaimCode = createClaimCodeOnServer, readCfg = readConfig } = deps;
|
|
60
|
+
|
|
61
|
+
const config = await readCfg();
|
|
62
|
+
if (config?.token) {
|
|
63
|
+
const err = new Error('Already bound. Run `openclaw coclaw unbind` to unbind first, then retry.');
|
|
64
|
+
err.code = 'ALREADY_BOUND';
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const baseUrl = resolveServerUrl(serverUrl);
|
|
69
|
+
const data = await createClaimCode({ baseUrl });
|
|
70
|
+
|
|
71
|
+
if (!data?.code || !data?.waitToken) {
|
|
72
|
+
throw new Error('invalid enroll response');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const appUrl = `${baseUrl}/claim?code=${data.code}`;
|
|
76
|
+
return {
|
|
77
|
+
code: data.code,
|
|
78
|
+
expiresAt: data.expiresAt,
|
|
79
|
+
waitToken: data.waitToken,
|
|
80
|
+
appUrl,
|
|
81
|
+
serverUrl: baseUrl,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function waitForClaimAndSave({ serverUrl, code, waitToken, signal }, deps = {}) {
|
|
86
|
+
const { waitClaimCode = waitClaimCodeOnServer, writeCfg = writeConfig, retryDelayMs = 2000 } = deps;
|
|
87
|
+
const baseUrl = resolveServerUrl(serverUrl);
|
|
88
|
+
|
|
89
|
+
// 循环长轮询,直到成功或超时
|
|
90
|
+
for (;;) {
|
|
91
|
+
if (signal?.aborted) {
|
|
92
|
+
throw new Error('enroll cancelled');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let data;
|
|
96
|
+
try {
|
|
97
|
+
data = await waitClaimCode({ baseUrl, code, waitToken });
|
|
98
|
+
} catch (err) {
|
|
99
|
+
// 认领码已失效 — 不可恢复,退出循环
|
|
100
|
+
if (err?.response?.status === 404) {
|
|
101
|
+
throw new Error('claim code not found or expired');
|
|
102
|
+
}
|
|
103
|
+
// 其他所有错误(HTTP 408/500、网络超时、TimeoutError 等)延迟后重试,
|
|
104
|
+
// 确保后台等待不会因瞬时故障而终止
|
|
105
|
+
await new Promise((r) => setTimeout(r, retryDelayMs));
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 已认领
|
|
110
|
+
if (data?.botId && data?.token) {
|
|
111
|
+
await writeCfg({
|
|
112
|
+
serverUrl: baseUrl,
|
|
113
|
+
botId: data.botId,
|
|
114
|
+
token: data.token,
|
|
115
|
+
boundAt: new Date().toISOString(),
|
|
116
|
+
});
|
|
117
|
+
return { botId: data.botId };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// PENDING — 延迟后继续轮询
|
|
121
|
+
if (data?.code === 'CLAIM_PENDING') {
|
|
122
|
+
await new Promise((r) => setTimeout(r, retryDelayMs));
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 其他未知状态
|
|
127
|
+
throw new Error(`unexpected claim wait response: ${JSON.stringify(data)}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
53
131
|
export async function unbindBot({ serverUrl }) {
|
|
54
132
|
const config = await readConfig();
|
|
55
133
|
if (!config?.token) {
|
|
@@ -2,6 +2,15 @@ import { spawn as nodeSpawn } from 'node:child_process';
|
|
|
2
2
|
|
|
3
3
|
const NOTIFY_TIMEOUT_MS = 10_000;
|
|
4
4
|
const KILL_DELAY_MS = 2000;
|
|
5
|
+
const IS_WIN = process.platform === 'win32';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Windows cmd.exe 下转义 JSON 字符串,使其作为单个参数传递
|
|
9
|
+
* 双引号用 \" 转义,外层包裹双引号(与 cross-spawn 策略一致)
|
|
10
|
+
*/
|
|
11
|
+
export function escapeJsonForCmd(json) {
|
|
12
|
+
return `"${json.replace(/"/g, '\\"')}"`;
|
|
13
|
+
}
|
|
5
14
|
|
|
6
15
|
/**
|
|
7
16
|
* 通过 spawn 调用 `openclaw gateway call <method> --json`
|
|
@@ -27,9 +36,16 @@ export function callGatewayMethod(method, spawnFn, opts) {
|
|
|
27
36
|
return new Promise((resolve) => {
|
|
28
37
|
let child;
|
|
29
38
|
try {
|
|
30
|
-
|
|
39
|
+
const isWin = opts?.isWin ?? IS_WIN;
|
|
40
|
+
const args = ['gateway', 'call', method, '--json'];
|
|
41
|
+
if (opts?.params) {
|
|
42
|
+
const json = JSON.stringify(opts.params);
|
|
43
|
+
// Windows 需 shell 解析 .cmd → 必须转义 JSON;非 Windows 不经 shell,直传
|
|
44
|
+
args.push('--params', isWin ? escapeJsonForCmd(json) : json);
|
|
45
|
+
}
|
|
46
|
+
child = doSpawn('openclaw', args, {
|
|
31
47
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
32
|
-
shell:
|
|
48
|
+
shell: isWin, // 仅 Windows 需 shell 以解析 npm 全局安装的 .cmd
|
|
33
49
|
});
|
|
34
50
|
} catch {
|
|
35
51
|
resolve({ ok: false, error: 'spawn_failed' });
|
|
@@ -37,6 +53,7 @@ export function callGatewayMethod(method, spawnFn, opts) {
|
|
|
37
53
|
}
|
|
38
54
|
|
|
39
55
|
let stdout = '';
|
|
56
|
+
let stderr = '';
|
|
40
57
|
let settled = false;
|
|
41
58
|
let graceTimer = null;
|
|
42
59
|
const killDelayMs = opts?.killDelayMs ?? KILL_DELAY_MS;
|
|
@@ -79,7 +96,7 @@ export function callGatewayMethod(method, spawnFn, opts) {
|
|
|
79
96
|
}
|
|
80
97
|
});
|
|
81
98
|
|
|
82
|
-
child.stderr?.on('data', () => {});
|
|
99
|
+
child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
|
|
83
100
|
|
|
84
101
|
child.on('error', () => finish({ ok: false, error: 'spawn_error' }));
|
|
85
102
|
|
|
@@ -88,7 +105,8 @@ export function callGatewayMethod(method, spawnFn, opts) {
|
|
|
88
105
|
if (code === 0 || stdout.trim()) {
|
|
89
106
|
finish(parseResult());
|
|
90
107
|
} else {
|
|
91
|
-
|
|
108
|
+
const stderrMsg = stderr.trim();
|
|
109
|
+
finish({ ok: false, error: `exit_code_${code}`, message: stderrMsg || undefined });
|
|
92
110
|
}
|
|
93
111
|
});
|
|
94
112
|
|
package/src/common/messages.js
CHANGED
|
@@ -29,3 +29,13 @@ export function gatewayNotified(action) {
|
|
|
29
29
|
export function gatewayNotifyFailed() {
|
|
30
30
|
return 'Note: could not notify the running gateway. If it is running, restart it manually.';
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
export function claimCodeCreated({ code, appUrl, expiresMinutes }) {
|
|
34
|
+
return [
|
|
35
|
+
`Claim code: ${code}`,
|
|
36
|
+
`Open this URL to complete binding: ${appUrl}`,
|
|
37
|
+
`The code expires in ${expiresMinutes} minutes.`,
|
|
38
|
+
'',
|
|
39
|
+
"If you don't have a CoClaw account yet, you can register on that page.",
|
|
40
|
+
].join('\n');
|
|
41
|
+
}
|
package/src/realtime-bridge.js
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from './device-identity.js';
|
|
12
12
|
import { getRuntime } from './runtime.js';
|
|
13
13
|
|
|
14
|
-
const DEFAULT_GATEWAY_WS_URL =
|
|
14
|
+
const DEFAULT_GATEWAY_WS_URL = `ws://127.0.0.1:${process.env.OPENCLAW_GATEWAY_PORT || '18789'}`;
|
|
15
15
|
const RECONNECT_MS = 10_000;
|
|
16
16
|
const CONNECT_TIMEOUT_MS = 10_000;
|
|
17
17
|
const SERVER_HB_PING_MS = 25_000;
|