@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 CHANGED
@@ -1,17 +1,16 @@
1
1
  # npm 客户端发布与安装
2
2
 
3
- 包名:`@landh93/web-codex-client`。当前候选版为 `0.1.0-rc.2`,使用 `next` 标签。仅支持 Linux / Node.js 24;这是独立安装候选版,不代表公网、多人或真实模型执行已完成生产验收。`remote-wss-v1` 明确允许远端 WSS,继续使用固定 Noise 加密、TLS 校验及本机指纹授权,不新增任何加密实现。项目尚未选定开源许可证,包暂保留 `UNLICENSED`;第三方代码许可见包内 `sdk/THIRD_PARTY_NOTICES.md`。
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。交互配置依次输入 WSS Origin、Codex socket(回车使用默认值)、设备 token(隐藏输入)。设备 token 来自 Web 工作台的设备注册,**不是 npm 发布 token**。
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`,token Authorization 头发送。
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 运行后,在网页选择设备,然后在另一终端执行 **`web-codex-client pair`**。对照终端与网页的完整指纹,在网页点击“与本机显示一致”,再在终端输入 `yes`。无需提前执行 `pair-open`,无需复制 64 位指纹或逐个配置已有项目目录。
27
+ Client 运行后,在网页输入同一个 Token 即可:自动选择设备、建立端到端加密连接、加载项目与会话。打开会话即可输入消息;写租约自动取得和续期,只有另一个浏览器占用时才需要处理冲突。无需 `pair-open`、`pair`、指纹核对或“申请控制权”。网页刷新后在同一标签页自动重连;Token 只保存在该标签页的 sessionStorage,退出登录清除。
29
28
 
30
- 核对并信任浏览器后,从本机 Codex 项目目录及活动/归档历史的 cwd 识别已有项目,自动授予这些目录的读写权限;扫描有分页、去重和数量上限,不会把浏览器任意指定的路径当作已有项目。系统根目录、用户家目录以及包含 Client 状态的父目录不自动授予。没有识别到项目时明确报错,可以使用 `pair --root /absolute/project` 在终端明确确认目录读写授权。
29
+ 默认授权本机 Codex 项目目录与活动/归档会话元数据识别出的已有项目读写,保留目录边界和 Codex 执行审批。授权在每次连接时重新读取,不持久保存临时浏览器身份。不同 token 对应不同设备,不能凭一个设备 Token 查看其他设备。
31
30
 
32
- 新增目录执行 **`web-codex-client authorize --root /absolute/directory`**:已有 Codex 项目直接授权读写,其他目录要求终端输入 `yes`。若有多个活动浏览器,使用 `--peer` 指定 `status` 中的浏览器公钥。权限按目录检查,新增写权限不会提升其他只读目录。Codex 执行任务仍使用工作区沙箱、操作审批及写租约;网页中的“申请控制权”管理同一对话的写入归属。
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.2.tgz \
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` 交互输入 WSS 地址、Codex socket 和隐藏的设备 token,保存在用户配置目录。`doctor` 检查私有配置、WASM、WSS 认证和 Codex 初始化,失败退出非零;既有 Client 在线时不会抢占它的连接。支持 `--quiet`。安装和升级不会重启 Codex,也不会自动批准浏览器。
19
+ `configure` 只需服务端 HTTPS/WSS 地址和连接 Token。启动后,在网页输入同一个 Token,就能查看项目、session 并发送对话;无需配对命令、指纹核对或申请控制权。Codex socket 自动发现,`doctor` 仅用于故障诊断。
21
20
 
22
- 网页选择设备后,另开终端运行 `web-codex-client pair`,核对完整指纹并在网页和终端确认。无需提前开启窗口;Codex 已有项目自动授予读写,新目录用 `web-codex-client authorize --root /absolute/path` 在本机确认。配置、身份和授权独立于 npm 安装目录,升级时保留。完整安装、token 标准输入、证书、后台运行与发布说明见 [NPM_RELEASE.md](./NPM_RELEASE.md)。
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.3.tgz`。Server 复制该文件到自己的 `vendor/` 后安装并固定锁文件,核对同名 `.sha256`。不要覆盖已发布文件,也不要通过 link: 指向本目录源码。构建后 `pnpm pack:sdk` 会拒绝覆盖已有版本;修改 SDK 需先调整 `scripts/build.mjs` 中的版本。
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
- 先取得 Server 提供的设备中继凭据和 WSS 地址。配置实例见 `deploy/config.example.json`。本机使用 `cryptoPolicy:"local-reviewed-v1"`,只允许回环 WSS;远端使用 `cryptoPolicy:"remote-wss-v1"`,示例见 `deploy/config.remote.example.json`。远端策略只明确放行 WSS 主机,不代表新增生产安全审查;TLS、固定 Noise 套件、完整指纹与本机授权照常执行。`experimentalCrypto:true` 保留兼容已有开发联调配置。工程审查与第三方审计边界见 [CRYPTO_REVIEW.md](./CRYPTO_REVIEW.md)。
48
+ 默认使用 Token 自动认证。Client 与网页分别持有相同 Token,通过 Noise 握手哈希和角色绑定的 HMAC 验证对端;中继只接收 Token 哈希凭据,不处理会话明文。Token 持有者可访问该设备已经在 Codex 中打开过的项目并读写。
50
49
 
51
- ```bash
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
- `status` 分别返回 relayOnline、handshakes、authorizedSessions、codexAvailable。Codex 连接按第一次已授权请求惰性建立,因此仅中继上线时 codexAvailable 仍为 false。上游断线后下一次请求重连,旧 epoch/订阅失效。后台服务无有限总工作量,不显示伪造百分比。
52
+ 管理员使用 Server 管理员口令登录后,可以连接该账号的所有在线设备,不需要逐台输入 TokenClient 信任配置的 Server 签发仅限当前路由的管理员凭据,并将其绑定到 Noise 握手验证;已识别项目范围、写租约及 Codex 审批仍生效。旧 Client rc.3 必须升级并重启才能接受管理员连接。
81
53
 
82
- `deploy/web-codex-client.service` 是 systemd --user 模板;调整 Node/项目绝对路径后自行安装启用。本次未修改或启用用户 systemd 服务。每个 stateDir 只运行一个实例;启动时拒绝已有活动控制 socket,并清理同用户拥有的失效 socket。
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", (data) => this.parse(data.toString()));
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 key = String(m.id);
128
- const p = this.pending.get(key);
127
+ const key2 = String(m.id);
128
+ const p = this.pending.get(key2);
129
129
  if (!p) return;
130
- this.pending.delete(key);
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 data = JSON.stringify(value);
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(data);
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(data + "\n");
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 bytes = await module.arrayBuffer();
607
- return await WebAssembly.instantiate(bytes, imports);
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(bytes) {
754
- const digest = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", new Uint8Array(bytes))), (n) => n.toString(16).padStart(2, "0")).join("");
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: bytes });
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, key) {
774
+ Initialize(prologue2, key2) {
775
775
  if (this.inner) throw Error("HANDSHAKE_STATE");
776
- this.inner = new Handshake(this.role === 1, prologue2, key);
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 bytes = new TextEncoder().encode(JSON.stringify(parseMessage(value)));
948
- if (bytes.length > MAX_MESSAGE_BYTES) throw Error("RESOURCE_TOO_LARGE");
949
- return bytes;
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 bytes = encodeMessage(message);
1172
- const total = Math.ceil(bytes.length / CHUNK_BYTES);
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: bytes.length,
1182
+ size: bytes2.length,
1182
1183
  data: b64(
1183
- bytes.subarray(index * CHUNK_BYTES, (index + 1) * CHUNK_BYTES)
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 data = JSON.parse(await privateRead(path));
1358
+ const data2 = JSON.parse(await privateRead(path));
1340
1359
  identity = {
1341
- privateKey: unb64(data.privateKey),
1342
- publicKey: unb64(data.publicKey)
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 bytes = Buffer.alloc(Math.min(length, h.size - offset));
1498
- const { bytesRead } = await h.fd.read(bytes, 0, bytes.length, offset);
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 !== bytes.length) throw new BridgeError("RESOURCE_CHANGED");
1519
+ if (bytesRead !== bytes2.length) throw new BridgeError("RESOURCE_CHANGED");
1501
1520
  return {
1502
1521
  resourceId: id2,
1503
1522
  offset,
1504
- data: bytes.toString("base64url"),
1505
- sha256: createHash("sha256").update(bytes).digest("hex"),
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 key = owner + ":" + id2;
1594
+ const key2 = owner + ":" + id2;
1576
1595
  const digest = createHash2("sha256").update(JSON.stringify(body)).digest("hex");
1577
- const old = this.entries.get(key);
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 bytes = Buffer.byteLength(JSON.stringify(result));
1589
- if (bytes > 1024 * 1024 || this.bytes + bytes > 8 * 1024 * 1024)
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 += bytes;
1613
+ this.bytes += bytes2;
1595
1614
  return result;
1596
1615
  });
1597
- this.entries.set(key, { digest, promise, at: Date.now() });
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 data2 = h.data.slice(h.offset, h.offset + p.limit);
1824
- h.offset += data2.length;
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: data2, nextCursor: nextCursor2, source: "bounded-fallback" };
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 bytes = Buffer.byteLength(JSON.stringify(r));
1860
- if (bytes > 8 * 1024 * 1024 || Array.from(this.histories.values()).reduce((n, h) => n + h.bytes, 0) + bytes > 8 * 1024 * 1024)
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 data = method === "turns.list" ? turns.map((t) => ({ ...t, items: [] })) : turns.filter((t) => !p.turnId || p.turnId === t.id).flatMap(
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 = data.length > p.limit ? "fallback:" + randomUUID4() : null;
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: data.slice(0, p.limit),
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 data = [];
1911
+ const data2 = [];
1893
1912
  let pages = 0;
1894
1913
  const visited = /* @__PURE__ */ new Set();
1895
- while (data.length < p.limit) {
1896
- const part = h.data.slice(h.offset, h.offset + p.limit - data.length);
1897
- data.push(...part);
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 (data.length >= p.limit || h.upstream === null || pages >= 10) break;
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 bytes = Buffer.byteLength(JSON.stringify(r));
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 (bytes > 8 * 1024 * 1024 || retained + bytes > 8 * 1024 * 1024) throw new BridgeError("RESOURCE_TOO_LARGE");
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 = 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 data = [];
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
- data.push({
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 data = [];
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) - data.length,
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
- data.push({
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 || data.length >= (p.limit ?? 50)) break;
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 data = [];
2116
+ const data2 = [];
2098
2117
  for (const t of r.data)
2099
2118
  try {
2100
2119
  await authorizedPath(g.roots, t.cwd);
2101
- data.push(t);
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: { Authorization: "Bearer " + this.token },
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", (data) => {
2491
+ this.ws.on("message", (data2) => {
2466
2492
  try {
2467
- this.receive(data.toString());
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 (grant) {
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 bytes = Buffer.byteLength(JSON.stringify(message));
2588
- if (r.bytes + bytes > 1024 * 1024 || this.ws.bufferedAmount > 4 * 1024 * 1024) {
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 += 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 data = "";
2871
+ let data2 = "";
2819
2872
  socket.on("data", (chunk) => {
2820
- data += chunk.toString();
2821
- if (data.length > 16384) {
2873
+ data2 += chunk.toString();
2874
+ if (data2.length > 16384) {
2822
2875
  socket.destroy();
2823
2876
  return;
2824
2877
  }
2825
- if (!data.includes("\n")) return;
2878
+ if (!data2.includes("\n")) return;
2826
2879
  socket.pause();
2827
2880
  void Promise.resolve().then(() => {
2828
- const request = JSON.parse(data);
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 data = "";
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
- data += chunk.toString();
2877
- if (Buffer.byteLength(data) > 65536) socket.destroy(Error("ADMIN_TOO_LARGE"));
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(data));
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: "Bearer " + token },
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", (bytes) => {
3069
+ ws.on("message", (bytes2) => {
3009
3070
  try {
3010
- const frame = JSON.parse(bytes.toString());
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 wss://HOST --token-stdin] [--socket PATH]\nweb-codex-client doctor [--config FILE] [--quiet] [--report FILE]\nweb-codex-client run [--config FILE]\nweb-codex-client pair [--root PATH] [--read-only] [--config FILE] (\u4EA4\u4E92\u6838\u5BF9\u6307\u7EB9\uFF1BCodex \u5DF2\u6709\u9879\u76EE\u9ED8\u8BA4\u8BFB\u5199)\nweb-codex-client authorize --root PATH [--peer PUBLIC_KEY] (\u65B0\u589E\u76EE\u5F55\uFF0C\u672C\u673A\u786E\u8BA4\u540E\u8BFB\u5199)\nweb-codex-client projects-known [--config FILE]\nweb-codex-client init [--state DIR]\nweb-codex-client status|pair-open|pair-list [--state DIR] [--config FILE]\nweb-codex-client pair-approve --fingerprint FULL_HASH [--root PATH] [--write|--read-only] [--state DIR]\nweb-codex-client revoke --peer PUBLIC_KEY [--state DIR]\nweb-codex-client probe [--transport unix|proxy] [--socket PATH] [--binary PATH] [--quiet] [--report FILE] (default: unix; --standalone starts an isolated stdio server)\nDefault config: " + defaultConfigPath()
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("Relay WSS origin (e.g. wss://relay.example.com): ")).trim();
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 ?? (values["token-stdin"] ? void 0 : (await question("Codex socket [" + controlSocketPath({ mode: "unix" }) + "]: ")).trim() || void 0);
3117
- const token = values["token-stdin"] ? await tokenFromStdin() : await question("Device token (hidden): ", true);
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 + "\nRun web-codex-client doctor, then web-codex-client run. Pairing and directory approval are still required.");
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. \u5728\u7F51\u9875\u9009\u62E9\u8BBE\u5907\u540E\uFF0C\u53E6\u5F00\u7EC8\u7AEF\u6267\u884C web-codex-client pair \u6838\u5BF9\u5E76\u4FE1\u4EFB\u6D4F\u89C8\u5668\u3002"
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.2",
4
- "description": "Encrypted local Codex bridge with explicit browser pairing and workspace authorization",
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"],