Skip to content

ActionRunner.executeScript doesn't recognize action.body — spec-valid script actions fail with a misleading error #2896

Description

@os-zhuang

Found while fixing the target/execute precedence in #2895.

What the spec says

@objectstack/spec ActionSchema carries a body field, and for script actions it is the preferred binding:

// packages/spec/src/ui/action.zod.ts:306
// For `script` type: prefer `body` over `target`. `target` is kept only for
// legacy bundle.functions[name] references.

// :353
body: HookBodySchema.optional().describe('Action body — expression (L1) or sandboxed JS (L2). Only used when type is `script`.'),

HookBodySchema is a discriminated union on language:

  • L1 { language: 'expression', source } — pure formula, no IO.
  • L2 { language: 'js', source, capabilities, timeoutMs, memoryMb } — function body wrapped in new AsyncFunction('input', 'ctx', source) and run inside an isolated VM, with capability tokens (api.read, api.write, api.transaction, crypto.*, log) enforced at invocation.

What already works — and what doesn't

This is not broken in the console. useConsoleActionRuntime registers a server handler for the whole script type:

// packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: modalActionHandler },

and ActionRunner gives registered handlers priority over its built-ins:

// packages/core/src/actions/ActionRunner.ts:493
if (actionType && this.handlers.has(actionType)) {  }   // handler wins

case 'script': result = await this.executeScript(action); // built-in fallback

So in the console a script action POSTs to /api/v1/actions/{object}/{action}, and the server runs the body through its sandbox (packages/runtime/src/sandbox/{script-runner,quickjs-runner,body-runner}.tsengine.executeAction). Bodies work.

The gap is the built-in fallback at line 504 — used by every consumer that does not register a script handler (standalone @object-ui/core, SDUI, embedded renderers). It reads only target/execute:

const script = action.target || action.execute;   // after #2895
if (!script) {
  return { success: false, error: 'No script provided for script action' };
}

A perfectly spec-valid action:

{ "type": "script", "body": { "language": "expression", "source": "input.amount > 1000" } }

fails there with "No script provided for script action" — which is false. A script was provided; this runner just can't run it. ActionDef doesn't even declare body, so nothing type-level flags it either.

The fix is NOT to execute bodies client-side

Worth stating up front, because "implement body" is the tempting reading:

  • L2 (js) must never run in the browser. The whole contract is an isolated VM enforcing declared capabilities, a hard timeoutMs and memoryMb, and mediated ctx.api read/write/transaction access. In a browser there is no isolate and no enforcement boundary — "checking" capabilities client-side is decoration, and api.write from an unsandboxed page is strictly worse than not supporting it.
  • L1 (expression) is a different dialect here. The spec evaluates L1 with the formula engine (CEL — packages/formula/src/cel-engine.ts). This repo's ExpressionEvaluator is a ${…} template-string evaluator for "dynamic UI behavior". The two agree on simple comparisons and diverge on the rest — silent wrong answers, which is worse than an error.

Bodies are a server-side execution surface, reached over HTTP. The client's job is to dispatch, not to interpret.

Proposed fix

Small and honest:

  1. Declare body? on ActionDef as an opaque marker (not a client-executed field), documenting that it is server-executed.
  2. In executeScript, when there is no client-side target/execute and a body is present, fail with an error that names the real cause and the remedy — the body runs server-side, register a script handler the way the console does — instead of the misleading "No script provided".
  3. Keep the existing message for actions that genuinely carry no source at all.

That turns a confusing dead end into a directed one, without pretending the browser can honor the L2 contract.

Out of scope

Wiring a default server dispatcher into @object-ui/core (so standalone consumers get body support without registering a handler) is a bigger design question — core has no opinion about auth, base URL, or object resolution today, all of which serverActionHandler needs. Worth its own discussion.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions