dsh-lan-peers 0.0.0-stage → 0.3.1

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/DESIGN.md ADDED
@@ -0,0 +1,150 @@
1
+ # dsh-lan-peers — 局域网 DSH 协同插件 · 设计说明 v0.1
2
+
3
+ > 两个(或多个)DSH 实例在同一局域网内,完成**一次性配对**后即可互相派发任务并取回结果。
4
+ > 本文是双方实现共同遵守的契约;MVP 只要求两件事:**互认** + **互问**。
5
+
6
+ ## 1. 范围(MVP)
7
+
8
+ 必须做到:
9
+ 1. 任一实例可发现/登记对端(先做显式登记 + `/v1/health` 探活,不做 mDNS)。
10
+ 2. 通过一次性配对码建立共享密钥,双方各自落盘。
11
+ 3. 每次请求双向 HMAC 签名校验(防伪造、防重放、防篡改)。
12
+ 4. `lan_peer_ask`:把一段 prompt 发给对端 agent,等它跑完,取回最终回复文本。
13
+ 5. 两个工具(模型可见):`lan_peers`、`lan_peer_ask`。
14
+
15
+ 明确不做(后续版本):mDNS 自动发现、多跳路由、共享任务板、权限细粒度代理、请求流式输出。
16
+
17
+ ## 2. 术语
18
+
19
+ - **实例**:一个正在运行的 DSH(instanceId = uuid v4,首次启动生成并持久化)。
20
+ - **对端**:已配对成功的另一实例,含 `peerId / name / baseUrl / secret`。
21
+ - **配对码**:6 位数字 + 10 分钟有效期,一次性。
22
+
23
+ ## 3. 身份与配对
24
+
25
+ 每个实例首次启动生成:
26
+ - `instanceId`(uuid)
27
+ - `selfSecret`(32 字节随机,base64url)——用于自己作为服务端时校验对方?**不需要**;自密钥仅用于记录本机身份。
28
+ - `pairingCode`(按需生成,10 分钟 TTL,用后即焚)
29
+
30
+ 配对流程(A 主动配对 B):
31
+ 1. 在 A 上执行 `lan-peers pair --url http://B:7788 --code 123456 --name B的显示名`。
32
+ 2. A → B:`POST /v1/pair`,body `{code, peerId: A.instanceId, peerName: A.name, secret: <A 生成给 B 用的密钥 S>}`。
33
+ 3. B 校验 `code`(存在、未过期、未使用),把 `{peerId: A, name, secret: S}` 写入 `peers.json`,返回 `{peerId: B.instanceId, peerName: B.name, secret: <B 生成给 A 用的密钥 T>}`。
34
+ 4. A 保存 `{peerId: B, name, baseUrl, secret: T}`。
35
+ 5. 之后:A→B 的请求用 `T` 签名;B→A 的请求用 `S` 签名。两把密钥不同,天然双向。
36
+
37
+ 配对码生成:`lan-peers code` → 打印 123456(同时写入状态文件)。
38
+
39
+ ## 4. 请求签名
40
+
41
+ 所有 `/v1/*`(除 `/v1/health`)都必须带:
42
+
43
+ ```
44
+ X-Peer-Id: <发送方 instanceId>
45
+ X-Peer-Ts: <毫秒时间戳>
46
+ X-Peer-Nonce: <16 字节随机 hex,单次使用>
47
+ X-Peer-Sign: base64url(HMAC-SHA256(secret, canonical))
48
+ ```
49
+
50
+ `canonical` = 五行,`\n` 连接、无尾随换行:
51
+
52
+ ```
53
+ <METHOD 大写>
54
+ <path,含 query,不含 host>
55
+ <X-Peer-Ts>
56
+ <X-Peer-Nonce>
57
+ <sha256(body) 的 hex;GET 用 sha256("")>
58
+ ```
59
+
60
+ 校验方:
61
+ 1. `|now - ts| <= 60_000`,否则 401 `clock-skew`。
62
+ 2. nonce 在 120s 窗口内未见过(内存 Set + 定期清理),否则 401 `replay`。
63
+ 3. 用对应 `secret` 重算签名,`timingSafeEqual` 比对,否则 401 `bad-signature`。
64
+ 4. 失败一律返回 `401 {"error": "..."}`,不泄露细节。
65
+
66
+ ## 5. 端点
67
+
68
+ | 方法 | 路径 | 认证 | 说明 |
69
+ |---|---|---|---|
70
+ | GET | `/v1/health` | 否 | `{name, instanceId, version, capability: ["ask","pair"], peers: <已配对数量>}` |
71
+ | POST | `/v1/pair` | 配对码 | 见 §3 |
72
+ | POST | `/v1/ask` | 是 | body `{requestId, prompt, timeoutSec?}` → `202 {sessionId}` |
73
+ | GET | `/v1/answer?sessionId=<id>` | 是 | `{status: "running"\|"done"\|"error", reply?, error?}` |
74
+ | GET | `/v1/peers` | 是 | `{peers:[{peerId,name,baseUrl}]}`(对端视角) |
75
+
76
+ `/v1/ask` 语义:
77
+ - 接收方用**专用工作区** `lanPeers.workspace`(默认 `~/.dsh/lan-peers/workspace`)建会话;
78
+ - 会话预设 `lanPeers.agentPreset`(默认 `standard`),权限预设 `lanPeers.permission`(默认 `read-only`);
79
+ - `allowRemotePrompts`(默认 **false**)为 false 时直接 403 `disabled`;
80
+ - 同一 `requestId` 幂等:重复提交返回同一 sessionId;
81
+ - 超时 `timeoutSec`(默认 300,上限 900)后 `answer` 返回 `error: timeout`。
82
+
83
+ `/v1/answer` 读取:会话 `turnOutline` 最后一轮的 `response` → `done`;`running` 为 true → `running`。
84
+
85
+ ## 6. 模型可见工具
86
+
87
+ - `lan_peers`:无参数,返回 `[{name, peerId, online, lastSeen}]`(oneline 探活,2s 超时)。
88
+ - `lan_peer_ask`:参数 `{peer: string, prompt: string, timeoutSec?: number}`,返回 `{peer, status, reply}`;超时返回 `status: "timeout"`,不抛异常中断回合。
89
+
90
+ 工具描述要写明:跨机调用会消耗对端额度,且对方可能拒绝。
91
+
92
+ ## 7. 配置(cordis config)
93
+
94
+ ```yaml
95
+ - id: lan-peers
96
+ name: dsh-lan-peers
97
+ config:
98
+ name: "fn-302" # 本机显示名
99
+ listenPort: 7788 # 监听 0.0.0.0
100
+ stateDir: "~/.dsh/lan-peers"
101
+ allowRemotePrompts: false # 默认拒绝远端派单
102
+ workspace: "~/.dsh/lan-peers/workspace"
103
+ agentPreset: "standard"
104
+ permission: "read-only"
105
+ trustedCidrs: ["192.168.5.0/24"] # 监听层白名单
106
+ ```
107
+
108
+ ## 8. 存储
109
+
110
+ ```
111
+ ~/.dsh/lan-peers/
112
+ state.json 0600 {instanceId, name, createdAt, pairingCode?: {code, expiresAt}}
113
+ peers.json 0600 {peers: {<peerId>: {name, baseUrl, secret, addedAt, lastSeen}}}
114
+ workspace/ 远端任务会话的工作目录
115
+ ```
116
+
117
+ ## 9. 安全默认
118
+
119
+ - 默认 `allowRemotePrompts: false`,装完不会自动被别人驱动。
120
+ - 远端会话默认 `read-only` 权限 + 专用 workspace,避免对端在你机器上写文件。
121
+ - 只监听局域网网段(`trustedCidrs`),公网 IP 直接拒。
122
+ - 密钥只落 0600 文件,日志不得打印 secret / 签名原文。
123
+
124
+ ## 10. 分工
125
+
126
+ | 半边 | 负责 | 产出 |
127
+ |---|---|---|
128
+ | **host half**(NAS 端 agent) | 签名校验、配对、`/v1/ask` 建会话、`/v1/answer` 取回复 | `host/` 目录 + 单测 |
129
+ | **client half**(本机) | 签名发送、`lan_peers` / `lan_peer_ask` 工具、`pair` / `code` CLI | `client/` 目录 + 单测 |
130
+
131
+ 两侧共用同一份 `protocol.md`(签名与端点契约),代码不共享。
132
+
133
+ ## 11. 验收用例
134
+
135
+ 1. `GET /v1/health` 无认证 200,`peers` 计数正确。
136
+ 2. 错误签名 → 401 `bad-signature`;重放同一 nonce → 401 `replay`;时间偏移 5 分钟 → 401 `clock-skew`。
137
+ 3. `allowRemotePrompts=false` 时 `POST /v1/ask` → 403 `disabled`。
138
+ 4. 打开开关后 `ask` → 202,轮询 `answer` 直到 `done`,`reply` 非空。
139
+ 5. `lan_peer_ask` 工具超时返回 `status: "timeout"`,不抛异常。
140
+ 6. 两个实例互相 `pair` 后,各自 `peers.json` 都有对方,且密钥不同。
141
+
142
+ ## 4.1 报文加密(v1)
143
+
144
+ - 头:X-Peer-Enc: v1
145
+ - 密钥:HKDF-SHA256(secret, salt='dsh-lan-peers/v1', info='dsh-lan-peers/body-encryption/v1') → 32B
146
+ - 算法:AES-256-GCM,随机 12B nonce,报文 = base64url(nonce ‖ ciphertext ‖ tag)
147
+ - 签名对象是明文 JSON:接收方先解密、再验签
148
+ - 响应:{enc:'v1', data:'<base64url>'},同一方向密钥加密
149
+ - requireEncryption 默认 true:缺 X-Peer-Enc 的认证请求 → 400 encryption-required
150
+ - 未加密:/v1/health(探活)与 /v1/pair(尚无共享密钥,只应在可信局域网配对)
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-lan-peers contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,155 @@
1
- # Temporary Holding Version
1
+ # 局域网 DSH 协同插件 · dsh-lan-peers
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 让同一局域网内的多个 **DeepSeek Harness (DSH)** 实例互认并协同:一次性配对码建立双向密钥,之后一个 agent 可以直接把任务派给另一个 agent,并取回它的回复。
4
+
5
+ ```
6
+ ┌──────────────┐ 签名 + AES-256-GCM 加密 ┌──────────────┐
7
+ │ DSH (A) │ ───────────────────────────▶ │ DSH (B) │
8
+ │ lan_peer_ask │ POST /v1/ask │ :7788 │
9
+ │ │ ◀─────────────────────────── │ peer server │
10
+ └──────────────┘ GET /v1/answer (加密) └──────────────┘
11
+ ```
12
+
13
+ ## 能力
14
+
15
+ | 面 | 内容 |
16
+ |---|---|
17
+ | 模型工具 | `lan_peers`(列出伙伴 + 在线探测)、`lan_peer_ask`(给伙伴派单并取回结果) |
18
+ | 界面 | Web / 桌面窗口左下角的 **🔗 伙伴** 面板:改设备名、生成临时配对码、添加伙伴 |
19
+ | 认证 | 一次性 6 位配对码(10 分钟、单次),每对伙伴两把独立密钥 |
20
+ | 传输 | HMAC-SHA256 请求签名(±60s 时钟窗 + nonce 防重放),请求与响应体积用 **AES-256-GCM** 加密 |
21
+ | 隔离 | 默认拒绝远端派单;可配 CIDR 白名单;专用工作目录 |
22
+
23
+ ## 环境要求
24
+
25
+ - DSH `>= 0.2.0-rc.2`(已在 0.2.1-rc.2 的 Node 版与桌面版上验证)
26
+ - Node.js 22 / 24(插件本身无第三方依赖,只用 Node 内置模块)
27
+
28
+ ## 安装
29
+
30
+ ### 1. 装包
31
+
32
+ ```bash
33
+ # 从 tarball(releases 产出的 dsh-lan-peers-x.y.z.tgz)
34
+ dsh plugin --profile web add /path/to/dsh-lan-peers-0.3.0.tgz
35
+
36
+ # 或本地目录(开发时)
37
+ dsh plugin --profile web add link:/path/to/dsh-lan-peers
38
+
39
+ # 或发布 registry 之后
40
+ dsh plugin --profile web add dsh-lan-peers
41
+ ```
42
+
43
+ 这一步只把包放进 profile 的 `node_modules` 与 `package.json`。
44
+
45
+ ### 2. 必须把插件登记为 profile bundle
46
+
47
+ **最容易踩的坑**:只写 `cordis.patch.yml` 的插件行,桌面版不会加载(网页版有时会,行为不一致)。把包名加进 profile 的 `dsh.profile.bundles`:
48
+
49
+ ```jsonc
50
+ // ~/.dsh/profiles/web/package.json
51
+ {
52
+ "dsh": {
53
+ "profile": {
54
+ "bundles": [
55
+ "@deepseek-ai/dsh-base",
56
+ "@deepseek-ai/dsh-web-app",
57
+ // …
58
+ "dsh-lan-peers" // ← 加这一行
59
+ ]
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ ### 3. 配置
66
+
67
+ 包自带的 `cordis.patch.yml` 会插入一行默认配置(端口 7788、拒绝远端派单)。要改就在 profile 的 `cordis.patch.yml` 里按 id 覆盖:
68
+
69
+ ```yaml
70
+ - id: lan-peers
71
+ name: dsh-lan-peers
72
+ config:
73
+ name: my-nas # 显示给伙伴的名字
74
+ listenPort: 7788 # 监听 0.0.0.0:<port>
75
+ stateDir: ~/.dsh/lan-peers # 身份与伙伴表位置
76
+ workspace: ~/.dsh/lan-peers/workspace # 远端派单会话的工作目录
77
+ allowRemotePrompts: false # 默认关;打开后才接受别人的派单
78
+ requireEncryption: true # 默认开;旧版本对端会被拒绝
79
+ trustedCidrs: ["192.168.5.0/24"] # 可选,限制来源网段
80
+ agentPreset: standard
81
+ ```
82
+
83
+ 各平台的参考覆盖片段在 `overlays/`:
84
+
85
+ - `overlays/web.patch.yml` —— 网页版(浏览器访问)
86
+ - `overlays/desktop.patch.yml` —— 桌面版(Electron)
87
+
88
+ ### 4. 重启 DSH
89
+
90
+ ```bash
91
+ # 网页版:重启 dsh web
92
+ # 桌面版:退出并重新打开应用
93
+ ```
94
+
95
+ 重启后 `http://<本机IP>:7788/v1/health` 应返回 `{"name":"…","capability":["ask","pair"]}`。
96
+
97
+ > 桌面版 profile 由 Electron 独占管理,`dsh plugin --profile desktop` 会被拒绝。请按 `overlays/desktop.patch.yml` 手工编辑 `~/.dsh/profiles/desktop/{package.json,cordis.patch.yml}`,然后重启应用。
98
+
99
+ ## 配对
100
+
101
+ **界面**:左下角 🔗 伙伴 → 「生成配对码」,把 6 位码给对方;对方在「添加伙伴」里填本机地址(如 `192.168.5.51:7788`)+ 码。两个方向各配一次,双方都能主动派单。
102
+
103
+ **命令行**:
104
+
105
+ ```bash
106
+ npx dsh-lan-peers code --state-dir ~/.dsh/lan-peers
107
+ npx dsh-lan-peers pair --url http://192.168.5.51:7788 --code 123456 --name my-nas
108
+ npx dsh-lan-peers peers --probe
109
+ ```
110
+
111
+ ## 使用
112
+
113
+ 模型侧(会话里直接说):
114
+
115
+ > 用 lan_peer_ask 问 fn-302:`/vol5/main` 下 `pwd` 输出什么?
116
+
117
+ 命令行:
118
+
119
+ ```bash
120
+ npx dsh-lan-peers ask --peer fn-302 "在 /vol5/main 执行 pwd" --timeout 120
121
+ ```
122
+
123
+ 对端会在自己的 `workspace` 里建真实会话、由对端模型执行,然后把最终回复加密回传。
124
+
125
+ ## 安全模型
126
+
127
+ - 配对码 6 位、10 分钟、一次性;每个伙伴两条独立密钥(我签给你的 / 你签给我的)。
128
+ - 每条认证请求验签:五行 canonical、±60s 时钟窗、nonce 防重放、定时安全比较。
129
+ - **报文加密**:`/v1/ask`、`/v1/answer`、`/v1/peers` 的请求体与响应体用 AES-256-GCM 加密,密钥由该方向 HMAC 密钥经 HKDF 派生;缺 `X-Peer-Enc` 的请求返回 `400 encryption-required`。
130
+ - `/v1/health` 公开;`/v1/pair` 因尚无共享密钥仍是明文——**只应在可信局域网内配对**。
131
+ - 管理接口(改名/发码/配对)只在 `/api/lan-peers/*` 且要求 Host 为回环地址,局域网访问 403。
132
+ - `allowRemotePrompts` 默认 `false`:装完不会自动被别人驱动。
133
+
134
+ ## 故障排查
135
+
136
+ | 现象 | 原因 / 处理 |
137
+ |---|---|
138
+ | 装完没有 7788 监听、日志无输出 | 插件没进 `dsh.profile.bundles`(安装第 2 步) |
139
+ | 启动日志 `is incompatible with dsh …` | 插件 `engines.dsh` 高于本机运行时;升级 DSH 或改 `engines` |
140
+ | `EADDRINUSE ... :7788` | 同机跑了两个 DSH 实例;给其中一个改 `listenPort` |
141
+ | 面板看不到 | 注入只对 HTTP 页面生效;桌面窗口是 `dsh-app://`,走 `lib/client.js` 客户端插件,需重启应用 |
142
+ | `400 encryption-required` | 对端是加密前的旧版本,两端都升级 |
143
+ | `401 bad-signature` / `clock-skew` / `replay` | 两端时钟偏差超过 60s、密钥不一致,或 nonce 重放 |
144
+
145
+ ## 开发
146
+
147
+ ```bash
148
+ node --test host/test/auth.test.mjs host/test/api.test.mjs # 协议/端点
149
+ node --test lib/wire-crypto.test.mjs # 加密
150
+ node host/server.mjs --port 7799 --state-dir /tmp/lp # 独立 host(内存 runner)
151
+ ```
152
+
153
+ ## 许可
154
+
155
+ MIT
package/client/cli.mjs ADDED
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env node
2
+ // dsh-lan-peers — client half CLI.
3
+ // node cli.mjs code [--host http://127.0.0.1:7788]
4
+ // node cli.mjs pair --url http://PEER:7788 --code 123456 [--name 本机名] [--peer-name 对端名]
5
+ // node cli.mjs peers [--probe]
6
+ // node cli.mjs ask --peer <peerId|name> "prompt" [--timeout 300]
7
+ import crypto from 'node:crypto';
8
+ import { execFileSync } from 'node:child_process';
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { newSecret } from './protocol.mjs';
14
+ import { callPeer } from './peer-call.mjs';
15
+
16
+ const DEFAULT_STATE_DIR = path.join(os.homedir(), '.dsh', 'lan-peers');
17
+
18
+ function parseArgs(argv) {
19
+ const out = { _: [] };
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const arg = argv[i];
22
+ if (arg.startsWith('--')) {
23
+ const key = arg.slice(2);
24
+ const next = argv[i + 1];
25
+ if (next === undefined || next.startsWith('--')) out[key] = true;
26
+ else {
27
+ out[key] = next;
28
+ i++;
29
+ }
30
+ } else out._.push(arg);
31
+ }
32
+ return out;
33
+ }
34
+
35
+ const stateDir = (args) => path.resolve(args['state-dir'] || DEFAULT_STATE_DIR);
36
+ const readJson = (file, fallback) => {
37
+ try {
38
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
39
+ } catch {
40
+ return fallback;
41
+ }
42
+ };
43
+ const writeJson = (file, value) => {
44
+ fs.mkdirSync(path.dirname(file), { recursive: true });
45
+ const tmp = `${file}.${process.pid}.tmp`;
46
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2), { mode: 0o600 });
47
+ fs.renameSync(tmp, file);
48
+ };
49
+
50
+ function identity(dir) {
51
+ const file = path.join(dir, 'state.json');
52
+ const state = readJson(file, {});
53
+ if (!state.instanceId) {
54
+ state.instanceId = crypto.randomUUID();
55
+ state.name = state.name || os.hostname();
56
+ state.createdAt = new Date().toISOString();
57
+ writeJson(file, state);
58
+ }
59
+ return state;
60
+ }
61
+
62
+ async function signedCall({ baseUrl, secret, senderId, method = 'GET', apiPath, body }) {
63
+ return callPeer({ baseUrl, secret, senderId, method, apiPath, body, timeoutMs: 60_000 });
64
+ }
65
+
66
+ async function cmdCode(args) {
67
+ // The code lives in state.json, which the host process re-reads; issuing it via
68
+ // the host CLI keeps one writer instead of racing the running server.
69
+ const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
70
+ const out = execFileSync(process.execPath, [
71
+ path.join(pkgRoot, 'host', 'server.mjs'),
72
+ '--code',
73
+ '--state-dir',
74
+ stateDir(args),
75
+ ], { encoding: 'utf8' });
76
+ const match = /(\d{6})/.exec(out);
77
+ if (!match) throw new Error(out.trim() || 'no pairing code');
78
+ console.log(`配对码: ${match[1]} 有效期: 10 分钟,一次性`);
79
+ console.log(`对方把它填进「添加伙伴」里即可配对(地址填本机 http://<ip>:7788)`);
80
+ }
81
+
82
+ async function cmdPair(args) {
83
+ const dir = stateDir(args);
84
+ const me = identity(dir);
85
+ const url = String(args.url || '').replace(/\/$/, '');
86
+ const code = String(args.code || '');
87
+ const name = String(args.name || me.name || os.hostname());
88
+ if (!url || !code) throw new Error('用法: pair --url http://PEER:7788 --code 123456 [--name 本机名]');
89
+
90
+ const secret = newSecret();
91
+ const res = await fetch(`${url}/v1/pair`, {
92
+ method: 'POST',
93
+ headers: { 'Content-Type': 'application/json' },
94
+ body: JSON.stringify({ code, peerId: me.instanceId, peerName: name, secret }),
95
+ });
96
+ const body = await res.json();
97
+ if (!res.ok) throw new Error(`配对失败: ${res.status} ${body.error || ''}`.trim());
98
+
99
+ const peersFile = path.join(dir, 'peers.json');
100
+ const store = readJson(peersFile, { peers: {} });
101
+ const previous = store.peers[body.peerId] || {};
102
+ // Two secrets per peer: `secret` is what the peer issued to us (we sign with
103
+ // it), `inboundSecret` is what we issued to the peer (we verify with it).
104
+ // Keeping both is what makes the pairing mutual in both directions.
105
+ store.peers[body.peerId] = {
106
+ ...previous,
107
+ name: args['peer-name'] || body.peerName || body.peerId,
108
+ baseUrl: url,
109
+ secret: body.secret,
110
+ inboundSecret: secret,
111
+ addedAt: previous.addedAt || new Date().toISOString(),
112
+ };
113
+ writeJson(peersFile, store);
114
+ console.log(`已配对: ${store.peers[body.peerId].name} (${body.peerId})`);
115
+ }
116
+
117
+ async function cmdPeers(args) {
118
+ const dir = stateDir(args);
119
+ const store = readJson(path.join(dir, 'peers.json'), { peers: {} });
120
+ const entries = Object.entries(store.peers || {});
121
+ if (entries.length === 0) {
122
+ console.log('(尚未配对任何对端)');
123
+ return;
124
+ }
125
+ for (const [peerId, peer] of entries) {
126
+ let status = 'unknown';
127
+ if (args.probe) {
128
+ try {
129
+ const res = await fetch(`${peer.baseUrl}/v1/health`, { signal: AbortSignal.timeout(2000) });
130
+ status = res.ok ? 'online' : `http ${res.status}`;
131
+ } catch {
132
+ status = 'offline';
133
+ }
134
+ }
135
+ console.log(`${peer.name}\t${peer.baseUrl}\t${peerId}\t${status}`);
136
+ }
137
+ }
138
+
139
+ async function cmdAsk(args) {
140
+ const dir = stateDir(args);
141
+ const me = identity(dir);
142
+ const prompt = args._.join(' ').trim();
143
+ if (!args.peer || !prompt) throw new Error('用法: ask --peer <peerId|name> "prompt"');
144
+ const store = readJson(path.join(dir, 'peers.json'), { peers: {} });
145
+ const match = Object.entries(store.peers || {}).find(
146
+ ([peerId, peer]) => peerId === args.peer || peer.name === args.peer,
147
+ );
148
+ if (!match) throw new Error(`未知对端: ${args.peer}`);
149
+ const [peerId, peer] = match;
150
+
151
+ const requestId = `req-${crypto.randomUUID()}`;
152
+ const accepted = await signedCall({
153
+ baseUrl: peer.baseUrl,
154
+ secret: peer.secret,
155
+ senderId: me.instanceId,
156
+ method: 'POST',
157
+ apiPath: '/v1/ask',
158
+ body: { requestId, prompt, timeoutSec: Number(args.timeout || 300) },
159
+ });
160
+ const sessionId = accepted.sessionId;
161
+ process.stderr.write(`已派单 → ${peer.name} (session ${sessionId})\n`);
162
+
163
+ const deadline = Date.now() + Number(args.timeout || 300) * 1000;
164
+ const started = Date.now();
165
+ while (Date.now() < deadline) {
166
+ await new Promise((r) => setTimeout(r, 1500));
167
+ const answer = await signedCall({
168
+ baseUrl: peer.baseUrl,
169
+ secret: peer.secret,
170
+ senderId: me.instanceId,
171
+ method: 'GET',
172
+ apiPath: `/v1/answer?sessionId=${encodeURIComponent(sessionId)}`,
173
+ });
174
+ process.stderr.write('.');
175
+ if (answer.status === 'done') {
176
+ process.stderr.write('\n');
177
+ console.log(answer.reply);
178
+ return;
179
+ }
180
+ if (answer.status === 'error') {
181
+ process.stderr.write('\n');
182
+ throw new Error(`对端返回错误: ${answer.error}`);
183
+ }
184
+ }
185
+ console.log(`timeout after ${Math.round((Date.now() - started) / 1000)}s`);
186
+ process.exitCode = 2;
187
+ }
188
+
189
+ const commands = { code: cmdCode, pair: cmdPair, peers: cmdPeers, ask: cmdAsk };
190
+
191
+ const args = parseArgs(process.argv.slice(2));
192
+ const command = commands[args._[0]];
193
+ if (!command) {
194
+ console.error('用法: cli.mjs <code|pair|peers|ask> [options]');
195
+ process.exit(1);
196
+ }
197
+ args._ = args._.slice(1);
198
+ command(args).catch((error) => {
199
+ console.error(String(error.message || error));
200
+ process.exit(1);
201
+ });
@@ -0,0 +1,50 @@
1
+ // dsh-lan-peers · one signed + encrypted peer call.
2
+ //
3
+ // The signature covers the *plaintext* JSON body (so the host can verify after
4
+ // decrypting), while the wire carries only AES-256-GCM ciphertext. Replay and
5
+ // clock checks still ride on the ts/nonce inside the canonical string.
6
+ import { ENC_VERSION, decryptJson, encryptBytes } from '../lib/wire-crypto.mjs';
7
+ import { canonical, newNonce, sign } from './protocol.mjs';
8
+
9
+ export async function callPeer({
10
+ baseUrl,
11
+ secret,
12
+ senderId,
13
+ method = 'GET',
14
+ apiPath,
15
+ body,
16
+ timeoutMs = 30_000,
17
+ }) {
18
+ const plain = body === undefined ? Buffer.alloc(0) : Buffer.from(JSON.stringify(body));
19
+ const ts = Date.now();
20
+ const nonce = newNonce();
21
+ const signature = sign(secret, canonical(method, apiPath, ts, nonce, plain));
22
+ const wire = plain.length ? Buffer.from(encryptBytes(secret, plain), 'utf8') : Buffer.alloc(0);
23
+
24
+ const res = await fetch(new URL(apiPath, baseUrl).toString(), {
25
+ method,
26
+ headers: {
27
+ 'Content-Type': plain.length ? 'application/octet-stream' : 'application/json',
28
+ 'X-Peer-Id': senderId,
29
+ 'X-Peer-Ts': String(ts),
30
+ 'X-Peer-Nonce': nonce,
31
+ 'X-Peer-Sign': signature,
32
+ 'X-Peer-Enc': ENC_VERSION,
33
+ },
34
+ body: wire.length ? wire : undefined,
35
+ signal: AbortSignal.timeout(timeoutMs),
36
+ });
37
+
38
+ const text = await res.text();
39
+ let json;
40
+ try {
41
+ json = JSON.parse(text);
42
+ } catch {
43
+ throw new Error(`HTTP ${res.status}: ${text.slice(0, 120)}`);
44
+ }
45
+ if (json && json.enc === ENC_VERSION && typeof json.data === 'string') {
46
+ json = decryptJson(secret, json.data);
47
+ }
48
+ if (!res.ok) throw new Error(`${res.status} ${json?.error || ''}`.trim());
49
+ return json;
50
+ }
@@ -0,0 +1,56 @@
1
+ // dsh-lan-peers — wire protocol helpers (client half).
2
+ // The contract lives in DESIGN.md §4: a five-line canonical string signed with
3
+ // HMAC-SHA256 over a per-peer secret, base64url encoded.
4
+ import crypto from 'node:crypto';
5
+
6
+ export const MAX_SKEW_MS = 60_000;
7
+ export const NONCE_TTL_MS = 120_000;
8
+
9
+ export const sha256Hex = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
10
+
11
+ /** Canonical string: METHOD \n PATH \n TS \n NONCE \n sha256hex(body). */
12
+ export function canonical(method, path, ts, nonce, body) {
13
+ return [method.toUpperCase(), path, String(ts), nonce, sha256Hex(body ?? Buffer.alloc(0))].join('\n');
14
+ }
15
+
16
+ /** HMAC-SHA256(secret, canonical) as base64url. The host half keys with the
17
+ * secret's UTF-8 bytes by default (DESIGN §4 + server config
18
+ * `secretKeyEncoding`), so the client must do the same. */
19
+ export function sign(secret, canonicalString) {
20
+ return crypto
21
+ .createHmac('sha256', Buffer.from(secret, 'utf8'))
22
+ .update(canonicalString, 'utf8')
23
+ .digest('base64url');
24
+ }
25
+
26
+ export function newSecret() {
27
+ return crypto.randomBytes(32).toString('base64url');
28
+ }
29
+
30
+ export function newNonce() {
31
+ return crypto.randomBytes(16).toString('hex');
32
+ }
33
+
34
+ /** Constant-time comparison of two base64url signatures. */
35
+ export function sameSignature(a, b) {
36
+ const left = Buffer.from(a, 'utf8');
37
+ const right = Buffer.from(b, 'utf8');
38
+ return left.length === right.length && crypto.timingSafeEqual(left, right);
39
+ }
40
+
41
+ /**
42
+ * Verify an inbound request the way the host half must. Returns null when the
43
+ * request is valid, otherwise a short reason code.
44
+ */
45
+ export function verifyRequest({ method, path, ts, nonce, body, signature, secret, seenNonces = new Map(), now = Date.now() }) {
46
+ const stamp = Number(ts);
47
+ if (!Number.isFinite(stamp) || Math.abs(now - stamp) > MAX_SKEW_MS) return 'clock-skew';
48
+ if (typeof nonce !== 'string' || nonce.length < 8) return 'bad-nonce';
49
+ const seenAt = seenNonces.get(nonce);
50
+ if (seenAt !== undefined && now - seenAt <= NONCE_TTL_MS) return 'replay';
51
+ const expected = sign(secret, canonical(method, path, stamp, nonce, body));
52
+ if (!sameSignature(expected, signature ?? '')) return 'bad-signature';
53
+ seenNonces.set(nonce, now);
54
+ for (const [key, at] of seenNonces) if (now - at > NONCE_TTL_MS) seenNonces.delete(key);
55
+ return null;
56
+ }