应用中心
应用中心是员工自建小工具的地方:员工在对话里描述需求,AI 用内置的应用构建技能写一个 WASI 应用、本地编译成 .wasm,员工在应用中心里填表发布;同事在桌面客户端的独立窗口里打开它。
本页讲的是这套平台的设计与边界——为什么是这个形态、配置契约长什么样、什么情况下会被拒绝、失败时表现是什么。需要「点哪里」的操作步骤见桌面客户端,管理员侧的处置动作见企业管控面。
设计目标与形态
Section titled “设计目标与形态”平台要解决的问题是「让非工程岗位的同事能给自己做工具」,同时不把企业的网络与证书面撑大。
| 问题 | 这个平台的答案 |
|---|---|
| 应用怎么被访问 | 只在桌面客户端内,独立应用窗口加载自定义协议地址 <应用源 scheme>://<app_id>/… |
| 服务端需要准备什么 | 什么都不用:不需要公网入口、不需要域名解析、不需要证书。服务器上只有一个内部 API 端点 |
| 前端资源放哪 | 跟 .wasm 打在一起(wasm 自定义段),运行期由平台在内存里直出 |
| 数据存哪 | 每个应用一个独立的 SQLite 库,随服务端数据目录落盘 |
| 谁能用 | 登录后按 access 决定;名单判定由应用自己读配置完成 |
形态的由来(一个删除掉的旧链路):早期方案是「每个应用一个子域 + 浏览器访问 + 一次性换票」。这条链路要通配域名、通配证书、反代配置,而且票务与会话固定面很难收敛,所以它被整体删除了——不是默认关闭、不是保留开关:应用基域配置端点、换票入口、匿名面、服务端 ai.chat 能力现在都不存在。
代价也说清楚:没有可贴进浏览器的地址。分享一个应用靠渠道深链(<渠道深链 scheme>://app/<app_id>),对方在自己的客户端里打开;任意浏览器直连应用地址都进不去。
全篇复用这套名词:
| 名词 | 说明 |
|---|---|
| 应用(app) | 由 app_id 标识;app_id 就是应用地址的 host 段,一经发布永久占位、不能改名 |
| 版本(release) | 一次发布产生的制品,形状为严格递增的 x.y.z(可带 -prerelease) |
| 生效版本 | 目录与访问实际使用的版本;只有通过审核的版本才会成为生效版本 |
| 发布者 / 归属 | apps.owner:首个成功发布者,登录态决定、不可伪造;被拒或删除也不释放 |
| 负责人 | 配置里的 owner 字段,是「出问题找谁」的声明,不是平台归属,也不参与任何权限判断 |
| 应用库 | 每应用一个 SQLite 数据库(路径形如 <data_root>/apps/<app_id>/app.db) |
| 随包资源 | .wasm 自定义段里的静态文件(index.html、static/app.css…),段名即包内逻辑路径 |
| 停用 / 下架 / 冻结 / 退役 | 四种不同状态,见「边界与失败行为」 |
打开(每次点「打开」都发生一次):
- 员工在应用中心点开某个应用,客户端调用本机的打开路由(面板本身不持员工令牌);
- 宿主向服务端
POST /api/client/v2/apps/wasm/:app_id/open校验一次,同时登记当日打开次数; - 校验通过后,客户端开一个独立窗口,加载
<应用源 scheme>://<app_id>/<path>。已有窗口时改为聚焦; - 响应里的应用版本(
X-PicoAide-App-Version)是客户端内容缓存的唯一来源;版本变了就清掉该应用在当前会话作用域下的全部缓存。
窗口内的每一次请求(页面自己 fetch、点链接、提交表单):
应用页 fetch('/notes?page=2') │ Chromium 把自定义协议地址交给客户端注册的协议 handler ▼协议 handler 合成请求信封(method / path / query / host / 白名单头 / base64 body) │ POST /api/client/v2/apps/wasm/<app_id>/request │ 头:Authorization: Bearer <员工令牌> + X-Pico-App-Proof: <持有性证明> ▼服务端:认证 → 准入 → 静态资源直出 或 wazero 执行 → 响应信封 │ {"status":200,"headers":{…},"body":"<base64>","truncated":false} ▼协议 handler 还原成页面能读的响应三条不变量(改错任何一条都是安全缺陷):
- 信封里的
host只接受<应用源 scheme>://<app_id>,app_id从 URL 的 host 段取,绝不从 Host 头反解; - 请求体 ≤ 1 MiB,信封 ≤
1 MiB × 4/3 + 64 KiB; Set-Cookie整体丢弃(自定义协议没有 cookie 语义),逐跳头与Content-Length剔除。
预留面:/__picoaide 及其子路径是宿主保留面。目前只有应用 AI 桥 POST /__picoaide/ai/chat 一条,由协议 handler 本地处理、绝不转发平台;其余 /__picoaide/* 一律 404,不会被当成应用自己的路由。
升级必须服务端与客户端同版本:应用地址由渠道包在组装期注入(desktop.app_origin_scheme,必填、不得与深链 scheme 相同、不得是 http 之类的保留 scheme),信封与协议头的形状是两端共同的契约。旧客户端撞上新服务端(或反过来)的表现是打开失败并提示「客户端与服务端必须同版本升级」,而不是静默降级——这条约束的代价是「服务端与客户端要一起升」,收益是协议面永远只有一份形状。
打开失败的三种原因各自可辨(平台把它们分开,不塌缩成一句「打开失败」):应用不存在 → 404;已下架 → 410 Gone(数据与链接都还在);被冻结 → 404 但带 platform_reason=app_frozen(文案说「已被管理员停用」,与「不存在」逐字可辨)。
应用配置契约
Section titled “应用配置契约”配置文件名固定为 picoaide.app.json(发布时以对象形态放进 config 字段)。单一真源是服务端 server/internal/wasmapp/appcfg/appcfg.json(schema picoaide-app-config/1),客户端镜像同一份字段规格并与它对拍;帧内协议版本是 picoaide-app/1。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
access | 枚举 | 否 | login(缺省)或 whitelist;只有这两个取值 |
whitelist | string[] | access=whitelist 时必填 | 准入名单,最多 2000 条;平台不比对,也不校验账号是否存在 |
purpose | 字符串 | 首版必填 | 一句话用途,用于应用中心与页面标题 |
data_sensitivity | 字符串 | 首版必填 | 数据敏感度声明(如 internal);平台没有默认值,只用于声明与追责 |
owner | 字符串 | 首版必填 | 负责人声明,与应用中心的「负责人」显示同源 |
sensitive_columns | string[] | 否 | 额外声明为敏感的列名(默认启发式之外),最多 100 条、单条 ≤ 64 字节 |
window.ratio | 字符串 | 否 | 强制锁定的宽高比("16:9" 或浮点),合法区间 0.25–4.0 |
window.width / window.height | 正整数 | 否 | 首次打开的窗口尺寸,缺省 1280×720 |
几条容易踩的规则:
access只有login与whitelist。历史版本里写过的"public"在读取侧仍按login执行(存量应用不会挂),但新版本不得再写它(422 拒绝);平台没有匿名面——应用只在已登录的客户端里可用。- 名单判定属于应用:平台只把身份与访问模式注入帧,不比对名单、也不校验账号(否则这个接口等价于账号枚举接口)。无权限页必须显示本人账号,这是作者发现名单拼错的唯一途径。
- 更新版本时缺省字段沿用上一版生效值(
access/whitelist/purpose/data_sensitivity/owner/sensitive_columns逐字段各自沿用),显式给值——包括显式空串——以提交为准。纯代码更新不会意外改写线上访问级别;要真的改访问级别就显式写出来。 - 改任何一项配置都要发一个新版本:运行期改不了
access,配置存的是解析并归一化之后的形态(名单去空白、去重,access缺省落定)。 - 配置文件上限 64 KiB;
window的未知子键会被拒(不静默忽略)。 - 发布字段的形状:
app_id≤ 63 字符、小写字母/数字/连字符、不得纯数字、不得以xn--开头、不得是保留字;title首版必填;changelog非首版必填(空即拒)。
发布与生命周期
Section titled “发布与生命周期”开发与上传路径
Section titled “开发与上传路径”- 开发:员工在对话里描述需求,内置的应用构建技能负责设计(数据模型、页面、权限、错误处理);产物形态固定为「静态前端(随包资源)+ wasm JSON 后端」,不在 wasm 里拼 HTML;
- 平台不做构建:产物在作者机器上编译好再上传(导入面只认
wasi_snapshot_preview1,组件模型产物直接拒); - AI 侧的工具面就是作者的入口:
wasm_app_list(查标识占用与当前版本)、wasm_app_validate(预检:静态校验 + 真编译 + 干跑,不占版本号、不进审计,可反复调)、wasm_app_publish(发布)、以及回读面的wasm_app_schema/wasm_app_diagnostics/wasm_app_rows; - 上传大小分两档:
.wasm≤ 32 MiB(base64 编码后的请求体 ≤ 48 MiB)。超过单片上限(8 MiB)时自动走分片 + 续传:开会话 → 逐片 PUT → 查询已收片 → 完成(POST /uploads、PUT /uploads/:id/chunks/:index、GET /uploads/:id、POST /uploads/:id/complete、DELETE /uploads/:id);单片 64 KiB–8 MiB、片数 ≤ 1024、会话 30 分钟、每用户 4 个并发会话与 48 MiB 在飞磁盘配额、上传频率 30 次/小时(预检 + 发布合计); - 预算交接:客户端整条上传链共用 90 秒预算,在「完成」那一跳会把该跳真正还会等多久通过请求头
X-Pico-Client-Budget-Ms告诉平台,平台据此收窄自己的预算(只许缩小、永不放大)——这样平台的结构化错误总能先于客户端放弃返回,员工不会只看到一句「网络错误」。
发布是一次同步调用,顺序固定(失败时的补偿也固定):
| 阶段 | 做什么 | 关键约束 |
|---|---|---|
| A 纯校验 | 标识 → 体积 → 静态检查(导入/导出/段表/体积)→ 配置校验 | 更新发布时先把缺席字段从上一版生效配置继承过来 |
| B 编译 | 真编译(编译子进程) | 单次编译预算受控制台限制,默认 60 s |
| C 抽取分流 | 自定义段 → 内存资源集,逐条校验逻辑路径 | 保留前缀拒、重名段拒、单文件与总量 ≤ 4 MiB |
| D 干跑 | 合成帧 Instantiate → _start → 响应帧 | 编译通过 ≠ 能跑(导出签名不匹配编译期全绿) |
| E 占名落行 | 写应用行与版本行,拿到版本 id | 从这一步开始才有写入;此前一行都没有 |
| F 投影 | 只有 approved 的版本才投影到应用行 | 待审版本不能提前改写目录(标题/描述/配置都不行) |
| G 收尾 | 审计 → 版本 GC | 保留最近几个生效版本,更早的软删 |
两个刻意的性质:
- 整条链路只写数据库,唯一的文件系统痕迹是编译用的临时文件(逐条路径 defer 删除)。因此「失败的发布不占版本号」:失败要么发生在第一次写入之前(一行都不留),要么由补偿闭包把刚建的行软删(软删行仍在 ⇒ 版本号仍占位)。
- 版本号永久占位:已落行的版本换号重发;编译或干跑失败可以直接重发同一个号。
管理员侧有一个发布审核开关(设置键 wasm.review_required,默认关)。
- 开关打开时,新版本落库为待审:版本落了、号占了,但线上仍是旧版本(生效版本只认已通过);
- 作者在自己的版本历史里看得到结论与被拒理由——这是审核结论唯一的作者侧出口;
- 审核开关读失败时发布直接 503、不落行(严格读法)。用宽松读法会把一次设置读取故障变成「静默放行未审核版本」。
上架、下架、冻结、退役
Section titled “上架、下架、冻结、退役”| 动作 | 谁可以做 | 效果 | 可逆 |
|---|---|---|---|
| 上架 / 下架 | 发布者本人、管理员 | 下架 → 访问返回 410 Gone(数据保留、链接不变) | 可逆(重新上架即恢复) |
| 冻结 / 解冻 | 发布者本人、管理员 | 冻结 = 停止服务(一律 404)并顺带下架;解冻不会自动上架 | 可逆(解冻后还要重新上架) |
| 删除(退役) | 发布者本人、管理员 | 行软删;标识与版本号永久占位,不会复用 | 不可逆 |
| 转移归属 | 仅管理员 | 改 apps.owner,用于人员流动/离职交接 | — |
两条容易误解的语义:
- 发布即占名:
app_id由首个成功发布者永久持有;被拒、被下架、被删除都不释放。想用别人的标识发布会拿到 409NAME_TAKEN(管理员放行)。 - 冻结行只对归属人本人可见:对其他人,冻结应用与「不存在」逐字节同形(不泄露存在性);对发布者本人,应用中心里一直看得到它(带「已冻结」标记),否则「解冻」这条路在员工产品里就没有入口。
作者能看到什么、看不到什么
Section titled “作者能看到什么、看不到什么”| 作者可见 | 说明 |
|---|---|
| 目录与自己的行 | 全部应用(含已下架,标「已下架」并禁用打开);自己的行带发布者视角 |
| 版本历史 | 自己应用的版本清单与审核结论(含被拒理由);返回体不含制品字节 |
| 诊断 | 时间窗口内的调用总数、失败数、最近失败、原因码与建议;带保留期说明 |
| 表结构 | 应用库的表/列/行数/占用 |
| 数据浏览 | 应用库的行(默认脱敏:启发式 + 作者声明的 sensitive_columns);显式「显示原值」会记一条审计 |
| 导出 | 控制面元数据快照(应用行 + 各版本行 + 配置),JSON |
| 作者不可见(如实列出) | 原因 |
|---|---|
| 导出里的资源内容与应用库内容 | 导出只含控制面元数据;库里的业务数据可能含个人信息,属于独立的管理员运维动作 |
| 应用日志表 | 应用 log 走服务端 stdout,没有可供作者查询的日志表;诊断只给失败的调用事件 |
| 别人的应用(含冻结行) | 与「不存在」同形,不泄露存在性 |
| 其他同事的使用明细 | 目录只给当日打开次数(PV/UV);不出现任何额度/用量字段 |
| 审核开关 | 开关在管理端;作者只能看到自己版本的状态 |
两条与数据有关的授权边界:
- 应用内的 AI 只有对话:
/__picoaide/ai/chat由宿主本地处理,只发送本次对话内容;没有工具、不能读写文件、不能用连接器与记忆。授权按「账号 × 服务端 × 应用」记录,随时可撤销。 - AI 读应用库默认关闭:作者要在应用中心里显式授权之后,AI 的数据工具才能读该应用的数据(仅脱敏列、每次调用写审计);撤销后立刻恢复默认(再调用被拒)。
guest 侧:只有 WASI
Section titled “guest 侧:只有 WASI”- 唯一允许的导入模块是
wasi_snapshot_preview1;env.*/js.*一律拒;组件模型(wasm32-*-component)产物直接拒; - 运行期只实例化 WASI 宿主模块,没有任何 preopen(不挂宿主目录)、不传
args/env; - WASI preview1 导出了
sock_accept/sock_recv/sock_send/sock_shutdown,但没有sock_open/sock_bind/sock_listen/sock_connect——新 fd 的唯一来源是 preopen 与sock_accept,两者都不可达,所以应用拿不到 socket fd,不能出站; - 每个请求新建实例,只缓存编译产物;随机源、时钟、睡眠都是真实实现(不是 wazero 的确定性默认值)。
宿主能力:一张封闭的方法表
Section titled “宿主能力:一张封闭的方法表”应用能调用的宿主方法只有 8 个,多一个都会被拒(HOST_METHOD_UNKNOWN,错误里会列出可用清单):
| 原语 | 方法 |
|---|---|
| 建表 | db.define |
| 查询 / 写入 | db.query / db.exec |
| 事务 | tx_begin / tx_commit / tx_rollback |
| 日志 | log |
| 读随包资源 | assets.read(参数是包内逻辑路径,不是宿主路径) |
服务端没有
ai.chat之类的 AI 能力了。应用要用模型,走「前端 JS 调/__picoaide/ai/chat→ 结果回传 wasm 落库」这条路;老应用在发布校验期就会被拒(IMPORT_NOT_ALLOWED+ 迁移指引),不会静默失效。
预算(编译期默认值)
Section titled “预算(编译期默认值)”| 项目 | 默认 | 说明 |
|---|---|---|
| 单实例线性内存 | 64 MiB | 硬上限,超了实例直接失败 |
| guest 执行 | 30 s | 进入宿主调用时暂停计时(「等数据库」不计入) |
| 宿主调用兜底 | 10 s | db.* / log / assets.read 的兜底预算,必须严格大于单条 SQL 预算 |
| 单条 SQL | 5 s | 到点回滚并给库打污染标记 |
| 请求端到端墙钟 | 60 s | 含排队,到点即拒 |
| 响应体 | 保证 168 KiB / 单帧 1 MiB | 168 KiB 是「不管内容长什么样都装得下」的保证值;低转义内容实测能到约 625 KB,但不承诺 |
| 应用请求体 | 1 MiB | 信封自带上限,服务端仍自套 MaxBytesReader 兜住长度撒谎 |
| 随包资源总量 | 4 MiB / 版本 | 单条逻辑路径 ≤ 256 B,单段 ≤ 255 B |
应用库(每个应用一个 SQLite)
Section titled “应用库(每个应用一个 SQLite)”- 体积上限 100 MB(平台固定,没有用户旋钮),页大小 4 KiB;
- 表名/列名规则
^[a-z][a-z0-9_]{0,30}$,每应用 ≤ 16 张表、每表 ≤ 16 列,列类型是封闭集合(text/int/real/bool/datetime); - 语句白名单只有
SELECT/INSERT/UPDATE/DELETE(全部 DDL 一律拒,建表走db.define); _row_id是平台保留列,应用提到即拒;ATTACH在引擎层被否决(SQLITE_LIMIT_ATTACHED = 0);- 返回结果会被截断(最多 5000 行、约 172 KB)并置
truncated标志——这是分页信号,不是错误; - WAL 下并发读:每个应用默认 4 条只读连接(可配 1–16),写仍由一个写者串行。
平台侧可运营项
Section titled “平台侧可运营项”并发、内存与时间预算都可以在管理后台的应用中心 → 限制项子页调整(管理面 GET|PUT /api/server/admin/wasm-apps/limits),唯一模型在服务端 applimits 包,字段名与后台表单逐字对齐。
| 字段 | 范围 | 默认 | 生效方式 |
|---|---|---|---|
max_instances | 1–256 | 32 | 即时 |
app_running | 1–max_instances | 4 | 即时 |
app_queue | 1–4096 | 32 | 即时 |
user_global_running | 1–max_instances | 4 | 即时 |
user_per_app_running | 1–max_instances | 1 | 即时 |
user_per_app_queued | 1–4096 | 4 | 即时 |
instance_memory_mb | 16–1024 | 64 | 需重启服务端 |
module_cache_mb | 8–4096 | 128 | 即时 |
module_cache_idle_min | 1–1440 | 10 | 即时 |
appdb_idle_min | 1–1440 | 3 | 即时 |
appdb_cache_kib | 128–65536 | 1024 | 下一次新建应用库句柄 |
app_db_readers | 1–16 | 4 | 下一次新建应用库句柄 |
guest_budget_seconds | 5–120 | 30 | 即时 |
dry_run_budget_seconds | 1–120 | 30 | 即时 |
host_call_budget_seconds | 5–120 | 10 | 即时 |
request_wall_clock_seconds | 6–300 | 60 | 即时 |
sql_statement_budget_seconds | 4–119 | 5 | 下一次新建应用库句柄 |
compile_timeout_seconds | 1–60 | 60 | 即时 |
几条判据(不是建议,是保存时的硬校验):
- 时间预算之间有序关系:
wall > guest ≥ host_call > sql > sqlite busy_timeout,且有独立上限(guest ≤ 120 s、compile ≤ 60 s)。表单里的区间就是有效区间,不是基础区间——不会把管理员引到一个必然被拒的格子里。 - 保存是 fail-loud 的:超范围、缺字段、未知字段、内存四笔账(实例池 + 编译峰值 + 上传峰值 + 缓存驻留)超过可用内存的 70%,全部拒绝保存并指名哪个字段。
instance_memory_mb是唯一需要重启的项:它住在运行时的不可变配置里。其余项即时下发;三项「下一次新建应用库句柄」的项既不阻塞保存也不要求重启。- 优先级:控制台保存值 > 部署内存档位(环境变量)> 编译期默认。启动时若已保存的设置超水位,会回落档位并告警,而不是拒绝启动(防止一条设置把服务端锁死)。
边界与失败行为
Section titled “边界与失败行为”两层错误模型
Section titled “两层错误模型”客户端的失败分两层,排障时先看是哪一层:
| 层 | 码 | 什么时候 |
|---|---|---|
| 传输层(外层 HTTP + 平台信封) | 401 AUTH_REQUIRED / AUTH_FAILED | 缺令牌 / 令牌无效、过期或被吊销 |
401 proof_required / proof_expired / proof_mismatch / proof_replayed | 持有性证明缺失、过期、绑定不符、重放 | |
403 FORBIDDEN | 审计账号 | |
400 VALIDATION | 信封形态、host 形态、头白名单、path/query 非法 | |
413 BODY_TOO_LARGE | 信封或应用请求体超上限 | |
503 | 发布面关停中(如编译缓存超上限、编译队列已满、编译子系统不可用) | |
应用管线(内层信封 status) | 404 / 410 | 应用不存在、未登记、软删、冻结 / 已下架 |
| 403 | 跨源写 | |
| 502 / 504 | 运行时无响应 / 超时 | |
500 RUNTIME_OUTPUT_OVERRUN | 输出超上限 |
为什么两层要分开:应用自己的 404(页面没找到)与「应用不存在」的 404 是同一个数字,但修法完全相反——外层 404 改 app_id,内层 404 改应用路由。混在一起只能靠猜。
常见拒绝的原因码(应用侧)
Section titled “常见拒绝的原因码(应用侧)”| 码 | 含义 |
|---|---|
IMPORT_NOT_ALLOWED | 导入了非 wasi_snapshot_preview1 的模块(含老应用的 ai.chat) |
SECTION_MALFORMED / SECTION_OVERRIDE_OVERSIZE | 段表非法 / 自定义段总量超 4 MiB |
ASSET_EXISTS | 资源段重名 |
APP_CONFIG_INVALID | 配置字段非法(如 window.ratio 越界) |
MISSING_FIELD | 首版缺 purpose / data_sensitivity / owner,或非首版缺 changelog |
NAME_TAKEN | 标识已被他人占用(管理员放行) |
APP_FROZEN | 应用已冻结,不能发布新版本 |
HOST_METHOD_UNKNOWN | 调了宿主方法表里没有的方法 |
HOST_CALL_OVER_BUDGET / DB_DENIED | 宿主调用超预算 / SQL 被拒(含语句超时) |
RUNTIME_TIMEOUT / RUNTIME_OUTPUT_OVERRUN | guest 超时 / 输出超上限 |
平台侧已登记的边界(认账)
Section titled “平台侧已登记的边界(认账)”以下都是当前实现的真实边界,不是 bug 列表;写在这里是为了让部署方按自己的风险偏好决定要不要叠加别的措施。
- 应用库的删除是「软删 + 永久占位」:删除后标识与版本号不会被复用,行仍在库里(目录不列);
- 退役的快照与真删任务未实现:导出端点里明确写着「保留期内可导出 → 保留期结束后真删」的说明,但周期性快照与真删由后台任务承担,该任务当前未实现;
- 编译与执行共用同一个数据根:编译进程以 OS 级隔离(
bwrap+ 只读白名单 + 独立 netns)运行,缓存目录权限 0700,执行进程以只读方式打开;缓存条目没有内容签名,与宿主同 uid 的写者仍可投毒——真正的闭合需要独立 uid / 只读挂载 / HMAC 清单(部署面); - 删除波次留下的历史设置行:应用泛域名等历史配置项保留在库里但不再被读取。