@xyagent/cli 1.0.0 → 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 +468 -86
- package/package.json +2 -2
- package/src/tunnel_service.mjs +17 -1
- package/src-ext/bin.mjs +12 -11
- package/src-ext/commands/agent.mjs +34 -0
- 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/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 +21 -1
- package/src-ext/core/runtimeRegistry.mjs +180 -0
- package/src-ext/core/scanWorkspaces.mjs +163 -23
- package/src-ext/core/unifiedDispatchHandler.mjs +9 -2
- 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/resolveWorkspaceCwd.mjs +47 -0
- package/src-ext/runtime/_shared/slashCommandRouter.mjs +10 -0
- package/src-ext/runtime/claude/handleRequest.mjs +15 -37
- 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/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
|
@@ -14,13 +14,17 @@
|
|
|
14
14
|
// API_SERVER_ENABLED=true
|
|
15
15
|
// API_SERVER_HOST=127.0.0.1 (仅当未设)
|
|
16
16
|
// API_SERVER_PORT=8642 (仅当未设)
|
|
17
|
-
//
|
|
17
|
+
// API_SERVER_KEY=<随机强密钥> (缺失或低于 Hermes v0.20 的 16 字符门槛时生成)
|
|
18
|
+
// 四行。用户已有自定义 HOST/PORT 与合格 KEY 不动;只补缺的。
|
|
18
19
|
|
|
19
20
|
import fs from "node:fs";
|
|
20
21
|
import os from "node:os";
|
|
21
22
|
import path from "node:path";
|
|
23
|
+
import { randomBytes } from "node:crypto";
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
// Hermes v0.20+ 要求 API server key 至少 16 字符;默认生成 32 字节 hex。
|
|
26
|
+
const API_SERVER_KEY_MIN_LENGTH = 16;
|
|
27
|
+
const API_SERVER_KEY_BYTES = 32;
|
|
24
28
|
|
|
25
29
|
const DEFAULTS = {
|
|
26
30
|
API_SERVER_ENABLED: "true",
|
|
@@ -76,7 +80,7 @@ function parseEnvFile(content) {
|
|
|
76
80
|
* 用户自定义的端口 / 监听地址)。
|
|
77
81
|
*
|
|
78
82
|
* 返回 { toAppend, toOverride } —— toAppend 是缺失要追加的 key 列表,
|
|
79
|
-
* toOverride 是已存在但值不对要重写的 key
|
|
83
|
+
* toOverride 是已存在但值不对要重写的 key 列表(ENABLED 或弱 KEY)。
|
|
80
84
|
*/
|
|
81
85
|
function decideEnvPatch(envMap) {
|
|
82
86
|
const toAppend = [];
|
|
@@ -92,6 +96,12 @@ function decideEnvPatch(envMap) {
|
|
|
92
96
|
if (!(k in envMap)) toAppend.push(k);
|
|
93
97
|
// 已设的 host/port 不动,尊重用户自定义(preflight 会自动读取最新值)
|
|
94
98
|
}
|
|
99
|
+
const apiServerKey = String(envMap.API_SERVER_KEY ?? "").trim();
|
|
100
|
+
if (!("API_SERVER_KEY" in envMap)) {
|
|
101
|
+
toAppend.push("API_SERVER_KEY");
|
|
102
|
+
} else if (apiServerKey.length < API_SERVER_KEY_MIN_LENGTH) {
|
|
103
|
+
toOverride.push("API_SERVER_KEY");
|
|
104
|
+
}
|
|
95
105
|
return { toAppend, toOverride };
|
|
96
106
|
}
|
|
97
107
|
|
|
@@ -99,7 +109,7 @@ function decideEnvPatch(envMap) {
|
|
|
99
109
|
* 用 line-by-line 改写把 toOverride 中的 key 替换成默认值。保留原文件
|
|
100
110
|
* 的注释/空行/格式;只动单行。
|
|
101
111
|
*/
|
|
102
|
-
function rewriteEnvOverrides(content, toOverride) {
|
|
112
|
+
function rewriteEnvOverrides(content, toOverride, patchValues) {
|
|
103
113
|
if (toOverride.length === 0) return { content, replaced: [] };
|
|
104
114
|
const overrideSet = new Set(toOverride);
|
|
105
115
|
const replaced = [];
|
|
@@ -115,7 +125,7 @@ function rewriteEnvOverrides(content, toOverride) {
|
|
|
115
125
|
const key = bare.slice(0, eq).trim();
|
|
116
126
|
if (!overrideSet.has(key)) continue;
|
|
117
127
|
// 替换整行;不保留 export / 引号 —— 简单一致
|
|
118
|
-
lines[i] = `${key}=${
|
|
128
|
+
lines[i] = `${key}=${patchValues[key]}`;
|
|
119
129
|
replaced.push(key);
|
|
120
130
|
overrideSet.delete(key);
|
|
121
131
|
}
|
|
@@ -129,7 +139,7 @@ function rewriteEnvOverrides(content, toOverride) {
|
|
|
129
139
|
* status: "ok" | "patched" | "no_home" | "io_error",
|
|
130
140
|
* envPath: string,
|
|
131
141
|
* appended: string[], // 缺失被追加的 key
|
|
132
|
-
* overridden: string[], // 已存在但值不对被覆盖的 key (
|
|
142
|
+
* overridden: string[], // 已存在但值不对被覆盖的 key (ENABLED 或弱 KEY)
|
|
133
143
|
* kept: { key: string, value: string }[], // 保留用户已设的 key/value(HOST/PORT)
|
|
134
144
|
* error?: string,
|
|
135
145
|
* warning?: string,
|
|
@@ -164,6 +174,10 @@ export function ensureHermesApiServer({ log, home: homeOverride } = {}) {
|
|
|
164
174
|
|
|
165
175
|
const envMap = parseEnvFile(content);
|
|
166
176
|
const { toAppend, toOverride } = decideEnvPatch(envMap);
|
|
177
|
+
const patchValues = { ...DEFAULTS };
|
|
178
|
+
if (toAppend.includes("API_SERVER_KEY") || toOverride.includes("API_SERVER_KEY")) {
|
|
179
|
+
patchValues.API_SERVER_KEY = randomBytes(API_SERVER_KEY_BYTES).toString("hex");
|
|
180
|
+
}
|
|
167
181
|
// 用户已设的 HOST/PORT 列出来,给调用方打印"沿用现有"提示。
|
|
168
182
|
const kept = [];
|
|
169
183
|
for (const k of ["API_SERVER_HOST", "API_SERVER_PORT"]) {
|
|
@@ -182,7 +196,7 @@ export function ensureHermesApiServer({ log, home: homeOverride } = {}) {
|
|
|
182
196
|
let newContent = content;
|
|
183
197
|
let replaced = [];
|
|
184
198
|
if (toOverride.length > 0) {
|
|
185
|
-
const r = rewriteEnvOverrides(content, toOverride);
|
|
199
|
+
const r = rewriteEnvOverrides(content, toOverride, patchValues);
|
|
186
200
|
newContent = r.content;
|
|
187
201
|
replaced = r.replaced;
|
|
188
202
|
}
|
|
@@ -202,7 +216,7 @@ export function ensureHermesApiServer({ log, home: homeOverride } = {}) {
|
|
|
202
216
|
"# → bridge 连不上 → App 消息不通。pair 流程强制写齐。",
|
|
203
217
|
);
|
|
204
218
|
for (const k of toAppend) {
|
|
205
|
-
tail.push(`${k}=${
|
|
219
|
+
tail.push(`${k}=${patchValues[k]}`);
|
|
206
220
|
}
|
|
207
221
|
tail.push("");
|
|
208
222
|
newContent += tail.join("\n");
|
|
@@ -16,10 +16,13 @@ import net from "node:net";
|
|
|
16
16
|
import os from "node:os";
|
|
17
17
|
import path from "node:path";
|
|
18
18
|
|
|
19
|
+
import { ensureHermesApiServer } from "./envSetup.mjs";
|
|
20
|
+
|
|
19
21
|
// gateway 启动后 listen 端口需要时间 —— 热路径 1.5~3s,但冷启动(首次加载
|
|
20
22
|
// skills/记忆/模型路由)实测可 >8s;给到 30s 上限,避免误判超时后回退到默认 profile(错身份)。
|
|
21
23
|
const HEALTH_POLL_INTERVAL_MS = 300;
|
|
22
24
|
const HEALTH_POLL_TIMEOUT_MS = 30000;
|
|
25
|
+
const PROFILE_HEALTH_PROBE_TIMEOUT_MS = 1500;
|
|
23
26
|
// kill 之后等端口完全释放,避免 spawn 时撞 EADDRINUSE。
|
|
24
27
|
const PORT_RELEASE_TIMEOUT_MS = 5000;
|
|
25
28
|
const PORT_RELEASE_INTERVAL_MS = 200;
|
|
@@ -129,6 +132,169 @@ export function readProfileEnvValue(hermesHome, key) {
|
|
|
129
132
|
return "";
|
|
130
133
|
}
|
|
131
134
|
|
|
135
|
+
/**
|
|
136
|
+
* 把分配好的端口 upsert 进 `<hermesHome>/.env` 的 `API_SERVER_PORT`(保留其它行)。
|
|
137
|
+
*
|
|
138
|
+
* 🔴 为什么必须写(2026-08-16 真机故障):hermes gateway 只认自己的配置
|
|
139
|
+
* (进程 env / `<HERMES_HOME>/.env`),**不知道** `gateway-port.json` 的存在。
|
|
140
|
+
* 分配了 8643 却不告诉 gateway,它就按默认 8642 起 → 与 default profile 的
|
|
141
|
+
* gateway 撞端口、启动即退 → `waitForHealth(8643)` 永远超时,用户看到
|
|
142
|
+
* 「profile gateway spawn 后 30000ms 内未通过 /health」。
|
|
143
|
+
* 写 `.env` 同时让 `resolveProfilePort` 的「.env 优先」路径下次直接命中同一端口,
|
|
144
|
+
* `ensureHermesApiServer` 的自愈(缺失才补 8642)也不会再把它盖回去。
|
|
145
|
+
* 写失败不抛:spawn env 里还会带一份 `API_SERVER_PORT`(进程 env 优先级更高),
|
|
146
|
+
* 单次启动仍然正确,只是下次解析少一层持久化。
|
|
147
|
+
*/
|
|
148
|
+
function upsertProfileEnvPort(hermesHome, port, log) {
|
|
149
|
+
const envPath = path.join(hermesHome, ".env");
|
|
150
|
+
const line = `API_SERVER_PORT=${port}`;
|
|
151
|
+
try {
|
|
152
|
+
let content = "";
|
|
153
|
+
try { content = fs.readFileSync(envPath, "utf8"); } catch { /* 没有 .env,全新写 */ }
|
|
154
|
+
const lines = content.split(/\r?\n/);
|
|
155
|
+
let replaced = false;
|
|
156
|
+
for (let i = 0; i < lines.length; i++) {
|
|
157
|
+
let bare = lines[i].trim();
|
|
158
|
+
if (!bare || bare.startsWith("#")) continue;
|
|
159
|
+
if (bare.startsWith("export ")) bare = bare.slice(7).trim();
|
|
160
|
+
const eq = bare.indexOf("=");
|
|
161
|
+
if (eq < 0 || bare.slice(0, eq).trim() !== "API_SERVER_PORT") continue;
|
|
162
|
+
if (lines[i].trim() === line) return; // 已一致,不动文件
|
|
163
|
+
lines[i] = line;
|
|
164
|
+
replaced = true;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
let next;
|
|
168
|
+
if (replaced) {
|
|
169
|
+
next = lines.join("\n");
|
|
170
|
+
} else {
|
|
171
|
+
const head = content.length > 0 && !content.endsWith("\n") ? `${content}\n` : content;
|
|
172
|
+
next = `${head}# Added by agentlink(per-profile gateway 端口,与 gateway-port.json 一致)\n${line}\n`;
|
|
173
|
+
}
|
|
174
|
+
fs.mkdirSync(hermesHome, { recursive: true });
|
|
175
|
+
fs.writeFileSync(envPath, next, "utf8");
|
|
176
|
+
} catch (err) {
|
|
177
|
+
log?.warn?.("hermes.profile.env_port_write_failed", "写 profile .env 端口失败(spawn env 仍会带端口)", {
|
|
178
|
+
envPath, port, err: err?.message || String(err),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 解析 `<dir>/.env` 为键值表(与 [`readProfileEnvValue`] 同一套行解析规则)。
|
|
185
|
+
* 文件不存在 / 读失败 → 空表。
|
|
186
|
+
*/
|
|
187
|
+
function readEnvFileMap(dir) {
|
|
188
|
+
const out = {};
|
|
189
|
+
try {
|
|
190
|
+
const raw = fs.readFileSync(path.join(dir, ".env"), "utf8");
|
|
191
|
+
for (const line of raw.split("\n")) {
|
|
192
|
+
let t = line.trim();
|
|
193
|
+
if (!t || t.startsWith("#")) continue;
|
|
194
|
+
if (t.startsWith("export ")) t = t.slice(7).trim();
|
|
195
|
+
const eq = t.indexOf("=");
|
|
196
|
+
if (eq < 0) continue;
|
|
197
|
+
const key = t.slice(0, eq).trim();
|
|
198
|
+
let v = t.slice(eq + 1).trim();
|
|
199
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
200
|
+
v = v.slice(1, -1);
|
|
201
|
+
}
|
|
202
|
+
if (key && !(key in out)) out[key] = v;
|
|
203
|
+
}
|
|
204
|
+
} catch { /* .env 不存在或读失败 */ }
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* per-profile gateway 从 default profile(`~/.hermes/.env`)**经内存**继承运行凭证
|
|
210
|
+
* (模型 API key 等)。
|
|
211
|
+
*
|
|
212
|
+
* 🔴 为什么需要(2026-08-16 真机第三环故障):profile home 是 workspace 目录,
|
|
213
|
+
* 它的 `.env` 只有 agentlink 写入的 API_SERVER_* 四键 —— hermes agent 执行时调
|
|
214
|
+
* 下游 LLM API 拿不到凭证,把「HTTP 401: Missing Authentication header」当成
|
|
215
|
+
* 回复正文返回给用户。
|
|
216
|
+
* 🔴 为什么不落盘:profile home 可能是用户的 git 仓库(powermore 就是),把
|
|
217
|
+
* OPENAI_API_KEY 之类外部凭证写进项目 `.env` 有被误提交泄漏的风险;
|
|
218
|
+
* spawn env 只活在进程内存里。
|
|
219
|
+
*
|
|
220
|
+
* 继承规则:API_SERVER_* 不继承(per-profile 专属,由调用方显式钉);
|
|
221
|
+
* profile 自己 `.env` 已有的键不继承(进程 env 优先于 .env,注入会盖掉用户自定义)。
|
|
222
|
+
*/
|
|
223
|
+
function inheritedDefaultProfileEnv(hermesHome) {
|
|
224
|
+
const defaultHome = path.join(os.homedir(), ".hermes");
|
|
225
|
+
if (path.normalize(hermesHome) === path.normalize(defaultHome)) return {};
|
|
226
|
+
const defaults = readEnvFileMap(defaultHome);
|
|
227
|
+
const profile = readEnvFileMap(hermesHome);
|
|
228
|
+
const out = {};
|
|
229
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
230
|
+
if (key.startsWith("API_SERVER_")) continue;
|
|
231
|
+
if (key in profile) continue;
|
|
232
|
+
out[key] = value;
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* spawn 前把 default profile 的模型路由配置带给 per-profile gateway
|
|
239
|
+
* (2026-08-16 真机第三环故障的固化,方案经端到端验证):
|
|
240
|
+
*
|
|
241
|
+
* 1. **config.yaml 复制**(缺失才复制):hermes 的模型路由(provider/model/base_url)
|
|
242
|
+
* 只认 `<HERMES_HOME>/config.yaml`,不支持 env 指定路径(HERMES_CONFIG 只是沙箱
|
|
243
|
+
* 白名单词条,无消费点)。profile home 缺它时 hermes 回落内置默认模型
|
|
244
|
+
* (openrouter),而那家的 key 通常不存在 → agent 把 401 当回复正文返回。
|
|
245
|
+
* config.yaml 实测无密钥(模型路由 + agent 行为),落盘 profile home 风险低;
|
|
246
|
+
* profile home 是 git 仓库时建议用户将其加进 .gitignore。
|
|
247
|
+
* 2. **凭证池 env 注入**(纯内存,绝不落盘):default 的 provider 凭证在
|
|
248
|
+
* `~/.hermes/auth.json` 的 credential_pool。auth.json 的 per-provider 全局回落
|
|
249
|
+
* (hermes_cli/auth.py `_global_auth_file_path`)只覆盖 provider state,
|
|
250
|
+
* **credential_pool 不回落**(实测 profile 进程报 No usable credentials,
|
|
251
|
+
* 提示设 `<PROVIDER>_API_KEY`)。这里按 hermes 的 env 键名约定
|
|
252
|
+
* (provider 大写、`-`→`_`、后缀 `_API_KEY`,minimax-cn→MINIMAX_CN_API_KEY 已实证)
|
|
253
|
+
* 把池里 api_key 类条目注入 spawn env。键名映射对个别 provider 可能不中 ——
|
|
254
|
+
* 不中时仅无效果(best-effort),绝不阻塞启动。
|
|
255
|
+
*/
|
|
256
|
+
function defaultProfileModelHandoff(hermesHome, log) {
|
|
257
|
+
const defaultHome = path.join(os.homedir(), ".hermes");
|
|
258
|
+
if (path.normalize(hermesHome) === path.normalize(defaultHome)) return {};
|
|
259
|
+
|
|
260
|
+
// 1. config.yaml 缺失才复制(已有的绝不覆盖——那是用户的 per-profile 定制)。
|
|
261
|
+
try {
|
|
262
|
+
const src = path.join(defaultHome, "config.yaml");
|
|
263
|
+
const dst = path.join(hermesHome, "config.yaml");
|
|
264
|
+
if (fs.existsSync(src) && !fs.existsSync(dst)) {
|
|
265
|
+
fs.copyFileSync(src, dst);
|
|
266
|
+
log?.info?.("hermes.profile.config_copied", "copied default config.yaml to profile home(git 仓库建议 gitignore)", {
|
|
267
|
+
hermesHome,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
} catch (err) {
|
|
271
|
+
log?.warn?.("hermes.profile.config_copy_failed", "config.yaml 复制失败(gateway 将用内置默认模型)", {
|
|
272
|
+
hermesHome, err: err?.message || String(err),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 2. credential_pool → <PROVIDER>_API_KEY 内存注入。
|
|
277
|
+
const env = {};
|
|
278
|
+
try {
|
|
279
|
+
const pool = JSON.parse(
|
|
280
|
+
fs.readFileSync(path.join(defaultHome, "auth.json"), "utf8"),
|
|
281
|
+
)?.credential_pool;
|
|
282
|
+
if (pool && typeof pool === "object") {
|
|
283
|
+
for (const [provider, entries] of Object.entries(pool)) {
|
|
284
|
+
if (!Array.isArray(entries) || entries.length === 0) continue;
|
|
285
|
+
const entry = [...entries]
|
|
286
|
+
.filter((e) => e && typeof e.access_token === "string" && e.access_token)
|
|
287
|
+
.filter((e) => (e.auth_type ?? "api_key") === "api_key")
|
|
288
|
+
.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))[0];
|
|
289
|
+
if (!entry) continue;
|
|
290
|
+
const key = `${provider.toUpperCase().replace(/-/g, "_")}_API_KEY`;
|
|
291
|
+
env[key] = entry.access_token;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
} catch { /* auth.json 不存在/形状变化:跳过注入,不阻塞启动 */ }
|
|
295
|
+
return env;
|
|
296
|
+
}
|
|
297
|
+
|
|
132
298
|
export async function resolveProfilePort(hermesHome) {
|
|
133
299
|
// default profile → 固定 8642
|
|
134
300
|
const defaultHome = path.join(os.homedir(), ".hermes");
|
|
@@ -199,7 +365,9 @@ export async function manageProfileGateway(slug, hermesHome, {
|
|
|
199
365
|
const cached = profileGatewayRegistry.get(slug);
|
|
200
366
|
if (cached && cached.port) {
|
|
201
367
|
const cachedBase = `http://127.0.0.1:${cached.port}`;
|
|
202
|
-
const probe = await probeHealth(cachedBase, {
|
|
368
|
+
const probe = await probeHealth(cachedBase, {
|
|
369
|
+
timeoutMs: PROFILE_HEALTH_PROBE_TIMEOUT_MS,
|
|
370
|
+
});
|
|
203
371
|
if (probe.ok) {
|
|
204
372
|
cached.lastActiveAt = new Date();
|
|
205
373
|
eLog?.info?.("hermes.profile.gateway.ensure.cached_ok", "profile gateway healthy (cached)", { slug, port: cached.port });
|
|
@@ -217,7 +385,9 @@ export async function manageProfileGateway(slug, hermesHome, {
|
|
|
217
385
|
})();
|
|
218
386
|
if (portFileRead) {
|
|
219
387
|
const savedBase = `http://127.0.0.1:${portFileRead}`;
|
|
220
|
-
const probe = await probeHealth(savedBase, {
|
|
388
|
+
const probe = await probeHealth(savedBase, {
|
|
389
|
+
timeoutMs: PROFILE_HEALTH_PROBE_TIMEOUT_MS,
|
|
390
|
+
});
|
|
221
391
|
if (probe.ok) {
|
|
222
392
|
eLog?.info?.("hermes.profile.gateway.ensure.portfile_ok", "profile gateway healthy (port file)", { slug, port: portFileRead });
|
|
223
393
|
profileGatewayRegistry.set(slug, {
|
|
@@ -239,6 +409,39 @@ export async function manageProfileGateway(slug, hermesHome, {
|
|
|
239
409
|
|
|
240
410
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
241
411
|
|
|
412
|
+
// default profile 固定使用 8642 且不会写 gateway-port.json。preflight 已经
|
|
413
|
+
// 拉起健康 gateway 时,前面的 cache / port-file 两条路径都无法命中;若直接
|
|
414
|
+
// 进入 stop + spawn,会把刚恢复的 gateway 杀掉,并与 Hermes 自己的 --replace
|
|
415
|
+
// 清理过程竞争,最终出现“端口曾被占用但现在无人监听”。分配出端口后再做一次
|
|
416
|
+
// 探活,健康就纳入注册表复用。
|
|
417
|
+
if (mode === "ensure") {
|
|
418
|
+
const probe = await probeHealth(baseUrl, {
|
|
419
|
+
timeoutMs: PROFILE_HEALTH_PROBE_TIMEOUT_MS,
|
|
420
|
+
});
|
|
421
|
+
if (probe.ok) {
|
|
422
|
+
const existingPid = findListeningPid(port) ?? 0;
|
|
423
|
+
profileGatewayRegistry.set(slug, {
|
|
424
|
+
port,
|
|
425
|
+
pid: existingPid,
|
|
426
|
+
baseUrl,
|
|
427
|
+
lastActiveAt: new Date(),
|
|
428
|
+
});
|
|
429
|
+
eLog?.info?.(
|
|
430
|
+
"hermes.profile.gateway.ensure.resolved_ok",
|
|
431
|
+
"profile gateway healthy (resolved port)",
|
|
432
|
+
{ slug, port },
|
|
433
|
+
);
|
|
434
|
+
return {
|
|
435
|
+
ok: true,
|
|
436
|
+
started: false,
|
|
437
|
+
pid: existingPid,
|
|
438
|
+
port,
|
|
439
|
+
baseUrl,
|
|
440
|
+
logPath,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
242
445
|
// 需要 spawn:先检查并发上限,执行 LRU 驱逐
|
|
243
446
|
await _evictIfNeeded({ eLog });
|
|
244
447
|
|
|
@@ -278,6 +481,28 @@ export async function manageProfileGateway(slug, hermesHome, {
|
|
|
278
481
|
}
|
|
279
482
|
|
|
280
483
|
// spawn 新 gateway
|
|
484
|
+
// 🔴 分配的端口必须同时经两条路交给 gateway(2026-08-16 真机故障,见 upsertProfileEnvPort):
|
|
485
|
+
// 1. 进程 env `API_SERVER_PORT`(hermes 的 _getenv 里 os.environ 优先,本次启动立即生效);
|
|
486
|
+
// 2. profile `.env` 持久化(下次任何路径拉起 gateway 都一致)。
|
|
487
|
+
// 少了这一步,gateway 按默认 8642 起、与 default profile 撞端口,健康检查在
|
|
488
|
+
// 分配端口上永远超时。
|
|
489
|
+
upsertProfileEnvPort(hermesHome, port, eLog);
|
|
490
|
+
// 🔴 API_SERVER_KEY 也必须在 spawn 前确定化(2026-08-16 真机第二环故障):
|
|
491
|
+
// bridge 发 per-profile 请求时读的是 profile `.env` 的 KEY(handleRequest 的
|
|
492
|
+
// per-conversation 路由),而 gateway 进程若从 bridge 的 process.env 继承了
|
|
493
|
+
// default profile 的 KEY,两侧就对不上 —— 表现为 401(profile `.env` 无 KEY 时
|
|
494
|
+
// bridge 干脆不带鉴权头,「Missing Authentication header」)。
|
|
495
|
+
// ensureHermesApiServer 幂等:缺 KEY 才生成强随机 key 写入 profile `.env`,
|
|
496
|
+
// ENABLED/HOST 一并补齐;PORT 已在上一步写好(缺失才补的语义不会碰它)。
|
|
497
|
+
let profileApiKey = "";
|
|
498
|
+
try {
|
|
499
|
+
ensureHermesApiServer({ log: eLog, home: hermesHome });
|
|
500
|
+
profileApiKey = readProfileEnvValue(hermesHome, "API_SERVER_KEY");
|
|
501
|
+
} catch (err) {
|
|
502
|
+
eLog?.warn?.("hermes.profile.env_key_setup_failed", "profile .env key 补齐失败(gateway 仍会尝试启动)", {
|
|
503
|
+
hermesHome, err: err?.message || String(err),
|
|
504
|
+
});
|
|
505
|
+
}
|
|
281
506
|
eLog?.info?.("hermes.profile.gateway.spawn", "spawning profile gateway", { slug, port, hermesHome });
|
|
282
507
|
let spawnResult;
|
|
283
508
|
try {
|
|
@@ -285,7 +510,18 @@ export async function manageProfileGateway(slug, hermesHome, {
|
|
|
285
510
|
binary: effectiveBinary,
|
|
286
511
|
logPath,
|
|
287
512
|
log: eLog,
|
|
288
|
-
env: {
|
|
513
|
+
env: {
|
|
514
|
+
// default profile 的运行凭证(模型 API key 等)经内存继承,
|
|
515
|
+
// 不落 profile `.env`(见 inheritedDefaultProfileEnv 的红线说明)。
|
|
516
|
+
...inheritedDefaultProfileEnv(hermesHome),
|
|
517
|
+
// 模型路由 config.yaml 复制 + credential_pool 凭证注入(见函数文档)。
|
|
518
|
+
...defaultProfileModelHandoff(hermesHome, eLog),
|
|
519
|
+
HERMES_HOME: hermesHome,
|
|
520
|
+
API_SERVER_PORT: String(port),
|
|
521
|
+
// 显式钉住本 profile 的鉴权配置,覆盖从 bridge 进程环境继承来的
|
|
522
|
+
// default profile 值 —— gateway 与 bridge 必须用同一把 key。
|
|
523
|
+
...(profileApiKey ? { API_SERVER_KEY: profileApiKey, API_SERVER_ENABLED: "true" } : {}),
|
|
524
|
+
},
|
|
289
525
|
});
|
|
290
526
|
} catch (err) {
|
|
291
527
|
return {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { randomBytes } from "node:crypto";
|
|
27
|
+
import { statSync } from "node:fs";
|
|
27
28
|
import os from "node:os";
|
|
28
29
|
import path from "node:path";
|
|
29
30
|
|
|
@@ -113,12 +114,19 @@ const SYSTEM_PROMPT_DEFAULT =
|
|
|
113
114
|
*/
|
|
114
115
|
function _resolveWorkspacePathProfile(workspacePath) {
|
|
115
116
|
if (!workspacePath || typeof workspacePath !== "string") return null;
|
|
117
|
+
const raw = workspacePath.trim();
|
|
118
|
+
if (!path.isAbsolute(raw) && raw !== "~" && !raw.startsWith("~/")) return null;
|
|
116
119
|
// expand ~ 前缀
|
|
117
120
|
const expanded = workspacePath.startsWith("~")
|
|
118
121
|
? path.join(os.homedir(), workspacePath.slice(1))
|
|
119
122
|
: workspacePath;
|
|
120
123
|
const normalized = path.resolve(expanded);
|
|
121
124
|
if (!normalized) return null;
|
|
125
|
+
try {
|
|
126
|
+
if (!statSync(normalized).isDirectory()) return null;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
122
130
|
|
|
123
131
|
// align-runtime-default-workspace: root hermesHome(~/.hermes)→ profileName="default"
|
|
124
132
|
const hermesRoot = path.join(os.homedir(), ".hermes");
|
|
@@ -209,6 +217,11 @@ export function createHermesHandleRequest({ pre, log, gwId, usage, dispatchHandl
|
|
|
209
217
|
let effectiveApiKey = pre.apiKey; // 默认:bridge 启动时固定的 apiKey
|
|
210
218
|
|
|
211
219
|
const workspaceResolved = workspace_path ? _resolveWorkspacePathProfile(workspace_path) : null;
|
|
220
|
+
if (workspace_path && !workspaceResolved) {
|
|
221
|
+
const error = new Error("hermes: requested workspace context is unavailable");
|
|
222
|
+
error.code = "workspace_context_unavailable";
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
212
225
|
if (workspaceResolved) {
|
|
213
226
|
const { profileName, profileHome: reqProfileHome } = workspaceResolved;
|
|
214
227
|
let gwResult;
|
|
@@ -145,6 +145,17 @@ export async function* streamChat(messages, opts) {
|
|
|
145
145
|
try { return JSON.parse(payload); } catch { return null; }
|
|
146
146
|
})();
|
|
147
147
|
if (parsed) {
|
|
148
|
+
// Hermes 网关在上游鉴权、额度或模型调用失败时仍保持 HTTP 200,
|
|
149
|
+
// 错误位于最后一个 SSE chunk。不能把它当作空的 completed,否则
|
|
150
|
+
// Desktop 会永久表现成“没有回复”且用户看不到真实处置原因。
|
|
151
|
+
const parsedChoice = parsed?.choices?.[0];
|
|
152
|
+
if (parsed?.error || parsedChoice?.finish_reason === "error") {
|
|
153
|
+
const message = String(
|
|
154
|
+
parsed?.error?.message || parsed?.hermes?.error || "Hermes upstream request failed",
|
|
155
|
+
).trim();
|
|
156
|
+
yield { kind: "error", error: message || "Hermes upstream request failed" };
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
148
159
|
// OpenAI streaming shape: choices[].delta.content + optional usage on
|
|
149
160
|
// last chunk (with stream_options.include_usage=true).
|
|
150
161
|
//
|
|
@@ -156,7 +167,7 @@ export async function* streamChat(messages, opts) {
|
|
|
156
167
|
// thinking inside delta.content (e.g. Gemini via OpenAI-compat
|
|
157
168
|
// gateway) won't trigger this branch — that's expected, since the
|
|
158
169
|
// upstream has not split reasoning out.
|
|
159
|
-
const choice =
|
|
170
|
+
const choice = parsedChoice;
|
|
160
171
|
const reasoningDelta = choice?.delta?.reasoning_content;
|
|
161
172
|
if (typeof reasoningDelta === "string" && reasoningDelta.length > 0) {
|
|
162
173
|
yield { kind: "thinking", text: reasoningDelta };
|
|
@@ -109,7 +109,7 @@ export async function runHermesBridge({ options, positional, log }) {
|
|
|
109
109
|
});
|
|
110
110
|
|
|
111
111
|
const worker = createRelayWorker({
|
|
112
|
-
relayUrl: relayBase, gwId, bridgeToken, runtime: "hermes", log: sessLog,
|
|
112
|
+
relayUrl: relayBase, gwId, bridgeToken, runtime: "hermes", profileName, log: sessLog,
|
|
113
113
|
});
|
|
114
114
|
installAutoTunnel({ relayUrl: relayBase, log: sessLog });
|
|
115
115
|
|
|
@@ -320,7 +320,8 @@ export async function hermesPreflightWithHeal({ options = {}, log, hermesHome }
|
|
|
320
320
|
const envChanged =
|
|
321
321
|
envResult.status === "patched" &&
|
|
322
322
|
(envResult.overridden?.length > 0 ||
|
|
323
|
-
(envResult.appended || []).
|
|
323
|
+
(envResult.appended || []).some((key) =>
|
|
324
|
+
key === "API_SERVER_ENABLED" || key === "API_SERVER_KEY"));
|
|
324
325
|
const mode = envChanged ? "restart" : "ensure";
|
|
325
326
|
const effectiveHome = hermesHome || resolveHermesHome();
|
|
326
327
|
const logPath = path.join(effectiveHome, "gateway.log");
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { recordBridgedSession } from "../_shared/bridgedSessionLedger.mjs";
|
|
2
|
+
import { runHeadlessCliBridge } from "../_shared/headlessCliBridge.mjs";
|
|
3
|
+
import { probeBinary, runNdjsonProcess } from "../_shared/ndjsonProcess.mjs";
|
|
4
|
+
import { resolveWorkspaceCwd } from "../_shared/resolveWorkspaceCwd.mjs";
|
|
5
|
+
|
|
6
|
+
export function kimiPreflight({ probe = probeBinary } = {}) {
|
|
7
|
+
let result = probe("kimi");
|
|
8
|
+
let binary = "kimi";
|
|
9
|
+
if (!result.ok) {
|
|
10
|
+
const legacy = probe("kimi-cli");
|
|
11
|
+
if (legacy.ok) {
|
|
12
|
+
result = legacy;
|
|
13
|
+
binary = "kimi-cli";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
if (!result.ok) result.hint = "请先安装 Kimi Code CLI,并执行 kimi login 完成认证。";
|
|
17
|
+
return { ...result, binary };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function translateKimiEvent(event) {
|
|
21
|
+
if (!event || typeof event !== "object") return null;
|
|
22
|
+
if (event.role === "assistant") {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
if (event.content) {
|
|
25
|
+
chunks.push({ event: "response.output_text.delta", delta: String(event.content) });
|
|
26
|
+
}
|
|
27
|
+
for (const call of Array.isArray(event.tool_calls) ? event.tool_calls : []) {
|
|
28
|
+
const callId = String(call?.id || "");
|
|
29
|
+
const name = String(call?.function?.name || "unknown");
|
|
30
|
+
let input = {};
|
|
31
|
+
try { input = JSON.parse(call?.function?.arguments || "{}"); } catch {}
|
|
32
|
+
chunks.push({ event: "response.tool_use.start", call_id: callId, name, input });
|
|
33
|
+
chunks.push({ event: "response.tool_use.completed", call_id: callId, name, input });
|
|
34
|
+
}
|
|
35
|
+
return chunks;
|
|
36
|
+
}
|
|
37
|
+
if (event.role === "tool") {
|
|
38
|
+
const text = typeof event.content === "string" ? event.content : "";
|
|
39
|
+
return {
|
|
40
|
+
event: "response.tool_result",
|
|
41
|
+
call_id: String(event.tool_call_id || ""),
|
|
42
|
+
output: text ? [{ type: "text", format: "plain", text }] : [],
|
|
43
|
+
is_error: false,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* kimi 真实 session id 的前缀(`session.resume_hint` 事件回报的形态)。
|
|
51
|
+
*
|
|
52
|
+
* 🔴 2026-08-16 真机故障固化的两条口径(kimi 0.34.0 实测):
|
|
53
|
+
* 1. `--print` 参数已不存在(报 `unknown option '--print' (Did you mean --prompt?)`),
|
|
54
|
+
* `--prompt` 本身就是「非交互跑一轮并打印」;
|
|
55
|
+
* 2. `--session <id>` 是**纯 resume** 语义 —— 传一个不存在的 id 直接报
|
|
56
|
+
* `Session "..." not found`,不再是旧版的 create-or-resume。所以首轮**不带**
|
|
57
|
+
* `--session`(让 CLI 自己建),从输出的 `session.resume_hint` 事件抓真实 id,
|
|
58
|
+
* 续轮才 resume;老 bridge 存过的 randomUUID 一律不信(没有此前缀)。
|
|
59
|
+
*/
|
|
60
|
+
const KIMI_SESSION_ID_PREFIX = "session_";
|
|
61
|
+
|
|
62
|
+
export function buildKimiArgs({ input, sessionId }) {
|
|
63
|
+
const args = ["--prompt", String(input || ""), "--output-format", "stream-json"];
|
|
64
|
+
if (sessionId) args.push("--session", sessionId);
|
|
65
|
+
return args;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createKimiInvoke({ fallbackWorkspace, preflightResult, log, runProcess = runNdjsonProcess }) {
|
|
69
|
+
const sessions = new Map();
|
|
70
|
+
return async ({ thread_id, input, workspace_path, resume_session_id, onChunk, onProcess }) => {
|
|
71
|
+
const { cwd } = resolveWorkspaceCwd({ workspacePath: workspace_path, fallbackWorkspace, runtime: "kimi", log });
|
|
72
|
+
// 只信带 kimi 真实前缀的 id(见 KIMI_SESSION_ID_PREFIX 的口径说明)。
|
|
73
|
+
const saved = sessions.get(thread_id) || resume_session_id;
|
|
74
|
+
const sessionId =
|
|
75
|
+
typeof saved === "string" && saved.startsWith(KIMI_SESSION_ID_PREFIX) ? saved : undefined;
|
|
76
|
+
await runProcess({
|
|
77
|
+
binary: preflightResult?.binary || "kimi",
|
|
78
|
+
args: buildKimiArgs({ input, sessionId }),
|
|
79
|
+
cwd,
|
|
80
|
+
parseEvent: (event) => {
|
|
81
|
+
// 首轮 CLI 自建会话后经 resume_hint 回报真实 id —— 记下来供续轮 resume。
|
|
82
|
+
if (
|
|
83
|
+
event?.type === "session.resume_hint" &&
|
|
84
|
+
typeof event.session_id === "string" &&
|
|
85
|
+
event.session_id.startsWith(KIMI_SESSION_ID_PREFIX)
|
|
86
|
+
) {
|
|
87
|
+
sessions.set(thread_id, event.session_id);
|
|
88
|
+
recordBridgedSession("kimi", event.session_id); // 打标:桥接产生的 CLI 会话,导入时跳过
|
|
89
|
+
}
|
|
90
|
+
return translateKimiEvent(event);
|
|
91
|
+
},
|
|
92
|
+
onChunk,
|
|
93
|
+
onProcess,
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function runKimiBridge({ options, log }) {
|
|
99
|
+
return runHeadlessCliBridge({ runtime: "kimi", options, log, preflight: kimiPreflight, createInvoke: createKimiInvoke });
|
|
100
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
function expandUserPath(value) {
|
|
6
|
+
const text = String(value || "").trim();
|
|
7
|
+
if (text === "~") return os.homedir();
|
|
8
|
+
if (text.startsWith("~/")) return path.join(os.homedir(), text.slice(2));
|
|
9
|
+
return text;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function configuredAgent(config, agentId) {
|
|
13
|
+
const entries = Array.isArray(config?.agents?.list) ? config.agents.list.filter(Boolean) : [];
|
|
14
|
+
const normalized = String(agentId || "main").trim().toLowerCase();
|
|
15
|
+
return entries.find((entry) => String(entry?.id || "").trim().toLowerCase() === normalized) || null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function defaultAgentId(config) {
|
|
19
|
+
const entries = Array.isArray(config?.agents?.list) ? config.agents.list.filter(Boolean) : [];
|
|
20
|
+
return String(entries.find((entry) => entry?.default)?.id || entries[0]?.id || "main").trim().toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveConfiguredOpenClawWorkspace(config, agentId) {
|
|
24
|
+
const normalizedAgentId = String(agentId || "main").trim().toLowerCase() || "main";
|
|
25
|
+
const configured = configuredAgent(config, normalizedAgentId)?.workspace;
|
|
26
|
+
if (typeof configured === "string" && configured.trim()) {
|
|
27
|
+
return path.resolve(expandUserPath(configured));
|
|
28
|
+
}
|
|
29
|
+
const fallback = config?.agents?.defaults?.workspace;
|
|
30
|
+
if (typeof fallback === "string" && fallback.trim()) {
|
|
31
|
+
const root = path.resolve(expandUserPath(fallback));
|
|
32
|
+
return normalizedAgentId === defaultAgentId(config) ? root : path.join(root, normalizedAgentId);
|
|
33
|
+
}
|
|
34
|
+
if (normalizedAgentId === defaultAgentId(config)) {
|
|
35
|
+
return path.join(os.homedir(), ".openclaw", "workspace");
|
|
36
|
+
}
|
|
37
|
+
return path.join(os.homedir(), ".openclaw", `workspace-${normalizedAgentId}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* OpenClaw /v1/responses has no request-level cwd field (strict request
|
|
42
|
+
* schema). Therefore a workspace run is safe only when the selected agent's
|
|
43
|
+
* configured workspace is exactly the requested directory.
|
|
44
|
+
*/
|
|
45
|
+
export function assertOpenClawWorkspaceContext({ config, agentId, workspacePath }) {
|
|
46
|
+
const requested = String(workspacePath || "").trim();
|
|
47
|
+
if (!requested) return;
|
|
48
|
+
let available = path.isAbsolute(requested);
|
|
49
|
+
if (available) {
|
|
50
|
+
try { available = fs.statSync(requested).isDirectory(); } catch { available = false; }
|
|
51
|
+
}
|
|
52
|
+
const configured = resolveConfiguredOpenClawWorkspace(config, agentId);
|
|
53
|
+
if (!available || path.resolve(requested) !== path.resolve(configured)) {
|
|
54
|
+
const error = new Error("openclaw: requested workspace context is unavailable");
|
|
55
|
+
error.code = "workspace_context_unavailable";
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|