grix-connector 3.7.1 → 3.8.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.
@@ -0,0 +1,492 @@
1
+ #!/usr/bin/env python3
2
+ """Grix 中转(虚拟 Key)在**独立 Hermes** profile 上的落地执行端。
3
+
4
+ 与连接器托管的 agent 不同:独立 Hermes 是用户自己跑的 gateway 进程,连接器既不 spawn 它、
5
+ 也不在 agents.json 里认识它,因此没有"spawn 时注入 env"这一步——只能直接改它 profile 里的
6
+ config.yaml。目标 profile **不靠传路径、靠 agent_id 认**:每个接了 Grix 的 profile 都在
7
+ ``channels.grix.wsUrl`` 里带着自己的 ``agent_id``,用它跟服务端下发的 agent_id 对上号。
8
+
9
+ **为什么必须用 Hermes 自己的解释器跑**(而不是在连接器里用 Node 解析 YAML):
10
+ 配置的读写全交给宿主的 ``hermes_cli.config``,它负责原子写(temp+fsync+replace)、保留原
11
+ 文件权限与 owner、对照 raw 配置剥离默认值、以及把文件尾那段注释区重新写回去。自己解析回写
12
+ YAML 会把注释区丢掉,也会跟宿主的归一化规则漂移。写入语义与用户在聊天里敲 ``/model`` 完全
13
+ 一致,走的是同一个 ``save_config``。
14
+
15
+ 协议:stdin 收一条 JSON 请求,stdout 回一条 JSON 响应。api_key 绝不进输出。
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import contextlib
20
+ import copy
21
+ import io
22
+ import json
23
+ import os
24
+ import shutil
25
+ import sys
26
+ import time
27
+ from typing import Any, Dict, List, Optional, Tuple
28
+ from urllib.parse import parse_qs, urlparse
29
+
30
+ # 这个脚本躺在连接器的源码树里,而 Python 会把"脚本所在目录"和 cwd 塞进 sys.path 的最前面。
31
+ # 宿主 Hermes 内部大量使用顶层 import(utils、gateway 等),一旦连接器目录下有同名文件或目录,
32
+ # 它就会解析错位并炸在 import 上。清掉这两个来源——本脚本不从相邻目录 import 任何东西。
33
+ _HERE = os.path.dirname(os.path.abspath(__file__))
34
+ sys.path[:] = [
35
+ entry for entry in sys.path
36
+ if entry and os.path.abspath(entry) != _HERE
37
+ ]
38
+
39
+ try:
40
+ from hermes_cli.config import (
41
+ clear_model_endpoint_credentials,
42
+ read_raw_config,
43
+ save_config,
44
+ )
45
+ from hermes_cli.profiles import list_profiles
46
+ except Exception as _import_exc: # noqa: BLE001 - 必须报清楚,不能被下游当成"没有 profile"
47
+ print(json.dumps({
48
+ "ok": False,
49
+ "error_code": "HERMES_IMPORT_FAILED",
50
+ "error_msg": f"cannot import hermes modules ({type(_import_exc).__name__}: {_import_exc}); "
51
+ "this script must run with hermes' own interpreter",
52
+ }))
53
+ sys.exit(1)
54
+
55
+ # 连接器写进 Hermes 的固定 provider id。
56
+ GRIX_PROVIDER_ID = "grix"
57
+ # 记录开中转前的原模型设置,供关闭时精确还原。放 profile 目录,不进 config.yaml。
58
+ SIDECAR_NAME = ".grix-relay-state.json"
59
+ SNAPSHOT_PREFIX = "config.yaml.grix-relay-snapshot."
60
+ # 快照是含凭证的整份配置副本,不能无限堆。每次开中转建新的,只留最近这几个。
61
+ SNAPSHOT_KEEP = 3
62
+
63
+
64
+ class RelayError(Exception):
65
+ def __init__(self, code: str, msg: str) -> None:
66
+ super().__init__(msg)
67
+ self.code = code
68
+ self.msg = msg
69
+
70
+
71
+ def _redact(text: str, req: Dict[str, Any]) -> str:
72
+ """把本次请求里的 api_key 从要吐出去的文本里抹掉。
73
+
74
+ 宿主抛的异常今天恰好不回显输入,但那不是能长期依赖的保证——换个带输入回显的异常,Key 就
75
+ 跟着错误信息出去了。真做脱敏,而不是在注释里许诺。
76
+ """
77
+ secret = str(req.get("api_key") or "").strip()
78
+ if secret and secret in text:
79
+ text = text.replace(secret, "***redacted***")
80
+ return text
81
+
82
+
83
+ def _agent_id_of(cfg: Dict[str, Any]) -> str:
84
+ """从 ``channels.grix.wsUrl`` 的 query 里取 agent_id —— profile 与平台 agent 的唯一锚点。"""
85
+ channels = cfg.get("channels")
86
+ if not isinstance(channels, dict):
87
+ return ""
88
+ grix = channels.get(GRIX_PROVIDER_ID)
89
+ if not isinstance(grix, dict):
90
+ return ""
91
+ ws_url = grix.get("wsUrl")
92
+ if not isinstance(ws_url, str) or not ws_url.strip():
93
+ return ""
94
+ try:
95
+ values = parse_qs(urlparse(ws_url).query).get("agent_id") or []
96
+ except Exception:
97
+ return ""
98
+ return str(values[0]).strip() if values else ""
99
+
100
+
101
+ def _config_path(home: str) -> str:
102
+ return os.path.join(home, "config.yaml")
103
+
104
+
105
+ def _read_profile_config(home: str) -> Dict[str, Any]:
106
+ """读某个 profile 的原始配置(不合并默认值)。切 HERMES_HOME 即切目标 profile。
107
+
108
+ 宿主的 ``read_raw_config`` **自己吞掉解析错误并回一个空 dict**,永远不抛。不识别这一点的话,
109
+ 一个语法坏掉的 config.yaml 会表现成"这个 profile 没接 Grix",最终报 PROFILE_NOT_FOUND——
110
+ 把人引向完全错误的方向。文件有内容却解析成空 ⇒ 它是坏的,如实抛出去。
111
+ """
112
+ os.environ["HERMES_HOME"] = home
113
+ cfg = read_raw_config() or {}
114
+ if not cfg:
115
+ path = _config_path(home)
116
+ try:
117
+ broken = os.path.exists(path) and os.path.getsize(path) > 0
118
+ except OSError:
119
+ broken = False
120
+ if broken:
121
+ raise RelayError(
122
+ "CONFIG_UNREADABLE",
123
+ f"config.yaml exists but hermes parsed it as empty (likely malformed): {path}",
124
+ )
125
+ return cfg
126
+
127
+
128
+ def _save_profile_config(home: str, cfg: Dict[str, Any]) -> None:
129
+ os.environ["HERMES_HOME"] = home
130
+ save_config(cfg)
131
+
132
+
133
+ def _verify_write(home: str, expect_relay: bool) -> None:
134
+ """回读校验:写入必须真的落盘了。
135
+
136
+ 宿主的 ``save_config`` 在托管安装下(``is_managed()``)只往 stderr 打一行就 return,**不抛
137
+ 异常**——不校验的话,接口会在配置一个字节都没变的情况下报成功。跨进程覆盖(gateway 同时
138
+ 在写同一份 config,双方都是 read-modify-write)同样会让我们的写入无声消失。回读是把这两种
139
+ 假成功变成可见失败的唯一办法。
140
+ """
141
+ fresh = _read_profile_config(home)
142
+ providers = fresh.get("providers")
143
+ has_provider = isinstance(providers, dict) and GRIX_PROVIDER_ID in providers
144
+ on_grix = _relay_active(fresh)
145
+
146
+ if expect_relay and not (has_provider and on_grix):
147
+ raise RelayError(
148
+ "CONFIG_WRITE_VERIFY_FAILED",
149
+ "config.yaml did not take the relay settings after save "
150
+ f"(grix provider present={has_provider}, model points at grix={on_grix}); "
151
+ "the write was silently dropped — hermes may be a managed install, "
152
+ "or its gateway overwrote the file concurrently",
153
+ )
154
+ if not expect_relay and (has_provider or on_grix):
155
+ raise RelayError(
156
+ "CONFIG_WRITE_VERIFY_FAILED",
157
+ "config.yaml still carries relay settings after save "
158
+ f"(grix provider present={has_provider}, model points at grix={on_grix}); "
159
+ "the write was silently dropped",
160
+ )
161
+
162
+
163
+ def _iter_profiles() -> List[Tuple[str, str, bool]]:
164
+ """(name, home, gateway_running),含 default profile。用宿主的枚举,不自己拼路径。"""
165
+ out: List[Tuple[str, str, bool]] = []
166
+ for info in list_profiles():
167
+ out.append((str(info.name), str(info.path), bool(getattr(info, "gateway_running", False))))
168
+ return out
169
+
170
+
171
+ def _model_section(cfg: Dict[str, Any]) -> Dict[str, Any]:
172
+ raw = cfg.get("model")
173
+ if isinstance(raw, dict):
174
+ return raw
175
+ section: Dict[str, Any] = {}
176
+ # 历史上 model 可能是个裸字符串(模型名),按宿主的语义归一成 {default: <name>}。
177
+ if isinstance(raw, str) and raw.strip():
178
+ section["default"] = raw.strip()
179
+ cfg["model"] = section
180
+ return section
181
+
182
+
183
+ def _relay_active(cfg: Dict[str, Any]) -> bool:
184
+ """这个 profile 当前是不是正开着中转。
185
+
186
+ 判据是**模型指没指着 grix**,不是"sidecar 在不在"。两者在"用户中途自己 /model 切走后又
187
+ 重开中转"这个场景里会分岔:sidecar 还在,但盘上的 model 段已经是用户的新选择了。
188
+ """
189
+ raw = cfg.get("model")
190
+ if not isinstance(raw, dict):
191
+ return False
192
+ return str(raw.get("provider") or "").strip() == GRIX_PROVIDER_ID
193
+
194
+
195
+ def _sidecar_path(home: str) -> str:
196
+ return os.path.join(home, SIDECAR_NAME)
197
+
198
+
199
+ def _read_sidecar(home: str) -> Optional[Dict[str, Any]]:
200
+ path = _sidecar_path(home)
201
+ if not os.path.exists(path):
202
+ return None
203
+ try:
204
+ with open(path, encoding="utf-8") as handle:
205
+ data = json.load(handle)
206
+ return data if isinstance(data, dict) else None
207
+ except Exception:
208
+ # 记录读坏了就当没有:宁可不还原 model 段,也不能拿瞎猜的值去覆盖用户配置。
209
+ return None
210
+
211
+
212
+ def _write_sidecar(home: str, data: Dict[str, Any]) -> None:
213
+ # 用 os.open 一步带权限创建:先 open(w) 再 chmod 的话,中间有一个窗口文件是 umask 默认权限,
214
+ # 而这里面存着用户自己的 api_key(我们从 model 段抄下来的原值)。
215
+ path = _sidecar_path(home)
216
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
217
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
218
+ json.dump(data, handle, indent=2, ensure_ascii=False)
219
+
220
+
221
+ def _prune_snapshots(home: str, keep: int = SNAPSHOT_KEEP) -> None:
222
+ """只留最近 keep 个快照——每个都是含凭证的整份配置副本,不能无限堆。"""
223
+ try:
224
+ rows = sorted(
225
+ (name for name in os.listdir(home)
226
+ if name.startswith(SNAPSHOT_PREFIX) and name.endswith(".bak")),
227
+ reverse=True,
228
+ )
229
+ for name in rows[keep:]:
230
+ with contextlib.suppress(OSError):
231
+ os.remove(os.path.join(home, name))
232
+ except OSError:
233
+ pass
234
+
235
+
236
+ def _snapshot(home: str) -> Optional[str]:
237
+ """开中转前存一份整份配置快照,纯逃生舱:不参与自动还原,只给人工兜底。"""
238
+ src = _config_path(home)
239
+ if not os.path.exists(src):
240
+ return None
241
+ # 时间戳带上微秒:只精确到秒的话,同一秒内的两次开启会互相覆盖。
242
+ stamp = time.strftime("%Y%m%d-%H%M%S") + "-%06d" % (int(time.time() * 1_000_000) % 1_000_000)
243
+ dst = os.path.join(home, SNAPSHOT_PREFIX + stamp + ".bak")
244
+ try:
245
+ shutil.copy2(src, dst)
246
+ except Exception:
247
+ return None
248
+ _prune_snapshots(home)
249
+ return os.path.basename(dst)
250
+
251
+
252
+ def _find_profile(agent_id: str) -> Tuple[str, str, bool, Dict[str, Any]]:
253
+ """按 agent_id 定位 profile。匹配不到、或多个 profile 抢同一个 agent_id,都要报错不猜。"""
254
+ agent_id = (agent_id or "").strip()
255
+ if not agent_id:
256
+ raise RelayError("MISSING_AGENT_ID", "agent_id is required")
257
+
258
+ matches: List[Tuple[str, str, bool, Dict[str, Any]]] = []
259
+ unreadable: List[str] = []
260
+ for name, home, running in _iter_profiles():
261
+ try:
262
+ cfg = _read_profile_config(home)
263
+ except Exception as exc: # noqa: BLE001
264
+ # 读不了的 profile 可能正是目标。绝不能静默跳过后报"没找到"——那会让一个坏掉的
265
+ # config.yaml 表现成"这个 agent 不是 hermes",把人引向完全错误的方向。
266
+ unreadable.append(f"{name} ({type(exc).__name__}: {exc})")
267
+ continue
268
+ if _agent_id_of(cfg) == agent_id:
269
+ matches.append((name, home, running, cfg))
270
+
271
+ if not matches:
272
+ detail = f"no hermes profile is bound to agent_id {agent_id}"
273
+ if unreadable:
274
+ detail += f"; {len(unreadable)} profile(s) could not be read: {'; '.join(unreadable)}"
275
+ raise RelayError("PROFILE_NOT_FOUND", detail)
276
+ if len(matches) > 1:
277
+ names = ", ".join(sorted(m[0] for m in matches))
278
+ raise RelayError(
279
+ "AMBIGUOUS_AGENT_ID",
280
+ f"agent_id {agent_id} is claimed by multiple profiles: {names}",
281
+ )
282
+ return matches[0]
283
+
284
+
285
+ def cmd_discover(_req: Dict[str, Any]) -> Dict[str, Any]:
286
+ profiles = []
287
+ unreadable: List[Dict[str, str]] = []
288
+ for name, home, running in _iter_profiles():
289
+ try:
290
+ cfg = _read_profile_config(home)
291
+ except Exception as exc: # noqa: BLE001 - 报出来,不装作这个 profile 不存在
292
+ unreadable.append({"name": name, "home": home, "error": f"{type(exc).__name__}: {exc}"})
293
+ continue
294
+ model = _model_section(dict(cfg))
295
+ agent_id = _agent_id_of(cfg)
296
+ if not agent_id:
297
+ # 没接 Grix 的 profile(比如纯本地用的)不是我们的目标,直接跳过。
298
+ continue
299
+ profiles.append({
300
+ "name": name,
301
+ "home": home,
302
+ "agent_id": agent_id,
303
+ "gateway_running": running,
304
+ "relay_active": str(model.get("provider") or "").strip() == GRIX_PROVIDER_ID,
305
+ "model_provider": model.get("provider"),
306
+ "model_default": model.get("default"),
307
+ })
308
+ return {"ok": True, "profiles": profiles, "unreadable": unreadable}
309
+
310
+
311
+ def cmd_apply(req: Dict[str, Any]) -> Dict[str, Any]:
312
+ base_url = str(req.get("base_url") or "").strip()
313
+ api_key = str(req.get("api_key") or "").strip()
314
+ model = str(req.get("model") or "").strip()
315
+ if not base_url:
316
+ raise RelayError("MISSING_BASE_URL", "base_url is required to enable Grix relay")
317
+ if not api_key:
318
+ raise RelayError("MISSING_API_KEY", "api_key is required to enable Grix relay")
319
+ if not model:
320
+ # hermes 的 provider 配置里模型名必填,缺了就是"写进去了、聊天必失败"的静默半配置。
321
+ raise RelayError("MISSING_MODEL", "model is required (hermes provider config needs a model name)")
322
+
323
+ name, home, running, cfg = _find_profile(req.get("agent_id"))
324
+ model_cfg = _model_section(cfg)
325
+
326
+ # 只在**当前没开着中转**时记原值——这时盘上的 model 段按定义就是用户自己的选择。
327
+ #
328
+ # 判据必须是"是否正在中转中"(_relay_active),不能是"sidecar 在不在":
329
+ # - 中转中重复下发 → 不记,否则会把"中转中"的 grix 当原值存下来,关了再也回不去;
330
+ # - 用户中途 /model 切走后再重开 → sidecar 还在,但盘上已是用户的新选择,**必须刷新**,
331
+ # 否则关中转时会拿陈旧的旧值把用户的选择冲掉。
332
+ already_on = _relay_active(cfg)
333
+ sidecar = _read_sidecar(home) if already_on else None
334
+ if not already_on:
335
+ sidecar = {
336
+ # 整段快照,不手抄"我们会改哪些字段"的清单:宿主的 clear_model_endpoint_credentials
337
+ # 哪天多清一个字段,手抄清单就会静默丢掉那个字段的原值。
338
+ "previous_model": copy.deepcopy(model_cfg),
339
+ "snapshot": _snapshot(home),
340
+ "created_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
341
+ }
342
+ _write_sidecar(home, sidecar)
343
+
344
+ providers = cfg.get("providers")
345
+ if not isinstance(providers, dict):
346
+ providers = {}
347
+ cfg["providers"] = providers
348
+ providers[GRIX_PROVIDER_ID] = {
349
+ "name": "Grix",
350
+ "api": base_url,
351
+ "api_key": api_key,
352
+ "default_model": model,
353
+ "transport": "openai_chat",
354
+ }
355
+
356
+ # model.base_url / api_key 这些内联 endpoint 字段只对 provider=custom 有意义,留着会污染
357
+ # 命名 provider 的解析(宿主自己切换非 custom provider 时也做同样的清理)。
358
+ clear_model_endpoint_credentials(model_cfg, clear_base_url=True)
359
+ model_cfg["provider"] = GRIX_PROVIDER_ID
360
+ model_cfg["default"] = model
361
+
362
+ _save_profile_config(home, cfg)
363
+ _verify_write(home, expect_relay=True)
364
+ return {
365
+ "ok": True,
366
+ "profile": name,
367
+ "home": home,
368
+ "model": model,
369
+ "gateway_running": running,
370
+ "restart_required": True,
371
+ "snapshot": (sidecar or {}).get("snapshot"),
372
+ }
373
+
374
+
375
+ def cmd_clear(req: Dict[str, Any]) -> Dict[str, Any]:
376
+ name, home, running, cfg = _find_profile(req.get("agent_id"))
377
+ model_cfg = _model_section(cfg)
378
+ sidecar = _read_sidecar(home)
379
+ snapshot = (sidecar or {}).get("snapshot")
380
+
381
+ providers = cfg.get("providers")
382
+ removed_provider = False
383
+ if isinstance(providers, dict) and GRIX_PROVIDER_ID in providers:
384
+ providers.pop(GRIX_PROVIDER_ID, None)
385
+ removed_provider = True
386
+ if not providers:
387
+ cfg.pop("providers", None)
388
+
389
+ # 中转开着的时候用户可能自己 /model 切走了——那是他的新选择,无脑把旧值写回去会把它冲掉。
390
+ # 只有确认模型还指着 grix 时才动 model 段。
391
+ still_on_grix = _relay_active(cfg)
392
+ previous = (sidecar or {}).get("previous_model")
393
+ restored = False
394
+ model_cleared = False
395
+
396
+ if still_on_grix:
397
+ if isinstance(previous, dict):
398
+ cfg["model"] = copy.deepcopy(previous)
399
+ restored = True
400
+ else:
401
+ # sidecar 丢了或读坏了。**绝不能把 model.provider=grix 留在盘上**——它会指向一个
402
+ # 刚被我们摘掉的 provider,配置直接是坏的,比还原和不还原都糟。清掉指向让 hermes
403
+ # 回落到它自己的默认,并在响应里如实说明我们没能还原成用户原来的模型。
404
+ model_cfg.pop("provider", None)
405
+ model_cfg.pop("default", None)
406
+ model_cleared = True
407
+
408
+ if removed_provider or restored or model_cleared:
409
+ _save_profile_config(home, cfg)
410
+ _verify_write(home, expect_relay=False)
411
+
412
+ sidecar_path = _sidecar_path(home)
413
+ if os.path.exists(sidecar_path):
414
+ with contextlib.suppress(Exception):
415
+ os.remove(sidecar_path)
416
+
417
+ result = {
418
+ "ok": True,
419
+ "profile": name,
420
+ "home": home,
421
+ "removed_provider": removed_provider,
422
+ "restored_model": restored,
423
+ # 用户中途自己换过模型:provider 已摘掉,但 model 段是他自己的选择,我们没动。
424
+ "user_switched_away": bool(not still_on_grix),
425
+ "gateway_running": running,
426
+ "restart_required": bool(removed_provider or restored or model_cleared),
427
+ }
428
+ if snapshot:
429
+ # 逃生舱得告诉人它在哪,否则等于没有。
430
+ result["snapshot"] = snapshot
431
+ if model_cleared:
432
+ result["model_cleared"] = True
433
+ result["warning"] = (
434
+ "relay state file was missing or unreadable: the grix provider was removed and the "
435
+ "model selection was cleared (hermes will fall back to its default). The previous "
436
+ "model settings could not be restored"
437
+ + (f"; a pre-relay snapshot is kept at {snapshot}" if snapshot else "")
438
+ )
439
+ return result
440
+
441
+
442
+ COMMANDS = {"discover": cmd_discover, "apply": cmd_apply, "clear": cmd_clear}
443
+
444
+
445
+ def main() -> int:
446
+ try:
447
+ raw = sys.stdin.read()
448
+ req = json.loads(raw) if raw.strip() else {}
449
+ except Exception as exc:
450
+ print(json.dumps({"ok": False, "error_code": "BAD_REQUEST", "error_msg": str(exc)}))
451
+ return 1
452
+
453
+ command = str(req.get("command") or "").strip()
454
+ handler = COMMANDS.get(command)
455
+ if handler is None:
456
+ print(json.dumps({
457
+ "ok": False,
458
+ "error_code": "BAD_REQUEST",
459
+ "error_msg": f"unknown command: {command or '(empty)'}",
460
+ }))
461
+ return 1
462
+
463
+ # 宿主的配置层会往 stdout 打提示(managed 配置等),那会污染我们的 JSON 协议。
464
+ # 业务执行期间把 stdout 折到 stderr,只有最终响应写回真正的 stdout。
465
+ real_stdout = sys.stdout
466
+ try:
467
+ with contextlib.redirect_stdout(io.StringIO()) as captured:
468
+ result = handler(req)
469
+ noise = captured.getvalue().strip()
470
+ if noise:
471
+ print(noise, file=sys.stderr)
472
+ except RelayError as exc:
473
+ print(json.dumps({
474
+ "ok": False,
475
+ "error_code": exc.code,
476
+ "error_msg": _redact(exc.msg, req),
477
+ }), file=real_stdout)
478
+ return 1
479
+ except Exception as exc: # noqa: BLE001 - 兜底
480
+ print(json.dumps({
481
+ "ok": False,
482
+ "error_code": "CONFIG_WRITE_FAILED",
483
+ "error_msg": _redact(f"{type(exc).__name__}: {exc}", req),
484
+ }), file=real_stdout)
485
+ return 1
486
+
487
+ print(json.dumps(result, ensure_ascii=False), file=real_stdout)
488
+ return 0
489
+
490
+
491
+ if __name__ == "__main__":
492
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ class o extends Error{code;constructor(r,e){super(r),this.code=e,this.name="RelayRouteError"}}function l(t,r,e){return t?.code!=="PROFILE_NOT_FOUND"?t:new o(`agent_id ${r} matches neither of the two places it could live on this machine: it is not among the ${e} agent(s) in this connector's agents.json, and no hermes profile is bound to it`,"NOT_FOUND")}async function c(t,r,e){const n=r?.trim();if(!n)throw new o("agent_id is required","MISSING_AGENT_ID");if(!e.virtualKey?.trim())throw new o("virtual_key is required","MISSING_VIRTUAL_KEY");const i=t.listAgents(),a=i.find(s=>(s.agentId??"").trim()===n);if(a)return{target:"managed-agent",result:await t.enableManaged(a.name,n,e)};try{return{target:"hermes-profile",result:await t.enableHermesProfile(n,e)}}catch(s){throw l(s,n,i.length)}}async function d(t,r){const e=r?.trim();if(!e)throw new o("agent_id is required","MISSING_AGENT_ID");const n=t.listAgents(),i=n.find(a=>(a.agentId??"").trim()===e);if(i)return{target:"managed-agent",result:await t.disableManaged(i.name)};try{return{target:"hermes-profile",result:await t.disableHermesProfile(e)}}catch(a){throw l(a,e,n.length)}}export{o as RelayRouteError,d as disableRelayCredential,c as enableRelayCredential};
package/dist/grix.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import v from"node:path";import{writeFileSync as L}from"node:fs";import{Manager as X}from"./manager.js";import{ensureGrixDirs as K,initLogger as J,log as n,installProcessLogRotation as q,setConsoleOutput as Y}from"./core/log/index.js";import{HealthServer as z,bindPortOrFail as N}from"./core/runtime/index.js";import{writePidFile as Q,removePidFile as P,readDaemonPid as Z}from"./core/runtime/index.js";import{resolveRuntimePaths as T}from"./core/config/index.js";import{supportsDirectProviderConfig as ee}from"./core/config/provider-env.js";import{ServiceManager as te}from"./service/service-manager.js";import{killProcessesByCommandLine as oe,isWindowsElevated as re}from"./service/process-control.js";import{acquireDaemonLock as ne,isLockHolderSameProcess as ae,readDaemonLock as se,releaseDaemonLock as k}from"./runtime/daemon-lock.js";import{writeDaemonStatus as $,removeDaemonStatus as ie}from"./runtime/service-state.js";import{AdminServer as le,generateToken as ce,writeTokenFile as de}from"./core/admin/index.js";import{initSentry as pe,closeSentry as F,reportFatal as O}from"./core/observability/sentry.js";import{initProxyManager as me,getProxyManager as m,relayHostsForClientType as _}from"./core/proxy/index.js";import{stopProxyIfNobodyNeedsIt as ge,disableAllRelayAndRestart as fe,setOneAgentRelayAndRestart as ue}from"./core/proxy/relay-orchestration.js";import{setAgentRelayCredential as he}from"./core/proxy/relay-credential.js";import{resolveClientVersion as ye}from"./core/util/client-version.js";import{parseCliArgs as we}from"./core/util/cli-args.js";const{command:u,flags:g,unknownFlags:C}=we(process.argv.slice(2));if(C.length>0&&(console.error(`Unknown option${C.length>1?"s":""}: ${C.map(t=>`--${t}`).join(", ")}
3
- Run \`grix-connector --help\` to see the supported commands and options.`),process.exit(1)),g.version&&(console.log(ye()),process.exit(0)),g.help&&(console.log(`grix-connector \u2014 Unified AI Agent Bridge
2
+ import b from"node:path";import{writeFileSync as M}from"node:fs";import{Manager as q}from"./manager.js";import{ensureGrixDirs as z,initLogger as Q,log as a,installProcessLogRotation as Z,setConsoleOutput as ee}from"./core/log/index.js";import{HealthServer as te,bindPortOrFail as B}from"./core/runtime/index.js";import{writePidFile as re,removePidFile as I,readDaemonPid as oe}from"./core/runtime/index.js";import{resolveRuntimePaths as U}from"./core/config/index.js";import{supportsDirectProviderConfig as ne}from"./core/config/provider-env.js";import{ServiceManager as ae}from"./service/service-manager.js";import{killProcessesByCommandLine as se,isWindowsElevated as ie}from"./service/process-control.js";import{acquireDaemonLock as le,isLockHolderSameProcess as ce,readDaemonLock as de,releaseDaemonLock as k}from"./runtime/daemon-lock.js";import{writeDaemonStatus as F,removeDaemonStatus as pe}from"./runtime/service-state.js";import{AdminServer as me,generateToken as ge,writeTokenFile as fe}from"./core/admin/index.js";import{initSentry as ue,closeSentry as O,reportFatal as C}from"./core/observability/sentry.js";import{initProxyManager as he,getProxyManager as g,relayHostsForClientType as G}from"./core/proxy/index.js";import{stopProxyIfNobodyNeedsIt as ye,disableAllRelayAndRestart as we,setOneAgentRelayAndRestart as Re}from"./core/proxy/relay-orchestration.js";import{setAgentRelayCredential as xe}from"./core/proxy/relay-credential.js";import{applyHermesProfileRelay as Se,clearHermesProfileRelay as Ae,discoverHermesProfiles as ve}from"./core/proxy/hermes-profile-relay.js";import{disableRelayCredential as Ee,enableRelayCredential as Pe,RelayRouteError as j}from"./core/proxy/relay-credential-router.js";import{resolveClientVersion as De}from"./core/util/client-version.js";import{parseCliArgs as be}from"./core/util/cli-args.js";const{command:h,flags:f,unknownFlags:H}=be(process.argv.slice(2));if(H.length>0&&(console.error(`Unknown option${H.length>1?"s":""}: ${H.map(t=>`--${t}`).join(", ")}
3
+ Run \`grix-connector --help\` to see the supported commands and options.`),process.exit(1)),f.version&&(console.log(De()),process.exit(0)),f.help&&(console.log(`grix-connector \u2014 Unified AI Agent Bridge
4
4
 
5
5
  Usage: grix-connector <command> [options]
6
6
 
@@ -34,8 +34,8 @@ Examples:
34
34
  grix-connector start # Start as system service
35
35
  grix-connector status # Check service status
36
36
  grix-connector restart # Restart the service
37
- `),process.exit(0)),u==="reload"){const t=Z();t||(console.error("reload failed: daemon is not running (no pid file)"),process.exit(1));try{process.kill(t,0)}catch{console.error(`reload failed: daemon process ${t} is not running (stale pid file)`),process.exit(1)}{const p=se(T().daemonLockFile);p&&p.pid===t&&!ae(p)&&(console.error(`reload failed: pid ${t} has been reused by another process (stale pid file)`),process.exit(1))}try{process.kill(t,"SIGHUP"),console.log(JSON.stringify({ok:!0,signaled:t},null,2)),process.exit(0)}catch(p){console.error(`reload failed: ${p instanceof Error?p.message:p}`),process.exit(1)}}const G=["start","stop","restart","status"];if(u&&G.includes(u)){process.platform==="win32"&&["start","stop","restart"].includes(u)&&!re()&&console.warn(`Warning: Not running as administrator. Task Scheduler registration is skipped;
37
+ `),process.exit(0)),h==="reload"){const t=oe();t||(console.error("reload failed: daemon is not running (no pid file)"),process.exit(1));try{process.kill(t,0)}catch{console.error(`reload failed: daemon process ${t} is not running (stale pid file)`),process.exit(1)}{const p=de(U().daemonLockFile);p&&p.pid===t&&!ce(p)&&(console.error(`reload failed: pid ${t} has been reused by another process (stale pid file)`),process.exit(1))}try{process.kill(t,"SIGHUP"),console.log(JSON.stringify({ok:!0,signaled:t},null,2)),process.exit(0)}catch(p){console.error(`reload failed: ${p instanceof Error?p.message:p}`),process.exit(1)}}const K=["start","stop","restart","status"];if(h&&K.includes(h)){process.platform==="win32"&&["start","stop","restart"].includes(h)&&!ie()&&console.warn(`Warning: Not running as administrator. Task Scheduler registration is skipped;
38
38
  using Startup folder auto-start instead. For full Task Scheduler integration,
39
- right-click the terminal and select "Run as administrator".`);const t=T(),p=g["config-dir"]??(g.profile?v.join(t.configDir,g.profile):void 0),h=v.resolve(process.argv[1]||`${t.rootDir}/dist/grix.js`),l=new te({cliPath:h,nodePath:process.execPath});try{let d;switch(u){case"start":(await l.status({rootDir:t.rootDir})).installed?d=await l.start({rootDir:t.rootDir}):d=await l.install({rootDir:t.rootDir,configDir:p});break;case"stop":d=await l.stop({rootDir:t.rootDir});break;case"restart":(await l.status({rootDir:t.rootDir})).installed?d=await l.restart({rootDir:t.rootDir}):d=await l.install({rootDir:t.rootDir,configDir:p});break;case"status":d=await l.status({rootDir:t.rootDir});break}console.log(JSON.stringify(d,null,2)),process.exit(0)}catch(d){console.error(`${u} failed: ${d instanceof Error?d.message:d}`),process.exit(1)}}else u&&(console.error(`Unknown command: ${u}
40
- Valid commands: ${G.join(", ")}`),process.exit(1));const c=T(),Se=g["config-dir"]??(g.profile?`${c.configDir}/${g.profile}`:void 0),a=new X,I=new z,j=ce(),w=new le(j);let U=!1;async function M(t){process.stderr.write(t.message+`
41
- `),n.error("main",t.message.replace(/\n/g," \u2014 ")),await $(c.daemonStatusFile,{state:"failed",pid:process.pid,updated_at:Date.now(),reason:`port_bind_${t.kind}:${t.label}:${t.port}`}).catch(()=>{}),await F(),k(c.daemonLockFile).catch(()=>{}),P(),process.exit(1)}async function b(t){if(U)return;U=!0,n.info("main",`Received ${t}, shutting down...`),I.markShuttingDown();const p=setTimeout(()=>{n.error("main","Shutdown timed out, forcing exit"),k(c.daemonLockFile).catch(()=>{}),P(),process.exit(2)},1e4);try{await a.stop(),await m()?.stop().catch(()=>{}),await w.stop(),await I.stop(),await F(),await k(c.daemonLockFile),await ie(c.daemonStatusFile).catch(()=>{}),clearTimeout(p),P(),n.info("main","Shutdown complete"),process.exit(0)}catch(h){n.error("main",`Shutdown error: ${h}`),k(c.daemonLockFile).catch(()=>{}),P(),process.exit(2)}}async function xe(){K(),J(),await pe(),q(c.stdoutLogFile,c.stderrLogFile),Y(!1),process.platform==="win32"&&await oe("GrixConnectorDaemon",{platform:"win32"});try{await ne(c.daemonLockFile,c.rootDir)}catch(e){console.error(e instanceof Error?e.message:e),process.exit(1)}Q(),n.info("main",`grix-connector starting (PID ${process.pid})`),await $(c.daemonStatusFile,{state:"starting",pid:process.pid,updated_at:Date.now()});const t=parseInt(g["health-port"]??process.env.GRIX_HEALTH_PORT??"19579",10);{const e=await N({label:"health",port:t,envVar:"GRIX_HEALTH_PORT",cliFlag:"health-port",start:i=>I.start(i)});e&&await M(e)}const p=v.join(c.dataDir,"health-port");L(p,String(t),"utf-8"),process.on("SIGINT",()=>b("SIGINT")),process.on("SIGTERM",()=>b("SIGTERM")),process.on("SIGHUP",()=>{U||(n.info("main","Received SIGHUP, reloading config..."),a.reload().then(e=>n.info("main",`reload done: ${JSON.stringify(e)}`)).catch(e=>n.error("main",`reload failed: ${e instanceof Error?e.message:e}`)))});let h="",l=0,d;process.on("uncaughtException",e=>{const i=e instanceof Error?e.stack??e.message:String(e);i===h?(l++,(l<=3||l%100===0)&&n.error("main",`Uncaught exception (x${l}): ${i}`)):(l>3&&n.error("main",`Previous exception repeated ${l} times total`),h=i,l=1,n.error("main",`Uncaught exception: ${e instanceof Error?e.stack:e}`),d||(d=setTimeout(()=>{l>3&&n.error("main",`Previous exception repeated ${l} times total`),h="",l=0,d=void 0},1e4).unref())),!B(e)&&(O(e,"uncaughtException"),b("uncaughtException"))}),process.on("unhandledRejection",e=>{n.error("main",`Unhandled rejection: ${e}`),!B(e)&&(O(e,"unhandledRejection"),b("unhandledRejection"))});const S=me(c.dataDir);await S.clearRuntimeState();const x=S.getRelayAgents();if(x.length>0)try{await S.startIfAnyRelayAgent(),n.info("main",`mitm proxy started for relay agents: ${x.join(", ")} (per-agent; others stay direct)`)}catch(e){n.error("main",`mitm proxy FAILED to start (relay agents: ${x.join(", ")}): ${e}. These agents will REFUSE to start (they must not silently fall back to your own account). Fix the proxy or turn relay off for them. Other agents are unaffected.`)}else n.info("main",'mitm proxy idle (no agent has Grix relay enabled; enable: PUT /api/proxy/agents/<name>/enabled {"enabled":true})');if(I.setStatusProvider(()=>a.getAgentsStatus()),await a.start(Se),x.length>0){const e=a.getAgentsStatus();for(const i of x){const o=e.find(s=>s.name===i);if(!o)continue;const r=_(o.clientType);r.length!==0&&await S.setAgentRelayEnabled(i,!0,{relayHosts:r}).catch(s=>{n.warn("main",`failed to reconcile relay hosts for "${i}": ${s}`)})}}const H=parseInt(g["admin-port"]??process.env.GRIX_ADMIN_PORT??"19580",10);if(w.setAgentHandler({list:()=>a.getAgentsStatus(),add:e=>a.addAgent(e),remove:e=>a.removeAgent(e),restart:e=>a.restartAgent(e),reload:()=>a.reload()}),w.setUpgradeHandler({check:()=>a.checkUpgrade(),trigger:()=>a.triggerUpgrade()}),w.setProbeHandler({probeAll:e=>a.probeAll(e),probeOne:(e,i)=>a.probeOne(e,i)}),w.setInstallHandler({listInstallable:()=>a.listInstallable(),installAgent:e=>a.installAgent(e),getInstallProgress:e=>a.getInstallProgress(e)}),m()){const e=async()=>{const o=m();o&&await ge(o,a)&&n.info("main","no agent uses Grix relay anymore; mitm proxy stopped")};a.setRelayEnvSettledHandler(()=>{e().catch(o=>{n.warn("main",`failed to stop idle mitm proxy: ${o}`)})});const i=()=>{const o=m(),r=o.getRelayAgents(),s=o.getDegradedRelayAgents();return{enabled:r.length>0,relayAgents:r,staleRelayAgents:a.getAgentsWithStaleRelayEnv(),degradedRelayAgents:s,runtime:o.getRuntimeInfo(),config:o.getConfigSnapshot()}};w.setProxyHandler({status:()=>i(),setAgentRelay:async(o,r)=>{const s=m(),y=a.getAgentsStatus().find(D=>D.name===o);if(y&&ee(y.clientType))return r?n.info("main",`relay enable for native provider agent "${o}" is a no-op; use relay-credential to enable`):await a.setAgentProvider(o,void 0),{...i(),restarted:!r,busy:!1};let A;if(r){if(!y)throw Object.assign(new Error(`Agent "${o}" not found`),{code:"NOT_FOUND"});if(A=_(y.clientType),A.length===0)throw Object.assign(new Error(`client type "${y.clientType??""}" does not support Grix relay`),{code:"UNSUPPORTED_CLIENT_TYPE"})}const{restarted:f,busy:E}=await ue(s,a,{info:D=>n.info("main",D),warn:D=>n.warn("main",D)},{agentName:o,enabled:r,relayHosts:A});return{...i(),restarted:f,busy:E}},setAgentRelayCredential:async(o,r)=>{const s=m(),R=a.getAgentsStatus(),{restarted:y,busy:A}=await he(s,a,f=>R.find(E=>E.name===f),{info:f=>n.info("main",f),warn:f=>n.warn("main",f)},{localName:o,agentId:r.agentId,virtualKey:r.virtualKey,anthropicBaseUrl:r.anthropicBaseUrl,openaiBaseUrl:r.openaiBaseUrl,model:r.model},(f,E)=>a.setAgentProvider(f,E));return{...i(),restarted:y,busy:A}},disableAll:async()=>{const o=m();await fe(o,a,{info:s=>n.info("main",s),warn:s=>n.warn("main",s)});const r=await a.clearDirectProviderConfigs();return r.length>0&&n.info("main",`relay disable-all cleared native provider config for: ${r.join(", ")}`),i()},setRoute:async(o,r)=>{const s=r,R=m();return R.setRoute({routeKey:o,targetBaseUrl:s.targetBaseUrl,...s.headers?{headers:s.headers}:{},...s.model?{model:s.model}:{},...s.modelMap?{modelMap:s.modelMap}:{},...s.passthrough?{passthrough:!0}:{},...s.relayOnly?{relayOnly:!0}:{},...s.codexResponsesToChat?{codexResponsesToChat:!0}:{}}),await R.persistConfig(),i()},deleteRoute:async o=>{const r=m();r.deleteRoute(o),await r.persistConfig()},setDefaultRoute:async o=>{const r=m();return r.setDefaultRouteKey(o),await r.persistConfig(),i()},setInterceptHosts:async o=>{const r=m();return r.setInterceptHosts(o),await r.persistConfig(),i()}})}{const e=await N({label:"admin",port:H,envVar:"GRIX_ADMIN_PORT",cliFlag:"admin-port",start:i=>w.start(i)});e&&await M(e)}const W=v.join(c.dataDir,"admin-token"),V=v.join(c.dataDir,"admin-port");de(W,j),L(V,String(H),"utf-8"),await $(c.daemonStatusFile,{state:"running",pid:process.pid,updated_at:Date.now()}),process.send&&process.send("ready"),n.info("main","grix-connector ready")}xe().catch(async t=>{n.error("main",`Fatal: ${t}`),O(t,"startup"),await F(),k(c.daemonLockFile).catch(()=>{}),P(),process.exit(1)});const Re=new Set(["ECONNRESET","ECONNREFUSED","ETIMEDOUT","EPIPE","EAI_AGAIN","ENOTFOUND","EHOSTUNREACH","ENETUNREACH","EIO"]);function B(t){return t instanceof Error&&"code"in t?Re.has(t.code):!1}
39
+ right-click the terminal and select "Run as administrator".`);const t=U(),p=f["config-dir"]??(f.profile?b.join(t.configDir,f.profile):void 0),w=b.resolve(process.argv[1]||`${t.rootDir}/dist/grix.js`),l=new ae({cliPath:w,nodePath:process.execPath});try{let d;switch(h){case"start":(await l.status({rootDir:t.rootDir})).installed?d=await l.start({rootDir:t.rootDir}):d=await l.install({rootDir:t.rootDir,configDir:p});break;case"stop":d=await l.stop({rootDir:t.rootDir});break;case"restart":(await l.status({rootDir:t.rootDir})).installed?d=await l.restart({rootDir:t.rootDir}):d=await l.install({rootDir:t.rootDir,configDir:p});break;case"status":d=await l.status({rootDir:t.rootDir});break}console.log(JSON.stringify(d,null,2)),process.exit(0)}catch(d){console.error(`${h} failed: ${d instanceof Error?d.message:d}`),process.exit(1)}}else h&&(console.error(`Unknown command: ${h}
40
+ Valid commands: ${K.join(", ")}`),process.exit(1));const c=U(),Ie=f["config-dir"]??(f.profile?`${c.configDir}/${f.profile}`:void 0),s=new q,T=new te,V=ge(),y=new me(V);let L=!1;async function X(t){process.stderr.write(t.message+`
41
+ `),a.error("main",t.message.replace(/\n/g," \u2014 ")),await F(c.daemonStatusFile,{state:"failed",pid:process.pid,updated_at:Date.now(),reason:`port_bind_${t.kind}:${t.label}:${t.port}`}).catch(()=>{}),await O(),k(c.daemonLockFile).catch(()=>{}),I(),process.exit(1)}async function $(t){if(L)return;L=!0,a.info("main",`Received ${t}, shutting down...`),T.markShuttingDown();const p=setTimeout(()=>{a.error("main","Shutdown timed out, forcing exit"),k(c.daemonLockFile).catch(()=>{}),I(),process.exit(2)},1e4);try{await s.stop(),await g()?.stop().catch(()=>{}),await y.stop(),await T.stop(),await O(),await k(c.daemonLockFile),await pe(c.daemonStatusFile).catch(()=>{}),clearTimeout(p),I(),a.info("main","Shutdown complete"),process.exit(0)}catch(w){a.error("main",`Shutdown error: ${w}`),k(c.daemonLockFile).catch(()=>{}),I(),process.exit(2)}}async function ke(){z(),Q(),await ue(),Z(c.stdoutLogFile,c.stderrLogFile),ee(!1),process.platform==="win32"&&await se("GrixConnectorDaemon",{platform:"win32"});try{await le(c.daemonLockFile,c.rootDir)}catch(e){console.error(e instanceof Error?e.message:e),process.exit(1)}re(),a.info("main",`grix-connector starting (PID ${process.pid})`),await F(c.daemonStatusFile,{state:"starting",pid:process.pid,updated_at:Date.now()});const t=parseInt(f["health-port"]??process.env.GRIX_HEALTH_PORT??"19579",10);{const e=await B({label:"health",port:t,envVar:"GRIX_HEALTH_PORT",cliFlag:"health-port",start:r=>T.start(r)});e&&await X(e)}const p=b.join(c.dataDir,"health-port");M(p,String(t),"utf-8"),process.on("SIGINT",()=>$("SIGINT")),process.on("SIGTERM",()=>$("SIGTERM")),process.on("SIGHUP",()=>{L||(a.info("main","Received SIGHUP, reloading config..."),s.reload().then(e=>a.info("main",`reload done: ${JSON.stringify(e)}`)).catch(e=>a.error("main",`reload failed: ${e instanceof Error?e.message:e}`)))});let w="",l=0,d;process.on("uncaughtException",e=>{const r=e instanceof Error?e.stack??e.message:String(e);r===w?(l++,(l<=3||l%100===0)&&a.error("main",`Uncaught exception (x${l}): ${r}`)):(l>3&&a.error("main",`Previous exception repeated ${l} times total`),w=r,l=1,a.error("main",`Uncaught exception: ${e instanceof Error?e.stack:e}`),d||(d=setTimeout(()=>{l>3&&a.error("main",`Previous exception repeated ${l} times total`),w="",l=0,d=void 0},1e4).unref())),!W(e)&&(C(e,"uncaughtException"),$("uncaughtException"))}),process.on("unhandledRejection",e=>{a.error("main",`Unhandled rejection: ${e}`),!W(e)&&(C(e,"unhandledRejection"),$("unhandledRejection"))});const x=he(c.dataDir);await x.clearRuntimeState();const S=x.getRelayAgents();if(S.length>0)try{await x.startIfAnyRelayAgent(),a.info("main",`mitm proxy started for relay agents: ${S.join(", ")} (per-agent; others stay direct)`)}catch(e){a.error("main",`mitm proxy FAILED to start (relay agents: ${S.join(", ")}): ${e}. These agents will REFUSE to start (they must not silently fall back to your own account). Fix the proxy or turn relay off for them. Other agents are unaffected.`)}else a.info("main",'mitm proxy idle (no agent has Grix relay enabled; enable: PUT /api/proxy/agents/<name>/enabled {"enabled":true})');if(T.setStatusProvider(()=>s.getAgentsStatus()),await s.start(Ie),S.length>0){const e=s.getAgentsStatus();for(const r of S){const m=e.find(n=>n.name===r);if(!m)continue;const o=G(m.clientType);o.length!==0&&await x.setAgentRelayEnabled(r,!0,{relayHosts:o}).catch(n=>{a.warn("main",`failed to reconcile relay hosts for "${r}": ${n}`)})}}const _=parseInt(f["admin-port"]??process.env.GRIX_ADMIN_PORT??"19580",10);y.setAgentHandler({list:()=>s.getAgentsStatus(),add:e=>s.addAgent(e),remove:e=>s.removeAgent(e),restart:e=>s.restartAgent(e),reload:()=>s.reload()}),y.setUpgradeHandler({check:()=>s.checkUpgrade(),trigger:()=>s.triggerUpgrade()}),y.setProbeHandler({probeAll:e=>s.probeAll(e),probeOne:(e,r)=>s.probeOne(e,r)}),y.setInstallHandler({listInstallable:()=>s.listInstallable(),installAgent:e=>s.installAgent(e),getInstallProgress:e=>s.getInstallProgress(e)});let A=null;if(g()){const e=async()=>{const o=g();o&&await ye(o,s)&&a.info("main","no agent uses Grix relay anymore; mitm proxy stopped")};s.setRelayEnvSettledHandler(()=>{e().catch(o=>{a.warn("main",`failed to stop idle mitm proxy: ${o}`)})});const r=()=>{const o=g(),n=o.getRelayAgents(),i=o.getDegradedRelayAgents();return{enabled:n.length>0,relayAgents:n,staleRelayAgents:s.getAgentsWithStaleRelayEnv(),degradedRelayAgents:i,runtime:o.getRuntimeInfo(),config:o.getConfigSnapshot()}},m={status:()=>r(),setAgentRelay:async(o,n)=>{const i=g(),R=s.getAgentsStatus().find(D=>D.name===o);if(R&&ne(R.clientType))return n?a.info("main",`relay enable for native provider agent "${o}" is a no-op; use relay-credential to enable`):await s.setAgentProvider(o,void 0),{...r(),restarted:!n,busy:!1};let E;if(n){if(!R)throw Object.assign(new Error(`Agent "${o}" not found`),{code:"NOT_FOUND"});if(E=G(R.clientType),E.length===0)throw Object.assign(new Error(`client type "${R.clientType??""}" does not support Grix relay`),{code:"UNSUPPORTED_CLIENT_TYPE"})}const{restarted:u,busy:P}=await Re(i,s,{info:D=>a.info("main",D),warn:D=>a.warn("main",D)},{agentName:o,enabled:n,relayHosts:E});return{...r(),restarted:u,busy:P}},setAgentRelayCredential:async(o,n)=>{const i=g(),v=s.getAgentsStatus(),{restarted:R,busy:E}=await xe(i,s,u=>v.find(P=>P.name===u),{info:u=>a.info("main",u),warn:u=>a.warn("main",u)},{localName:o,agentId:n.agentId,virtualKey:n.virtualKey,anthropicBaseUrl:n.anthropicBaseUrl,openaiBaseUrl:n.openaiBaseUrl,model:n.model},(u,P)=>s.setAgentProvider(u,P));return{...r(),restarted:R,busy:E}},disableAll:async()=>{const o=g();await we(o,s,{info:i=>a.info("main",i),warn:i=>a.warn("main",i)});const n=await s.clearDirectProviderConfigs();return n.length>0&&a.info("main",`relay disable-all cleared native provider config for: ${n.join(", ")}`),r()},setRoute:async(o,n)=>{const i=n,v=g();return v.setRoute({routeKey:o,targetBaseUrl:i.targetBaseUrl,...i.headers?{headers:i.headers}:{},...i.model?{model:i.model}:{},...i.modelMap?{modelMap:i.modelMap}:{},...i.passthrough?{passthrough:!0}:{},...i.relayOnly?{relayOnly:!0}:{},...i.codexResponsesToChat?{codexResponsesToChat:!0}:{}}),await v.persistConfig(),r()},deleteRoute:async o=>{const n=g();n.deleteRoute(o),await n.persistConfig()},setDefaultRoute:async o=>{const n=g();return n.setDefaultRouteKey(o),await n.persistConfig(),r()},setInterceptHosts:async o=>{const n=g();return n.setInterceptHosts(o),await n.persistConfig(),r()}};A=m,y.setProxyHandler(m)}const N={listAgents:()=>s.getAgentsStatus(),enableManaged:(e,r,m)=>{if(!A)throw new j(`agent "${e}" needs the MITM proxy for relay, but it is not running`,"PROXY_UNAVAILABLE");return A.setAgentRelayCredential(e,{agentId:r,virtualKey:m.virtualKey,anthropicBaseUrl:m.anthropicBaseUrl,openaiBaseUrl:m.openaiBaseUrl,model:m.model})},disableManaged:e=>{if(!A)throw new j(`agent "${e}" needs the MITM proxy for relay, but it is not running`,"PROXY_UNAVAILABLE");return A.setAgentRelay(e,!1)},enableHermesProfile:(e,r)=>Se({agentId:e,baseUrl:r.openaiBaseUrl??"",virtualKey:r.virtualKey,model:r.model??""}),disableHermesProfile:e=>Ae(e)};y.setRelayCredentialHandler({listHermesProfiles:()=>ve(),enable:async(e,r)=>{const{target:m,result:o}=await Pe(N,e,r);return{target:m,...o}},disable:async e=>{const{target:r,result:m}=await Ee(N,e);return{target:r,...m}}});{const e=await B({label:"admin",port:_,envVar:"GRIX_ADMIN_PORT",cliFlag:"admin-port",start:r=>y.start(r)});e&&await X(e)}const J=b.join(c.dataDir,"admin-token"),Y=b.join(c.dataDir,"admin-port");fe(J,V),M(Y,String(_),"utf-8"),await F(c.daemonStatusFile,{state:"running",pid:process.pid,updated_at:Date.now()}),process.send&&process.send("ready"),a.info("main","grix-connector ready")}ke().catch(async t=>{a.error("main",`Fatal: ${t}`),C(t,"startup"),await O(),k(c.daemonLockFile).catch(()=>{}),I(),process.exit(1)});const Te=new Set(["ECONNRESET","ECONNREFUSED","ETIMEDOUT","EPIPE","EAI_AGAIN","ENOTFOUND","EHOSTUNREACH","ENETUNREACH","EIO"]);function W(t){return t instanceof Error&&"code"in t?Te.has(t.code):!1}
package/dist/log.js CHANGED
@@ -1,3 +1,3 @@
1
- import{createWriteStream as g,mkdirSync as l,existsSync as f}from"node:fs";import{join as i}from"node:path";import{homedir as m}from"node:os";const n=i(m(),".grix"),s={base:n,config:i(n,"config"),log:i(n,"log"),data:i(n,"data")};function S(){for(const o of Object.values(s))f(o)||l(o,{recursive:!0})}let a=null;function $(){const o=new Date().toISOString().slice(0,10),r=i(s.log,`grix-acp-${o}.log`);a=g(r,{flags:"a"})}function c(){return new Date().toISOString().slice(11,19)}const u={info(o,r,...t){const e=`${c()} [${o}] ${r}${t.length?" "+t.map(String).join(" "):""}`;console.log(e),a?.write(e+`
2
- `)},error(o,r,...t){const e=`${c()} [${o}] ERROR ${r}${t.length?" "+t.map(String).join(" "):""}`;console.error(e),a?.write(e+`
1
+ import{createWriteStream as g,mkdirSync as l,existsSync as f}from"node:fs";import{join as t}from"node:path";import{homedir as m}from"node:os";const i=t(m(),".grix"),s={base:i,config:t(i,"config"),log:t(i,"log"),data:t(i,"data")};function S(){for(const o of Object.values(s))f(o)||l(o,{recursive:!0})}let a=null;function $(){const o=new Date().toISOString().slice(0,10),r=t(s.log,`grix-acp-${o}.log`);a=g(r,{flags:"a"})}function c(){return new Date().toISOString().slice(11,19)}const u={info(o,r,...n){const e=`${c()} [${o}] ${r}${n.length?" "+n.map(String).join(" "):""}`;console.log(e),a?.write(e+`
2
+ `)},error(o,r,...n){const e=`${c()} [${o}] ERROR ${r}${n.length?" "+n.map(String).join(" "):""}`;console.error(e),a?.write(e+`
3
3
  `)}};export{s as GRIX_PATHS,S as ensureGrixDirs,$ as initLogger,u as log};
@@ -1 +1 @@
1
- import*as n from"node:net";const i={bind:"127.0.0.1",port:0,endpoint:"/mcp",sessionTimeoutMs:18e5,invokeTimeoutMs:3e4};function s(u){const e={bind:u?.bind??i.bind,port:u?.port??i.port,endpoint:u?.endpoint??i.endpoint,sessionTimeoutMs:u?.sessionTimeoutMs??i.sessionTimeoutMs,invokeTimeoutMs:u?.invokeTimeoutMs??i.invokeTimeoutMs,allowedOrigins:u?.allowedOrigins,allowedHosts:u?.allowedHosts};return t(e.bind),e.port!==0&&o(e.port),r(e.sessionTimeoutMs),e}function t(u){if(!u||!n.isIPv4(u)&&!n.isIPv6(u))throw new Error(`\u914D\u7F6E\u6821\u9A8C\u5931\u8D25: bind \u5730\u5740 "${u}" \u4E0D\u662F\u5408\u6CD5\u7684 IPv4 \u6216 IPv6 \u5730\u5740`)}function o(u){if(!Number.isInteger(u)||u<1||u>65535)throw new Error(`\u914D\u7F6E\u6821\u9A8C\u5931\u8D25: port \u503C ${u} \u4E0D\u5728\u5408\u6CD5\u8303\u56F4 1-65535 \u5185\u6216\u4E0D\u662F\u6574\u6570`)}function r(u){if(!Number.isInteger(u)||u<1e3||u>864e5)throw new Error(`\u914D\u7F6E\u6821\u9A8C\u5931\u8D25: session_timeout_ms \u503C ${u} \u4E0D\u5728\u5408\u6CD5\u8303\u56F4 1000-86400000 \u5185`)}export{s as createDefaultGatewayConfig};
1
+ import*as i from"node:net";const e={bind:"127.0.0.1",port:0,endpoint:"/mcp",sessionTimeoutMs:18e5,invokeTimeoutMs:3e4};function n(o){const u={bind:o?.bind??e.bind,port:o?.port??e.port,endpoint:o?.endpoint??e.endpoint,sessionTimeoutMs:o?.sessionTimeoutMs??e.sessionTimeoutMs,invokeTimeoutMs:o?.invokeTimeoutMs??e.invokeTimeoutMs,allowedOrigins:o?.allowedOrigins,allowedHosts:o?.allowedHosts};return s(u.bind),u.port!==0&&t(u.port),r(u.sessionTimeoutMs),u}function s(o){if(!o||!i.isIPv4(o)&&!i.isIPv6(o))throw new Error(`\u914D\u7F6E\u6821\u9A8C\u5931\u8D25: bind \u5730\u5740 "${o}" \u4E0D\u662F\u5408\u6CD5\u7684 IPv4 \u6216 IPv6 \u5730\u5740`)}function t(o){if(!Number.isInteger(o)||o<1||o>65535)throw new Error(`\u914D\u7F6E\u6821\u9A8C\u5931\u8D25: port \u503C ${o} \u4E0D\u5728\u5408\u6CD5\u8303\u56F4 1-65535 \u5185\u6216\u4E0D\u662F\u6574\u6570`)}function r(o){if(!Number.isInteger(o)||o<1e3||o>864e5)throw new Error(`\u914D\u7F6E\u6821\u9A8C\u5931\u8D25: session_timeout_ms \u503C ${o} \u4E0D\u5728\u5408\u6CD5\u8303\u56F4 1000-86400000 \u5185`)}export{n as createDefaultGatewayConfig};
@@ -1 +1 @@
1
- const a=3e4;class c{connectionManager;onDisconnected;bindings=new Map;constructor(n,i){this.connectionManager=n,this.onDisconnected=i}async bind(n,i){if(this.bindings.has(n))throw new Error(`Session ${n} is already bound to a connection`);const e=await this.connectWithTimeout(i),t=[],s=e.onDisconnected(()=>{this.removeBinding(n),this.onDisconnected(n)});return t.push(s),this.bindings.set(n,{sessionId:n,handle:e,subscriptions:t}),e}getHandle(n){return this.bindings.get(n)?.handle}unbind(n){const i=this.bindings.get(n);if(i){this.bindings.delete(n);for(const e of i.subscriptions)e();i.handle.disconnect()}}unbindAll(){const n=[...this.bindings.keys()];for(const i of n)this.unbind(i)}connectWithTimeout(n){return new Promise((i,e)=>{let t=!1;const s=setTimeout(()=>{t||(t=!0,e(new Error("Connection bind timeout after 30000ms")))},3e4);this.connectionManager.connect({agentId:n.agentId,apiKey:n.apiKey,url:n.wsUrl,clientType:n.clientType,capabilities:["agent_invoke"],adapterHint:`${n.clientType}/base`},{maxRetries:0}).then(o=>{t?o.disconnect():(t=!0,clearTimeout(s),i(o))}).catch(o=>{t||(t=!0,clearTimeout(s),e(o))})})}removeBinding(n){const i=this.bindings.get(n);if(i){this.bindings.delete(n);for(const e of i.subscriptions)e()}}}export{c as ConnectionBindingImpl};
1
+ const r=3e4;class c{connectionManager;onDisconnected;bindings=new Map;constructor(n,i){this.connectionManager=n,this.onDisconnected=i}async bind(n,i){if(this.bindings.has(n))throw new Error(`Session ${n} is already bound to a connection`);const e=await this.connectWithTimeout(i),t=[],s=e.onDisconnected(()=>{this.removeBinding(n),this.onDisconnected(n)});return t.push(s),this.bindings.set(n,{sessionId:n,handle:e,subscriptions:t}),e}getHandle(n){return this.bindings.get(n)?.handle}unbind(n){const i=this.bindings.get(n);if(i){this.bindings.delete(n);for(const e of i.subscriptions)e();i.handle.disconnect()}}unbindAll(){const n=[...this.bindings.keys()];for(const i of n)this.unbind(i)}connectWithTimeout(n){return new Promise((i,e)=>{let t=!1;const s=setTimeout(()=>{t||(t=!0,e(new Error("Connection bind timeout after 30000ms")))},3e4);this.connectionManager.connect({agentId:n.agentId,apiKey:n.apiKey,url:n.wsUrl,clientType:n.clientType,capabilities:["agent_invoke"],adapterHint:`${n.clientType}/base`},{maxRetries:0}).then(o=>{t?o.disconnect():(t=!0,clearTimeout(s),i(o))}).catch(o=>{t||(t=!0,clearTimeout(s),e(o))})})}removeBinding(n){const i=this.bindings.get(n);if(i){this.bindings.delete(n);for(const e of i.subscriptions)e()}}}export{c as ConnectionBindingImpl};
@@ -1 +1 @@
1
- function a(t){const o=new Set([`http://127.0.0.1:${t.serverPort}`,`http://localhost:${t.serverPort}`,...t.allowedOrigins]),e=new Set([`127.0.0.1:${t.serverPort}`,`localhost:${t.serverPort}`,...t.allowedHosts]);return{validateRequest(s){const r=i(s,o);if(!r.ok)return r;const n=l(s,e);return n.ok?{ok:!0}:n}}}function i(t,o){const e=t.headers.origin;return e?o.has(e)?{ok:!0}:{ok:!1,statusCode:403,message:`Origin not allowed: ${e}`}:{ok:!0}}function l(t,o){const e=t.headers.host;return e?o.has(e)?{ok:!0}:{ok:!1,statusCode:403,message:`Host not allowed: ${e}`}:{ok:!1,statusCode:403,message:"Missing Host header"}}export{a as createSecurityPolicy};
1
+ function a(e){const t=new Set([`http://127.0.0.1:${e.serverPort}`,`http://localhost:${e.serverPort}`,...e.allowedOrigins]),o=new Set([`127.0.0.1:${e.serverPort}`,`localhost:${e.serverPort}`,...e.allowedHosts]);return{validateRequest(s){const r=i(s,t);if(!r.ok)return r;const n=l(s,o);return n.ok?{ok:!0}:n}}}function i(e,t){const o=e.headers.origin;return o?t.has(o)?{ok:!0}:{ok:!1,statusCode:403,message:`Origin not allowed: ${o}`}:{ok:!0}}function l(e,t){const o=e.headers.host;return o?t.has(o)?{ok:!0}:{ok:!1,statusCode:403,message:`Host not allowed: ${o}`}:{ok:!1,statusCode:403,message:"Missing Host header"}}export{a as createSecurityPolicy};
@@ -1 +1 @@
1
- import{toolCallToInvoke as i}from"../../core/mcp/tools.js";import{ToolRegistryImpl as l}from"./tool-registry.js";import{validateToolArgs as a}from"./tool-schemas.js";import{isEventTool as p,executeEventTool as d}from"./event-tool-executor.js";class y{registry;constructor(){this.registry=new l}async execute(r,e,t,n){if(!this.registry.hasTool(e))return this.errorResult(`\u672A\u77E5\u5DE5\u5177: ${e}`);const s=a(e,t);if(!s.valid)return this.errorResult(`\u53C2\u6570\u6821\u9A8C\u5931\u8D25: ${s.error}`);if(r.status!=="ready")return this.errorResult(`\u8FDE\u63A5\u4E0D\u53EF\u7528: \u5F53\u524D\u72B6\u6001\u4E3A ${r.status}`);if(p(e))return this.executeEventTool(r,e,t);const o=i(e,t);try{const u=await r.agentInvoke(o.action,o.params,n);return this.normalizeResult(u)}catch(u){const c=u instanceof Error?u.message:String(u);return c.toLowerCase().includes("timeout")?this.errorResult(`\u8C03\u7528\u8D85\u65F6: ${c}`):this.errorResult(`\u8C03\u7528\u5931\u8D25: ${c}`)}}normalizeResult(r){if(r==null||typeof r!="object")return this.successResult(r??null);const e=r,t=typeof e.code=="number"?e.code:0;if(t===0){const s="data"in e?e.data:null;return this.successResult(s??null)}const n=typeof e.msg=="string"?e.msg:"\u672A\u77E5\u9519\u8BEF";return this.errorResult(`\u4E0A\u6E38\u9519\u8BEF [code=${t}]: ${n}`)}successResult(r){return{content:[{type:"text",text:JSON.stringify(r)}],isError:!1}}errorResult(r){return{content:[{type:"text",text:r}],isError:!0}}async executeEventTool(r,e,t){return e==="grix_access_control"?this.executeAccessControl(r,t):d(r,e,t)}async executeAccessControl(r,e){const t=String(e.action??""),n={pair_approve:"pair_approve",pair_deny:"pair_deny",allow_sender:"sender_allow",remove_sender:"sender_remove",set_policy:"policy_set"}[t];if(!n)return this.errorResult(`\u672A\u77E5 access_control action: ${t}`);const s={};e.code!=null&&(s.code=e.code),e.sender_id!=null&&(s.sender_id=e.sender_id),e.policy!=null&&(s.policy=e.policy);try{const o=await r.agentInvoke("claude_access_control",{verb:n,payload:s},3e4);return this.successResult(o)}catch(o){const u=o instanceof Error?o.message:String(o);return this.errorResult(`access_control \u8C03\u7528\u5931\u8D25: ${u}`)}}}export{y as ToolExecutorImpl};
1
+ import{toolCallToInvoke as i}from"../../core/mcp/tools.js";import{ToolRegistryImpl as l}from"./tool-registry.js";import{validateToolArgs as a}from"./tool-schemas.js";import{isEventTool as p,executeEventTool as d}from"./event-tool-executor.js";class y{registry;constructor(){this.registry=new l}async execute(t,e,r,n){if(!this.registry.hasTool(e))return this.errorResult(`\u672A\u77E5\u5DE5\u5177: ${e}`);const s=a(e,r);if(!s.valid)return this.errorResult(`\u53C2\u6570\u6821\u9A8C\u5931\u8D25: ${s.error}`);if(t.status!=="ready")return this.errorResult(`\u8FDE\u63A5\u4E0D\u53EF\u7528: \u5F53\u524D\u72B6\u6001\u4E3A ${t.status}`);if(p(e))return this.executeEventTool(t,e,r);const o=i(e,r);try{const u=await t.agentInvoke(o.action,o.params,n);return this.normalizeResult(u)}catch(u){const c=u instanceof Error?u.message:String(u);return c.toLowerCase().includes("timeout")?this.errorResult(`\u8C03\u7528\u8D85\u65F6: ${c}`):this.errorResult(`\u8C03\u7528\u5931\u8D25: ${c}`)}}normalizeResult(t){if(t==null||typeof t!="object")return this.successResult(t??null);const e=t,r=typeof e.code=="number"?e.code:0;if(r===0){const s="data"in e?e.data:null;return this.successResult(s??null)}const n=typeof e.msg=="string"?e.msg:"\u672A\u77E5\u9519\u8BEF";return this.errorResult(`\u4E0A\u6E38\u9519\u8BEF [code=${r}]: ${n}`)}successResult(t){return{content:[{type:"text",text:JSON.stringify(t)}],isError:!1}}errorResult(t){return{content:[{type:"text",text:t}],isError:!0}}async executeEventTool(t,e,r){return e==="grix_access_control"?this.executeAccessControl(t,r):d(t,e,r)}async executeAccessControl(t,e){const r=String(e.action??""),n={pair_approve:"pair_approve",pair_deny:"pair_deny",allow_sender:"sender_allow",remove_sender:"sender_remove",set_policy:"policy_set"}[r];if(!n)return this.errorResult(`\u672A\u77E5 access_control action: ${r}`);const s={};e.code!=null&&(s.code=e.code),e.sender_id!=null&&(s.sender_id=e.sender_id),e.policy!=null&&(s.policy=e.policy);try{const o=await t.agentInvoke("claude_access_control",{verb:n,payload:s},3e4);return this.successResult(o)}catch(o){const u=o instanceof Error?o.message:String(o);return this.errorResult(`access_control \u8C03\u7528\u5931\u8D25: ${u}`)}}}export{y as ToolExecutorImpl};
@@ -1 +1 @@
1
- import{TOOLS as o,EVENT_TOOLS as s}from"../../core/mcp/tools.js";const e=new Set(["grix_query","grix_group","grix_message_send","grix_message_unsend","grix_admin"]),r=new Set(["grix_reply","grix_complete","grix_event_ack","grix_composing","grix_access_control","grix_status"]);class a{tools;toolMap;constructor(){this.tools=[...o.filter(t=>e.has(t.name)),...s.filter(t=>r.has(t.name))],this.toolMap=new Map(this.tools.map(t=>[t.name,t]))}getTools(){return this.tools}getTool(t){return this.toolMap.get(t)}hasTool(t){return this.toolMap.has(t)}}export{a as ToolRegistryImpl};
1
+ import{TOOLS as s,EVENT_TOOLS as t}from"../../core/mcp/tools.js";const e=new Set(["grix_query","grix_group","grix_message_send","grix_message_unsend","grix_admin"]),r=new Set(["grix_reply","grix_complete","grix_event_ack","grix_composing","grix_access_control","grix_status"]);class a{tools;toolMap;constructor(){this.tools=[...s.filter(o=>e.has(o.name)),...t.filter(o=>r.has(o.name))],this.toolMap=new Map(this.tools.map(o=>[o.name,o]))}getTools(){return this.tools}getTool(o){return this.toolMap.get(o)}hasTool(o){return this.toolMap.has(o)}}export{a as ToolRegistryImpl};