Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/action-error-clean-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/runtime': patch
---

fix(runtime): surface the clean business message from a failed action, not the sandbox debug wrapper

A user throw inside a script/action body is wrapped by the sandbox as
`<kind> '<name>' threw: <msg>` for server logs, but the action HTTP endpoint
returned that whole wrapper as the client-facing `error` — so an action's error
toast leaked the debug prefix to end users (e.g. `action 'lead_apply_convert'
threw: Error: 线索信息不完整…` instead of just `线索信息不完整…`).

`SandboxError` now also carries `innerMessage`: the plain business message with
no `<kind> '<name>' threw:` wrapper and no default `Error: ` name prefix. The
action route surfaces `innerMessage` to the client and keeps the full wrapper in
the server log.
11 changes: 9 additions & 2 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3186,8 +3186,15 @@ export class HttpDispatcher {
}
return { handled: true, response: this.success({ success: true, data: result }) };
} catch (err: any) {
const msg = err?.message ?? String(err);
return { handled: true, response: this.success({ success: false, error: msg }) };
const full = err?.message ?? String(err);
// The sandbox wraps a user throw as `<kind> '<name>' threw: <msg>` for
// server logs; surface only the business `<msg>` (SandboxError.innerMessage)
// to the client so an action's error toast reads as plain text instead of
// leaking the debug prefix. Keep the full wrapper in the log for debugging.
const inner: unknown = err?.innerMessage;
const clientMsg = (typeof inner === 'string' && inner) ? inner : full;
if (clientMsg !== full) console.error(`[action ${objectName}/${actionName}] ${full}`);
return { handled: true, response: this.success({ success: false, error: clientMsg }) };
}
}

Expand Down
16 changes: 16 additions & 0 deletions packages/runtime/src/sandbox/quickjs-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,22 @@ describe('QuickJSScriptRunner — L2 hook script', () => {
),
).rejects.toThrow(/hook 'oops'/);
});

it('exposes the clean business message via SandboxError.innerMessage', async () => {
// `.message` keeps the `<kind> '<name>' threw: …` debug wrapper for logs;
// `.innerMessage` is the plain business message (no wrapper, no `Error: `
// name prefix) that the HTTP layer surfaces to end users.
const err = await runner
.runScript(
{ language: 'js', source: "throw new Error('线索信息不完整');", capabilities: [] },
ctx(),
{ origin: { kind: 'action', name: 'lead_apply_convert' } },
)
.then(() => null, (e) => e as SandboxError);
expect(err).toBeInstanceOf(SandboxError);
expect(err!.message).toContain("action 'lead_apply_convert' threw:");
expect(err!.innerMessage).toBe('线索信息不完整');
});
});

describe('QuickJSScriptRunner — L2 action script', () => {
Expand Down
42 changes: 37 additions & 5 deletions packages/runtime/src/sandbox/quickjs-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,10 @@ export class QuickJSScriptRunner implements ScriptRunner {
if (result.error) {
const err = vm.dump(result.error);
result.error.dispose();
throw new SandboxError(`${args.origin.kind} '${args.origin.name}' threw: ${formatErr(err)}`);
throw new SandboxError(
`${args.origin.kind} '${args.origin.name}' threw: ${formatErr(err)}`,
userFacingMessage(formatErr(err)),
);
}
result.value.dispose();
const resH = vm.getProp(vm.global, '__result');
Expand Down Expand Up @@ -182,7 +185,10 @@ export class QuickJSScriptRunner implements ScriptRunner {
if (evalRes.error) {
const err = vm.dump(evalRes.error);
evalRes.error.dispose();
throw new SandboxError(`${args.origin.kind} '${args.origin.name}' threw: ${formatErr(err)}`);
throw new SandboxError(
`${args.origin.kind} '${args.origin.name}' threw: ${formatErr(err)}`,
userFacingMessage(formatErr(err)),
);
}
evalRes.value.dispose();

Expand All @@ -206,14 +212,20 @@ export class QuickJSScriptRunner implements ScriptRunner {
if (pending.error) {
const err = vm.dump(pending.error);
pending.error.dispose();
throw new SandboxError(`${args.origin.kind} '${args.origin.name}' threw: ${formatErr(err)}`);
throw new SandboxError(
`${args.origin.kind} '${args.origin.name}' threw: ${formatErr(err)}`,
userFacingMessage(formatErr(err)),
);
}

const errH = vm.getProp(vm.global, '__error');
const errStr = vm.dump(errH);
errH.dispose();
if (errStr) {
throw new SandboxError(`${args.origin.kind} '${args.origin.name}' threw: ${errStr}`);
throw new SandboxError(
`${args.origin.kind} '${args.origin.name}' threw: ${errStr}`,
userFacingMessage(String(errStr)),
);
}

const resH = vm.getProp(vm.global, '__result');
Expand Down Expand Up @@ -645,8 +657,28 @@ function formatErr(err: unknown): string {
}

export class SandboxError extends Error {
constructor(message: string) {
/**
* For errors thrown by *user* script/hook/action code: the original business
* message without the `<kind> '<name>' threw:` debug wrapper that lives in
* `.message`. Safe to surface to end users (e.g. an action's error toast);
* the wrapped `.message` stays for server logs. Undefined for the sandbox's
* own internal errors (capability denials, timeouts, marshalling failures),
* which have no user-meaningful inner message.
*/
readonly innerMessage?: string;
constructor(message: string, innerMessage?: string) {
super(message);
this.name = 'SandboxError';
this.innerMessage = innerMessage;
}
}

/**
* Strip a leading default `Error: ` name prefix so a thrown business message
* (`new Error('线索信息不完整…')`) reads as plain text for end users. Non-default
* names (`TypeError:`, `RangeError:`) are kept — they signal a genuine bug
* rather than a deliberately thrown business rule, which is useful context.
*/
function userFacingMessage(raw: string): string {
return raw.startsWith('Error: ') ? raw.slice('Error: '.length) : raw;
}