Skip to content

Commit d9c4f0d

Browse files
committed
fix(runtime,spec,lint): bind action.body only for type 'script' (#4352)
`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
1 parent 58434f5 commit d9c4f0d

8 files changed

Lines changed: 350 additions & 6 deletions

File tree

packages/lint/src/validate-action-body-writes.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,10 +390,32 @@ describe('validateActionBodyWrites — where action bodies live', () => {
390390
expect(findings[0].where).toBe('action "close_deal" › body');
391391
});
392392

393-
it('checks a body on a non-script action — the runtime binds one regardless of `type`', () => {
393+
// [#4352] The inversion of #4344's original assertion. That test pinned
394+
// "checks a body on a non-script action — the runtime binds one regardless of
395+
// `type`", which was true of the runtime at the time and was recorded as
396+
// provisional. The ruling closed the gap at the producer instead: the body
397+
// no longer binds (`actionBodyRunnerFactory`) and the pair no longer
398+
// publishes (`ActionSchema`). Nothing runs, so there is no write set to
399+
// advise about — and a finding here would point at the write when the defect
400+
// is the `type`.
401+
it('skips a body on a non-script action — nothing binds it, so there is no write to check', () => {
394402
const findings = validateActionBodyWrites(
395403
stackWith("await ctx.api.object('crm_deal').update({ stag: 'won' });", { type: 'url', target: '/x' }),
396404
);
405+
expect(findings).toEqual([]);
406+
});
407+
408+
it('still checks a body on an action that omits `type` — the spec default is `script`', () => {
409+
const findings = validateActionBodyWrites(
410+
stackWith("await ctx.api.object('crm_deal').update({ stag: 'won' });"),
411+
);
412+
expect(findings).toHaveLength(1);
413+
});
414+
415+
it('still checks a body on an explicit `type: "script"` action', () => {
416+
const findings = validateActionBodyWrites(
417+
stackWith("await ctx.api.object('crm_deal').update({ stag: 'won' });", { type: 'script' }),
418+
);
397419
expect(findings).toHaveLength(1);
398420
});
399421
});

packages/lint/src/validate-action-body-writes.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -218,17 +218,32 @@ function actionObjectBinding(action: AnyRec, parentObject?: string): string | un
218218
* The top-level entry is walked first, so a merged action reports at
219219
* `actions[i]` — the authored location, not the derived copy.
220220
*
221-
* `type` is deliberately not consulted: the runtime binds a handler from
222-
* `action.body` alone (`actionBodyRunnerFactory` never reads `type`), so a body
223-
* on a non-`script` action still runs and still fails silently. Checking what
224-
* executes beats checking what the schema says should.
221+
* Only `type: 'script'` bodies are walked (`type` omitted counts, since
222+
* `ActionType.default('script')` makes that the same declaration).
223+
*
224+
* This rule USED to be deliberately type-blind, on the grounds that the
225+
* runtime bound a handler from `action.body` alone and so a body on a
226+
* non-`script` action still ran and still failed silently — checking what
227+
* executes beat checking what the schema said should. That comment predicted
228+
* its own revision ("定了之后 lint 那边要跟着调"), and #4352 is the ruling:
229+
* `actionBodyRunnerFactory` now refuses to bind a handler unless the type is
230+
* `script`, and `ActionSchema` rejects the contradictory pair at publish. So
231+
* what executes and what the schema says are the same set again, and walking
232+
* a non-`script` body here would produce advice about writes that provably
233+
* never happen — noise pointing at metadata whose real defect is the `type`,
234+
* which the publish gate already names with its own prescription.
225235
*/
226236
function collectActionBodies(stack: AnyRec): ActionBodySite[] {
227237
const sites: ActionBodySite[] = [];
228238
const seen = new Set<string>();
229239

230240
const collect = (actions: unknown, pathPrefix: string, parentObject?: string): void => {
231241
asArray(actions).forEach((action, index) => {
242+
// Same default the spec declares, and the same one the runtime gate
243+
// applies — a stack may reach lint unparsed, so an omitted `type` is
244+
// `'script'`, not "unknown".
245+
const type = typeof action.type === 'string' ? action.type : 'script';
246+
if (type !== 'script') return;
232247
const body = action.body;
233248
if (!isRec(body) || body.language !== 'js') return;
234249
const source = body.source;
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#4352] `action.body` binds a handler ONLY for `type: 'script'`.
5+
*
6+
* `ActionSchema.body` has always said "Only used when type is `script`", and
7+
* its JSDoc is more explicit still ("Only meaningful when `type === 'script'`").
8+
* The runtime read neither: `collectBundleActions` collected any named action
9+
* and `actionBodyRunnerFactory` bound a handler the moment `body` parsed. So a
10+
* `type: 'url'` action carrying a leftover body was registered in the action
11+
* registry and executed in the sandbox — the declared ≠ enforced shape of
12+
* Prime Directive #10, in its nastiest form: an author flips `type` away from
13+
* `script`, reasonably concludes the body is now dead, and it is not.
14+
*
15+
* The sibling tests in `sandbox/body-runner.test.ts` pin the factory in
16+
* isolation. THIS file pins the composition AppPlugin actually performs —
17+
* `collectBundleActions(bundle)` → `actionBodyRunnerFactory(...)` → skip when
18+
* no handler → `ql.registerAction(...)` — because that loop is where the
19+
* registration decision is really made, and a factory that returns `undefined`
20+
* only matters if the loop honours it (it does: `if (!handler) continue`).
21+
*
22+
* The bind loop is replicated rather than driven through a booted AppPlugin on
23+
* purpose: booting one needs a kernel, an ObjectQL engine and a QuickJS
24+
* sandbox, none of which participate in the decision under test. The
25+
* replication is kept honest by asserting the collector's own output too, so a
26+
* change to how actions are collected still surfaces here.
27+
*/
28+
29+
import { describe, it, expect } from 'vitest';
30+
import { collectBundleActions } from './app-plugin.js';
31+
import { actionBodyRunnerFactory } from './sandbox/body-runner.js';
32+
import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js';
33+
34+
const jsBody = { language: 'js', source: 'return { ran: true };', capabilities: [] } as const;
35+
36+
/** The exact registration loop from `AppPlugin.bindDeclarativeActions`. */
37+
function bindActions(bundle: unknown, logger?: { warn: (msg: string) => void }) {
38+
const registered: Array<{ object: string; name: string }> = [];
39+
const actions = collectBundleActions(bundle);
40+
const runner = actionBodyRunnerFactory(new QuickJSScriptRunner(), {
41+
ql: {},
42+
appId: 'crm',
43+
logger,
44+
});
45+
for (const action of actions) {
46+
const handler = runner(action);
47+
if (!handler) continue;
48+
registered.push({ object: action.object ?? 'global', name: action.name });
49+
}
50+
return { collected: actions, registered };
51+
}
52+
53+
describe('#4352 — a non-script action with a body binds no handler', () => {
54+
it("registers the script action and skips the `type: 'url'` one", () => {
55+
const warnings: string[] = [];
56+
const { collected, registered } = bindActions(
57+
{
58+
actions: [
59+
// The regression population: an explicit non-script type + a body.
60+
{ name: 'open_docs', label: 'Docs', type: 'url', target: 'https://x', body: jsBody },
61+
// The overwhelmingly common case — unchanged.
62+
{ name: 'close_deal', label: 'Close', type: 'script', object: 'crm_deal', body: jsBody },
63+
],
64+
},
65+
{ warn: (msg: string) => warnings.push(msg) },
66+
);
67+
68+
// The collector stays type-blind by design — it feeds governance surfaces
69+
// that must see every declared action, bound or not.
70+
expect(collected.map((a) => a.name)).toEqual(['open_docs', 'close_deal']);
71+
72+
// ...but only the script action becomes an executable handler.
73+
expect(registered).toEqual([{ object: 'crm_deal', name: 'close_deal' }]);
74+
75+
// And the refusal is audible: silence here would just move the invisibility.
76+
expect(warnings).toHaveLength(1);
77+
expect(warnings[0]).toContain('open_docs');
78+
expect(warnings[0]).toContain("type: 'url'");
79+
});
80+
81+
it('skips a non-script body declared under an object', () => {
82+
const { registered } = bindActions({
83+
objects: [
84+
{
85+
name: 'crm_lead',
86+
actions: [
87+
{ name: 'open_portal', label: 'Portal', type: 'url', target: '/p', body: jsBody },
88+
{ name: 'score_lead', label: 'Score', type: 'script', body: jsBody },
89+
],
90+
},
91+
],
92+
});
93+
expect(registered).toEqual([{ object: 'crm_lead', name: 'score_lead' }]);
94+
});
95+
96+
it('binds an action that omits `type` — `ActionType.default(\'script\')`', () => {
97+
// Bundles reach the collector RAW. A `strict: false` `defineStack` and a
98+
// legacy `manifest.actions[]` never pass through `ActionSchema`, so the
99+
// schema's default has to be applied here or the common shape breaks.
100+
const { registered } = bindActions({
101+
manifest: { actions: [{ name: 'legacy_untyped', label: 'Legacy', body: jsBody }] },
102+
});
103+
expect(registered).toEqual([{ object: 'global', name: 'legacy_untyped' }]);
104+
});
105+
106+
it('leaves bodyless non-script actions exactly as they were', () => {
107+
const warnings: string[] = [];
108+
const { collected, registered } = bindActions(
109+
{
110+
actions: [
111+
{ name: 'open_docs', label: 'Docs', type: 'url', target: 'https://x' },
112+
{ name: 'convert', label: 'Convert', type: 'flow', target: 'crm_convert' },
113+
],
114+
},
115+
{ warn: (msg: string) => warnings.push(msg) },
116+
);
117+
expect(collected).toHaveLength(2);
118+
// They never bound a handler before this change either — nothing to warn about.
119+
expect(registered).toEqual([]);
120+
expect(warnings).toEqual([]);
121+
});
122+
});

packages/runtime/src/app-plugin.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1441,6 +1441,15 @@ export function collectBundleHooks(bundle: any): any[] {
14411441
*
14421442
* Each returned record is a shallow copy with `object` set when the action
14431443
* originated under an object (and not already present on the action itself).
1444+
*
1445+
* Deliberately type-BLIND, and it must stay that way: this collects every
1446+
* declared action, most of which (`url`, `modal`, `flow`, `api`, `form`)
1447+
* legitimately have no body and bind nothing. The `type: 'script'` gate that
1448+
* decides whether a `body` becomes an executable handler lives at the single
1449+
* bind point — `actionBodyRunnerFactory` (#4352) — because the other binder
1450+
* (`engine.setDefaultActionRunner`, for Studio-authored actions) never walks
1451+
* this collector at all. Re-filtering here would duplicate half the rule and
1452+
* leave the other binder ungated.
14441453
*/
14451454
export function collectBundleActions(
14461455
bundle: any,

packages/runtime/src/sandbox/body-runner.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,64 @@ describe('actionBodyRunnerFactory', () => {
166166
expect(factory({ name: 'noop' })).toBeUndefined();
167167
});
168168

169+
// ─── [#4352] the `type` gate ──────────────────────────────────────────────
170+
// `ActionSchema.body` always said "Only used when type is `script`"; the
171+
// runtime never read `type`, so a `type: 'url'` action carrying a leftover
172+
// body still bound a handler and still executed. These pin the enforcement.
173+
describe('binds a body only for `type: "script"` (#4352)', () => {
174+
const body = { language: 'js', source: 'return { ran: true };', capabilities: [] } as const;
175+
176+
for (const type of ['url', 'modal', 'flow', 'api', 'form'] as const) {
177+
it(`binds no handler for type: '${type}' and says why`, () => {
178+
const warnings: string[] = [];
179+
const factory = actionBodyRunnerFactory(runner, {
180+
ql: {},
181+
appId: 'crm',
182+
logger: { warn: (msg: string) => warnings.push(msg) },
183+
});
184+
expect(factory({ name: 'leftover', object: 'lead', type, body })).toBeUndefined();
185+
// Refusing silently would only relocate the invisibility the issue is about.
186+
expect(warnings).toHaveLength(1);
187+
expect(warnings[0]).toContain("type: '" + type + "'");
188+
expect(warnings[0]).toContain('#4352');
189+
});
190+
}
191+
192+
it("binds for an explicit type: 'script'", async () => {
193+
const factory = actionBodyRunnerFactory(runner, { ql: {}, appId: 'crm' });
194+
const fn = factory({ name: 'ok', object: 'lead', type: 'script', body });
195+
expect(typeof fn).toBe('function');
196+
await expect(fn!({ params: {} })).resolves.toEqual({ ran: true });
197+
});
198+
199+
it('binds when `type` is omitted — the spec default is `script`', async () => {
200+
// The collectors walk RAW bundle objects; a `strict: false` defineStack or
201+
// a legacy `manifest.actions[]` never passed through `ActionType.default`,
202+
// so an omitted type must still mean `script` here.
203+
const warnings: string[] = [];
204+
const factory = actionBodyRunnerFactory(runner, {
205+
ql: {},
206+
appId: 'crm',
207+
logger: { warn: (msg: string) => warnings.push(msg) },
208+
});
209+
const fn = factory({ name: 'ok', object: 'lead', body });
210+
expect(typeof fn).toBe('function');
211+
await expect(fn!({ params: {} })).resolves.toEqual({ ran: true });
212+
expect(warnings).toEqual([]);
213+
});
214+
215+
it('stays silent for a non-script action with no body — nothing is contradictory', () => {
216+
const warnings: string[] = [];
217+
const factory = actionBodyRunnerFactory(runner, {
218+
ql: {},
219+
appId: 'crm',
220+
logger: { warn: (msg: string) => warnings.push(msg) },
221+
});
222+
expect(factory({ name: 'open_docs', type: 'url' })).toBeUndefined();
223+
expect(warnings).toEqual([]);
224+
});
225+
});
226+
169227
it('runs an L2 action body and returns its value', async () => {
170228
const factory = actionBodyRunnerFactory(runner, { ql: {}, appId: 'crm' });
171229
const fn = factory({

packages/runtime/src/sandbox/body-runner.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,17 +102,61 @@ export function hookBodyRunnerFactory(
102102
* Returns a handler with the shape ObjectQL's `executeAction` expects:
103103
* `(actionCtx) => Promise<unknown>`. The action's return value bubbles up
104104
* to the HTTP dispatcher which JSON-serialises it back to the caller.
105+
*
106+
* This is the ONE choke point where an `action.body` becomes an executable
107+
* handler — both bind paths go through it (`AppPlugin`'s bundle walk over
108+
* `collectBundleActions`, and `engine.setDefaultActionRunner` for the
109+
* Studio-authored `action` metadata ObjectQLPlugin re-syncs). So the
110+
* `type` gate below is enforced here rather than at either call site: a
111+
* second copy at the collector would be a rule that can drift from this one.
105112
*/
106113
export function actionBodyRunnerFactory(
107114
runner: ScriptRunner,
108115
opts: FactoryOptions,
109-
): (action: { name: string; body?: unknown; object?: string; timeoutMs?: number }) =>
116+
): (action: { name: string; body?: unknown; object?: string; type?: string; timeoutMs?: number }) =>
110117
| ((actionCtx: any) => Promise<unknown>)
111118
| undefined {
112119
return (action) => {
113120
const raw = action.body;
114121
if (!raw) return undefined;
115122

123+
// [#4352] `body` binds a handler ONLY for `type: 'script'` — the rule the
124+
// spec always stated (`ActionSchema.body`: "Only used when type is
125+
// `script`") and the runtime never enforced. Every other type dispatches
126+
// on `target` (the URL, page, flow or endpoint), so a body alongside one
127+
// is self-contradictory metadata: two implementations, only one of which
128+
// the author can see running.
129+
//
130+
// Binding it anyway produced the worst-shaped bug this repo has a name
131+
// for — an author flips `type` from `script` to `url`, reasonably reads
132+
// that as "the body no longer runs", and it keeps running, reachable
133+
// through `ql.object(o).execute(name)` (the ObjectQL proxy calls
134+
// `executeAction` with no type branching of its own) and counted by the
135+
// ADR-0110 D5 governance inventory as a live handler.
136+
//
137+
// `?? 'script'` is the schema's own default (`ActionType.default('script')`),
138+
// not a tolerant fallback: the collectors walk RAW bundle objects, which
139+
// for a `strict: false` `defineStack` or a legacy `manifest.actions[]`
140+
// never went through `ActionSchema`, so an omitted `type` still has to
141+
// mean what the spec says it means. An action that EXPLICITLY declares
142+
// another type is the only one whose behavior changes.
143+
//
144+
// The publish gate rejects this shape at authoring time (`ActionSchema`'s
145+
// non-script-body refinement, #4438), so anything arriving here is either
146+
// data at rest published before that gate existed or a bundle that never
147+
// parsed. Refusing silently would just relocate the invisibility, so the
148+
// refusal is logged with the same prescription the schema gives.
149+
const type = action.type ?? 'script';
150+
if (type !== 'script') {
151+
opts.logger?.warn?.(
152+
`[BodyRunner] action '${action.name}' declares \`type: '${type}'\` and carries a \`body\` — ` +
153+
`no handler was bound. \`body\` only runs for \`type: 'script'\`; a '${type}' action dispatches ` +
154+
`on \`target\`. Set \`type: 'script'\` to run the body, or drop the \`body\`. See #4352.`,
155+
{ appId: opts.appId, action: action.name, object: action.object, type },
156+
);
157+
return undefined;
158+
}
159+
116160
const parsed = HookBodySchema.safeParse(raw);
117161
if (!parsed.success) {
118162
opts.logger?.warn?.('[BodyRunner] invalid action.body shape', {

0 commit comments

Comments
 (0)