@cjhyy/code-shell-server 0.9.8 → 0.9.10
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/README.md +85 -23
- package/dist/bin/code-shell-project-runtime.d.ts +2 -0
- package/dist/bin/code-shell-project-runtime.js +8 -0
- package/dist/desktop-web/http-api.d.ts +43 -0
- package/dist/desktop-web/http-api.js +376 -0
- package/dist/hub/approval-lease.d.ts +30 -0
- package/dist/hub/approval-lease.js +89 -0
- package/dist/hub/auth-http.d.ts +23 -0
- package/dist/hub/auth-http.js +259 -0
- package/dist/hub/auth-store.d.ts +52 -0
- package/dist/hub/auth-store.js +316 -0
- package/dist/hub/configuration.d.ts +64 -0
- package/dist/hub/configuration.js +555 -0
- package/dist/hub/files.d.ts +9 -0
- package/dist/hub/files.js +278 -0
- package/dist/hub/mcp-configuration.d.ts +51 -0
- package/dist/hub/mcp-configuration.js +558 -0
- package/dist/hub/mcp-probe.d.ts +24 -0
- package/dist/hub/mcp-probe.js +392 -0
- package/dist/hub/model-probe.d.ts +20 -0
- package/dist/hub/model-probe.js +193 -0
- package/dist/hub/outbound-transport.d.ts +36 -0
- package/dist/hub/outbound-transport.js +122 -0
- package/dist/hub/run-replay.d.ts +42 -0
- package/dist/hub/run-replay.js +89 -0
- package/dist/hub/session-management.d.ts +36 -0
- package/dist/hub/session-management.js +405 -0
- package/dist/hub/skills-management.d.ts +43 -0
- package/dist/hub/skills-management.js +487 -0
- package/dist/hub/uploads.d.ts +22 -0
- package/dist/hub/uploads.js +241 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.desktop-web.d.ts +2 -0
- package/dist/index.desktop-web.js +2 -0
- package/dist/index.js +3 -0
- package/dist/index.links.d.ts +6 -0
- package/dist/index.links.js +6 -0
- package/dist/index.panels.d.ts +9 -0
- package/dist/index.panels.js +8 -0
- package/dist/index.serve.d.ts +1 -0
- package/dist/index.serve.js +1 -0
- package/dist/index.storage.d.ts +2 -0
- package/dist/index.storage.js +2 -0
- package/dist/links/catalog.d.ts +10 -0
- package/dist/links/catalog.js +41 -0
- package/dist/links/device-oauth.d.ts +52 -0
- package/dist/links/device-oauth.js +255 -0
- package/dist/links/http.d.ts +34 -0
- package/dist/links/http.js +144 -0
- package/dist/links/service.d.ts +54 -0
- package/dist/links/service.js +578 -0
- package/dist/links/types.d.ts +6 -0
- package/dist/links/types.js +1 -0
- package/dist/mobile-remote/mobile-client-event-validator.js +5 -2
- package/dist/mobile-remote/pending-approvals.js +2 -0
- package/dist/mobile-remote/remote-host-manager.d.ts +8 -0
- package/dist/mobile-remote/remote-host-manager.js +90 -15
- package/dist/mobile-remote/room-manager.js +17 -5
- package/dist/panels/agent-task-host.d.ts +32 -0
- package/dist/panels/agent-task-host.js +457 -0
- package/dist/panels/agent-task-models.d.ts +27 -0
- package/dist/panels/agent-task-models.js +51 -0
- package/dist/panels/agent-task-service.d.ts +105 -0
- package/dist/panels/agent-task-service.js +283 -0
- package/dist/panels/http.d.ts +34 -0
- package/dist/panels/http.js +37 -0
- package/dist/panels/hub-binding.d.ts +12 -0
- package/dist/panels/hub-binding.js +174 -0
- package/dist/panels/management-http.d.ts +29 -0
- package/dist/panels/management-http.js +119 -0
- package/dist/panels/management.d.ts +39 -0
- package/dist/panels/management.js +494 -0
- package/dist/panels/process-files.d.ts +14 -0
- package/dist/panels/process-files.js +299 -0
- package/dist/panels/process-service.d.ts +96 -0
- package/dist/panels/process-service.js +501 -0
- package/dist/panels/runtime-services.d.ts +24 -0
- package/dist/panels/runtime-services.js +462 -0
- package/dist/panels/runtime.d.ts +51 -0
- package/dist/panels/runtime.js +1063 -0
- package/dist/panels/storage-store.d.ts +9 -0
- package/dist/panels/storage-store.js +154 -0
- package/dist/panels/types.d.ts +52 -0
- package/dist/panels/types.js +1 -0
- package/dist/project-runtime/control-server.d.ts +19 -0
- package/dist/project-runtime/control-server.js +300 -0
- package/dist/project-runtime/docker-provider.d.ts +33 -0
- package/dist/project-runtime/docker-provider.js +487 -0
- package/dist/project-runtime/lock.d.ts +6 -0
- package/dist/project-runtime/lock.js +43 -0
- package/dist/project-runtime/managed-entry.d.ts +10 -0
- package/dist/project-runtime/managed-entry.js +113 -0
- package/dist/project-runtime/manager.d.ts +40 -0
- package/dist/project-runtime/manager.js +249 -0
- package/dist/project-runtime/proxy.d.ts +22 -0
- package/dist/project-runtime/proxy.js +706 -0
- package/dist/project-runtime/registry.d.ts +54 -0
- package/dist/project-runtime/registry.js +286 -0
- package/dist/project-runtime/types.d.ts +43 -0
- package/dist/project-runtime/types.js +1 -0
- package/dist/serve/cli.d.ts +5 -0
- package/dist/serve/cli.js +82 -23
- package/dist/serve/headless-server.d.ts +11 -0
- package/dist/serve/headless-server.js +737 -83
- package/dist/session-titles-store.d.ts +17 -0
- package/dist/session-titles-store.js +174 -0
- package/dist/sessions-service.d.ts +2 -0
- package/dist/sessions-service.js +8 -0
- package/dist/static-files.js +1 -0
- package/dist/worker-bridge-core.js +17 -6
- package/package.json +19 -5
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# @cjhyy/code-shell-server
|
|
2
2
|
|
|
3
|
-
CodeShell
|
|
3
|
+
CodeShell 服务端传输与管理服务(纯 Node、零 Electron):mobile remote 的 HTTP/WS host、配对、passcode 门、tunnel、rooms、上传,供 Desktop Web 与 Hub 共用的配置/会话/文件/Link 服务,以及 **个人 Hub Web host(`code-shell-serve`)**。
|
|
4
|
+
|
|
5
|
+
完整的原生 Node.js 安装、模型配置、管理员初始化、systemd 后台运行、HTTPS 和备份步骤见 [服务端部署指南](../../docs/deployment.md)。
|
|
4
6
|
|
|
5
7
|
## 聚焦入口
|
|
6
8
|
|
|
@@ -12,6 +14,8 @@ CodeShell 服务端传输层(纯 Node、零 Electron):mobile remote 的 HT
|
|
|
12
14
|
| `@cjhyy/code-shell-server/worker` | 与传输无关的 stdio worker 生命周期和行协议桥接 |
|
|
13
15
|
| `@cjhyy/code-shell-server/mobile-remote` | 配对、访问门禁、rooms、上传、LAN/tunnel host 和移动端协议类型 |
|
|
14
16
|
| `@cjhyy/code-shell-server/serve` | Headless HTTP/WebSocket host 与 `code-shell-serve` CLI 组合默认值 |
|
|
17
|
+
| `@cjhyy/code-shell-server/desktop-web` | 设备配对换 Cookie、已知 Workspace 校验与共享管理 HTTP API |
|
|
18
|
+
| `@cjhyy/code-shell-server/links` | Link 目录、连接管理、设备授权及受限 HTTP handler |
|
|
15
19
|
|
|
16
20
|
```ts
|
|
17
21
|
import { listDiskSessions } from "@cjhyy/code-shell-server/storage";
|
|
@@ -19,7 +23,8 @@ import { RemoteHostManager } from "@cjhyy/code-shell-server/mobile-remote";
|
|
|
19
23
|
import { WorkerBridgeCore } from "@cjhyy/code-shell-server/worker";
|
|
20
24
|
```
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
包根入口保留 storage、worker、mobile-remote、serve 和 desktop-web 的兼容导出;
|
|
27
|
+
Link 使用独立 `/links` 入口。新消费方应避免根入口,以免只使用 storage
|
|
23
28
|
或 transport 时也求值无关的宿主组合。
|
|
24
29
|
|
|
25
30
|
`/storage`、`/worker` 和 `/mobile-remote` 没有 Coding/Web 静态导入。外部
|
|
@@ -27,52 +32,109 @@ Agent 策略由产品宿主通过 `ResidentAgentOptions.appendSystemPrompt` 注
|
|
|
27
32
|
`/serve` 则是有意保留的开箱即用产品入口:CLI 被调用时解析 Coding stdio
|
|
28
33
|
worker 和已构建的 Web app。
|
|
29
34
|
|
|
30
|
-
##
|
|
35
|
+
## 两种宿主,共用浏览器工作台
|
|
36
|
+
|
|
37
|
+
`packages/web/app/Workbench.tsx` 组合对话、模型、Skills、MCP、历史、文件及 Link 页面。
|
|
38
|
+
Hub 的 `App` / `useHubController` 提供管理员认证后的状态与操作;Desktop 的
|
|
39
|
+
`DesktopApp` 使用原有 `useRemoteApp` 和设备协议,驱动原来的 Desktop Worker。
|
|
40
|
+
Desktop Web 不会创建第二个 Hub Worker,也不需要再建 Hub 管理员。
|
|
41
|
+
|
|
42
|
+
`createDesktopWebApi` 将配对设备换成短期 HttpOnly Cookie,用于同一套受限 HTTP
|
|
43
|
+
管理服务。宿主注入已知 Workspace 解析、运行门禁和热更新回调;业务服务复用
|
|
44
|
+
`hub/` 与 `/links` 实现。Electron 原生 renderer 仍使用原来的 IPC。
|
|
45
|
+
具体文件与环境差异见 [共享 Web 工作台](../../docs/todo/shared-web-workbench.md)。
|
|
46
|
+
|
|
47
|
+
## code-shell-serve — 个人 Hub Web host
|
|
31
48
|
|
|
32
49
|
在任意机器上把一个 workspace 变成浏览器可访问的 CodeShell:
|
|
33
50
|
|
|
34
51
|
```bash
|
|
35
|
-
# 构建(repo
|
|
36
|
-
bun run build
|
|
52
|
+
# 构建(repo 内,先按部署指南安装依赖)
|
|
53
|
+
bun run build:server
|
|
37
54
|
|
|
38
55
|
# 启动
|
|
39
56
|
node packages/server/dist/bin/code-shell-serve.js \
|
|
40
57
|
--cwd ~/work/my-repo \
|
|
41
58
|
--port 8790 \
|
|
42
|
-
--
|
|
59
|
+
--data-dir ~/codeshell-data
|
|
43
60
|
```
|
|
44
61
|
|
|
45
|
-
|
|
62
|
+
首次启动会打印带 `#setup=` 的管理员初始化链接。创建管理员后,可在 Web「设置 → 模型与连接」添加连接并选择默认模型,也可以预先准备 Workspace 的 `.code-shell/settings.local.json`。没有可用模型时 HTTP 健康检查和配置界面仍可使用,对话需要先完成模型配置。
|
|
63
|
+
|
|
64
|
+
当前 Web 工作台提供:
|
|
65
|
+
|
|
66
|
+
- 对话、Markdown/代码/表格、模型提供的思考内容、工具结果、子任务、附件、审批与停止;刷新时恢复已保存历史和有界的当前运行事件。
|
|
67
|
+
- 会话搜索、重命名、归档/恢复、Markdown/JSON 导出;Workspace 文件列表、文本/图片预览和认证下载。
|
|
68
|
+
- 模型连接增删改、默认/辅助模型选择和真实连接测试;项目 Skills 增删改、启停、GitHub 预览安装/更新;MCP 配置、启停、工具权限和临时连接探测。
|
|
69
|
+
- 独立 Link 页面:token 验证连接、重命名/断开、绑定宿主已登录 CLI,以及配置了应用 client ID 的 GitHub/GitLab 设备授权。
|
|
70
|
+
- 单管理员登录、设备撤销、手机布局和 PWA 页面外壳。
|
|
71
|
+
|
|
72
|
+
浏览器可以连接远端 Hub;Desktop 开启的 Web 可访问桌面已知项目,独立 Hub 固定一个 Workspace。Electron 原生窗口的远端执行目标、Hub 多 Workspace、团队账号隔离、插件市场和远程 Panel Host 仍属后续范围。
|
|
46
73
|
|
|
47
74
|
### 架构
|
|
48
75
|
|
|
49
76
|
```text
|
|
50
|
-
浏览器 SPA(packages/web dist-app
|
|
51
|
-
│ WS /ws
|
|
77
|
+
浏览器 SPA(packages/web dist-app)
|
|
78
|
+
│ HTTP /api/v1/* + WS /ws(管理员 Session Cookie 与 Origin 校验)
|
|
52
79
|
headless serve(本包 serve/)
|
|
53
80
|
│ stdio line-JSON-RPC(WorkerBridgeCore:按需 spawn、崩溃记账)
|
|
54
|
-
agent-server-stdio worker
|
|
81
|
+
agent-server-stdio worker(Core 入口 + Coding 能力包)
|
|
55
82
|
```
|
|
56
83
|
|
|
57
|
-
- 浏览器使用受限的
|
|
84
|
+
- 浏览器使用受限的 Core 协议投影:serve 自己处理会话列表/详情,只允许向 worker 转发 `agent/run`、`agent/approve`、`agent/cancel`,并在转发前校验 Workspace 与 Session 归属。配置通过专用认证 HTTP 接口修改;任意配置 RPC 和 Workspace 切换不对 Web 开放。
|
|
58
85
|
- 每个 tab 的请求 ID 会在 host 内重写,响应只回到发起 tab;`agent/streamEvent` 等通知才广播给所有已认证 tab,避免多 tab 使用相同本地 ID 时串包。
|
|
59
|
-
-
|
|
60
|
-
- 默认只绑 `127.0.0.1
|
|
61
|
-
-
|
|
86
|
+
- 默认使用 Hub 单管理员初始化、登录与可撤销 Session Cookie;`--auth passcode` 保留旧共享口令模式。当前只支持单用户、单 Workspace。
|
|
87
|
+
- 默认只绑 `127.0.0.1`;远端可用 SSH 转发或 HTTPS 反向代理。HTTPS 部署需通过 `--public-url` 或对应环境变量配置实际公开 Origin,不能只改监听地址。
|
|
88
|
+
- 会话持久在 serve 独占的 Worker data root(默认 `<data-dir>/worker`);重启后可恢复列表与已保存历史,不会自动继续被中断的任务。活动运行缓冲有上限,超过后明确退回持久历史。
|
|
89
|
+
- Server 与 Desktop 复用 Core 引擎、SettingsManager、Skills 管理及会话数据逻辑;共享的 `web` 库负责浏览器可用的消息投影。工具、MCP 和 Skill 脚本在服务端执行。桌面窗口、钥匙串、IPC 和 Panel Host 不随 Web 部署迁移。
|
|
90
|
+
|
|
91
|
+
### Hub HTTP 接口分组
|
|
92
|
+
|
|
93
|
+
这些是当前 Web 使用的受限接口,不是多租户 Gateway。除初始化、登录及健康检查等公开入口外,要求有效管理员登录;浏览器请求还受 Origin 校验。
|
|
94
|
+
|
|
95
|
+
| 路径 | 用途 |
|
|
96
|
+
| ---------------------------- | -------------------------------------------------- |
|
|
97
|
+
| `/api/v1/auth/*` | 初始化、登录/退出、登录状态及 `/sessions` 设备管理 |
|
|
98
|
+
| `/api/v1/uploads/:browserId` | 当前登录 Session 所属的受限附件暂存 |
|
|
99
|
+
| `/api/v1/configuration/*` | 模型连接、默认值和配置摘要 |
|
|
100
|
+
| `/api/v1/skills/*` | 项目 Skill 管理与 GitHub 安装/更新 |
|
|
101
|
+
| `/api/v1/mcp/*` | MCP 配置、继承、启停和连接探测 |
|
|
102
|
+
| `/api/v1/sessions/*` | Workspace 内会话查询、标题、归档与导出 |
|
|
103
|
+
| `/api/v1/files/*` | Workspace 内文件列表、预览和下载 |
|
|
104
|
+
| `/api/v1/links/*` | Link 连接、CLI 状态/绑定及设备授权 |
|
|
105
|
+
|
|
106
|
+
审批快照、设备 lease 和运行流通过 `/ws` 协议交换。配置写入 Workspace 本地层,任务运行期间拒绝修改(409),空闲保存后通过 Core 热更新用于下一次任务。列表和详情不返回已保存的模型密钥或 MCP 敏感值。模型测试会发送一次可能计费的请求;MCP 测试只连接及列工具,stdio 测试会启动配置的程序。完整的功能边界与限额见部署指南。
|
|
107
|
+
|
|
108
|
+
Link 新连接写入宿主用户级 `CredentialStore`,不会放入模型配置的 `credentials[]`。
|
|
109
|
+
Web 只绑定已经登录的 CLI,不接受任意命令或启动交互式登录。token 与设备授权在验证后保存,
|
|
110
|
+
写入前重新检查发起者和连接版本;注销、设备撤销及关闭服务取消未完成操作。
|
|
111
|
+
断开绕过运行忙碌门禁,以便 Core 凭证订阅取消活跃 LinkAction。
|
|
112
|
+
当前 Link OAuth 过期需重新授权,没有自动续期或云端 Link Server;多设备共用一个宿主用户的连接。
|
|
62
113
|
|
|
63
114
|
### CLI 参数
|
|
64
115
|
|
|
65
|
-
| 参数 | 默认 | 说明
|
|
66
|
-
| --------------- | --------------------- |
|
|
67
|
-
| `--cwd` | 当前目录 | worker 的 workspace 根
|
|
68
|
-
| `--port` | 8790 | 监听端口
|
|
69
|
-
| `--host` | 127.0.0.1 | 监听地址
|
|
70
|
-
| `--
|
|
71
|
-
| `--
|
|
72
|
-
| `--
|
|
116
|
+
| 参数 | 默认 | 说明 |
|
|
117
|
+
| --------------- | --------------------- | --------------------------------------------------------------------------------------- |
|
|
118
|
+
| `--cwd` | 当前目录 | worker 的 workspace 根 |
|
|
119
|
+
| `--port` | 8790 | 监听端口 |
|
|
120
|
+
| `--host` | 127.0.0.1 | 监听地址 |
|
|
121
|
+
| `--auth` | hub | `hub` 管理员认证或 `passcode` 旧模式 |
|
|
122
|
+
| `--public-url` | 根据监听地址生成 | 浏览器实际访问的根 Origin,也可用 `CODE_SHELL_SERVE_PUBLIC_URL`;非 loopback 要求 HTTPS |
|
|
123
|
+
| `--passcode` | 旧模式下自动生成 | 显式传入会默认选择 passcode 模式,不能与 `--auth hub` 同用 |
|
|
124
|
+
| `--data-dir` | `~/.code-shell/serve` | 认证数据与隔离 Worker 数据根;设置 `CODE_SHELL_HOME` 时默认改为其下的 `serve` |
|
|
125
|
+
| `--static-root` | 自动解析 web dist-app | 覆盖静态资源目录 |
|
|
126
|
+
| `--debug-logs` | 关闭 | 包含可能带任务正文的 Worker 诊断,仅写入私有日志 |
|
|
73
127
|
|
|
74
128
|
### Web 客户端开发
|
|
75
129
|
|
|
130
|
+
先运行本地 Hub(后端默认 8790),再启动 Vite。开发时把 Hub 的公开 Origin 设为实际 Vite 地址,例如 `--public-url http://127.0.0.1:5173`;Vite 代理 `/api` 和 `/ws` 到 `127.0.0.1:8790`。浏览器始终使用该 Vite Origin 登录和访问:
|
|
131
|
+
|
|
76
132
|
```bash
|
|
77
|
-
bun run --cwd packages/web dev:app
|
|
133
|
+
bun run --cwd packages/web dev:app --host 127.0.0.1 --port 5173 --strictPort
|
|
78
134
|
```
|
|
135
|
+
|
|
136
|
+
Docker 部署到其他机器见 [Docker 部署说明](../../docs/docker-deployment.md)。默认宿主机端口为 8791,可与原生 Node.js 服务并行。
|
|
137
|
+
|
|
138
|
+
更新 Hub 后需重建并重启服务。Desktop Web 的入口构建到 `packages/desktop/out/mobile`,
|
|
139
|
+
还依赖新的主进程 HTTP 接线,因此必须重新构建 Desktop 并重启应用;只更新 Hub 的 `dist-app`
|
|
140
|
+
或刷新旧桌面的浏览器页面不会完成升级。
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runManagedProjectEntry } from "../project-runtime/managed-entry.js";
|
|
3
|
+
runManagedProjectEntry().catch(() => {
|
|
4
|
+
// Secret-file parsing and credential validation can contain private values in
|
|
5
|
+
// their underlying errors. Expose only the operational failure to Docker logs.
|
|
6
|
+
console.error("[project-runtime] initialization failed; check this project's runtime configuration.");
|
|
7
|
+
process.exit(1);
|
|
8
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { TrustedDeviceStore } from "../mobile-remote/trusted-device-store.js";
|
|
3
|
+
export interface DesktopWebRequestContext {
|
|
4
|
+
deviceId: string;
|
|
5
|
+
sessionId: string;
|
|
6
|
+
cwd: string;
|
|
7
|
+
/** Recheck both the paired device and Desktop's authoritative workspace registry. */
|
|
8
|
+
isAuthorized: () => Promise<boolean>;
|
|
9
|
+
}
|
|
10
|
+
export interface DesktopWebApiOptions {
|
|
11
|
+
devices: TrustedDeviceStore;
|
|
12
|
+
/** Desktop's existing title store directory: codeShellHome()/desktop. */
|
|
13
|
+
dataDir: string;
|
|
14
|
+
sessionRootDir: string;
|
|
15
|
+
/** Input is untrusted. Return only a workspace already known to Desktop. */
|
|
16
|
+
resolveWorkspace: (input: string | undefined, deviceId: string) => string | undefined | Promise<string | undefined>;
|
|
17
|
+
/**
|
|
18
|
+
* The existing Desktop AgentBridge owns this run/configuration admission lock.
|
|
19
|
+
* Reject active runs with status 409, hold the lock through write + hot reload,
|
|
20
|
+
* and notify the existing renderer/remote clients after a successful reload.
|
|
21
|
+
*/
|
|
22
|
+
withConfigurationMutation: <T>(cwd: string, write: () => Promise<T>) => Promise<T>;
|
|
23
|
+
isRunning: (sessionId: string) => boolean;
|
|
24
|
+
onSessionsChanged?: (cwd: string, sessionId: string) => void;
|
|
25
|
+
/** Release owner-scoped resources contributed by handleExtra (for example Link probes). */
|
|
26
|
+
onSessionRevoked?: (sessionId: string) => void;
|
|
27
|
+
onClose?: () => void | Promise<void>;
|
|
28
|
+
/** Authenticated same-origin composition point, for example the shared Link API. */
|
|
29
|
+
handleExtra?: (request: IncomingMessage, response: ServerResponse, context: DesktopWebRequestContext) => boolean | Promise<boolean>;
|
|
30
|
+
now?: () => number;
|
|
31
|
+
sessionTtlMs?: number;
|
|
32
|
+
}
|
|
33
|
+
export interface DesktopWebHttpApi {
|
|
34
|
+
handle: (request: IncomingMessage, response: ServerResponse, context: {
|
|
35
|
+
baseUrl: string;
|
|
36
|
+
}) => Promise<boolean>;
|
|
37
|
+
/** Reopen after RemoteHostManager.stop(), without reviving previous cookies. */
|
|
38
|
+
start: () => void;
|
|
39
|
+
close: () => Promise<void>;
|
|
40
|
+
revokeDevice: (deviceId: string) => void;
|
|
41
|
+
}
|
|
42
|
+
/** HTTP facade over existing Desktop storage and worker callbacks; never starts a worker. */
|
|
43
|
+
export declare function createDesktopWebApi(options: DesktopWebApiOptions): DesktopWebHttpApi;
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
3
|
+
import { createHubConfiguration, HubConfigurationError } from "../hub/configuration.js";
|
|
4
|
+
import { createHubFiles } from "../hub/files.js";
|
|
5
|
+
import { createHubMcpConfiguration } from "../hub/mcp-configuration.js";
|
|
6
|
+
import { createHubSessions } from "../hub/session-management.js";
|
|
7
|
+
import { createHubSkills } from "../hub/skills-management.js";
|
|
8
|
+
const COOKIE = "cs_desktop_session";
|
|
9
|
+
const SESSION_TTL = 30 * 60_000;
|
|
10
|
+
const MAX_SESSIONS = 512;
|
|
11
|
+
const MAX_WORKSPACES = 32;
|
|
12
|
+
function json(response, status, body) {
|
|
13
|
+
if (response.destroyed || response.writableEnded)
|
|
14
|
+
return;
|
|
15
|
+
response.writeHead(status, {
|
|
16
|
+
"content-type": "application/json; charset=utf-8",
|
|
17
|
+
"cache-control": "no-store",
|
|
18
|
+
"x-content-type-options": "nosniff",
|
|
19
|
+
});
|
|
20
|
+
response.end(JSON.stringify(body));
|
|
21
|
+
}
|
|
22
|
+
function workspaceInput(request, url) {
|
|
23
|
+
const raw = request.headers["x-codeshell-workspace"];
|
|
24
|
+
let header;
|
|
25
|
+
try {
|
|
26
|
+
if (Array.isArray(raw))
|
|
27
|
+
throw new Error();
|
|
28
|
+
header = raw === undefined ? undefined : decodeURIComponent(raw);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new HubConfigurationError(400, "工作区请求头无效。");
|
|
32
|
+
}
|
|
33
|
+
const queries = url.searchParams.getAll("workspace");
|
|
34
|
+
if (queries.length > 1 || (header !== undefined && queries.length && header !== queries[0]))
|
|
35
|
+
throw new HubConfigurationError(400, "请求中的工作区不一致。");
|
|
36
|
+
// URLSearchParams has already decoded the query; literal percent signs in paths survive.
|
|
37
|
+
const input = header ?? queries[0];
|
|
38
|
+
if (input !== undefined && (!input || input.length > 4096 || input.includes("\0")))
|
|
39
|
+
throw new HubConfigurationError(400, "工作区路径无效。");
|
|
40
|
+
return input;
|
|
41
|
+
}
|
|
42
|
+
function sameOrigin(request, baseUrl) {
|
|
43
|
+
const expected = new URL(baseUrl);
|
|
44
|
+
if (!["http:", "https:"].includes(expected.protocol))
|
|
45
|
+
throw new HubConfigurationError(503, "桌面远程地址尚未就绪。");
|
|
46
|
+
// The manager supplies its bound/public URL. Never derive trust from Host or forwarded headers.
|
|
47
|
+
if (request.headers.host?.toLowerCase() !== expected.host.toLowerCase())
|
|
48
|
+
throw new HubConfigurationError(403, "请使用桌面显示的远程访问地址。");
|
|
49
|
+
const origin = request.headers.origin;
|
|
50
|
+
const readOnly = request.method === "GET" || request.method === "HEAD";
|
|
51
|
+
if ((origin !== undefined && origin !== expected.origin) ||
|
|
52
|
+
(!readOnly && origin === undefined) ||
|
|
53
|
+
request.headers["sec-fetch-site"] === "cross-site")
|
|
54
|
+
throw new HubConfigurationError(403, "请求必须来自同一个桌面远程页面。");
|
|
55
|
+
return expected;
|
|
56
|
+
}
|
|
57
|
+
function cookieToken(request) {
|
|
58
|
+
const tokens = (request.headers.cookie ?? "")
|
|
59
|
+
.split(";")
|
|
60
|
+
.map((part) => part.trim())
|
|
61
|
+
.filter((part) => part.startsWith(`${COOKIE}=`))
|
|
62
|
+
.map((part) => part.slice(COOKIE.length + 1));
|
|
63
|
+
return tokens.length === 1 && /^[A-Za-z0-9_-]{43}$/.test(tokens[0]) ? tokens[0] : undefined;
|
|
64
|
+
}
|
|
65
|
+
async function credentials(request) {
|
|
66
|
+
if (request.headers["content-type"]?.split(";", 1)[0]?.trim() !== "application/json")
|
|
67
|
+
throw new HubConfigurationError(415, "请求必须使用 JSON 格式。");
|
|
68
|
+
const declared = Number(request.headers["content-length"] ?? 0);
|
|
69
|
+
if (!Number.isFinite(declared) || declared > 16 * 1024)
|
|
70
|
+
throw new HubConfigurationError(413, "设备认证内容过大。");
|
|
71
|
+
const chunks = [];
|
|
72
|
+
let bytes = 0;
|
|
73
|
+
const timeout = setTimeout(() => request.destroy(), 10_000);
|
|
74
|
+
timeout.unref();
|
|
75
|
+
try {
|
|
76
|
+
for await (const chunk of request) {
|
|
77
|
+
bytes += Buffer.byteLength(chunk);
|
|
78
|
+
if (bytes > 16 * 1024)
|
|
79
|
+
throw new HubConfigurationError(413, "设备认证内容过大。");
|
|
80
|
+
chunks.push(Buffer.from(chunk));
|
|
81
|
+
}
|
|
82
|
+
let input;
|
|
83
|
+
try {
|
|
84
|
+
input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
throw new HubConfigurationError(400, "设备认证格式无效。");
|
|
88
|
+
}
|
|
89
|
+
if (!input ||
|
|
90
|
+
Array.isArray(input) ||
|
|
91
|
+
Object.keys(input).some((key) => !["deviceId", "secretHash"].includes(key)) ||
|
|
92
|
+
typeof input.deviceId !== "string" ||
|
|
93
|
+
!input.deviceId ||
|
|
94
|
+
input.deviceId.length > 512 ||
|
|
95
|
+
typeof input.secretHash !== "string" ||
|
|
96
|
+
!input.secretHash ||
|
|
97
|
+
input.secretHash.length > 4096)
|
|
98
|
+
throw new HubConfigurationError(400, "设备认证格式无效。");
|
|
99
|
+
return { deviceId: input.deviceId, secretHash: input.secretHash };
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
clearTimeout(timeout);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** HTTP facade over existing Desktop storage and worker callbacks; never starts a worker. */
|
|
106
|
+
export function createDesktopWebApi(options) {
|
|
107
|
+
const now = options.now ?? Date.now;
|
|
108
|
+
const ttl = options.sessionTtlMs ?? SESSION_TTL;
|
|
109
|
+
if (!Number.isSafeInteger(ttl) || ttl < 1000 || ttl > 24 * 60 * 60_000)
|
|
110
|
+
throw new Error("Desktop HTTP session lifetime must be between one second and one day");
|
|
111
|
+
const sessions = new Map();
|
|
112
|
+
const identities = new WeakMap();
|
|
113
|
+
const workspaces = new Map();
|
|
114
|
+
let generation = 0;
|
|
115
|
+
let closed = false;
|
|
116
|
+
function cancelSession(session) {
|
|
117
|
+
for (const services of workspaces.values()) {
|
|
118
|
+
services.configuration.cancelOwner(session.id);
|
|
119
|
+
services.mcp.cancelOwner(session.id);
|
|
120
|
+
}
|
|
121
|
+
// Optional extensions cannot prevent the primary transports from being revoked.
|
|
122
|
+
try {
|
|
123
|
+
options.onSessionRevoked?.(session.id);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Their host-wide onClose callback remains a second cleanup opportunity.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function dropSession(token) {
|
|
130
|
+
const session = sessions.get(token);
|
|
131
|
+
if (session)
|
|
132
|
+
cancelSession(session);
|
|
133
|
+
sessions.delete(token);
|
|
134
|
+
}
|
|
135
|
+
function principal(token) {
|
|
136
|
+
if (closed || !token)
|
|
137
|
+
return;
|
|
138
|
+
const session = sessions.get(token);
|
|
139
|
+
if (!session)
|
|
140
|
+
return;
|
|
141
|
+
if (session.expiresAt <= now()) {
|
|
142
|
+
dropSession(token);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// A signed-in cookie never bypasses a later revocation/removal from the on-disk device store.
|
|
146
|
+
const device = options.devices
|
|
147
|
+
.listDevices()
|
|
148
|
+
.find((row) => row.id === session.deviceId && !row.revokedAt);
|
|
149
|
+
if (!device) {
|
|
150
|
+
dropSession(token);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
return { session, device };
|
|
154
|
+
}
|
|
155
|
+
async function authorized(request) {
|
|
156
|
+
const identity = identities.get(request);
|
|
157
|
+
if (!identity || closed || identity.generation !== generation)
|
|
158
|
+
return false;
|
|
159
|
+
try {
|
|
160
|
+
if (principal(identity.token)?.session !== identity.session)
|
|
161
|
+
return false;
|
|
162
|
+
const resolved = await options.resolveWorkspace(identity.input, identity.session.deviceId);
|
|
163
|
+
// The resolver can yield while a device is revoked or the host is stopped.
|
|
164
|
+
return (resolved === identity.cwd &&
|
|
165
|
+
!closed &&
|
|
166
|
+
identity.generation === generation &&
|
|
167
|
+
principal(identity.token)?.session === identity.session);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async function owner(request) {
|
|
174
|
+
return (await authorized(request)) ? identities.get(request)?.session.id : undefined;
|
|
175
|
+
}
|
|
176
|
+
function createServices(cwd) {
|
|
177
|
+
const shared = {
|
|
178
|
+
cwd,
|
|
179
|
+
isAuthorized: authorized,
|
|
180
|
+
ownerId: owner,
|
|
181
|
+
withMutation: async (write) => {
|
|
182
|
+
try {
|
|
183
|
+
return await options.withConfigurationMutation(cwd, write);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
if (error instanceof HubConfigurationError)
|
|
187
|
+
throw error;
|
|
188
|
+
const status = error?.status;
|
|
189
|
+
if (status === 409 || status === 503)
|
|
190
|
+
throw new HubConfigurationError(status, status === 409
|
|
191
|
+
? "桌面正在运行任务,请等待任务结束后再保存。"
|
|
192
|
+
: "桌面配置尚未重新载入,请稍后重试。");
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
const configuration = createHubConfiguration(shared);
|
|
198
|
+
const skills = createHubSkills({
|
|
199
|
+
...shared,
|
|
200
|
+
dataDir: options.dataDir,
|
|
201
|
+
owner: async (req) => (await owner(req)) ?? null,
|
|
202
|
+
});
|
|
203
|
+
const mcp = createHubMcpConfiguration(shared);
|
|
204
|
+
const files = createHubFiles(shared);
|
|
205
|
+
const history = createHubSessions({
|
|
206
|
+
...shared,
|
|
207
|
+
sessionRootDir: options.sessionRootDir,
|
|
208
|
+
dataDir: options.dataDir,
|
|
209
|
+
isRunning: options.isRunning,
|
|
210
|
+
onChanged: (id) => options.onSessionsChanged?.(cwd, id),
|
|
211
|
+
});
|
|
212
|
+
return {
|
|
213
|
+
configuration,
|
|
214
|
+
mcp,
|
|
215
|
+
active: 0,
|
|
216
|
+
async handle(request, response) {
|
|
217
|
+
for (const service of [configuration, skills, mcp, files, history])
|
|
218
|
+
if (await service.handle(request, response))
|
|
219
|
+
return true;
|
|
220
|
+
return false;
|
|
221
|
+
},
|
|
222
|
+
async close() {
|
|
223
|
+
configuration.close();
|
|
224
|
+
await Promise.allSettled([skills.close(), mcp.close(), files.close()]);
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function servicesFor(cwd) {
|
|
229
|
+
let services = workspaces.get(cwd);
|
|
230
|
+
if (services)
|
|
231
|
+
workspaces.delete(cwd);
|
|
232
|
+
else {
|
|
233
|
+
if (workspaces.size >= MAX_WORKSPACES) {
|
|
234
|
+
const idle = [...workspaces].find(([, entry]) => entry.active === 0);
|
|
235
|
+
if (!idle)
|
|
236
|
+
throw new HubConfigurationError(429, "同时打开的工作区过多,请稍后重试。");
|
|
237
|
+
workspaces.delete(idle[0]);
|
|
238
|
+
void idle[1].close();
|
|
239
|
+
}
|
|
240
|
+
services = createServices(cwd);
|
|
241
|
+
}
|
|
242
|
+
workspaces.set(cwd, services);
|
|
243
|
+
return services;
|
|
244
|
+
}
|
|
245
|
+
function status(session, device) {
|
|
246
|
+
return {
|
|
247
|
+
host: "desktop",
|
|
248
|
+
initialized: true,
|
|
249
|
+
authenticated: !!session,
|
|
250
|
+
...(session && device
|
|
251
|
+
? { session: { id: session.id, username: device.name, deviceName: device.name } }
|
|
252
|
+
: {}),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
function setCookie(response, token, secure) {
|
|
256
|
+
response.setHeader("set-cookie", `${COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${token ? Math.floor(ttl / 1000) : 0}${secure ? "; Secure" : ""}`);
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
start() {
|
|
260
|
+
closed = false;
|
|
261
|
+
},
|
|
262
|
+
revokeDevice(deviceId) {
|
|
263
|
+
for (const [token, session] of sessions)
|
|
264
|
+
if (session.deviceId === deviceId)
|
|
265
|
+
dropSession(token);
|
|
266
|
+
},
|
|
267
|
+
async close() {
|
|
268
|
+
if (closed)
|
|
269
|
+
return;
|
|
270
|
+
closed = true;
|
|
271
|
+
generation++;
|
|
272
|
+
for (const session of sessions.values())
|
|
273
|
+
cancelSession(session);
|
|
274
|
+
sessions.clear();
|
|
275
|
+
const entries = [...workspaces.values()];
|
|
276
|
+
workspaces.clear();
|
|
277
|
+
await Promise.allSettled([...entries.map((entry) => entry.close()), options.onClose?.()]);
|
|
278
|
+
},
|
|
279
|
+
async handle(request, response, context) {
|
|
280
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
281
|
+
if (!url.pathname.startsWith("/api/v1/"))
|
|
282
|
+
return false;
|
|
283
|
+
try {
|
|
284
|
+
if (closed)
|
|
285
|
+
throw new HubConfigurationError(503, "桌面远程服务已停止。");
|
|
286
|
+
const requestGeneration = generation;
|
|
287
|
+
const origin = sameOrigin(request, context.baseUrl);
|
|
288
|
+
const secure = origin.protocol === "https:";
|
|
289
|
+
const token = cookieToken(request);
|
|
290
|
+
if (request.method === "POST" && url.pathname === "/api/v1/desktop/session") {
|
|
291
|
+
const input = await credentials(request);
|
|
292
|
+
if (closed || requestGeneration !== generation)
|
|
293
|
+
throw new HubConfigurationError(503, "桌面远程服务已停止。");
|
|
294
|
+
const device = options.devices.authenticate(input.deviceId, input.secretHash);
|
|
295
|
+
if (!device)
|
|
296
|
+
throw new HubConfigurationError(401, "设备未配对或已被撤销,请在桌面重新配对。");
|
|
297
|
+
const existing = principal(token);
|
|
298
|
+
if (token && existing?.session.deviceId === device.id) {
|
|
299
|
+
// Tabs share a cookie and WebSocket reconnects exchange it again.
|
|
300
|
+
// Keep owner-scoped probes and Link authorizations alive across that handshake.
|
|
301
|
+
existing.session.expiresAt = now() + ttl;
|
|
302
|
+
setCookie(response, token, secure);
|
|
303
|
+
json(response, 200, status(existing.session, device));
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
// Re-exchange replaces only this browser's cookie. Other tabs/devices remain signed in.
|
|
307
|
+
if (token)
|
|
308
|
+
dropSession(token);
|
|
309
|
+
for (const [key, session] of sessions)
|
|
310
|
+
if (session.expiresAt <= now())
|
|
311
|
+
dropSession(key);
|
|
312
|
+
const own = [...sessions].filter(([, session]) => session.deviceId === device.id);
|
|
313
|
+
while (own.length >= 8)
|
|
314
|
+
dropSession(own.shift()[0]);
|
|
315
|
+
while (sessions.size >= MAX_SESSIONS)
|
|
316
|
+
dropSession(sessions.keys().next().value);
|
|
317
|
+
const nextToken = randomBytes(32).toString("base64url");
|
|
318
|
+
const session = { id: randomUUID(), deviceId: device.id, expiresAt: now() + ttl };
|
|
319
|
+
sessions.set(nextToken, session);
|
|
320
|
+
setCookie(response, nextToken, secure);
|
|
321
|
+
json(response, 200, status(session, device));
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
const current = principal(token);
|
|
325
|
+
if (request.method === "GET" &&
|
|
326
|
+
["/api/v1/auth/status", "/api/v1/auth"].includes(url.pathname)) {
|
|
327
|
+
json(response, 200, status(current?.session, current?.device));
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
if (request.method === "POST" && url.pathname === "/api/v1/auth/logout") {
|
|
331
|
+
if (token)
|
|
332
|
+
dropSession(token);
|
|
333
|
+
setCookie(response, "", secure);
|
|
334
|
+
json(response, 200, { ok: true });
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
if (!current || !token)
|
|
338
|
+
throw new HubConfigurationError(401, "设备连接已失效,请重新连接桌面。");
|
|
339
|
+
const input = workspaceInput(request, url);
|
|
340
|
+
const cwd = await options.resolveWorkspace(input, current.device.id);
|
|
341
|
+
if (closed ||
|
|
342
|
+
generation !== requestGeneration ||
|
|
343
|
+
principal(token)?.session !== current.session)
|
|
344
|
+
throw new HubConfigurationError(401, "设备连接已失效,请重新连接桌面。");
|
|
345
|
+
if (!cwd || !isAbsolute(cwd))
|
|
346
|
+
throw new HubConfigurationError(403, "只能访问桌面已知的项目或会话工作区。");
|
|
347
|
+
identities.set(request, { token, session: current.session, input, cwd, generation });
|
|
348
|
+
const services = servicesFor(cwd);
|
|
349
|
+
services.active++;
|
|
350
|
+
try {
|
|
351
|
+
if (await services.handle(request, response))
|
|
352
|
+
return true;
|
|
353
|
+
if (await options.handleExtra?.(request, response, {
|
|
354
|
+
deviceId: current.device.id,
|
|
355
|
+
sessionId: current.session.id,
|
|
356
|
+
cwd,
|
|
357
|
+
isAuthorized: () => authorized(request),
|
|
358
|
+
}))
|
|
359
|
+
return true;
|
|
360
|
+
json(response, 404, { error: "这个接口不适用于桌面远程模式。" });
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
services.active--;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
json(response, error instanceof HubConfigurationError ? error.status : 500, {
|
|
368
|
+
error: error instanceof HubConfigurationError
|
|
369
|
+
? error.message
|
|
370
|
+
: "桌面远程请求失败,请稍后重试。",
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
return true;
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** One runtime's live approvals. A lease reserves a decision; commit is one-shot. */
|
|
2
|
+
export interface HubApproval {
|
|
3
|
+
requestId: string;
|
|
4
|
+
sessionId: string;
|
|
5
|
+
[key: string]: unknown;
|
|
6
|
+
}
|
|
7
|
+
export interface ApprovalLease {
|
|
8
|
+
requestId: string;
|
|
9
|
+
holderId: string | null;
|
|
10
|
+
expiresAt: number | null;
|
|
11
|
+
}
|
|
12
|
+
export declare class ApprovalLeases {
|
|
13
|
+
private readonly onChange;
|
|
14
|
+
private readonly ttlMs;
|
|
15
|
+
private readonly now;
|
|
16
|
+
private readonly entries;
|
|
17
|
+
constructor(onChange: (lease: ApprovalLease) => void, ttlMs?: number, now?: () => number);
|
|
18
|
+
add(approval: HubApproval): void;
|
|
19
|
+
get(requestId: string): HubApproval | undefined;
|
|
20
|
+
snapshot(): HubApproval[];
|
|
21
|
+
claim(requestId: string, holderId: string): ApprovalLease;
|
|
22
|
+
/** Hold until worker acknowledgement; expiry must never permit duplicate decisions. */
|
|
23
|
+
submit(requestId: string, holderId: string): HubApproval;
|
|
24
|
+
release(requestId: string, holderId: string): void;
|
|
25
|
+
releaseHolder(holderId: string): void;
|
|
26
|
+
resolved(requestId: string): void;
|
|
27
|
+
retry(requestId: string): void;
|
|
28
|
+
clear(): void;
|
|
29
|
+
sweep(): void;
|
|
30
|
+
}
|