@landh93/web-codex-client 0.1.0-rc.1 → 0.1.0-rc.3

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.1`,使用 `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.3`,使用 `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,9 +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-open`、`pair-list`,核对浏览器的完整指纹,再用 `pair-approve --fingerprint FULL_HASH --root /absolute/project` 授权。默认只读,需要执行任务时才加 `--write`。这些命令自动使用配置中的 stateDir;旧的 `--state` 用法仍有效。
27
+ Client 运行后,在网页输入同一个 Token 即可:自动选择设备、建立端到端加密连接、加载项目与会话。打开会话即可输入消息;写租约自动取得和续期,只有另一个浏览器占用时才需要处理冲突。无需 `pair-open`、`pair`、指纹核对或“申请控制权”。网页刷新后在同一标签页自动重连;Token 只保存在该标签页的 sessionStorage,退出登录清除。
28
+
29
+ 默认授权本机 Codex 项目目录与活动/归档会话元数据识别出的已有项目读写,保留目录边界和 Codex 执行审批。授权在每次连接时重新读取,不持久保存临时浏览器身份。不同 token 对应不同设备,不能凭一个设备 Token 查看其他设备。
30
+
31
+ 此版本需要同时更新 Server 网关、网页和 Client(SDK rc.4)。旧 Server 不支持新的 Token 登录;应先部署 Server 再升级并重启 Client。原有设备 Token 可继续使用,无需重新注册。新部署的 Server 可通过 `CONNECTION_TOKEN_FILE=/private/path/token` 直接配置同一个 Token,无需先创建管理员账号;文件权限为 0600,Token 至少 32 个无空白字符。管理员口令和 `pairingMode:"manual"` 仅保留为兼容入口,不属于默认连接流程。
29
32
 
30
33
  后台常驻可使用 systemd 用户服务,将 `ExecStart` 设为实际安装的 `web-codex-client run --config /absolute/config.json`。npm 安装不会自动启用服务或打开配对窗口。
31
34
 
@@ -65,7 +68,7 @@ npm whoami --registry=https://registry.npmjs.org/ --userconfig="$HOME/.config/we
65
68
  随后发布已通过验证的**具体 tgz**,不重新构建另一个包:
66
69
 
67
70
  ```bash
68
- npm publish .test-output/landh93-web-codex-client-0.1.0-rc.1.tgz \
71
+ npm publish .test-output/landh93-web-codex-client-0.1.0-rc.3.tgz \
69
72
  --access public --tag next --ignore-scripts \
70
73
  --registry=https://registry.npmjs.org/ \
71
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-open`,浏览器连接后用 `pair-list` 核对完整指纹,再 `pair-approve --fingerprint FULL_HASH --root /absolute/project`。配置、身份和授权独立于 npm 安装目录,升级时保留。完整安装、token 标准输入、证书、后台运行与发布说明见 [NPM_RELEASE.md](./NPM_RELEASE.md)。
21
+ 已有 Codex 项目自动允许读写,执行审批和目录边界仍保留。配置、Token 和身份与 npm 安装目录分开存放,升级保留。Client rc.3 需要新版 Server SDK rc.4;请先更新 Server,再升级 Client。完整安装和发布说明见 [NPM_RELEASE.md](./NPM_RELEASE.md)。
23
22
 
24
23
  ## 安装、构建、自测
25
24
 
@@ -42,44 +41,15 @@ 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-open --state ~/.config/web-codex-client/state
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
- 配对窗口 120 秒,本机确认还要求浏览器已确认相同指纹。默认只读;`--write` 才允许执行和租约。撤销立即关闭该身份的会话并拒绝后续业务。浏览器身份默认仅存内存,刷新后重新配对;同一页面断线重连可以继续使用内存身份和固定公钥。
79
-
80
- `status` 分别返回 relayOnline、handshakes、authorizedSessions、codexAvailable。Codex 连接按第一次已授权请求惰性建立,因此仅中继上线时 codexAvailable 仍为 false。上游断线后下一次请求重连,旧 epoch/订阅失效。后台服务无有限总工作量,不显示伪造百分比。
50
+ 只允许 WSS 并验证证书;私有 CA 使用 `NODE_EXTRA_CA_CERTS`。浏览器在当前标签页保存 Token 以支持刷新自动重连,退出登录清除。已识别项目每次连接重新扫描,根目录、用户主目录和 Client 私有状态的父目录不会自动授权。
81
51
 
82
- `deploy/web-codex-client.service` 是 systemd --user 模板;调整 Node/项目绝对路径后自行安装启用。本次未修改或启用用户 systemd 服务。每个 stateDir 只运行一个实例;启动时拒绝已有活动控制 socket,并清理同用户拥有的失效 socket。
52
+ 旧的人工配对仅在配置显式设置 `pairingMode:"manual"` 时启用,不再是默认流程。每个 stateDir 只运行一个 Client;`status` 和 `doctor` 可诊断连接,`deploy/web-codex-client.service` 提供后台运行模板。
83
53
 
84
54
  ## Codex 接入与只读诊断
85
55
 
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");
@@ -1356,15 +1375,18 @@ async function loadState(dir) {
1356
1375
  list = z4.array(
1357
1376
  z4.strictObject({
1358
1377
  peer: z4.string(),
1359
- roots: z4.array(z4.string()).min(1).max(32),
1360
- write: z4.boolean()
1378
+ roots: z4.array(z4.string()).min(1).max(256),
1379
+ write: z4.boolean(),
1380
+ writeRoots: z4.array(z4.string()).max(256).optional()
1361
1381
  })
1362
1382
  ).max(256).parse(JSON.parse(await privateRead(dir + "/grants.json")));
1363
1383
  } catch (e) {
1364
1384
  if (e.code !== "ENOENT") throw e;
1365
1385
  }
1366
- for (const g of list)
1386
+ for (const g of list) {
1367
1387
  g.roots = await Promise.all(g.roots.map((r) => realpath(r)));
1388
+ if (g.writeRoots) g.writeRoots = await Promise.all(g.writeRoots.map((r) => realpath(r)));
1389
+ }
1368
1390
  return { identity, grants: new Map(list.map((g) => [g.peer, g])) };
1369
1391
  }
1370
1392
 
@@ -1374,7 +1396,7 @@ import {
1374
1396
  createServer,
1375
1397
  createConnection
1376
1398
  } from "node:net";
1377
- import { chmod, realpath as realpath3, unlink, lstat as lstat2 } from "node:fs/promises";
1399
+ import { chmod, realpath as realpath4, unlink, lstat as lstat2, stat as stat3 } from "node:fs/promises";
1378
1400
  import { z as z5 } from "zod";
1379
1401
 
1380
1402
  // src/bridge.ts
@@ -1491,15 +1513,15 @@ var FileResources = class {
1491
1513
  };
1492
1514
  try {
1493
1515
  await verify();
1494
- const bytes = Buffer.alloc(Math.min(length, h.size - offset));
1495
- 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);
1496
1518
  await verify();
1497
- if (bytesRead !== bytes.length) throw new BridgeError("RESOURCE_CHANGED");
1519
+ if (bytesRead !== bytes2.length) throw new BridgeError("RESOURCE_CHANGED");
1498
1520
  return {
1499
1521
  resourceId: id2,
1500
1522
  offset,
1501
- data: bytes.toString("base64url"),
1502
- sha256: createHash("sha256").update(bytes).digest("hex"),
1523
+ data: bytes2.toString("base64url"),
1524
+ sha256: createHash("sha256").update(bytes2).digest("hex"),
1503
1525
  eof: offset + bytesRead === h.size
1504
1526
  };
1505
1527
  } catch (e) {
@@ -1569,9 +1591,9 @@ var Dedup = class {
1569
1591
  bytes = 0;
1570
1592
  entries = /* @__PURE__ */ new Map();
1571
1593
  run(owner, id2, body, fn) {
1572
- const key = owner + ":" + id2;
1594
+ const key2 = owner + ":" + id2;
1573
1595
  const digest = createHash2("sha256").update(JSON.stringify(body)).digest("hex");
1574
- const old = this.entries.get(key);
1596
+ const old = this.entries.get(key2);
1575
1597
  if (old) {
1576
1598
  if (old.digest !== digest) throw new BridgeError("REQUEST_ID_REUSED");
1577
1599
  return old.promise;
@@ -1582,16 +1604,16 @@ var Dedup = class {
1582
1604
  "Dedup budget exhausted; reconnect after a deliberate client restart and reconcile outcomes"
1583
1605
  );
1584
1606
  const promise = Promise.resolve().then(fn).then((result) => {
1585
- const bytes = Buffer.byteLength(JSON.stringify(result));
1586
- 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)
1587
1609
  throw new BridgeError(
1588
1610
  "OUTCOME_UNKNOWN",
1589
1611
  "Write executed; response exceeds retained dedup budget; reconcile upstream"
1590
1612
  );
1591
- this.bytes += bytes;
1613
+ this.bytes += bytes2;
1592
1614
  return result;
1593
1615
  });
1594
- this.entries.set(key, { digest, promise, at: Date.now() });
1616
+ this.entries.set(key2, { digest, promise, at: Date.now() });
1595
1617
  return promise;
1596
1618
  }
1597
1619
  };
@@ -1772,6 +1794,11 @@ var Bridge = class {
1772
1794
  if (!grant) throw new BridgeError("NOT_PAIRED");
1773
1795
  return grant;
1774
1796
  }
1797
+ async writable(owner, path) {
1798
+ const grant = this.grant(owner);
1799
+ if (!grant.write) throw new BridgeError("FORBIDDEN", "\u76EE\u5F55\u5C1A\u672A\u5141\u8BB8\u5199\u5165\uFF0C\u8BF7\u5728\u672C\u673A\u8FD0\u884C web-codex-client authorize --root \u76EE\u5F55\u3002");
1800
+ await authorizedPath(grant.writeRoots ?? grant.roots, path);
1801
+ }
1775
1802
  async project(owner, id2) {
1776
1803
  const g = this.grant(owner);
1777
1804
  if (id2.startsWith("cwd:")) {
@@ -1812,12 +1839,12 @@ var Bridge = class {
1812
1839
  const h = this.histories.get(p.cursor);
1813
1840
  if (!h || h.owner !== owner || h.thread !== p.threadId || h.kind !== method + ":" + (p.turnId ?? "") || h.expires < Date.now())
1814
1841
  throw new BridgeError("RESYNC_REQUIRED");
1815
- const data2 = h.data.slice(h.offset, h.offset + p.limit);
1816
- h.offset += data2.length;
1842
+ const data3 = h.data.slice(h.offset, h.offset + p.limit);
1843
+ h.offset += data3.length;
1817
1844
  this.histories.delete(p.cursor);
1818
1845
  const nextCursor2 = h.offset < h.data.length ? "fallback:" + randomUUID4() : null;
1819
1846
  if (nextCursor2) this.histories.set(nextCursor2, h);
1820
- return { data: data2, nextCursor: nextCursor2, source: "bounded-fallback" };
1847
+ return { data: data3, nextCursor: nextCursor2, source: "bounded-fallback" };
1821
1848
  }
1822
1849
  try {
1823
1850
  const r2 = await this.upstream.call(
@@ -1848,31 +1875,31 @@ var Bridge = class {
1848
1875
  threadId: p.threadId,
1849
1876
  includeTurns: true
1850
1877
  });
1851
- const bytes = Buffer.byteLength(JSON.stringify(r));
1852
- 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)
1853
1880
  throw new BridgeError(
1854
1881
  "RESOURCE_TOO_LARGE",
1855
1882
  "Legacy history exceeds bounded fallback budget"
1856
1883
  );
1857
1884
  const turns = (r.thread.turns ?? []).slice().reverse();
1858
- 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(
1859
1886
  (t) => (t.items ?? []).slice().reverse().map((item) => ({ turnId: t.id, item }))
1860
1887
  );
1861
- const nextCursor = data.length > p.limit ? "fallback:" + randomUUID4() : null;
1888
+ const nextCursor = data2.length > p.limit ? "fallback:" + randomUUID4() : null;
1862
1889
  if (nextCursor) {
1863
1890
  if (this.histories.size >= 32) throw new BridgeError("BUSY");
1864
1891
  this.histories.set(nextCursor, {
1865
1892
  owner,
1866
1893
  thread: p.threadId,
1867
1894
  kind: method + ":" + (p.turnId ?? ""),
1868
- data,
1895
+ data: data2,
1869
1896
  offset: p.limit,
1870
- bytes,
1897
+ bytes: bytes2,
1871
1898
  expires: Date.now() + 6e4
1872
1899
  });
1873
1900
  }
1874
1901
  return {
1875
- data: data.slice(0, p.limit),
1902
+ data: data2.slice(0, p.limit),
1876
1903
  nextCursor,
1877
1904
  source: "bounded-fallback"
1878
1905
  };
@@ -1881,25 +1908,25 @@ var Bridge = class {
1881
1908
  * Buffer at most one turn; never load a whole long conversation for this path. */
1882
1909
  async turnItems(owner, p, previous) {
1883
1910
  const h = previous ? { ...previous } : { owner, thread: p.threadId, kind: "items.list:" + (p.turnId ?? ""), data: [], offset: 0, bytes: 0, expires: Date.now() + 6e4 };
1884
- const data = [];
1911
+ const data2 = [];
1885
1912
  let pages = 0;
1886
1913
  const visited = /* @__PURE__ */ new Set();
1887
- while (data.length < p.limit) {
1888
- const part = h.data.slice(h.offset, h.offset + p.limit - data.length);
1889
- 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);
1890
1917
  h.offset += part.length;
1891
- if (data.length >= p.limit || h.upstream === null || pages >= 10) break;
1918
+ if (data2.length >= p.limit || h.upstream === null || pages >= 10) break;
1892
1919
  if (h.upstream && visited.has(h.upstream)) throw new BridgeError("RESYNC_REQUIRED");
1893
1920
  if (h.upstream) visited.add(h.upstream);
1894
1921
  const r = await this.upstream.call("thread/turns/list", { threadId: p.threadId, cursor: h.upstream, limit: 1, sortDirection: "desc", itemsView: "full" });
1895
1922
  pages++;
1896
- const bytes = Buffer.byteLength(JSON.stringify(r));
1923
+ const bytes2 = Buffer.byteLength(JSON.stringify(r));
1897
1924
  const retained = Array.from(this.histories.values()).reduce((sum, entry) => sum + (entry === previous ? 0 : entry.bytes), 0);
1898
- 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");
1899
1926
  if (r.data.some((turn) => turn.itemsView && turn.itemsView !== "full")) throw new BridgeError("UNSUPPORTED_CAPABILITY", "Upstream did not return full turn items");
1900
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 })));
1901
1928
  h.offset = 0;
1902
- h.bytes = bytes;
1929
+ h.bytes = bytes2;
1903
1930
  h.upstream = r.nextCursor ?? null;
1904
1931
  }
1905
1932
  const more = h.offset < h.data.length || h.upstream !== null;
@@ -1907,7 +1934,7 @@ var Bridge = class {
1907
1934
  if (previous) this.histories.delete(p.cursor);
1908
1935
  const nextCursor = more ? "turn-items:" + randomUUID4() : null;
1909
1936
  if (nextCursor) this.histories.set(nextCursor, h);
1910
- return { data, nextCursor, source: "bounded-fallback" };
1937
+ return { data: data2, nextCursor, source: "bounded-fallback" };
1911
1938
  }
1912
1939
  async handle(owner, message) {
1913
1940
  if (message.kind === "serverResponse") {
@@ -1979,7 +2006,10 @@ var Bridge = class {
1979
2006
  historyFallback: "bounded",
1980
2007
  activeTakeover: "unverified"
1981
2008
  };
1982
- if ("threadId" in p) await this.thread(owner, p.threadId);
2009
+ if ("threadId" in p) {
2010
+ const thread2 = await this.thread(owner, p.threadId);
2011
+ if (writes.has(method) || method === "leases.acquire") await this.writable(owner, thread2.cwd);
2012
+ }
1983
2013
  if (writes.has(method) && "threadId" in p) {
1984
2014
  this.leases.assert(p.threadId, owner);
1985
2015
  if ((method === "turns.start" || method === "turns.interrupt") && p.connectionEpoch !== this.subscriptions.epoch)
@@ -1999,12 +2029,12 @@ var Bridge = class {
1999
2029
  const r = await this.upstream.call("project/list", p);
2000
2030
  if (!p.cursor && r.data.length === 0 && !r.nextCursor)
2001
2031
  throw new RpcError(-32601, "Empty project catalog; discover history roots");
2002
- const data = [];
2032
+ const data2 = [];
2003
2033
  for (const item of r.data) {
2004
2034
  for (const root of item.roots ?? []) {
2005
2035
  try {
2006
2036
  const cwd = await authorizedPath(g.roots, root.path);
2007
- data.push({
2037
+ data2.push({
2008
2038
  id: item.id,
2009
2039
  name: item.name,
2010
2040
  cwd,
@@ -2015,10 +2045,10 @@ var Bridge = class {
2015
2045
  }
2016
2046
  }
2017
2047
  }
2018
- return { data, nextCursor: r.nextCursor ?? null };
2048
+ return { data: data2, nextCursor: r.nextCursor ?? null };
2019
2049
  } catch (e) {
2020
2050
  if (!unsupported(e)) throw e;
2021
- const data = [];
2051
+ const data2 = [];
2022
2052
  const seen = new Set(previous?.seen);
2023
2053
  let upstreamCursor = previous?.upstream ?? p.cursor;
2024
2054
  const visited = /* @__PURE__ */ new Set();
@@ -2029,7 +2059,7 @@ var Bridge = class {
2029
2059
  }
2030
2060
  const r = await this.upstream.call("thread/list", {
2031
2061
  cursor: upstreamCursor,
2032
- limit: (p.limit ?? 50) - data.length,
2062
+ limit: (p.limit ?? 50) - data2.length,
2033
2063
  useStateDbOnly: true,
2034
2064
  sourceKinds: sources,
2035
2065
  sortKey: "updated_at",
@@ -2044,7 +2074,7 @@ var Bridge = class {
2044
2074
  }
2045
2075
  if (!seen.has(cwd)) {
2046
2076
  seen.add(cwd);
2047
- data.push({
2077
+ data2.push({
2048
2078
  id: "cwd:" + Buffer.from(cwd).toString("base64url"),
2049
2079
  name: basename2(cwd),
2050
2080
  cwd,
@@ -2053,7 +2083,7 @@ var Bridge = class {
2053
2083
  }
2054
2084
  }
2055
2085
  upstreamCursor = r.nextCursor ?? void 0;
2056
- if (!upstreamCursor || data.length >= (p.limit ?? 50)) break;
2086
+ if (!upstreamCursor || data2.length >= (p.limit ?? 50)) break;
2057
2087
  }
2058
2088
  if (seen.size > 1024 || this.projectCursors.size >= 128)
2059
2089
  throw new BridgeError("RESOURCE_TOO_LARGE");
@@ -2066,7 +2096,7 @@ var Bridge = class {
2066
2096
  seen,
2067
2097
  expires: Date.now() + 6e4
2068
2098
  });
2069
- return { data, nextCursor };
2099
+ return { data: data2, nextCursor };
2070
2100
  }
2071
2101
  }
2072
2102
  case "projects.read":
@@ -2083,14 +2113,14 @@ var Bridge = class {
2083
2113
  sortKey: "updated_at",
2084
2114
  sortDirection: "desc"
2085
2115
  });
2086
- const data = [];
2116
+ const data2 = [];
2087
2117
  for (const t of r.data)
2088
2118
  try {
2089
2119
  await authorizedPath(g.roots, t.cwd);
2090
- data.push(t);
2120
+ data2.push(t);
2091
2121
  } catch {
2092
2122
  }
2093
- return { data, nextCursor: r.nextCursor ?? null };
2123
+ return { data: data2, nextCursor: r.nextCursor ?? null };
2094
2124
  }
2095
2125
  case "threads.read":
2096
2126
  return { thread: await this.thread(owner, p.threadId) };
@@ -2124,6 +2154,7 @@ var Bridge = class {
2124
2154
  }
2125
2155
  case "threads.start": {
2126
2156
  const project = await this.project(owner, p.projectId);
2157
+ await this.writable(owner, project.cwd);
2127
2158
  const r = await this.upstream.call("thread/start", {
2128
2159
  cwd: project.cwd,
2129
2160
  sandbox: "workspace-write",
@@ -2237,7 +2268,8 @@ var Bridge = class {
2237
2268
  throw new BridgeError("APPROVAL_EXPIRED");
2238
2269
  if (!g.write) throw new BridgeError("FORBIDDEN");
2239
2270
  this.leases.assert(a.thread, owner);
2240
- await this.thread(owner, a.thread);
2271
+ const thread2 = await this.thread(owner, a.thread);
2272
+ await this.writable(owner, thread2.cwd);
2241
2273
  if (this.approvals.get(id2) !== a) throw new BridgeError("APPROVAL_EXPIRED");
2242
2274
  this.grant(owner);
2243
2275
  this.leases.assert(a.thread, owner);
@@ -2296,7 +2328,84 @@ var Bridge = class {
2296
2328
  }
2297
2329
  };
2298
2330
 
2331
+ // src/admin-errors.ts
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",
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",
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",
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",
2337
+ PAIRING_WINDOW_EXPIRED: "\u914D\u5BF9\u7A97\u53E3\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u8FDE\u63A5\u7F51\u9875\u3002",
2338
+ DIRECTORY_UNAVAILABLE: "\u76EE\u5F55\u4E0D\u5B58\u5728\u3001\u4E0D\u662F\u76EE\u5F55\u6216\u5F53\u524D\u7528\u6237\u65E0\u6CD5\u8BBF\u95EE\u3002",
2339
+ GRANT_SAVE_FAILED: "\u6388\u6743\u4FDD\u5B58\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u72B6\u6001\u76EE\u5F55\u6743\u9650\u548C\u78C1\u76D8\u7A7A\u95F4\u3002",
2340
+ GRANT_LIMIT: "\u6388\u6743\u6570\u91CF\u5DF2\u8FBE\u4E0A\u9650\uFF0C\u8BF7\u5148\u64A4\u9500\u4E0D\u518D\u4F7F\u7528\u7684\u6D4F\u89C8\u5668\u3002",
2341
+ CODEX_UNAVAILABLE: "\u65E0\u6CD5\u8BFB\u53D6\u672C\u673A Codex \u9879\u76EE\uFF0C\u8BF7\u8FD0\u884C web-codex-client doctor\u3002",
2342
+ PROJECT_DISCOVERY_LIMIT: "\u9879\u76EE\u76EE\u5F55\u626B\u63CF\u8FBE\u5230\u4E0A\u9650\uFF0C\u8BF7\u7528 --root \u660E\u786E\u6307\u5B9A\u9879\u76EE\u3002",
2343
+ NO_KNOWN_PROJECTS: "\u6CA1\u6709\u627E\u5230\u53EF\u81EA\u52A8\u6388\u6743\u7684 Codex \u9879\u76EE\u3002\u8BF7\u7528 --root \u6307\u5B9A\u76EE\u5F55\u5E76\u786E\u8BA4\u6388\u6743\u3002",
2344
+ DIRECTORY_APPROVAL_REQUIRED: "\u8BE5\u76EE\u5F55\u4E0D\u662F\u5DF2\u8BC6\u522B\u7684 Codex \u9879\u76EE\uFF1B\u9996\u6B21\u914D\u5BF9\u7528 pair --root \u8DEF\u5F84\uFF0C\u5DF2\u914D\u5BF9\u7528 authorize --root \u8DEF\u5F84\uFF0C\u5728\u672C\u673A\u786E\u8BA4\u3002",
2345
+ PEER_REQUIRED: "\u5B58\u5728\u591A\u4E2A\u6D4F\u89C8\u5668\uFF0C\u8BF7\u7528 --peer \u6307\u5B9A pair-list \u6216 status \u4E2D\u7684\u6D4F\u89C8\u5668\u3002",
2346
+ NOT_PAIRED: "\u5C1A\u672A\u4FE1\u4EFB\u6D4F\u89C8\u5668\uFF0C\u8BF7\u5148\u5728\u7F51\u9875\u8FDE\u63A5\u5E76\u8FD0\u884C web-codex-client pair\u3002",
2347
+ INVALID_ADMIN_REQUEST: "\u547D\u4EE4\u53C2\u6570\u65E0\u6548\uFF0C\u8BF7\u67E5\u770B web-codex-client --help\u3002",
2348
+ ADMIN_REQUEST_REJECTED: "\u672C\u673A\u63A7\u5236\u8BF7\u6C42\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5 Client \u72B6\u6001\u3002"
2349
+ };
2350
+ var AdminError = class extends Error {
2351
+ constructor(code) {
2352
+ super(adminMessages[code]);
2353
+ this.code = code;
2354
+ }
2355
+ };
2356
+ function adminFailure(error) {
2357
+ const code = error instanceof AdminError ? error.code : error instanceof Error && error.name === "ZodError" || error instanceof SyntaxError ? "INVALID_ADMIN_REQUEST" : "ADMIN_REQUEST_REJECTED";
2358
+ return { error: code, message: adminMessages[code] };
2359
+ }
2360
+
2361
+ // src/known-projects.ts
2362
+ import { realpath as realpath3, stat as stat2 } from "node:fs/promises";
2363
+ import { homedir as homedir2 } from "node:os";
2364
+ import { isAbsolute as isAbsolute2 } from "node:path";
2365
+ async function knownProjects(upstream, stateDir) {
2366
+ const roots = /* @__PURE__ */ new Set();
2367
+ async function add(value) {
2368
+ if (typeof value !== "string" || !isAbsolute2(value)) return;
2369
+ try {
2370
+ const root = await realpath3(value);
2371
+ if (!(await stat2(root)).isDirectory() || root === "/" || root === homedir2() || within(root, stateDir)) return;
2372
+ roots.add(root);
2373
+ if (roots.size > 256) throw new AdminError("PROJECT_DISCOVERY_LIMIT");
2374
+ } catch (error) {
2375
+ if (error instanceof AdminError) throw error;
2376
+ }
2377
+ }
2378
+ async function scan(method, extra, consume) {
2379
+ let cursor;
2380
+ const seen = /* @__PURE__ */ new Set();
2381
+ for (let page2 = 0; page2 < 100; page2++) {
2382
+ const result = await upstream.call(method, { ...extra, limit: 100, cursor });
2383
+ if (!Array.isArray(result.data) || result.data.length > 100) throw new AdminError("CODEX_UNAVAILABLE");
2384
+ for (const row of result.data) await consume(row);
2385
+ if (!result.nextCursor) return;
2386
+ if (typeof result.nextCursor !== "string" || seen.has(result.nextCursor)) throw new AdminError("PROJECT_DISCOVERY_LIMIT");
2387
+ cursor = result.nextCursor;
2388
+ seen.add(result.nextCursor);
2389
+ }
2390
+ throw new AdminError("PROJECT_DISCOVERY_LIMIT");
2391
+ }
2392
+ try {
2393
+ await scan("project/list", {}, async (row) => {
2394
+ for (const root of row.roots ?? []) await add(root.path);
2395
+ });
2396
+ } catch (error) {
2397
+ if (!(error instanceof RpcError && error.code === -32601)) throw error;
2398
+ }
2399
+ for (const archived of [false, true]) {
2400
+ await scan("thread/list", { useStateDbOnly: true, archived, sourceKinds: ["cli", "vscode", "exec", "appServer", "subAgent", "subAgentReview", "subAgentCompact", "subAgentThreadSpawn", "subAgentOther", "unknown"] }, async (row) => {
2401
+ await add(row.cwd);
2402
+ });
2403
+ }
2404
+ return [...roots].sort();
2405
+ }
2406
+
2299
2407
  // src/daemon.ts
2408
+ var PAIRING_MS = 5 * 60 * 1e3;
2300
2409
  var ClientDaemon = class {
2301
2410
  constructor(config, identity, grants, connectUpstream = () => CodexRpc.connect(config.upstream)) {
2302
2411
  this.config = config;
@@ -2310,18 +2419,24 @@ var ClientDaemon = class {
2310
2419
  stopped = false;
2311
2420
  routes = /* @__PURE__ */ new Map();
2312
2421
  pairUntil = 0;
2422
+ lastRouteClosure;
2423
+ expiredFingerprints = /* @__PURE__ */ new Set();
2313
2424
  admin;
2425
+ adminQueue = Promise.resolve();
2314
2426
  timer;
2315
2427
  retry;
2316
2428
  attempts = 0;
2317
2429
  noise;
2318
2430
  token = "";
2431
+ relayToken = "";
2432
+ tokenPeers = /* @__PURE__ */ new Set();
2319
2433
  alive = true;
2320
2434
  connecting;
2321
2435
  async start() {
2322
2436
  this.noise = await loadNoise();
2323
2437
  this.token = (await privateRead(this.config.deviceTokenFile)).trim();
2324
2438
  if (!this.token || /[\r\n]/.test(this.token)) throw Error("DEVICE_TOKEN");
2439
+ this.relayToken = await relayCredential(this.token);
2325
2440
  await this.startAdmin();
2326
2441
  this.connect();
2327
2442
  this.timer = setInterval(() => {
@@ -2333,7 +2448,7 @@ var ClientDaemon = class {
2333
2448
  }
2334
2449
  }
2335
2450
  for (const [id2, r] of this.routes)
2336
- if (Date.now() > r.expires && !r.owner) this.closeRoute(id2);
2451
+ if (Date.now() > r.expires && !r.owner) this.closeRoute(id2, true, "PAIRING_ROUTE_EXPIRED");
2337
2452
  }, 1e4);
2338
2453
  this.timer.unref();
2339
2454
  }
@@ -2358,7 +2473,7 @@ var ClientDaemon = class {
2358
2473
  connect() {
2359
2474
  if (this.stopped) return;
2360
2475
  this.ws = new WebSocket2(new URL("/ws/v1/client", this.config.relayUrl), {
2361
- headers: { Authorization: "Bearer " + this.token },
2476
+ headers: { Authorization: this.config.pairingMode === "manual" ? "Bearer " + this.token : "TokenHash " + this.relayToken },
2362
2477
  perMessageDeflate: false,
2363
2478
  maxPayload: 128 * 1024,
2364
2479
  handshakeTimeout: 1e4
@@ -2370,9 +2485,9 @@ var ClientDaemon = class {
2370
2485
  this.ws.on("pong", () => this.alive = true);
2371
2486
  this.ws.on("error", () => {
2372
2487
  });
2373
- this.ws.on("message", (data) => {
2488
+ this.ws.on("message", (data2) => {
2374
2489
  try {
2375
- this.receive(data.toString());
2490
+ this.receive(data2.toString());
2376
2491
  } catch {
2377
2492
  this.ws?.close(1008, "Protocol violation");
2378
2493
  }
@@ -2406,7 +2521,7 @@ var ClientDaemon = class {
2406
2521
  }
2407
2522
  this.routes.set(m.routeId, {
2408
2523
  confirmed: false,
2409
- expires: Date.now() + 12e4,
2524
+ expires: Date.now() + PAIRING_MS,
2410
2525
  queue: Promise.resolve(),
2411
2526
  pending: 0,
2412
2527
  out: [],
@@ -2416,7 +2531,7 @@ var ClientDaemon = class {
2416
2531
  return;
2417
2532
  }
2418
2533
  if (m.type === "route.closed") {
2419
- this.closeRoute(m.routeId, false);
2534
+ this.closeRoute(m.routeId, false, "RELAY_CLOSED");
2420
2535
  return;
2421
2536
  }
2422
2537
  if (m.type !== "secure") return;
@@ -2448,7 +2563,14 @@ var ClientDaemon = class {
2448
2563
  if (reply) this.ws.send(encodeFrame(reply));
2449
2564
  if (r.session.ready) {
2450
2565
  const grant = this.grants.get(r.session.peerPublicKey);
2451
- if (grant) {
2566
+ if (this.config.pairingMode !== "manual") {
2567
+ this.send(frame.routeId, {
2568
+ kind: "pairing",
2569
+ status: "pending",
2570
+ fingerprint: r.session.fingerprint,
2571
+ proof: await tokenProof(this.token, r.session.fingerprint, "client")
2572
+ });
2573
+ } else if (grant) {
2452
2574
  r.session.trust(r.session.fingerprint);
2453
2575
  r.owner = grant.peer;
2454
2576
  this.send(frame.routeId, {
@@ -2457,8 +2579,6 @@ var ClientDaemon = class {
2457
2579
  fingerprint: r.session.fingerprint
2458
2580
  });
2459
2581
  } else {
2460
- if (Date.now() > this.pairUntil)
2461
- throw new BridgeError("NOT_PAIRED");
2462
2582
  this.send(frame.routeId, {
2463
2583
  kind: "pairing",
2464
2584
  status: "pending",
@@ -2471,28 +2591,47 @@ var ClientDaemon = class {
2471
2591
  const message = r.session.open(frame);
2472
2592
  if (!message) return;
2473
2593
  if (message.kind === "pairing") {
2474
- if (message.fingerprint !== r.session.fingerprint || message.status !== "confirmed" || Date.now() > r.expires)
2594
+ if (r.confirmed || message.fingerprint !== r.session.fingerprint || message.status !== "confirmed" || Date.now() > r.expires)
2475
2595
  throw Error("PAIRING_MISMATCH");
2596
+ if (this.config.pairingMode !== "manual") {
2597
+ if (!await verifyTokenProof(this.token, r.session.fingerprint, "browser", message.proof)) throw new BridgeError("TOKEN_AUTH_FAILED");
2598
+ const roots = await this.discoverProjects();
2599
+ if (this.routes.get(frame.routeId) !== r || Date.now() > r.expires) return;
2600
+ const peer = r.session.peerPublicKey;
2601
+ this.grants.set(peer, { peer, roots, write: true, writeRoots: roots });
2602
+ this.tokenPeers.add(peer);
2603
+ r.session.trust(r.session.fingerprint);
2604
+ r.owner = peer;
2605
+ this.send(frame.routeId, { kind: "pairing", status: "ready", fingerprint: r.session.fingerprint });
2606
+ }
2476
2607
  r.confirmed = true;
2477
2608
  return;
2478
2609
  }
2479
2610
  if (!r.owner || !this.grants.has(r.owner))
2480
2611
  throw new BridgeError("NOT_PAIRED");
2481
- await this.ensureUpstream();
2612
+ try {
2613
+ await this.ensureUpstream();
2614
+ } catch {
2615
+ if (message.kind === "request") {
2616
+ this.send(frame.routeId, { kind: "response", requestId: message.requestId, error: { code: "UPSTREAM_UNAVAILABLE", message: "\u672C\u673A Codex \u6682\u4E0D\u53EF\u7528\uFF0C\u8BF7\u8FD0\u884C web-codex-client doctor \u540E\u91CD\u8BD5\u3002", retryable: true } });
2617
+ return;
2618
+ }
2619
+ throw new BridgeError("UPSTREAM_UNAVAILABLE");
2620
+ }
2482
2621
  const response = await this.bridge.handle(r.owner, message);
2483
2622
  if (response) this.send(frame.routeId, response);
2484
- }).catch(() => this.closeRoute(frame.routeId)).finally(() => r.pending--);
2623
+ }).catch((error) => this.closeRoute(frame.routeId, true, error instanceof BridgeError ? error.code : "PROTOCOL_ERROR")).finally(() => r.pending--);
2485
2624
  }
2486
2625
  send(id2, message) {
2487
2626
  const r = this.routes.get(id2);
2488
2627
  if (!r?.session?.ready) return;
2489
- const bytes = Buffer.byteLength(JSON.stringify(message));
2490
- if (r.bytes + bytes > 1024 * 1024 || this.ws.bufferedAmount > 4 * 1024 * 1024) {
2628
+ const bytes2 = Buffer.byteLength(JSON.stringify(message));
2629
+ if (r.bytes + bytes2 > 1024 * 1024 || this.ws.bufferedAmount > 4 * 1024 * 1024) {
2491
2630
  this.closeRoute(id2);
2492
2631
  return;
2493
2632
  }
2494
2633
  r.out.push(message);
2495
- r.bytes += bytes;
2634
+ r.bytes += bytes2;
2496
2635
  if (!r.sending) void this.flush(id2, r);
2497
2636
  }
2498
2637
  async flush(id2, r) {
@@ -2518,14 +2657,21 @@ var ClientDaemon = class {
2518
2657
  r.sending = false;
2519
2658
  }
2520
2659
  }
2521
- closeRoute(id2, notify = true) {
2660
+ closeRoute(id2, notify = true, reason = "CLOSED") {
2522
2661
  const r = this.routes.get(id2);
2523
2662
  if (!r) return;
2663
+ this.lastRouteClosure = { at: Date.now(), reason };
2664
+ if (reason === "PAIRING_ROUTE_EXPIRED" && r.session?.fingerprint) {
2665
+ if (this.expiredFingerprints.size >= 64) this.expiredFingerprints.delete(this.expiredFingerprints.values().next().value);
2666
+ this.expiredFingerprints.add(r.session.fingerprint);
2667
+ }
2524
2668
  this.routes.delete(id2);
2525
2669
  r.session?.close();
2526
2670
  r.out = [];
2527
- if (r.owner && !Array.from(this.routes.values()).some((x) => x.owner === r.owner))
2671
+ if (r.owner && !Array.from(this.routes.values()).some((x) => x.owner === r.owner)) {
2528
2672
  this.bridge?.disconnect(r.owner);
2673
+ if (this.tokenPeers.delete(r.owner)) this.grants.delete(r.owner);
2674
+ }
2529
2675
  if (notify) this.control({ type: "route.close", routeId: id2 });
2530
2676
  }
2531
2677
  async adminCommand(value) {
@@ -2541,50 +2687,66 @@ var ClientDaemon = class {
2541
2687
  authorizedSessions: Array.from(this.routes.values()).filter(
2542
2688
  (r) => r.owner
2543
2689
  ).length,
2544
- pairingUntil: this.pairUntil
2690
+ pairingUntil: this.pairUntil,
2691
+ pairingMode: this.config.pairingMode === "manual" ? "local-confirmation" : "token",
2692
+ pairingRemainingMs: Math.max(0, this.pairUntil - Date.now()),
2693
+ lastRouteClosure: this.lastRouteClosure,
2694
+ grants: Array.from(this.grants.values()).map((g) => ({ peer: g.peer, rootCount: g.roots.length, write: g.write }))
2545
2695
  };
2546
2696
  case "pair-open":
2547
- this.pairUntil = Date.now() + 12e4;
2697
+ this.pairUntil = Date.now() + PAIRING_MS;
2698
+ for (const r of this.routes.values()) if (!r.owner) r.expires = this.pairUntil;
2548
2699
  return { expiresAt: this.pairUntil };
2549
2700
  case "pair-list":
2550
2701
  return {
2702
+ pairingMode: this.config.pairingMode === "manual" ? "local-confirmation" : "token",
2703
+ windowRequired: false,
2704
+ pairingUntil: this.pairUntil,
2705
+ pairingRemainingMs: Math.max(0, this.pairUntil - Date.now()),
2551
2706
  pending: Array.from(this.routes.entries()).filter(([, r]) => r.session?.ready && !r.owner).map(([routeId, r]) => ({
2552
2707
  routeId,
2553
2708
  fingerprint: r.session.fingerprint,
2554
2709
  peer: r.session.peerPublicKey,
2555
2710
  browserConfirmed: r.confirmed,
2556
- expiresAt: r.expires
2711
+ expiresAt: r.expires,
2712
+ remainingMs: Math.max(0, r.expires - Date.now()),
2713
+ canApprove: r.confirmed && Date.now() <= r.expires
2557
2714
  }))
2558
2715
  };
2559
2716
  case "pair-approve": {
2717
+ if (this.config.pairingMode !== "manual") throw new AdminError("TOKEN_PAIRING_AUTOMATIC");
2560
2718
  const p = z5.object({
2561
2719
  fingerprint: z5.string().regex(/^[a-f0-9]{64}$/),
2562
- roots: z5.array(z5.string()).min(1).max(32),
2563
- write: z5.boolean().default(false)
2720
+ roots: z5.array(z5.string()).min(1).max(32).optional(),
2721
+ write: z5.boolean().optional(),
2722
+ allowUnknown: z5.boolean().default(false)
2564
2723
  }).parse(m);
2565
2724
  const found = Array.from(this.routes.entries()).find(
2566
2725
  ([, r2]) => r2.session?.fingerprint === p.fingerprint
2567
2726
  );
2568
- if (!found || !found[1].confirmed || Date.now() > this.pairUntil || Date.now() > found[1].expires)
2569
- throw Error("PAIRING_EXPIRED_OR_UNCONFIRMED");
2570
- if (this.grants.size >= 256) throw Error("GRANT_LIMIT");
2727
+ if (!found) throw new AdminError(this.expiredFingerprints.has(p.fingerprint) ? "PAIRING_ROUTE_EXPIRED" : "FINGERPRINT_NOT_FOUND");
2571
2728
  const [id2, r] = found;
2729
+ if (r.owner) return { peer: r.owner, approved: true, alreadyApproved: true };
2730
+ if (Date.now() > r.expires) throw new AdminError("PAIRING_ROUTE_EXPIRED");
2731
+ if (!r.confirmed) throw new AdminError("BROWSER_NOT_CONFIRMED");
2732
+ if (this.grants.size >= 256) throw new AdminError("GRANT_LIMIT");
2572
2733
  const peer = r.session.peerPublicKey;
2734
+ const selectedRoots = p.roots ? await this.canonicalRoots(p.roots) : void 0;
2735
+ let known = [];
2736
+ if (p.write === void 0 || !p.roots) known = await this.discoverProjects();
2737
+ const roots = selectedRoots ?? known;
2738
+ if (!roots.length) throw new AdminError("NO_KNOWN_PROJECTS");
2739
+ const unknown = roots.filter((root) => !known.includes(root));
2740
+ if (unknown.length && p.write === void 0 && !p.allowUnknown) throw new AdminError("DIRECTORY_APPROVAL_REQUIRED");
2741
+ const writeRoots = p.write === false ? [] : p.write || p.allowUnknown ? roots : roots.filter((root) => known.includes(root));
2742
+ if (this.routes.get(id2) !== r || Date.now() > r.expires) throw new AdminError("PAIRING_ROUTE_EXPIRED");
2573
2743
  const grant = {
2574
2744
  peer,
2575
- roots: await Promise.all(p.roots.map((x) => realpath3(x))),
2576
- write: p.write
2745
+ roots,
2746
+ write: writeRoots.length > 0,
2747
+ writeRoots
2577
2748
  };
2578
- this.grants.set(peer, grant);
2579
- try {
2580
- await atomicPrivate(
2581
- this.config.stateDir + "/grants.json",
2582
- Array.from(this.grants.values())
2583
- );
2584
- } catch (e) {
2585
- this.grants.delete(peer);
2586
- throw e;
2587
- }
2749
+ await this.saveGrant(grant, () => this.routes.get(id2) === r && Date.now() <= r.expires);
2588
2750
  r.session.trust(p.fingerprint);
2589
2751
  r.owner = peer;
2590
2752
  this.pairUntil = 0;
@@ -2593,7 +2755,24 @@ var ClientDaemon = class {
2593
2755
  status: "ready",
2594
2756
  fingerprint: p.fingerprint
2595
2757
  });
2596
- return { peer, approved: true };
2758
+ return { peer, approved: true, roots, write: grant.write, writeRoots };
2759
+ }
2760
+ case "projects-known":
2761
+ return { roots: await this.discoverProjects() };
2762
+ case "authorize": {
2763
+ const p = z5.object({ peer: z5.string().optional(), roots: z5.array(z5.string()).min(1).max(32), allowUnknown: z5.boolean().default(false) }).parse(m);
2764
+ const activePeers = [...new Set([...this.routes.values()].map((r) => r.owner).filter((peer2) => !!peer2))];
2765
+ const peer = p.peer ?? (activePeers.length === 1 ? activePeers[0] : this.grants.size === 1 ? this.grants.keys().next().value : void 0);
2766
+ if (!peer) throw new AdminError(this.grants.size ? "PEER_REQUIRED" : "NOT_PAIRED");
2767
+ const old = this.grants.get(peer);
2768
+ if (!old) throw new AdminError("NOT_PAIRED");
2769
+ const roots = await this.canonicalRoots(p.roots), known = await this.discoverProjects();
2770
+ if (!p.allowUnknown && roots.some((root) => !known.includes(root))) throw new AdminError("DIRECTORY_APPROVAL_REQUIRED");
2771
+ const nextRoots = [.../* @__PURE__ */ new Set([...old.roots, ...roots])];
2772
+ if (nextRoots.length > 256) throw new AdminError("GRANT_LIMIT");
2773
+ const writeRoots = [.../* @__PURE__ */ new Set([...old.writeRoots ?? (old.write ? old.roots : []), ...roots])];
2774
+ await this.saveGrant({ ...old, roots: nextRoots, write: true, writeRoots });
2775
+ return { peer, approved: true, roots: nextRoots, writeRoots };
2597
2776
  }
2598
2777
  case "revoke": {
2599
2778
  const peer = z5.string().parse(m.peer);
@@ -2609,9 +2788,47 @@ var ClientDaemon = class {
2609
2788
  return { revoked: true };
2610
2789
  }
2611
2790
  default:
2612
- throw Error("ADMIN_COMMAND");
2791
+ throw new AdminError("INVALID_ADMIN_REQUEST");
2792
+ }
2793
+ }
2794
+ async canonicalRoots(roots) {
2795
+ try {
2796
+ return await Promise.all(roots.map(async (path) => {
2797
+ const root = await realpath4(path);
2798
+ if (!(await stat3(root)).isDirectory()) throw Error();
2799
+ return root;
2800
+ }));
2801
+ } catch {
2802
+ throw new AdminError("DIRECTORY_UNAVAILABLE");
2803
+ }
2804
+ }
2805
+ async discoverProjects() {
2806
+ try {
2807
+ await this.ensureUpstream();
2808
+ return await knownProjects(this.bridge.upstream, this.config.stateDir);
2809
+ } catch (error) {
2810
+ if (error instanceof AdminError) throw error;
2811
+ throw new AdminError("CODEX_UNAVAILABLE");
2613
2812
  }
2614
2813
  }
2814
+ async saveGrant(grant, stillValid) {
2815
+ const values = new Map(this.grants);
2816
+ values.set(grant.peer, grant);
2817
+ try {
2818
+ await atomicPrivate(this.config.stateDir + "/grants.json", [...values.values()]);
2819
+ } catch {
2820
+ throw new AdminError("GRANT_SAVE_FAILED");
2821
+ }
2822
+ if (stillValid && !stillValid()) {
2823
+ try {
2824
+ await atomicPrivate(this.config.stateDir + "/grants.json", [...this.grants.values()]);
2825
+ } catch {
2826
+ throw new AdminError("GRANT_SAVE_FAILED");
2827
+ }
2828
+ throw new AdminError("PAIRING_ROUTE_EXPIRED");
2829
+ }
2830
+ this.grants.set(grant.peer, grant);
2831
+ }
2615
2832
  async startAdmin() {
2616
2833
  const path = this.config.stateDir + "/control.sock";
2617
2834
  try {
@@ -2641,20 +2858,27 @@ var ClientDaemon = class {
2641
2858
  if (e.code !== "ENOENT") throw e;
2642
2859
  }
2643
2860
  this.admin = createServer((socket) => {
2644
- socket.setTimeout(5e3, () => socket.destroy());
2645
- let data = "";
2861
+ socket.setTimeout(6e4, () => socket.destroy());
2862
+ let data2 = "";
2646
2863
  socket.on("data", (chunk) => {
2647
- data += chunk.toString();
2648
- if (data.length > 16384) {
2864
+ data2 += chunk.toString();
2865
+ if (data2.length > 16384) {
2649
2866
  socket.destroy();
2650
2867
  return;
2651
2868
  }
2652
- if (!data.includes("\n")) return;
2869
+ if (!data2.includes("\n")) return;
2653
2870
  socket.pause();
2654
- void Promise.resolve().then(() => this.adminCommand(JSON.parse(data))).then(
2871
+ void Promise.resolve().then(() => {
2872
+ const request = JSON.parse(data2);
2873
+ const result = this.adminQueue.then(() => this.adminCommand(request));
2874
+ this.adminQueue = result.then(() => {
2875
+ }, () => {
2876
+ });
2877
+ return result;
2878
+ }).then(
2655
2879
  (result) => socket.end(JSON.stringify({ result }) + "\n"),
2656
- () => socket.end(
2657
- JSON.stringify({ error: "ADMIN_REQUEST_REJECTED" }) + "\n"
2880
+ (error) => socket.end(
2881
+ JSON.stringify(adminFailure(error)) + "\n"
2658
2882
  )
2659
2883
  );
2660
2884
  });
@@ -2689,17 +2913,17 @@ import { join as join2 } from "node:path";
2689
2913
  function control(stateDir, request) {
2690
2914
  return new Promise((resolve5, reject) => {
2691
2915
  const socket = createConnection2(join2(stateDir, "control.sock"));
2692
- let data = "";
2693
- socket.setTimeout(5e3, () => socket.destroy(Error("ADMIN_TIMEOUT")));
2916
+ let data2 = "";
2917
+ socket.setTimeout(6e4, () => socket.destroy(Error("ADMIN_TIMEOUT: \u672C\u673A\u5904\u7406\u8D85\u65F6\uFF0C\u8BF7\u8FD0\u884C doctor \u540E\u91CD\u8BD5")));
2694
2918
  socket.on("connect", () => socket.write(JSON.stringify(request) + "\n"));
2695
2919
  socket.on("data", (chunk) => {
2696
- data += chunk.toString();
2697
- if (Buffer.byteLength(data) > 65536) socket.destroy(Error("ADMIN_TOO_LARGE"));
2920
+ data2 += chunk.toString();
2921
+ if (Buffer.byteLength(data2) > 65536) socket.destroy(Error("ADMIN_TOO_LARGE"));
2698
2922
  });
2699
2923
  socket.on("error", reject);
2700
2924
  socket.on("end", () => {
2701
2925
  try {
2702
- resolve5(JSON.parse(data));
2926
+ resolve5(JSON.parse(data2));
2703
2927
  } catch {
2704
2928
  reject(Error("ADMIN_INVALID_RESPONSE"));
2705
2929
  }
@@ -2708,14 +2932,14 @@ function control(stateDir, request) {
2708
2932
  }
2709
2933
 
2710
2934
  // src/setup.ts
2711
- import { homedir as homedir2 } from "node:os";
2935
+ import { homedir as homedir3 } from "node:os";
2712
2936
  import { dirname as dirname3, join as join3, resolve as resolve3, basename as basename3 } from "node:path";
2713
2937
  import { mkdir as mkdir2, lstat as lstat3, writeFile as writeFile2, unlink as unlink2 } from "node:fs/promises";
2714
2938
  import { randomUUID as randomUUID5 } from "node:crypto";
2715
2939
  import { createInterface } from "node:readline/promises";
2716
2940
  import { Writable } from "node:stream";
2717
2941
  function defaultConfigPath() {
2718
- return resolve3(process.env.WEB_CODEX_CLIENT_CONFIG ?? join3(process.env.XDG_CONFIG_HOME ?? join3(homedir2(), ".config"), "web-codex-client", "config.json"));
2942
+ return resolve3(process.env.WEB_CODEX_CLIENT_CONFIG ?? join3(process.env.XDG_CONFIG_HOME ?? join3(homedir3(), ".config"), "web-codex-client", "config.json"));
2719
2943
  }
2720
2944
  function validateToken(value) {
2721
2945
  const token = value.trim();
@@ -2756,6 +2980,13 @@ async function question(prompt, secret = false) {
2756
2980
  async function saveConfiguration(path, relayUrl, token, socket) {
2757
2981
  path = resolve3(path);
2758
2982
  const directory = dirname3(path);
2983
+ try {
2984
+ const url = new URL(relayUrl);
2985
+ if (url.protocol === "https:") url.protocol = "wss:";
2986
+ relayUrl = url.toString();
2987
+ } catch {
2988
+ throw Error("RELAY_URL_INVALID: use an HTTPS or WSS server address");
2989
+ }
2759
2990
  const parsed = configSchema.safeParse({ relayUrl, deviceTokenFile: "placeholder", stateDir: "./state", cryptoPolicy: "remote-wss-v1", upstream: { mode: "unix", ...socket ? { socket: resolve3(socket) } : {} } });
2760
2991
  if (!parsed.success) throw Error("RELAY_URL_INVALID: use a WSS origin without path, credentials, query or fragment");
2761
2992
  token = validateToken(token);
@@ -2801,10 +3032,11 @@ async function saveConfiguration(path, relayUrl, token, socket) {
2801
3032
  import { join as join4, dirname as dirname4 } from "node:path";
2802
3033
  import WebSocket3 from "ws";
2803
3034
  import { SingleBar, Presets } from "cli-progress";
2804
- async function authenticateRelay(relayUrl, token) {
3035
+ async function authenticateRelay(relayUrl, token, manual = false) {
3036
+ const credential = manual ? "Bearer " + token : "TokenHash " + await relayCredential(token);
2805
3037
  await new Promise((resolve5, reject) => {
2806
3038
  const ws = new WebSocket3(new URL("/ws/v1/client", relayUrl), {
2807
- headers: { Authorization: "Bearer " + token },
3039
+ headers: { Authorization: credential },
2808
3040
  perMessageDeflate: false,
2809
3041
  maxPayload: 4096,
2810
3042
  handshakeTimeout: 1e4
@@ -2825,9 +3057,9 @@ async function authenticateRelay(relayUrl, token) {
2825
3057
  finish(Error(response.statusCode === 401 ? "RELAY_UNAUTHORIZED: replace the device token" : "RELAY_HTTP_REJECTED: check the reverse proxy"));
2826
3058
  });
2827
3059
  ws.on("close", () => finish(Error("RELAY_CLOSED")));
2828
- ws.on("message", (bytes) => {
3060
+ ws.on("message", (bytes2) => {
2829
3061
  try {
2830
- const frame = JSON.parse(bytes.toString());
3062
+ const frame = JSON.parse(bytes2.toString());
2831
3063
  if (frame.type === "device.ready" && typeof frame.deviceId === "string") finish();
2832
3064
  else finish(Error("RELAY_PROTOCOL"));
2833
3065
  } catch {
@@ -2874,7 +3106,7 @@ async function doctor(configPath, quiet = false, reportPath = join4(dirname4(con
2874
3106
  } catch (e) {
2875
3107
  if (!["ENOENT", "ECONNREFUSED"].includes(e.code ?? "")) throw e;
2876
3108
  }
2877
- await authenticateRelay(config.relayUrl, token);
3109
+ await authenticateRelay(config.relayUrl, token, config.pairingMode === "manual");
2878
3110
  });
2879
3111
  await check("Codex initialization (no model request)", async () => {
2880
3112
  const rpc = await CodexRpc.connect(config.upstream);
@@ -2891,37 +3123,39 @@ async function doctor(configPath, quiet = false, reportPath = join4(dirname4(con
2891
3123
  }
2892
3124
 
2893
3125
  // src/cli.ts
2894
- var { values, positionals } = parseArgs({
2895
- allowPositionals: true,
2896
- options: {
2897
- config: { type: "string" },
2898
- state: { type: "string" },
2899
- socket: { type: "string" },
2900
- binary: { type: "string" },
2901
- standalone: { type: "boolean" },
2902
- transport: { type: "string" },
2903
- "codex-home": { type: "string" },
2904
- fingerprint: { type: "string" },
2905
- peer: { type: "string" },
2906
- root: { type: "string", multiple: true },
2907
- write: { type: "boolean" },
2908
- quiet: { type: "boolean" },
2909
- report: { type: "string" },
2910
- relay: { type: "string" },
2911
- "token-stdin": { type: "boolean" },
2912
- help: { type: "boolean", short: "h" },
2913
- version: { type: "boolean", short: "v" }
2914
- }
2915
- });
2916
- var command = positionals[0] ?? "help";
2917
3126
  async function main() {
3127
+ const { values, positionals } = parseArgs({
3128
+ allowPositionals: true,
3129
+ options: {
3130
+ config: { type: "string" },
3131
+ state: { type: "string" },
3132
+ socket: { type: "string" },
3133
+ binary: { type: "string" },
3134
+ standalone: { type: "boolean" },
3135
+ transport: { type: "string" },
3136
+ "codex-home": { type: "string" },
3137
+ fingerprint: { type: "string" },
3138
+ peer: { type: "string" },
3139
+ root: { type: "string", multiple: true },
3140
+ write: { type: "boolean" },
3141
+ "read-only": { type: "boolean" },
3142
+ quiet: { type: "boolean" },
3143
+ report: { type: "string" },
3144
+ relay: { type: "string" },
3145
+ "token-stdin": { type: "boolean" },
3146
+ help: { type: "boolean", short: "h" },
3147
+ version: { type: "boolean", short: "v" }
3148
+ }
3149
+ });
3150
+ const command = positionals[0] ?? "help";
3151
+ if (values.write && values["read-only"]) throw Error("\u4E0D\u8981\u540C\u65F6\u4F7F\u7528 --write \u548C --read-only\uFF0C\u8BF7\u67E5\u770B --help\u3002");
2918
3152
  if (values.version) {
2919
3153
  console.log(JSON.parse(await readFile3(new URL("../package.json", import.meta.url), "utf8")).version);
2920
3154
  return;
2921
3155
  }
2922
3156
  if (command === "help" || values.help) {
2923
3157
  console.log(
2924
- "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 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] [--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()
3158
+ "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()
2925
3159
  );
2926
3160
  return;
2927
3161
  }
@@ -2929,12 +3163,12 @@ async function main() {
2929
3163
  if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === "0") throw Error("TLS_VERIFICATION_REQUIRED: remove NODE_TLS_REJECT_UNAUTHORIZED=0");
2930
3164
  const configPath = resolve4(values.config ?? defaultConfigPath());
2931
3165
  if (command === "configure") {
2932
- const relay = values.relay ?? (await question("Relay WSS origin (e.g. wss://relay.example.com): ")).trim();
3166
+ const relay = values.relay ?? (await question("\u670D\u52A1\u7AEF\u5730\u5740\uFF08https:// \u6216 wss://\uFF09\uFF1A")).trim();
2933
3167
  if (values["token-stdin"] && !values.relay) throw Error("--token-stdin requires --relay");
2934
- const socket = values.socket ?? (values["token-stdin"] ? void 0 : (await question("Codex socket [" + controlSocketPath({ mode: "unix" }) + "]: ")).trim() || void 0);
2935
- const token = values["token-stdin"] ? await tokenFromStdin() : await question("Device token (hidden): ", true);
3168
+ const socket = values.socket;
3169
+ const token = values["token-stdin"] ? await tokenFromStdin() : await question("\u8FDE\u63A5 Token\uFF08\u9690\u85CF\u8F93\u5165\uFF09\uFF1A", true);
2936
3170
  const result2 = await saveConfiguration(configPath, relay, token, socket);
2937
- console.log("Configuration saved: " + result2.path + "\nRun web-codex-client doctor, then web-codex-client run. Pairing and directory approval are still required.");
3171
+ 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");
2938
3172
  return;
2939
3173
  }
2940
3174
  if (command === "doctor") {
@@ -2967,7 +3201,7 @@ async function main() {
2967
3201
  const daemon = new ClientDaemon(config, state.identity, state.grants);
2968
3202
  await daemon.start();
2969
3203
  console.log(
2970
- "Client running; use local status/pair-open/pair-list commands."
3204
+ "Client running. \u7F51\u9875\u8F93\u5165\u76F8\u540C Token \u5373\u53EF\u67E5\u770B\u9879\u76EE\u3001\u4F1A\u8BDD\u5E76\u53D1\u9001\u6D88\u606F\u3002"
2971
3205
  );
2972
3206
  let stopping = false;
2973
3207
  const stop = () => {
@@ -2979,21 +3213,59 @@ async function main() {
2979
3213
  process.on("SIGTERM", stop);
2980
3214
  return;
2981
3215
  }
2982
- if (!["status", "pair-open", "pair-list", "pair-approve", "revoke"].includes(command)) throw Error("UNKNOWN_COMMAND: use --help");
3216
+ if (!["status", "pair", "pair-open", "pair-list", "pair-approve", "revoke", "authorize", "projects-known"].includes(command)) throw Error("UNKNOWN_COMMAND: use --help");
2983
3217
  const stateDir = values.state ? resolve4(values.state) : (await readConfig(configPath)).stateDir;
3218
+ const send = async (request2) => {
3219
+ const reply = await control(stateDir, request2);
3220
+ if (reply.error) throw Error(reply.error + ": " + (reply.message ?? "\u672C\u673A Client \u62D2\u7EDD\u8BF7\u6C42\uFF0C\u8BF7\u68C0\u67E5 pair-list \u5E76\u786E\u8BA4 run \u4F7F\u7528\u540C\u4E00\u914D\u7F6E\u3002"));
3221
+ return reply.result;
3222
+ };
3223
+ if (command === "pair") {
3224
+ const list = await send({ command: "pair-list" });
3225
+ if (list.pairingMode === "token") {
3226
+ console.log("\u7F51\u9875\u8F93\u5165\u4E0E Client \u76F8\u540C\u7684 Token \u5373\u53EF\uFF0C\u65E0\u9700\u914D\u5BF9\u547D\u4EE4\u3002");
3227
+ return;
3228
+ }
3229
+ const pending2 = list.pending.filter((p) => (p.remainingMs ?? p.expiresAt - Date.now()) > 0);
3230
+ 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");
3231
+ 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"}`);
3232
+ const selected = pending2.length === 1 ? pending2[0] : pending2[Number(await question("\u9009\u62E9\u6D4F\u89C8\u5668\u7F16\u53F7\uFF1A")) - 1];
3233
+ if (!selected) throw Error("INVALID_SELECTION");
3234
+ console.log("\u8BF7\u6838\u5BF9\u7F51\u9875\u4E2D\u7684\u5B8C\u6574\u6307\u7EB9\uFF0C\u5E76\u5148\u70B9\u51FB\u7F51\u9875\u7684\u201C\u4E0E\u672C\u673A\u663E\u793A\u4E00\u81F4\u201D\u3002");
3235
+ const access = values["read-only"] ? "\u53EA\u8BFB" : "\u8BFB\u5199";
3236
+ console.log(values.root ? `\u5C06\u6388\u6743\u6307\u5B9A\u76EE\u5F55${access}\uFF1A` + values.root.map((root) => resolve4(root)).join(", ") : `\u786E\u8BA4\u540E\uFF0C\u4EC5\u5BF9\u672C\u673A Codex \u5DF2\u6709\u9879\u76EE\u6388\u4E88${access}\u6743\u9650\u3002`);
3237
+ if ((await question("\u6307\u7EB9\u5B8C\u5168\u4E00\u81F4\u5E76\u4FE1\u4EFB\u6B64\u6D4F\u89C8\u5668\uFF1F\u8F93\u5165 yes\uFF1A")).trim().toLowerCase() !== "yes") throw Error("PAIRING_CANCELLED");
3238
+ console.log("\u6B63\u5728\u8BFB\u53D6\u672C\u673A\u9879\u76EE\u5E76\u4FDD\u5B58\u6388\u6743\u2026");
3239
+ const result2 = await send({ command: "pair-approve", fingerprint: selected.fingerprint, roots: values.root?.map((root) => resolve4(root)), ...values.root ? { write: !values["read-only"], allowUnknown: true } : values["read-only"] ? { write: false } : {} });
3240
+ console.log(`\u5DF2\u4FE1\u4EFB\u6D4F\u89C8\u5668\uFF0C${result2.roots?.length ?? 0} \u4E2A\u9879\u76EE\u5DF2\u6388\u6743\u3002\u8FD4\u56DE\u7F51\u9875\u5373\u53EF\u4F7F\u7528\u3002`);
3241
+ return;
3242
+ }
3243
+ if (command === "authorize") {
3244
+ if (!values.root?.length) throw Error("ROOT_REQUIRED: \u8BF7\u7528 --root \u6307\u5B9A\u76EE\u5F55");
3245
+ const request2 = { command, roots: values.root.map((root) => resolve4(root)), peer: values.peer };
3246
+ let reply = await control(stateDir, request2);
3247
+ if (reply.error === "DIRECTORY_APPROVAL_REQUIRED") {
3248
+ console.log("Codex \u672A\u8BC6\u522B\u4E3A\u5DF2\u6709\u9879\u76EE\uFF0C\u9700\u8981\u4F60\u5728\u672C\u673A\u786E\u8BA4\u8BFB\u5199\u6388\u6743\uFF1A" + request2.roots.join(", "));
3249
+ if ((await question("\u5141\u8BB8\u8BE5\u6D4F\u89C8\u5668\u8BBF\u95EE\u8FD9\u4E9B\u76EE\u5F55\uFF1F\u8F93\u5165 yes\uFF1A")).trim().toLowerCase() !== "yes") throw Error("AUTHORIZATION_CANCELLED");
3250
+ reply = await control(stateDir, { ...request2, allowUnknown: true });
3251
+ }
3252
+ console.log(JSON.stringify(reply));
3253
+ if (reply.error) process.exitCode = 1;
3254
+ return;
3255
+ }
2984
3256
  const request = {
2985
3257
  command,
2986
3258
  fingerprint: values.fingerprint,
2987
3259
  peer: values.peer,
2988
- roots: values.root,
2989
- write: values.write ?? false
3260
+ roots: values.root?.map((root) => resolve4(root)),
3261
+ write: values["read-only"] ? false : values.write
2990
3262
  };
2991
3263
  const result = await control(stateDir, request);
2992
3264
  console.log(JSON.stringify(result));
2993
3265
  if (result.error) process.exitCode = 1;
2994
3266
  }
2995
3267
  main().catch((e) => {
2996
- console.error(e?.code === "ENOENT" ? "CONFIG_OR_FILE_MISSING: run configure; check the configured paths" : e?.name === "ZodError" ? "CONFIG_INVALID: check the configuration fields" : e instanceof Error ? e.message : "CLIENT_FAILED");
3268
+ console.error(e?.code?.startsWith("ERR_PARSE_ARGS") ? "\u53C2\u6570\u4E0D\u53D7\u652F\u6301\u6216\u683C\u5F0F\u65E0\u6548\uFF08\u6CA1\u6709 --admin \u53C2\u6570\uFF09\uFF1B\u8BF7\u67E5\u770B web-codex-client --help\u3002" : e?.code === "ENOENT" ? "CONFIG_OR_FILE_MISSING: run configure; check the configured paths" : e?.name === "ZodError" ? "CONFIG_INVALID: check the configuration fields" : e instanceof Error ? e.message : "CLIENT_FAILED");
2997
3269
  process.exitCode = 1;
2998
3270
  });
2999
3271
  //# sourceMappingURL=cli.js.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@landh93/web-codex-client",
3
- "version": "0.1.0-rc.1",
4
- "description": "Encrypted local Codex bridge with explicit browser pairing and workspace authorization",
3
+ "version": "0.1.0-rc.3",
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"],