ccoding 0.2.2__tar.gz → 0.2.4__tar.gz

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.
Files changed (33) hide show
  1. {ccoding-0.2.2 → ccoding-0.2.4}/PKG-INFO +31 -23
  2. {ccoding-0.2.2 → ccoding-0.2.4}/README.md +30 -22
  3. {ccoding-0.2.2 → ccoding-0.2.4}/pyproject.toml +1 -1
  4. {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/config.py +28 -8
  5. {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/remote.py +58 -46
  6. {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/remote_cli.py +5 -3
  7. {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/remote_client.py +7 -7
  8. {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/remote_common.py +89 -32
  9. {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/remote_server.py +67 -30
  10. {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/ui.py +35 -23
  11. {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/web/app.js +99 -30
  12. {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/web/index.html +6 -4
  13. {ccoding-0.2.2 → ccoding-0.2.4}/uv.lock +1 -1
  14. {ccoding-0.2.2 → ccoding-0.2.4}/.github/workflows/publish.yml +0 -0
  15. {ccoding-0.2.2 → ccoding-0.2.4}/.gitignore +0 -0
  16. {ccoding-0.2.2 → ccoding-0.2.4}/CLAUDE.md +0 -0
  17. {ccoding-0.2.2 → ccoding-0.2.4}/LICENSE +0 -0
  18. {ccoding-0.2.2 → ccoding-0.2.4}/screenshot.png +0 -0
  19. {ccoding-0.2.2 → ccoding-0.2.4}/screenshot_mobile1.jpeg +0 -0
  20. {ccoding-0.2.2 → ccoding-0.2.4}/screenshot_mobile2.jpeg +0 -0
  21. {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/__init__.py +0 -0
  22. {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/__main__.py +0 -0
  23. {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/api.py +0 -0
  24. {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/cli.py +0 -0
  25. {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/env_schema.json +0 -0
  26. {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/logo.py +0 -0
  27. {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/web/__init__.py +0 -0
  28. {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/web/assets/addon-fit.js +0 -0
  29. {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/web/assets/xterm-LICENSE +0 -0
  30. {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/web/assets/xterm-addon-fit-LICENSE +0 -0
  31. {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/web/assets/xterm.css +0 -0
  32. {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/web/assets/xterm.js +0 -0
  33. {ccoding-0.2.2 → ccoding-0.2.4}/src/ccode/web/style.css +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ccoding
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Curses-based Claude Code launcher
5
5
  Project-URL: Homepage, https://github.com/8DE4732A/ccode
6
6
  Project-URL: Repository, https://github.com/8DE4732A/ccode
@@ -173,8 +173,8 @@ uv sync --extra remote # 同时安装 client/server 依赖
173
173
 
174
174
  - `↑ / ↓`:移动字段焦点
175
175
  - `Enter / Space`:切换开关项
176
- - 文本输入:编辑 BASE_URL / API_KEY、remote local/server URL/token/device/prefix 等字段
177
- - `g`:在 Remote local token 或 server client token 字段重新生成访问 token
176
+ - 文本输入:编辑 BASE_URL / API_KEY、remote local/server URL/appId/appKey/device/prefix 等字段
177
+ - `g`:在 Remote appId 或 appKey 字段重新生成对应值
178
178
  - `ESC`:自动保存并返回主界面
179
179
 
180
180
  ## 配置文件
@@ -209,11 +209,13 @@ uv sync --extra remote # 同时安装 client/server 依赖
209
209
  "local": {
210
210
  "host": "127.0.0.1",
211
211
  "port": 8765,
212
- "token": ""
212
+ "appId": "",
213
+ "appKey": ""
213
214
  },
214
215
  "server": {
215
216
  "url": "",
216
- "token": "",
217
+ "appId": "",
218
+ "appKey": "",
217
219
  "device_name": "",
218
220
  "device_id": "",
219
221
  "auto_connect": true,
@@ -232,6 +234,8 @@ Remote/Web 让 Claude Code 运行在 tmux session 中,浏览器通过 WebSocke
232
234
  | `local` | 直接访问本机 local hub | 当前这台机器 | 单机使用,或把这一台机器通过 tunnel 暴露出去 |
233
235
  | `server` | 访问公网中心 `ccode-remote-server` | 各客户端设备本机 | 多设备、NAT 后设备、统一入口管理多个 sessions |
234
236
 
237
+ Remote/Web 使用 appId/appKey HMAC 认证。网络请求只携带 `appId`、`timestamp` 和 `sign`,其中 `sign` 由 appKey 计算,appKey 不会被发送。浏览器端只保存 24 小时有效的签名票据,过期后需要重新输入 appKey;客户端 connector 会从本地配置读取 appKey 并在重连时自动重新签名。旧版配置中的 `token` 会在读取时迁移为 `appKey`,并自动生成 `appId`。
238
+
235
239
  ### local 模式
236
240
 
237
241
  local 模式会在本机启动一个共享 FastAPI hub。浏览器直接访问这个 hub,hub 再 attach 到本机 tmux session。
@@ -262,23 +266,23 @@ claude
262
266
  - `MODE`:`local`
263
267
  - `LOCAL HOST`:默认 `127.0.0.1`
264
268
  - `LOCAL PORT`:默认 `8765`
265
- - `LOCAL TOKEN`:手动输入,或在该字段按 `g` 生成
269
+ - `LOCAL APP ID` / `LOCAL APP KEY`:手动输入,或在对应字段按 `g` 生成
266
270
 
267
271
  3. 回到主界面,按 `r` 开启 Remote/Web,再按 `Enter` 启动 remote session。
268
272
 
269
- 4. 浏览器打开终端输出的本地地址,输入 `LOCAL TOKEN`,选择 session 后进入 terminal。
273
+ 4. 浏览器打开终端输出的本地地址,输入 `LOCAL APP ID` / `LOCAL APP KEY`,选择 session 后进入 terminal。
270
274
 
271
- local 模式默认只监听 `127.0.0.1`。终端只输出不带 token 的本地访问地址。浏览器打开页面后需要手动输入 token,前端会用 `sessionStorage` 做浏览器会话级缓存;关闭该浏览器会话后需要重新输入。所有 session list、session API 和 WebSocket 连接仍然需要 token。
275
+ local 模式默认只监听 `127.0.0.1`。终端只输出不带认证信息的本地访问地址。浏览器打开页面后需要手动输入 appId/appKey,前端只保存由 appKey 生成的 24 小时 HMAC 签名票据,不保存 appKey;票据过期或关闭浏览器会话后需要重新输入。所有 session list、session API 和 WebSocket 连接仍然需要签名认证。
272
276
 
273
- 每个启用 Remote/Web 的 `ccode` 进程都会创建一个唯一 tmux session,session 名由配置里的 `session_name` 前缀、时间戳和进程号组成,例如 `ccode-claude-20260509-153022-12345`。本机只启动一个共享 remote hub,多个本地 `ccode` 进程会复用同一个 host/port/token。浏览器认证后会先显示 session 列表,选择某个 session 后进入 terminal,也可以返回列表重新选择。
277
+ 每个启用 Remote/Web 的 `ccode` 进程都会创建一个唯一 tmux session,session 名由配置里的 `session_name` 前缀、时间戳和进程号组成,例如 `ccode-claude-20260509-153022-12345`。本机只启动一个共享 remote hub,多个本地 `ccode` 进程会复用同一个 host/port/appId/appKey。浏览器认证后会先显示 session 列表,选择某个 session 后进入 terminal,也可以返回列表重新选择。
274
278
 
275
- 如果通过 Cloudflare Tunnel、Nginx、Caddy 等反向代理暴露 local hub,建议额外叠加 Cloudflare Access、Basic Auth 或 OAuth2 proxy。监听 `0.0.0.0` 会把所有 remote sessions 暴露给网络内可达主机,请保护好访问入口和 token。
279
+ 如果通过 Cloudflare Tunnel、Nginx、Caddy 等反向代理暴露 local hub,建议额外叠加 Cloudflare Access、Basic Auth 或 OAuth2 proxy。监听 `0.0.0.0` 会把所有 remote sessions 暴露给网络内可达主机,请保护好访问入口和 app credentials。
276
280
 
277
281
  ```bash
278
282
  cloudflared tunnel --url http://127.0.0.1:8765
279
283
  ```
280
284
 
281
- local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、session 注册、session API 和 WebSocket 连接状态,不记录 token。启动 hub 前如果日志超过 5 MiB 会自动轮转,默认保留 3 个历史文件(`.1`、`.2`、`.3`)。
285
+ local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、session 注册、session API 和 WebSocket 连接状态,不记录 appKey。启动 hub 前如果日志超过 5 MiB 会自动轮转,默认保留 3 个历史文件(`.1`、`.2`、`.3`)。
282
286
 
283
287
  ### server 模式和 `ccode-remote-server`
284
288
 
@@ -289,7 +293,7 @@ local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、sessio
289
293
  ```text
290
294
  Browser
291
295
  │
292
- │ HTTPS/WSS + admin token
296
+ │ HTTPS/WSS + admin HMAC
293
297
  ▼
294
298
  ┌───────────────────────┐
295
299
  │ ccode-remote-server │
@@ -314,31 +318,35 @@ local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、sessio
314
318
 
315
319
  server 不反连客户端,所以客户端可以在 NAT、家庭网络或公司内网后面。只要客户端能主动访问 server,就可以注册设备和暴露自己的 remote sessions。
316
320
 
317
- #### server token
321
+ #### server app credentials
318
322
 
319
- server token 不会自动生成,必须通过环境变量显式设置:
323
+ server app credentials 不会自动生成,必须通过环境变量显式设置。认证时网络上传输 appId、timestamp 和 HMAC sign,不传输 appKey:
320
324
 
321
- - `CCODE_REMOTE_SERVER_ADMIN_TOKEN`:浏览器 Web UI / `/api/*` / browser WebSocket 使用的管理 token。
322
- - `CCODE_REMOTE_SERVER_CLIENT_TOKEN`:各设备客户端 connector 注册和心跳使用的 token。
325
+ - `CCODE_REMOTE_SERVER_ADMIN_APP_ID` / `CCODE_REMOTE_SERVER_ADMIN_APP_KEY`:浏览器 Web UI / `/api/*` / browser WebSocket 使用的管理凭据。
326
+ - `CCODE_REMOTE_SERVER_CLIENT_APP_ID` / `CCODE_REMOTE_SERVER_CLIENT_APP_KEY`:各设备客户端 connector 注册和心跳使用的客户端凭据。
323
327
  - `CCODE_REMOTE_SERVER_HOST` / `CCODE_REMOTE_SERVER_PORT`:可选,默认监听 `127.0.0.1:8765`。
324
328
 
325
329
  启动中心 server:
326
330
 
327
331
  ```bash
328
- CCODE_REMOTE_SERVER_ADMIN_TOKEN="admin-secret" \
329
- CCODE_REMOTE_SERVER_CLIENT_TOKEN="client-secret" \
332
+ CCODE_REMOTE_SERVER_ADMIN_APP_ID="admin-app" \
333
+ CCODE_REMOTE_SERVER_ADMIN_APP_KEY="admin-secret" \
334
+ CCODE_REMOTE_SERVER_CLIENT_APP_ID="client-app" \
335
+ CCODE_REMOTE_SERVER_CLIENT_APP_KEY="client-secret" \
330
336
  ccode-remote-server --host 127.0.0.1 --port 8765
331
337
  ```
332
338
 
333
339
  如果使用源码开发环境运行:
334
340
 
335
341
  ```bash
336
- CCODE_REMOTE_SERVER_ADMIN_TOKEN="admin-secret" \
337
- CCODE_REMOTE_SERVER_CLIENT_TOKEN="client-secret" \
342
+ CCODE_REMOTE_SERVER_ADMIN_APP_ID="admin-app" \
343
+ CCODE_REMOTE_SERVER_ADMIN_APP_KEY="admin-secret" \
344
+ CCODE_REMOTE_SERVER_CLIENT_APP_ID="client-app" \
345
+ CCODE_REMOTE_SERVER_CLIENT_APP_KEY="client-secret" \
338
346
  uv run --extra remote-server ccode-remote-server --host 127.0.0.1 --port 8765
339
347
  ```
340
348
 
341
- 浏览器访问 server 页面时输入 `admin-secret`。如果未设置 `CCODE_REMOTE_SERVER_ADMIN_TOKEN`,管理 API 会返回 401,页面上任何 token 都无法通过认证。
349
+ 浏览器访问 server 页面时输入 admin appId/appKey。前端只保存 24 小时有效的 HMAC 签名票据,不保存 appKey。如果未设置 `CCODE_REMOTE_SERVER_ADMIN_APP_ID` / `CCODE_REMOTE_SERVER_ADMIN_APP_KEY`,管理 API 会返回 401。
342
350
 
343
351
  #### 客户端设备配置
344
352
 
@@ -352,7 +360,7 @@ uv tool install 'ccoding[remote-client]'
352
360
 
353
361
  - `MODE`:`server`
354
362
  - `SERVER URL`:中心 server 地址,例如 `http://127.0.0.1:8765`;如果只填 `127.0.0.1:8765`,会自动按 `http://127.0.0.1:8765` 处理
355
- - `CLIENT TOKEN`:与 server 的 `CCODE_REMOTE_SERVER_CLIENT_TOKEN` 一致
363
+ - `CLIENT APP ID` / `CLIENT APP KEY`:与 server 的 `CCODE_REMOTE_SERVER_CLIENT_APP_ID` / `CCODE_REMOTE_SERVER_CLIENT_APP_KEY` 一致
356
364
  - `DEVICE NAME`:设备显示名,可留空后由配置迁移填充 hostname
357
365
  - `DEVICE ID`:稳定设备 ID,可留空后自动生成
358
366
  - `AUTO CONNECT`:是否默认自动连接中心 server
@@ -384,7 +392,7 @@ server 只做 registry/router/relay,不保存 terminal frame data,不保存
384
392
 
385
393
  #### Cloudflare Tunnel / 反向代理
386
394
 
387
- 如果用 Cloudflare Tunnel 暴露中心 server,只需要 tunnel server 这一处入口,多台客户端设备都配置同一个 `SERVER URL` 和 `CLIENT TOKEN`:
395
+ 如果用 Cloudflare Tunnel 暴露中心 server,只需要 tunnel server 这一处入口,多台客户端设备都配置同一个 `SERVER URL` 和 `CLIENT APP ID` / `CLIENT APP KEY`:
388
396
 
389
397
  ```bash
390
398
  cloudflared tunnel --url http://127.0.0.1:8765
@@ -122,8 +122,8 @@ uv sync --extra remote # 同时安装 client/server 依赖
122
122
 
123
123
  - `↑ / ↓`:移动字段焦点
124
124
  - `Enter / Space`:切换开关项
125
- - 文本输入:编辑 BASE_URL / API_KEY、remote local/server URL/token/device/prefix 等字段
126
- - `g`:在 Remote local token 或 server client token 字段重新生成访问 token
125
+ - 文本输入:编辑 BASE_URL / API_KEY、remote local/server URL/appId/appKey/device/prefix 等字段
126
+ - `g`:在 Remote appId 或 appKey 字段重新生成对应值
127
127
  - `ESC`:自动保存并返回主界面
128
128
 
129
129
  ## 配置文件
@@ -158,11 +158,13 @@ uv sync --extra remote # 同时安装 client/server 依赖
158
158
  "local": {
159
159
  "host": "127.0.0.1",
160
160
  "port": 8765,
161
- "token": ""
161
+ "appId": "",
162
+ "appKey": ""
162
163
  },
163
164
  "server": {
164
165
  "url": "",
165
- "token": "",
166
+ "appId": "",
167
+ "appKey": "",
166
168
  "device_name": "",
167
169
  "device_id": "",
168
170
  "auto_connect": true,
@@ -181,6 +183,8 @@ Remote/Web 让 Claude Code 运行在 tmux session 中,浏览器通过 WebSocke
181
183
  | `local` | 直接访问本机 local hub | 当前这台机器 | 单机使用,或把这一台机器通过 tunnel 暴露出去 |
182
184
  | `server` | 访问公网中心 `ccode-remote-server` | 各客户端设备本机 | 多设备、NAT 后设备、统一入口管理多个 sessions |
183
185
 
186
+ Remote/Web 使用 appId/appKey HMAC 认证。网络请求只携带 `appId`、`timestamp` 和 `sign`,其中 `sign` 由 appKey 计算,appKey 不会被发送。浏览器端只保存 24 小时有效的签名票据,过期后需要重新输入 appKey;客户端 connector 会从本地配置读取 appKey 并在重连时自动重新签名。旧版配置中的 `token` 会在读取时迁移为 `appKey`,并自动生成 `appId`。
187
+
184
188
  ### local 模式
185
189
 
186
190
  local 模式会在本机启动一个共享 FastAPI hub。浏览器直接访问这个 hub,hub 再 attach 到本机 tmux session。
@@ -211,23 +215,23 @@ claude
211
215
  - `MODE`:`local`
212
216
  - `LOCAL HOST`:默认 `127.0.0.1`
213
217
  - `LOCAL PORT`:默认 `8765`
214
- - `LOCAL TOKEN`:手动输入,或在该字段按 `g` 生成
218
+ - `LOCAL APP ID` / `LOCAL APP KEY`:手动输入,或在对应字段按 `g` 生成
215
219
 
216
220
  3. 回到主界面,按 `r` 开启 Remote/Web,再按 `Enter` 启动 remote session。
217
221
 
218
- 4. 浏览器打开终端输出的本地地址,输入 `LOCAL TOKEN`,选择 session 后进入 terminal。
222
+ 4. 浏览器打开终端输出的本地地址,输入 `LOCAL APP ID` / `LOCAL APP KEY`,选择 session 后进入 terminal。
219
223
 
220
- local 模式默认只监听 `127.0.0.1`。终端只输出不带 token 的本地访问地址。浏览器打开页面后需要手动输入 token,前端会用 `sessionStorage` 做浏览器会话级缓存;关闭该浏览器会话后需要重新输入。所有 session list、session API 和 WebSocket 连接仍然需要 token。
224
+ local 模式默认只监听 `127.0.0.1`。终端只输出不带认证信息的本地访问地址。浏览器打开页面后需要手动输入 appId/appKey,前端只保存由 appKey 生成的 24 小时 HMAC 签名票据,不保存 appKey;票据过期或关闭浏览器会话后需要重新输入。所有 session list、session API 和 WebSocket 连接仍然需要签名认证。
221
225
 
222
- 每个启用 Remote/Web 的 `ccode` 进程都会创建一个唯一 tmux session,session 名由配置里的 `session_name` 前缀、时间戳和进程号组成,例如 `ccode-claude-20260509-153022-12345`。本机只启动一个共享 remote hub,多个本地 `ccode` 进程会复用同一个 host/port/token。浏览器认证后会先显示 session 列表,选择某个 session 后进入 terminal,也可以返回列表重新选择。
226
+ 每个启用 Remote/Web 的 `ccode` 进程都会创建一个唯一 tmux session,session 名由配置里的 `session_name` 前缀、时间戳和进程号组成,例如 `ccode-claude-20260509-153022-12345`。本机只启动一个共享 remote hub,多个本地 `ccode` 进程会复用同一个 host/port/appId/appKey。浏览器认证后会先显示 session 列表,选择某个 session 后进入 terminal,也可以返回列表重新选择。
223
227
 
224
- 如果通过 Cloudflare Tunnel、Nginx、Caddy 等反向代理暴露 local hub,建议额外叠加 Cloudflare Access、Basic Auth 或 OAuth2 proxy。监听 `0.0.0.0` 会把所有 remote sessions 暴露给网络内可达主机,请保护好访问入口和 token。
228
+ 如果通过 Cloudflare Tunnel、Nginx、Caddy 等反向代理暴露 local hub,建议额外叠加 Cloudflare Access、Basic Auth 或 OAuth2 proxy。监听 `0.0.0.0` 会把所有 remote sessions 暴露给网络内可达主机,请保护好访问入口和 app credentials。
225
229
 
226
230
  ```bash
227
231
  cloudflared tunnel --url http://127.0.0.1:8765
228
232
  ```
229
233
 
230
- local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、session 注册、session API 和 WebSocket 连接状态,不记录 token。启动 hub 前如果日志超过 5 MiB 会自动轮转,默认保留 3 个历史文件(`.1`、`.2`、`.3`)。
234
+ local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、session 注册、session API 和 WebSocket 连接状态,不记录 appKey。启动 hub 前如果日志超过 5 MiB 会自动轮转,默认保留 3 个历史文件(`.1`、`.2`、`.3`)。
231
235
 
232
236
  ### server 模式和 `ccode-remote-server`
233
237
 
@@ -238,7 +242,7 @@ local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、sessio
238
242
  ```text
239
243
  Browser
240
244
  │
241
- │ HTTPS/WSS + admin token
245
+ │ HTTPS/WSS + admin HMAC
242
246
  ▼
243
247
  ┌───────────────────────┐
244
248
  │ ccode-remote-server │
@@ -263,31 +267,35 @@ local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、sessio
263
267
 
264
268
  server 不反连客户端,所以客户端可以在 NAT、家庭网络或公司内网后面。只要客户端能主动访问 server,就可以注册设备和暴露自己的 remote sessions。
265
269
 
266
- #### server token
270
+ #### server app credentials
267
271
 
268
- server token 不会自动生成,必须通过环境变量显式设置:
272
+ server app credentials 不会自动生成,必须通过环境变量显式设置。认证时网络上传输 appId、timestamp 和 HMAC sign,不传输 appKey:
269
273
 
270
- - `CCODE_REMOTE_SERVER_ADMIN_TOKEN`:浏览器 Web UI / `/api/*` / browser WebSocket 使用的管理 token。
271
- - `CCODE_REMOTE_SERVER_CLIENT_TOKEN`:各设备客户端 connector 注册和心跳使用的 token。
274
+ - `CCODE_REMOTE_SERVER_ADMIN_APP_ID` / `CCODE_REMOTE_SERVER_ADMIN_APP_KEY`:浏览器 Web UI / `/api/*` / browser WebSocket 使用的管理凭据。
275
+ - `CCODE_REMOTE_SERVER_CLIENT_APP_ID` / `CCODE_REMOTE_SERVER_CLIENT_APP_KEY`:各设备客户端 connector 注册和心跳使用的客户端凭据。
272
276
  - `CCODE_REMOTE_SERVER_HOST` / `CCODE_REMOTE_SERVER_PORT`:可选,默认监听 `127.0.0.1:8765`。
273
277
 
274
278
  启动中心 server:
275
279
 
276
280
  ```bash
277
- CCODE_REMOTE_SERVER_ADMIN_TOKEN="admin-secret" \
278
- CCODE_REMOTE_SERVER_CLIENT_TOKEN="client-secret" \
281
+ CCODE_REMOTE_SERVER_ADMIN_APP_ID="admin-app" \
282
+ CCODE_REMOTE_SERVER_ADMIN_APP_KEY="admin-secret" \
283
+ CCODE_REMOTE_SERVER_CLIENT_APP_ID="client-app" \
284
+ CCODE_REMOTE_SERVER_CLIENT_APP_KEY="client-secret" \
279
285
  ccode-remote-server --host 127.0.0.1 --port 8765
280
286
  ```
281
287
 
282
288
  如果使用源码开发环境运行:
283
289
 
284
290
  ```bash
285
- CCODE_REMOTE_SERVER_ADMIN_TOKEN="admin-secret" \
286
- CCODE_REMOTE_SERVER_CLIENT_TOKEN="client-secret" \
291
+ CCODE_REMOTE_SERVER_ADMIN_APP_ID="admin-app" \
292
+ CCODE_REMOTE_SERVER_ADMIN_APP_KEY="admin-secret" \
293
+ CCODE_REMOTE_SERVER_CLIENT_APP_ID="client-app" \
294
+ CCODE_REMOTE_SERVER_CLIENT_APP_KEY="client-secret" \
287
295
  uv run --extra remote-server ccode-remote-server --host 127.0.0.1 --port 8765
288
296
  ```
289
297
 
290
- 浏览器访问 server 页面时输入 `admin-secret`。如果未设置 `CCODE_REMOTE_SERVER_ADMIN_TOKEN`,管理 API 会返回 401,页面上任何 token 都无法通过认证。
298
+ 浏览器访问 server 页面时输入 admin appId/appKey。前端只保存 24 小时有效的 HMAC 签名票据,不保存 appKey。如果未设置 `CCODE_REMOTE_SERVER_ADMIN_APP_ID` / `CCODE_REMOTE_SERVER_ADMIN_APP_KEY`,管理 API 会返回 401。
291
299
 
292
300
  #### 客户端设备配置
293
301
 
@@ -301,7 +309,7 @@ uv tool install 'ccoding[remote-client]'
301
309
 
302
310
  - `MODE`:`server`
303
311
  - `SERVER URL`:中心 server 地址,例如 `http://127.0.0.1:8765`;如果只填 `127.0.0.1:8765`,会自动按 `http://127.0.0.1:8765` 处理
304
- - `CLIENT TOKEN`:与 server 的 `CCODE_REMOTE_SERVER_CLIENT_TOKEN` 一致
312
+ - `CLIENT APP ID` / `CLIENT APP KEY`:与 server 的 `CCODE_REMOTE_SERVER_CLIENT_APP_ID` / `CCODE_REMOTE_SERVER_CLIENT_APP_KEY` 一致
305
313
  - `DEVICE NAME`:设备显示名,可留空后由配置迁移填充 hostname
306
314
  - `DEVICE ID`:稳定设备 ID,可留空后自动生成
307
315
  - `AUTO CONNECT`:是否默认自动连接中心 server
@@ -333,7 +341,7 @@ server 只做 registry/router/relay,不保存 terminal frame data,不保存
333
341
 
334
342
  #### Cloudflare Tunnel / 反向代理
335
343
 
336
- 如果用 Cloudflare Tunnel 暴露中心 server,只需要 tunnel server 这一处入口,多台客户端设备都配置同一个 `SERVER URL` 和 `CLIENT TOKEN`:
344
+ 如果用 Cloudflare Tunnel 暴露中心 server,只需要 tunnel server 这一处入口,多台客户端设备都配置同一个 `SERVER URL` 和 `CLIENT APP ID` / `CLIENT APP KEY`:
337
345
 
338
346
  ```bash
339
347
  cloudflared tunnel --url http://127.0.0.1:8765
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ccoding"
3
- version = "0.2.2"
3
+ version = "0.2.4"
4
4
  description = "Curses-based Claude Code launcher"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import copy
4
4
  import json
5
+ import secrets
5
6
  import socket
6
7
  import uuid
7
8
  from pathlib import Path
@@ -32,11 +33,13 @@ DEFAULT_REMOTE: dict[str, Any] = {
32
33
  "local": {
33
34
  "host": "127.0.0.1",
34
35
  "port": 8765,
35
- "token": "",
36
+ "appId": "",
37
+ "appKey": "",
36
38
  },
37
39
  "server": {
38
40
  "url": "",
39
- "token": "",
41
+ "appId": "",
42
+ "appKey": "",
40
43
  "device_name": "",
41
44
  "device_id": "",
42
45
  "auto_connect": True,
@@ -103,6 +106,10 @@ def _new_device_id() -> str:
103
106
  return f"ccode-{uuid.uuid4().hex}"
104
107
 
105
108
 
109
+ def _new_app_id() -> str:
110
+ return f"app_{secrets.token_urlsafe(9)}"
111
+
112
+
106
113
  def normalize_remote(remote: Any) -> dict[str, Any]:
107
114
  normalized = copy.deepcopy(DEFAULT_REMOTE)
108
115
  if not isinstance(remote, dict):
@@ -132,18 +139,31 @@ def normalize_remote(remote: Any) -> dict[str, Any]:
132
139
  port = remote.get("port")
133
140
  if isinstance(port, int):
134
141
  normalized["local"]["port"] = port
135
- token = local.get("token") if isinstance(local, dict) else None
136
- if not isinstance(token, str):
137
- token = remote.get("token")
138
- if isinstance(token, str):
139
- normalized["local"]["token"] = token
142
+ local_app_id = local.get("appId") if isinstance(local, dict) else None
143
+ local_app_key = local.get("appKey") if isinstance(local, dict) else None
144
+ local_token = local.get("token") if isinstance(local, dict) else None
145
+ if not isinstance(local_token, str):
146
+ local_token = remote.get("token")
147
+ if isinstance(local_app_id, str):
148
+ normalized["local"]["appId"] = local_app_id
149
+ elif isinstance(local_token, str) and local_token:
150
+ normalized["local"]["appId"] = _new_app_id()
151
+ if isinstance(local_app_key, str):
152
+ normalized["local"]["appKey"] = local_app_key
153
+ elif isinstance(local_token, str):
154
+ normalized["local"]["appKey"] = local_token
140
155
 
141
156
  server = remote.get("server") if isinstance(remote.get("server"), dict) else {}
142
157
  if isinstance(server, dict):
143
- for key in ("url", "token", "device_name", "device_id"):
158
+ server_token = server.get("token")
159
+ for key in ("url", "appId", "appKey", "device_name", "device_id"):
144
160
  value = server.get(key)
145
161
  if isinstance(value, str):
146
162
  normalized["server"][key] = value
163
+ if not normalized["server"].get("appId") and isinstance(server_token, str) and server_token:
164
+ normalized["server"]["appId"] = _new_app_id()
165
+ if not normalized["server"].get("appKey") and isinstance(server_token, str):
166
+ normalized["server"]["appKey"] = server_token
147
167
  for key in ("auto_connect", "verify_tls"):
148
168
  value = server.get(key)
149
169
  if isinstance(value, bool):
@@ -15,20 +15,20 @@ from .remote_common import (
15
15
  _new_session_id,
16
16
  _sanitize_session_name,
17
17
  _utc_now,
18
+ AuthResult,
18
19
  asset_text,
19
- auth_token_matches,
20
20
  file_lock,
21
21
  missing_extra_message,
22
+ remote_local_app_id,
23
+ remote_local_app_key,
22
24
  remote_local_host_port,
23
- remote_local_token,
24
25
  remote_local_url,
25
26
  remote_mode,
26
27
  remote_server_config,
27
28
  remote_server_url,
28
29
  rotate_log,
29
- token_matches,
30
+ verify_auth_payload,
30
31
  web_asset_text,
31
- websocket_auth_token,
32
32
  )
33
33
 
34
34
  REMOTE_SESSIONS_PATH = CONFIG_DIR / "remote_sessions.json"
@@ -321,29 +321,33 @@ def ensure_remote_hub(config: dict[str, Any]) -> str | None:
321
321
  return f"Failed to start remote hub. See {REMOTE_SERVER_LOG_PATH} for details."
322
322
 
323
323
 
324
- _TOKEN_CACHE: tuple[float | None, str] = (None, "")
324
+ _AUTH_CACHE: tuple[float | None, str, str] = (None, "", "")
325
325
 
326
326
 
327
- def _current_remote_token() -> str:
328
- global _TOKEN_CACHE
327
+ def _current_remote_credentials() -> tuple[str, str]:
328
+ global _AUTH_CACHE
329
329
  try:
330
330
  mtime = CONFIG_PATH.stat().st_mtime
331
331
  except FileNotFoundError:
332
332
  mtime = None
333
- cached_mtime, cached_token = _TOKEN_CACHE
333
+ cached_mtime, app_id, app_key = _AUTH_CACHE
334
334
  if cached_mtime == mtime:
335
- return cached_token
336
- token = remote_local_token(load_config()).strip()
337
- _TOKEN_CACHE = (mtime, token)
338
- return token
335
+ return app_id, app_key
336
+ config = load_config()
337
+ app_id = remote_local_app_id(config).strip()
338
+ app_key = remote_local_app_key(config).strip()
339
+ _AUTH_CACHE = (mtime, app_id, app_key)
340
+ return app_id, app_key
339
341
 
340
342
 
341
- def _valid_token(token: str | None) -> bool:
342
- return token_matches(_current_remote_token(), token)
343
+ def _valid_auth_payload(payload: dict[str, Any], scope: str) -> AuthResult:
344
+ app_id, app_key = _current_remote_credentials()
345
+ return verify_auth_payload(app_id, app_key, payload, scope)
343
346
 
344
347
 
345
- def _valid_auth_token(token: str | None, authorization: str | None = None) -> bool:
346
- return auth_token_matches(_current_remote_token(), token, authorization)
348
+ def _auth_response(result: AuthResult, response_class: Any) -> Any:
349
+ status_code = 401 if result.error in {"auth_required", "reauth_required"} else 403
350
+ return response_class({"error": result.error or "auth_failed", "message": result.message}, status_code=status_code)
347
351
 
348
352
 
349
353
  async def _attach_websocket_to_session(websocket: Any, session: dict[str, Any]) -> None:
@@ -402,11 +406,11 @@ def create_app() -> Any:
402
406
 
403
407
  @app.get("/app.js")
404
408
  async def app_js() -> Response:
405
- return Response(asset_text("app.js"), media_type="application/javascript")
409
+ return Response(asset_text("app.js"), media_type="application/javascript", headers={"Cache-Control": "no-store"})
406
410
 
407
411
  @app.get("/style.css")
408
412
  async def style_css() -> Response:
409
- return Response(asset_text("style.css"), media_type="text/css")
413
+ return Response(asset_text("style.css"), media_type="text/css", headers={"Cache-Control": "no-store"})
410
414
 
411
415
  @app.get("/assets/{asset_name}")
412
416
  async def web_asset(asset_name: str) -> Response:
@@ -418,20 +422,22 @@ def create_app() -> Any:
418
422
  media_type = media_types.get(asset_name)
419
423
  if media_type is None:
420
424
  return Response(status_code=404)
421
- return Response(web_asset_text(asset_name), media_type=media_type)
425
+ return Response(web_asset_text(asset_name), media_type=media_type, headers={"Cache-Control": "no-store"})
422
426
 
423
427
  @app.get("/health")
424
428
  async def health() -> dict[str, Any]:
425
- return {"ok": True, "name": REMOTE_HUB_NAME, "multi_session": True}
429
+ return {"ok": True, "name": REMOTE_HUB_NAME, "multi_session": True, "remote_kind": "local"}
426
430
 
427
431
  @app.get("/api/sessions")
428
432
  async def sessions(
429
- token: str | None = None,
430
- authorization: str | None = Header(default=None),
433
+ x_ccode_app_id: str | None = Header(default=None),
434
+ x_ccode_timestamp: str | None = Header(default=None),
435
+ x_ccode_sign: str | None = Header(default=None),
431
436
  ) -> Response:
432
- if not _valid_auth_token(token, authorization):
433
- _log_event("api sessions unauthorized")
434
- return JSONResponse({"error": "unauthorized"}, status_code=401)
437
+ auth_result = _valid_auth_payload({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "local.api")
438
+ if not auth_result.ok:
439
+ _log_event(f"api sessions unauthorized error={auth_result.error}")
440
+ return _auth_response(auth_result, JSONResponse)
435
441
  sessions = list_remote_sessions()
436
442
  _log_event(f"api sessions returned count={len(sessions)}")
437
443
  return JSONResponse({"sessions": sessions})
@@ -439,12 +445,14 @@ def create_app() -> Any:
439
445
  @app.get("/api/sessions/{session_id}")
440
446
  async def session_detail(
441
447
  session_id: str,
442
- token: str | None = None,
443
- authorization: str | None = Header(default=None),
448
+ x_ccode_app_id: str | None = Header(default=None),
449
+ x_ccode_timestamp: str | None = Header(default=None),
450
+ x_ccode_sign: str | None = Header(default=None),
444
451
  ) -> Response:
445
- if not _valid_auth_token(token, authorization):
446
- _log_event(f"api session detail unauthorized session_id={session_id}")
447
- return JSONResponse({"error": "unauthorized"}, status_code=401)
452
+ auth_result = _valid_auth_payload({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "local.api")
453
+ if not auth_result.ok:
454
+ _log_event(f"api session detail unauthorized session_id={session_id} error={auth_result.error}")
455
+ return _auth_response(auth_result, JSONResponse)
448
456
  session = get_remote_session(session_id)
449
457
  if session is None or not _session_is_running(session):
450
458
  _log_event(f"api session detail not_found session_id={session_id}")
@@ -454,12 +462,14 @@ def create_app() -> Any:
454
462
 
455
463
  @app.get("/api/session")
456
464
  async def legacy_session(
457
- token: str | None = None,
458
- authorization: str | None = Header(default=None),
465
+ x_ccode_app_id: str | None = Header(default=None),
466
+ x_ccode_timestamp: str | None = Header(default=None),
467
+ x_ccode_sign: str | None = Header(default=None),
459
468
  ) -> Response:
460
- if not _valid_auth_token(token, authorization):
461
- _log_event("legacy api session unauthorized")
462
- return JSONResponse({"error": "unauthorized"}, status_code=401)
469
+ auth_result = _valid_auth_payload({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "local.api")
470
+ if not auth_result.ok:
471
+ _log_event(f"legacy api session unauthorized error={auth_result.error}")
472
+ return _auth_response(auth_result, JSONResponse)
463
473
  sessions = list_remote_sessions()
464
474
  _log_event(f"legacy api session requested running_count={len(sessions)}")
465
475
  if len(sessions) == 1:
@@ -471,9 +481,10 @@ def create_app() -> Any:
471
481
  async def websocket_session(websocket: FastAPIWebSocket, session_id: str) -> None:
472
482
  await websocket.accept()
473
483
  _log_event(f"websocket accepted session_id={session_id}")
474
- if not _valid_token(await websocket_auth_token(websocket)):
475
- _log_event(f"websocket unauthorized session_id={session_id}")
476
- await websocket.send_json({"type": "status", "message": "unauthorized: invalid token"})
484
+ auth_result = _valid_auth_payload(dict(websocket.query_params), "local.ws")
485
+ if not auth_result.ok:
486
+ _log_event(f"websocket unauthorized session_id={session_id} error={auth_result.error}")
487
+ await websocket.send_json({"type": "error", "error": auth_result.error, "message": auth_result.message})
477
488
  await websocket.close(code=1008)
478
489
  return
479
490
  session = get_remote_session(session_id)
@@ -488,9 +499,10 @@ def create_app() -> Any:
488
499
  async def legacy_websocket(websocket: FastAPIWebSocket) -> None:
489
500
  await websocket.accept()
490
501
  _log_event("legacy websocket accepted")
491
- if not _valid_token(await websocket_auth_token(websocket)):
492
- _log_event("legacy websocket unauthorized")
493
- await websocket.send_json({"type": "status", "message": "unauthorized: invalid token"})
502
+ auth_result = _valid_auth_payload(dict(websocket.query_params), "local.ws")
503
+ if not auth_result.ok:
504
+ _log_event(f"legacy websocket unauthorized error={auth_result.error}")
505
+ await websocket.send_json({"type": "error", "error": auth_result.error, "message": auth_result.message})
494
506
  await websocket.close(code=1008)
495
507
  return
496
508
  sessions = list_remote_sessions()
@@ -530,11 +542,11 @@ def run_remote_hub_forever(config: dict[str, Any]) -> str | None:
530
542
 
531
543
  def run_remote_session(config: dict[str, Any], args: list[str]) -> str | None:
532
544
  mode = remote_mode(config)
533
- if mode == "local" and not remote_local_token(config).strip():
534
- return "Remote token is required. Set it in the config screen or press g on the TOKEN field to generate one."
545
+ if mode == "local" and (not remote_local_app_id(config).strip() or not remote_local_app_key(config).strip()):
546
+ return "Remote app ID and app key are required. Set them in the config screen or press g on the APP ID / APP KEY fields to generate them."
535
547
  server = remote_server_config(config)
536
- if mode == "server" and (not remote_server_url(config) or not str(server.get("token") or "").strip()):
537
- return "Remote server URL and client token are required in server mode."
548
+ if mode == "server" and (not remote_server_url(config) or not str(server.get("appId") or "").strip() or not str(server.get("appKey") or "").strip()):
549
+ return "Remote server URL, client app ID, and client app key are required in server mode."
538
550
  error = _check_runtime()
539
551
  if error is not None:
540
552
  return error
@@ -604,7 +616,7 @@ def run_remote_session(config: dict[str, Any], args: list[str]) -> str | None:
604
616
  print(f" {session_name}\n")
605
617
  if host == "0.0.0.0":
606
618
  print("Security warning:")
607
- print(" Listening on 0.0.0.0 exposes all remote sessions to your network. Protect the access URL and token.\n")
619
+ print(" Listening on 0.0.0.0 exposes all remote sessions to your network. Protect the access URL and app credentials.\n")
608
620
  print("This terminal will attach to this tmux session. The web hub stays running independently.")
609
621
  print("Detach with Ctrl+B then D. Browser users can choose this session from the session list.\n")
610
622
 
@@ -8,7 +8,7 @@ from pathlib import Path
8
8
  from typing import Any
9
9
 
10
10
  from .config import load_config, mask_secret
11
- from .remote_common import pid_from_path, remote_display_url, remote_local_host_port, remote_local_token, remote_mode, remote_server_config, remote_server_url
11
+ from .remote_common import pid_from_path, remote_display_url, remote_local_app_id, remote_local_app_key, remote_local_host_port, remote_mode, remote_server_config, remote_server_url
12
12
 
13
13
 
14
14
  def _format_bytes(value: int) -> str:
@@ -90,12 +90,14 @@ def print_status(kind: str | None = None) -> int:
90
90
  server = remote_server_config(config)
91
91
  print(f" device name: {server.get('device_name') or '<unset>'}")
92
92
  print(f" device id: {server.get('device_id') or '<unset>'}")
93
- print(f" client token: {mask_secret(str(server.get('token') or ''))}")
93
+ print(f" client app id: {server.get('appId') or '<unset>'}")
94
+ print(f" client app key: {mask_secret(str(server.get('appKey') or ''))}")
94
95
  else:
95
96
  host, port = remote_local_host_port(config)
96
97
  print(f" host: {host}")
97
98
  print(f" port: {port}")
98
- print(f" token: {mask_secret(remote_local_token(config))}")
99
+ print(f" app id: {remote_local_app_id(config) or '<unset>'}")
100
+ print(f" app key: {mask_secret(remote_local_app_key(config))}")
99
101
  print()
100
102
 
101
103
  if effective == "client":