@ducking-mind/proxy-app-cli 0.1.1 → 0.2.0
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 +53 -15
- package/agent-contract.mjs +15 -11
- package/agent-runtime/package-guide.mjs +4 -53
- package/agent-runtime/protocol.mjs +4 -124
- package/backend-container.mjs +199 -0
- package/local-dev-credentials.mjs +5 -5
- package/local-dev-relay.mjs +129 -522
- package/package.json +7 -5
- package/platform-settings.mjs +20 -0
- package/prepare-agent-runtime.mjs +7 -40
- package/proxy-app.mjs +383 -304
- package/runtime-sources.mjs +83 -0
- package/agent-runtime/runtime-status.mjs +0 -70
- package/release-runtime/release-config.mjs +0 -154
- package/release-runtime/release-target.mjs +0 -48
package/README.md
CHANGED
|
@@ -36,6 +36,8 @@ proxy-app publish --version 0.1.0 # 切换到指定已上传版本,也可回
|
|
|
36
36
|
`publish --version <version>` 可选择历史已发布版本回滚,也可发布已上传草稿;不修改本地 manifest,
|
|
37
37
|
不要求本地版本号与目标一致,不构建或上传。目标尚未上传时直接报错,目标已是当前线上版本时不重复切换。
|
|
38
38
|
|
|
39
|
+
桌面宿主不属于本 CLI 的开源交付范围。CLI 只生成公开应用包;桌面安装包、更新元数据和签名材料由内部私有发布流水线生成。
|
|
40
|
+
|
|
39
41
|
首次创建应用:
|
|
40
42
|
|
|
41
43
|
```bash
|
|
@@ -50,22 +52,59 @@ npm run dev
|
|
|
50
52
|
|
|
51
53
|
应用目录内统一使用 `npm run dev`,参数可以通过 `npm run dev -- --port 5175` 传入。新项目的 `dev` 脚本调用 CLI,`dev:server` 脚本启动底层 Vite;CLI 完成授权和平台检查后再调用 `dev:server`。旧项目只有底层 `dev` 脚本时,仍可直接使用 `proxy-app dev` 启动。缓存开发账户无权访问应用时,CLI 会自动清除缓存并请求新的创建者授权。
|
|
52
54
|
|
|
55
|
+
### 应用服务端运行时
|
|
56
|
+
|
|
57
|
+
应用函数有一次性请求函数和任务函数两种契约,由 `@ducking-mind/proxy-app-sdk` 提供。CLI 只负责开发授权、
|
|
58
|
+
构建、校验、打包和发布,不承载云端 JavaScript 执行引擎。应用在本地使用 SDK 的
|
|
59
|
+
`createAppRuntimeDevPlugin()` 时,handler 会挂到现有 Vite server 的 `/api/*` 中,不会额外启动
|
|
60
|
+
Node 服务;这是一次性请求函数的本地模式。任务函数始终通过 `/v1/app-runtime/tasks` 进入 Gateway;
|
|
61
|
+
开发时 CLI 将本地 `runtime/*.js|*.mjs` 登记到应用当前草稿版本,Gateway 持久化任务并派发给 Runtime Host,
|
|
62
|
+
和发布版本使用同一条任务链路。开发者只能预览自己的未发布函数;已上传或发布版本的函数源码不可被本地开发覆盖,修改时必须递增 `manifest.version`。
|
|
63
|
+
|
|
64
|
+
运行时应用不得在 `manifest.json` 声明数据库。Runtime Host 是无状态 QuickJS 执行器,不连接数据库、
|
|
65
|
+
不提供文件系统和 Secret 能力。函数需要平台数据时,通过 `context.platform.request()` 调用 Gateway
|
|
66
|
+
公开接口;任务的状态、租约、进度、重试和结果由 Gateway 任务面持久化。
|
|
67
|
+
|
|
68
|
+
#### 本地容器后端(过渡联调)
|
|
69
|
+
|
|
70
|
+
manifest 可选择声明 Node 22 应用后端:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"backend": {
|
|
75
|
+
"type": "container",
|
|
76
|
+
"runtime": "node22",
|
|
77
|
+
"entry": "server/index.mjs",
|
|
78
|
+
"port": 8080,
|
|
79
|
+
"health_check": "/healthz",
|
|
80
|
+
"routes": ["/api/*"]
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
入口必须位于 `server/`,并由应用监听 CLI 提供的 `HOST` 与 `PORT`。`proxy-app dev` 完成平台开发检查后,会在 Docker Engine 中以 Node 22 启动后端,并与前端 Vite 服务一起管理;容器以非 root 用户运行,根文件系统只读,源码和依赖描述只读挂载,依赖安装在临时文件系统中,且设置 CPU、内存和进程数限制。后端端口仅绑定到本机回环地址,前端访问同源 `/api/*` 会转发到该后端。开发环境要求 Docker Engine 已启动,依赖必须有 `package-lock.json` 或 Yarn Classic `yarn.lock`。`health_check`、`routes`、`resources.max_concurrency` 当前只做格式校验并写入包内;CPU 和内存会用于本地容器限额,CLI 尚未执行健康探测或本地路由限制。Docker 开发容器用于本地兼容联调,不代表云端多租户安全隔离,也不限制应用代码访问公网。
|
|
86
|
+
|
|
87
|
+
`proxy-app pack` 会将构建后的前端和 `server/` 源码放入 ZIP。平台目前没有应用 handler 的构建、共享运行时和 Gateway 路由能力,因此 `upload` 与 `publish` 会拒绝此类 manifest;打包产物仅供本地检查和后续云端构建链路使用。后端暂时没有可信的用户身份上下文或 Server SDK,不能把浏览器提交的用户 ID、权限或身份头作为服务端授权依据。Node 容器是本地联调过渡方案,云端目标架构见[无状态 JavaScript 服务端运行时](../../docs/APP_CONTAINER_RUNTIME_DESIGN.md)。
|
|
88
|
+
|
|
53
89
|
`init` 生成的项目统一使用根目录 `index.html` 入口,并包含一个可被主应用注册的
|
|
54
|
-
`read_metadata` 示例、`src/agent-tools.mjs`、`agent/guide.md
|
|
55
|
-
|
|
56
|
-
|
|
90
|
+
`read_metadata` 示例、`src/agent-tools.mjs`、`agent/guide.md`、工作流和校验示例。能力和处理器在 `registerAgentTools`
|
|
91
|
+
中的同一条 `registry.register(declaration, handler)` 路径实现;CLI 执行该函数收集构建目录。源码 manifest 不包含 Agent 配置,
|
|
92
|
+
也不支持单独导出的能力数组。新增能力的最短流程见
|
|
57
93
|
[应用开发指南](../docs/APP_DEVELOPMENT.md)。
|
|
58
94
|
|
|
59
95
|
新项目的 manifest 不需要 `apis`、`permissions`、`backend`、`pricing`。省略时默认使用平台 Gateway,
|
|
60
96
|
开放全部当前公开接口和 KV;收费在“我的应用”中设置,提交开放申请前必须填写按调用抽成比例。已有应用可保留显式数组来缩小能力范围,显式空数组表示不使用。
|
|
61
|
-
|
|
97
|
+
已发布应用由 Portal 以同源顶层文档启动。浏览器在 Portal 登录后,使用 HttpOnly 设备 Cookie 换取绑定应用版本的 `ast_*` App Session,并只保存在当前标签页;资料、KV 和 Gateway 请求通过该会话访问 Gateway。SDK 的 `local` 模式通过 Portal 登录弹窗获取同样的 App Session,Vite 只代理 Gateway `/v1`;任务函数仍由 Gateway 持久化并派发给 Runtime Host,不在 Vite 中执行。
|
|
62
98
|
入口统一为应用根目录的 `index.html`。旧应用即使仍带有 `runtime` 或 `desktop` 字段也会继续兼容读取;这些字段不参与运行方式选择,历史 iframe 运行方式仍不再支持。
|
|
63
99
|
|
|
64
|
-
|
|
65
|
-
`
|
|
100
|
+
已有项目迁移时,将旧 `manifest.agent` 的元数据移入 `agentMetadata`,并把每项能力的 Schema 与实际处理器合并到
|
|
101
|
+
`registerAgentTools` 中的注册调用,再从源码 manifest 删除 `agent`。已经发布的旧版应用包仍保持宿主兼容;新版 CLI
|
|
102
|
+
只从注册实现生成发布目录,声明数组形式的源码会被拒绝。
|
|
66
103
|
|
|
67
104
|
开发和发布已有应用时,将 `manifest.id` 设为平台应用的公开 slug(或稳定 `app_...` ID),即可直接运行
|
|
68
105
|
`proxy-app dev`、`proxy-app upload` 和 `proxy-app publish`;不需要本地绑定文件、`link` 或 `repair`。
|
|
106
|
+
|
|
107
|
+
本地开发默认将 `/v1` 代理到线上 Gateway `https://api.duckingmind.com/v1`,与远程平台签发的 App Session 保持同一数据面。需要整套本地平台联调时,显式设置 `PROXY_PLATFORM_GATEWAY_URL` 覆盖默认地址。
|
|
69
108
|
`.proxy-app.json` 仅作为旧项目的兼容缓存,仍可由 `create` / `link` / `repair` 生成;`upload`、`publish` 和 `status` 不读取它,始终按 `manifest.id` 查询平台应用。
|
|
70
109
|
公开 slug 直接使用输入值,例如 `canvas`、`my-app` 或 `app.proxy.canvas`,不会自动添加前缀;
|
|
71
110
|
长度为 1–120,规则为 `^[a-z0-9]+(?:[.-][a-z0-9]+)*$`。`validate`、`pack`、`dev` 都使用同一规则,
|
|
@@ -117,14 +156,13 @@ proxy-app pack apps/my-app dist/my-app.zip
|
|
|
117
156
|
CLI 会继续检查并启动应用;十分钟内未完成会超时,也可用 Ctrl+C 取消。
|
|
118
157
|
本地开发不要求手工配置 token。授权失败时,页面会显示当前授权账户、开发者登录页面、API 地址和应用 slug,便于区分账户、slug 与平台地址不匹配。若看到旧式通用错误页,请先停止旧的开发进程,再从应用目录运行 `npm run dev`。
|
|
119
158
|
|
|
120
|
-
macOS
|
|
159
|
+
macOS 默认将开发者授权保存在系统钥匙串,按平台地址、应用和项目目录隔离。运行用户的 Portal Cookie 由 Portal 管理,App Session 只保存在当前浏览器标签页。
|
|
121
160
|
每次重启都重新登记当前 manifest 与本地入口,开发权限不能依赖上一次检查结果。
|
|
122
|
-
|
|
123
|
-
|
|
161
|
+
开发检查通过后,CLI 直接启动 Vite,不预取用户 App Session。
|
|
162
|
+
应用打开后通过 Portal 登录弹窗取得当前用户的 App Session。开发时 Vite 的同源桥仅转发 HttpOnly 设备 Cookie 和 CLI 为当前草稿签发的预览许可,不读取 Portal JWT,也不缓存 App Session;生产版本直接由 Portal Cookie 换取已发布版本会话。预览许可绑定单个应用和版本,用户 AST 仍归属于各自的 Portal 账户。
|
|
124
163
|
|
|
125
|
-
开发账户错误时,重新运行 `proxy-app dev apps/my-app` 即可。CLI 会在登记返回账户无权访问的 401、403 或 404
|
|
126
|
-
|
|
127
|
-
`app.auth.login({ forceLogin: true })` 只切换运行用户。平台确认缓存授权过期后才要求重新登录,临时故障保留缓存。
|
|
164
|
+
开发账户错误时,重新运行 `proxy-app dev apps/my-app` 即可。CLI 会在登记返回账户无权访问的 401、403 或 404 时清除开发者授权缓存,并打开一次新的创建者授权入口。运行用户会话独立于 CLI。
|
|
165
|
+
应用内的认证状态通过 Gateway `GET /v1/profile` 预检;App Session 过期或无效时,应用登录按钮会打开 Portal,通过 HttpOnly 设备 Cookie 换取新会话。若设备 Cookie 不存在,Portal 登录后会签发 Cookie 并重试换票;账户切换后重新加载应用以隔离旧账户工作区。
|
|
128
166
|
|
|
129
167
|
其他系统或钥匙串不可用时,仅使用当前进程内存,不会退回明文文件保存 token。
|
|
130
168
|
如需禁用持久缓存,可运行:
|
|
@@ -133,7 +171,7 @@ macOS 默认分别缓存开发者授权和应用运行用户授权,按平台
|
|
|
133
171
|
proxy-app dev apps/my-app --no-auth-cache
|
|
134
172
|
```
|
|
135
173
|
|
|
136
|
-
|
|
174
|
+
此选项禁用开发者授权的钥匙串缓存,不读取或更新已有的钥匙串授权。CLI 不会把用户会话写入项目或构建产物。
|
|
137
175
|
|
|
138
176
|
`build`、`validate`、`pack` 只验证本地产物,不代表远程绑定有效。`doctor` 的绑定检查也是本地结构检查。
|
|
139
177
|
上传和发布继续在平台验证应用归属与版本,开发启动前置检查不能替代它们。
|
|
@@ -159,10 +197,10 @@ CLI 从已导出的变量读取配置,不会自动加载 `.env` 文件。请
|
|
|
159
197
|
|
|
160
198
|
创建应用时,`--category` 支持 `ecommerce`(电商)、`comic`(漫剧)和 `other`(其他,默认)。分类用于应用市场展示,创建后可在“编辑应用资料”中调整。
|
|
161
199
|
|
|
162
|
-
|
|
163
|
-
只设置 `PROXY_PLATFORM_URL=https://platform.example` 即可;CLI 自动使用该站点的 `/api` 与登录页。若平台把 API 部署在独立域名,再额外设置 `PROXY_API_BASE_URL` 或 `PROXY_PORTAL_URL` 覆盖对应地址。单地址配置时,请清除已导出的旧覆盖变量。
|
|
200
|
+
默认使用线上平台 `https://www.duckingmind.com`,API 地址为 `https://www.duckingmind.com/api`,登录页也使用该域名。其他环境若 Portal 与 API 使用同一域名,只设置 `PROXY_PLATFORM_URL=https://platform.example` 即可;若两者使用不同地址,再额外设置 `PROXY_API_BASE_URL` 或 `PROXY_PORTAL_URL` 覆盖对应地址。连接本地平台时,可分别设置 `PROXY_API_BASE_URL=http://localhost:9003/api` 与 `PROXY_PORTAL_URL=http://localhost:9004`。
|
|
164
201
|
管理命令(创建、发布等)仍需 `PROXY_USER_ACCESS_TOKEN`;本地开发使用浏览器授权,不需要这个变量。
|
|
165
202
|
若 shell 已全局导出 `PROXY_USER_ACCESS_TOKEN` 或旧别名 `PROXY_ACCESS_TOKEN`,开发命令会优先使用该令牌;想在浏览器中选择创建者账户时先运行 `unset PROXY_USER_ACCESS_TOKEN PROXY_ACCESS_TOKEN`。
|
|
203
|
+
本地开发时,Portal 在登录后设置 HttpOnly 设备 Cookie;应用通过 Portal 登录弹窗换取自己的 App Session,并将它保存在当前标签页。Vite 只在本地换票时转发设备 Cookie,不读取或转存 Portal JWT,也不保存或续期用户 App Session。
|
|
166
204
|
|
|
167
205
|
manifest、运行时 API 和发布规范请参阅仓库文档:
|
|
168
206
|
|
package/agent-contract.mjs
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { spawn } from 'node:child_process'
|
|
4
|
-
import {
|
|
4
|
+
import { realpathSync } from 'node:fs'
|
|
5
|
+
import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
5
6
|
import { resolve, sep } from 'node:path'
|
|
6
|
-
import { pathToFileURL } from 'node:url'
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
7
8
|
|
|
8
|
-
const runtimeDirectory =
|
|
9
|
-
if (error.code !== 'ENOENT') throw error
|
|
10
|
-
return './agent-runtime/'
|
|
11
|
-
})
|
|
9
|
+
const runtimeDirectory = './agent-runtime/'
|
|
12
10
|
const { normalizeCapabilityDeclaration, normalizeManifestAgent } = await import(new URL(`${runtimeDirectory}protocol.mjs`, import.meta.url))
|
|
13
11
|
const { copyAgentPackage } = await import(new URL(`${runtimeDirectory}package-guide.mjs`, import.meta.url))
|
|
14
12
|
|
|
@@ -40,7 +38,7 @@ async function assertSafeEntry(root, entry) {
|
|
|
40
38
|
// the build validating Node's cached version. The child receives a path only
|
|
41
39
|
// after it has passed the project-local source checks above.
|
|
42
40
|
async function loadContract(target) {
|
|
43
|
-
const worker = `import { writeFileSync } from 'node:fs';\nconst module = await import(process.argv[1]);\nwriteFileSync(3, JSON.stringify({ agentMetadata: module.agentMetadata, capabilities
|
|
41
|
+
const worker = `import { writeFileSync } from 'node:fs';\nconst module = await import(process.argv[1]);\nif (typeof module.registerAgentTools !== 'function') throw new Error('src/agent-tools.mjs must export registerAgentTools(); capability arrays are not supported');\nconst capabilities = [];\nconst cleanup = module.registerAgentTools({ register(declaration) { capabilities.push(declaration); return () => {}; } });\nif (cleanup !== undefined && !Array.isArray(cleanup)) throw new Error('registerAgentTools must return an array of cleanup functions or undefined');\nwriteFileSync(3, JSON.stringify({ agentMetadata: module.agentMetadata, capabilities }));\nprocess.exit(0);`
|
|
44
42
|
return new Promise((resolvePromise, reject) => {
|
|
45
43
|
const child = spawn(process.execPath, ['--input-type=module', '-e', worker, pathToFileURL(target).href], { stdio: ['ignore', 'ignore', 'pipe', 'pipe'] })
|
|
46
44
|
const output = [], errors = []
|
|
@@ -58,7 +56,7 @@ async function loadContract(target) {
|
|
|
58
56
|
if (failure) return reject(failure)
|
|
59
57
|
if (code !== 0) return reject(new Error(`cannot load agent contract source: ${Buffer.concat(errors).toString().slice(0, 2048).trim() || `process exited with ${code}`}`))
|
|
60
58
|
try { resolvePromise(JSON.parse(Buffer.concat(output).toString())) }
|
|
61
|
-
catch { reject(new Error('src/agent-tools.mjs must
|
|
59
|
+
catch { reject(new Error('src/agent-tools.mjs must expose JSON-serializable agentMetadata and capabilities through registerAgentTools()')) }
|
|
62
60
|
})
|
|
63
61
|
})
|
|
64
62
|
}
|
|
@@ -66,7 +64,9 @@ async function loadContract(target) {
|
|
|
66
64
|
/**
|
|
67
65
|
* Resolve the code-owned Agent contract for a local source project.
|
|
68
66
|
* `src/agent-tools.mjs` is discovered by convention; source manifests never
|
|
69
|
-
* opt into or enumerate Agent capabilities.
|
|
67
|
+
* opt into or enumerate Agent capabilities. The module's registration function
|
|
68
|
+
* is executed with a collector, so the package contract is derived from the
|
|
69
|
+
* same code path that binds runtime handlers. Installed packages carry the
|
|
70
70
|
* generated contract so MCP can discover tools before launching the app.
|
|
71
71
|
*/
|
|
72
72
|
export async function resolveAgentContract(root, manifest) {
|
|
@@ -82,7 +82,7 @@ export async function resolveAgentContract(root, manifest) {
|
|
|
82
82
|
throw error
|
|
83
83
|
}
|
|
84
84
|
const { agentMetadata, capabilities } = await loadContract(target)
|
|
85
|
-
if (!Array.isArray(capabilities) || capabilities.length > 64) throw new Error('src/agent-tools.mjs must
|
|
85
|
+
if (!Array.isArray(capabilities) || capabilities.length > 64) throw new Error('src/agent-tools.mjs must register at most 64 capabilities')
|
|
86
86
|
if (!isRecord(agentMetadata)) throw new Error('src/agent-tools.mjs must export agentMetadata as an object')
|
|
87
87
|
const allowedMetadata = new Set(['namespace', 'targets', 'contract_version', 'summary', 'keywords', 'guide', 'examples'])
|
|
88
88
|
for (const key of Object.keys(agentMetadata)) if (!allowedMetadata.has(key)) throw new Error(`unsupported agentMetadata field: ${key}`)
|
|
@@ -116,7 +116,11 @@ export async function writeAgentContract(root, destination) {
|
|
|
116
116
|
return manifest
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
|
|
119
|
+
const invokedDirectly = process.argv[1] && (() => {
|
|
120
|
+
try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)) } catch { return false }
|
|
121
|
+
})()
|
|
122
|
+
|
|
123
|
+
if (invokedDirectly) {
|
|
120
124
|
try {
|
|
121
125
|
const [, , root = '.', destination = 'dist'] = process.argv
|
|
122
126
|
await writeAgentContract(root, destination)
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
/** Public Agent guide and example package validation helpers. The private host
|
|
2
|
+
* owns lifecycle, transport, authorization, and release behavior. */
|
|
3
|
+
|
|
1
4
|
import { createHash } from 'node:crypto'
|
|
2
5
|
import { constants } from 'node:fs'
|
|
3
6
|
import { lstat, mkdir, open, realpath } from 'node:fs/promises'
|
|
4
7
|
import { join, resolve } from 'node:path'
|
|
5
|
-
import { normalizeManifestAgent,
|
|
8
|
+
import { normalizeManifestAgent, validateValue } from './protocol.mjs'
|
|
6
9
|
|
|
7
10
|
const MAX_TEXT_BYTES = 64 * 1024
|
|
8
11
|
const MAX_PACKAGE_BYTES = 256 * 1024
|
|
@@ -112,47 +115,6 @@ async function readAgentPackage(root, manifest) {
|
|
|
112
115
|
return readAgentPackageWithReader(manifest, entry => readPackageFile(packageRoot, entry))
|
|
113
116
|
}
|
|
114
117
|
|
|
115
|
-
/** Load the same bounded, schema-validated package resources for Web apps.
|
|
116
|
-
* The platform-provided versioned entry anchors every URL; no account bearer
|
|
117
|
-
* credential is sent to application content, even when hosted on a CDN.
|
|
118
|
-
*/
|
|
119
|
-
export async function loadWebAgentPackage(manifest, { entryURL, platformOrigin, fetchImpl = fetch, signal } = {}) {
|
|
120
|
-
signal = signal ? AbortSignal.any([signal, AbortSignal.timeout(15000)]) : AbortSignal.timeout(15000)
|
|
121
|
-
const entry = new URL(entryURL, platformOrigin)
|
|
122
|
-
check(['https:', 'http:'].includes(entry.protocol) && !entry.username && !entry.password, 'invalid Web entry URL')
|
|
123
|
-
check(entry.protocol === 'https:' || ['localhost', '127.0.0.1', '[::1]'].includes(entry.hostname), 'insecure Web entry URL')
|
|
124
|
-
const runtimeEntry = 'index.html'
|
|
125
|
-
const suffix = runtimeEntry.split('/').map(encodeURIComponent).join('/')
|
|
126
|
-
check(entry.pathname.endsWith(`/${suffix}`), 'Web entry does not match the declared application entry')
|
|
127
|
-
const prefix = entry.pathname.slice(0, -suffix.length)
|
|
128
|
-
const { files, ...data } = await readAgentPackageWithReader(manifest, async path => {
|
|
129
|
-
const url = new URL(entry)
|
|
130
|
-
url.pathname = prefix + path.split('/').map(encodeURIComponent).join('/')
|
|
131
|
-
url.hash = ''
|
|
132
|
-
const response = await fetchImpl(url, { signal, redirect: 'error', credentials: 'omit' })
|
|
133
|
-
check(response.ok, `Web package entry unavailable: ${path} (${response.status})`)
|
|
134
|
-
check(!response.headers.get('content-length') || Number(response.headers.get('content-length')) <= MAX_TEXT_BYTES, `entry exceeds 64 KiB: ${path}`)
|
|
135
|
-
check(response.body, `empty Web package response: ${path}`)
|
|
136
|
-
const reader = response.body.getReader()
|
|
137
|
-
const chunks = []
|
|
138
|
-
let length = 0
|
|
139
|
-
try {
|
|
140
|
-
while (true) {
|
|
141
|
-
const { value, done } = await reader.read()
|
|
142
|
-
if (done) break
|
|
143
|
-
length += value.length
|
|
144
|
-
check(length <= MAX_TEXT_BYTES, `entry exceeds 64 KiB: ${path}`)
|
|
145
|
-
chunks.push(value)
|
|
146
|
-
}
|
|
147
|
-
} finally { await reader.cancel().catch(() => {}) }
|
|
148
|
-
const bytes = Buffer.concat(chunks)
|
|
149
|
-
let content
|
|
150
|
-
try { content = new TextDecoder('utf-8', { fatal: true }).decode(bytes) } catch { throw new Error(`Invalid UTF-8 agent package entry: ${path}`) }
|
|
151
|
-
return { bytes, content }
|
|
152
|
-
})
|
|
153
|
-
return data
|
|
154
|
-
}
|
|
155
|
-
|
|
156
118
|
/** Read only declared, bounded files from the selected application version. */
|
|
157
119
|
export async function loadAgentPackage(root, manifest) {
|
|
158
120
|
const { files, ...data } = await readAgentPackage(root, manifest)
|
|
@@ -180,14 +142,3 @@ export async function copyAgentPackage(source, destination, manifest) {
|
|
|
180
142
|
}
|
|
181
143
|
return data
|
|
182
144
|
}
|
|
183
|
-
|
|
184
|
-
/** The API topic is generated from the same declaration tools/list publishes. */
|
|
185
|
-
export function renderAgentReference(manifest) {
|
|
186
|
-
const agent = normalizeManifestAgent(manifest)
|
|
187
|
-
if (!agent) return ''
|
|
188
|
-
const lines = [`# ${manifest.name || manifest.id} API`, '', `Application: ${manifest.id}`, `Application version: ${manifest.version}`, `Contract version: ${manifest.agent.contract_version || manifest.version}`, `Application targets: ${(agent.targets || ['desktop']).join(', ')}`, '', 'Generated from the active package manifest. Read the overview and workflow topics for business rules.', '']
|
|
189
|
-
for (const capability of agent.capabilities) {
|
|
190
|
-
lines.push(`## ${toolNameFor(agent.namespace, capability.name)}`, '', capability.description, '', `Targets: ${capability.targets.join(', ')}; access: ${capability.access}; idempotent: ${capability.idempotent}.`, '', 'Input schema:', '```json', JSON.stringify(capability.input_schema, null, 2), '```', '', 'Output schema:', '```json', JSON.stringify(capability.output_schema, null, 2), '```', '')
|
|
191
|
-
}
|
|
192
|
-
return lines.join('\n')
|
|
193
|
-
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Public compatibility primitives for an app Agent contract (for example,
|
|
3
|
+
* the `proxy.desktop.agent/v1` data format). This module validates application
|
|
4
|
+
* declarations and schemas; runtime registration, transport, authorization,
|
|
5
|
+
* and host lifecycle remain in the private desktop implementation.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
|
-
import { serializeAgentError } from './runtime-status.mjs'
|
|
8
|
-
import { createHash } from 'node:crypto'
|
|
9
|
-
|
|
10
8
|
export const AGENT_PROTOCOL = 'proxy.desktop.agent/v1'
|
|
11
9
|
export const AGENT_PROTOCOL_VERSION = 1
|
|
12
10
|
|
|
@@ -38,17 +36,6 @@ export function isAppManifestID(value) {
|
|
|
38
36
|
return isPublicAppSlug(value) || (typeof value === 'string' && value === value.trim() && PLATFORM_APP_ID_RE.test(value))
|
|
39
37
|
}
|
|
40
38
|
|
|
41
|
-
// Only metadata from the authenticated platform (or the trusted account
|
|
42
|
-
// broker) may approve a historical manifest identity. Never infer an alias by
|
|
43
|
-
// adding/removing a prefix, and never alias one stable platform ID to another.
|
|
44
|
-
export function appManifestIdentityMatches(value, metadata) {
|
|
45
|
-
if (!isAppManifestID(value) || !metadata) return false
|
|
46
|
-
const id = metadata.app_id || metadata.id
|
|
47
|
-
if (PLATFORM_APP_ID_RE.test(value)) return value === id
|
|
48
|
-
if (value === (metadata.app_slug || metadata.slug)) return true
|
|
49
|
-
return Array.isArray(metadata.app_aliases) && metadata.app_aliases.some(alias => isPublicAppSlug(alias) && alias === value)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
39
|
export function assertAppId(value) {
|
|
53
40
|
assert(isAppManifestID(value) || (typeof value === 'string' && value === value.trim() && LEGACY_APP_ID_RE.test(value)), `invalid app id: ${value}`)
|
|
54
41
|
return value
|
|
@@ -164,86 +151,6 @@ export function normalizeManifestAgent(manifest) {
|
|
|
164
151
|
return Object.freeze({ enabled: true, namespace, targets, contractVersion, summary, keywords, guide: agent.guide, examples: agent.examples, capabilities })
|
|
165
152
|
}
|
|
166
153
|
|
|
167
|
-
const canonical = value => Array.isArray(value) ? value.map(canonical) : isRecord(value) ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value
|
|
168
|
-
/** Same contract identity for market metadata and installed packages, independent of transport/runtime state. */
|
|
169
|
-
export function describeAgentContract(manifest) {
|
|
170
|
-
const agent = normalizeManifestAgent(manifest)
|
|
171
|
-
if (!agent) return null
|
|
172
|
-
const contract = { appId: manifest.id, namespace: agent.namespace, contractVersion: agent.contractVersion, targets: agent.targets,
|
|
173
|
-
capabilities: [...agent.capabilities].sort((a, b) => a.name.localeCompare(b.name)) }
|
|
174
|
-
return { enabled: true, namespace: agent.namespace, targets: agent.targets, contractVersion: agent.contractVersion,
|
|
175
|
-
contractHash: createHash('sha256').update(JSON.stringify(canonical(contract))).digest('hex'),
|
|
176
|
-
capabilityCount: agent.capabilities.length,
|
|
177
|
-
desktopCapabilityCount: agent.capabilities.filter(capability => capability.targets.includes('desktop')).length,
|
|
178
|
-
webCapabilityCount: agent.capabilities.filter(capability => capability.targets.includes('web')).length,
|
|
179
|
-
capabilities: agent.capabilities.map(({ name, title, description, access, targets, billable, completion }) => ({ name, title, description, access, targets, billable, completion })) }
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export function normalizeRuntimeCapability(input) {
|
|
183
|
-
assert(isRecord(input), 'runtime capability must be an object')
|
|
184
|
-
const name = assertCapabilityName(input.name)
|
|
185
|
-
if (input.handlerVersion !== undefined) {
|
|
186
|
-
assert(Number.isInteger(input.handlerVersion) && input.handlerVersion > 0, `invalid handlerVersion for ${name}`)
|
|
187
|
-
}
|
|
188
|
-
if (input.available !== undefined) assert(typeof input.available === 'boolean', `invalid available for ${name}`)
|
|
189
|
-
return Object.freeze({ name, handlerVersion: input.handlerVersion || 1, available: input.available !== false })
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
export function validateRuntimeRegistration(manifest, registration, { target = 'desktop' } = {}) {
|
|
193
|
-
assert(AGENT_TARGETS.includes(target), 'invalid runtime target')
|
|
194
|
-
assert(isRecord(registration), 'runtime registration must be an object')
|
|
195
|
-
const agent = normalizeManifestAgent(manifest)
|
|
196
|
-
assert(agent, 'agent capabilities are not enabled in manifest')
|
|
197
|
-
assertAppId(registration.appId)
|
|
198
|
-
assertVersion(registration.version)
|
|
199
|
-
assert(registration.appId === manifest.id, 'runtime app id does not match manifest')
|
|
200
|
-
assert(registration.version === manifest.version, 'runtime app version does not match manifest')
|
|
201
|
-
assert(Array.isArray(registration.capabilities) && registration.capabilities.length <= 64, 'runtime capabilities must be an array with at most 64 entries')
|
|
202
|
-
const codeFirst = manifest.agent.contract_source === 'code'
|
|
203
|
-
const dynamic = registration.dynamic === true && !codeFirst
|
|
204
|
-
const declarations = new Map(agent.capabilities.map((capability) => [capability.name, capability]))
|
|
205
|
-
const runtime = []
|
|
206
|
-
const registeredDeclarations = []
|
|
207
|
-
const seen = new Set()
|
|
208
|
-
for (const raw of registration.capabilities) {
|
|
209
|
-
const capability = normalizeRuntimeCapability(raw)
|
|
210
|
-
assert(!seen.has(capability.name), `duplicate runtime capability: ${capability.name}`)
|
|
211
|
-
seen.add(capability.name)
|
|
212
|
-
let declared = declarations.get(capability.name)
|
|
213
|
-
const hasContract = ['title', 'description', 'access', 'input_schema', 'inputSchema', 'output_schema', 'outputSchema', 'targets', 'completion', 'idempotent', 'destructive', 'open_world', 'billable'].some(field => Object.hasOwn(raw, field))
|
|
214
|
-
let supplied
|
|
215
|
-
if (codeFirst || dynamic || hasContract) {
|
|
216
|
-
if (codeFirst || dynamic) assert(raw.access !== undefined && (raw.input_schema ?? raw.inputSchema) !== undefined && (raw.output_schema ?? raw.outputSchema) !== undefined, `runtime capability requires a complete code declaration: ${capability.name}`)
|
|
217
|
-
supplied = normalizeCapabilityDeclaration(raw, { defaultTargets: agent.targets })
|
|
218
|
-
assert(supplied.targets.every(item => agent.targets.includes(item)), `${capability.name}.targets must be a subset of application targets`)
|
|
219
|
-
assert(supplied.targets.includes(target), `runtime capability does not support ${target}: ${capability.name}`)
|
|
220
|
-
}
|
|
221
|
-
// Code-first applications provide the complete contract at runtime. The
|
|
222
|
-
// names-only form remains a compatibility path for already packaged
|
|
223
|
-
// applications whose generated manifest contains the contract.
|
|
224
|
-
if (declared) {
|
|
225
|
-
assert(declared.targets.includes(target), `runtime capability does not support ${target}: ${capability.name}`)
|
|
226
|
-
if (codeFirst) {
|
|
227
|
-
assert(JSON.stringify(canonical(supplied)) === JSON.stringify(canonical(declared)), `runtime capability contract differs from packaged code contract: ${capability.name}`)
|
|
228
|
-
}
|
|
229
|
-
} else if (!dynamic) {
|
|
230
|
-
assert(false, `runtime capability is not declared: ${capability.name}`)
|
|
231
|
-
}
|
|
232
|
-
if (dynamic) {
|
|
233
|
-
declared = supplied
|
|
234
|
-
declarations.set(capability.name, declared)
|
|
235
|
-
}
|
|
236
|
-
registeredDeclarations.push(declared)
|
|
237
|
-
if (capability.available) runtime.push({ ...capability, declaration: declared })
|
|
238
|
-
}
|
|
239
|
-
return Object.freeze({
|
|
240
|
-
appId: registration.appId,
|
|
241
|
-
version: registration.version,
|
|
242
|
-
capabilities: runtime,
|
|
243
|
-
declarations: registeredDeclarations,
|
|
244
|
-
})
|
|
245
|
-
}
|
|
246
|
-
|
|
247
154
|
export function toolNameFor(namespace, capability) {
|
|
248
155
|
assertNamespace(namespace)
|
|
249
156
|
assertCapabilityName(capability)
|
|
@@ -252,33 +159,6 @@ export function toolNameFor(namespace, capability) {
|
|
|
252
159
|
return name
|
|
253
160
|
}
|
|
254
161
|
|
|
255
|
-
export function toMcpTool({ namespace, appId, version, contractVersion = version, contractHash, capability, runtime }) {
|
|
256
|
-
const declaration = normalizeCapabilityDeclaration({ ...capability, name: capability.name })
|
|
257
|
-
const tool = {
|
|
258
|
-
name: toolNameFor(namespace, declaration.name),
|
|
259
|
-
title: declaration.title,
|
|
260
|
-
description: [declaration.description || `${appId} ${declaration.name}`, declaration.billable ? 'May incur model or service charges.' : '', declaration.completion === 'accepted' ? 'Returns acceptance only; use the application task query to check completion.' : ''].filter(Boolean).join(' '),
|
|
261
|
-
inputSchema: declaration.input_schema,
|
|
262
|
-
_meta: { proxy: { appId, version, contractVersion, ...(contractHash ? { contractHash } : {}), capability: declaration.name, billable: declaration.billable, completion: declaration.completion, targets: declaration.targets } },
|
|
263
|
-
}
|
|
264
|
-
if (declaration.output_schema) tool.outputSchema = declaration.output_schema
|
|
265
|
-
tool.annotations = { readOnlyHint: declaration.access === ACCESS.READ, destructiveHint: declaration.destructive, idempotentHint: declaration.idempotent, openWorldHint: declaration.open_world }
|
|
266
|
-
if (runtime) tool._meta.proxy.runtime = runtime
|
|
267
|
-
return Object.freeze(tool)
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
export function createInvocation({ requestId, appId, appVersion, capability, args, deadlineAt, cancellationToken }) {
|
|
271
|
-
assert(typeof requestId === 'string' && requestId.length > 0 && requestId.length <= 128, 'invalid request id')
|
|
272
|
-
assertAppId(appId); assertVersion(appVersion); assertCapabilityName(capability)
|
|
273
|
-
assert(args === undefined || isRecord(args), 'agent arguments must be an object')
|
|
274
|
-
return Object.freeze({ requestId, appId, appVersion, capability, args: args || {}, deadlineAt, cancellationToken })
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
export function serializeError(error) {
|
|
278
|
-
return serializeAgentError(error)
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
|
|
282
162
|
// Deliberately bounded JSON Schema subset. Reject unsupported keywords instead
|
|
283
163
|
// of advertising validation that the host cannot actually enforce.
|
|
284
164
|
const keywords = new Set(['type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'minLength', 'maxLength', 'minimum', 'maximum', 'minItems', 'maxItems', 'description', 'title', 'default'])
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { lstat, readFile, readdir } from 'node:fs/promises'
|
|
4
|
+
import { join, resolve } from 'node:path'
|
|
5
|
+
|
|
6
|
+
export const LOCAL_BACKEND_IMAGE = 'node:22-bookworm-slim'
|
|
7
|
+
|
|
8
|
+
const LOCKFILES = Object.freeze([
|
|
9
|
+
{ file: 'package-lock.json', install: 'npm ci --prefix /workspace --cache /tmp/npm-cache --no-audit --no-fund' },
|
|
10
|
+
{ file: 'yarn.lock', install: 'corepack yarn@1.22.22 --cwd /workspace install --frozen-lockfile --non-interactive --cache-folder /tmp/yarn-cache --modules-folder /workspace/node_modules' },
|
|
11
|
+
])
|
|
12
|
+
|
|
13
|
+
function shellQuote(value) {
|
|
14
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function dockerMountPath(value) {
|
|
18
|
+
return resolve(value).replaceAll('\\', '\\\\').replaceAll(',', '\\,')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function dockerMount(source, target, readOnly = false) {
|
|
22
|
+
return `type=bind,src=${dockerMountPath(source)},dst=${target}${readOnly ? ',readonly' : ''}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function safeContainerLabel(value) {
|
|
26
|
+
return String(value).replace(/[^A-Za-z0-9_.-]/g, '-').slice(0, 120) || 'app'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function backendSources(serverDir) {
|
|
30
|
+
const root = await lstat(serverDir).catch((error) => {
|
|
31
|
+
if (error.code === 'ENOENT') throw new Error('container backend source directory is missing: server/')
|
|
32
|
+
throw error
|
|
33
|
+
})
|
|
34
|
+
if (!root.isDirectory() || root.isSymbolicLink()) throw new Error('container backend source must be a regular server/ directory')
|
|
35
|
+
|
|
36
|
+
const visit = async (directory, relative = '') => {
|
|
37
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
38
|
+
const path = join(directory, entry.name)
|
|
39
|
+
const name = relative ? `${relative}/${entry.name}` : entry.name
|
|
40
|
+
if (entry.isSymbolicLink()) throw new Error(`container backend source cannot contain symbolic links: server/${name}`)
|
|
41
|
+
if (entry.name === 'node_modules') throw new Error('server/node_modules is not mounted; declare backend dependencies in the app package.json')
|
|
42
|
+
if (entry.name === '.npmrc' || entry.name === '.env' || entry.name.startsWith('.env.')) {
|
|
43
|
+
throw new Error(`sensitive file cannot be mounted into the local backend container: server/${name}`)
|
|
44
|
+
}
|
|
45
|
+
if (entry.isDirectory()) await visit(path, name)
|
|
46
|
+
else if (!entry.isFile()) throw new Error(`container backend source contains an unsupported file: server/${name}`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
await visit(serverDir)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function dependencySetup(root) {
|
|
53
|
+
const found = []
|
|
54
|
+
for (const item of LOCKFILES) {
|
|
55
|
+
const path = join(root, item.file)
|
|
56
|
+
const info = await lstat(path).catch((error) => {
|
|
57
|
+
if (error.code === 'ENOENT') return null
|
|
58
|
+
throw error
|
|
59
|
+
})
|
|
60
|
+
if (!info) continue
|
|
61
|
+
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`backend dependency lock must be a regular file: ${item.file}`)
|
|
62
|
+
found.push({ ...item, path })
|
|
63
|
+
}
|
|
64
|
+
if (found.length > 1) throw new Error('container backend requires exactly one lockfile; remove the unused npm/yarn/pnpm lockfiles')
|
|
65
|
+
const selected = found[0]
|
|
66
|
+
if (!selected) {
|
|
67
|
+
throw new Error('container backend requires a dependency lockfile (package-lock.json or a Yarn Classic yarn.lock); run npm install or yarn install first')
|
|
68
|
+
}
|
|
69
|
+
if (selected?.file === 'yarn.lock' && !(await readFile(selected.path, 'utf8')).startsWith('# yarn lockfile v1')) {
|
|
70
|
+
throw new Error('local container development currently supports Yarn Classic lockfiles; use package-lock.json for other package managers')
|
|
71
|
+
}
|
|
72
|
+
return { install: selected.install, lockfile: selected }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function gatewayForContainer(value) {
|
|
76
|
+
try {
|
|
77
|
+
const url = new URL(value)
|
|
78
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('invalid gateway URL')
|
|
79
|
+
if (['localhost', '127.0.0.1', '::1'].includes(url.hostname)) url.hostname = 'host.docker.internal'
|
|
80
|
+
return url.toString()
|
|
81
|
+
} catch {
|
|
82
|
+
throw new Error('PROXY_PLATFORM_GATEWAY_URL must be an absolute HTTP(S) URL')
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function createBackendContainerSpec({ appDir, backend, appID, version, gatewayURL, dockerBin = 'docker', dockerEnv = process.env } = {}) {
|
|
87
|
+
const root = resolve(appDir)
|
|
88
|
+
const serverDir = join(root, 'server')
|
|
89
|
+
await backendSources(serverDir)
|
|
90
|
+
|
|
91
|
+
const packagePath = join(root, 'package.json')
|
|
92
|
+
const packageInfo = await lstat(packagePath).catch((error) => {
|
|
93
|
+
if (error.code === 'ENOENT') throw new Error('container backend requires an app package.json')
|
|
94
|
+
throw error
|
|
95
|
+
})
|
|
96
|
+
if (!packageInfo.isFile() || packageInfo.isSymbolicLink()) throw new Error('container backend package.json must be a regular file')
|
|
97
|
+
|
|
98
|
+
const { install, lockfile } = await dependencySetup(root)
|
|
99
|
+
const identity = safeContainerLabel(appID)
|
|
100
|
+
const rootHash = createHash('sha256').update(root).digest('hex').slice(0, 10)
|
|
101
|
+
const containerName = `proxy-app-dev-${identity.slice(0, 36)}-${rootHash}-${process.pid}`
|
|
102
|
+
const cpu = ((backend.resources?.cpu_millicores ?? 1000) / 1000).toFixed(3).replace(/0+$/, '').replace(/\.$/, '')
|
|
103
|
+
const memory = backend.resources?.memory_mib ?? 512
|
|
104
|
+
const env = {
|
|
105
|
+
HOME: '/tmp/home',
|
|
106
|
+
TMPDIR: '/tmp',
|
|
107
|
+
COREPACK_HOME: '/tmp/corepack',
|
|
108
|
+
NODE_ENV: 'development',
|
|
109
|
+
PORT: String(backend.port),
|
|
110
|
+
HOST: '0.0.0.0',
|
|
111
|
+
PROXY_APP_ID: appID,
|
|
112
|
+
PROXY_APP_VERSION: version,
|
|
113
|
+
PROXY_APP_BACKEND_HOST: '0.0.0.0',
|
|
114
|
+
PROXY_APP_BACKEND_PORT: String(backend.port),
|
|
115
|
+
...(gatewayURL ? { PROXY_PLATFORM_GATEWAY_URL: gatewayForContainer(gatewayURL) } : {}),
|
|
116
|
+
...(backend.resources?.max_concurrency ? { PROXY_APP_MAX_CONCURRENCY: String(backend.resources.max_concurrency) } : {}),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const args = [
|
|
120
|
+
'run', '--rm', '--init', '--pull=missing',
|
|
121
|
+
`--name=${containerName}`,
|
|
122
|
+
'--label=proxy.app.dev=true',
|
|
123
|
+
`--label=proxy.app.id=${safeContainerLabel(appID)}`,
|
|
124
|
+
`--label=proxy.app.version=${safeContainerLabel(version)}`,
|
|
125
|
+
'--user=1000:1000',
|
|
126
|
+
'--read-only',
|
|
127
|
+
'--cap-drop=ALL',
|
|
128
|
+
'--security-opt=no-new-privileges:true',
|
|
129
|
+
'--pids-limit=128',
|
|
130
|
+
`--memory=${memory}m`,
|
|
131
|
+
`--memory-swap=${memory}m`,
|
|
132
|
+
`--cpus=${cpu}`,
|
|
133
|
+
'--ulimit=nofile=1024:1024',
|
|
134
|
+
'--add-host=host.docker.internal:host-gateway',
|
|
135
|
+
`--publish=127.0.0.1:${backend.port}:${backend.port}/tcp`,
|
|
136
|
+
'--tmpfs=/workspace:rw,nosuid,nodev,size=1024m,uid=1000,gid=1000,mode=755',
|
|
137
|
+
'--tmpfs=/tmp:rw,nosuid,nodev,size=256m,uid=1000,gid=1000,mode=1777',
|
|
138
|
+
`--mount=${dockerMount(serverDir, '/workspace/server', true)}`,
|
|
139
|
+
`--mount=${dockerMount(packagePath, '/workspace/package.json', true)}`,
|
|
140
|
+
...(lockfile ? [`--mount=${dockerMount(lockfile.path, `/workspace/${lockfile.file}`, true)}`] : []),
|
|
141
|
+
'--workdir=/workspace',
|
|
142
|
+
...Object.entries(env).map(([name, value]) => `--env=${name}=${value}`),
|
|
143
|
+
'--entrypoint=/bin/sh',
|
|
144
|
+
LOCAL_BACKEND_IMAGE,
|
|
145
|
+
'-lc',
|
|
146
|
+
`set -eu; mkdir -p /tmp/home; ${install}; exec node --watch -- ${shellQuote(`/workspace/${backend.entry}`)}`,
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
command: dockerBin,
|
|
151
|
+
args,
|
|
152
|
+
cwd: root,
|
|
153
|
+
env: dockerClientEnvironment(dockerEnv),
|
|
154
|
+
name: '应用后端容器',
|
|
155
|
+
containerName,
|
|
156
|
+
image: LOCAL_BACKEND_IMAGE,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function dockerClientEnvironment(source = process.env) {
|
|
161
|
+
const allowed = [
|
|
162
|
+
'PATH', 'HOME', 'USERPROFILE', 'DOCKER_HOST', 'DOCKER_CONTEXT',
|
|
163
|
+
'DOCKER_CONFIG', 'DOCKER_CERT_PATH', 'DOCKER_TLS_VERIFY',
|
|
164
|
+
'DOCKER_API_VERSION', 'DOCKER_DEFAULT_PLATFORM', 'TMPDIR', 'TEMP', 'TMP', 'SYSTEMROOT',
|
|
165
|
+
]
|
|
166
|
+
return Object.fromEntries(allowed.filter((name) => source[name] !== undefined).map((name) => [name, source[name]]))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function verifyDockerEngine({ dockerBin = 'docker', env = dockerClientEnvironment(), timeoutMs = 8_000 } = {}) {
|
|
170
|
+
await new Promise((resolvePromise, reject) => {
|
|
171
|
+
const child = spawn(dockerBin, ['info', '--format={{.ServerVersion}}'], { stdio: ['ignore', 'ignore', 'pipe'], env })
|
|
172
|
+
let settled = false
|
|
173
|
+
let stderr = ''
|
|
174
|
+
let timer
|
|
175
|
+
const finish = (error) => {
|
|
176
|
+
if (settled) return
|
|
177
|
+
settled = true
|
|
178
|
+
clearTimeout(timer)
|
|
179
|
+
if (error) reject(error)
|
|
180
|
+
else resolvePromise()
|
|
181
|
+
}
|
|
182
|
+
timer = setTimeout(() => {
|
|
183
|
+
child.kill('SIGTERM')
|
|
184
|
+
finish(new Error('Docker Engine 检查超时;请确认 Docker Desktop 或 Docker Engine 已启动。'))
|
|
185
|
+
}, timeoutMs)
|
|
186
|
+
timer.unref?.()
|
|
187
|
+
child.stderr.on('data', (chunk) => {
|
|
188
|
+
if (stderr.length < 4_000) stderr += chunk.toString('utf8').slice(0, 4_000 - stderr.length)
|
|
189
|
+
})
|
|
190
|
+
child.once('error', (error) => {
|
|
191
|
+
finish(new Error(`无法启动 Docker CLI:${error.message}`))
|
|
192
|
+
})
|
|
193
|
+
child.once('close', (code) => {
|
|
194
|
+
if (code === 0) return finish()
|
|
195
|
+
const detail = stderr.trim().split('\n').at(-1)
|
|
196
|
+
finish(new Error(`Docker Engine 不可用${detail ? `:${detail}` : ''};请启动 Docker Desktop 或 Docker Engine 后重试。`))
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
}
|