@landh93/web-codex-client 0.1.0-rc.2 → 0.1.0-rc.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/NPM_RELEASE.md +7 -14
- package/README.md +8 -36
- package/dist/cli.js +162 -97
- package/package.json +2 -2
package/NPM_RELEASE.md
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
# npm 客户端发布与安装
|
|
2
2
|
|
|
3
|
-
包名:`@landh93/web-codex-client`。当前候选版为 `0.1.0-rc.
|
|
3
|
+
包名:`@landh93/web-codex-client`。当前候选版为 `0.1.0-rc.4`,使用 `next` 标签。仅支持 Linux / Node.js 24;这是独立安装候选版,不代表公网、多人或真实模型执行已完成生产验收。`remote-wss-v1` 明确允许远端 WSS,继续使用固定 Noise 加密、TLS 校验及 Token 自动认证。项目尚未选定开源许可证,包暂保留 `UNLICENSED`;第三方代码许可见包内 `sdk/THIRD_PARTY_NOTICES.md`。
|
|
4
4
|
|
|
5
5
|
## 用户安装
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
npm install -g @landh93/web-codex-client@next
|
|
9
9
|
web-codex-client configure
|
|
10
|
-
web-codex-client doctor
|
|
11
10
|
web-codex-client run
|
|
12
11
|
```
|
|
13
12
|
|
|
14
|
-
无需安装 pnpm、Rust 或构建工具。Codex 及其私有 Unix socket 必须在这台机器可用;本包不会安装或重启 Codex
|
|
13
|
+
无需安装 pnpm、Rust 或构建工具。Codex 及其私有 Unix socket 必须在这台机器可用;本包不会安装或重启 Codex。交互配置只输入服务端 HTTPS/WSS 地址和连接 Token(隐藏输入)。Codex socket 自动发现;需要自定义时使用 `--socket`。网页输入同一个连接 Token,**不是 npm 发布 token**。
|
|
15
14
|
|
|
16
15
|
配置默认位于 `${XDG_CONFIG_HOME:-$HOME/.config}/web-codex-client/config.json`,可用 `--config FILE` 或 `WEB_CODEX_CLIENT_CONFIG` 指定。状态目录和 token 文件分别为 0700/0600。重新配置保留身份和授权;运行中的 Client 需先停止再配置。升级包不改动用户配置;配置不会写入 npm 安装目录。
|
|
17
16
|
|
|
@@ -23,19 +22,13 @@ web-codex-client configure --relay wss://relay.example.com --token-stdin < /path
|
|
|
23
22
|
|
|
24
23
|
`doctor` 检查配置及 token 权限、WASM 完整性、WSS 设备认证和 Codex 初始化,不读取历史或调用模型。已有本地 Client 在线时复用其状态,不抢占连接;没有运行时创建一次短暂的设备认证连接,**同一设备 token 不要在其他机器同时使用**。报告默认是配置目录中的 `doctor-report.json`,不含 token、私钥、历史或上游原始报错。支持 `--quiet`、`--report PRIVATE_PATH`。失败阶段落盘后退出非零。
|
|
25
24
|
|
|
26
|
-
使用公开 CA 证书时通常无需额外设置;私有 CA 可在启动 Node 前设置 `NODE_EXTRA_CA_CERTS=/path/to/ca.pem`。禁止 `NODE_TLS_REJECT_UNAUTHORIZED=0`。Nginx 要正确代理 `/ws/v1/client`,
|
|
25
|
+
使用公开 CA 证书时通常无需额外设置;私有 CA 可在启动 Node 前设置 `NODE_EXTRA_CA_CERTS=/path/to/ca.pem`。禁止 `NODE_TLS_REJECT_UNAUTHORIZED=0`。Nginx 要正确代理 `/ws/v1/client`,Token 的 SHA-256 派生凭据经 Authorization 头发送,原始 Token 不发送给中继。
|
|
27
26
|
|
|
28
|
-
Client
|
|
27
|
+
Client 运行后,在网页输入同一个 Token 即可:自动选择设备、建立端到端加密连接、加载项目与会话。打开会话即可输入消息;写租约自动取得和续期,只有另一个浏览器占用时才需要处理冲突。无需 `pair-open`、`pair`、指纹核对或“申请控制权”。网页刷新后在同一标签页自动重连;Token 只保存在该标签页的 sessionStorage,退出登录清除。
|
|
29
28
|
|
|
30
|
-
|
|
29
|
+
默认授权本机 Codex 项目目录与活动/归档会话元数据识别出的已有项目读写,保留目录边界和 Codex 执行审批。授权在每次连接时重新读取,不持久保存临时浏览器身份。不同 token 对应不同设备,不能凭一个设备 Token 查看其他设备。
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
自动授权是配对时的目录快照。配对后新建的 Codex 项目可使用上述 `authorize` 命令加入,无需重新配对。升级不会擅自提升已有只读授权。
|
|
35
|
-
|
|
36
|
-
旧的 `pair-approve --fingerprint FULL_HASH`、`pair-list`、`--state` 用法仍有效。`pair-approve` 不传 `--root` 时采用已有项目;指定已有项目且不传权限参数时自动读写;`--read-only` 明确保持只读;未知目录可通过 `pair --root` 交互确认,或通过显式 `pair-approve --root PATH --write` 授权。重复批准同一已授权指纹返回成功,不会因为窗口已关闭而失败。
|
|
37
|
-
|
|
38
|
-
配对等待最多 5 分钟,期间只能核对身份,不能读取内容。`pair-list` 返回网页是否确认、剩余时间和可否批准;本机错误响应区分指纹不存在、网页未确认、路由过期、目录不可访问、授权保存失败和授权数量上限,且不包含底层异常正文。网页刷新或重新连接后必须使用新的指纹。旧 Web 的 2 分钟等待仍可能先到期;更新 Server 前端后会显示新指引并使用 5 分钟等待。协议及固定 SDK 版本保持兼容。
|
|
31
|
+
此版本需要同时更新 Server 网关、网页和 Client(SDK rc.4)。旧 Server 不支持新的 Token 登录;应先部署 Server 再升级并重启 Client。原有设备 Token 可继续使用,无需重新注册。新部署的 Server 可通过 `CONNECTION_TOKEN_FILE=/private/path/token` 直接配置同一个 Token,无需先创建管理员账号;文件权限为 0600,Token 至少 32 个无空白字符。管理员口令可登录并连接账号下所有在线设备;rc.4 通过管理员路由 v1 支持此流程。升级时保留配置和 Token,只重启 Client,无需重启 Codex。`pairingMode:"manual"` 仍是显式本机确认的兼容模式,不接受管理员自动授权。
|
|
39
32
|
|
|
40
33
|
后台常驻可使用 systemd 用户服务,将 `ExecStart` 设为实际安装的 `web-codex-client run --config /absolute/config.json`。npm 安装不会自动启用服务或打开配对窗口。
|
|
41
34
|
|
|
@@ -75,7 +68,7 @@ npm whoami --registry=https://registry.npmjs.org/ --userconfig="$HOME/.config/we
|
|
|
75
68
|
随后发布已通过验证的**具体 tgz**,不重新构建另一个包:
|
|
76
69
|
|
|
77
70
|
```bash
|
|
78
|
-
npm publish .test-output/landh93-web-codex-client-0.1.0-rc.
|
|
71
|
+
npm publish .test-output/landh93-web-codex-client-0.1.0-rc.4.tgz \
|
|
79
72
|
--access public --tag next --ignore-scripts \
|
|
80
73
|
--registry=https://registry.npmjs.org/ \
|
|
81
74
|
--userconfig="$HOME/.config/web-codex-npm/npmrc"
|
package/README.md
CHANGED
|
@@ -13,13 +13,12 @@
|
|
|
13
13
|
```bash
|
|
14
14
|
npm install -g @landh93/web-codex-client@next
|
|
15
15
|
web-codex-client configure
|
|
16
|
-
web-codex-client doctor
|
|
17
16
|
web-codex-client run
|
|
18
17
|
```
|
|
19
18
|
|
|
20
|
-
`configure`
|
|
19
|
+
`configure` 只需服务端 HTTPS/WSS 地址和连接 Token。启动后,在网页输入同一个 Token,就能查看项目、session 并发送对话;无需配对命令、指纹核对或申请控制权。Codex socket 自动发现,`doctor` 仅用于故障诊断。
|
|
21
20
|
|
|
22
|
-
|
|
21
|
+
已有 Codex 项目自动允许读写,执行审批和目录边界仍保留。配置、Token 和身份与 npm 安装目录分开存放,升级保留。Client rc.4 增加管理员连接,需要支持管理员路由 v1 的 Server;SDK 保持 rc.4。请先更新 Server,再升级并重启 Client。完整安装和发布说明见 [NPM_RELEASE.md](./NPM_RELEASE.md)。
|
|
23
22
|
|
|
24
23
|
## 安装、构建、自测
|
|
25
24
|
|
|
@@ -42,44 +41,17 @@ NODE_EXTRA_CA_CERTS=/tmp/web-codex-tls/cert.pem pnpm test:cli
|
|
|
42
41
|
|
|
43
42
|
Chromium 已装在其他位置时可设置 `CHROMIUM_EXECUTABLE=/absolute/path/to/chrome`。本次实测使用 `/tmp/web-codex-browsers/chromium-1187/chrome-linux/chrome`。浏览器测试基于真实完成的四个阶段显示百分比、耗时、速率和 ETA;只使用合成数据。
|
|
44
43
|
|
|
45
|
-
已发布 SDK:`artifacts/web-codex-bridge-sdk-0.1.0-rc.
|
|
44
|
+
已发布 SDK:`artifacts/web-codex-bridge-sdk-0.1.0-rc.4.tgz`。Server 复制该文件到自己的 `vendor/` 后安装并固定锁文件,核对同名 `.sha256`。不要覆盖已发布文件,也不要通过 link: 指向本目录源码。构建后 `pnpm pack:sdk` 会拒绝覆盖已有版本;修改 SDK 需先调整 `scripts/build.mjs` 中的版本。
|
|
46
45
|
|
|
47
|
-
##
|
|
46
|
+
## 连接与授权
|
|
48
47
|
|
|
49
|
-
|
|
48
|
+
默认使用 Token 自动认证。Client 与网页分别持有相同 Token,通过 Noise 握手哈希和角色绑定的 HMAC 验证对端;中继只接收 Token 哈希凭据,不处理会话明文。Token 持有者可访问该设备已经在 Codex 中打开过的项目并读写。
|
|
50
49
|
|
|
51
|
-
|
|
52
|
-
mkdir -p ~/.config/web-codex-client
|
|
53
|
-
chmod 700 ~/.config/web-codex-client
|
|
54
|
-
cp deploy/config.example.json ~/.config/web-codex-client/config.json
|
|
55
|
-
# 修改 relayUrl、upstream.socket;把 Server 给出的 token 写入 device-token 文件。
|
|
56
|
-
chmod 600 ~/.config/web-codex-client/device-token
|
|
57
|
-
node dist/cli.js init --state ~/.config/web-codex-client/state
|
|
58
|
-
node dist/cli.js run --config ~/.config/web-codex-client/config.json
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
中继仅允许 WSS;不会禁用 TLS 验证。自签名开发证书使用 `NODE_EXTRA_CA_CERTS`。长期 token 不放 URL 或命令参数。设备身份私钥存于 0700 state 目录中的 0600 identity.json;这是磁盘明文身份文件,需要 OS/磁盘保护。授权表只含公钥、规范化工作区路径和读写权限,不含对话。
|
|
62
|
-
|
|
63
|
-
另开本机终端进行配对:
|
|
64
|
-
|
|
65
|
-
```bash
|
|
66
|
-
node dist/cli.js status --state ~/.config/web-codex-client/state
|
|
67
|
-
node dist/cli.js pair --config ~/.config/web-codex-client/config.json
|
|
68
|
-
node dist/cli.js pair-list --state ~/.config/web-codex-client/state
|
|
69
|
-
# 浏览器完成握手后,与本地 pair-list 核对完整的 64 位指纹。
|
|
70
|
-
# 在可信浏览器点击确认,再在本机明确授予目录权限:
|
|
71
|
-
node dist/cli.js pair-approve --state ~/.config/web-codex-client/state \
|
|
72
|
-
--fingerprint FULL_64_CHARACTER_FINGERPRINT --root /absolute/project
|
|
73
|
-
# 需要通过 Codex 执行任务时,审批命令额外加 --write。
|
|
74
|
-
node dist/cli.js revoke --state ~/.config/web-codex-client/state \
|
|
75
|
-
--peer BROWSER_PUBLIC_KEY_FROM_PAIR_LIST
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
rc.2 配对等待 5 分钟,本机确认还要求浏览器已确认相同指纹;不再强制先执行 `pair-open`。默认对识别到的 Codex 项目授予读写,其他目录需要显式本机授权;`--read-only` 可指定只读。`pair-list` 显示剩余时间,失败返回白名单错误码和处理提示。撤销立即关闭该身份的会话并拒绝后续业务。浏览器身份默认仅存内存,刷新后重新配对;同一页面断线重连可以继续使用内存身份和固定公钥。
|
|
50
|
+
只允许 WSS 并验证证书;私有 CA 使用 `NODE_EXTRA_CA_CERTS`。浏览器在当前标签页保存 Token 以支持刷新自动重连,退出登录清除。已识别项目每次连接重新扫描,根目录、用户主目录和 Client 私有状态的父目录不会自动授权。
|
|
79
51
|
|
|
80
|
-
|
|
52
|
+
管理员使用 Server 管理员口令登录后,可以连接该账号的所有在线设备,不需要逐台输入 Token。Client 信任配置的 Server 签发仅限当前路由的管理员凭据,并将其绑定到 Noise 握手验证;已识别项目范围、写租约及 Codex 审批仍生效。旧 Client rc.3 必须升级并重启才能接受管理员连接。
|
|
81
53
|
|
|
82
|
-
`deploy/web-codex-client.service`
|
|
54
|
+
旧的人工配对仅在配置显式设置 `pairingMode:"manual"` 时启用,不再是默认流程。每个 stateDir 只运行一个 Client;`status` 和 `doctor` 可诊断连接,`deploy/web-codex-client.service` 提供后台运行模板。
|
|
83
55
|
|
|
84
56
|
## Codex 接入与只读诊断
|
|
85
57
|
|
package/dist/cli.js
CHANGED
|
@@ -83,7 +83,7 @@ var CodexRpc = class _CodexRpc extends EventEmitter {
|
|
|
83
83
|
this.ws.once("open", resolve5);
|
|
84
84
|
this.ws.once("error", reject);
|
|
85
85
|
});
|
|
86
|
-
this.ws.on("message", (
|
|
86
|
+
this.ws.on("message", (data2) => this.parse(data2.toString()));
|
|
87
87
|
this.ws.on("error", () => this.fail());
|
|
88
88
|
this.ws.on("close", () => this.fail());
|
|
89
89
|
return;
|
|
@@ -124,10 +124,10 @@ var CodexRpc = class _CodexRpc extends EventEmitter {
|
|
|
124
124
|
this.emit(m.id === void 0 ? "notification" : "serverRequest", m);
|
|
125
125
|
return;
|
|
126
126
|
}
|
|
127
|
-
const
|
|
128
|
-
const p = this.pending.get(
|
|
127
|
+
const key2 = String(m.id);
|
|
128
|
+
const p = this.pending.get(key2);
|
|
129
129
|
if (!p) return;
|
|
130
|
-
this.pending.delete(
|
|
130
|
+
this.pending.delete(key2);
|
|
131
131
|
clearTimeout(p.timer);
|
|
132
132
|
if (m.error) p.reject(new RpcError(m.error.code, m.error.message));
|
|
133
133
|
else p.resolve(m.result);
|
|
@@ -137,14 +137,14 @@ var CodexRpc = class _CodexRpc extends EventEmitter {
|
|
|
137
137
|
}
|
|
138
138
|
send(value) {
|
|
139
139
|
if (this.ended) throw new BridgeError("UPSTREAM_UNAVAILABLE");
|
|
140
|
-
const
|
|
140
|
+
const data2 = JSON.stringify(value);
|
|
141
141
|
if (this.ws) {
|
|
142
142
|
if (this.ws.bufferedAmount > 1024 * 1024) throw new BridgeError("BUSY");
|
|
143
|
-
this.ws.send(
|
|
143
|
+
this.ws.send(data2);
|
|
144
144
|
} else {
|
|
145
145
|
if (!this.child || this.child.stdin.writableLength > 1024 * 1024)
|
|
146
146
|
throw new BridgeError("BUSY");
|
|
147
|
-
this.child.stdin.write(
|
|
147
|
+
this.child.stdin.write(data2 + "\n");
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
call(method, params) {
|
|
@@ -603,8 +603,8 @@ async function __wbg_load(module, imports) {
|
|
|
603
603
|
}
|
|
604
604
|
}
|
|
605
605
|
}
|
|
606
|
-
const
|
|
607
|
-
return await WebAssembly.instantiate(
|
|
606
|
+
const bytes2 = await module.arrayBuffer();
|
|
607
|
+
return await WebAssembly.instantiate(bytes2, imports);
|
|
608
608
|
} else {
|
|
609
609
|
const instance = await WebAssembly.instantiate(module, imports);
|
|
610
610
|
if (instance instanceof WebAssembly.Instance) {
|
|
@@ -750,10 +750,10 @@ function aad(f) {
|
|
|
750
750
|
var WASM_SHA256 = "1ac97442f8b0fd8ef20b31d46d2e4cbc30b6d877346f0cf2438eb08026330490";
|
|
751
751
|
|
|
752
752
|
// sdk/src/noise-provider.ts
|
|
753
|
-
async function initializeNoise(
|
|
754
|
-
const digest = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", new Uint8Array(
|
|
753
|
+
async function initializeNoise(bytes2) {
|
|
754
|
+
const digest = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", new Uint8Array(bytes2))), (n) => n.toString(16).padStart(2, "0")).join("");
|
|
755
755
|
if (digest !== WASM_SHA256) throw Error("CRYPTO_ARTIFACT_MISMATCH");
|
|
756
|
-
initSync({ module:
|
|
756
|
+
initSync({ module: bytes2 });
|
|
757
757
|
return {
|
|
758
758
|
constants: { NOISE_DH_CURVE25519: 1, NOISE_ROLE_INITIATOR: 1, NOISE_ROLE_RESPONDER: 2, NOISE_ACTION_WRITE_MESSAGE: 1, NOISE_ACTION_READ_MESSAGE: 2, NOISE_ACTION_SPLIT: 3 },
|
|
759
759
|
CreateKeyPair(algorithm) {
|
|
@@ -771,9 +771,9 @@ async function initializeNoise(bytes) {
|
|
|
771
771
|
if (suite !== SUITE || ![1, 2].includes(role)) throw Error("UNSUPPORTED_SUITE");
|
|
772
772
|
}
|
|
773
773
|
inner;
|
|
774
|
-
Initialize(prologue2,
|
|
774
|
+
Initialize(prologue2, key2) {
|
|
775
775
|
if (this.inner) throw Error("HANDSHAKE_STATE");
|
|
776
|
-
this.inner = new Handshake(this.role === 1, prologue2,
|
|
776
|
+
this.inner = new Handshake(this.role === 1, prologue2, key2);
|
|
777
777
|
}
|
|
778
778
|
get state() {
|
|
779
779
|
if (!this.inner) throw Error("HANDSHAKE_STATE");
|
|
@@ -929,7 +929,8 @@ var messageSchema = z2.union([
|
|
|
929
929
|
z2.strictObject({
|
|
930
930
|
kind: z2.literal("pairing"),
|
|
931
931
|
status: z2.enum(["pending", "confirmed", "ready", "rejected"]),
|
|
932
|
-
fingerprint: z2.string().regex(/^[a-f0-9]{64}$/)
|
|
932
|
+
fingerprint: z2.string().regex(/^[a-f0-9]{64}$/),
|
|
933
|
+
proof: z2.string().regex(/^[a-f0-9]{64}$/).optional()
|
|
933
934
|
}),
|
|
934
935
|
z2.strictObject({
|
|
935
936
|
kind: z2.literal("ack"),
|
|
@@ -944,9 +945,9 @@ function parseMessage(value) {
|
|
|
944
945
|
return m;
|
|
945
946
|
}
|
|
946
947
|
function encodeMessage(value) {
|
|
947
|
-
const
|
|
948
|
-
if (
|
|
949
|
-
return
|
|
948
|
+
const bytes2 = new TextEncoder().encode(JSON.stringify(parseMessage(value)));
|
|
949
|
+
if (bytes2.length > MAX_MESSAGE_BYTES) throw Error("RESOURCE_TOO_LARGE");
|
|
950
|
+
return bytes2;
|
|
950
951
|
}
|
|
951
952
|
var opaque = z2.object({ id: z2.string() }).passthrough();
|
|
952
953
|
var projectDto = z2.strictObject({
|
|
@@ -1168,8 +1169,8 @@ var SecureSession = class {
|
|
|
1168
1169
|
this.guard();
|
|
1169
1170
|
if (!this.ready || !this.trusted && message.kind !== "pairing")
|
|
1170
1171
|
throw Error("NOT_PAIRED");
|
|
1171
|
-
const
|
|
1172
|
-
const total = Math.ceil(
|
|
1172
|
+
const bytes2 = encodeMessage(message);
|
|
1173
|
+
const total = Math.ceil(bytes2.length / CHUNK_BYTES);
|
|
1173
1174
|
const id2 = randomId();
|
|
1174
1175
|
for (let index = 0; index < total; index++) {
|
|
1175
1176
|
this.guard();
|
|
@@ -1178,9 +1179,9 @@ var SecureSession = class {
|
|
|
1178
1179
|
id: id2,
|
|
1179
1180
|
index,
|
|
1180
1181
|
total,
|
|
1181
|
-
size:
|
|
1182
|
+
size: bytes2.length,
|
|
1182
1183
|
data: b64(
|
|
1183
|
-
|
|
1184
|
+
bytes2.subarray(index * CHUNK_BYTES, (index + 1) * CHUNK_BYTES)
|
|
1184
1185
|
)
|
|
1185
1186
|
})
|
|
1186
1187
|
);
|
|
@@ -1268,6 +1269,23 @@ var SecureSession = class {
|
|
|
1268
1269
|
}
|
|
1269
1270
|
};
|
|
1270
1271
|
|
|
1272
|
+
// sdk/src/token.ts
|
|
1273
|
+
var bytes = (value) => new TextEncoder().encode(value);
|
|
1274
|
+
async function relayCredential(token) {
|
|
1275
|
+
return hex(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes(token))));
|
|
1276
|
+
}
|
|
1277
|
+
var data = (fingerprint, role) => bytes(JSON.stringify(["web-codex-token-v1", role, fingerprint]));
|
|
1278
|
+
async function key(token) {
|
|
1279
|
+
return crypto.subtle.importKey("raw", bytes(token), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
|
|
1280
|
+
}
|
|
1281
|
+
async function tokenProof(token, fingerprint, role) {
|
|
1282
|
+
return hex(new Uint8Array(await crypto.subtle.sign("HMAC", await key(token), data(fingerprint, role))));
|
|
1283
|
+
}
|
|
1284
|
+
async function verifyTokenProof(token, fingerprint, role, proof) {
|
|
1285
|
+
if (!proof || !/^[a-f0-9]{64}$/.test(proof)) return false;
|
|
1286
|
+
return crypto.subtle.verify("HMAC", await key(token), Uint8Array.from(proof.match(/../g), (value) => parseInt(value, 16)), data(fingerprint, role));
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1271
1289
|
// sdk/src/node.ts
|
|
1272
1290
|
var pending;
|
|
1273
1291
|
function loadNoise() {
|
|
@@ -1288,6 +1306,7 @@ var configSchema = z4.strictObject({
|
|
|
1288
1306
|
return u.protocol === "wss:" && !u.username && !u.password && !u.search && !u.hash && u.pathname === "/";
|
|
1289
1307
|
}, "Relay requires WSS without URL credentials"),
|
|
1290
1308
|
deviceTokenFile: z4.string(),
|
|
1309
|
+
pairingMode: z4.enum(["token", "manual"]).optional(),
|
|
1291
1310
|
stateDir: z4.string(),
|
|
1292
1311
|
experimentalCrypto: z4.literal(true).optional(),
|
|
1293
1312
|
cryptoPolicy: z4.enum(["local-reviewed-v1", "remote-wss-v1"]).optional(),
|
|
@@ -1336,10 +1355,10 @@ async function loadState(dir) {
|
|
|
1336
1355
|
const path = dir + "/identity.json";
|
|
1337
1356
|
let identity;
|
|
1338
1357
|
try {
|
|
1339
|
-
const
|
|
1358
|
+
const data2 = JSON.parse(await privateRead(path));
|
|
1340
1359
|
identity = {
|
|
1341
|
-
privateKey: unb64(
|
|
1342
|
-
publicKey: unb64(
|
|
1360
|
+
privateKey: unb64(data2.privateKey),
|
|
1361
|
+
publicKey: unb64(data2.publicKey)
|
|
1343
1362
|
};
|
|
1344
1363
|
if (identity.privateKey.length !== 32 || identity.publicKey.length !== 32)
|
|
1345
1364
|
throw Error("IDENTITY");
|
|
@@ -1494,15 +1513,15 @@ var FileResources = class {
|
|
|
1494
1513
|
};
|
|
1495
1514
|
try {
|
|
1496
1515
|
await verify();
|
|
1497
|
-
const
|
|
1498
|
-
const { bytesRead } = await h.fd.read(
|
|
1516
|
+
const bytes2 = Buffer.alloc(Math.min(length, h.size - offset));
|
|
1517
|
+
const { bytesRead } = await h.fd.read(bytes2, 0, bytes2.length, offset);
|
|
1499
1518
|
await verify();
|
|
1500
|
-
if (bytesRead !==
|
|
1519
|
+
if (bytesRead !== bytes2.length) throw new BridgeError("RESOURCE_CHANGED");
|
|
1501
1520
|
return {
|
|
1502
1521
|
resourceId: id2,
|
|
1503
1522
|
offset,
|
|
1504
|
-
data:
|
|
1505
|
-
sha256: createHash("sha256").update(
|
|
1523
|
+
data: bytes2.toString("base64url"),
|
|
1524
|
+
sha256: createHash("sha256").update(bytes2).digest("hex"),
|
|
1506
1525
|
eof: offset + bytesRead === h.size
|
|
1507
1526
|
};
|
|
1508
1527
|
} catch (e) {
|
|
@@ -1572,9 +1591,9 @@ var Dedup = class {
|
|
|
1572
1591
|
bytes = 0;
|
|
1573
1592
|
entries = /* @__PURE__ */ new Map();
|
|
1574
1593
|
run(owner, id2, body, fn) {
|
|
1575
|
-
const
|
|
1594
|
+
const key2 = owner + ":" + id2;
|
|
1576
1595
|
const digest = createHash2("sha256").update(JSON.stringify(body)).digest("hex");
|
|
1577
|
-
const old = this.entries.get(
|
|
1596
|
+
const old = this.entries.get(key2);
|
|
1578
1597
|
if (old) {
|
|
1579
1598
|
if (old.digest !== digest) throw new BridgeError("REQUEST_ID_REUSED");
|
|
1580
1599
|
return old.promise;
|
|
@@ -1585,16 +1604,16 @@ var Dedup = class {
|
|
|
1585
1604
|
"Dedup budget exhausted; reconnect after a deliberate client restart and reconcile outcomes"
|
|
1586
1605
|
);
|
|
1587
1606
|
const promise = Promise.resolve().then(fn).then((result) => {
|
|
1588
|
-
const
|
|
1589
|
-
if (
|
|
1607
|
+
const bytes2 = Buffer.byteLength(JSON.stringify(result));
|
|
1608
|
+
if (bytes2 > 1024 * 1024 || this.bytes + bytes2 > 8 * 1024 * 1024)
|
|
1590
1609
|
throw new BridgeError(
|
|
1591
1610
|
"OUTCOME_UNKNOWN",
|
|
1592
1611
|
"Write executed; response exceeds retained dedup budget; reconcile upstream"
|
|
1593
1612
|
);
|
|
1594
|
-
this.bytes +=
|
|
1613
|
+
this.bytes += bytes2;
|
|
1595
1614
|
return result;
|
|
1596
1615
|
});
|
|
1597
|
-
this.entries.set(
|
|
1616
|
+
this.entries.set(key2, { digest, promise, at: Date.now() });
|
|
1598
1617
|
return promise;
|
|
1599
1618
|
}
|
|
1600
1619
|
};
|
|
@@ -1820,12 +1839,12 @@ var Bridge = class {
|
|
|
1820
1839
|
const h = this.histories.get(p.cursor);
|
|
1821
1840
|
if (!h || h.owner !== owner || h.thread !== p.threadId || h.kind !== method + ":" + (p.turnId ?? "") || h.expires < Date.now())
|
|
1822
1841
|
throw new BridgeError("RESYNC_REQUIRED");
|
|
1823
|
-
const
|
|
1824
|
-
h.offset +=
|
|
1842
|
+
const data3 = h.data.slice(h.offset, h.offset + p.limit);
|
|
1843
|
+
h.offset += data3.length;
|
|
1825
1844
|
this.histories.delete(p.cursor);
|
|
1826
1845
|
const nextCursor2 = h.offset < h.data.length ? "fallback:" + randomUUID4() : null;
|
|
1827
1846
|
if (nextCursor2) this.histories.set(nextCursor2, h);
|
|
1828
|
-
return { data:
|
|
1847
|
+
return { data: data3, nextCursor: nextCursor2, source: "bounded-fallback" };
|
|
1829
1848
|
}
|
|
1830
1849
|
try {
|
|
1831
1850
|
const r2 = await this.upstream.call(
|
|
@@ -1856,31 +1875,31 @@ var Bridge = class {
|
|
|
1856
1875
|
threadId: p.threadId,
|
|
1857
1876
|
includeTurns: true
|
|
1858
1877
|
});
|
|
1859
|
-
const
|
|
1860
|
-
if (
|
|
1878
|
+
const bytes2 = Buffer.byteLength(JSON.stringify(r));
|
|
1879
|
+
if (bytes2 > 8 * 1024 * 1024 || Array.from(this.histories.values()).reduce((n, h) => n + h.bytes, 0) + bytes2 > 8 * 1024 * 1024)
|
|
1861
1880
|
throw new BridgeError(
|
|
1862
1881
|
"RESOURCE_TOO_LARGE",
|
|
1863
1882
|
"Legacy history exceeds bounded fallback budget"
|
|
1864
1883
|
);
|
|
1865
1884
|
const turns = (r.thread.turns ?? []).slice().reverse();
|
|
1866
|
-
const
|
|
1885
|
+
const data2 = method === "turns.list" ? turns.map((t) => ({ ...t, items: [] })) : turns.filter((t) => !p.turnId || p.turnId === t.id).flatMap(
|
|
1867
1886
|
(t) => (t.items ?? []).slice().reverse().map((item) => ({ turnId: t.id, item }))
|
|
1868
1887
|
);
|
|
1869
|
-
const nextCursor =
|
|
1888
|
+
const nextCursor = data2.length > p.limit ? "fallback:" + randomUUID4() : null;
|
|
1870
1889
|
if (nextCursor) {
|
|
1871
1890
|
if (this.histories.size >= 32) throw new BridgeError("BUSY");
|
|
1872
1891
|
this.histories.set(nextCursor, {
|
|
1873
1892
|
owner,
|
|
1874
1893
|
thread: p.threadId,
|
|
1875
1894
|
kind: method + ":" + (p.turnId ?? ""),
|
|
1876
|
-
data,
|
|
1895
|
+
data: data2,
|
|
1877
1896
|
offset: p.limit,
|
|
1878
|
-
bytes,
|
|
1897
|
+
bytes: bytes2,
|
|
1879
1898
|
expires: Date.now() + 6e4
|
|
1880
1899
|
});
|
|
1881
1900
|
}
|
|
1882
1901
|
return {
|
|
1883
|
-
data:
|
|
1902
|
+
data: data2.slice(0, p.limit),
|
|
1884
1903
|
nextCursor,
|
|
1885
1904
|
source: "bounded-fallback"
|
|
1886
1905
|
};
|
|
@@ -1889,25 +1908,25 @@ var Bridge = class {
|
|
|
1889
1908
|
* Buffer at most one turn; never load a whole long conversation for this path. */
|
|
1890
1909
|
async turnItems(owner, p, previous) {
|
|
1891
1910
|
const h = previous ? { ...previous } : { owner, thread: p.threadId, kind: "items.list:" + (p.turnId ?? ""), data: [], offset: 0, bytes: 0, expires: Date.now() + 6e4 };
|
|
1892
|
-
const
|
|
1911
|
+
const data2 = [];
|
|
1893
1912
|
let pages = 0;
|
|
1894
1913
|
const visited = /* @__PURE__ */ new Set();
|
|
1895
|
-
while (
|
|
1896
|
-
const part = h.data.slice(h.offset, h.offset + p.limit -
|
|
1897
|
-
|
|
1914
|
+
while (data2.length < p.limit) {
|
|
1915
|
+
const part = h.data.slice(h.offset, h.offset + p.limit - data2.length);
|
|
1916
|
+
data2.push(...part);
|
|
1898
1917
|
h.offset += part.length;
|
|
1899
|
-
if (
|
|
1918
|
+
if (data2.length >= p.limit || h.upstream === null || pages >= 10) break;
|
|
1900
1919
|
if (h.upstream && visited.has(h.upstream)) throw new BridgeError("RESYNC_REQUIRED");
|
|
1901
1920
|
if (h.upstream) visited.add(h.upstream);
|
|
1902
1921
|
const r = await this.upstream.call("thread/turns/list", { threadId: p.threadId, cursor: h.upstream, limit: 1, sortDirection: "desc", itemsView: "full" });
|
|
1903
1922
|
pages++;
|
|
1904
|
-
const
|
|
1923
|
+
const bytes2 = Buffer.byteLength(JSON.stringify(r));
|
|
1905
1924
|
const retained = Array.from(this.histories.values()).reduce((sum, entry) => sum + (entry === previous ? 0 : entry.bytes), 0);
|
|
1906
|
-
if (
|
|
1925
|
+
if (bytes2 > 8 * 1024 * 1024 || retained + bytes2 > 8 * 1024 * 1024) throw new BridgeError("RESOURCE_TOO_LARGE");
|
|
1907
1926
|
if (r.data.some((turn) => turn.itemsView && turn.itemsView !== "full")) throw new BridgeError("UNSUPPORTED_CAPABILITY", "Upstream did not return full turn items");
|
|
1908
1927
|
h.data = r.data.filter((turn) => !p.turnId || turn.id === p.turnId).flatMap((turn) => (turn.items ?? []).slice().reverse().map((item) => ({ turnId: turn.id, item })));
|
|
1909
1928
|
h.offset = 0;
|
|
1910
|
-
h.bytes =
|
|
1929
|
+
h.bytes = bytes2;
|
|
1911
1930
|
h.upstream = r.nextCursor ?? null;
|
|
1912
1931
|
}
|
|
1913
1932
|
const more = h.offset < h.data.length || h.upstream !== null;
|
|
@@ -1915,7 +1934,7 @@ var Bridge = class {
|
|
|
1915
1934
|
if (previous) this.histories.delete(p.cursor);
|
|
1916
1935
|
const nextCursor = more ? "turn-items:" + randomUUID4() : null;
|
|
1917
1936
|
if (nextCursor) this.histories.set(nextCursor, h);
|
|
1918
|
-
return { data, nextCursor, source: "bounded-fallback" };
|
|
1937
|
+
return { data: data2, nextCursor, source: "bounded-fallback" };
|
|
1919
1938
|
}
|
|
1920
1939
|
async handle(owner, message) {
|
|
1921
1940
|
if (message.kind === "serverResponse") {
|
|
@@ -2010,12 +2029,12 @@ var Bridge = class {
|
|
|
2010
2029
|
const r = await this.upstream.call("project/list", p);
|
|
2011
2030
|
if (!p.cursor && r.data.length === 0 && !r.nextCursor)
|
|
2012
2031
|
throw new RpcError(-32601, "Empty project catalog; discover history roots");
|
|
2013
|
-
const
|
|
2032
|
+
const data2 = [];
|
|
2014
2033
|
for (const item of r.data) {
|
|
2015
2034
|
for (const root of item.roots ?? []) {
|
|
2016
2035
|
try {
|
|
2017
2036
|
const cwd = await authorizedPath(g.roots, root.path);
|
|
2018
|
-
|
|
2037
|
+
data2.push({
|
|
2019
2038
|
id: item.id,
|
|
2020
2039
|
name: item.name,
|
|
2021
2040
|
cwd,
|
|
@@ -2026,10 +2045,10 @@ var Bridge = class {
|
|
|
2026
2045
|
}
|
|
2027
2046
|
}
|
|
2028
2047
|
}
|
|
2029
|
-
return { data, nextCursor: r.nextCursor ?? null };
|
|
2048
|
+
return { data: data2, nextCursor: r.nextCursor ?? null };
|
|
2030
2049
|
} catch (e) {
|
|
2031
2050
|
if (!unsupported(e)) throw e;
|
|
2032
|
-
const
|
|
2051
|
+
const data2 = [];
|
|
2033
2052
|
const seen = new Set(previous?.seen);
|
|
2034
2053
|
let upstreamCursor = previous?.upstream ?? p.cursor;
|
|
2035
2054
|
const visited = /* @__PURE__ */ new Set();
|
|
@@ -2040,7 +2059,7 @@ var Bridge = class {
|
|
|
2040
2059
|
}
|
|
2041
2060
|
const r = await this.upstream.call("thread/list", {
|
|
2042
2061
|
cursor: upstreamCursor,
|
|
2043
|
-
limit: (p.limit ?? 50) -
|
|
2062
|
+
limit: (p.limit ?? 50) - data2.length,
|
|
2044
2063
|
useStateDbOnly: true,
|
|
2045
2064
|
sourceKinds: sources,
|
|
2046
2065
|
sortKey: "updated_at",
|
|
@@ -2055,7 +2074,7 @@ var Bridge = class {
|
|
|
2055
2074
|
}
|
|
2056
2075
|
if (!seen.has(cwd)) {
|
|
2057
2076
|
seen.add(cwd);
|
|
2058
|
-
|
|
2077
|
+
data2.push({
|
|
2059
2078
|
id: "cwd:" + Buffer.from(cwd).toString("base64url"),
|
|
2060
2079
|
name: basename2(cwd),
|
|
2061
2080
|
cwd,
|
|
@@ -2064,7 +2083,7 @@ var Bridge = class {
|
|
|
2064
2083
|
}
|
|
2065
2084
|
}
|
|
2066
2085
|
upstreamCursor = r.nextCursor ?? void 0;
|
|
2067
|
-
if (!upstreamCursor ||
|
|
2086
|
+
if (!upstreamCursor || data2.length >= (p.limit ?? 50)) break;
|
|
2068
2087
|
}
|
|
2069
2088
|
if (seen.size > 1024 || this.projectCursors.size >= 128)
|
|
2070
2089
|
throw new BridgeError("RESOURCE_TOO_LARGE");
|
|
@@ -2077,7 +2096,7 @@ var Bridge = class {
|
|
|
2077
2096
|
seen,
|
|
2078
2097
|
expires: Date.now() + 6e4
|
|
2079
2098
|
});
|
|
2080
|
-
return { data, nextCursor };
|
|
2099
|
+
return { data: data2, nextCursor };
|
|
2081
2100
|
}
|
|
2082
2101
|
}
|
|
2083
2102
|
case "projects.read":
|
|
@@ -2094,14 +2113,14 @@ var Bridge = class {
|
|
|
2094
2113
|
sortKey: "updated_at",
|
|
2095
2114
|
sortDirection: "desc"
|
|
2096
2115
|
});
|
|
2097
|
-
const
|
|
2116
|
+
const data2 = [];
|
|
2098
2117
|
for (const t of r.data)
|
|
2099
2118
|
try {
|
|
2100
2119
|
await authorizedPath(g.roots, t.cwd);
|
|
2101
|
-
|
|
2120
|
+
data2.push(t);
|
|
2102
2121
|
} catch {
|
|
2103
2122
|
}
|
|
2104
|
-
return { data, nextCursor: r.nextCursor ?? null };
|
|
2123
|
+
return { data: data2, nextCursor: r.nextCursor ?? null };
|
|
2105
2124
|
}
|
|
2106
2125
|
case "threads.read":
|
|
2107
2126
|
return { thread: await this.thread(owner, p.threadId) };
|
|
@@ -2311,6 +2330,7 @@ var Bridge = class {
|
|
|
2311
2330
|
|
|
2312
2331
|
// src/admin-errors.ts
|
|
2313
2332
|
var adminMessages = {
|
|
2333
|
+
TOKEN_PAIRING_AUTOMATIC: "\u4F7F\u7528\u4E0E Client \u76F8\u540C\u7684 Token \u767B\u5F55\u7F51\u9875\u5373\u53EF\uFF0C\u65E0\u9700\u8FD0\u884C pair \u6216 pair-approve\u3002",
|
|
2314
2334
|
FINGERPRINT_NOT_FOUND: "\u5F53\u524D\u6307\u7EB9\u4E0D\u5B58\u5728\u3002\u8BF7\u5728\u7F51\u9875\u91CD\u65B0\u8FDE\u63A5\uFF0C\u518D\u8FD0\u884C web-codex-client pair\u3002",
|
|
2315
2335
|
BROWSER_NOT_CONFIRMED: "\u8BF7\u5148\u5728\u7F51\u9875\u70B9\u51FB\u201C\u4E0E\u672C\u673A\u663E\u793A\u4E00\u81F4\u201D\uFF0C\u7136\u540E\u91CD\u8BD5\u3002",
|
|
2316
2336
|
PAIRING_ROUTE_EXPIRED: "\u6B64\u914D\u5BF9\u5DF2\u8FC7\u671F\u3002\u8BF7\u5728\u7F51\u9875\u91CD\u65B0\u8FDE\u63A5\u5E76\u6838\u5BF9\u65B0\u6307\u7EB9\u3002",
|
|
@@ -2408,12 +2428,15 @@ var ClientDaemon = class {
|
|
|
2408
2428
|
attempts = 0;
|
|
2409
2429
|
noise;
|
|
2410
2430
|
token = "";
|
|
2431
|
+
relayToken = "";
|
|
2432
|
+
tokenPeers = /* @__PURE__ */ new Set();
|
|
2411
2433
|
alive = true;
|
|
2412
2434
|
connecting;
|
|
2413
2435
|
async start() {
|
|
2414
2436
|
this.noise = await loadNoise();
|
|
2415
2437
|
this.token = (await privateRead(this.config.deviceTokenFile)).trim();
|
|
2416
2438
|
if (!this.token || /[\r\n]/.test(this.token)) throw Error("DEVICE_TOKEN");
|
|
2439
|
+
this.relayToken = await relayCredential(this.token);
|
|
2417
2440
|
await this.startAdmin();
|
|
2418
2441
|
this.connect();
|
|
2419
2442
|
this.timer = setInterval(() => {
|
|
@@ -2450,7 +2473,10 @@ var ClientDaemon = class {
|
|
|
2450
2473
|
connect() {
|
|
2451
2474
|
if (this.stopped) return;
|
|
2452
2475
|
this.ws = new WebSocket2(new URL("/ws/v1/client", this.config.relayUrl), {
|
|
2453
|
-
headers: {
|
|
2476
|
+
headers: {
|
|
2477
|
+
Authorization: this.config.pairingMode === "manual" ? "Bearer " + this.token : "TokenHash " + this.relayToken,
|
|
2478
|
+
...this.config.pairingMode !== "manual" ? { "X-Web-Codex-Admin-Routes": "v1" } : {}
|
|
2479
|
+
},
|
|
2454
2480
|
perMessageDeflate: false,
|
|
2455
2481
|
maxPayload: 128 * 1024,
|
|
2456
2482
|
handshakeTimeout: 1e4
|
|
@@ -2462,9 +2488,9 @@ var ClientDaemon = class {
|
|
|
2462
2488
|
this.ws.on("pong", () => this.alive = true);
|
|
2463
2489
|
this.ws.on("error", () => {
|
|
2464
2490
|
});
|
|
2465
|
-
this.ws.on("message", (
|
|
2491
|
+
this.ws.on("message", (data2) => {
|
|
2466
2492
|
try {
|
|
2467
|
-
this.receive(
|
|
2493
|
+
this.receive(data2.toString());
|
|
2468
2494
|
} catch {
|
|
2469
2495
|
this.ws?.close(1008, "Protocol violation");
|
|
2470
2496
|
}
|
|
@@ -2496,7 +2522,12 @@ var ClientDaemon = class {
|
|
|
2496
2522
|
this.control({ type: "route.close", routeId: m.routeId });
|
|
2497
2523
|
return;
|
|
2498
2524
|
}
|
|
2525
|
+
if (m.adminToken !== void 0 && (this.config.pairingMode === "manual" || typeof m.adminToken !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(m.adminToken))) {
|
|
2526
|
+
this.control({ type: "route.close", routeId: m.routeId });
|
|
2527
|
+
return;
|
|
2528
|
+
}
|
|
2499
2529
|
this.routes.set(m.routeId, {
|
|
2530
|
+
proofToken: m.adminToken ?? this.token,
|
|
2500
2531
|
confirmed: false,
|
|
2501
2532
|
expires: Date.now() + PAIRING_MS,
|
|
2502
2533
|
queue: Promise.resolve(),
|
|
@@ -2540,7 +2571,14 @@ var ClientDaemon = class {
|
|
|
2540
2571
|
if (reply) this.ws.send(encodeFrame(reply));
|
|
2541
2572
|
if (r.session.ready) {
|
|
2542
2573
|
const grant = this.grants.get(r.session.peerPublicKey);
|
|
2543
|
-
if (
|
|
2574
|
+
if (this.config.pairingMode !== "manual") {
|
|
2575
|
+
this.send(frame.routeId, {
|
|
2576
|
+
kind: "pairing",
|
|
2577
|
+
status: "pending",
|
|
2578
|
+
fingerprint: r.session.fingerprint,
|
|
2579
|
+
proof: await tokenProof(r.proofToken, r.session.fingerprint, "client")
|
|
2580
|
+
});
|
|
2581
|
+
} else if (grant) {
|
|
2544
2582
|
r.session.trust(r.session.fingerprint);
|
|
2545
2583
|
r.owner = grant.peer;
|
|
2546
2584
|
this.send(frame.routeId, {
|
|
@@ -2561,9 +2599,21 @@ var ClientDaemon = class {
|
|
|
2561
2599
|
const message = r.session.open(frame);
|
|
2562
2600
|
if (!message) return;
|
|
2563
2601
|
if (message.kind === "pairing") {
|
|
2564
|
-
if (message.fingerprint !== r.session.fingerprint || message.status !== "confirmed" || Date.now() > r.expires)
|
|
2602
|
+
if (r.confirmed || message.fingerprint !== r.session.fingerprint || message.status !== "confirmed" || Date.now() > r.expires)
|
|
2565
2603
|
throw Error("PAIRING_MISMATCH");
|
|
2604
|
+
if (this.config.pairingMode !== "manual") {
|
|
2605
|
+
if (!await verifyTokenProof(r.proofToken, r.session.fingerprint, "browser", message.proof)) throw new BridgeError("TOKEN_AUTH_FAILED");
|
|
2606
|
+
const roots = await this.discoverProjects();
|
|
2607
|
+
if (this.routes.get(frame.routeId) !== r || Date.now() > r.expires) return;
|
|
2608
|
+
const peer = r.session.peerPublicKey;
|
|
2609
|
+
this.grants.set(peer, { peer, roots, write: true, writeRoots: roots });
|
|
2610
|
+
this.tokenPeers.add(peer);
|
|
2611
|
+
r.session.trust(r.session.fingerprint);
|
|
2612
|
+
r.owner = peer;
|
|
2613
|
+
this.send(frame.routeId, { kind: "pairing", status: "ready", fingerprint: r.session.fingerprint });
|
|
2614
|
+
}
|
|
2566
2615
|
r.confirmed = true;
|
|
2616
|
+
r.proofToken = "";
|
|
2567
2617
|
return;
|
|
2568
2618
|
}
|
|
2569
2619
|
if (!r.owner || !this.grants.has(r.owner))
|
|
@@ -2584,13 +2634,13 @@ var ClientDaemon = class {
|
|
|
2584
2634
|
send(id2, message) {
|
|
2585
2635
|
const r = this.routes.get(id2);
|
|
2586
2636
|
if (!r?.session?.ready) return;
|
|
2587
|
-
const
|
|
2588
|
-
if (r.bytes +
|
|
2637
|
+
const bytes2 = Buffer.byteLength(JSON.stringify(message));
|
|
2638
|
+
if (r.bytes + bytes2 > 1024 * 1024 || this.ws.bufferedAmount > 4 * 1024 * 1024) {
|
|
2589
2639
|
this.closeRoute(id2);
|
|
2590
2640
|
return;
|
|
2591
2641
|
}
|
|
2592
2642
|
r.out.push(message);
|
|
2593
|
-
r.bytes +=
|
|
2643
|
+
r.bytes += bytes2;
|
|
2594
2644
|
if (!r.sending) void this.flush(id2, r);
|
|
2595
2645
|
}
|
|
2596
2646
|
async flush(id2, r) {
|
|
@@ -2627,8 +2677,10 @@ var ClientDaemon = class {
|
|
|
2627
2677
|
this.routes.delete(id2);
|
|
2628
2678
|
r.session?.close();
|
|
2629
2679
|
r.out = [];
|
|
2630
|
-
if (r.owner && !Array.from(this.routes.values()).some((x) => x.owner === r.owner))
|
|
2680
|
+
if (r.owner && !Array.from(this.routes.values()).some((x) => x.owner === r.owner)) {
|
|
2631
2681
|
this.bridge?.disconnect(r.owner);
|
|
2682
|
+
if (this.tokenPeers.delete(r.owner)) this.grants.delete(r.owner);
|
|
2683
|
+
}
|
|
2632
2684
|
if (notify) this.control({ type: "route.close", routeId: id2 });
|
|
2633
2685
|
}
|
|
2634
2686
|
async adminCommand(value) {
|
|
@@ -2645,7 +2697,7 @@ var ClientDaemon = class {
|
|
|
2645
2697
|
(r) => r.owner
|
|
2646
2698
|
).length,
|
|
2647
2699
|
pairingUntil: this.pairUntil,
|
|
2648
|
-
pairingMode: "local-confirmation",
|
|
2700
|
+
pairingMode: this.config.pairingMode === "manual" ? "local-confirmation" : "token",
|
|
2649
2701
|
pairingRemainingMs: Math.max(0, this.pairUntil - Date.now()),
|
|
2650
2702
|
lastRouteClosure: this.lastRouteClosure,
|
|
2651
2703
|
grants: Array.from(this.grants.values()).map((g) => ({ peer: g.peer, rootCount: g.roots.length, write: g.write }))
|
|
@@ -2656,7 +2708,7 @@ var ClientDaemon = class {
|
|
|
2656
2708
|
return { expiresAt: this.pairUntil };
|
|
2657
2709
|
case "pair-list":
|
|
2658
2710
|
return {
|
|
2659
|
-
pairingMode: "local-confirmation",
|
|
2711
|
+
pairingMode: this.config.pairingMode === "manual" ? "local-confirmation" : "token",
|
|
2660
2712
|
windowRequired: false,
|
|
2661
2713
|
pairingUntil: this.pairUntil,
|
|
2662
2714
|
pairingRemainingMs: Math.max(0, this.pairUntil - Date.now()),
|
|
@@ -2671,6 +2723,7 @@ var ClientDaemon = class {
|
|
|
2671
2723
|
}))
|
|
2672
2724
|
};
|
|
2673
2725
|
case "pair-approve": {
|
|
2726
|
+
if (this.config.pairingMode !== "manual") throw new AdminError("TOKEN_PAIRING_AUTOMATIC");
|
|
2674
2727
|
const p = z5.object({
|
|
2675
2728
|
fingerprint: z5.string().regex(/^[a-f0-9]{64}$/),
|
|
2676
2729
|
roots: z5.array(z5.string()).min(1).max(32).optional(),
|
|
@@ -2815,17 +2868,17 @@ var ClientDaemon = class {
|
|
|
2815
2868
|
}
|
|
2816
2869
|
this.admin = createServer((socket) => {
|
|
2817
2870
|
socket.setTimeout(6e4, () => socket.destroy());
|
|
2818
|
-
let
|
|
2871
|
+
let data2 = "";
|
|
2819
2872
|
socket.on("data", (chunk) => {
|
|
2820
|
-
|
|
2821
|
-
if (
|
|
2873
|
+
data2 += chunk.toString();
|
|
2874
|
+
if (data2.length > 16384) {
|
|
2822
2875
|
socket.destroy();
|
|
2823
2876
|
return;
|
|
2824
2877
|
}
|
|
2825
|
-
if (!
|
|
2878
|
+
if (!data2.includes("\n")) return;
|
|
2826
2879
|
socket.pause();
|
|
2827
2880
|
void Promise.resolve().then(() => {
|
|
2828
|
-
const request = JSON.parse(
|
|
2881
|
+
const request = JSON.parse(data2);
|
|
2829
2882
|
const result = this.adminQueue.then(() => this.adminCommand(request));
|
|
2830
2883
|
this.adminQueue = result.then(() => {
|
|
2831
2884
|
}, () => {
|
|
@@ -2869,17 +2922,17 @@ import { join as join2 } from "node:path";
|
|
|
2869
2922
|
function control(stateDir, request) {
|
|
2870
2923
|
return new Promise((resolve5, reject) => {
|
|
2871
2924
|
const socket = createConnection2(join2(stateDir, "control.sock"));
|
|
2872
|
-
let
|
|
2925
|
+
let data2 = "";
|
|
2873
2926
|
socket.setTimeout(6e4, () => socket.destroy(Error("ADMIN_TIMEOUT: \u672C\u673A\u5904\u7406\u8D85\u65F6\uFF0C\u8BF7\u8FD0\u884C doctor \u540E\u91CD\u8BD5")));
|
|
2874
2927
|
socket.on("connect", () => socket.write(JSON.stringify(request) + "\n"));
|
|
2875
2928
|
socket.on("data", (chunk) => {
|
|
2876
|
-
|
|
2877
|
-
if (Buffer.byteLength(
|
|
2929
|
+
data2 += chunk.toString();
|
|
2930
|
+
if (Buffer.byteLength(data2) > 65536) socket.destroy(Error("ADMIN_TOO_LARGE"));
|
|
2878
2931
|
});
|
|
2879
2932
|
socket.on("error", reject);
|
|
2880
2933
|
socket.on("end", () => {
|
|
2881
2934
|
try {
|
|
2882
|
-
resolve5(JSON.parse(
|
|
2935
|
+
resolve5(JSON.parse(data2));
|
|
2883
2936
|
} catch {
|
|
2884
2937
|
reject(Error("ADMIN_INVALID_RESPONSE"));
|
|
2885
2938
|
}
|
|
@@ -2936,6 +2989,13 @@ async function question(prompt, secret = false) {
|
|
|
2936
2989
|
async function saveConfiguration(path, relayUrl, token, socket) {
|
|
2937
2990
|
path = resolve3(path);
|
|
2938
2991
|
const directory = dirname3(path);
|
|
2992
|
+
try {
|
|
2993
|
+
const url = new URL(relayUrl);
|
|
2994
|
+
if (url.protocol === "https:") url.protocol = "wss:";
|
|
2995
|
+
relayUrl = url.toString();
|
|
2996
|
+
} catch {
|
|
2997
|
+
throw Error("RELAY_URL_INVALID: use an HTTPS or WSS server address");
|
|
2998
|
+
}
|
|
2939
2999
|
const parsed = configSchema.safeParse({ relayUrl, deviceTokenFile: "placeholder", stateDir: "./state", cryptoPolicy: "remote-wss-v1", upstream: { mode: "unix", ...socket ? { socket: resolve3(socket) } : {} } });
|
|
2940
3000
|
if (!parsed.success) throw Error("RELAY_URL_INVALID: use a WSS origin without path, credentials, query or fragment");
|
|
2941
3001
|
token = validateToken(token);
|
|
@@ -2981,10 +3041,11 @@ async function saveConfiguration(path, relayUrl, token, socket) {
|
|
|
2981
3041
|
import { join as join4, dirname as dirname4 } from "node:path";
|
|
2982
3042
|
import WebSocket3 from "ws";
|
|
2983
3043
|
import { SingleBar, Presets } from "cli-progress";
|
|
2984
|
-
async function authenticateRelay(relayUrl, token) {
|
|
3044
|
+
async function authenticateRelay(relayUrl, token, manual = false) {
|
|
3045
|
+
const credential = manual ? "Bearer " + token : "TokenHash " + await relayCredential(token);
|
|
2985
3046
|
await new Promise((resolve5, reject) => {
|
|
2986
3047
|
const ws = new WebSocket3(new URL("/ws/v1/client", relayUrl), {
|
|
2987
|
-
headers: { Authorization:
|
|
3048
|
+
headers: { Authorization: credential },
|
|
2988
3049
|
perMessageDeflate: false,
|
|
2989
3050
|
maxPayload: 4096,
|
|
2990
3051
|
handshakeTimeout: 1e4
|
|
@@ -3005,9 +3066,9 @@ async function authenticateRelay(relayUrl, token) {
|
|
|
3005
3066
|
finish(Error(response.statusCode === 401 ? "RELAY_UNAUTHORIZED: replace the device token" : "RELAY_HTTP_REJECTED: check the reverse proxy"));
|
|
3006
3067
|
});
|
|
3007
3068
|
ws.on("close", () => finish(Error("RELAY_CLOSED")));
|
|
3008
|
-
ws.on("message", (
|
|
3069
|
+
ws.on("message", (bytes2) => {
|
|
3009
3070
|
try {
|
|
3010
|
-
const frame = JSON.parse(
|
|
3071
|
+
const frame = JSON.parse(bytes2.toString());
|
|
3011
3072
|
if (frame.type === "device.ready" && typeof frame.deviceId === "string") finish();
|
|
3012
3073
|
else finish(Error("RELAY_PROTOCOL"));
|
|
3013
3074
|
} catch {
|
|
@@ -3054,7 +3115,7 @@ async function doctor(configPath, quiet = false, reportPath = join4(dirname4(con
|
|
|
3054
3115
|
} catch (e) {
|
|
3055
3116
|
if (!["ENOENT", "ECONNREFUSED"].includes(e.code ?? "")) throw e;
|
|
3056
3117
|
}
|
|
3057
|
-
await authenticateRelay(config.relayUrl, token);
|
|
3118
|
+
await authenticateRelay(config.relayUrl, token, config.pairingMode === "manual");
|
|
3058
3119
|
});
|
|
3059
3120
|
await check("Codex initialization (no model request)", async () => {
|
|
3060
3121
|
const rpc = await CodexRpc.connect(config.upstream);
|
|
@@ -3103,7 +3164,7 @@ async function main() {
|
|
|
3103
3164
|
}
|
|
3104
3165
|
if (command === "help" || values.help) {
|
|
3105
3166
|
console.log(
|
|
3106
|
-
"web-codex-client configure [--config FILE] [--relay
|
|
3167
|
+
"web-codex-client configure [--config FILE] [--relay https://HOST --token-stdin]\nweb-codex-client run [--config FILE]\nweb-codex-client status [--config FILE]\nweb-codex-client doctor [--config FILE] [--quiet]\n\n\u914D\u7F6E\u670D\u52A1\u7AEF\u5730\u5740\u548C Token\uFF0C\u8FD0\u884C Client\uFF1B\u7F51\u9875\u4F7F\u7528\u540C\u4E00\u4E2A Token \u5373\u53EF\u8BBF\u95EE\u9879\u76EE\u4E0E\u5BF9\u8BDD\u3002\nDefault config: " + defaultConfigPath()
|
|
3107
3168
|
);
|
|
3108
3169
|
return;
|
|
3109
3170
|
}
|
|
@@ -3111,12 +3172,12 @@ async function main() {
|
|
|
3111
3172
|
if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === "0") throw Error("TLS_VERIFICATION_REQUIRED: remove NODE_TLS_REJECT_UNAUTHORIZED=0");
|
|
3112
3173
|
const configPath = resolve4(values.config ?? defaultConfigPath());
|
|
3113
3174
|
if (command === "configure") {
|
|
3114
|
-
const relay = values.relay ?? (await question("
|
|
3175
|
+
const relay = values.relay ?? (await question("\u670D\u52A1\u7AEF\u5730\u5740\uFF08https:// \u6216 wss://\uFF09\uFF1A")).trim();
|
|
3115
3176
|
if (values["token-stdin"] && !values.relay) throw Error("--token-stdin requires --relay");
|
|
3116
|
-
const socket = values.socket
|
|
3117
|
-
const token = values["token-stdin"] ? await tokenFromStdin() : await question("
|
|
3177
|
+
const socket = values.socket;
|
|
3178
|
+
const token = values["token-stdin"] ? await tokenFromStdin() : await question("\u8FDE\u63A5 Token\uFF08\u9690\u85CF\u8F93\u5165\uFF09\uFF1A", true);
|
|
3118
3179
|
const result2 = await saveConfiguration(configPath, relay, token, socket);
|
|
3119
|
-
console.log("Configuration saved: " + result2.path + "\
|
|
3180
|
+
console.log("Configuration saved: " + result2.path + "\n\u8FD0\u884C web-codex-client run\uFF0C\u7136\u540E\u5728\u7F51\u9875\u8F93\u5165\u540C\u4E00\u4E2A Token \u5373\u53EF\u4F7F\u7528\u3002");
|
|
3120
3181
|
return;
|
|
3121
3182
|
}
|
|
3122
3183
|
if (command === "doctor") {
|
|
@@ -3149,7 +3210,7 @@ async function main() {
|
|
|
3149
3210
|
const daemon = new ClientDaemon(config, state.identity, state.grants);
|
|
3150
3211
|
await daemon.start();
|
|
3151
3212
|
console.log(
|
|
3152
|
-
"Client running. \
|
|
3213
|
+
"Client running. \u7F51\u9875\u8F93\u5165\u76F8\u540C Token \u5373\u53EF\u67E5\u770B\u9879\u76EE\u3001\u4F1A\u8BDD\u5E76\u53D1\u9001\u6D88\u606F\u3002"
|
|
3153
3214
|
);
|
|
3154
3215
|
let stopping = false;
|
|
3155
3216
|
const stop = () => {
|
|
@@ -3170,6 +3231,10 @@ async function main() {
|
|
|
3170
3231
|
};
|
|
3171
3232
|
if (command === "pair") {
|
|
3172
3233
|
const list = await send({ command: "pair-list" });
|
|
3234
|
+
if (list.pairingMode === "token") {
|
|
3235
|
+
console.log("\u7F51\u9875\u8F93\u5165\u4E0E Client \u76F8\u540C\u7684 Token \u5373\u53EF\uFF0C\u65E0\u9700\u914D\u5BF9\u547D\u4EE4\u3002");
|
|
3236
|
+
return;
|
|
3237
|
+
}
|
|
3173
3238
|
const pending2 = list.pending.filter((p) => (p.remainingMs ?? p.expiresAt - Date.now()) > 0);
|
|
3174
3239
|
if (!pending2.length) throw Error("NO_PENDING_BROWSER: \u8BF7\u5148\u5728\u7F51\u9875\u9009\u62E9\u8BBE\u5907\u6216\u91CD\u65B0\u8FDE\u63A5\uFF0C\u7136\u540E\u518D\u6B21\u8FD0\u884C pair\u3002\u65E0\u9700\u5148\u6267\u884C pair-open\u3002");
|
|
3175
3240
|
for (let index = 0; index < pending2.length; index++) console.log(`${index + 1}. ${pending2[index].fingerprint} \u7F51\u9875\u786E\u8BA4\uFF1A${pending2[index].browserConfirmed ? "\u662F" : "\u5426"}`);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@landh93/web-codex-client",
|
|
3
|
-
"version": "0.1.0-rc.
|
|
4
|
-
"description": "Encrypted local Codex bridge with
|
|
3
|
+
"version": "0.1.0-rc.4",
|
|
4
|
+
"description": "Encrypted local Codex bridge with automatic token authentication and workspace access",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"bin": { "web-codex-client": "dist/cli.js" },
|
|
7
7
|
"files": ["dist/cli.js", "dist/assets/noise.wasm", "README.md", "NPM_RELEASE.md", "sdk/THIRD_PARTY_NOTICES.md", "deploy/config.example.json", "deploy/config.remote.example.json", "deploy/web-codex-client.service"],
|