@xyagent/cli 0.0.1 → 1.0.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/bin/agentlink +551 -176
- package/bin/agentlink-agent +0 -0
- package/bin/agentlink-mcp-stdio +0 -0
- package/package.json +4 -2
- package/src/core.mjs +26 -0
- package/src-ext/bin.mjs +0 -1
- package/src-ext/commands/agent.mjs +0 -11
- 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/relayWorker.mjs +1 -1
- 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/unifiedDispatchHandler.mjs +3 -3
- 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/relayObjectToBlock.mjs +9 -11
- package/src-ext/runtime/_shared/todoTranslatorUtils.mjs +1 -1
- package/src-ext/runtime/claude/launcher.mjs +0 -6
- package/src-ext/runtime/openclaw/buildOpenclawDaemonInput.mjs +0 -6
- 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,
|
|
@@ -62,6 +63,9 @@ 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";
|
|
64
65
|
import { buildPairSessionResults } from "../src-ext/core/pairInventory.mjs";
|
|
66
|
+
import { SCAN_QR_PNG_OUTPUT_PATH, clearQRImage } from "../src-ext/core/scanPairFlow.mjs";
|
|
67
|
+
import { renderQrToTerminal } from "../src-ext/core/scanQrRenderer.mjs";
|
|
68
|
+
import { buildScanDeeplink, renderScanQrPng } from "../src-ext/core/scanDeeplink.mjs";
|
|
65
69
|
|
|
66
70
|
// Minimum Node.js 22+ — required for the built-in WebSocket client and the
|
|
67
71
|
// runtime surface we rely on for bridge/service behavior across platforms.
|
|
@@ -102,6 +106,12 @@ const SERVICE_UNIT_NAME = "agentlink-bridge.service";
|
|
|
102
106
|
const SERVICE_TASK_NAME = "Agentlink Bridge";
|
|
103
107
|
const RUNTIME_KIND_OPENCLAW = "openclaw";
|
|
104
108
|
const RUNTIME_KIND_HERMES = "hermes";
|
|
109
|
+
// scan-capable runtimes (unify-scan-pairing): openclaw/hermes read their gateway
|
|
110
|
+
// credentials locally (~/.agentlink/config.json via ensureRelayBridgeCredentials)
|
|
111
|
+
// and can host-claim immediately. claude/codex credentials are issued by the
|
|
112
|
+
// server at consume-time — no host-claim path exists for them this cycle (see
|
|
113
|
+
// spec "术语" + Non-goals; S2b covers the explicit unsupported-runtime notice).
|
|
114
|
+
const SCAN_CAPABLE_RUNTIME_KINDS = Object.freeze([RUNTIME_KIND_OPENCLAW, RUNTIME_KIND_HERMES]);
|
|
105
115
|
|
|
106
116
|
function serviceNames(runtimeKind) {
|
|
107
117
|
const kind = normalizeRuntimeKind(runtimeKind, RUNTIME_KIND_OPENCLAW);
|
|
@@ -774,6 +784,7 @@ function printHelp() {
|
|
|
774
784
|
` ${CLI_COMMAND_NAME} pair <4-digit|6-digit|8-alnum> [-r|--runtime <openclaw|hermes|claude|codex|all>] [--mode <relay>] [--strict-plugin] # default: all`,
|
|
775
785
|
` ${CLI_COMMAND_NAME} reset [-r|--runtime <openclaw|hermes|claude|codex|all>] [--relay <url>] [--gateway <url>] [--json] # default: all`,
|
|
776
786
|
` ${CLI_COMMAND_NAME} bridge [--relay <url>] [--gateway <url>] [-r|--runtime <openclaw|hermes|claude|codex>] [--json]`,
|
|
787
|
+
` ${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
788
|
` ${CLI_COMMAND_NAME} service <status|install|stop|uninstall|restart> [--relay <url>] [--gateway <url>] [--runtime <openclaw|hermes>] [--json]`,
|
|
778
789
|
` ${CLI_COMMAND_NAME} reload [-r|--runtime <openclaw|hermes|claude|codex>] [--json]`,
|
|
779
790
|
` ${CLI_COMMAND_NAME} pair-url [<4-digit|6-digit|8-alnum>] [--account <id>] [--code <4-digit|6-digit|8-alnum>] [--json]`,
|
|
@@ -838,6 +849,13 @@ function parseArgs(argv) {
|
|
|
838
849
|
doctorAction: undefined,
|
|
839
850
|
mcpSubcommand: undefined,
|
|
840
851
|
mcpRuntime: undefined,
|
|
852
|
+
noServe: false,
|
|
853
|
+
qrPath: undefined,
|
|
854
|
+
// Internal plumbing flag — not advertised in --help. Set by `runScan`'s
|
|
855
|
+
// per-runtime host-claim child spawn (`--pair-mode scan`), never expected
|
|
856
|
+
// from a human-typed `agentlink pair` invocation. See runPair()'s
|
|
857
|
+
// pairMode branch (unify-scan-pairing).
|
|
858
|
+
pairMode: "",
|
|
841
859
|
};
|
|
842
860
|
|
|
843
861
|
while (args.length > 0) {
|
|
@@ -893,6 +911,31 @@ function parseArgs(argv) {
|
|
|
893
911
|
options.fix = true;
|
|
894
912
|
continue;
|
|
895
913
|
}
|
|
914
|
+
if (token === "--no-serve") {
|
|
915
|
+
// `agentlink scan` 专属:只配对不起 worker(spec S5)。与已有的
|
|
916
|
+
// --no-serve-relay(options.serveRelay,pair 命令的"配完对不装后台服务"
|
|
917
|
+
// 语义)刻意分开——scan 这里是"不启动前台 bridge worker",不是装/不装
|
|
918
|
+
// service,两者不通用。
|
|
919
|
+
options.noServe = true;
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
if (token === "--qr-path") {
|
|
923
|
+
const value = args.shift();
|
|
924
|
+
if (!value || value.startsWith("--")) fail(t("err_missing_value", { flag: "--qr-path" }));
|
|
925
|
+
options.qrPath = value.trim();
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
if (token === "--pair-mode") {
|
|
929
|
+
// Internal — see options.pairMode default comment above.
|
|
930
|
+
const value = args.shift();
|
|
931
|
+
if (!value || value.startsWith("--")) fail(t("err_missing_value", { flag: "--pair-mode" }));
|
|
932
|
+
const normalized = value.trim().toLowerCase();
|
|
933
|
+
if (normalized !== "" && normalized !== "scan") {
|
|
934
|
+
fail(`--pair-mode must be "scan" (got ${value})`);
|
|
935
|
+
}
|
|
936
|
+
options.pairMode = normalized;
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
896
939
|
|
|
897
940
|
if (token === "--relay") {
|
|
898
941
|
const value = args.shift();
|
|
@@ -4199,6 +4242,14 @@ async function publishPairToRelay({
|
|
|
4199
4242
|
relayClientToken,
|
|
4200
4243
|
relayGatewayToken,
|
|
4201
4244
|
accountId,
|
|
4245
|
+
// unify-scan-pairing: "" (default, pair code path) | "scan". Threaded into
|
|
4246
|
+
// the request body so the server can allow an empty clientUserId for the
|
|
4247
|
+
// scan host-claim branch (spec "服务端支持 scan 模式的 pending 归属").
|
|
4248
|
+
// Never persisted server-side — request-only semantics (spec L81).
|
|
4249
|
+
pairMode = "",
|
|
4250
|
+
// scan pending rows SHALL expire within 5 minutes (spec 安全信封 §2); the
|
|
4251
|
+
// pair-code path keeps its existing 600s default.
|
|
4252
|
+
ttlSeconds = 600,
|
|
4202
4253
|
}) {
|
|
4203
4254
|
const url = `${relayUrl}/v1/pair-sessions`;
|
|
4204
4255
|
const headers = {
|
|
@@ -4220,9 +4271,10 @@ async function publishPairToRelay({
|
|
|
4220
4271
|
...(relayClientToken ? { relayClientToken } : {}),
|
|
4221
4272
|
...(relayGatewayToken ? { relayGatewayToken } : {}),
|
|
4222
4273
|
accountId,
|
|
4223
|
-
ttlSeconds
|
|
4274
|
+
ttlSeconds,
|
|
4224
4275
|
source: "agentlink-cli",
|
|
4225
4276
|
hostName: normalizeHostName(os.hostname()),
|
|
4277
|
+
...(pairMode ? { pairMode } : {}),
|
|
4226
4278
|
}),
|
|
4227
4279
|
});
|
|
4228
4280
|
const text = await response.text();
|
|
@@ -5645,10 +5697,14 @@ async function handleUnifiedRunDispatch({
|
|
|
5645
5697
|
// 每轮都是全新 session → 上下文断裂(实测 "go on" 不知道前文是什么)。
|
|
5646
5698
|
// 用 thread_id 后,同一 agentlink thread 内的所有 turn 落到 openclaw 的
|
|
5647
5699
|
// 同一个 session bucket,上下文自然续接。
|
|
5700
|
+
// unify-scan-pairing 全量移除了上一代扫码硬件的命名空间归一 hack:扫码路径
|
|
5701
|
+
// 现在与 pair code 共用同一套真实 runtime binding(openclaw/hermes),不再有
|
|
5702
|
+
// App 以合成命名空间 agent 绑定的场景,agentId 直接原样使用。
|
|
5703
|
+
const agentId = String(inner.agent_id || request.agentId || "main").trim() || "main";
|
|
5648
5704
|
const headers = {
|
|
5649
5705
|
"content-type": "application/json",
|
|
5650
5706
|
"accept": "text/event-stream",
|
|
5651
|
-
"x-openclaw-agent-id":
|
|
5707
|
+
"x-openclaw-agent-id": agentId,
|
|
5652
5708
|
"x-openclaw-session-key": thread_id,
|
|
5653
5709
|
"x-openclaw-scopes": DEFAULT_GATEWAY_SCOPES,
|
|
5654
5710
|
};
|
|
@@ -5664,7 +5720,7 @@ async function handleUnifiedRunDispatch({
|
|
|
5664
5720
|
// (which lives in agents.defaults.model.primary). The accepted forms are
|
|
5665
5721
|
// `openclaw` (default agent) or `openclaw/<agentId>`. The agentId comes from
|
|
5666
5722
|
// the dispatch payload's agent_id (or "main" if absent).
|
|
5667
|
-
|
|
5723
|
+
// (agentId 已在上方解析,缺省回退 "main"。)
|
|
5668
5724
|
if (!jsonMode) {
|
|
5669
5725
|
const blockSummary = blocks.map((b) => {
|
|
5670
5726
|
const kind = b?.source?.kind ? `/${b.source.kind}` : "";
|
|
@@ -6531,6 +6587,176 @@ function detectInstalledRuntimes({ probeVersion = true } = {}) {
|
|
|
6531
6587
|
return results;
|
|
6532
6588
|
}
|
|
6533
6589
|
|
|
6590
|
+
// ─── per-runtime local pair-state helpers ───────────────────────────────────
|
|
6591
|
+
// 抽出自 runPairAll(原为其内部闭包):unify-scan-pairing 的 runScan 复用同一套
|
|
6592
|
+
// "本机已有凭证 + 服务端 binding 是否还活着" 判定逻辑(spec S3),避免两套配对
|
|
6593
|
+
// 代码漂移。纯函数/无 runPairAll 局部状态捕获,提到顶层不改变行为。
|
|
6594
|
+
|
|
6595
|
+
// openclaw + hermes 的 pair state 都存在 ~/.agentlink/config.json:
|
|
6596
|
+
// openclaw → relayGatewayId/relayGatewayToken
|
|
6597
|
+
// hermes → hermesRelayGatewayId/hermesRelayGatewayToken
|
|
6598
|
+
// claude/codex 存在 ~/.agentlink/ext-<runtime>-last.json。
|
|
6599
|
+
//
|
|
6600
|
+
// 返回 { gw_id, token, relay_url } 或 null。
|
|
6601
|
+
function readLocalPairState(kind) {
|
|
6602
|
+
try {
|
|
6603
|
+
const home = process.env.HOME || "";
|
|
6604
|
+
if (!home) return null;
|
|
6605
|
+
if (kind === "claude" || kind === "codex") {
|
|
6606
|
+
const p = `${home}/.agentlink/ext-${kind}-last.json`;
|
|
6607
|
+
if (!fs.existsSync(p)) return null;
|
|
6608
|
+
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
6609
|
+
const gw = j?.gw_id || "";
|
|
6610
|
+
const tok = j?.bridge_token || "";
|
|
6611
|
+
if (!gw || !tok) return null;
|
|
6612
|
+
return { gw_id: gw, token: tok, relay_url: j?.relay_url || "" };
|
|
6613
|
+
}
|
|
6614
|
+
if (kind === "openclaw" || kind === "hermes") {
|
|
6615
|
+
const p = `${home}/.agentlink/config.json`;
|
|
6616
|
+
if (!fs.existsSync(p)) return null;
|
|
6617
|
+
let j;
|
|
6618
|
+
try { j = JSON.parse(fs.readFileSync(p, "utf8")); }
|
|
6619
|
+
catch { return null; }
|
|
6620
|
+
const idKey = kind === "openclaw" ? "relayGatewayId" : "hermesRelayGatewayId";
|
|
6621
|
+
const tokKey = kind === "openclaw" ? "relayGatewayToken" : "hermesRelayGatewayToken";
|
|
6622
|
+
const gw = j?.[idKey] || "";
|
|
6623
|
+
const tok = j?.[tokKey] || "";
|
|
6624
|
+
if (!gw) return null;
|
|
6625
|
+
return { gw_id: gw, token: tok, relay_url: j?.relayUrl || "" };
|
|
6626
|
+
}
|
|
6627
|
+
return null;
|
|
6628
|
+
} catch {
|
|
6629
|
+
return null;
|
|
6630
|
+
}
|
|
6631
|
+
}
|
|
6632
|
+
|
|
6633
|
+
// 删本机 state 文件(App 删除后 server 没了,CLI 需要清干净本机才能 re-pair)。
|
|
6634
|
+
function clearLocalPairState(kind) {
|
|
6635
|
+
try {
|
|
6636
|
+
const home = process.env.HOME || "";
|
|
6637
|
+
if (!home) return;
|
|
6638
|
+
if (kind === "claude" || kind === "codex") {
|
|
6639
|
+
const p = `${home}/.agentlink/ext-${kind}-last.json`;
|
|
6640
|
+
if (fs.existsSync(p)) {
|
|
6641
|
+
try { fs.unlinkSync(p); } catch {}
|
|
6642
|
+
}
|
|
6643
|
+
return;
|
|
6644
|
+
}
|
|
6645
|
+
if (kind === "openclaw" || kind === "hermes") {
|
|
6646
|
+
const p = `${home}/.agentlink/config.json`;
|
|
6647
|
+
if (!fs.existsSync(p)) return;
|
|
6648
|
+
let j;
|
|
6649
|
+
try { j = JSON.parse(fs.readFileSync(p, "utf8")); } catch { return; }
|
|
6650
|
+
const idKey = kind === "openclaw" ? "relayGatewayId" : "hermesRelayGatewayId";
|
|
6651
|
+
const tokKey = kind === "openclaw" ? "relayGatewayToken" : "hermesRelayGatewayToken";
|
|
6652
|
+
const clientTokKey = kind === "openclaw" ? "relayClientToken" : "hermesRelayClientToken";
|
|
6653
|
+
delete j[idKey];
|
|
6654
|
+
delete j[tokKey];
|
|
6655
|
+
delete j[clientTokKey];
|
|
6656
|
+
try { fs.writeFileSync(p, JSON.stringify(j, null, 2)); } catch {}
|
|
6657
|
+
}
|
|
6658
|
+
} catch {}
|
|
6659
|
+
}
|
|
6660
|
+
|
|
6661
|
+
// 服务端探"绑定状态":GET /v1/gateways/<gw>/binding 返回当前 active
|
|
6662
|
+
// (consumed=1) 的 app-user 绑定。
|
|
6663
|
+
// - 404/401/410 → 服务端 pair_session 已不存在/无权 → 视为已删
|
|
6664
|
+
// - 200 且 binding=null → 网关 session 还在但没有 active 绑定(典型:App
|
|
6665
|
+
// 删除了智能体把 pair_session 软删;或网关重连
|
|
6666
|
+
// host-claim 复活出一条 consumed=0 的行)→ 视为已删
|
|
6667
|
+
// - 200 且 binding!=null → 仍有 active 绑定 → 还在
|
|
6668
|
+
// - 5xx / 超时 → 网络/服务端问题,保守按"还在"处理,不误删本机 state
|
|
6669
|
+
//
|
|
6670
|
+
// 不要用 /meta 探活:那只校验网关 bridge_token / 进程是否存活,网关还在跑就返
|
|
6671
|
+
// 200,**不反映 app-user 绑定是否已被删**。openclaw/hermes 网关常驻,App 删除
|
|
6672
|
+
// 智能体后 /meta 仍 200 → 误判"已配对"、跳过重配对(这正是删 openclaw 后再添加
|
|
6673
|
+
// 显示"已配对"的根因)。
|
|
6674
|
+
//
|
|
6675
|
+
// 也不要比对 binding.code 跟用户传的 code —— pair code 每次扫码新生成,用户拿
|
|
6676
|
+
// 新 code 重跑 pair 时 binding 里仍是旧 code,对不上是常态;只判断 binding 是否
|
|
6677
|
+
// 存在即可,避免把"换码重配对"误判成需要 re-pair。
|
|
6678
|
+
async function probeRelayBinding(state) {
|
|
6679
|
+
if (!state || !state.gw_id) {
|
|
6680
|
+
return { alive: true, reason: "no_probe_url" };
|
|
6681
|
+
}
|
|
6682
|
+
// 老 state 缺 relay_url(典型 openclaw 老格式)→ 回退 env / 内置默认 relay
|
|
6683
|
+
// 再探,而不是直接按"已配对"跳过:那条逃逸正是 P0#18 ReOpen 的残留路径——
|
|
6684
|
+
// App 删除智能体后用户重新 pair,老 state 机器永远卡在「已配对,跳过」。
|
|
6685
|
+
let relayUrl = String(state.relay_url || "").trim();
|
|
6686
|
+
if (!relayUrl) {
|
|
6687
|
+
relayUrl =
|
|
6688
|
+
String(process.env.OPENIM_RELAY_URL || "").trim() ||
|
|
6689
|
+
String(process.env.AGENTLINK_RELAY_URL || "").trim() ||
|
|
6690
|
+
DEFAULT_RELAY_URL;
|
|
6691
|
+
}
|
|
6692
|
+
if (!state.token) {
|
|
6693
|
+
// 没有网关凭证 → 探不了也证明不了 binding 存活。用户此刻正在主动
|
|
6694
|
+
// `pair <code>`,重配对幂等且是用户意图;按 stale 走清理+重配对,
|
|
6695
|
+
// 比误判「已配对」把用户卡死安全。
|
|
6696
|
+
return { alive: false, reason: "no_gateway_token" };
|
|
6697
|
+
}
|
|
6698
|
+
const base = relayUrl.replace(/\/$/, "");
|
|
6699
|
+
const url = `${base}/v1/gateways/${encodeURIComponent(state.gw_id)}/binding`;
|
|
6700
|
+
const controller = new AbortController();
|
|
6701
|
+
const timer = setTimeout(() => controller.abort(), 3000);
|
|
6702
|
+
try {
|
|
6703
|
+
const resp = await fetch(url, {
|
|
6704
|
+
method: "GET",
|
|
6705
|
+
signal: controller.signal,
|
|
6706
|
+
headers: {
|
|
6707
|
+
"Authorization": `Bearer ${state.token}`,
|
|
6708
|
+
},
|
|
6709
|
+
});
|
|
6710
|
+
clearTimeout(timer);
|
|
6711
|
+
// 401 (token 无效) / 404 (gateway session 不存在) / 410 → server 端已删
|
|
6712
|
+
if (resp.status === 401 || resp.status === 404 || resp.status === 410) {
|
|
6713
|
+
return { alive: false, reason: `http_${resp.status}` };
|
|
6714
|
+
}
|
|
6715
|
+
if (resp.ok) {
|
|
6716
|
+
let body = null;
|
|
6717
|
+
try {
|
|
6718
|
+
body = await resp.json();
|
|
6719
|
+
} catch {
|
|
6720
|
+
// 解析失败时保守按"还在"处理,不误删
|
|
6721
|
+
return { alive: true, reason: "binding_parse_failed_assume_alive" };
|
|
6722
|
+
}
|
|
6723
|
+
const binding =
|
|
6724
|
+
body && typeof body === "object" ? body.binding : undefined;
|
|
6725
|
+
// binding 为 null/缺失 → 没有 active 绑定 → 视为已删
|
|
6726
|
+
if (binding == null) {
|
|
6727
|
+
return { alive: false, reason: "binding_null" };
|
|
6728
|
+
}
|
|
6729
|
+
return { alive: true, reason: `http_${resp.status}` };
|
|
6730
|
+
}
|
|
6731
|
+
// 500/503 等服务端错误 → 保守不删
|
|
6732
|
+
return { alive: true, reason: `http_${resp.status}_assume_alive` };
|
|
6733
|
+
} catch (err) {
|
|
6734
|
+
clearTimeout(timer);
|
|
6735
|
+
// 网络超时 / DNS 失败 → 保守不删
|
|
6736
|
+
return { alive: true, reason: `network_err_assume_alive` };
|
|
6737
|
+
}
|
|
6738
|
+
}
|
|
6739
|
+
|
|
6740
|
+
// 对应 runtime 的"重置后重新配对"命令提示。
|
|
6741
|
+
function resetHintFor(kind, code) {
|
|
6742
|
+
if (kind === "claude" || kind === "codex") {
|
|
6743
|
+
return `agentlink uninstall -r ${kind} && agentlink pair ${code} -r ${kind}`;
|
|
6744
|
+
}
|
|
6745
|
+
// openclaw / hermes 走 reset 子命令
|
|
6746
|
+
return `agentlink reset -r ${kind} && agentlink pair ${code} -r ${kind}`;
|
|
6747
|
+
}
|
|
6748
|
+
|
|
6749
|
+
// 每 runtime 一次 host-claim 子进程 spawn(unify-scan-pairing 抽出,供
|
|
6750
|
+
// runPairAll 与 runScan 共用,"出码方式"通过 extraArgv 注入 —— pair code
|
|
6751
|
+
// 路径转发原始 argv 不变;scan 路径注入 `pair <code> --pair-mode scan
|
|
6752
|
+
// --no-serve-relay ...`,见 runScan())。隔离 fail()/process.exit:单个
|
|
6753
|
+
// runtime 失败不影响其余 runtime(每个都是独立子进程)。
|
|
6754
|
+
function spawnPairChildForRuntime({ argvTail, runtimeKind, env }) {
|
|
6755
|
+
const argv = [CLI_ENTRY, ...argvTail, "--runtime", runtimeKind];
|
|
6756
|
+
const child = spawnSync(process.execPath, argv, { stdio: "inherit", env });
|
|
6757
|
+
return { kind: runtimeKind, ok: child.status === 0, status: child.status ?? null };
|
|
6758
|
+
}
|
|
6759
|
+
|
|
6534
6760
|
// `agentlink pair <code>` 默认批量配对所有已安装 runtime。
|
|
6535
6761
|
// 也兼容 `agentlink pair <code> --runtime all` 显式写法。
|
|
6536
6762
|
// 检测本机安装的 4 种 runtime(openclaw / hermes / claude / codex),
|
|
@@ -6642,159 +6868,8 @@ async function runPairAll(options) {
|
|
|
6642
6868
|
AGENTLINK_AGENT_LOG_LEVEL: process.env.AGENTLINK_AGENT_LOG_LEVEL || "warn",
|
|
6643
6869
|
};
|
|
6644
6870
|
|
|
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
|
-
}
|
|
6871
|
+
// readLocalPairState / clearLocalPairState / probeRelayBinding / resetHintFor
|
|
6872
|
+
// 已提到顶层(供 runScan 复用,见其上方注释),行为不变。
|
|
6798
6873
|
|
|
6799
6874
|
const summary = [];
|
|
6800
6875
|
for (const kind of installedKinds) {
|
|
@@ -6825,12 +6900,10 @@ async function runPairAll(options) {
|
|
|
6825
6900
|
clearLocalPairState(kind);
|
|
6826
6901
|
}
|
|
6827
6902
|
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 });
|
|
6903
|
+
const { ok, status } = spawnPairChildForRuntime({ argvTail: forwardedArgv, runtimeKind: kind, env: quietEnv });
|
|
6904
|
+
summary.push({ kind, ok, status });
|
|
6832
6905
|
if (!ok) {
|
|
6833
|
-
console.warn(t("pair_all_runtime_pair_failed", { kind, status:
|
|
6906
|
+
console.warn(t("pair_all_runtime_pair_failed", { kind, status: status ?? "?" }));
|
|
6834
6907
|
}
|
|
6835
6908
|
}
|
|
6836
6909
|
|
|
@@ -7040,7 +7113,12 @@ async function runPair({
|
|
|
7040
7113
|
runtime,
|
|
7041
7114
|
strictPlugin,
|
|
7042
7115
|
allowRebind,
|
|
7116
|
+
// unify-scan-pairing: "" (default, pair code manual-entry path) | "scan".
|
|
7117
|
+
// Set internally by runScan's per-runtime host-claim child spawn
|
|
7118
|
+
// (`--pair-mode scan`) — never by a human-typed `agentlink pair` call.
|
|
7119
|
+
pairMode = "",
|
|
7043
7120
|
}) {
|
|
7121
|
+
const isScanMode = pairMode === "scan";
|
|
7044
7122
|
if (!explicitCode || !String(explicitCode).trim()) {
|
|
7045
7123
|
fail(t("pair_usage_missing"));
|
|
7046
7124
|
}
|
|
@@ -7067,11 +7145,24 @@ async function runPair({
|
|
|
7067
7145
|
);
|
|
7068
7146
|
}
|
|
7069
7147
|
|
|
7070
|
-
|
|
7071
|
-
|
|
7072
|
-
|
|
7073
|
-
|
|
7074
|
-
|
|
7148
|
+
// scan 模式 SHALL 不做 host-ready 预检(spec P1-4):code 由 CLI 本地生成、
|
|
7149
|
+
// 无 App 预建行,readPairSessionFromRelay 的 GET 预检会因"行不存在"误挡。
|
|
7150
|
+
// 直接以合成的空会话继续,走下面统一的 publishPairToRelay(pairMode=scan)
|
|
7151
|
+
// Upsert 建 pending 行。
|
|
7152
|
+
const currentSession = isScanMode
|
|
7153
|
+
? {
|
|
7154
|
+
accountId: "",
|
|
7155
|
+
runtimeKind: requestedRuntime,
|
|
7156
|
+
transportMode: "relay",
|
|
7157
|
+
clientUserID: "",
|
|
7158
|
+
bindUrl: "",
|
|
7159
|
+
consumed: false,
|
|
7160
|
+
}
|
|
7161
|
+
: await readPairSessionFromRelay({
|
|
7162
|
+
relayUrl,
|
|
7163
|
+
code,
|
|
7164
|
+
runtimeKind: requestedRuntime,
|
|
7165
|
+
});
|
|
7075
7166
|
if (!currentSession) {
|
|
7076
7167
|
fail(
|
|
7077
7168
|
[
|
|
@@ -7092,15 +7183,18 @@ async function runPair({
|
|
|
7092
7183
|
const runtimeKind = requestedRuntime;
|
|
7093
7184
|
// 复合主键升级后,GET 已经按 runtimeKind 精确取了对应的行,server 不会
|
|
7094
7185
|
// 再返回其他 runtime 的数据,这里 mismatch 不可能发生 —— 保留旧检查仅作冗余防护。
|
|
7095
|
-
|
|
7186
|
+
// scan 模式没有真实 GET 结果(currentSession 是本函数合成的),该检查天然满足,跳过即可。
|
|
7187
|
+
if (!isScanMode && runtime && runtimeKind !== sessionRuntimeKind) {
|
|
7096
7188
|
fail(t("pair_runtime_mismatch", { app: sessionRuntimeKind, cli: runtimeKind }));
|
|
7097
7189
|
}
|
|
7098
7190
|
const transportMode = normalizeChatTransportMode(
|
|
7099
7191
|
mode,
|
|
7100
7192
|
normalizeChatTransportMode(currentSession.transportMode, "relay"),
|
|
7101
7193
|
);
|
|
7194
|
+
// scan 模式:clientUserId 由 App scan-claim 时从 Bearer 派生,host-claim 阶段
|
|
7195
|
+
// 天生为空(spec「服务端支持 scan 模式的 pending 归属」)——不是错误,不 fail()。
|
|
7102
7196
|
const clientUserId = String(currentSession.clientUserID ?? "").trim().toLowerCase();
|
|
7103
|
-
if (!clientUserId) {
|
|
7197
|
+
if (!isScanMode && !clientUserId) {
|
|
7104
7198
|
fail(
|
|
7105
7199
|
[
|
|
7106
7200
|
t("pair_missing_client_user_id"),
|
|
@@ -7176,8 +7270,13 @@ async function runPair({
|
|
|
7176
7270
|
process.env.OPENCLAW_CUSTOM_IM_PUBLIC_BASE_URL?.trim() ||
|
|
7177
7271
|
"";
|
|
7178
7272
|
|
|
7273
|
+
// scan 模式:gateway.port 可能尚未配置(真机上通常已配置,但 host-claim 本身
|
|
7274
|
+
// 不依赖它——scan 是 CLI-bridge 模型,relay-worker 轮询,不像 pair code 那条
|
|
7275
|
+
// legacy 直连 webhook 路径需要一个外部可达的 bindUrl)。optional:true 让
|
|
7276
|
+
// resolveGatewayBaseUrl 缺配置时返回 null 而不是 fail()/exit;pair code 路径
|
|
7277
|
+
// (isScanMode=false)行为完全不变。
|
|
7179
7278
|
const localGatewayBaseUrl = runtimeNeedsOpenClaw(runtimeKind)
|
|
7180
|
-
? resolveGatewayBaseUrl({ gateway }, config)
|
|
7279
|
+
? resolveGatewayBaseUrl({ gateway, optional: isScanMode }, config)
|
|
7181
7280
|
: normalizeBaseUrl(gateway) || "";
|
|
7182
7281
|
const gatewayAuthToken = runtimeNeedsOpenClaw(runtimeKind)
|
|
7183
7282
|
? resolveGatewayAuthToken(config)
|
|
@@ -7196,8 +7295,8 @@ async function runPair({
|
|
|
7196
7295
|
accountId,
|
|
7197
7296
|
code,
|
|
7198
7297
|
});
|
|
7199
|
-
const relayBindUrl = currentSession.bindUrl || publicBindUrl || localBindUrl;
|
|
7200
|
-
if (!relayBindUrl) {
|
|
7298
|
+
const relayBindUrl = currentSession.bindUrl || publicBindUrl || localBindUrl || (isScanMode ? "" : null);
|
|
7299
|
+
if (!isScanMode && !relayBindUrl) {
|
|
7201
7300
|
fail(t("pairurl_bind_url_unknown"));
|
|
7202
7301
|
}
|
|
7203
7302
|
|
|
@@ -7291,16 +7390,24 @@ async function runPair({
|
|
|
7291
7390
|
c.startsWith("gateway.http.endpoints.responses.enabled") ||
|
|
7292
7391
|
c.startsWith("gateway.auth."),
|
|
7293
7392
|
);
|
|
7294
|
-
|
|
7393
|
+
// scan 模式跳过重启宽限等待:出码循环追求"CLI 秒起二维码",这段睡眠/探测只是
|
|
7394
|
+
// "让日志更友好"的诊断性等待(gatewayProbe.ok=false 只 warn,从不 fail() ——
|
|
7395
|
+
// 不影响 publishPairToRelay 是否成功),对 pair code 手输路径行为不变
|
|
7396
|
+
// (isScanMode 为 false 时,下面两处判断与改动前完全一致)。
|
|
7397
|
+
if (runtimeNeedsOpenClaw(runtimeKind) && prepared.changed && !isScanMode) {
|
|
7295
7398
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
7296
7399
|
}
|
|
7297
7400
|
|
|
7298
|
-
|
|
7401
|
+
// scan 模式且 gateway.port 未配置(localGatewayBaseUrl=null,见上方 optional:true)
|
|
7402
|
+
// 时完全跳过探测——没有 URL 可探,buildGatewayResponsesUrlOrFail 遇 null 会
|
|
7403
|
+
// fail()/exit,而这段探测本就只是诊断性的(见上方注释),跳过不影响正确性。
|
|
7404
|
+
if (runtimeNeedsOpenClaw(runtimeKind) && (!isScanMode || localGatewayBaseUrl)) {
|
|
7299
7405
|
// 90s covers the observed ~35s restart with ample margin; ~7s handles the
|
|
7300
|
-
// steady-state case where openclaw is already serving.
|
|
7301
|
-
|
|
7406
|
+
// steady-state case where openclaw is already serving. scan 模式只探测
|
|
7407
|
+
// 1 次、不重试(见上方注释)。
|
|
7408
|
+
const PROBE_ATTEMPTS = isScanMode ? 1 : criticalChange ? 90 : prepared.changed ? 15 : 6;
|
|
7302
7409
|
const PROBE_INTERVAL_MS = 1000;
|
|
7303
|
-
const announcedStart = criticalChange;
|
|
7410
|
+
const announcedStart = !isScanMode && criticalChange;
|
|
7304
7411
|
if (announcedStart) {
|
|
7305
7412
|
console.log(t("gateway_wait_restart"));
|
|
7306
7413
|
}
|
|
@@ -7353,6 +7460,7 @@ async function runPair({
|
|
|
7353
7460
|
relayClientToken: relayCredentials?.clientToken,
|
|
7354
7461
|
relayGatewayToken: relayCredentials?.gatewayToken,
|
|
7355
7462
|
accountId,
|
|
7463
|
+
...(isScanMode ? { pairMode: "scan", ttlSeconds: 300 } : {}),
|
|
7356
7464
|
});
|
|
7357
7465
|
|
|
7358
7466
|
const output = {
|
|
@@ -7960,6 +8068,270 @@ function runPairUrl({ json, account, code: explicitCode }) {
|
|
|
7960
8068
|
console.log(resolvedBindUrl);
|
|
7961
8069
|
}
|
|
7962
8070
|
|
|
8071
|
+
// `agentlink scan`:统一扫码配对(unify-scan-pairing,取代 add-openclaw-scan-pairing
|
|
8072
|
+
// 的上一代扫码硬件方案)——CLI 本地出一个高熵 code → 对每个 scan-capable
|
|
8073
|
+
// runtime(openclaw/hermes)host-claim → 渲染中性 deeplink 二维码 → 等 App
|
|
8074
|
+
// scan-claim → 起 worker。配对后效果与 pair code 路径完全一致(同一
|
|
8075
|
+
// `pair_sessions` 底座,同一 App 侧工作区选择流程),只有出码方式不同。
|
|
8076
|
+
//
|
|
8077
|
+
// `scan --json` 跨多阶段持续输出(探测 → 等待扫码 → 配对成功 → 起 worker),
|
|
8078
|
+
// 采用 NDJSON:每阶段一行紧凑 JSON object(见 printScanEvent)。
|
|
8079
|
+
function printScanEvent(fields) {
|
|
8080
|
+
console.log(JSON.stringify({ command: "scan", ...fields }));
|
|
8081
|
+
}
|
|
8082
|
+
|
|
8083
|
+
// claude/codex 在 scan 模式下没有 host-claim 路径(gateway 凭证由服务端在
|
|
8084
|
+
// consume 时才下发,host-claim 阶段无来源——spec 术语 + Non-goals,本期不做
|
|
8085
|
+
// pending 预分配子系统)。检测到时打印明确提示,不静默、不失败(S2b)。
|
|
8086
|
+
function scanUnsupportedRuntimeMessage(kinds) {
|
|
8087
|
+
return `⚠️ ${kinds.join("/")} 暂不支持扫码配对(scan-capable runtime 仅 openclaw/hermes)。请改用 \`${CLI_COMMAND_NAME} pair <code> -r ${kinds[0]}\` 手动配对。`;
|
|
8088
|
+
}
|
|
8089
|
+
|
|
8090
|
+
// env 覆盖仅供测试加速 S4(过期重出码)场景用——与既有
|
|
8091
|
+
// AGENTLINK_SCAN_QR_PNG_PATH 同一约定(见 scanPairFlow.mjs)。生产默认值:
|
|
8092
|
+
// 2s 轮询间隔、300s TTL(与 host-claim 请求的 ttlSeconds 对齐,安全信封 §2)。
|
|
8093
|
+
const SCAN_CLAIM_POLL_INTERVAL_MS = parseNonNegativeInt(process.env.AGENTLINK_SCAN_CLAIM_POLL_INTERVAL_MS) || 2000;
|
|
8094
|
+
// 轮询超过这个窗口还没等到 scan-claim,视为码已过期,重新出码(S4),而不是无限等下去。
|
|
8095
|
+
const SCAN_CLAIM_TTL_MS = (parseNonNegativeInt(process.env.AGENTLINK_SCAN_CLAIM_TTL_SEC) || 300) * 1000;
|
|
8096
|
+
|
|
8097
|
+
// 轮询等待 App scan-claim 完成。复用既有 readPairSessionFromRelay(GET
|
|
8098
|
+
// /v1/pair-sessions/:code)——scan-claim 会把该 code 下全部 pending 行标记
|
|
8099
|
+
// consumed=1(spec「新增端点」),任取其中一个已 host-claim 的 runtimeKind
|
|
8100
|
+
// 查询即可代表整组的归属状态,不需要新端点。
|
|
8101
|
+
async function waitForScanClaim({ relayUrl, code, representativeRuntimeKind, ttlMs, sleepImpl }) {
|
|
8102
|
+
const sleep = sleepImpl || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
8103
|
+
const deadline = Date.now() + ttlMs;
|
|
8104
|
+
for (;;) {
|
|
8105
|
+
const session = await readPairSessionFromRelay({
|
|
8106
|
+
relayUrl,
|
|
8107
|
+
code,
|
|
8108
|
+
runtimeKind: representativeRuntimeKind,
|
|
8109
|
+
});
|
|
8110
|
+
if (session && session.consumed) {
|
|
8111
|
+
return true;
|
|
8112
|
+
}
|
|
8113
|
+
if (Date.now() >= deadline) {
|
|
8114
|
+
return false;
|
|
8115
|
+
}
|
|
8116
|
+
await sleep(SCAN_CLAIM_POLL_INTERVAL_MS);
|
|
8117
|
+
}
|
|
8118
|
+
}
|
|
8119
|
+
|
|
8120
|
+
// 起本地已装 scan-capable runtime 的 worker:单 runtime 用既有前台 runBridge
|
|
8121
|
+
// (与 revoke 时既有 selfUninstallOnRevoke 退出路径完全一致,S6);多 runtime
|
|
8122
|
+
// 复用 runPair `--serve-relay` 分支已经在用的后台服务安装机制(安装成功后各自
|
|
8123
|
+
// 独立常驻,不受本进程退出影响)。S1 的多 runtime 场景是 manual-device 验收
|
|
8124
|
+
// 载体(spec 执行载体声明),这里不额外发明新机制,只编排既有函数。
|
|
8125
|
+
async function startScanWorkers({ kinds, relayUrl, gateway, json }) {
|
|
8126
|
+
if (kinds.length === 1) {
|
|
8127
|
+
await runBridge({ json, relay: relayUrl, noRelay: false, gateway, runtime: kinds[0] });
|
|
8128
|
+
return;
|
|
8129
|
+
}
|
|
8130
|
+
for (const kind of kinds) {
|
|
8131
|
+
const gatewayBaseUrl = runtimeNeedsOpenClaw(kind)
|
|
8132
|
+
? resolveGatewayBaseUrl({ gateway, optional: true }, ensureObject(readOptionalJsonFile(resolveOpenClawConfigPath()))) || ""
|
|
8133
|
+
: relayUrl; // hermes 占位:installBridgeService 契约要求非空,hermes 分支不读它。
|
|
8134
|
+
try {
|
|
8135
|
+
const serviceStatus = getBridgeServiceStatus(kind);
|
|
8136
|
+
(serviceStatus.installed ? restartBridgeService : installBridgeService)({
|
|
8137
|
+
relayUrl, gatewayBaseUrl, runtimeKind: kind,
|
|
8138
|
+
});
|
|
8139
|
+
if (!json) console.log(t("service_installed"));
|
|
8140
|
+
} catch (err) {
|
|
8141
|
+
if (!json) console.warn(`⚠️ ${kind} bridge service 安装失败:${err?.message || err}`);
|
|
8142
|
+
}
|
|
8143
|
+
}
|
|
8144
|
+
}
|
|
8145
|
+
|
|
8146
|
+
async function runScan({ json, relay, noRelay, gateway, qrPath, noServe, strictPlugin, runtime }) {
|
|
8147
|
+
// S2b(显式 -r claude/codex):不出码、不联系 relay,直接提示 + 非零退出。
|
|
8148
|
+
if (runtime === "claude" || runtime === "codex") {
|
|
8149
|
+
const message = scanUnsupportedRuntimeMessage([runtime]);
|
|
8150
|
+
if (json) {
|
|
8151
|
+
printScanEvent({ phase: "unsupported_runtime", unsupportedRuntimes: [runtime], hint: message });
|
|
8152
|
+
} else {
|
|
8153
|
+
console.error(message);
|
|
8154
|
+
}
|
|
8155
|
+
process.exit(1);
|
|
8156
|
+
}
|
|
8157
|
+
|
|
8158
|
+
const config = ensureObject(readOptionalJsonFile(resolveOpenClawConfigPath()));
|
|
8159
|
+
const section = readChannelSection(config);
|
|
8160
|
+
const relayUrl = resolveRelayUrl({ relay, noRelay }, section);
|
|
8161
|
+
if (!relayUrl) {
|
|
8162
|
+
fail(
|
|
8163
|
+
[
|
|
8164
|
+
t("relay_url_required"),
|
|
8165
|
+
t("relay_url_missing_detail"),
|
|
8166
|
+
t("relay_url_hint"),
|
|
8167
|
+
].join("\n"),
|
|
8168
|
+
);
|
|
8169
|
+
}
|
|
8170
|
+
|
|
8171
|
+
// ── 探测本机已装 runtime,划出「scan-capable 候选集」(S1/S2)───────────
|
|
8172
|
+
const detected = detectInstalledRuntimes();
|
|
8173
|
+
const installedKinds = new Set(detected.filter((d) => d.installed).map((d) => d.kind));
|
|
8174
|
+
const unsupportedDetected = detected
|
|
8175
|
+
.filter((d) => d.installed && (d.kind === "claude" || d.kind === "codex"))
|
|
8176
|
+
.map((d) => d.kind);
|
|
8177
|
+
|
|
8178
|
+
let candidateKinds;
|
|
8179
|
+
if (runtime) {
|
|
8180
|
+
// -r openclaw|hermes:只配这一个(S2)。校验值域已在 CLI 参数解析阶段完成
|
|
8181
|
+
// (openclaw/hermes/claude/codex/all),claude/codex 已在上面提前返回。
|
|
8182
|
+
if (!installedKinds.has(runtime)) {
|
|
8183
|
+
fail(`${CLI_COMMAND_NAME} scan -r ${runtime}: runtime not installed on this host`);
|
|
8184
|
+
}
|
|
8185
|
+
candidateKinds = [runtime];
|
|
8186
|
+
} else {
|
|
8187
|
+
candidateKinds = SCAN_CAPABLE_RUNTIME_KINDS.filter((k) => installedKinds.has(k));
|
|
8188
|
+
}
|
|
8189
|
+
if (candidateKinds.length === 0) {
|
|
8190
|
+
fail(t("pair_all_no_runtimes"));
|
|
8191
|
+
}
|
|
8192
|
+
|
|
8193
|
+
if (unsupportedDetected.length > 0) {
|
|
8194
|
+
const message = scanUnsupportedRuntimeMessage(unsupportedDetected);
|
|
8195
|
+
if (json) {
|
|
8196
|
+
printScanEvent({ phase: "unsupported_runtime_detected", unsupportedRuntimes: unsupportedDetected, hint: message });
|
|
8197
|
+
} else {
|
|
8198
|
+
console.log(message);
|
|
8199
|
+
}
|
|
8200
|
+
}
|
|
8201
|
+
|
|
8202
|
+
// ── S3:已有有效凭证 + 服务端 binding 仍存活的 runtime 直接跳过出码 ──────
|
|
8203
|
+
const quietEnv = {
|
|
8204
|
+
...process.env,
|
|
8205
|
+
AGENTLINK_AGENT_LOG_LEVEL: process.env.AGENTLINK_AGENT_LOG_LEVEL || "warn",
|
|
8206
|
+
};
|
|
8207
|
+
const alreadyAliveKinds = [];
|
|
8208
|
+
const needsClaimKinds = [];
|
|
8209
|
+
for (const kind of candidateKinds) {
|
|
8210
|
+
const state = readLocalPairState(kind);
|
|
8211
|
+
if (state) {
|
|
8212
|
+
const probe = await probeRelayBinding(state);
|
|
8213
|
+
if (probe.alive) {
|
|
8214
|
+
alreadyAliveKinds.push(kind);
|
|
8215
|
+
if (kind === RUNTIME_KIND_HERMES) {
|
|
8216
|
+
try {
|
|
8217
|
+
const hLog = _createBubLogger({ comp: "scan" }).child({ rt: "hermes" });
|
|
8218
|
+
await ensureHermesEnvAndProbe(hLog);
|
|
8219
|
+
} catch (err) {
|
|
8220
|
+
console.warn(`⚠️ hermes env/gateway 自检失败:${err?.message || err}`);
|
|
8221
|
+
}
|
|
8222
|
+
}
|
|
8223
|
+
continue;
|
|
8224
|
+
}
|
|
8225
|
+
clearLocalPairState(kind);
|
|
8226
|
+
}
|
|
8227
|
+
needsClaimKinds.push(kind);
|
|
8228
|
+
}
|
|
8229
|
+
|
|
8230
|
+
let claimedKinds = [];
|
|
8231
|
+
if (needsClaimKinds.length === 0) {
|
|
8232
|
+
if (!json) {
|
|
8233
|
+
console.log(`ℹ️ ${alreadyAliveKinds.join("、")} 已有有效凭证,跳过扫码,直接启动 bridge…`);
|
|
8234
|
+
} else {
|
|
8235
|
+
printScanEvent({ phase: "skip_pairing", relayUrl, runtimes: alreadyAliveKinds });
|
|
8236
|
+
}
|
|
8237
|
+
} else {
|
|
8238
|
+
// ── 出码循环(S1 前半段 + S4):本地生成高熵 code → 对 needsClaimKinds
|
|
8239
|
+
// 逐个 host-claim(子进程,与 pair-all 同一隔离机制)→ 渲染二维码 → 轮询
|
|
8240
|
+
// scan-claim;超时/过期则清二维码、重新出码,进程不退出。
|
|
8241
|
+
const resolvedQrPath = qrPath || SCAN_QR_PNG_OUTPUT_PATH;
|
|
8242
|
+
for (;;) {
|
|
8243
|
+
const code = generateScanPairCode();
|
|
8244
|
+
const argvTail = [
|
|
8245
|
+
"pair", code,
|
|
8246
|
+
"--relay", relayUrl,
|
|
8247
|
+
"--pair-mode", "scan",
|
|
8248
|
+
"--no-serve-relay",
|
|
8249
|
+
...(json ? ["--json"] : []),
|
|
8250
|
+
...(strictPlugin ? ["--strict-plugin"] : []),
|
|
8251
|
+
];
|
|
8252
|
+
const roundResults = needsClaimKinds.map((kind) =>
|
|
8253
|
+
spawnPairChildForRuntime({ argvTail, runtimeKind: kind, env: quietEnv }),
|
|
8254
|
+
);
|
|
8255
|
+
const roundClaimed = roundResults.filter((r) => r.ok).map((r) => r.kind);
|
|
8256
|
+
const roundFailed = roundResults.filter((r) => !r.ok).map((r) => r.kind);
|
|
8257
|
+
if (roundFailed.length > 0 && !json) {
|
|
8258
|
+
console.warn(`⚠️ 以下 runtime host-claim 失败,不会出现在本轮二维码中:${roundFailed.join("、")}`);
|
|
8259
|
+
}
|
|
8260
|
+
if (roundClaimed.length === 0) {
|
|
8261
|
+
fail(`agentlink scan: host-claim failed for all candidate runtimes (${needsClaimKinds.join(", ")})`);
|
|
8262
|
+
}
|
|
8263
|
+
|
|
8264
|
+
const deeplink = buildScanDeeplink({ code, relay: relayUrl });
|
|
8265
|
+
const pngResult = await renderScanQrPng({ payload: deeplink, outputPath: resolvedQrPath });
|
|
8266
|
+
if (json) {
|
|
8267
|
+
printScanEvent({
|
|
8268
|
+
phase: "waiting_for_scan",
|
|
8269
|
+
code,
|
|
8270
|
+
deeplink,
|
|
8271
|
+
runtimes: roundClaimed,
|
|
8272
|
+
qr_png_path: pngResult.ok ? pngResult.path : null,
|
|
8273
|
+
qr_png_error: pngResult.ok ? null : pngResult.error,
|
|
8274
|
+
});
|
|
8275
|
+
} else {
|
|
8276
|
+
console.log(`\n扫码配对(${roundClaimed.join("、")}):用 App 扫描下方二维码` +
|
|
8277
|
+
(pngResult.ok ? `(或打开 ${pngResult.path} 用图片扫码)` : "") + "\n");
|
|
8278
|
+
renderQrToTerminal(deeplink);
|
|
8279
|
+
console.log(`\n扫不到?把这段贴到浏览器/App 输入框也可以:${deeplink}\n`);
|
|
8280
|
+
}
|
|
8281
|
+
|
|
8282
|
+
const claimedInTime = await waitForScanClaim({
|
|
8283
|
+
relayUrl,
|
|
8284
|
+
code,
|
|
8285
|
+
representativeRuntimeKind: roundClaimed[0],
|
|
8286
|
+
ttlMs: SCAN_CLAIM_TTL_MS,
|
|
8287
|
+
});
|
|
8288
|
+
clearQRImage(resolvedQrPath);
|
|
8289
|
+
if (claimedInTime) {
|
|
8290
|
+
claimedKinds = roundClaimed;
|
|
8291
|
+
if (json) {
|
|
8292
|
+
printScanEvent({ phase: "paired", relayUrl, runtimes: claimedKinds });
|
|
8293
|
+
} else {
|
|
8294
|
+
console.log(`✅ 配对成功(${claimedKinds.join("、")})`);
|
|
8295
|
+
}
|
|
8296
|
+
break;
|
|
8297
|
+
}
|
|
8298
|
+
// 过期/超时(S4):重新生成 code,重新 host-claim,刷新二维码,不退出。
|
|
8299
|
+
if (!json) {
|
|
8300
|
+
console.log("⏳ 配对码已过期,重新生成二维码…");
|
|
8301
|
+
} else {
|
|
8302
|
+
printScanEvent({ phase: "code_expired_retrying", runtimes: roundClaimed });
|
|
8303
|
+
}
|
|
8304
|
+
}
|
|
8305
|
+
}
|
|
8306
|
+
|
|
8307
|
+
const finalKinds = [...alreadyAliveKinds, ...claimedKinds];
|
|
8308
|
+
|
|
8309
|
+
// ── --no-serve:只配对不起 worker(S5 命令签名之一)─────────────────────
|
|
8310
|
+
if (noServe) {
|
|
8311
|
+
if (json) {
|
|
8312
|
+
printScanEvent({ phase: "done", serve: false, runtimes: finalKinds, relayUrl });
|
|
8313
|
+
} else {
|
|
8314
|
+
console.log("ℹ️ --no-serve:已完成配对,不启动 bridge worker。");
|
|
8315
|
+
}
|
|
8316
|
+
return;
|
|
8317
|
+
}
|
|
8318
|
+
|
|
8319
|
+
// ── 起 worker;revoke 由既有共享路径清凭证 + exit(S6,不逆改)──────────
|
|
8320
|
+
if (json) {
|
|
8321
|
+
printScanEvent({
|
|
8322
|
+
phase: "starting_worker",
|
|
8323
|
+
runtimes: finalKinds,
|
|
8324
|
+
relayUrl,
|
|
8325
|
+
note: `on revoke this process/service clears credentials and exits; re-run \`${CLI_COMMAND_NAME} scan\` to re-pair`,
|
|
8326
|
+
});
|
|
8327
|
+
} else {
|
|
8328
|
+
console.log(
|
|
8329
|
+
`若之后在 App 端解绑(或凭证失效),worker 会自动清凭证并退出 —— 到时重跑 \`${CLI_COMMAND_NAME} scan\` 即可重新配对。`,
|
|
8330
|
+
);
|
|
8331
|
+
}
|
|
8332
|
+
await startScanWorkers({ kinds: finalKinds, relayUrl, gateway, json });
|
|
8333
|
+
}
|
|
8334
|
+
|
|
7963
8335
|
async function runBridge({ json, relay, noRelay, gateway, runtime }) {
|
|
7964
8336
|
const configPath = resolveOpenClawConfigPath();
|
|
7965
8337
|
// 先容忍读(缺文件 → null):runtimeKind 可能来自 ~/.agentlink state 的
|
|
@@ -8534,6 +8906,9 @@ async function main() {
|
|
|
8534
8906
|
case "bridge":
|
|
8535
8907
|
await runBridge(options);
|
|
8536
8908
|
return;
|
|
8909
|
+
case "scan":
|
|
8910
|
+
await runScan(options);
|
|
8911
|
+
return;
|
|
8537
8912
|
case "service":
|
|
8538
8913
|
await runService(options);
|
|
8539
8914
|
return;
|