feat(kap-server): surface originating stack trace on error envelopes

- add optional `stack` field to errEnvelope and the envelope schema/interface; omitted when undefined so the wire shape stays byte-identical for callers without a stack
- thread `err.stack` through route error mappers plus the global and transport error handlers
- preserve `details` on `session.undo_unavailable` while adding its stack
- update tests to assert stacks are surfaced, reversing the prior no-leak contract
This commit is contained in:
haozhe.yang 2026-07-09 15:11:01 +08:00
parent bf976f470c
commit b51d77527e
16 changed files with 98 additions and 47 deletions

View file

@ -45,6 +45,7 @@ export function installErrorHandler(app: ErrorHandlerHost): void {
ErrorCode.INTERNAL_ERROR,
err.message !== undefined && err.message !== '' ? err.message : 'internal error',
requestId,
err.stack,
),
);
});

View file

@ -483,34 +483,34 @@ function sendMappedError(reply: Reply, requestId: string, err: unknown): void {
if (isKimiError(err)) {
switch (err.code) {
case ErrorCodes.FS_PATH_ESCAPES:
reply.send(errEnvelope(ErrorCode.FS_PATH_ESCAPES_SESSION, err.message, requestId));
reply.send(errEnvelope(ErrorCode.FS_PATH_ESCAPES_SESSION, err.message, requestId, err.stack));
return;
case ErrorCodes.FS_PATH_NOT_FOUND:
reply.send(errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, err.message, requestId));
reply.send(errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, err.message, requestId, err.stack));
return;
case ErrorCodes.FS_IS_DIRECTORY:
reply.send(errEnvelope(ErrorCode.FS_IS_DIRECTORY, err.message, requestId));
reply.send(errEnvelope(ErrorCode.FS_IS_DIRECTORY, err.message, requestId, err.stack));
return;
case ErrorCodes.FS_ALREADY_EXISTS:
reply.send(errEnvelope(ErrorCode.FS_ALREADY_EXISTS, err.message, requestId));
reply.send(errEnvelope(ErrorCode.FS_ALREADY_EXISTS, err.message, requestId, err.stack));
return;
case ErrorCodes.FS_IS_BINARY:
reply.send(errEnvelope(ErrorCode.FS_IS_BINARY, err.message, requestId));
reply.send(errEnvelope(ErrorCode.FS_IS_BINARY, err.message, requestId, err.stack));
return;
case ErrorCodes.FS_TOO_LARGE:
reply.send(errEnvelope(ErrorCode.FS_TOO_LARGE, err.message, requestId));
reply.send(errEnvelope(ErrorCode.FS_TOO_LARGE, err.message, requestId, err.stack));
return;
case ErrorCodes.FS_TOO_MANY_RESULTS:
reply.send(errEnvelope(ErrorCode.FS_TOO_MANY_RESULTS, err.message, requestId));
reply.send(errEnvelope(ErrorCode.FS_TOO_MANY_RESULTS, err.message, requestId, err.stack));
return;
case ErrorCodes.FS_GREP_TIMEOUT:
reply.send(errEnvelope(ErrorCode.FS_GREP_TIMEOUT, err.message, requestId));
reply.send(errEnvelope(ErrorCode.FS_GREP_TIMEOUT, err.message, requestId, err.stack));
return;
case ErrorCodes.FS_GIT_UNAVAILABLE:
reply.send(errEnvelope(ErrorCode.FS_GIT_UNAVAILABLE, err.message, requestId));
reply.send(errEnvelope(ErrorCode.FS_GIT_UNAVAILABLE, err.message, requestId, err.stack));
return;
case ErrorCodes.SESSION_NOT_FOUND:
reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId));
reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId, err.stack));
return;
}
}
@ -519,6 +519,7 @@ function sendMappedError(reply: Reply, requestId: string, err: unknown): void {
ErrorCode.INTERNAL_ERROR,
err instanceof Error ? err.message : String(err),
requestId,
err instanceof Error ? err.stack : undefined,
),
);
}

View file

@ -158,14 +158,14 @@ function sendMappedError(
if (isKimiError(err)) {
switch (err.code) {
case 'session.not_found':
reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId));
reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId, err.stack));
return;
case 'message.not_found':
reply.send(errEnvelope(ErrorCode.MESSAGE_NOT_FOUND, err.message, requestId));
reply.send(errEnvelope(ErrorCode.MESSAGE_NOT_FOUND, err.message, requestId, err.stack));
return;
}
}
reply.send(
errEnvelope(ErrorCode.INTERNAL_ERROR, err instanceof Error ? err.message : String(err), requestId),
errEnvelope(ErrorCode.INTERNAL_ERROR, err instanceof Error ? err.message : String(err), requestId, err instanceof Error ? err.stack : undefined),
);
}

View file

@ -290,11 +290,11 @@ function sendMappedError(
): boolean {
if (!isKimiError(err)) return false;
if (err.code === 'provider.not_found') {
reply.send(errEnvelope(ErrorCode.PROVIDER_NOT_FOUND, err.message, requestId));
reply.send(errEnvelope(ErrorCode.PROVIDER_NOT_FOUND, err.message, requestId, err.stack));
return true;
}
if (err.code === 'model.not_found') {
reply.send(errEnvelope(ErrorCode.MODEL_NOT_FOUND, err.message, requestId));
reply.send(errEnvelope(ErrorCode.MODEL_NOT_FOUND, err.message, requestId, err.stack));
return true;
}
return false;

View file

@ -221,13 +221,13 @@ function sendMappedError(
switch (err.code) {
case 'session.not_found':
case 'agent.not_found':
reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId));
reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId, err.stack));
return;
case 'prompt.not_found':
reply.send(errEnvelope(ErrorCode.PROMPT_NOT_FOUND, err.message, requestId));
reply.send(errEnvelope(ErrorCode.PROMPT_NOT_FOUND, err.message, requestId, err.stack));
return;
case 'session.busy':
reply.send(errEnvelope(ErrorCode.SESSION_BUSY, err.message, requestId));
reply.send(errEnvelope(ErrorCode.SESSION_BUSY, err.message, requestId, err.stack));
return;
case 'prompt.already_completed':
reply.send({
@ -235,11 +235,12 @@ function sendMappedError(
msg: err.message,
data: { aborted: false },
request_id: requestId,
stack: err.stack,
});
return;
case 'request.invalid':
case 'validation.failed':
reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, err.message, requestId));
reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, err.message, requestId, err.stack));
return;
case 'auth.provisioning_required':
reply.send({
@ -247,6 +248,7 @@ function sendMappedError(
msg: err.message,
data: null,
request_id: requestId,
stack: err.stack,
details: null,
});
return;
@ -267,6 +269,7 @@ function sendMappedError(
msg: err.message,
data: null,
request_id: requestId,
stack: err.stack,
details,
});
return;
@ -288,6 +291,7 @@ function sendMappedError(
msg: err.message,
data: null,
request_id: requestId,
stack: err.stack,
details,
});
return;
@ -298,6 +302,7 @@ function sendMappedError(
msg: err.message,
data: null,
request_id: requestId,
stack: err.stack,
details: authModelDetails(err),
});
return;
@ -308,6 +313,7 @@ function sendMappedError(
ErrorCode.INTERNAL_ERROR,
err instanceof Error ? err.message : String(err),
requestId,
err instanceof Error ? err.stack : undefined,
),
);
}

View file

@ -913,38 +913,44 @@ function sendMappedError(
switch (err.code) {
case 'session.not_found':
case 'agent.not_found':
reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId));
reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId, err.stack));
return;
case 'session.fork_active_turn':
reply.send(errEnvelope(ErrorCode.SESSION_BUSY, err.message, requestId));
reply.send(errEnvelope(ErrorCode.SESSION_BUSY, err.message, requestId, err.stack));
return;
case 'compaction.unable':
reply.send(errEnvelope(ErrorCode.COMPACTION_UNABLE, err.message, requestId));
reply.send(errEnvelope(ErrorCode.COMPACTION_UNABLE, err.message, requestId, err.stack));
return;
case 'session.undo_unavailable':
reply.send(errEnvelope(ErrorCode.SESSION_UNDO_UNAVAILABLE, err.message, requestId));
reply.send({
code: ErrorCode.SESSION_UNDO_UNAVAILABLE,
msg: err.message,
data: (err as { details?: unknown }).details ?? null,
request_id: requestId,
stack: err.stack,
});
return;
case ErrorCodes.GOAL_ALREADY_EXISTS:
reply.send(errEnvelope(ErrorCode.GOAL_ALREADY_EXISTS, err.message, requestId));
reply.send(errEnvelope(ErrorCode.GOAL_ALREADY_EXISTS, err.message, requestId, err.stack));
return;
case ErrorCodes.GOAL_NOT_FOUND:
reply.send(errEnvelope(ErrorCode.GOAL_NOT_FOUND, err.message, requestId));
reply.send(errEnvelope(ErrorCode.GOAL_NOT_FOUND, err.message, requestId, err.stack));
return;
case ErrorCodes.GOAL_STATUS_INVALID:
reply.send(errEnvelope(ErrorCode.GOAL_STATUS_INVALID, err.message, requestId));
reply.send(errEnvelope(ErrorCode.GOAL_STATUS_INVALID, err.message, requestId, err.stack));
return;
case ErrorCodes.GOAL_NOT_RESUMABLE:
reply.send(errEnvelope(ErrorCode.GOAL_NOT_RESUMABLE, err.message, requestId));
reply.send(errEnvelope(ErrorCode.GOAL_NOT_RESUMABLE, err.message, requestId, err.stack));
return;
case ErrorCodes.GOAL_OBJECTIVE_EMPTY:
reply.send(errEnvelope(ErrorCode.GOAL_OBJECTIVE_EMPTY, err.message, requestId));
reply.send(errEnvelope(ErrorCode.GOAL_OBJECTIVE_EMPTY, err.message, requestId, err.stack));
return;
case ErrorCodes.GOAL_OBJECTIVE_TOO_LONG:
reply.send(errEnvelope(ErrorCode.GOAL_OBJECTIVE_TOO_LONG, err.message, requestId));
reply.send(errEnvelope(ErrorCode.GOAL_OBJECTIVE_TOO_LONG, err.message, requestId, err.stack));
return;
case 'request.invalid':
case 'validation.failed':
reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, err.message, requestId));
reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, err.message, requestId, err.stack));
return;
}
}
@ -953,6 +959,7 @@ function sendMappedError(
ErrorCode.INTERNAL_ERROR,
err instanceof Error ? err.message : String(err),
requestId,
err instanceof Error ? err.stack : undefined,
),
);
}

View file

@ -375,10 +375,10 @@ function sendMappedError(
switch (err.code) {
case ErrorCodes.SKILL_NOT_FOUND:
case ErrorCodes.SKILL_NAME_EMPTY:
reply.send(errEnvelope(ErrorCode.SKILL_NOT_FOUND, err.message, requestId));
reply.send(errEnvelope(ErrorCode.SKILL_NOT_FOUND, err.message, requestId, err.stack));
return;
case ErrorCodes.SKILL_TYPE_UNSUPPORTED:
reply.send(errEnvelope(ErrorCode.SKILL_NOT_ACTIVATABLE, err.message, requestId));
reply.send(errEnvelope(ErrorCode.SKILL_NOT_ACTIVATABLE, err.message, requestId, err.stack));
return;
}
}

View file

@ -107,14 +107,14 @@ export function registerSnapshotRoutes(app: SnapshotRouteHost, deps: SnapshotRou
reply.send(okEnvelope(data, req.id));
} catch (err) {
if (err instanceof SnapshotNotFoundError) {
reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, req.id));
reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, req.id, err.stack));
return;
}
if (err instanceof SnapshotTimeoutError) {
core.accessor
.get(ILogService)
.warn('snapshot.timeout', { sid: session_id, duration_ms: err.timeoutMs });
reply.send(errEnvelope(ErrorCode.INTERNAL_ERROR, err.message, req.id));
reply.send(errEnvelope(ErrorCode.INTERNAL_ERROR, err.message, req.id, err.stack));
return;
}
throw err;

View file

@ -232,10 +232,10 @@ function sendMappedError(
if (isKimiError(err)) {
switch (err.code) {
case ErrorCodes.SESSION_NOT_FOUND:
reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId));
reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId, err.stack));
return;
case ErrorCodes.TERMINAL_NOT_FOUND:
reply.send(errEnvelope(ErrorCode.TERMINAL_NOT_FOUND, err.message, requestId));
reply.send(errEnvelope(ErrorCode.TERMINAL_NOT_FOUND, err.message, requestId, err.stack));
return;
}
}
@ -244,7 +244,7 @@ function sendMappedError(
// escapes. TODO: push a coded error into `assertAllowed` so this branch can
// be folded into the `isKimiError` switch above.
if (err instanceof Error && err.message.startsWith('Path outside workspace')) {
reply.send(errEnvelope(ErrorCode.FS_PATH_ESCAPES_SESSION, err.message, requestId));
reply.send(errEnvelope(ErrorCode.FS_PATH_ESCAPES_SESSION, err.message, requestId, err.stack));
return;
}
throw err;

View file

@ -321,7 +321,7 @@ function sendMappedError(
err: unknown,
): void {
if (err instanceof KimiError && err.code === ErrorCodes.MCP_SERVER_NOT_FOUND) {
reply.send(errEnvelope(ErrorCode.MCP_SERVER_NOT_FOUND, err.message, requestId));
reply.send(errEnvelope(ErrorCode.MCP_SERVER_NOT_FOUND, err.message, requestId, err.stack));
return;
}
throw err;

View file

@ -113,15 +113,15 @@ function sendMappedError(
err: unknown,
): void {
if (err instanceof HostFolderNotAbsoluteError) {
reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, err.message, requestId));
reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, err.message, requestId, err.stack));
return;
}
if (err instanceof HostFolderNotFoundError) {
reply.send(errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, err.message, requestId));
reply.send(errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, err.message, requestId, err.stack));
return;
}
if (err instanceof HostFolderPermissionError) {
reply.send(errEnvelope(ErrorCode.FS_PERMISSION_DENIED, err.message, requestId));
reply.send(errEnvelope(ErrorCode.FS_PERMISSION_DENIED, err.message, requestId, err.stack));
return;
}
throw err;

View file

@ -50,15 +50,16 @@ const KIMI_TO_PROTOCOL: Record<string, ErrorCode> = {
export function mapError(err: unknown, requestId: string): ReturnType<typeof errEnvelope> {
if (err instanceof KimiError) {
const code = KIMI_TO_PROTOCOL[err.code] ?? ErrorCode.INTERNAL_ERROR;
return errEnvelope(code, err.message, requestId);
return errEnvelope(code, err.message, requestId, err.stack);
}
if (err instanceof TimeoutError) {
return errEnvelope(ErrorCode.INTERNAL_ERROR, err.message, requestId);
return errEnvelope(ErrorCode.INTERNAL_ERROR, err.message, requestId, err.stack);
}
return errEnvelope(
ErrorCode.INTERNAL_ERROR,
err instanceof Error ? err.message : String(err),
requestId,
err instanceof Error ? err.stack : undefined,
);
}

View file

@ -499,9 +499,13 @@ describe('server-v2 /api/v2 RPC', () => {
expect(code).not.toBe(0);
});
it('does not leak stack traces on error', async () => {
it('surfaces the originating stack trace on error', async () => {
const { body } = await call<null>('POST', '/api/v2/session/nope/session:read');
expect(JSON.stringify(body)).not.toContain('stack');
// Contract: error envelopes carry the thrown error's stack so operators can
// locate the source (the 40401 below originates in `dispatch`).
const json = JSON.stringify(body);
expect(json).toContain('"stack"');
expect(json).toContain('dispatch');
});
});

View file

@ -16,6 +16,7 @@ interface Envelope<T> {
data: T;
request_id: string;
details?: { path: string; message: string }[];
stack?: string;
}
interface SessionWire {
@ -339,6 +340,8 @@ describe('server-v2 /api/v1/sessions', () => {
// (40911), not the pre-fix "session does not exist" (40401).
expect(res.body.code).toBe(40911);
expect(res.body.msg).toMatch(/nothing to undo/i);
// The thrown KimiError's stack is surfaced so operators can locate the source.
expect(res.body.stack).toEqual(expect.stringContaining('sessionLegacyService'));
});
it('rejects an unsupported action suffix (40001)', async () => {

View file

@ -57,6 +57,20 @@ describe('envelope', () => {
'{"code":40001,"msg":"validation failed","data":null,"request_id":"req_z"}',
);
});
it('errEnvelope surfaces stack when provided and omits it when absent', () => {
const err = new Error('boom');
const withStack = errEnvelope(ErrorCode.INTERNAL_ERROR, 'boom', 'req_s', err.stack);
expect(withStack.stack).toBe(err.stack);
expect(JSON.stringify(withStack)).toContain('"stack":');
expect(envelopeSchema(z.any()).parse(withStack).stack).toBe(err.stack);
// No stack → field is absent and the wire shape is byte-identical to before.
const without = errEnvelope(ErrorCode.INTERNAL_ERROR, 'boom', 'req_s');
expect(JSON.stringify(without)).toBe(
'{"code":50001,"msg":"boom","data":null,"request_id":"req_s"}',
);
});
});
describe('error-codes', () => {

View file

@ -7,6 +7,7 @@ export const envelopeSchema = <T extends z.ZodTypeAny>(data: T) =>
data: data.nullable(),
request_id: z.string(),
details: z.unknown().optional(),
stack: z.string().optional(),
});
export interface Envelope<T> {
@ -15,12 +16,25 @@ export interface Envelope<T> {
data: T | null;
request_id: string;
details?: unknown;
stack?: string;
}
export function okEnvelope<T>(data: T, requestId: string): Envelope<T> {
return { code: 0, msg: 'success', data, request_id: requestId };
}
export function errEnvelope(code: number, msg: string, requestId: string): Envelope<null> {
return { code, msg, data: null, request_id: requestId };
/**
* Build an error envelope. When `stack` is provided it is surfaced verbatim on
* the wire so operators can see where a thrown error originated; when omitted
* (or `undefined`) the field is absent and the wire shape stays byte-identical
* to the original `{ code, msg, data: null, request_id }` `JSON.stringify`
* drops `undefined` properties, so callers that have no stack are unaffected.
*/
export function errEnvelope(
code: number,
msg: string,
requestId: string,
stack?: string,
): Envelope<null> {
return { code, msg, data: null, request_id: requestId, stack };
}