hermes-connect-services 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 CHANGED
@@ -19,7 +19,25 @@ node hermes-connect-services/bin/sync-hermes-config.mjs
19
19
  node hermes-connect-services/bin/sync-hermes-config.mjs --install-hooks
20
20
  ```
21
21
 
22
- 两步可以合成一次带 `--install-hooks` 的运行。配置文件位置自动探测:
22
+ 两步可以合成一次带 `--install-hooks` 的运行。
23
+
24
+ `--install-hooks` 顺手把 hook 真正跑起来所需的两件东西一起办了(只写 config.yaml 是不够的):
25
+
26
+ | 目标文件 | 写入内容 | 少了它会怎样 |
27
+ |---|---|---|
28
+ | `shell-hooks-allowlist.json` | `on_session_start` 的审批条目 | hook 每次会话都被跳过,端点从不启动 |
29
+ | `.env` | 托管块 `NO_PROXY=127.0.0.1,…` | 代理软件会把回环调用吞掉 |
30
+
31
+ 写完**重启 hermes**:hook 只在网关启动时注册。
32
+
33
+ 也可以走 npm(四个端点包 + 本包装在同一项目里,脚本会自动在 `node_modules/` 找兄弟包):
34
+
35
+ ```sh
36
+ npm i zcode-trae-connect zcode-workbuddy-connect zcode-qoder-connect zcode-qwenwork-connect hermes-connect-services
37
+ npx hermes-connect-sync --install-hooks
38
+ ```
39
+
40
+ 配置文件位置自动探测:
23
41
 
24
42
  | 平台 | 路径 |
25
43
  |---|---|
@@ -105,17 +123,33 @@ model:
105
123
 
106
124
  **401 / 模型不在了** — 端点 token 轮换或上游名册变了,重跑 `sync-hermes-config.mjs`。
107
125
 
108
- **hook 第一次不执行** — hermes 首次运行 hook 需要交互确认。非交互场景在 config.yaml 设:
126
+ **hook 被静默跳过 —— 这是最常见的"provider temporarily unavailable"真因。** hermes 只运行 `(event, command)` 出现在 `shell-hooks-allowlist.json` 里的 hook;没列入的会被日志记一句 `not allowlisted … — skipped` 然后**永不执行**,于是四个端点从未启动,hermes 侧只看到 connection refused,一路重试到 5 个 cycle。
127
+
128
+ `--install-hooks` 现在会自己把这一对写进 allowlist(位于 hermes home,与 config.yaml 同级),并保留你其他的 hook 审批。检查:
129
+
130
+ ```sh
131
+ node bin/sync-hermes-config.mjs --install-hooks --verbose # 应输出 hook allowlist: added|present
132
+ grep -c allowlisted ~/AppData/Local/hermes/logs/agent.log # 不该再增长
133
+ ```
134
+
135
+ 手工兜底(可选,范围更大):
109
136
 
110
137
  ```yaml
111
138
  hooks_auto_accept: true
112
139
  ```
113
140
 
114
- **连不上回环地址(`404 no such route` 带绝对 URL、**或 connection refused)** — hermes 是 Python + httpx,会读 `HTTP_PROXY` / `HTTPS_PROXY`。这些变量存在时本地请求会被送去代理,回环地址自然打不通。启动时带上 no_proxy:
141
+ **改完必须重启 hermes** — hook 在网关启动时注册,已运行的会话不会补挂。
142
+
143
+ **连不上回环地址(`404 no such route` 带绝对 URL,或 connection refused)** — hermes 是 Python + httpx,会读 `HTTP_PROXY` / `HTTPS_PROXY`。历史上这条会把手打回环的请求送去代理,`--install-hooks` 因此会在 hermes home 的 `.env` 里托管一段:
115
144
 
116
- ```sh
117
- NO_PROXY=127.0.0.1,localhost # PowerShell: $env:NO_PROXY="127.0.0.1,localhost"
118
145
  ```
146
+ # >>> zcode-ai-connect >>>
147
+ NO_PROXY=127.0.0.1,localhost,::1,0.0.0.0
148
+ no_proxy=127.0.0.1,localhost,::1,0.0.0.0
149
+ # <<< zcode-ai-connect <<<
150
+ ```
151
+
152
+ 实测 httpx + openai SDK 通常已自行放行 `127.0.0.1`,这段属于兜底:机器换代理软件、或代理换成强制拦截型时能少一次排查。(`.env` 是 `override=True` 载入的,所以它会顶掉系统里同名的 `NO_PROXY`;要在代理侧仍走别的例外host,把它们的域名补进这四行。)
119
153
 
120
154
  **Thoughts 太长被压缩** — `model_overrides` 里的 `context_window` 来自端点实时 roster;换模型名后重跑同步脚本同步上下文窗口。
121
155
 
@@ -27,6 +27,7 @@ import { fileURLToPath } from 'node:url';
27
27
  import { SERVICES, HOME, SUITE_ROOT, ensure as ensureService } from '../src/services.mjs';
28
28
  import { loadRoster, describe } from '../src/roster.mjs';
29
29
  import { patchManagedBlock, readManagedBlock, readManagedChildren } from '../src/yaml-patch.mjs';
30
+ import { allowHook, revokeHooks, ensureNoProxy, removeNoProxy } from '../src/hermes-home.mjs';
30
31
 
31
32
  const HERE = pathDirname(fileURLToPath(import.meta.url));
32
33
  const MARKER = 'zcode-ai-connect';
@@ -118,15 +119,20 @@ function overridesBlockYaml(service, models) {
118
119
  function hooksBlockYaml() {
119
120
  const nodeExe = process.execPath.replaceAll('\\', '/');
120
121
  const script = join(HERE, 'ensure-services.mjs').replaceAll('\\', '/');
122
+ // The raw string hermes shlex-splits into argv; also the key it matches
123
+ // against shell-hooks-allowlist.json, so the two must stay byte-identical.
124
+ const command = `"${nodeExe}" "${script}"`;
121
125
  // shlex.split + shell=False: the token that reaches Popen must keep both
122
126
  // paths quoted, so the whole value is single-quoted YAML rather than two
123
127
  // adjacent double-quoted scalars (which YAML rejects outright).
124
- const command = `'${`"${nodeExe}" "${script}"`}'`;
125
- return [
126
- `on_session_start:`,
127
- ` - command: ${command}`,
128
- ` timeout: 30`,
129
- ];
128
+ return {
129
+ command,
130
+ lines: [
131
+ `on_session_start:`,
132
+ ` - command: '${command}'`,
133
+ ` timeout: 30`,
134
+ ],
135
+ };
130
136
  }
131
137
 
132
138
  // --- collect rosters --------------------------------------------------------
@@ -192,6 +198,10 @@ if (options.remove) {
192
198
  const result = patchManagedBlock(text, key, '', { marker: MARKER });
193
199
  text = result.text;
194
200
  }
201
+ const HOME_DIR = pathDirname(CONFIG_PATH);
202
+ const revoked = revokeHooks(HOME_DIR, [hooksBlockYaml().command], { dryRun: options.dryRun });
203
+ if (revoked) console.log(` hook allowlist: removed ${revoked}`);
204
+ if (removeNoProxy(HOME_DIR, { dryRun: options.dryRun })) console.log(' no_proxy: removed');
195
205
  } else {
196
206
  const providerUpdates = [];
197
207
  const overrideUpdates = [];
@@ -213,7 +223,8 @@ if (options.remove) {
213
223
  }
214
224
 
215
225
  if (options.installHooks) {
216
- const body = hooksBlockYaml().join('\n');
226
+ const hook = hooksBlockYaml();
227
+ const body = hook.lines.join('\n');
217
228
  if (readManagedBlock(text, 'hooks', { marker: MARKER }).trim() !== '') {
218
229
  text = patchManagedBlock(text, 'hooks', body, { marker: MARKER }).text;
219
230
  changes.push('hooks: replaced');
@@ -226,6 +237,16 @@ if (options.remove) {
226
237
  text = result.text;
227
238
  changes.push(`hooks: ${result.action}`);
228
239
  }
240
+
241
+ // Writing the hook is only half of it: hermes refuses to run any
242
+ // (event, command) it has not approved, and silently skips it each session.
243
+ const HOME_DIR = pathDirname(CONFIG_PATH);
244
+ const allowed = allowHook(HOME_DIR, 'on_session_start', hook.command, { dryRun: options.dryRun });
245
+ changes.push(`hook allowlist: ${allowed}`);
246
+ // httpx honours HTTP_PROXY; without this the loopback calls die as
247
+ // "Provider temporarily unavailable".
248
+ const proxied = ensureNoProxy(HOME_DIR, { dryRun: options.dryRun });
249
+ changes.push(`no_proxy: ${proxied}`);
229
250
  }
230
251
  console.log(changes.map((line) => ` ${line}`).join('\n'));
231
252
  }
@@ -253,13 +274,11 @@ console.log(` hermes --provider ${collected[0].service.id} --model <id>`);
253
274
  console.log('');
254
275
  console.log('Token 轮换(报 401)或上游名册变化时,重跑本脚本即可。');
255
276
 
256
- if (options.installHooks && !options.remove && !options.dryRun) {
257
- const autoAccept = readFileSync(CONFIG_PATH, 'utf8').match(/^hooks_auto_accept:\s*(.+)$/m)?.[1]?.trim();
258
- if (autoAccept !== 'true') {
259
- console.log('');
260
- console.log(`提示: 首次运行 hook 需要交互确认。非交互场景请在 ${CONFIG_PATH} 设置 hooks_auto_accept: true`);
261
- console.log(' (当前值: ' + (autoAccept ?? '未设置') + ')');
262
- }
277
+ if (options.installHooks && !options.remove) {
278
+ console.log('');
279
+ console.log('⚠ 重启 hermes 让两件事生效:');
280
+ console.log(' 1. on_session_start hook 重新注册(已写入 allowlist,不会再被跳过)');
281
+ console.log(' 2. .env 里的 NO_PROXY(否则 127.0.0.1 的请求会被 HTTP_PROXY 吞掉)');
263
282
  }
264
283
 
265
284
  if (options.remove) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermes-connect-services",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Bring zcode-ai-connect's four loopback endpoints to hermes: lazy-start them on session start and sync them into hermes' config.yaml as OpenAI-compatible providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,6 +23,7 @@
23
23
  "files": [
24
24
  "bin",
25
25
  "src",
26
+ "tests",
26
27
  "README.md",
27
28
  "LICENSE"
28
29
  ],
@@ -0,0 +1,122 @@
1
+ /**
2
+ * hermes-home.mjs — the two side files a shell hook needs before it will run.
3
+ *
4
+ * `config.yaml` alone is not enough to make `hooks.on_session_start` fire:
5
+ *
6
+ * 1. shell-hooks-allowlist.json — hermes only runs a hook whose exact
7
+ * (event, command) pair is approved. Without it the gateway logs
8
+ * "not allowlisted ... — skipped" on every session and the endpoints are
9
+ * never started. We append the pair, keeping other hooks' approvals.
10
+ *
11
+ * 2. .env NO_PROXY — the OpenAI SDK underneath hermes is httpx, which honours
12
+ * HTTP_PROXY. A machine-wide proxy therefore swallows every 127.0.0.1 call
13
+ * and the provider dies with ECONNREFUSED. The loopback endpoints must be
14
+ * excluded from proxying.
15
+ */
16
+
17
+ import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+
20
+ const ALLOWLIST_FILENAME = 'shell-hooks-allowlist.json';
21
+ const ENV_MARKER_START = '# >>> zcode-ai-connect >>>';
22
+ const ENV_MARKER_END = '# <<< zcode-ai-connect <<<';
23
+ const LOOPBACK = '127.0.0.1,localhost,::1,0.0.0.0';
24
+
25
+ function readJson(path) {
26
+ try {
27
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
28
+ return parsed && typeof parsed === 'object' ? parsed : { approvals: [] };
29
+ } catch {
30
+ return { approvals: [] };
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Approve one (event, command) pair. Idempotent: an identical pair is left
36
+ * alone so the approved_at timestamp does not churn on every sync.
37
+ *
38
+ * @returns {'added' | 'present' | 'skipped-dry-run'}
39
+ */
40
+ export function allowHook(homeDir, event, command, { dryRun = false } = {}) {
41
+ const path = join(homeDir, ALLOWLIST_FILENAME);
42
+ const data = readJson(path);
43
+ const approvals = Array.isArray(data.approvals) ? data.approvals : [];
44
+ if (approvals.some((e) => e && e.event === event && e.command === command)) return 'present';
45
+ if (dryRun) return 'skipped-dry-run';
46
+
47
+ if (existsSync(path)) copyFileSync(path, `${path}.bak`);
48
+ data.approvals = [...approvals, {
49
+ event,
50
+ command,
51
+ approved_at: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
52
+ script_mtime_at_approval: null,
53
+ }];
54
+ writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
55
+ return 'added';
56
+ }
57
+
58
+ /** Drop our own approvals (used by --remove). Other hooks stay approved. */
59
+ export function revokeHooks(homeDir, commands, { dryRun = false } = {}) {
60
+ const path = join(homeDir, ALLOWLIST_FILENAME);
61
+ if (!existsSync(path)) return 0;
62
+ const data = readJson(path);
63
+ const approvals = Array.isArray(data.approvals) ? data.approvals : [];
64
+ const kept = approvals.filter((e) => !(e && commands.includes(e.command)));
65
+ const removed = approvals.length - kept.length;
66
+ if (removed === 0 || dryRun) return removed;
67
+ copyFileSync(path, `${path}.bak`);
68
+ data.approvals = kept;
69
+ writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
70
+ return removed;
71
+ }
72
+
73
+ /**
74
+ * Make sure the loopback hosts bypass the machine proxy. The managed block is
75
+ * rewritten in place so repeated runs stay byte-stable.
76
+ *
77
+ * @returns {'wrote' | 'updated' | 'present' | 'skipped-dry-run'}
78
+ */
79
+ export function ensureNoProxy(homeDir, { dryRun = false } = {}) {
80
+ const path = join(homeDir, '.env');
81
+ const body = [
82
+ ENV_MARKER_START,
83
+ `NO_PROXY=${LOOPBACK}`,
84
+ `no_proxy=${LOOPBACK}`,
85
+ ENV_MARKER_END,
86
+ ].join('\n');
87
+
88
+ const text = existsSync(path) ? readFileSync(path, 'utf8') : '';
89
+ const start = text.indexOf(ENV_MARKER_START);
90
+ const end = text.indexOf(ENV_MARKER_END);
91
+
92
+ if (start !== -1 && end !== -1 && end > start) {
93
+ const current = text.slice(start, end + ENV_MARKER_END.length);
94
+ if (current === body) return 'present';
95
+ if (dryRun) return 'skipped-dry-run';
96
+ copyFileSync(path, `${path}.bak`);
97
+ writeFileSync(path, text.slice(0, start) + body + text.slice(end + ENV_MARKER_END.length), 'utf8');
98
+ return 'updated';
99
+ }
100
+
101
+ if (dryRun) return 'skipped-dry-run';
102
+ if (existsSync(path)) copyFileSync(path, `${path}.bak`);
103
+ const prefix = text === '' ? '' : (text.endsWith('\n') ? '\n' : '\n\n');
104
+ writeFileSync(path, `${text}${prefix}${body}\n`, 'utf8');
105
+ return 'wrote';
106
+ }
107
+
108
+ /** Remove the managed NO_PROXY block (used by --remove). */
109
+ export function removeNoProxy(homeDir, { dryRun = false } = {}) {
110
+ const path = join(homeDir, '.env');
111
+ if (!existsSync(path)) return false;
112
+ const text = readFileSync(path, 'utf8');
113
+ const start = text.indexOf(ENV_MARKER_START);
114
+ const end = text.indexOf(ENV_MARKER_END);
115
+ if (start === -1 || end === -1) return false;
116
+ if (dryRun) return true;
117
+ copyFileSync(path, `${path}.bak`);
118
+ const before = text.slice(0, start).replace(/\n+$/, '\n');
119
+ const after = text.slice(end + ENV_MARKER_END.length).replace(/^\n+/, '');
120
+ writeFileSync(path, `${before}${after}`, 'utf8');
121
+ return true;
122
+ }
package/src/roster.mjs CHANGED
@@ -16,22 +16,31 @@ import { readEndpoint } from './services.mjs';
16
16
  * @returns {Promise<{ baseUrl: string, token: string, port: number, rows: Array<object> }>}
17
17
  * @throws when the endpoint is unreachable or answers non-200.
18
18
  */
19
- export async function loadRoster(service, { timeoutMs = 5000 } = {}) {
19
+ export async function loadRoster(service, { timeoutMs = 5000, attempts = 3, delayMs = 1500 } = {}) {
20
20
  const { token, port } = await readEndpoint(service);
21
- const response = await fetch(`http://127.0.0.1:${port}/v1/models`, {
22
- headers: token === '' ? {} : { Authorization: `Bearer ${token}` },
23
- signal: AbortSignal.timeout(timeoutMs),
24
- });
25
- if (!response.ok) {
26
- throw new Error(`/v1/models -> ${response.status} (端点没跑?先 serve)`);
21
+ // A sidecar that `ensure` just spawned answers /healthz before its roster is
22
+ // populated; reading once here writes a provider with zero models and hermes
23
+ // then shows nothing under it. Retry the empty answer, not the error.
24
+ let last = { baseUrl: `http://127.0.0.1:${port}/v1`, token, port, rows: [] };
25
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
26
+ const response = await fetch(`http://127.0.0.1:${port}/v1/models`, {
27
+ headers: token === '' ? {} : { Authorization: `Bearer ${token}` },
28
+ signal: AbortSignal.timeout(timeoutMs),
29
+ });
30
+ if (!response.ok) {
31
+ throw new Error(`/v1/models -> ${response.status} (端点没跑?先 serve)`);
32
+ }
33
+ const json = await response.json();
34
+ last = {
35
+ baseUrl: `http://127.0.0.1:${port}/v1`,
36
+ token,
37
+ port,
38
+ rows: Array.isArray(json?.data) ? json.data : [],
39
+ };
40
+ if (last.rows.length > 0 || attempt === attempts) break;
41
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
27
42
  }
28
- const json = await response.json();
29
- return {
30
- baseUrl: `http://127.0.0.1:${port}/v1`,
31
- token,
32
- port,
33
- rows: Array.isArray(json?.data) ? json.data : [],
34
- };
43
+ return last;
35
44
  }
36
45
 
37
46
  /**
package/src/services.mjs CHANGED
@@ -15,12 +15,21 @@ import { existsSync } from 'node:fs';
15
15
  import { readFile } from 'node:fs/promises';
16
16
  import { mkdir, appendFile } from 'node:fs/promises';
17
17
  import { spawn } from 'node:child_process';
18
- import { dirname, join, resolve } from 'node:path';
18
+ import { dirname, join, resolve, basename } from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
20
 
21
- /** Suite root = parent of this directory; overridable for relocation/tests. */
21
+ /**
22
+ * Suite root = the directory the sibling tools live in.
23
+ *
24
+ * In a git checkout that is the repo root; in an npm install the module sits
25
+ * at `<proj>/node_modules/hermes-connect-services/src/`, so one level up from
26
+ * `node_modules` is what we want — otherwise every path would gain a second
27
+ * `node_modules`.
28
+ */
29
+ const PACKAGE_PARENT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
22
30
  export const SUITE_ROOT = resolve(
23
- process.env.AI_CONNECT_SUITE_ROOT ?? dirname(dirname(dirname(fileURLToPath(import.meta.url)))),
31
+ process.env.AI_CONNECT_SUITE_ROOT
32
+ ?? (basename(PACKAGE_PARENT) === 'node_modules' ? dirname(PACKAGE_PARENT) : PACKAGE_PARENT),
24
33
  );
25
34
 
26
35
  export const SERVICES = [
@@ -56,8 +65,39 @@ export const SERVICES = [
56
65
 
57
66
  export const HOME = process.env.USERPROFILE ?? process.env.HOME ?? '.';
58
67
 
59
- export const cliPath = (service) => join(SUITE_ROOT, service.tool, 'bin', 'cli.mjs');
60
- export const toolRoot = (service) => join(SUITE_ROOT, service.tool);
68
+ const toolDirCache = new Map();
69
+
70
+ /**
71
+ * Where a sibling tool actually lives.
72
+ *
73
+ * Three layouts have to work, because this module ships both as a git checkout
74
+ * and as an npm package:
75
+ *
76
+ * <repo>/hermes-connect-services/ + <repo>/zcode-trae-connect/ (checkout)
77
+ * <proj>/node_modules/zcode-trae-connect/ (npm install)
78
+ * <prefix>/node_modules/zcode-trae-connect/ (npm -g)
79
+ *
80
+ * `AI_CONNECT_SUITE_ROOT` wins when set, which is the escape hatch for any
81
+ * layout not covered above.
82
+ */
83
+ export function toolDir(service) {
84
+ const cached = toolDirCache.get(service.id);
85
+ if (cached) return cached;
86
+
87
+ const candidates = [
88
+ process.env.AI_CONNECT_SUITE_ROOT ? join(process.env.AI_CONNECT_SUITE_ROOT, service.tool) : null,
89
+ join(SUITE_ROOT, service.tool),
90
+ join(SUITE_ROOT, 'node_modules', service.tool),
91
+ join(dirname(SUITE_ROOT), service.tool),
92
+ ].filter(Boolean);
93
+
94
+ const resolved = candidates.find((dir) => existsSync(join(dir, 'bin', 'cli.mjs'))) ?? candidates[1];
95
+ toolDirCache.set(service.id, resolved);
96
+ return resolved;
97
+ }
98
+
99
+ export const cliPath = (service) => join(toolDir(service), 'bin', 'cli.mjs');
100
+ export const toolRoot = (service) => toolDir(service);
61
101
  export const stateDir = (service) => join(HOME, service.state);
62
102
 
63
103
  /** Read `{token, port}` from the tool's state dir, falling back to its default port. */
@@ -19,6 +19,8 @@
19
19
 
20
20
  const START = (marker) => `# >>> ${marker} >>>`;
21
21
  const END = (marker) => `# <<< ${marker} <<<`;
22
+ /** A child key this suite owns — used to find an orphaned region's real start. */
23
+ const OWNS_CHILD = /^\s{0,4}zc-[A-Za-z0-9._-]+\s*:\s*$/;
22
24
 
23
25
  /**
24
26
  * Replace the managed region inside one top-level mapping key.
@@ -156,6 +158,18 @@ function mappingRegion(lines, keyIndex) {
156
158
  return { start: keyIndex + 1, end };
157
159
  }
158
160
 
161
+ /**
162
+ * Locate the managed region inside one mapping.
163
+ *
164
+ * `orphan` covers the case that actually bites in the field: hermes rewrites
165
+ * config.yaml from its own model (GUI model switch, `hermes config set`, …) and
166
+ * drops the opening marker comment while keeping the body and the closing one.
167
+ * Treating that as "no region" makes the next sync insert a second copy of every
168
+ * provider — duplicate top-level keys, and PyYAML silently keeps the last. So an
169
+ * orphan is claimed from its first owned child (`zc-*`) to the stray end marker
170
+ * and rewritten whole. Starting at the child—not at the mapping—is deliberate:
171
+ * a hand-written provider above ours must never be swallowed.
172
+ */
159
173
  function findManagedRegion(lines, region, startToken, endToken) {
160
174
  let start = -1;
161
175
  for (let i = region.start; i < region.end; i += 1) {
@@ -164,7 +178,14 @@ function findManagedRegion(lines, region, startToken, endToken) {
164
178
  break;
165
179
  }
166
180
  }
167
- if (start === -1) return null;
181
+ if (start === -1) {
182
+ const endIndex = lines.findIndex((line, i) => i >= region.start && i < region.end && line.trim() === endToken);
183
+ if (endIndex === -1) return null;
184
+ for (let i = region.start; i < endIndex; i += 1) {
185
+ if (OWNS_CHILD.test(lines[i])) return { start: i, end: endIndex, orphan: true };
186
+ }
187
+ return null;
188
+ }
168
189
  for (let i = start; i < region.end; i += 1) {
169
190
  if (lines[i].trim() === endToken) return { start, end: i };
170
191
  }
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Regressions for the YAML patcher — the one piece of this module that can
4
+ * corrupt a user's config, so it gets tests instead of good intentions.
5
+ *
6
+ * Run: node hermes-connect-services/tests/test-yaml-patch.mjs
7
+ */
8
+ import assert from 'node:assert/strict';
9
+ import { patchManagedBlock, readManagedBlock, readManagedChildren } from '../src/yaml-patch.mjs';
10
+
11
+ const MARKER = 'zcode-ai-connect';
12
+ const sample = [
13
+ 'model:',
14
+ ' provider: nous',
15
+ 'providers:',
16
+ ' xiaoxin:',
17
+ ' name: xiaoxin',
18
+ ' base_url: https://example.test',
19
+ 'fallback_providers: []',
20
+ '',
21
+ '# trailing comment that must survive',
22
+ 'display:',
23
+ ' language: zh',
24
+ '',
25
+ ].join('\n');
26
+
27
+ let passed = 0;
28
+ const check = (name, fn) => {
29
+ fn();
30
+ passed += 1;
31
+ console.log(` ok ${name}`);
32
+ };
33
+
34
+ console.log('test-yaml-patch');
35
+
36
+ check('inserts a managed region inside an existing mapping', () => {
37
+ const { text, action } = patchManagedBlock(sample, 'providers', 'zc-trae:\n base_url: "http://127.0.0.1:1/v1"', { marker: MARKER });
38
+ assert.equal(action, 'inserted');
39
+ assert.match(text, /providers:\n xiaoxin:/);
40
+ assert.match(text, /# >>> zcode-ai-connect >>>\n {2}zc-trae:/);
41
+ assert.match(text, /fallback_providers: \[\]/);
42
+ });
43
+
44
+ check('replaces its own region and leaves foreign keys alone', () => {
45
+ const first = patchManagedBlock(sample, 'providers', 'zc-trae:\n base_url: "a"', { marker: MARKER }).text;
46
+ const second = patchManagedBlock(first, 'providers', 'zc-trae:\n base_url: "b"', { marker: MARKER }).text;
47
+ assert.match(second, /base_url: "b"/);
48
+ assert.match(second, / xiaoxin:/);
49
+ assert.equal(readManagedChildren(second, 'providers', { marker: MARKER }).length, 1);
50
+ });
51
+
52
+ check('is idempotent: re-patching identical content writes no change', () => {
53
+ const once = patchManagedBlock(sample, 'providers', 'zc-trae:\n base_url: "a"', { marker: MARKER }).text;
54
+ const twice = patchManagedBlock(once, 'providers', 'zc-trae:\n base_url: "a"', { marker: MARKER });
55
+ assert.equal(twice.changed, false);
56
+ assert.equal(twice.text, once);
57
+ });
58
+
59
+ check('removal restores the original bytes exactly', () => {
60
+ const once = patchManagedBlock(sample, 'providers', 'zc-trae:\n base_url: "a"', { marker: MARKER }).text;
61
+ const gone = patchManagedBlock(once, 'providers', '', { marker: MARKER }).text;
62
+ assert.equal(gone, sample);
63
+ });
64
+
65
+ check('creates the top-level key when it is missing', () => {
66
+ const { text, action } = patchManagedBlock(sample, 'model_overrides', 'zc-trae:\n "id":\n supports_tools: true', { marker: MARKER });
67
+ assert.equal(action, 'inserted');
68
+ assert.match(text, /\nmodel_overrides:\n {2}# >>> zcode-ai-connect >>>/);
69
+ assert.match(text, /display:\n {2}language: zh/);
70
+ });
71
+
72
+ check('never touches content outside its own mapping', () => {
73
+ const { text } = patchManagedBlock(sample, 'providers', 'zc-trae:\n base_url: "a"', { marker: MARKER });
74
+ assert.equal(readManagedBlock(text, 'model', { marker: MARKER }), '');
75
+ assert.match(text, /model:\n {2}provider: nous/);
76
+ });
77
+
78
+ check('readManagedChildren round-trips every provider id', () => {
79
+ const body = [
80
+ 'zc-trae:',
81
+ ' base_url: "http://127.0.0.1:39272/v1"',
82
+ ' models:',
83
+ ' "a": {}',
84
+ 'zc-workbuddy:',
85
+ ' base_url: "http://127.0.0.1:39271/v1"',
86
+ ].join('\n');
87
+ const once = patchManagedBlock(sample, 'providers', body, { marker: MARKER }).text;
88
+ const children = readManagedChildren(once, 'providers', { marker: MARKER });
89
+ assert.deepEqual(children.map((c) => c.key), ['zc-trae', 'zc-workbuddy']);
90
+ assert.match(children[0].body, /models:\n {4}"a": \{\}/);
91
+ // Feeding them back unchanged must be a no-op — this is what keeps `--only`
92
+ // from silently deleting the providers it was not asked about.
93
+ const twice = patchManagedBlock(once, 'providers', children.map((c) => c.body).join('\n'), { marker: MARKER });
94
+ assert.equal(twice.text, once);
95
+ });
96
+
97
+ check('survives an unterminated marker (hand-edit gone wrong)', () => {
98
+ // A hand-edit that dropped the closing marker leaves an orphan inside the
99
+ // providers mapping — the next sync must repair it, not stack a second one.
100
+ const broken = sample.replace(
101
+ 'fallback_providers: []',
102
+ ` # >>> ${MARKER} >>>\n zc-trae:\n base_url: "a"\nfallback_providers: []`,
103
+ );
104
+ const repaired = patchManagedBlock(broken, 'providers', 'zc-trae:\n base_url: "a"', { marker: MARKER });
105
+ assert.equal(repaired.text.match(new RegExp(`# >>> ${MARKER} >>>`, 'g')).length, 1);
106
+ assert.match(repaired.text, /# <<< zcode-ai-connect <<</);
107
+ });
108
+
109
+ check('reclaims an orphaned region whose opening marker hermes ate', () => {
110
+ // Real-world shape: hermes rewrote config.yaml from its own model and kept the
111
+ // body plus the closing marker, losing only the opening one. A naive patcher
112
+ // reads "no region" and inserts a second copy of every provider — duplicate
113
+ // top-level keys that PyYAML silently collapses.
114
+ const orphan = [
115
+ 'providers:',
116
+ ' xiaoxin:',
117
+ ' name: xiaoxin',
118
+ ' zc-trae:',
119
+ ' base_url: "a"',
120
+ ` # <<< ${MARKER} <<<`,
121
+ 'fallback_providers: []',
122
+ '',
123
+ ].join('\n');
124
+ const fixed = patchManagedBlock(orphan, 'providers', 'zc-trae:\n base_url: "b"', { marker: MARKER });
125
+ assert.equal(fixed.action, 'replaced');
126
+ assert.equal(fixed.text.match(new RegExp(`# >>> ${MARKER} >>>`, 'g')).length, 1);
127
+ assert.equal(fixed.text.match(new RegExp(`# <<< ${MARKER} <<<`, 'g')).length, 1);
128
+ // The hand-written provider above ours survives; ours is rewritten.
129
+ assert.match(fixed.text, / {2}xiaoxin:\n {4}name: xiaoxin/);
130
+ assert.match(fixed.text, /base_url: "b"/);
131
+ // And it is stable from now on.
132
+ assert.equal(patchManagedBlock(fixed.text, 'providers', 'zc-trae:\n base_url: "b"', { marker: MARKER }).changed, false);
133
+ });
134
+
135
+ console.log(`\n${passed} passed`);