Skip to content

Commit 852bc8e

Browse files
authored
fix(runtime): surface clean business message from failed action, not the sandbox debug wrapper (#2534)
* fix(runtime): surface clean business message from action errors, 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 (e.g. "action 'lead_apply_convert' threw: Error: ...") to end users. SandboxError now also carries `innerMessage` — the plain business message (no wrapper, no default `Error: ` name prefix). The action route surfaces that to the client and keeps the full wrapper in the server log. * chore: changeset for action error message fix
1 parent 1f32204 commit 852bc8e

4 files changed

Lines changed: 78 additions & 7 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): surface the clean business message from a failed action, not the sandbox debug wrapper
6+
7+
A user throw inside a script/action body is wrapped by the sandbox as
8+
`<kind> '<name>' threw: <msg>` for server logs, but the action HTTP endpoint
9+
returned that whole wrapper as the client-facing `error` — so an action's error
10+
toast leaked the debug prefix to end users (e.g. `action 'lead_apply_convert'
11+
threw: Error: 线索信息不完整…` instead of just `线索信息不完整…`).
12+
13+
`SandboxError` now also carries `innerMessage`: the plain business message with
14+
no `<kind> '<name>' threw:` wrapper and no default `Error: ` name prefix. The
15+
action route surfaces `innerMessage` to the client and keeps the full wrapper in
16+
the server log.

packages/runtime/src/http-dispatcher.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3186,8 +3186,15 @@ export class HttpDispatcher {
31863186
}
31873187
return { handled: true, response: this.success({ success: true, data: result }) };
31883188
} catch (err: any) {
3189-
const msg = err?.message ?? String(err);
3190-
return { handled: true, response: this.success({ success: false, error: msg }) };
3189+
const full = err?.message ?? String(err);
3190+
// The sandbox wraps a user throw as `<kind> '<name>' threw: <msg>` for
3191+
// server logs; surface only the business `<msg>` (SandboxError.innerMessage)
3192+
// to the client so an action's error toast reads as plain text instead of
3193+
// leaking the debug prefix. Keep the full wrapper in the log for debugging.
3194+
const inner: unknown = err?.innerMessage;
3195+
const clientMsg = (typeof inner === 'string' && inner) ? inner : full;
3196+
if (clientMsg !== full) console.error(`[action ${objectName}/${actionName}] ${full}`);
3197+
return { handled: true, response: this.success({ success: false, error: clientMsg }) };
31913198
}
31923199
}
31933200

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,22 @@ describe('QuickJSScriptRunner — L2 hook script', () => {
146146
),
147147
).rejects.toThrow(/hook 'oops'/);
148148
});
149+
150+
it('exposes the clean business message via SandboxError.innerMessage', async () => {
151+
// `.message` keeps the `<kind> '<name>' threw: …` debug wrapper for logs;
152+
// `.innerMessage` is the plain business message (no wrapper, no `Error: `
153+
// name prefix) that the HTTP layer surfaces to end users.
154+
const err = await runner
155+
.runScript(
156+
{ language: 'js', source: "throw new Error('线索信息不完整');", capabilities: [] },
157+
ctx(),
158+
{ origin: { kind: 'action', name: 'lead_apply_convert' } },
159+
)
160+
.then(() => null, (e) => e as SandboxError);
161+
expect(err).toBeInstanceOf(SandboxError);
162+
expect(err!.message).toContain("action 'lead_apply_convert' threw:");
163+
expect(err!.innerMessage).toBe('线索信息不完整');
164+
});
149165
});
150166

151167
describe('QuickJSScriptRunner — L2 action script', () => {

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

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,10 @@ export class QuickJSScriptRunner implements ScriptRunner {
149149
if (result.error) {
150150
const err = vm.dump(result.error);
151151
result.error.dispose();
152-
throw new SandboxError(`${args.origin.kind} '${args.origin.name}' threw: ${formatErr(err)}`);
152+
throw new SandboxError(
153+
`${args.origin.kind} '${args.origin.name}' threw: ${formatErr(err)}`,
154+
userFacingMessage(formatErr(err)),
155+
);
153156
}
154157
result.value.dispose();
155158
const resH = vm.getProp(vm.global, '__result');
@@ -182,7 +185,10 @@ export class QuickJSScriptRunner implements ScriptRunner {
182185
if (evalRes.error) {
183186
const err = vm.dump(evalRes.error);
184187
evalRes.error.dispose();
185-
throw new SandboxError(`${args.origin.kind} '${args.origin.name}' threw: ${formatErr(err)}`);
188+
throw new SandboxError(
189+
`${args.origin.kind} '${args.origin.name}' threw: ${formatErr(err)}`,
190+
userFacingMessage(formatErr(err)),
191+
);
186192
}
187193
evalRes.value.dispose();
188194

@@ -206,14 +212,20 @@ export class QuickJSScriptRunner implements ScriptRunner {
206212
if (pending.error) {
207213
const err = vm.dump(pending.error);
208214
pending.error.dispose();
209-
throw new SandboxError(`${args.origin.kind} '${args.origin.name}' threw: ${formatErr(err)}`);
215+
throw new SandboxError(
216+
`${args.origin.kind} '${args.origin.name}' threw: ${formatErr(err)}`,
217+
userFacingMessage(formatErr(err)),
218+
);
210219
}
211220

212221
const errH = vm.getProp(vm.global, '__error');
213222
const errStr = vm.dump(errH);
214223
errH.dispose();
215224
if (errStr) {
216-
throw new SandboxError(`${args.origin.kind} '${args.origin.name}' threw: ${errStr}`);
225+
throw new SandboxError(
226+
`${args.origin.kind} '${args.origin.name}' threw: ${errStr}`,
227+
userFacingMessage(String(errStr)),
228+
);
217229
}
218230

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

647659
export class SandboxError extends Error {
648-
constructor(message: string) {
660+
/**
661+
* For errors thrown by *user* script/hook/action code: the original business
662+
* message without the `<kind> '<name>' threw:` debug wrapper that lives in
663+
* `.message`. Safe to surface to end users (e.g. an action's error toast);
664+
* the wrapped `.message` stays for server logs. Undefined for the sandbox's
665+
* own internal errors (capability denials, timeouts, marshalling failures),
666+
* which have no user-meaningful inner message.
667+
*/
668+
readonly innerMessage?: string;
669+
constructor(message: string, innerMessage?: string) {
649670
super(message);
650671
this.name = 'SandboxError';
672+
this.innerMessage = innerMessage;
651673
}
652674
}
675+
676+
/**
677+
* Strip a leading default `Error: ` name prefix so a thrown business message
678+
* (`new Error('线索信息不完整…')`) reads as plain text for end users. Non-default
679+
* names (`TypeError:`, `RangeError:`) are kept — they signal a genuine bug
680+
* rather than a deliberately thrown business rule, which is useful context.
681+
*/
682+
function userFacingMessage(raw: string): string {
683+
return raw.startsWith('Error: ') ? raw.slice('Error: '.length) : raw;
684+
}

0 commit comments

Comments
 (0)