@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 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
- 源码 manifest 不包含 Agent 配置;CLI 自动发现 `src/agent-tools.mjs`,处理器注册使用同一份完整定义。构建自动生成发布契约,
56
- 不要同时手工维护 manifest 的能力清单。新增能力的最短流程见
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
- 所有应用都由 Portal 以同源顶层文档启动,资料、KV 和 Gateway 请求使用 App Session Cookie。本地开发源码会自动选择 `local`。
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
- 已有项目后续发布时,将旧 `manifest.agent` 中的元数据和能力数组迁移到 `src/agent-tools.mjs` 的
65
- `agentMetadata` 和 `capabilities`,再从源码 manifest 删除 `agent`。已经发布的旧版应用包仍保持兼容;迁移后由 CLI 从代码声明生成新版发布能力目录。
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
- 完成开发准备后,应用内用户登录才创建短期 App Session;用户的登录、缓存恢复、退出和切换账户
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
- 退出开发服务会撤销本次 App Session,保留两类缓存。应用调用 `app.auth.logout()` 只清除运行用户授权;
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
- 此选项禁用两类缓存,不读取或更新已有的钥匙串授权。CLI 不会把授权凭据写入项目、构建产物或本地应用的浏览器存储。
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
- 本地平台可直接使用默认地址,不需要任何配置。远程平台若 Portal 与 API 使用同一域名,
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
 
@@ -1,14 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawn } from 'node:child_process'
4
- import { lstat, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
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 = await stat(new URL('../desktop/agent/protocol.mjs', import.meta.url)).then(() => '../desktop/agent/').catch(error => {
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: module.capabilities }));\nprocess.exit(0);`
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 export JSON-serializable agentMetadata and capabilities')) }
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. Installed packages carry the
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 export capabilities as an array with at most 64 entries')
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
- if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
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, toolNameFor, validateValue } from './protocol.mjs'
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
- * Shared protocol primitives for exposing Proxy Desktop applications to an
3
- * external agent (for example, through MCP). This module has no Electron or
4
- * transport dependency and is safe to use from the desktop host and tests.
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
+ }