From 05a7a4001f0ad4f38b9f4fad1c2feeefe2caf1b0 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 03:08:34 +0300 Subject: [PATCH] fix(share-ai): cap per-request output + unify provider errors (#60, #95) #60: streamText had no maxOutputTokens, so one anonymous request could run up the provider bill. Add maxOutputTokens (env SHARE_AI_MAX_OUTPUT_TOKENS, default 512) via resolveShareAiMaxOutputTokens(). #95: the anonymous path hand-built error strings, diverging from the unified describeProviderError format used on the authenticated path; both onError blocks now call describeProviderError so a share reader sees 402/429/503 causes in the same form (and the stack is still logged). Both changes are in this one file and share hunks, hence one commit. Co-Authored-By: Claude Opus 4.8 --- .../core/ai-chat/public-share-chat.service.ts | 43 +++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/apps/server/src/core/ai-chat/public-share-chat.service.ts b/apps/server/src/core/ai-chat/public-share-chat.service.ts index d385c4f0..310fd0cf 100644 --- a/apps/server/src/core/ai-chat/public-share-chat.service.ts +++ b/apps/server/src/core/ai-chat/public-share-chat.service.ts @@ -19,6 +19,7 @@ import { PublicShareWorkspaceLimiter, createPublicShareWorkspaceLimiter, } from './public-share-workspace-limiter'; +import { describeProviderError } from '../../integrations/ai/ai-error.util'; /** * Loose shape of the anonymous public-share chat POST body. We do NOT bind a @@ -63,6 +64,24 @@ export interface PublicShareChatStreamArgs { export const MAX_SHARE_MESSAGES = 30; export const MAX_SHARE_MESSAGE_CHARS = 8000; +/** + * Default per-request output cap for the anonymous share assistant. Bounds the + * tokens a single anonymous request can generate; worst case = steps x this. + */ +export const SHARE_AI_MAX_OUTPUT_TOKENS = 512; + +/** + * Read the per-request output cap from the environment (overridable seam), + * falling back to the sane default. A non-positive / unparseable value uses the + * default. Mirrors resolveShareAiWorkspaceMax(). + */ +export function resolveShareAiMaxOutputTokens(): number { + const raw = Number(process.env.SHARE_AI_MAX_OUTPUT_TOKENS); + return Number.isFinite(raw) && raw > 0 + ? Math.floor(raw) + : SHARE_AI_MAX_OUTPUT_TOKENS; +} + /** * Keep ONLY genuine conversation turns from the client-held transcript. The * payload is fully attacker-controlled; a forged `system` turn could try to @@ -204,16 +223,15 @@ export class PublicShareChatService { tools, // Bound the agent loop for anonymous callers. stopWhen: stepCountIs(5), + // Bounds per-request output so one anonymous request can't run up the + // provider bill; worst case = steps x this. + maxOutputTokens: resolveShareAiMaxOutputTokens(), abortSignal: signal, onError: ({ error }) => { - const e = error as { - statusCode?: number; - message?: string; - stack?: string; - }; - const errorText = e?.statusCode - ? `${e.statusCode}: ${e.message ?? String(error)}` - : (e?.message ?? String(error)); + // Reuse the shared formatter so provider error formatting stays + // unified (statusCode + body) with the authenticated path. + const e = error as { stack?: string }; + const errorText = describeProviderError(error, String(error)); // Never persist anonymous transcripts; just log the failure. this.logger.error( `Public share chat stream error: ${errorText}`, @@ -228,10 +246,11 @@ export class PublicShareChatService { result.pipeUIMessageStreamToResponse(res.raw, { headers: { 'X-Accel-Buffering': 'no' }, onError: (error: unknown) => { - const e = error as { statusCode?: number; message?: string }; - return e?.statusCode - ? `${e.statusCode}: ${e.message}` - : (e?.message ?? 'AI stream error'); + // Reuse the shared formatter so provider error formatting stays + // unified between the log line and the streamed error message — a + // share reader sees 402/429/503 causes consistently with the + // authenticated path. + return describeProviderError(error, 'AI stream error'); }, });