@ducking-mind/proxy-app-cli 0.1.0 → 0.1.1

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
@@ -1,105 +1,170 @@
1
1
  # @ducking-mind/proxy-app-cli
2
2
 
3
- CLI for developing, validating, packaging, and publishing applications for the
4
- Proxy App platform.
3
+ `proxy-app` 是用于创建、开发、校验、打包和发布 Proxy 平台应用的命令行工具。
5
4
 
6
- ## Install
5
+ ## 安装
7
6
 
8
- The CLI requires Node.js 20.19 or newer. Install it from npm after the package
9
- is published:
7
+ CLI 需要 Node.js 22.18 或更高版本。使用 npm 安装已发布的软件包:
10
8
 
11
9
  ```bash
12
10
  npm install --global @ducking-mind/proxy-app-cli
13
11
  ```
14
12
 
15
- When working in this repository, run it from the repository root instead:
13
+ 在本仓库中开发时,可以从仓库根目录运行工作区版本:
16
14
 
17
15
  ```bash
18
- npm run app -- --help
16
+ npx proxy-app --help
19
17
  ```
20
18
 
21
- ## Commands
19
+ ## 命令
22
20
 
23
- `<app-dir>` is the directory containing `manifest.json` and the app's build
24
- script.
21
+ 在包含 `manifest.json` 的应用目录里,日常命令如下:
25
22
 
26
23
  ```bash
27
- # Create a starter app and connect it to a platform app
24
+ proxy-app dev # 开发检查通过后启动
25
+ proxy-app pack # 重新构建、校验并生成 ZIP
26
+ proxy-app upload # 构建并上传待发布版本,保持线上版本
27
+ proxy-app publish # 手动发布当前 manifest 版本,缺包时先构建上传
28
+ proxy-app publish --version 0.1.0 # 切换到指定已上传版本,也可回滚
29
+ ```
30
+
31
+ 不必先执行 `build`、`validate` 再执行 `pack`,也不必为了发布新版本连续执行 `pack`、`upload`、`publish`。
32
+ 每次调用 `pack` 都重新运行应用的 `npm run build`,随后校验、打包;构建失败会直接停止,不使用旧 `dist`。
33
+ 构建前会暂存旧 `dist`,要求本次真正生成新目录;失败时恢复旧产物。同一应用的构建同时只能运行一次。
34
+ `publish` 遇到已经上传的同版本则沿用服务端产物推进发布,不重建、不覆盖该版本。发布修改后的源码请先递增版本号。
35
+ 上传成功不会自动切换线上版本。测试完成后执行 `publish`,才会将所选版本设为当前线上版本。
36
+ `publish --version <version>` 可选择历史已发布版本回滚,也可发布已上传草稿;不修改本地 manifest,
37
+ 不要求本地版本号与目标一致,不构建或上传。目标尚未上传时直接报错,目标已是当前线上版本时不重复切换。
38
+
39
+ 首次创建应用:
40
+
41
+ ```bash
42
+ # 创建起始应用
28
43
  proxy-app init apps/my-app --name "My App"
29
- proxy-app create apps/my-app
30
44
 
31
- # Link an existing local app to a platform app by its platform ID
32
- proxy-app link apps/my-app <app-id>
45
+ cd apps/my-app
46
+ npm install
47
+ proxy-app create --category ecommerce
48
+ npm run dev
49
+ ```
50
+
51
+ 应用目录内统一使用 `npm run dev`,参数可以通过 `npm run dev -- --port 5175` 传入。新项目的 `dev` 脚本调用 CLI,`dev:server` 脚本启动底层 Vite;CLI 完成授权和平台检查后再调用 `dev:server`。旧项目只有底层 `dev` 脚本时,仍可直接使用 `proxy-app dev` 启动。缓存开发账户无权访问应用时,CLI 会自动清除缓存并请求新的创建者授权。
52
+
53
+ `init` 生成的项目统一使用根目录 `index.html` 入口,并包含一个可被主应用注册的
54
+ `read_metadata` 示例、`src/agent-tools.mjs`、`agent/guide.md`、工作流和校验示例。能力定义只在代码模块中维护,
55
+ 源码 manifest 不包含 Agent 配置;CLI 自动发现 `src/agent-tools.mjs`,处理器注册使用同一份完整定义。构建自动生成发布契约,
56
+ 不要同时手工维护 manifest 的能力清单。新增能力的最短流程见
57
+ [应用开发指南](../docs/APP_DEVELOPMENT.md)。
58
+
59
+ 新项目的 manifest 不需要 `apis`、`permissions`、`backend`、`pricing`。省略时默认使用平台 Gateway,
60
+ 开放全部当前公开接口和 KV;收费在“我的应用”中设置,提交开放申请前必须填写按调用抽成比例。已有应用可保留显式数组来缩小能力范围,显式空数组表示不使用。
61
+ 所有应用都由 Portal 以同源顶层文档启动,资料、KV 和 Gateway 请求使用 App Session Cookie。本地开发源码会自动选择 `local`。
62
+ 入口统一为应用根目录的 `index.html`。旧应用即使仍带有 `runtime` 或 `desktop` 字段也会继续兼容读取;这些字段不参与运行方式选择,历史 iframe 运行方式仍不再支持。
63
+
64
+ 已有项目后续发布时,将旧 `manifest.agent` 中的元数据和能力数组迁移到 `src/agent-tools.mjs` 的
65
+ `agentMetadata` 和 `capabilities`,再从源码 manifest 删除 `agent`。已经发布的旧版应用包仍保持兼容;迁移后由 CLI 从代码声明生成新版发布能力目录。
66
+
67
+ 开发和发布已有应用时,将 `manifest.id` 设为平台应用的公开 slug(或稳定 `app_...` ID),即可直接运行
68
+ `proxy-app dev`、`proxy-app upload` 和 `proxy-app publish`;不需要本地绑定文件、`link` 或 `repair`。
69
+ `.proxy-app.json` 仅作为旧项目的兼容缓存,仍可由 `create` / `link` / `repair` 生成;`upload`、`publish` 和 `status` 不读取它,始终按 `manifest.id` 查询平台应用。
70
+ 公开 slug 直接使用输入值,例如 `canvas`、`my-app` 或 `app.proxy.canvas`,不会自动添加前缀;
71
+ 长度为 1–120,规则为 `^[a-z0-9]+(?:[.-][a-z0-9]+)*$`。`validate`、`pack`、`dev` 都使用同一规则,
72
+ 并继续兼容平台分配的 `app_...` 稳定 ID。`create` / `link` 的管理绑定仍保存稳定 ID,平台返回的短 slug 原样保留。
73
+ 改名不会生成新的应用身份;旧包只有在平台记录了对应历史别名时才能继续使用,CLI 不会通过删除前缀猜测绑定。
74
+ `pack` / `validate` 只检查当前 manifest 和本地产物,不读取本地绑定文件。管理命令会按 `manifest.id`
75
+ 解析当前账户可访问的应用;只有 manifest 与目标应用确实不一致时才会停止上传。
76
+ 所有命令的 `<app-dir>` 都可省略,默认当前目录;
77
+ 从其他目录操作仍可用 `proxy-app pack apps/my-app`,旧命令保持兼容。
78
+
79
+ `pack`、`upload`、`publish` 支持 `--output <output.zip>`(简写 `-o`),输出路径相对当前工作目录。
80
+ `publish --version` 使用服务端已有产物,不能同时指定输出路径:
33
81
 
34
- # Run the app's own build script and validate the resulting dist/ directory
35
- proxy-app build apps/my-app
36
- proxy-app validate apps/my-app
37
- proxy-app doctor apps/my-app
82
+ ```bash
83
+ proxy-app pack -o dist/my-app.zip
84
+ # 兼容旧的两个位置参数
85
+ proxy-app pack apps/my-app dist/my-app.zip
86
+ ```
38
87
 
39
- # Package the built app (manifest.json is placed at the ZIP root)
40
- proxy-app pack apps/my-app
88
+ 默认 ZIP 仍输出到应用目录的 `<manifest.id>-<version>.zip`。`upload` 会自动构建、校验、打包后上传;
89
+ `build`、`validate`、`doctor`、`status` 保留为单独构建、只读校验、诊断和状态查询命令。
90
+ `status` 同时显示 `local_version` 与线上 `current_version`;尚未首次发布时,`current_version` 为 `null`。
91
+ `build` 脚本应直接调用 Vite 等构建工具,不要调用 CLI 的 `pack` 或 `publish`,避免循环构建。
41
92
 
42
- # Upload a version without submitting it for review
43
- proxy-app upload apps/my-app
93
+ 发布命令使用成功构建生成的 `dist/` 输出。每次发布新版本时,应用 manifest 的版本号
94
+ 必须递增。CLI 构建在 `dist/manifest.json` 保存本次配置快照;后续 `validate` 可检查源码 manifest 是否已改变。
95
+ 构建或打包期间修改 manifest 会被拒绝。`pack` 重新构建归档,`upload` 将版本发送到平台;应用尚未开放时,`publish` 提交
96
+ 应用开放申请,将所选版本作为预览材料。应用开放后,上传仍只保存待发布版本,由开发者手动切换线上版本。`--source` 仅用于本地调试;
97
+ 该选项跳过构建并打包源码目录(不包含旧 dist),使用它生成的源码包不是发布包。
44
98
 
45
- # Upload a missing version and submit it for platform review
46
- proxy-app publish apps/my-app
99
+ 收费方式统一为按调用抽成。应用首次申请开放和后续新版本发布前必须设置有效抽成比例;已保存比例只能降低,不能调高。manifest 中的 `pricing` 可省略,显式声明时仅支持 `commission`,用于初始化收费配置,后续版本继承平台保存的比例。历史免费和 `one_time` 版本继续按原快照结算,新版本需迁移到抽成。
47
100
 
48
- # Inspect the local binding and remote version state
49
- proxy-app status apps/my-app
101
+ 同一应用同时只保留一个待审开放申请;需要更换预览版本时,在“我的应用”的应用卡片撤回后重新申请。审核决定对应独立的申请编号,版本只是材料。`status` 会返回 `opening_request`,用于查看当前申请及结果。应用开放后,`publish` 会手动切换到所选版本,无需重复上传或审核;历史产物保留供回滚使用。暂停、平台禁用状态会保留,版本切换不会自动解除限制。详见 [应用开放与版本管理](https://github.com/duckingmind/app/blob/main/docs/APP_LIFECYCLE.md)。
102
+
103
+ ## 平台认证
104
+
105
+ ### 本地开发登录
106
+
107
+ `proxy-app dev` 先检查开发环境,再启动应用。CLI 使用 `manifest.id` 的公开 slug 和开发者授权,
108
+ 由平台解析内部应用身份、校验开发权限与当前 manifest,并登记本次本地入口;
109
+ 内部 `app_id` 仅在进程内用于后续会话和撤销操作,不读取或写入 `.proxy-app.json`,不需要 `repair`。
110
+ 检查通过后才打印 Local app 地址、运行应用开发服务。
111
+ 应用不存在、账户无开发权限、版本冲突或平台故障会在终端报错并停止启动,不会延迟到应用内用户登录。
112
+ 默认从 `5173`(或 `--port` / `PROXY_APP_DEV_PORT` 指定的端口)开始寻找空闲的回环端口;
113
+ 如果目标端口已被占用,CLI 会自动选择后续可用端口,并使用该实际端口登记入口和启动 Vite。
114
+ 设置 `PROXY_APP_DEV_URL` 时视为显式入口地址,CLI 不会改写它,端口冲突仍会按显式配置报错。
115
+
116
+ 首次开发没有有效授权时,终端输出“开发者授权入口”。打开该链接、使用应用创建者账户完成授权,
117
+ CLI 会继续检查并启动应用;十分钟内未完成会超时,也可用 Ctrl+C 取消。
118
+ 本地开发不要求手工配置 token。授权失败时,页面会显示当前授权账户、开发者登录页面、API 地址和应用 slug,便于区分账户、slug 与平台地址不匹配。若看到旧式通用错误页,请先停止旧的开发进程,再从应用目录运行 `npm run dev`。
119
+
120
+ macOS 默认分别缓存开发者授权和应用运行用户授权,按平台地址、应用、项目目录及用途隔离。
121
+ 每次重启都重新登记当前 manifest 与本地入口,开发权限不能依赖上一次检查结果。
122
+ 完成开发准备后,应用内用户登录才创建短期 App Session;用户的登录、缓存恢复、退出和切换账户
123
+ 均不调用开发登记接口,也不清除开发授权。未发布草稿仍受平台访问权限限制,非创建者需使用其有权访问的已发布版本。
124
+
125
+ 开发账户错误时,重新运行 `proxy-app dev apps/my-app` 即可。CLI 会在登记返回账户无权访问的 401、403 或 404 时清除开发账户缓存,并打开一次新的创建者授权入口;运行用户的授权不会被清除。
126
+ 退出开发服务会撤销本次 App Session,保留两类缓存。应用调用 `app.auth.logout()` 只清除运行用户授权;
127
+ `app.auth.login({ forceLogin: true })` 只切换运行用户。平台确认缓存授权过期后才要求重新登录,临时故障保留缓存。
128
+
129
+ 其他系统或钥匙串不可用时,仅使用当前进程内存,不会退回明文文件保存 token。
130
+ 如需禁用持久缓存,可运行:
131
+
132
+ ```bash
133
+ proxy-app dev apps/my-app --no-auth-cache
50
134
  ```
51
135
 
52
- Release `pack`, `upload`, and `publish` require a successful build and use the
53
- `dist/` output. `--source` is available on `pack` and `upload` only for local
54
- debugging; source packages are not release packages. The app manifest version
55
- must be increased for a new artifact. `publish` submits the version for review;
56
- it does not bypass platform review or make an app immediately public.
136
+ 此选项禁用两类缓存,不读取或更新已有的钥匙串授权。CLI 不会把授权凭据写入项目、构建产物或本地应用的浏览器存储。
57
137
 
58
- ## Management authentication
138
+ `build`、`validate`、`pack` 只验证本地产物,不代表远程绑定有效。`doctor` 的绑定检查也是本地结构检查。
139
+ 上传和发布继续在平台验证应用归属与版本,开发启动前置检查不能替代它们。
59
140
 
60
- Management commands (`create`, `link`, `upload`, `publish`, and authenticated
61
- `status`) use the current platform Portal user's access token. The CLI does not
62
- provide a `login` command and never passes this token to the app runtime.
141
+ ### 应用管理与发布
63
142
 
64
- 1. Sign in to the platform Portal as the app owner.
65
- 2. In the browser developer tools, open the Portal origin's local storage and
66
- copy the `access_token` value.
67
- 3. Export it only in the shell used for the command:
143
+ 管理命令(`create`、兼容性的 `link`、需要认证的 `status`、`upload` 和 `publish`)需要平台用户
144
+ 访问令牌。请通过所用平台的官方认证流程获取令牌,并在 shell 或 CI 环境中提供:
68
145
 
69
146
  ```bash
70
- export PROXY_API_BASE_URL="https://your-platform.example/api"
71
- printf 'Portal access token: '
72
- read -rs PROXY_USER_ACCESS_TOKEN
73
- export PROXY_USER_ACCESS_TOKEN
74
- printf '\n'
147
+ export PROXY_API_BASE_URL="https://platform.example/api"
148
+ export PROXY_USER_ACCESS_TOKEN="<platform-user-token>"
75
149
 
76
150
  proxy-app create apps/my-app
77
- proxy-app build apps/my-app
78
151
  proxy-app upload apps/my-app
79
152
  proxy-app publish apps/my-app
80
-
81
- unset PROXY_USER_ACCESS_TOKEN
82
153
  ```
83
154
 
84
- For a local platform, the default API base is
85
- `http://localhost:9003/api`. `PROXY_ACCESS_TOKEN` is accepted as a legacy alias
86
- for `PROXY_USER_ACCESS_TOKEN`. Never commit either variable, browser cookies,
87
- App Session tokens, or provider credentials.
155
+ CLI 从已导出的变量读取配置,不会自动加载 `.env` 文件。请勿将令牌、Cookie、供应商
156
+ 凭证和生成的本地关联文件纳入版本控制。管理令牌仅由 CLI 使用,不会传递给应用运行时。
88
157
 
89
- ## Local development
158
+ ## 配置
90
159
 
91
- ```bash
92
- proxy-app dev apps/my-app --port 5173
93
- ```
160
+ 创建应用时,`--category` 支持 `ecommerce`(电商)、`comic`(漫剧)和 `other`(其他,默认)。分类用于应用市场展示,创建后可在“编辑应用资料”中调整。
94
161
 
95
- Development uses a loopback Relay and the signed-in user's short-lived App
96
- Session. The default runtime requests use HTTP. Pass `--legacy-bridge` only when
97
- running an app that explicitly needs the v1 Bridge compatibility path. The
98
- management token is not injected into the app process.
162
+ 本地平台可直接使用默认地址,不需要任何配置。远程平台若 Portal 与 API 使用同一域名,
163
+ 只设置 `PROXY_PLATFORM_URL=https://platform.example` 即可;CLI 自动使用该站点的 `/api` 与登录页。若平台把 API 部署在独立域名,再额外设置 `PROXY_API_BASE_URL` 或 `PROXY_PORTAL_URL` 覆盖对应地址。单地址配置时,请清除已导出的旧覆盖变量。
164
+ 管理命令(创建、发布等)仍需 `PROXY_USER_ACCESS_TOKEN`;本地开发使用浏览器授权,不需要这个变量。
165
+ 若 shell 已全局导出 `PROXY_USER_ACCESS_TOKEN` 或旧别名 `PROXY_ACCESS_TOKEN`,开发命令会优先使用该令牌;想在浏览器中选择创建者账户时先运行 `unset PROXY_USER_ACCESS_TOKEN PROXY_ACCESS_TOKEN`。
99
166
 
100
- See the repository documentation for the manifest, runtime API, and release
101
- contract:
167
+ manifest、运行时 API 和发布规范请参阅仓库文档:
102
168
 
103
169
  - [`docs/APP_MARKET.md`](../docs/APP_MARKET.md)
104
170
  - [`docs/API_REFERENCE.md`](../docs/API_REFERENCE.md)
105
- - [`docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md)
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'node:child_process'
4
+ import { lstat, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
5
+ import { resolve, sep } from 'node:path'
6
+ import { pathToFileURL } from 'node:url'
7
+
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
+ })
12
+ const { normalizeCapabilityDeclaration, normalizeManifestAgent } = await import(new URL(`${runtimeDirectory}protocol.mjs`, import.meta.url))
13
+ const { copyAgentPackage } = await import(new URL(`${runtimeDirectory}package-guide.mjs`, import.meta.url))
14
+
15
+ const AGENT_ENTRY_RE = /^[^/\\][^/\\]*\.(?:mjs|js|cjs)$/
16
+
17
+ const isRecord = value => value !== null && typeof value === 'object' && !Array.isArray(value)
18
+
19
+ async function assertSafeEntry(root, entry) {
20
+ if (typeof entry !== 'string' || !entry || entry.length > 256 || entry.includes('\\') || entry.split('/').some(part => !part || part === '.' || part === '..' || part.startsWith('.'))) {
21
+ throw new Error('agent contract source must be a visible relative source path')
22
+ }
23
+ if (!AGENT_ENTRY_RE.test(entry.split('/').at(-1))) throw new Error('agent contract source must point to a JavaScript module')
24
+ const projectRoot = resolve(root)
25
+ const target = resolve(projectRoot, entry)
26
+ const prefix = `${projectRoot}${sep}`
27
+ if (!target.startsWith(prefix)) throw new Error('agent contract source escapes the application project')
28
+ let current = projectRoot
29
+ for (const part of entry.split('/')) {
30
+ current = resolve(current, part)
31
+ const info = await lstat(current).catch(error => { throw new Error(`agent contract source is missing or unreadable: ${entry} (${error.code})`) })
32
+ if (info.isSymbolicLink()) throw new Error(`agent contract source cannot use a symbolic link: ${entry}`)
33
+ }
34
+ const info = await lstat(target)
35
+ if (!info.isFile()) throw new Error('agent contract source must be a regular file')
36
+ return target
37
+ }
38
+
39
+ // Use a fresh process so editing a shared contract dependency cannot leave
40
+ // the build validating Node's cached version. The child receives a path only
41
+ // after it has passed the project-local source checks above.
42
+ 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);`
44
+ return new Promise((resolvePromise, reject) => {
45
+ const child = spawn(process.execPath, ['--input-type=module', '-e', worker, pathToFileURL(target).href], { stdio: ['ignore', 'ignore', 'pipe', 'pipe'] })
46
+ const output = [], errors = []
47
+ let outputBytes = 0, errorBytes = 0, failure
48
+ const timer = setTimeout(() => { failure = new Error('agent contract source exceeded the 15 second contract loading limit'); child.kill('SIGKILL') }, 15000)
49
+ child.stdio[3].on('data', chunk => {
50
+ outputBytes += chunk.length
51
+ if (outputBytes > 8 * 1024 * 1024) { failure = new Error('agent contract source exported an oversized capability contract'); child.kill('SIGKILL'); return }
52
+ output.push(chunk)
53
+ })
54
+ child.stderr.on('data', chunk => { if (errorBytes < 2048) errors.push(chunk); errorBytes += chunk.length })
55
+ child.on('error', error => { clearTimeout(timer); reject(error) })
56
+ child.on('close', code => {
57
+ clearTimeout(timer)
58
+ if (failure) return reject(failure)
59
+ 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
+ try { resolvePromise(JSON.parse(Buffer.concat(output).toString())) }
61
+ catch { reject(new Error('src/agent-tools.mjs must export JSON-serializable agentMetadata and capabilities')) }
62
+ })
63
+ })
64
+ }
65
+
66
+ /**
67
+ * Resolve the code-owned Agent contract for a local source project.
68
+ * `src/agent-tools.mjs` is discovered by convention; source manifests never
69
+ * opt into or enumerate Agent capabilities. Installed packages carry the
70
+ * generated contract so MCP can discover tools before launching the app.
71
+ */
72
+ export async function resolveAgentContract(root, manifest) {
73
+ if (!isRecord(manifest)) throw new TypeError('manifest must be an object')
74
+ const rawAgent = manifest.agent
75
+ if (rawAgent !== undefined && !isRecord(rawAgent)) throw new TypeError('manifest.agent must be an object')
76
+ if (rawAgent !== undefined) throw new Error('source manifest must not contain manifest.agent; MCP is discovered from src/agent-tools.mjs')
77
+ const entry = 'src/agent-tools.mjs'
78
+ let target
79
+ try { target = await assertSafeEntry(root, entry) }
80
+ catch (error) {
81
+ if (error.message.includes('(ENOENT)')) return manifest
82
+ throw error
83
+ }
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')
86
+ if (!isRecord(agentMetadata)) throw new Error('src/agent-tools.mjs must export agentMetadata as an object')
87
+ const allowedMetadata = new Set(['namespace', 'targets', 'contract_version', 'summary', 'keywords', 'guide', 'examples'])
88
+ for (const key of Object.keys(agentMetadata)) if (!allowedMetadata.has(key)) throw new Error(`unsupported agentMetadata field: ${key}`)
89
+ const normalized = capabilities.map(capability => {
90
+ if (!isRecord(capability)) throw new TypeError('agent capability must be an object')
91
+ for (const field of ['access', 'input_schema', 'output_schema']) {
92
+ if (!Object.hasOwn(capability, field)) throw new Error(`code capability ${capability.name || '(unnamed)'} must explicitly declare ${field}`)
93
+ }
94
+ return normalizeCapabilityDeclaration(capability, { defaultTargets: agentMetadata.targets || ['desktop'] })
95
+ })
96
+ const names = new Set()
97
+ for (const capability of normalized) {
98
+ if (names.has(capability.name)) throw new Error(`duplicate agent capability: ${capability.name}`)
99
+ names.add(capability.name)
100
+ }
101
+ const effective = structuredClone(manifest)
102
+ effective.agent = { enabled: true, ...agentMetadata, contract_source: 'code', capabilities: normalized }
103
+ normalizeManifestAgent(effective)
104
+ return effective
105
+ }
106
+
107
+ /** Build helper for projects whose bundler is invoked directly. */
108
+ export async function writeAgentContract(root, destination) {
109
+ const projectRoot = resolve(root)
110
+ const source = JSON.parse(await readFile(resolve(projectRoot, 'manifest.json'), 'utf8'))
111
+ const manifest = await resolveAgentContract(projectRoot, source)
112
+ const output = resolve(projectRoot, destination)
113
+ await mkdir(output, { recursive: true })
114
+ await copyAgentPackage(projectRoot, output, manifest)
115
+ await writeFile(resolve(output, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
116
+ return manifest
117
+ }
118
+
119
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
120
+ try {
121
+ const [, , root = '.', destination = 'dist'] = process.argv
122
+ await writeAgentContract(root, destination)
123
+ } catch (error) {
124
+ console.error(`proxy agent contract: ${error.message}`)
125
+ process.exitCode = 1
126
+ }
127
+ }
128
+
@@ -0,0 +1,193 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { constants } from 'node:fs'
3
+ import { lstat, mkdir, open, realpath } from 'node:fs/promises'
4
+ import { join, resolve } from 'node:path'
5
+ import { normalizeManifestAgent, toolNameFor, validateValue } from './protocol.mjs'
6
+
7
+ const MAX_TEXT_BYTES = 64 * 1024
8
+ const MAX_PACKAGE_BYTES = 256 * 1024
9
+ const hash = value => createHash('sha256').update(value).digest('hex')
10
+ const record = value => value && typeof value === 'object' && !Array.isArray(value)
11
+ const check = (condition, message) => { if (!condition) throw new Error(`Invalid agent package: ${message}`) }
12
+
13
+ function packagePath(value, extension) {
14
+ check(typeof value === 'string' && value.length > 0 && value.length <= 256, 'entry must be a relative path')
15
+ check(!value.includes('\\') && !value.includes(':') && !/[\x00-\x1f]/.test(value), `invalid entry: ${value}`)
16
+ const parts = value.split('/')
17
+ check(parts.every(part => part && part !== '.' && part !== '..' && !part.startsWith('.')), `entry escapes package or uses a hidden path: ${value}`)
18
+ check(value.endsWith(extension), `entry must end in ${extension}: ${value}`)
19
+ check(value !== 'manifest.json' && value !== 'package.json', `reserved entry: ${value}`)
20
+ return value
21
+ }
22
+
23
+ function declarations(manifest) {
24
+ const agent = normalizeManifestAgent(manifest)
25
+ if (!agent) return { agent: null, topics: [], examples: null }
26
+ const raw = manifest.agent
27
+ const topics = []
28
+ if (raw.guide !== undefined) {
29
+ check(record(raw.guide), 'guide must be an object')
30
+ topics.push({ id: 'overview', title: 'Overview', entry: packagePath(raw.guide.entry, '.md') })
31
+ check(raw.guide.topics === undefined || Array.isArray(raw.guide.topics), 'guide.topics must be an array')
32
+ check((raw.guide.topics?.length || 0) <= 16, 'guide contains too many topics')
33
+ for (const topic of raw.guide.topics || []) {
34
+ check(record(topic) && typeof topic.id === 'string' && /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/.test(topic.id) && topic.id.length <= 64, 'invalid guide topic id')
35
+ check(!['api', 'examples'].includes(topic.id) && !topics.some(item => item.id === topic.id), `reserved or duplicate guide topic: ${topic.id}`)
36
+ check(typeof topic.title === 'string' && topic.title.trim() && topic.title.length <= 160, `invalid title for ${topic.id}`)
37
+ topics.push({ id: topic.id, title: topic.title, entry: packagePath(topic.entry, '.md') })
38
+ }
39
+ }
40
+ const examples = raw.examples === undefined ? null : packagePath(raw.examples, '.json')
41
+ const entries = [...topics.map(topic => topic.entry), ...(examples ? [examples] : [])]
42
+ check(new Set(entries).size === entries.length, 'duplicate guide or examples entry')
43
+ for (const entry of entries) check(entry !== 'index.html', `guide cannot replace an application entry: ${entry}`)
44
+ return { agent, topics, examples }
45
+ }
46
+
47
+ async function readPackageFile(root, entry) {
48
+ let path = root
49
+ for (const part of entry.split('/')) {
50
+ path = join(path, part)
51
+ const info = await lstat(path).catch(error => { throw new Error(`Agent package entry is missing or unreadable: ${entry} (${error.code})`) })
52
+ check(!info.isSymbolicLink(), `symbolic links are not allowed: ${entry}`)
53
+ }
54
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW)
55
+ try {
56
+ const info = await file.stat()
57
+ check(info.isFile(), `entry is not a regular file: ${entry}`)
58
+ check(info.size <= MAX_TEXT_BYTES, `entry exceeds 64 KiB: ${entry}`)
59
+ const buffer = Buffer.alloc(MAX_TEXT_BYTES + 1)
60
+ let length = 0
61
+ while (length < buffer.length) {
62
+ const { bytesRead } = await file.read(buffer, length, buffer.length - length)
63
+ if (!bytesRead) break
64
+ length += bytesRead
65
+ }
66
+ check(length <= MAX_TEXT_BYTES, `entry exceeds 64 KiB: ${entry}`)
67
+ const bytes = buffer.subarray(0, length)
68
+ let content
69
+ try { content = new TextDecoder('utf-8', { fatal: true }).decode(bytes) } catch { throw new Error(`Invalid UTF-8 agent package entry: ${entry}`) }
70
+ return { bytes, content }
71
+ } finally { await file.close() }
72
+ }
73
+
74
+ async function readAgentPackageWithReader(manifest, readFile) {
75
+ const declared = declarations(manifest)
76
+ if (!declared.agent) return { guide: null, files: [] }
77
+ let total = 0
78
+ const files = []
79
+ const read = async entry => {
80
+ const file = await readFile(entry)
81
+ total += file.bytes.length
82
+ check(total <= MAX_PACKAGE_BYTES, 'guide and examples exceed 256 KiB in total')
83
+ files.push({ entry, ...file })
84
+ return file.content
85
+ }
86
+ const topics = []
87
+ for (const topic of declared.topics) {
88
+ const content = await read(topic.entry)
89
+ topics.push({ id: topic.id, title: topic.title, content, hash: hash(content) })
90
+ }
91
+ const guide = topics.length ? { topics, hash: hash(JSON.stringify(topics.map(({ id, title, hash }) => ({ id, title, hash })))) } : null
92
+ let examples
93
+ if (declared.examples) {
94
+ try { examples = JSON.parse(await read(declared.examples)) } catch (error) { throw new Error(`Invalid agent examples: ${error.message}`) }
95
+ check(Array.isArray(examples) && examples.length <= 128, 'examples must be an array with at most 128 entries')
96
+ for (const [index, example] of examples.entries()) {
97
+ check(record(example) && typeof example.capability === 'string', `examples[${index}] must name a capability`)
98
+ check(Object.keys(example).every(key => ['capability', 'input', 'output'].includes(key)), `examples[${index}] contains an unknown field`)
99
+ const capability = declared.agent.capabilities.find(item => item.name === example.capability)
100
+ check(capability, `examples[${index}] references unknown capability ${example.capability}`)
101
+ try {
102
+ validateValue(capability.input_schema, example.input)
103
+ if (Object.hasOwn(example, 'output')) validateValue(capability.output_schema, example.output)
104
+ } catch (error) { throw new Error(`Invalid agent examples[${index}] (${example.capability}): ${error.message}`) }
105
+ }
106
+ }
107
+ return { guide, ...(examples === undefined ? {} : { examples }), files }
108
+ }
109
+
110
+ async function readAgentPackage(root, manifest) {
111
+ const packageRoot = await realpath(resolve(root))
112
+ return readAgentPackageWithReader(manifest, entry => readPackageFile(packageRoot, entry))
113
+ }
114
+
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
+ /** Read only declared, bounded files from the selected application version. */
157
+ export async function loadAgentPackage(root, manifest) {
158
+ const { files, ...data } = await readAgentPackage(root, manifest)
159
+ return data
160
+ }
161
+
162
+ /** Copy a validated snapshot after the bundler has produced a fresh dist. */
163
+ export async function copyAgentPackage(source, destination, manifest) {
164
+ const { files, ...data } = await readAgentPackage(source, manifest)
165
+ const root = await realpath(resolve(destination))
166
+ for (const file of files) {
167
+ let directory = root
168
+ for (const part of file.entry.split('/').slice(0, -1)) {
169
+ directory = join(directory, part)
170
+ await mkdir(directory).catch(error => { if (error.code !== 'EEXIST') throw error })
171
+ const info = await lstat(directory)
172
+ check(info.isDirectory() && !info.isSymbolicLink(), `invalid output directory: ${file.entry}`)
173
+ }
174
+ const target = join(root, file.entry)
175
+ const existing = await lstat(target).catch(error => { if (error.code !== 'ENOENT') throw error; return null })
176
+ check(!existing || existing.isFile() && !existing.isSymbolicLink(), `invalid output entry: ${file.entry}`)
177
+ // O_NOFOLLOW also protects the final component if a build process swaps it.
178
+ const handle = await open(target, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o644)
179
+ try { await handle.writeFile(file.bytes) } finally { await handle.close() }
180
+ }
181
+ return data
182
+ }
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
+ }