From 53f164056af468bd847e8cd465f743f80dd7e247 Mon Sep 17 00:00:00 2001 From: NarwhalChen Date: Thu, 30 Jul 2026 21:46:28 +0800 Subject: [PATCH 1/2] fix(app): validate complete local AI requests --- .../ipc/local-ai-context.test.ts | 42 +++++++++++++++++ .../electro-bridge/ipc/local-ai-context.ts | 47 +++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts b/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts index e4ef7998..a1426c2b 100644 --- a/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts +++ b/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts @@ -252,6 +252,48 @@ describe("local AI IPC", () => { expect(runtime.startChat).not.toHaveBeenCalled(); }); + it("rejects malformed metadata, generation options, and oversized prompts", () => { + const sender = new FakeWebContents(1); + const runtime = createRuntime(); + const { handlers, ipc } = createMainIPC(); + setupLocalAIIPC( + { + runtime, + getAllowedWebContents: () => sender as never, + }, + ipc as never, + ); + const start = handlers.get(LOCAL_AI_CHANNELS.START_CHAT); + const baseRequest = { + requestId: "request-1", + providerId: "codex-cli", + messages: [{ role: "user", content: "hello" }], + }; + const invalidRequests = [ + { ...baseRequest, modelId: { id: "not-a-string" } }, + { ...baseRequest, agent: { systemPrompt: 42 } }, + { ...baseRequest, options: { temperature: Number.NaN } }, + { ...baseRequest, options: { maxOutputTokens: 0 } }, + { + ...baseRequest, + agent: { systemPrompt: "x" }, + messages: Array.from({ length: 5 }, () => ({ + role: "user", + content: "x".repeat(200_000), + })), + }, + ]; + + for (const invalidRequest of invalidRequests) { + expect(start?.(createEvent(sender), invalidRequest)).toMatchObject({ + success: false, + accepted: false, + error: { code: "LOCAL_AI_INVALID_REQUEST" }, + }); + } + expect(runtime.startChat).not.toHaveBeenCalled(); + }); + it("accepts interaction responses only from the active request owner", async () => { const allowedSender = new FakeWebContents(1); const otherSender = new FakeWebContents(2); diff --git a/packages/app/src/electro-bridge/ipc/local-ai-context.ts b/packages/app/src/electro-bridge/ipc/local-ai-context.ts index 96e9cf4b..77735324 100644 --- a/packages/app/src/electro-bridge/ipc/local-ai-context.ts +++ b/packages/app/src/electro-bridge/ipc/local-ai-context.ts @@ -48,6 +48,9 @@ const ALLOWED_PROVIDER_IDS = new Set(["claude-code", "codex-cli"]); const MAX_MESSAGE_CHARS = 200_000; const MAX_REQUEST_CHARS = 1_000_000; const MAX_INTERACTION_RESPONSE_CHARS = 20_000; +const MAX_METADATA_CHARS = 512; +const MAX_CWD_CHARS = 4_096; +const MAX_OUTPUT_TOKENS = 1_000_000; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; @@ -57,6 +60,13 @@ function createError(message: string, code: string): LocalAISerializableError { return { name: "LocalAIIPCError", message, code }; } +function isOptionalString(value: unknown, maximumLength: number): boolean { + return ( + value === undefined || + (typeof value === "string" && value.length <= maximumLength) + ); +} + export function serializeLocalAIError( error: unknown, ): LocalAISerializableError { @@ -116,10 +126,47 @@ function validateRequest(request: unknown): request is LocalAIChatRequest { return false; } + if (!isOptionalString(request.modelId, MAX_METADATA_CHARS)) { + return false; + } + let totalChars = 0; + if (request.agent !== undefined) { + if (!isRecord(request.agent)) return false; + if (!isOptionalString(request.agent.id, MAX_METADATA_CHARS)) return false; + if (!isOptionalString(request.agent.systemPrompt, MAX_MESSAGE_CHARS)) { + return false; + } + if (typeof request.agent.systemPrompt === "string") { + totalChars += request.agent.systemPrompt.length; + } + } + + if (request.options !== undefined) { + if (!isRecord(request.options)) return false; + if (!isOptionalString(request.options.cwd, MAX_CWD_CHARS)) return false; + if ( + request.options.temperature !== undefined && + (typeof request.options.temperature !== "number" || + !Number.isFinite(request.options.temperature)) + ) { + return false; + } + if ( + request.options.maxOutputTokens !== undefined && + (typeof request.options.maxOutputTokens !== "number" || + !Number.isInteger(request.options.maxOutputTokens) || + request.options.maxOutputTokens <= 0 || + request.options.maxOutputTokens > MAX_OUTPUT_TOKENS) + ) { + return false; + } + } + return request.messages.every((message) => { if ( isRecord(message) && + isOptionalString(message.id, MAX_METADATA_CHARS) && (message.role === "system" || message.role === "user" || message.role === "assistant") && From 27758a24feab783309d1380259cf1b4e758cdef9 Mon Sep 17 00:00:00 2001 From: NarwhalChen Date: Thu, 30 Jul 2026 21:46:33 +0800 Subject: [PATCH 2/2] fix(app): stop aborted local AI work --- .../src/electron/ai/__tests__/runtime.test.ts | 101 ++++++++++++++++++ packages/app/src/electron/ai/runtime.ts | 11 +- .../src/renderer/components/home/index.tsx | 9 +- 3 files changed, 118 insertions(+), 3 deletions(-) diff --git a/packages/app/src/electron/ai/__tests__/runtime.test.ts b/packages/app/src/electron/ai/__tests__/runtime.test.ts index d3ac491a..2e2f58dd 100644 --- a/packages/app/src/electron/ai/__tests__/runtime.test.ts +++ b/packages/app/src/electron/ai/__tests__/runtime.test.ts @@ -249,6 +249,107 @@ describe("LocalAiRuntime", () => { expect(adapter.dispose).toHaveBeenCalledOnce(); }); + it("does not create a provider model after aborting during status discovery", async () => { + const events: LocalAIStreamEvent[] = []; + const adapter = fakeAdapter("codex-cli"); + let finishStatusDiscovery: (() => void) | undefined; + vi.mocked(adapter.getStatus).mockImplementation( + () => + new Promise((resolve) => { + finishStatusDiscovery = () => + resolve({ + ...LOCAL_AI_PROVIDER_DESCRIPTORS["codex-cli"], + available: true, + authenticated: true, + executablePath: "/test/codex", + checkedAt: new Date(0).toISOString(), + }); + }), + ); + const streamInvoker = vi.fn(); + const runtime = new LocalAiRuntime({ + adapters: [adapter], + streamInvoker, + }); + + const chat = runtime.startChat( + request({ providerId: "codex-cli" }), + (event) => events.push(event), + ); + await vi.waitFor(() => { + expect(adapter.getStatus).toHaveBeenCalledOnce(); + }); + expect(runtime.abort("request-1")).toBe(true); + finishStatusDiscovery?.(); + await chat; + + expect(adapter.createModel).not.toHaveBeenCalled(); + expect(streamInvoker).not.toHaveBeenCalled(); + expect(events.at(-1)).toEqual({ + type: "finish", + requestId: "request-1", + finishReason: "aborted", + }); + }); + + it("rejects a tool interaction that starts after its request was aborted", async () => { + const events: LocalAIStreamEvent[] = []; + let toolContext: + | Parameters[2] + | undefined; + let continueStream: (() => void) | undefined; + const adapter = fakeAdapter("claude-code"); + vi.mocked(adapter.createModel).mockImplementation( + async (_request, _status, context) => { + toolContext = context; + return {} as LanguageModel; + }, + ); + const executeTool = vi.fn(async () => ({ written: true })); + const runtime = new LocalAiRuntime({ + adapters: [adapter], + getToolGroups: () => [ + { + serverName: "external", + tools: [ + { + name: "write_value", + inputSchema: { type: "object", properties: {} }, + }, + ], + }, + ], + executeTool, + streamInvoker: () => ({ + toUIMessageStream: async function* () { + yield { type: "start" as const, messageId: "assistant-1" }; + await new Promise((resolve) => { + continueStream = resolve; + }); + await toolContext?.tools[0]?.execute({}); + }, + }), + }); + + const chat = runtime.startChat(request(), (event) => events.push(event)); + await vi.waitFor(() => { + expect(continueStream).toBeTypeOf("function"); + }); + expect(runtime.abort("request-1")).toBe(true); + continueStream?.(); + await chat; + + expect(executeTool).not.toHaveBeenCalled(); + expect(events).not.toContainEqual( + expect.objectContaining({ type: "interaction" }), + ); + expect(events.at(-1)).toEqual({ + type: "finish", + requestId: "request-1", + finishReason: "aborted", + }); + }); + it("pauses an approval-gated tool until the renderer responds", async () => { const events: LocalAIStreamEvent[] = []; let toolContext: diff --git a/packages/app/src/electron/ai/runtime.ts b/packages/app/src/electron/ai/runtime.ts index 5ddf1296..918c4508 100644 --- a/packages/app/src/electron/ai/runtime.ts +++ b/packages/app/src/electron/ai/runtime.ts @@ -324,6 +324,7 @@ export class LocalAiRuntime implements LocalAIRuntimeService { try { const probeStatus = await adapter.getStatus(); + controller.signal.throwIfAborted(); if (!probeStatus.available || !probeStatus.authenticated) { this.emitFailure( request.requestId, @@ -355,8 +356,10 @@ export class LocalAiRuntime implements LocalAIRuntimeService { controller.signal, emit, ); + const toolGroups = await this.getToolGroups(); + controller.signal.throwIfAborted(); const tools = createAgentToolCatalog({ - groups: await this.getToolGroups(), + groups: toolGroups, executeTool: this.executeTool, requestInteraction, }); @@ -364,6 +367,7 @@ export class LocalAiRuntime implements LocalAIRuntimeService { tools, requestInteraction, }); + controller.signal.throwIfAborted(); const result = this.streamInvoker({ model, messages: toMessages(request), @@ -497,6 +501,11 @@ export class LocalAiRuntime implements LocalAIRuntimeService { const interactionId = randomUUID(); return new Promise((resolve, reject) => { + if (abortSignal.aborted) { + reject(new Error(`Interaction cancelled for ${interaction.name}.`)); + return; + } + const onAbort = () => { const pending = this.pendingInteractions.get(interactionId); if (!pending) return; diff --git a/packages/app/src/renderer/components/home/index.tsx b/packages/app/src/renderer/components/home/index.tsx index fdc97d92..398008d2 100644 --- a/packages/app/src/renderer/components/home/index.tsx +++ b/packages/app/src/renderer/components/home/index.tsx @@ -303,9 +303,14 @@ export function HomePage() {