diff --git a/.changeset/action-error-clean-message.md b/.changeset/action-error-clean-message.md new file mode 100644 index 0000000000..49e4bdf6a7 --- /dev/null +++ b/.changeset/action-error-clean-message.md @@ -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 +` '' threw: ` 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 ` '' 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. diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 25ccaf0857..fef3dec106 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -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 ` '' threw: ` for + // server logs; surface only the business `` (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 }) }; } } diff --git a/packages/runtime/src/sandbox/quickjs-runner.test.ts b/packages/runtime/src/sandbox/quickjs-runner.test.ts index bada24928b..79c2616d44 100644 --- a/packages/runtime/src/sandbox/quickjs-runner.test.ts +++ b/packages/runtime/src/sandbox/quickjs-runner.test.ts @@ -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 ` '' 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', () => { diff --git a/packages/runtime/src/sandbox/quickjs-runner.ts b/packages/runtime/src/sandbox/quickjs-runner.ts index 87c4fcade8..febfa39e90 100644 --- a/packages/runtime/src/sandbox/quickjs-runner.ts +++ b/packages/runtime/src/sandbox/quickjs-runner.ts @@ -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'); @@ -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(); @@ -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'); @@ -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 ` '' 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; +}