From 2e2fff02176b4191646496b4e056baee28a91e3c Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Wed, 19 Aug 2026 04:04:05 -0400 Subject: [PATCH 1/3] fix(embedding): guard Windows Bun and support headers --- CONFIGURATION.md | 5 ++ README.md | 2 +- assets/magic-context.schema.json | 12 +++ .../cli/src/commands/doctor-opencode.test.ts | 33 +++++++++ packages/cli/src/commands/doctor-opencode.ts | 25 ++++++- packages/cli/src/commands/doctor-pi.test.ts | 73 +++++++++++++++++++ packages/cli/src/commands/doctor-pi.ts | 44 ++++++----- .../cli/src/lib/embedding-runtime.test.ts | 14 ++++ packages/cli/src/lib/embedding-runtime.ts | 11 +++ .../content/docs/reference/configuration.md | 5 ++ packages/pi-plugin/README.md | 2 + packages/plugin/scripts/build-config-docs.ts | 7 +- .../src/config/latch-permanence-guard.test.ts | 5 ++ .../src/config/project-security.test.ts | 6 +- .../plugin/src/config/project-security.ts | 11 ++- .../src/config/schema/magic-context.test.ts | 4 + .../plugin/src/config/schema/magic-context.ts | 10 +++ .../memory/embedding-identity.ts | 3 + .../memory/embedding-local.test.ts | 51 ++++++++++++- .../magic-context/memory/embedding-local.ts | 24 ++++++ .../memory/embedding-openai.test.ts | 43 +++++++++++ .../magic-context/memory/embedding-openai.ts | 11 ++- .../memory/embedding-probe.test.ts | 50 +++++++++++-- .../magic-context/memory/embedding-probe.ts | 31 ++++++-- .../magic-context/memory/embedding.ts | 3 + .../project-embedding-registry.test.ts | 41 +++++++++++ .../project-embedding-registry.ts | 57 ++++++++++++--- .../magic-context/format-embed-status.ts | 3 + .../src/plugin/embedding-routing.test.ts | 28 ++++++- .../plugin/src/plugin/embedding-routing.ts | 19 ++++- 30 files changed, 576 insertions(+), 57 deletions(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 111c84929..e45a762d9 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -554,6 +554,7 @@ Controls semantic search for cross-session memories. | `local_dtype` | `string` | — | Local provider only. ONNX model dtype passed to the transformers.js feature-extraction pipeline (`auto`, `fp32`, `fp16`, `q8`, `int8`, `uint8`, `q4`, `bnb4`, `q4f16`, `q2`, `q2f16`, `q1`, `q1f16`). Omitted keeps the default `fp32` behavior. | | `endpoint` | `string` | — | Required for `"openai-compatible"`. | | `api_key` | `string` | — | Optional API key for remote endpoints. | +| `headers` | `Record` | — | User-level only. Additional headers for `"openai-compatible"`; use `{env:VAR}` or `{file:path}` for secrets. A custom `Authorization` header takes precedence over `api_key`. Project config cannot set this field. | When `provider: "off"`: @@ -577,6 +578,10 @@ When `provider: "off"`: > **Not every provider offers embeddings.** OpenRouter and Anthropic's public API do not expose `/embeddings`; use OpenAI, Voyage, Together, LM Studio, or the bundled `"local"` provider instead. `doctor` will flag 404/405 responses and show the actual error. +> **Authentication is explicit.** Magic Context cannot reuse or delegate to the host's OpenCode/Pi/OMP model-provider credentials or OAuth session. An `"openai-compatible"` embedding backend therefore needs its own `endpoint` and, when required, `api_key` or user-level `headers`. Header values are treated as secrets: project config cannot supply them, and status/doctor diagnostics do not print them. + +> **Windows + Bun:** the in-process `"local"` provider is disabled only when the plugin host is Bun on Windows because `onnxruntime-node` can crash the entire host process before JavaScript can recover. Semantic embedding calls degrade to unavailable; FTS5 keyword search and context management continue. Configure `"openai-compatible"` for semantic search or `"off"` to make the fallback explicit. Windows under Node and Bun on macOS/Linux are not disabled by this guard. `doctor` reports this as a warning rather than a local-provider pass. + > **Local provider — `local_dtype` (issue #259):** The default `Xenova/all-MiniLM-L6-v2` model is lightweight but performs poorly when matching queries in one language (e.g. Chinese) to memories in another (e.g. English). A multilingual model such as `Xenova/paraphrase-multilingual-MiniLM-L12-v2` fixes the recall, but its full-precision (`fp32`) ONNX weights are large (~448 MiB) and memory-hungry for a coding-agent process that may run parallel subagents. Set `embedding.local_dtype` to a quantized variant (e.g. `"q8"`) to load a smaller ONNX model (~113 MiB) with comparable retrieval quality and far lower peak RSS. The dtype is passed to the transformers.js `feature-extraction` pipeline and, because it changes the produced vectors, a non-default value folds into the embedding model identity — so switching dtype re-embeds your corpus rather than mixing incompatible vector spaces. Omit the field to keep the default `fp32` behavior; existing installs see zero change on upgrade. --- diff --git a/README.md b/README.md index d1ee2cff9..40ce243e8 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ Then create `magic-context.jsonc` with the one setting the historian needs: - **Required:** `historian.model` must be a real `provider/model-id`. Without it, the plugin loads but historian runs fail, older history is not summarized, and repeated failures show a `Magic Context — history comparting needs attention` notice. - **Optional:** `dreamer` and `sidekick` model/disable blocks. Omit them to leave periodic memory consolidation and `/ctx-aug` off. -- **Optional:** `embedding`. Omit it to use the local `Xenova/all-MiniLM-L6-v2`; turning embeddings off removes semantic/embedding-backed search, but keyword search and context management continue. +- **Optional:** `embedding`. Omit it to use the local `Xenova/all-MiniLM-L6-v2`; turning embeddings off removes semantic/embedding-backed search, but keyword search and context management continue. The local provider is unavailable when the plugin host is Bun on Windows; use `openai-compatible` or `off` there. Remote embeddings require their own endpoint/auth configuration—host-provider OAuth and credentials are not delegated. User-level config is `~/.config/cortexkit/magic-context.jsonc` on macOS/Linux and `%USERPROFILE%\.config\cortexkit\magic-context.jsonc` on Windows (or `$XDG_CONFIG_HOME/cortexkit/magic-context.jsonc` when set). OpenCode Desktop users can use the dashboard's config editor or hand-edit that file; Desktop does not include the CLI setup wizard. diff --git a/assets/magic-context.schema.json b/assets/magic-context.schema.json index d9581cbbb..1ad202d50 100644 --- a/assets/magic-context.schema.json +++ b/assets/magic-context.schema.json @@ -1398,6 +1398,18 @@ "description": "API key for remote embedding provider (optional)", "type": "string" }, + "headers": { + "description": "USER-LEVEL ONLY custom HTTP headers for openai-compatible embedding requests. Use config variable substitution for secrets (for example Authorization: {env:EMBEDDING_AUTHORIZATION}). Custom Authorization takes precedence over api_key. Project config cannot set headers, and status/doctor diagnostics never print header values.", + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + } + }, "input_type": { "description": "Default input_type for stored/indexed (passage) embeddings in the request body. Required by some openai-compatible providers (e.g. NVIDIA NIM). Omitted from the request when unset.", "type": "string" diff --git a/packages/cli/src/commands/doctor-opencode.test.ts b/packages/cli/src/commands/doctor-opencode.test.ts index 484b4f8e6..bced687e6 100644 --- a/packages/cli/src/commands/doctor-opencode.test.ts +++ b/packages/cli/src/commands/doctor-opencode.test.ts @@ -19,6 +19,7 @@ import { runV22BackfillCommands } from "../lib/v22-backfill-commands"; import { checkUserMemoriesDreamerCompatibility, collectNpmReleaseAgeWarnings, + getOpenCodeLocalEmbeddingRuntimeDoctorWarning, getUserNpmrcPath, isPinnedOpenCodePluginSpecifier, migrateLegacyAgentEnabledConfigForDoctor, @@ -34,6 +35,38 @@ function migrate(input: Record) { return { config: input, logs, result }; } +describe("OpenCode doctor embedding runtime target", () => { + it("warns for a Windows OpenCode CLI even when doctor itself runs under Node", () => { + expect( + getOpenCodeLocalEmbeddingRuntimeDoctorWarning( + { + path: "C:\\Users\\test\\.opencode\\bin\\opencode.exe", + source: "home-bin", + kind: "cli", + version: "1.2.3", + active: true, + }, + "win32", + ), + ).toContain("Bun on Windows"); + }); + + it("does not classify OpenCode Desktop's Electron host as Bun", () => { + expect( + getOpenCodeLocalEmbeddingRuntimeDoctorWarning( + { + path: "C:\\Users\\test\\AppData\\Roaming\\ai.opencode.desktop", + source: "desktop", + kind: "desktop", + version: "unknown", + active: true, + }, + "win32", + ), + ).toBeNull(); + }); +}); + describe("doctor OpenCode legacy agent enabled migration", () => { it("migrates legacy enabled fields with conflict rules and warning text", () => { const { config, logs, result } = migrate({ diff --git a/packages/cli/src/commands/doctor-opencode.ts b/packages/cli/src/commands/doctor-opencode.ts index b5029f547..224863a9c 100644 --- a/packages/cli/src/commands/doctor-opencode.ts +++ b/packages/cli/src/commands/doctor-opencode.ts @@ -8,6 +8,7 @@ import { isCompactionEnabled } from "@magic-context/core/config/agent-disable"; import { substituteConfigVariables } from "@magic-context/core/config/variable"; import { type EmbeddingProbeOutcome, + parseEmbeddingHeaders, probeEmbeddingEndpoint, } from "@magic-context/core/features/magic-context/memory/embedding-probe"; import { getLiveMigrationBlockingProcesses } from "@magic-context/core/features/magic-context/storage-db"; @@ -34,6 +35,7 @@ import { collectDiagnostics } from "../lib/diagnostics-opencode"; import { checkLocalEmbeddingRuntime, formatLocalEmbeddingRuntimeDoctorWarning, + getLocalEmbeddingRuntimeDoctorWarning, isLocalEmbeddingRuntimeBroken, } from "../lib/embedding-runtime"; import { bundleIssueReport } from "../lib/logs-opencode"; @@ -406,11 +408,23 @@ async function runIssueFlow(): Promise { // resolver error in the log. Shared by the explicit-`local` branch AND the // no-config / default-provider path (local is the default, so a missing config // still means local embeddings). -function checkLocalEmbeddingRuntimeForDoctor(): { +export function getOpenCodeLocalEmbeddingRuntimeDoctorWarning( + installation: OpenCodeInstallationReport, + platform: NodeJS.Platform = process.platform, +): string | null { + return getLocalEmbeddingRuntimeDoctorWarning(platform, installation.kind === "cli"); +} + +function checkLocalEmbeddingRuntimeForDoctor(installation: OpenCodeInstallationReport): { issues: number; localRuntimeBroken?: boolean; unverified?: boolean; } { + const unavailableWarning = getOpenCodeLocalEmbeddingRuntimeDoctorWarning(installation); + if (unavailableWarning) { + log.warn(unavailableWarning); + return { issues: 0, unverified: true }; + } const runtime = checkLocalEmbeddingRuntime(getOpenCodePluginCacheRoots()); if (isLocalEmbeddingRuntimeBroken(runtime)) { log.warn(formatLocalEmbeddingRuntimeDoctorWarning(runtime)); @@ -426,12 +440,13 @@ function checkLocalEmbeddingRuntimeForDoctor(): { async function checkEmbeddingConfig( magicContextConfigPath: string, + installation: OpenCodeInstallationReport, ): Promise<{ issues: number; localRuntimeBroken?: boolean; unverified?: boolean }> { if (!existsSync(magicContextConfigPath)) { // No config → local provider defaults apply. Still verify the local // runtime: local is the DEFAULT, so "no config" means local embeddings, // and a broken onnxruntime-node would silently fail (#128/#6). - return checkLocalEmbeddingRuntimeForDoctor(); + return checkLocalEmbeddingRuntimeForDoctor(installation); } let rawText: string; @@ -469,7 +484,7 @@ async function checkEmbeddingConfig( } if (provider === undefined || provider === "local") { - return checkLocalEmbeddingRuntimeForDoctor(); + return checkLocalEmbeddingRuntimeForDoctor(installation); } if (provider !== "openai-compatible") { @@ -482,6 +497,7 @@ async function checkEmbeddingConfig( const endpoint = typeof embedding?.endpoint === "string" ? embedding.endpoint.trim() : ""; const model = typeof embedding?.model === "string" ? embedding.model.trim() : ""; const apiKey = typeof embedding?.api_key === "string" ? embedding.api_key : undefined; + const headers = parseEmbeddingHeaders(embedding?.headers); const inputType = typeof embedding?.input_type === "string" ? embedding.input_type.trim() : undefined; const truncateMode = @@ -539,6 +555,7 @@ async function checkEmbeddingConfig( endpoint, model, apiKey: apiKey, + ...(headers ? { headers } : {}), ...(inputType ? { inputType } : {}), ...(truncateMode ? { truncate: truncateMode } : {}), timeoutMs: 10_000, @@ -1251,7 +1268,7 @@ export async function runDoctor( // 7b. Validate embedding configuration — runs a real probe against the // configured endpoint so users catch misconfigured URL / missing env var / // wrong provider issues before relying on semantic memory search. - const embeddingCheck = await checkEmbeddingConfig(paths.magicContextConfig); + const embeddingCheck = await checkEmbeddingConfig(paths.magicContextConfig, activeInstallation); issues += embeddingCheck.issues; if (embeddingCheck.issues > 0) failCount += embeddingCheck.issues; else if (embeddingCheck.unverified) warnCount++; diff --git a/packages/cli/src/commands/doctor-pi.test.ts b/packages/cli/src/commands/doctor-pi.test.ts index 812b1c1a2..bb9f3baf4 100644 --- a/packages/cli/src/commands/doctor-pi.test.ts +++ b/packages/cli/src/commands/doctor-pi.test.ts @@ -3,11 +3,13 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { join } from "node:path"; +import type { EmbeddingProbeOptions } from "@magic-context/core/features/magic-context/memory/embedding-probe"; import { LATEST_SUPPORTED_VERSION } from "@magic-context/core/features/magic-context/storage-db"; import { Database } from "@magic-context/core/shared/sqlite"; import { parse as parseJsonc } from "comment-json"; import { openExistingContextDatabase } from "../lib/database-access"; import type { PiDiagnosticReport } from "../lib/diagnostics-pi"; +import { getLocalEmbeddingRuntimeDoctorWarning } from "../lib/embedding-runtime"; import type { PromptIO, PromptSpinner, SelectOption } from "../lib/prompts"; import { parseDoctorArgs, type RunDoctorOptions, runDoctor } from "./doctor-pi"; @@ -173,6 +175,7 @@ function baseOptions(root: string, cwd: string, prompts: MockPrompts): RunDoctor }), getPiVersion: () => "0.74.0", getLatestNpmVersion: () => "0.1.0", + getLocalEmbeddingRuntimeDoctorWarning: () => null, openExistingContextDatabase: () => createMockDb(), now: () => new Date("2026-04-28T12:34:56Z"), execFileSync: () => { @@ -348,6 +351,33 @@ describe("Pi doctor", () => { expect(output).toContain("PASS Embedding provider: local (native runtime present)"); }); + it("warns instead of passing local embeddings under Bun on Windows", async () => { + // Given + const root = makeTempRoot(); + const cwd = makeTempRoot("mc-pi-doctor-cwd-"); + const agentDir = setEnv(root, cwd); + writeHealthyFiles(agentDir, cwd); + createInstalledPiPlugin(agentDir, true); + const prompts = new MockPrompts(); + + // When + const options = baseOptions(root, cwd, prompts); + if (!options.deps) throw new Error("expected doctor dependencies"); + options.deps.getLocalEmbeddingRuntimeDoctorWarning = () => + getLocalEmbeddingRuntimeDoctorWarning("win32", true); + const code = await runDoctor(options); + + // Then + expect(code).toBe(0); + const output = prompts.messages.join("\n"); + expect(output).toContain( + "WARN Embedding provider: local is unavailable under Bun on Windows", + ); + expect(output).toContain("openai-compatible"); + expect(output).toContain("embedding.provider=off"); + expect(output).not.toContain("PASS Embedding provider: local (native runtime present)"); + }); + it("repairs missing package entry and missing user config in --force mode", async () => { const root = makeTempRoot(); const cwd = makeTempRoot("mc-pi-doctor-cwd-"); @@ -696,6 +726,49 @@ describe("Pi doctor", () => { expect(output).not.toContain("api_key=secret"); }); + it("passes header-only auth and custom Authorization precedence to the embedding probe", async () => { + const root = makeTempRoot(); + const cwd = makeTempRoot("mc-pi-doctor-cwd-"); + const agentDir = setEnv(root, cwd); + writeHealthyFiles(agentDir, cwd); + writeFileSync( + join(root, ".config", "cortexkit", "magic-context.jsonc"), + JSON.stringify({ + embedding: { + provider: "openai-compatible", + endpoint: "https://example.com/v1", + model: "text-embedding-3-small", + api_key: "fallback-key", + headers: { + Authorization: "Token custom-authorization", + "X-API-Key": "header-only-token", + }, + }, + }), + ); + const prompts = new MockPrompts(); + const options = baseOptions(root, cwd, prompts); + let probeOptions: EmbeddingProbeOptions | undefined; + + const code = await runDoctor({ + ...options, + deps: { + ...options.deps, + probeEmbeddingEndpoint: async (received) => { + probeOptions = received; + return { kind: "ok", status: 200, dimensions: 3 }; + }, + }, + }); + + expect(code).toBe(0); + expect(probeOptions?.headers).toEqual({ + Authorization: "Token custom-authorization", + "X-API-Key": "header-only-token", + }); + expect(probeOptions?.apiKey).toBe("fallback-key"); + }); + it("sanitizes thrown embedding probe errors before printing them", async () => { const root = makeTempRoot(); const cwd = makeTempRoot("mc-pi-doctor-cwd-"); diff --git a/packages/cli/src/commands/doctor-pi.ts b/packages/cli/src/commands/doctor-pi.ts index 51404106f..569b9643a 100644 --- a/packages/cli/src/commands/doctor-pi.ts +++ b/packages/cli/src/commands/doctor-pi.ts @@ -12,6 +12,7 @@ import { MagicContextConfigSchema } from "@magic-context/core/config/schema/magi import { substituteConfigVariables } from "@magic-context/core/config/variable"; import { type EmbeddingProbeOutcome, + parseEmbeddingHeaders, probeEmbeddingEndpoint, } from "@magic-context/core/features/magic-context/memory/embedding-probe"; import type { ContextDatabase } from "@magic-context/core/features/magic-context/storage"; @@ -39,6 +40,7 @@ import { collectDiagnostics } from "../lib/diagnostics-pi"; import { checkLocalEmbeddingRuntimeByResolution, formatLocalEmbeddingRuntimeDoctorWarning, + getLocalEmbeddingRuntimeDoctorWarning, isLocalEmbeddingRuntimeBroken, } from "../lib/embedding-runtime"; import { bundleIssueReport } from "../lib/logs-pi"; @@ -107,6 +109,7 @@ interface DoctorDeps { getLatestNpmVersion: () => string | null; selfVersion: () => string; probeEmbeddingEndpoint: typeof probeEmbeddingEndpoint; + getLocalEmbeddingRuntimeDoctorWarning: () => string | null; openExistingContextDatabase: typeof openExistingContextDatabase; now: () => Date; execFileSync: typeof execFileSync; @@ -130,6 +133,7 @@ const DEFAULT_DEPS: DoctorDeps = { getLatestNpmVersion: () => getLatestNpmVersion(PACKAGE_NAME), selfVersion, probeEmbeddingEndpoint, + getLocalEmbeddingRuntimeDoctorWarning: () => getLocalEmbeddingRuntimeDoctorWarning(), openExistingContextDatabase, now: () => new Date(), execFileSync, @@ -693,6 +697,7 @@ async function runHealthChecks(options: { const model = typeof mergedEmbedding.model === "string" ? mergedEmbedding.model.trim() : ""; const apiKey = typeof mergedEmbedding.api_key === "string" ? mergedEmbedding.api_key : undefined; + const headers = parseEmbeddingHeaders(mergedEmbedding.headers); const inputType = typeof mergedEmbedding.input_type === "string" ? mergedEmbedding.input_type.trim() @@ -713,6 +718,7 @@ async function runHealthChecks(options: { endpoint, model, apiKey, + ...(headers ? { headers } : {}), ...(inputType ? { inputType } : {}), ...(truncateMode ? { truncate: truncateMode } : {}), timeoutMs: 10_000, @@ -735,25 +741,29 @@ async function runHealthChecks(options: { // Windows it sometimes fails to install and the plugin's static import // throws on every embedding (#128). Layout-agnostic resolution from the // installed plugin dir; stays silent if no plugin dir can be inspected. - let runtimeReported = false; + const unavailableWarning = options.deps.getLocalEmbeddingRuntimeDoctorWarning(); + if (unavailableWarning) add(results, "warn", unavailableWarning); + let runtimeReported = unavailableWarning !== null; let runtimeUnverifiedReason = "no installed plugin tree found to inspect"; - for (const pluginDir of piPluginDirCandidates(packages, options.cwd)) { - const runtime = checkLocalEmbeddingRuntimeByResolution(pluginDir); - if (runtime.state === "ok") { - add( - results, - "pass", - `Embedding provider: ${loadedConfig.config.embedding.provider} (native runtime present)`, - ); - runtimeReported = true; - break; - } - if (isLocalEmbeddingRuntimeBroken(runtime)) { - add(results, "warn", formatLocalEmbeddingRuntimeDoctorWarning(runtime)); - runtimeReported = true; - break; + if (!runtimeReported) { + for (const pluginDir of piPluginDirCandidates(packages, options.cwd)) { + const runtime = checkLocalEmbeddingRuntimeByResolution(pluginDir); + if (runtime.state === "ok") { + add( + results, + "pass", + `Embedding provider: ${loadedConfig.config.embedding.provider} (native runtime present)`, + ); + runtimeReported = true; + break; + } + if (isLocalEmbeddingRuntimeBroken(runtime)) { + add(results, "warn", formatLocalEmbeddingRuntimeDoctorWarning(runtime)); + runtimeReported = true; + break; + } + if (runtime.state === "unknown") runtimeUnverifiedReason = runtime.reason; } - if (runtime.state === "unknown") runtimeUnverifiedReason = runtime.reason; } if (!runtimeReported) { add( diff --git a/packages/cli/src/lib/embedding-runtime.test.ts b/packages/cli/src/lib/embedding-runtime.test.ts index c84adc5d7..0679c870e 100644 --- a/packages/cli/src/lib/embedding-runtime.test.ts +++ b/packages/cli/src/lib/embedding-runtime.test.ts @@ -8,12 +8,26 @@ import { checkLocalEmbeddingRuntimeAt, checkLocalEmbeddingRuntimeByResolution, formatLocalEmbeddingRuntimeDoctorWarning, + getLocalEmbeddingRuntimeDoctorWarning, } from "./embedding-runtime"; afterEach(() => { __setEmbeddingRuntimeTestHooks({}); }); +describe("getLocalEmbeddingRuntimeDoctorWarning", () => { + test("warns that local embeddings are unavailable for Bun on Windows", () => { + expect(getLocalEmbeddingRuntimeDoctorWarning("win32", true)).toContain("openai-compatible"); + expect(getLocalEmbeddingRuntimeDoctorWarning("win32", true)).toContain( + "embedding.provider=off", + ); + }); + + test("does not disable local embeddings for Node on Windows", () => { + expect(getLocalEmbeddingRuntimeDoctorWarning("win32", false)).toBeNull(); + }); +}); + function makeRoot(): string { return mkdtempSync(join(tmpdir(), "mc-embruntime-")); } diff --git a/packages/cli/src/lib/embedding-runtime.ts b/packages/cli/src/lib/embedding-runtime.ts index 161d8b9f5..9e95273d7 100644 --- a/packages/cli/src/lib/embedding-runtime.ts +++ b/packages/cli/src/lib/embedding-runtime.ts @@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, join, sep } from "node:path"; +import { getLocalEmbeddingUnavailableReason } from "@magic-context/core/features/magic-context/memory/embedding-local"; /** * Detects whether the local-embedding native runtime (`onnxruntime-node`) is @@ -28,6 +29,16 @@ export type BrokenLocalEmbeddingRuntimeStatus = Extract< { state: "package-missing" | "binary-missing" | "load-failed" } >; +export function getLocalEmbeddingRuntimeDoctorWarning( + platform: NodeJS.Platform = process.platform, + bunHost: boolean = Boolean(process.versions.bun) || "Bun" in globalThis, +): string | null { + const reason = getLocalEmbeddingUnavailableReason(platform, bunHost); + return reason + ? `Embedding provider: local is unavailable under Bun on Windows — ${reason}. Configure embedding.provider=openai-compatible, or set embedding.provider=off to keep keyword search without semantic embeddings.` + : null; +} + function describeError(error: unknown): string { const message = error instanceof Error ? error.message : String(error ?? "unknown error"); const code = (error as { code?: unknown } | null)?.code; diff --git a/packages/docs/src/content/docs/reference/configuration.md b/packages/docs/src/content/docs/reference/configuration.md index daed71ffd..4350aadce 100644 --- a/packages/docs/src/content/docs/reference/configuration.md +++ b/packages/docs/src/content/docs/reference/configuration.md @@ -130,12 +130,17 @@ Durable project memory, semantic search, and recall features. | `embedding.model` | string | — | Embedding model name. Required for openai-compatible, ignored for local. | | `embedding.endpoint` | string | — | API endpoint URL. Required when provider is openai-compatible. | | `embedding.api_key` | string | — | API key for remote embedding provider (optional) | +| `embedding.headers` | map | — | USER-LEVEL ONLY custom HTTP headers for openai-compatible embedding requests. Use config variable substitution for secrets (for example Authorization: {env:EMBEDDING_AUTHORIZATION}). Custom Authorization takes precedence over api_key. Project config cannot set headers, and status/doctor diagnostics never print header values. | | `embedding.input_type` | string | — | Default input_type for stored/indexed (passage) embeddings in the request body. Required by some openai-compatible providers (e.g. NVIDIA NIM). Omitted from the request when unset. | | `embedding.query_input_type` | string | — | Optional input_type for query (search) embeddings on asymmetric models (e.g. NVIDIA NIM 'query'). When unset, query embeddings use embedding.input_type. Passage/stored content always uses embedding.input_type. | | `embedding.truncate` | string | — | Optional truncate mode sent in the embedding request body (e.g. NVIDIA NIM accepts 'NONE' \| 'START' \| 'END'). Omitted from the request when unset. | | `embedding.max_input_tokens` | integer (–9007199254740991) | — | Optional maximum input tokens for chunk embeddings. Defaults conservatively to 512 when omitted. | | `embedding.local_dtype` | `"auto"` \\| `"fp32"` \\| `"fp16"` \\| `"q8"` \\| `"int8"` \\| `"uint8"` \\| `"q4"` \\| `"bnb4"` \\| `"q4f16"` \\| `"q2"` \\| `"q2f16"` \\| `"q1"` \\| `"q1f16"` | — | Local provider only: ONNX model dtype passed to the transformers.js feature-extraction pipeline. Accepts the @huggingface/transformers DataType strings (auto, fp32, fp16, q8, int8, uint8, q4, bnb4, q4f16, q2, q2f16, q1, q1f16). Omitted keeps today's behavior (fp32). A non-default value changes the produced vectors and folds into the embedding model identity, so switching dtype re-embeds rather than mixing vector spaces. Useful for selecting a quantized variant (e.g. q8) of a larger multilingual model to cut memory and CPU cost; see issue #259. | +Magic Context does not inherit or delegate to OpenCode, Pi, or OMP model-provider OAuth sessions or credentials. Configure authentication for `openai-compatible` embeddings explicitly with `api_key` or user-level `headers`. + +When the plugin host is **Bun on Windows**, the in-process `local` provider is unavailable because `onnxruntime-node` can crash the host before JavaScript can recover. The guard is limited to that runtime combination: Windows under Node and Bun on macOS/Linux remain eligible. Keyword/FTS search and context management continue; use `openai-compatible` for semantic search or `off` to make keyword-only operation explicit. `doctor` reports a warning with both remedies and never reports the bundled local provider as passing in this mode. + ## Background agents Off-hours maintenance (Dreamer) and on-demand prompt augmentation (Sidekick). diff --git a/packages/pi-plugin/README.md b/packages/pi-plugin/README.md index 791089eeb..4c3eb6949 100644 --- a/packages/pi-plugin/README.md +++ b/packages/pi-plugin/README.md @@ -104,6 +104,8 @@ Session discovery follows the active host. Relative `pi.subagent_extensions` are For the full configuration reference (including dreamer, sidekick, auto-search, and experimental features), see [CONFIGURATION.md](https://github.com/cortexkit/magic-context/blob/master/CONFIGURATION.md) in the main repository — OpenCode, Pi, and OMP share the same schema. +The in-process `local` embedding provider is unavailable when the extension host is Bun on Windows because its native ONNX runtime can crash the host. Keyword search remains available; configure `openai-compatible` or `off`. Remote embedding authentication is explicit: the extension cannot reuse the host's model-provider OAuth session or credentials, so provide an embedding-specific `api_key` or user-level `headers`. + --- ## Slash commands diff --git a/packages/plugin/scripts/build-config-docs.ts b/packages/plugin/scripts/build-config-docs.ts index ee7bd144f..424158fd8 100644 --- a/packages/plugin/scripts/build-config-docs.ts +++ b/packages/plugin/scripts/build-config-docs.ts @@ -103,7 +103,7 @@ function collectLeaves(schema: JsonSchema, prefix: string, rows: LeafRow[]): voi } } -const SECTION_ORDER: Array<{ keys: string[]; title: string; intro: string }> = [ +const SECTION_ORDER: Array<{ keys: string[]; title: string; intro: string; notes?: string }> = [ { keys: [ "enabled", @@ -144,6 +144,7 @@ const SECTION_ORDER: Array<{ keys: string[]; title: string; intro: string }> = [ keys: ["memory", "embedding"], title: "Memory & recall", intro: "Durable project memory, semantic search, and recall features.", + notes: "Magic Context does not inherit or delegate to OpenCode, Pi, or OMP model-provider OAuth sessions or credentials. Configure authentication for `openai-compatible` embeddings explicitly with `api_key` or user-level `headers`.\n\nWhen the plugin host is **Bun on Windows**, the in-process `local` provider is unavailable because `onnxruntime-node` can crash the host before JavaScript can recover. The guard is limited to that runtime combination: Windows under Node and Bun on macOS/Linux remain eligible. Keyword/FTS search and context management continue; use `openai-compatible` for semantic search or `off` to make keyword-only operation explicit. `doctor` reports a warning with both remedies and never reports the bundled local provider as passing in this mode.", }, { keys: ["dreamer", "sidekick"], @@ -219,7 +220,9 @@ export function buildConfigDocs(): string { } } if (rows.length > 0) { - sections.push(`## ${section.title}\n\n${section.intro}\n\n${renderTable(rows)}`); + sections.push( + `## ${section.title}\n\n${section.intro}\n\n${renderTable(rows)}${section.notes ? `\n\n${section.notes}` : ""}`, + ); } } diff --git a/packages/plugin/src/config/latch-permanence-guard.test.ts b/packages/plugin/src/config/latch-permanence-guard.test.ts index 78c153069..a343496c7 100644 --- a/packages/plugin/src/config/latch-permanence-guard.test.ts +++ b/packages/plugin/src/config/latch-permanence-guard.test.ts @@ -27,6 +27,11 @@ const KNOWN_SLOTS: Record = { classification: "VERDICT", reason: "Correct: a missing or unloadable native binding needs an install repair, which this process cannot observe.", }, + "packages/plugin/src/features/magic-context/memory/embedding-local.ts:windowsBunDisabledLogged": + { + classification: "DIAGNOSTIC", + reason: "Correct by scope: Bun and the operating system cannot change while the process runs; the slot only suppresses duplicate warnings.", + }, "packages/plugin/src/features/magic-context/memory/embedding-synapse.ts:sharedClientPromise": { classification: "VERDICT", reason: "DEFECT: a rejected connection promise remains shared after the daemon recovers.", diff --git a/packages/plugin/src/config/project-security.test.ts b/packages/plugin/src/config/project-security.test.ts index 9b64e2115..d7d82d08a 100644 --- a/packages/plugin/src/config/project-security.test.ts +++ b/packages/plugin/src/config/project-security.test.ts @@ -143,6 +143,9 @@ describe("stripUnsafeProjectConfigFields", () => { endpoint: "https://evil.example/v1", model: "text-embedding-3-small", query_input_type: "query", + headers: { + Authorization: "Bearer repo-controlled-token", + }, }, }; @@ -153,7 +156,8 @@ describe("stripUnsafeProjectConfigFields", () => { expect(embedding.endpoint).toBeUndefined(); expect(embedding.model).toBe("text-embedding-3-small"); expect(embedding.query_input_type).toBe("query"); - expect(warnings.some((w) => w.includes("embedding.endpoint/provider"))).toBe(true); + expect(embedding.headers).toBeUndefined(); + expect(warnings.some((w) => w.includes("embedding.endpoint/provider/headers"))).toBe(true); }); it("strips historian model selection from project config but keeps safe tuning fields", () => { diff --git a/packages/plugin/src/config/project-security.ts b/packages/plugin/src/config/project-security.ts index 5fea6fb2b..15a3f99ca 100644 --- a/packages/plugin/src/config/project-security.ts +++ b/packages/plugin/src/config/project-security.ts @@ -42,7 +42,12 @@ const PROMPT_SURFACE_USER_ONLY_FIELDS = ["guidance_override_path", "tool_descrip * historian spend on the user's dime. */ const AGENT_ESCALATION_FIELDS = ["prompt", "permission", "tools", "system_prompt"] as const; -const EMBEDDING_DESTINATION_FIELDS = ["endpoint", "provider", "fallback_provider"] as const; +const EMBEDDING_DESTINATION_FIELDS = [ + "endpoint", + "provider", + "fallback_provider", + "headers", +] as const; const PERCENTAGE_THRESHOLD_REASON = "security: a repository may only raise compaction thresholds above the user's effective value; it cannot force earlier historian work or cloned-repo cost escalation."; const TOKEN_THRESHOLD_REASON = @@ -207,9 +212,9 @@ function makeProjectThresholdWarning(field: string, reason: string): string { * owner-private to group-readable changes every session and memory's local * confidentiality. Only the machine operator's user config may opt into an * externally managed trusted-group deployment. - * - `embedding.endpoint` / `embedding.provider` — a repo must not choose + * - `embedding.endpoint` / `embedding.provider` / `embedding.headers` — a repo must not choose * where private memory/search/commit text is embedded. User-level config is - * the trust boundary for embedding destinations. + * the trust boundary for embedding destinations and authentication headers. * - `transform_mode` is intentionally allowed at project tier so a repository * can opt its own runtime into the experimental Rust pipeline. The resolver * requires trusted user-level `subc` configuration before Rust can activate. diff --git a/packages/plugin/src/config/schema/magic-context.test.ts b/packages/plugin/src/config/schema/magic-context.test.ts index 651d51f24..9a6a934e1 100644 --- a/packages/plugin/src/config/schema/magic-context.test.ts +++ b/packages/plugin/src/config/schema/magic-context.test.ts @@ -122,6 +122,10 @@ describe("MagicContextConfigSchema", () => { endpoint: "http://localhost:1234/v1", model: "text-embedding-3-small", api_key: "secret-embedding", + headers: { + Authorization: "Bearer custom-token", + "X-Embedding-Tenant": "tenant-a", + }, }, memory: { enabled: true, diff --git a/packages/plugin/src/config/schema/magic-context.ts b/packages/plugin/src/config/schema/magic-context.ts index 11ed67f29..93369e864 100644 --- a/packages/plugin/src/config/schema/magic-context.ts +++ b/packages/plugin/src/config/schema/magic-context.ts @@ -315,6 +315,12 @@ const BaseEmbeddingConfigSchema = z .optional() .describe("API endpoint URL. Required when provider is openai-compatible."), api_key: z.string().optional().describe("API key for remote embedding provider (optional)"), + headers: z + .record(z.string().trim().min(1), z.string().min(1)) + .optional() + .describe( + "USER-LEVEL ONLY custom HTTP headers for openai-compatible embedding requests. Use config variable substitution for secrets (for example Authorization: {env:EMBEDDING_AUTHORIZATION}). Custom Authorization takes precedence over api_key. Project config cannot set headers, and status/doctor diagnostics never print header values.", + ), input_type: z .string() .optional() @@ -387,6 +393,7 @@ export const EmbeddingConfigSchema = BaseEmbeddingConfigSchema.transform((data) const model = data.model?.trim(); const endpoint = data.endpoint?.trim(); const apiKey = data.api_key?.trim(); + const headers = data.headers; const inputType = data.input_type?.trim(); const queryInputType = data.query_input_type?.trim(); const truncate = data.truncate?.trim(); @@ -396,6 +403,7 @@ export const EmbeddingConfigSchema = BaseEmbeddingConfigSchema.transform((data) ...(model ? { model } : {}), ...(endpoint ? { endpoint } : {}), ...(apiKey ? { api_key: apiKey } : {}), + ...(headers ? { headers } : {}), ...(inputType ? { input_type: inputType } : {}), ...(queryInputType ? { query_input_type: queryInputType } : {}), ...(truncate ? { truncate } : {}), @@ -420,6 +428,7 @@ export const EmbeddingConfigSchema = BaseEmbeddingConfigSchema.transform((data) if (data.provider === "openai-compatible") { const apiKey = data.api_key?.trim(); + const headers = data.headers; const inputType = data.input_type?.trim(); const queryInputType = data.query_input_type?.trim(); const truncate = data.truncate?.trim(); @@ -428,6 +437,7 @@ export const EmbeddingConfigSchema = BaseEmbeddingConfigSchema.transform((data) model: data.model?.trim() ?? "", endpoint: data.endpoint?.trim() ?? "", ...(apiKey ? { api_key: apiKey } : {}), + ...(headers ? { headers } : {}), ...(inputType ? { input_type: inputType } : {}), ...(queryInputType ? { query_input_type: queryInputType } : {}), ...(truncate ? { truncate } : {}), diff --git a/packages/plugin/src/features/magic-context/memory/embedding-identity.ts b/packages/plugin/src/features/magic-context/memory/embedding-identity.ts index c05eee15d..7d9449593 100644 --- a/packages/plugin/src/features/magic-context/memory/embedding-identity.ts +++ b/packages/plugin/src/features/magic-context/memory/embedding-identity.ts @@ -52,6 +52,9 @@ export function getEmbeddingProviderIdentity(config: EmbeddingConfig): string { model: config.model.trim(), endpoint: normalizeEndpoint(config.endpoint), apiKeyPresent: Boolean(config.api_key?.trim()), + ...(Object.keys(config.headers ?? {}).length > 0 + ? { customHeadersPresent: true } + : {}), // input_type changes the embedding vector space (e.g. NIM // 'query' vs 'passage'), so it participates in identity — a // change must re-embed. truncate changes which text an over-long diff --git a/packages/plugin/src/features/magic-context/memory/embedding-local.test.ts b/packages/plugin/src/features/magic-context/memory/embedding-local.test.ts index 58f41d7ba..e40855e2d 100644 --- a/packages/plugin/src/features/magic-context/memory/embedding-local.test.ts +++ b/packages/plugin/src/features/magic-context/memory/embedding-local.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, mock, test } from "bun:test"; import { getEmbeddingProviderIdentity } from "./embedding-identity"; import { isNativeRuntimeMissingError, @@ -6,6 +6,21 @@ import { LocalEmbeddingProvider, } from "./embedding-local"; +const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); +const originalBunVersionDescriptor = Object.getOwnPropertyDescriptor(process.versions, "bun"); + +afterEach(() => { + if (originalPlatformDescriptor) { + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } + if (originalBunVersionDescriptor) { + Object.defineProperty(process.versions, "bun", originalBunVersionDescriptor); + } else { + delete process.versions.bun; + } + mock.restore(); +}); + // Part A of issue #128: classify the PERMANENT "native runtime not installed" // failure so the provider degrades once (one actionable log line) instead of // re-importing transformers and re-spamming the cryptic resolver error on every @@ -145,3 +160,37 @@ describe("LocalEmbeddingProvider dtype threading (#259)", () => { expect(q8.modelId).not.toBe(int8.modelId); }); }); + +describe("LocalEmbeddingProvider Windows safety", () => { + test("never enters transformers inference under Bun on Windows", async () => { + // Given + let inferenceEntries = 0; + mock.module("@huggingface/transformers", () => ({ + env: {}, + LogLevel: { ERROR: "error" }, + pipeline: () => { + inferenceEntries += 1; + throw new Error("unsafe local inference entered"); + }, + })); + Object.defineProperty(process, "platform", { + ...originalPlatformDescriptor, + value: "win32", + }); + Object.defineProperty(process.versions, "bun", { + configurable: true, + enumerable: true, + value: undefined, + writable: true, + }); + const provider = new LocalEmbeddingProvider(); + + // When + const initialized = await provider.initialize(); + + // Then + expect(initialized).toBe(false); + expect(inferenceEntries).toBe(0); + expect(provider.isLoaded()).toBe(false); + }); +}); diff --git a/packages/plugin/src/features/magic-context/memory/embedding-local.ts b/packages/plugin/src/features/magic-context/memory/embedding-local.ts index 91229596c..c71593e62 100644 --- a/packages/plugin/src/features/magic-context/memory/embedding-local.ts +++ b/packages/plugin/src/features/magic-context/memory/embedding-local.ts @@ -305,6 +305,16 @@ async function withQuietConsole(fn: () => Promise): Promise { // re-importing transformers and re-failing. Process-global (not per-instance) // because the missing package affects the whole install, not one model. let nativeRuntimeMissing = false; +let windowsBunDisabledLogged = false; + +export function getLocalEmbeddingUnavailableReason( + platform: NodeJS.Platform = process.platform, + bunHost: boolean = Boolean(process.versions.bun) || "Bun" in globalThis, +): string | null { + return platform === "win32" && bunHost + ? "local embeddings are unavailable under Bun on Windows because onnxruntime-node can crash the host process" + : null; +} export function isNativeRuntimeMissingError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error ?? ""); @@ -443,6 +453,20 @@ export class LocalEmbeddingProvider implements EmbeddingProvider { return false; } + // Bun on Windows can segfault the whole host process inside + // onnxruntime-node, which JavaScript cannot catch or recover from. Refuse + // local inference before importing transformers; remote providers and + // keyword/full-text search remain available. + if (getLocalEmbeddingUnavailableReason()) { + if (!windowsBunDisabledLogged) { + windowsBunDisabledLogged = true; + log( + "[magic-context] local embeddings are disabled under Bun on Windows to avoid an onnxruntime-node process crash; configure embedding.provider=openai-compatible or off", + ); + } + return false; + } + if (this.pipeline) { return true; } diff --git a/packages/plugin/src/features/magic-context/memory/embedding-openai.test.ts b/packages/plugin/src/features/magic-context/memory/embedding-openai.test.ts index 159e69dea..52784a10f 100644 --- a/packages/plugin/src/features/magic-context/memory/embedding-openai.test.ts +++ b/packages/plugin/src/features/magic-context/memory/embedding-openai.test.ts @@ -178,6 +178,49 @@ describe("OpenAICompatibleEmbeddingProvider request body (NVIDIA NIM fields, iss expect(body.input).toBeDefined(); }); + test("sends trusted custom headers and lets custom authorization replace api_key", async () => { + // Given + const provider = new OpenAICompatibleEmbeddingProvider({ + endpoint: "http://127.0.0.1:65535", + model: "text-embedding-3-small", + apiKey: "legacy-key", + headers: { + Authorization: "Bearer custom-token", + "X-Embedding-Tenant": "tenant-a", + }, + }); + fetchSpy.mockImplementation((async () => successResponse()) as FetchLike); + + // When + await provider.embed("hello"); + + // Then + const init = fetchSpy.mock.calls[0]?.[1]; + const headers = new Headers(init?.headers); + expect(headers.get("authorization")).toBe("Bearer custom-token"); + expect(headers.get("x-embedding-tenant")).toBe("tenant-a"); + expect(headers.get("content-type")).toBe("application/json"); + }); + + test("supports header-only authentication without api_key", async () => { + // Given + const provider = new OpenAICompatibleEmbeddingProvider({ + endpoint: "http://127.0.0.1:65535", + model: "text-embedding-3-small", + headers: { "X-API-Key": "header-only-token" }, + }); + fetchSpy.mockImplementation((async () => successResponse()) as FetchLike); + + // When + await provider.embed("hello"); + + // Then + const init = fetchSpy.mock.calls[0]?.[1]; + const headers = new Headers(init?.headers); + expect(headers.get("x-api-key")).toBe("header-only-token"); + expect(headers.get("authorization")).toBeNull(); + }); + test("coerces empty / whitespace-only input to a space so the provider can't 400 the batch", async () => { const provider = new OpenAICompatibleEmbeddingProvider({ endpoint: "http://127.0.0.1:65535", diff --git a/packages/plugin/src/features/magic-context/memory/embedding-openai.ts b/packages/plugin/src/features/magic-context/memory/embedding-openai.ts index f713cefd4..031f0ea51 100644 --- a/packages/plugin/src/features/magic-context/memory/embedding-openai.ts +++ b/packages/plugin/src/features/magic-context/memory/embedding-openai.ts @@ -1,5 +1,6 @@ import { log } from "../../../shared/logger"; import { getEmbeddingProviderIdentity } from "./embedding-identity"; +import { buildEmbeddingRequestHeaders } from "./embedding-probe"; import type { EmbeddingProvider, EmbeddingPurpose } from "./embedding-provider"; import { blockedEmbeddingEndpointReason } from "./embedding-ssrf"; @@ -7,6 +8,7 @@ interface OpenAICompatibleEmbeddingProviderOptions { endpoint?: string; model?: string; apiKey?: string; + headers?: Readonly>; /** Default/passage `input_type` body field (e.g. NVIDIA NIM 'passage'). */ inputType?: string; /** Optional query `input_type` for search embeddings; falls back to inputType when unset. */ @@ -137,6 +139,7 @@ export class OpenAICompatibleEmbeddingProvider implements EmbeddingProvider { private readonly endpoint: string; private readonly model: string; private readonly apiKey: string; + private readonly headers: Readonly>; private readonly inputType: string; private readonly queryInputType: string; private readonly truncate: string; @@ -160,6 +163,7 @@ export class OpenAICompatibleEmbeddingProvider implements EmbeddingProvider { this.endpoint = normalizeEndpoint(options.endpoint); this.model = options.model?.trim() ?? ""; this.apiKey = options.apiKey?.trim() ?? ""; + this.headers = { ...options.headers }; this.inputType = options.inputType?.trim() ?? ""; this.queryInputType = options.queryInputType?.trim() ?? ""; this.truncate = options.truncate?.trim() ?? ""; @@ -172,6 +176,7 @@ export class OpenAICompatibleEmbeddingProvider implements EmbeddingProvider { endpoint: this.endpoint, model: this.model, ...(this.apiKey ? { api_key: this.apiKey } : {}), + ...(Object.keys(this.headers).length > 0 ? { headers: this.headers } : {}), ...(this.inputType ? { input_type: this.inputType } : {}), // truncate participates in identity (it changes which text an // over-long input embeds). MUST mirror getEmbeddingProviderIdentity @@ -282,12 +287,10 @@ export class OpenAICompatibleEmbeddingProvider implements EmbeddingProvider { } const inputTypeForRequest = this.resolveInputTypeForPurpose(purpose); + const headers = buildEmbeddingRequestHeaders(this.headers, this.apiKey); const response = await fetch(`${this.endpoint}/embeddings`, { method: "POST", - headers: { - "content-type": "application/json", - ...(this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {}), - }, + headers, body: JSON.stringify({ model: this.model, input: requestTexts, diff --git a/packages/plugin/src/features/magic-context/memory/embedding-probe.test.ts b/packages/plugin/src/features/magic-context/memory/embedding-probe.test.ts index 440762b4b..ebf941892 100644 --- a/packages/plugin/src/features/magic-context/memory/embedding-probe.test.ts +++ b/packages/plugin/src/features/magic-context/memory/embedding-probe.test.ts @@ -271,8 +271,46 @@ describe("probeEmbeddingEndpoint", () => { fetch, }); - const headers = capture.init?.headers as Record | undefined; - expect(headers?.authorization).toBe("Bearer sk-real"); + const headers = new Headers(capture.init?.headers); + expect(headers.get("authorization")).toBe("Bearer sk-real"); + }); + + it("sends custom headers without an apiKey", async () => { + const capture: FetchCapture = {}; + const fetch = mockFetch( + new Response(JSON.stringify({ data: [{ embedding: [0.1] }] }), { status: 200 }), + capture, + ); + + await probeEmbeddingEndpoint({ + endpoint: "https://api.example.com/v1", + model: "m", + headers: { "X-API-Key": "header-only-token" }, + fetch, + }); + + const headers = new Headers(capture.init?.headers); + expect(headers.get("x-api-key")).toBe("header-only-token"); + expect(headers.get("authorization")).toBeNull(); + }); + + it("lets custom Authorization override apiKey", async () => { + const capture: FetchCapture = {}; + const fetch = mockFetch( + new Response(JSON.stringify({ data: [{ embedding: [0.1] }] }), { status: 200 }), + capture, + ); + + await probeEmbeddingEndpoint({ + endpoint: "https://api.example.com/v1", + model: "m", + apiKey: "fallback-key", + headers: { Authorization: "Token custom-authorization" }, + fetch, + }); + + const headers = new Headers(capture.init?.headers); + expect(headers.get("authorization")).toBe("Token custom-authorization"); }); it("omits authorization header when no apiKey", async () => { @@ -288,8 +326,8 @@ describe("probeEmbeddingEndpoint", () => { fetch, }); - const headers = capture.init?.headers as Record | undefined; - expect(headers?.authorization).toBeUndefined(); + const headers = new Headers(capture.init?.headers); + expect(headers.get("authorization")).toBeNull(); }); it("does not send empty apiKey as a header", async () => { @@ -306,8 +344,8 @@ describe("probeEmbeddingEndpoint", () => { fetch, }); - const headers = capture.init?.headers as Record | undefined; - expect(headers?.authorization).toBeUndefined(); + const headers = new Headers(capture.init?.headers); + expect(headers.get("authorization")).toBeNull(); }); it("truncates very long error body previews", async () => { diff --git a/packages/plugin/src/features/magic-context/memory/embedding-probe.ts b/packages/plugin/src/features/magic-context/memory/embedding-probe.ts index b94529f9f..77f187894 100644 --- a/packages/plugin/src/features/magic-context/memory/embedding-probe.ts +++ b/packages/plugin/src/features/magic-context/memory/embedding-probe.ts @@ -27,6 +27,7 @@ export interface EmbeddingProbeOptions { endpoint: string; model: string; apiKey?: string; + headers?: Readonly>; /** Optional `input_type` body field — required by some providers (NVIDIA NIM) * for the probe to succeed. Omitted from the body when unset. */ inputType?: string; @@ -45,6 +46,30 @@ export interface EmbeddingProbeOptions { const DEFAULT_TIMEOUT_MS = 10_000; const MAX_PREVIEW_CHARS = 240; +export function buildEmbeddingRequestHeaders( + customHeaders?: Readonly>, + apiKey?: string, +): Headers { + const headers = new Headers(customHeaders); + headers.set("content-type", "application/json"); + const normalizedApiKey = apiKey?.trim(); + if (normalizedApiKey && !headers.has("authorization")) { + headers.set("authorization", `Bearer ${normalizedApiKey}`); + } + return headers; +} + +export function parseEmbeddingHeaders( + value: unknown, +): Readonly> | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const entries = Object.entries(value); + if (!entries.every((entry): entry is [string, string] => typeof entry[1] === "string")) { + return undefined; + } + return Object.fromEntries(entries); +} + /** * Probe an embeddings endpoint and classify the outcome. * @@ -73,11 +98,7 @@ export async function probeEmbeddingEndpoint( const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; const url = `${endpoint}/embeddings`; - const apiKey = options.apiKey?.trim(); - const headers: Record = { "content-type": "application/json" }; - if (apiKey) { - headers.authorization = `Bearer ${apiKey}`; - } + const headers = buildEmbeddingRequestHeaders(options.headers, options.apiKey); // Use a short fixed probe string. Providers bill by tokens, so minimal // input keeps the check cheap even on metered accounts. diff --git a/packages/plugin/src/features/magic-context/memory/embedding.ts b/packages/plugin/src/features/magic-context/memory/embedding.ts index 1bdc8bf67..628813761 100644 --- a/packages/plugin/src/features/magic-context/memory/embedding.ts +++ b/packages/plugin/src/features/magic-context/memory/embedding.ts @@ -67,6 +67,7 @@ function resolveEmbeddingConfig(config?: EmbeddingConfig): EmbeddingConfig { if (config.provider === "openai-compatible") { const apiKey = config.api_key?.trim(); + const headers = config.headers; const inputType = config.input_type?.trim(); const queryInputType = config.query_input_type?.trim(); const truncate = config.truncate?.trim(); @@ -75,6 +76,7 @@ function resolveEmbeddingConfig(config?: EmbeddingConfig): EmbeddingConfig { model: config.model.trim(), endpoint: config.endpoint.trim(), ...(apiKey ? { api_key: apiKey } : {}), + ...(headers ? { headers } : {}), ...(inputType ? { input_type: inputType } : {}), ...(queryInputType ? { query_input_type: queryInputType } : {}), ...(truncate ? { truncate } : {}), @@ -113,6 +115,7 @@ function createProvider(config: EmbeddingConfig): EmbeddingProvider | null { endpoint: config.endpoint, model: config.model, apiKey: config.api_key, + headers: config.headers, inputType: config.input_type, queryInputType: config.query_input_type, truncate: config.truncate, diff --git a/packages/plugin/src/features/magic-context/project-embedding-registry.test.ts b/packages/plugin/src/features/magic-context/project-embedding-registry.test.ts index 6f17a3dde..37455697e 100644 --- a/packages/plugin/src/features/magic-context/project-embedding-registry.test.ts +++ b/packages/plugin/src/features/magic-context/project-embedding-registry.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { EmbeddingConfig } from "../../config/schema/magic-context"; +import { formatEmbedStatusText } from "../../hooks/magic-context/format-embed-status"; import { chunkCanonicalText, loadCompartmentChunkEmbeddingsForSearch, @@ -26,6 +27,7 @@ import { } from "./memory/storage-memory-embeddings"; import { _resetProjectEmbeddingRegistryForTests, + _setTestLocalEmbeddingUnavailableReasonForProject, _setTestProviderFactoryForProject, drainCommitBacklogForProject, embedSessionCompartmentChunks, @@ -33,6 +35,7 @@ import { embedUnembeddedCompartmentChunksForProject, embedUnembeddedMemoriesForProject, flushShadowEmbeddingBacklog, + getEmbeddingCoverageStatus, getProjectEmbeddingSnapshot, getShadowBackfillStopReason, markProjectLoadUntrusted, @@ -210,6 +213,7 @@ describe("project embedding registry", () => { const dir = mkdtempSync(join(tmpdir(), "project-embedding-registry-")); tempDirs.push(dir); process.env.XDG_DATA_HOME = dir; + _setTestLocalEmbeddingUnavailableReasonForProject(() => null); return openDatabase(); } @@ -227,6 +231,43 @@ describe("project embedding registry", () => { tempDirs.length = 0; }); + it("marks local embeddings unavailable without creating a provider under Bun on Windows", async () => { + // Given + const db = useTempDb(); + let providerCreations = 0; + _setTestProviderFactoryForProject(() => { + providerCreations += 1; + return new FakeEmbeddingProvider("must-not-load"); + }); + _setTestLocalEmbeddingUnavailableReasonForProject( + () => + "local embeddings are unavailable under Bun on Windows because onnxruntime-node can crash the host process", + ); + + // When + const snapshot = registerProjectEmbedding( + db, + "windows-bun-local", + localConfig("Xenova/all-MiniLM-L6-v2"), + { memoryEnabled: true, gitCommitEnabled: true }, + "/repo", + ); + const embedded = await embedTextForProject("windows-bun-local", "query"); + const coverage = getEmbeddingCoverageStatus(db, "windows-bun-local", "session-1"); + const statusText = formatEmbedStatusText(coverage, { status: "idle" }); + + // Then + expect(snapshot.enabled).toBe(false); + expect(snapshot.provider).toBe("local"); + expect(snapshot.model).toBe("Xenova/all-MiniLM-L6-v2"); + expect(snapshot.unavailableReason).toContain("Bun on Windows"); + expect(coverage.unavailableReason).toBe(snapshot.unavailableReason); + expect(statusText).toContain("Embedding unavailable"); + expect(statusText).toContain("openai-compatible or off"); + expect(embedded).toBeNull(); + expect(providerCreations).toBe(0); + }); + it("preserves existing provider and runtime identity goldens", () => { const db = useTempDb(); const features = { memoryEnabled: true, gitCommitEnabled: true }; diff --git a/packages/plugin/src/features/magic-context/project-embedding-registry.ts b/packages/plugin/src/features/magic-context/project-embedding-registry.ts index 0e4bea5d8..b0787021a 100644 --- a/packages/plugin/src/features/magic-context/project-embedding-registry.ts +++ b/packages/plugin/src/features/magic-context/project-embedding-registry.ts @@ -31,7 +31,10 @@ import { } from "./git-commits/sweep-coordinator"; import { invalidateProject } from "./memory/embedding-cache"; import { getEmbeddingProviderIdentity } from "./memory/embedding-identity"; -import { LocalEmbeddingProvider } from "./memory/embedding-local"; +import { + getLocalEmbeddingUnavailableReason, + LocalEmbeddingProvider, +} from "./memory/embedding-local"; import { OpenAICompatibleEmbeddingProvider } from "./memory/embedding-openai"; import type { EmbeddingProvider, EmbeddingPurpose } from "./memory/embedding-provider"; import { @@ -124,6 +127,7 @@ export interface ProjectEmbeddingRegistrationSnapshot { model: string; /** Configured provider kind (e.g. "openai-compatible", "local", "ollama"). */ provider: string; + unavailableReason?: string; } interface ProjectEmbeddingRegistration { @@ -138,6 +142,7 @@ interface ProjectEmbeddingRegistration { modelId: string; chunkModelId: string; observationMode: boolean; + unavailableReason: string | null; } interface UnembeddedMemoryRow { @@ -237,6 +242,7 @@ export function markProjectLoadUntrusted(projectIdentity: string): void { } let projectSweepInProgress = false; let testProviderFactory: ((config: EmbeddingConfig) => EmbeddingProvider | null) | null = null; +let localEmbeddingUnavailableReasonForRuntime = getLocalEmbeddingUnavailableReason; function synapseConfigFields(config: EmbeddingConfig): { model?: string; @@ -394,6 +400,7 @@ function resolveEmbeddingConfig(config?: EmbeddingConfig): EmbeddingConfig { if (config.provider === "openai-compatible") { const apiKey = config.api_key?.trim(); + const headers = config.headers; const inputType = config.input_type?.trim(); const queryInputType = config.query_input_type?.trim(); const truncate = config.truncate?.trim(); @@ -402,6 +409,7 @@ function resolveEmbeddingConfig(config?: EmbeddingConfig): EmbeddingConfig { model: config.model.trim(), endpoint: config.endpoint.trim(), ...(apiKey ? { api_key: apiKey } : {}), + ...(headers ? { headers } : {}), // Preserve provider-specific request fields (NVIDIA NIM input_type; // truncate). They must survive normalization so (a) they reach the // provider request body and (b) a change to either is part of the @@ -480,6 +488,7 @@ function createProvider( endpoint: config.endpoint, model: config.model, apiKey: config.api_key, + headers: config.headers, inputType: config.input_type, queryInputType: config.query_input_type, truncate: config.truncate, @@ -585,6 +594,7 @@ function snapshotFor( "model" in registration.config && typeof registration.config.model === "string" ? registration.config.model.trim() : ""; + const unavailableReason = registration.unavailableReason; return { projectIdentity: registration.projectIdentity, sourceDirectory: registration.sourceDirectory, @@ -598,15 +608,20 @@ function snapshotFor( chunkModelId: registration.observationMode || !providerIsOn ? "off" : registration.chunkModelId, model: - registration.observationMode || !providerIsOn - ? "off" - : configuredModel - ? configuredModel - : registration.modelId, + unavailableReason !== null + ? configuredModel + : registration.observationMode || !providerIsOn + ? "off" + : configuredModel + ? configuredModel + : registration.modelId, provider: - registration.observationMode || !providerIsOn - ? "off" - : (registration.config.provider ?? "local"), + unavailableReason !== null + ? registration.config.provider + : registration.observationMode || !providerIsOn + ? "off" + : (registration.config.provider ?? "local"), + ...(unavailableReason !== null ? { unavailableReason } : {}), }; } @@ -982,8 +997,14 @@ export function registerProjectEmbedding( sourceDirectory: string, ): ProjectEmbeddingRegistrationSnapshot { const resolvedConfig = resolveEmbeddingConfig(config); - const providerIdentity = getEmbeddingProviderIdentity(resolvedConfig); - const runtimeFingerprint = getRuntimeFingerprint(resolvedConfig); + const unavailableReason = + resolvedConfig.provider === "local" ? localEmbeddingUnavailableReasonForRuntime() : null; + const providerIdentity = unavailableReason + ? OFF_PROVIDER_IDENTITY + : getEmbeddingProviderIdentity(resolvedConfig); + const runtimeFingerprint = unavailableReason + ? `${OFF_PROVIDER_IDENTITY}:windows-bun-local` + : getRuntimeFingerprint(resolvedConfig); const chunkModelId = getChunkEmbeddingModelId(resolvedConfig, providerIdentity); const prior = projectRegistrations.get(projectIdentity); const canReuseProvider = @@ -1018,6 +1039,7 @@ export function registerProjectEmbedding( modelId: providerIdentity === OFF_PROVIDER_IDENTITY ? "off" : providerIdentity, chunkModelId: providerIdentity === OFF_PROVIDER_IDENTITY ? "off" : chunkModelId, observationMode: false, + unavailableReason, }; projectRegistrations.set(projectIdentity, registration); @@ -1069,6 +1091,7 @@ export function registerProjectShadowEmbedding( modelId: prior.modelId, chunkModelId: prior.chunkModelId, observationMode: false, + unavailableReason: null, }), provider: "synapse", }; @@ -1699,6 +1722,7 @@ export function registerProjectInObservationMode( modelId: "off", chunkModelId: "off", observationMode: true, + unavailableReason: null, }; projectRegistrations.set(projectIdentity, registration); @@ -2593,6 +2617,7 @@ export interface EmbeddingCoverageStatus { model: string; /** Configured provider kind ("local" / "openai-compatible" / "ollama" / "off"). */ provider: string; + unavailableReason?: string; /** This session's compartment-chunk coverage. */ session: { embedded: number; total: number }; /** Project-wide active-memory coverage. */ @@ -2617,6 +2642,9 @@ export function getEmbeddingCoverageStatus( enabled: false, model: snapshot?.model ?? "off", provider: snapshot?.provider ?? "off", + ...(snapshot?.unavailableReason + ? { unavailableReason: snapshot.unavailableReason } + : {}), session: { embedded: 0, total: 0 }, memories: { embedded: 0, total: 0 }, commits: { embedded: 0, total: 0, gitEnabled: false }, @@ -2728,6 +2756,12 @@ export function _setTestProviderFactoryForProject( testProviderFactory = factory; } +export function _setTestLocalEmbeddingUnavailableReasonForProject( + resolver: (() => string | null) | null, +): void { + localEmbeddingUnavailableReasonForRuntime = resolver ?? getLocalEmbeddingUnavailableReason; +} + export function _resetProjectEmbeddingRegistryForTests(): void { for (const registration of projectRegistrations.values()) { disposeProvider(registration.provider); @@ -2745,4 +2779,5 @@ export function _resetProjectEmbeddingRegistryForTests(): void { globalRegistrationGeneration = 0; projectSweepInProgress = false; testProviderFactory = null; + localEmbeddingUnavailableReasonForRuntime = getLocalEmbeddingUnavailableReason; } diff --git a/packages/plugin/src/hooks/magic-context/format-embed-status.ts b/packages/plugin/src/hooks/magic-context/format-embed-status.ts index ac2d9fd90..6acd13321 100644 --- a/packages/plugin/src/hooks/magic-context/format-embed-status.ts +++ b/packages/plugin/src/hooks/magic-context/format-embed-status.ts @@ -6,6 +6,9 @@ export function formatEmbedStatusText( drain: { status: EmbedDrainUiStatus; embedded?: number; total?: number; failed?: number }, ): string { if (!coverage.enabled) { + if (coverage.unavailableReason) { + return `Embedding unavailable — model: ${coverage.model} (${coverage.provider}). ${coverage.unavailableReason}. Configure embedding.provider=openai-compatible or off.`; + } return "Embedding is off (no provider configured)."; } diff --git a/packages/plugin/src/plugin/embedding-routing.test.ts b/packages/plugin/src/plugin/embedding-routing.test.ts index 664cfd80b..a659bc73c 100644 --- a/packages/plugin/src/plugin/embedding-routing.test.ts +++ b/packages/plugin/src/plugin/embedding-routing.test.ts @@ -1,9 +1,35 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it } from "bun:test"; import { homedir } from "node:os"; import { MagicContextConfigSchema } from "../config/schema/magic-context"; import { resolveEmbeddingRouting } from "./embedding-routing"; +const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + +afterEach(() => { + if (originalPlatformDescriptor) { + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } +}); + describe("embedding routing", () => { + it("reports the local lane unavailable under Bun on Windows", async () => { + // Given + Object.defineProperty(process, "platform", { + ...originalPlatformDescriptor, + value: "win32", + }); + const config = MagicContextConfigSchema.parse({ + embedding: { provider: "local" }, + }); + + // When + const routing = await resolveEmbeddingRouting({ config, projectRoot: "C:\\repo" }); + + // Then + expect(routing.primary.provider).toBe("local"); + expect(routing.warnings.join(" ")).toContain("Bun on Windows"); + }); + it("keeps Synapse transport settings out of the resolved fallback config", async () => { const config = MagicContextConfigSchema.parse({ embedding: { provider: "synapse", fallback_provider: "local" }, diff --git a/packages/plugin/src/plugin/embedding-routing.ts b/packages/plugin/src/plugin/embedding-routing.ts index 93722d709..3434a9ab4 100644 --- a/packages/plugin/src/plugin/embedding-routing.ts +++ b/packages/plugin/src/plugin/embedding-routing.ts @@ -4,6 +4,7 @@ import type { MagicContextConfig, } from "../config/schema/magic-context"; import { DEFAULT_LOCAL_EMBEDDING_MODEL } from "../config/schema/magic-context"; +import { getLocalEmbeddingUnavailableReason } from "../features/magic-context/memory/embedding-local"; import { getSynapseLaneIdentity, SYNAPSE_DEFAULT_MODEL, @@ -41,6 +42,12 @@ export interface ResolvedEmbeddingRouting { warnings: string[]; } +function warnIfLocalUnavailable(config: EmbeddingConfig, warnings: string[]): void { + if (config.provider !== "local") return; + const reason = getLocalEmbeddingUnavailableReason(); + if (reason) warnings.push(`${reason}; configure embedding.provider=openai-compatible or off`); +} + function fallbackConfig( config: EmbeddingConfig, provider: EmbeddingFallbackProvider | undefined, @@ -49,6 +56,10 @@ function fallbackConfig( const model = typeof raw.model === "string" ? raw.model.trim() : ""; const endpoint = typeof raw.endpoint === "string" ? raw.endpoint.trim() : ""; const apiKey = typeof raw.api_key === "string" ? raw.api_key.trim() : ""; + const headers = + typeof raw.headers === "object" && raw.headers !== null && !Array.isArray(raw.headers) + ? (raw.headers as Record) + : undefined; const inputType = typeof raw.input_type === "string" ? raw.input_type.trim() : ""; const queryInputType = typeof raw.query_input_type === "string" ? raw.query_input_type.trim() : ""; @@ -63,6 +74,7 @@ function fallbackConfig( model, endpoint, ...(apiKey ? { api_key: apiKey } : {}), + ...(headers ? { headers } : {}), ...(inputType ? { input_type: inputType } : {}), ...(queryInputType ? { query_input_type: queryInputType } : {}), ...(truncate ? { truncate } : {}), @@ -190,6 +202,7 @@ export async function resolveEmbeddingRouting(args: { ); } } + warnIfLocalUnavailable(config, warnings); return { primary: config, shadow, warnings }; } @@ -201,13 +214,16 @@ export async function resolveEmbeddingRouting(args: { const fallback = fallbackConfig(config, fallbackProvider); if (!subc) { warnings.push("embedding.provider synapse requires a subc block; using fallback provider"); + warnIfLocalUnavailable(fallback, warnings); return { primary: fallback, shadow: null, warnings }; } if (!fallbackProvider) { warnings.push( "embedding.provider synapse requires embedding.fallback_provider; using local fallback", ); - return { primary: fallbackConfig(config, "local"), shadow: null, warnings }; + const localFallback = fallbackConfig(config, "local"); + warnIfLocalUnavailable(localFallback, warnings); + return { primary: localFallback, shadow: null, warnings }; } try { @@ -232,6 +248,7 @@ export async function resolveEmbeddingRouting(args: { `Synapse is not ready; using embedding.fallback_provider=${fallbackProvider}: ${error instanceof Error ? error.message : String(error)}`, ); log(`[magic-context] Synapse routing fell back: ${warnings.at(-1)}`); + warnIfLocalUnavailable(fallback, warnings); return { primary: fallback, shadow: null, warnings }; } } From 9019e346cbb34477a980e3e59f9b9f4760bc65ca Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Wed, 19 Aug 2026 06:51:28 -0400 Subject: [PATCH 2/3] fix(embedding): secure custom header lifecycle --- packages/cli/src/commands/doctor-opencode.ts | 10 +++- packages/cli/src/commands/doctor-pi.test.ts | 49 +++++++++++++++++++ packages/cli/src/commands/doctor-pi.ts | 15 ++++-- packages/cli/src/lib/diagnostics-pi.test.ts | 21 ++++++++ packages/cli/src/lib/diagnostics-pi.ts | 12 +++-- packages/cli/src/lib/redaction.test.ts | 17 +++++++ .../src/config/schema/magic-context.test.ts | 18 +++++++ .../plugin/src/config/schema/magic-context.ts | 23 ++++++--- .../memory/embedding-identity.ts | 12 +++-- .../memory/embedding-probe.test.ts | 46 +++++++++++++++++ .../magic-context/memory/embedding-probe.ts | 20 ++++++-- .../magic-context/memory/embedding.test.ts | 28 +++++++++++ .../project-embedding-registry.test.ts | 46 +++++++++++++++++ .../plugin/src/shared/embedding-headers.ts | 19 +++++++ packages/plugin/src/shared/redaction.ts | 3 ++ 15 files changed, 318 insertions(+), 21 deletions(-) create mode 100644 packages/plugin/src/shared/embedding-headers.ts diff --git a/packages/cli/src/commands/doctor-opencode.ts b/packages/cli/src/commands/doctor-opencode.ts index 224863a9c..9ca6f1889 100644 --- a/packages/cli/src/commands/doctor-opencode.ts +++ b/packages/cli/src/commands/doctor-opencode.ts @@ -8,6 +8,7 @@ import { isCompactionEnabled } from "@magic-context/core/config/agent-disable"; import { substituteConfigVariables } from "@magic-context/core/config/variable"; import { type EmbeddingProbeOutcome, + InvalidEmbeddingHeadersError, parseEmbeddingHeaders, probeEmbeddingEndpoint, } from "@magic-context/core/features/magic-context/memory/embedding-probe"; @@ -497,7 +498,14 @@ async function checkEmbeddingConfig( const endpoint = typeof embedding?.endpoint === "string" ? embedding.endpoint.trim() : ""; const model = typeof embedding?.model === "string" ? embedding.model.trim() : ""; const apiKey = typeof embedding?.api_key === "string" ? embedding.api_key : undefined; - const headers = parseEmbeddingHeaders(embedding?.headers); + let headers: Readonly> | undefined; + try { + headers = parseEmbeddingHeaders(embedding?.headers); + } catch (error) { + if (!(error instanceof InvalidEmbeddingHeadersError)) throw error; + log.error(error.message); + return { issues: 1 }; + } const inputType = typeof embedding?.input_type === "string" ? embedding.input_type.trim() : undefined; const truncateMode = diff --git a/packages/cli/src/commands/doctor-pi.test.ts b/packages/cli/src/commands/doctor-pi.test.ts index bb9f3baf4..1dc09d76a 100644 --- a/packages/cli/src/commands/doctor-pi.test.ts +++ b/packages/cli/src/commands/doctor-pi.test.ts @@ -769,6 +769,55 @@ describe("Pi doctor", () => { expect(probeOptions?.apiKey).toBe("fallback-key"); }); + it("fails closed on invalid embedding headers without probing or echoing values", async () => { + const root = makeTempRoot(); + const cwd = makeTempRoot("mc-pi-doctor-cwd-"); + const agentDir = setEnv(root, cwd); + writeHealthyFiles(agentDir, cwd); + const secret = "benign-name-secret-value"; + writeFileSync( + join(root, ".config", "cortexkit", "magic-context.jsonc"), + JSON.stringify({ + embedding: { + provider: "openai-compatible", + endpoint: "https://example.com/v1", + model: "text-embedding-3-small", + headers: { "Invalid Header": secret }, + }, + }), + ); + const prompts = new MockPrompts(); + const options = baseOptions(root, cwd, prompts); + let probeCalls = 0; + const errors: string[] = []; + const originalError = console.error; + console.error = (message?: unknown) => { + errors.push(String(message)); + }; + + try { + const code = await runDoctor({ + ...options, + deps: { + ...options.deps, + probeEmbeddingEndpoint: async () => { + probeCalls++; + return { kind: "ok", status: 200, dimensions: 3 }; + }, + }, + }); + + expect(code).toBe(1); + } finally { + console.error = originalError; + } + + const output = errors.join("\n"); + expect(output).toContain("Invalid embedding.headers"); + expect(output).not.toContain(secret); + expect(probeCalls).toBe(0); + }); + it("sanitizes thrown embedding probe errors before printing them", async () => { const root = makeTempRoot(); const cwd = makeTempRoot("mc-pi-doctor-cwd-"); diff --git a/packages/cli/src/commands/doctor-pi.ts b/packages/cli/src/commands/doctor-pi.ts index 569b9643a..0bd0066d5 100644 --- a/packages/cli/src/commands/doctor-pi.ts +++ b/packages/cli/src/commands/doctor-pi.ts @@ -12,6 +12,7 @@ import { MagicContextConfigSchema } from "@magic-context/core/config/schema/magi import { substituteConfigVariables } from "@magic-context/core/config/variable"; import { type EmbeddingProbeOutcome, + InvalidEmbeddingHeadersError, parseEmbeddingHeaders, probeEmbeddingEndpoint, } from "@magic-context/core/features/magic-context/memory/embedding-probe"; @@ -697,7 +698,15 @@ async function runHealthChecks(options: { const model = typeof mergedEmbedding.model === "string" ? mergedEmbedding.model.trim() : ""; const apiKey = typeof mergedEmbedding.api_key === "string" ? mergedEmbedding.api_key : undefined; - const headers = parseEmbeddingHeaders(mergedEmbedding.headers); + let headers: Readonly> | undefined; + let headersValid = true; + try { + headers = parseEmbeddingHeaders(mergedEmbedding.headers); + } catch (error) { + if (!(error instanceof InvalidEmbeddingHeadersError)) throw error; + headersValid = false; + add(results, "fail", error.message); + } const inputType = typeof mergedEmbedding.input_type === "string" ? mergedEmbedding.input_type.trim() @@ -706,13 +715,13 @@ async function runHealthChecks(options: { typeof mergedEmbedding.truncate === "string" ? mergedEmbedding.truncate.trim() : undefined; - if (!endpoint || !model) { + if (headersValid && (!endpoint || !model)) { add( results, "fail", "Embedding provider is openai-compatible but endpoint/model is missing", ); - } else { + } else if (headersValid) { try { const outcome = await options.deps.probeEmbeddingEndpoint({ endpoint, diff --git a/packages/cli/src/lib/diagnostics-pi.test.ts b/packages/cli/src/lib/diagnostics-pi.test.ts index f601297f5..426d4f095 100644 --- a/packages/cli/src/lib/diagnostics-pi.test.ts +++ b/packages/cli/src/lib/diagnostics-pi.test.ts @@ -49,6 +49,27 @@ describe("sanitizeValue Pi diagnostics redaction", () => { api_key: "", }); }); + + it("redacts every embedding header value regardless of header name", () => { + const sanitized = sanitizeValue({ + embedding: { + headers: { + "X-Workspace": "workspace-credential", + "X-Region": "us-east-1", + }, + }, + }); + + expect(sanitized).toEqual({ + embedding: { + headers: { + "X-Workspace": "", + "X-Region": "", + }, + }, + }); + expect(JSON.stringify(sanitized)).not.toContain("workspace-credential"); + }); }); describe("collectDiagnostics Pi path resolution", () => { diff --git a/packages/cli/src/lib/diagnostics-pi.ts b/packages/cli/src/lib/diagnostics-pi.ts index d9496d48f..c507ca3ca 100644 --- a/packages/cli/src/lib/diagnostics-pi.ts +++ b/packages/cli/src/lib/diagnostics-pi.ts @@ -205,16 +205,22 @@ function shouldRedactKey(key: string): boolean { return /api[_-]?key|token|secret|password|authorization|cookie/i.test(key); } -export function sanitizeValue(value: unknown, key = ""): unknown { +export function sanitizeValue(value: unknown, keyPath: readonly string[] = []): unknown { if (value === null || typeof value === "number" || typeof value === "boolean") return value; + const key = keyPath.at(-1) ?? ""; + if (keyPath.length >= 3 && keyPath.at(-3) === "embedding" && keyPath.at(-2) === "headers") { + return ""; + } if (shouldRedactKey(key)) return ""; if (typeof value === "string") return sanitizeString(value); - if (Array.isArray(value)) return value.map((entry) => sanitizeValue(entry)); + if (Array.isArray(value)) { + return value.map((entry, index) => sanitizeValue(entry, [...keyPath, String(index)])); + } if (value && typeof value === "object") { return Object.fromEntries( Object.entries(value).map(([entryKey, entry]) => [ entryKey, - sanitizeValue(entry, entryKey), + sanitizeValue(entry, [...keyPath, entryKey]), ]), ); } diff --git a/packages/cli/src/lib/redaction.test.ts b/packages/cli/src/lib/redaction.test.ts index 137211181..3fe7d9133 100644 --- a/packages/cli/src/lib/redaction.test.ts +++ b/packages/cli/src/lib/redaction.test.ts @@ -119,6 +119,23 @@ describe("sanitizeConfigValue — preserves benign config keys", () => { expect(sanitized.embedding?.model).toBe("text-embedding-3-small"); }); + it("redacts every embedding header value regardless of header name", () => { + const sanitized = sanitizeConfigValue({ + embedding: { + headers: { + "X-Workspace": "workspace-credential", + "X-Region": "us-east-1", + }, + }, + }) as Record>>; + + expect(sanitized.embedding?.headers).toEqual({ + "X-Workspace": "", + "X-Region": "", + }); + expect(JSON.stringify(sanitized)).not.toContain("workspace-credential"); + }); + it("does not redact memory.injection_budget_tokens", () => { const config = { memory: { diff --git a/packages/plugin/src/config/schema/magic-context.test.ts b/packages/plugin/src/config/schema/magic-context.test.ts index 9a6a934e1..99c84815b 100644 --- a/packages/plugin/src/config/schema/magic-context.test.ts +++ b/packages/plugin/src/config/schema/magic-context.test.ts @@ -8,6 +8,24 @@ import { } from "./magic-context"; describe("MagicContextConfigSchema", () => { + it("rejects invalid embedding header names and values without echoing their contents", () => { + const secret = "credential-with-newline\nsecond-line"; + const parsed = MagicContextConfigSchema.safeParse({ + embedding: { + provider: "openai-compatible", + endpoint: "https://api.example.com/v1", + model: "embedding-model", + headers: { "Invalid Header": secret }, + }, + }); + + expect(parsed.success).toBe(false); + if (!parsed.success) { + const message = parsed.error.message; + expect(message).toContain("embedding.headers"); + expect(message).not.toContain(secret); + } + }); describe("defaults", () => { it("applies defaults for an empty config", () => { const result = MagicContextConfigSchema.parse({}); diff --git a/packages/plugin/src/config/schema/magic-context.ts b/packages/plugin/src/config/schema/magic-context.ts index 93369e864..d43f83c66 100644 --- a/packages/plugin/src/config/schema/magic-context.ts +++ b/packages/plugin/src/config/schema/magic-context.ts @@ -7,6 +7,7 @@ import type { AGENTIC_DREAM_TASKS, DreamTaskName, } from "../../features/magic-context/dreamer/task-registry"; +import { normalizeEmbeddingHeaders } from "../../shared/embedding-headers"; import { isValidPromptSurfaceModelKey } from "../../shared/prompt-surface"; import { AgentOverrideConfigSchema } from "./agent-overrides"; @@ -288,6 +289,19 @@ export type HistorianConfig = NonNullable> const EmbeddingFallbackProviderSchema = z.enum(["local", "openai-compatible", "off"]); +const EmbeddingHeadersSchema = z + .record(z.string().trim().min(1), z.string().min(1)) + .superRefine((headers, ctx) => { + try { + normalizeEmbeddingHeaders(headers); + } catch { + ctx.addIssue({ + code: "custom", + message: "embedding.headers contains invalid HTTP header names or values", + }); + } + }); + function expandConfigPath(value: string): string { const trimmed = value.trim(); if (trimmed === "~") return homedir(); @@ -315,12 +329,9 @@ const BaseEmbeddingConfigSchema = z .optional() .describe("API endpoint URL. Required when provider is openai-compatible."), api_key: z.string().optional().describe("API key for remote embedding provider (optional)"), - headers: z - .record(z.string().trim().min(1), z.string().min(1)) - .optional() - .describe( - "USER-LEVEL ONLY custom HTTP headers for openai-compatible embedding requests. Use config variable substitution for secrets (for example Authorization: {env:EMBEDDING_AUTHORIZATION}). Custom Authorization takes precedence over api_key. Project config cannot set headers, and status/doctor diagnostics never print header values.", - ), + headers: EmbeddingHeadersSchema.optional().describe( + "USER-LEVEL ONLY custom HTTP headers for openai-compatible embedding requests. Use config variable substitution for secrets (for example Authorization: {env:EMBEDDING_AUTHORIZATION}). Custom Authorization takes precedence over api_key. Project config cannot set headers, and status/doctor diagnostics never print header values.", + ), input_type: z .string() .optional() diff --git a/packages/plugin/src/features/magic-context/memory/embedding-identity.ts b/packages/plugin/src/features/magic-context/memory/embedding-identity.ts index 7d9449593..d6d19ab6d 100644 --- a/packages/plugin/src/features/magic-context/memory/embedding-identity.ts +++ b/packages/plugin/src/features/magic-context/memory/embedding-identity.ts @@ -1,5 +1,7 @@ +import { createHash } from "node:crypto"; import type { EmbeddingConfig } from "../../../config/schema/magic-context"; import { DEFAULT_LOCAL_EMBEDDING_MODEL } from "../../../config/schema/magic-context"; +import { normalizeEmbeddingHeaders } from "../../../shared/embedding-headers"; import { getSynapseLaneIdentity } from "./embedding-synapse"; import { computeNormalizedHash } from "./normalize-hash"; @@ -34,6 +36,12 @@ export function getEmbeddingProviderIdentity(config: EmbeddingConfig): string { } const truncate = config.provider === "openai-compatible" ? config.truncate?.trim() : undefined; + const customHeadersFingerprint = + config.provider === "openai-compatible" && Object.keys(config.headers ?? {}).length > 0 + ? createHash("sha256") + .update(JSON.stringify(normalizeEmbeddingHeaders(config.headers ?? {}))) + .digest("hex") + : undefined; // local_dtype changes the produced vectors (a quantized ONNX model emits // different embeddings than fp32), so a non-default dtype MUST fold into the // model identity — switching dtype re-embeds rather than mixing vector @@ -52,9 +60,7 @@ export function getEmbeddingProviderIdentity(config: EmbeddingConfig): string { model: config.model.trim(), endpoint: normalizeEndpoint(config.endpoint), apiKeyPresent: Boolean(config.api_key?.trim()), - ...(Object.keys(config.headers ?? {}).length > 0 - ? { customHeadersPresent: true } - : {}), + ...(customHeadersFingerprint ? { customHeadersFingerprint } : {}), // input_type changes the embedding vector space (e.g. NIM // 'query' vs 'passage'), so it participates in identity — a // change must re-embed. truncate changes which text an over-long diff --git a/packages/plugin/src/features/magic-context/memory/embedding-probe.test.ts b/packages/plugin/src/features/magic-context/memory/embedding-probe.test.ts index ebf941892..40d23e034 100644 --- a/packages/plugin/src/features/magic-context/memory/embedding-probe.test.ts +++ b/packages/plugin/src/features/magic-context/memory/embedding-probe.test.ts @@ -313,6 +313,52 @@ describe("probeEmbeddingEndpoint", () => { expect(headers.get("authorization")).toBe("Token custom-authorization"); }); + it("fails closed on invalid header names without echoing header values", async () => { + let fetchCalls = 0; + const secret = "benign-name-secret-value"; + + const result = await probeEmbeddingEndpoint({ + endpoint: "https://api.example.com/v1", + model: "m", + headers: { "Invalid Header": secret }, + fetch: async () => { + fetchCalls++; + return new Response(); + }, + }); + + expect(result).toEqual({ + kind: "network_error", + message: + "Invalid embedding.headers: header names and values must be valid HTTP headers", + }); + expect(JSON.stringify(result)).not.toContain(secret); + expect(fetchCalls).toBe(0); + }); + + it("fails closed on invalid header values without echoing the value", async () => { + let fetchCalls = 0; + const secret = "credential-with-newline\nsecond-line"; + + const result = await probeEmbeddingEndpoint({ + endpoint: "https://api.example.com/v1", + model: "m", + headers: { "X-Workspace": secret }, + fetch: async () => { + fetchCalls++; + return new Response(); + }, + }); + + expect(result).toEqual({ + kind: "network_error", + message: + "Invalid embedding.headers: header names and values must be valid HTTP headers", + }); + expect(JSON.stringify(result)).not.toContain(secret); + expect(fetchCalls).toBe(0); + }); + it("omits authorization header when no apiKey", async () => { const capture: FetchCapture = {}; const fetch = mockFetch( diff --git a/packages/plugin/src/features/magic-context/memory/embedding-probe.ts b/packages/plugin/src/features/magic-context/memory/embedding-probe.ts index 77f187894..8871f424d 100644 --- a/packages/plugin/src/features/magic-context/memory/embedding-probe.ts +++ b/packages/plugin/src/features/magic-context/memory/embedding-probe.ts @@ -1,3 +1,10 @@ +import { + InvalidEmbeddingHeadersError, + normalizeEmbeddingHeaders, +} from "../../../shared/embedding-headers"; + +export { InvalidEmbeddingHeadersError } from "../../../shared/embedding-headers"; + /** * Live verification of an OpenAI-compatible embeddings endpoint. * @@ -50,7 +57,7 @@ export function buildEmbeddingRequestHeaders( customHeaders?: Readonly>, apiKey?: string, ): Headers { - const headers = new Headers(customHeaders); + const headers = new Headers(normalizeEmbeddingHeaders(customHeaders ?? {})); headers.set("content-type", "application/json"); const normalizedApiKey = apiKey?.trim(); if (normalizedApiKey && !headers.has("authorization")) { @@ -62,11 +69,15 @@ export function buildEmbeddingRequestHeaders( export function parseEmbeddingHeaders( value: unknown, ): Readonly> | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + if (value === undefined) return undefined; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new InvalidEmbeddingHeadersError(); + } const entries = Object.entries(value); if (!entries.every((entry): entry is [string, string] => typeof entry[1] === "string")) { - return undefined; + throw new InvalidEmbeddingHeadersError(); } + normalizeEmbeddingHeaders(Object.fromEntries(entries)); return Object.fromEntries(entries); } @@ -98,8 +109,6 @@ export async function probeEmbeddingEndpoint( const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; const url = `${endpoint}/embeddings`; - const headers = buildEmbeddingRequestHeaders(options.headers, options.apiKey); - // Use a short fixed probe string. Providers bill by tokens, so minimal // input keeps the check cheap even on metered accounts. const inputType = options.inputType?.trim(); @@ -113,6 +122,7 @@ export async function probeEmbeddingEndpoint( let response: Response; try { + const headers = buildEmbeddingRequestHeaders(options.headers, options.apiKey); response = await fetchImpl(url, { method: "POST", headers, diff --git a/packages/plugin/src/features/magic-context/memory/embedding.test.ts b/packages/plugin/src/features/magic-context/memory/embedding.test.ts index f2df36fd3..2f6486ed2 100644 --- a/packages/plugin/src/features/magic-context/memory/embedding.test.ts +++ b/packages/plugin/src/features/magic-context/memory/embedding.test.ts @@ -110,5 +110,33 @@ describe("embedding module", () => { expect(first.modelId).not.toBe(anonymous.modelId); expect(first.modelId).not.toContain("secret"); }); + + it("openai-compatible identity is stable for equivalent headers and rotates on any header change", () => { + const common = { + endpoint: "http://localhost:1234/v1", + model: "text-embedding-3-small", + }; + const first = new OpenAICompatibleEmbeddingProvider({ + ...common, + headers: { "X-Workspace": "credential-one", "X-Region": "us-east-1" }, + }); + const equivalent = new OpenAICompatibleEmbeddingProvider({ + ...common, + headers: { "x-region": "us-east-1", "x-workspace": "credential-one" }, + }); + const rotatedValue = new OpenAICompatibleEmbeddingProvider({ + ...common, + headers: { "X-Workspace": "credential-two", "X-Region": "us-east-1" }, + }); + const rotatedName = new OpenAICompatibleEmbeddingProvider({ + ...common, + headers: { "X-Account": "credential-one", "X-Region": "us-east-1" }, + }); + + expect(first.modelId).toBe(equivalent.modelId); + expect(first.modelId).not.toBe(rotatedValue.modelId); + expect(first.modelId).not.toBe(rotatedName.modelId); + expect(first.modelId).not.toContain("credential-one"); + }); }); }); diff --git a/packages/plugin/src/features/magic-context/project-embedding-registry.test.ts b/packages/plugin/src/features/magic-context/project-embedding-registry.test.ts index 37455697e..23999edf7 100644 --- a/packages/plugin/src/features/magic-context/project-embedding-registry.test.ts +++ b/packages/plugin/src/features/magic-context/project-embedding-registry.test.ts @@ -18,6 +18,7 @@ import { } from "./git-commits/storage-git-commit-embeddings"; import { upsertCommits } from "./git-commits/storage-git-commits"; import { acquireGitSweepLease, releaseGitSweepLease } from "./git-commits/sweep-coordinator"; +import { getEmbeddingProviderIdentity } from "./memory/embedding-identity"; import type { EmbeddingProvider, EmbeddingPurpose } from "./memory/embedding-provider"; import { insertMemory } from "./memory/storage-memory"; import { @@ -341,6 +342,51 @@ describe("project embedding registry", () => { ); }); + it("reuses equivalent custom headers and recreates the provider when a header rotates", async () => { + const providers: FakeEmbeddingProvider[] = []; + _setTestProviderFactoryForProject((config) => { + const created = new FakeEmbeddingProvider(getEmbeddingProviderIdentity(config)); + providers.push(created); + return created; + }); + const db = useTempDb(); + const features = { memoryEnabled: true, gitCommitEnabled: false }; + const common = { + provider: "openai-compatible" as const, + endpoint: "https://api.example.com/v1", + model: "embedding-model", + }; + + registerProjectEmbedding( + db, + "git:header-rotation", + { ...common, headers: { "X-Workspace": "credential-one", "X-Region": "east" } }, + features, + "/repo", + ); + await embedTextForProject("git:header-rotation", "first"); + registerProjectEmbedding( + db, + "git:header-rotation", + { ...common, headers: { "X-Workspace": "credential-one", "X-Region": "east" } }, + features, + "/repo", + ); + await embedTextForProject("git:header-rotation", "equivalent"); + registerProjectEmbedding( + db, + "git:header-rotation", + { ...common, headers: { "X-Workspace": "credential-two", "X-Region": "east" } }, + features, + "/repo", + ); + await embedTextForProject("git:header-rotation", "rotated"); + + expect(providers).toHaveLength(2); + expect(providers[0]?.disposed).toBe(true); + expect(providers[1]?.disposed).toBe(false); + }); + it("a non-default local_dtype folds into the identity and differs from the default (#259)", () => { const db = useTempDb(); const features = { memoryEnabled: true, gitCommitEnabled: true }; diff --git a/packages/plugin/src/shared/embedding-headers.ts b/packages/plugin/src/shared/embedding-headers.ts new file mode 100644 index 000000000..a8a15138e --- /dev/null +++ b/packages/plugin/src/shared/embedding-headers.ts @@ -0,0 +1,19 @@ +export class InvalidEmbeddingHeadersError extends Error { + readonly name = "InvalidEmbeddingHeadersError"; + + constructor() { + super("Invalid embedding.headers: header names and values must be valid HTTP headers"); + } +} + +export function normalizeEmbeddingHeaders( + headers: Readonly>, +): [string, string][] { + try { + return [...new Headers(headers).entries()].sort(([left], [right]) => + left.localeCompare(right), + ); + } catch { + throw new InvalidEmbeddingHeadersError(); + } +} diff --git a/packages/plugin/src/shared/redaction.ts b/packages/plugin/src/shared/redaction.ts index 49d2e0964..28dec9121 100644 --- a/packages/plugin/src/shared/redaction.ts +++ b/packages/plugin/src/shared/redaction.ts @@ -246,6 +246,9 @@ export function hasShareabilitySensitiveText(text: string): boolean { export function sanitizeConfigValue(value: unknown, keyPath: string[] = []): unknown { if (value === null || typeof value === "number" || typeof value === "boolean") return value; const key = keyPath.at(-1) ?? ""; + if (keyPath.length >= 3 && keyPath.at(-3) === "embedding" && keyPath.at(-2) === "headers") { + return ""; + } if (key && isSecretKey(key)) { return ``; } From fb57aa75931e5abf3976ab287539e26ae5044041 Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Wed, 19 Aug 2026 07:20:16 -0400 Subject: [PATCH 3/3] test(cli): expect blanket header redaction --- packages/cli/src/lib/logs-opencode.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/lib/logs-opencode.test.ts b/packages/cli/src/lib/logs-opencode.test.ts index f900bb53d..1307f840d 100644 --- a/packages/cli/src/lib/logs-opencode.test.ts +++ b/packages/cli/src/lib/logs-opencode.test.ts @@ -398,8 +398,8 @@ describe("bundleIssueReport secret redaction", () => { const body = readFileSync(bundled.path, "utf-8"); expect(body).toContain('"api_key": ""'); - expect(body).toContain('"Authorization": ""'); - expect(body).toContain('"X-Api-Key": ""'); + expect(body).toContain('"Authorization": ""'); + expect(body).toContain('"X-Api-Key": ""'); expect(body).not.toContain("emb-secret-value"); expect(body).not.toContain("historian-secret-value"); expect(body).not.toContain("header-secret-value");