跳转到内容

应用中心

应用中心是员工自建小工具的地方:员工在对话里描述需求,AI 用内置的应用构建技能写一个 WASI 应用、本地编译成 .wasm,员工在应用中心里填表发布;同事在桌面客户端的独立窗口里打开它。

本页讲的是这套平台的设计与边界——为什么是这个形态、配置契约长什么样、什么情况下会被拒绝、失败时表现是什么。需要「点哪里」的操作步骤见桌面客户端,管理员侧的处置动作见企业管控面。

平台要解决的问题是「让非工程岗位的同事能给自己做工具」,同时不把企业的网络与证书面撑大。

问题这个平台的答案
应用怎么被访问只在桌面客户端内,独立应用窗口加载自定义协议地址 <应用源 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…),段名即包内逻辑路径
停用 / 下架 / 冻结 / 退役四种不同状态,见「边界与失败行为」

打开(每次点「打开」都发生一次):

  1. 员工在应用中心点开某个应用,客户端调用本机的打开路由(面板本身不持员工令牌);
  2. 宿主向服务端 POST /api/client/v2/apps/wasm/:app_id/open 校验一次,同时登记当日打开次数;
  3. 校验通过后,客户端开一个独立窗口,加载 <应用源 scheme>://<app_id>/<path>。已有窗口时改为聚焦;
  4. 响应里的应用版本(X-ZeoKeel-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-ZeoKeel-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 剔除。

预留面:/__zeokeel 及其子路径是宿主保留面。目前只有应用 AI 桥 POST /__zeokeel/ai/chat 一条,由协议 handler 本地处理、绝不转发平台;其余 /__zeokeel/* 一律 404,不会被当成应用自己的路由。

升级必须服务端与客户端同版本:应用地址由渠道包在组装期注入(desktop.app_origin_scheme,必填、不得与深链 scheme 相同、不得是 http 之类的保留 scheme),信封与协议头的形状是两端共同的契约。旧客户端撞上新服务端(或反过来)的表现是打开失败并提示「客户端与服务端必须同版本升级」,而不是静默降级——这条约束的代价是「服务端与客户端要一起升」,收益是协议面永远只有一份形状。

打开失败的三种原因各自可辨(平台把它们分开,不塌缩成一句「打开失败」):应用不存在 → 404;已下架 → 410 Gone(数据与链接都还在);被冻结 → 404 但带 platform_reason=app_frozen(文案说「已被管理员停用」,与「不存在」逐字可辨)。

配置文件名固定为 zeokeel.app.json(发布时以对象形态放进 config 字段)。单一真源是服务端 server/internal/wasmapp/appcfg/appcfg.json(schema zeokeel-app-config/1),客户端镜像同一份字段规格并与它对拍;帧内协议版本是 zeokeel-app/1。

字段类型必填说明
access枚举否login(缺省)或 whitelist;只有这两个取值
whiteliststring[]access=whitelist 时必填准入名单,最多 2000 条;平台不比对,也不校验账号是否存在
purpose字符串首版必填一句话用途,用于应用中心与页面标题
data_sensitivity字符串首版必填数据敏感度声明(如 internal);平台没有默认值,只用于声明与追责
owner字符串首版必填负责人声明,与应用中心的「负责人」显示同源
sensitive_columnsstring[]否额外声明为敏感的列名(默认启发式之外),最多 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 非首版必填(空即拒)。
  • 开发:员工在对话里描述需求,内置的应用构建技能负责设计(数据模型、页面、权限、错误处理);产物形态固定为「静态前端(随包资源)+ 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-ZeoKeel-Client-Budget-Ms 告诉平台,平台据此收窄自己的预算(只许缩小、永不放大)——这样平台的结构化错误总能先于客户端放弃返回,员工不会只看到一句「网络错误」。

发布是一次同步调用,顺序固定(失败时的补偿也固定):

阶段做什么关键约束
A 纯校验标识 → 体积 → 静态检查(导入/导出/段表/体积)→ 配置校验更新发布时先把缺席字段从上一版生效配置继承过来
B 编译真编译(编译子进程)单次编译预算受控制台限制,默认 60 s
C 抽取分流自定义段 → 内存资源集,逐条校验逻辑路径保留前缀拒、重名段拒、单文件与总量 ≤ 4 MiB
D 干跑合成帧 Instantiate → _start → 响应帧编译通过 ≠ 能跑(导出签名不匹配编译期全绿)
E 占名落行写应用行与版本行,拿到版本 id从这一步开始才有写入;此前一行都没有
F 投影只有 approved 的版本才投影到应用行待审版本不能提前改写目录(标题/描述/配置都不行)
G 收尾审计 → 版本 GC保留最近几个生效版本,更早的软删

两个刻意的性质:

  • 整条链路只写数据库,唯一的文件系统痕迹是编译用的临时文件(逐条路径 defer 删除)。因此「失败的发布不占版本号」:失败要么发生在第一次写入之前(一行都不留),要么由补偿闭包把刚建的行软删(软删行仍在 ⇒ 版本号仍占位)。
  • 版本号永久占位:已落行的版本换号重发;编译或干跑失败可以直接重发同一个号。

管理员侧有一个发布审核开关(设置键 wasm.review_required,默认关)。

  • 开关打开时,新版本落库为待审:版本落了、号占了,但线上仍是旧版本(生效版本只认已通过);
  • 作者在自己的版本历史里看得到结论与被拒理由——这是审核结论唯一的作者侧出口;
  • 审核开关读失败时发布直接 503、不落行(严格读法)。用宽松读法会把一次设置读取故障变成「静默放行未审核版本」。
动作谁可以做效果可逆
上架 / 下架发布者本人、管理员下架 → 访问返回 410 Gone(数据保留、链接不变)可逆(重新上架即恢复)
冻结 / 解冻发布者本人、管理员冻结 = 停止服务(一律 404)并顺带下架;解冻不会自动上架可逆(解冻后还要重新上架)
删除(退役)发布者本人、管理员行软删;标识与版本号永久占位,不会复用不可逆
转移归属仅管理员改 apps.owner,用于人员流动/离职交接—

两条容易误解的语义:

  • 发布即占名:app_id 由首个成功发布者永久持有;被拒、被下架、被删除都不释放。想用别人的标识发布会拿到 409 NAME_TAKEN(管理员放行)。
  • 冻结行只对归属人本人可见:对其他人,冻结应用与「不存在」逐字节同形(不泄露存在性);对发布者本人,应用中心里一直看得到它(带「已冻结」标记),否则「解冻」这条路在员工产品里就没有入口。
作者可见说明
目录与自己的行全部应用(含已下架,标「已下架」并禁用打开);自己的行带发布者视角
版本历史自己应用的版本清单与审核结论(含被拒理由);返回体不含制品字节
诊断时间窗口内的调用总数、失败数、最近失败、原因码与建议;带保留期说明
表结构应用库的表/列/行数/占用
数据浏览应用库的行(默认脱敏:启发式 + 作者声明的 sensitive_columns);显式「显示原值」会记一条审计
导出控制面元数据快照(应用行 + 各版本行 + 配置),JSON
作者不可见(如实列出)原因
导出里的资源内容与应用库内容导出只含控制面元数据;库里的业务数据可能含个人信息,属于独立的管理员运维动作
应用日志表应用 log 走服务端 stdout,没有可供作者查询的日志表;诊断只给失败的调用事件
别人的应用(含冻结行)与「不存在」同形,不泄露存在性
其他同事的使用明细目录只给当日打开次数(PV/UV);不出现任何额度/用量字段
审核开关开关在管理端;作者只能看到自己版本的状态

两条与数据有关的授权边界:

  • 应用内的 AI 只有对话:/__zeokeel/ai/chat 由宿主本地处理,只发送本次对话内容;没有工具、不能读写文件、不能用连接器与记忆。授权按「账号 × 服务端 × 应用」记录,随时可撤销。
  • AI 读应用库默认关闭:作者要在应用中心里显式授权之后,AI 的数据工具才能读该应用的数据(仅脱敏列、每次调用写审计);撤销后立刻恢复默认(再调用被拒)。
  • 唯一允许的导入模块是 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 的确定性默认值)。

应用能调用的宿主方法只有 8 个,多一个都会被拒(HOST_METHOD_UNKNOWN,错误里会列出可用清单):

原语方法
建表db.define
查询 / 写入db.query / db.exec
事务tx_begin / tx_commit / tx_rollback
日志log
读随包资源assets.read(参数是包内逻辑路径,不是宿主路径)

服务端没有 ai.chat 之类的 AI 能力了。应用要用模型,走「前端 JS 调 /__zeokeel/ai/chat → 结果回传 wasm 落库」这条路;老应用在发布校验期就会被拒(IMPORT_NOT_ALLOWED + 迁移指引),不会静默失效。

项目默认说明
单实例线性内存64 MiB硬上限,超了实例直接失败
guest 执行30 s进入宿主调用时暂停计时(「等数据库」不计入)
宿主调用兜底10 sdb.* / log / assets.read 的兜底预算,必须严格大于单条 SQL 预算
单条 SQL5 s到点回滚并给库打污染标记
请求端到端墙钟60 s含排队,到点即拒
响应体保证 168 KiB / 单帧 1 MiB168 KiB 是「不管内容长什么样都装得下」的保证值;低转义内容实测能到约 625 KB,但不承诺
应用请求体1 MiB信封自带上限,服务端仍自套 MaxBytesReader 兜住长度撒谎
随包资源总量4 MiB / 版本单条逻辑路径 ≤ 256 B,单段 ≤ 255 B
  • 体积上限 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),写仍由一个写者串行。

并发、内存与时间预算都可以在管理后台的应用中心 → 限制项子页调整(管理面 GET|PUT /api/server/admin/wasm-apps/limits),唯一模型在服务端 applimits 包,字段名与后台表单逐字对齐。

字段范围默认生效方式
max_instances1–25632即时
app_running1–max_instances4即时
app_queue1–409632即时
user_global_running1–max_instances4即时
user_per_app_running1–max_instances1即时
user_per_app_queued1–40964即时
instance_memory_mb16–102464需重启服务端
module_cache_mb8–4096128即时
module_cache_idle_min1–144010即时
appdb_idle_min1–14403即时
appdb_cache_kib128–655361024下一次新建应用库句柄
app_db_readers1–164下一次新建应用库句柄
guest_budget_seconds5–12030即时
dry_run_budget_seconds1–12030即时
host_call_budget_seconds5–12010即时
request_wall_clock_seconds6–30060即时
sql_statement_budget_seconds4–1195下一次新建应用库句柄
compile_timeout_seconds1–6060即时

几条判据(不是建议,是保存时的硬校验):

  • 时间预算之间有序关系:wall > guest ≥ host_call > sql > sqlite busy_timeout,且有独立上限(guest ≤ 120 s、compile ≤ 60 s)。表单里的区间就是有效区间,不是基础区间——不会把管理员引到一个必然被拒的格子里。
  • 保存是 fail-loud 的:超范围、缺字段、未知字段、内存四笔账(实例池 + 编译峰值 + 上传峰值 + 缓存驻留)超过可用内存的 70%,全部拒绝保存并指名哪个字段。
  • instance_memory_mb 是唯一需要重启的项:它住在运行时的不可变配置里。其余项即时下发;三项「下一次新建应用库句柄」的项既不阻塞保存也不要求重启。
  • 优先级:控制台保存值 > 部署内存档位(环境变量)> 编译期默认。启动时若已保存的设置超水位,会回落档位并告警,而不是拒绝启动(防止一条设置把服务端锁死)。

客户端的失败分两层,排障时先看是哪一层:

层码什么时候
传输层(外层 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 改应用路由。混在一起只能靠猜。

码含义
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_OVERRUNguest 超时 / 输出超上限

以下都是当前实现的真实边界,不是 bug 列表;写在这里是为了让部署方按自己的风险偏好决定要不要叠加别的措施。

  • 应用库的删除是「软删 + 永久占位」:删除后标识与版本号不会被复用,行仍在库里(目录不列);
  • 退役的快照与真删任务未实现:导出端点里明确写着「保留期内可导出 → 保留期结束后真删」的说明,但周期性快照与真删由后台任务承担,该任务当前未实现;
  • 编译与执行共用同一个数据根:编译进程以 OS 级隔离(bwrap + 只读白名单 + 独立 netns)运行,缓存目录权限 0700,执行进程以只读方式打开;缓存条目没有内容签名,与宿主同 uid 的写者仍可投毒——真正的闭合需要独立 uid / 只读挂载 / HMAC 清单(部署面);
  • 删除波次留下的历史设置行:应用泛域名等历史配置项保留在库里但不再被读取。