@fxz26284407/yuanma-gateway 0.1.2 → 0.1.4
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/LICENSE +0 -1
- package/NOTICE +0 -1
- package/README.md +28 -116
- package/bin/yuanma-gateway.mjs +24 -63
- package/dist/antigravity-conversation.js +58 -3
- package/dist/antigravity-native.js +106 -45
- package/dist/antigravity.js +290 -13
- package/dist/cli.js +5 -10
- package/dist/codex.js +2 -2
- package/dist/deepseek-conversation.js +4 -3
- package/dist/deepseek.js +37 -14
- package/dist/execution-summary.js +5 -0
- package/dist/mobile-history.js +8 -1
- package/dist/progress.js +22 -11
- package/dist/relay.js +55 -0
- package/dist/secure-ws.js +4 -2
- package/dist/server.js +5 -25
- package/gateway/config.example.json +1 -3
- package/package.json +2 -3
- package/scripts/pairing-qr.mjs +14 -23
- package/scripts/restart-local-gateway.mjs +13 -26
- package/dist/antigravity-acp.js +0 -132
- package/dist/lan.js +0 -44
- package/dist/relay-client.js +0 -57
- package/dist/version.js +0 -2
package/LICENSE
CHANGED
package/NOTICE
CHANGED
|
@@ -23,4 +23,3 @@ qrcode-terminal includes QRCode for JavaScript, Copyright (c) 2009 Kazuhiko Aras
|
|
|
23
23
|
Development and transitive dependencies retain their respective licenses in the npm packages and package lock metadata. Relay packaging copies the complete ws package, including its license. No third-party copyright notices are removed.
|
|
24
24
|
|
|
25
25
|
Original Yuanma gateway code is licensed under Apache-2.0; dependency licenses remain unchanged.
|
|
26
|
-
|
package/README.md
CHANGED
|
@@ -1,59 +1,23 @@
|
|
|
1
1
|
# 远码网关(yuanma-gateway)
|
|
2
2
|
|
|
3
|
-
远码的 Mac 本地网关与 HTTP/WS
|
|
4
|
-
|
|
5
|
-
## 局域网直连(本地开发版本,尚未发布到 npm)
|
|
6
|
-
|
|
7
|
-
本地代码已支持手机直接连接 Mac,无需启动 Relay。npm 已发布的 0.1.0 仍沿用原中继流程;使用直连需同时更新本地网关和鸿蒙 App,旧 App 不支持新的直连二维码。
|
|
8
|
-
|
|
9
|
-
在本仓库使用 Node.js 22 构建后运行:
|
|
10
|
-
|
|
11
|
-
```sh
|
|
12
|
-
npm run build
|
|
13
|
-
node bin/yuanma-gateway.mjs init
|
|
14
|
-
node bin/yuanma-gateway.mjs pair
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
首次初始化的中继地址可以留空。单个常规内网 IPv4 自动选择,多个地址展示网卡与 IP 供选择;虚拟网卡需确认手机可达。默认端口为 `19089`,二维码地址为 `ws://所选内网IP:19089/v1/secure`。仅该加密入口对内网开放,本地管理接口继续使用 Unix socket。仍需准备与桌面共享的 Codex 后端及现有密钥保存模式。
|
|
18
|
-
|
|
19
|
-
普通 start/restart 沿用已保存模式;pair 默认局域网,多个内网 IP 时交互选择,直接执行:
|
|
20
|
-
|
|
21
|
-
```sh
|
|
22
|
-
node bin/yuanma-gateway.mjs pair
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
非交互或需要指定网卡、端口时:
|
|
26
|
-
|
|
27
|
-
```sh
|
|
28
|
-
node bin/yuanma-gateway.mjs pair --lan-host 192.168.1.10 --lan-port 19089
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
将示例 IP 替换为本机实际内网地址。IP 失效时重新选择并扫码,端口冲突时更换端口;不要将手机的 localhost 当作 Mac。网关保存连接方式,后续启动复用;设备身份与授权保持原样。
|
|
32
|
-
|
|
33
|
-
保留 Relay 扩展,切换时使用:
|
|
34
|
-
|
|
35
|
-
```sh
|
|
36
|
-
node bin/yuanma-gateway.mjs pair --relay-url wss://relay.example.com
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
局域网和 Relay 首版互斥,不会自动回退公网。以下中继安装与部署说明适用于已有发布版本及需要中继扩展的用户。
|
|
3
|
+
远码的 Mac 本地网关与 HTTP/WS 局域网密文中继。只包含两端网关,不包含鸿蒙工程、签名文件、个人文档库或运行数据。
|
|
40
4
|
|
|
41
5
|
## 项目系列
|
|
42
6
|
|
|
43
7
|
[远码 Yuanma · GitHub Project](https://github.com/users/fxz26284407/projects/2)
|
|
44
8
|
|
|
45
|
-
- [
|
|
9
|
+
- [远码网关与局域网中继](https://github.com/fxz26284407/yuanma-gateway)
|
|
46
10
|
- [远码鸿蒙客户端](https://github.com/fxz26284407/yuanma-harmony)
|
|
47
11
|
|
|
48
12
|
## 部署架构
|
|
49
13
|
|
|
50
14
|

|
|
51
15
|
|
|
52
|
-
|
|
16
|
+
手机与 Mac 位于同一局域网,主动连接用户自行部署的内网 WS 中继,例如 `ws://192.168.1.10:19088`。业务内容通过 Noise 在两端加密,中继只负责转发。Codex Desktop 与 Mac 网关连接同一个 app-server,任务、文件操作和审批发生在 Mac。SQLite 消息账本与设备授权也保存在 Mac。
|
|
53
17
|
|
|
54
|
-
|
|
18
|
+
中继部署在 Mac 的 Docker 或同一局域网内的主机,端口映射为 `19088:8080`,支持 amd64 / arm64。示例使用私有 IP,不需要公网域名、内网穿透或外部反向代理;不要在路由器上配置公网端口转发。按下文将服务绑定到宿主机实际的局域网 IP,并由防火墙仅允许可信内网设备访问。
|
|
55
19
|
|
|
56
|
-
|
|
20
|
+
这里的“局域网”指手机、网关与中继之间的连接范围,不表示模型在本地离线运行。Mac 上的 Codex、DeepSeek 及所选模型服务仍按各自配置联网;安装依赖和拉取镜像也需要访问相应软件源。
|
|
57
21
|
|
|
58
22
|
[架构图源文件](docs/deployment.archify.json)
|
|
59
23
|
|
|
@@ -62,7 +26,7 @@ node bin/yuanma-gateway.mjs pair --relay-url wss://relay.example.com
|
|
|
62
26
|
| 目录 | 内容 | 运行位置 |
|
|
63
27
|
| --- | --- | --- |
|
|
64
28
|
| `gateway/` | Codex 适配、鉴权、项目权限、SQLite 消息账本、自有队列、图片和审批路由 | 工作 Mac |
|
|
65
|
-
| `gateway/relay.ts`、`deploy/relay/` | HTTP 健康检查、WebSocket 密文路由、Docker 配置 |
|
|
29
|
+
| `gateway/relay.ts`、`deploy/relay/` | HTTP 健康检查、WebSocket 密文路由、Docker 配置 | 局域网主机或 Mac Docker |
|
|
66
30
|
| `scripts/` | 配对二维码、Mac 本地批准页、网关重启和中继打包 | Mac |
|
|
67
31
|
|
|
68
32
|
手机与 Mac 建立固定公钥的 Noise 加密通道,中继只转发密文。Codex 桌面和网关需要连接同一后端才能共享实时会话。
|
|
@@ -74,14 +38,6 @@ node bin/yuanma-gateway.mjs pair --relay-url wss://relay.example.com
|
|
|
74
38
|
- Codex 与 DeepSeek 共用下一步建议开关、预览及采用流程,各自使用自己的模型和额度。建议只填入可编辑草稿,不自动发送。
|
|
75
39
|
- DeepSeek 建议复用 `/Applications/DeepSeek Harness.app` 内置 SDK 和既有登录,独立请求近期会话文本,不加载 Agent、会话持久化或工具。支持 `deepseek-account` 与 `deepseek-official` 路由;其他第三方提供方尚未适配。桌面 SDK 更新不兼容时,建议暂不可用,原会话仍走现有桌面 Host。
|
|
76
40
|
|
|
77
|
-
## Antigravity 接入进度
|
|
78
|
-
|
|
79
|
-
支持读取 Antigravity App 的已有工作区、会话、历史分页和全文。手机可切换第三个智能体,文件引用按 Markdown 标签展示。CLI 不可用时保留只读浏览。
|
|
80
|
-
|
|
81
|
-
控制通道使用官方 Antigravity CLI 1.5.0 的 ACP,默认可执行文件为 `~/.local/bin/agy`;其他路径可在网关配置中设置 `antigravityCli`。目前已接线已有会话发送、实时进度、手机回合取消、原生审批/字符串问题回复、模型与思考档位、执行策略及图片输入。实际用户任务的发送、审批与取消闭环尚未完成验收;新建、引导、会话菜单、文档附件、扩展、Diff 和下一步建议未开放。
|
|
82
|
-
|
|
83
|
-
进程重启后无法确认的发送会保留未知状态,不自动重发。当前只支持取消手机通过本网关发起的回合,尚不提供桌面独立回合的停止或审批接管,整体使用体验仍未与 Codex 对齐。历史与摘要单文件上限为 32 MiB,超限会明确提示。
|
|
84
|
-
|
|
85
41
|
## Mac 网关安装(npm)
|
|
86
42
|
|
|
87
43
|
需要 **macOS、Node.js 22**,以及已登录、与桌面共用的 Codex app-server。当前适配固定为 app-server **0.166.0**;其他版本需要先适配协议。DeepSeek 功能另外依赖本机已有的 DeepSeek Harness 和登录。
|
|
@@ -110,18 +66,18 @@ yuanma-gateway projects
|
|
|
110
66
|
### 初始化
|
|
111
67
|
|
|
112
68
|
```sh
|
|
113
|
-
yuanma-gateway init --relay-url
|
|
69
|
+
yuanma-gateway init --relay-url ws://192.168.1.10:19088
|
|
114
70
|
```
|
|
115
71
|
|
|
116
|
-
|
|
72
|
+
将示例 IP 替换为运行中继的主机局域网 IP,并先按下文启动中继。初始化读取真实 Codex 项目列表,由你选择初始项目。**现有产品会继续同步发现其他项目;配对后所有发现的项目和工作区默认可执行,初始列表不是权限白名单。** 初始化会明确说明并要求接受这项既有策略;会话中的执行审批仍独立管理。
|
|
117
73
|
|
|
118
|
-
|
|
74
|
+
当前版本的跨设备连接还需显式启用本机私有文件保存传输密钥的模式;交互界面会说明并征求选择。
|
|
119
75
|
|
|
120
76
|
在非交互环境中,提供真实项目 ID 和对应选项:
|
|
121
77
|
|
|
122
78
|
```sh
|
|
123
79
|
yuanma-gateway init \
|
|
124
|
-
--relay-url
|
|
80
|
+
--relay-url ws://192.168.1.10:19088 \
|
|
125
81
|
--projects '真实项目ID1,真实项目ID2' \
|
|
126
82
|
--allow-development-keys \
|
|
127
83
|
--accept-project-access
|
|
@@ -151,7 +107,7 @@ yuanma-gateway restart
|
|
|
151
107
|
更新中继地址并保存,供后续重启/启动复用:
|
|
152
108
|
|
|
153
109
|
```sh
|
|
154
|
-
yuanma-gateway restart --relay-url
|
|
110
|
+
yuanma-gateway restart --relay-url ws://192.168.1.20:19088
|
|
155
111
|
```
|
|
156
112
|
|
|
157
113
|
网关尚未运行时使用 `start --relay-url 地址`。URL 支持 `ws://` 和 `wss://`,拒绝内嵌账号密码、查询参数、片段或非法端口。状态输出中的 `relayUrl` 是实际生效地址,末尾 `/` 属正常规范化结果。
|
|
@@ -170,7 +126,7 @@ yuanma-gateway pair
|
|
|
170
126
|
|
|
171
127
|
### 手机版本要求
|
|
172
128
|
|
|
173
|
-
|
|
129
|
+
局域网连接使用 `ws://内网IP:19088`。建议手机与 Mac 网关保持兼容版本;更新 relay 镜像或 npm 网关,不会自动升级手机 App。
|
|
174
130
|
|
|
175
131
|
使用同一签名的新包覆盖安装,保留应用数据与原配对;不要先卸载或清空数据。在手机连接页选择“扫码更新连接地址”,扫描刚生成且未过期的二维码。手机和 Mac 须连接同一套 relay,两套中继之间不共享注册路由。
|
|
176
132
|
|
|
@@ -243,7 +199,7 @@ yuanma-gateway devices
|
|
|
243
199
|
|
|
244
200
|
同步前会在网关私有状态目录保存桌面状态备份;桌面运行时不会写入其状态文件。待同步记录在网关重启后保留,旧版手机分区记录会自动纳入一次补同步。
|
|
245
201
|
|
|
246
|
-
##
|
|
202
|
+
## 局域网中继部署:直接拉取 Docker Hub 镜像
|
|
247
203
|
|
|
248
204
|
公开仓库:[fxz26284407/yuanma-relay](https://hub.docker.com/r/fxz26284407/yuanma-relay)。普通部署不再需要在 Mac 打包源码、上传发布目录或在服务器构建镜像;宿主机只需 Docker,不需要安装 Node、Codex 或网关依赖。
|
|
249
205
|
|
|
@@ -256,14 +212,14 @@ yuanma-gateway devices
|
|
|
256
212
|
|
|
257
213
|
### 启动与端口
|
|
258
214
|
|
|
259
|
-
|
|
215
|
+
以下假设中继宿主机的局域网 IP 为 `192.168.1.10`。先替换为本机实际 IP,确认 `19088` 未被占用,再执行:
|
|
260
216
|
|
|
261
217
|
```sh
|
|
262
218
|
docker pull fxz26284407/yuanma-relay:2026.11.03
|
|
263
219
|
|
|
264
220
|
docker run -d --name yuanma-relay \
|
|
265
221
|
--restart unless-stopped --init \
|
|
266
|
-
-p 19088:8080 \
|
|
222
|
+
-p 192.168.1.10:19088:8080 \
|
|
267
223
|
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=16m \
|
|
268
224
|
--cap-drop ALL --security-opt no-new-privileges:true \
|
|
269
225
|
--memory 128m --cpus 0.5 --pids-limit 64 \
|
|
@@ -271,82 +227,38 @@ docker run -d --name yuanma-relay \
|
|
|
271
227
|
fxz26284407/yuanma-relay:2026.11.03
|
|
272
228
|
```
|
|
273
229
|
|
|
274
|
-
`19088:8080` 表示
|
|
230
|
+
`192.168.1.10:19088:8080` 表示 **宿主机局域网 IP 的 19088 → 容器 8080**,仅绑定指定内网接口。如需换端口,同步修改两端连接地址。无需给 relay 挂载工作目录、数据库、网关配置或 Mac 私钥。镜像自带 Node 运行时,内部不含 Nginx。
|
|
275
231
|
|
|
276
232
|
```sh
|
|
277
|
-
curl --max-time 5 -fsS http://
|
|
233
|
+
curl --max-time 5 -fsS http://192.168.1.10:19088/health
|
|
278
234
|
docker inspect yuanma-relay --format '{{.State.Status}} / {{.State.Health.Status}}'
|
|
279
235
|
docker logs --tail 30 yuanma-relay
|
|
280
236
|
```
|
|
281
237
|
|
|
282
238
|
健康接口应返回 `{"ok":true}`;启动后短暂的 `starting` 会在健康检查完成后转为 `healthy`。重启已有容器可执行 `docker restart yuanma-relay`。仅拉取新镜像不会自动替换运行中的容器,升级时按原部署方式重建并保留端口和资源配置;已有 relay 占用端口时不要重复启动。
|
|
283
239
|
|
|
284
|
-
###
|
|
285
|
-
|
|
286
|
-
花生壳客户端需上线,并能访问宿主机发布的 relay 端口:
|
|
287
|
-
|
|
288
|
-
```text
|
|
289
|
-
手机 / Mac 网关
|
|
290
|
-
→ wss://你的花生壳域名:443
|
|
291
|
-
→ 花生壳 HTTPS 入口(TLS 与证书)
|
|
292
|
-
→ 花生壳客户端
|
|
293
|
-
→ 宿主机可达地址:19088
|
|
294
|
-
→ relay 容器 8080(HTTP/WS)
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
| 花生壳配置项 | 填写方式 |
|
|
298
|
-
| --- | --- |
|
|
299
|
-
| 映射协议 | HTTPS |
|
|
300
|
-
| 外网域名 | 花生壳分配或绑定的实际域名 |
|
|
301
|
-
| 外网端口 | 默认 443,或产品实际提供的 HTTPS 外网端口 |
|
|
302
|
-
| 内网主机 | 从花生壳客户端所在环境可达的宿主机地址 |
|
|
303
|
-
| 内网端口 | 19088 |
|
|
304
|
-
| 回源协议(如有此选项) | HTTP,relay 内部不提供 TLS |
|
|
305
|
-
|
|
306
|
-
**公网 HTTPS 不等于内网也要 HTTPS。** 花生壳负责证书和 TLS,relay 继续用 HTTP/WS。外层入口需要支持 WebSocket 升级;若有单独的 WebSocket 开关,应开启。容器内加 Nginx 无法替代外层的 WS 支持。
|
|
240
|
+
### 手机与 Mac 连接同一局域网
|
|
307
241
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
- 花生壳在 Docker 容器里:`127.0.0.1` 是花生壳容器自身,应改为可达的宿主机地址。
|
|
312
|
-
- Mac Docker Desktop 可查询 `host.docker.internal`;本次环境解析为 `192.168.65.254`,已核对从花生壳容器能访问 `192.168.65.254:19088`。该 IP 不是所有机器的固定值。
|
|
313
|
-
- Linux 或其他环境使用从花生壳客户端可达的宿主机内网 IP,不直接照抄 Mac 示例。
|
|
314
|
-
|
|
315
|
-
例如花生壳容器名为 `oray`,且内置以下工具:
|
|
316
|
-
|
|
317
|
-
```sh
|
|
318
|
-
docker exec oray getent hosts host.docker.internal
|
|
319
|
-
docker exec oray wget -qO- -T 5 -t 1 http://192.168.65.254:19088/health
|
|
320
|
-
curl --max-time 10 -fsS https://relay.example.com/health
|
|
321
|
-
```
|
|
322
|
-
|
|
323
|
-
容器名、内网 IP 和公网域名均应替换为实际值。两跳健康接口都应返回 `{"ok":true}`,HTTPS 证书应有效。健康检查通过不等于 WebSocket 可用;还需确认 WSS 的 HTTP 101 升级及手机安全登录。
|
|
324
|
-
|
|
325
|
-
配置完成后,已通过 npm 初始化的 Mac 网关可在任意目录执行:
|
|
242
|
+
1. 将手机连接到与 Mac、中继互通的 Wi-Fi;Mac 或中继主机可使用同一路由器下的有线网络。不要使用隔离设备的访客网络。
|
|
243
|
+
2. 为中继主机配置 DHCP 地址保留或固定内网 IP,允许可信内网设备访问 TCP `19088`,关闭路由器端口转发、穿透及其他对外入口。
|
|
244
|
+
3. 在 Mac 检查 `http://192.168.1.10:19088/health`,返回 `{"ok":true}` 后更新网关并配对:
|
|
326
245
|
|
|
327
246
|
```sh
|
|
328
|
-
|
|
329
|
-
nvm use 22
|
|
330
|
-
yuanma-gateway pair --relay-url wss://relay.example.com
|
|
247
|
+
yuanma-gateway pair --relay-url ws://192.168.1.10:19088
|
|
331
248
|
```
|
|
332
249
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
本机部署依赖 Mac、Docker Desktop 和花生壳持续在线。休眠、退出 Docker 或花生壳离线都会中断连接;中继镜像不会自动配置这些运行条件。
|
|
336
|
-
|
|
337
|
-
### 公网服务器直连或外部反代
|
|
250
|
+
将示例 IP 替换为中继主机实际的内网 IP。手机扫描 Mac 生成的二维码;已有配对通过“扫码更新连接地址”沿用原身份。手机和 Mac 须使用同一个中继地址,手机不能将 `127.0.0.1` 或 `localhost` 作为 Mac 地址。
|
|
338
251
|
|
|
339
|
-
|
|
252
|
+
离开该局域网后,此部署方式无法连接 Mac;回到互通网络后可重新连接。更换局域网或中继 IP 后,更新网关地址并重新扫码。部署在 Mac Docker 上时,Mac 与 Docker 必须持续运行;休眠或退出 Docker 会中断连接。
|
|
340
253
|
|
|
341
254
|
### 常见连接问题
|
|
342
255
|
|
|
343
256
|
| 现象 | 优先核对 |
|
|
344
257
|
| --- | --- |
|
|
345
|
-
| 宿主机健康检查失败 | relay
|
|
346
|
-
|
|
|
347
|
-
|
|
|
348
|
-
|
|
|
349
|
-
| 手机扫码后仍恢复旧连接 | 手机版本是否支持 WSS、二维码是否过期、新地址是否成功登录并保存 |
|
|
258
|
+
| 宿主机健康检查失败 | relay 是否运行、绑定的内网 IP 是否属于本机、端口是否被占用 |
|
|
259
|
+
| Mac 可达,手机不可达 | 手机是否连接同一局域网、是否启用访客隔离、防火墙是否允许内网端口 |
|
|
260
|
+
| 手机扫码后仍恢复旧连接 | 二维码是否过期、新地址是否成功登录并保存 |
|
|
261
|
+
| 更换网络后无法连接 | 是否仍能访问中继内网 IP;地址变化后需更新网关并重新扫码 |
|
|
350
262
|
| WS 打开后路由立即断开 | Mac 网关是否注册到同一套 relay;“配置了中继”不等于注册成功 |
|
|
351
263
|
|
|
352
264
|
Docker Hub 首页有完整的镜像使用说明。源码构建和 `npm run package:relay` 仍保留给需要自行修改中继的开发者,不作为普通部署的前置步骤。
|
package/bin/yuanma-gateway.mjs
CHANGED
|
@@ -9,9 +9,8 @@ import {parseArgs} from 'node:util';
|
|
|
9
9
|
import {createInterface} from 'node:readline/promises';
|
|
10
10
|
import {setTimeout as delay} from 'node:timers/promises';
|
|
11
11
|
import {Rpc} from '../dist/rpc.mjs';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {connectionArgs, gatewayLaunch, restartGateway, gatewayStatus, stopGateway, openGatewayLog} from '../scripts/restart-local-gateway.mjs';
|
|
12
|
+
import {relayEndpoint} from '../dist/relay.js';
|
|
13
|
+
import {gatewayLaunch, restartGateway, gatewayStatus, stopGateway, openGatewayLog} from '../scripts/restart-local-gateway.mjs';
|
|
15
14
|
|
|
16
15
|
process.umask(0o077);
|
|
17
16
|
const root=fileURLToPath(new URL('../',import.meta.url));
|
|
@@ -23,10 +22,7 @@ const help=`远码网关(需要 Node.js 22 和与桌面共享的 Codex 后端
|
|
|
23
22
|
yuanma-gateway init --config 原配置绝对路径 --state-dir 原状态目录绝对路径
|
|
24
23
|
yuanma-gateway start [--relay-url wss://域名]
|
|
25
24
|
yuanma-gateway restart [--relay-url wss://域名]
|
|
26
|
-
yuanma-gateway pair
|
|
27
|
-
yuanma-gateway pair --relay-url ws[s]://中继地址
|
|
28
|
-
局域网选项:--connection-mode lan [--lan-host 内网IP] [--lan-port 19089]
|
|
29
|
-
中继选项:--relay-url ws[s]://中继地址(start/restart 沿用原模式,pair 默认局域网)
|
|
25
|
+
yuanma-gateway pair [--relay-url wss://域名]
|
|
30
26
|
yuanma-gateway status | stop | projects | devices | security
|
|
31
27
|
yuanma-gateway device-project-policy --device-id 设备ID --capability control
|
|
32
28
|
默认配置与状态位于 ~/.local/share/yuanma/gateway/,npm 升级不会覆盖。
|
|
@@ -48,7 +44,6 @@ async function createJson(path,value){
|
|
|
48
44
|
}
|
|
49
45
|
function validateConfig(config){
|
|
50
46
|
if(!Array.isArray(config.projects)||!config.projects.length||config.projects.some(p=>typeof p.id!=='string'||!p.id||!Array.isArray(p.roots)||!p.roots.length||p.roots.some(r=>typeof r!=='string'||!r.startsWith('/'))))throw Error('配置需要明确的项目 ID 和绝对根目录');
|
|
51
|
-
if(config.connectionMode!==undefined&&!['lan','relay'].includes(config.connectionMode))throw Error('配置中的连接模式无效');
|
|
52
47
|
if(config.relayUrl!=null)relayEndpoint(config.relayUrl);
|
|
53
48
|
}
|
|
54
49
|
async function installation(values){
|
|
@@ -58,46 +53,16 @@ async function installation(values){
|
|
|
58
53
|
if(typeof info.config!=='string'||!info.config.startsWith('/')||typeof info.stateDir!=='string'||!info.stateDir.startsWith('/'))throw Error('安装记录中的路径无效');
|
|
59
54
|
const config=await privateJson(info.config);validateConfig(config);
|
|
60
55
|
await lstat(info.stateDir);await privateDirectory(info.stateDir);
|
|
61
|
-
if(info.relayUrl
|
|
56
|
+
if(info.relayUrl!==undefined)relayEndpoint(info.relayUrl);
|
|
62
57
|
return {...info,configData:config};
|
|
63
58
|
}
|
|
64
|
-
async function
|
|
59
|
+
async function rememberRelay(info,relayUrl){
|
|
65
60
|
const current=await privateJson(settingsPath).catch(e=>{if(e.code==='ENOENT')return null;throw e;});
|
|
66
|
-
if(!current||current.config!==info.config||current.stateDir!==info.stateDir)
|
|
67
|
-
const config=await privateJson(info.config);
|
|
68
|
-
const temporary=join(dirname(info.config),'connection-'+randomUUID()+'.tmp');
|
|
69
|
-
try{await createJson(temporary,{...config,...connection,relayUrl:connection.connectionMode==='relay'?connection.relayUrl:null});await rename(temporary,info.config);}
|
|
70
|
-
finally{await unlink(temporary).catch(e=>{if(e.code!=='ENOENT')throw e;});}
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
61
|
+
if(!current||current.config!==info.config||current.stateDir!==info.stateDir||current.relayUrl===relayUrl)return;
|
|
73
62
|
const temporary=join(home,'installation-'+randomUUID()+'.tmp');
|
|
74
|
-
try{await createJson(temporary,{...current
|
|
63
|
+
try{await createJson(temporary,{...current,relayUrl});await rename(temporary,settingsPath);}
|
|
75
64
|
finally{await unlink(temporary).catch(e=>{if(e.code!=='ENOENT')throw e;});}
|
|
76
65
|
}
|
|
77
|
-
async function selectConnection(values,saved={},choose=false){
|
|
78
|
-
if(values['relay-url']!==undefined&&values['connection-mode']==='lan')throw Error('--relay-url 与局域网模式冲突');
|
|
79
|
-
if(values['lan-host']!==undefined&&(values['relay-url']!==undefined||values['connection-mode']==='relay'))throw Error('--lan-host 与 Relay 模式冲突');
|
|
80
|
-
const mode=values['connection-mode']??(values['relay-url']!==undefined?'relay':values['lan-host']!==undefined?'lan':saved.connectionMode??(saved.relayUrl?'relay':'lan'));
|
|
81
|
-
if(mode==='relay')return {connectionMode:'relay',relayUrl:relayEndpoint(values['relay-url']??saved.relayUrl)};
|
|
82
|
-
if(mode!=='lan')throw Error('--connection-mode 只能是 lan 或 relay');
|
|
83
|
-
let host=values['lan-host']??saved.lanHost;
|
|
84
|
-
const addresses=lanAddresses();
|
|
85
|
-
if(values['lan-host']!==undefined)lanConfig(host,values['lan-port']??saved.lanPort??DEFAULT_LAN_PORT);
|
|
86
|
-
if(!addresses.some(a=>a.address===host)||(choose&&values['lan-host']===undefined&&new Set(addresses.map(a=>a.address)).size>1)){
|
|
87
|
-
if(host&&!addresses.some(a=>a.address===host))console.log('原局域网 IP 已不可用,请重新选择。');
|
|
88
|
-
const unique=addresses.filter((a,i)=>addresses.findIndex(b=>a.address===b.address)===i);
|
|
89
|
-
if(!unique.length)throw Error('未发现局域网 IPv4,请连接 Wi-Fi 或有线网络');
|
|
90
|
-
unique.forEach((a,i)=>console.log(`${i+1}. ${a.name}: ${a.address}${a.virtual?'(虚拟网卡/VPN,请确认手机可达)':''}`));
|
|
91
|
-
if(unique.length===1&&!unique[0].virtual)host=unique[0].address;
|
|
92
|
-
else{
|
|
93
|
-
if(!process.stdin.isTTY)throw Error('请显式提供 --lan-host 选择局域网 IP');
|
|
94
|
-
const prompt=createInterface({input:process.stdin,output:process.stdout});
|
|
95
|
-
try{const previous=unique.findIndex(a=>a.address===host)+1;const answer=(await prompt.question('选择手机可达的局域网 IP 序号'+(previous?'(回车沿用 '+previous+')':'')+':')).trim()||(previous?String(previous):'');const index=Number(answer);if(!/^\d+$/.test(answer)||index<1||index>unique.length)throw Error('网卡序号无效');host=unique[index-1].address;}finally{prompt.close();}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
const lan=lanConfig(host,values['lan-port']??saved.lanPort??DEFAULT_LAN_PORT);
|
|
99
|
-
return {connectionMode:'lan',lanHost:lan.host,lanPort:lan.port,relayUrl:null};
|
|
100
|
-
}
|
|
101
66
|
async function catalog(socket){
|
|
102
67
|
const rpc=new Rpc(socket,'yuanma_gateway_setup',{retainMessages:false});
|
|
103
68
|
try{await rpc.initialize();const result=await rpc.call('project/list',{limit:100});return result.data;}finally{rpc.close();}
|
|
@@ -115,18 +80,16 @@ async function init(values){
|
|
|
115
80
|
const active=await gatewayStatus(info.stateDir).catch(e=>{if(['ENOENT','ECONNREFUSED'].includes(e.code))return null;throw e;});
|
|
116
81
|
if(active)await gatewayLaunch(info.stateDir);
|
|
117
82
|
// ponytail: 原地登记既有目录,不复制正在使用的 SQLite 或身份文件。
|
|
118
|
-
await createJson(settingsPath,{config:info.config,stateDir:info.stateDir,...(active?.
|
|
83
|
+
await createJson(settingsPath,{config:info.config,stateDir:info.stateDir,...(active?.relayUrl||info.configData.relayUrl?{relayUrl:relayEndpoint(active?.relayUrl??info.configData.relayUrl)}:{})});
|
|
119
84
|
console.log('已沿用原配置、密钥、设备授权和消息账本;请保留原目录。');return;
|
|
120
85
|
}
|
|
121
86
|
const configPath=join(home,'config.json'),stateDir=join(home,'state');
|
|
122
87
|
const saved=await privateJson(configPath).catch(e=>{if(e.code==='ENOENT')return null;throw e;});
|
|
123
|
-
if(saved){validateConfig(saved);await lstat(stateDir);await privateDirectory(stateDir);await createJson(settingsPath,{config:configPath,stateDir,...(saved.
|
|
88
|
+
if(saved){validateConfig(saved);await lstat(stateDir);await privateDirectory(stateDir);await createJson(settingsPath,{config:configPath,stateDir,...(saved.relayUrl?{relayUrl:relayEndpoint(saved.relayUrl)}:{})});console.log('已保留原配置并恢复安装记录。');return;}
|
|
124
89
|
let prompt;
|
|
125
|
-
const ask=async question=>{if(!process.stdin.isTTY)throw Error('非交互初始化需提供 --projects、--allow-development-keys 和 --accept-project-access
|
|
90
|
+
const ask=async question=>{if(!process.stdin.isTTY)throw Error('非交互初始化需提供 --relay-url、--projects、--allow-development-keys 和 --accept-project-access');prompt??=createInterface({input:process.stdin,output:process.stdout});return prompt.question(question);};
|
|
126
91
|
try{
|
|
127
|
-
const
|
|
128
|
-
prompt?.close();prompt=undefined;
|
|
129
|
-
const connection=await selectConnection({...values,...(entered.trim()?{'relay-url':entered.trim()}:{})});
|
|
92
|
+
const relay=relayEndpoint(values['relay-url']??await ask('中继地址(ws:// 或 wss://):'));
|
|
130
93
|
const socket=resolve(values['codex-socket']??join(homedir(),'.local/share/yuanma/shared/app-server.sock'));
|
|
131
94
|
const projects=await catalog(socket);
|
|
132
95
|
if(!Array.isArray(projects)||!projects.length)throw Error('共享 Codex 后端没有可选择的项目');
|
|
@@ -142,23 +105,23 @@ async function init(values){
|
|
|
142
105
|
if(!allow)throw Error('未启用当前版本所需的密钥模式,未创建配置或网关身份');
|
|
143
106
|
const accepted=values['accept-project-access']||(await ask('配对后所有发现的项目和工作区默认可执行,初始项目列表不是权限白名单。接受此策略?[y/N]:')).trim().toLowerCase()==='y';
|
|
144
107
|
if(!accepted)throw Error('未接受现有项目访问策略,未创建配置或网关身份');
|
|
145
|
-
const config={codexSocket:socket
|
|
108
|
+
const config={codexSocket:socket,relayUrl:relay,allowDevelopmentKeys:true,projects:selected.map(p=>({id:p.id,...(p.name?{name:p.name}:{}),roots:p.roots}))};validateConfig(config);
|
|
146
109
|
await privateDirectory(stateDir);
|
|
147
110
|
await createJson(configPath,config);
|
|
148
|
-
await createJson(settingsPath,{config:configPath,stateDir
|
|
111
|
+
await createJson(settingsPath,{config:configPath,stateDir,relayUrl:relay});
|
|
149
112
|
console.log('初始化完成;配置:'+configPath+'\n状态:'+stateDir+'\n接下来运行 yuanma-gateway start 和 yuanma-gateway pair。');
|
|
150
113
|
}finally{prompt?.close();}
|
|
151
114
|
}
|
|
152
|
-
async function start(info,
|
|
153
|
-
const
|
|
154
|
-
const
|
|
115
|
+
async function start(info,relay){
|
|
116
|
+
const value=relay??info.relayUrl??info.configData.relayUrl??undefined;
|
|
117
|
+
const wanted=value===undefined?undefined:relayEndpoint(value);
|
|
155
118
|
const current=await gatewayStatus(info.stateDir).catch(e=>{if(['ENOENT','ECONNREFUSED'].includes(e.code))return null;throw e;});
|
|
156
|
-
if(current){await gatewayLaunch(info.stateDir);if(
|
|
119
|
+
if(current){await gatewayLaunch(info.stateDir);if(wanted&¤t.relayUrl!==wanted)return restartGateway(info.stateDir,wanted);if(!current.backendReady)throw Error('已有网关尚未就绪,请查看状态和日志');return current;}
|
|
157
120
|
await privateDirectory(info.stateDir);
|
|
158
121
|
const logPath=join(info.stateDir,'gateway.log');
|
|
159
122
|
const log=await openGatewayLog(logPath);
|
|
160
123
|
try{
|
|
161
|
-
const args=[cli,'start','--config',info.config,'--state-dir',info.stateDir];args.push(
|
|
124
|
+
const args=[cli,'start','--config',info.config,'--state-dir',info.stateDir];if(wanted)args.push('--relay-url',wanted);
|
|
162
125
|
const child=spawn(process.execPath,args,{cwd:dirname(info.config),detached:true,stdio:['ignore',log.fd,log.fd]});child.unref();
|
|
163
126
|
let error,exited=false;child.on('error',e=>{error=e;});child.on('exit',()=>{exited=true;});
|
|
164
127
|
for(let n=0;n<150;n++){
|
|
@@ -166,7 +129,7 @@ async function start(info,connection){
|
|
|
166
129
|
if(exited)throw Error('网关启动失败,请查看 '+logPath);
|
|
167
130
|
const state=await gatewayStatus(info.stateDir).catch(e=>{if(['ENOENT','ECONNREFUSED'].includes(e.code))return null;throw e;});
|
|
168
131
|
if(state?.processId&&state.processId!==child.pid)throw Error('另一实例取得了网关状态目录,未启动第二套身份');
|
|
169
|
-
if(state?.backendReady
|
|
132
|
+
if(state?.backendReady)return state;
|
|
170
133
|
await delay(100);
|
|
171
134
|
}
|
|
172
135
|
throw Error('启动尚未完成,请检查 yuanma-gateway status 和 '+logPath+';不要重复初始化');
|
|
@@ -183,7 +146,7 @@ try{
|
|
|
183
146
|
if(['help','--help','-h'].includes(action)||args.includes('--help'))console.log(help);
|
|
184
147
|
else if(['--version','version'].includes(action))console.log(JSON.parse(await readFile(join(root,'package.json'),'utf8')).version);
|
|
185
148
|
else{
|
|
186
|
-
const {values}=parseArgs({args,options:{config:{type:'string'},'state-dir':{type:'string'},'relay-url':{type:'string'},'
|
|
149
|
+
const {values}=parseArgs({args,options:{config:{type:'string'},'state-dir':{type:'string'},'relay-url':{type:'string'},'codex-socket':{type:'string'},projects:{type:'string'},'allow-development-keys':{type:'boolean'},'accept-project-access':{type:'boolean'},backend:{type:'string'},'device-id':{type:'string'},capability:{type:'string'},'claim-id':{type:'string'},'grants-json':{type:'string'}}});
|
|
187
150
|
if(action==='init')await init(values);
|
|
188
151
|
else if(action==='projects'){
|
|
189
152
|
if(values.backend&&values.backend!=='codex')await run(cli,['projects','--backend',values.backend]);
|
|
@@ -191,17 +154,15 @@ try{
|
|
|
191
154
|
}else if(['start','restart','stop','status','pair','devices','security','device-project-policy','pairing-pending','pairing-approve','pairing-reject','device-revoke'].includes(action)){
|
|
192
155
|
const info=await installation(values);
|
|
193
156
|
if(action==='start'||action==='restart'){
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
await rememberConnection(info,connection);
|
|
157
|
+
const state=action==='start'?await start(info,values['relay-url']):await restartGateway(info.stateDir,values['relay-url']??info.relayUrl);
|
|
158
|
+
if(state.relayUrl)await rememberRelay(info,state.relayUrl);
|
|
197
159
|
console.log(JSON.stringify(state,null,2));
|
|
198
160
|
}
|
|
199
161
|
else if(action==='stop'){await stopGateway(info.stateDir);console.log('网关已停止,配置和设备数据保留。');}
|
|
200
162
|
else if(action==='status')console.log(JSON.stringify(await gatewayStatus(info.stateDir),null,2));
|
|
201
163
|
else if(action==='pair'){
|
|
202
|
-
const
|
|
203
|
-
await
|
|
204
|
-
await run(join(root,'scripts/pairing-qr.mjs'),['--state-dir',info.stateDir,'--auto-approve','--no-ui',...connectionArgs(connection)]);
|
|
164
|
+
const state=await start(info,values['relay-url']);if(state.relayUrl)await rememberRelay(info,state.relayUrl);
|
|
165
|
+
await run(join(root,'scripts/pairing-qr.mjs'),['--state-dir',info.stateDir,'--auto-approve','--no-ui',...(values['relay-url']?['--relay-url',values['relay-url']]:[])]);
|
|
205
166
|
}else{
|
|
206
167
|
const forwarded=[action,'--state-dir',info.stateDir];
|
|
207
168
|
for(const key of ['backend','device-id','capability','claim-id','grants-json'])if(values[key]!==undefined)forwarded.push('--'+key,values[key]);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { basename } from 'node:path';
|
|
2
3
|
import { requireValue } from './domain.js';
|
|
3
4
|
export const fingerprint = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16);
|
|
4
5
|
const valueText = (value) => typeof value === 'string' ? value : value === undefined ? '' : JSON.stringify(value, null, 2);
|
|
@@ -24,7 +25,7 @@ export function stepParts(step) {
|
|
|
24
25
|
parts.push({ part: 'content', role: 'assistant', text: valueText(step.content) });
|
|
25
26
|
}
|
|
26
27
|
else if (step.source === 'MODEL' && step.type === 'GENERIC' && step.content) {
|
|
27
|
-
parts.push({ part: 'content', role: 'tool', text: valueText(step.content), label: '执行记录' });
|
|
28
|
+
parts.push({ part: 'content', role: 'tool', text: valueText(step.content), label: step.label ?? '执行记录' });
|
|
28
29
|
}
|
|
29
30
|
return parts;
|
|
30
31
|
}
|
|
@@ -53,7 +54,59 @@ export function recordSteps(lines) {
|
|
|
53
54
|
}
|
|
54
55
|
return records;
|
|
55
56
|
}
|
|
56
|
-
|
|
57
|
+
// Content changes during generation; identity must survive native updates and disk catch-up.
|
|
58
|
+
export function partId(step, part) { const date = Date.parse(step.created_at); return 'agy:' + step.step_index + ':' + fingerprint([step.source, step.type, Number.isFinite(date) ? date : step.created_at]) + ':' + part; }
|
|
59
|
+
export function stepStatus(status) {
|
|
60
|
+
const value = status.replace('CORTEX_STEP_STATUS_', '');
|
|
61
|
+
return value === 'DONE' ? 'completed' : value === 'ERROR' ? 'failed' : value === 'CANCELED' ? 'cancelled' : value === 'WAITING' ? 'waiting' : ['RUNNING', 'GENERATING'].includes(value) ? 'running' : 'pending';
|
|
62
|
+
}
|
|
63
|
+
export function nativeRecords(steps, fallbackTurn = 'agy-context') {
|
|
64
|
+
let turnId = fallbackTurn;
|
|
65
|
+
const records = [];
|
|
66
|
+
for (const [index, raw] of [...steps].sort((a, b) => a[0] - b[0])) {
|
|
67
|
+
if (raw.userInput?.isQueuedMessage)
|
|
68
|
+
continue;
|
|
69
|
+
const source = String(raw.metadata?.source ?? '').replace('CORTEX_STEP_SOURCE_', ''), type = String(raw.type ?? '').replace('CORTEX_STEP_TYPE_', '');
|
|
70
|
+
const step = { step_index: index, source, type, status: String(raw.status ?? '').replace('CORTEX_STEP_STATUS_', ''), created_at: raw.metadata?.createdAt ?? '' };
|
|
71
|
+
if (raw.userInput) {
|
|
72
|
+
step.content = (raw.userInput.items ?? []).filter((i) => typeof i.text === 'string').map((i) => i.text).join('');
|
|
73
|
+
if (source === 'USER_EXPLICIT' && !raw.userInput.isQueuedMessage && !/^<USER_SETTINGS_CHANGE>/.test(userText(step).trim()))
|
|
74
|
+
turnId = 'agy-turn-' + index;
|
|
75
|
+
}
|
|
76
|
+
else if (raw.plannerResponse) {
|
|
77
|
+
step.content = raw.plannerResponse.response ?? raw.plannerResponse.modifiedResponse;
|
|
78
|
+
if (!raw.plannerResponse.thinkingRedacted)
|
|
79
|
+
step.thinking = raw.plannerResponse.thinking;
|
|
80
|
+
step.tool_calls = raw.plannerResponse.toolCalls;
|
|
81
|
+
}
|
|
82
|
+
else if (source === 'MODEL') {
|
|
83
|
+
const key = ['runCommand', 'viewFile', 'codeAction', 'generic'].find(k => raw[k]);
|
|
84
|
+
if (key) {
|
|
85
|
+
step.type = 'GENERIC';
|
|
86
|
+
step.content = raw[key];
|
|
87
|
+
const render = raw.generic?.result?.stepRenderInfo, name = raw.metadata?.toolCall?.name;
|
|
88
|
+
const action = { edit_file: '编辑文件', write_to_file: '写入文件', write_file: '写入文件', create_file: '创建文件', read_file: '读取文件', view_file: '读取文件', run_command: '执行命令', run_terminal_command: '执行命令', list_dir: '浏览目录', find_by_name: '查找文件', grep_search: '搜索文件', search_web: '搜索网页' }[name] ?? { edit: '编辑文件', read: '读取文件', write: '写入文件' }[render?.icon] ?? { runCommand: '执行命令', viewFile: '读取文件', codeAction: '执行文件操作', generic: '执行操作' }[key];
|
|
89
|
+
const files = [];
|
|
90
|
+
for (const part of Array.isArray(render?.titleParts) ? render.titleParts : []) {
|
|
91
|
+
const uri = part?.file?.uri ?? part?.fileDiff?.uri;
|
|
92
|
+
if (typeof uri === 'string')
|
|
93
|
+
try {
|
|
94
|
+
const file = basename(decodeURIComponent(new URL(uri).pathname));
|
|
95
|
+
if (file && !files.includes(file))
|
|
96
|
+
files.push(file);
|
|
97
|
+
}
|
|
98
|
+
catch { /* Optional display metadata must not break conversation reading. */ }
|
|
99
|
+
}
|
|
100
|
+
const status = stepStatus(step.status), summary = raw.metadata?.toolSummary ?? raw.metadata?.toolAction;
|
|
101
|
+
step.label = status === 'waiting' ? (name === 'ask_question' ? '等待回答…' : '等待批准…') : typeof summary === 'string' && summary.trim() ? summary.slice(0, 200) : (status === 'running' || status === 'pending' ? '正在' : status === 'completed' ? '已' : status === 'failed' ? '失败 · ' : '已停止 · ') + action + (files.length ? ' ' + files.join('、') : '');
|
|
102
|
+
step.label = step.label.slice(0, 240);
|
|
103
|
+
step.execution_kind = { edit_file: 'edit', write_to_file: 'write', write_file: 'write', create_file: 'write', read_file: 'read', view_file: 'read', run_command: 'command', run_terminal_command: 'command', ask_question: 'question' }[name] ?? { runCommand: 'command', viewFile: 'read', codeAction: 'edit', generic: 'tool' }[key];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
records.push({ step, turnId });
|
|
107
|
+
}
|
|
108
|
+
return records;
|
|
109
|
+
}
|
|
57
110
|
export function conversationPage(records, nextCursor, fullReplies = new Map()) {
|
|
58
111
|
const turns = new Map();
|
|
59
112
|
for (const { step, turnId } of records)
|
|
@@ -63,8 +116,10 @@ export function conversationPage(records, nextCursor, fullReplies = new Map()) {
|
|
|
63
116
|
const text = fullReplies.get(id) ?? part.text;
|
|
64
117
|
const truncated = (!reply && text.length > 1000) || (fields.includes(part.part === 'tools' ? 'tool_calls' : part.part) && !fullReplies.has(id));
|
|
65
118
|
const item = { id, type: part.role === 'user' ? 'userMessage' : reply ? 'agentMessage' : 'commandExecution', role: part.role, text: reply ? text : text.slice(0, 1000), totalChars: text.length, truncated };
|
|
119
|
+
item.readId = id + ':' + fingerprint(step);
|
|
120
|
+
item.groupKey = turnId + ':' + step.step_index;
|
|
66
121
|
if (part.role === 'tool')
|
|
67
|
-
item.execution = { kind: part.part === 'thinking' ? 'reasoning' : 'tool', label: part.label, status: step.status
|
|
122
|
+
item.execution = { kind: part.part === 'thinking' ? 'reasoning' : step.execution_kind ?? 'tool', label: part.label, status: stepStatus(step.status) };
|
|
68
123
|
if (!turns.has(turnId))
|
|
69
124
|
turns.set(turnId, { id: turnId, status: 'recorded', items: [] });
|
|
70
125
|
turns.get(turnId).items.push(item);
|