From ae8680654c06462100b53df6468bc651badefe0a Mon Sep 17 00:00:00 2001 From: Texas-walker15 Date: Thu, 3 Sep 2026 20:24:20 +0200 Subject: [PATCH 1/4] fix: widen Windows settings rename retry window --- src/main/settings.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/settings.ts b/src/main/settings.ts index efe03f6..3824a61 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -46,8 +46,8 @@ const MAX_SETTINGS_JSON_DEPTH = 64; * operation is unsafe to retry: each attempt is still one whole-file * rename, so retrying never risks a partial write, only a delayed one. */ -const RENAME_MAX_ATTEMPTS = 5; -const RENAME_RETRY_DELAY_MS = 15; +const RENAME_MAX_ATTEMPTS = 10; +const RENAME_RETRY_DELAY_MS = 20; function isTransientRenameError(error: unknown): boolean { if (typeof error !== 'object' || error === null || !('code' in error)) return false; From 63ae414cce275e2a22db21051ab783f4225266bc Mon Sep 17 00:00:00 2001 From: Texas-walker15 Date: Thu, 3 Sep 2026 21:11:14 +0200 Subject: [PATCH 2/4] feat: add secure OpenAI-compatible provider integration --- README.md | 99 +++-- docs/architecture.md | 61 +-- docs/phase-2-provider-architecture.md | 9 + docs/phase-2-real-provider-architecture.md | 373 ++++++++++++++++++ docs/security-model.md | 131 ++++-- src/main/chat-provider-registry.ts | 112 ++++++ src/main/ipc.ts | 104 +++++ src/main/openai-compatible-provider.ts | 249 ++++++++++++ src/preload/index.ts | 31 ++ src/renderer/chat/ipc-chat-provider.ts | 92 +++++ src/renderer/chat/useActiveChatProvider.ts | 45 ++- src/renderer/local-agent-bridge.d.ts | 9 + src/shared/chat/index.ts | 1 + src/shared/chat/registry.ts | 148 +++++-- src/shared/constants.ts | 15 +- src/shared/schemas/chat.schema.ts | 8 +- src/shared/schemas/index.ts | 10 + src/shared/schemas/ipc.schema.ts | 66 ++++ src/shared/schemas/permissions.schema.ts | 8 + tests/e2e/electron-smoke.test.ts | 41 +- .../unit/main/chat-provider-registry.test.ts | 202 ++++++++++ tests/unit/main/ipc.test.ts | 277 ++++++++++++- .../main/openai-compatible-provider.test.ts | 300 ++++++++++++++ tests/unit/renderer/ipc-chat-provider.test.ts | 144 +++++++ tests/unit/shared/chat-boundary-scan.test.ts | 53 ++- tests/unit/shared/chat-registry.test.ts | 60 ++- tests/unit/shared/ipc.schema.test.ts | 137 ++++++- 27 files changed, 2564 insertions(+), 221 deletions(-) create mode 100644 docs/phase-2-real-provider-architecture.md create mode 100644 src/main/chat-provider-registry.ts create mode 100644 src/main/openai-compatible-provider.ts create mode 100644 src/renderer/chat/ipc-chat-provider.ts create mode 100644 tests/unit/main/chat-provider-registry.test.ts create mode 100644 tests/unit/main/openai-compatible-provider.test.ts create mode 100644 tests/unit/renderer/ipc-chat-provider.test.ts diff --git a/README.md b/README.md index 832579b..8ed6b4c 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ A local-first, permission-controlled desktop assistant for Windows. The assistant is named **JARVIS** by default; the product is **Local Agent**. -> **Status: Phase 1 complete. Phase 2, Milestone 2 (provider-adapter -> foundation) in progress.** +> **Status: Phase 1 complete. Phase 2, Milestone 3 (real provider +> integration) in progress.** > Phase 1 delivered the hardened desktop shell, non-secret settings storage, > an audit-log foundation, the permission-policy runtime, persisted > emergency-stop state, first-run onboarding, an encrypted secret store, and @@ -12,24 +12,28 @@ assistant is named **JARVIS** by default; the product is **Local Agent**. > chat message and conversation model, a provider-independent > `ChatProvider` interface, a deterministic mock provider, and a chat surface > in the renderer with empty, loading, error and retry states. Milestone 2 -> adds the **provider-adapter foundation** on top of that: bounded provider +> added the **provider-adapter foundation** on top of that: bounded provider > request/response schemas, a normalized five-category error vocabulary, an -> approved-provider registry that **fails closed for every one of the four -> approved identifiers** (`none`, `glm`, `openai-compatible`, `ollama` — -> none of them makes a network request), a composable timeout decorator, and -> safe provider-status display. **No real model provider is called. No -> network request is made anywhere in this milestone** — the whole provider -> layer lives inside `src/shared`, where the same lint boundary that blocks -> Electron and Node access also blocks `fetch`, `XMLHttpRequest`, -> `WebSocket` and `EventSource` as globals. Chat still requires **no new IPC -> channel**: it never calls `window.localAgent`, so nothing a chat message or -> a provider response contains can reach a privileged API, a file, or the -> permission engine. See -> [docs/phase-2-chat-architecture.md](docs/phase-2-chat-architecture.md) and +> approved-provider registry, a composable timeout decorator, and safe +> provider-status display, with **no real model provider called and no +> network request made anywhere in that milestone**. Milestone 3 adds the +> **first real provider**: an OpenAI-compatible chat-completions adapter, +> living entirely in the privileged main process, reached from the renderer +> only through two new, narrow, schema-validated IPC channels (`chat.send`, +> `chat.cancel`). Sending a message is now a genuine permission-controlled +> action — `chat.send` passes through the same unmodified permission engine, +> emergency-stop gate and audit writer every other action type does — and the +> API key it uses is read from the existing encrypted secret store inside the +> main process and never crosses into the renderer. `glm` and `ollama` remain +> unimplemented (still fail closed); `anthropic`, `openai`, `claude` and +> `gemini` remain absent. See +> [docs/phase-2-chat-architecture.md](docs/phase-2-chat-architecture.md), > [docs/phase-2-provider-architecture.md](docs/phase-2-provider-architecture.md) -> for the full design and for what remains explicitly deferred (real -> providers, tool/action execution from model output, memory, and everything -> else Phase 2 has not reached yet). +> and +> [docs/phase-2-real-provider-architecture.md](docs/phase-2-real-provider-architecture.md) +> for the full design and for what remains explicitly deferred (streaming, +> the remaining providers, tool/action execution from model output, memory, +> and everything else Phase 2 has not reached yet). All rights reserved. No licence has been granted for this project. @@ -70,9 +74,10 @@ postponed. - The Electron main process is the only privileged boundary. - The preload bridge exposes only narrow, explicitly named, typed functions — `localAgent.health`, `localAgent.settings.{get,update}`, - `localAgent.secrets.{status,write,clear}` — never `ipcRenderer` itself and - never a generic invoke-any-channel function. None of them can return a - plaintext API key. + `localAgent.secrets.{status,write,clear}`, + `localAgent.chat.{send,cancel}` — never `ipcRenderer` itself and never a + generic invoke-any-channel function. None of them can return a plaintext + API key. - A strict Content-Security-Policy blocks remote script and network access outright; navigation, `window.open` and `` are all denied. - An action with no matching policy rule is **denied** — enforced both by the @@ -97,21 +102,32 @@ postponed. - `hasApiKey`, the only secret-related value the interface ever sees, is reconciled against the encrypted store on every read and write — the store is the source of truth, never the cached flag. -- Chat and its provider layer (Phase 2, Milestones 1–2) make no network - request and add no IPC channel. Every provider file lives under - `src/shared/chat`, where the same lint boundary that keeps `src/shared` - free of Electron and Node access also blocks `fetch`, `XMLHttpRequest`, - `WebSocket` and `EventSource` as globals. The provider registry fails - closed for every one of the four approved identifiers — none of them, not - even `none`, ever resolves a `send()` call. Assistant text is rendered as - plain JSX text, never as HTML, and is never treated as authorization for - anything — there is no action for it to authorize, since chat never calls - `window.localAgent`. +- Chat's provider-independent layer (Phase 2, Milestones 1–2) still makes no + network request itself: every file under `src/shared/chat` is bound by the + same lint boundary that keeps `src/shared` free of Electron and Node + access and also blocks `fetch`, `XMLHttpRequest`, `WebSocket` and + `EventSource` as globals, and its own registry still fails closed for + every one of the four approved identifiers, unconditionally. Milestone 3 + adds the one real provider on top of that, entirely in the main process: + `src/main/openai-compatible-provider.ts` makes the network request, + `src/main/chat-provider-registry.ts` resolves which credential to use from + the existing encrypted secret store, and `chat.send` reaches both only + through the unmodified permission engine, emergency-stop gate and audit + writer — the same pipeline `secrets.write` already uses. `window.localAgent` + is called from exactly one renderer file, + `src/renderer/chat/ipc-chat-provider.ts`, asserted by an automated source + scan; the API key itself never crosses into the renderer. Assistant text + is still rendered as plain JSX text, never as HTML, and is still never + treated as authorization for anything — there is no path from a chat + message, typed or received, to `main/executor.ts` or to an action + proposal. Full detail, including known limitations, is in [docs/security-model.md](docs/security-model.md); the chat-specific design is -in [docs/phase-2-chat-architecture.md](docs/phase-2-chat-architecture.md) and -[docs/phase-2-provider-architecture.md](docs/phase-2-provider-architecture.md). +in [docs/phase-2-chat-architecture.md](docs/phase-2-chat-architecture.md), +[docs/phase-2-provider-architecture.md](docs/phase-2-provider-architecture.md) +and +[docs/phase-2-real-provider-architecture.md](docs/phase-2-real-provider-architecture.md). ## Where your data lives @@ -194,7 +210,11 @@ src/main/ Privileged Electron main process. Owns the BrowserWindow, store (secrets.ts), settings/secret reconciliation (settings-service.ts), the per-request policy/emergency-state loader (action-runtime.ts), the native confirmation dialog - (confirm.ts), and the five registered IPC channels (ipc.ts). + (confirm.ts), the real OpenAI-compatible network adapter + (openai-compatible-provider.ts, Phase 2 Milestone 3), the + main-process provider registry that resolves it from settings + and the encrypted secret store (chat-provider-registry.ts), + and the seven registered IPC channels (ipc.ts). src/preload/ The single contextBridge. Exposes a narrow, explicitly enumerated, typed API — never ipcRenderer, never a generic invoke-any-channel function. Bundled into one file: a @@ -202,11 +222,14 @@ src/preload/ The single contextBridge. Exposes a narrow, explicitly src/renderer/ React interface: App.tsx gates on onboardingCompleted, Onboarding.tsx is the first-run form, chat/ is the Phase 2 chat surface (Chat.tsx, useConversation.ts, the - framework-independent conversation-controller.ts, and + framework-independent conversation-controller.ts, useActiveChatProvider.ts — the one place that resolves which - provider is active). No Node, no Electron, no filesystem - access — only the bridge at window.localAgent, which chat - never calls. + provider is active — and, since Milestone 3, + ipc-chat-provider.ts, the one file in this directory + permitted to call window.localAgent, reaching the real + provider through chat.send/chat.cancel). No Node, no + Electron, no direct filesystem or network access anywhere + else in this directory — only the bridge at window.localAgent. tests/unit/ Unit tests. `npm test`. tests/e2e/ Playwright + Electron smoke test against the built app. `npm run test:e2e`. diff --git a/docs/architecture.md b/docs/architecture.md index 6f1f8f4..ee79d27 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,10 +58,13 @@ is the reason Electron was chosen; see The renderer cannot perform a privileged action. Not "should not" — it has no mechanism to. With `sandbox: true`, `contextIsolation: true` and `nodeIntegration: false`, a fully compromised renderer gains only the narrow -preload API: `health`, `settings.get`/`settings.update`, and -`secrets.status`/`secrets.write`/`secrets.clear` as of Milestone 7 — every one -of them policy-gated and audited before it can perform a privileged action, -and none of them able to return a plaintext key. +preload API: `health`, `settings.get`/`settings.update`, +`secrets.status`/`secrets.write`/`secrets.clear` as of Milestone 7, and +`chat.send`/`chat.cancel` as of Phase 2 Milestone 3 — every one of them +except `chat.cancel` policy-gated and audited before it can perform a +privileged action (`chat.cancel` has no privileged side effect of its own; +see `docs/phase-2-real-provider-architecture.md`), and none of them able to +return a plaintext key. ## Modules @@ -199,13 +202,13 @@ cannot be turned into an unsafe one in memory after loading. ## Desktop shell (implemented) -| File | Describes | -| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `src/main/index.ts` | Window creation, CSP, permission-request denial, navigation/window-open/webview hardening, lifecycle, IPC-runtime composition | -| `src/main/ipc.ts` | `ipcMain` handler registration for all five channels; validates request and response against the shared schema for each | -| `src/preload/index.ts` | The single `contextBridge` API: `localAgent.health()`, `localAgent.settings.{get,update}()`, `localAgent.secrets.{status,write,clear}()` | -| `src/renderer/` | React shell: `App.tsx` gates on `onboardingCompleted`, `Onboarding.tsx` is the first-run form | -| `src/shared/schemas/ipc.schema.ts` | Every `IPC_*_CHANNEL` constant and its request/response schema, shared by `main` and `preload` | +| File | Describes | +| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/main/index.ts` | Window creation, CSP, permission-request denial, navigation/window-open/webview hardening, lifecycle, IPC-runtime composition | +| `src/main/ipc.ts` | `ipcMain` handler registration for all seven channels; validates request and response against the shared schema for each | +| `src/preload/index.ts` | The single `contextBridge` API: `localAgent.health()`, `localAgent.settings.{get,update}()`, `localAgent.secrets.{status,write,clear}()`, `localAgent.chat.{send,cancel}()` | +| `src/renderer/` | React shell: `App.tsx` gates on `onboardingCompleted`, `Onboarding.tsx` is the first-run form | +| `src/shared/schemas/ipc.schema.ts` | Every `IPC_*_CHANNEL` constant and its request/response schema, shared by `main` and `preload` | Build layout, and why it is not uniform across the three layers: @@ -459,13 +462,17 @@ before anything reaches disk (see the audit log section above). If that write itself throws, the error propagates rather than returning a result that was never actually recorded. -**Milestone 5 registered no IPC channel; Milestone 7 registers the first -five, and none of them bypass this engine.** `execute` structurally cannot -run `perform` without an authorizing verdict, so `main/ipc.ts`'s five -handlers — `settings:get`, `settings:update`, `secrets:status`, -`secrets:write`, `secrets:clear` — each build an `ActionProposal` and hand it +**Milestone 5 registered no IPC channel; Milestone 7 registered the first +five; Phase 2 Milestone 3 adds a sixth, `chat:send` — none of them bypass +this engine.** `execute` structurally cannot run `perform` without an +authorizing verdict, so `main/ipc.ts`'s six action-backed handlers — +`settings:get`, `settings:update`, `secrets:status`, `secrets:write`, +`secrets:clear`, `chat:send` — each build an `ActionProposal` and hand it to `main/action-runtime.ts`'s `runAction`, which calls this unmodified -`handleActionProposal`. There is no code path in `main/ipc.ts` that calls +`handleActionProposal`. (`chat:cancel` is the seventh registered channel and +the one exception: it has no privileged side effect of its own to route +through this engine — see `docs/phase-2-real-provider-architecture.md`.) +There is no code path in `main/ipc.ts` that calls `execute`, `main/secrets.ts`, or `writeSettings` directly. `main/index.ts` still calls `loadPermissionPolicy` read-only at startup to prove the real policy path resolves before the window opens, but every real IPC call now @@ -575,16 +582,16 @@ documented before persistence existed. ## Secrets and onboarding (implemented) -| File | Describes | -| -------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `src/main/secrets.ts` | `loadSecretStoreState`, `hasStoredSecret`, `writeSecret`, `clearSecret`, `readSecret` (internal only) | -| `src/main/settings-service.ts` | `readReconciledSettings`, `writeOnboardingSettings`, `refreshHasApiKeyAfterSecretChange` | -| `src/main/action-runtime.ts` | `runAction` — loads policy and emergency state fresh, then calls `handleActionProposal` | -| `src/main/confirm.ts` | `showNativeConfirmation` — the real `dialog.showMessageBox` | -| `src/main/ipc.ts` | The five channel handlers, each: validate → build proposal → `runAction` → validate response | -| `src/renderer/Onboarding.tsx` | First-run form: assistant name, user name, language, provider, model, base URL, optional API key | -| `src/renderer/App.tsx` | Calls `settings:get` on mount; renders `Onboarding` while `onboardingCompleted` is `false` | -| `src/shared/schemas/secrets.schema.ts` | `secretStoreFileSchema` — the on-disk shape of `secrets.enc`, never a plaintext field | +| File | Describes | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/main/secrets.ts` | `loadSecretStoreState`, `hasStoredSecret`, `writeSecret`, `clearSecret`, `readSecret` (no IPC channel returns it; called by `chat-provider-registry.ts` since Phase 2 M3) | +| `src/main/settings-service.ts` | `readReconciledSettings`, `writeOnboardingSettings`, `refreshHasApiKeyAfterSecretChange` | +| `src/main/action-runtime.ts` | `runAction` — loads policy and emergency state fresh, then calls `handleActionProposal` | +| `src/main/confirm.ts` | `showNativeConfirmation` — the real `dialog.showMessageBox` | +| `src/main/ipc.ts` | The seven channel handlers, each: validate → build proposal → `runAction` → validate response (`chat:cancel` is the one exception — see `docs/phase-2-real-provider-architecture.md`) | +| `src/renderer/Onboarding.tsx` | First-run form: assistant name, user name, language, provider, model, base URL, optional API key | +| `src/renderer/App.tsx` | Calls `settings:get` on mount; renders `Onboarding` while `onboardingCompleted` is `false` | +| `src/shared/schemas/secrets.schema.ts` | `secretStoreFileSchema` — the on-disk shape of `secrets.enc`, never a plaintext field | **`safeStorage` is synchronous, verified against the installed `electron@44.1.1` typings rather than assumed from memory or older diff --git a/docs/phase-2-provider-architecture.md b/docs/phase-2-provider-architecture.md index aceed85..56b0758 100644 --- a/docs/phase-2-provider-architecture.md +++ b/docs/phase-2-provider-architecture.md @@ -8,6 +8,15 @@ > model and `ConversationController`, both unchanged in shape. **No real > provider is implemented. No network request is made anywhere in this > milestone, in tests or in normal execution.** +> +> Milestone 3 (see +> [docs/phase-2-real-provider-architecture.md](phase-2-real-provider-architecture.md)) +> adds the first real, network-capable adapter — `openai-compatible` — on top +> of this same foundation, without changing any of it: `ChatProvider`, the +> normalized error codes, `withProviderTimeout`, the request/response +> schemas and `createChatProviderForSelection`'s fail-closed behavior are all +> unchanged in shape and in this file's description of them. This document +> is kept as the record of what Milestone 2 built. --- diff --git a/docs/phase-2-real-provider-architecture.md b/docs/phase-2-real-provider-architecture.md new file mode 100644 index 0000000..ccb7ec3 --- /dev/null +++ b/docs/phase-2-real-provider-architecture.md @@ -0,0 +1,373 @@ +# Phase 2 real provider architecture + +> **Current state.** Phase 2, Milestone 3 implements the first real, +> network-capable `ChatProvider`: an OpenAI-compatible chat-completions +> adapter. It builds directly on +> [docs/phase-2-chat-architecture.md](phase-2-chat-architecture.md) (message +> model, `ConversationController`) and +> [docs/phase-2-provider-architecture.md](phase-2-provider-architecture.md) +> (the `ChatProvider` interface, the five-code error vocabulary, +> `withProviderTimeout`, the provider request/response schemas), none of +> which changed shape. **`glm` and `ollama` remain unimplemented. +> `anthropic`, `openai`, `claude` and `gemini` remain absent.** + +--- + +## Summary + +| Property | State | +| ----------------------------------- | -------------------------------------------------------------------------------------------------- | +| Real `openai-compatible` adapter | **[implemented]** — `src/main/openai-compatible-provider.ts` | +| Network access location | **Main process only.** Never the renderer, never `src/shared` | +| New IPC channels | **[implemented]** — `chat:send`, `chat:cancel` | +| New privileged action type | **[implemented]** — `chat.send`, routed through the unmodified permission/audit pipeline | +| API-key retrieval | **[implemented]** — `main/secrets.ts`'s existing, previously-unused `readSecret`, called only here | +| Plaintext key reaching the renderer | **Never.** No code path returns one; see Security boundary | +| `glm` / `ollama` real adapters | **[deferred]** — still fail closed via the shared registry | +| Disapproved identifiers | **Absent.** Not referenced anywhere in source (verified by scan) | + +--- + +## Why this needed a new IPC channel, a new action type, and nothing else new + +This is the one architecturally consequential decision in this milestone, so +it is stated explicitly. + +A real provider call is a genuine privileged side effect: it reads a +decrypted credential and sends the user's conversation to a remote (or +self-hosted) endpoint over the network. `CLAUDE.md` is explicit that nothing +outside the permission-controlled executor may perform a privileged side +effect, and that an IPC handler must never call the executor directly — it +must go through `handleActionProposal`, exactly like every other action. +There was no way to satisfy that requirement without: + +1. a new `ActionType`, `'chat.send'`, added to `ACTION_TYPES` + (`src/shared/constants.ts`); +2. a default policy rule for it (`createDefaultPermissionPolicy`, + `src/shared/schemas/permissions.schema.ts`); +3. a new IPC channel, `chat:send`, whose handler + (`main/ipc.ts`) calls `runAction` — the same function `settings:update` + and `secrets:write` already call — never the executor or a provider + directly. + +**The one judgment call: `chat.send` is `allow` by default, not +`confirm`.** `AGENTS.md` §5 lists "external" and "privacy-sensitive" among +the categories that require confirmation, and a network call carrying the +user's typed conversation is unmistakably both. The confirmation floor +(`CONFIRMATION_REQUIRED_ACTION_TYPES`) was deliberately **not** widened to +include it, for a reason distinct from every existing floor member: `secrets.write`, +`secrets.clear`, `emergency.reset` and `app.exit` are each a rare, +one-time-per-change action where a confirmation dialog is a proportionate +speed bump. Chat is the product's core, repeated interaction — a dialog on +every single message would not add a meaningful safety check (the user just +finished typing the message and pressed Send, which is already the +deliberate, per-message consent), and would make the feature unusable. The +credential itself already requires its own explicit confirmation exactly +once, when it is first stored, through the pre-existing `secrets.write` +flow — this milestone adds no new path to that store. + +`chat.send` is still fully gated, not merely audited: it is **not** on the +emergency-stop exemption list (`EMERGENCY_STOP_EXEMPT_ACTION_TYPES`), so an +engaged emergency stop blocks it exactly as it blocks `settings.write`, and +it is denied by default if a hand-edited policy file omits it (unmatched +actions are always denied). If this reading of "external actions need +confirmation" turns out not to match what was intended, the fix is narrow: +change `chat.send`'s entry in `createDefaultPermissionPolicy` from `allow` +to `confirm`. Nothing else in this milestone assumes one or the other. + +`chat:cancel` is deliberately **not** routed through the permission engine +at all — see [Cancellation](#cancellation) below for why that is safe. + +--- + +## The real adapter: `openai-compatible-provider.ts` + +`src/main/openai-compatible-provider.ts`'s `createOpenAiCompatibleProvider(config)` +implements the same `ChatProvider` interface every provider in this codebase +implements (`{id, send(request, options)}`) — nothing that consumes a +`ChatProvider` needed to change. It lives in `src/main`, never +`src/shared`, because `src/shared`'s lint-enforced purity boundary +structurally cannot make a network request; see +`docs/phase-2-provider-architecture.md`'s registry section for why +`createChatProviderForSelection` (the shared registry) still fails closed +for `openai-compatible` too, unconditionally, regardless of this adapter's +existence. + +What it does, request to response: + +1. Builds `/chat/completions` (trailing slash on `baseUrl` handled). +2. Forwards only `system`/`user`/`assistant` messages as `{role, content}` — + a `tool`-role message (declared in the schema for forward compatibility, + never produced by anything in this phase) is dropped rather than sent, + since this milestone implements no tool-call protocol. +3. Sends `POST` with `Authorization: Bearer ` and the conversation as + `{model, messages, stream: false}`. +4. Reads the response body under a byte cap + (`MAX_RESPONSE_BYTES`, 1,000,000) via a streaming reader, rejecting a + response that exceeds it before the whole body is ever buffered — the + base URL is user-supplied, including self-hosted endpoints, so it is + never assumed to behave well. +5. Parses the body as JSON, extracts `choices[0].message.content`, and + validates it through the same `chatProviderResultSchema` every other + provider's result already passes through — bounded length, no unsafe + control characters, no bidi overrides — before ever returning it. + +Every failure path — a non-2xx status, a network error, an already-aborted +or mid-flight-aborted signal, malformed JSON, a missing or empty `content`, +an oversized body — is caught and re-thrown as a `ChatProviderError` with +one of the five existing codes (see [Error mapping](#error-mapping)) and a +fixed, safe message. **Nothing in this file logs anything** — no request, +no response, no header, no error detail — verified by a test that spies on +`console.log`/`console.error`/`console.warn` across a full successful call +and asserts none were called. + +Timeout is **not** implemented inside this adapter. Exactly as +`docs/phase-2-provider-architecture.md`'s Timeout decorator section +anticipated, `main/chat-provider-registry.ts` wraps the constructed +provider in the unmodified `withProviderTimeout` decorator +(`OPENAI_COMPATIBLE_REQUEST_TIMEOUT_MS`, 30 seconds) rather than this file +starting its own timer. + +--- + +## The real registry: `chat-provider-registry.ts` + +`src/main/chat-provider-registry.ts`'s `resolveMainChatProvider(options)` is +the main-process counterpart to `src/shared/chat/registry.ts`'s +`createChatProviderForSelection` — a different function, in a different +process, because only this one can read a secret: + +- For `none`, `glm`, `ollama`, or an identifier outside `MODEL_PROVIDERS`: + delegates straight to the shared, always-fail-closed registry rather than + re-implementing "unavailable" a second time. +- For `openai-compatible`: checks, in order, a non-empty `baseUrl` (already + enforced by `settingsSchema` for a persisted document, re-checked here as + defence in depth), a non-empty `model` (not enforced by the settings + schema, so this is the first real check for it), and a stored key via + `main/secrets.ts`'s `readSecret` — the same function that has existed, + fully tested, since Milestone 7, but was previously called by nothing + outside its own test file (its doc comment already anticipated this: "for + a future milestone that actually calls a provider from the main + process"). Any of the three missing produces a `ChatProvider` whose + `send()` rejects with `PROVIDER_INVALID_CONFIGURATION` and a safe, + specific message — never a network request. +- Only once all three are present is `createOpenAiCompatibleProvider` + actually constructed, wrapped in `withProviderTimeout`, and returned. + +This function **never throws** — every failure to resolve a working adapter +becomes a fail-closed `ChatProvider`, so `main/ipc.ts` always has exactly +one `ChatProvider` to call, uniformly, regardless of why a real call might +not be possible. + +--- + +## `chat:send` / `chat:cancel` + +`src/shared/schemas/ipc.schema.ts` adds both channels: + +- **`chatSendRequestSchema`** — `{requestId: uuid, messages: ChatMessage[]}`, + the array bounded to `CHAT_CONVERSATION_MAX_MESSAGES`, `strictObject` so no + extra field (an `apiKey`, a `baseUrl`) can ride along. `requestId` exists + solely to correlate a later `chat:cancel` call to this specific in-flight + request — it carries no other meaning and is never persisted. +- **`chatSendResponseSchema`** — `{outcome, content?, errorCode?}`. + `errorCode`, when present, is `z.enum(CHAT_PROVIDER_ERROR_CODES)` — the + existing five-code vocabulary, reused rather than re-invented at this + boundary — so a raw provider error string cannot cross into the response + even by accident. `content`, when present, is the same bounded, + control-character- and bidi-safe schema every other provider result uses. +- **`chatCancelRequestSchema`** — `{requestId: uuid}`. +- **`chatCancelResponseSchema`** — always exactly `{acknowledged: true}`; + cancellation is best-effort and idempotent, so there is no failure shape + to represent. + +`main/ipc.ts`'s `chat:send` handler: + +1. Validates the request. +2. Reads current settings (for the provider identifier and, later, for + `resolveMainChatProvider`) and builds a `chat.send` proposal whose + `parameters` are **only** `{provider, messageCount}` — never message + content, matching `secrets.write`'s own minimal + `{provider, keyPresent}` precedent. A dedicated test asserts the full + conversation text never appears in the audit log. +3. Creates one `AbortController`, registers it in a per-runtime + `Map` before calling `runAction`. +4. Calls `runAction(actionRuntime, proposal, null, perform)` — `null` + because `chat.send` never requires confirmation (see above) — where + `perform` resolves the provider via `resolveMainChatProvider` and calls + `provider.send({messages}, {signal})`. A caught `ChatProviderError` is + re-thrown as `ActionExecutionError(error.code, error.message)`, the same + translation `secrets.write` already performs for + `SecretStoreUnavailableError`, so `execute()`'s existing + `ActionResult.errorCode` extraction picks up the normalized code + unchanged. +5. Removes the controller from the map in a `finally`, regardless of + outcome. + +`chat:cancel`'s handler looks `requestId` up in that same map and calls +`.abort()` if found — see [Cancellation](#cancellation). + +--- + +## Cancellation + +`AbortSignal` cannot cross the IPC boundary directly, so cancellation is a +second, explicit round trip rather than a single shared signal: + +- `src/renderer/chat/ipc-chat-provider.ts` generates one `requestId` + (`crypto.randomUUID()`) per `send()` call and attaches an `'abort'` + listener to the caller's `AbortSignal` (the same signal + `ConversationController` already creates and aborts on `setProvider`, + retry-supersession, and `dispose()`) that calls + `window.localAgent.chat.cancel(requestId)` — fire-and-forget. +- The adapter's own rejection is **never contingent on that round trip + succeeding or arriving in time**: immediately after `chat.send` resolves + (or the signal fires), it re-checks `signal.aborted` and throws + `PROVIDER_ABORTED` regardless of what the main process ultimately + answered — mirroring `ConversationController`'s own + `if (controller.signal.aborted) return;` guard, so a stale, otherwise- + successful response can never land, exactly as Milestone 2 already + guaranteed for provider switching. A test using a deliberately + late-resolving fake bridge proves this: the response is discarded even + when it reports `outcome: 'success'`. +- `chat:cancel` is **not** routed through `handleActionProposal` — it is + the one IPC channel in this milestone with no privileged side effect of + its own. It cannot start a network request, read a secret, or perform any + action the permission engine would need to authorize; it can only ask an + **already-authorized**, already-in-flight `chat:send` call to stop early. + Best-effort and idempotent: cancelling a request that already finished, + or one that was never sent, is a no-op, not an error — there is nothing + in the map to abort. + +--- + +## Error mapping + +The five codes from `docs/phase-2-provider-architecture.md` are reused +without addition, mapped as follows for a real HTTP exchange: + +| Condition | Code | +| ------------------------------------------------------- | -------------------------------- | +| `none` selected, or `glm`/`ollama` selected | `PROVIDER_UNAVAILABLE` | +| Empty `baseUrl`, empty `model`, or no key stored | `PROVIDER_INVALID_CONFIGURATION` | +| HTTP `401` / `403` | `PROVIDER_INVALID_CONFIGURATION` | +| HTTP `429`, any other non-2xx status | `PROVIDER_REQUEST_FAILED` | +| `fetch` rejects for a reason other than abort | `PROVIDER_REQUEST_FAILED` | +| Malformed JSON, missing/empty `content`, oversized body | `PROVIDER_REQUEST_FAILED` | +| `withProviderTimeout`'s budget elapses | `PROVIDER_TIMEOUT` | +| Signal aborted (already, or mid-flight) | `PROVIDER_ABORTED` | + +**The one judgment call here:** a `401`/`403` is mapped to +`PROVIDER_INVALID_CONFIGURATION` rather than a new "authentication failed" +code, since the five-code vocabulary has no such member and the task +required reusing it, not extending it. This matches +`docs/phase-2-provider-architecture.md`'s own definition of that code — +"the provider is implemented but its configuration is unusable" — and a +rejected credential is exactly that from the caller's perspective: the fix +is in provider settings, not a retry. `429` is deliberately **not** folded +into the same code: a rate limit is not a configuration problem and will +often succeed on retry, so it stays `PROVIDER_REQUEST_FAILED`, the generic, +retry-appropriate bucket. + +Every code still carries only a fixed, safe message — never the response +body, a status line, a URL, or a header — verified by a test that stuffs an +identifiable string into a mocked error response body and asserts it never +appears in the thrown error's `message`. + +--- + +## Security boundary + +Everything Milestones 1–2 established holds unchanged — see those +documents' own Security boundary sections — plus, new in this milestone: + +- **All network access is in the main process.** `src/shared` cannot reach + it (unchanged, structural). The renderer cannot reach it either: the CSP's + `connect-src 'none'` (unchanged, `main/index.ts`) still blocks + `fetch`/`XHR`/`WebSocket` from the renderer's own script context — proven + unmodified by the e2e suite's existing "enforces the CSP" test — and + nothing under `src/renderer/chat` imports a network-capable global either, + per the source-scan boundary test. +- **The plaintext API key never reaches the renderer.** It is read by + `readSecret` inside `resolveMainChatProvider`, held only for the duration + of one `provider.send()` call, used only to build one `Authorization` + header, and is never placed in `ActionProposal.parameters` (the audit + record only ever sees `{provider, messageCount}`), never in a thrown + error's message, never in the `chatSendResponseSchema` response shape, + and never logged. Tests assert the key is absent from the IPC response, + from every audit record, and from the outgoing request body (it belongs + in the `Authorization` header alone). +- **The registry fails closed for every provider without a real adapter, + and for missing configuration on the one that has one** — + `none`/`glm`/`ollama` via the unmodified shared registry, `openai-compatible` + with an empty `baseUrl`, an empty `model`, or no stored key via + `resolveMainChatProvider`'s own checks — verified by tests, in every case + without a network call. +- **`chat.send` passes through the unmodified permission engine, confirmation + floor, emergency-stop gate and audit writer**, exactly like every other + action type — `main/permissions.ts`, `main/executor.ts`, + `main/action-pipeline.ts` and `main/audit.ts` all have zero diff in this + milestone. `chat:cancel` has no side effect requiring authorization — see + [Cancellation](#cancellation). +- **No message content, no response content, and no error detail ever + reaches the audit log.** Only `{provider, messageCount}` on the request + side; the audit schema's own bounds and redaction contract + (`auditParametersSchema`) are the backstop if that were ever violated. +- **Model output still authorizes nothing.** There is no new path from a + `ChatSendResponse`'s `content` to `main/executor.ts`, to + `handleActionProposal`, or to any action proposal — it becomes, at most, + one more assistant `ChatMessage`, rendered as plain JSX text exactly as + every prior milestone's assistant message was. +- **`src/main`'s pre-existing privileged modules are untouched.** + `main/permissions.ts`, `main/executor.ts`, `main/action-pipeline.ts`, + `main/action-runtime.ts`, `main/emergency.ts`, `main/policy.ts`, + `main/settings.ts`, `main/settings-service.ts` and `main/secrets.ts` all + have zero diff — confirmed by `git diff --stat`. `main/secrets.ts`'s + `readSecret` is called for the first time outside its own tests, but its + implementation did not change. +- **A source-scan test** (`tests/unit/shared/chat-boundary-scan.test.ts`, + extended this milestone) asserts `window.localAgent` appears in exactly + one file under `src/renderer/chat` — + `ipc-chat-provider.ts` — and nowhere under `src/shared/chat`, so a future + contributor adding a second, uncontrolled call site fails this test + immediately, independent of code review. + +--- + +## Testing + +| Area | File | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | +| Real adapter: success, headers/body shape, role filtering, auth failure, rate limit, generic failure, network failure, cancellation, malformed/oversized response, no logging | `tests/unit/main/openai-compatible-provider.test.ts` | +| Main-process registry: fail-closed delegation, missing configuration, successful resolution, timeout wrapping, undecryptable key | `tests/unit/main/chat-provider-registry.test.ts` | +| `chat:send` / `chat:cancel` IPC: permission gating, emergency-stop denial, request validation, audit content (never message text or the key), successful and failing real calls, cancellation of an in-flight call | `tests/unit/main/ipc.test.ts` | +| `chat:send` / `chat:cancel` schema validation, bounds, normalized error-code enum | `tests/unit/shared/ipc.schema.test.ts` | +| Renderer IPC adapter: request/response mapping, error-code fallback, abort-before-call, abort-mid-flight discarding a late response, listener cleanup | `tests/unit/renderer/ipc-chat-provider.test.ts` | +| Registry capability/status changes (`implemented`, `ready`, `missing-api-key`) | `tests/unit/shared/chat-registry.test.ts` | +| Boundary scan: `window.localAgent` confined to one named file; every other forbidden pattern unchanged | `tests/unit/shared/chat-boundary-scan.test.ts` | +| Bridge shape (`chat.send`, `chat.cancel` present; no generic invoke surface; CSP still blocks renderer network access) | `tests/e2e/electron-smoke.test.ts` | + +No test in this milestone makes a real network request: every `fetch` call +in every new test is either stubbed with `vi.stubGlobal('fetch', ...)` or, +in `tests/e2e/electron-smoke.test.ts`, deliberately never invoked — `chat.send` +is exercised only against a temporary directory and a mocked `fetch`, in +`tests/unit/main/ipc.test.ts`, matching how `settings:*` and `secrets:*` are +already tested there rather than against the real, running application. + +--- + +## What remains deferred + +- Real `glm` and `ollama` adapters — both still resolve through the shared, + fail-closed registry. +- Streaming responses — `stream: false` is hardcoded; the adapter reads one + complete response, not a token stream. +- Per-provider request options (temperature, max tokens, system-prompt + overrides) — the request body carries only `model` and `messages`. +- Retrying a rate-limited or transiently failed request automatically — the + existing `Retry` button in the chat UI (Milestone 1) is the only retry + path, and it is always user-initiated. +- Everything Milestones 1–2 already deferred and this milestone was not + asked to add: filesystem tools, shell execution, browser automation, + model-generated tool execution, vector memory/RAG, plugin runtime, + telemetry, cloud sync, and any relaxation of the approved-identifier list. diff --git a/docs/security-model.md b/docs/security-model.md index 6dc2064..81d13d3 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -14,9 +14,14 @@ > native confirmation dialog, the encrypted secret store backed by Electron's > synchronous `safeStorage`, `hasApiKey` reconciliation, and the first-run > onboarding interface — again under a permission engine, executor and -> pipeline that needed **zero code changes**. Controls below are marked -> **[implemented]**, **[enforced by schema]** or **[planned, milestone N]**. -> Nothing here is claimed as working before it exists. +> pipeline that needed **zero code changes**. Phase 2, Milestone 3 registers +> the first two channels since Phase 1 (`chat.send`, `chat.cancel`) and the +> first action type with real network egress, `chat.send` — routed through +> the same unmodified permission engine, executor and pipeline once again, +> and the first caller of `main/secrets.ts`'s `readSecret` outside that +> module's own tests. Controls below are marked **[implemented]**, +> **[enforced by schema]** or **[planned, milestone N]**. Nothing here is +> claimed as working before it exists. --- @@ -59,12 +64,17 @@ asserted by an end-to-end Playwright test against the built application, not only declared in source. A compromised renderer therefore gains only the narrow preload API — as of -Milestone 7, `health`, `settings.get`, `settings.update`, `secrets.status`, -`secrets.write` and `secrets.clear`, and nothing else. Every one of those five -privileged functions is gated and audited through +Phase 2 Milestone 3, `health`, `settings.get`, `settings.update`, +`secrets.status`, `secrets.write`, `secrets.clear`, `chat.send` and +`chat.cancel`, and nothing else. Every one of those seven privileged +functions except `chat.cancel` is gated and audited through `main/action-pipeline.ts`'s unmodified `handleActionProposal` (`main/ipc.ts` → `main/action-runtime.ts`'s `runAction` → `handleActionProposal`), -and none of them can return a plaintext key. +and none of them can return a plaintext key. `chat.cancel` has no privileged +side effect of its own to gate — it can only ask an already-authorized +`chat.send` call already in flight to abort early; see +`docs/phase-2-real-provider-architecture.md`'s Cancellation section for why +that is safe without a permission decision of its own. ### Permission model @@ -138,6 +148,41 @@ action or its parameters, so it has no way to leak one either way. An HTML dialog rendered by the renderer remains explicitly disallowed and is not how this is implemented. +### Network egress + +**[implemented, Phase 2 Milestone 3]** The only network-capable code in this +codebase is `src/main/openai-compatible-provider.ts`, called only through +`main/chat-provider-registry.ts`'s `resolveMainChatProvider`, called only +from `chat.send`'s `perform` callback in `main/ipc.ts`. Three independent +facts hold this to the main process alone: + +- `src/shared`'s lint-enforced purity boundary (see _Shared-layer purity_ + below) blocks `fetch`/`XMLHttpRequest`/`WebSocket`/`EventSource` as + globals, so no file under `src/shared`, including every other + `ChatProvider` in this codebase, can ever place a real request. +- The renderer's Content-Security-Policy sets `connect-src 'none'` + (`main/index.ts`, unchanged since Milestone 2), independently blocking a + network request initiated from the renderer's own script context — + asserted by an end-to-end test. +- `src/renderer/chat/ipc-chat-provider.ts` — the only renderer file that + reaches the real adapter at all — calls only the narrow, typed + `chat.send`/`chat.cancel` preload functions, never a network API directly; + a source-scan test asserts no file under `src/renderer/chat` references a + network-capable global either. + +`chat.send` is a genuine privileged action, not a bypass of the model above: +it is gated by the same, unmodified `decidePermission` → `execute` → +`appendAuditRecord` pipeline every other action type uses, denied outright +while the emergency stop is engaged (it is not on the exemption list), and +recorded in the audit trail with only `{provider, messageCount}` — never +message content, never a response, never a credential. See +`docs/phase-2-real-provider-architecture.md` for the full design, including +the one deliberate policy choice this milestone made: `chat.send` is `allow` +by default rather than `confirm`, since the send itself is already a +direct, per-message user action, unlike the rare, one-time changes the +confirmation floor (`secrets.write`, `secrets.clear`, `emergency.reset`, +`app.exit`) protects. + ### Secret handling **[enforced by schema]** The settings schema is strict and declares no field @@ -542,40 +587,44 @@ exposes no way to address one. No generic pass-through channel exists. ## Threats and status -| Threat | Status | -| -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Renderer compromise (XSS, malicious UI dependency) | Addressed, M2 — process isolation and CSP; asserted by an end-to-end test | -| Malicious or malformed IPC payload | Addressed, M2–M7 — schema validation on every registered channel (`health`, `settings:get`, `settings:update`, `secrets:status`, `secrets:write`, `secrets:clear`); no unvalidated or generic channel exists | -| Secret exfiltration through the interface | Addressed — no channel returns a key; asserted by test | -| Secret leakage into logs or errors | Addressed, M4 — schema-level redaction contract plus writer-side redaction, both verified | -| Credential persisted inside a settings _value_ | Addressed — `baseUrl` rejects embedded userinfo | -| Log injection via a crafted user name | Addressed — control characters rejected | -| Display spoofing via bidirectional overrides | Addressed — bidi overrides and isolates rejected in display strings | -| Forged audit record | Addressed — biconditional cross-field integrity rules | -| Unbounded or non-serialisable audit payload | Addressed — bounded JSON-safe parameter schema; writer-side scan bounds the whole record | -| Audit record overwritten or truncated by a write | Addressed, M4 — append-only file handle; proven under concurrent writes by test | -| Prototype-pollution key bypassing schema validation | Addressed, M4 — explicit writer-side scan; schema-only reliance was verified insufficient | -| Settings tampering or corruption | Addressed, M3 — strict validation, fail-safe loading to defaults, atomic writes | -| Policy tampering or corruption | Addressed, M5 — fail-closed loading (`main/policy.ts`) plus engine-side floor enforcement independent of it | -| Policy file removing the user's emergency controls | Addressed, M5 — availability floor enforced at both the schema layer and, independently, by `decidePermission` | -| Policy bypassing schema validation before reaching the engine | Addressed, M5 — floors re-verified by the engine regardless of validation history | -| Corruption of a shared security default in memory | Addressed — exported defaults deeply frozen; resolvers return fresh objects | -| `src/shared` reaching the OS, network or eval | Addressed — lint boundary, verified by probe; not a runtime sandbox | -| Privilege escalation via the executor | Addressed, M5 — `execute` requires a verdict as an explicit argument; proven by tests that no denial or rejection reaches `perform` | -| A side effect running without a permission decision | Addressed, M5 — structural: no code path in `execute` calls `perform` without an authorizing verdict | -| Model rationale/confidence used as authorization | Addressed, M5 — `decidePermission` never reads `proposal.parameters`; proven by test | -| Emergency-stop bypass (decision logic) | Addressed, M5 — engine denies non-exempt actions when engaged, evaluated after both floors | -| Emergency-stop bypass (persistence) | Addressed, M6 — atomic writes, fail-safe loading; reset requires an approved confirmation the pipeline already enforces | -| Emergency-stop state corruption fails open instead of closed | Addressed, M6 — malformed or unreadable state resolves engaged, not disengaged; proven by test | -| Emergency reset triggered by a model or a policy rule alone | Addressed, M6 — always resolves to `confirm`; proven across a policy × emergency-state test matrix | -| Plaintext API key stored as a fallback when encryption is unavailable | Addressed, M7 — `writeSecret` throws `SecretStoreUnavailableError` before any disk write when `safeStorage.isEncryptionAvailable()` is `false`; proven by test | -| Plaintext API key reaching settings.json, an audit record, an error message, or the renderer | Addressed, M7 — `apiKey` never enters `proposal.parameters`; secrets IPC responses carry only `{present: boolean}`; proven by test | -| `hasApiKey` reported as `true` for a provider that does not use a key (`none`, `ollama`) | Addressed, M7 — forced `false` regardless of the secret store; proven by test | -| A denied or rejected secret operation still mutating the store or `hasApiKey` | Addressed, M7 — `perform` is structurally unreachable on `deny`/`reject`; `hasApiKey` is only refreshed after the store write already succeeded; proven by test | -| Renderer-supplied `hasApiKey` reaching a persisted document | Addressed, M7 — `settingsUpdateRequestSchema` has no such field; `strictObject` rejects the extra key outright | -| Path traversal | Not reachable — all paths derive from the app-data directory; none is user-supplied | -| Prompt injection, untrusted model or tool output | Not reachable in Phase 1 — no model call exists. The proposal/executor split pre-empts it | -| Supply-chain compromise via npm | Mitigated, not eliminated — see below | +| Threat | Status | +| -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Renderer compromise (XSS, malicious UI dependency) | Addressed, M2 — process isolation and CSP; asserted by an end-to-end test | +| Malicious or malformed IPC payload | Addressed, M2–M7 — schema validation on every registered channel (`health`, `settings:get`, `settings:update`, `secrets:status`, `secrets:write`, `secrets:clear`); no unvalidated or generic channel exists | +| Secret exfiltration through the interface | Addressed — no channel returns a key; asserted by test | +| Secret leakage into logs or errors | Addressed, M4 — schema-level redaction contract plus writer-side redaction, both verified | +| Credential persisted inside a settings _value_ | Addressed — `baseUrl` rejects embedded userinfo | +| Log injection via a crafted user name | Addressed — control characters rejected | +| Display spoofing via bidirectional overrides | Addressed — bidi overrides and isolates rejected in display strings | +| Forged audit record | Addressed — biconditional cross-field integrity rules | +| Unbounded or non-serialisable audit payload | Addressed — bounded JSON-safe parameter schema; writer-side scan bounds the whole record | +| Audit record overwritten or truncated by a write | Addressed, M4 — append-only file handle; proven under concurrent writes by test | +| Prototype-pollution key bypassing schema validation | Addressed, M4 — explicit writer-side scan; schema-only reliance was verified insufficient | +| Settings tampering or corruption | Addressed, M3 — strict validation, fail-safe loading to defaults, atomic writes | +| Policy tampering or corruption | Addressed, M5 — fail-closed loading (`main/policy.ts`) plus engine-side floor enforcement independent of it | +| Policy file removing the user's emergency controls | Addressed, M5 — availability floor enforced at both the schema layer and, independently, by `decidePermission` | +| Policy bypassing schema validation before reaching the engine | Addressed, M5 — floors re-verified by the engine regardless of validation history | +| Corruption of a shared security default in memory | Addressed — exported defaults deeply frozen; resolvers return fresh objects | +| `src/shared` reaching the OS, network or eval | Addressed — lint boundary, verified by probe; not a runtime sandbox | +| Privilege escalation via the executor | Addressed, M5 — `execute` requires a verdict as an explicit argument; proven by tests that no denial or rejection reaches `perform` | +| A side effect running without a permission decision | Addressed, M5 — structural: no code path in `execute` calls `perform` without an authorizing verdict | +| Model rationale/confidence used as authorization | Addressed, M5 — `decidePermission` never reads `proposal.parameters`; proven by test | +| Emergency-stop bypass (decision logic) | Addressed, M5 — engine denies non-exempt actions when engaged, evaluated after both floors | +| Emergency-stop bypass (persistence) | Addressed, M6 — atomic writes, fail-safe loading; reset requires an approved confirmation the pipeline already enforces | +| Emergency-stop state corruption fails open instead of closed | Addressed, M6 — malformed or unreadable state resolves engaged, not disengaged; proven by test | +| Emergency reset triggered by a model or a policy rule alone | Addressed, M6 — always resolves to `confirm`; proven across a policy × emergency-state test matrix | +| Plaintext API key stored as a fallback when encryption is unavailable | Addressed, M7 — `writeSecret` throws `SecretStoreUnavailableError` before any disk write when `safeStorage.isEncryptionAvailable()` is `false`; proven by test | +| Plaintext API key reaching settings.json, an audit record, an error message, or the renderer | Addressed, M7 — `apiKey` never enters `proposal.parameters`; secrets IPC responses carry only `{present: boolean}`; proven by test | +| `hasApiKey` reported as `true` for a provider that does not use a key (`none`, `ollama`) | Addressed, M7 — forced `false` regardless of the secret store; proven by test | +| A denied or rejected secret operation still mutating the store or `hasApiKey` | Addressed, M7 — `perform` is structurally unreachable on `deny`/`reject`; `hasApiKey` is only refreshed after the store write already succeeded; proven by test | +| Renderer-supplied `hasApiKey` reaching a persisted document | Addressed, M7 — `settingsUpdateRequestSchema` has no such field; `strictObject` rejects the extra key outright | +| Path traversal | Not reachable — all paths derive from the app-data directory; none is user-supplied | +| Prompt injection, untrusted model or tool output | Reachable from Phase 2 M3 (a real provider replies) — no tool exists for a model to invoke, so untrusted assistant text still cannot authorize or perform an action; see the proposal/executor split | +| Renderer or a compromised dependency making a network request directly | Addressed, Phase 2 M3 — CSP `connect-src 'none'` (unmodified since M2) plus the `src/shared` and `src/renderer/chat` purity/scan boundaries; only `src/main/openai-compatible-provider.ts` can reach the network | +| Plaintext API key reaching the renderer through a real provider call | Addressed, Phase 2 M3 — stays inside `resolveMainChatProvider`/`openai-compatible-provider.ts`; never in `ActionProposal.parameters`, `chatSendResponseSchema`, an audit record, or a thrown error; proven by test | +| A real provider call bypassing the permission engine or the emergency stop | Addressed, Phase 2 M3 — reached only through the unmodified `runAction`/`handleActionProposal`; denied while the emergency stop is engaged | +| Message content or a raw provider error leaking into the audit trail | Addressed, Phase 2 M3 — the `chat.send` proposal carries only `{provider, messageCount}`; every provider failure normalizes to one of five fixed codes first | +| Supply-chain compromise via npm | Mitigated, not eliminated — see below | --- diff --git a/src/main/chat-provider-registry.ts b/src/main/chat-provider-registry.ts new file mode 100644 index 0000000..586d821 --- /dev/null +++ b/src/main/chat-provider-registry.ts @@ -0,0 +1,112 @@ +/** + * Resolves the real, main-process `ChatProvider` for the currently selected + * provider (Phase 2, Milestone 3). + * + * This is the counterpart to `src/shared/chat/registry.ts`'s + * `createChatProviderForSelection`, and deliberately a separate module in a + * separate process: this one is the only place in the codebase that reads a + * decrypted API key and hands it to a network-capable adapter, so it must + * live where secrets already live — `src/main` — never in `src/shared`. + * + * For every identifier without a real adapter (`none`, `glm`, `ollama`, and + * anything outside {@link MODEL_PROVIDERS}), this delegates straight back to + * the shared, always-fail-closed registry rather than re-implementing the + * same "unavailable" behaviour a second time. Only `openai-compatible` takes + * a different path here: configuration is checked (base URL, model, a + * stored key), and if all three are present, the real adapter + * (`./openai-compatible-provider.ts`) is constructed and wrapped in + * `withProviderTimeout` — composed here, not inside the adapter itself, + * exactly as `docs/phase-2-provider-architecture.md` describes for any + * future real adapter. + */ + +import type { SafeStorage } from 'electron'; + +import { createOpenAiCompatibleProvider } from './openai-compatible-provider'; +import { readSecret } from './secrets'; +import { ChatProviderError, type ChatProvider } from '../shared/chat/provider'; +import { createChatProviderForSelection } from '../shared/chat/registry'; +import { withProviderTimeout } from '../shared/chat/timeout'; +import { PROVIDERS_REQUIRING_API_KEY } from '../shared/constants'; +import type { ModelProviderSettings } from '../shared/schemas'; + +/** + * Bounds how long a real provider call may run before it is aborted and + * reported as `PROVIDER_TIMEOUT`. A chat completion is a synchronous + * request/response exchange in this milestone (no streaming), so a single, + * generous fixed budget is enough — there is no per-call configuration for + * it yet, matching the rest of Phase 2's provider settings. + */ +export const OPENAI_COMPATIBLE_REQUEST_TIMEOUT_MS = 30_000; + +export interface ResolveChatProviderOptions { + readonly modelProvider: ModelProviderSettings; + readonly secretsFile: string; + readonly safeStorage: SafeStorage; +} + +function unavailable(id: string, message: string): ChatProvider { + return { + id, + send() { + return Promise.reject(new ChatProviderError('PROVIDER_INVALID_CONFIGURATION', message)); + }, + }; +} + +/** + * Resolves the `ChatProvider` a real `chat:send` call should use. + * + * Never throws: every failure to resolve a working adapter (no key stored, + * `safeStorage` unavailable, an empty model, an unimplemented identifier) is + * represented as a `ChatProvider` whose `send()` rejects with a normalized + * code, exactly like the shared registry's fail-closed placeholders — so a + * caller (`main/ipc.ts`) always has one `ChatProvider` to call, uniformly, + * regardless of why it might fail. + */ +export async function resolveMainChatProvider( + options: ResolveChatProviderOptions, +): Promise { + const { modelProvider, secretsFile, safeStorage } = options; + + if (modelProvider.provider !== 'openai-compatible') { + return createChatProviderForSelection(modelProvider.provider); + } + + if (modelProvider.baseUrl.trim() === '') { + return unavailable( + 'openai-compatible', + 'No endpoint is configured. Add one in provider settings.', + ); + } + + if (modelProvider.model.trim() === '') { + return unavailable( + 'openai-compatible', + 'No model is configured. Add one in provider settings.', + ); + } + + if (!PROVIDERS_REQUIRING_API_KEY.includes('openai-compatible')) { + // Unreachable given today's constant, kept so a future change to that + // list cannot silently skip the key check below without this failing + // loudly in review instead. + return unavailable('openai-compatible', 'This provider is not correctly configured.'); + } + + const apiKey = await readSecret(secretsFile, safeStorage); + if (apiKey === null) { + return unavailable( + 'openai-compatible', + 'No API key is stored yet. Add one in provider settings.', + ); + } + + const provider = createOpenAiCompatibleProvider({ + baseUrl: modelProvider.baseUrl, + model: modelProvider.model, + apiKey, + }); + + return withProviderTimeout(provider, OPENAI_COMPATIBLE_REQUEST_TIMEOUT_MS); +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 4887750..f879c99 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -23,6 +23,7 @@ import type { IpcMain, SafeStorage } from 'electron'; import { runAction } from './action-runtime'; import type { ActionRuntime } from './action-runtime'; +import { resolveMainChatProvider } from './chat-provider-registry'; import { ActionExecutionError } from './executor'; import type { UserDataPaths } from './paths'; import { clearSecret, SecretStoreUnavailableError, writeSecret } from './secrets'; @@ -32,10 +33,18 @@ import { writeOnboardingSettings, } from './settings-service'; import { loadSettings } from './settings'; +import { CHAT_PROVIDER_ERROR_CODES, ChatProviderError } from '../shared/chat/provider'; +import type { ChatProviderResult } from '../shared/chat/provider'; import { PROVIDERS_REQUIRING_API_KEY } from '../shared/constants'; import { + chatCancelRequestSchema, + chatCancelResponseSchema, + chatSendRequestSchema, + chatSendResponseSchema, healthCheckRequestSchema, healthCheckResponseSchema, + IPC_CHAT_CANCEL_CHANNEL, + IPC_CHAT_SEND_CHANNEL, IPC_HEALTH_CHANNEL, IPC_SECRETS_CLEAR_CHANNEL, IPC_SECRETS_STATUS_CHANNEL, @@ -54,6 +63,7 @@ import { settingsUpdateResponseSchema, } from '../shared/schemas'; import type { + ChatSendResponse, SecretsActionResponse, SecretStatusResult, SettingsActionResponse, @@ -65,6 +75,12 @@ import type { ActionProposal, ActionResult, ActionType, ConfirmationResult } fro export const SECRETS_ERROR_PROVIDER_DOES_NOT_USE_API_KEY = 'PROVIDER_DOES_NOT_USE_API_KEY'; export const SECRETS_ERROR_STORE_UNAVAILABLE = 'SECRET_STORE_UNAVAILABLE'; +function isKnownChatProviderErrorCode( + code: string, +): code is (typeof CHAT_PROVIDER_ERROR_CODES)[number] { + return (CHAT_PROVIDER_ERROR_CODES as readonly string[]).includes(code); +} + /** * Everything an IPC handler needs to build and run a proposal, resolved once * at startup in `main/index.ts` and passed in here — mirroring how every @@ -124,6 +140,29 @@ function toSecretsResponse(result: ActionResult): SecretsAct }; } +/** + * `result.errorCode` is normally always one of {@link CHAT_PROVIDER_ERROR_CODES} + * — `perform` below never throws anything except a `ChatProviderError`, + * translated to an `ActionExecutionError` carrying that same code. This + * degrades any other value to `PROVIDER_REQUEST_FAILED` rather than letting + * an unexpected internal error code fail `chatSendResponseSchema.parse` + * outright — defence in depth for a path that should not be reachable, not + * a substitute for `perform` only ever throwing a normalized error. + */ +function toChatSendResponse(result: ActionResult): ChatSendResponse { + const errorCode = + result.errorCode === undefined + ? undefined + : isKnownChatProviderErrorCode(result.errorCode) + ? result.errorCode + : 'PROVIDER_REQUEST_FAILED'; + return { + outcome: result.outcome, + ...(result.value === undefined ? {} : { content: result.value.content }), + ...(errorCode === undefined ? {} : { errorCode }), + }; +} + /** * `ipcMain` is passed in rather than imported here. * @@ -137,6 +176,18 @@ function toSecretsResponse(result: ActionResult): SecretsAct * before anything runs — so the `electron` type imports above are safe. */ export function registerIpcHandlers(ipcMain: IpcMain, runtime: IpcHandlerRuntime): void { + /** + * One `AbortController` per in-flight `chat:send` call, keyed by the + * caller-supplied `requestId`. `chat:cancel` looks a request up here and + * aborts it; nothing else ever reads or holds a reference to a request + * once it settles — the `finally` below always removes it, on success, + * on failure, and on cancellation alike, so this map never grows with + * completed requests and never leaks a controller across calls to + * `registerIpcHandlers` (each call — one per test, one per app run — gets + * its own map, never a module-level shared one). + */ + const inFlightChatRequests = new Map(); + ipcMain.handle(IPC_HEALTH_CHANNEL, (_event, ...args: unknown[]) => { healthCheckRequestSchema.parse(args); return healthCheckResponseSchema.parse({ status: 'ok' }); @@ -253,4 +304,57 @@ export function registerIpcHandlers(ipcMain: IpcMain, runtime: IpcHandlerRuntime return secretsClearResponseSchema.parse(toSecretsResponse(result)); }); + + ipcMain.handle(IPC_CHAT_SEND_CHANNEL, async (_event, ...args: unknown[]) => { + const [{ requestId, messages }] = chatSendRequestSchema.parse(args); + const now = runtime.nowFn(); + const actionRuntime = buildActionRuntime(runtime, now); + + const currentSettings = await loadSettings(runtime.userDataPaths.settingsFile, now); + // Never the full conversation: only enough to describe what happened, + // matching `secrets.write`'s own minimal `{provider, keyPresent}` + // parameters. Message content never enters an audit record. + const proposal = newProposal('chat.send', { + provider: currentSettings.modelProvider.provider, + messageCount: messages.length, + }); + + const abortController = new AbortController(); + inFlightChatRequests.set(requestId, abortController); + + try { + const result = await runAction(actionRuntime, proposal, null, async () => { + const provider = await resolveMainChatProvider({ + modelProvider: currentSettings.modelProvider, + secretsFile: runtime.userDataPaths.secretsFile, + safeStorage: runtime.safeStorage, + }); + + try { + return await provider.send({ messages }, { signal: abortController.signal }); + } catch (error) { + if (error instanceof ChatProviderError) { + throw new ActionExecutionError(error.code, error.message); + } + throw error; + } + }); + + return chatSendResponseSchema.parse(toChatSendResponse(result)); + } finally { + inFlightChatRequests.delete(requestId); + } + }); + + ipcMain.handle(IPC_CHAT_CANCEL_CHANNEL, (_event, ...args: unknown[]) => { + const [{ requestId }] = chatCancelRequestSchema.parse(args); + // Best-effort and idempotent: a request that already finished, or one + // that never existed, is simply not in the map — there is nothing to + // abort, and that is not an error. No permission check applies here: + // this cannot start a new action, read a secret, or reach the network + // itself, it can only ask an already-authorized `chat:send` call + // already in flight to stop early. + inFlightChatRequests.get(requestId)?.abort(); + return chatCancelResponseSchema.parse({ acknowledged: true }); + }); } diff --git a/src/main/openai-compatible-provider.ts b/src/main/openai-compatible-provider.ts new file mode 100644 index 0000000..f02a98b --- /dev/null +++ b/src/main/openai-compatible-provider.ts @@ -0,0 +1,249 @@ +/** + * The real OpenAI-compatible chat provider adapter (Phase 2, Milestone 3). + * + * The first, and so far only, `ChatProvider` implementation in this codebase + * that makes a real network request. It lives in `src/main` deliberately — + * `src/shared`, where every other `ChatProvider` lives, structurally cannot + * perform network access (see `src/shared/chat/provider.ts`'s doc comment) — + * and is never imported by `src/renderer` or `src/shared`: the renderer + * reaches it only through the `chat:send` IPC channel (`main/ipc.ts`), which + * resolves it through `main/chat-provider-registry.ts`. + * + * Security properties, all structural, not conventions: + * + * - **The plaintext API key is a plain function parameter, never a module + * global.** It is read from the encrypted secret store immediately before + * this module is called (`main/chat-provider-registry.ts`) and is used + * only to build one `Authorization` header per request. It is never + * logged, never placed in a thrown error's `message`, and never appears + * in this module's return value. + * - **Nothing here logs a request or a response.** No `console.log` (or + * equivalent) exists anywhere in this file — not the outgoing headers, + * not the request body (which carries the user's conversation), not the + * raw response body, not a caught error's message. + * - **The upstream response is never trusted structurally.** Its body is + * read under a byte cap ({@link MAX_RESPONSE_BYTES}) before it is ever + * parsed as JSON, and the extracted `content` is validated by the same + * {@link chatProviderResultSchema} every other provider's result is + * validated by — bounded length, no unsafe control characters, no bidi + * overrides — before this function ever returns it. + * - **Every failure normalizes into the existing five-code vocabulary** + * (`src/shared/chat/provider.ts`) — never a raw `Error.message`, a URL, a + * header, or a status body reaches a caller of this module. + */ + +import { z } from 'zod'; + +import { + ChatProviderError, + type ChatProvider, + type ChatProviderRequest, + type ChatProviderRequestOptions, + type ChatProviderResult, +} from '../shared/chat/provider'; +import { chatProviderResultSchema } from '../shared/schemas/chat.schema'; + +/** OpenAI-compatible chat completions endpoint, relative to the configured base URL. */ +const CHAT_COMPLETIONS_PATH = '/chat/completions'; + +/** + * Upper bound on the bytes read from a response body before this module + * gives up and reports a malformed response, rather than buffering an + * unbounded amount of memory for a hostile or misbehaving endpoint — the + * base URL is user-supplied (including self-hosted endpoints), so it is + * never assumed to be well-behaved. Generous for any real chat completion + * (a very long reply is still a few hundred KB of JSON at most) and tight + * enough to bound memory use for a pathological response. + */ +export const MAX_RESPONSE_BYTES = 1_000_000; + +export interface OpenAiCompatibleProviderConfig { + /** Already validated: absolute http/https, no embedded credentials — see `settingsSchema`. */ + readonly baseUrl: string; + readonly model: string; + /** Plaintext, decrypted immediately before this is constructed. Never logged. */ + readonly apiKey: string; +} + +/** Only the roles this milestone's chat can produce are forwarded to the wire format. */ +const FORWARDED_ROLES = new Set(['system', 'user', 'assistant']); + +const chatCompletionMessageSchema = z.object({ + content: z.string().nullable().optional(), +}); +const chatCompletionChoiceSchema = z.object({ + message: chatCompletionMessageSchema, +}); +/** + * The minimal shape this adapter reads from an OpenAI-compatible response. + * Deliberately not `strictObject`: this describes an upstream API this + * codebase does not control, which may carry vendor-specific fields this + * adapter has no use for and must not reject on that basis alone — only the + * one field this function actually reads is validated. + */ +const chatCompletionResponseSchema = z.object({ + choices: z.array(chatCompletionChoiceSchema).min(1).max(32), +}); + +function joinUrl(baseUrl: string, path: string): string { + const trimmedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; + const trimmedPath = path.startsWith('/') ? path : `/${path}`; + return `${trimmedBase}${trimmedPath}`; +} + +/** + * Reads `response`'s body as text, rejecting once more than `maxBytes` have + * been received rather than buffering an unbounded amount. Falls back to + * `response.text()` only when no streaming reader is available (a test + * double, say) — still applying the same cap to what comes back. + */ +async function readBoundedText(response: Response, maxBytes: number): Promise { + const reader = response.body?.getReader(); + if (!reader) { + const text = await response.text(); + if (Buffer.byteLength(text, 'utf8') > maxBytes) { + throw new Error('response body exceeds the configured byte cap'); + } + return text; + } + + const decoder = new TextDecoder(); + let received = 0; + let result = ''; + for (;;) { + // Sequential by nature: each chunk must be measured before the next is requested. + const { done, value } = await reader.read(); + if (done) break; + received += value.byteLength; + if (received > maxBytes) { + await reader.cancel().catch(() => undefined); + throw new Error('response body exceeds the configured byte cap'); + } + result += decoder.decode(value, { stream: true }); + } + result += decoder.decode(); + return result; +} + +function toWireMessages( + request: ChatProviderRequest, +): { readonly role: string; readonly content: string }[] { + return request.messages + .filter((message) => FORWARDED_ROLES.has(message.role)) + .map((message) => ({ role: message.role, content: message.content })); +} + +/** + * Creates the real OpenAI-compatible adapter for one resolved configuration. + * + * Callers apply `withProviderTimeout` around the returned provider — this + * function starts no timer of its own, matching every other `ChatProvider` + * in this codebase (see `src/shared/chat/timeout.ts`). + */ +export function createOpenAiCompatibleProvider( + config: OpenAiCompatibleProviderConfig, +): ChatProvider { + return { + id: 'openai-compatible', + async send( + request: ChatProviderRequest, + options?: ChatProviderRequestOptions, + ): Promise { + const signal = options?.signal; + if (signal?.aborted) { + throw new ChatProviderError('PROVIDER_ABORTED', 'The request was already aborted.'); + } + + const url = joinUrl(config.baseUrl, CHAT_COMPLETIONS_PATH); + const body = JSON.stringify({ + model: config.model, + messages: toWireMessages(request), + stream: false, + }); + + let response: Response; + try { + response = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${config.apiKey}`, + }, + body, + ...(signal ? { signal } : {}), + }); + } catch (error) { + if (signal?.aborted) { + throw new ChatProviderError('PROVIDER_ABORTED', 'The request was aborted.', { + cause: error, + }); + } + throw new ChatProviderError( + 'PROVIDER_REQUEST_FAILED', + 'The network request to the provider failed.', + { cause: error }, + ); + } + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + throw new ChatProviderError( + 'PROVIDER_INVALID_CONFIGURATION', + 'The provider rejected the configured credentials.', + ); + } + if (response.status === 429) { + throw new ChatProviderError( + 'PROVIDER_REQUEST_FAILED', + 'The provider is rate-limiting requests. Try again shortly.', + ); + } + throw new ChatProviderError( + 'PROVIDER_REQUEST_FAILED', + 'The provider returned an error response.', + ); + } + + let rawText: string; + try { + rawText = await readBoundedText(response, MAX_RESPONSE_BYTES); + } catch (error) { + throw new ChatProviderError( + 'PROVIDER_REQUEST_FAILED', + 'The provider response was too large or could not be read.', + { cause: error }, + ); + } + + let parsedJson: unknown; + try { + parsedJson = JSON.parse(rawText); + } catch (error) { + throw new ChatProviderError( + 'PROVIDER_REQUEST_FAILED', + 'The provider returned a malformed response.', + { cause: error }, + ); + } + + const wireResult = chatCompletionResponseSchema.safeParse(parsedJson); + const content = wireResult.success ? wireResult.data.choices[0]?.message.content : undefined; + if (typeof content !== 'string') { + throw new ChatProviderError( + 'PROVIDER_REQUEST_FAILED', + 'The provider returned a malformed response.', + ); + } + + const result = chatProviderResultSchema.safeParse({ content }); + if (!result.success) { + throw new ChatProviderError( + 'PROVIDER_REQUEST_FAILED', + 'The provider returned a malformed response.', + ); + } + + return result.data; + }, + }; +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 97d7d60..7c1923e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,7 +1,11 @@ import { contextBridge, ipcRenderer } from 'electron'; import { + chatCancelResponseSchema, + chatSendResponseSchema, healthCheckResponseSchema, + IPC_CHAT_CANCEL_CHANNEL, + IPC_CHAT_SEND_CHANNEL, IPC_HEALTH_CHANNEL, IPC_SECRETS_CLEAR_CHANNEL, IPC_SECRETS_STATUS_CHANNEL, @@ -13,6 +17,8 @@ import { secretsWriteResponseSchema, settingsGetResponseSchema, settingsUpdateResponseSchema, + type ChatMessage, + type ChatSendResponse, type HealthCheckResponse, type SecretsActionResponse, type SettingsActionResponse, @@ -67,6 +73,31 @@ const bridge = { return secretsClearResponseSchema.parse(result); }, }, + /** + * The one network-capable pair of channels (Phase 2, Milestone 3). Neither + * accepts or returns an API key, a header, or a provider URL — `send` + * takes only the conversation itself; the main process resolves which + * provider and which stored credential to use from settings and the + * encrypted secret store, never from a renderer-supplied value. `cancel` + * is fire-and-forget best effort: it asks the main process to abort a + * `send` already in flight, identified by the same `requestId`. + */ + chat: { + send: async ( + requestId: string, + messages: readonly ChatMessage[], + ): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_CHAT_SEND_CHANNEL, { + requestId, + messages, + }); + return chatSendResponseSchema.parse(result); + }, + cancel: async (requestId: string): Promise => { + const result: unknown = await ipcRenderer.invoke(IPC_CHAT_CANCEL_CHANNEL, { requestId }); + chatCancelResponseSchema.parse(result); + }, + }, }; contextBridge.exposeInMainWorld('localAgent', bridge); diff --git a/src/renderer/chat/ipc-chat-provider.ts b/src/renderer/chat/ipc-chat-provider.ts new file mode 100644 index 0000000..38c44ba --- /dev/null +++ b/src/renderer/chat/ipc-chat-provider.ts @@ -0,0 +1,92 @@ +/** + * Renderer-side `ChatProvider` backed by the real, network-capable adapter + * that lives in the main process (Phase 2, Milestone 3). + * + * This is the one file under `src/renderer/chat` permitted to reference + * `window.localAgent` — `tests/unit/shared/chat-boundary-scan.test.ts` + * asserts that no other file in this directory does. Every other chat file + * stays exactly as renderer-local as it was in Milestones 1-2: no IPC, no + * network, no secret. + * + * No network call, no secret, and no privileged API is reachable from this + * file directly — it only ever calls the two narrow, typed, + * schema-validated functions the preload bridge exposes (`chat.send`, + * `chat.cancel`). The actual HTTP request, the API key, and the provider + * selection all live in the main process; see + * `src/main/chat-provider-registry.ts` and + * `src/main/openai-compatible-provider.ts`. + * + * Never trusts text that crossed the IPC boundary: on failure, only the + * bounded `errorCode` enum from `chatSendResponseSchema` is read. The + * message shown to the user is always one of this codebase's own fixed, + * reviewed strings (`ConversationController`'s `describeProviderFailure`), + * never anything reconstructed from main-process input. + */ + +import { + CHAT_PROVIDER_ERROR_CODES, + ChatProviderError, + type ChatProvider, + type ChatProviderErrorCode, + type ChatProviderRequest, + type ChatProviderRequestOptions, + type ChatProviderResult, +} from '../../shared/chat'; + +/** The identifier for this adapter, distinct from `MOCK_CHAT_PROVIDER_ID`. */ +export const IPC_CHAT_PROVIDER_ID = 'openai-compatible'; + +const GENERIC_IPC_FAILURE_MESSAGE = 'The provider request did not succeed.'; + +function isKnownErrorCode(code: string | undefined): code is ChatProviderErrorCode { + return code !== undefined && (CHAT_PROVIDER_ERROR_CODES as readonly string[]).includes(code); +} + +/** + * Creates the `ChatProvider` that reaches the real adapter through + * `chat:send`/`chat:cancel`. Cancellation is cooperative: aborting the + * caller's `AbortSignal` asks the main process (best effort) to abort the + * matching in-flight request, and always rejects locally with + * `PROVIDER_ABORTED` regardless of whether that message is acknowledged in + * time — the caller's own signal, not the round trip, is authoritative for + * whether this call is considered cancelled. + */ +export function createIpcChatProvider(): ChatProvider { + return { + id: IPC_CHAT_PROVIDER_ID, + async send( + request: ChatProviderRequest, + options?: ChatProviderRequestOptions, + ): Promise { + const signal = options?.signal; + if (signal?.aborted) { + throw new ChatProviderError('PROVIDER_ABORTED', GENERIC_IPC_FAILURE_MESSAGE); + } + + const requestId = crypto.randomUUID(); + const onAbort = (): void => { + void window.localAgent.chat.cancel(requestId); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + + try { + const response = await window.localAgent.chat.send(requestId, [...request.messages]); + + if (signal?.aborted) { + throw new ChatProviderError('PROVIDER_ABORTED', GENERIC_IPC_FAILURE_MESSAGE); + } + + if (response.outcome === 'success' && response.content !== undefined) { + return { content: response.content }; + } + + const code = isKnownErrorCode(response.errorCode) + ? response.errorCode + : 'PROVIDER_REQUEST_FAILED'; + throw new ChatProviderError(code, GENERIC_IPC_FAILURE_MESSAGE); + } finally { + signal?.removeEventListener('abort', onAbort); + } + }, + }; +} diff --git a/src/renderer/chat/useActiveChatProvider.ts b/src/renderer/chat/useActiveChatProvider.ts index 701271c..c0e53fe 100644 --- a/src/renderer/chat/useActiveChatProvider.ts +++ b/src/renderer/chat/useActiveChatProvider.ts @@ -1,30 +1,34 @@ /** * Resolves the active `ChatProvider` and its display status (Phase 2, - * Milestone 2). + * Milestones 2-3). * * This is the one, explicit place that decides which adapter powers chat — - * `Chat.tsx` no longer imports `createMockChatProvider` itself. In this - * milestone the answer is always the deterministic mock, for every one of - * the four approved `ModelProvider` selections, because it is the only - * adapter that actually works: `createChatProviderForSelection` (the - * registry) returns a fail-closed placeholder for all four, by design — see - * `docs/phase-2-provider-architecture.md` for why chat still functions - * through the mock rather than going inert whenever no provider is - * configured, while `describeChatProviderStatus` still reports honestly on - * what the user actually selected. + * `Chat.tsx` imports neither `createMockChatProvider` nor + * `createIpcChatProvider` itself. The decision is + * `getChatProviderCapabilities(modelProvider.provider).implemented`: `true` + * only for `openai-compatible` since Milestone 3 (a real, network-capable + * adapter exists for it in `src/main`, reached through `chat:send`), still + * `false` for `none`, `glm` and `ollama` — those keep talking to the + * deterministic mock, exactly as every identifier did in Milestone 2. See + * `docs/phase-2-provider-architecture.md` and + * `docs/phase-2-real-provider-architecture.md`. * - * The provider instance is stable for the component's lifetime (`useRef`, + * Both provider instances are stable for the component's lifetime (`useRef`, * not re-created per render), independent of `status`, which does change - * with `modelProvider.provider` — so a settings change never itself resets - * the conversation, even before `ConversationController.setProvider`'s own - * same-reference no-op guard is considered. + * with `modelProvider` — so a settings change never itself resets the + * conversation, even before `ConversationController.setProvider`'s own + * same-reference no-op guard is considered. Switching between the mock and + * the real adapter goes through that same `setProvider` path as switching + * between two mock instances would — no special case. */ import { useRef } from 'react'; +import { createIpcChatProvider } from './ipc-chat-provider'; import { createMockChatProvider, describeChatProviderStatus, + getChatProviderCapabilities, type ChatProvider, type ChatProviderStatus, } from '../../shared/chat'; @@ -36,11 +40,16 @@ export interface ActiveChatProvider { } export function useActiveChatProvider(modelProvider: ModelProviderSettings): ActiveChatProvider { - const providerRef = useRef(null); - providerRef.current ??= createMockChatProvider(); + const mockProviderRef = useRef(null); + mockProviderRef.current ??= createMockChatProvider(); + + const ipcProviderRef = useRef(null); + ipcProviderRef.current ??= createIpcChatProvider(); + + const capabilities = getChatProviderCapabilities(modelProvider.provider); return { - provider: providerRef.current, - status: describeChatProviderStatus(modelProvider.provider), + provider: capabilities.implemented ? ipcProviderRef.current : mockProviderRef.current, + status: describeChatProviderStatus(modelProvider), }; } diff --git a/src/renderer/local-agent-bridge.d.ts b/src/renderer/local-agent-bridge.d.ts index 0bf0f7d..07f31d5 100644 --- a/src/renderer/local-agent-bridge.d.ts +++ b/src/renderer/local-agent-bridge.d.ts @@ -9,6 +9,8 @@ */ import type { + ChatMessage, + ChatSendResponse, HealthCheckResponse, SecretsActionResponse, SettingsActionResponse, @@ -30,6 +32,13 @@ declare global { readonly write: (apiKey: string) => Promise; readonly clear: () => Promise; }; + readonly chat: { + readonly send: ( + requestId: string, + messages: readonly ChatMessage[], + ) => Promise; + readonly cancel: (requestId: string) => Promise; + }; }; } } diff --git a/src/shared/chat/index.ts b/src/shared/chat/index.ts index 645b81e..d024e8d 100644 --- a/src/shared/chat/index.ts +++ b/src/shared/chat/index.ts @@ -28,6 +28,7 @@ export type { ChatProviderAvailability, ChatProviderCapabilities, ChatProviderStatus, + ChatProviderStatusInput, } from './registry'; export { withProviderTimeout } from './timeout'; diff --git a/src/shared/chat/registry.ts b/src/shared/chat/registry.ts index b1cd40a..74147a8 100644 --- a/src/shared/chat/registry.ts +++ b/src/shared/chat/registry.ts @@ -1,24 +1,34 @@ /** - * Approved-provider registry (Phase 2, Milestone 2). + * Approved-provider registry (Phase 2, Milestones 2-3). * * Maps the four settings-level provider identifiers (`none`, `glm`, * `openai-compatible`, `ollama` — {@link MODEL_PROVIDERS}) to safe capability - * metadata and to a `ChatProvider` adapter. No real network adapter exists - * for any of them yet, so {@link createChatProviderForSelection} returns a - * fail-closed placeholder for all four: asking any of them to `send()` - * always rejects with a normalized `PROVIDER_UNAVAILABLE` error, never a - * silent success and never a real request. This is deliberate, not a gap — - * see `docs/phase-2-provider-architecture.md` for why `none` and the three - * unimplemented real identifiers are treated identically at the `send()` - * boundary and are distinguished only by status text, never by one of them - * quietly working. + * metadata and to a `ChatProvider` adapter. + * + * **{@link createChatProviderForSelection} in this file always fails + * closed, for all four identifiers, including `openai-compatible` — this + * has not changed since Milestone 2 and never will while this function + * lives under `src/shared`.** `src/shared` cannot perform network access — + * `eslint.config.js`'s purity boundary blocks `fetch` and every other + * network-capable global here, structurally, not by convention — so a + * "registry" living in this directory can never itself place a real call. + * Milestone 3's real, network-capable `openai-compatible` adapter lives in + * `src/main/openai-compatible-provider.ts` and is resolved by + * `src/main/chat-provider-registry.ts`'s `resolveMainChatProvider`, reached + * from the renderer only through the `chat:send` IPC channel + * (`main/ipc.ts`) — never through this function. This module's + * {@link ChatProviderCapabilities.implemented} flag reflects that a real + * adapter exists *somewhere* in the codebase for `openai-compatible` now; + * `createChatProviderForSelection` reflects what *this* file can do, which + * is, and stays, nothing. See `docs/phase-2-provider-architecture.md` and + * `docs/phase-2-real-provider-architecture.md`. * * The deterministic mock (`./mock-provider.ts`) is a separate, always- - * available adapter — for tests, and as the one thing that actually answers - * a chat message in this phase. `src/renderer/chat/useActiveChatProvider.ts` - * is the single, explicit place that decides to use it; nothing here wires - * it in automatically for any approved identifier, so the fail-closed - * guarantee below is real and independently testable. + * available adapter — for tests, and as what answers a chat message for + * every identifier `implemented` is still `false` for. + * `src/renderer/chat/useActiveChatProvider.ts` is the single, explicit place + * that decides between the mock and the real IPC-backed adapter, based on + * {@link ChatProviderCapabilities.implemented}. * * No I/O, no Electron, no network — the same `src/shared` purity boundary as * every file in this directory. @@ -34,7 +44,12 @@ export interface ChatProviderCapabilities { readonly label: string; /** Whether this provider authenticates with a key from the encrypted secret store. */ readonly requiresApiKey: boolean; - /** Whether a real, network-capable adapter exists for this identifier yet. Always `false` in Phase 2. */ + /** + * Whether a real, network-capable adapter exists for this identifier + * *anywhere in the codebase* — never inside `src/shared`, always in + * `src/main`, reached only through IPC. `true` for `openai-compatible` + * since Milestone 3; `false` for `none`, `glm` and `ollama`, still. + */ readonly implemented: boolean; } @@ -45,6 +60,15 @@ const LABEL_BY_PROVIDER: Readonly> = { ollama: 'Ollama (local)', }; +/** + * Identifiers with a real, network-capable adapter somewhere in the + * codebase (always `src/main`, never here). The single place this milestone + * flips an identifier from mock-only to real — see + * `src/renderer/chat/useActiveChatProvider.ts`, the one thing that reads + * this through {@link getChatProviderCapabilities}. + */ +const IMPLEMENTED_PROVIDERS: ReadonlySet = new Set(['openai-compatible']); + /** * Safe capability metadata for an approved provider identifier. * @@ -58,30 +82,61 @@ export function getChatProviderCapabilities(id: ModelProvider): ChatProviderCapa id, label: LABEL_BY_PROVIDER[id], requiresApiKey: PROVIDERS_REQUIRING_API_KEY.includes(id), - implemented: false, + implemented: IMPLEMENTED_PROVIDERS.has(id), }; } -/** What the interface should tell the user about the currently selected provider. */ -export type ChatProviderAvailability = 'not-configured' | 'not-implemented'; +/** + * What the interface should tell the user about the currently selected + * provider: + * + * - `not-configured` — `none` is selected. Chat runs on the mock. + * - `not-implemented` — a real identifier with no adapter yet (`glm`, + * `ollama`) is selected. Chat runs on the mock. + * - `missing-api-key` — an implemented, key-requiring provider is selected, + * but no key is stored. Chat uses the real adapter regardless; a missing + * key surfaces as an ordinary `PROVIDER_INVALID_CONFIGURATION` failure + * through the existing error/retry UI the first time a message is sent, + * rather than this status function guessing at what a real send would do. + * - `ready` — an implemented provider is selected and, if it requires one, + * a key is stored. Chat uses the real adapter. + */ +export type ChatProviderAvailability = + 'not-configured' | 'not-implemented' | 'missing-api-key' | 'ready'; export interface ChatProviderStatus { readonly selected: ModelProvider; readonly availability: ChatProviderAvailability; /** * Safe, user-facing summary text. Never contains a key, a header, a URL, - * or any value from the encrypted secret store — it is built entirely from - * the fixed labels above and a fixed template, never from settings input. + * or any value from the encrypted secret store beyond the boolean + * `hasApiKey` this function's input already carries — built entirely from + * fixed labels and fixed templates, never from settings input rendered + * verbatim. */ readonly summary: string; } +/** The fields {@link describeChatProviderStatus} actually reads. */ +export interface ChatProviderStatusInput { + readonly provider: ModelProvider; + readonly hasApiKey: boolean; +} + /** - * Describes the currently selected provider for display, without implying a - * real connection exists — none does yet. + * Describes the currently selected provider for display. + * + * Does not itself attempt a connection or a decrypt — `hasApiKey` is the + * same already-reconciled boolean `settings:get` returns, not something + * this function checks freshly. For `ready`, "ready" means "chat will use + * the real adapter for this identifier," not "this endpoint and key are + * known-good" — that can only be discovered by actually sending a message, + * exactly as for any other network call. */ -export function describeChatProviderStatus(selected: ModelProvider): ChatProviderStatus { +export function describeChatProviderStatus(input: ChatProviderStatusInput): ChatProviderStatus { + const { provider: selected, hasApiKey } = input; const capabilities = getChatProviderCapabilities(selected); + if (selected === 'none') { return { selected, @@ -89,10 +144,27 @@ export function describeChatProviderStatus(selected: ModelProvider): ChatProvide summary: 'No provider configured. Chat is running on the local mock provider.', }; } + + if (!capabilities.implemented) { + return { + selected, + availability: 'not-implemented', + summary: `${capabilities.label} is selected but not connected yet. Chat is running on the local mock provider.`, + }; + } + + if (capabilities.requiresApiKey && !hasApiKey) { + return { + selected, + availability: 'missing-api-key', + summary: `${capabilities.label} is selected, but no API key is stored yet. Add one in provider settings.`, + }; + } + return { selected, - availability: 'not-implemented', - summary: `${capabilities.label} is selected but not connected yet. Chat is running on the local mock provider.`, + availability: 'ready', + summary: `Using ${capabilities.label}.`, }; } @@ -100,7 +172,7 @@ const UNAVAILABLE_MESSAGE_BY_PROVIDER: Readonly> = none: 'No provider is configured. Choose one in provider settings.', glm: 'GLM is configured but not yet connected in this version.', 'openai-compatible': - 'This OpenAI-compatible endpoint is configured but not yet connected in this version.', + 'This function never reaches the network; use chat:send for the real OpenAI-compatible adapter.', ollama: 'Ollama is configured but not yet connected in this version.', }; @@ -120,15 +192,21 @@ function createUnavailableChatProvider(id: string, message: string): ChatProvide } /** - * Resolves the `ChatProvider` for an approved settings-level identifier. + * Resolves the `ChatProvider` for an approved settings-level identifier — + * **from inside `src/shared` only**, which is why this always fails closed, + * for all four identifiers, `openai-compatible` included, regardless of + * {@link ChatProviderCapabilities.implemented}. No branch of this specific + * function can ever place a network request or read a credential, because + * this file cannot: see this module's own doc comment. The real + * `openai-compatible` adapter is `src/main/openai-compatible-provider.ts`, + * resolved by `src/main/chat-provider-registry.ts`, reached only through the + * `chat:send` IPC channel — a different registry, in a different process, + * for a reason this one cannot satisfy by construction. * - * Every one of the four approved identifiers — including `none` — maps to a - * fail-closed adapter in this milestone: none of them makes a network - * request, and none of them silently succeeds. An identifier outside - * {@link MODEL_PROVIDERS} (which should not happen, since `ModelProvider` is - * a closed type validated by `settingsSchema`, but may reach here through an - * untrusted boundary that bypassed that validation) fails closed the same - * way rather than throwing. + * An identifier outside {@link MODEL_PROVIDERS} (which should not happen, + * since `ModelProvider` is a closed type validated by `settingsSchema`, but + * may reach here through an untrusted boundary that bypassed that + * validation) fails closed the same way rather than throwing. */ export function createChatProviderForSelection(id: ModelProvider): ChatProvider { if (!MODEL_PROVIDERS.includes(id)) { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7f55623..b595609 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -136,12 +136,16 @@ export const BIDI_CONTROL_PATTERN = /[\u202A-\u202E\u2066-\u2069]/; // --------------------------------------------------------------------------- /** - * Every privileged operation available in Phase 1. + * Every privileged operation available so far. * - * Phase 1 deliberately contains no filesystem tool, no shell execution and no - * network action. The list stays this short until a later phase adds one, and - * every addition needs a matching policy rule because unmatched actions are - * denied. + * Phase 1 contained no filesystem tool, no shell execution and no network + * action. Phase 2, Milestone 3 adds exactly one network-capable action, + * `chat.send` — sending the current conversation to the provider the user + * selected in settings, through the permission engine like every other + * action here, never directly from an IPC handler. It remains the only + * network-capable action: no filesystem tool, no shell execution, and no + * generic "make a request" action exist. Every addition needs a matching + * policy rule because unmatched actions are denied. */ export const ACTION_TYPES = [ 'settings.read', @@ -153,6 +157,7 @@ export const ACTION_TYPES = [ 'emergency.engage', 'emergency.reset', 'app.exit', + 'chat.send', ] as const; export type ActionType = (typeof ACTION_TYPES)[number]; diff --git a/src/shared/schemas/chat.schema.ts b/src/shared/schemas/chat.schema.ts index d8f0da5..aad1fd1 100644 --- a/src/shared/schemas/chat.schema.ts +++ b/src/shared/schemas/chat.schema.ts @@ -29,11 +29,13 @@ import type { AuditParameters } from './audit-parameters.schema'; /** * Bounded, control-character- and bidi-safe text, shared by a message's own - * `content` and by {@link chatProviderResultSchema}'s `content` — the same + * `content`, by {@link chatProviderResultSchema}'s `content`, and by + * `chatSendResponseSchema` (`ipc.schema.ts`, Phase 2 Milestone 3) — the same * safety rules apply the moment either crosses a validation boundary, - * whether that text was typed by the user or returned by a provider. + * whether that text was typed by the user, returned by a provider, or + * crossed the IPC boundary from the main process's real adapter. */ -const chatContentSchema = z +export const chatContentSchema = z .string() .min(CHAT_MESSAGE_CONTENT_MIN_LENGTH) .max(CHAT_MESSAGE_CONTENT_MAX_LENGTH) diff --git a/src/shared/schemas/index.ts b/src/shared/schemas/index.ts index 320d1cd..29777a7 100644 --- a/src/shared/schemas/index.ts +++ b/src/shared/schemas/index.ts @@ -57,8 +57,14 @@ export type { } from './emergency.schema'; export { + chatCancelRequestSchema, + chatCancelResponseSchema, + chatSendRequestSchema, + chatSendResponseSchema, healthCheckRequestSchema, healthCheckResponseSchema, + IPC_CHAT_CANCEL_CHANNEL, + IPC_CHAT_SEND_CHANNEL, IPC_HEALTH_CHANNEL, IPC_SECRETS_CLEAR_CHANNEL, IPC_SECRETS_STATUS_CHANNEL, @@ -78,6 +84,9 @@ export { settingsUpdateResponseSchema, } from './ipc.schema'; export type { + ChatCancelResponse, + ChatSendRequestInput, + ChatSendResponse, HealthCheckResponse, SecretsActionResponse, SecretStatusResult, @@ -89,6 +98,7 @@ export { createEmptySecretStoreFile, secretStoreFileSchema } from './secrets.sch export type { SecretStoreFile } from './secrets.schema'; export { + chatContentSchema, chatMessageMetadataSchema, chatMessageSchema, chatProviderRequestSchema, diff --git a/src/shared/schemas/ipc.schema.ts b/src/shared/schemas/ipc.schema.ts index 0dbefc0..5f1f27c 100644 --- a/src/shared/schemas/ipc.schema.ts +++ b/src/shared/schemas/ipc.schema.ts @@ -36,12 +36,15 @@ import { z } from 'zod'; +import { CHAT_PROVIDER_ERROR_CODES } from '../chat/provider'; import { API_KEY_MAX_LENGTH, API_KEY_MIN_LENGTH, AUDIT_OUTCOMES, + CHAT_CONVERSATION_MAX_MESSAGES, CONTROL_CHARACTER_PATTERN, } from '../constants'; +import { chatContentSchema, chatMessageSchema } from './chat.schema'; import { assistantSettingsSchema, languageSettingsSchema, @@ -165,3 +168,66 @@ export const secretsWriteResponseSchema = secretsActionResponseSchema; export const secretsClearResponseSchema = secretsActionResponseSchema; export type SecretsActionResponse = z.infer; + +// --------------------------------------------------------------------------- +// Chat: chat.send (Phase 2, Milestone 3) +// +// The one network-capable action in this codebase. `chat:send` routes +// through the same `handleActionProposal` pipeline as every channel above — +// see `main/ipc.ts` — so a real provider call is permission-gated, +// emergency-stop-gated and audited exactly like `secrets.write` is, never +// reached directly from this IPC handler. `chat:cancel` has no side effect +// of its own: it only asks the main process to abort a `chat:send` call +// already in flight and already authorized, so it is not routed through the +// permission engine — see `main/ipc.ts`'s handler for the reasoning. +// +// Neither request schema below accepts an API key, a header, or a provider +// URL: the request carries only the conversation itself. `main/ipc.ts` +// resolves which provider to call, and with which stored credential, from +// settings and the encrypted secret store — never from renderer input. +// --------------------------------------------------------------------------- + +export const IPC_CHAT_SEND_CHANNEL = 'chat:send'; +export const IPC_CHAT_CANCEL_CHANNEL = 'chat:cancel'; + +export const chatSendRequestSchema = z.tuple([ + z.strictObject({ + /** Correlates a later `chat:cancel` call to this specific in-flight request. */ + requestId: z.uuid(), + messages: z.array(chatMessageSchema).max(CHAT_CONVERSATION_MAX_MESSAGES), + }), +]); + +export type ChatSendRequestInput = z.infer[0]; + +/** + * `content` is present only on `outcome: 'success'`; `errorCode` is present + * only otherwise, and is always one of {@link CHAT_PROVIDER_ERROR_CODES} — + * the same normalized vocabulary `ChatProvider` implementations already + * throw, reused rather than re-invented at this boundary. Never a raw + * provider error message, a URL, a header, or a credential. + */ +export const chatSendResponseSchema = z.strictObject({ + outcome: z.enum(AUDIT_OUTCOMES), + content: chatContentSchema.optional(), + errorCode: z.enum(CHAT_PROVIDER_ERROR_CODES).optional(), +}); + +export type ChatSendResponse = z.infer; + +export const chatCancelRequestSchema = z.tuple([ + z.strictObject({ + requestId: z.uuid(), + }), +]); + +/** + * Always `{ acknowledged: true }`. Cancellation is best-effort and + * idempotent — asking to cancel a request that already finished, or one that + * never existed, is not an error; there is simply nothing left to abort. + */ +export const chatCancelResponseSchema = z.strictObject({ + acknowledged: z.literal(true), +}); + +export type ChatCancelResponse = z.infer; diff --git a/src/shared/schemas/permissions.schema.ts b/src/shared/schemas/permissions.schema.ts index abb55d5..b3193c1 100644 --- a/src/shared/schemas/permissions.schema.ts +++ b/src/shared/schemas/permissions.schema.ts @@ -196,6 +196,14 @@ export function createDefaultPermissionPolicy(): PermissionPolicy { priority: 100, reason: 'Exiting is user-visible and interruptive.', }, + { + id: 'chat.send', + actionType: 'chat.send', + decision: 'allow', + priority: 100, + reason: + 'Sending a chat message is a direct, per-message user action, not a background or model-initiated one; the send itself is the consent.', + }, ], }; } diff --git a/tests/e2e/electron-smoke.test.ts b/tests/e2e/electron-smoke.test.ts index 07d58c2..d6f7457 100644 --- a/tests/e2e/electron-smoke.test.ts +++ b/tests/e2e/electron-smoke.test.ts @@ -30,17 +30,19 @@ interface ExposedLocalAgentBridge { } /** - * The complete, narrow bridge surface as of Milestone 7. Asserted by exact - * key list rather than "at least these" so that a future channel added - * without updating this test fails loudly here — the same reason the + * The complete, narrow bridge surface as of Phase 2, Milestone 3. Asserted + * by exact key list rather than "at least these" so that a future channel + * added without updating this test fails loudly here — the same reason the * Milestone 2 version of this test asserted `['health']` exactly. `settings` * and `secrets` were added in Milestone 7 for onboarding, provider settings, - * and the encrypted secret store; both stay two narrow, explicitly named - * sub-objects, never a generic invoke surface. + * and the encrypted secret store; `chat` was added in Phase 2 Milestone 3 + * for the one network-capable action in this codebase. Every one stays a + * narrow, explicitly named sub-object, never a generic invoke surface. */ -const EXPECTED_BRIDGE_KEYS = ['health', 'secrets', 'settings'] as const; +const EXPECTED_BRIDGE_KEYS = ['chat', 'health', 'secrets', 'settings'] as const; const EXPECTED_SETTINGS_KEYS = ['get', 'update'] as const; const EXPECTED_SECRETS_KEYS = ['clear', 'status', 'write'] as const; +const EXPECTED_CHAT_KEYS = ['cancel', 'send'] as const; function launchEnv(): Record { const env: Record = {}; @@ -113,10 +115,13 @@ describe('Electron desktop shell — security and health-check smoke test', () = expect(hasIpcRenderer).toBe(false); }); - it('exposes exactly one bridge object with exactly the narrow, named functions of Milestone 7', async () => { + it('exposes exactly one bridge object with exactly the narrow, named functions of Phase 2 Milestone 3', async () => { const bridgeShape = await page.evaluate(() => { const w = window as unknown as { - localAgent?: { settings?: object; secrets?: object } & Record; + localAgent?: { settings?: object; secrets?: object; chat?: object } & Record< + string, + unknown + >; }; const localAgent = w.localAgent; return { @@ -124,6 +129,7 @@ describe('Electron desktop shell — security and health-check smoke test', () = keys: localAgent ? Object.keys(localAgent).sort() : [], settingsKeys: localAgent?.settings ? Object.keys(localAgent.settings).sort() : [], secretsKeys: localAgent?.secrets ? Object.keys(localAgent.secrets).sort() : [], + chatKeys: localAgent?.chat ? Object.keys(localAgent.chat).sort() : [], }; }); expect(bridgeShape).toEqual({ @@ -131,6 +137,7 @@ describe('Electron desktop shell — security and health-check smoke test', () = keys: [...EXPECTED_BRIDGE_KEYS], settingsKeys: [...EXPECTED_SETTINGS_KEYS], secretsKeys: [...EXPECTED_SECRETS_KEYS], + chatKeys: [...EXPECTED_CHAT_KEYS], }); }); @@ -142,9 +149,10 @@ describe('Electron desktop shell — security and health-check smoke test', () = | (Record & { settings?: Record; secrets?: Record; + chat?: Record; }) | undefined; - const nested = [localAgent?.settings, localAgent?.secrets].filter( + const nested = [localAgent?.settings, localAgent?.secrets, localAgent?.chat].filter( (value): value is Record => value !== undefined, ); return ( @@ -156,12 +164,15 @@ describe('Electron desktop shell — security and health-check smoke test', () = expect(hasGenericInvoke).toBe(false); }); - // Deliberately no test here calls `settings.*` or `secrets.*` against the - // real running app: unlike `health`, both have real side effects (an audit - // write, a settings read/write) against the *real* `%APPDATA%\Local-Agent\` - // — this suite never overrides that path, so exercising them belongs to - // `tests/unit/main/ipc.test.ts`, which does so against a temporary - // directory. This suite only inspects the bridge's static shape. + // Deliberately no test here calls `settings.*`, `secrets.*`, or `chat.*` + // against the real running app: unlike `health`, all three have real side + // effects (an audit write, a settings read/write, and — for `chat.send` — + // an actual network request to whatever provider is configured) against + // the *real* `%APPDATA%\Local-Agent\` — this suite never overrides that + // path and must never make a real network call in a test, so exercising + // them belongs to `tests/unit/main/ipc.test.ts`, which does so against a + // temporary directory and a mocked `fetch`. This suite only inspects the + // bridge's static shape. it('answers the named, schema-validated health-check channel', async () => { const result = await page.evaluate(async () => { diff --git a/tests/unit/main/chat-provider-registry.test.ts b/tests/unit/main/chat-provider-registry.test.ts new file mode 100644 index 0000000..76edb93 --- /dev/null +++ b/tests/unit/main/chat-provider-registry.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { SafeStorage } from 'electron'; + +import { + OPENAI_COMPATIBLE_REQUEST_TIMEOUT_MS, + resolveMainChatProvider, +} from '../../../src/main/chat-provider-registry'; +import { writeSecret } from '../../../src/main/secrets'; +import { MODEL_PROVIDERS, type ModelProvider } from '../../../src/shared/constants'; +import type { ModelProviderSettings } from '../../../src/shared/schemas'; + +const PLAINTEXT_KEY = 'sk-test-not-a-real-key'; + +function fakeSafeStorage(available = true): SafeStorage { + return { + isEncryptionAvailable: () => available, + encryptString: (plainText: string) => Buffer.from(`fake-enc:${plainText}`, 'utf8'), + decryptString: (encrypted: Buffer) => encrypted.toString('utf8').slice('fake-enc:'.length), + } as unknown as SafeStorage; +} + +function settings(overrides: Partial = {}): ModelProviderSettings { + return { + provider: 'openai-compatible', + model: 'gpt-test', + baseUrl: 'https://api.example.test/v1', + hasApiKey: false, + ...overrides, + }; +} + +let dir: string; +let secretsFile: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'local-agent-chat-registry-')); + secretsFile = join(dir, 'secrets', 'secrets.enc'); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('resolveMainChatProvider — identifiers without a real adapter', () => { + it.each(['none', 'glm', 'ollama'] as const)( + 'delegates %s to the shared fail-closed registry', + async (provider) => { + const chatProvider = await resolveMainChatProvider({ + modelProvider: settings({ provider }), + secretsFile, + safeStorage: fakeSafeStorage(), + }); + await expect(chatProvider.send({ messages: [] })).rejects.toMatchObject({ + code: 'PROVIDER_UNAVAILABLE', + }); + }, + ); + + it('never calls fetch for an unimplemented identifier', async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + const chatProvider = await resolveMainChatProvider({ + modelProvider: settings({ provider: 'none' }), + secretsFile, + safeStorage: fakeSafeStorage(), + }); + await chatProvider.send({ messages: [] }).catch(() => undefined); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe('resolveMainChatProvider — openai-compatible configuration checks', () => { + it('fails closed with PROVIDER_INVALID_CONFIGURATION when baseUrl is empty', async () => { + const chatProvider = await resolveMainChatProvider({ + modelProvider: settings({ baseUrl: '' }), + secretsFile, + safeStorage: fakeSafeStorage(), + }); + await expect(chatProvider.send({ messages: [] })).rejects.toMatchObject({ + code: 'PROVIDER_INVALID_CONFIGURATION', + }); + }); + + it('fails closed with PROVIDER_INVALID_CONFIGURATION when model is empty', async () => { + const chatProvider = await resolveMainChatProvider({ + modelProvider: settings({ model: '' }), + secretsFile, + safeStorage: fakeSafeStorage(), + }); + await expect(chatProvider.send({ messages: [] })).rejects.toMatchObject({ + code: 'PROVIDER_INVALID_CONFIGURATION', + }); + }); + + it('fails closed with PROVIDER_INVALID_CONFIGURATION when no key is stored', async () => { + const chatProvider = await resolveMainChatProvider({ + modelProvider: settings(), + secretsFile, + safeStorage: fakeSafeStorage(), + }); + await expect(chatProvider.send({ messages: [] })).rejects.toMatchObject({ + code: 'PROVIDER_INVALID_CONFIGURATION', + }); + }); + + it('never calls fetch when configuration is incomplete', async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + const chatProvider = await resolveMainChatProvider({ + modelProvider: settings({ baseUrl: '' }), + secretsFile, + safeStorage: fakeSafeStorage(), + }); + await chatProvider.send({ messages: [] }).catch(() => undefined); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe('resolveMainChatProvider — openai-compatible, fully configured', () => { + it('resolves a real provider that calls fetch and returns content once a key is stored', async () => { + await writeSecret(secretsFile, PLAINTEXT_KEY, fakeSafeStorage()); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ choices: [{ message: { content: 'hi there' } }] }), { + status: 200, + }), + ), + ); + + const chatProvider = await resolveMainChatProvider({ + modelProvider: settings(), + secretsFile, + safeStorage: fakeSafeStorage(), + }); + const result = await chatProvider.send({ messages: [] }); + expect(result.content).toBe('hi there'); + }); + + it('is wrapped in the shared timeout decorator, using OPENAI_COMPATIBLE_REQUEST_TIMEOUT_MS', async () => { + await writeSecret(secretsFile, PLAINTEXT_KEY, fakeSafeStorage()); + vi.useFakeTimers(); + // Mirrors real `fetch`'s own behaviour: a passed `signal` rejects the + // pending request once it aborts. Without this, the mock would hang + // forever instead of ever letting the timeout decorator's own `await` + // settle, since the decorator only reacts to the *wrapped call* + // rejecting — it does not race a timer against the call itself. + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation((_url: string, init: { signal?: AbortSignal }) => { + return new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => { + reject(new DOMException('The operation was aborted.', 'AbortError')); + }); + }); + }), + ); + + const chatProvider = await resolveMainChatProvider({ + modelProvider: settings(), + secretsFile, + safeStorage: fakeSafeStorage(), + }); + + const pending = chatProvider.send({ messages: [] }); + const assertion = expect(pending).rejects.toMatchObject({ code: 'PROVIDER_TIMEOUT' }); + await vi.advanceTimersByTimeAsync(OPENAI_COMPATIBLE_REQUEST_TIMEOUT_MS + 1); + await assertion; + }); + + it('fails closed with PROVIDER_INVALID_CONFIGURATION when safeStorage cannot decrypt', async () => { + await writeSecret(secretsFile, PLAINTEXT_KEY, fakeSafeStorage()); + const chatProvider = await resolveMainChatProvider({ + modelProvider: settings(), + secretsFile, + // A different fake store cannot decrypt what the one above encrypted. + safeStorage: fakeSafeStorage(false), + }); + await expect(chatProvider.send({ messages: [] })).rejects.toMatchObject({ + code: 'PROVIDER_INVALID_CONFIGURATION', + }); + }); +}); + +describe('resolveMainChatProvider — every approved identifier resolves to something', () => { + it.each(MODEL_PROVIDERS)('never throws synchronously for %s', async (provider: ModelProvider) => { + await expect( + resolveMainChatProvider({ + modelProvider: settings({ provider }), + secretsFile, + safeStorage: fakeSafeStorage(), + }), + ).resolves.toBeDefined(); + }); +}); diff --git a/tests/unit/main/ipc.test.ts b/tests/unit/main/ipc.test.ts index f3dcc14..3152e4f 100644 --- a/tests/unit/main/ipc.test.ts +++ b/tests/unit/main/ipc.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -13,18 +14,26 @@ import { } from '../../../src/main/ipc'; import type { IpcHandlerRuntime } from '../../../src/main/ipc'; import type { UserDataPaths } from '../../../src/main/paths'; -import { hasStoredSecret } from '../../../src/main/secrets'; +import { hasStoredSecret, writeSecret } from '../../../src/main/secrets'; import { loadSettings, writeSettings } from '../../../src/main/settings'; import { AUDIT_LOG_FILE_EXTENSION, AUDIT_LOG_FILE_PREFIX } from '../../../src/shared/constants'; import { + createChatMessage, createDefaultSettings, + IPC_CHAT_CANCEL_CHANNEL, + IPC_CHAT_SEND_CHANNEL, IPC_SECRETS_CLEAR_CHANNEL, IPC_SECRETS_STATUS_CHANNEL, IPC_SECRETS_WRITE_CHANNEL, IPC_SETTINGS_GET_CHANNEL, IPC_SETTINGS_UPDATE_CHANNEL, } from '../../../src/shared/schemas'; -import type { SecretsActionResponse, SettingsActionResponse } from '../../../src/shared/schemas'; +import type { + ChatCancelResponse, + ChatSendResponse, + SecretsActionResponse, + SettingsActionResponse, +} from '../../../src/shared/schemas'; import type { ConfirmationResult } from '../../../src/shared/types'; const NOW = '2026-08-07T00:00:00.000Z'; @@ -48,7 +57,15 @@ function createFakeIpcMain(): { if (!handler) { return Promise.reject(new Error(`no handler registered for channel: ${channel}`)); } - return Promise.resolve(handler({}, ...args)); + // Mirrors real Electron: a handler that throws synchronously (never + // awaiting anything, so validation failure is not itself wrapped in a + // promise) still rejects `ipcRenderer.invoke`'s promise, exactly as one + // that returns a rejected promise does. + try { + return Promise.resolve(handler({}, ...args)); + } catch (error) { + return Promise.reject(error instanceof Error ? error : new Error(String(error))); + } }, }; } @@ -500,3 +517,257 @@ describe('a corrupt settings.json falls back safely rather than trusting partial expect(response.settings?.onboardingCompleted).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// chat:send / chat:cancel (Phase 2, Milestone 3) +// --------------------------------------------------------------------------- + +function userMessage(content: string) { + return createChatMessage({ id: randomUUID(), role: 'user', content, createdAt: NOW }); +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +async function configureOpenAiCompatible(): Promise { + await writeSettings(paths.settingsFile, { + ...createDefaultSettings(NOW), + modelProvider: { + provider: 'openai-compatible', + model: 'gpt-test', + baseUrl: 'https://api.example.test/v1', + hasApiKey: false, + }, + }); + await writeSecret(paths.secretsFile, PLAINTEXT_KEY, fakeSafeStorage(true)); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('chat:send — provider none (default settings)', () => { + it('fails closed with PROVIDER_UNAVAILABLE and never calls fetch', async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + const invoke = setUp(); + const response = (await invoke(IPC_CHAT_SEND_CHANNEL, { + requestId: randomUUID(), + messages: [userMessage('hello')], + })) as ChatSendResponse; + + expect(response.outcome).toBe('failure'); + expect(response.errorCode).toBe('PROVIDER_UNAVAILABLE'); + expect(response.content).toBeUndefined(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('does not require confirmation (chat.send is allow by default policy)', async () => { + const requestConfirmation = vi.fn<() => Promise>(); + const invoke = setUp({ requestConfirmation }); + await invoke(IPC_CHAT_SEND_CHANNEL, { requestId: randomUUID(), messages: [userMessage('hi')] }); + expect(requestConfirmation).not.toHaveBeenCalled(); + }); + + it('is denied while the emergency stop is engaged, and never resolves a provider', async () => { + await engageEmergencyStop(paths.emergencyStateFile, NOW); + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + const requestConfirmation = vi.fn<() => Promise>(); + + const invoke = setUp({ requestConfirmation }); + const response = (await invoke(IPC_CHAT_SEND_CHANNEL, { + requestId: randomUUID(), + messages: [userMessage('hi')], + })) as ChatSendResponse; + + expect(response.outcome).toBe('denied'); + expect(requestConfirmation).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('rejects a malformed request before any proposal is built (no audit record)', async () => { + const invoke = setUp(); + await expect( + invoke(IPC_CHAT_SEND_CHANNEL, { requestId: 'not-a-uuid', messages: [] }), + ).rejects.toThrow(); + await expect(readdir(paths.auditLogDir)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('rejects a request carrying an unexpected field', async () => { + const invoke = setUp(); + await expect( + invoke(IPC_CHAT_SEND_CHANNEL, { + requestId: randomUUID(), + messages: [userMessage('hi')], + apiKey: 'sk-not-real', + }), + ).rejects.toThrow(); + }); +}); + +describe('chat:send — audit trail', () => { + it('records provider and messageCount only, never message content', async () => { + const invoke = setUp(); + await invoke(IPC_CHAT_SEND_CHANNEL, { + requestId: randomUUID(), + messages: [userMessage('a secret-shaped message nobody should log')], + }); + + const lines = await readAuditLines(); + const record = lines.find((line) => line.actionType === 'chat.send'); + expect(record?.parameters).toEqual({ provider: 'none', messageCount: 1 }); + expect(JSON.stringify(lines)).not.toContain('a secret-shaped message nobody should log'); + }); +}); + +describe('chat:send — openai-compatible, fully configured', () => { + it('returns the assistant content on a successful call', async () => { + await configureOpenAiCompatible(); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ choices: [{ message: { content: 'hi there' } }] })), + ); + + const invoke = setUp(); + const response = (await invoke(IPC_CHAT_SEND_CHANNEL, { + requestId: randomUUID(), + messages: [userMessage('hello')], + })) as ChatSendResponse; + + expect(response.outcome).toBe('success'); + expect(response.content).toBe('hi there'); + }); + + it('never includes the API key anywhere in the response', async () => { + await configureOpenAiCompatible(); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ choices: [{ message: { content: 'hi there' } }] })), + ); + + const invoke = setUp(); + const response = await invoke(IPC_CHAT_SEND_CHANNEL, { + requestId: randomUUID(), + messages: [userMessage('hello')], + }); + expect(JSON.stringify(response)).not.toContain(PLAINTEXT_KEY); + }); + + it('never includes the API key anywhere in the audit trail', async () => { + await configureOpenAiCompatible(); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ choices: [{ message: { content: 'hi there' } }] })), + ); + + const invoke = setUp(); + await invoke(IPC_CHAT_SEND_CHANNEL, { + requestId: randomUUID(), + messages: [userMessage('hello')], + }); + const lines = await readAuditLines(); + expect(JSON.stringify(lines)).not.toContain(PLAINTEXT_KEY); + }); + + it('reports PROVIDER_INVALID_CONFIGURATION when the endpoint rejects the credentials', async () => { + await configureOpenAiCompatible(); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('unauthorized', { status: 401 })), + ); + + const invoke = setUp(); + const response = (await invoke(IPC_CHAT_SEND_CHANNEL, { + requestId: randomUUID(), + messages: [userMessage('hello')], + })) as ChatSendResponse; + + expect(response.outcome).toBe('failure'); + expect(response.errorCode).toBe('PROVIDER_INVALID_CONFIGURATION'); + }); + + it('reports PROVIDER_INVALID_CONFIGURATION when no key is stored yet', async () => { + await writeSettings(paths.settingsFile, { + ...createDefaultSettings(NOW), + modelProvider: { + provider: 'openai-compatible', + model: 'gpt-test', + baseUrl: 'https://api.example.test/v1', + hasApiKey: false, + }, + }); + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + const invoke = setUp(); + const response = (await invoke(IPC_CHAT_SEND_CHANNEL, { + requestId: randomUUID(), + messages: [userMessage('hello')], + })) as ChatSendResponse; + + expect(response.outcome).toBe('failure'); + expect(response.errorCode).toBe('PROVIDER_INVALID_CONFIGURATION'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe('chat:cancel', () => { + it('aborts a matching in-flight chat:send call', async () => { + await configureOpenAiCompatible(); + + // Resolves once `fetch` is actually reached, which can only happen after + // the handler has already registered the in-flight `AbortController` — + // deterministic synchronization instead of a fixed delay. + let notifyFetchCalled: () => void = () => undefined; + const fetchCalled = new Promise((resolve) => { + notifyFetchCalled = resolve; + }); + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation((_url: string, init: { signal?: AbortSignal }) => { + notifyFetchCalled(); + return new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => { + reject(new DOMException('The operation was aborted.', 'AbortError')); + }); + }); + }), + ); + + const invoke = setUp(); + const requestId = randomUUID(); + const pending = invoke(IPC_CHAT_SEND_CHANNEL, { + requestId, + messages: [userMessage('hello')], + }) as Promise; + + await fetchCalled; + const cancelResponse = (await invoke(IPC_CHAT_CANCEL_CHANNEL, { + requestId, + })) as ChatCancelResponse; + expect(cancelResponse).toEqual({ acknowledged: true }); + + const response = await pending; + expect(response.outcome).toBe('failure'); + expect(response.errorCode).toBe('PROVIDER_ABORTED'); + }); + + it('is a harmless no-op for an unknown or already-completed requestId', async () => { + const invoke = setUp(); + const response = (await invoke(IPC_CHAT_CANCEL_CHANNEL, { + requestId: randomUUID(), + })) as ChatCancelResponse; + expect(response).toEqual({ acknowledged: true }); + }); + + it('rejects a malformed request', async () => { + const invoke = setUp(); + await expect(invoke(IPC_CHAT_CANCEL_CHANNEL, { requestId: 'not-a-uuid' })).rejects.toThrow(); + }); +}); diff --git a/tests/unit/main/openai-compatible-provider.test.ts b/tests/unit/main/openai-compatible-provider.test.ts new file mode 100644 index 0000000..d5a9c92 --- /dev/null +++ b/tests/unit/main/openai-compatible-provider.test.ts @@ -0,0 +1,300 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createOpenAiCompatibleProvider, + MAX_RESPONSE_BYTES, +} from '../../../src/main/openai-compatible-provider'; +import { ChatProviderError } from '../../../src/shared/chat/provider'; +import { createChatMessage } from '../../../src/shared/schemas/chat.schema'; + +const NOW = '2026-08-07T00:00:00.000Z'; +const PLAINTEXT_KEY = 'sk-test-not-a-real-key'; + +function userMessage(content: string, id = '11111111-1111-4111-8111-111111111111') { + return createChatMessage({ id, role: 'user', content, createdAt: NOW }); +} + +function baseConfig(overrides: Partial<{ baseUrl: string; model: string; apiKey: string }> = {}) { + return { + baseUrl: 'https://api.example.test/v1', + model: 'gpt-test', + apiKey: PLAINTEXT_KEY, + ...overrides, + }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('createOpenAiCompatibleProvider — success', () => { + it('returns the assistant content from a well-formed completion', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue(jsonResponse({ choices: [{ message: { content: 'hello there' } }] })), + ); + const provider = createOpenAiCompatibleProvider(baseConfig()); + + const result = await provider.send({ messages: [userMessage('hi')] }); + expect(result).toEqual({ content: 'hello there' }); + }); + + it('posts to /chat/completions, handling a trailing slash on baseUrl', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ choices: [{ message: { content: 'ok' } }] })); + vi.stubGlobal('fetch', fetchMock); + + await createOpenAiCompatibleProvider( + baseConfig({ baseUrl: 'https://api.example.test/v1/' }), + ).send({ messages: [userMessage('hi')] }); + + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://api.example.test/v1/chat/completions'); + }); + + it('sends the API key as a bearer token, and the request body never contains it', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ choices: [{ message: { content: 'ok' } }] })); + vi.stubGlobal('fetch', fetchMock); + + await createOpenAiCompatibleProvider(baseConfig()).send({ messages: [userMessage('hi')] }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers.authorization).toBe(`Bearer ${PLAINTEXT_KEY}`); + // The key belongs in the Authorization header alone. + const bodyText = init.body as string; + expect(bodyText).not.toContain(PLAINTEXT_KEY); + }); + + it('forwards only system/user/assistant messages, dropping tool messages', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ choices: [{ message: { content: 'ok' } }] })); + vi.stubGlobal('fetch', fetchMock); + + const toolMessage = createChatMessage({ + id: '22222222-2222-4222-8222-222222222222', + role: 'tool', + content: 'tool output', + createdAt: NOW, + }); + + await createOpenAiCompatibleProvider(baseConfig()).send({ + messages: [userMessage('hi'), toolMessage], + }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string) as { messages: { role: string }[] }; + expect(body.messages.map((m) => m.role)).toEqual(['user']); + }); + + it('never logs anything, including on a successful call', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ choices: [{ message: { content: 'ok' } }] })), + ); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + await createOpenAiCompatibleProvider(baseConfig()).send({ messages: [userMessage('hi')] }); + + expect(logSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); + +describe('createOpenAiCompatibleProvider — authentication and rate limiting', () => { + it('reports PROVIDER_INVALID_CONFIGURATION for a 401', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('unauthorized', { status: 401 })), + ); + const provider = createOpenAiCompatibleProvider(baseConfig()); + await expect(provider.send({ messages: [userMessage('hi')] })).rejects.toMatchObject({ + code: 'PROVIDER_INVALID_CONFIGURATION', + }); + }); + + it('reports PROVIDER_INVALID_CONFIGURATION for a 403', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('forbidden', { status: 403 }))); + const provider = createOpenAiCompatibleProvider(baseConfig()); + await expect(provider.send({ messages: [userMessage('hi')] })).rejects.toMatchObject({ + code: 'PROVIDER_INVALID_CONFIGURATION', + }); + }); + + it('reports PROVIDER_REQUEST_FAILED for a 429', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('too many requests', { status: 429 })), + ); + const provider = createOpenAiCompatibleProvider(baseConfig()); + await expect(provider.send({ messages: [userMessage('hi')] })).rejects.toMatchObject({ + code: 'PROVIDER_REQUEST_FAILED', + }); + }); + + it('reports PROVIDER_REQUEST_FAILED for a generic 500', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('server error', { status: 500 })), + ); + const provider = createOpenAiCompatibleProvider(baseConfig()); + await expect(provider.send({ messages: [userMessage('hi')] })).rejects.toMatchObject({ + code: 'PROVIDER_REQUEST_FAILED', + }); + }); + + it('never includes a response body or status text in the thrown error message', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('leaky-diagnostic-detail', { status: 500 })), + ); + const provider = createOpenAiCompatibleProvider(baseConfig()); + await expect(provider.send({ messages: [userMessage('hi')] })).rejects.toSatisfy( + (error: unknown) => { + expect(error).toBeInstanceOf(ChatProviderError); + expect((error as ChatProviderError).message).not.toContain('leaky-diagnostic-detail'); + return true; + }, + ); + }); +}); + +describe('createOpenAiCompatibleProvider — network failure and cancellation', () => { + it('reports PROVIDER_REQUEST_FAILED when fetch rejects for a reason other than abort', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('getaddrinfo ENOTFOUND'))); + const provider = createOpenAiCompatibleProvider(baseConfig()); + await expect(provider.send({ messages: [userMessage('hi')] })).rejects.toMatchObject({ + code: 'PROVIDER_REQUEST_FAILED', + }); + }); + + it('never forwards a raw network error message', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED 127.0.0.1:9999'))); + const provider = createOpenAiCompatibleProvider(baseConfig()); + await expect(provider.send({ messages: [userMessage('hi')] })).rejects.toSatisfy( + (error: unknown) => { + expect((error as ChatProviderError).message).not.toContain('ECONNREFUSED'); + return true; + }, + ); + }); + + it('rejects immediately with PROVIDER_ABORTED when the signal is already aborted', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const controller = new AbortController(); + controller.abort(); + + const provider = createOpenAiCompatibleProvider(baseConfig()); + await expect( + provider.send({ messages: [userMessage('hi')] }, { signal: controller.signal }), + ).rejects.toMatchObject({ code: 'PROVIDER_ABORTED' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('reports PROVIDER_ABORTED when fetch rejects because the signal fired mid-flight', async () => { + const controller = new AbortController(); + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => { + controller.abort(); + return Promise.reject(new DOMException('The operation was aborted.', 'AbortError')); + }), + ); + + const provider = createOpenAiCompatibleProvider(baseConfig()); + await expect( + provider.send({ messages: [userMessage('hi')] }, { signal: controller.signal }), + ).rejects.toMatchObject({ code: 'PROVIDER_ABORTED' }); + }); +}); + +describe('createOpenAiCompatibleProvider — malformed and oversized responses', () => { + it('reports PROVIDER_REQUEST_FAILED for a response that is not valid JSON', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('not json at all', { status: 200 })), + ); + const provider = createOpenAiCompatibleProvider(baseConfig()); + await expect(provider.send({ messages: [userMessage('hi')] })).rejects.toMatchObject({ + code: 'PROVIDER_REQUEST_FAILED', + }); + }); + + it('reports PROVIDER_REQUEST_FAILED when choices is missing', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({}))); + const provider = createOpenAiCompatibleProvider(baseConfig()); + await expect(provider.send({ messages: [userMessage('hi')] })).rejects.toMatchObject({ + code: 'PROVIDER_REQUEST_FAILED', + }); + }); + + it('reports PROVIDER_REQUEST_FAILED when choices is empty', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ choices: [] }))); + const provider = createOpenAiCompatibleProvider(baseConfig()); + await expect(provider.send({ messages: [userMessage('hi')] })).rejects.toMatchObject({ + code: 'PROVIDER_REQUEST_FAILED', + }); + }); + + it('reports PROVIDER_REQUEST_FAILED when message.content is missing', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ choices: [{ message: {} }] }))); + const provider = createOpenAiCompatibleProvider(baseConfig()); + await expect(provider.send({ messages: [userMessage('hi')] })).rejects.toMatchObject({ + code: 'PROVIDER_REQUEST_FAILED', + }); + }); + + it('reports PROVIDER_REQUEST_FAILED when content fails the shared content-safety schema (control character)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + jsonResponse({ + choices: [{ message: { content: 'bad' + String.fromCharCode(0) + 'char' } }], + }), + ), + ); + const provider = createOpenAiCompatibleProvider(baseConfig()); + await expect(provider.send({ messages: [userMessage('hi')] })).rejects.toMatchObject({ + code: 'PROVIDER_REQUEST_FAILED', + }); + }); + + it('reports PROVIDER_REQUEST_FAILED for a response body larger than the byte cap, without buffering it all', async () => { + const oversized = 'a'.repeat(MAX_RESPONSE_BYTES + 1024); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(oversized, { status: 200 }))); + const provider = createOpenAiCompatibleProvider(baseConfig()); + await expect(provider.send({ messages: [userMessage('hi')] })).rejects.toMatchObject({ + code: 'PROVIDER_REQUEST_FAILED', + }); + }); + + it('accepts a response body at exactly the byte cap', async () => { + const atCap = JSON.stringify({ + choices: [{ message: { content: 'x'.repeat(100) } }], + padding: 'p'.repeat(MAX_RESPONSE_BYTES - 200), + }); + expect(Buffer.byteLength(atCap, 'utf8')).toBeLessThanOrEqual(MAX_RESPONSE_BYTES); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(atCap, { status: 200 }))); + const provider = createOpenAiCompatibleProvider(baseConfig()); + const result = await provider.send({ messages: [userMessage('hi')] }); + expect(result.content).toBe('x'.repeat(100)); + }); +}); diff --git a/tests/unit/renderer/ipc-chat-provider.test.ts b/tests/unit/renderer/ipc-chat-provider.test.ts new file mode 100644 index 0000000..c2c73b5 --- /dev/null +++ b/tests/unit/renderer/ipc-chat-provider.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createIpcChatProvider, + IPC_CHAT_PROVIDER_ID, +} from '../../../src/renderer/chat/ipc-chat-provider'; +import { ChatProviderError } from '../../../src/shared/chat/provider'; +import { createChatMessage } from '../../../src/shared/schemas/chat.schema'; +import type { ChatSendResponse } from '../../../src/shared/schemas'; + +const NOW = '2026-08-07T00:00:00.000Z'; + +function userMessage(content: string) { + return createChatMessage({ + id: '11111111-1111-4111-8111-111111111111', + role: 'user', + content, + createdAt: NOW, + }); +} + +interface FakeBridge { + readonly send: ReturnType; + readonly cancel: ReturnType; +} + +function installFakeBridge(): FakeBridge { + const send = vi.fn<(requestId: string, messages: unknown[]) => Promise>(); + const cancel = vi.fn<(requestId: string) => Promise>(); + vi.stubGlobal('window', { localAgent: { chat: { send, cancel } } }); + return { send, cancel }; +} + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('createIpcChatProvider', () => { + it('has the openai-compatible id', () => { + const { send } = installFakeBridge(); + send.mockResolvedValue({ outcome: 'success', content: 'ok' }); + const provider = createIpcChatProvider(); + expect(provider.id).toBe(IPC_CHAT_PROVIDER_ID); + expect(provider.id).toBe('openai-compatible'); + }); + + it('calls window.localAgent.chat.send with a fresh requestId and the request messages', async () => { + const { send } = installFakeBridge(); + send.mockResolvedValue({ outcome: 'success', content: 'hello' }); + const provider = createIpcChatProvider(); + + const result = await provider.send({ messages: [userMessage('hi')] }); + + expect(result).toEqual({ content: 'hello' }); + expect(send).toHaveBeenCalledTimes(1); + const [requestId, messages] = send.mock.calls[0] as [string, unknown[]]; + expect(typeof requestId).toBe('string'); + expect(requestId.length).toBeGreaterThan(0); + expect(messages).toEqual([userMessage('hi')]); + }); + + it('throws a ChatProviderError with the response errorCode on failure', async () => { + const { send } = installFakeBridge(); + send.mockResolvedValue({ outcome: 'failure', errorCode: 'PROVIDER_INVALID_CONFIGURATION' }); + const provider = createIpcChatProvider(); + + await expect(provider.send({ messages: [userMessage('hi')] })).rejects.toMatchObject({ + code: 'PROVIDER_INVALID_CONFIGURATION', + }); + }); + + it('falls back to PROVIDER_REQUEST_FAILED for an unrecognised or missing errorCode', async () => { + const { send } = installFakeBridge(); + send.mockResolvedValue({ outcome: 'denied' }); + const provider = createIpcChatProvider(); + + await expect(provider.send({ messages: [userMessage('hi')] })).rejects.toMatchObject({ + code: 'PROVIDER_REQUEST_FAILED', + }); + }); + + it('never returns undefined content, even if outcome is success with content missing', async () => { + const { send } = installFakeBridge(); + send.mockResolvedValue({ outcome: 'success' }); + const provider = createIpcChatProvider(); + + await expect(provider.send({ messages: [userMessage('hi')] })).rejects.toBeInstanceOf( + ChatProviderError, + ); + }); + + it('rejects immediately with PROVIDER_ABORTED and never calls send when the signal is already aborted', async () => { + const { send } = installFakeBridge(); + const controller = new AbortController(); + controller.abort(); + const provider = createIpcChatProvider(); + + await expect( + provider.send({ messages: [userMessage('hi')] }, { signal: controller.signal }), + ).rejects.toMatchObject({ code: 'PROVIDER_ABORTED' }); + expect(send).not.toHaveBeenCalled(); + }); + + it('calls chat.cancel with the same requestId and rejects with PROVIDER_ABORTED when aborted mid-flight', async () => { + const { send, cancel } = installFakeBridge(); + const { promise, resolve } = deferred(); + send.mockReturnValue(promise); + cancel.mockResolvedValue(undefined); + + const controller = new AbortController(); + const provider = createIpcChatProvider(); + + const pending = provider.send({ messages: [userMessage('hi')] }, { signal: controller.signal }); + controller.abort(); + + const sentRequestId = (send.mock.calls[0] as [string, unknown[]])[0]; + expect(cancel).toHaveBeenCalledWith(sentRequestId); + + // Even a late, otherwise-successful response must not overwrite the abort. + resolve({ outcome: 'success', content: 'too late' }); + await expect(pending).rejects.toMatchObject({ code: 'PROVIDER_ABORTED' }); + }); + + it('never leaves an abort listener attached after the call settles', async () => { + const { send } = installFakeBridge(); + send.mockResolvedValue({ outcome: 'success', content: 'ok' }); + const provider = createIpcChatProvider(); + const controller = new AbortController(); + const removeSpy = vi.spyOn(controller.signal, 'removeEventListener'); + + await provider.send({ messages: [userMessage('hi')] }, { signal: controller.signal }); + + expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function)); + }); +}); diff --git a/tests/unit/shared/chat-boundary-scan.test.ts b/tests/unit/shared/chat-boundary-scan.test.ts index 40c9a15..6bea265 100644 --- a/tests/unit/shared/chat-boundary-scan.test.ts +++ b/tests/unit/shared/chat-boundary-scan.test.ts @@ -3,29 +3,37 @@ import { readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; /** - * Source-scan regression test (Phase 2, Milestone 2). + * Source-scan regression test (Phase 2, Milestones 2-3). * - * The chat and provider layers are already structurally barred from - * reaching Electron, Node built-ins, the network, or `window.localAgent` — - * `eslint.config.js`'s purity boundary covers `src/shared/**`, and nothing - * under `src/renderer/chat` imports the preload bridge. This test makes that - * guarantee empirical rather than relying solely on lint staying configured - * correctly forever: it reads the actual source of every file in both - * directories, strips comments (this codebase's own doc comments freely - * *describe* what is absent — "never touches `window.localAgent`" — which + * `src/shared/chat` is structurally barred from reaching Electron, Node + * built-ins, the network, or `window.localAgent` — `eslint.config.js`'s + * purity boundary covers `src/shared/**`. `src/renderer/chat` is barred from + * all of the same things **except** `window.localAgent`, which exactly one + * file — `ipc-chat-provider.ts` — is deliberately permitted to call, since + * Milestone 3 needs one seam through which the real, network-capable + * adapter in `src/main` is reached. This test makes both guarantees + * empirical rather than relying solely on lint staying configured correctly + * forever: it reads the actual source of every file in both directories, + * strips comments (this codebase's own doc comments freely *describe* what + * is absent or narrowly permitted — "never touches `window.localAgent`", + * "the one file... permitted to reference `window.localAgent`" — which * would otherwise trip a naive substring scan on prose, not code), and - * asserts none of the forbidden references appear anywhere in what remains. + * asserts every forbidden reference is absent, with `window.localAgent` + * checked separately against its one named exception. */ const REPO_ROOT = join(__dirname, '..', '..', '..'); -const SCAN_DIRECTORIES = [ - join(REPO_ROOT, 'src', 'shared', 'chat'), - join(REPO_ROOT, 'src', 'renderer', 'chat'), -]; +const SHARED_CHAT_DIR = join(REPO_ROOT, 'src', 'shared', 'chat'); +const RENDERER_CHAT_DIR = join(REPO_ROOT, 'src', 'renderer', 'chat'); +const SCAN_DIRECTORIES = [SHARED_CHAT_DIR, RENDERER_CHAT_DIR]; + +/** The one file under `src/renderer/chat` permitted to reference `window.localAgent`. */ +const IPC_CHAT_PROVIDER_FILE = join(RENDERER_CHAT_DIR, 'ipc-chat-provider.ts'); + +const WINDOW_LOCAL_AGENT_SUBSTRING = 'window.localAgent'; const FORBIDDEN_PATTERNS: readonly { readonly label: string; readonly pattern: RegExp }[] = [ { label: 'ipcRenderer', pattern: /ipcRenderer/ }, - { label: 'window.localAgent', pattern: /window\.localAgent/ }, { label: "import ... from 'electron'", pattern: /from ['"]electron['"]/ }, { label: 'require("electron")', pattern: /require\(['"]electron['"]\)/ }, { label: 'child_process', pattern: /child_process/ }, @@ -89,4 +97,19 @@ describe('chat/provider layer source-scan boundary', () => { .map(([file]) => file); expect(offenders).toEqual([]); }); + + it('no code under src/shared/chat references window.localAgent', () => { + const offenders = [...codeByFile.entries()] + .filter(([file]) => file.startsWith(SHARED_CHAT_DIR)) + .filter(([, code]) => code.includes(WINDOW_LOCAL_AGENT_SUBSTRING)) + .map(([file]) => file); + expect(offenders).toEqual([]); + }); + + it('window.localAgent is referenced by exactly, and only, ipc-chat-provider.ts', () => { + const offenders = [...codeByFile.entries()] + .filter(([, code]) => code.includes(WINDOW_LOCAL_AGENT_SUBSTRING)) + .map(([file]) => file); + expect(offenders).toEqual([IPC_CHAT_PROVIDER_FILE]); + }); }); diff --git a/tests/unit/shared/chat-registry.test.ts b/tests/unit/shared/chat-registry.test.ts index 834953d..7b11e07 100644 --- a/tests/unit/shared/chat-registry.test.ts +++ b/tests/unit/shared/chat-registry.test.ts @@ -23,10 +23,11 @@ describe('getChatProviderCapabilities', () => { expect(getChatProviderCapabilities('openai-compatible').requiresApiKey).toBe(true); }); - it('reports every provider as not implemented in this phase', () => { - for (const id of MODEL_PROVIDERS) { - expect(getChatProviderCapabilities(id).implemented).toBe(false); - } + it('reports implemented only for openai-compatible, since Milestone 3', () => { + expect(getChatProviderCapabilities('none').implemented).toBe(false); + expect(getChatProviderCapabilities('glm').implemented).toBe(false); + expect(getChatProviderCapabilities('ollama').implemented).toBe(false); + expect(getChatProviderCapabilities('openai-compatible').implemented).toBe(true); }); it('exposes requiresApiKey only as a boolean flag, never a credential-shaped value', () => { @@ -48,29 +49,48 @@ describe('getChatProviderCapabilities', () => { describe('describeChatProviderStatus', () => { it('reports "none" as not-configured', () => { - const status = describeChatProviderStatus('none'); + const status = describeChatProviderStatus({ provider: 'none', hasApiKey: false }); expect(status.availability).toBe('not-configured'); expect(status.summary).toMatch(/mock/i); }); - it.each(['glm', 'openai-compatible', 'ollama'] as const)( - 'reports %s as not-implemented', - (id) => { - const status = describeChatProviderStatus(id); - expect(status.availability).toBe('not-implemented'); - expect(status.summary).toMatch(/mock/i); - }, - ); + it.each(['glm', 'ollama'] as const)('reports %s as not-implemented', (id) => { + const status = describeChatProviderStatus({ provider: id, hasApiKey: false }); + expect(status.availability).toBe('not-implemented'); + expect(status.summary).toMatch(/mock/i); + }); + + it('reports openai-compatible without a stored key as missing-api-key', () => { + const status = describeChatProviderStatus({ provider: 'openai-compatible', hasApiKey: false }); + expect(status.availability).toBe('missing-api-key'); + expect(status.summary).not.toMatch(/mock/i); + }); + + it('reports openai-compatible with a stored key as ready', () => { + const status = describeChatProviderStatus({ provider: 'openai-compatible', hasApiKey: true }); + expect(status.availability).toBe('ready'); + expect(status.summary).not.toMatch(/mock/i); + }); + + it('ignores hasApiKey for providers that do not require one', () => { + for (const id of ['none', 'ollama'] as const) { + const withoutKey = describeChatProviderStatus({ provider: id, hasApiKey: false }); + const withKey = describeChatProviderStatus({ provider: id, hasApiKey: true }); + expect(withKey).toEqual(withoutKey); + } + }); it('never places settings input into the summary text, only fixed labels', () => { - // The function's only input is the enum identifier itself — there is no - // parameter through which a hostile or unexpected string could reach - // the summary, so this asserts the output is always one of the four - // known, reviewed templates. + // The function's only inputs are the enum identifier and a boolean — + // there is no parameter through which a hostile or unexpected string + // could reach the summary, so this asserts the output is always one of + // this function's known, reviewed templates. for (const id of MODEL_PROVIDERS) { - const summary = describeChatProviderStatus(id).summary; - expect(summary).not.toContain('apiKey'); - expect(summary).not.toContain('token'); + for (const hasApiKey of [true, false]) { + const summary = describeChatProviderStatus({ provider: id, hasApiKey }).summary; + expect(summary).not.toContain('apiKey'); + expect(summary).not.toContain('token'); + } } }); }); diff --git a/tests/unit/shared/ipc.schema.test.ts b/tests/unit/shared/ipc.schema.test.ts index a28ea79..23f23e0 100644 --- a/tests/unit/shared/ipc.schema.test.ts +++ b/tests/unit/shared/ipc.schema.test.ts @@ -1,8 +1,18 @@ import { describe, expect, it } from 'vitest'; -import { API_KEY_MAX_LENGTH, MODEL_PROVIDERS, UI_LANGUAGES } from '../../../src/shared/constants'; +import { CHAT_PROVIDER_ERROR_CODES } from '../../../src/shared/chat/provider'; +import { + API_KEY_MAX_LENGTH, + CHAT_CONVERSATION_MAX_MESSAGES, + MODEL_PROVIDERS, + UI_LANGUAGES, +} from '../../../src/shared/constants'; import { createDefaultSettings } from '../../../src/shared/schemas/settings.schema'; import { + chatCancelRequestSchema, + chatCancelResponseSchema, + chatSendRequestSchema, + chatSendResponseSchema, secretsActionResponseSchema, secretsClearRequestSchema, secretsStatusRequestSchema, @@ -214,3 +224,128 @@ describe('secretsActionResponseSchema', () => { expect(secretsActionResponseSchema.safeParse({ outcome: 'aborted' }).success).toBe(true); }); }); + +describe('chatSendRequestSchema (Phase 2, Milestone 3)', () => { + function validSend() { + return { + requestId: '11111111-1111-4111-8111-111111111111', + messages: [ + { + id: '22222222-2222-4222-8222-222222222222', + role: 'user' as const, + content: 'hello', + createdAt: NOW, + }, + ], + }; + } + + it('accepts a well-formed request', () => { + expect(chatSendRequestSchema.safeParse([validSend()]).success).toBe(true); + }); + + it('accepts an empty message list', () => { + expect(chatSendRequestSchema.safeParse([{ ...validSend(), messages: [] }]).success).toBe(true); + }); + + it('rejects a non-uuid requestId', () => { + expect( + chatSendRequestSchema.safeParse([{ ...validSend(), requestId: 'not-a-uuid' }]).success, + ).toBe(false); + }); + + it('rejects a conversation over the maximum length', () => { + const oversized = { + ...validSend(), + messages: Array.from({ length: CHAT_CONVERSATION_MAX_MESSAGES + 1 }, (_, index) => ({ + id: '22222222-2222-4222-8222-222222222222', + role: 'user' as const, + content: `message ${String(index)}`, + createdAt: NOW, + })), + }; + expect(chatSendRequestSchema.safeParse([oversized]).success).toBe(false); + }); + + it('rejects an unknown top-level field — never an apiKey, header, or URL', () => { + for (const extra of [ + { apiKey: 'sk-test' }, + { baseUrl: 'https://evil.example' }, + { header: 'x' }, + ]) { + expect(chatSendRequestSchema.safeParse([{ ...validSend(), ...extra }]).success).toBe(false); + } + }); + + it('rejects a malformed message inside the conversation', () => { + const invalid = { ...validSend(), messages: [{ role: 'user', content: 'hi' }] }; + expect(chatSendRequestSchema.safeParse([invalid]).success).toBe(false); + }); +}); + +describe('chatSendResponseSchema', () => { + it('accepts a success response carrying content', () => { + expect(chatSendResponseSchema.safeParse({ outcome: 'success', content: 'hi' }).success).toBe( + true, + ); + }); + + it('accepts a denied response with neither content nor errorCode', () => { + expect(chatSendResponseSchema.safeParse({ outcome: 'denied' }).success).toBe(true); + }); + + it('accepts a failure response carrying a normalized provider error code', () => { + for (const errorCode of CHAT_PROVIDER_ERROR_CODES) { + const result = chatSendResponseSchema.safeParse({ outcome: 'failure', errorCode }); + expect(result.success).toBe(true); + } + }); + + it('rejects an errorCode outside the normalized vocabulary — never a raw provider error string', () => { + const result = chatSendResponseSchema.safeParse({ + outcome: 'failure', + errorCode: 'ECONNREFUSED: connection refused at 10.0.0.5:443', + }); + expect(result.success).toBe(false); + }); + + it('rejects an unknown field — never a header, a URL, or a raw upstream body', () => { + const result = chatSendResponseSchema.safeParse({ + outcome: 'success', + content: 'hi', + rawUpstreamResponse: { status: 200 }, + }); + expect(result.success).toBe(false); + }); + + it('rejects content that fails the shared content-safety schema', () => { + const result = chatSendResponseSchema.safeParse({ outcome: 'success', content: '' }); + expect(result.success).toBe(false); + }); +}); + +describe('chatCancelRequestSchema / chatCancelResponseSchema', () => { + it('accepts a well-formed cancel request', () => { + const result = chatCancelRequestSchema.safeParse([ + { requestId: '11111111-1111-4111-8111-111111111111' }, + ]); + expect(result.success).toBe(true); + }); + + it('rejects a non-uuid requestId', () => { + expect(chatCancelRequestSchema.safeParse([{ requestId: 'nope' }]).success).toBe(false); + }); + + it('rejects an unexpected field', () => { + const result = chatCancelRequestSchema.safeParse([ + { requestId: '11111111-1111-4111-8111-111111111111', reason: 'because' }, + ]); + expect(result.success).toBe(false); + }); + + it('the response is always exactly {acknowledged: true}', () => { + expect(chatCancelResponseSchema.safeParse({ acknowledged: true }).success).toBe(true); + expect(chatCancelResponseSchema.safeParse({ acknowledged: false }).success).toBe(false); + expect(chatCancelResponseSchema.safeParse({}).success).toBe(false); + }); +}); From 3eb67db33ebb34aff7ee2608a2631080aa7bbc59 Mon Sep 17 00:00:00 2001 From: Texas-walker15 Date: Sun, 6 Sep 2026 18:08:56 +0200 Subject: [PATCH 3/4] add GLM Ollama and streaming provider support --- README.md | 91 ++-- docs/architecture.md | 61 ++- docs/phase-2-provider-completion.md | 289 +++++++++++ docs/phase-2-real-provider-architecture.md | 10 + docs/security-model.md | 69 ++- src/main/chat-completions-transport.ts | 490 ++++++++++++++++++ src/main/chat-provider-registry.ts | 159 ++++-- src/main/glm-provider.ts | 67 +++ src/main/ipc.ts | 58 ++- src/main/ollama-provider.ts | 115 ++++ src/main/openai-compatible-provider.ts | 250 +-------- src/preload/index.ts | 45 ++ src/renderer/chat/Chat.tsx | 16 +- src/renderer/chat/conversation-controller.ts | 76 ++- src/renderer/chat/ipc-chat-provider.ts | 46 +- src/renderer/local-agent-bridge.d.ts | 3 + src/renderer/styles.css | 4 + src/shared/chat/index.ts | 2 + src/shared/chat/local-endpoint.ts | 126 +++++ src/shared/chat/mock-provider.ts | 59 ++- src/shared/chat/provider.ts | 39 +- src/shared/chat/registry.ts | 49 +- src/shared/chat/timeout.ts | 9 +- src/shared/constants.ts | 71 ++- src/shared/schemas/chat.schema.ts | 25 + src/shared/schemas/index.ts | 4 + src/shared/schemas/ipc.schema.ts | 33 +- tests/e2e/electron-smoke.test.ts | 30 +- .../main/chat-completions-transport.test.ts | 400 ++++++++++++++ .../unit/main/chat-provider-registry.test.ts | 249 ++++++++- tests/unit/main/glm-provider.test.ts | 113 ++++ tests/unit/main/ipc.test.ts | 165 +++++- tests/unit/main/ollama-provider.test.ts | 131 +++++ .../renderer/conversation-controller.test.ts | 210 +++++++- tests/unit/renderer/ipc-chat-provider.test.ts | 153 +++++- tests/unit/shared/chat-registry.test.ts | 40 +- tests/unit/shared/ipc.schema.test.ts | 71 +++ tests/unit/shared/local-endpoint.test.ts | 88 ++++ tests/unit/shared/mock-provider.test.ts | 79 +++ tests/unit/shared/provider-timeout.test.ts | 20 + 40 files changed, 3571 insertions(+), 444 deletions(-) create mode 100644 docs/phase-2-provider-completion.md create mode 100644 src/main/chat-completions-transport.ts create mode 100644 src/main/glm-provider.ts create mode 100644 src/main/ollama-provider.ts create mode 100644 src/shared/chat/local-endpoint.ts create mode 100644 tests/unit/main/chat-completions-transport.test.ts create mode 100644 tests/unit/main/glm-provider.test.ts create mode 100644 tests/unit/main/ollama-provider.test.ts create mode 100644 tests/unit/shared/local-endpoint.test.ts diff --git a/README.md b/README.md index 8ed6b4c..af2ad9c 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ A local-first, permission-controlled desktop assistant for Windows. The assistant is named **JARVIS** by default; the product is **Local Agent**. -> **Status: Phase 1 complete. Phase 2, Milestone 3 (real provider -> integration) in progress.** +> **Status: Phase 1 complete. Phase 2, Milestone 4 (provider completion and +> streaming) in progress.** > Phase 1 delivered the hardened desktop shell, non-secret settings storage, > an audit-log foundation, the permission-policy runtime, persisted > emergency-stop state, first-run onboarding, an encrypted secret store, and @@ -24,16 +24,24 @@ assistant is named **JARVIS** by default; the product is **Local Agent**. > action — `chat.send` passes through the same unmodified permission engine, > emergency-stop gate and audit writer every other action type does — and the > API key it uses is read from the existing encrypted secret store inside the -> main process and never crosses into the renderer. `glm` and `ollama` remain -> unimplemented (still fail closed); `anthropic`, `openai`, `claude` and -> `gemini` remain absent. See +> main process and never crosses into the renderer. Milestone 4 **completes +> the provider layer and adds streaming**: `glm` and `ollama` join it over one +> shared, audited HTTP transport, and assistant replies now arrive +> incrementally, bounded at every hop, through a single one-way +> `chat.onChunk` event. Ollama is **enforced local** — a configured endpoint +> must be a loopback, private or link-local address, or the request is +> refused rather than sent — and reads no credential at all. A streamed +> fragment is a preview, never a message: the reply committed to the +> conversation is still the whole, separately validated one. `anthropic`, +> `openai`, `claude` and `gemini` remain absent. See > [docs/phase-2-chat-architecture.md](docs/phase-2-chat-architecture.md), -> [docs/phase-2-provider-architecture.md](docs/phase-2-provider-architecture.md) -> and +> [docs/phase-2-provider-architecture.md](docs/phase-2-provider-architecture.md), > [docs/phase-2-real-provider-architecture.md](docs/phase-2-real-provider-architecture.md) -> for the full design and for what remains explicitly deferred (streaming, -> the remaining providers, tool/action execution from model output, memory, -> and everything else Phase 2 has not reached yet). +> and +> [docs/phase-2-provider-completion.md](docs/phase-2-provider-completion.md) +> for the full design and for what remains explicitly deferred (tool/action +> execution from model output, memory, and everything else Phase 2 has not +> reached yet). All rights reserved. No licence has been granted for this project. @@ -75,9 +83,12 @@ postponed. - The preload bridge exposes only narrow, explicitly named, typed functions — `localAgent.health`, `localAgent.settings.{get,update}`, `localAgent.secrets.{status,write,clear}`, - `localAgent.chat.{send,cancel}` — never `ipcRenderer` itself and never a - generic invoke-any-channel function. None of them can return a plaintext - API key. + `localAgent.chat.{send,cancel,onChunk}` — never `ipcRenderer` itself, never + a generic invoke-any-channel function, and never a generic + listen-to-any-channel one: `chat.onChunk` subscribes to a single fixed, + one-way streaming channel whose payloads are schema-validated in the + preload before any renderer code sees them. None of them can return a + plaintext API key. - A strict Content-Security-Policy blocks remote script and network access outright; navigation, `window.open` and `` are all denied. - An action with no matching policy rule is **denied** — enforced both by the @@ -107,27 +118,30 @@ postponed. same lint boundary that keeps `src/shared` free of Electron and Node access and also blocks `fetch`, `XMLHttpRequest`, `WebSocket` and `EventSource` as globals, and its own registry still fails closed for - every one of the four approved identifiers, unconditionally. Milestone 3 - adds the one real provider on top of that, entirely in the main process: - `src/main/openai-compatible-provider.ts` makes the network request, - `src/main/chat-provider-registry.ts` resolves which credential to use from - the existing encrypted secret store, and `chat.send` reaches both only - through the unmodified permission engine, emergency-stop gate and audit - writer — the same pipeline `secrets.write` already uses. `window.localAgent` - is called from exactly one renderer file, + every one of the four approved identifiers, unconditionally. Milestones 3 + and 4 add the real providers on top of that, entirely in the main process: + one audited transport (`src/main/chat-completions-transport.ts`) makes + every network request for all three adapters, + `src/main/chat-provider-registry.ts` resolves which endpoint and which + credential each may use from the existing encrypted secret store, and + `chat.send` reaches both only through the unmodified permission engine, + emergency-stop gate and audit writer — the same pipeline `secrets.write` + already uses. Ollama is enforced local and reads no credential at all. + `window.localAgent` is called from exactly one renderer file, `src/renderer/chat/ipc-chat-provider.ts`, asserted by an automated source - scan; the API key itself never crosses into the renderer. Assistant text - is still rendered as plain JSX text, never as HTML, and is still never - treated as authorization for anything — there is no path from a chat - message, typed or received, to `main/executor.ts` or to an action - proposal. + scan; the API key itself never crosses into the renderer. Assistant text — + streamed in fragments or returned whole — is still rendered as plain JSX + text, never as HTML, and is still never treated as authorization for + anything: there is no path from a chat message, typed, streamed or + received, to `main/executor.ts` or to an action proposal. Full detail, including known limitations, is in [docs/security-model.md](docs/security-model.md); the chat-specific design is in [docs/phase-2-chat-architecture.md](docs/phase-2-chat-architecture.md), -[docs/phase-2-provider-architecture.md](docs/phase-2-provider-architecture.md) +[docs/phase-2-provider-architecture.md](docs/phase-2-provider-architecture.md), +[docs/phase-2-real-provider-architecture.md](docs/phase-2-real-provider-architecture.md) and -[docs/phase-2-real-provider-architecture.md](docs/phase-2-real-provider-architecture.md). +[docs/phase-2-provider-completion.md](docs/phase-2-provider-completion.md). ## Where your data lives @@ -195,9 +209,10 @@ platform this project does not ship on. src/shared/ Pure schemas, types and constants, plus chat/ (the provider-independent ChatProvider interface, the deterministic mock provider, the approved-provider registry - with its fail-closed adapters, and the composable timeout - decorator). No I/O, no Electron, no network — safe to import - from any process, including the renderer. + with its fail-closed adapters, the composable timeout + decorator, and the local-endpoint classifier that keeps + Ollama local). No I/O, no Electron, no network — safe to + import from any process, including the renderer. src/main/ Privileged Electron main process. Owns the BrowserWindow, the Content-Security-Policy, navigation/window-open/webview hardening, non-secret settings storage (paths.ts, @@ -210,11 +225,15 @@ src/main/ Privileged Electron main process. Owns the BrowserWindow, store (secrets.ts), settings/secret reconciliation (settings-service.ts), the per-request policy/emergency-state loader (action-runtime.ts), the native confirmation dialog - (confirm.ts), the real OpenAI-compatible network adapter - (openai-compatible-provider.ts, Phase 2 Milestone 3), the - main-process provider registry that resolves it from settings - and the encrypted secret store (chat-provider-registry.ts), - and the seven registered IPC channels (ipc.ts). + (confirm.ts), the one audited HTTP transport every real + provider shares (chat-completions-transport.ts) with its three + adapters (openai-compatible-provider.ts, glm-provider.ts, + ollama-provider.ts — the last enforced local), the + main-process provider registry that resolves them from + settings and the encrypted secret store + (chat-provider-registry.ts), and the eight registered IPC + channels (ipc.ts), of which chat:chunk is the only + main-to-renderer event. src/preload/ The single contextBridge. Exposes a narrow, explicitly enumerated, typed API — never ipcRenderer, never a generic invoke-any-channel function. Bundled into one file: a diff --git a/docs/architecture.md b/docs/architecture.md index ee79d27..287dbd3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,12 +59,16 @@ The renderer cannot perform a privileged action. Not "should not" — it has no mechanism to. With `sandbox: true`, `contextIsolation: true` and `nodeIntegration: false`, a fully compromised renderer gains only the narrow preload API: `health`, `settings.get`/`settings.update`, -`secrets.status`/`secrets.write`/`secrets.clear` as of Milestone 7, and -`chat.send`/`chat.cancel` as of Phase 2 Milestone 3 — every one of them -except `chat.cancel` policy-gated and audited before it can perform a -privileged action (`chat.cancel` has no privileged side effect of its own; -see `docs/phase-2-real-provider-architecture.md`), and none of them able to -return a plaintext key. +`secrets.status`/`secrets.write`/`secrets.clear` as of Milestone 7, +`chat.send`/`chat.cancel` as of Phase 2 Milestone 3, and `chat.onChunk` as of +Phase 2 Milestone 4 — every one of them except the last two policy-gated and +audited before it can perform a privileged action, and none of them able to +return a plaintext key. The two exceptions carry no authority of their own: +`chat.cancel` can only ask an already-authorized call to stop early, and +`chat.onChunk` only subscribes to bounded, validated preview text for a +`chat.send` the renderer itself initiated — see +`docs/phase-2-real-provider-architecture.md` and +`docs/phase-2-provider-completion.md`. ## Modules @@ -202,13 +206,13 @@ cannot be turned into an unsafe one in memory after loading. ## Desktop shell (implemented) -| File | Describes | -| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/main/index.ts` | Window creation, CSP, permission-request denial, navigation/window-open/webview hardening, lifecycle, IPC-runtime composition | -| `src/main/ipc.ts` | `ipcMain` handler registration for all seven channels; validates request and response against the shared schema for each | -| `src/preload/index.ts` | The single `contextBridge` API: `localAgent.health()`, `localAgent.settings.{get,update}()`, `localAgent.secrets.{status,write,clear}()`, `localAgent.chat.{send,cancel}()` | -| `src/renderer/` | React shell: `App.tsx` gates on `onboardingCompleted`, `Onboarding.tsx` is the first-run form | -| `src/shared/schemas/ipc.schema.ts` | Every `IPC_*_CHANNEL` constant and its request/response schema, shared by `main` and `preload` | +| File | Describes | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/main/index.ts` | Window creation, CSP, permission-request denial, navigation/window-open/webview hardening, lifecycle, IPC-runtime composition | +| `src/main/ipc.ts` | `ipcMain` handler registration for all eight channels; validates request and response against the shared schema for each, in both directions | +| `src/preload/index.ts` | The single `contextBridge` API: `localAgent.health()`, `localAgent.settings.{get,update}()`, `localAgent.secrets.{status,write,clear}()`, `localAgent.chat.{send,cancel,onChunk}()` | +| `src/renderer/` | React shell: `App.tsx` gates on `onboardingCompleted`, `Onboarding.tsx` is the first-run form | +| `src/shared/schemas/ipc.schema.ts` | Every `IPC_*_CHANNEL` constant and its request/response schema, shared by `main` and `preload` | Build layout, and why it is not uniform across the three layers: @@ -469,9 +473,14 @@ authorizing verdict, so `main/ipc.ts`'s six action-backed handlers — `settings:get`, `settings:update`, `secrets:status`, `secrets:write`, `secrets:clear`, `chat:send` — each build an `ActionProposal` and hand it to `main/action-runtime.ts`'s `runAction`, which calls this unmodified -`handleActionProposal`. (`chat:cancel` is the seventh registered channel and -the one exception: it has no privileged side effect of its own to route -through this engine — see `docs/phase-2-real-provider-architecture.md`.) +`handleActionProposal`. (Two registered channels are outside this path and +neither carries authority: `chat:cancel` has no privileged side effect of +its own — see `docs/phase-2-real-provider-architecture.md` — and +`chat:chunk`, added in Phase 2 Milestone 4, is an outbound streaming event +emitted from inside an already-authorized `chat:send`, never a request the +renderer can make — see `docs/phase-2-provider-completion.md`. Milestone 4 +adds no new action type at all: every provider call, for every provider, +is still `chat.send`.) There is no code path in `main/ipc.ts` that calls `execute`, `main/secrets.ts`, or `writeSettings` directly. `main/index.ts` still calls `loadPermissionPolicy` read-only at startup to prove the real @@ -582,16 +591,16 @@ documented before persistence existed. ## Secrets and onboarding (implemented) -| File | Describes | -| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/main/secrets.ts` | `loadSecretStoreState`, `hasStoredSecret`, `writeSecret`, `clearSecret`, `readSecret` (no IPC channel returns it; called by `chat-provider-registry.ts` since Phase 2 M3) | -| `src/main/settings-service.ts` | `readReconciledSettings`, `writeOnboardingSettings`, `refreshHasApiKeyAfterSecretChange` | -| `src/main/action-runtime.ts` | `runAction` — loads policy and emergency state fresh, then calls `handleActionProposal` | -| `src/main/confirm.ts` | `showNativeConfirmation` — the real `dialog.showMessageBox` | -| `src/main/ipc.ts` | The seven channel handlers, each: validate → build proposal → `runAction` → validate response (`chat:cancel` is the one exception — see `docs/phase-2-real-provider-architecture.md`) | -| `src/renderer/Onboarding.tsx` | First-run form: assistant name, user name, language, provider, model, base URL, optional API key | -| `src/renderer/App.tsx` | Calls `settings:get` on mount; renders `Onboarding` while `onboardingCompleted` is `false` | -| `src/shared/schemas/secrets.schema.ts` | `secretStoreFileSchema` — the on-disk shape of `secrets.enc`, never a plaintext field | +| File | Describes | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `src/main/secrets.ts` | `loadSecretStoreState`, `hasStoredSecret`, `writeSecret`, `clearSecret`, `readSecret` (no IPC channel returns it; called by `chat-provider-registry.ts` since Phase 2 M3) | +| `src/main/settings-service.ts` | `readReconciledSettings`, `writeOnboardingSettings`, `refreshHasApiKeyAfterSecretChange` | +| `src/main/action-runtime.ts` | `runAction` — loads policy and emergency state fresh, then calls `handleActionProposal` | +| `src/main/confirm.ts` | `showNativeConfirmation` — the real `dialog.showMessageBox` | +| `src/main/ipc.ts` | The eight registered channels. Six are action-backed: validate → build proposal → `runAction` → validate response. `chat:cancel` has no side effect to gate, and `chat:chunk` is an outbound streaming event rather than a handler — see `docs/phase-2-provider-completion.md` | +| `src/renderer/Onboarding.tsx` | First-run form: assistant name, user name, language, provider, model, base URL, optional API key | +| `src/renderer/App.tsx` | Calls `settings:get` on mount; renders `Onboarding` while `onboardingCompleted` is `false` | +| `src/shared/schemas/secrets.schema.ts` | `secretStoreFileSchema` — the on-disk shape of `secrets.enc`, never a plaintext field | **`safeStorage` is synchronous, verified against the installed `electron@44.1.1` typings rather than assumed from memory or older diff --git a/docs/phase-2-provider-completion.md b/docs/phase-2-provider-completion.md new file mode 100644 index 0000000..1a9aa5b --- /dev/null +++ b/docs/phase-2-provider-completion.md @@ -0,0 +1,289 @@ +# Phase 2 provider completion and streaming + +> **Current state.** Phase 2, Milestone 4 completes the provider layer — GLM +> and Ollama join the OpenAI-compatible adapter Milestone 3 delivered — and +> adds bounded streaming from the provider all the way to the interface. It +> builds on [docs/phase-2-chat-architecture.md](phase-2-chat-architecture.md), +> [docs/phase-2-provider-architecture.md](phase-2-provider-architecture.md) +> and +> [docs/phase-2-real-provider-architecture.md](phase-2-real-provider-architecture.md), +> none of whose security boundaries changed. **`anthropic`, `openai`, +> `claude` and `gemini` remain absent.** + +--- + +## Summary + +| Property | State | +| ----------------------------------- | -------------------------------------------------------------------------- | +| `glm` adapter | **[implemented]** — `src/main/glm-provider.ts` | +| `ollama` adapter | **[implemented]** — `src/main/ollama-provider.ts`, local endpoints only | +| Shared HTTP transport | **[implemented]** — `src/main/chat-completions-transport.ts` | +| Bounded streaming, provider → main | **[implemented]** — server-sent events, four independent bounds | +| Bounded streaming, main → renderer | **[implemented]** — `chat:chunk`, one-way, validated, correlated, advisory | +| Streaming preview in the interface | **[implemented]** — plain text, never HTML, never committed as a message | +| Network access location | **Main process only.** Unchanged | +| Plaintext key reaching the renderer | **Never.** Unchanged; Ollama reads no key at all | +| New privileged action type | **None.** `chat.send` still covers every provider call | + +--- + +## One transport, three adapters + +GLM's public API and Ollama's `/v1` API both speak the OpenAI +chat-completions wire format. Rather than implement that exchange three +times, Milestone 4 factored Milestone 3's implementation into +`src/main/chat-completions-transport.ts` and left three thin, separately +reviewable adapters over it: + +| Module | Endpoint | Credential | Identity | +| ------------------------------- | ----------------------------------------- | -------------------- | ------------------- | +| `openai-compatible-provider.ts` | required, user-supplied | bearer key, required | `openai-compatible` | +| `glm-provider.ts` | `GLM_DEFAULT_BASE_URL`, overridable | bearer key, required | `glm` | +| `ollama-provider.ts` | `OLLAMA_DEFAULT_BASE_URL`, **local-only** | none, ever | `ollama` | + +The transport is where a request is built, sent, bounded, parsed and +normalized, and it is the single place to audit for the properties that hold +for all three: no logging of any kind, the API key confined to one +`Authorization` header, every response validated by the same +`chatProviderResultSchema` as every other provider, and every failure +normalized into the five existing error codes. Ollama's native `/api/chat` +protocol is deliberately **not** implemented — a second streaming format +would be a second thing to get right for no capability this milestone needs. + +Milestone 3's behaviour is preserved exactly, including for an endpoint that +ignores `stream`: the transport requests streaming but handles both reply +shapes, branching on the response's `content-type`. That is why no setting +had to be added to describe which behaviour a given endpoint has. + +--- + +## Ollama is local, and that is enforced + +"Local" is a security property here, not a label. Ollama's endpoint is +configurable — a different port, a different loopback form, another machine +on the user's own network — but `src/shared/chat/local-endpoint.ts` decides, +**statically and without DNS**, whether a configured `baseUrl` may be used at +all: + +- `localhost` and `*.localhost` (RFC 6761), case-insensitively; +- literal IPv4 loopback (`127.0.0.0/8`), private (`10/8`, `172.16/12`, + `192.168/16`) and link-local (`169.254/16`) addresses; +- literal IPv6 loopback (`[::1]`), unique-local (`fc00::/7`) and link-local + (`fe80::/10`) addresses. + +Everything else is refused, **including a hostname that would resolve to a +private address**. That is the deliberate trade: a name cannot be checked +without asking a resolver, and a resolver's answer is exactly what a typo or +an attacker controls, so names other than `localhost` are not accepted and +the user configures an address instead. Rejecting something legitimate is +recoverable; silently uploading a conversation to a cloud service under the +label "local" is not. + +A refused endpoint **fails closed and never falls back** to the local +default: sending the request somewhere the user did not configure would be a +silent provider substitution, which this milestone forbids itself elsewhere +too. The refusal surfaces as an ordinary `PROVIDER_INVALID_CONFIGURATION` +through the existing error and retry UI. + +Enforcement lives at provider-resolution time rather than in +`settingsSchema`. Tightening the schema would have made a previously-valid +`settings.json` invalid, and `loadSettings` fails safe by discarding a +document it cannot validate — so a stricter schema would have silently reset +a user's whole configuration. Resolution-time refusal reaches the same +outcome (no request is made) without that cost. + +--- + +## Streaming + +### The shape of it + +Streaming runs the length of the system, and each hop keeps the guarantees +of the hop before it: + +``` +provider ──SSE──▶ transport ──onChunk──▶ main/ipc.ts ──chat:chunk──▶ preload ──▶ renderer + (accumulates, (splits, validates, (validates) (previews + bounds) correlates, one window) as text) +``` + +The load-bearing decision is that **a delta is a preview, never a result**. +The value that becomes a `ChatMessage` is still the whole reply returned by +`chat:send` and validated by `chatProviderResultSchema`, exactly as in +Milestone 3. Dropping every chunk event would cost the live preview and +change nothing about the resulting conversation. That is what lets streaming +be additive: no existing validation, permission, audit or cancellation +behaviour had to be relaxed to make room for it. + +### What is bounded, and where + +| Bound | Value | Enforced in | +| -------------------------------- | ---------------------------------- | ---------------------------------- | +| Bytes read from a streamed body | `CHAT_STREAM_MAX_RESPONSE_BYTES` | transport | +| One unterminated protocol line | `CHAT_STREAM_MAX_LINE_LENGTH` | transport | +| Total accumulated assistant text | `CHAT_MESSAGE_CONTENT_MAX_LENGTH` | transport, again in the controller | +| One delta crossing IPC | `CHAT_STREAM_MAX_DELTA_LENGTH` | `main/ipc.ts`, schema-checked | +| Whole-call duration | `CHAT_PROVIDER_REQUEST_TIMEOUT_MS` | `withProviderTimeout` | +| Renderer-side preview length | `CHAT_MESSAGE_CONTENT_MAX_LENGTH` | `ConversationController` | + +Nothing accumulates without a cap, and no single bound is trusted twice over: +the renderer bounds the preview itself rather than assuming the main process +already did. + +A delta larger than the IPC bound is **split across several events**, never +truncated and never dropped — the preview stays faithful while each message +stays small. + +### Failure, cancellation and partial streams + +| Condition | Result | +| ---------------------------------------------- | ---------------------------------------------------------- | +| Malformed `data:` frame | Skipped; the rest of the stream is still read | +| Frame carrying no content, comment, blank line | Skipped — all three are legitimate SSE | +| `[DONE]` sentinel | Reading stops; anything after it is not part of the reply | +| Stream ends without `[DONE]`, content present | Accepted — many servers simply close | +| Stream produces no content at all | `PROVIDER_REQUEST_FAILED` | +| Any bound exceeded | `PROVIDER_REQUEST_FAILED`, read abandoned at once | +| Connection drops mid-stream | `PROVIDER_REQUEST_FAILED`; no partial message is committed | +| `AbortSignal` fires | `PROVIDER_ABORTED`; no further delta is forwarded | +| Budget elapses | `PROVIDER_TIMEOUT` | +| Caller's `onChunk` throws | Swallowed; the request completes normally | + +A failed or cancelled stream discards its preview. Accepting a stream that +ends without `[DONE]` is a deliberate, documented judgement: refusing one +would break every server that simply closes the connection, and the partial +text is validated like any other reply before it is shown. + +### The one new IPC channel + +`chat:chunk` is the only main → renderer _push_ channel in this codebase. It +is narrow by construction: + +- **One direction, one purpose.** The renderer can only listen; there is no + request it can make on it and no reply it can send back. +- **One window.** Events go to `event.sender` — the `WebContents` that + invoked that `chat:send` — never broadcast, and never to a destroyed one. +- **Correlated.** Every event carries the `requestId` of the call it belongs + to, and both the renderer adapter and the controller discard anything that + is not the request they are waiting on. +- **Validated twice.** `chatChunkEventSchema` runs in `main/ipc.ts` before an + event is sent and again in `src/preload/index.ts` before any renderer + listener sees it. A payload that fails is dropped silently. +- **Never a generic listener.** `chat.onChunk` fixes the channel name in the + preload and hands back an unsubscribe function; it never exposes + `ipcRenderer`, never accepts a caller-supplied channel, and never passes + the raw Electron event through. An e2e test asserts no `on`/`once`/ + `addListener`-shaped function exists anywhere on the bridge. + +### In the interface + +`ConversationState` gains `streamingContent`, kept **outside** `messages` for +the same reason `status` and `error` are: the message list stays append-only +and holds only fully validated messages. The preview renders through the same +bubble as a real message and with the same safety — a plain JSX text child, +which React escapes — so a streamed fragment can no more inject markup than a +completed reply can. `dangerouslySetInnerHTML` remains absent from the whole +codebase, and Markdown is still not rendered at all. + +Streamed output reaches nothing but the screen. There is no path from a delta +to `main/executor.ts`, to an `ActionProposal`, to the permission engine, to +the filesystem, to a terminal, to Git, or to any Windows API — the same +structural absence Milestone 1 established, unchanged by making the text +arrive in pieces. + +--- + +## A defect this milestone found and fixed + +`withProviderTimeout` (Milestone 2) rebuilt its options object as +`{ signal: combined.signal }`, forwarding only the option it owns. When +`onChunk` was added, every provider the decorator wraps — which is every real +provider — silently lost streaming: the transport streamed correctly, the +main process accumulated correctly, and not one delta ever reached the +renderer. An IPC-level test caught it before any of this shipped. + +The decorator now spreads the caller's options and overrides `signal` alone, +so an option it does not understand reaches the wrapped provider unchanged, +and `tests/unit/shared/provider-timeout.test.ts` asserts exactly that so the +next added option cannot repeat it. + +A related hazard is worth recording, because it bit twice in this codebase. +A `let` assigned only inside a closure is narrowed by TypeScript to its +initial literal type for the rest of the function, so a later `if (flag)` +reads as statically always-false — and an automated lint fix will "simplify" +it by deleting the check, silently changing behaviour. `src/shared/chat/timeout.ts` +hit this in Milestone 2 and solved it with an `AbortSignal.reason` sentinel; +the streaming reader hit it here and solved it by having its line consumer +**return** whether the stream finished instead of setting a flag. Both are +noted in the code so the pattern is recognised the third time. + +--- + +## Security boundary + +Everything Milestones 1–3 established holds unchanged. New in this milestone: + +- **No new privileged action type and no new permission decision.** Every + provider call is still `chat.send`, still routed through the unmodified + `runAction` → `handleActionProposal` pipeline, still denied while the + emergency stop is engaged, still audited with `{provider, messageCount}` + and nothing else. `main/permissions.ts`, `main/executor.ts`, + `main/action-pipeline.ts`, `main/action-runtime.ts`, `main/audit.ts`, + `main/secrets.ts` and `main/settings*.ts` all have **zero diff**. +- **Streamed content never reaches the audit log.** Chunks are not audit + events; the audit record is written once per action, as before, and a test + asserts streamed text appears nowhere in it. +- **Ollama never reads the secret store.** `createOllamaProvider`'s config + type has no key field at all, and `resolveMainChatProvider` skips the + secret read entirely for it, so a key stored for another provider cannot + reach a local endpoint even by a caller's mistake. +- **GLM's key follows the Milestone 3 path exactly** — read from the + encrypted store immediately before the adapter is constructed, used only + for one `Authorization` header, never logged, never in an error, never in + a response, never in an audit record. Tests assert its absence from the + request body and from thrown errors. +- **The renderer still cannot reach the network.** CSP `connect-src 'none'` + is unchanged, `src/shared`'s purity boundary is unchanged, and the + source-scan test still confines `window.localAgent` to one named file. +- **The new event channel adds no capability to the renderer** beyond + receiving bounded, validated text for a request it already initiated. + +--- + +## Testing + +| Area | File | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| Transport: request shape, streaming, multi-chunk, split frames, `[DONE]`, malformed frames, every bound, mid-stream failure, abort, no logging, non-streamed fallback | `tests/unit/main/chat-completions-transport.test.ts` | +| GLM: default and configured endpoint, bearer auth, key never in body or error, streaming | `tests/unit/main/glm-provider.test.ts` | +| Ollama: local default, no auth header, non-local refusal, no fallback, streaming | `tests/unit/main/ollama-provider.test.ts` | +| Local-endpoint classification: loopback/private/link-local accepted, public and look-alike names refused | `tests/unit/shared/local-endpoint.test.ts` | +| Resolution per provider, unapproved identifiers, timeout wrapping | `tests/unit/main/chat-provider-registry.test.ts` | +| `chat:chunk` end to end: per-fragment events, correlation, splitting, destroyed window, denial, audit silence | `tests/unit/main/ipc.test.ts` | +| Renderer adapter: forwarding, correlation filtering, abort, unsubscribe, authoritative result | `tests/unit/renderer/ipc-chat-provider.test.ts` | +| Preview state: accumulation, bounds, never a message, cleared on failure/switch/retry | `tests/unit/renderer/conversation-controller.test.ts` | +| Chunk-event schema: bounds, control characters, bidi, correlation, unknown fields | `tests/unit/shared/ipc.schema.test.ts` | +| Mock streaming: fragments concatenate to the final content, no preview on failure, abort | `tests/unit/shared/mock-provider.test.ts` | +| Timeout decorator forwards options it does not own | `tests/unit/shared/provider-timeout.test.ts` | +| Bridge shape, no generic invoke **or listen** surface, CSP still blocks renderer network access | `tests/e2e/electron-smoke.test.ts` | + +No test makes a real network request: every `fetch` is stubbed, and the e2e +suite still exercises only the bridge's static shape. + +--- + +## What remains deferred + +- Ollama's native `/api/chat` protocol, and any provider that does not speak + the OpenAI chat-completions format. +- Per-provider request options (temperature, max tokens, system prompts) — + the request body still carries only `model` and `messages`. +- A user-visible streaming toggle: streaming is requested always and + degrades gracefully, so no setting describes it. +- Automatic retry of a rate-limited or transiently failed request; the + user-initiated Retry button remains the only retry path. +- Everything earlier milestones deferred and this one was not asked to add: + filesystem tools, terminal execution, Git automation, browser or email + tools, memory, voice, screen awareness, workflows, and model-generated + tool execution. diff --git a/docs/phase-2-real-provider-architecture.md b/docs/phase-2-real-provider-architecture.md index ccb7ec3..bc2b751 100644 --- a/docs/phase-2-real-provider-architecture.md +++ b/docs/phase-2-real-provider-architecture.md @@ -10,6 +10,16 @@ > `withProviderTimeout`, the provider request/response schemas), none of > which changed shape. **`glm` and `ollama` remain unimplemented. > `anthropic`, `openai`, `claude` and `gemini` remain absent.** +> +> Milestone 4 (see +> [docs/phase-2-provider-completion.md](phase-2-provider-completion.md)) +> implements `glm` and `ollama` on top of this same design and adds bounded +> streaming, without changing any boundary described here: the exchange +> below was factored into a shared transport +> (`src/main/chat-completions-transport.ts`) that all three adapters use, +> `chat.send` is still the only provider action, and the timeout constant was +> renamed `CHAT_PROVIDER_REQUEST_TIMEOUT_MS` now that it covers every +> provider. This document is kept as the record of what Milestone 3 built. --- diff --git a/docs/security-model.md b/docs/security-model.md index 81d13d3..fde18c0 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -19,9 +19,13 @@ > first action type with real network egress, `chat.send` — routed through > the same unmodified permission engine, executor and pipeline once again, > and the first caller of `main/secrets.ts`'s `readSecret` outside that -> module's own tests. Controls below are marked **[implemented]**, -> **[enforced by schema]** or **[planned, milestone N]**. Nothing here is -> claimed as working before it exists. +> module's own tests. Phase 2, Milestone 4 completes the provider layer (GLM +> and Ollama, the latter refused unless its endpoint is a local address) and +> adds bounded streaming, introducing `chat:chunk` — the first and only +> main → renderer _push_ channel — while adding **no** new action type, no +> new permission decision and no new secret-store access. Controls below are +> marked **[implemented]**, **[enforced by schema]** or **[planned, milestone +> N]**. Nothing here is claimed as working before it exists. --- @@ -64,10 +68,14 @@ asserted by an end-to-end Playwright test against the built application, not only declared in source. A compromised renderer therefore gains only the narrow preload API — as of -Phase 2 Milestone 3, `health`, `settings.get`, `settings.update`, -`secrets.status`, `secrets.write`, `secrets.clear`, `chat.send` and -`chat.cancel`, and nothing else. Every one of those seven privileged -functions except `chat.cancel` is gated and audited through +Phase 2 Milestone 4, `health`, `settings.get`, `settings.update`, +`secrets.status`, `secrets.write`, `secrets.clear`, `chat.send`, +`chat.cancel` and `chat.onChunk`, and nothing else. `chat.onChunk` is a +subscription to one fixed, one-way event channel, not a request: it can +receive bounded, schema-validated preview text for a `chat.send` the +renderer itself initiated, and can do nothing else. Every one of the +remaining privileged functions except `chat.cancel` is gated and audited +through `main/action-pipeline.ts`'s unmodified `handleActionProposal` (`main/ipc.ts` → `main/action-runtime.ts`'s `runAction` → `handleActionProposal`), and none of them can return a plaintext key. `chat.cancel` has no privileged @@ -150,11 +158,14 @@ this is implemented. ### Network egress -**[implemented, Phase 2 Milestone 3]** The only network-capable code in this -codebase is `src/main/openai-compatible-provider.ts`, called only through -`main/chat-provider-registry.ts`'s `resolveMainChatProvider`, called only -from `chat.send`'s `perform` callback in `main/ipc.ts`. Three independent -facts hold this to the main process alone: +**[implemented, Phase 2 Milestones 3-4]** The only network-capable code in +this codebase is `src/main/chat-completions-transport.ts` — the one HTTP +exchange shared by all three real adapters +(`openai-compatible-provider.ts`, `glm-provider.ts`, `ollama-provider.ts`) — +called only through `main/chat-provider-registry.ts`'s +`resolveMainChatProvider`, called only from `chat.send`'s `perform` callback +in `main/ipc.ts`. Three independent facts hold this to the main process +alone: - `src/shared`'s lint-enforced purity boundary (see _Shared-layer purity_ below) blocks `fetch`/`XMLHttpRequest`/`WebSocket`/`EventSource` as @@ -166,9 +177,32 @@ facts hold this to the main process alone: asserted by an end-to-end test. - `src/renderer/chat/ipc-chat-provider.ts` — the only renderer file that reaches the real adapter at all — calls only the narrow, typed - `chat.send`/`chat.cancel` preload functions, never a network API directly; - a source-scan test asserts no file under `src/renderer/chat` references a - network-capable global either. + `chat.send`/`chat.cancel`/`chat.onChunk` preload functions, never a network + API directly; a source-scan test asserts no file under `src/renderer/chat` + references a network-capable global either. + +**[implemented, Phase 2 Milestone 4]** The `ollama` provider is refused +unless its endpoint is local. `src/shared/chat/local-endpoint.ts` classifies +a configured `baseUrl` statically, without DNS — loopback, RFC 1918 private +and link-local literals plus `localhost`/`*.localhost` are accepted, and +every other host, **including a name that would resolve to a private +address**, is refused. A refused endpoint fails closed with +`PROVIDER_INVALID_CONFIGURATION` and never falls back to the local default, +because sending the request somewhere the user did not configure would be a +silent provider substitution. Ollama also reads no credential at all: its +config type has no key field, and `resolveMainChatProvider` skips the secret +read entirely for it, so a key stored for another provider cannot reach a +local endpoint. + +**[implemented, Phase 2 Milestone 4]** Streaming adds no authority. A +streamed fragment is a preview: it is bounded (bytes read, line length, +delta size, accumulated length, whole-call duration), validated at every +hop, correlated to the request that asked for it, delivered only to the +window that made that request, and never committed to conversation state — +the message that is committed remains the whole reply `chat.send` resolves +with, validated as one document. Streamed text reaches no audit record, no +action proposal, no executor, and no privileged API; it is rendered as a +plain JSX text child, the same escaping every other message gets. `chat.send` is a genuine privileged action, not a bypass of the model above: it is gated by the same, unmodified `decidePermission` → `execute` → @@ -624,6 +658,11 @@ exposes no way to address one. No generic pass-through channel exists. | Plaintext API key reaching the renderer through a real provider call | Addressed, Phase 2 M3 — stays inside `resolveMainChatProvider`/`openai-compatible-provider.ts`; never in `ActionProposal.parameters`, `chatSendResponseSchema`, an audit record, or a thrown error; proven by test | | A real provider call bypassing the permission engine or the emergency stop | Addressed, Phase 2 M3 — reached only through the unmodified `runAction`/`handleActionProposal`; denied while the emergency stop is engaged | | Message content or a raw provider error leaking into the audit trail | Addressed, Phase 2 M3 — the `chat.send` proposal carries only `{provider, messageCount}`; every provider failure normalizes to one of five fixed codes first | +| A "local" provider silently reaching a cloud service | Addressed, Phase 2 M4 — an Ollama endpoint must be a loopback, private or link-local literal (or localhost); a name that merely looks local is refused, and a refused endpoint never falls back to the default | +| A credential stored for one provider being sent to another | Addressed, Phase 2 M4 — the secret read is skipped entirely for providers outside PROVIDERS_REQUIRING_API_KEY, and the Ollama adapter has no config field that could carry a key | +| Unbounded memory or output from a hostile streaming endpoint | Addressed, Phase 2 M4 — bytes read, line length, delta size and accumulated content are each capped independently, in the main process and again in the renderer | +| Streamed model output treated as a message, or as authorization | Addressed, Phase 2 M4 — deltas are previews held outside the message list; the committed reply is the separately validated whole, and no path exists from either to the executor | +| The renderer subscribing to arbitrary IPC events through the new push channel | Addressed, Phase 2 M4 — chat.onChunk fixes the channel in the preload, validates every payload, hands back only an unsubscribe function, and never exposes ipcRenderer or the raw event | | Supply-chain compromise via npm | Mitigated, not eliminated — see below | --- diff --git a/src/main/chat-completions-transport.ts b/src/main/chat-completions-transport.ts new file mode 100644 index 0000000..3558bb6 --- /dev/null +++ b/src/main/chat-completions-transport.ts @@ -0,0 +1,490 @@ +/** + * The one HTTP transport behind every real chat provider (Phase 2, + * Milestone 4). + * + * Milestone 3 implemented the OpenAI-compatible chat-completions exchange + * inside `openai-compatible-provider.ts`. Milestone 4 adds two more + * providers — GLM and Ollama — that speak the *same* wire protocol (GLM's + * public API is OpenAI-compatible; Ollama serves an OpenAI-compatible API + * under `/v1`), so the exchange itself was factored out here rather than + * copied three times. Each provider module is now a thin, separately + * reviewable declaration of its endpoint, its authentication policy and its + * identity; this module is the single place where a request is actually + * built, sent, bounded, parsed and normalized. + * + * One transport also means one place to audit for the properties that + * matter, all of which hold for every provider that uses it: + * + * - **The plaintext API key is a plain function parameter, never a module + * global.** It is read from the encrypted secret store immediately before + * a provider is constructed (`main/chat-provider-registry.ts`) and is used + * only to build one `Authorization` header per request. It is never + * logged, never placed in a thrown error's `message`, and never appears + * in a return value. A provider configured with `apiKey: null` (Ollama) + * sends no `Authorization` header at all. + * - **Nothing here logs anything.** No `console` call exists in this file — + * not the outgoing headers, not the request body (which carries the + * user's conversation), not the raw response body, not a caught error. + * - **Nothing accumulates without a bound.** The bytes read from the + * socket, the length of a single unterminated protocol line, the total + * assistant text accumulated from a stream, and the size of one delta + * handed to a caller are each capped separately — see + * `src/shared/constants.ts`'s streaming-bounds section. + * - **The response is never trusted structurally.** Whatever comes back is + * parsed defensively and the final text is validated by the same + * {@link chatProviderResultSchema} every other provider's result passes + * through, before it is returned to anything. + * - **Every failure normalizes into the existing five-code vocabulary** + * (`src/shared/chat/provider.ts`) — never a raw `Error.message`, a URL, a + * header, or a response body reaches a caller. + */ + +import { z } from 'zod'; + +import { + ChatProviderError, + type ChatProvider, + type ChatProviderRequest, + type ChatProviderRequestOptions, + type ChatProviderResult, +} from '../shared/chat/provider'; +import { + CHAT_MESSAGE_CONTENT_MAX_LENGTH, + CHAT_STREAM_MAX_LINE_LENGTH, + CHAT_STREAM_MAX_RESPONSE_BYTES, +} from '../shared/constants'; +import { chatProviderResultSchema } from '../shared/schemas/chat.schema'; + +/** OpenAI-compatible chat completions endpoint, relative to the configured base URL. */ +const CHAT_COMPLETIONS_PATH = '/chat/completions'; + +/** + * Upper bound on the bytes read from a **non-streamed** response body, + * before this module gives up and reports a malformed response rather than + * buffering an unbounded amount of memory for a hostile or misbehaving + * endpoint — the base URL is user-supplied (including self-hosted + * endpoints), so it is never assumed to be well-behaved. Generous for any + * real chat completion (a very long reply is still a few hundred KB of JSON) + * and tight enough to bound memory use for a pathological response. The + * streamed path has its own, larger byte cap — see + * {@link CHAT_STREAM_MAX_RESPONSE_BYTES} for why the two differ. + */ +export const MAX_RESPONSE_BYTES = 1_000_000; + +export interface ChatCompletionsConfig { + /** The `ChatProvider.id` the constructed provider reports. */ + readonly providerId: string; + /** Already validated: absolute http/https, no embedded credentials — see `settingsSchema`. */ + readonly baseUrl: string; + readonly model: string; + /** + * Plaintext, decrypted immediately before this is constructed, or `null` + * for a provider that authenticates with no key at all (Ollama). Never + * logged, never returned, never placed in an error. + */ + readonly apiKey: string | null; +} + +/** Only the roles this phase's chat can produce are forwarded to the wire format. */ +const FORWARDED_ROLES = new Set(['system', 'user', 'assistant']); + +const chatCompletionMessageSchema = z.object({ + content: z.string().nullable().optional(), +}); + +/** + * The minimal shape this transport reads from a non-streamed response. + * Deliberately not `strictObject`: this describes an upstream API this + * codebase does not control, which may carry vendor-specific fields this + * transport has no use for and must not reject on that basis alone — only + * the one field actually read is validated. + */ +const chatCompletionResponseSchema = z.object({ + choices: z + .array( + z.object({ + message: chatCompletionMessageSchema, + }), + ) + .min(1) + .max(32), +}); + +/** The minimal shape read from one streamed frame. Same reasoning as above. */ +const chatCompletionChunkSchema = z.object({ + choices: z + .array( + z.object({ + delta: z + .object({ + content: z.string().nullable().optional(), + }) + .optional(), + finish_reason: z.string().nullable().optional(), + }), + ) + .max(32) + .optional(), +}); + +function joinUrl(baseUrl: string, path: string): string { + const trimmedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; + const trimmedPath = path.startsWith('/') ? path : `/${path}`; + return `${trimmedBase}${trimmedPath}`; +} + +function toWireMessages( + request: ChatProviderRequest, +): { readonly role: string; readonly content: string }[] { + return request.messages + .filter((message) => FORWARDED_ROLES.has(message.role)) + .map((message) => ({ role: message.role, content: message.content })); +} + +/** + * Thrown internally when a bound is exceeded or a body cannot be read. + * Never escapes this module: every caller converts it into a + * `ChatProviderError` with a fixed, safe message first. + */ +class BoundedReadError extends Error {} + +/** + * Reads `response`'s body as text, rejecting once more than `maxBytes` have + * been received rather than buffering an unbounded amount. Falls back to + * `response.text()` only when no streaming reader is available (a test + * double, say) — still applying the same cap to what comes back. + */ +async function readBoundedText(response: Response, maxBytes: number): Promise { + const reader = response.body?.getReader(); + if (!reader) { + const text = await response.text(); + if (Buffer.byteLength(text, 'utf8') > maxBytes) { + throw new BoundedReadError('response body exceeds the configured byte cap'); + } + return text; + } + + const decoder = new TextDecoder(); + let received = 0; + let result = ''; + for (;;) { + // Sequential by nature: each chunk must be measured before the next is requested. + const { done, value } = await reader.read(); + if (done) break; + received += value.byteLength; + if (received > maxBytes) { + await reader.cancel().catch(() => undefined); + throw new BoundedReadError('response body exceeds the configured byte cap'); + } + result += decoder.decode(value, { stream: true }); + } + result += decoder.decode(); + return result; +} + +/** + * The text carried by one server-sent-events `data:` line, or `null` when + * the line carries none. + * + * A `null` result is deliberately **not** an error. Real streams legitimately + * contain blank separator lines, `:` keep-alive comments, and `event:`/`id:` + * fields this transport has no use for; a `data:` line whose JSON does not + * parse, or which carries no text, is treated the same way — skipped, never + * fatal. A stream that produces no text at all is caught afterwards, once, + * by the empty-result check, rather than by guessing which individual frame + * was the problem. + */ +function readSseDataLine(line: string): { readonly delta: string | null; readonly done: boolean } { + if (!line.startsWith('data:')) return { delta: null, done: false }; + + const payload = line.slice('data:'.length).trim(); + if (payload === '') return { delta: null, done: false }; + if (payload === '[DONE]') return { delta: null, done: true }; + + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + return { delta: null, done: false }; + } + + const frame = chatCompletionChunkSchema.safeParse(parsed); + if (!frame.success) return { delta: null, done: false }; + + const choice = frame.data.choices?.[0]; + const content = choice?.delta?.content; + const finished = typeof choice?.finish_reason === 'string'; + + return { + delta: typeof content === 'string' && content.length > 0 ? content : null, + done: finished, + }; +} + +interface StreamAccumulationOptions { + readonly signal: AbortSignal | undefined; + readonly onChunk: ((delta: string) => void) | undefined; +} + +/** + * Consumes a `text/event-stream` body, forwarding each fragment to + * `onChunk` and accumulating the whole reply. + * + * Three independent bounds apply, and exceeding any of them ends the read + * rather than growing a buffer: total bytes from the socket, the length of a + * single line that never terminates, and the accumulated assistant text. + * + * A caller's `onChunk` is invoked inside a `try`/`catch` that discards + * whatever it throws: a live preview failing in the renderer must never turn + * a working provider response into a failed one. + */ +async function readStreamedCompletion( + response: Response, + options: StreamAccumulationOptions, +): Promise { + const reader = response.body?.getReader(); + if (!reader) { + throw new BoundedReadError('a streamed response carried no readable body'); + } + + const decoder = new TextDecoder(); + let received = 0; + let buffer = ''; + let accumulated = ''; + + const emit = (delta: string): void => { + if (options.signal?.aborted) return; + if (options.onChunk === undefined) return; + try { + options.onChunk(delta); + } catch { + // Advisory only — see this function's doc comment. + } + }; + + /** + * Consumes one protocol line and **returns** whether the stream declared + * itself finished, rather than setting a flag in the enclosing scope. + * + * That is deliberate and not a style preference: a `let` assigned only + * inside a closure is narrowed by TypeScript to its initial literal type + * for the rest of the function, which makes a later `if (finished)` read + * as statically always-false — the same control-flow quirk + * `src/shared/chat/timeout.ts` documents for its own timeout flag, and one + * an automated lint fix will happily "simplify" by deleting the check + * altogether, silently changing behaviour. Returning the value keeps the + * decision in ordinary, analysable control flow. + */ + const consumeLine = (rawLine: string): boolean => { + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; + const { delta, done } = readSseDataLine(line); + if (delta !== null) { + accumulated += delta; + if (accumulated.length > CHAT_MESSAGE_CONTENT_MAX_LENGTH) { + throw new BoundedReadError('streamed content exceeds the maximum message length'); + } + emit(delta); + } + return done; + }; + + let streamFinished = false; + + try { + while (!streamFinished) { + // Sequential by nature: each chunk must be measured and parsed before + // the next is requested, so nothing is read past a breached bound. + const { done, value } = await reader.read(); + if (done) break; + + received += value.byteLength; + if (received > CHAT_STREAM_MAX_RESPONSE_BYTES) { + throw new BoundedReadError('streamed response exceeds the configured byte cap'); + } + + buffer += decoder.decode(value, { stream: true }); + + let newlineIndex = buffer.indexOf('\n'); + while (newlineIndex !== -1 && !streamFinished) { + streamFinished = consumeLine(buffer.slice(0, newlineIndex)); + buffer = buffer.slice(newlineIndex + 1); + newlineIndex = buffer.indexOf('\n'); + } + + if (buffer.length > CHAT_STREAM_MAX_LINE_LENGTH) { + throw new BoundedReadError('a streamed line exceeded the maximum line length'); + } + } + + // A final line with no trailing newline is still a line — unless the + // stream already said it was done, in which case whatever trails the + // terminator is not part of the reply. + if (!streamFinished) { + buffer += decoder.decode(); + if (buffer.length > 0) consumeLine(buffer); + } + } finally { + await reader.cancel().catch(() => undefined); + } + + return accumulated; +} + +function malformedResponse(cause?: unknown): ChatProviderError { + return new ChatProviderError( + 'PROVIDER_REQUEST_FAILED', + 'The provider returned a malformed response.', + cause === undefined ? undefined : { cause }, + ); +} + +function isEventStream(response: Response): boolean { + const contentType = response.headers.get('content-type') ?? ''; + return contentType.toLowerCase().includes('text/event-stream'); +} + +/** + * Creates a `ChatProvider` for one resolved OpenAI-compatible configuration. + * + * Streaming is always *requested* (`stream: true`) and both reply shapes are + * handled: a `text/event-stream` body is consumed incrementally, and any + * other body is read as a single bounded JSON document, exactly as + * Milestone 3 did. An endpoint that ignores `stream` therefore keeps + * working unchanged, and no setting has to be added to describe which + * behaviour a given endpoint has. + * + * Callers apply `withProviderTimeout` around the returned provider — this + * function starts no timer of its own, matching every other `ChatProvider` + * in this codebase (see `src/shared/chat/timeout.ts`). + */ +export function createChatCompletionsProvider(config: ChatCompletionsConfig): ChatProvider { + return { + id: config.providerId, + async send( + request: ChatProviderRequest, + options?: ChatProviderRequestOptions, + ): Promise { + const signal = options?.signal; + if (signal?.aborted) { + throw new ChatProviderError('PROVIDER_ABORTED', 'The request was already aborted.'); + } + + const url = joinUrl(config.baseUrl, CHAT_COMPLETIONS_PATH); + const body = JSON.stringify({ + model: config.model, + messages: toWireMessages(request), + stream: true, + }); + + let response: Response; + try { + response = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'text/event-stream, application/json', + ...(config.apiKey === null ? {} : { authorization: `Bearer ${config.apiKey}` }), + }, + body, + ...(signal ? { signal } : {}), + }); + } catch (error) { + if (signal?.aborted) { + throw new ChatProviderError('PROVIDER_ABORTED', 'The request was aborted.', { + cause: error, + }); + } + throw new ChatProviderError( + 'PROVIDER_REQUEST_FAILED', + 'The network request to the provider failed.', + { cause: error }, + ); + } + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + throw new ChatProviderError( + 'PROVIDER_INVALID_CONFIGURATION', + 'The provider rejected the configured credentials.', + ); + } + if (response.status === 429) { + throw new ChatProviderError( + 'PROVIDER_REQUEST_FAILED', + 'The provider is rate-limiting requests. Try again shortly.', + ); + } + throw new ChatProviderError( + 'PROVIDER_REQUEST_FAILED', + 'The provider returned an error response.', + ); + } + + const content = isEventStream(response) + ? await readStreamed(response, signal, options?.onChunk) + : await readWhole(response); + + const result = chatProviderResultSchema.safeParse({ content }); + if (!result.success) { + throw malformedResponse(); + } + return result.data; + }, + }; +} + +/** The streamed branch, with every failure already normalized. */ +async function readStreamed( + response: Response, + signal: AbortSignal | undefined, + onChunk: ((delta: string) => void) | undefined, +): Promise { + try { + return await readStreamedCompletion(response, { signal, onChunk }); + } catch (error) { + if (signal?.aborted) { + throw new ChatProviderError('PROVIDER_ABORTED', 'The request was aborted.', { cause: error }); + } + if (error instanceof BoundedReadError) { + throw new ChatProviderError( + 'PROVIDER_REQUEST_FAILED', + 'The provider response was too large or could not be read.', + { cause: error }, + ); + } + // A mid-stream socket failure: the connection dropped, not a bound. + throw new ChatProviderError( + 'PROVIDER_REQUEST_FAILED', + 'The connection to the provider was lost before the reply completed.', + { cause: error }, + ); + } +} + +/** The non-streamed branch, byte-for-byte the Milestone 3 behaviour. */ +async function readWhole(response: Response): Promise { + let rawText: string; + try { + rawText = await readBoundedText(response, MAX_RESPONSE_BYTES); + } catch (error) { + throw new ChatProviderError( + 'PROVIDER_REQUEST_FAILED', + 'The provider response was too large or could not be read.', + { cause: error }, + ); + } + + let parsedJson: unknown; + try { + parsedJson = JSON.parse(rawText); + } catch (error) { + throw malformedResponse(error); + } + + const wireResult = chatCompletionResponseSchema.safeParse(parsedJson); + const content = wireResult.success ? wireResult.data.choices[0]?.message.content : undefined; + if (typeof content !== 'string') { + throw malformedResponse(); + } + return content; +} diff --git a/src/main/chat-provider-registry.ts b/src/main/chat-provider-registry.ts index 586d821..924d976 100644 --- a/src/main/chat-provider-registry.ts +++ b/src/main/chat-provider-registry.ts @@ -1,6 +1,6 @@ /** * Resolves the real, main-process `ChatProvider` for the currently selected - * provider (Phase 2, Milestone 3). + * provider (Phase 2, Milestones 3-4). * * This is the counterpart to `src/shared/chat/registry.ts`'s * `createChatProviderForSelection`, and deliberately a separate module in a @@ -8,36 +8,57 @@ * decrypted API key and hands it to a network-capable adapter, so it must * live where secrets already live — `src/main` — never in `src/shared`. * - * For every identifier without a real adapter (`none`, `glm`, `ollama`, and - * anything outside {@link MODEL_PROVIDERS}), this delegates straight back to - * the shared, always-fail-closed registry rather than re-implementing the - * same "unavailable" behaviour a second time. Only `openai-compatible` takes - * a different path here: configuration is checked (base URL, model, a - * stored key), and if all three are present, the real adapter - * (`./openai-compatible-provider.ts`) is constructed and wrapped in - * `withProviderTimeout` — composed here, not inside the adapter itself, - * exactly as `docs/phase-2-provider-architecture.md` describes for any - * future real adapter. + * It is also the one place that decides, per provider, what a *usable* + * configuration is: + * + * | Identifier | Endpoint | Credential | + * | ------------------- | ------------------------------- | --------------------- | + * | `none` | — | — | + * | `openai-compatible` | required, user-supplied | required | + * | `glm` | optional, public default | required | + * | `ollama` | optional, local default, **must be local** | never read | + * + * A model identifier is required for all three real providers. + * + * Every failure to reach a usable configuration produces a fail-closed + * `ChatProvider` rather than an exception or a substitution: `none` (and + * anything outside {@link MODEL_PROVIDERS}) delegates straight back to the + * shared, always-fail-closed registry; a missing key, a missing model or a + * non-local Ollama endpoint each yield a provider whose `send()` rejects + * with `PROVIDER_INVALID_CONFIGURATION`. **Nothing here ever silently + * substitutes one provider for another** — the user's selection is either + * honoured or refused, never quietly redirected. */ import type { SafeStorage } from 'electron'; +import { createGlmProvider } from './glm-provider'; +import { createOllamaProvider } from './ollama-provider'; import { createOpenAiCompatibleProvider } from './openai-compatible-provider'; import { readSecret } from './secrets'; import { ChatProviderError, type ChatProvider } from '../shared/chat/provider'; import { createChatProviderForSelection } from '../shared/chat/registry'; import { withProviderTimeout } from '../shared/chat/timeout'; -import { PROVIDERS_REQUIRING_API_KEY } from '../shared/constants'; +import { + MODEL_PROVIDERS, + PROVIDERS_REQUIRING_API_KEY, + type ModelProvider, +} from '../shared/constants'; import type { ModelProviderSettings } from '../shared/schemas'; /** * Bounds how long a real provider call may run before it is aborted and - * reported as `PROVIDER_TIMEOUT`. A chat completion is a synchronous - * request/response exchange in this milestone (no streaming), so a single, - * generous fixed budget is enough — there is no per-call configuration for - * it yet, matching the rest of Phase 2's provider settings. + * reported as `PROVIDER_TIMEOUT`. + * + * One budget covers the whole call, streamed or not: for a streamed reply + * that means the complete exchange, not the gap between two deltas. The + * accumulated reply is itself bounded to `CHAT_MESSAGE_CONTENT_MAX_LENGTH` + * characters, so "the whole call" cannot be arbitrarily long by design; + * a provider that streams unusually slowly is the case this budget will cut + * short, and it does so safely — the request is aborted, nothing partial is + * committed to the conversation, and the user can retry. */ -export const OPENAI_COMPATIBLE_REQUEST_TIMEOUT_MS = 30_000; +export const CHAT_PROVIDER_REQUEST_TIMEOUT_MS = 30_000; export interface ResolveChatProviderOptions { readonly modelProvider: ModelProviderSettings; @@ -54,59 +75,97 @@ function unavailable(id: string, message: string): ChatProvider { }; } +const NO_MODEL_MESSAGE = 'No model is configured. Add one in provider settings.'; +const NO_KEY_MESSAGE = 'No API key is stored yet. Add one in provider settings.'; + +/** + * Reads the stored key for a provider that requires one. + * + * Returns `null` when no key is stored, when `safeStorage` cannot decrypt, + * or — defensively — when the provider is not one that takes a key at all, + * so no caller can reach the secret store on behalf of a provider that has + * no business authenticating. + */ +async function readKeyFor( + provider: ModelProvider, + secretsFile: string, + safeStorage: SafeStorage, +): Promise { + if (!PROVIDERS_REQUIRING_API_KEY.includes(provider)) return null; + return readSecret(secretsFile, safeStorage); +} + /** * Resolves the `ChatProvider` a real `chat:send` call should use. * * Never throws: every failure to resolve a working adapter (no key stored, - * `safeStorage` unavailable, an empty model, an unimplemented identifier) is - * represented as a `ChatProvider` whose `send()` rejects with a normalized - * code, exactly like the shared registry's fail-closed placeholders — so a - * caller (`main/ipc.ts`) always has one `ChatProvider` to call, uniformly, - * regardless of why it might fail. + * `safeStorage` unavailable, an empty model, a non-local Ollama endpoint, an + * unimplemented identifier) is represented as a `ChatProvider` whose + * `send()` rejects with a normalized code, exactly like the shared + * registry's fail-closed placeholders — so a caller (`main/ipc.ts`) always + * has one `ChatProvider` to call, uniformly, regardless of why it might + * fail. */ export async function resolveMainChatProvider( options: ResolveChatProviderOptions, ): Promise { const { modelProvider, secretsFile, safeStorage } = options; + const { provider, baseUrl, model } = modelProvider; - if (modelProvider.provider !== 'openai-compatible') { - return createChatProviderForSelection(modelProvider.provider); + // Checked first, and by membership rather than by exhausting the union + // below: `ModelProvider` is a closed type, but this function is reached + // from an IPC boundary, so an identifier that never passed `settingsSchema` + // must fail closed here instead of falling through to whichever branch + // happens to be last. Written as a runtime membership test on purpose — + // an equality chain the type system can prove exhaustive is exactly what + // an automated lint fix will delete as "unnecessary", taking the + // fail-closed guarantee with it. + if (!(MODEL_PROVIDERS as readonly string[]).includes(provider)) { + return createChatProviderForSelection(provider); } - if (modelProvider.baseUrl.trim() === '') { - return unavailable( - 'openai-compatible', - 'No endpoint is configured. Add one in provider settings.', - ); + if (provider === 'none') { + return createChatProviderForSelection(provider); } - if (modelProvider.model.trim() === '') { - return unavailable( - 'openai-compatible', - 'No model is configured. Add one in provider settings.', + if (provider === 'openai-compatible') { + if (baseUrl.trim() === '') { + return unavailable(provider, 'No endpoint is configured. Add one in provider settings.'); + } + if (model.trim() === '') return unavailable(provider, NO_MODEL_MESSAGE); + + const apiKey = await readKeyFor(provider, secretsFile, safeStorage); + if (apiKey === null) return unavailable(provider, NO_KEY_MESSAGE); + + return withProviderTimeout( + createOpenAiCompatibleProvider({ baseUrl, model, apiKey }), + CHAT_PROVIDER_REQUEST_TIMEOUT_MS, ); } - if (!PROVIDERS_REQUIRING_API_KEY.includes('openai-compatible')) { - // Unreachable given today's constant, kept so a future change to that - // list cannot silently skip the key check below without this failing - // loudly in review instead. - return unavailable('openai-compatible', 'This provider is not correctly configured.'); - } + if (provider === 'glm') { + if (model.trim() === '') return unavailable(provider, NO_MODEL_MESSAGE); + + const apiKey = await readKeyFor(provider, secretsFile, safeStorage); + if (apiKey === null) return unavailable(provider, NO_KEY_MESSAGE); - const apiKey = await readSecret(secretsFile, safeStorage); - if (apiKey === null) { - return unavailable( - 'openai-compatible', - 'No API key is stored yet. Add one in provider settings.', + return withProviderTimeout( + createGlmProvider({ baseUrl, model, apiKey }), + CHAT_PROVIDER_REQUEST_TIMEOUT_MS, ); } - const provider = createOpenAiCompatibleProvider({ - baseUrl: modelProvider.baseUrl, - model: modelProvider.model, - apiKey, - }); + // `ollama` is what remains: the membership check above already rejected + // anything outside `MODEL_PROVIDERS`, and the three branches above consumed + // every other approved identifier. + if (model.trim() === '') return unavailable(provider, NO_MODEL_MESSAGE); - return withProviderTimeout(provider, OPENAI_COMPATIBLE_REQUEST_TIMEOUT_MS); + // No secret is read here at all: Ollama authenticates with nothing, and + // `createOllamaProvider` has no parameter that could carry a key even if + // one were read by mistake. A non-local endpoint is refused inside that + // factory, which returns its own fail-closed provider. + return withProviderTimeout( + createOllamaProvider({ baseUrl, model }), + CHAT_PROVIDER_REQUEST_TIMEOUT_MS, + ); } diff --git a/src/main/glm-provider.ts b/src/main/glm-provider.ts new file mode 100644 index 0000000..b8b450d --- /dev/null +++ b/src/main/glm-provider.ts @@ -0,0 +1,67 @@ +/** + * The `glm` provider adapter (Phase 2, Milestone 4). + * + * GLM's public API speaks the OpenAI chat-completions wire format — + * `POST /chat/completions`, a bearer key, the same request and + * response shapes, the same server-sent-events streaming — so this module is + * a configuration of `./chat-completions-transport.ts` and nothing more. + * Every security property of the exchange (no logging, bounded reads, + * validated output, normalized errors, the key never leaving the + * `Authorization` header) is that module's, audited once for all three real + * providers, rather than re-implemented and re-reviewed here. + * + * Two things are specific to GLM, and both are here rather than buried in + * the transport: + * + * - **A default endpoint.** Unlike `openai-compatible`, whose whole point + * is a user-supplied endpoint, GLM has a known public one + * ({@link GLM_DEFAULT_BASE_URL}). A configured `baseUrl` still wins, so a + * regional mirror or a corporate proxy stays reachable — the default is a + * fallback, never an override. + * - **A key is mandatory.** GLM is in `PROVIDERS_REQUIRING_API_KEY`; + * `main/chat-provider-registry.ts` refuses to construct this adapter + * without one, so the key reaching this module is always a real, stored, + * decrypted value rather than an empty string standing in for one. + */ + +import { + createChatCompletionsProvider, + type ChatCompletionsConfig, +} from './chat-completions-transport'; +import type { ChatProvider } from '../shared/chat/provider'; +import { GLM_DEFAULT_BASE_URL } from '../shared/constants'; + +export interface GlmProviderConfig { + /** + * The user's configured endpoint, or an empty string to use + * {@link GLM_DEFAULT_BASE_URL}. When non-empty it has already been + * validated as an absolute, credential-free http/https URL by + * `settingsSchema`. + */ + readonly baseUrl: string; + readonly model: string; + /** Plaintext, decrypted immediately before this is constructed. Never logged. */ + readonly apiKey: string; +} + +/** The endpoint a given configuration resolves to. Exported for tests and diagnostics. */ +export function resolveGlmBaseUrl(configuredBaseUrl: string): string { + const trimmed = configuredBaseUrl.trim(); + return trimmed === '' ? GLM_DEFAULT_BASE_URL : trimmed; +} + +/** + * Creates the `glm` adapter for one resolved configuration. + * + * Callers apply `withProviderTimeout` around the returned provider — this + * function starts no timer of its own. + */ +export function createGlmProvider(config: GlmProviderConfig): ChatProvider { + const transportConfig: ChatCompletionsConfig = { + providerId: 'glm', + baseUrl: resolveGlmBaseUrl(config.baseUrl), + model: config.model, + apiKey: config.apiKey, + }; + return createChatCompletionsProvider(transportConfig); +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index f879c99..2024fe2 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -15,11 +15,18 @@ * Milestone 5 `handleActionProposal`. No handler here calls `execute` or the * secret-store / settings-write functions directly; every side effect is * reached only through a permission decision. + * + * Phase 2 Milestone 3 adds `chat:send` (routed through that same pipeline) + * and `chat:cancel` (no side effect of its own to gate). Phase 2 Milestone 4 + * adds `chat:chunk`, the only main → renderer *push* channel in this + * codebase: one-way, correlated to a single in-flight `chat:send`, bounded + * and validated before it is sent, and advisory — see {@link emitChatChunks} + * and `docs/phase-2-provider-completion.md`. */ import { randomUUID } from 'node:crypto'; -import type { IpcMain, SafeStorage } from 'electron'; +import type { IpcMain, IpcMainInvokeEvent, SafeStorage } from 'electron'; import { runAction } from './action-runtime'; import type { ActionRuntime } from './action-runtime'; @@ -35,15 +42,17 @@ import { import { loadSettings } from './settings'; import { CHAT_PROVIDER_ERROR_CODES, ChatProviderError } from '../shared/chat/provider'; import type { ChatProviderResult } from '../shared/chat/provider'; -import { PROVIDERS_REQUIRING_API_KEY } from '../shared/constants'; +import { CHAT_STREAM_MAX_DELTA_LENGTH, PROVIDERS_REQUIRING_API_KEY } from '../shared/constants'; import { chatCancelRequestSchema, chatCancelResponseSchema, + chatChunkEventSchema, chatSendRequestSchema, chatSendResponseSchema, healthCheckRequestSchema, healthCheckResponseSchema, IPC_CHAT_CANCEL_CHANNEL, + IPC_CHAT_CHUNK_CHANNEL, IPC_CHAT_SEND_CHANNEL, IPC_HEALTH_CHANNEL, IPC_SECRETS_CLEAR_CHANNEL, @@ -140,6 +149,39 @@ function toSecretsResponse(result: ActionResult): SecretsAct }; } +/** + * Forwards one streamed fragment to the renderer that asked for it, as one + * or more `chat:chunk` events (Phase 2, Milestone 4). + * + * Four properties, all enforced here rather than assumed of the caller: + * + * - **Only the requesting window is told.** The event goes to + * `event.sender` — the `WebContents` that invoked this `chat:send` — not + * broadcast, and never to a destroyed one. + * - **Only bounded fragments are sent.** A delta longer than + * {@link CHAT_STREAM_MAX_DELTA_LENGTH} is split across several events + * rather than truncated or dropped, so no single IPC message is + * unbounded and no text is lost. + * - **Only content-safe fragments are sent.** Each piece is validated by + * {@link chatChunkEventSchema}; one that fails is skipped silently. A + * dropped preview fragment costs nothing, because the authoritative reply + * is the validated one `chat:send` resolves with — never the sum of these + * events. + * - **A failure here never fails the request.** Streaming is advisory; the + * provider call continues regardless. + */ +function emitChatChunks(event: IpcMainInvokeEvent, requestId: string, delta: string): void { + const sender = event.sender; + if (sender.isDestroyed()) return; + + for (let index = 0; index < delta.length; index += CHAT_STREAM_MAX_DELTA_LENGTH) { + const piece = delta.slice(index, index + CHAT_STREAM_MAX_DELTA_LENGTH); + const payload = chatChunkEventSchema.safeParse({ requestId, delta: piece }); + if (!payload.success) continue; + sender.send(IPC_CHAT_CHUNK_CHANNEL, payload.data); + } +} + /** * `result.errorCode` is normally always one of {@link CHAT_PROVIDER_ERROR_CODES} * — `perform` below never throws anything except a `ChatProviderError`, @@ -305,7 +347,7 @@ export function registerIpcHandlers(ipcMain: IpcMain, runtime: IpcHandlerRuntime return secretsClearResponseSchema.parse(toSecretsResponse(result)); }); - ipcMain.handle(IPC_CHAT_SEND_CHANNEL, async (_event, ...args: unknown[]) => { + ipcMain.handle(IPC_CHAT_SEND_CHANNEL, async (event: IpcMainInvokeEvent, ...args: unknown[]) => { const [{ requestId, messages }] = chatSendRequestSchema.parse(args); const now = runtime.nowFn(); const actionRuntime = buildActionRuntime(runtime, now); @@ -331,7 +373,15 @@ export function registerIpcHandlers(ipcMain: IpcMain, runtime: IpcHandlerRuntime }); try { - return await provider.send({ messages }, { signal: abortController.signal }); + return await provider.send( + { messages }, + { + signal: abortController.signal, + onChunk: (delta) => { + emitChatChunks(event, requestId, delta); + }, + }, + ); } catch (error) { if (error instanceof ChatProviderError) { throw new ActionExecutionError(error.code, error.message); diff --git a/src/main/ollama-provider.ts b/src/main/ollama-provider.ts new file mode 100644 index 0000000..0cdd0fc --- /dev/null +++ b/src/main/ollama-provider.ts @@ -0,0 +1,115 @@ +/** + * The `ollama` provider adapter (Phase 2, Milestone 4). + * + * Ollama is a **local** model runtime. It serves an OpenAI-compatible API + * under `/v1`, which is what this adapter talks to, so the exchange is the + * same `./chat-completions-transport.ts` every other real provider uses — + * one wire protocol, one parser, one set of bounds, audited once. Ollama's + * own native `/api/chat` protocol is deliberately not implemented: a second + * streaming format would be a second thing to get right for no capability + * this milestone needs. + * + * Three things are specific to Ollama, and all three are enforced here + * rather than assumed: + * + * - **No credential is sent.** Ollama is unauthenticated and is not in + * `PROVIDERS_REQUIRING_API_KEY`; this adapter passes `apiKey: null`, so + * the transport sends no `Authorization` header at all. A key stored for + * some other provider is never read for, or forwarded to, Ollama — a + * property {@link createOllamaProvider}'s signature makes structural, + * since it has no way to accept one. + * - **The endpoint must be local.** {@link isLocalHttpEndpoint} decides, + * statically and without DNS, whether a configured `baseUrl` is a + * loopback, private or link-local address (or `localhost`). Anything else + * is refused before a request is built. Without this, a mistyped or + * hostile endpoint would ship the user's entire conversation to a cloud + * service while the interface still called the provider "local". + * - **A default endpoint.** {@link OLLAMA_DEFAULT_BASE_URL} — + * `http://127.0.0.1:11434/v1`, Ollama's own default port — is used when + * nothing is configured, so the common case needs no setup at all. A + * configured endpoint still wins, provided it is local. + */ + +import { + createChatCompletionsProvider, + type ChatCompletionsConfig, +} from './chat-completions-transport'; +import { isLocalHttpEndpoint } from '../shared/chat/local-endpoint'; +import { ChatProviderError, type ChatProvider } from '../shared/chat/provider'; +import { OLLAMA_DEFAULT_BASE_URL } from '../shared/constants'; + +export interface OllamaProviderConfig { + /** + * The user's configured endpoint, or an empty string to use + * {@link OLLAMA_DEFAULT_BASE_URL}. Must resolve to a local address either + * way — see {@link resolveOllamaBaseUrl}. + */ + readonly baseUrl: string; + readonly model: string; +} + +/** Thrown by {@link resolveOllamaBaseUrl} for an endpoint that is not local. */ +export class NonLocalOllamaEndpointError extends Error { + constructor() { + super('the configured Ollama endpoint is not a local address'); + this.name = 'NonLocalOllamaEndpointError'; + } +} + +/** + * The endpoint a given configuration resolves to. + * + * Throws {@link NonLocalOllamaEndpointError} for a configured endpoint that + * is not local. The caller (`main/chat-provider-registry.ts`) turns that + * into a fail-closed `PROVIDER_INVALID_CONFIGURATION` provider, so the + * refusal reaches the user as an ordinary configuration error rather than an + * exception — but the refusal itself happens here, before any URL is built. + */ +export function resolveOllamaBaseUrl(configuredBaseUrl: string): string { + const trimmed = configuredBaseUrl.trim(); + if (trimmed === '') return OLLAMA_DEFAULT_BASE_URL; + if (!isLocalHttpEndpoint(trimmed)) throw new NonLocalOllamaEndpointError(); + return trimmed; +} + +/** + * Creates the `ollama` adapter for one resolved configuration. + * + * Rejects a non-local endpoint by returning a provider whose `send()` always + * fails closed with `PROVIDER_INVALID_CONFIGURATION` — never by throwing + * synchronously, matching how every other resolution failure in this + * codebase is represented, and never by quietly falling back to the default + * local endpoint, which would send the request somewhere the user did not + * configure. + * + * Callers apply `withProviderTimeout` around the returned provider — this + * function starts no timer of its own. + */ +export function createOllamaProvider(config: OllamaProviderConfig): ChatProvider { + let baseUrl: string; + try { + baseUrl = resolveOllamaBaseUrl(config.baseUrl); + } catch { + return { + id: 'ollama', + send() { + return Promise.reject( + new ChatProviderError( + 'PROVIDER_INVALID_CONFIGURATION', + 'The configured Ollama endpoint is not a local address. Ollama runs locally; point it at a loopback or private address.', + ), + ); + }, + }; + } + + const transportConfig: ChatCompletionsConfig = { + providerId: 'ollama', + baseUrl, + model: config.model, + // Never a key: Ollama is unauthenticated, and a key stored for another + // provider must not leak to a local endpoint. + apiKey: null, + }; + return createChatCompletionsProvider(transportConfig); +} diff --git a/src/main/openai-compatible-provider.ts b/src/main/openai-compatible-provider.ts index f02a98b..692dd7a 100644 --- a/src/main/openai-compatible-provider.ts +++ b/src/main/openai-compatible-provider.ts @@ -1,61 +1,28 @@ /** - * The real OpenAI-compatible chat provider adapter (Phase 2, Milestone 3). + * The `openai-compatible` provider adapter (Phase 2, Milestone 3; factored + * onto the shared transport in Milestone 4). * - * The first, and so far only, `ChatProvider` implementation in this codebase - * that makes a real network request. It lives in `src/main` deliberately — - * `src/shared`, where every other `ChatProvider` lives, structurally cannot - * perform network access (see `src/shared/chat/provider.ts`'s doc comment) — - * and is never imported by `src/renderer` or `src/shared`: the renderer - * reaches it only through the `chat:send` IPC channel (`main/ipc.ts`), which - * resolves it through `main/chat-provider-registry.ts`. + * The generic case: an endpoint the user supplies in full, authenticated + * with a bearer key from the encrypted secret store. Everything about the + * exchange itself — request building, bounded reading, streaming, response + * validation, error normalization, the guarantee that nothing here logs — + * lives in `./chat-completions-transport.ts`, which this module configures + * and nothing more. See that module's doc comment for the security + * properties that hold for every provider built on it. * - * Security properties, all structural, not conventions: - * - * - **The plaintext API key is a plain function parameter, never a module - * global.** It is read from the encrypted secret store immediately before - * this module is called (`main/chat-provider-registry.ts`) and is used - * only to build one `Authorization` header per request. It is never - * logged, never placed in a thrown error's `message`, and never appears - * in this module's return value. - * - **Nothing here logs a request or a response.** No `console.log` (or - * equivalent) exists anywhere in this file — not the outgoing headers, - * not the request body (which carries the user's conversation), not the - * raw response body, not a caught error's message. - * - **The upstream response is never trusted structurally.** Its body is - * read under a byte cap ({@link MAX_RESPONSE_BYTES}) before it is ever - * parsed as JSON, and the extracted `content` is validated by the same - * {@link chatProviderResultSchema} every other provider's result is - * validated by — bounded length, no unsafe control characters, no bidi - * overrides — before this function ever returns it. - * - **Every failure normalizes into the existing five-code vocabulary** - * (`src/shared/chat/provider.ts`) — never a raw `Error.message`, a URL, a - * header, or a status body reaches a caller of this module. + * Milestone 3's behaviour is preserved exactly, including for an endpoint + * that ignores `stream`: the transport handles a plain JSON completion and a + * server-sent-events stream alike. */ -import { z } from 'zod'; - import { - ChatProviderError, - type ChatProvider, - type ChatProviderRequest, - type ChatProviderRequestOptions, - type ChatProviderResult, -} from '../shared/chat/provider'; -import { chatProviderResultSchema } from '../shared/schemas/chat.schema'; + createChatCompletionsProvider, + MAX_RESPONSE_BYTES, + type ChatCompletionsConfig, +} from './chat-completions-transport'; +import type { ChatProvider } from '../shared/chat/provider'; -/** OpenAI-compatible chat completions endpoint, relative to the configured base URL. */ -const CHAT_COMPLETIONS_PATH = '/chat/completions'; - -/** - * Upper bound on the bytes read from a response body before this module - * gives up and reports a malformed response, rather than buffering an - * unbounded amount of memory for a hostile or misbehaving endpoint — the - * base URL is user-supplied (including self-hosted endpoints), so it is - * never assumed to be well-behaved. Generous for any real chat completion - * (a very long reply is still a few hundred KB of JSON at most) and tight - * enough to bound memory use for a pathological response. - */ -export const MAX_RESPONSE_BYTES = 1_000_000; +export { MAX_RESPONSE_BYTES }; export interface OpenAiCompatibleProviderConfig { /** Already validated: absolute http/https, no embedded credentials — see `settingsSchema`. */ @@ -65,185 +32,20 @@ export interface OpenAiCompatibleProviderConfig { readonly apiKey: string; } -/** Only the roles this milestone's chat can produce are forwarded to the wire format. */ -const FORWARDED_ROLES = new Set(['system', 'user', 'assistant']); - -const chatCompletionMessageSchema = z.object({ - content: z.string().nullable().optional(), -}); -const chatCompletionChoiceSchema = z.object({ - message: chatCompletionMessageSchema, -}); -/** - * The minimal shape this adapter reads from an OpenAI-compatible response. - * Deliberately not `strictObject`: this describes an upstream API this - * codebase does not control, which may carry vendor-specific fields this - * adapter has no use for and must not reject on that basis alone — only the - * one field this function actually reads is validated. - */ -const chatCompletionResponseSchema = z.object({ - choices: z.array(chatCompletionChoiceSchema).min(1).max(32), -}); - -function joinUrl(baseUrl: string, path: string): string { - const trimmedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; - const trimmedPath = path.startsWith('/') ? path : `/${path}`; - return `${trimmedBase}${trimmedPath}`; -} - -/** - * Reads `response`'s body as text, rejecting once more than `maxBytes` have - * been received rather than buffering an unbounded amount. Falls back to - * `response.text()` only when no streaming reader is available (a test - * double, say) — still applying the same cap to what comes back. - */ -async function readBoundedText(response: Response, maxBytes: number): Promise { - const reader = response.body?.getReader(); - if (!reader) { - const text = await response.text(); - if (Buffer.byteLength(text, 'utf8') > maxBytes) { - throw new Error('response body exceeds the configured byte cap'); - } - return text; - } - - const decoder = new TextDecoder(); - let received = 0; - let result = ''; - for (;;) { - // Sequential by nature: each chunk must be measured before the next is requested. - const { done, value } = await reader.read(); - if (done) break; - received += value.byteLength; - if (received > maxBytes) { - await reader.cancel().catch(() => undefined); - throw new Error('response body exceeds the configured byte cap'); - } - result += decoder.decode(value, { stream: true }); - } - result += decoder.decode(); - return result; -} - -function toWireMessages( - request: ChatProviderRequest, -): { readonly role: string; readonly content: string }[] { - return request.messages - .filter((message) => FORWARDED_ROLES.has(message.role)) - .map((message) => ({ role: message.role, content: message.content })); -} - /** - * Creates the real OpenAI-compatible adapter for one resolved configuration. + * Creates the `openai-compatible` adapter for one resolved configuration. * * Callers apply `withProviderTimeout` around the returned provider — this - * function starts no timer of its own, matching every other `ChatProvider` - * in this codebase (see `src/shared/chat/timeout.ts`). + * function starts no timer of its own. */ export function createOpenAiCompatibleProvider( config: OpenAiCompatibleProviderConfig, ): ChatProvider { - return { - id: 'openai-compatible', - async send( - request: ChatProviderRequest, - options?: ChatProviderRequestOptions, - ): Promise { - const signal = options?.signal; - if (signal?.aborted) { - throw new ChatProviderError('PROVIDER_ABORTED', 'The request was already aborted.'); - } - - const url = joinUrl(config.baseUrl, CHAT_COMPLETIONS_PATH); - const body = JSON.stringify({ - model: config.model, - messages: toWireMessages(request), - stream: false, - }); - - let response: Response; - try { - response = await fetch(url, { - method: 'POST', - headers: { - 'content-type': 'application/json', - authorization: `Bearer ${config.apiKey}`, - }, - body, - ...(signal ? { signal } : {}), - }); - } catch (error) { - if (signal?.aborted) { - throw new ChatProviderError('PROVIDER_ABORTED', 'The request was aborted.', { - cause: error, - }); - } - throw new ChatProviderError( - 'PROVIDER_REQUEST_FAILED', - 'The network request to the provider failed.', - { cause: error }, - ); - } - - if (!response.ok) { - if (response.status === 401 || response.status === 403) { - throw new ChatProviderError( - 'PROVIDER_INVALID_CONFIGURATION', - 'The provider rejected the configured credentials.', - ); - } - if (response.status === 429) { - throw new ChatProviderError( - 'PROVIDER_REQUEST_FAILED', - 'The provider is rate-limiting requests. Try again shortly.', - ); - } - throw new ChatProviderError( - 'PROVIDER_REQUEST_FAILED', - 'The provider returned an error response.', - ); - } - - let rawText: string; - try { - rawText = await readBoundedText(response, MAX_RESPONSE_BYTES); - } catch (error) { - throw new ChatProviderError( - 'PROVIDER_REQUEST_FAILED', - 'The provider response was too large or could not be read.', - { cause: error }, - ); - } - - let parsedJson: unknown; - try { - parsedJson = JSON.parse(rawText); - } catch (error) { - throw new ChatProviderError( - 'PROVIDER_REQUEST_FAILED', - 'The provider returned a malformed response.', - { cause: error }, - ); - } - - const wireResult = chatCompletionResponseSchema.safeParse(parsedJson); - const content = wireResult.success ? wireResult.data.choices[0]?.message.content : undefined; - if (typeof content !== 'string') { - throw new ChatProviderError( - 'PROVIDER_REQUEST_FAILED', - 'The provider returned a malformed response.', - ); - } - - const result = chatProviderResultSchema.safeParse({ content }); - if (!result.success) { - throw new ChatProviderError( - 'PROVIDER_REQUEST_FAILED', - 'The provider returned a malformed response.', - ); - } - - return result.data; - }, + const transportConfig: ChatCompletionsConfig = { + providerId: 'openai-compatible', + baseUrl: config.baseUrl, + model: config.model, + apiKey: config.apiKey, }; + return createChatCompletionsProvider(transportConfig); } diff --git a/src/preload/index.ts b/src/preload/index.ts index 7c1923e..27e31a8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,9 +2,11 @@ import { contextBridge, ipcRenderer } from 'electron'; import { chatCancelResponseSchema, + chatChunkEventSchema, chatSendResponseSchema, healthCheckResponseSchema, IPC_CHAT_CANCEL_CHANNEL, + IPC_CHAT_CHUNK_CHANNEL, IPC_CHAT_SEND_CHANNEL, IPC_HEALTH_CHANNEL, IPC_SECRETS_CLEAR_CHANNEL, @@ -17,6 +19,7 @@ import { secretsWriteResponseSchema, settingsGetResponseSchema, settingsUpdateResponseSchema, + type ChatChunkEvent, type ChatMessage, type ChatSendResponse, type HealthCheckResponse, @@ -43,6 +46,13 @@ import { * input is `main/ipc.ts`'s job, the actual trust boundary, not this bridge's * — this module's own responsibility is only the channel allowlist and the * response shape. + * + * `chat.onChunk` (Phase 2, Milestone 4) is the single exception to + * "everything here is a request": it subscribes to one fixed, one-way event + * channel. It is held to the same rules — a fixed channel name, no + * `ipcRenderer`, no raw Electron event, a validated payload — and returns an + * unsubscribe function so a listener's lifetime is the caller's to end. See + * its own doc comment below. */ const bridge = { health: async (): Promise => { @@ -97,6 +107,41 @@ const bridge = { const result: unknown = await ipcRenderer.invoke(IPC_CHAT_CANCEL_CHANNEL, { requestId }); chatCancelResponseSchema.parse(result); }, + /** + * Subscribes to streaming previews for in-flight `chat.send` calls + * (Phase 2, Milestone 4), returning an unsubscribe function. + * + * The one place this bridge exposes a main → renderer *event* rather + * than a request, and deliberately the narrowest possible form of one: + * + * - the channel is fixed here, never a caller-supplied name, so this + * cannot become a generic "listen to any channel" surface any more + * than `send` could become a generic invoke; + * - `ipcRenderer` itself is never handed out, and neither is the raw + * Electron event — the listener receives only a validated + * {@link ChatChunkEvent}, never the `IpcRendererEvent` (which carries + * `sender` and reply handles a renderer has no business holding); + * - a payload that fails {@link chatChunkEventSchema} is dropped + * silently rather than forwarded or thrown, so a malformed or + * hostile event cannot reach renderer code or break its event loop; + * - a listener that throws is contained here, for the same reason. + */ + onChunk: (listener: (event: ChatChunkEvent) => void): (() => void) => { + const handler = (_event: unknown, payload: unknown): void => { + const parsed = chatChunkEventSchema.safeParse(payload); + if (!parsed.success) return; + try { + listener(parsed.data); + } catch { + // A failing preview listener must not break the IPC event loop. + } + }; + + ipcRenderer.on(IPC_CHAT_CHUNK_CHANNEL, handler); + return () => { + ipcRenderer.off(IPC_CHAT_CHUNK_CHANNEL, handler); + }; + }, }, }; diff --git a/src/renderer/chat/Chat.tsx b/src/renderer/chat/Chat.tsx index df9fa49..b34faf1 100644 --- a/src/renderer/chat/Chat.tsx +++ b/src/renderer/chat/Chat.tsx @@ -91,7 +91,21 @@ export function Chat({ assistantName, modelProvider }: ChatProps) { ) : ( state.messages.map((message) => ) )} - {state.status === 'awaiting-response' && ( + {/* + The streaming preview (Phase 2, Milestone 4). Rendered exactly like + a message bubble and with exactly the same safety: `streamingContent` + is a plain JSX text child, which React escapes, so a streamed + fragment can no more inject markup than a completed reply can. + It is not a member of `state.messages` and never becomes one — the + validated reply replaces it wholesale when the request completes. + */} + {state.streamingContent !== null && ( +
+ {ROLE_LABELS.assistant} +

{state.streamingContent}

+
+ )} + {state.status === 'awaiting-response' && state.streamingContent === null && (

{assistantName} is thinking…

diff --git a/src/renderer/chat/conversation-controller.ts b/src/renderer/chat/conversation-controller.ts index 87f1b04..848055e 100644 --- a/src/renderer/chat/conversation-controller.ts +++ b/src/renderer/chat/conversation-controller.ts @@ -29,7 +29,10 @@ import { createChatMessage, type ChatMessage, } from '../../shared/schemas'; -import { CHAT_CONVERSATION_MAX_MESSAGES } from '../../shared/constants'; +import { + CHAT_CONVERSATION_MAX_MESSAGES, + CHAT_MESSAGE_CONTENT_MAX_LENGTH, +} from '../../shared/constants'; import type { ChatProvider } from '../../shared/chat'; export type ConversationStatus = 'idle' | 'awaiting-response'; @@ -42,6 +45,24 @@ export interface ConversationState { readonly messages: readonly ChatMessage[]; readonly status: ConversationStatus; readonly error: ConversationError | null; + /** + * The assistant reply as it streams in, or `null` when nothing is + * streaming (Phase 2, Milestone 4). + * + * A **preview**, deliberately kept outside `messages` — exactly as + * `status` and `error` are, and for the same reason: the message list + * stays append-only and only ever holds fully validated messages. This + * value is replaced wholesale by the real assistant message on success and + * discarded on failure, cancellation or a provider switch; it is never + * itself committed to the conversation, never persisted, and never treated + * as authorization for anything. + * + * Bounded to {@link CHAT_MESSAGE_CONTENT_MAX_LENGTH}: a provider streaming + * more than a message may contain cannot grow renderer memory without + * limit here, independently of the identical cap the main process already + * applies while reading the stream. + */ + readonly streamingContent: string | null; } export type ConversationListener = (state: ConversationState) => void; @@ -55,7 +76,7 @@ export interface ConversationControllerDeps { } function initialState(): ConversationState { - return { messages: [], status: 'idle', error: null }; + return { messages: [], status: 'idle', error: null, streamingContent: null }; } const GENERIC_SEND_ERROR = @@ -133,8 +154,11 @@ export class ConversationController { if (provider === this.provider) return; this.provider = provider; this.activeAbortController?.abort(); - if (this.state.status === 'awaiting-response') { - this.setState({ ...this.state, status: 'idle' }); + if (this.state.status === 'awaiting-response' || this.state.streamingContent !== null) { + // Any streaming preview belonged to the superseded request; it is + // discarded with it rather than left on screen under a provider that + // never produced it. + this.setState({ ...this.state, status: 'idle', streamingContent: null }); } } @@ -186,7 +210,12 @@ export class ConversationController { } const nextMessages = [...this.state.messages, userMessage]; - this.setState({ messages: nextMessages, status: 'awaiting-response', error: null }); + this.setState({ + messages: nextMessages, + status: 'awaiting-response', + error: null, + streamingContent: null, + }); await this.requestAssistantReply(nextMessages); } @@ -203,7 +232,7 @@ export class ConversationController { if (this.state.messages.length === 0) return; const messages = this.state.messages; - this.setState({ messages, status: 'awaiting-response', error: null }); + this.setState({ messages, status: 'awaiting-response', error: null, streamingContent: null }); await this.requestAssistantReply(messages); } @@ -212,6 +241,11 @@ export class ConversationController { const controller = new AbortController(); this.activeAbortController = controller; + // Accumulated locally rather than read back out of `state`, so a + // superseded request that is still emitting deltas can never append to + // the preview of the request that replaced it. + let streamed = ''; + try { // Defence in depth: `messages` is already every element individually // validated by `createChatMessage`, and `submit()`'s own runtime check @@ -221,13 +255,27 @@ export class ConversationController { // provider, so a future bug in either caller does not silently send an // unbounded request. Never expected to reject in normal operation. const request = chatProviderRequestSchema.parse({ messages }); - const rawResult = await this.provider.send(request, { signal: controller.signal }); + const rawResult = await this.provider.send(request, { + signal: controller.signal, + onChunk: (delta) => { + // A delta from an aborted or superseded request is dropped, and + // the preview never grows past what a message may hold. + if (controller.signal.aborted) return; + if (this.activeAbortController !== controller) return; + if (streamed.length >= CHAT_MESSAGE_CONTENT_MAX_LENGTH) return; + + streamed = (streamed + delta).slice(0, CHAT_MESSAGE_CONTENT_MAX_LENGTH); + this.setState({ ...this.state, streamingContent: streamed }); + }, + }); if (controller.signal.aborted) return; // The provider's raw result is untrusted the moment it returns — - // whether that is the deterministic mock today or a real adapter in a - // later phase — so it is validated here, before any of it is trusted - // enough to become part of the conversation. + // whether that is the deterministic mock, a real adapter, or the + // accumulation of a stream — so it is validated here, before any of it + // is trusted enough to become part of the conversation. The streamed + // preview is discarded at this point in every case: what is committed + // is this validated value, never the sum of the deltas. const result = chatProviderResultSchema.parse(rawResult); const assistantMessage = createChatMessage({ @@ -236,13 +284,19 @@ export class ConversationController { content: result.content, createdAt: this.now(), }); - this.setState({ messages: [...messages, assistantMessage], status: 'idle', error: null }); + this.setState({ + messages: [...messages, assistantMessage], + status: 'idle', + error: null, + streamingContent: null, + }); } catch (error) { if (controller.signal.aborted) return; this.setState({ messages, status: 'idle', error: { message: describeProviderFailure(error) }, + streamingContent: null, }); } } diff --git a/src/renderer/chat/ipc-chat-provider.ts b/src/renderer/chat/ipc-chat-provider.ts index 38c44ba..309f4df 100644 --- a/src/renderer/chat/ipc-chat-provider.ts +++ b/src/renderer/chat/ipc-chat-provider.ts @@ -1,6 +1,6 @@ /** - * Renderer-side `ChatProvider` backed by the real, network-capable adapter - * that lives in the main process (Phase 2, Milestone 3). + * Renderer-side `ChatProvider` backed by whichever real, network-capable + * adapter the main process resolves (Phase 2, Milestones 3-4). * * This is the one file under `src/renderer/chat` permitted to reference * `window.localAgent` — `tests/unit/shared/chat-boundary-scan.test.ts` @@ -9,12 +9,16 @@ * network, no secret. * * No network call, no secret, and no privileged API is reachable from this - * file directly — it only ever calls the two narrow, typed, + * file directly — it only ever calls the three narrow, typed, * schema-validated functions the preload bridge exposes (`chat.send`, - * `chat.cancel`). The actual HTTP request, the API key, and the provider - * selection all live in the main process; see - * `src/main/chat-provider-registry.ts` and - * `src/main/openai-compatible-provider.ts`. + * `chat.cancel`, `chat.onChunk`). The actual HTTP request, the API key, and + * the provider selection all live in the main process; see + * `src/main/chat-provider-registry.ts` and the adapters it resolves. + * + * Streamed deltas are **previews, never results**: they are forwarded to + * `options.onChunk` for the caller to display, while the value this function + * resolves with — the only thing that becomes a conversation message — is + * still the whole, separately validated reply from `chat.send`. * * Never trusts text that crossed the IPC boundary: on failure, only the * bounded `errorCode` enum from `chatSendResponseSchema` is read. The @@ -33,8 +37,16 @@ import { type ChatProviderResult, } from '../../shared/chat'; -/** The identifier for this adapter, distinct from `MOCK_CHAT_PROVIDER_ID`. */ -export const IPC_CHAT_PROVIDER_ID = 'openai-compatible'; +/** + * The identifier for this adapter, distinct from `MOCK_CHAT_PROVIDER_ID`. + * + * Deliberately not the name of any one provider: since Milestone 4 this + * single adapter fronts whichever real provider the main process resolves + * (`openai-compatible`, `glm` or `ollama`), and the renderer is never told + * which — it does not need to know, and not knowing is what keeps provider + * selection a main-process decision. + */ +export const IPC_CHAT_PROVIDER_ID = 'ipc'; const GENERIC_IPC_FAILURE_MESSAGE = 'The provider request did not succeed.'; @@ -69,6 +81,21 @@ export function createIpcChatProvider(): ChatProvider { }; signal?.addEventListener('abort', onAbort, { once: true }); + // Streaming previews (Phase 2, Milestone 4). Subscribed *before* the + // request is sent, so no early delta can arrive unobserved, and + // unsubscribed in the `finally` below, so one subscription never + // outlives the one call it belongs to. Deltas for another request, or + // any delta after this call was cancelled, are discarded here rather + // than trusted — the correlating `requestId` is checked on arrival. + const unsubscribe = + options?.onChunk === undefined + ? null + : window.localAgent.chat.onChunk((chunkEvent) => { + if (chunkEvent.requestId !== requestId) return; + if (signal?.aborted) return; + options.onChunk?.(chunkEvent.delta); + }); + try { const response = await window.localAgent.chat.send(requestId, [...request.messages]); @@ -85,6 +112,7 @@ export function createIpcChatProvider(): ChatProvider { : 'PROVIDER_REQUEST_FAILED'; throw new ChatProviderError(code, GENERIC_IPC_FAILURE_MESSAGE); } finally { + unsubscribe?.(); signal?.removeEventListener('abort', onAbort); } }, diff --git a/src/renderer/local-agent-bridge.d.ts b/src/renderer/local-agent-bridge.d.ts index 07f31d5..3bccade 100644 --- a/src/renderer/local-agent-bridge.d.ts +++ b/src/renderer/local-agent-bridge.d.ts @@ -9,6 +9,7 @@ */ import type { + ChatChunkEvent, ChatMessage, ChatSendResponse, HealthCheckResponse, @@ -38,6 +39,8 @@ declare global { messages: readonly ChatMessage[], ) => Promise; readonly cancel: (requestId: string) => Promise; + /** Subscribe to streaming previews; returns an unsubscribe function. */ + readonly onChunk: (listener: (event: ChatChunkEvent) => void) => () => void; }; }; } diff --git a/src/renderer/styles.css b/src/renderer/styles.css index d0650a3..5555674 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -171,6 +171,10 @@ main.app-ready { background: transparent; border: 1px dashed #2a3750; } +/* A reply still streaming in: same bubble, marked as not yet final. */ +.chat-message--streaming { + border: 1px dashed #2a3750; +} .chat-message__role { font-size: 0.7rem; letter-spacing: 0.08em; diff --git a/src/shared/chat/index.ts b/src/shared/chat/index.ts index d024e8d..04ebe48 100644 --- a/src/shared/chat/index.ts +++ b/src/shared/chat/index.ts @@ -32,3 +32,5 @@ export type { } from './registry'; export { withProviderTimeout } from './timeout'; + +export { isLocalHttpEndpoint } from './local-endpoint'; diff --git a/src/shared/chat/local-endpoint.ts b/src/shared/chat/local-endpoint.ts new file mode 100644 index 0000000..4dd8a02 --- /dev/null +++ b/src/shared/chat/local-endpoint.ts @@ -0,0 +1,126 @@ +/** + * Local-endpoint classification for the Ollama adapter (Phase 2, Milestone 4). + * + * Ollama is a **local** model runtime. Its endpoint is configurable — a + * different port, a different loopback form, a second machine on the same + * private network — but it must never resolve to a public host: a mistyped + * or hostile `baseUrl` would otherwise ship the user's whole conversation to + * a cloud service while the interface still labels the provider "local". + * `main/ollama-provider.ts` refuses to send anywhere this function rejects. + * + * What counts as local here is deliberately decidable **statically, without + * DNS**, because a resolver answer is exactly the thing an attacker or a + * typo controls: + * + * - `localhost` and any `*.localhost` name (reserved for loopback by + * RFC 6761), matched case-insensitively; + * - a literal IPv4 loopback address (`127.0.0.0/8`); + * - a literal IPv4 private address (`10/8`, `172.16/12`, `192.168/16`) or + * link-local address (`169.254/16`), so an Ollama host on the user's own + * LAN stays usable; + * - the literal IPv6 loopback (`[::1]`), unique-local (`fc00::/7`) and + * link-local (`fe80::/10`) forms. + * + * Every other hostname is rejected, **including a hostname that would + * resolve to a private address**. That is the deliberate trade: a name + * cannot be checked without asking a resolver, so names other than + * `localhost` are simply not accepted, and the user configures an address + * instead. Rejecting something legitimate is recoverable; silently + * uploading a conversation is not. + * + * Pure: no I/O, no DNS, no network. Safe to import from any process — the + * WHATWG `URL` parser is the only thing it uses, exactly as + * `settings.schema.ts` already does. + */ + +/** Matches a dotted-quad IPv4 literal and captures its four octets. */ +const IPV4_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + +function parseIpv4Octets(hostname: string): number[] | null { + const match = IPV4_PATTERN.exec(hostname); + if (match === null) return null; + + const octets = match.slice(1).map((part) => Number.parseInt(part, 10)); + if (octets.some((octet) => Number.isNaN(octet) || octet < 0 || octet > 255)) { + return null; + } + return octets; +} + +function isLocalIpv4(hostname: string): boolean { + const octets = parseIpv4Octets(hostname); + if (octets === null) return false; + + const [first = -1, second = -1] = octets; + + // 127.0.0.0/8 — loopback. + if (first === 127) return true; + // 10.0.0.0/8 — private. + if (first === 10) return true; + // 172.16.0.0/12 — private. + if (first === 172 && second >= 16 && second <= 31) return true; + // 192.168.0.0/16 — private. + if (first === 192 && second === 168) return true; + // 169.254.0.0/16 — link-local. + if (first === 169 && second === 254) return true; + + return false; +} + +/** + * `URL.hostname` returns an IPv6 literal wrapped in brackets and already + * lowercased and canonicalised, so this only has to classify the prefix. + */ +function isLocalIpv6(hostname: string): boolean { + if (!hostname.startsWith('[') || !hostname.endsWith(']')) return false; + const address = hostname.slice(1, -1); + + // ::1 — loopback. + if (address === '::1') return true; + + // fc00::/7 — unique local. fe80::/10 — link local. + const firstGroup = address.split(':')[0] ?? ''; + if (firstGroup.length === 0) return false; + const leading = Number.parseInt(firstGroup, 16); + if (Number.isNaN(leading)) return false; + + if (firstGroup.length === 4) { + // fc00::/7 covers fc00–fdff; fe80::/10 covers fe80–febf. + if (leading >= 0xfc00 && leading <= 0xfdff) return true; + if (leading >= 0xfe80 && leading <= 0xfebf) return true; + } + + return false; +} + +function isLocalHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + if (normalized === 'localhost' || normalized.endsWith('.localhost')) return true; + if (isLocalIpv4(normalized)) return true; + if (isLocalIpv6(normalized)) return true; + return false; +} + +/** + * Whether `value` is an absolute `http`/`https` URL whose host is local by + * the rules in this module's doc comment. + * + * Returns `false` — never throws — for an unparsable value, a non-http + * scheme, an empty string, or a URL carrying embedded credentials (which + * `settingsSchema` already rejects at the boundary; re-checked here so this + * function is safe to call on any string, not only one that survived that + * schema). + */ +export function isLocalHttpEndpoint(value: string): boolean { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return false; + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + if (parsed.username !== '' || parsed.password !== '') return false; + + return isLocalHostname(parsed.hostname); +} diff --git a/src/shared/chat/mock-provider.ts b/src/shared/chat/mock-provider.ts index 8d1fe4f..5bd29fa 100644 --- a/src/shared/chat/mock-provider.ts +++ b/src/shared/chat/mock-provider.ts @@ -109,11 +109,30 @@ async function delay(delayMs: number, signal: AbortSignal | undefined): Promise< }); } +/** + * Longest fragment the mock emits through `onChunk`. Small enough that a + * normal reply arrives as several deltas, so the streaming path is genuinely + * exercised rather than delivered in one piece. + */ +const MOCK_STREAM_CHUNK_LENGTH = 24; + +function splitIntoChunks(content: string, size: number): string[] { + const chunks: string[] = []; + for (let index = 0; index < content.length; index += size) { + chunks.push(content.slice(index, index + size)); + } + return chunks; +} + /** * Creates the deterministic mock provider. * - * `id` is {@link MOCK_CHAT_PROVIDER_ID} — the only provider identifier in - * use anywhere in this codebase today. + * `id` is {@link MOCK_CHAT_PROVIDER_ID}. Streaming (Phase 2, Milestone 4) is + * offered only when the caller passes `onChunk`: the reply is then delivered + * as several fragments spread across the *same* simulated delay — not an + * additional one — so total timing, and the final content, are byte-for-byte + * what they were without streaming. A caller that passes no `onChunk` + * observes exactly the Milestone 1 behaviour. */ export function createMockChatProvider(options: CreateMockChatProviderOptions = {}): ChatProvider { const delayMs = options.delayMs ?? MOCK_CHAT_PROVIDER_DEFAULT_DELAY_MS; @@ -124,17 +143,37 @@ export function createMockChatProvider(options: CreateMockChatProviderOptions = request: ChatProviderRequest, requestOptions?: ChatProviderRequestOptions, ): Promise { - await delay(delayMs, requestOptions?.signal); - + const signal = requestOptions?.signal; + const onChunk = requestOptions?.onChunk; const latest = lastUserContent(request); - if (latest !== null && latest.trim() === MOCK_CHAT_PROVIDER_FAILURE_TRIGGER) { - throw new ChatProviderError( - 'PROVIDER_REQUEST_FAILED', - 'The mock provider was asked to simulate a failure.', - ); + const shouldFail = latest !== null && latest.trim() === MOCK_CHAT_PROVIDER_FAILURE_TRIGGER; + + if (onChunk === undefined || shouldFail) { + // The failure trigger deliberately streams nothing: a request that + // fails must not leave a partial preview behind, exactly as a real + // adapter's failure does not. + await delay(delayMs, signal); + if (shouldFail) { + throw new ChatProviderError( + 'PROVIDER_REQUEST_FAILED', + 'The mock provider was asked to simulate a failure.', + ); + } + return { content: buildReplyContent(request) }; + } + + const content = buildReplyContent(request); + const chunks = splitIntoChunks(content, MOCK_STREAM_CHUNK_LENGTH); + const perChunkDelayMs = chunks.length === 0 ? delayMs : delayMs / chunks.length; + + for (const chunk of chunks) { + // Sequential by nature: a stream is ordered, and each fragment waits + // its share of the same total delay. + await delay(perChunkDelayMs, signal); + onChunk(chunk); } - return { content: buildReplyContent(request) }; + return { content }; }, }; } diff --git a/src/shared/chat/provider.ts b/src/shared/chat/provider.ts index 03c669a..ce2a049 100644 --- a/src/shared/chat/provider.ts +++ b/src/shared/chat/provider.ts @@ -1,15 +1,19 @@ /** - * Chat provider abstraction (Phase 2, Milestones 1–2). + * Chat provider abstraction (Phase 2, Milestones 1–4). * * A `ChatProvider` turns a validated conversation into assistant reply text. - * This module defines the shape only. The one working implementation in this - * codebase is still the deterministic mock (`./mock-provider.ts`) — Milestone - * 2 adds the registry (`./registry.ts`), request/response schemas - * (`../schemas/chat.schema.ts`) and a timeout decorator (`./timeout.ts`) - * around this same interface, but implements no real provider. A future - * phase adding one (GLM, an OpenAI-compatible endpoint, Ollama) implements - * this same interface; nothing that consumes a `ChatProvider` needs to - * change when that happens. + * This module defines the shape only, and the shape has not changed since + * Milestone 1 apart from one optional, advisory streaming callback added in + * Milestone 4 ({@link ChatProviderRequestOptions.onChunk}). + * + * The only implementation **inside `src/shared`** is still the deterministic + * mock (`./mock-provider.ts`); the registry here (`./registry.ts`) fails + * closed for every identifier, by construction. The real, network-capable + * adapters — `openai-compatible` (Milestone 3), `glm` and `ollama` + * (Milestone 4) — all implement this same interface from `src/main`, where + * network access and secrets are permitted, and the renderer reaches them + * only through the `chat:send` IPC channel. Nothing that consumes a + * `ChatProvider` had to change as those arrived. * * Deliberately provider-independent and inert: * @@ -106,6 +110,23 @@ export class ChatProviderError extends Error { export interface ChatProviderRequestOptions { /** Abort an in-flight request. See the module doc comment. */ readonly signal?: AbortSignal; + /** + * Optional incremental delivery of assistant text as it arrives (Phase 2, + * Milestone 4). + * + * **Advisory only, and never authoritative.** A provider that streams may + * call this any number of times with successive fragments; a provider that + * does not stream simply never calls it, and every caller must work + * identically in both cases. What a caller may finally *trust* is only the + * resolved {@link ChatProviderResult}, which is validated as a whole — + * a delta passed here has been bounded but is still untrusted text, so a + * caller may render it as a live preview and must not commit it to + * conversation state as a message. + * + * A caller's implementation must not throw: a provider is not required to + * defend its own protocol loop against a hostile callback. + */ + readonly onChunk?: (delta: string) => void; } /** diff --git a/src/shared/chat/registry.ts b/src/shared/chat/registry.ts index 74147a8..6f5d26e 100644 --- a/src/shared/chat/registry.ts +++ b/src/shared/chat/registry.ts @@ -1,34 +1,36 @@ /** - * Approved-provider registry (Phase 2, Milestones 2-3). + * Approved-provider registry (Phase 2, Milestones 2-4). * * Maps the four settings-level provider identifiers (`none`, `glm`, * `openai-compatible`, `ollama` — {@link MODEL_PROVIDERS}) to safe capability * metadata and to a `ChatProvider` adapter. * * **{@link createChatProviderForSelection} in this file always fails - * closed, for all four identifiers, including `openai-compatible` — this - * has not changed since Milestone 2 and never will while this function - * lives under `src/shared`.** `src/shared` cannot perform network access — + * closed, for all four identifiers, real adapters or not — this has not + * changed since Milestone 2 and never will while this function lives under + * `src/shared`.** `src/shared` cannot perform network access — * `eslint.config.js`'s purity boundary blocks `fetch` and every other * network-capable global here, structurally, not by convention — so a * "registry" living in this directory can never itself place a real call. - * Milestone 3's real, network-capable `openai-compatible` adapter lives in - * `src/main/openai-compatible-provider.ts` and is resolved by + * The real, network-capable adapters all live in `src/main` + * (`openai-compatible-provider.ts`, `glm-provider.ts`, `ollama-provider.ts`, + * over the shared `chat-completions-transport.ts`) and are resolved by * `src/main/chat-provider-registry.ts`'s `resolveMainChatProvider`, reached * from the renderer only through the `chat:send` IPC channel * (`main/ipc.ts`) — never through this function. This module's * {@link ChatProviderCapabilities.implemented} flag reflects that a real - * adapter exists *somewhere* in the codebase for `openai-compatible` now; + * adapter exists *somewhere* in the codebase for an identifier; * `createChatProviderForSelection` reflects what *this* file can do, which - * is, and stays, nothing. See `docs/phase-2-provider-architecture.md` and - * `docs/phase-2-real-provider-architecture.md`. + * is, and stays, nothing. See `docs/phase-2-provider-architecture.md`, + * `docs/phase-2-real-provider-architecture.md` and + * `docs/phase-2-provider-completion.md`. * * The deterministic mock (`./mock-provider.ts`) is a separate, always- * available adapter — for tests, and as what answers a chat message for - * every identifier `implemented` is still `false` for. - * `src/renderer/chat/useActiveChatProvider.ts` is the single, explicit place - * that decides between the mock and the real IPC-backed adapter, based on - * {@link ChatProviderCapabilities.implemented}. + * every identifier `implemented` is still `false` for (only `none`, as of + * Milestone 4). `src/renderer/chat/useActiveChatProvider.ts` is the single, + * explicit place that decides between the mock and the real IPC-backed + * adapter, based on {@link ChatProviderCapabilities.implemented}. * * No I/O, no Electron, no network — the same `src/shared` purity boundary as * every file in this directory. @@ -62,12 +64,19 @@ const LABEL_BY_PROVIDER: Readonly> = { /** * Identifiers with a real, network-capable adapter somewhere in the - * codebase (always `src/main`, never here). The single place this milestone + * codebase (always `src/main`, never here). The single place a milestone * flips an identifier from mock-only to real — see * `src/renderer/chat/useActiveChatProvider.ts`, the one thing that reads * this through {@link getChatProviderCapabilities}. + * + * `openai-compatible` since Milestone 3; `glm` and `ollama` since Milestone + * 4. `none` is not an adapter and never becomes one. */ -const IMPLEMENTED_PROVIDERS: ReadonlySet = new Set(['openai-compatible']); +const IMPLEMENTED_PROVIDERS: ReadonlySet = new Set([ + 'openai-compatible', + 'glm', + 'ollama', +]); /** * Safe capability metadata for an approved provider identifier. @@ -168,12 +177,14 @@ export function describeChatProviderStatus(input: ChatProviderStatusInput): Chat }; } +const REACHED_THROUGH_IPC_MESSAGE = + 'This function never reaches the network; use chat:send for the real adapter.'; + const UNAVAILABLE_MESSAGE_BY_PROVIDER: Readonly> = { none: 'No provider is configured. Choose one in provider settings.', - glm: 'GLM is configured but not yet connected in this version.', - 'openai-compatible': - 'This function never reaches the network; use chat:send for the real OpenAI-compatible adapter.', - ollama: 'Ollama is configured but not yet connected in this version.', + glm: REACHED_THROUGH_IPC_MESSAGE, + 'openai-compatible': REACHED_THROUGH_IPC_MESSAGE, + ollama: REACHED_THROUGH_IPC_MESSAGE, }; /** diff --git a/src/shared/chat/timeout.ts b/src/shared/chat/timeout.ts index 33c9d00..4c88611 100644 --- a/src/shared/chat/timeout.ts +++ b/src/shared/chat/timeout.ts @@ -82,7 +82,14 @@ export function withProviderTimeout(provider: ChatProvider, timeoutMs: number): }, timeoutMs); try { - return await provider.send(request, { signal: combined.signal }); + // Every option except `signal` is forwarded untouched: this + // decorator owns cancellation and nothing else, so an option it does + // not understand — `onChunk` since Milestone 4, whatever a later + // milestone adds — must reach the wrapped provider unchanged. + // Constructing a fresh options object with only `signal` silently + // disabled streaming when `onChunk` arrived, which is exactly the + // failure this spread prevents from recurring. + return await provider.send(request, { ...options, signal: combined.signal }); } catch (error) { if (combined.signal.reason === TIMEOUT_REASON) { throw new ChatProviderError( diff --git a/src/shared/constants.ts b/src/shared/constants.ts index b595609..5777980 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -84,6 +84,29 @@ export const PROVIDERS_REQUIRING_API_KEY: readonly ModelProvider[] = [ 'openai-compatible', ] as const; +/** + * Endpoint used for `glm` when the user has not configured one (Phase 2, + * Milestone 4). GLM's public API speaks the OpenAI chat-completions wire + * format, so `/chat/completions` is appended to this base exactly as it is + * for `openai-compatible`. A user-configured `baseUrl` always wins — this is + * only the fallback, so a mirror or a corporate proxy stays configurable. + */ +export const GLM_DEFAULT_BASE_URL = 'https://open.bigmodel.cn/api/paas/v4'; + +/** + * Endpoint used for `ollama` when the user has not configured one (Phase 2, + * Milestone 4). Ollama serves an OpenAI-compatible API under `/v1`, which is + * what this codebase talks to, so one wire protocol and one parser serve + * every real provider. + * + * Loopback by default, and — unlike every other provider — a configured + * `baseUrl` for `ollama` must *also* resolve to a local address, enforced by + * `src/shared/chat/local-endpoint.ts`. Ollama is a local runtime; a + * mistyped or hostile endpoint must never silently ship a conversation to a + * cloud service under the label "local". + */ +export const OLLAMA_DEFAULT_BASE_URL = 'http://127.0.0.1:11434/v1'; + // --------------------------------------------------------------------------- // Field limits // --------------------------------------------------------------------------- @@ -435,7 +458,53 @@ export const CHAT_CONVERSATION_MAX_MESSAGES = 200; // eslint-disable-next-line no-control-regex export const CHAT_CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/; -/** The only provider implemented in this milestone. */ +// --------------------------------------------------------------------------- +// Streaming bounds (Phase 2, Milestone 4) +// +// A streamed response arrives as many small pieces from an endpoint this +// codebase does not control, so every dimension of it is bounded: the bytes +// read from the socket, the length of a single unterminated protocol line, +// the size of one delta forwarded to the renderer, and — through +// CHAT_MESSAGE_CONTENT_MAX_LENGTH, reused rather than duplicated — the total +// accumulated assistant output. Nothing accumulates without a cap. +// --------------------------------------------------------------------------- + +/** + * Maximum bytes read from a streamed response body. + * + * Deliberately larger than {@link CHAT_MESSAGE_CONTENT_MAX_LENGTH} allows as + * *content*: server-sent-events framing wraps every delta in its own JSON + * envelope, so a provider streaming one character at a time spends far more + * bytes on the wire than the reply itself contains. The real bound on output + * is the accumulated-content cap, which trips first for any well-behaved + * stream; this is the backstop that keeps a pathological one from being read + * indefinitely. + */ +export const CHAT_STREAM_MAX_RESPONSE_BYTES = 4_000_000; + +/** + * Maximum length of a single unterminated line while parsing a streamed + * response. A stream that never emits a newline would otherwise grow the + * line buffer without limit, which is exactly the unbounded buffering the + * byte cap above exists to prevent — enforced separately because the two + * fail for different reasons and a reviewer should be able to tell them + * apart. + */ +export const CHAT_STREAM_MAX_LINE_LENGTH = 64_000; + +/** + * Maximum length of one delta forwarded to the renderer as a streaming + * preview. A larger delta is split across several events rather than + * dropped or truncated, so this bounds the size of a single IPC message + * without ever losing text. + */ +export const CHAT_STREAM_MAX_DELTA_LENGTH = 4_000; + +/** + * The deterministic mock provider's identifier. Still the only `ChatProvider` + * implemented inside `src/shared`; the real adapters live in `src/main` and + * are reached only through IPC. + */ export const MOCK_CHAT_PROVIDER_ID = 'mock'; /** diff --git a/src/shared/schemas/chat.schema.ts b/src/shared/schemas/chat.schema.ts index aad1fd1..b0e247e 100644 --- a/src/shared/schemas/chat.schema.ts +++ b/src/shared/schemas/chat.schema.ts @@ -23,6 +23,7 @@ import { CHAT_MESSAGE_CONTENT_MIN_LENGTH, CHAT_MESSAGE_ROLES, CHAT_MESSAGE_STATUSES, + CHAT_STREAM_MAX_DELTA_LENGTH, } from '../constants'; import { auditParametersSchema } from './audit-parameters.schema'; import type { AuditParameters } from './audit-parameters.schema'; @@ -126,3 +127,27 @@ export const chatProviderResultSchema = z.strictObject({ content: chatContentSchema, }); export type ChatProviderResultPayload = z.infer; + +/** + * One fragment of a streamed assistant reply (Phase 2, Milestone 4). + * + * Held to the same content-safety rules as a whole message — no unsafe + * control characters, no bidirectional overrides — and separately bounded by + * {@link CHAT_STREAM_MAX_DELTA_LENGTH}, which is far smaller than a full + * message: a delta is one piece of an answer, not an answer. + * + * A delta is a **preview**, never a message. Failing this schema drops that + * one fragment from the preview and nothing more; whether the reply as a + * whole is acceptable is decided separately, and authoritatively, by + * {@link chatProviderResultSchema} once the stream completes. + */ +export const chatStreamDeltaSchema = z + .string() + .min(1) + .max(CHAT_STREAM_MAX_DELTA_LENGTH) + .refine((value) => !CHAT_CONTROL_CHARACTER_PATTERN.test(value), { + message: 'must not contain unsafe control characters', + }) + .refine((value) => !BIDI_CONTROL_PATTERN.test(value), { + message: 'must not contain bidirectional control characters', + }); diff --git a/src/shared/schemas/index.ts b/src/shared/schemas/index.ts index 29777a7..feeebeb 100644 --- a/src/shared/schemas/index.ts +++ b/src/shared/schemas/index.ts @@ -59,11 +59,13 @@ export type { export { chatCancelRequestSchema, chatCancelResponseSchema, + chatChunkEventSchema, chatSendRequestSchema, chatSendResponseSchema, healthCheckRequestSchema, healthCheckResponseSchema, IPC_CHAT_CANCEL_CHANNEL, + IPC_CHAT_CHUNK_CHANNEL, IPC_CHAT_SEND_CHANNEL, IPC_HEALTH_CHANNEL, IPC_SECRETS_CLEAR_CHANNEL, @@ -85,6 +87,7 @@ export { } from './ipc.schema'; export type { ChatCancelResponse, + ChatChunkEvent, ChatSendRequestInput, ChatSendResponse, HealthCheckResponse, @@ -103,6 +106,7 @@ export { chatMessageSchema, chatProviderRequestSchema, chatProviderResultSchema, + chatStreamDeltaSchema, createChatMessage, } from './chat.schema'; export type { diff --git a/src/shared/schemas/ipc.schema.ts b/src/shared/schemas/ipc.schema.ts index 5f1f27c..cf28096 100644 --- a/src/shared/schemas/ipc.schema.ts +++ b/src/shared/schemas/ipc.schema.ts @@ -44,7 +44,7 @@ import { CHAT_CONVERSATION_MAX_MESSAGES, CONTROL_CHARACTER_PATTERN, } from '../constants'; -import { chatContentSchema, chatMessageSchema } from './chat.schema'; +import { chatContentSchema, chatMessageSchema, chatStreamDeltaSchema } from './chat.schema'; import { assistantSettingsSchema, languageSettingsSchema, @@ -231,3 +231,34 @@ export const chatCancelResponseSchema = z.strictObject({ }); export type ChatCancelResponse = z.infer; + +/** + * The one main → renderer push channel in this codebase (Phase 2, Milestone + * 4), carrying streaming previews for an in-flight `chat:send`. + * + * Deliberately narrow, in every dimension: + * + * - **One direction, one purpose.** The renderer can only listen; there is + * no request it can make on this channel and no reply it can send back. + * - **Correlated.** `requestId` matches the `chat:send` call this delta + * belongs to, so a listener discards anything that is not its own + * request rather than trusting whatever arrives. + * - **Bounded and content-safe.** `delta` is {@link chatStreamDeltaSchema}: + * short, control-character-free, bidi-free. A larger fragment is split by + * the sender into several events rather than sent whole. + * - **Advisory.** A delta is a preview to render, never a message to + * commit. The authoritative reply is still the one `chat:send` resolves + * with, validated as a whole. Dropping every event on this channel would + * cost the live preview and change nothing about the final conversation. + * + * Validated by `main/ipc.ts` before it is sent and again by + * `src/preload/index.ts` before any renderer listener sees it. + */ +export const IPC_CHAT_CHUNK_CHANNEL = 'chat:chunk'; + +export const chatChunkEventSchema = z.strictObject({ + requestId: z.uuid(), + delta: chatStreamDeltaSchema, +}); + +export type ChatChunkEvent = z.infer; diff --git a/tests/e2e/electron-smoke.test.ts b/tests/e2e/electron-smoke.test.ts index d6f7457..cfae9fa 100644 --- a/tests/e2e/electron-smoke.test.ts +++ b/tests/e2e/electron-smoke.test.ts @@ -36,13 +36,15 @@ interface ExposedLocalAgentBridge { * Milestone 2 version of this test asserted `['health']` exactly. `settings` * and `secrets` were added in Milestone 7 for onboarding, provider settings, * and the encrypted secret store; `chat` was added in Phase 2 Milestone 3 - * for the one network-capable action in this codebase. Every one stays a - * narrow, explicitly named sub-object, never a generic invoke surface. + * for the one network-capable action in this codebase, and gained + * `onChunk` — a subscription to one fixed, one-way streaming-preview + * channel — in Milestone 4. Every one stays a narrow, explicitly named + * sub-object, never a generic invoke or listen surface. */ const EXPECTED_BRIDGE_KEYS = ['chat', 'health', 'secrets', 'settings'] as const; const EXPECTED_SETTINGS_KEYS = ['get', 'update'] as const; const EXPECTED_SECRETS_KEYS = ['clear', 'status', 'write'] as const; -const EXPECTED_CHAT_KEYS = ['cancel', 'send'] as const; +const EXPECTED_CHAT_KEYS = ['cancel', 'onChunk', 'send'] as const; function launchEnv(): Record { const env: Record = {}; @@ -164,6 +166,28 @@ describe('Electron desktop shell — security and health-check smoke test', () = expect(hasGenericInvoke).toBe(false); }); + it('has no generic listen-to-any-channel function either, despite exposing one event subscription', async () => { + // `chat.onChunk` subscribes to one fixed channel chosen in the preload, + // not to a caller-supplied one. Nothing named like a generic emitter API + // may appear anywhere on the bridge. + const hasGenericListener = await page.evaluate(() => { + const w = window as unknown as Record; + const generic = ['on', 'once', 'addListener', 'removeListener', 'off', 'emit']; + const localAgent = w.localAgent as + | (Record & { + settings?: Record; + secrets?: Record; + chat?: Record; + }) + | undefined; + const surfaces = [localAgent, localAgent?.settings, localAgent?.secrets, localAgent?.chat]; + return surfaces.some( + (surface) => surface !== undefined && generic.some((key) => key in surface), + ); + }); + expect(hasGenericListener).toBe(false); + }); + // Deliberately no test here calls `settings.*`, `secrets.*`, or `chat.*` // against the real running app: unlike `health`, all three have real side // effects (an audit write, a settings read/write, and — for `chat.send` — diff --git a/tests/unit/main/chat-completions-transport.test.ts b/tests/unit/main/chat-completions-transport.test.ts new file mode 100644 index 0000000..f2820d4 --- /dev/null +++ b/tests/unit/main/chat-completions-transport.test.ts @@ -0,0 +1,400 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createChatCompletionsProvider } from '../../../src/main/chat-completions-transport'; +import { ChatProviderError } from '../../../src/shared/chat/provider'; +import { + CHAT_MESSAGE_CONTENT_MAX_LENGTH, + CHAT_STREAM_MAX_LINE_LENGTH, +} from '../../../src/shared/constants'; +import { createChatMessage } from '../../../src/shared/schemas/chat.schema'; + +const NOW = '2026-08-07T00:00:00.000Z'; +const PLAINTEXT_KEY = 'sk-test-not-a-real-key'; + +function userMessage(content: string) { + return createChatMessage({ + id: '11111111-1111-4111-8111-111111111111', + role: 'user', + content, + createdAt: NOW, + }); +} + +function provider(overrides: Partial[0]> = {}) { + return createChatCompletionsProvider({ + providerId: 'test-provider', + baseUrl: 'https://api.example.test/v1', + model: 'test-model', + apiKey: PLAINTEXT_KEY, + ...overrides, + }); +} + +function sseResponse(body: string, status = 200): Response { + return new Response(body, { status, headers: { 'content-type': 'text/event-stream' } }); +} + +function frame(content: string): string { + return `data: ${JSON.stringify({ choices: [{ delta: { content } }] })}\n\n`; +} + +/** A `fetch` double that streams `chunks` one read at a time, honouring abort. */ +function streamingFetch(chunks: string[], options: { readonly neverEnd?: boolean } = {}) { + return vi.fn().mockImplementation((_url: string, init: { signal?: AbortSignal }) => { + const encoder = new TextEncoder(); + let index = 0; + const stream = new ReadableStream({ + pull(controller) { + if (init.signal?.aborted) { + controller.error(new DOMException('The operation was aborted.', 'AbortError')); + return; + } + if (index < chunks.length) { + controller.enqueue(encoder.encode(chunks[index] ?? '')); + index += 1; + return; + } + if (options.neverEnd === true) return; + controller.close(); + }, + }); + return Promise.resolve( + new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } }), + ); + }); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('createChatCompletionsProvider — request shape', () => { + it('requests streaming and posts to /chat/completions', async () => { + const fetchMock = vi.fn().mockResolvedValue(sseResponse(frame('hi'))); + vi.stubGlobal('fetch', fetchMock); + + await provider().send({ messages: [userMessage('hello')] }); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://api.example.test/v1/chat/completions'); + const body = JSON.parse(init.body as string) as { stream: boolean; model: string }; + expect(body.stream).toBe(true); + expect(body.model).toBe('test-model'); + }); + + it('sends no Authorization header when configured with a null key', async () => { + const fetchMock = vi.fn().mockResolvedValue(sseResponse(frame('hi'))); + vi.stubGlobal('fetch', fetchMock); + + await provider({ apiKey: null }).send({ messages: [userMessage('hello')] }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.headers as Record).not.toHaveProperty('authorization'); + expect(JSON.stringify(init)).not.toContain(PLAINTEXT_KEY); + }); + + it('reports the configured provider id', () => { + expect(provider({ providerId: 'glm' }).id).toBe('glm'); + }); +}); + +describe('createChatCompletionsProvider — streaming', () => { + it('accumulates multiple chunks into one reply', async () => { + vi.stubGlobal('fetch', streamingFetch([frame('Hello'), frame(', '), frame('world')])); + + const result = await provider().send({ messages: [userMessage('hi')] }); + expect(result.content).toBe('Hello, world'); + }); + + it('forwards each fragment to onChunk in order', async () => { + vi.stubGlobal('fetch', streamingFetch([frame('a'), frame('b'), frame('c')])); + const deltas: string[] = []; + + const result = await provider().send( + { messages: [userMessage('hi')] }, + { onChunk: (delta) => deltas.push(delta) }, + ); + + expect(deltas).toEqual(['a', 'b', 'c']); + expect(result.content).toBe('abc'); + }); + + it('handles a frame split across two socket reads', async () => { + const whole = frame('split across reads'); + const half = Math.floor(whole.length / 2); + vi.stubGlobal('fetch', streamingFetch([whole.slice(0, half), whole.slice(half)])); + + const result = await provider().send({ messages: [userMessage('hi')] }); + expect(result.content).toBe('split across reads'); + }); + + it('stops at the [DONE] sentinel and ignores anything after it', async () => { + vi.stubGlobal( + 'fetch', + streamingFetch([frame('kept'), 'data: [DONE]\n\n', frame('ignored after done')]), + ); + + const result = await provider().send({ messages: [userMessage('hi')] }); + expect(result.content).toBe('kept'); + }); + + it('ignores comments, blank lines and non-data fields', async () => { + vi.stubGlobal( + 'fetch', + streamingFetch([': keep-alive\n', '\n', 'event: message\n', frame('only this')]), + ); + + const result = await provider().send({ messages: [userMessage('hi')] }); + expect(result.content).toBe('only this'); + }); + + it('skips a malformed data frame and keeps the rest of the stream', async () => { + vi.stubGlobal( + 'fetch', + streamingFetch([frame('good '), 'data: {not json at all\n\n', frame('parts')]), + ); + + const result = await provider().send({ messages: [userMessage('hi')] }); + expect(result.content).toBe('good parts'); + }); + + it('skips a well-formed frame that carries no content', async () => { + vi.stubGlobal( + 'fetch', + streamingFetch([ + 'data: {"choices":[{"delta":{}}]}\n\n', + 'data: {"choices":[]}\n\n', + frame('text'), + ]), + ); + + const result = await provider().send({ messages: [userMessage('hi')] }); + expect(result.content).toBe('text'); + }); + + it('accepts a final frame with no trailing newline', async () => { + vi.stubGlobal( + 'fetch', + streamingFetch(['data: {"choices":[{"delta":{"content":"no trailing newline"}}]}']), + ); + + const result = await provider().send({ messages: [userMessage('hi')] }); + expect(result.content).toBe('no trailing newline'); + }); + + it('fails when a stream produces no content at all', async () => { + vi.stubGlobal('fetch', streamingFetch([': keep-alive\n', 'data: [DONE]\n\n'])); + + await expect(provider().send({ messages: [userMessage('hi')] })).rejects.toMatchObject({ + code: 'PROVIDER_REQUEST_FAILED', + }); + }); + + it('accepts a stream that ends without [DONE] but carried content', async () => { + vi.stubGlobal('fetch', streamingFetch([frame('truncated but present')])); + + const result = await provider().send({ messages: [userMessage('hi')] }); + expect(result.content).toBe('truncated but present'); + }); +}); + +describe('createChatCompletionsProvider — streaming bounds', () => { + it('rejects once accumulated content passes the maximum message length', async () => { + const oversized = 'a'.repeat(CHAT_MESSAGE_CONTENT_MAX_LENGTH + 100); + vi.stubGlobal('fetch', streamingFetch([frame(oversized)])); + + await expect(provider().send({ messages: [userMessage('hi')] })).rejects.toMatchObject({ + code: 'PROVIDER_REQUEST_FAILED', + }); + }); + + it('rejects a stream whose accumulated deltas exceed the cap across many frames', async () => { + const piece = 'b'.repeat(1000); + const frames = Array.from({ length: 12 }, () => frame(piece)); + vi.stubGlobal('fetch', streamingFetch(frames)); + + await expect(provider().send({ messages: [userMessage('hi')] })).rejects.toMatchObject({ + code: 'PROVIDER_REQUEST_FAILED', + }); + }); + + it('rejects a single line that never terminates, without buffering it forever', async () => { + const endless = 'data: {"choices":[{"delta":{"content":"' + 'c'.repeat(20_000); + vi.stubGlobal('fetch', streamingFetch([endless, endless, endless, endless, endless])); + + await expect(provider().send({ messages: [userMessage('hi')] })).rejects.toMatchObject({ + code: 'PROVIDER_REQUEST_FAILED', + }); + }); + + it('does not reject a long line that stays under the line cap', async () => { + const content = 'd'.repeat(CHAT_MESSAGE_CONTENT_MAX_LENGTH - 1); + expect(frame(content).length).toBeLessThan(CHAT_STREAM_MAX_LINE_LENGTH); + vi.stubGlobal('fetch', streamingFetch([frame(content)])); + + const result = await provider().send({ messages: [userMessage('hi')] }); + expect(result.content).toBe(content); + }); + + it('never forwards a delta after the accumulated cap is reached', async () => { + const piece = 'e'.repeat(1000); + const frames = Array.from({ length: 12 }, () => frame(piece)); + vi.stubGlobal('fetch', streamingFetch(frames)); + const deltas: string[] = []; + + await provider() + .send({ messages: [userMessage('hi')] }, { onChunk: (delta) => deltas.push(delta) }) + .catch(() => undefined); + + const forwarded = deltas.join('').length; + expect(forwarded).toBeLessThanOrEqual(CHAT_MESSAGE_CONTENT_MAX_LENGTH); + }); +}); + +describe('createChatCompletionsProvider — streaming failures', () => { + it('reports PROVIDER_ABORTED when the signal fires mid-stream', async () => { + const controller = new AbortController(); + vi.stubGlobal('fetch', streamingFetch([frame('partial')], { neverEnd: true })); + + const pending = provider().send( + { messages: [userMessage('hi')] }, + { + signal: controller.signal, + onChunk: () => { + controller.abort(); + }, + }, + ); + + await expect(pending).rejects.toMatchObject({ code: 'PROVIDER_ABORTED' }); + }); + + it('stops forwarding deltas once the signal has fired', async () => { + const controller = new AbortController(); + vi.stubGlobal('fetch', streamingFetch([frame('one'), frame('two'), frame('three')])); + const deltas: string[] = []; + + await provider() + .send( + { messages: [userMessage('hi')] }, + { + signal: controller.signal, + onChunk: (delta) => { + deltas.push(delta); + controller.abort(); + }, + }, + ) + .catch(() => undefined); + + expect(deltas).toEqual(['one']); + }); + + it('reports PROVIDER_REQUEST_FAILED when the connection drops mid-stream', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(frame('partial'))); + controller.error(new Error('socket hang up')); + }, + }); + return Promise.resolve( + new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } }), + ); + }), + ); + + await expect(provider().send({ messages: [userMessage('hi')] })).rejects.toMatchObject({ + code: 'PROVIDER_REQUEST_FAILED', + }); + }); + + it('never leaks a raw stream error message into the thrown error', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => { + const stream = new ReadableStream({ + start(controller) { + controller.error(new Error('ECONNRESET at 10.1.2.3:443 leaky-detail')); + }, + }); + return Promise.resolve( + new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } }), + ); + }), + ); + + await expect(provider().send({ messages: [userMessage('hi')] })).rejects.toSatisfy( + (error: unknown) => { + expect(error).toBeInstanceOf(ChatProviderError); + expect((error as ChatProviderError).message).not.toContain('leaky-detail'); + expect((error as ChatProviderError).message).not.toContain('10.1.2.3'); + return true; + }, + ); + }); + + it('a throwing onChunk callback never fails the request', async () => { + vi.stubGlobal('fetch', streamingFetch([frame('resilient')])); + + const result = await provider().send( + { messages: [userMessage('hi')] }, + { + onChunk: () => { + throw new Error('renderer preview blew up'); + }, + }, + ); + + expect(result.content).toBe('resilient'); + }); + + it('never logs during a streamed exchange', async () => { + vi.stubGlobal('fetch', streamingFetch([frame('quiet')])); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + await provider().send({ messages: [userMessage('hi')] }); + + expect(logSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); + +describe('createChatCompletionsProvider — non-streamed responses still work', () => { + it('parses a plain JSON completion from an endpoint that ignored stream:true', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ choices: [{ message: { content: 'whole reply' } }] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); + + const result = await provider().send({ messages: [userMessage('hi')] }); + expect(result.content).toBe('whole reply'); + }); + + it('calls onChunk not at all for a non-streamed response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ choices: [{ message: { content: 'whole reply' } }] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); + const onChunk = vi.fn(); + + await provider().send({ messages: [userMessage('hi')] }, { onChunk }); + expect(onChunk).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/main/chat-provider-registry.test.ts b/tests/unit/main/chat-provider-registry.test.ts index 76edb93..990ad0e 100644 --- a/tests/unit/main/chat-provider-registry.test.ts +++ b/tests/unit/main/chat-provider-registry.test.ts @@ -6,11 +6,16 @@ import { join } from 'node:path'; import type { SafeStorage } from 'electron'; import { - OPENAI_COMPATIBLE_REQUEST_TIMEOUT_MS, + CHAT_PROVIDER_REQUEST_TIMEOUT_MS, resolveMainChatProvider, } from '../../../src/main/chat-provider-registry'; import { writeSecret } from '../../../src/main/secrets'; -import { MODEL_PROVIDERS, type ModelProvider } from '../../../src/shared/constants'; +import { + GLM_DEFAULT_BASE_URL, + MODEL_PROVIDERS, + OLLAMA_DEFAULT_BASE_URL, + type ModelProvider, +} from '../../../src/shared/constants'; import type { ModelProviderSettings } from '../../../src/shared/schemas'; const PLAINTEXT_KEY = 'sk-test-not-a-real-key'; @@ -49,17 +54,36 @@ afterEach(async () => { }); describe('resolveMainChatProvider — identifiers without a real adapter', () => { - it.each(['none', 'glm', 'ollama'] as const)( - 'delegates %s to the shared fail-closed registry', - async (provider) => { + it('delegates none to the shared fail-closed registry', async () => { + const chatProvider = await resolveMainChatProvider({ + modelProvider: settings({ provider: 'none' }), + secretsFile, + safeStorage: fakeSafeStorage(), + }); + await expect(chatProvider.send({ messages: [] })).rejects.toMatchObject({ + code: 'PROVIDER_UNAVAILABLE', + }); + }); + + it.each(['anthropic', 'openai', 'claude', 'gemini', ''])( + 'fails closed for the unapproved identifier %s, never treating it as a real provider', + async (hostile) => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + const chatProvider = await resolveMainChatProvider({ - modelProvider: settings({ provider }), + // Simulates an identifier reaching this function without having + // passed `settingsSchema` — the type system cannot express it, so + // the runtime membership check is what has to catch it. + modelProvider: settings({ provider: hostile as ModelProvider }), secretsFile, safeStorage: fakeSafeStorage(), }); + await expect(chatProvider.send({ messages: [] })).rejects.toMatchObject({ code: 'PROVIDER_UNAVAILABLE', }); + expect(fetchSpy).not.toHaveBeenCalled(); }, ); @@ -144,7 +168,7 @@ describe('resolveMainChatProvider — openai-compatible, fully configured', () = expect(result.content).toBe('hi there'); }); - it('is wrapped in the shared timeout decorator, using OPENAI_COMPATIBLE_REQUEST_TIMEOUT_MS', async () => { + it('is wrapped in the shared timeout decorator, using CHAT_PROVIDER_REQUEST_TIMEOUT_MS', async () => { await writeSecret(secretsFile, PLAINTEXT_KEY, fakeSafeStorage()); vi.useFakeTimers(); // Mirrors real `fetch`'s own behaviour: a passed `signal` rejects the @@ -171,7 +195,7 @@ describe('resolveMainChatProvider — openai-compatible, fully configured', () = const pending = chatProvider.send({ messages: [] }); const assertion = expect(pending).rejects.toMatchObject({ code: 'PROVIDER_TIMEOUT' }); - await vi.advanceTimersByTimeAsync(OPENAI_COMPATIBLE_REQUEST_TIMEOUT_MS + 1); + await vi.advanceTimersByTimeAsync(CHAT_PROVIDER_REQUEST_TIMEOUT_MS + 1); await assertion; }); @@ -200,3 +224,212 @@ describe('resolveMainChatProvider — every approved identifier resolves to some ).resolves.toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// glm (Phase 2, Milestone 4) +// --------------------------------------------------------------------------- + +function glmSettings(overrides: Partial = {}): ModelProviderSettings { + return settings({ provider: 'glm', model: 'glm-4', baseUrl: '', ...overrides }); +} + +function okStream(body: string): Response { + return new Response(body, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }); +} + +describe('resolveMainChatProvider — glm', () => { + it('fails closed with PROVIDER_INVALID_CONFIGURATION when no key is stored', async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + const chatProvider = await resolveMainChatProvider({ + modelProvider: glmSettings(), + secretsFile, + safeStorage: fakeSafeStorage(), + }); + + await expect(chatProvider.send({ messages: [] })).rejects.toMatchObject({ + code: 'PROVIDER_INVALID_CONFIGURATION', + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('fails closed with PROVIDER_INVALID_CONFIGURATION when no model is configured', async () => { + await writeSecret(secretsFile, PLAINTEXT_KEY, fakeSafeStorage()); + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + const chatProvider = await resolveMainChatProvider({ + modelProvider: glmSettings({ model: '' }), + secretsFile, + safeStorage: fakeSafeStorage(), + }); + + await expect(chatProvider.send({ messages: [] })).rejects.toMatchObject({ + code: 'PROVIDER_INVALID_CONFIGURATION', + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('uses the public default endpoint and a bearer key once configured', async () => { + await writeSecret(secretsFile, PLAINTEXT_KEY, fakeSafeStorage()); + const fetchMock = vi + .fn() + .mockResolvedValue( + okStream('data: {"choices":[{"delta":{"content":"ni hao"}}]}\n\ndata: [DONE]\n\n'), + ); + vi.stubGlobal('fetch', fetchMock); + + const chatProvider = await resolveMainChatProvider({ + modelProvider: glmSettings(), + secretsFile, + safeStorage: fakeSafeStorage(), + }); + const result = await chatProvider.send({ messages: [] }); + + expect(result.content).toBe('ni hao'); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${GLM_DEFAULT_BASE_URL}/chat/completions`); + expect((init.headers as Record).authorization).toBe(`Bearer ${PLAINTEXT_KEY}`); + }); + + it('prefers a configured endpoint over the default', async () => { + await writeSecret(secretsFile, PLAINTEXT_KEY, fakeSafeStorage()); + const fetchMock = vi + .fn() + .mockResolvedValue(okStream('data: {"choices":[{"delta":{"content":"hi"}}]}\n\n')); + vi.stubGlobal('fetch', fetchMock); + + const chatProvider = await resolveMainChatProvider({ + modelProvider: glmSettings({ baseUrl: 'https://glm.mirror.test/v4' }), + secretsFile, + safeStorage: fakeSafeStorage(), + }); + await chatProvider.send({ messages: [] }); + + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://glm.mirror.test/v4/chat/completions'); + }); +}); + +// --------------------------------------------------------------------------- +// ollama (Phase 2, Milestone 4) +// --------------------------------------------------------------------------- + +function ollamaSettings(overrides: Partial = {}): ModelProviderSettings { + return settings({ provider: 'ollama', model: 'llama3.1', baseUrl: '', ...overrides }); +} + +describe('resolveMainChatProvider — ollama', () => { + it('uses the local default endpoint and sends no Authorization header', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(okStream('data: {"choices":[{"delta":{"content":"local hi"}}]}\n\n')); + vi.stubGlobal('fetch', fetchMock); + + const chatProvider = await resolveMainChatProvider({ + modelProvider: ollamaSettings(), + secretsFile, + safeStorage: fakeSafeStorage(), + }); + const result = await chatProvider.send({ messages: [] }); + + expect(result.content).toBe('local hi'); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${OLLAMA_DEFAULT_BASE_URL}/chat/completions`); + expect(init.headers as Record).not.toHaveProperty('authorization'); + }); + + it('never reads the secret store, even when a key is stored for another provider', async () => { + await writeSecret(secretsFile, PLAINTEXT_KEY, fakeSafeStorage()); + const fetchMock = vi + .fn() + .mockResolvedValue(okStream('data: {"choices":[{"delta":{"content":"hi"}}]}\n\n')); + vi.stubGlobal('fetch', fetchMock); + + const chatProvider = await resolveMainChatProvider({ + modelProvider: ollamaSettings(), + secretsFile, + safeStorage: fakeSafeStorage(), + }); + await chatProvider.send({ messages: [] }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.stringify(init)).not.toContain(PLAINTEXT_KEY); + }); + + it.each([ + 'http://127.0.0.1:11434/v1', + 'http://localhost:11434/v1', + 'http://[::1]:11434/v1', + 'http://192.168.1.50:11434/v1', + ])('accepts the local endpoint %s', async (baseUrl) => { + const fetchMock = vi + .fn() + .mockResolvedValue(okStream('data: {"choices":[{"delta":{"content":"hi"}}]}\n\n')); + vi.stubGlobal('fetch', fetchMock); + + const chatProvider = await resolveMainChatProvider({ + modelProvider: ollamaSettings({ baseUrl }), + secretsFile, + safeStorage: fakeSafeStorage(), + }); + await chatProvider.send({ messages: [] }); + + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${baseUrl}/chat/completions`); + }); + + it.each([ + 'https://api.openai-like.test/v1', + 'http://example.test:11434/v1', + 'https://ollama.somecloud.test', + ])('refuses the non-local endpoint %s without any request', async (baseUrl) => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + const chatProvider = await resolveMainChatProvider({ + modelProvider: ollamaSettings({ baseUrl }), + secretsFile, + safeStorage: fakeSafeStorage(), + }); + + await expect(chatProvider.send({ messages: [] })).rejects.toMatchObject({ + code: 'PROVIDER_INVALID_CONFIGURATION', + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('never silently falls back to the local default when a non-local endpoint is configured', async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + const chatProvider = await resolveMainChatProvider({ + modelProvider: ollamaSettings({ baseUrl: 'https://ollama.somecloud.test' }), + secretsFile, + safeStorage: fakeSafeStorage(), + }); + await chatProvider.send({ messages: [] }).catch(() => undefined); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('fails closed with PROVIDER_INVALID_CONFIGURATION when no model is configured', async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + const chatProvider = await resolveMainChatProvider({ + modelProvider: ollamaSettings({ model: '' }), + secretsFile, + safeStorage: fakeSafeStorage(), + }); + + await expect(chatProvider.send({ messages: [] })).rejects.toMatchObject({ + code: 'PROVIDER_INVALID_CONFIGURATION', + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/main/glm-provider.test.ts b/tests/unit/main/glm-provider.test.ts new file mode 100644 index 0000000..5bb5fb9 --- /dev/null +++ b/tests/unit/main/glm-provider.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createGlmProvider, resolveGlmBaseUrl } from '../../../src/main/glm-provider'; +import { GLM_DEFAULT_BASE_URL } from '../../../src/shared/constants'; +import { createChatMessage } from '../../../src/shared/schemas/chat.schema'; + +const NOW = '2026-08-07T00:00:00.000Z'; +const PLAINTEXT_KEY = 'sk-glm-test-not-a-real-key'; + +function userMessage(content: string) { + return createChatMessage({ + id: '11111111-1111-4111-8111-111111111111', + role: 'user', + content, + createdAt: NOW, + }); +} + +function sseOk(content: string): Response { + return new Response(`data: ${JSON.stringify({ choices: [{ delta: { content } }] })}\n\n`, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('resolveGlmBaseUrl', () => { + it('falls back to the public default when nothing is configured', () => { + expect(resolveGlmBaseUrl('')).toBe(GLM_DEFAULT_BASE_URL); + expect(resolveGlmBaseUrl(' ')).toBe(GLM_DEFAULT_BASE_URL); + }); + + it('prefers a configured endpoint', () => { + expect(resolveGlmBaseUrl('https://glm.mirror.test/v4')).toBe('https://glm.mirror.test/v4'); + }); + + it('trims surrounding whitespace rather than building a broken URL', () => { + expect(resolveGlmBaseUrl(' https://glm.mirror.test/v4 ')).toBe('https://glm.mirror.test/v4'); + }); +}); + +describe('createGlmProvider', () => { + it('identifies itself as glm', () => { + expect(createGlmProvider({ baseUrl: '', model: 'glm-4', apiKey: PLAINTEXT_KEY }).id).toBe( + 'glm', + ); + }); + + it('sends the stored key as a bearer token and never in the body', async () => { + const fetchMock = vi.fn().mockResolvedValue(sseOk('hello')); + vi.stubGlobal('fetch', fetchMock); + + const provider = createGlmProvider({ baseUrl: '', model: 'glm-4', apiKey: PLAINTEXT_KEY }); + const result = await provider.send({ messages: [userMessage('hi')] }); + + expect(result.content).toBe('hello'); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${GLM_DEFAULT_BASE_URL}/chat/completions`); + expect((init.headers as Record).authorization).toBe(`Bearer ${PLAINTEXT_KEY}`); + expect(init.body as string).not.toContain(PLAINTEXT_KEY); + }); + + it('never places the key in a thrown error', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('nope', { status: 401 }))); + + const provider = createGlmProvider({ baseUrl: '', model: 'glm-4', apiKey: PLAINTEXT_KEY }); + await expect(provider.send({ messages: [userMessage('hi')] })).rejects.toSatisfy( + (error: unknown) => { + expect((error as Error).message).not.toContain(PLAINTEXT_KEY); + return true; + }, + ); + }); + + it('normalizes a rejected credential to PROVIDER_INVALID_CONFIGURATION', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('nope', { status: 401 }))); + + const provider = createGlmProvider({ baseUrl: '', model: 'glm-4', apiKey: PLAINTEXT_KEY }); + await expect(provider.send({ messages: [userMessage('hi')] })).rejects.toMatchObject({ + code: 'PROVIDER_INVALID_CONFIGURATION', + }); + }); + + it('streams incrementally through onChunk', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + new Response( + `data: ${JSON.stringify({ choices: [{ delta: { content: 'ni ' } }] })}\n\n` + + `data: ${JSON.stringify({ choices: [{ delta: { content: 'hao' } }] })}\n\n` + + 'data: [DONE]\n\n', + { status: 200, headers: { 'content-type': 'text/event-stream' } }, + ), + ), + ); + const deltas: string[] = []; + + const provider = createGlmProvider({ baseUrl: '', model: 'glm-4', apiKey: PLAINTEXT_KEY }); + const result = await provider.send( + { messages: [userMessage('hi')] }, + { onChunk: (delta) => deltas.push(delta) }, + ); + + expect(deltas).toEqual(['ni ', 'hao']); + expect(result.content).toBe('ni hao'); + }); +}); diff --git a/tests/unit/main/ipc.test.ts b/tests/unit/main/ipc.test.ts index 3152e4f..9197a0b 100644 --- a/tests/unit/main/ipc.test.ts +++ b/tests/unit/main/ipc.test.ts @@ -16,11 +16,16 @@ import type { IpcHandlerRuntime } from '../../../src/main/ipc'; import type { UserDataPaths } from '../../../src/main/paths'; import { hasStoredSecret, writeSecret } from '../../../src/main/secrets'; import { loadSettings, writeSettings } from '../../../src/main/settings'; -import { AUDIT_LOG_FILE_EXTENSION, AUDIT_LOG_FILE_PREFIX } from '../../../src/shared/constants'; +import { + AUDIT_LOG_FILE_EXTENSION, + AUDIT_LOG_FILE_PREFIX, + CHAT_STREAM_MAX_DELTA_LENGTH, +} from '../../../src/shared/constants'; import { createChatMessage, createDefaultSettings, IPC_CHAT_CANCEL_CHANNEL, + IPC_CHAT_CHUNK_CHANNEL, IPC_CHAT_SEND_CHANNEL, IPC_SECRETS_CLEAR_CHANNEL, IPC_SECRETS_STATUS_CHANNEL, @@ -39,9 +44,19 @@ import type { ConfirmationResult } from '../../../src/shared/types'; const NOW = '2026-08-07T00:00:00.000Z'; const PLAINTEXT_KEY = 'sk-super-secret-onboarding-key'; +/** One main → renderer event the fake `WebContents` was asked to send. */ +interface SentEvent { + readonly channel: string; + readonly payload: unknown; +} + function createFakeIpcMain(): { ipcMain: IpcMain; invoke: (channel: string, ...args: unknown[]) => Promise; + /** Everything pushed to the invoking renderer, in order. */ + sentEvents: SentEvent[]; + /** Simulates the window closing mid-request. */ + destroySender: () => void; } { const handlers = new Map unknown>(); const ipcMain = { @@ -50,8 +65,26 @@ function createFakeIpcMain(): { }, } as unknown as IpcMain; + // Stands in for the `IpcMainInvokeEvent.sender` `WebContents` that real + // Electron hands a handler: the only way a handler can push an event back + // to the renderer that invoked it. + const sentEvents: SentEvent[] = []; + let senderDestroyed = false; + const event = { + sender: { + isDestroyed: () => senderDestroyed, + send: (channel: string, payload: unknown) => { + sentEvents.push({ channel, payload }); + }, + }, + }; + return { ipcMain, + sentEvents, + destroySender: () => { + senderDestroyed = true; + }, invoke: (channel: string, ...args: unknown[]) => { const handler = handlers.get(channel); if (!handler) { @@ -62,7 +95,7 @@ function createFakeIpcMain(): { // promise) still rejects `ipcRenderer.invoke`'s promise, exactly as one // that returns a rejected promise does. try { - return Promise.resolve(handler({}, ...args)); + return Promise.resolve(handler(event, ...args)); } catch (error) { return Promise.reject(error instanceof Error ? error : new Error(String(error))); } @@ -122,6 +155,13 @@ function setUp(overrides: Partial = {}) { return invoke; } +/** Like {@link setUp}, but also exposes what was pushed back to the renderer. */ +function setUpWithEvents(overrides: Partial = {}) { + const fake = createFakeIpcMain(); + registerIpcHandlers(fake.ipcMain, buildRuntime(overrides)); + return fake; +} + async function readAuditLines(): Promise[]> { const filePath = join( paths.auditLogDir, @@ -717,6 +757,127 @@ describe('chat:send — openai-compatible, fully configured', () => { }); }); +describe('chat:send — streaming previews (chat:chunk)', () => { + function sseResponse(...contents: string[]): Response { + const body = + contents + .map((content) => `data: ${JSON.stringify({ choices: [{ delta: { content } }] })}\n\n`) + .join('') + 'data: [DONE]\n\n'; + return new Response(body, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }); + } + + it('pushes one chat:chunk event per streamed fragment, correlated by requestId', async () => { + await configureOpenAiCompatible(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse('Hel', 'lo'))); + + const { invoke, sentEvents } = setUpWithEvents(); + const requestId = randomUUID(); + const response = (await invoke(IPC_CHAT_SEND_CHANNEL, { + requestId, + messages: [userMessage('hi')], + })) as ChatSendResponse; + + expect(response.outcome).toBe('success'); + expect(response.content).toBe('Hello'); + expect(sentEvents).toEqual([ + { channel: IPC_CHAT_CHUNK_CHANNEL, payload: { requestId, delta: 'Hel' } }, + { channel: IPC_CHAT_CHUNK_CHANNEL, payload: { requestId, delta: 'lo' } }, + ]); + }); + + it('sends no chunk event for a non-streamed response', async () => { + await configureOpenAiCompatible(); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ choices: [{ message: { content: 'whole' } }] })), + ); + + const { invoke, sentEvents } = setUpWithEvents(); + await invoke(IPC_CHAT_SEND_CHANNEL, { requestId: randomUUID(), messages: [userMessage('hi')] }); + + expect(sentEvents).toEqual([]); + }); + + it('sends no chunk event when the action is denied by the emergency stop', async () => { + await configureOpenAiCompatible(); + await engageEmergencyStop(paths.emergencyStateFile, NOW); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse('never'))); + + const { invoke, sentEvents } = setUpWithEvents(); + const response = (await invoke(IPC_CHAT_SEND_CHANNEL, { + requestId: randomUUID(), + messages: [userMessage('hi')], + })) as ChatSendResponse; + + expect(response.outcome).toBe('denied'); + expect(sentEvents).toEqual([]); + }); + + it('splits an oversized fragment across several bounded events rather than dropping it', async () => { + await configureOpenAiCompatible(); + const long = 'x'.repeat(CHAT_STREAM_MAX_DELTA_LENGTH + 500); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse(long))); + + const { invoke, sentEvents } = setUpWithEvents(); + await invoke(IPC_CHAT_SEND_CHANNEL, { requestId: randomUUID(), messages: [userMessage('hi')] }); + + expect(sentEvents.length).toBe(2); + for (const event of sentEvents) { + const { delta } = event.payload as { delta: string }; + expect(delta.length).toBeLessThanOrEqual(CHAT_STREAM_MAX_DELTA_LENGTH); + } + const rejoined = sentEvents.map((event) => (event.payload as { delta: string }).delta).join(''); + expect(rejoined).toBe(long); + }); + + it('never sends a chunk event to a destroyed renderer', async () => { + await configureOpenAiCompatible(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse('gone'))); + + const fake = setUpWithEvents(); + fake.destroySender(); + const response = (await fake.invoke(IPC_CHAT_SEND_CHANNEL, { + requestId: randomUUID(), + messages: [userMessage('hi')], + })) as ChatSendResponse; + + // The request itself still completes; only the preview is skipped. + expect(response.outcome).toBe('success'); + expect(fake.sentEvents).toEqual([]); + }); + + it('never includes the API key or the conversation in a chunk event', async () => { + await configureOpenAiCompatible(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse('safe reply'))); + + const { invoke, sentEvents } = setUpWithEvents(); + await invoke(IPC_CHAT_SEND_CHANNEL, { + requestId: randomUUID(), + messages: [userMessage('a private question nobody should echo')], + }); + + const serialized = JSON.stringify(sentEvents); + expect(serialized).not.toContain(PLAINTEXT_KEY); + expect(serialized).not.toContain('a private question nobody should echo'); + }); + + it('records nothing about streamed content in the audit trail', async () => { + await configureOpenAiCompatible(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse('audited nowhere'))); + + const { invoke } = setUpWithEvents(); + await invoke(IPC_CHAT_SEND_CHANNEL, { requestId: randomUUID(), messages: [userMessage('hi')] }); + + const lines = await readAuditLines(); + const record = lines.find((line) => line.actionType === 'chat.send'); + expect(record?.parameters).toEqual({ provider: 'openai-compatible', messageCount: 1 }); + expect(JSON.stringify(lines)).not.toContain('audited nowhere'); + }); +}); + describe('chat:cancel', () => { it('aborts a matching in-flight chat:send call', async () => { await configureOpenAiCompatible(); diff --git a/tests/unit/main/ollama-provider.test.ts b/tests/unit/main/ollama-provider.test.ts new file mode 100644 index 0000000..ba5c333 --- /dev/null +++ b/tests/unit/main/ollama-provider.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createOllamaProvider, + NonLocalOllamaEndpointError, + resolveOllamaBaseUrl, +} from '../../../src/main/ollama-provider'; +import { OLLAMA_DEFAULT_BASE_URL } from '../../../src/shared/constants'; +import { createChatMessage } from '../../../src/shared/schemas/chat.schema'; + +const NOW = '2026-08-07T00:00:00.000Z'; + +function userMessage(content: string) { + return createChatMessage({ + id: '11111111-1111-4111-8111-111111111111', + role: 'user', + content, + createdAt: NOW, + }); +} + +function sseOk(content: string): Response { + return new Response(`data: ${JSON.stringify({ choices: [{ delta: { content } }] })}\n\n`, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('resolveOllamaBaseUrl', () => { + it('falls back to the local default when nothing is configured', () => { + expect(resolveOllamaBaseUrl('')).toBe(OLLAMA_DEFAULT_BASE_URL); + expect(resolveOllamaBaseUrl(' ')).toBe(OLLAMA_DEFAULT_BASE_URL); + }); + + it('accepts a configured local endpoint', () => { + expect(resolveOllamaBaseUrl('http://192.168.1.50:11434/v1')).toBe( + 'http://192.168.1.50:11434/v1', + ); + }); + + it('throws for a non-local endpoint rather than falling back to the default', () => { + expect(() => resolveOllamaBaseUrl('https://ollama.somecloud.test')).toThrow( + NonLocalOllamaEndpointError, + ); + }); +}); + +describe('createOllamaProvider', () => { + it('identifies itself as ollama', () => { + expect(createOllamaProvider({ baseUrl: '', model: 'llama3.1' }).id).toBe('ollama'); + }); + + it('talks to the local default and sends no Authorization header', async () => { + const fetchMock = vi.fn().mockResolvedValue(sseOk('local reply')); + vi.stubGlobal('fetch', fetchMock); + + const provider = createOllamaProvider({ baseUrl: '', model: 'llama3.1' }); + const result = await provider.send({ messages: [userMessage('hi')] }); + + expect(result.content).toBe('local reply'); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${OLLAMA_DEFAULT_BASE_URL}/chat/completions`); + expect(init.headers as Record).not.toHaveProperty('authorization'); + }); + + it('has no way to be given a key at all', () => { + // Structural, not a convention: the config type has no key field, so a + // credential cannot reach a local endpoint even by a caller's mistake. + const config: Parameters[0] = { baseUrl: '', model: 'llama3.1' }; + expect(Object.keys(config).sort()).toEqual(['baseUrl', 'model']); + }); + + it('fails closed for a non-local endpoint without any request', async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + const provider = createOllamaProvider({ + baseUrl: 'https://ollama.somecloud.test', + model: 'llama3.1', + }); + + await expect(provider.send({ messages: [userMessage('hi')] })).rejects.toMatchObject({ + code: 'PROVIDER_INVALID_CONFIGURATION', + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('returns a fail-closed provider rather than throwing synchronously', () => { + expect(() => + createOllamaProvider({ baseUrl: 'https://ollama.somecloud.test', model: 'llama3.1' }), + ).not.toThrow(); + }); + + it('still identifies itself as ollama when failing closed', () => { + const provider = createOllamaProvider({ + baseUrl: 'https://ollama.somecloud.test', + model: 'llama3.1', + }); + expect(provider.id).toBe('ollama'); + }); + + it('streams incrementally through onChunk', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + new Response( + `data: ${JSON.stringify({ choices: [{ delta: { content: 'local ' } }] })}\n\n` + + `data: ${JSON.stringify({ choices: [{ delta: { content: 'stream' } }] })}\n\n`, + { status: 200, headers: { 'content-type': 'text/event-stream' } }, + ), + ), + ); + const deltas: string[] = []; + + const provider = createOllamaProvider({ baseUrl: '', model: 'llama3.1' }); + const result = await provider.send( + { messages: [userMessage('hi')] }, + { onChunk: (delta) => deltas.push(delta) }, + ); + + expect(deltas).toEqual(['local ', 'stream']); + expect(result.content).toBe('local stream'); + }); +}); diff --git a/tests/unit/renderer/conversation-controller.test.ts b/tests/unit/renderer/conversation-controller.test.ts index 16caa77..eddc793 100644 --- a/tests/unit/renderer/conversation-controller.test.ts +++ b/tests/unit/renderer/conversation-controller.test.ts @@ -84,7 +84,12 @@ describe('ConversationController', () => { provider: instantProvider(() => ({ content: 'unused' })), ...makeDeps(), }); - expect(controller.getState()).toEqual({ messages: [], status: 'idle', error: null }); + expect(controller.getState()).toEqual({ + messages: [], + status: 'idle', + error: null, + streamingContent: null, + }); expect(controller.canSubmit).toBe(true); }); @@ -130,7 +135,12 @@ describe('ConversationController', () => { ...makeDeps(), }); await controller.submit(' '); - expect(controller.getState()).toEqual({ messages: [], status: 'idle', error: null }); + expect(controller.getState()).toEqual({ + messages: [], + status: 'idle', + error: null, + streamingContent: null, + }); }); it('prevents a second submission while a response is pending', async () => { @@ -414,3 +424,199 @@ describe('ConversationController', () => { }); }); }); + +// --------------------------------------------------------------------------- +// Streaming previews (Phase 2, Milestone 4) +// --------------------------------------------------------------------------- + +/** A provider that hands the test its `onChunk` callback to drive directly. */ +function streamingProvider(): { + provider: ChatProvider; + emit: (delta: string) => void; + resolve: (result: ChatProviderResult) => void; + reject: (error: unknown) => void; +} { + let emitFn: (delta: string) => void = () => undefined; + let resolveFn: (result: ChatProviderResult) => void = () => undefined; + let rejectFn: (error: unknown) => void = () => undefined; + + const provider: ChatProvider = { + id: 'streaming-test-provider', + send: (_request, options) => { + emitFn = (delta) => options?.onChunk?.(delta); + return new Promise((resolve, reject) => { + resolveFn = resolve; + rejectFn = reject; + }); + }, + }; + + return { + provider, + emit: (delta) => { + emitFn(delta); + }, + resolve: (result) => { + resolveFn(result); + }, + reject: (error) => { + rejectFn(error); + }, + }; +} + +describe('ConversationController — streaming', () => { + it('starts with no streaming content', () => { + const controller = new ConversationController({ + provider: instantProvider(() => ({ content: 'unused' })), + ...makeDeps(), + }); + expect(controller.getState().streamingContent).toBeNull(); + }); + + it('exposes accumulated deltas as a preview while awaiting the response', async () => { + const { provider, emit, resolve } = streamingProvider(); + const controller = new ConversationController({ provider, ...makeDeps() }); + + const pending = controller.submit('hello'); + emit('Hel'); + expect(controller.getState().streamingContent).toBe('Hel'); + emit('lo there'); + expect(controller.getState().streamingContent).toBe('Hello there'); + expect(controller.getState().status).toBe('awaiting-response'); + + resolve({ content: 'Hello there' }); + await pending; + }); + + it('never adds a streamed preview to the message list', async () => { + const { provider, emit, resolve } = streamingProvider(); + const controller = new ConversationController({ provider, ...makeDeps() }); + + const pending = controller.submit('hello'); + emit('partial preview'); + expect(controller.getState().messages).toHaveLength(1); + expect(controller.getState().messages[0]?.role).toBe('user'); + + resolve({ content: 'the real reply' }); + await pending; + + const { messages, streamingContent } = controller.getState(); + expect(messages).toHaveLength(2); + expect(messages[1]?.content).toBe('the real reply'); + expect(streamingContent).toBeNull(); + }); + + it('commits the validated response, not the accumulated deltas', async () => { + const { provider, emit, resolve } = streamingProvider(); + const controller = new ConversationController({ provider, ...makeDeps() }); + + const pending = controller.submit('hello'); + emit('deltas that disagree'); + resolve({ content: 'authoritative reply' }); + await pending; + + expect(controller.getState().messages[1]?.content).toBe('authoritative reply'); + }); + + it('discards the preview when the request fails, keeping the user message', async () => { + const { provider, emit, reject } = streamingProvider(); + const controller = new ConversationController({ provider, ...makeDeps() }); + + const pending = controller.submit('hello'); + emit('half an answer'); + reject(new ChatProviderError('PROVIDER_REQUEST_FAILED', 'nope')); + await pending; + + const state = controller.getState(); + expect(state.streamingContent).toBeNull(); + expect(state.messages).toHaveLength(1); + expect(state.error).not.toBeNull(); + }); + + it('bounds the preview at the maximum message length', async () => { + const { provider, emit, resolve } = streamingProvider(); + const controller = new ConversationController({ provider, ...makeDeps() }); + + const pending = controller.submit('hello'); + for (let index = 0; index < 20; index += 1) { + emit('z'.repeat(1000)); + } + + const preview = controller.getState().streamingContent ?? ''; + expect(preview.length).toBe(CHAT_MESSAGE_CONTENT_MAX_LENGTH); + + resolve({ content: 'done' }); + await pending; + }); + + it('ignores deltas from a request that was superseded by a provider switch', async () => { + const stale = streamingProvider(); + const controller = new ConversationController({ provider: stale.provider, ...makeDeps() }); + + const pending = controller.submit('hello'); + stale.emit('early'); + expect(controller.getState().streamingContent).toBe('early'); + + controller.setProvider(instantProvider(() => ({ content: 'from the new provider' }))); + expect(controller.getState().streamingContent).toBeNull(); + + stale.emit('late delta from the old provider'); + expect(controller.getState().streamingContent).toBeNull(); + + stale.resolve({ content: 'stale reply' }); + await pending; + expect(controller.getState().messages).toHaveLength(1); + }); + + it('clears the preview between two consecutive submissions', async () => { + const first = streamingProvider(); + const controller = new ConversationController({ provider: first.provider, ...makeDeps() }); + + const firstPending = controller.submit('one'); + first.emit('first preview'); + first.resolve({ content: 'first reply' }); + await firstPending; + expect(controller.getState().streamingContent).toBeNull(); + + const second = streamingProvider(); + controller.setProvider(second.provider); + const secondPending = controller.submit('two'); + expect(controller.getState().streamingContent).toBeNull(); + second.emit('second preview'); + expect(controller.getState().streamingContent).toBe('second preview'); + + second.resolve({ content: 'second reply' }); + await secondPending; + }); + + it('notifies subscribers as the preview grows', async () => { + const { provider, emit, resolve } = streamingProvider(); + const controller = new ConversationController({ provider, ...makeDeps() }); + const previews: (string | null)[] = []; + controller.subscribe((state) => previews.push(state.streamingContent)); + + const pending = controller.submit('hello'); + emit('a'); + emit('b'); + resolve({ content: 'ab' }); + await pending; + + expect(previews).toContain('a'); + expect(previews).toContain('ab'); + expect(previews.at(-1)).toBeNull(); + }); + + it('works unchanged with a provider that never streams', async () => { + const controller = new ConversationController({ + provider: instantProvider(() => ({ content: 'no streaming here' })), + ...makeDeps(), + }); + + await controller.submit('hello'); + + const state = controller.getState(); + expect(state.streamingContent).toBeNull(); + expect(state.messages[1]?.content).toBe('no streaming here'); + }); +}); diff --git a/tests/unit/renderer/ipc-chat-provider.test.ts b/tests/unit/renderer/ipc-chat-provider.test.ts index c2c73b5..67fdb23 100644 --- a/tests/unit/renderer/ipc-chat-provider.test.ts +++ b/tests/unit/renderer/ipc-chat-provider.test.ts @@ -5,8 +5,9 @@ import { IPC_CHAT_PROVIDER_ID, } from '../../../src/renderer/chat/ipc-chat-provider'; import { ChatProviderError } from '../../../src/shared/chat/provider'; +import { MODEL_PROVIDERS } from '../../../src/shared/constants'; import { createChatMessage } from '../../../src/shared/schemas/chat.schema'; -import type { ChatSendResponse } from '../../../src/shared/schemas'; +import type { ChatChunkEvent, ChatSendResponse } from '../../../src/shared/schemas'; const NOW = '2026-08-07T00:00:00.000Z'; @@ -22,13 +23,36 @@ function userMessage(content: string) { interface FakeBridge { readonly send: ReturnType; readonly cancel: ReturnType; + readonly onChunk: ReturnType; + /** Delivers one event to every currently-subscribed listener. */ + readonly emit: (event: ChatChunkEvent) => void; + /** How many listeners are currently subscribed — a leak check. */ + readonly listenerCount: () => number; } function installFakeBridge(): FakeBridge { const send = vi.fn<(requestId: string, messages: unknown[]) => Promise>(); const cancel = vi.fn<(requestId: string) => Promise>(); - vi.stubGlobal('window', { localAgent: { chat: { send, cancel } } }); - return { send, cancel }; + const listeners = new Set<(event: ChatChunkEvent) => void>(); + + const onChunk = vi.fn((listener: (event: ChatChunkEvent) => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }); + + vi.stubGlobal('window', { localAgent: { chat: { send, cancel, onChunk } } }); + + return { + send, + cancel, + onChunk, + emit: (event) => { + for (const listener of [...listeners]) listener(event); + }, + listenerCount: () => listeners.size, + }; } function deferred(): { promise: Promise; resolve: (value: T) => void } { @@ -45,12 +69,15 @@ afterEach(() => { }); describe('createIpcChatProvider', () => { - it('has the openai-compatible id', () => { + it('reports a transport id, not the name of any one provider', () => { const { send } = installFakeBridge(); send.mockResolvedValue({ outcome: 'success', content: 'ok' }); const provider = createIpcChatProvider(); expect(provider.id).toBe(IPC_CHAT_PROVIDER_ID); - expect(provider.id).toBe('openai-compatible'); + expect(provider.id).toBe('ipc'); + // Which real provider answers is a main-process decision the renderer is + // never told, so this id must not name one. + expect(MODEL_PROVIDERS).not.toContain(provider.id); }); it('calls window.localAgent.chat.send with a fresh requestId and the request messages', async () => { @@ -142,3 +169,119 @@ describe('createIpcChatProvider', () => { expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function)); }); }); + +describe('createIpcChatProvider — streaming previews', () => { + it('forwards deltas for its own requestId, in order', async () => { + const bridge = installFakeBridge(); + const { promise, resolve } = deferred(); + bridge.send.mockReturnValue(promise); + const deltas: string[] = []; + + const provider = createIpcChatProvider(); + const pending = provider.send( + { messages: [userMessage('hi')] }, + { onChunk: (delta) => deltas.push(delta) }, + ); + + const requestId = (bridge.send.mock.calls[0] as [string, unknown[]])[0]; + bridge.emit({ requestId, delta: 'Hel' }); + bridge.emit({ requestId, delta: 'lo' }); + + resolve({ outcome: 'success', content: 'Hello' }); + await pending; + + expect(deltas).toEqual(['Hel', 'lo']); + }); + + it('discards deltas belonging to a different request', async () => { + const bridge = installFakeBridge(); + const { promise, resolve } = deferred(); + bridge.send.mockReturnValue(promise); + const deltas: string[] = []; + + const provider = createIpcChatProvider(); + const pending = provider.send( + { messages: [userMessage('hi')] }, + { onChunk: (delta) => deltas.push(delta) }, + ); + + bridge.emit({ requestId: '99999999-9999-4999-8999-999999999999', delta: 'not mine' }); + + resolve({ outcome: 'success', content: 'mine' }); + await pending; + + expect(deltas).toEqual([]); + }); + + it('stops forwarding deltas once the caller aborts', async () => { + const bridge = installFakeBridge(); + const { promise, resolve } = deferred(); + bridge.send.mockReturnValue(promise); + bridge.cancel.mockResolvedValue(undefined); + const deltas: string[] = []; + + const controller = new AbortController(); + const provider = createIpcChatProvider(); + const pending = provider.send( + { messages: [userMessage('hi')] }, + { signal: controller.signal, onChunk: (delta) => deltas.push(delta) }, + ); + + const requestId = (bridge.send.mock.calls[0] as [string, unknown[]])[0]; + bridge.emit({ requestId, delta: 'before' }); + controller.abort(); + bridge.emit({ requestId, delta: 'after' }); + + resolve({ outcome: 'success', content: 'too late' }); + await expect(pending).rejects.toMatchObject({ code: 'PROVIDER_ABORTED' }); + + expect(deltas).toEqual(['before']); + }); + + it('unsubscribes when the call settles, leaving no listener behind', async () => { + const bridge = installFakeBridge(); + bridge.send.mockResolvedValue({ outcome: 'success', content: 'ok' }); + + const provider = createIpcChatProvider(); + await provider.send({ messages: [userMessage('hi')] }, { onChunk: () => undefined }); + + expect(bridge.listenerCount()).toBe(0); + }); + + it('unsubscribes even when the call fails', async () => { + const bridge = installFakeBridge(); + bridge.send.mockResolvedValue({ outcome: 'failure', errorCode: 'PROVIDER_REQUEST_FAILED' }); + + const provider = createIpcChatProvider(); + await provider + .send({ messages: [userMessage('hi')] }, { onChunk: () => undefined }) + .catch(() => undefined); + + expect(bridge.listenerCount()).toBe(0); + }); + + it('does not subscribe at all when the caller wants no previews', async () => { + const bridge = installFakeBridge(); + bridge.send.mockResolvedValue({ outcome: 'success', content: 'ok' }); + + const provider = createIpcChatProvider(); + await provider.send({ messages: [userMessage('hi')] }); + + expect(bridge.onChunk).not.toHaveBeenCalled(); + }); + + it('returns the authoritative response content, not the accumulated deltas', async () => { + const bridge = installFakeBridge(); + const { promise, resolve } = deferred(); + bridge.send.mockReturnValue(promise); + + const provider = createIpcChatProvider(); + const pending = provider.send({ messages: [userMessage('hi')] }, { onChunk: () => undefined }); + + const requestId = (bridge.send.mock.calls[0] as [string, unknown[]])[0]; + bridge.emit({ requestId, delta: 'streamed preview text' }); + + resolve({ outcome: 'success', content: 'authoritative reply' }); + await expect(pending).resolves.toEqual({ content: 'authoritative reply' }); + }); +}); diff --git a/tests/unit/shared/chat-registry.test.ts b/tests/unit/shared/chat-registry.test.ts index 7b11e07..99cc7a4 100644 --- a/tests/unit/shared/chat-registry.test.ts +++ b/tests/unit/shared/chat-registry.test.ts @@ -23,10 +23,10 @@ describe('getChatProviderCapabilities', () => { expect(getChatProviderCapabilities('openai-compatible').requiresApiKey).toBe(true); }); - it('reports implemented only for openai-compatible, since Milestone 3', () => { + it('reports implemented for every real provider, and never for none', () => { expect(getChatProviderCapabilities('none').implemented).toBe(false); - expect(getChatProviderCapabilities('glm').implemented).toBe(false); - expect(getChatProviderCapabilities('ollama').implemented).toBe(false); + expect(getChatProviderCapabilities('glm').implemented).toBe(true); + expect(getChatProviderCapabilities('ollama').implemented).toBe(true); expect(getChatProviderCapabilities('openai-compatible').implemented).toBe(true); }); @@ -54,10 +54,36 @@ describe('describeChatProviderStatus', () => { expect(status.summary).toMatch(/mock/i); }); - it.each(['glm', 'ollama'] as const)('reports %s as not-implemented', (id) => { - const status = describeChatProviderStatus({ provider: id, hasApiKey: false }); - expect(status.availability).toBe('not-implemented'); - expect(status.summary).toMatch(/mock/i); + it('reports glm without a stored key as missing-api-key', () => { + const status = describeChatProviderStatus({ provider: 'glm', hasApiKey: false }); + expect(status.availability).toBe('missing-api-key'); + expect(status.summary).not.toMatch(/mock/i); + }); + + it('reports glm with a stored key as ready', () => { + const status = describeChatProviderStatus({ provider: 'glm', hasApiKey: true }); + expect(status.availability).toBe('ready'); + expect(status.summary).not.toMatch(/mock/i); + }); + + it('reports ollama as ready with or without a key, since it needs none', () => { + for (const hasApiKey of [true, false]) { + const status = describeChatProviderStatus({ provider: 'ollama', hasApiKey }); + expect(status.availability).toBe('ready'); + expect(status.summary).not.toMatch(/mock/i); + } + }); + + it('describes ollama as local in its own status text', () => { + const status = describeChatProviderStatus({ provider: 'ollama', hasApiKey: false }); + expect(status.summary).toMatch(/local/i); + }); + + it('still routes only `none` to the mock provider', () => { + const mockBacked = MODEL_PROVIDERS.filter((id) => + /mock/i.test(describeChatProviderStatus({ provider: id, hasApiKey: false }).summary), + ); + expect(mockBacked).toEqual(['none']); }); it('reports openai-compatible without a stored key as missing-api-key', () => { diff --git a/tests/unit/shared/ipc.schema.test.ts b/tests/unit/shared/ipc.schema.test.ts index 23f23e0..0ee860d 100644 --- a/tests/unit/shared/ipc.schema.test.ts +++ b/tests/unit/shared/ipc.schema.test.ts @@ -4,6 +4,7 @@ import { CHAT_PROVIDER_ERROR_CODES } from '../../../src/shared/chat/provider'; import { API_KEY_MAX_LENGTH, CHAT_CONVERSATION_MAX_MESSAGES, + CHAT_STREAM_MAX_DELTA_LENGTH, MODEL_PROVIDERS, UI_LANGUAGES, } from '../../../src/shared/constants'; @@ -11,6 +12,7 @@ import { createDefaultSettings } from '../../../src/shared/schemas/settings.sche import { chatCancelRequestSchema, chatCancelResponseSchema, + chatChunkEventSchema, chatSendRequestSchema, chatSendResponseSchema, secretsActionResponseSchema, @@ -324,6 +326,75 @@ describe('chatSendResponseSchema', () => { }); }); +describe('chatChunkEventSchema (Phase 2, Milestone 4)', () => { + const REQUEST_ID = '11111111-1111-4111-8111-111111111111'; + + it('accepts a well-formed streaming event', () => { + const result = chatChunkEventSchema.safeParse({ requestId: REQUEST_ID, delta: 'hello' }); + expect(result.success).toBe(true); + }); + + it('rejects a non-uuid requestId, so an uncorrelated event cannot be delivered', () => { + const result = chatChunkEventSchema.safeParse({ requestId: 'nope', delta: 'hello' }); + expect(result.success).toBe(false); + }); + + it('rejects an empty delta', () => { + expect(chatChunkEventSchema.safeParse({ requestId: REQUEST_ID, delta: '' }).success).toBe( + false, + ); + }); + + it('rejects a delta longer than the per-chunk bound', () => { + const result = chatChunkEventSchema.safeParse({ + requestId: REQUEST_ID, + delta: 'a'.repeat(CHAT_STREAM_MAX_DELTA_LENGTH + 1), + }); + expect(result.success).toBe(false); + }); + + it('accepts a delta exactly at the per-chunk bound', () => { + const result = chatChunkEventSchema.safeParse({ + requestId: REQUEST_ID, + delta: 'a'.repeat(CHAT_STREAM_MAX_DELTA_LENGTH), + }); + expect(result.success).toBe(true); + }); + + it('rejects a delta carrying an unsafe control character', () => { + const result = chatChunkEventSchema.safeParse({ + requestId: REQUEST_ID, + delta: 'bad' + String.fromCharCode(0) + 'delta', + }); + expect(result.success).toBe(false); + }); + + it('rejects a delta carrying a bidirectional override', () => { + const result = chatChunkEventSchema.safeParse({ + requestId: REQUEST_ID, + delta: 'spoofed‮text', + }); + expect(result.success).toBe(false); + }); + + it('allows the newlines and tabs a real reply legitimately contains', () => { + const result = chatChunkEventSchema.safeParse({ + requestId: REQUEST_ID, + delta: 'line one\nline two\tindented', + }); + expect(result.success).toBe(true); + }); + + it('rejects an unknown field — a chunk carries nothing but its correlation and its text', () => { + const result = chatChunkEventSchema.safeParse({ + requestId: REQUEST_ID, + delta: 'hello', + apiKey: 'sk-not-real', + }); + expect(result.success).toBe(false); + }); +}); + describe('chatCancelRequestSchema / chatCancelResponseSchema', () => { it('accepts a well-formed cancel request', () => { const result = chatCancelRequestSchema.safeParse([ diff --git a/tests/unit/shared/local-endpoint.test.ts b/tests/unit/shared/local-endpoint.test.ts new file mode 100644 index 0000000..120fcfe --- /dev/null +++ b/tests/unit/shared/local-endpoint.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; + +import { isLocalHttpEndpoint } from '../../../src/shared/chat/local-endpoint'; +import { OLLAMA_DEFAULT_BASE_URL } from '../../../src/shared/constants'; + +describe('isLocalHttpEndpoint — accepted local forms', () => { + it.each([ + 'http://localhost:11434/v1', + 'http://LOCALHOST:11434/v1', + 'http://ollama.localhost:11434/v1', + 'http://127.0.0.1:11434/v1', + 'http://127.1.2.3:11434', + 'https://127.0.0.1:11434/v1', + 'http://[::1]:11434/v1', + 'http://10.0.0.5:11434/v1', + 'http://172.16.0.1:11434/v1', + 'http://172.31.255.255:11434/v1', + 'http://192.168.1.50:11434/v1', + 'http://169.254.1.1:11434/v1', + 'http://[fd00::1]:11434/v1', + 'http://[fe80::1]:11434/v1', + ])('accepts %s', (value) => { + expect(isLocalHttpEndpoint(value)).toBe(true); + }); + + it('accepts the shipped Ollama default', () => { + expect(isLocalHttpEndpoint(OLLAMA_DEFAULT_BASE_URL)).toBe(true); + }); +}); + +describe('isLocalHttpEndpoint — rejected public and malformed forms', () => { + it.each([ + 'https://api.openai-like.test/v1', + 'https://open.bigmodel.cn/api/paas/v4', + 'http://example.test:11434/v1', + 'https://ollama.somecloud.test', + 'http://8.8.8.8:11434', + 'http://172.32.0.1:11434', + 'http://172.15.255.255:11434', + 'http://192.169.1.1:11434', + 'http://11.0.0.1:11434', + 'http://[2001:4860:4860::8888]:11434', + ])('rejects the public endpoint %s', (value) => { + expect(isLocalHttpEndpoint(value)).toBe(false); + }); + + it.each([ + '', + 'not a url', + '127.0.0.1:11434', + 'localhost:11434', + 'ftp://127.0.0.1/v1', + 'file:///etc/hosts', + 'javascript:alert(1)', + 'data:text/plain,hi', + ])('rejects the malformed or non-http value %s', (value) => { + expect(isLocalHttpEndpoint(value)).toBe(false); + }); + + it('rejects a local address carrying embedded credentials', () => { + expect(isLocalHttpEndpoint('http://user:pass@127.0.0.1:11434/v1')).toBe(false); + expect(isLocalHttpEndpoint('http://user@localhost:11434/v1')).toBe(false); + }); + + it('rejects a hostname that merely looks local', () => { + // A name can only be resolved by asking a resolver, which is exactly the + // thing a typo or an attacker controls — so names other than `localhost` + // are refused rather than trusted. + for (const value of [ + 'http://localhost.evil.test:11434', + 'http://127.0.0.1.evil.test:11434', + 'http://not-localhost:11434', + 'http://mylocalhost:11434', + ]) { + expect(isLocalHttpEndpoint(value)).toBe(false); + } + }); + + it('rejects an octet outside the valid range rather than treating it as local', () => { + expect(isLocalHttpEndpoint('http://127.0.0.999:11434')).toBe(false); + }); + + it('never throws, whatever it is given', () => { + for (const value of ['', '::::', 'http://', 'http://[', '%%%', 'http://127.0.0.1:notaport']) { + expect(() => isLocalHttpEndpoint(value)).not.toThrow(); + } + }); +}); diff --git a/tests/unit/shared/mock-provider.test.ts b/tests/unit/shared/mock-provider.test.ts index 0975241..b4782cb 100644 --- a/tests/unit/shared/mock-provider.test.ts +++ b/tests/unit/shared/mock-provider.test.ts @@ -149,3 +149,82 @@ describe('createMockChatProvider', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); }); + +describe('createMockChatProvider — streaming (Phase 2, Milestone 4)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('emits several fragments whose concatenation is exactly the final content', async () => { + const provider = createMockChatProvider({ delayMs: 0 }); + const deltas: string[] = []; + + const result = await provider.send( + { messages: [userMessage('hello there')] }, + { onChunk: (delta) => deltas.push(delta) }, + ); + + expect(deltas.length).toBeGreaterThan(1); + expect(deltas.join('')).toBe(result.content); + }); + + it('produces the same content whether or not the caller streams', async () => { + const provider = createMockChatProvider({ delayMs: 0 }); + const request = { messages: [userMessage('same question')] }; + + const streamed = await provider.send(request, { onChunk: () => undefined }); + const plain = await provider.send(request); + + expect(streamed.content).toBe(plain.content); + }); + + it('emits nothing when the caller passes no onChunk', async () => { + const provider = createMockChatProvider({ delayMs: 0 }); + const result = await provider.send({ messages: [userMessage('hi')] }); + expect(result.content.length).toBeGreaterThan(0); + }); + + it('streams no partial preview for the deliberate failure trigger', async () => { + const provider = createMockChatProvider({ delayMs: 0 }); + const deltas: string[] = []; + + await expect( + provider.send( + { messages: [userMessage(MOCK_CHAT_PROVIDER_FAILURE_TRIGGER)] }, + { onChunk: (delta) => deltas.push(delta) }, + ), + ).rejects.toBeInstanceOf(ChatProviderError); + + expect(deltas).toEqual([]); + }); + + it('stops streaming when the request is aborted', async () => { + const provider = createMockChatProvider({ delayMs: 4 }); + const controller = new AbortController(); + const deltas: string[] = []; + + const pending = provider.send( + { messages: [userMessage('hello there, this is a longer message to chunk up')] }, + { + signal: controller.signal, + onChunk: (delta) => { + deltas.push(delta); + controller.abort(); + }, + }, + ); + + await expect(pending).rejects.toMatchObject({ code: 'PROVIDER_ABORTED' }); + expect(deltas).toHaveLength(1); + }); + + it('never calls the global fetch function while streaming', async () => { + if (typeof globalThis.fetch !== 'function') return; + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const provider = createMockChatProvider({ delayMs: 0 }); + + await provider.send({ messages: [userMessage('hi')] }, { onChunk: () => undefined }); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/shared/provider-timeout.test.ts b/tests/unit/shared/provider-timeout.test.ts index 9352e1a..0106042 100644 --- a/tests/unit/shared/provider-timeout.test.ts +++ b/tests/unit/shared/provider-timeout.test.ts @@ -128,6 +128,26 @@ describe('withProviderTimeout', () => { expect(receivedSignals[0]).not.toBe(external.signal); }); + it('forwards options it does not own, such as onChunk, to the wrapped provider', async () => { + // Regression guard: an earlier version built a fresh options object + // carrying only `signal`, which silently disabled streaming for every + // provider this decorator wraps. + const receivedOptions: (ChatProviderRequestOptions | undefined)[] = []; + const provider: ChatProvider = { + id: 'option-recording-provider', + send: (_request: ChatProviderRequest, options?: ChatProviderRequestOptions) => { + receivedOptions.push(options); + return Promise.resolve({ content: 'ok' }); + }, + }; + const onChunk = vi.fn(); + + await withProviderTimeout(provider, 1000).send(EMPTY_REQUEST, { onChunk }); + + expect(receivedOptions[0]?.onChunk).toBe(onChunk); + expect(receivedOptions[0]?.signal).toBeInstanceOf(AbortSignal); + }); + it('never calls the global fetch function', async () => { if (typeof globalThis.fetch !== 'function') return; const fetchSpy = vi.spyOn(globalThis, 'fetch'); From 6fbc8795f7fadb7959a27b00ee1d1dab04fd358a Mon Sep 17 00:00:00 2001 From: Texas-walker15 Date: Mon, 7 Sep 2026 21:06:47 +0200 Subject: [PATCH 4/4] fix: make Electron renderer assets load correctly --- package-lock.json | 21 +++++++++++++++++---- package.json | 2 +- vite.config.ts | 1 + 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9205626..499bfdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "0.1.0", "license": "UNLICENSED", "dependencies": { - "electron": "44.1.1", "react": "19.2.8", "react-dom": "19.2.8", "zod": "4.4.3" @@ -20,6 +19,7 @@ "@types/react": "19.2.18", "@types/react-dom": "19.2.5", "@vitejs/plugin-react": "6.1.1", + "electron": "^44.1.1", "eslint": "10.8.0", "eslint-config-prettier": "10.1.8", "playwright": "1.62.1", @@ -37,6 +37,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=22.12.0" @@ -46,6 +47,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1", @@ -1101,6 +1103,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1135,6 +1138,7 @@ "version": "44.1.1", "resolved": "https://registry.npmjs.org/electron/-/electron-44.1.1.tgz", "integrity": "sha512-N2WCq2sbOkqQgvXJYx2lS6UiO8bF+Yr67trDnS6JKa2WxTCRQsAjGl57SUtdW9h6r5PlduBFjIhxhgd3dzv1hg==", + "dev": true, "license": "MIT", "dependencies": { "@electron-internal/extract-zip": "^1.0.1", @@ -1153,6 +1157,7 @@ "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -1162,12 +1167,14 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, "license": "MIT" }, "node_modules/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -1509,6 +1516,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/ignore": { @@ -1925,6 +1933,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -2170,6 +2179,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -2249,6 +2259,7 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2315,6 +2326,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "debug": "^4.1.0" @@ -2432,9 +2444,10 @@ } }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "dev": true, "license": "MIT", "optional": true, "engines": { diff --git a/package.json b/package.json index 1ea5c19..893432e 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,6 @@ "verify": "npm run typecheck && npm run lint && npm run format:check && npm test" }, "dependencies": { - "electron": "44.1.1", "react": "19.2.8", "react-dom": "19.2.8", "zod": "4.4.3" @@ -36,6 +35,7 @@ "@types/react": "19.2.18", "@types/react-dom": "19.2.5", "@vitejs/plugin-react": "6.1.1", + "electron": "^44.1.1", "eslint": "10.8.0", "eslint-config-prettier": "10.1.8", "playwright": "1.62.1", diff --git a/vite.config.ts b/vite.config.ts index de2ca2e..74b2327 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react'; export default defineConfig({ root: 'src/renderer', + base: './', plugins: [react()], build: { outDir: '../../dist/renderer',