ccoding 0.2.1__tar.gz → 0.2.3__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 (35) hide show
  1. {ccoding-0.2.1 → ccoding-0.2.3}/PKG-INFO +45 -23
  2. {ccoding-0.2.1 → ccoding-0.2.3}/README.md +44 -22
  3. {ccoding-0.2.1 → ccoding-0.2.3}/pyproject.toml +1 -1
  4. ccoding-0.2.3/screenshot_mobile1.jpeg +0 -0
  5. ccoding-0.2.3/screenshot_mobile2.jpeg +0 -0
  6. {ccoding-0.2.1 → ccoding-0.2.3}/src/ccode/config.py +28 -8
  7. {ccoding-0.2.1 → ccoding-0.2.3}/src/ccode/remote.py +55 -43
  8. {ccoding-0.2.1 → ccoding-0.2.3}/src/ccode/remote_cli.py +5 -3
  9. {ccoding-0.2.1 → ccoding-0.2.3}/src/ccode/remote_client.py +7 -7
  10. {ccoding-0.2.1 → ccoding-0.2.3}/src/ccode/remote_common.py +89 -32
  11. {ccoding-0.2.1 → ccoding-0.2.3}/src/ccode/remote_server.py +64 -27
  12. {ccoding-0.2.1 → ccoding-0.2.3}/src/ccode/ui.py +35 -23
  13. ccoding-0.2.3/src/ccode/web/app.js +523 -0
  14. ccoding-0.2.3/src/ccode/web/index.html +57 -0
  15. {ccoding-0.2.1 → ccoding-0.2.3}/src/ccode/web/style.css +102 -0
  16. {ccoding-0.2.1 → ccoding-0.2.3}/uv.lock +1 -1
  17. ccoding-0.2.1/src/ccode/web/app.js +0 -288
  18. ccoding-0.2.1/src/ccode/web/index.html +0 -39
  19. {ccoding-0.2.1 → ccoding-0.2.3}/.github/workflows/publish.yml +0 -0
  20. {ccoding-0.2.1 → ccoding-0.2.3}/.gitignore +0 -0
  21. {ccoding-0.2.1 → ccoding-0.2.3}/CLAUDE.md +0 -0
  22. {ccoding-0.2.1 → ccoding-0.2.3}/LICENSE +0 -0
  23. {ccoding-0.2.1 → ccoding-0.2.3}/screenshot.png +0 -0
  24. {ccoding-0.2.1 → ccoding-0.2.3}/src/ccode/__init__.py +0 -0
  25. {ccoding-0.2.1 → ccoding-0.2.3}/src/ccode/__main__.py +0 -0
  26. {ccoding-0.2.1 → ccoding-0.2.3}/src/ccode/api.py +0 -0
  27. {ccoding-0.2.1 → ccoding-0.2.3}/src/ccode/cli.py +0 -0
  28. {ccoding-0.2.1 → ccoding-0.2.3}/src/ccode/env_schema.json +0 -0
  29. {ccoding-0.2.1 → ccoding-0.2.3}/src/ccode/logo.py +0 -0
  30. {ccoding-0.2.1 → ccoding-0.2.3}/src/ccode/web/__init__.py +0 -0
  31. {ccoding-0.2.1 → ccoding-0.2.3}/src/ccode/web/assets/addon-fit.js +0 -0
  32. {ccoding-0.2.1 → ccoding-0.2.3}/src/ccode/web/assets/xterm-LICENSE +0 -0
  33. {ccoding-0.2.1 → ccoding-0.2.3}/src/ccode/web/assets/xterm-addon-fit-LICENSE +0 -0
  34. {ccoding-0.2.1 → ccoding-0.2.3}/src/ccode/web/assets/xterm.css +0 -0
  35. {ccoding-0.2.1 → ccoding-0.2.3}/src/ccode/web/assets/xterm.js +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ccoding
3
- Version: 0.2.1
3
+ Version: 0.2.3
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
@@ -57,6 +57,11 @@ Description-Content-Type: text/markdown
57
57
 
58
58
  ![](screenshot.png)
59
59
 
60
+ <p align="center">
61
+ <img src="screenshot_mobile1.jpeg" alt="Mobile remote terminal screenshot 1" width="45%" />
62
+ <img src="screenshot_mobile2.jpeg" alt="Mobile remote terminal screenshot 2" width="45%" />
63
+ </p>
64
+
60
65
  ## 功能特点
61
66
 
62
67
  - 终端 curses UI,键盘全操作
@@ -127,6 +132,15 @@ ccode-remote # 查看 remote hub / client connector 状态
127
132
 
128
133
  需要 Python >=3.10。基础 TUI 不再强制安装 remote 依赖。Remote/Web 模式需要额外依赖,并需要系统中已安装 `tmux` 和 `claude`;Windows 建议在 WSL 中使用 remote 模式。
129
134
 
135
+ 使用 Remote/Web 时,推荐在 `~/.tmux.conf` 中配置:
136
+
137
+ ```tmux
138
+ set -g history-limit 50000
139
+ set -g mouse on
140
+ set -g default-terminal "screen-256color"
141
+ set-option -ga terminal-overrides ",xterm-256color:Tc"
142
+ ```
143
+
130
144
  ```bash
131
145
  git clone https://github.com/8DE4732A/ccode
132
146
  cd ccode
@@ -159,8 +173,8 @@ uv sync --extra remote # 同时安装 client/server 依赖
159
173
 
160
174
  - `↑ / ↓`:移动字段焦点
161
175
  - `Enter / Space`:切换开关项
162
- - 文本输入:编辑 BASE_URL / API_KEY、remote local/server URL/token/device/prefix 等字段
163
- - `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 字段重新生成对应值
164
178
  - `ESC`:自动保存并返回主界面
165
179
 
166
180
  ## 配置文件
@@ -195,11 +209,13 @@ uv sync --extra remote # 同时安装 client/server 依赖
195
209
  "local": {
196
210
  "host": "127.0.0.1",
197
211
  "port": 8765,
198
- "token": ""
212
+ "appId": "",
213
+ "appKey": ""
199
214
  },
200
215
  "server": {
201
216
  "url": "",
202
- "token": "",
217
+ "appId": "",
218
+ "appKey": "",
203
219
  "device_name": "",
204
220
  "device_id": "",
205
221
  "auto_connect": true,
@@ -218,6 +234,8 @@ Remote/Web 让 Claude Code 运行在 tmux session 中,浏览器通过 WebSocke
218
234
  | `local` | 直接访问本机 local hub | 当前这台机器 | 单机使用,或把这一台机器通过 tunnel 暴露出去 |
219
235
  | `server` | 访问公网中心 `ccode-remote-server` | 各客户端设备本机 | 多设备、NAT 后设备、统一入口管理多个 sessions |
220
236
 
237
+ Remote/Web 使用 appId/appKey HMAC 认证。网络请求只携带 `appId`、`timestamp` 和 `sign`,其中 `sign` 由 appKey 计算,appKey 不会被发送。浏览器端只保存 24 小时有效的签名票据,过期后需要重新输入 appKey;客户端 connector 会从本地配置读取 appKey 并在重连时自动重新签名。旧版配置中的 `token` 会在读取时迁移为 `appKey`,并自动生成 `appId`。
238
+
221
239
  ### local 模式
222
240
 
223
241
  local 模式会在本机启动一个共享 FastAPI hub。浏览器直接访问这个 hub,hub 再 attach 到本机 tmux session。
@@ -248,23 +266,23 @@ claude
248
266
  - `MODE`:`local`
249
267
  - `LOCAL HOST`:默认 `127.0.0.1`
250
268
  - `LOCAL PORT`:默认 `8765`
251
- - `LOCAL TOKEN`:手动输入,或在该字段按 `g` 生成
269
+ - `LOCAL APP ID` / `LOCAL APP KEY`:手动输入,或在对应字段按 `g` 生成
252
270
 
253
271
  3. 回到主界面,按 `r` 开启 Remote/Web,再按 `Enter` 启动 remote session。
254
272
 
255
- 4. 浏览器打开终端输出的本地地址,输入 `LOCAL TOKEN`,选择 session 后进入 terminal。
273
+ 4. 浏览器打开终端输出的本地地址,输入 `LOCAL APP ID` / `LOCAL APP KEY`,选择 session 后进入 terminal。
256
274
 
257
- 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 连接仍然需要签名认证。
258
276
 
259
- 每个启用 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,也可以返回列表重新选择。
260
278
 
261
- 如果通过 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。
262
280
 
263
281
  ```bash
264
282
  cloudflared tunnel --url http://127.0.0.1:8765
265
283
  ```
266
284
 
267
- 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`)。
268
286
 
269
287
  ### server 模式和 `ccode-remote-server`
270
288
 
@@ -275,7 +293,7 @@ local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、sessio
275
293
  ```text
276
294
  Browser
277
295
  │
278
- │ HTTPS/WSS + admin token
296
+ │ HTTPS/WSS + admin HMAC
279
297
  ▼
280
298
  ┌───────────────────────┐
281
299
  │ ccode-remote-server │
@@ -300,31 +318,35 @@ local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、sessio
300
318
 
301
319
  server 不反连客户端,所以客户端可以在 NAT、家庭网络或公司内网后面。只要客户端能主动访问 server,就可以注册设备和暴露自己的 remote sessions。
302
320
 
303
- #### server token
321
+ #### server app credentials
304
322
 
305
- server token 不会自动生成,必须通过环境变量显式设置:
323
+ server app credentials 不会自动生成,必须通过环境变量显式设置。认证时网络上传输 appId、timestamp 和 HMAC sign,不传输 appKey:
306
324
 
307
- - `CCODE_REMOTE_SERVER_ADMIN_TOKEN`:浏览器 Web UI / `/api/*` / browser WebSocket 使用的管理 token。
308
- - `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 注册和心跳使用的客户端凭据。
309
327
  - `CCODE_REMOTE_SERVER_HOST` / `CCODE_REMOTE_SERVER_PORT`:可选,默认监听 `127.0.0.1:8765`。
310
328
 
311
329
  启动中心 server:
312
330
 
313
331
  ```bash
314
- CCODE_REMOTE_SERVER_ADMIN_TOKEN="admin-secret" \
315
- 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" \
316
336
  ccode-remote-server --host 127.0.0.1 --port 8765
317
337
  ```
318
338
 
319
339
  如果使用源码开发环境运行:
320
340
 
321
341
  ```bash
322
- CCODE_REMOTE_SERVER_ADMIN_TOKEN="admin-secret" \
323
- 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" \
324
346
  uv run --extra remote-server ccode-remote-server --host 127.0.0.1 --port 8765
325
347
  ```
326
348
 
327
- 浏览器访问 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。
328
350
 
329
351
  #### 客户端设备配置
330
352
 
@@ -338,7 +360,7 @@ uv tool install 'ccoding[remote-client]'
338
360
 
339
361
  - `MODE`:`server`
340
362
  - `SERVER URL`:中心 server 地址,例如 `http://127.0.0.1:8765`;如果只填 `127.0.0.1:8765`,会自动按 `http://127.0.0.1:8765` 处理
341
- - `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` 一致
342
364
  - `DEVICE NAME`:设备显示名,可留空后由配置迁移填充 hostname
343
365
  - `DEVICE ID`:稳定设备 ID,可留空后自动生成
344
366
  - `AUTO CONNECT`:是否默认自动连接中心 server
@@ -370,7 +392,7 @@ server 只做 registry/router/relay,不保存 terminal frame data,不保存
370
392
 
371
393
  #### Cloudflare Tunnel / 反向代理
372
394
 
373
- 如果用 Cloudflare Tunnel 暴露中心 server,只需要 tunnel server 这一处入口,多台客户端设备都配置同一个 `SERVER URL` 和 `CLIENT TOKEN`:
395
+ 如果用 Cloudflare Tunnel 暴露中心 server,只需要 tunnel server 这一处入口,多台客户端设备都配置同一个 `SERVER URL` 和 `CLIENT APP ID` / `CLIENT APP KEY`:
374
396
 
375
397
  ```bash
376
398
  cloudflared tunnel --url http://127.0.0.1:8765
@@ -6,6 +6,11 @@
6
6
 
7
7
  ![](screenshot.png)
8
8
 
9
+ <p align="center">
10
+ <img src="screenshot_mobile1.jpeg" alt="Mobile remote terminal screenshot 1" width="45%" />
11
+ <img src="screenshot_mobile2.jpeg" alt="Mobile remote terminal screenshot 2" width="45%" />
12
+ </p>
13
+
9
14
  ## 功能特点
10
15
 
11
16
  - 终端 curses UI,键盘全操作
@@ -76,6 +81,15 @@ ccode-remote # 查看 remote hub / client connector 状态
76
81
 
77
82
  需要 Python >=3.10。基础 TUI 不再强制安装 remote 依赖。Remote/Web 模式需要额外依赖,并需要系统中已安装 `tmux` 和 `claude`;Windows 建议在 WSL 中使用 remote 模式。
78
83
 
84
+ 使用 Remote/Web 时,推荐在 `~/.tmux.conf` 中配置:
85
+
86
+ ```tmux
87
+ set -g history-limit 50000
88
+ set -g mouse on
89
+ set -g default-terminal "screen-256color"
90
+ set-option -ga terminal-overrides ",xterm-256color:Tc"
91
+ ```
92
+
79
93
  ```bash
80
94
  git clone https://github.com/8DE4732A/ccode
81
95
  cd ccode
@@ -108,8 +122,8 @@ uv sync --extra remote # 同时安装 client/server 依赖
108
122
 
109
123
  - `↑ / ↓`:移动字段焦点
110
124
  - `Enter / Space`:切换开关项
111
- - 文本输入:编辑 BASE_URL / API_KEY、remote local/server URL/token/device/prefix 等字段
112
- - `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 字段重新生成对应值
113
127
  - `ESC`:自动保存并返回主界面
114
128
 
115
129
  ## 配置文件
@@ -144,11 +158,13 @@ uv sync --extra remote # 同时安装 client/server 依赖
144
158
  "local": {
145
159
  "host": "127.0.0.1",
146
160
  "port": 8765,
147
- "token": ""
161
+ "appId": "",
162
+ "appKey": ""
148
163
  },
149
164
  "server": {
150
165
  "url": "",
151
- "token": "",
166
+ "appId": "",
167
+ "appKey": "",
152
168
  "device_name": "",
153
169
  "device_id": "",
154
170
  "auto_connect": true,
@@ -167,6 +183,8 @@ Remote/Web 让 Claude Code 运行在 tmux session 中,浏览器通过 WebSocke
167
183
  | `local` | 直接访问本机 local hub | 当前这台机器 | 单机使用,或把这一台机器通过 tunnel 暴露出去 |
168
184
  | `server` | 访问公网中心 `ccode-remote-server` | 各客户端设备本机 | 多设备、NAT 后设备、统一入口管理多个 sessions |
169
185
 
186
+ Remote/Web 使用 appId/appKey HMAC 认证。网络请求只携带 `appId`、`timestamp` 和 `sign`,其中 `sign` 由 appKey 计算,appKey 不会被发送。浏览器端只保存 24 小时有效的签名票据,过期后需要重新输入 appKey;客户端 connector 会从本地配置读取 appKey 并在重连时自动重新签名。旧版配置中的 `token` 会在读取时迁移为 `appKey`,并自动生成 `appId`。
187
+
170
188
  ### local 模式
171
189
 
172
190
  local 模式会在本机启动一个共享 FastAPI hub。浏览器直接访问这个 hub,hub 再 attach 到本机 tmux session。
@@ -197,23 +215,23 @@ claude
197
215
  - `MODE`:`local`
198
216
  - `LOCAL HOST`:默认 `127.0.0.1`
199
217
  - `LOCAL PORT`:默认 `8765`
200
- - `LOCAL TOKEN`:手动输入,或在该字段按 `g` 生成
218
+ - `LOCAL APP ID` / `LOCAL APP KEY`:手动输入,或在对应字段按 `g` 生成
201
219
 
202
220
  3. 回到主界面,按 `r` 开启 Remote/Web,再按 `Enter` 启动 remote session。
203
221
 
204
- 4. 浏览器打开终端输出的本地地址,输入 `LOCAL TOKEN`,选择 session 后进入 terminal。
222
+ 4. 浏览器打开终端输出的本地地址,输入 `LOCAL APP ID` / `LOCAL APP KEY`,选择 session 后进入 terminal。
205
223
 
206
- 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 连接仍然需要签名认证。
207
225
 
208
- 每个启用 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,也可以返回列表重新选择。
209
227
 
210
- 如果通过 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。
211
229
 
212
230
  ```bash
213
231
  cloudflared tunnel --url http://127.0.0.1:8765
214
232
  ```
215
233
 
216
- 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`)。
217
235
 
218
236
  ### server 模式和 `ccode-remote-server`
219
237
 
@@ -224,7 +242,7 @@ local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、sessio
224
242
  ```text
225
243
  Browser
226
244
  │
227
- │ HTTPS/WSS + admin token
245
+ │ HTTPS/WSS + admin HMAC
228
246
  ▼
229
247
  ┌───────────────────────┐
230
248
  │ ccode-remote-server │
@@ -249,31 +267,35 @@ local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、sessio
249
267
 
250
268
  server 不反连客户端,所以客户端可以在 NAT、家庭网络或公司内网后面。只要客户端能主动访问 server,就可以注册设备和暴露自己的 remote sessions。
251
269
 
252
- #### server token
270
+ #### server app credentials
253
271
 
254
- server token 不会自动生成,必须通过环境变量显式设置:
272
+ server app credentials 不会自动生成,必须通过环境变量显式设置。认证时网络上传输 appId、timestamp 和 HMAC sign,不传输 appKey:
255
273
 
256
- - `CCODE_REMOTE_SERVER_ADMIN_TOKEN`:浏览器 Web UI / `/api/*` / browser WebSocket 使用的管理 token。
257
- - `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 注册和心跳使用的客户端凭据。
258
276
  - `CCODE_REMOTE_SERVER_HOST` / `CCODE_REMOTE_SERVER_PORT`:可选,默认监听 `127.0.0.1:8765`。
259
277
 
260
278
  启动中心 server:
261
279
 
262
280
  ```bash
263
- CCODE_REMOTE_SERVER_ADMIN_TOKEN="admin-secret" \
264
- 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" \
265
285
  ccode-remote-server --host 127.0.0.1 --port 8765
266
286
  ```
267
287
 
268
288
  如果使用源码开发环境运行:
269
289
 
270
290
  ```bash
271
- CCODE_REMOTE_SERVER_ADMIN_TOKEN="admin-secret" \
272
- 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" \
273
295
  uv run --extra remote-server ccode-remote-server --host 127.0.0.1 --port 8765
274
296
  ```
275
297
 
276
- 浏览器访问 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。
277
299
 
278
300
  #### 客户端设备配置
279
301
 
@@ -287,7 +309,7 @@ uv tool install 'ccoding[remote-client]'
287
309
 
288
310
  - `MODE`:`server`
289
311
  - `SERVER URL`:中心 server 地址,例如 `http://127.0.0.1:8765`;如果只填 `127.0.0.1:8765`,会自动按 `http://127.0.0.1:8765` 处理
290
- - `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` 一致
291
313
  - `DEVICE NAME`:设备显示名,可留空后由配置迁移填充 hostname
292
314
  - `DEVICE ID`:稳定设备 ID,可留空后自动生成
293
315
  - `AUTO CONNECT`:是否默认自动连接中心 server
@@ -319,7 +341,7 @@ server 只做 registry/router/relay,不保存 terminal frame data,不保存
319
341
 
320
342
  #### Cloudflare Tunnel / 反向代理
321
343
 
322
- 如果用 Cloudflare Tunnel 暴露中心 server,只需要 tunnel server 这一处入口,多台客户端设备都配置同一个 `SERVER URL` 和 `CLIENT TOKEN`:
344
+ 如果用 Cloudflare Tunnel 暴露中心 server,只需要 tunnel server 这一处入口,多台客户端设备都配置同一个 `SERVER URL` 和 `CLIENT APP ID` / `CLIENT APP KEY`:
323
345
 
324
346
  ```bash
325
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.1"
3
+ version = "0.2.3"
4
4
  description = "Curses-based Claude Code launcher"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
Binary file
Binary file
@@ -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:
@@ -422,16 +426,18 @@ def create_app() -> Any:
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":