@xyagent/cli 0.0.1 → 1.1.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 +42 -0
- package/bin/agentlink +1002 -245
- package/bin/agentlink-agent +0 -0
- package/bin/agentlink-mcp-stdio +0 -0
- package/package.json +5 -3
- package/src/core.mjs +26 -0
- package/src/tunnel_service.mjs +17 -1
- package/src-ext/bin.mjs +12 -12
- package/src-ext/commands/agent.mjs +33 -10
- package/src-ext/commands/pair.mjs +122 -23
- package/src-ext/commands/service.mjs +1 -1
- package/src-ext/core/activeRuns.mjs +26 -9
- package/src-ext/core/agentlinkToolsBootstrap.mjs +1 -1
- package/src-ext/core/autoTunnelDetector.mjs +6 -1
- package/src-ext/core/autoTunnelWiring.mjs +1 -1
- package/src-ext/core/bridgeSelfUninstall.mjs +1 -1
- package/src-ext/core/defaultWorkspace.mjs +43 -13
- package/src-ext/core/defaultWorkspaceSync.mjs +20 -0
- package/src-ext/core/installationIdentity.mjs +94 -0
- package/src-ext/core/mcpRuntimeFanout.mjs +23 -1
- package/src-ext/core/pairCodeClient.mjs +48 -8
- package/src-ext/core/pairInventory.mjs +31 -6
- package/src-ext/core/relayWorker.mjs +22 -2
- package/src-ext/core/runtimeRegistry.mjs +180 -0
- package/src-ext/core/scanDeeplink.mjs +67 -0
- package/src-ext/core/scanPairFlow.mjs +28 -0
- package/src-ext/core/scanQrRenderer.mjs +40 -0
- package/src-ext/core/scanWorkspaces.mjs +163 -23
- package/src-ext/core/unifiedDispatchHandler.mjs +12 -5
- package/src-ext/core/usageReporter.mjs +1 -2
- package/src-ext/openclaw-plugin/envelope-builder.cjs +2 -2
- package/src-ext/openclaw-plugin/todoTranslatorUtils.cjs +1 -1
- package/src-ext/runtime/_shared/bridgedSessionLedger.mjs +60 -0
- package/src-ext/runtime/_shared/claudeSchemaEvent.mjs +49 -0
- package/src-ext/runtime/_shared/headlessCliBridge.mjs +168 -0
- package/src-ext/runtime/_shared/jsonMcpConfigAdapter.mjs +70 -0
- package/src-ext/runtime/_shared/ndjsonProcess.mjs +141 -0
- package/src-ext/runtime/_shared/relayObjectToBlock.mjs +9 -11
- package/src-ext/runtime/_shared/resolveWorkspaceCwd.mjs +47 -0
- package/src-ext/runtime/_shared/slashCommandRouter.mjs +10 -0
- package/src-ext/runtime/_shared/todoTranslatorUtils.mjs +1 -1
- package/src-ext/runtime/claude/handleRequest.mjs +15 -37
- package/src-ext/runtime/claude/launcher.mjs +0 -6
- package/src-ext/runtime/claude/stdoutParser.mjs +8 -3
- package/src-ext/runtime/codebuddy/index.mjs +41 -0
- package/src-ext/runtime/codex/handleRequest.mjs +13 -35
- package/src-ext/runtime/cursor/index.mjs +46 -0
- package/src-ext/runtime/cursor/mcpConfigAdapter.mjs +15 -0
- package/src-ext/runtime/deepagents/preflight.mjs +57 -0
- package/src-ext/runtime/hermes/envSetup.mjs +22 -8
- package/src-ext/runtime/hermes/gatewayManager.mjs +239 -3
- package/src-ext/runtime/hermes/handleRequest.mjs +13 -0
- package/src-ext/runtime/hermes/httpBackend.mjs +12 -1
- package/src-ext/runtime/hermes/index.mjs +1 -1
- package/src-ext/runtime/hermes/preflight.mjs +2 -1
- package/src-ext/runtime/kimi/index.mjs +100 -0
- package/src-ext/runtime/openclaw/buildOpenclawDaemonInput.mjs +0 -6
- package/src-ext/runtime/openclaw/workspaceContext.mjs +58 -0
- package/src-ext/runtime/opencode/index.mjs +48 -0
- package/src-ext/runtime/opencode/mcpConfigAdapter.mjs +15 -0
- package/src-ext/runtime/opencode/preflight.mjs +72 -0
- package/src-ext/runtime/qwen/index.mjs +42 -0
- package/src-ext/service/serviceManager.mjs +120 -42
- package/src-shared/envelope_builder.mjs +2 -2
- package/src-ext/runtime/picoclaw/constants.mjs +0 -39
- package/src-ext/runtime/picoclaw/handleRequest.mjs +0 -289
- package/src-ext/runtime/picoclaw/index.mjs +0 -314
- package/src-ext/runtime/picoclaw/pairFlow.mjs +0 -224
- package/src-ext/runtime/picoclaw/state.mjs +0 -78
- package/src-ext/runtime/picoclaw/todoTranslator.mjs +0 -67
- package/src-ext/runtime/picoclaw/translator.mjs +0 -272
- package/src-ext/runtime/picoclaw/wsClient.mjs +0 -129
package/bin/agentlink
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
formatHostForUrl,
|
|
31
31
|
generatePairCode,
|
|
32
32
|
generateRelayGatewayId,
|
|
33
|
+
generateScanPairCode,
|
|
33
34
|
isTransientNetworkError,
|
|
34
35
|
normalizeBaseUrl,
|
|
35
36
|
normalizeChatTransportMode,
|
|
@@ -61,15 +62,30 @@ import { createLogger as _createBubLogger } from "../src-shared/logger.mjs";
|
|
|
61
62
|
import { scanWorkspacesByRuntime } from "../src-ext/core/scanWorkspaces.mjs";
|
|
62
63
|
import { createWorkspace } from "../src-ext/core/createWorkspace.mjs";
|
|
63
64
|
import { normalizeHostName } from "../src-ext/core/pairCodeClient.mjs";
|
|
65
|
+
import { getOrCreateInstallationId } from "../src-ext/core/installationIdentity.mjs";
|
|
64
66
|
import { buildPairSessionResults } from "../src-ext/core/pairInventory.mjs";
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
|
|
67
|
+
// runtime 能力分层的**单一真相**(bin 与 src-ext 共用,防两边各持一份名单漂移)。
|
|
68
|
+
// 语义分层的完整说明见该模块头注释 —— 改分层前先读它。
|
|
69
|
+
import {
|
|
70
|
+
PAIRABLE_RUNTIME_KINDS,
|
|
71
|
+
BRIDGE_CAPABLE_RUNTIME_KINDS,
|
|
72
|
+
PAIR_ONLY_RUNTIME_KINDS,
|
|
73
|
+
EXT_AGENT_RUNTIME_KINDS,
|
|
74
|
+
RUNTIME_CAPABILITIES,
|
|
75
|
+
RUNTIME_BINARY_CANDIDATES,
|
|
76
|
+
pairingCapabilitiesForRuntime,
|
|
77
|
+
} from "../src-ext/core/runtimeRegistry.mjs";
|
|
78
|
+
import { SCAN_QR_PNG_OUTPUT_PATH, clearQRImage } from "../src-ext/core/scanPairFlow.mjs";
|
|
79
|
+
import { renderQrToTerminal } from "../src-ext/core/scanQrRenderer.mjs";
|
|
80
|
+
import { buildScanDeeplink, renderScanQrPng } from "../src-ext/core/scanDeeplink.mjs";
|
|
81
|
+
|
|
82
|
+
// Keep this aligned with Desktop's embedded CLI compatibility floor.
|
|
83
|
+
const MIN_SUPPORTED_NODE_MAJOR = 18;
|
|
68
84
|
{
|
|
69
85
|
const major = Number.parseInt((process.versions?.node ?? "0").split(".")[0], 10);
|
|
70
|
-
if (major <
|
|
86
|
+
if (major < MIN_SUPPORTED_NODE_MAJOR) {
|
|
71
87
|
process.stderr.write(
|
|
72
|
-
`Error: agentlink requires Node.js >=
|
|
88
|
+
`Error: agentlink requires Node.js >= ${MIN_SUPPORTED_NODE_MAJOR}, current: ${process.version}\nPlease upgrade: https://nodejs.org/\n`,
|
|
73
89
|
);
|
|
74
90
|
process.exit(1);
|
|
75
91
|
}
|
|
@@ -100,20 +116,139 @@ const CLI_COMMAND_NAME = "agentlink";
|
|
|
100
116
|
const SERVICE_LABEL = "com.agentlink.bridge";
|
|
101
117
|
const SERVICE_UNIT_NAME = "agentlink-bridge.service";
|
|
102
118
|
const SERVICE_TASK_NAME = "Agentlink Bridge";
|
|
119
|
+
// 非默认状态根(例如 Desktop Debug 的 ~/.agentlink-debug)必须有独立服务名,
|
|
120
|
+
// 否则会覆盖正式 ~/.agentlink 的 launchd/systemd/schtasks 服务。
|
|
121
|
+
const SERVICE_SCOPE_HASH_LENGTH = 12;
|
|
103
122
|
const RUNTIME_KIND_OPENCLAW = "openclaw";
|
|
104
123
|
const RUNTIME_KIND_HERMES = "hermes";
|
|
124
|
+
const RUNTIME_KIND_CLAUDE = "claude";
|
|
125
|
+
const RUNTIME_KIND_CODEX = "codex";
|
|
126
|
+
const RUNTIME_KIND_CURSOR = "cursor";
|
|
127
|
+
// add-deepagents-opencode-runtime-enum: 已知 runtime 枚举扩到 6 个。
|
|
128
|
+
// opencode 自 devagent-m-20260807-cli-runtime-opencode-pairable 起**有了配对实现**
|
|
129
|
+
// (PATH 探测 + 握手 + 凭证落地),但仍**没有** bridge / mcpConfigAdapter;
|
|
130
|
+
// deepagents 至今只入枚举。两者的差别由下面的分层集合承载,不靠人记。
|
|
131
|
+
const RUNTIME_KIND_OPENCODE = "opencode";
|
|
132
|
+
const RUNTIME_KIND_DEEPAGENTS = "deepagents";
|
|
133
|
+
const RUNTIME_KIND_QWEN = "qwen";
|
|
134
|
+
const RUNTIME_KIND_KIMI = "kimi";
|
|
135
|
+
const RUNTIME_KIND_CODEBUDDY = "codebuddy";
|
|
136
|
+
|
|
137
|
+
// ─── runtime 种类登记表(cli-runtime-kind-registry) ─────────────────────────
|
|
138
|
+
// 本仓有 **多种语义不同** 的 runtime 集合。早期它们恰好都等于同一批 4 个字符串,
|
|
139
|
+
// 于是被当成"4 种 runtime"散落成字面量;新增 opencode/deepagents 打破了这个巧合,
|
|
140
|
+
// 所以这里把每一层显式列出来 —— 以后加 runtime 只需在对应集合里加一行,
|
|
141
|
+
// 「这一层要不要带上它」被逼成一次显式选择,不再靠人记得满仓 grep。
|
|
142
|
+
//
|
|
143
|
+
// ① 能力分层(PAIRABLE / BRIDGE_CAPABLE / PAIR_ONLY / EXT_AGENT)已上移到
|
|
144
|
+
// src-ext/core/runtimeRegistry.mjs(见文件顶部 import)—— 因为 agentlink-agent
|
|
145
|
+
// 那侧(src-ext/**)也要用同一套判定,各持一份必然漂移。
|
|
146
|
+
// · PAIRABLE = 有 pair/reset 实现(openclaw/hermes/claude/codex/opencode)
|
|
147
|
+
// · BRIDGE_CAPABLE= 有 bridge/service/MCP 实现(前四个,**不含 opencode**)
|
|
148
|
+
// · PAIR_ONLY = 配得上但跑不起来(= opencode),命令层必须显式拦停
|
|
149
|
+
// · EXT_AGENT = 凭证存 ext-<rt>-last.json(claude/codex/opencode)
|
|
150
|
+
// ② 走 `which <bin>`(win `where`)两阶段 PATH 探测的 runtime。
|
|
151
|
+
// openclaw 不在此列 —— 它以 openclaw.json 是否存在作为"已安装"信号。
|
|
152
|
+
const PATH_PROBED_RUNTIME_KINDS = Object.freeze([
|
|
153
|
+
RUNTIME_KIND_HERMES, RUNTIME_KIND_CLAUDE, RUNTIME_KIND_CODEX,
|
|
154
|
+
RUNTIME_KIND_CURSOR, RUNTIME_KIND_OPENCODE, RUNTIME_KIND_DEEPAGENTS,
|
|
155
|
+
RUNTIME_KIND_QWEN, RUNTIME_KIND_KIMI, RUNTIME_KIND_CODEBUDDY,
|
|
156
|
+
]);
|
|
157
|
+
// ③ 本机探测集 = detectInstalledRuntimes() 的返回顺序,也是 host-inventory
|
|
158
|
+
// 上报(Phase 1 runtimes / Phase 2 results)的口径。
|
|
159
|
+
// DeepAgents 以 `agentlink-deepagents` 独立 sidecar 命令作为 PATH 探测信号。
|
|
160
|
+
const HOST_DETECTED_RUNTIME_KINDS = Object.freeze([
|
|
161
|
+
RUNTIME_KIND_OPENCLAW, ...PATH_PROBED_RUNTIME_KINDS,
|
|
162
|
+
]);
|
|
163
|
+
// ④ 已知 runtime 枚举 = `--runtime` 参数解析层承认的全集(不含 `all` 别名)。
|
|
164
|
+
const KNOWN_RUNTIME_KINDS = Object.freeze([
|
|
165
|
+
...HOST_DETECTED_RUNTIME_KINDS,
|
|
166
|
+
]);
|
|
167
|
+
// ⑤ 已知但连配对都没有的 runtime。必须显式拦停:
|
|
168
|
+
// runPair/runReset/runBridge 都以 normalizeRuntimeKind(runtime, "openclaw")
|
|
169
|
+
// 开头,而它只认 openclaw/hermes、其余一律回落 fallback —— 不拦停就会把
|
|
170
|
+
// 非 openclaw 的配对静默写进 openclaw 的凭证槽位(relayGatewayId/Token),
|
|
171
|
+
// 用户还以为配的是自己选的那个 runtime。
|
|
172
|
+
const ENUM_ONLY_RUNTIME_KINDS = Object.freeze(
|
|
173
|
+
KNOWN_RUNTIME_KINDS.filter((k) => !PAIRABLE_RUNTIME_KINDS.includes(k)),
|
|
174
|
+
);
|
|
175
|
+
// scan-capable runtimes (unify-scan-pairing): openclaw/hermes read their gateway
|
|
176
|
+
// credentials locally (~/.agentlink/config.json via ensureRelayBridgeCredentials)
|
|
177
|
+
// and can host-claim immediately. claude/codex credentials are issued by the
|
|
178
|
+
// server at consume-time — no host-claim path exists for them this cycle (see
|
|
179
|
+
// spec "术语" + Non-goals; S2b covers the explicit unsupported-runtime notice).
|
|
180
|
+
// 🔴 opencode **不得**加进来:它没有本机 host-claim 凭证路径,走 pair 路径。
|
|
181
|
+
const SCAN_CAPABLE_RUNTIME_KINDS = Object.freeze([RUNTIME_KIND_OPENCLAW, RUNTIME_KIND_HERMES]);
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* enum-only runtime(已知枚举 ∖ 可配对集,当前 = deepagents)撞上任何实现路径时
|
|
185
|
+
* 显式失败退出,绝不静默回落成 openclaw。
|
|
186
|
+
*
|
|
187
|
+
* 必须在**所有命令分派之前**调用:runPair / runReset / runBridge / runService /
|
|
188
|
+
* runScan 都以 normalizeRuntimeKind(runtime, RUNTIME_KIND_OPENCLAW) 起手,而
|
|
189
|
+
* src/core.mjs 的 normalizeRuntimeKind **只认 openclaw/hermes**、其余一律回落
|
|
190
|
+
* fallback。不拦停就会把 `-r <未实现 runtime>` 静默当成 openclaw 执行(配对结果
|
|
191
|
+
* 写进 openclaw 的 relayGatewayId/Token 槽位),用户看到的却是「配对成功」
|
|
192
|
+
* = 静默错配。
|
|
193
|
+
*
|
|
194
|
+
* ⚠️ 这与「取值非法」是两类失败:取值域错误由 parseArgs 给 `must be one of ...`,
|
|
195
|
+
* 这里给的是「取值合法但本轮没有实现」。两条文案不得混用(测试有断言)。
|
|
196
|
+
*
|
|
197
|
+
* @param {string|undefined} runtime options.runtime 原值(未 normalize)
|
|
198
|
+
*/
|
|
199
|
+
function assertRuntimeOperableOrFail(runtime) {
|
|
200
|
+
if (runtime && ENUM_ONLY_RUNTIME_KINDS.includes(runtime)) {
|
|
201
|
+
fail(t("runtime_known_but_unsupported", {
|
|
202
|
+
runtime,
|
|
203
|
+
cli: CLI_COMMAND_NAME,
|
|
204
|
+
pairable: PAIRABLE_RUNTIME_KINDS.join(" / "),
|
|
205
|
+
}));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* PAIR_ONLY runtime(可配对、但本轮没有 bridge/service 实现,当前 = opencode)
|
|
211
|
+
* 撞上「需要真的把进程跑起来 / 展示运行态」的命令时显式失败退出。
|
|
212
|
+
*
|
|
213
|
+
* 🔴 这是 assertRuntimeOperableOrFail 让出的那半边防护,不是可选的收尾:
|
|
214
|
+
* opencode 进了可配对集之后就不再被上面那道拦停命中,而 runBridge / runService /
|
|
215
|
+
* runReload / runScan / runStatus 依然全都以 normalizeRuntimeKind(x, "openclaw")
|
|
216
|
+
* 起手 —— 少了这道闸,`agentlink status -r opencode` 会把 **openclaw 的**
|
|
217
|
+
* 凭证与服务态当成 opencode 的展示给用户(同一个静默错配家族的另一种形态)。
|
|
218
|
+
*
|
|
219
|
+
* pair / reset 刻意**不**调这个:它们对 opencode 是真实现(转发 agentlink-agent)。
|
|
220
|
+
*
|
|
221
|
+
* @param {string|undefined} runtime options.runtime 原值(未 normalize)
|
|
222
|
+
* @param {string} command 触发的命令名,仅用于文案
|
|
223
|
+
*/
|
|
224
|
+
function assertRuntimeBridgeSupportedOrFail(runtime, command) {
|
|
225
|
+
if (runtime && PAIR_ONLY_RUNTIME_KINDS.includes(runtime)) {
|
|
226
|
+
fail(t("runtime_paired_but_no_bridge", {
|
|
227
|
+
runtime,
|
|
228
|
+
command,
|
|
229
|
+
cli: CLI_COMMAND_NAME,
|
|
230
|
+
bridgeable: BRIDGE_CAPABLE_RUNTIME_KINDS.join(" / "),
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
105
234
|
|
|
106
235
|
function serviceNames(runtimeKind) {
|
|
107
236
|
const kind = normalizeRuntimeKind(runtimeKind, RUNTIME_KIND_OPENCLAW);
|
|
237
|
+
const scope = serviceScopeSuffix();
|
|
108
238
|
if (kind === RUNTIME_KIND_OPENCLAW) {
|
|
109
|
-
return {
|
|
239
|
+
return {
|
|
240
|
+
label: `${SERVICE_LABEL}${scope}`,
|
|
241
|
+
unit: scope ? `agentlink-bridge${scope}.service` : SERVICE_UNIT_NAME,
|
|
242
|
+
task: `${SERVICE_TASK_NAME}${scope}`,
|
|
243
|
+
logPrefix: `bridge${scope}`,
|
|
244
|
+
};
|
|
110
245
|
}
|
|
111
246
|
const capitalized = kind.charAt(0).toUpperCase() + kind.slice(1);
|
|
112
247
|
return {
|
|
113
|
-
label: `${SERVICE_LABEL}-${kind}`,
|
|
114
|
-
unit: `agentlink-bridge-${kind}.service`,
|
|
115
|
-
task: `${SERVICE_TASK_NAME} ${capitalized}`,
|
|
116
|
-
logPrefix: `bridge-${kind}`,
|
|
248
|
+
label: `${SERVICE_LABEL}-${kind}${scope}`,
|
|
249
|
+
unit: `agentlink-bridge-${kind}${scope}.service`,
|
|
250
|
+
task: `${SERVICE_TASK_NAME} ${capitalized}${scope}`,
|
|
251
|
+
logPrefix: `bridge-${kind}${scope}`,
|
|
117
252
|
};
|
|
118
253
|
}
|
|
119
254
|
// Mirror the WS bridge's operator scopes for local HTTP proxy calls as well.
|
|
@@ -357,7 +492,21 @@ const CLI_I18N = {
|
|
|
357
492
|
pair_all_detecting_runtimes: "\n检测本机已安装的 AI runtime ...",
|
|
358
493
|
pair_all_runtime_installed_extra: "({detail})",
|
|
359
494
|
pair_all_runtime_not_installed: " · {kind} 未安装,跳过{hint}",
|
|
360
|
-
|
|
495
|
+
// {kinds} 由 HOST_DETECTED_RUNTIME_KINDS 渲染 —— 文案本身不再持有 runtime 名,
|
|
496
|
+
// 加 runtime 时中英两侧自动跟随,从结构上消灭「两份枚举串漂移」。
|
|
497
|
+
pair_all_no_runtimes: "本机未检测到任何可用 runtime({kinds})。",
|
|
498
|
+
// 已知 runtime 枚举里、但本轮还没有 pair/bridge 实现的 runtime。
|
|
499
|
+
runtime_known_but_unsupported:
|
|
500
|
+
"runtime `{runtime}` 已被识别,但本版 {cli} 还没有它的配对/桥接实现,暂不支持该命令。\n"
|
|
501
|
+
+ "当前可操作的 runtime:{pairable}。",
|
|
502
|
+
// 可配对、但本轮没有 bridge/service 实现的 runtime(PAIR_ONLY)。
|
|
503
|
+
// 与上一条是**两类**失败:这条的配对是通的,只是跑不起来。
|
|
504
|
+
runtime_paired_but_no_bridge:
|
|
505
|
+
"runtime `{runtime}` 本版 {cli} 只支持配对(pair / reset),还没有 bridge 实现,`{command}` 暂不可用。\n"
|
|
506
|
+
+ "支持 bridge/service 的 runtime:{bridgeable}。",
|
|
507
|
+
pair_all_runtime_pair_unsupported: " · {kind} 已安装但本版暂无配对实现,跳过配对(已如实上报本机清单)",
|
|
508
|
+
pair_all_summary_pair_unsupported: "ℹ️ 已装但暂无配对实现 ({count}): {kinds}",
|
|
509
|
+
pair_all_summary_nothing_pairable: "\n本机没有可配对的 runtime;已把探测结果如实上报给 App。",
|
|
361
510
|
pair_all_host_inventory_report_failed: " ⚠️ host-inventory 上报失败 (HTTP {status}),继续配对 ...",
|
|
362
511
|
pair_all_host_inventory_report_error: " ⚠️ host-inventory 上报异常 ({error}),继续配对 ...",
|
|
363
512
|
pair_all_host_inventory_prepare_failed: " ⚠️ host-inventory 准备失败 ({error})",
|
|
@@ -385,6 +534,8 @@ const CLI_I18N = {
|
|
|
385
534
|
reset_all_detecting_runtimes: "\n检测本机已安装的 AI runtime(逐个解绑)...",
|
|
386
535
|
reset_all_no_runtimes: "本机未检测到任何已安装 runtime,也无可解绑的本地残留。",
|
|
387
536
|
reset_all_runtime_state_only: " ⚠ {kind} 未安装,但检测到本地绑定残留,将清理",
|
|
537
|
+
// 已装、但本轮没有配对实现 → 不可能存在可解绑的绑定,不进解绑目标集。
|
|
538
|
+
reset_all_runtime_no_binding: " · {kind} 已安装但本版暂无配对实现,无可解绑绑定,跳过",
|
|
388
539
|
reset_all_resetting_runtime: "\n→ 正在解绑 {kind} ...",
|
|
389
540
|
reset_all_runtime_reset_failed: " ⚠️ {kind} 解绑失败 (退出码 {status}),继续下一个 ...",
|
|
390
541
|
reset_all_summary_header: "\n──────────────── 解绑结果 ────────────────",
|
|
@@ -602,7 +753,21 @@ const CLI_I18N = {
|
|
|
602
753
|
pair_all_detecting_runtimes: "\nDetecting installed AI runtimes on this host ...",
|
|
603
754
|
pair_all_runtime_installed_extra: " ({detail})",
|
|
604
755
|
pair_all_runtime_not_installed: " · {kind} not installed, skipping{hint}",
|
|
605
|
-
|
|
756
|
+
// {kinds} rendered from HOST_DETECTED_RUNTIME_KINDS — see the zh table note:
|
|
757
|
+
// the text no longer holds runtime names, so both locales can never drift.
|
|
758
|
+
pair_all_no_runtimes: "No usable runtime detected on this host ({kinds}).",
|
|
759
|
+
// Pairable but no bridge/service implementation yet (PAIR_ONLY tier).
|
|
760
|
+
// A *different* failure class from runtime_known_but_unsupported below:
|
|
761
|
+
// pairing does work here, the runtime just cannot be run yet.
|
|
762
|
+
runtime_paired_but_no_bridge:
|
|
763
|
+
"runtime `{runtime}` only supports pairing (pair / reset) in this {cli} build; there is no bridge implementation yet, so `{command}` is unavailable.\n"
|
|
764
|
+
+ "Runtimes with bridge/service support: {bridgeable}.",
|
|
765
|
+
runtime_known_but_unsupported:
|
|
766
|
+
"runtime `{runtime}` is recognized, but this {cli} build has no pairing/bridge implementation for it yet.\n"
|
|
767
|
+
+ "Currently operable runtimes: {pairable}.",
|
|
768
|
+
pair_all_runtime_pair_unsupported: " · {kind} installed but no pairing implementation in this build, skipping pairing (still reported in the host inventory)",
|
|
769
|
+
pair_all_summary_pair_unsupported: "ℹ️ Installed, pairing not implemented yet ({count}): {kinds}",
|
|
770
|
+
pair_all_summary_nothing_pairable: "\nNo pairable runtime on this host; the detection result was reported to the App as-is.",
|
|
606
771
|
pair_all_host_inventory_report_failed: " ⚠️ host-inventory report failed (HTTP {status}), continuing pairing ...",
|
|
607
772
|
pair_all_host_inventory_report_error: " ⚠️ host-inventory report error ({error}), continuing pairing ...",
|
|
608
773
|
pair_all_host_inventory_prepare_failed: " ⚠️ host-inventory prepare failed ({error})",
|
|
@@ -630,6 +795,7 @@ const CLI_I18N = {
|
|
|
630
795
|
reset_all_detecting_runtimes: "\nDetecting installed AI runtimes on this host (unbind each) ...",
|
|
631
796
|
reset_all_no_runtimes: "No installed runtime and no local binding trace on this host; nothing to unbind.",
|
|
632
797
|
reset_all_runtime_state_only: " ⚠ {kind} not installed, but a local binding trace was found — will clean it up",
|
|
798
|
+
reset_all_runtime_no_binding: " · {kind} installed but pairing is not implemented in this build; no binding to unbind, skipping",
|
|
633
799
|
reset_all_resetting_runtime: "\n→ Resetting {kind} ...",
|
|
634
800
|
reset_all_runtime_reset_failed: " ⚠️ {kind} reset failed (exit {status}), continuing ...",
|
|
635
801
|
reset_all_summary_header: "\n──────────────── Reset Results ────────────────",
|
|
@@ -771,11 +937,15 @@ function printHelp() {
|
|
|
771
937
|
"",
|
|
772
938
|
t("help_usage"),
|
|
773
939
|
` ${CLI_COMMAND_NAME} setup [--relay <url>] [--public-base <url>] [--mode <relay>] [--account <id>] [--restart|--no-restart] [--strict-plugin] [--json]`,
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
` ${CLI_COMMAND_NAME}
|
|
940
|
+
// pair / reset 的取值域 = 可配对集(含 opencode),由登记表渲染,
|
|
941
|
+
// 不再手抄第二份枚举串(加 runtime 时用法行自动跟随)。
|
|
942
|
+
` ${CLI_COMMAND_NAME} pair <4-digit|6-digit|8-alnum> [-r|--runtime <${PAIRABLE_RUNTIME_KINDS.join("|")}|all>] [--mode <relay>] [--strict-plugin] # default: all`,
|
|
943
|
+
` ${CLI_COMMAND_NAME} reset [-r|--runtime <${PAIRABLE_RUNTIME_KINDS.join("|")}|all>] [--relay <url>] [--gateway <url>] [--json] # default: all`,
|
|
944
|
+
// bridge / reload 的取值域 = 可桥接集(**不含** opencode:本轮它只能配对)。
|
|
945
|
+
` ${CLI_COMMAND_NAME} bridge [--relay <url>] [--gateway <url>] [-r|--runtime <${BRIDGE_CAPABLE_RUNTIME_KINDS.join("|")}>] [--json]`,
|
|
946
|
+
` ${CLI_COMMAND_NAME} scan [-r|--runtime <openclaw|hermes>] [--relay <url>] [--json] [--no-serve] [--qr-path <path>] [--strict-plugin] # 扫码配对:出二维码 → App 扫码 → 本机 scan-capable runtime(openclaw/hermes)收发;claude/codex 不支持,请改用 pair`,
|
|
777
947
|
` ${CLI_COMMAND_NAME} service <status|install|stop|uninstall|restart> [--relay <url>] [--gateway <url>] [--runtime <openclaw|hermes>] [--json]`,
|
|
778
|
-
` ${CLI_COMMAND_NAME} reload [-r|--runtime
|
|
948
|
+
` ${CLI_COMMAND_NAME} reload [-r|--runtime <${BRIDGE_CAPABLE_RUNTIME_KINDS.join("|")}>] [--json]`,
|
|
779
949
|
` ${CLI_COMMAND_NAME} pair-url [<4-digit|6-digit|8-alnum>] [--account <id>] [--code <4-digit|6-digit|8-alnum>] [--json]`,
|
|
780
950
|
` ${CLI_COMMAND_NAME} tunnel <on|off> <port> [--relay <url>] [--ws|--tcp] [--json]`,
|
|
781
951
|
` ${CLI_COMMAND_NAME} tunnel ls [--json]`,
|
|
@@ -838,6 +1008,16 @@ function parseArgs(argv) {
|
|
|
838
1008
|
doctorAction: undefined,
|
|
839
1009
|
mcpSubcommand: undefined,
|
|
840
1010
|
mcpRuntime: undefined,
|
|
1011
|
+
noServe: false,
|
|
1012
|
+
qrPath: undefined,
|
|
1013
|
+
// Debug Desktop passes an isolated state root here. Keep it internal so normal
|
|
1014
|
+
// users do not accidentally split their existing runtime pairing state.
|
|
1015
|
+
agentlinkHome: undefined,
|
|
1016
|
+
// Internal plumbing flag — not advertised in --help. Set by `runScan`'s
|
|
1017
|
+
// per-runtime host-claim child spawn (`--pair-mode scan`), never expected
|
|
1018
|
+
// from a human-typed `agentlink pair` invocation. See runPair()'s
|
|
1019
|
+
// pairMode branch (unify-scan-pairing).
|
|
1020
|
+
pairMode: "",
|
|
841
1021
|
};
|
|
842
1022
|
|
|
843
1023
|
while (args.length > 0) {
|
|
@@ -893,6 +1073,39 @@ function parseArgs(argv) {
|
|
|
893
1073
|
options.fix = true;
|
|
894
1074
|
continue;
|
|
895
1075
|
}
|
|
1076
|
+
if (token === "--no-serve") {
|
|
1077
|
+
// `agentlink scan` 专属:只配对不起 worker(spec S5)。与已有的
|
|
1078
|
+
// --no-serve-relay(options.serveRelay,pair 命令的"配完对不装后台服务"
|
|
1079
|
+
// 语义)刻意分开——scan 这里是"不启动前台 bridge worker",不是装/不装
|
|
1080
|
+
// service,两者不通用。
|
|
1081
|
+
options.noServe = true;
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
if (token === "--qr-path") {
|
|
1085
|
+
const value = args.shift();
|
|
1086
|
+
if (!value || value.startsWith("--")) fail(t("err_missing_value", { flag: "--qr-path" }));
|
|
1087
|
+
options.qrPath = value.trim();
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
if (token === "--pair-mode") {
|
|
1091
|
+
// Internal — see options.pairMode default comment above.
|
|
1092
|
+
const value = args.shift();
|
|
1093
|
+
if (!value || value.startsWith("--")) fail(t("err_missing_value", { flag: "--pair-mode" }));
|
|
1094
|
+
const normalized = value.trim().toLowerCase();
|
|
1095
|
+
if (normalized !== "" && normalized !== "scan") {
|
|
1096
|
+
fail(`--pair-mode must be "scan" (got ${value})`);
|
|
1097
|
+
}
|
|
1098
|
+
options.pairMode = normalized;
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
if (token === "--agentlink-home") {
|
|
1103
|
+
const value = args.shift();
|
|
1104
|
+
if (!value || value.startsWith("--")) fail(t("err_missing_value", { flag: "--agentlink-home" }));
|
|
1105
|
+
if (!path.isAbsolute(value.trim())) fail("--agentlink-home must be an absolute path");
|
|
1106
|
+
options.agentlinkHome = value.trim();
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
896
1109
|
|
|
897
1110
|
if (token === "--relay") {
|
|
898
1111
|
const value = args.shift();
|
|
@@ -948,18 +1161,25 @@ function parseArgs(argv) {
|
|
|
948
1161
|
const value = args.shift();
|
|
949
1162
|
if (!value || value.startsWith("--")) fail(`missing value for ${token}`);
|
|
950
1163
|
const raw = value.trim().toLowerCase();
|
|
951
|
-
//
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
|
|
1164
|
+
// 解析层只做「取值在不在已知枚举里」这一件事(KNOWN_RUNTIME_KINDS,见常量区):
|
|
1165
|
+
// · openclaw / hermes → bin 内 native 路径
|
|
1166
|
+
// · claude / codex / opencode → main() 转发 agentlink-agent(shim)。
|
|
1167
|
+
// opencode 自 opencode-pairable 起进了可配对集:pair / reset / uninstall
|
|
1168
|
+
// 是真实现,assertRuntimeOperableOrFail() 对它**不再触发**;
|
|
1169
|
+
// bridge / service / reload / scan / status 另由
|
|
1170
|
+
// assertRuntimeBridgeSupportedOrFail() 显式拦停(两道闸分工见其文档注释)
|
|
1171
|
+
// · deepagents → 本轮只有枚举,真执行由 main() 的
|
|
1172
|
+
// assertRuntimeOperableOrFail() 给显式「暂不支持」错误
|
|
1173
|
+
// · all → 仅 pair / reset 支持,逐 runtime 串行
|
|
1174
|
+
// 这里刻意 **不** 再走 normalizeRuntimeKind —— 它只认 openclaw/hermes、其余
|
|
1175
|
+
// 静默回落 fallback,会把新 runtime 悄悄变成 openclaw(静默错配)。
|
|
1176
|
+
// raw 已 trim + toLowerCase,与 normalizeRuntimeKind 的归一化口径一致。
|
|
1177
|
+
if (raw === "all" || KNOWN_RUNTIME_KINDS.includes(raw)) {
|
|
955
1178
|
options.runtime = raw;
|
|
956
1179
|
continue;
|
|
957
1180
|
}
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
fail(`${token} must be openclaw / hermes / claude / codex / all`);
|
|
961
|
-
}
|
|
962
|
-
options.runtime = normalizedRuntime;
|
|
1181
|
+
// 取值域文案由常量渲染 —— 加 runtime 时无需再改这行(防两处真相漂移)。
|
|
1182
|
+
fail(`${token} must be one of ${KNOWN_RUNTIME_KINDS.join(" / ")} / all`);
|
|
963
1183
|
continue;
|
|
964
1184
|
}
|
|
965
1185
|
if (token === "--hermes-python") {
|
|
@@ -1173,8 +1393,8 @@ function resolveAgentlinkStatePath() {
|
|
|
1173
1393
|
// reset 用:某 runtime 是否有「可解绑的本地痕迹」(已配对的凭证 / state 文件)。
|
|
1174
1394
|
// 与「二进制是否安装」解耦 —— 卸了 CLI 但 binding 还在的 runtime 仍须能被 reset
|
|
1175
1395
|
// 清掉(否则 server 端绑定 + 本地 state 永久泄漏)。读 reset 实际操作的同一批文件:
|
|
1176
|
-
// openclaw / hermes
|
|
1177
|
-
// claude / codex
|
|
1396
|
+
// openclaw / hermes → ~/.agentlink/config.json(relayGatewayId / hermesRelayGatewayId)
|
|
1397
|
+
// claude / codex / opencode → ~/.agentlink/ext-<rt>-last.json(gw_id)
|
|
1178
1398
|
function runtimeHasLocalState(kind) {
|
|
1179
1399
|
// 1) 残留的 bridge service 也算「有痕迹」—— 凭证 id 已被上一次部分 reset 清掉、
|
|
1180
1400
|
// 但 service 还在的孤儿,必须仍纳入 reset 清理,否则后台服务空转、卸不掉。
|
|
@@ -1182,9 +1402,19 @@ function runtimeHasLocalState(kind) {
|
|
|
1182
1402
|
// linux ~/.config/systemd/user),**不**走 `launchctl print`/`systemctl --user`
|
|
1183
1403
|
// 那种用户级全局探测 —— 否则 reset 的扇出决策会依赖跨 HOME 的全局服务状态,
|
|
1184
1404
|
// 破坏隔离、并可能误删别的上下文的服务。(win 用 schtasks 无文件,跳过该信号。)
|
|
1405
|
+
//
|
|
1406
|
+
// 🔴 只对 **native 服务** (openclaw/hermes)做这个探测:resolveMacLaunchAgentPath /
|
|
1407
|
+
// resolveLinuxUserUnitPath 都经 serviceNames() → normalizeRuntimeKind(x,"openclaw"),
|
|
1408
|
+
// 而后者只认 openclaw/hermes —— 对 ext-agent runtime(claude/codex/opencode)会
|
|
1409
|
+
// **别名到 openclaw 自己的 plist/unit**,于是"装了 openclaw 的 bridge 服务"会被
|
|
1410
|
+
// 误判成"claude/opencode 有本地痕迹"。ext-agent 的服务单元命名另在
|
|
1411
|
+
// src-ext/service/serviceManager.mjs(com.agentlink.agent.<rt>),不由这里定位;
|
|
1412
|
+
// 它们的痕迹判定完全走下面的 ext-<rt>-last.json 凭证文件,够用且不会串台。
|
|
1185
1413
|
try {
|
|
1186
|
-
if (
|
|
1187
|
-
|
|
1414
|
+
if (!EXT_AGENT_RUNTIME_KINDS.includes(kind)) {
|
|
1415
|
+
if (process.platform === "darwin" && fs.existsSync(resolveMacLaunchAgentPath(kind))) return true;
|
|
1416
|
+
if (process.platform === "linux" && fs.existsSync(resolveLinuxUserUnitPath(kind))) return true;
|
|
1417
|
+
}
|
|
1188
1418
|
} catch { /* 文件探测失败不影响下面的凭证判定 */ }
|
|
1189
1419
|
try {
|
|
1190
1420
|
if (kind === RUNTIME_KIND_OPENCLAW || kind === RUNTIME_KIND_HERMES) {
|
|
@@ -1193,7 +1423,7 @@ function runtimeHasLocalState(kind) {
|
|
|
1193
1423
|
const idKey = kind === RUNTIME_KIND_OPENCLAW ? "relayGatewayId" : "hermesRelayGatewayId";
|
|
1194
1424
|
return Boolean(String(cfg[idKey] ?? "").trim());
|
|
1195
1425
|
}
|
|
1196
|
-
if (kind
|
|
1426
|
+
if (EXT_AGENT_RUNTIME_KINDS.includes(kind)) {
|
|
1197
1427
|
// ext state(ext-<rt>-last.json)由 pairCodeClient.stateDir() 定位,现已统一
|
|
1198
1428
|
// 优先 AGENTLINK_HOME(见该文件注释),与此处 resolveAgentlinkHome() 一致 ——
|
|
1199
1429
|
// 探测与实际清理读写同一份文件,无 AGENTLINK_HOME 分叉。
|
|
@@ -1206,7 +1436,15 @@ function runtimeHasLocalState(kind) {
|
|
|
1206
1436
|
}
|
|
1207
1437
|
|
|
1208
1438
|
function resolveMatecliRuntimeDir() {
|
|
1209
|
-
return
|
|
1439
|
+
return resolveAgentlinkHome();
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
function serviceScopeSuffix() {
|
|
1443
|
+
const currentHome = path.resolve(resolveAgentlinkHome());
|
|
1444
|
+
const defaultHome = path.resolve(path.join(os.homedir(), ".agentlink"));
|
|
1445
|
+
if (currentHome === defaultHome) return "";
|
|
1446
|
+
const digest = createHash("sha256").update(currentHome).digest("hex");
|
|
1447
|
+
return `-${digest.slice(0, SERVICE_SCOPE_HASH_LENGTH)}`;
|
|
1210
1448
|
}
|
|
1211
1449
|
|
|
1212
1450
|
function resolveBridgeLogsDir() {
|
|
@@ -2647,6 +2885,34 @@ function ensureRelayBridgeCredentials({
|
|
|
2647
2885
|
}
|
|
2648
2886
|
}
|
|
2649
2887
|
|
|
2888
|
+
// 🔴 把 relay 地址一起落盘。缺了它,openclaw / hermes 的常驻服务重启后**静默连到
|
|
2889
|
+
// 生产** —— 这不是理论问题,2026-08-13 在真机上坐实过:
|
|
2890
|
+
//
|
|
2891
|
+
// · 桌面端配对时确实传了 `--relay <测试环境>`(pairing.rs pair_args)
|
|
2892
|
+
// · 但 launchd plist 的 ProgramArguments 只有 [node, agentlink-agent, <rt>],
|
|
2893
|
+
// 没有 --relay(plutil 解析确认)
|
|
2894
|
+
// · config.json 里只有 gateway 凭据、**没有任何 URL**
|
|
2895
|
+
// → pairCodeClient.readPairStateFromConfig 读 parsed.relayUrl 读到 undefined
|
|
2896
|
+
// → resolveRelayUrl 回落到 DEFAULT_RELAY_URL = https://go-relay.xyagent.com(生产)
|
|
2897
|
+
// → `agentlink doctor` 实测:relay.reachable = https://go-relay.xyagent.com
|
|
2898
|
+
//
|
|
2899
|
+
// 后果对测试用户最恶劣:配对当时一切正常,**重启电脑后全部悄悄连到生产**,表现是
|
|
2900
|
+
// 「配对好了但会话和消息都不见了」——极难排查。
|
|
2901
|
+
//
|
|
2902
|
+
// 读侧(readPairStateFromConfig)早就在读 `parsed.relayUrl` 了,只有写侧一直没写;
|
|
2903
|
+
// 本函数连 relayUrl 参数都收了、没有它还直接早退,却从不落盘。属纯遗漏。
|
|
2904
|
+
//
|
|
2905
|
+
// 用单个 `relayUrl` 而非 runtime 分槽(hermesRelayUrl/relayUrl):读侧对 hermes 与
|
|
2906
|
+
// openclaw 读的是**同一个 key**,写成两个反而对不上。四端本来同属一台机器、同一个
|
|
2907
|
+
// 桌面端,relay 地址天然相同;真出现要分开的需求时再连读侧一起改。
|
|
2908
|
+
const normalizedRelayUrl = String(relayUrl).trim();
|
|
2909
|
+
if (normalizedRelayUrl && section.relayUrl !== normalizedRelayUrl) {
|
|
2910
|
+
section.relayUrl = normalizedRelayUrl;
|
|
2911
|
+
// 记进 changes 才会触发下面的落盘 —— 只有 URL 变化(凭据都已存在)时,
|
|
2912
|
+
// changes 为空就会静默不写,这一步等于没做。
|
|
2913
|
+
changes.push(`state.relayUrl=${normalizedRelayUrl}`);
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2650
2916
|
if (changes.length > 0) {
|
|
2651
2917
|
writeJsonFile(resolveAgentlinkStatePath(), section);
|
|
2652
2918
|
}
|
|
@@ -3395,6 +3661,21 @@ function writeBridgeLaunchScript(installConfig) {
|
|
|
3395
3661
|
const appData =
|
|
3396
3662
|
(process.env.APPDATA ?? "").trim() ||
|
|
3397
3663
|
path.join(capturedHome, "AppData", "Roaming");
|
|
3664
|
+
// ScheduledTask 的 PATH 是注册表值,看不到安装时进程里的增量(尤其是桌面端
|
|
3665
|
+
// 私有 Node 的目录)—— 与 POSIX 版 `export PATH=<defaultPath>:$PATH` 同口径,
|
|
3666
|
+
// 把 node/openclaw 兄弟目录 + 常见安装位快照进脚本,`%` 转义防意外展开。
|
|
3667
|
+
// 兄弟目录解析显式用 win32 语义、分隔符显式 `;`(生产 win 上与默认等价;
|
|
3668
|
+
// 保证非 win 环境下生成的脚本形态也正确,便于测试与排查)。
|
|
3669
|
+
const defaultPath = buildPathString(
|
|
3670
|
+
[
|
|
3671
|
+
...resolveSiblingBinDirs(installConfig.nodePath, path.win32),
|
|
3672
|
+
...resolveSiblingBinDirs(installConfig.openclawPath, path.win32),
|
|
3673
|
+
path.join(capturedHome, ".volta", "bin"),
|
|
3674
|
+
path.join(appData, "npm"),
|
|
3675
|
+
"C:\\Program Files\\nodejs",
|
|
3676
|
+
],
|
|
3677
|
+
";",
|
|
3678
|
+
);
|
|
3398
3679
|
const script = [
|
|
3399
3680
|
"@echo off",
|
|
3400
3681
|
`set "HOME=${capturedHome}"`,
|
|
@@ -3402,6 +3683,7 @@ function writeBridgeLaunchScript(installConfig) {
|
|
|
3402
3683
|
`set "APPDATA=${appData}"`,
|
|
3403
3684
|
`set "OPENCLAW_HOME=${capturedOpenClawHome}"`,
|
|
3404
3685
|
`set "AGENTLINK_HOME=${capturedAgentlinkHome}"`,
|
|
3686
|
+
`set "PATH=${defaultPath.replaceAll("%", "%%")};%PATH%"`,
|
|
3405
3687
|
`"${installConfig.nodePath}" ${nodeArg}`,
|
|
3406
3688
|
"",
|
|
3407
3689
|
].join("\r\n");
|
|
@@ -4199,8 +4481,17 @@ async function publishPairToRelay({
|
|
|
4199
4481
|
relayClientToken,
|
|
4200
4482
|
relayGatewayToken,
|
|
4201
4483
|
accountId,
|
|
4484
|
+
// unify-scan-pairing: "" (default, pair code path) | "scan". Threaded into
|
|
4485
|
+
// the request body so the server can allow an empty clientUserId for the
|
|
4486
|
+
// scan host-claim branch (spec "服务端支持 scan 模式的 pending 归属").
|
|
4487
|
+
// Never persisted server-side — request-only semantics (spec L81).
|
|
4488
|
+
pairMode = "",
|
|
4489
|
+
// scan pending rows SHALL expire within 5 minutes (spec 安全信封 §2); the
|
|
4490
|
+
// pair-code path keeps its existing 600s default.
|
|
4491
|
+
ttlSeconds = 600,
|
|
4202
4492
|
}) {
|
|
4203
4493
|
const url = `${relayUrl}/v1/pair-sessions`;
|
|
4494
|
+
const normalizedRuntime = normalizeRuntimeKind(runtimeKind, RUNTIME_KIND_OPENCLAW);
|
|
4204
4495
|
const headers = {
|
|
4205
4496
|
"content-type": "application/json",
|
|
4206
4497
|
...relayAuthHeaders(),
|
|
@@ -4210,7 +4501,7 @@ async function publishPairToRelay({
|
|
|
4210
4501
|
headers,
|
|
4211
4502
|
body: JSON.stringify({
|
|
4212
4503
|
code,
|
|
4213
|
-
runtimeKind:
|
|
4504
|
+
runtimeKind: normalizedRuntime,
|
|
4214
4505
|
transportMode: normalizeChatTransportMode(transportMode, "relay"),
|
|
4215
4506
|
...(clientUserId ? { clientUserId } : {}),
|
|
4216
4507
|
bindUrl,
|
|
@@ -4220,9 +4511,14 @@ async function publishPairToRelay({
|
|
|
4220
4511
|
...(relayClientToken ? { relayClientToken } : {}),
|
|
4221
4512
|
...(relayGatewayToken ? { relayGatewayToken } : {}),
|
|
4222
4513
|
accountId,
|
|
4223
|
-
ttlSeconds
|
|
4514
|
+
ttlSeconds,
|
|
4224
4515
|
source: "agentlink-cli",
|
|
4225
4516
|
hostName: normalizeHostName(os.hostname()),
|
|
4517
|
+
// /v1/pair-sessions 是 OpenClaw/Hermes 的旧配对入口;必须同步 v2
|
|
4518
|
+
// 工作区身份,避免 relay 把已配对 gateway 当作 legacy bridge。
|
|
4519
|
+
installation_id: getOrCreateInstallationId(),
|
|
4520
|
+
capabilities: pairingCapabilitiesForRuntime(normalizedRuntime),
|
|
4521
|
+
...(pairMode ? { pairMode } : {}),
|
|
4226
4522
|
}),
|
|
4227
4523
|
});
|
|
4228
4524
|
const text = await response.text();
|
|
@@ -5444,11 +5740,12 @@ async function handleUnifiedRunDispatch({
|
|
|
5444
5740
|
gatewayAuthToken,
|
|
5445
5741
|
bridgeToken,
|
|
5446
5742
|
defaultModel,
|
|
5743
|
+
openclawConfig,
|
|
5447
5744
|
request,
|
|
5448
5745
|
json: jsonMode,
|
|
5449
5746
|
}) {
|
|
5450
5747
|
const inner = request?.request ?? {};
|
|
5451
|
-
const { thread_id, run_id, input, blocks: rawBlocks = [] } = inner;
|
|
5748
|
+
const { thread_id, run_id, input, blocks: rawBlocks = [], workspace_path } = inner;
|
|
5452
5749
|
|
|
5453
5750
|
if (!thread_id || !run_id) {
|
|
5454
5751
|
if (!jsonMode) {
|
|
@@ -5555,6 +5852,28 @@ async function handleUnifiedRunDispatch({
|
|
|
5555
5852
|
}
|
|
5556
5853
|
}
|
|
5557
5854
|
|
|
5855
|
+
// OpenClaw's strict /v1/responses schema has no request-level cwd. Select
|
|
5856
|
+
// the workspace through agent_id and verify that agent's configured
|
|
5857
|
+
// workspace before dispatch; a mismatch must never fall back to main.
|
|
5858
|
+
const agentId = String(inner.agent_id || request.agentId || "main").trim() || "main";
|
|
5859
|
+
if (workspace_path) {
|
|
5860
|
+
try {
|
|
5861
|
+
const contextUrl = new URL("../src-ext/runtime/openclaw/workspaceContext.mjs", import.meta.url).href;
|
|
5862
|
+
const { assertOpenClawWorkspaceContext } = await import(contextUrl);
|
|
5863
|
+
assertOpenClawWorkspaceContext({ config: openclawConfig, agentId, workspacePath: workspace_path });
|
|
5864
|
+
} catch (workspaceError) {
|
|
5865
|
+
try {
|
|
5866
|
+
await postEnvelopes([_buildRunFailed(
|
|
5867
|
+
run_id,
|
|
5868
|
+
"workspace_context_unavailable",
|
|
5869
|
+
String(workspaceError?.message || "openclaw workspace context is unavailable"),
|
|
5870
|
+
)]);
|
|
5871
|
+
} catch { /* best-effort */ }
|
|
5872
|
+
cleanupActiveRunContext(thread_id, run_id);
|
|
5873
|
+
return;
|
|
5874
|
+
}
|
|
5875
|
+
}
|
|
5876
|
+
|
|
5558
5877
|
// bridge-auto-tunnel: chat-side `/tunnel ls` / `/tunnel off <port>` 拦截。
|
|
5559
5878
|
// 命中 → 直接 reply bridge-origin text block + run.completed,跳过本地
|
|
5560
5879
|
// OpenClaw 网关派发。失败 / 未命中 → 兜底 pass-through 继续走 runtime。
|
|
@@ -5645,10 +5964,13 @@ async function handleUnifiedRunDispatch({
|
|
|
5645
5964
|
// 每轮都是全新 session → 上下文断裂(实测 "go on" 不知道前文是什么)。
|
|
5646
5965
|
// 用 thread_id 后,同一 agentlink thread 内的所有 turn 落到 openclaw 的
|
|
5647
5966
|
// 同一个 session bucket,上下文自然续接。
|
|
5967
|
+
// unify-scan-pairing 全量移除了上一代扫码硬件的命名空间归一 hack:扫码路径
|
|
5968
|
+
// 现在与 pair code 共用同一套真实 runtime binding(openclaw/hermes),不再有
|
|
5969
|
+
// App 以合成命名空间 agent 绑定的场景,agentId 直接原样使用。
|
|
5648
5970
|
const headers = {
|
|
5649
5971
|
"content-type": "application/json",
|
|
5650
5972
|
"accept": "text/event-stream",
|
|
5651
|
-
"x-openclaw-agent-id":
|
|
5973
|
+
"x-openclaw-agent-id": agentId,
|
|
5652
5974
|
"x-openclaw-session-key": thread_id,
|
|
5653
5975
|
"x-openclaw-scopes": DEFAULT_GATEWAY_SCOPES,
|
|
5654
5976
|
};
|
|
@@ -5664,7 +5986,7 @@ async function handleUnifiedRunDispatch({
|
|
|
5664
5986
|
// (which lives in agents.defaults.model.primary). The accepted forms are
|
|
5665
5987
|
// `openclaw` (default agent) or `openclaw/<agentId>`. The agentId comes from
|
|
5666
5988
|
// the dispatch payload's agent_id (or "main" if absent).
|
|
5667
|
-
|
|
5989
|
+
// (agentId 已在上方解析,缺省回退 "main"。)
|
|
5668
5990
|
if (!jsonMode) {
|
|
5669
5991
|
const blockSummary = blocks.map((b) => {
|
|
5670
5992
|
const kind = b?.source?.kind ? `/${b.source.kind}` : "";
|
|
@@ -6340,6 +6662,7 @@ async function runRelayBridge({
|
|
|
6340
6662
|
gatewayAuthToken,
|
|
6341
6663
|
bridgeToken: gatewayToken, // server lookupBridgeToken accepts relay_gateway_token
|
|
6342
6664
|
defaultModel: bridgeMeta?.openclawConfig?.agents?.defaults?.model?.primary || null,
|
|
6665
|
+
openclawConfig: bridgeMeta?.openclawConfig || null,
|
|
6343
6666
|
request,
|
|
6344
6667
|
json,
|
|
6345
6668
|
});
|
|
@@ -6425,7 +6748,10 @@ function formatServiceStatusOutput(result) {
|
|
|
6425
6748
|
.join("\n");
|
|
6426
6749
|
}
|
|
6427
6750
|
|
|
6428
|
-
// 探测本机已安装的
|
|
6751
|
+
// 探测本机已安装的 runtime。集合与返回顺序 = HOST_DETECTED_RUNTIME_KINDS
|
|
6752
|
+
// (openclaw / hermes / claude / codex / opencode)。
|
|
6753
|
+
// 🔴 deepagents 不在这里:它只入 runtime 枚举,本机没有任何探测手段 —— 没探测过
|
|
6754
|
+
// 就不能出现在探测表/host-inventory 里谎报 not_installed。
|
|
6429
6755
|
// 设计:每项探测要快(≤3s),失败时给出可读 hint。
|
|
6430
6756
|
// opts.probeVersion=false:只判 installed(which / 文件存在),跳过 `--version`
|
|
6431
6757
|
// 子进程探测 —— reset 不需要版本号,省去 hermes 最长 60s 的 ls-remote 宽限延迟。
|
|
@@ -6462,7 +6788,11 @@ function detectInstalledRuntimes({ probeVersion = true } = {}) {
|
|
|
6462
6788
|
});
|
|
6463
6789
|
}
|
|
6464
6790
|
|
|
6465
|
-
// hermes / claude / codex:检测 PATH 中的 CLI 二进制。
|
|
6791
|
+
// hermes / claude / codex / opencode:检测 PATH 中的 CLI 二进制。
|
|
6792
|
+
// opencode(add-deepagents-opencode-runtime-enum)复用**完全同一套** installed
|
|
6793
|
+
// 判定,不另造探测逻辑;自 opencode-pairable 起它同时进入配对扇出目标
|
|
6794
|
+
// (单 runtime 的 `pair -r opencode` 另有 src-ext/runtime/opencode/preflight.mjs
|
|
6795
|
+
// 在消费配对码前再确认一次二进制可用,同构自 codex 的 preflight)。
|
|
6466
6796
|
//
|
|
6467
6797
|
// hermes = Nous Research `hermes-agent`(https://github.com/nousresearch/hermes-agent),
|
|
6468
6798
|
// 装好后会在 PATH 暴露 `hermes` 命令,`hermes --version` 首行形如
|
|
@@ -6480,27 +6810,37 @@ function detectInstalledRuntimes({ probeVersion = true } = {}) {
|
|
|
6480
6810
|
// (banner.py:140 timeout=10s),网络慢时整条 spawnSync 会 ETIMEDOUT 被
|
|
6481
6811
|
// 上层误判"未安装";现在不依赖它。
|
|
6482
6812
|
// ② 拿到 binary 后再跑 `--version` 取版本字符串,超时不影响 installed 判断;
|
|
6483
|
-
// hermes 给 60s 宽限(GitHub ls-remote + git fetch 可能慢),
|
|
6484
|
-
// 是 native 二进制保留 10s。
|
|
6485
|
-
for (const
|
|
6813
|
+
// hermes 给 60s 宽限(GitHub ls-remote + git fetch 可能慢),
|
|
6814
|
+
// claude/codex/opencode 是 native 二进制保留 10s。
|
|
6815
|
+
for (const kind of PATH_PROBED_RUNTIME_KINDS) {
|
|
6486
6816
|
const whichCmd = process.platform === "win32" ? "where" : "which";
|
|
6487
|
-
const
|
|
6488
|
-
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6817
|
+
const candidates = RUNTIME_BINARY_CANDIDATES[kind] ?? [kind];
|
|
6818
|
+
let bin = candidates[0];
|
|
6819
|
+
let which = null;
|
|
6820
|
+
let binPath = "";
|
|
6821
|
+
for (const candidate of candidates) {
|
|
6822
|
+
const probeWhich = spawnSync(whichCmd, [candidate], {
|
|
6823
|
+
encoding: "utf8", timeout: 2000, windowsHide: true,
|
|
6824
|
+
});
|
|
6825
|
+
if (!probeWhich.error && probeWhich.status === 0) {
|
|
6826
|
+
bin = candidate;
|
|
6827
|
+
which = probeWhich;
|
|
6828
|
+
binPath = String(probeWhich.stdout ?? "").trim().split(/\r?\n/)[0];
|
|
6829
|
+
break;
|
|
6830
|
+
}
|
|
6831
|
+
which = probeWhich;
|
|
6832
|
+
}
|
|
6493
6833
|
if (!binPath) {
|
|
6494
6834
|
const code = which.error?.code || "";
|
|
6495
6835
|
const hint = code && code !== "ENOENT"
|
|
6496
6836
|
? t("pair_all_hint_which_failed", { cmd: whichCmd, bin, code })
|
|
6497
6837
|
: t("pair_all_hint_bin_not_in_path", { bin });
|
|
6498
|
-
results.push({ kind
|
|
6838
|
+
results.push({ kind, installed: false, hint });
|
|
6499
6839
|
continue;
|
|
6500
6840
|
}
|
|
6501
6841
|
if (!probeVersion) {
|
|
6502
6842
|
// reset 路径:只需 installed 布尔,跳过 `--version`(省 hermes 最长 60s 宽限)。
|
|
6503
|
-
results.push({ kind
|
|
6843
|
+
results.push({ kind, installed: true });
|
|
6504
6844
|
continue;
|
|
6505
6845
|
}
|
|
6506
6846
|
// ② 取版本(best-effort)。hermes 第一次跑会做 git ls-remote 检查更新,
|
|
@@ -6513,7 +6853,7 @@ function detectInstalledRuntimes({ probeVersion = true } = {}) {
|
|
|
6513
6853
|
if (!probe.error && probe.status === 0) {
|
|
6514
6854
|
version = String(probe.stdout ?? "").trim().split(/\r?\n/)[0] || "";
|
|
6515
6855
|
}
|
|
6516
|
-
const entry = { kind
|
|
6856
|
+
const entry = { kind, installed: true, version: version || t("pair_all_version_unknown") };
|
|
6517
6857
|
if (!version) {
|
|
6518
6858
|
// 记一下为啥没拿到版本,方便用户/我们之后定位
|
|
6519
6859
|
const code = probe.error?.code;
|
|
@@ -6531,11 +6871,199 @@ function detectInstalledRuntimes({ probeVersion = true } = {}) {
|
|
|
6531
6871
|
return results;
|
|
6532
6872
|
}
|
|
6533
6873
|
|
|
6874
|
+
// ─── per-runtime local pair-state helpers ───────────────────────────────────
|
|
6875
|
+
// 抽出自 runPairAll(原为其内部闭包):unify-scan-pairing 的 runScan 复用同一套
|
|
6876
|
+
// "本机已有凭证 + 服务端 binding 是否还活着" 判定逻辑(spec S3),避免两套配对
|
|
6877
|
+
// 代码漂移。纯函数/无 runPairAll 局部状态捕获,提到顶层不改变行为。
|
|
6878
|
+
|
|
6879
|
+
// openclaw + hermes 的 pair state 都存在 ~/.agentlink/config.json:
|
|
6880
|
+
// openclaw → relayGatewayId/relayGatewayToken
|
|
6881
|
+
// hermes → hermesRelayGatewayId/hermesRelayGatewayToken
|
|
6882
|
+
// claude/codex/opencode 存在 ~/.agentlink/ext-<runtime>-last.json。
|
|
6883
|
+
//
|
|
6884
|
+
// 🔴 目录一律走 resolveAgentlinkHome()(优先 AGENTLINK_HOME),**不得**用
|
|
6885
|
+
// `process.env.HOME + "/.agentlink"` 自己拼:写方(ext 侧 pairCodeClient.stateDir())
|
|
6886
|
+
// 与探方(runtimeHasLocalState)都优先认 AGENTLINK_HOME,这里拼裸 HOME 会造成
|
|
6887
|
+
// 「写在 A、探在 A、读/清在 B」的三方分叉 —— 已配对的 ext-agent runtime 被判成未
|
|
6888
|
+
// 配对而重复 consume(服务端多一条 orphan binding),stale 清理又会去删另一个
|
|
6889
|
+
// HOME 下的同名凭证(误删别人的)。launchd/systemd 单元本身就注入 AGENTLINK_HOME
|
|
6890
|
+
// (见 3600/3636/3841 行),所以这不是理论风险。
|
|
6891
|
+
//
|
|
6892
|
+
// 返回 { gw_id, token, relay_url } 或 null。
|
|
6893
|
+
function readLocalPairState(kind) {
|
|
6894
|
+
try {
|
|
6895
|
+
const agentlinkHome = resolveAgentlinkHome();
|
|
6896
|
+
if (!agentlinkHome) return null;
|
|
6897
|
+
// ext-agent 家族(claude / codex / opencode):凭证在 ext-<kind>-last.json。
|
|
6898
|
+
// 🔴 判据走登记表而不是字面量 —— 漏一个就会掉进下面的 config.json 分支,
|
|
6899
|
+
// 把 openclaw 的 relayGatewayId 当成它的凭证读出来(静默错配的读方向)。
|
|
6900
|
+
if (EXT_AGENT_RUNTIME_KINDS.includes(kind)) {
|
|
6901
|
+
const p = path.join(agentlinkHome, `ext-${kind}-last.json`);
|
|
6902
|
+
if (!fs.existsSync(p)) return null;
|
|
6903
|
+
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
6904
|
+
const gw = j?.gw_id || "";
|
|
6905
|
+
const tok = j?.bridge_token || "";
|
|
6906
|
+
if (!gw || !tok) return null;
|
|
6907
|
+
return { gw_id: gw, token: tok, relay_url: j?.relay_url || "" };
|
|
6908
|
+
}
|
|
6909
|
+
if (kind === "openclaw" || kind === "hermes") {
|
|
6910
|
+
const p = path.join(agentlinkHome, "config.json");
|
|
6911
|
+
if (!fs.existsSync(p)) return null;
|
|
6912
|
+
let j;
|
|
6913
|
+
try { j = JSON.parse(fs.readFileSync(p, "utf8")); }
|
|
6914
|
+
catch { return null; }
|
|
6915
|
+
const idKey = kind === "openclaw" ? "relayGatewayId" : "hermesRelayGatewayId";
|
|
6916
|
+
const tokKey = kind === "openclaw" ? "relayGatewayToken" : "hermesRelayGatewayToken";
|
|
6917
|
+
const gw = j?.[idKey] || "";
|
|
6918
|
+
const tok = j?.[tokKey] || "";
|
|
6919
|
+
if (!gw) return null;
|
|
6920
|
+
return { gw_id: gw, token: tok, relay_url: j?.relayUrl || "" };
|
|
6921
|
+
}
|
|
6922
|
+
return null;
|
|
6923
|
+
} catch {
|
|
6924
|
+
return null;
|
|
6925
|
+
}
|
|
6926
|
+
}
|
|
6927
|
+
|
|
6928
|
+
// 删本机 state 文件(App 删除后 server 没了,CLI 需要清干净本机才能 re-pair)。
|
|
6929
|
+
function clearLocalPairState(kind) {
|
|
6930
|
+
try {
|
|
6931
|
+
// 与 readLocalPairState 同一判据(登记表驱动)**且同一目录解析**
|
|
6932
|
+
// (resolveAgentlinkHome / 优先 AGENTLINK_HOME)—— 读写必须落在同一个文件上,
|
|
6933
|
+
// 否则"清理"是空操作、真正被删的却是另一个 HOME 下的同名凭证。
|
|
6934
|
+
const agentlinkHome = resolveAgentlinkHome();
|
|
6935
|
+
if (!agentlinkHome) return;
|
|
6936
|
+
if (EXT_AGENT_RUNTIME_KINDS.includes(kind)) {
|
|
6937
|
+
const p = path.join(agentlinkHome, `ext-${kind}-last.json`);
|
|
6938
|
+
if (fs.existsSync(p)) {
|
|
6939
|
+
try { fs.unlinkSync(p); } catch {}
|
|
6940
|
+
}
|
|
6941
|
+
return;
|
|
6942
|
+
}
|
|
6943
|
+
if (kind === "openclaw" || kind === "hermes") {
|
|
6944
|
+
const p = path.join(agentlinkHome, "config.json");
|
|
6945
|
+
if (!fs.existsSync(p)) return;
|
|
6946
|
+
let j;
|
|
6947
|
+
try { j = JSON.parse(fs.readFileSync(p, "utf8")); } catch { return; }
|
|
6948
|
+
const idKey = kind === "openclaw" ? "relayGatewayId" : "hermesRelayGatewayId";
|
|
6949
|
+
const tokKey = kind === "openclaw" ? "relayGatewayToken" : "hermesRelayGatewayToken";
|
|
6950
|
+
const clientTokKey = kind === "openclaw" ? "relayClientToken" : "hermesRelayClientToken";
|
|
6951
|
+
delete j[idKey];
|
|
6952
|
+
delete j[tokKey];
|
|
6953
|
+
delete j[clientTokKey];
|
|
6954
|
+
try { fs.writeFileSync(p, JSON.stringify(j, null, 2)); } catch {}
|
|
6955
|
+
}
|
|
6956
|
+
} catch {}
|
|
6957
|
+
}
|
|
6958
|
+
|
|
6959
|
+
// 服务端探"绑定状态":GET /v1/gateways/<gw>/binding 返回当前 active
|
|
6960
|
+
// (consumed=1) 的 app-user 绑定。
|
|
6961
|
+
// - 404/401/410 → 服务端 pair_session 已不存在/无权 → 视为已删
|
|
6962
|
+
// - 200 且 binding=null → 网关 session 还在但没有 active 绑定(典型:App
|
|
6963
|
+
// 删除了智能体把 pair_session 软删;或网关重连
|
|
6964
|
+
// host-claim 复活出一条 consumed=0 的行)→ 视为已删
|
|
6965
|
+
// - 200 且 binding!=null → 仍有 active 绑定 → 还在
|
|
6966
|
+
// - 5xx / 超时 → 网络/服务端问题,保守按"还在"处理,不误删本机 state
|
|
6967
|
+
//
|
|
6968
|
+
// 不要用 /meta 探活:那只校验网关 bridge_token / 进程是否存活,网关还在跑就返
|
|
6969
|
+
// 200,**不反映 app-user 绑定是否已被删**。openclaw/hermes 网关常驻,App 删除
|
|
6970
|
+
// 智能体后 /meta 仍 200 → 误判"已配对"、跳过重配对(这正是删 openclaw 后再添加
|
|
6971
|
+
// 显示"已配对"的根因)。
|
|
6972
|
+
//
|
|
6973
|
+
// 也不要比对 binding.code 跟用户传的 code —— pair code 每次扫码新生成,用户拿
|
|
6974
|
+
// 新 code 重跑 pair 时 binding 里仍是旧 code,对不上是常态;只判断 binding 是否
|
|
6975
|
+
// 存在即可,避免把"换码重配对"误判成需要 re-pair。
|
|
6976
|
+
async function probeRelayBinding(state) {
|
|
6977
|
+
if (!state || !state.gw_id) {
|
|
6978
|
+
return { alive: true, reason: "no_probe_url" };
|
|
6979
|
+
}
|
|
6980
|
+
// 老 state 缺 relay_url(典型 openclaw 老格式)→ 回退 env / 内置默认 relay
|
|
6981
|
+
// 再探,而不是直接按"已配对"跳过:那条逃逸正是 P0#18 ReOpen 的残留路径——
|
|
6982
|
+
// App 删除智能体后用户重新 pair,老 state 机器永远卡在「已配对,跳过」。
|
|
6983
|
+
let relayUrl = String(state.relay_url || "").trim();
|
|
6984
|
+
if (!relayUrl) {
|
|
6985
|
+
relayUrl =
|
|
6986
|
+
String(process.env.OPENIM_RELAY_URL || "").trim() ||
|
|
6987
|
+
String(process.env.AGENTLINK_RELAY_URL || "").trim() ||
|
|
6988
|
+
DEFAULT_RELAY_URL;
|
|
6989
|
+
}
|
|
6990
|
+
if (!state.token) {
|
|
6991
|
+
// 没有网关凭证 → 探不了也证明不了 binding 存活。用户此刻正在主动
|
|
6992
|
+
// `pair <code>`,重配对幂等且是用户意图;按 stale 走清理+重配对,
|
|
6993
|
+
// 比误判「已配对」把用户卡死安全。
|
|
6994
|
+
return { alive: false, reason: "no_gateway_token" };
|
|
6995
|
+
}
|
|
6996
|
+
const base = relayUrl.replace(/\/$/, "");
|
|
6997
|
+
const url = `${base}/v1/gateways/${encodeURIComponent(state.gw_id)}/binding`;
|
|
6998
|
+
const controller = new AbortController();
|
|
6999
|
+
const timer = setTimeout(() => controller.abort(), 3000);
|
|
7000
|
+
try {
|
|
7001
|
+
const resp = await fetch(url, {
|
|
7002
|
+
method: "GET",
|
|
7003
|
+
signal: controller.signal,
|
|
7004
|
+
headers: {
|
|
7005
|
+
"Authorization": `Bearer ${state.token}`,
|
|
7006
|
+
},
|
|
7007
|
+
});
|
|
7008
|
+
clearTimeout(timer);
|
|
7009
|
+
// 401 (token 无效) / 404 (gateway session 不存在) / 410 → server 端已删
|
|
7010
|
+
if (resp.status === 401 || resp.status === 404 || resp.status === 410) {
|
|
7011
|
+
return { alive: false, reason: `http_${resp.status}` };
|
|
7012
|
+
}
|
|
7013
|
+
if (resp.ok) {
|
|
7014
|
+
let body = null;
|
|
7015
|
+
try {
|
|
7016
|
+
body = await resp.json();
|
|
7017
|
+
} catch {
|
|
7018
|
+
// 解析失败时保守按"还在"处理,不误删
|
|
7019
|
+
return { alive: true, reason: "binding_parse_failed_assume_alive" };
|
|
7020
|
+
}
|
|
7021
|
+
const binding =
|
|
7022
|
+
body && typeof body === "object" ? body.binding : undefined;
|
|
7023
|
+
// binding 为 null/缺失 → 没有 active 绑定 → 视为已删
|
|
7024
|
+
if (binding == null) {
|
|
7025
|
+
return { alive: false, reason: "binding_null" };
|
|
7026
|
+
}
|
|
7027
|
+
return { alive: true, reason: `http_${resp.status}` };
|
|
7028
|
+
}
|
|
7029
|
+
// 500/503 等服务端错误 → 保守不删
|
|
7030
|
+
return { alive: true, reason: `http_${resp.status}_assume_alive` };
|
|
7031
|
+
} catch (err) {
|
|
7032
|
+
clearTimeout(timer);
|
|
7033
|
+
// 网络超时 / DNS 失败 → 保守不删
|
|
7034
|
+
return { alive: true, reason: `network_err_assume_alive` };
|
|
7035
|
+
}
|
|
7036
|
+
}
|
|
7037
|
+
|
|
7038
|
+
// 对应 runtime 的"重置后重新配对"命令提示。
|
|
7039
|
+
function resetHintFor(kind, code) {
|
|
7040
|
+
// ext-agent 家族没有 reset 子命令,其"解绑"即 uninstall(见 forwardToAgent)。
|
|
7041
|
+
if (EXT_AGENT_RUNTIME_KINDS.includes(kind)) {
|
|
7042
|
+
return `agentlink uninstall -r ${kind} && agentlink pair ${code} -r ${kind}`;
|
|
7043
|
+
}
|
|
7044
|
+
// openclaw / hermes 走 reset 子命令
|
|
7045
|
+
return `agentlink reset -r ${kind} && agentlink pair ${code} -r ${kind}`;
|
|
7046
|
+
}
|
|
7047
|
+
|
|
7048
|
+
// 每 runtime 一次 host-claim 子进程 spawn(unify-scan-pairing 抽出,供
|
|
7049
|
+
// runPairAll 与 runScan 共用,"出码方式"通过 extraArgv 注入 —— pair code
|
|
7050
|
+
// 路径转发原始 argv 不变;scan 路径注入 `pair <code> --pair-mode scan
|
|
7051
|
+
// --no-serve-relay ...`,见 runScan())。隔离 fail()/process.exit:单个
|
|
7052
|
+
// runtime 失败不影响其余 runtime(每个都是独立子进程)。
|
|
7053
|
+
function spawnPairChildForRuntime({ argvTail, runtimeKind, env }) {
|
|
7054
|
+
const argv = [CLI_ENTRY, ...argvTail, "--runtime", runtimeKind];
|
|
7055
|
+
const child = spawnSync(process.execPath, argv, { stdio: "inherit", env });
|
|
7056
|
+
return { kind: runtimeKind, ok: child.status === 0, status: child.status ?? null };
|
|
7057
|
+
}
|
|
7058
|
+
|
|
6534
7059
|
// `agentlink pair <code>` 默认批量配对所有已安装 runtime。
|
|
6535
7060
|
// 也兼容 `agentlink pair <code> --runtime all` 显式写法。
|
|
6536
|
-
// 检测本机安装的
|
|
6537
|
-
//
|
|
7061
|
+
// 检测本机安装的 runtime(HOST_DETECTED_RUNTIME_KINDS),对每个「已安装 **且**
|
|
7062
|
+
// 本轮有配对实现」的 runtime 串行 spawn 一次自身(替换 --runtime <kind>),
|
|
6538
7063
|
// 互不影响 fail(),最后汇总结果。
|
|
7064
|
+
// opencode 自 devagent-m-20260807-cli-runtime-opencode-pairable 起**已进扇出**
|
|
7065
|
+
// (它有了真配对实现)。已装但仍无配对实现的 runtime 才只进 host-inventory 上报、
|
|
7066
|
+
// 不进扇出 —— 见 runPairAll 内 pairTargetKinds 处注释。
|
|
6539
7067
|
async function runPairAll(options) {
|
|
6540
7068
|
if (!options.code || !String(options.code).trim()) {
|
|
6541
7069
|
fail(t("pair_usage_missing"));
|
|
@@ -6557,7 +7085,21 @@ async function runPairAll(options) {
|
|
|
6557
7085
|
|
|
6558
7086
|
const installedKinds = detected.filter((d) => d.installed).map((d) => d.kind);
|
|
6559
7087
|
if (installedKinds.length === 0) {
|
|
6560
|
-
fail(t("pair_all_no_runtimes"));
|
|
7088
|
+
fail(t("pair_all_no_runtimes", { kinds: HOST_DETECTED_RUNTIME_KINDS.join(" / ") }));
|
|
7089
|
+
}
|
|
7090
|
+
|
|
7091
|
+
// 扇出目标 = 已安装 ∩ 可配对集。已装但没有配对实现的 runtime **必须**排除在外:
|
|
7092
|
+
// 给它 spawn 一次 `--runtime <kind>` 只会撞 assertRuntimeOperableOrFail() 白拿
|
|
7093
|
+
// 一个非 0 退出,把整批 pair-all 拖成失败(纯回归)。
|
|
7094
|
+
// 但它仍留在 installedKinds 里上报给 relay —— 「本机装了它」是事实,App 需要知道。
|
|
7095
|
+
//
|
|
7096
|
+
// 现状:本机探测集 ⊆ 可配对集(opencode 自本轮起有了配对实现),所以
|
|
7097
|
+
// pairUnsupportedKinds 实际恒空。**保留**这条分支而不是删掉:它是由登记表
|
|
7098
|
+
// 派生的通用兜底,下一个"只入探测、还没配对实现"的 runtime 会立刻用上。
|
|
7099
|
+
const pairTargetKinds = installedKinds.filter((k) => PAIRABLE_RUNTIME_KINDS.includes(k));
|
|
7100
|
+
const pairUnsupportedKinds = installedKinds.filter((k) => !PAIRABLE_RUNTIME_KINDS.includes(k));
|
|
7101
|
+
for (const kind of pairUnsupportedKinds) {
|
|
7102
|
+
console.log(t("pair_all_runtime_pair_unsupported", { kind }));
|
|
6561
7103
|
}
|
|
6562
7104
|
|
|
6563
7105
|
// host-inventory side channel —— 在 spawn 子配对命令之前,先把本机装好的
|
|
@@ -6626,7 +7168,10 @@ async function runPairAll(options) {
|
|
|
6626
7168
|
continue;
|
|
6627
7169
|
}
|
|
6628
7170
|
if (a === "--allow-rebind") {
|
|
6629
|
-
|
|
7171
|
+
// 刻意**不**转发给子进程:pair-all 的语义是「把没配对的都配上」,已配对的
|
|
7172
|
+
// 由下面的 probeRelayBinding 判定后跳过,不做覆盖兜底(见下一段注释)。
|
|
7173
|
+
// ⚠️ 注释曾写「后面会显式加一次」——那是早期实现,现已不再回注;别照着补回去。
|
|
7174
|
+
continue;
|
|
6630
7175
|
}
|
|
6631
7176
|
forwardedArgv.push(a);
|
|
6632
7177
|
}
|
|
@@ -6642,162 +7187,11 @@ async function runPairAll(options) {
|
|
|
6642
7187
|
AGENTLINK_AGENT_LOG_LEVEL: process.env.AGENTLINK_AGENT_LOG_LEVEL || "warn",
|
|
6643
7188
|
};
|
|
6644
7189
|
|
|
6645
|
-
//
|
|
6646
|
-
//
|
|
6647
|
-
// hermes → hermesRelayGatewayId/hermesRelayGatewayToken
|
|
6648
|
-
// claude/codex 存在 ~/.agentlink/ext-<runtime>-last.json。
|
|
6649
|
-
//
|
|
6650
|
-
// 返回 { gw_id, token, relay_url } 或 null。
|
|
6651
|
-
function readLocalPairState(kind) {
|
|
6652
|
-
try {
|
|
6653
|
-
const home = process.env.HOME || "";
|
|
6654
|
-
if (!home) return null;
|
|
6655
|
-
if (kind === "claude" || kind === "codex") {
|
|
6656
|
-
const p = `${home}/.agentlink/ext-${kind}-last.json`;
|
|
6657
|
-
if (!fs.existsSync(p)) return null;
|
|
6658
|
-
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
6659
|
-
const gw = j?.gw_id || "";
|
|
6660
|
-
const tok = j?.bridge_token || "";
|
|
6661
|
-
if (!gw || !tok) return null;
|
|
6662
|
-
return { gw_id: gw, token: tok, relay_url: j?.relay_url || "" };
|
|
6663
|
-
}
|
|
6664
|
-
if (kind === "openclaw" || kind === "hermes") {
|
|
6665
|
-
const p = `${home}/.agentlink/config.json`;
|
|
6666
|
-
if (!fs.existsSync(p)) return null;
|
|
6667
|
-
let j;
|
|
6668
|
-
try { j = JSON.parse(fs.readFileSync(p, "utf8")); }
|
|
6669
|
-
catch { return null; }
|
|
6670
|
-
const idKey = kind === "openclaw" ? "relayGatewayId" : "hermesRelayGatewayId";
|
|
6671
|
-
const tokKey = kind === "openclaw" ? "relayGatewayToken" : "hermesRelayGatewayToken";
|
|
6672
|
-
const gw = j?.[idKey] || "";
|
|
6673
|
-
const tok = j?.[tokKey] || "";
|
|
6674
|
-
if (!gw) return null;
|
|
6675
|
-
return { gw_id: gw, token: tok, relay_url: j?.relayUrl || "" };
|
|
6676
|
-
}
|
|
6677
|
-
return null;
|
|
6678
|
-
} catch {
|
|
6679
|
-
return null;
|
|
6680
|
-
}
|
|
6681
|
-
}
|
|
6682
|
-
|
|
6683
|
-
// 删本机 state 文件(App 删除后 server 没了,CLI 需要清干净本机才能 re-pair)。
|
|
6684
|
-
function clearLocalPairState(kind) {
|
|
6685
|
-
try {
|
|
6686
|
-
const home = process.env.HOME || "";
|
|
6687
|
-
if (!home) return;
|
|
6688
|
-
if (kind === "claude" || kind === "codex") {
|
|
6689
|
-
const p = `${home}/.agentlink/ext-${kind}-last.json`;
|
|
6690
|
-
if (fs.existsSync(p)) {
|
|
6691
|
-
try { fs.unlinkSync(p); } catch {}
|
|
6692
|
-
}
|
|
6693
|
-
return;
|
|
6694
|
-
}
|
|
6695
|
-
if (kind === "openclaw" || kind === "hermes") {
|
|
6696
|
-
const p = `${home}/.agentlink/config.json`;
|
|
6697
|
-
if (!fs.existsSync(p)) return;
|
|
6698
|
-
let j;
|
|
6699
|
-
try { j = JSON.parse(fs.readFileSync(p, "utf8")); } catch { return; }
|
|
6700
|
-
const idKey = kind === "openclaw" ? "relayGatewayId" : "hermesRelayGatewayId";
|
|
6701
|
-
const tokKey = kind === "openclaw" ? "relayGatewayToken" : "hermesRelayGatewayToken";
|
|
6702
|
-
const clientTokKey = kind === "openclaw" ? "relayClientToken" : "hermesRelayClientToken";
|
|
6703
|
-
delete j[idKey];
|
|
6704
|
-
delete j[tokKey];
|
|
6705
|
-
delete j[clientTokKey];
|
|
6706
|
-
try { fs.writeFileSync(p, JSON.stringify(j, null, 2)); } catch {}
|
|
6707
|
-
}
|
|
6708
|
-
} catch {}
|
|
6709
|
-
}
|
|
6710
|
-
|
|
6711
|
-
// 服务端探"绑定状态":GET /v1/gateways/<gw>/binding 返回当前 active
|
|
6712
|
-
// (consumed=1) 的 app-user 绑定。
|
|
6713
|
-
// - 404/401/410 → 服务端 pair_session 已不存在/无权 → 视为已删
|
|
6714
|
-
// - 200 且 binding=null → 网关 session 还在但没有 active 绑定(典型:App
|
|
6715
|
-
// 删除了智能体把 pair_session 软删;或网关重连
|
|
6716
|
-
// host-claim 复活出一条 consumed=0 的行)→ 视为已删
|
|
6717
|
-
// - 200 且 binding!=null → 仍有 active 绑定 → 还在
|
|
6718
|
-
// - 5xx / 超时 → 网络/服务端问题,保守按"还在"处理,不误删本机 state
|
|
6719
|
-
//
|
|
6720
|
-
// 不要用 /meta 探活:那只校验网关 bridge_token / 进程是否存活,网关还在跑就返
|
|
6721
|
-
// 200,**不反映 app-user 绑定是否已被删**。openclaw/hermes 网关常驻,App 删除
|
|
6722
|
-
// 智能体后 /meta 仍 200 → 误判"已配对"、跳过重配对(这正是删 openclaw 后再添加
|
|
6723
|
-
// 显示"已配对"的根因)。
|
|
6724
|
-
//
|
|
6725
|
-
// 也不要比对 binding.code 跟用户传的 code —— pair code 每次扫码新生成,用户拿
|
|
6726
|
-
// 新 code 重跑 pair 时 binding 里仍是旧 code,对不上是常态;只判断 binding 是否
|
|
6727
|
-
// 存在即可,避免把"换码重配对"误判成需要 re-pair。
|
|
6728
|
-
async function probeRelayBinding(state) {
|
|
6729
|
-
if (!state || !state.gw_id) {
|
|
6730
|
-
return { alive: true, reason: "no_probe_url" };
|
|
6731
|
-
}
|
|
6732
|
-
// 老 state 缺 relay_url(典型 openclaw 老格式)→ 回退 env / 内置默认 relay
|
|
6733
|
-
// 再探,而不是直接按"已配对"跳过:那条逃逸正是 P0#18 ReOpen 的残留路径——
|
|
6734
|
-
// App 删除智能体后用户重新 pair,老 state 机器永远卡在「已配对,跳过」。
|
|
6735
|
-
let relayUrl = String(state.relay_url || "").trim();
|
|
6736
|
-
if (!relayUrl) {
|
|
6737
|
-
relayUrl =
|
|
6738
|
-
String(process.env.OPENIM_RELAY_URL || "").trim() ||
|
|
6739
|
-
String(process.env.AGENTLINK_RELAY_URL || "").trim() ||
|
|
6740
|
-
DEFAULT_RELAY_URL;
|
|
6741
|
-
}
|
|
6742
|
-
if (!state.token) {
|
|
6743
|
-
// 没有网关凭证 → 探不了也证明不了 binding 存活。用户此刻正在主动
|
|
6744
|
-
// `pair <code>`,重配对幂等且是用户意图;按 stale 走清理+重配对,
|
|
6745
|
-
// 比误判「已配对」把用户卡死安全。
|
|
6746
|
-
return { alive: false, reason: "no_gateway_token" };
|
|
6747
|
-
}
|
|
6748
|
-
const base = relayUrl.replace(/\/$/, "");
|
|
6749
|
-
const url = `${base}/v1/gateways/${encodeURIComponent(state.gw_id)}/binding`;
|
|
6750
|
-
const controller = new AbortController();
|
|
6751
|
-
const timer = setTimeout(() => controller.abort(), 3000);
|
|
6752
|
-
try {
|
|
6753
|
-
const resp = await fetch(url, {
|
|
6754
|
-
method: "GET",
|
|
6755
|
-
signal: controller.signal,
|
|
6756
|
-
headers: {
|
|
6757
|
-
"Authorization": `Bearer ${state.token}`,
|
|
6758
|
-
},
|
|
6759
|
-
});
|
|
6760
|
-
clearTimeout(timer);
|
|
6761
|
-
// 401 (token 无效) / 404 (gateway session 不存在) / 410 → server 端已删
|
|
6762
|
-
if (resp.status === 401 || resp.status === 404 || resp.status === 410) {
|
|
6763
|
-
return { alive: false, reason: `http_${resp.status}` };
|
|
6764
|
-
}
|
|
6765
|
-
if (resp.ok) {
|
|
6766
|
-
let body = null;
|
|
6767
|
-
try {
|
|
6768
|
-
body = await resp.json();
|
|
6769
|
-
} catch {
|
|
6770
|
-
// 解析失败时保守按"还在"处理,不误删
|
|
6771
|
-
return { alive: true, reason: "binding_parse_failed_assume_alive" };
|
|
6772
|
-
}
|
|
6773
|
-
const binding =
|
|
6774
|
-
body && typeof body === "object" ? body.binding : undefined;
|
|
6775
|
-
// binding 为 null/缺失 → 没有 active 绑定 → 视为已删
|
|
6776
|
-
if (binding == null) {
|
|
6777
|
-
return { alive: false, reason: "binding_null" };
|
|
6778
|
-
}
|
|
6779
|
-
return { alive: true, reason: `http_${resp.status}` };
|
|
6780
|
-
}
|
|
6781
|
-
// 500/503 等服务端错误 → 保守不删
|
|
6782
|
-
return { alive: true, reason: `http_${resp.status}_assume_alive` };
|
|
6783
|
-
} catch (err) {
|
|
6784
|
-
clearTimeout(timer);
|
|
6785
|
-
// 网络超时 / DNS 失败 → 保守不删
|
|
6786
|
-
return { alive: true, reason: `network_err_assume_alive` };
|
|
6787
|
-
}
|
|
6788
|
-
}
|
|
6789
|
-
|
|
6790
|
-
// 对应 runtime 的"重置后重新配对"命令提示。
|
|
6791
|
-
function resetHintFor(kind, code) {
|
|
6792
|
-
if (kind === "claude" || kind === "codex") {
|
|
6793
|
-
return `agentlink uninstall -r ${kind} && agentlink pair ${code} -r ${kind}`;
|
|
6794
|
-
}
|
|
6795
|
-
// openclaw / hermes 走 reset 子命令
|
|
6796
|
-
return `agentlink reset -r ${kind} && agentlink pair ${code} -r ${kind}`;
|
|
6797
|
-
}
|
|
7190
|
+
// readLocalPairState / clearLocalPairState / probeRelayBinding / resetHintFor
|
|
7191
|
+
// 已提到顶层(供 runScan 复用,见其上方注释),行为不变。
|
|
6798
7192
|
|
|
6799
7193
|
const summary = [];
|
|
6800
|
-
for (const kind of
|
|
7194
|
+
for (const kind of pairTargetKinds) {
|
|
6801
7195
|
const state = readLocalPairState(kind);
|
|
6802
7196
|
if (state) {
|
|
6803
7197
|
const probe = await probeRelayBinding(state);
|
|
@@ -6825,12 +7219,10 @@ async function runPairAll(options) {
|
|
|
6825
7219
|
clearLocalPairState(kind);
|
|
6826
7220
|
}
|
|
6827
7221
|
console.log(t("pair_all_pairing_runtime", { kind }));
|
|
6828
|
-
const
|
|
6829
|
-
|
|
6830
|
-
const ok = child.status === 0;
|
|
6831
|
-
summary.push({ kind, ok, status: child.status ?? null });
|
|
7222
|
+
const { ok, status } = spawnPairChildForRuntime({ argvTail: forwardedArgv, runtimeKind: kind, env: quietEnv });
|
|
7223
|
+
summary.push({ kind, ok, status });
|
|
6832
7224
|
if (!ok) {
|
|
6833
|
-
console.warn(t("pair_all_runtime_pair_failed", { kind, status:
|
|
7225
|
+
console.warn(t("pair_all_runtime_pair_failed", { kind, status: status ?? "?" }));
|
|
6834
7226
|
}
|
|
6835
7227
|
}
|
|
6836
7228
|
|
|
@@ -6859,7 +7251,7 @@ async function runPairAll(options) {
|
|
|
6859
7251
|
}
|
|
6860
7252
|
}
|
|
6861
7253
|
|
|
6862
|
-
//
|
|
7254
|
+
// 汇总:一行一类,一眼扫完。
|
|
6863
7255
|
const newlyPaired = summary.filter((s) => s.ok && !s.skipped).map((s) => s.kind);
|
|
6864
7256
|
const alreadyPaired = summary.filter((s) => s.skipped).map((s) => s.kind);
|
|
6865
7257
|
const failList = summary.filter((s) => !s.ok);
|
|
@@ -6878,13 +7270,23 @@ async function runPairAll(options) {
|
|
|
6878
7270
|
detail: failList.map((s) => `${s.kind}[exit ${s.status}]`).join("、"),
|
|
6879
7271
|
}));
|
|
6880
7272
|
}
|
|
7273
|
+
if (pairUnsupportedKinds.length > 0) {
|
|
7274
|
+
console.log(t("pair_all_summary_pair_unsupported", {
|
|
7275
|
+
count: pairUnsupportedKinds.length,
|
|
7276
|
+
kinds: pairUnsupportedKinds.join("、"),
|
|
7277
|
+
}));
|
|
7278
|
+
}
|
|
6881
7279
|
if (notInstalledList.length > 0) {
|
|
6882
7280
|
console.log(t("pair_all_summary_not_installed", { count: notInstalledList.length, kinds: notInstalledList.join("、") }));
|
|
6883
7281
|
}
|
|
6884
|
-
if (failList.length
|
|
7282
|
+
if (failList.length > 0) {
|
|
7283
|
+
console.log(t("pair_all_summary_failure_hint"));
|
|
7284
|
+
} else if (newlyPaired.length > 0 || alreadyPaired.length > 0) {
|
|
6885
7285
|
console.log(t("pair_all_summary_ok_hint"));
|
|
6886
7286
|
} else {
|
|
6887
|
-
|
|
7287
|
+
// 本机只装了「已知但无配对实现」的 runtime(如只装了 opencode):
|
|
7288
|
+
// 一个都没配上,别报「回 App 看已配对的智能体」那种假成功。
|
|
7289
|
+
console.log(t("pair_all_summary_nothing_pairable"));
|
|
6888
7290
|
}
|
|
6889
7291
|
console.log("");
|
|
6890
7292
|
|
|
@@ -6913,13 +7315,19 @@ async function runResetAll(options) {
|
|
|
6913
7315
|
// reset 不需要版本号 → 跳过 `--version` 探测(省 hermes 最长 60s 宽限)。
|
|
6914
7316
|
const detected = detectInstalledRuntimes({ probeVersion: false });
|
|
6915
7317
|
const installedKinds = detected.filter((d) => d.installed).map((d) => d.kind);
|
|
7318
|
+
// 只有「可配对集」的 runtime 会留下可解绑痕迹(config.json 的凭证 / ext-<rt>-last.json)。
|
|
7319
|
+
// 无配对实现的 runtime 不可能存在绑定,装了也没什么可解绑的 ——
|
|
7320
|
+
// 放进目标集只会让子进程撞 assertRuntimeOperableOrFail()、把整批 reset 拖成非 0 退出。
|
|
7321
|
+
// opencode 自本轮起在可配对集内:它会正常进入解绑目标(走 agentlink-agent uninstall)。
|
|
7322
|
+
const installedPairableKinds = installedKinds.filter((k) => PAIRABLE_RUNTIME_KINDS.includes(k));
|
|
7323
|
+
const noBindingKinds = installedKinds.filter((k) => !PAIRABLE_RUNTIME_KINDS.includes(k));
|
|
6916
7324
|
// 未安装但有本地痕迹(卸了 CLI / 删了 openclaw.json 但 binding 还在)的 runtime
|
|
6917
7325
|
// 也要纳入清理。读 reset 实际操作的同一批文件(config.json / ext-<rt>-last.json)。
|
|
6918
|
-
const ALL_KINDS =
|
|
7326
|
+
const ALL_KINDS = PAIRABLE_RUNTIME_KINDS;
|
|
6919
7327
|
const stateOnlyKinds = ALL_KINDS.filter(
|
|
6920
|
-
(k) => !
|
|
7328
|
+
(k) => !installedPairableKinds.includes(k) && runtimeHasLocalState(k),
|
|
6921
7329
|
);
|
|
6922
|
-
const targetKinds = [...
|
|
7330
|
+
const targetKinds = [...installedPairableKinds, ...stateOnlyKinds];
|
|
6923
7331
|
// 真正「无事可做」的 runtime(既没装、也没痕迹)才算 not-installed。
|
|
6924
7332
|
const notInstalledList = detected
|
|
6925
7333
|
.filter((d) => !d.installed && !stateOnlyKinds.includes(d.kind))
|
|
@@ -6927,7 +7335,9 @@ async function runResetAll(options) {
|
|
|
6927
7335
|
|
|
6928
7336
|
if (!json) {
|
|
6929
7337
|
for (const entry of detected) {
|
|
6930
|
-
if (entry.installed) {
|
|
7338
|
+
if (entry.installed && noBindingKinds.includes(entry.kind)) {
|
|
7339
|
+
console.log(t("reset_all_runtime_no_binding", { kind: entry.kind }));
|
|
7340
|
+
} else if (entry.installed) {
|
|
6931
7341
|
console.log(` ✓ ${entry.kind}`);
|
|
6932
7342
|
} else if (stateOnlyKinds.includes(entry.kind)) {
|
|
6933
7343
|
console.log(t("reset_all_runtime_state_only", { kind: entry.kind }));
|
|
@@ -6939,6 +7349,8 @@ async function runResetAll(options) {
|
|
|
6939
7349
|
}
|
|
6940
7350
|
log.info("reset_all.targets", "resolved reset targets", {
|
|
6941
7351
|
installed: installedKinds, stateOnly: stateOnlyKinds, notInstalled: notInstalledList,
|
|
7352
|
+
// 已装但无配对实现 → 无绑定可解,不进目标集(诊断时能一眼看出为何被跳过)
|
|
7353
|
+
noBinding: noBindingKinds,
|
|
6942
7354
|
});
|
|
6943
7355
|
|
|
6944
7356
|
if (targetKinds.length === 0) {
|
|
@@ -7040,7 +7452,12 @@ async function runPair({
|
|
|
7040
7452
|
runtime,
|
|
7041
7453
|
strictPlugin,
|
|
7042
7454
|
allowRebind,
|
|
7455
|
+
// unify-scan-pairing: "" (default, pair code manual-entry path) | "scan".
|
|
7456
|
+
// Set internally by runScan's per-runtime host-claim child spawn
|
|
7457
|
+
// (`--pair-mode scan`) — never by a human-typed `agentlink pair` call.
|
|
7458
|
+
pairMode = "",
|
|
7043
7459
|
}) {
|
|
7460
|
+
const isScanMode = pairMode === "scan";
|
|
7044
7461
|
if (!explicitCode || !String(explicitCode).trim()) {
|
|
7045
7462
|
fail(t("pair_usage_missing"));
|
|
7046
7463
|
}
|
|
@@ -7067,11 +7484,24 @@ async function runPair({
|
|
|
7067
7484
|
);
|
|
7068
7485
|
}
|
|
7069
7486
|
|
|
7070
|
-
|
|
7071
|
-
|
|
7072
|
-
|
|
7073
|
-
|
|
7074
|
-
|
|
7487
|
+
// scan 模式 SHALL 不做 host-ready 预检(spec P1-4):code 由 CLI 本地生成、
|
|
7488
|
+
// 无 App 预建行,readPairSessionFromRelay 的 GET 预检会因"行不存在"误挡。
|
|
7489
|
+
// 直接以合成的空会话继续,走下面统一的 publishPairToRelay(pairMode=scan)
|
|
7490
|
+
// Upsert 建 pending 行。
|
|
7491
|
+
const currentSession = isScanMode
|
|
7492
|
+
? {
|
|
7493
|
+
accountId: "",
|
|
7494
|
+
runtimeKind: requestedRuntime,
|
|
7495
|
+
transportMode: "relay",
|
|
7496
|
+
clientUserID: "",
|
|
7497
|
+
bindUrl: "",
|
|
7498
|
+
consumed: false,
|
|
7499
|
+
}
|
|
7500
|
+
: await readPairSessionFromRelay({
|
|
7501
|
+
relayUrl,
|
|
7502
|
+
code,
|
|
7503
|
+
runtimeKind: requestedRuntime,
|
|
7504
|
+
});
|
|
7075
7505
|
if (!currentSession) {
|
|
7076
7506
|
fail(
|
|
7077
7507
|
[
|
|
@@ -7092,15 +7522,18 @@ async function runPair({
|
|
|
7092
7522
|
const runtimeKind = requestedRuntime;
|
|
7093
7523
|
// 复合主键升级后,GET 已经按 runtimeKind 精确取了对应的行,server 不会
|
|
7094
7524
|
// 再返回其他 runtime 的数据,这里 mismatch 不可能发生 —— 保留旧检查仅作冗余防护。
|
|
7095
|
-
|
|
7525
|
+
// scan 模式没有真实 GET 结果(currentSession 是本函数合成的),该检查天然满足,跳过即可。
|
|
7526
|
+
if (!isScanMode && runtime && runtimeKind !== sessionRuntimeKind) {
|
|
7096
7527
|
fail(t("pair_runtime_mismatch", { app: sessionRuntimeKind, cli: runtimeKind }));
|
|
7097
7528
|
}
|
|
7098
7529
|
const transportMode = normalizeChatTransportMode(
|
|
7099
7530
|
mode,
|
|
7100
7531
|
normalizeChatTransportMode(currentSession.transportMode, "relay"),
|
|
7101
7532
|
);
|
|
7533
|
+
// scan 模式:clientUserId 由 App scan-claim 时从 Bearer 派生,host-claim 阶段
|
|
7534
|
+
// 天生为空(spec「服务端支持 scan 模式的 pending 归属」)——不是错误,不 fail()。
|
|
7102
7535
|
const clientUserId = String(currentSession.clientUserID ?? "").trim().toLowerCase();
|
|
7103
|
-
if (!clientUserId) {
|
|
7536
|
+
if (!isScanMode && !clientUserId) {
|
|
7104
7537
|
fail(
|
|
7105
7538
|
[
|
|
7106
7539
|
t("pair_missing_client_user_id"),
|
|
@@ -7176,8 +7609,13 @@ async function runPair({
|
|
|
7176
7609
|
process.env.OPENCLAW_CUSTOM_IM_PUBLIC_BASE_URL?.trim() ||
|
|
7177
7610
|
"";
|
|
7178
7611
|
|
|
7612
|
+
// scan 模式:gateway.port 可能尚未配置(真机上通常已配置,但 host-claim 本身
|
|
7613
|
+
// 不依赖它——scan 是 CLI-bridge 模型,relay-worker 轮询,不像 pair code 那条
|
|
7614
|
+
// legacy 直连 webhook 路径需要一个外部可达的 bindUrl)。optional:true 让
|
|
7615
|
+
// resolveGatewayBaseUrl 缺配置时返回 null 而不是 fail()/exit;pair code 路径
|
|
7616
|
+
// (isScanMode=false)行为完全不变。
|
|
7179
7617
|
const localGatewayBaseUrl = runtimeNeedsOpenClaw(runtimeKind)
|
|
7180
|
-
? resolveGatewayBaseUrl({ gateway }, config)
|
|
7618
|
+
? resolveGatewayBaseUrl({ gateway, optional: isScanMode }, config)
|
|
7181
7619
|
: normalizeBaseUrl(gateway) || "";
|
|
7182
7620
|
const gatewayAuthToken = runtimeNeedsOpenClaw(runtimeKind)
|
|
7183
7621
|
? resolveGatewayAuthToken(config)
|
|
@@ -7196,8 +7634,8 @@ async function runPair({
|
|
|
7196
7634
|
accountId,
|
|
7197
7635
|
code,
|
|
7198
7636
|
});
|
|
7199
|
-
const relayBindUrl = currentSession.bindUrl || publicBindUrl || localBindUrl;
|
|
7200
|
-
if (!relayBindUrl) {
|
|
7637
|
+
const relayBindUrl = currentSession.bindUrl || publicBindUrl || localBindUrl || (isScanMode ? "" : null);
|
|
7638
|
+
if (!isScanMode && !relayBindUrl) {
|
|
7201
7639
|
fail(t("pairurl_bind_url_unknown"));
|
|
7202
7640
|
}
|
|
7203
7641
|
|
|
@@ -7291,16 +7729,24 @@ async function runPair({
|
|
|
7291
7729
|
c.startsWith("gateway.http.endpoints.responses.enabled") ||
|
|
7292
7730
|
c.startsWith("gateway.auth."),
|
|
7293
7731
|
);
|
|
7294
|
-
|
|
7732
|
+
// scan 模式跳过重启宽限等待:出码循环追求"CLI 秒起二维码",这段睡眠/探测只是
|
|
7733
|
+
// "让日志更友好"的诊断性等待(gatewayProbe.ok=false 只 warn,从不 fail() ——
|
|
7734
|
+
// 不影响 publishPairToRelay 是否成功),对 pair code 手输路径行为不变
|
|
7735
|
+
// (isScanMode 为 false 时,下面两处判断与改动前完全一致)。
|
|
7736
|
+
if (runtimeNeedsOpenClaw(runtimeKind) && prepared.changed && !isScanMode) {
|
|
7295
7737
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
7296
7738
|
}
|
|
7297
7739
|
|
|
7298
|
-
|
|
7740
|
+
// scan 模式且 gateway.port 未配置(localGatewayBaseUrl=null,见上方 optional:true)
|
|
7741
|
+
// 时完全跳过探测——没有 URL 可探,buildGatewayResponsesUrlOrFail 遇 null 会
|
|
7742
|
+
// fail()/exit,而这段探测本就只是诊断性的(见上方注释),跳过不影响正确性。
|
|
7743
|
+
if (runtimeNeedsOpenClaw(runtimeKind) && (!isScanMode || localGatewayBaseUrl)) {
|
|
7299
7744
|
// 90s covers the observed ~35s restart with ample margin; ~7s handles the
|
|
7300
|
-
// steady-state case where openclaw is already serving.
|
|
7301
|
-
|
|
7745
|
+
// steady-state case where openclaw is already serving. scan 模式只探测
|
|
7746
|
+
// 1 次、不重试(见上方注释)。
|
|
7747
|
+
const PROBE_ATTEMPTS = isScanMode ? 1 : criticalChange ? 90 : prepared.changed ? 15 : 6;
|
|
7302
7748
|
const PROBE_INTERVAL_MS = 1000;
|
|
7303
|
-
const announcedStart = criticalChange;
|
|
7749
|
+
const announcedStart = !isScanMode && criticalChange;
|
|
7304
7750
|
if (announcedStart) {
|
|
7305
7751
|
console.log(t("gateway_wait_restart"));
|
|
7306
7752
|
}
|
|
@@ -7353,6 +7799,7 @@ async function runPair({
|
|
|
7353
7799
|
relayClientToken: relayCredentials?.clientToken,
|
|
7354
7800
|
relayGatewayToken: relayCredentials?.gatewayToken,
|
|
7355
7801
|
accountId,
|
|
7802
|
+
...(isScanMode ? { pairMode: "scan", ttlSeconds: 300 } : {}),
|
|
7356
7803
|
});
|
|
7357
7804
|
|
|
7358
7805
|
const output = {
|
|
@@ -7960,6 +8407,281 @@ function runPairUrl({ json, account, code: explicitCode }) {
|
|
|
7960
8407
|
console.log(resolvedBindUrl);
|
|
7961
8408
|
}
|
|
7962
8409
|
|
|
8410
|
+
// `agentlink scan`:统一扫码配对(unify-scan-pairing,取代 add-openclaw-scan-pairing
|
|
8411
|
+
// 的上一代扫码硬件方案)——CLI 本地出一个高熵 code → 对每个 scan-capable
|
|
8412
|
+
// runtime(openclaw/hermes)host-claim → 渲染中性 deeplink 二维码 → 等 App
|
|
8413
|
+
// scan-claim → 起 worker。配对后效果与 pair code 路径完全一致(同一
|
|
8414
|
+
// `pair_sessions` 底座,同一 App 侧工作区选择流程),只有出码方式不同。
|
|
8415
|
+
//
|
|
8416
|
+
// `scan --json` 跨多阶段持续输出(探测 → 等待扫码 → 配对成功 → 起 worker),
|
|
8417
|
+
// 采用 NDJSON:每阶段一行紧凑 JSON object(见 printScanEvent)。
|
|
8418
|
+
function printScanEvent(fields) {
|
|
8419
|
+
console.log(JSON.stringify({ command: "scan", ...fields }));
|
|
8420
|
+
}
|
|
8421
|
+
|
|
8422
|
+
// claude/codex 在 scan 模式下没有 host-claim 路径(gateway 凭证由服务端在
|
|
8423
|
+
// consume 时才下发,host-claim 阶段无来源——spec 术语 + Non-goals,本期不做
|
|
8424
|
+
// pending 预分配子系统)。检测到时打印明确提示,不静默、不失败(S2b)。
|
|
8425
|
+
function scanUnsupportedRuntimeMessage(kinds) {
|
|
8426
|
+
return `⚠️ ${kinds.join("/")} 暂不支持扫码配对(scan-capable runtime 仅 openclaw/hermes)。请改用 \`${CLI_COMMAND_NAME} pair <code> -r ${kinds[0]}\` 手动配对。`;
|
|
8427
|
+
}
|
|
8428
|
+
|
|
8429
|
+
// env 覆盖仅供测试加速 S4(过期重出码)场景用——与既有
|
|
8430
|
+
// AGENTLINK_SCAN_QR_PNG_PATH 同一约定(见 scanPairFlow.mjs)。生产默认值:
|
|
8431
|
+
// 2s 轮询间隔、300s TTL(与 host-claim 请求的 ttlSeconds 对齐,安全信封 §2)。
|
|
8432
|
+
const SCAN_CLAIM_POLL_INTERVAL_MS = parseNonNegativeInt(process.env.AGENTLINK_SCAN_CLAIM_POLL_INTERVAL_MS) || 2000;
|
|
8433
|
+
// 轮询超过这个窗口还没等到 scan-claim,视为码已过期,重新出码(S4),而不是无限等下去。
|
|
8434
|
+
const SCAN_CLAIM_TTL_MS = (parseNonNegativeInt(process.env.AGENTLINK_SCAN_CLAIM_TTL_SEC) || 300) * 1000;
|
|
8435
|
+
|
|
8436
|
+
// 轮询等待 App scan-claim 完成。复用既有 readPairSessionFromRelay(GET
|
|
8437
|
+
// /v1/pair-sessions/:code)——scan-claim 会把该 code 下全部 pending 行标记
|
|
8438
|
+
// consumed=1(spec「新增端点」),任取其中一个已 host-claim 的 runtimeKind
|
|
8439
|
+
// 查询即可代表整组的归属状态,不需要新端点。
|
|
8440
|
+
async function waitForScanClaim({ relayUrl, code, representativeRuntimeKind, ttlMs, sleepImpl }) {
|
|
8441
|
+
const sleep = sleepImpl || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
8442
|
+
const deadline = Date.now() + ttlMs;
|
|
8443
|
+
for (;;) {
|
|
8444
|
+
const session = await readPairSessionFromRelay({
|
|
8445
|
+
relayUrl,
|
|
8446
|
+
code,
|
|
8447
|
+
runtimeKind: representativeRuntimeKind,
|
|
8448
|
+
});
|
|
8449
|
+
if (session && session.consumed) {
|
|
8450
|
+
return true;
|
|
8451
|
+
}
|
|
8452
|
+
if (Date.now() >= deadline) {
|
|
8453
|
+
return false;
|
|
8454
|
+
}
|
|
8455
|
+
await sleep(SCAN_CLAIM_POLL_INTERVAL_MS);
|
|
8456
|
+
}
|
|
8457
|
+
}
|
|
8458
|
+
|
|
8459
|
+
// 起本地已装 scan-capable runtime 的 worker:单 runtime 用既有前台 runBridge
|
|
8460
|
+
// (与 revoke 时既有 selfUninstallOnRevoke 退出路径完全一致,S6);多 runtime
|
|
8461
|
+
// 复用 runPair `--serve-relay` 分支已经在用的后台服务安装机制(安装成功后各自
|
|
8462
|
+
// 独立常驻,不受本进程退出影响)。S1 的多 runtime 场景是 manual-device 验收
|
|
8463
|
+
// 载体(spec 执行载体声明),这里不额外发明新机制,只编排既有函数。
|
|
8464
|
+
async function startScanWorkers({ kinds, relayUrl, gateway, json }) {
|
|
8465
|
+
if (kinds.length === 1) {
|
|
8466
|
+
await runBridge({ json, relay: relayUrl, noRelay: false, gateway, runtime: kinds[0] });
|
|
8467
|
+
return;
|
|
8468
|
+
}
|
|
8469
|
+
for (const kind of kinds) {
|
|
8470
|
+
const gatewayBaseUrl = runtimeNeedsOpenClaw(kind)
|
|
8471
|
+
? resolveGatewayBaseUrl({ gateway, optional: true }, ensureObject(readOptionalJsonFile(resolveOpenClawConfigPath()))) || ""
|
|
8472
|
+
: relayUrl; // hermes 占位:installBridgeService 契约要求非空,hermes 分支不读它。
|
|
8473
|
+
try {
|
|
8474
|
+
const serviceStatus = getBridgeServiceStatus(kind);
|
|
8475
|
+
(serviceStatus.installed ? restartBridgeService : installBridgeService)({
|
|
8476
|
+
relayUrl, gatewayBaseUrl, runtimeKind: kind,
|
|
8477
|
+
});
|
|
8478
|
+
if (!json) console.log(t("service_installed"));
|
|
8479
|
+
} catch (err) {
|
|
8480
|
+
if (!json) console.warn(`⚠️ ${kind} bridge service 安装失败:${err?.message || err}`);
|
|
8481
|
+
}
|
|
8482
|
+
}
|
|
8483
|
+
}
|
|
8484
|
+
|
|
8485
|
+
async function runScan({ json, relay, noRelay, gateway, qrPath, noServe, strictPlugin, runtime }) {
|
|
8486
|
+
// S2b(显式 -r 到非 scan-capable 的 ext-agent runtime):不出码、不联系 relay,
|
|
8487
|
+
// 直接提示 + 非零退出。(opencode 更早已被 assertRuntimeBridgeSupportedOrFail
|
|
8488
|
+
// 拦停;这里保留登记表判据作双保险。)
|
|
8489
|
+
if (EXT_AGENT_RUNTIME_KINDS.includes(runtime)) {
|
|
8490
|
+
const message = scanUnsupportedRuntimeMessage([runtime]);
|
|
8491
|
+
if (json) {
|
|
8492
|
+
printScanEvent({ phase: "unsupported_runtime", unsupportedRuntimes: [runtime], hint: message });
|
|
8493
|
+
} else {
|
|
8494
|
+
console.error(message);
|
|
8495
|
+
}
|
|
8496
|
+
process.exit(1);
|
|
8497
|
+
}
|
|
8498
|
+
|
|
8499
|
+
const config = ensureObject(readOptionalJsonFile(resolveOpenClawConfigPath()));
|
|
8500
|
+
const section = readChannelSection(config);
|
|
8501
|
+
const relayUrl = resolveRelayUrl({ relay, noRelay }, section);
|
|
8502
|
+
if (!relayUrl) {
|
|
8503
|
+
fail(
|
|
8504
|
+
[
|
|
8505
|
+
t("relay_url_required"),
|
|
8506
|
+
t("relay_url_missing_detail"),
|
|
8507
|
+
t("relay_url_hint"),
|
|
8508
|
+
].join("\n"),
|
|
8509
|
+
);
|
|
8510
|
+
}
|
|
8511
|
+
|
|
8512
|
+
// ── 探测本机已装 runtime,划出「scan-capable 候选集」(S1/S2)───────────
|
|
8513
|
+
const detected = detectInstalledRuntimes();
|
|
8514
|
+
const installedKinds = new Set(detected.filter((d) => d.installed).map((d) => d.kind));
|
|
8515
|
+
// 已装、但不在 scan-capable 白名单里的 runtime:提示改走 pair(ext-agent 家族
|
|
8516
|
+
// 的凭证由服务端在 consume 时下发,本机没有 host-claim 路径)。判据走登记表,
|
|
8517
|
+
// 加 runtime 时不会漏提示。
|
|
8518
|
+
const unsupportedDetected = detected
|
|
8519
|
+
.filter((d) => d.installed && EXT_AGENT_RUNTIME_KINDS.includes(d.kind))
|
|
8520
|
+
.map((d) => d.kind);
|
|
8521
|
+
|
|
8522
|
+
let candidateKinds;
|
|
8523
|
+
if (runtime) {
|
|
8524
|
+
// -r openclaw|hermes:只配这一个(S2)。校验值域已在 CLI 参数解析阶段完成
|
|
8525
|
+
// (openclaw/hermes/claude/codex/opencode/deepagents/all);ext-agent 家族
|
|
8526
|
+
// (claude/codex/opencode)已在上面提前返回,enum-only 的 deepagents 与
|
|
8527
|
+
// pair-only 的 opencode 更早就被命令分派前的两道显式拦停挡下,走不到这里。
|
|
8528
|
+
if (!installedKinds.has(runtime)) {
|
|
8529
|
+
fail(`${CLI_COMMAND_NAME} scan -r ${runtime}: runtime not installed on this host`);
|
|
8530
|
+
}
|
|
8531
|
+
candidateKinds = [runtime];
|
|
8532
|
+
} else {
|
|
8533
|
+
candidateKinds = SCAN_CAPABLE_RUNTIME_KINDS.filter((k) => installedKinds.has(k));
|
|
8534
|
+
}
|
|
8535
|
+
if (candidateKinds.length === 0) {
|
|
8536
|
+
// 🔴 kinds 必须显式传:该文案自 add-deepagents-opencode-runtime-enum 起改成
|
|
8537
|
+
// {kinds} 占位(不再把 runtime 名写死进文案),漏传会把字面 "{kinds}" 打给用户。
|
|
8538
|
+
// 这里的语境是 scan,所以报的是 **scan-capable 集合**(openclaw/hermes),
|
|
8539
|
+
// 不是 HOST_DETECTED 全集 —— 装了 opencode 也不构成"可扫码配对的 runtime"。
|
|
8540
|
+
fail(t("pair_all_no_runtimes", { kinds: SCAN_CAPABLE_RUNTIME_KINDS.join(" / ") }));
|
|
8541
|
+
}
|
|
8542
|
+
|
|
8543
|
+
if (unsupportedDetected.length > 0) {
|
|
8544
|
+
const message = scanUnsupportedRuntimeMessage(unsupportedDetected);
|
|
8545
|
+
if (json) {
|
|
8546
|
+
printScanEvent({ phase: "unsupported_runtime_detected", unsupportedRuntimes: unsupportedDetected, hint: message });
|
|
8547
|
+
} else {
|
|
8548
|
+
console.log(message);
|
|
8549
|
+
}
|
|
8550
|
+
}
|
|
8551
|
+
|
|
8552
|
+
// ── S3:已有有效凭证 + 服务端 binding 仍存活的 runtime 直接跳过出码 ──────
|
|
8553
|
+
const quietEnv = {
|
|
8554
|
+
...process.env,
|
|
8555
|
+
AGENTLINK_AGENT_LOG_LEVEL: process.env.AGENTLINK_AGENT_LOG_LEVEL || "warn",
|
|
8556
|
+
};
|
|
8557
|
+
const alreadyAliveKinds = [];
|
|
8558
|
+
const needsClaimKinds = [];
|
|
8559
|
+
for (const kind of candidateKinds) {
|
|
8560
|
+
const state = readLocalPairState(kind);
|
|
8561
|
+
if (state) {
|
|
8562
|
+
const probe = await probeRelayBinding(state);
|
|
8563
|
+
if (probe.alive) {
|
|
8564
|
+
alreadyAliveKinds.push(kind);
|
|
8565
|
+
if (kind === RUNTIME_KIND_HERMES) {
|
|
8566
|
+
try {
|
|
8567
|
+
const hLog = _createBubLogger({ comp: "scan" }).child({ rt: "hermes" });
|
|
8568
|
+
await ensureHermesEnvAndProbe(hLog);
|
|
8569
|
+
} catch (err) {
|
|
8570
|
+
console.warn(`⚠️ hermes env/gateway 自检失败:${err?.message || err}`);
|
|
8571
|
+
}
|
|
8572
|
+
}
|
|
8573
|
+
continue;
|
|
8574
|
+
}
|
|
8575
|
+
clearLocalPairState(kind);
|
|
8576
|
+
}
|
|
8577
|
+
needsClaimKinds.push(kind);
|
|
8578
|
+
}
|
|
8579
|
+
|
|
8580
|
+
let claimedKinds = [];
|
|
8581
|
+
if (needsClaimKinds.length === 0) {
|
|
8582
|
+
if (!json) {
|
|
8583
|
+
console.log(`ℹ️ ${alreadyAliveKinds.join("、")} 已有有效凭证,跳过扫码,直接启动 bridge…`);
|
|
8584
|
+
} else {
|
|
8585
|
+
printScanEvent({ phase: "skip_pairing", relayUrl, runtimes: alreadyAliveKinds });
|
|
8586
|
+
}
|
|
8587
|
+
} else {
|
|
8588
|
+
// ── 出码循环(S1 前半段 + S4):本地生成高熵 code → 对 needsClaimKinds
|
|
8589
|
+
// 逐个 host-claim(子进程,与 pair-all 同一隔离机制)→ 渲染二维码 → 轮询
|
|
8590
|
+
// scan-claim;超时/过期则清二维码、重新出码,进程不退出。
|
|
8591
|
+
const resolvedQrPath = qrPath || SCAN_QR_PNG_OUTPUT_PATH;
|
|
8592
|
+
for (;;) {
|
|
8593
|
+
const code = generateScanPairCode();
|
|
8594
|
+
const argvTail = [
|
|
8595
|
+
"pair", code,
|
|
8596
|
+
"--relay", relayUrl,
|
|
8597
|
+
"--pair-mode", "scan",
|
|
8598
|
+
"--no-serve-relay",
|
|
8599
|
+
...(json ? ["--json"] : []),
|
|
8600
|
+
...(strictPlugin ? ["--strict-plugin"] : []),
|
|
8601
|
+
];
|
|
8602
|
+
const roundResults = needsClaimKinds.map((kind) =>
|
|
8603
|
+
spawnPairChildForRuntime({ argvTail, runtimeKind: kind, env: quietEnv }),
|
|
8604
|
+
);
|
|
8605
|
+
const roundClaimed = roundResults.filter((r) => r.ok).map((r) => r.kind);
|
|
8606
|
+
const roundFailed = roundResults.filter((r) => !r.ok).map((r) => r.kind);
|
|
8607
|
+
if (roundFailed.length > 0 && !json) {
|
|
8608
|
+
console.warn(`⚠️ 以下 runtime host-claim 失败,不会出现在本轮二维码中:${roundFailed.join("、")}`);
|
|
8609
|
+
}
|
|
8610
|
+
if (roundClaimed.length === 0) {
|
|
8611
|
+
fail(`agentlink scan: host-claim failed for all candidate runtimes (${needsClaimKinds.join(", ")})`);
|
|
8612
|
+
}
|
|
8613
|
+
|
|
8614
|
+
const deeplink = buildScanDeeplink({ code, relay: relayUrl });
|
|
8615
|
+
const pngResult = await renderScanQrPng({ payload: deeplink, outputPath: resolvedQrPath });
|
|
8616
|
+
if (json) {
|
|
8617
|
+
printScanEvent({
|
|
8618
|
+
phase: "waiting_for_scan",
|
|
8619
|
+
code,
|
|
8620
|
+
deeplink,
|
|
8621
|
+
runtimes: roundClaimed,
|
|
8622
|
+
qr_png_path: pngResult.ok ? pngResult.path : null,
|
|
8623
|
+
qr_png_error: pngResult.ok ? null : pngResult.error,
|
|
8624
|
+
});
|
|
8625
|
+
} else {
|
|
8626
|
+
console.log(`\n扫码配对(${roundClaimed.join("、")}):用 App 扫描下方二维码` +
|
|
8627
|
+
(pngResult.ok ? `(或打开 ${pngResult.path} 用图片扫码)` : "") + "\n");
|
|
8628
|
+
renderQrToTerminal(deeplink);
|
|
8629
|
+
console.log(`\n扫不到?把这段贴到浏览器/App 输入框也可以:${deeplink}\n`);
|
|
8630
|
+
}
|
|
8631
|
+
|
|
8632
|
+
const claimedInTime = await waitForScanClaim({
|
|
8633
|
+
relayUrl,
|
|
8634
|
+
code,
|
|
8635
|
+
representativeRuntimeKind: roundClaimed[0],
|
|
8636
|
+
ttlMs: SCAN_CLAIM_TTL_MS,
|
|
8637
|
+
});
|
|
8638
|
+
clearQRImage(resolvedQrPath);
|
|
8639
|
+
if (claimedInTime) {
|
|
8640
|
+
claimedKinds = roundClaimed;
|
|
8641
|
+
if (json) {
|
|
8642
|
+
printScanEvent({ phase: "paired", relayUrl, runtimes: claimedKinds });
|
|
8643
|
+
} else {
|
|
8644
|
+
console.log(`✅ 配对成功(${claimedKinds.join("、")})`);
|
|
8645
|
+
}
|
|
8646
|
+
break;
|
|
8647
|
+
}
|
|
8648
|
+
// 过期/超时(S4):重新生成 code,重新 host-claim,刷新二维码,不退出。
|
|
8649
|
+
if (!json) {
|
|
8650
|
+
console.log("⏳ 配对码已过期,重新生成二维码…");
|
|
8651
|
+
} else {
|
|
8652
|
+
printScanEvent({ phase: "code_expired_retrying", runtimes: roundClaimed });
|
|
8653
|
+
}
|
|
8654
|
+
}
|
|
8655
|
+
}
|
|
8656
|
+
|
|
8657
|
+
const finalKinds = [...alreadyAliveKinds, ...claimedKinds];
|
|
8658
|
+
|
|
8659
|
+
// ── --no-serve:只配对不起 worker(S5 命令签名之一)─────────────────────
|
|
8660
|
+
if (noServe) {
|
|
8661
|
+
if (json) {
|
|
8662
|
+
printScanEvent({ phase: "done", serve: false, runtimes: finalKinds, relayUrl });
|
|
8663
|
+
} else {
|
|
8664
|
+
console.log("ℹ️ --no-serve:已完成配对,不启动 bridge worker。");
|
|
8665
|
+
}
|
|
8666
|
+
return;
|
|
8667
|
+
}
|
|
8668
|
+
|
|
8669
|
+
// ── 起 worker;revoke 由既有共享路径清凭证 + exit(S6,不逆改)──────────
|
|
8670
|
+
if (json) {
|
|
8671
|
+
printScanEvent({
|
|
8672
|
+
phase: "starting_worker",
|
|
8673
|
+
runtimes: finalKinds,
|
|
8674
|
+
relayUrl,
|
|
8675
|
+
note: `on revoke this process/service clears credentials and exits; re-run \`${CLI_COMMAND_NAME} scan\` to re-pair`,
|
|
8676
|
+
});
|
|
8677
|
+
} else {
|
|
8678
|
+
console.log(
|
|
8679
|
+
`若之后在 App 端解绑(或凭证失效),worker 会自动清凭证并退出 —— 到时重跑 \`${CLI_COMMAND_NAME} scan\` 即可重新配对。`,
|
|
8680
|
+
);
|
|
8681
|
+
}
|
|
8682
|
+
await startScanWorkers({ kinds: finalKinds, relayUrl, gateway, json });
|
|
8683
|
+
}
|
|
8684
|
+
|
|
7963
8685
|
async function runBridge({ json, relay, noRelay, gateway, runtime }) {
|
|
7964
8686
|
const configPath = resolveOpenClawConfigPath();
|
|
7965
8687
|
// 先容忍读(缺文件 → null):runtimeKind 可能来自 ~/.agentlink state 的
|
|
@@ -8213,10 +8935,11 @@ async function runBridge({ json, relay, noRelay, gateway, runtime }) {
|
|
|
8213
8935
|
}
|
|
8214
8936
|
|
|
8215
8937
|
function runStatus({ json, runtime }) {
|
|
8216
|
-
//
|
|
8938
|
+
// ext-agent runtime 不走 bin 的 openclaw/hermes 状态视图(normalizeRuntimeKind 会把
|
|
8217
8939
|
// 它们静默回退成 openclaw,导致展示错 runtime 的状态)。显式拦下并指向正确命令,
|
|
8218
|
-
//
|
|
8219
|
-
|
|
8940
|
+
// 而非静默误导。(opencode 更早已被 assertRuntimeBridgeSupportedOrFail 拦停,
|
|
8941
|
+
// 这里保留登记表判据是双保险 —— 两道闸的判据不同、都不能漏。)
|
|
8942
|
+
if (EXT_AGENT_RUNTIME_KINDS.includes(runtime)) {
|
|
8220
8943
|
fail(t("status_ext_runtime_unsupported", { runtime, cli: CLI_COMMAND_NAME }));
|
|
8221
8944
|
}
|
|
8222
8945
|
const configPath = resolveOpenClawConfigPath();
|
|
@@ -8267,14 +8990,23 @@ function runStatus({ json, runtime }) {
|
|
|
8267
8990
|
: null,
|
|
8268
8991
|
};
|
|
8269
8992
|
|
|
8270
|
-
//
|
|
8993
|
+
// 多端汇总(不指定 -r 时也能一眼看全**可配对** runtime 的配对 + 服务态),
|
|
8271
8994
|
// 对称 pair/reset 的「无 -r = 全部」。paired 走 HOME 隔离的凭证判定;service 态走
|
|
8272
8995
|
// 实际服务管理器(展示用途,反映真实情况)。best-effort,不让汇总失败影响主输出。
|
|
8273
|
-
|
|
8996
|
+
// 🔴 这里刻意用 **可配对集** 而不是已知枚举全集:本视图展示的是「配对态 + 服务态」,
|
|
8997
|
+
// 而 enum-only runtime(deepagents)永远没有配对、也没有 service,
|
|
8998
|
+
// 加进来只会长期多出恒为空的行 = 噪音。它们的"装没装"由 pair 的探测清单负责。
|
|
8999
|
+
status.runtimes = PAIRABLE_RUNTIME_KINDS.map((k) => {
|
|
8274
9000
|
let paired = false;
|
|
8275
9001
|
try { paired = runtimeHasLocalState(k); } catch { /* ignore */ }
|
|
8276
9002
|
let svc = { installed: false, running: false };
|
|
8277
|
-
|
|
9003
|
+
// 🔴 无 bridge 实现的 runtime(PAIR_ONLY,当前 = opencode)**不探服务态**:
|
|
9004
|
+
// getBridgeServiceStatus → serviceNames() → normalizeRuntimeKind(x,"openclaw")
|
|
9005
|
+
// 会把它别名到 openclaw 的 plist/unit,于是「openclaw 的 bridge 在跑」会被
|
|
9006
|
+
// 展示成「opencode 的服务在跑」= 静默错配。它压根没有 service,恒 false 才是实话。
|
|
9007
|
+
if (BRIDGE_CAPABLE_RUNTIME_KINDS.includes(k)) {
|
|
9008
|
+
try { svc = getBridgeServiceStatus(k); } catch { /* ignore */ }
|
|
9009
|
+
}
|
|
8278
9010
|
return { kind: k, paired, serviceInstalled: !!svc.installed, serviceRunning: !!svc.running };
|
|
8279
9011
|
});
|
|
8280
9012
|
|
|
@@ -8495,6 +9227,15 @@ async function forwardToAgent(command, options) {
|
|
|
8495
9227
|
|
|
8496
9228
|
async function main() {
|
|
8497
9229
|
const { command, options } = parseArgs(process.argv.slice(2));
|
|
9230
|
+
// Must happen before any pairing/runtime dispatch: every downstream resolver
|
|
9231
|
+
// reads AGENTLINK_HOME, and external-agent child processes inherit it.
|
|
9232
|
+
if (options.agentlinkHome) {
|
|
9233
|
+
process.env.AGENTLINK_HOME = options.agentlinkHome;
|
|
9234
|
+
}
|
|
9235
|
+
if (command === "capabilities") {
|
|
9236
|
+
process.stdout.write(`${JSON.stringify(RUNTIME_CAPABILITIES)}\n`);
|
|
9237
|
+
return;
|
|
9238
|
+
}
|
|
8498
9239
|
// `agentlink pair <code>` / `agentlink reset` 默认分别为本机已安装的 4 种
|
|
8499
9240
|
// runtime 各 spawn 一次(不写死默认 openclaw —— 四端互不依赖:没装 openclaw
|
|
8500
9241
|
// 的机器也能整机 reset)。`--runtime all` 保持为兼容别名。
|
|
@@ -8510,13 +9251,26 @@ async function main() {
|
|
|
8510
9251
|
if (command !== "pair" && command !== "reset" && options.runtime === "all") {
|
|
8511
9252
|
fail(`--runtime all 仅 \`${CLI_COMMAND_NAME} pair\` / \`${CLI_COMMAND_NAME} reset\` 命令支持`);
|
|
8512
9253
|
}
|
|
8513
|
-
//
|
|
8514
|
-
//
|
|
8515
|
-
|
|
9254
|
+
// 🔴 已知枚举 ∖ 可配对集(deepagents):显式拦停,绝不放进实现路径。
|
|
9255
|
+
// 判据与理由都在 assertRuntimeOperableOrFail 的文档注释里(单一事实源)。
|
|
9256
|
+
assertRuntimeOperableOrFail(options.runtime);
|
|
9257
|
+
// 🔴 可配对 ∖ 可桥接(opencode):pair / reset 放行,其余"要把进程跑起来 / 展示
|
|
9258
|
+
// 运行态"的命令显式拦停 —— 见 assertRuntimeBridgeSupportedOrFail 的文档注释。
|
|
9259
|
+
// 放在分派之前统一做,避免每个 run* 各自记得判一次(漏一个就退化成静默回落)。
|
|
9260
|
+
if (command === "bridge" || command === "service" || command === "reload" ||
|
|
9261
|
+
command === "scan" || command === "status" || command === "setup" ||
|
|
9262
|
+
command === "prepare") {
|
|
9263
|
+
assertRuntimeBridgeSupportedOrFail(options.runtime, command);
|
|
9264
|
+
}
|
|
9265
|
+
// External Agents (Claude / Codex / OpenCode) shim ——
|
|
9266
|
+
// 统一把 `agentlink pair <code> -r <ext>`、`bridge -r <ext>`、
|
|
9267
|
+
// `reset -r <ext>`(→ uninstall)转发到 agentlink-agent。避免 external
|
|
8516
9268
|
// 平行世界代码污染老 bin,也防止 reset 被 normalizeRuntimeKind 回退成 openclaw。
|
|
9269
|
+
// 目标集用 EXT_AGENT_RUNTIME_KINDS(登记表),不再写死 claude/codex 字面量 ——
|
|
9270
|
+
// opencode 的凭证同样存 ext-<rt>-last.json,必须走同一条转发路径。
|
|
8517
9271
|
if ((command === "pair" || command === "bridge" || command === "service" ||
|
|
8518
9272
|
command === "uninstall" || command === "reset") &&
|
|
8519
|
-
(options.runtime
|
|
9273
|
+
EXT_AGENT_RUNTIME_KINDS.includes(options.runtime)) {
|
|
8520
9274
|
await forwardToAgent(command, options);
|
|
8521
9275
|
return;
|
|
8522
9276
|
}
|
|
@@ -8534,6 +9288,9 @@ async function main() {
|
|
|
8534
9288
|
case "bridge":
|
|
8535
9289
|
await runBridge(options);
|
|
8536
9290
|
return;
|
|
9291
|
+
case "scan":
|
|
9292
|
+
await runScan(options);
|
|
9293
|
+
return;
|
|
8537
9294
|
case "service":
|
|
8538
9295
|
await runService(options);
|
|
8539
9296
|
return;
|