fix(runtime,spec,lint): bind action.body only for type 'script' (#4352) - #4654
Conversation
`ActionSchema.body` has always said "Only used when type is `script`", but
the runtime read `body` alone: `actionBodyRunnerFactory` bound a handler the
moment the body parsed, so a `type: 'url'` action carrying a leftover body
was registered and executed. Declared != enforced, in its nastiest shape —
an author flips `type` away from `script`, reasonably concludes the body is
dead, and it keeps running.
- runtime: `actionBodyRunnerFactory` refuses to bind unless the type is
`script` (omitted `type` = the spec's own `ActionType.default('script')`),
and logs the refusal with the schema's prescription rather than dropping
it silently. The gate lives at the single bind point, not the collector —
`collectBundleActions` stays type-blind so governance surfaces still see
every declared action, and the second binder
(`engine.setDefaultActionRunner`) never walks the collector at all.
- spec: pins that the publish gate RESOLVES to the rejecting schema —
`getMetadataTypeSchema('action')` and `ObjectSchema.actions` — so a
re-point of either registration cannot silently reopen the hole.
- lint: `validate-action-body-writes` filters by `type` again (#4344's
provisional type-blindness is over) and its stale rationale is rewritten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 3 package(s): 114 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…ion-body-type-gate
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5
影响面实测(#3746 先例)—— 三个示例 app + 全部
|
| App | 扫描入口 | 带 body 的节点 | 非 script 命中 |
|---|---|---|---|
app-todo |
objectstack.config.ts(完整 stack) |
0 | 0 |
app-crm |
objectstack.config.ts(完整 stack) |
0 | 0 |
app-showcase |
各元数据 barrel(见下) | 9 | 0 |
app-showcase 的完整 config 需要 @objectstack/cloud-connection 等连接器插件构建产物,所以按 barrel 逐个扫,覆盖 action 能被声明的每个位置:
src/ui/actions/index.ts— 5 个 body,全部type: 'script':showcase_action_param_gallery、showcase_archive_task、showcase_mark_done、showcase_portfolio_snapshot、showcase_submit_signoffsrc/data/hooks/index.ts— 4 个 body:showcase_audit_task_completion、showcase_normalize_task_title、showcase_stamp_inquiry_defaults、showcase_warn_over_budget。这些是 hook 不是 action,没有type字段,本 PR 的门完全不碰它们(走的是hookBodyRunnerFactory)。src/data/objects/index.ts、src/ui/pages/index.ts、src/ui/views/index.ts、src/ui/apps/index.ts— 0 个 body(即没有内联在对象/页面上的 action body)。
交叉验证:app-showcase/src/ui/actions/index.ts 里那些非 script 的 action(type: 'url' L67、'flow' L84、'modal' L96/L175、'api' L108/L145、'form' L159)文本上确认均不带 body;app-crm 唯一的 action crm_convert_lead 是 type: 'flow' + target,无 body。
content/docs
37 行 body: + 5 行 "body",逐条归类后 action 相关的只有三处,全部合规:
| 位置 | 内容 | 判定 |
|---|---|---|
ui/actions.mdx:84 |
MarkDoneAction 示例 |
type: 'script' ✅ |
protocol/objectui/actions.mdx:64,73 |
greet_user / stamp_now YAML 示例 |
均 type: script ✅ |
data-modeling/formulas.mdx:339 |
notify_on_escalation |
hook 的 body,无 type ✅ |
其余为 fetch(..., { body })、通知/邮件正文、flow step 的 body 区域、hook-bodies.mdx 的 hook body、以及名为 body 的字段。
值得单独指出:content/docs/protocol/objectui/actions.mdx 已经把这条规则写成了「parse-time error」——
bodyis only meaningful whentypeisscript, and declaring one on any other type is a parse-time error — those types dispatch ontarget, so the body would never be invoked.
也就是说文档描述的一直是本 PR(连同 #4438)实现的那个世界,本 PR 不需要改任何一页 docs。
结论
没有任何 app 或 doc 依赖「非 script 也跑 body」。 裁决里那句「若出现真实依赖的命中就停手按 needs_decision 回报」的前提没有出现,按裁决继续实施。
Generated by Claude Code
Fixes #4352
背景
ActionSchema.body的describe()一直写着 "Only used when type isscript",JSDoc 说得更明确("Only meaningful whentype === 'script'")。但运行时从来没读过type:actionBodyRunnerFactory只要body解析成功就绑定 handler,collectBundleActions也照单全收。于是一个type: 'url'的 action 带着遗留的body,照样被注册进 action registry、照样在沙箱里执行。这是 Prime Directive #10「declared ≠ enforced」最难受的一种形状:作者把
type从script改成url,合理地认为 body 已经死了,但它没有——仍然可以通过ql.object(o).execute(name)触达(ObjectQL proxy 直接调executeAction,自己不做 type 分支),并且仍然被 ADR-0110 D5 治理清单算作一个活的 handler。改动
按 issue 里 maintainer 裁定的方向,两端一起收口:
运行时(
packages/runtime/src/sandbox/body-runner.ts)——actionBodyRunnerFactory只在type === 'script'时绑定 handler,其余类型返回undefined并logger.warn说明原因(静默拒绝只是把「看不见」换个地方,不算修好)。门放在唯一的绑定点,不放在 collector:
collectBundleActions刻意保持 type-blind(治理面需要看到所有声明的 action,不管绑没绑),而且另一条绑定路径engine.setDefaultActionRunner(Studio 里写的action元数据)根本不走这个 collector——在 collector 上再抄一份规则,等于一半重复、另一半漏网。type省略时按'script'处理。这不是宽容 fallback,而是 schema 自己的默认值(ActionType.default('script')):collector 走的是原始 bundle 对象,strict: false的defineStack和 legacymanifest.actions[]从来没经过ActionSchema,所以省略的type必须仍然等于 spec 说的那个意思。只有显式声明了别的 type 的 action,行为才发生变化。Spec(
packages/spec/src/ui/action.zod.ts)——发布门本身的拒绝规则(type !== 'script' && body)在 fix(spec,objectql,metadata-protocol): auserfield carries its target in the TYPE — bare{type:'user'}is not targetless #4438 已经落地,本 PR 不重复实现,而是补上接线的 pin:发布门并不直接 importActionSchema,它通过getMetadataTypeSchema('action')按元数据类型解析(metadata-protocol的存盘校验和metadata-diagnostics都走这条路),对象内联 action 则由ObjectSchema.actions判定。这两处注册只要被重新指向,洞就会悄悄重开,而上面所有 schema 测试仍然全绿。新增测试正是钉住这一点。JSDoc 同步改写为「两端强制」而不再只是一句描述。Lint(
packages/lint/src/validate-action-body-writes.ts)——feat(lint,spec): L2 action body 写不存在字段从盲区变为作者时 lint 告警 (#4271) #4344 当时刻意让这条规则 type-blind,理由是「运行时不看 type,所以检查真正执行的东西比检查 schema 声称的东西更有意义」,并且那段注释自己就预告了会被改("定了之后 lint 那边要跟着调")。现在裁定下来了:执行集合和声明集合重新合一,非script的 body provably 不会跑,再对它的写操作提建议就是噪音——指向的是 write,真正的缺陷是type,而发布门已经用自己的措辞点名了。规则改回按type过滤,陈旧的 rationale 注释一并重写。测试
packages/runtime/src/action-body-type-gate.test.ts(新增)——钉住 AppPlugin 真正执行的组合:collectBundleActions→actionBodyRunnerFactory→if (!handler) continue→registerAction。注册决定是在这个循环里做出的,factory 返回undefined只有在循环尊重它时才算数。同时断言 collector 自身的输出,保证「复刻循环」不会和真实实现悄悄脱节。packages/runtime/src/sandbox/body-runner.test.ts—— 隔离地钉 factory:url/modal/flow/api/form五种类型各自不绑定且必须发出 warn;显式script和省略type均照常绑定;非 script 且没有 body 的 action 不产生任何 warn(没有矛盾就不该有噪音)。packages/spec/src/ui/action.test.ts—— 发布门解析链的 pin(见上)。packages/lint/src/validate-action-body-writes.test.ts—— 把 feat(lint,spec): L2 action body 写不存在字段从盲区变为作者时 lint 告警 (#4271) #4344 那条 provisional 断言反过来,并补上「省略type」「显式script」两种仍然要检查的情形。(本 PR 为 draft:验证与影响面盘点的完整证据随后补充到评论区。)
🤖 Generated with Claude Code
https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5
Generated by Claude Code