From 14afdcb92346ba1eb1c301552085a6970e4a37f4 Mon Sep 17 00:00:00 2001 From: Benson K B Date: Mon, 17 Aug 2026 16:51:53 +0530 Subject: [PATCH 01/10] fix(routing): fallback to default model alias seeds when unmapped in database (#10124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(routing): fallback to default model alias seeds when unmapped in database * fix(routing): rename seed-fallback resolver; hermetic 401 regression test Maintainer review (PR #10124): 1. Rename resolveModelAlias -> resolveModelAliasWithSeedFallback (and resolveModelAliasOnBody -> resolveModelAliasWithSeedFallbackOnBody) to avoid the export collision with the sync resolveModelAlias in open-sse/services/modelDeprecation.ts and src/shared/constants/modelSpecs.ts. 2. Regression test now reproduces the 401: alias unmapped in the (empty, DATA_DIR-isolated) modelAliases namespace but present in the static seed resolves to the seed target instead of passing through unmapped. 3. Test isolates DATA_DIR (temp dir + resetDbInstance) instead of reading the operator's live DB. * fix(models): add outputTokenLimit to CustomModelEntry Fixes the open-sse typecheck gate regression: catalog.ts reads model.outputTokenLimit (for max_output_tokens in custom model metadata) but CustomModelEntry only declared inputTokenLimit — TS2551. The field exists in the runtime model data and is already consumed; the interface just never declared it. --- src/app/api/v1/chat/completions/route.ts | 4 +- src/lib/modelAliasResolver.ts | 16 +++-- src/lib/modelAliasSeed.ts | 3 + tests/unit/model-alias-seed-fallback.test.ts | 66 ++++++++++++++++++++ 4 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 tests/unit/model-alias-seed-fallback.test.ts diff --git a/src/app/api/v1/chat/completions/route.ts b/src/app/api/v1/chat/completions/route.ts index d181c4fd3d..a7e02842c6 100644 --- a/src/app/api/v1/chat/completions/route.ts +++ b/src/app/api/v1/chat/completions/route.ts @@ -26,7 +26,7 @@ import { readCompressionRequestHeader, withCompressionHeaderEcho, } from "@/shared/utils/compressionHeaderEcho"; -import { resolveModelAliasOnBody } from "@/lib/modelAliasResolver"; +import { resolveModelAliasWithSeedFallbackOnBody } from "@/lib/modelAliasResolver"; let initPromise = null; @@ -161,7 +161,7 @@ export async function POST(request) { // Resolve model alias before forwarding to handleChat if (parsedBody && typeof parsedBody === "object") { - await resolveModelAliasOnBody(parsedBody).catch(() => { + await resolveModelAliasWithSeedFallbackOnBody(parsedBody).catch(() => { /* swallow — fall through with original model */ }); } diff --git a/src/lib/modelAliasResolver.ts b/src/lib/modelAliasResolver.ts index 8872019e15..7e0a079521 100644 --- a/src/lib/modelAliasResolver.ts +++ b/src/lib/modelAliasResolver.ts @@ -9,6 +9,7 @@ * `src/lib/modelAliasSeed.ts`. */ import { getModelAliases } from "@/lib/db/models/aliases"; +import { DEFAULT_MODEL_ALIAS_SEED } from "@/lib/modelAliasSeed"; let cachedAliases: Record | null = null; let lastFetch = 0; @@ -25,17 +26,22 @@ async function loadAliases(): Promise> { } /** - * Resolve a model alias to its target provider model ID. + * Resolve a model alias to its target provider model ID, falling back to the + * static DEFAULT_MODEL_ALIAS_SEED when the alias is not in the database. * If the alias maps to an array, returns the first element. * If no alias is found, returns the original model name unchanged. + * + * Named distinctly from `resolveModelAlias` (modelDeprecation.ts / + * modelSpecs.ts, sync string→string) to avoid export collisions when both + * modules are imported together. */ -export async function resolveModelAlias( +export async function resolveModelAliasWithSeedFallback( model: string | null | undefined ): Promise { if (!model) return model; const aliases = await loadAliases(); - const target = aliases[model]; + const target = aliases[model] ?? (DEFAULT_MODEL_ALIAS_SEED as Record)[model]; if (target === undefined) return model; @@ -58,11 +64,11 @@ export async function resolveModelAlias( * Resolve model alias on a parsed request body in-place. * Mutates `body.model` if an alias is found. */ -export async function resolveModelAliasOnBody( +export async function resolveModelAliasWithSeedFallbackOnBody( body: Record | null | undefined ): Promise { if (!body || typeof body !== "object") return; - body.model = await resolveModelAlias(body.model as string | null | undefined); + body.model = await resolveModelAliasWithSeedFallback(body.model as string | null | undefined); } /** diff --git a/src/lib/modelAliasSeed.ts b/src/lib/modelAliasSeed.ts index ed4b0c770f..7edf62e9ae 100644 --- a/src/lib/modelAliasSeed.ts +++ b/src/lib/modelAliasSeed.ts @@ -3,6 +3,9 @@ import { deleteModelAlias, getModelAliases, setModelAlias } from "@/lib/db/model export const DEFAULT_MODEL_ALIAS_SEED = Object.freeze({ "gemini-3.1-pro": "agy/gemini-pro-agent", "gemini-3.1-flash-lite-preview": "gemini/gemini-3.1-flash-lite", + "claude-sonnet-4-6": "agy/claude-sonnet-4-6", + "claude-opus-4-6-thinking": "agy/claude-opus-4-6-thinking", + "gemini-3.6-flash-low": "agy/gemini-3.6-flash-low", }); // Remove only aliases that still match a default value previously shipped by OmniRoute. diff --git a/tests/unit/model-alias-seed-fallback.test.ts b/tests/unit/model-alias-seed-fallback.test.ts new file mode 100644 index 0000000000..8a3ff1ff11 --- /dev/null +++ b/tests/unit/model-alias-seed-fallback.test.ts @@ -0,0 +1,66 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { resolveModelAliasWithSeedFallback } from "../../src/lib/modelAliasResolver"; + +// Hermetic test: isolate DATA_DIR so the alias lookup reads an EMPTY +// modelAliases namespace (fresh install state) instead of the operator's live +// DB. This is the exact scenario the 401 fix targets — aliases unmapped in +// the DB must fall back to the static seed. +async function withEmptyAliasDb(fn: () => Promise) { + const prevDataDir = process.env.DATA_DIR; + const prevKey = process.env.STORAGE_ENCRYPTION_KEY; + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "alias-seed-fallback-")); + process.env.DATA_DIR = dataDir; + delete process.env.STORAGE_ENCRYPTION_KEY; + + try { + // Reset the module-level DB singleton so it binds to the temp dir. + const { resetDbInstance } = await import("../../src/lib/db/core"); + resetDbInstance?.(); + await fn(); + } finally { + fs.rmSync(dataDir, { recursive: true, force: true }); + const { resetDbInstance } = await import("../../src/lib/db/core"); + resetDbInstance?.(); + if (prevDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = prevDataDir; + if (prevKey === undefined) delete process.env.STORAGE_ENCRYPTION_KEY; + else process.env.STORAGE_ENCRYPTION_KEY = prevKey; + } +} + +test("resolveModelAliasWithSeedFallback: falls back to DEFAULT_MODEL_ALIAS_SEED for unmapped models", async () => { + await withEmptyAliasDb(async () => { + const opus = await resolveModelAliasWithSeedFallback("claude-opus-4-6-thinking"); + assert.equal(opus, "agy/claude-opus-4-6-thinking"); + + const flash = await resolveModelAliasWithSeedFallback("gemini-3.6-flash-low"); + assert.equal(flash, "agy/gemini-3.6-flash-low"); + + const unknown = await resolveModelAliasWithSeedFallback("unknown-custom-model-999"); + assert.equal(unknown, "unknown-custom-model-999"); + }); +}); + +// Regression for the 401 the PR fixes: a client sends a model alias that is +// NOT in the database alias table (empty modelAliases namespace = fresh +// install / wiped aliases) but IS in the static seed. Before the fix the +// alias resolved to itself → upstream rejects with 401 "no such model"; after +// the fix it maps to the seed target (agy/...), which routes to a real model. +test("resolveModelAliasWithSeedFallback: unmapped-but-seeded alias resolves (401 regression)", async () => { + await withEmptyAliasDb(async () => { + const resolved = await resolveModelAliasWithSeedFallback("claude-opus-4-6-thinking"); + assert.equal(resolved, "agy/claude-opus-4-6-thinking"); + }); +}); + +// The exported name must not collide with the sync resolveModelAlias in +// modelDeprecation.ts / modelSpecs.ts (maintainer review note on PR #10124). +test("resolveModelAliasWithSeedFallback: export name is distinct from the sync resolveModelAlias", async () => { + const mod = await import("../../src/lib/modelAliasResolver"); + assert.equal(typeof mod.resolveModelAliasWithSeedFallback, "function"); + assert.equal(mod.resolveModelAlias, undefined, "must not export the colliding sync name"); +}); From 0f402a84a4e3f826723a7aa5b1fbec9c18509177 Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Mon, 17 Aug 2026 13:22:17 +0200 Subject: [PATCH 02/10] feat(responses): virtualize previous_response_id continuation regardless of upstream support (#10262) * feat(responses): virtualize previous_response_id continuation regardless of upstream support OmniRoute now exposes OpenAI-compatible previous_response_id/store continuation to clients unconditionally, even when the selected upstream provider has no native Responses-API state support. Reconstruction happens server-side in handleChatImplementation, before any downstream validation or provider translation: OmniRoute resolves the response id back to the full input/output it previously produced, prepends it to the client's delta, and forwards the full reconstructed history upstream exactly as it does today. Client<->OmniRoute traffic shrinks to the new delta only; OmniRoute<->provider traffic is unchanged. Storage reuses the existing call-log pipeline artifact (already gated by call_log_pipeline_enabled, already retained/cleaned up by the existing call-log lifecycle) instead of duplicating conversation content into a second store -- only a lightweight call_logs.response_id index is new. Every lookup is scoped by api_key_id so one client can never resolve another client's stored conversation, and any unresolvable/missing/ size-limit-omitted state fails closed with OpenAI's own previous_response_not_found contract. Stacked on feat/openai-responses-store-toggle (#10121). * fix(db): re-export responsesContinuationStore from the localDb barrel check-db-rules requires every db/ module to be re-exported (or explicitly allowlisted as intentionally-internal) for discoverability. Missed this when the module was first added. * fix(db): renumber previous_response_id index migration to 154 The migration was numbered 153, but release/v3.8.50 already carries 153_radar_local_model_state.sql. The emngrating runner's collision guard throws on two live .sql files sharing a numeric prefix, so the refreshed merge would fail DB startup. Renumber to the next free slot (154). Co-authored-by: diegosouzapw * docs(db): sync migration count to 149 across llm.txt mirrors The responses-continuation store adds one migration, so the docs' migration count is now 149 (was 148). Update README/AGENTS/llm.txt and regenerate the i18n llm.txt mirrors to keep check:docs-all green. Co-authored-by: diegosouzapw * fix(responses-continuation): respect preserve mode, drop dead export - Un-export ResponsesContinuationState: it's never imported outside responsesContinuationStore.ts, its own defining file. Fixes the check:dead-code regression (410 > baseline 409). - Scope the previous_response_id virtualization interception in chat.ts to skip entirely when responsesPreviousResponseIdMode=preserve. The interception ran unconditionally before target/connection selection, ahead of applyResponsesPreviousResponseIdPolicy (chatCore.ts) -- the existing per-target enforcement point for this setting -- so "preserve" (the explicit, connection-independent contract for "let the upstream resolve previous_response_id natively") was silently unreachable: the field was already deleted and replaced with locally-reconstructed input by the time that policy ran. This also broke Codex's own executor, which relies on an untouched previous_response_id to delegate history resolution upstream (see stripOrphanedCodexFunctionCallOutputs in codex.ts). "auto" and "strip" modes are unaffected -- virtualization is a strict improvement over their old "drop the field, hope the client resent everything" behavior. - Add a regression test exercising the actual chat.ts handler (not just the policy helper in isolation): confirms mode=preserve now proceeds to normal routing instead of the virtualization's previous_response_not_found rejection, and that default/auto mode's existing virtualization behavior is unchanged. Verified the test fails for the right reason against pre-fix chat.ts. Addresses PR review feedback. --------- Co-authored-by: adevwithpurpose Co-authored-by: hartmark Co-authored-by: diegosouzapw --- open-sse/handlers/chatCore/attemptLogging.ts | 16 ++ .../migrations/154_call_logs_response_id.sql | 11 ++ src/lib/db/responsesContinuationStore.ts | 75 +++++++++ src/lib/localDb.ts | 1 + src/lib/usage/callLogs.ts | 9 +- src/sse/handlers/chat.ts | 64 +++++++ ...previous-response-id-preserve-mode.test.ts | 63 +++++++ .../unit/responses-continuation-store.test.ts | 159 ++++++++++++++++++ 8 files changed, 396 insertions(+), 2 deletions(-) create mode 100644 src/lib/db/migrations/154_call_logs_response_id.sql create mode 100644 src/lib/db/responsesContinuationStore.ts create mode 100644 tests/unit/chat-previous-response-id-preserve-mode.test.ts create mode 100644 tests/unit/responses-continuation-store.test.ts diff --git a/open-sse/handlers/chatCore/attemptLogging.ts b/open-sse/handlers/chatCore/attemptLogging.ts index 245aafdcc3..bbe049cd63 100644 --- a/open-sse/handlers/chatCore/attemptLogging.ts +++ b/open-sse/handlers/chatCore/attemptLogging.ts @@ -15,9 +15,24 @@ import { logAuditEvent } from "@/lib/compliance"; import { emit } from "@/lib/events/eventBus"; import type { RequestCompletedPayload, RequestFailedPayload } from "@/lib/events/types"; import { saveCallLog } from "@/lib/usageDb"; +import { FORMATS } from "../../translator/formats.ts"; import { cloneBoundedChatLogPayload, truncateForLog } from "./logTruncation.ts"; import { attachLogMeta } from "./cacheUsageMeta.ts"; +/** + * Extract the OpenAI Responses API response id this attempt produced, so it + * can be indexed for OmniRoute-native `previous_response_id` continuation + * (see src/lib/db/responsesContinuationStore.ts). Only meaningful when the + * client actually used the Responses endpoint -- a Chat Completions + * `chatcmpl-*` id must never be mistaken for a Responses response id. + */ +function extractResponsesId(sourceFormat: unknown, clientResponse: unknown): string | null { + if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return null; + if (!clientResponse || typeof clientResponse !== "object") return null; + const id = (clientResponse as { id?: unknown }).id; + return typeof id === "string" && id.length > 0 ? id : null; +} + export type PersistAttemptLogsArgs = { status: number; tokens?: unknown; @@ -276,6 +291,7 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt correlationId, modelPinned: modelPinned || false, sessionTag: sessionTag || null, + responseId: extractResponsesId(sourceFormat, clientResponse), }).catch(() => {}); // Emit the terminal request-lifecycle event to the live dashboard bus. `request.started` diff --git a/src/lib/db/migrations/154_call_logs_response_id.sql b/src/lib/db/migrations/154_call_logs_response_id.sql new file mode 100644 index 0000000000..c03f117037 --- /dev/null +++ b/src/lib/db/migrations/154_call_logs_response_id.sql @@ -0,0 +1,11 @@ +-- 154_call_logs_response_id.sql +-- Index a completed OpenAI Responses API call by the response id it returned +-- to the client (real upstream id, or OmniRoute's own synthesized `resp_` +-- id — see normalizeResponsesId in open-sse/handlers/responseSanitizer.ts). +-- Lets a later request's `previous_response_id` resolve back to this row's +-- already-captured call-log artifact (full, untruncated request/response +-- pipeline payloads) instead of duplicating conversation content into a +-- second store. See src/lib/db/responsesContinuationStore.ts. + +ALTER TABLE call_logs ADD COLUMN response_id TEXT DEFAULT NULL; +CREATE INDEX IF NOT EXISTS idx_cl_response_id ON call_logs(response_id); diff --git a/src/lib/db/responsesContinuationStore.ts b/src/lib/db/responsesContinuationStore.ts new file mode 100644 index 0000000000..a3b846fb95 --- /dev/null +++ b/src/lib/db/responsesContinuationStore.ts @@ -0,0 +1,75 @@ +/** + * responsesContinuationStore.ts — OmniRoute-native `previous_response_id` + * virtualization for the OpenAI Responses API. + * + * Exposes `previous_response_id` continuation to clients unconditionally, + * regardless of whether the actual upstream provider for a connection + * supports Responses-API state at all: OmniRoute resolves the response id + * back to the full input/output it produced and reconstructs the full + * request server-side before forwarding upstream (full history, exactly as + * today) -- the client only ever has to resend the new delta. + * + * Storage: reuses the existing call-log pipeline artifact (full, untruncated + * request/response payloads, already gated by `call_log_pipeline_enabled` + * and already retained/cleaned up by the existing call-log lifecycle) + * instead of duplicating conversation content into a second store. Only a + * lightweight `call_logs.response_id` index (154_call_logs_response_id.sql) + * is new. Every lookup is scoped by `api_key_id` -- one client can never + * resolve another client's stored conversation. + */ + +import { getDbInstance } from "./core"; +import { readCallArtifact } from "../usage/callLogArtifacts"; + +type ResponsesContinuationState = { + input: unknown[]; + output: unknown[]; +}; + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Resolve the full input + output a prior Responses API call produced, so + * the caller can reconstruct `full_input = stored.input + stored.output + + * new_delta`. Returns null on any lookup/read/shape failure (unknown id, + * wrong tenant, artifact missing, or an artifact whose pipeline payload was + * size-limit-omitted -- see MAX_CALL_LOG_ARTIFACT_BYTES in + * callLogArtifacts.ts) so the caller can fail closed and ask the client to + * resend full history, exactly like a real `previous_response_not_found` + * from OpenAI itself. + */ +export function resolvePreviousResponseState( + responseId: string, + apiKeyId: string | null | undefined +): ResponsesContinuationState | null { + if (!responseId) return null; + + const db = getDbInstance(); + const row = db + .prepare( + `SELECT artifact_relpath, api_key_id FROM call_logs + WHERE response_id = ? AND detail_state = 'ready' + ORDER BY timestamp DESC LIMIT 1` + ) + .get(responseId) as { artifact_relpath: string | null; api_key_id: string | null } | undefined; + + if (!row || !row.artifact_relpath) return null; + // Tenant isolation: a response id is only ever handed back to the API key + // that created it. A stored row with no api_key_id at all (no-log/legacy) + // can never be resolved by any key -- fail closed rather than guess. + if (!apiKeyId || row.api_key_id !== apiKeyId) return null; + + const { artifact, state } = readCallArtifact(row.artifact_relpath); + if (state !== "ready" || !artifact?.pipeline) return null; + + const providerRequest = artifact.pipeline.providerRequest as { body?: unknown } | undefined; + const clientResponse = artifact.pipeline.clientResponse as { output?: unknown } | undefined; + + const input = isPlainRecord(providerRequest?.body) ? providerRequest.body.input : undefined; + const output = clientResponse?.output; + if (!Array.isArray(input) || !Array.isArray(output)) return null; + + return { input, output }; +} diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 70e7eb04c3..81c8ff9ae5 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -96,6 +96,7 @@ export * from "./db/compressionContextBudget"; export * from "./db/compressionRunTelemetry"; export * from "./db/jobRegistryDb"; export * from "./db/modelContextOverrides"; +export * from "./db/responsesContinuationStore"; export { getApiKeys, diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index 77f1a60a76..7f2ffbc820 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -496,6 +496,11 @@ export async function saveCallLog(entry: any) { correlationId: entry.correlationId || null, modelPinned: entry.modelPinned ? 1 : 0, sessionTag: entry.sessionTag || null, + // OpenAI Responses API response id, when this attempt produced one -- + // indexed so a later request's `previous_response_id` can resolve + // this row's artifact for OmniRoute-native continuation. See + // src/lib/db/responsesContinuationStore.ts. + responseId: typeof entry.responseId === "string" ? entry.responseId : null, }; const requestSummary = noLogEnabled @@ -544,7 +549,7 @@ export async function saveCallLog(entry: any) { combo_name, combo_step_id, combo_execution_key, error_summary, detail_state, artifact_relpath, artifact_size_bytes, artifact_sha256, has_request_body, has_response_body, has_pipeline_details, request_summary, - correlation_id, model_pinned, session_tag + correlation_id, model_pinned, session_tag, response_id ) VALUES ( @id, @timestamp, @method, @path, @status, @model, @requestedModel, @provider, @@ -555,7 +560,7 @@ export async function saveCallLog(entry: any) { @comboName, @comboStepId, @comboExecutionKey, @errorSummary, @detailState, @artifactRelPath, @artifactSizeBytes, @artifactSha256, @hasRequestBody, @hasResponseBody, @hasPipelineDetails, @requestSummary, - @correlationId, @modelPinned, @sessionTag + @correlationId, @modelPinned, @sessionTag, @responseId ) ` ).run({ diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 2e0d426722..6362433cd8 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -4,6 +4,10 @@ import * as chatAdmission from "./chatAdmission.ts"; import { buildClientRawRequest, resolveDispatchClientRawRequest } from "./chat/clientRawRequest.ts"; export { buildClientRawRequest, resolveDispatchClientRawRequest }; import { normalizeReasoningRequest } from "@/shared/reasoning/effortStandardization"; +import { isDetailedLoggingEnabled } from "@/lib/db/detailedLogs"; +import { resolvePreviousResponseState } from "@/lib/db/responsesContinuationStore"; +import { normalizeResponsesPreviousResponseIdMode } from "@omniroute/open-sse/utils/responsesStatePolicy.ts"; +import { FORMATS } from "@omniroute/open-sse/translator/formats.ts"; import { resolveRoutingModel, RoutingModelOps } from "./resolveRoutingModel"; import { getProviderCredentialsWithQuotaPreflight, @@ -517,6 +521,66 @@ async function handleChatImplementation( const bypassProviderQuotaPolicy = hasProviderQuotaBypassScope(apiKeyInfo?.scopes); telemetry.endPhase(); + // OmniRoute-native `previous_response_id` continuation: reconstruct the + // full input server-side before ANY downstream validation/translation + // sees this request, so everything after this point (message-shape + // guards, token-budget checks, provider translation) treats it exactly + // like an ordinary full-history request. This works regardless of + // whether the eventually-selected upstream provider itself understands + // Responses-API state -- OmniRoute always forwards the full reconstructed + // history upstream, exactly as it does today for a non-continued request. + // Client<->OmniRoute traffic shrinks to the new delta; OmniRoute<-> + // provider traffic is unchanged. See src/lib/db/responsesContinuationStore.ts. + // + // Skipped entirely when the operator has set responsesPreviousResponseIdMode + // to "preserve": that mode is the explicit, connection-independent contract + // for "never touch previous_response_id, let the upstream resolve it + // natively" (see applyResponsesPreviousResponseIdPolicy in chatCore.ts, + // which enforces it per-target once a connection is selected). Codex's own + // executor relies on an untouched previous_response_id to delegate history + // resolution upstream (stripOrphanedCodexFunctionCallOutputs in codex.ts); + // reconstructing and deleting the field here would make that downstream + // "preserve" enforcement a no-op since the field would already be gone. + const settingsForContinuation = await getCachedSettings().catch( + () => ({}) as Record + ); + const previousResponseIdMode = normalizeResponsesPreviousResponseIdMode( + (settingsForContinuation as { responsesPreviousResponseIdMode?: unknown }) + .responsesPreviousResponseIdMode + ); + if ( + previousResponseIdMode !== "preserve" && + sourceFormat === FORMATS.OPENAI_RESPONSES && + typeof (body as { previous_response_id?: unknown }).previous_response_id === "string" + ) { + const previousResponseId = (body as { previous_response_id: string }).previous_response_id; + const detailedLoggingEnabled = await isDetailedLoggingEnabled(); + const stored = detailedLoggingEnabled + ? resolvePreviousResponseState(previousResponseId, apiKeyInfo?.id ?? null) + : null; + if (!stored) { + // Matches OpenAI's own `previous_response_not_found` contract (missing + // or expired server-side state) so a client with the matching retry + // behavior -- resend the full request, same turn -- recovers exactly + // as it would against the real OpenAI backend. + return new Response( + JSON.stringify({ + error: { + message: "Previous response not found.", + type: "invalid_request_error", + code: "previous_response_not_found", + }, + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + const deltaInput = Array.isArray((body as { input?: unknown }).input) + ? (body as { input: unknown[] }).input + : []; + body = { ...body, input: [...stored.input, ...stored.output, ...deltaInput] }; + delete (body as { previous_response_id?: unknown }).previous_response_id; + } + const admissionRejection = await admissionContext.acquire(apiKeyInfo?.id, request, body); if (admissionRejection) return admissionRejection; clientRawRequest = chatAdmission.resolveClientRawAfterAdmission(clientRawRequest, () => diff --git a/tests/unit/chat-previous-response-id-preserve-mode.test.ts b/tests/unit/chat-previous-response-id-preserve-mode.test.ts new file mode 100644 index 0000000000..2ae96b62fe --- /dev/null +++ b/tests/unit/chat-previous-response-id-preserve-mode.test.ts @@ -0,0 +1,63 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.ts"; + +// Regression guard: the OmniRoute-native previous_response_id virtualization +// in chat.ts (see src/lib/db/responsesContinuationStore.ts) used to run +// unconditionally for every OpenAI-Responses-source request, before target +// selection and before applyResponsesPreviousResponseIdPolicy (chatCore.ts) +// ever got a chance to enforce responsesPreviousResponseIdMode. That made +// mode="preserve" -- the explicit, connection-independent contract for "let +// the upstream resolve previous_response_id natively" -- a no-op: the field +// was already deleted and replaced with a reconstructed `input` before the +// policy ever ran, hard-rejecting any previous_response_id that OmniRoute's +// own call-log store never captured, instead of forwarding it upstream like +// a real Codex/ChatGPT-store-enabled connection expects. + +const harness = await createChatPipelineHarness("chat-prev-resp-id-preserve"); +const { buildRequest, handleChat, resetStorage, settingsDb } = harness; + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + await harness.cleanup(); +}); + +async function postResponses(previousResponseId: string) { + const response = await handleChat( + buildRequest({ + url: "http://localhost/v1/responses", + body: { + model: "nonexistent-provider/nonexistent-model", + stream: false, + previous_response_id: previousResponseId, + input: [{ type: "message", role: "user", content: "continue" }], + }, + }) + ); + const payload = (await response.json()) as { error?: { code?: string; message?: string } }; + return { status: response.status, payload }; +} + +test("mode=auto (default): unknown previous_response_id is virtualized and fails closed with previous_response_not_found", async () => { + const { status, payload } = await postResponses("resp_never_seen_by_omniroute"); + assert.equal(status, 400); + assert.equal(payload.error?.code, "previous_response_not_found"); +}); + +test("mode=preserve: previous_response_id is left untouched, request proceeds to normal routing instead of local virtualization", async () => { + await settingsDb.updateSettings({ responsesPreviousResponseIdMode: "preserve" }); + + const { status, payload } = await postResponses("resp_never_seen_by_omniroute"); + + // Virtualization is skipped entirely: the id is not looked up against + // OmniRoute's own store, so this must NOT be the virtualization's + // previous_response_not_found rejection. It falls through to ordinary + // model routing, which 404s because the test model doesn't exist -- + // exactly like a request with no previous_response_id at all would. + assert.notEqual(payload.error?.code, "previous_response_not_found"); + assert.equal(status, 404); +}); diff --git a/tests/unit/responses-continuation-store.test.ts b/tests/unit/responses-continuation-store.test.ts new file mode 100644 index 0000000000..fee45e4290 --- /dev/null +++ b/tests/unit/responses-continuation-store.test.ts @@ -0,0 +1,159 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// OmniRoute-native `previous_response_id` virtualization: resolvePreviousResponseState +// resolves a response id back to the full input/output a prior call produced by +// reading the already-persisted call-log artifact, so a later request can be +// reconstructed to full history server-side without duplicating conversation +// content into a second store. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-responses-continuation-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const store = await import("../../src/lib/db/responsesContinuationStore.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +function insertCallLog(row: { + id: string; + responseId: string | null; + apiKeyId: string | null; + detailState: string; + artifactRelPath: string | null; +}) { + const db = core.getDbInstance(); + db.prepare( + `INSERT INTO call_logs + (id, timestamp, method, path, status, model, provider, account, duration, + tokens_in, tokens_out, api_key_id, detail_state, artifact_relpath, response_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + row.id, + new Date().toISOString(), + "POST", + "/v1/responses", + 200, + "gpt-5.4-pro", + "openai", + "acc1", + 100, + 10, + 20, + row.apiKeyId, + row.detailState, + row.artifactRelPath, + row.responseId + ); +} + +function writeArtifact(relPath: string, pipeline: Record) { + const absPath = path.join(TEST_DATA_DIR, "call_logs", relPath); + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync( + absPath, + JSON.stringify({ + schemaVersion: 5, + summary: {}, + requestBody: null, + responseBody: null, + error: null, + pipeline, + }) + ); +} + +test("resolvePreviousResponseState reconstructs input/output from the call-log artifact", () => { + insertCallLog({ + id: "log-1", + responseId: "resp_abc", + apiKeyId: "key-1", + detailState: "ready", + artifactRelPath: "2026-01-01/log-1.json", + }); + writeArtifact("2026-01-01/log-1.json", { + providerRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } }, + clientResponse: { + id: "resp_abc", + output: [{ type: "message", role: "assistant", content: "hello" }], + }, + }); + + const result = store.resolvePreviousResponseState("resp_abc", "key-1"); + assert.deepEqual(result, { + input: [{ type: "message", role: "user", content: "hi" }], + output: [{ type: "message", role: "assistant", content: "hello" }], + }); +}); + +test("resolvePreviousResponseState returns null for an unknown response id", () => { + const result = store.resolvePreviousResponseState("resp_does_not_exist", "key-1"); + assert.equal(result, null); +}); + +test("resolvePreviousResponseState never crosses tenants (scoped by api_key_id)", () => { + insertCallLog({ + id: "log-2", + responseId: "resp_tenant_a", + apiKeyId: "key-a", + detailState: "ready", + artifactRelPath: "2026-01-01/log-2.json", + }); + writeArtifact("2026-01-01/log-2.json", { + providerRequest: { body: { input: [{ role: "user", content: "secret" }] } }, + clientResponse: { id: "resp_tenant_a", output: [{ role: "assistant", content: "reply" }] }, + }); + + assert.equal(store.resolvePreviousResponseState("resp_tenant_a", "key-b"), null); + assert.equal(store.resolvePreviousResponseState("resp_tenant_a", null), null); + assert.notEqual(store.resolvePreviousResponseState("resp_tenant_a", "key-a"), null); +}); + +test("resolvePreviousResponseState returns null when the artifact is missing on disk", () => { + insertCallLog({ + id: "log-3", + responseId: "resp_missing_file", + apiKeyId: "key-1", + detailState: "ready", + artifactRelPath: "2026-01-01/does-not-exist.json", + }); + + assert.equal(store.resolvePreviousResponseState("resp_missing_file", "key-1"), null); +}); + +test("resolvePreviousResponseState fails closed when the pipeline payload was size-limit-omitted", () => { + insertCallLog({ + id: "log-4", + responseId: "resp_omitted", + apiKeyId: "key-1", + detailState: "ready", + artifactRelPath: "2026-01-01/log-4.json", + }); + // A size-limit-omitted payload is replaced with a placeholder string, not + // an object -- resolvePreviousResponseState must never try to reconstruct + // from it and silently drop history. + writeArtifact("2026-01-01/log-4.json", { + providerRequest: { body: "[omitted: call log artifact size limit exceeded]" }, + clientResponse: { id: "resp_omitted", output: [] }, + }); + + assert.equal(store.resolvePreviousResponseState("resp_omitted", "key-1"), null); +}); + +test("resolvePreviousResponseState returns null when detail logging was never captured for this row", () => { + insertCallLog({ + id: "log-5", + responseId: "resp_no_detail", + apiKeyId: "key-1", + detailState: "none", + artifactRelPath: null, + }); + + assert.equal(store.resolvePreviousResponseState("resp_no_detail", "key-1"), null); +}); From 722748f7c197f66a7ca47424a171502bfc9d4a69 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:22:42 +0200 Subject: [PATCH 03/10] fix(sse): keep Codex quota headers under the forwarding budget (#10306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(deps): bump nanoid, dompurify for 2 new Dependabot alerts (#189, #190) Bumps: nanoid ^3.3.17 (was transitive, now overridden), dompurify ^3.4.13 (with monaco-editor scoped override). Closes Dependabot #189, #190. Remaining #182-#188 (js-yaml + mermaid) already closed by #9651 merge — awaiting Dependabot re-scan. npm audit → 0 vulnerabilities. * fix(repo): harden .gitignore to also ignore a _tasks symlink (/_tasks) _tasks is a SEPARATE nested git repo (gitignored). The pattern _tasks/ (trailing slash) ignores only a directory, not a SYMLINK named _tasks. A self-referential _tasks symlink can slip in via git add -A and, once pulled, checkout materializes it over the real _tasks repo (destroying plans/specs/hands-off). Anchored /_tasks ignores the symlink too, preventing re-capture. * Hide health-check excluded models from /v1/models catalog (#10026) Mirror the request-time exclusion rule (provider_specific_data.excludedModels) in the unified catalog builder: a model is hidden when its provider has connections but none of them is eligible for it. Applied across the PROVIDER_MODELS, synced, custom, alias-backed, and managed-fallback loops so ghost models no longer appear as available. Co-authored-by: ritheshcn25 * fix(models): memoize getModelsDevPricing (event loop / healthz) (#10055) * fix(models): memoize getModelsDevPricing for /v1/models catalog resolveCatalogPricing called getModelsDevPricing once per model while building GET /v1/models. Each call re-scanned models_dev_pricing and JSON.parsed every row (~10k SQL scans + multi-GB parse work), pegging the event loop so even /healthz timed out (#9685, #10052). Memoize the parsed map until saveModelsDevPricing / clearModelsDevPricing and add a unit test for invalidation. Signed-off-by: Ravi Tharuma * fix(db): invalidate modelsDevPricing cache on DB reset (#10055) Copilot review fixes: 1. Register invalidateModelsDevPricingCache() with DB state reset system so resetDbInstance() clears the process-local memo, preventing stale pricing data from surviving across DB reset/restore operations. 2. Add test assertion verifying DB reset bypasses the memo (Copilot #10055). The process-local memo at modelsDevSync.ts:204 caches getModelsDevPricing() results until saveModelsDevPricing()/clearModelsDevPricing() to avoid re-scanning all pricing rows on every /v1/models request. Without this hook, backup restore and test DB resets would serve stale cached data from the previous connection. Tests: npm run test:unit:serial -- tests/unit/modelsDevSync-extended.test.ts --------- Signed-off-by: Ravi Tharuma Co-authored-by: Ravi Tharuma Co-authored-by: Cursor Agent * fix(sse): keep Codex quota headers under the forwarding budget The 768-byte cap plus priority-3 for any name that does not contain "ratelimit" dropped x-codex-*-used-percent / reset / credits on every stream. x-codex-turn-state (314 bytes) ate the budget. Raise the cap, treat Codex quota headers as rate-limit priority, and do not forward turn-state. --------- Signed-off-by: Ravi Tharuma Co-authored-by: diegosouzapw Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: diegosouzapw Co-authored-by: ritheshcn25 Co-authored-by: ritheshcn25 Co-authored-by: Ravi Tharuma Co-authored-by: Cursor Agent --- .../fixes/forward-codex-quota-headers.md | 1 + open-sse/handlers/chatCore/responseHeaders.ts | 27 +++++++++++++++++++ .../unit/middleware-header-strip-5849.test.ts | 19 +++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 changelog.d/fixes/forward-codex-quota-headers.md diff --git a/changelog.d/fixes/forward-codex-quota-headers.md b/changelog.d/fixes/forward-codex-quota-headers.md new file mode 100644 index 0000000000..86aa1e7df4 --- /dev/null +++ b/changelog.d/fixes/forward-codex-quota-headers.md @@ -0,0 +1 @@ +- **fix(sse):** keep Codex/Anthropic quota headers under the upstream forwarding budget; drop `x-codex-turn-state` and raise the 768-byte cap (`open-sse/handlers/chatCore/responseHeaders.ts`) diff --git a/open-sse/handlers/chatCore/responseHeaders.ts b/open-sse/handlers/chatCore/responseHeaders.ts index 1a6602f506..59c45ba829 100644 --- a/open-sse/handlers/chatCore/responseHeaders.ts +++ b/open-sse/handlers/chatCore/responseHeaders.ts @@ -28,6 +28,9 @@ const STREAMING_RESPONSE_HEADER_DENYLIST = new Set([ "x-amz-security-token", "x-auth-token", "x-accel-buffering", + // 314-byte Codex session blob. It is not a client rate-limit signal and + // alone ate ~40% of the old 768-byte budget, evicting x-codex-*-used-percent. + "x-codex-turn-state", ]); const DEFAULT_FORWARDED_HEADER_BUDGET_BYTES = 768; @@ -105,6 +108,30 @@ function getForwardingPriority(headerName: string): number { } if (normalized === "retry-after") return 1; if (normalized.includes("ratelimit") || normalized.includes("rate-limit")) return 2; + // Codex quota / reset / credits do not contain "ratelimit" in the name, + // so they used to fall through to priority 3 and lose to date/csp/cf-ray. + if ( + normalized.startsWith("x-codex-") && + (normalized.includes("used-percent") || + normalized.includes("reset") || + normalized.includes("window") || + normalized.includes("credits") || + normalized.includes("over-secondary") || + normalized.includes("plan-type")) + ) { + return 2; + } + if ( + normalized === "date" || + normalized === "vary" || + normalized === "x-robots-tag" || + normalized === "content-security-policy" || + normalized.startsWith("cf-") || + normalized.endsWith("-organization-id") || + normalized.endsWith("-workspace-id") + ) { + return 4; + } return 3; } diff --git a/tests/unit/middleware-header-strip-5849.test.ts b/tests/unit/middleware-header-strip-5849.test.ts index 7fbe9b18e3..ed9ea6be4a 100644 --- a/tests/unit/middleware-header-strip-5849.test.ts +++ b/tests/unit/middleware-header-strip-5849.test.ts @@ -100,6 +100,25 @@ test("streaming path bounds the aggregate size of many small upstream response h assert.equal(getHeaderValue(out, "x-request-id"), "req-many-small-headers"); }); +test("streaming path keeps Codex quota headers and drops x-codex-turn-state", () => { + const upstream = new Headers(); + upstream.set("x-codex-turn-state", "s".repeat(300)); + upstream.set("content-security-policy", "default-src 'none'"); + upstream.set("cf-ray", "abcdefghijklmnopqrstuvwxyz"); + upstream.set("date", "Thu, 13 Aug 2026 21:00:00 GMT"); + upstream.set("x-codex-primary-used-percent", "41"); + upstream.set("x-codex-primary-reset-after-seconds", "120"); + upstream.set("x-codex-credits-has-credits", "true"); + upstream.set("x-request-id", "req-codex-quota"); + + const out = buildStreamingResponseHeaders(upstream, {}, null); + assert.equal(getHeaderValue(out, "x-request-id"), "req-codex-quota"); + assert.equal(getHeaderValue(out, "x-codex-primary-used-percent"), "41"); + assert.equal(getHeaderValue(out, "x-codex-primary-reset-after-seconds"), "120"); + assert.equal(getHeaderValue(out, "x-codex-credits-has-credits"), "true"); + assert.equal(getHeaderValue(out, "x-codex-turn-state"), undefined); +}); + test("streaming path prioritizes request and rate-limit headers over diagnostics", () => { const upstream = new Headers(); for (let index = 0; index < 20; index += 1) { From 24ef1dc3d477441503fb88a46f831792ecf0a31e Mon Sep 17 00:00:00 2001 From: blarovse Date: Mon, 17 Aug 2026 12:23:10 +0100 Subject: [PATCH 04/10] =?UTF-8?q?Sanitize=20test=20fixtures,=20add=20devel?= =?UTF-8?q?oper=20.env=20guidance,=20and=20add=20gitleaks=E2=80=A6=20(#104?= =?UTF-8?q?11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Sanitize test fixtures, add developer .env guidance, and add gitleaks workflow - Replace realistic-looking AWS keys and PEM fixtures in unit tests with synthetic placeholders to avoid false positives from secret scanners. - Add docs/DEVELOPER-ENVIRONMENT.md describing postinstall .env behavior and remediation guidance. - Add .github/workflows/gitleaks.yml to run gitleaks on pull requests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add gitleaks baseline and CI baseline support; update ignore and PR body\n\n- Copy gitleaks-local.json -> gitleaks-baseline.json\n- Add --baseline-path to workflow\n- Allowlist baseline in .gitleaks.toml\n- Ignore gitleaks-local.json\n- Add PR_BODY.md with scan summary\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore(security): fix gitleaks config, drop redundant baseline/CI, clean doc artifacts - Fix the malformed .gitleaks.toml [[rules]] block: an inline [rules.allowlist] with only paths (no regex/path at rule level) made gitleaks refuse to load the config (`FTL Failed to load config ... both |regex| and |path| are empty`), turning the project's blocking check-secrets ratchet into a hard failure. Verified: check-secrets config now loads and exits 0. - Reconcile with the existing gitleaks gate: remove the redundant .github/workflows/gitleaks.yml and root gitleaks-baseline.json (a second, differently-scoped scanning mechanism + an unreviewed 430-finding blanket baseline) — the project already runs scripts/check/check-secrets.mjs as a blocking ratchet in ci.yml/quality.yml and its .gitleaks.toml policy is to fix real findings, not blanket-allowlist them. - Remove the stray PR_BODY.md automation artifact from the repo root. - Fix the duplicated
tag in README.md. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: OmniRoute Bot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: blarovse <312250233+blarovse@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .gitignore | 2 + README.md | 2 + docs/DEVELOPER-ENVIRONMENT.md | 29 ++ tests/unit/github-collector.test.ts | 2 +- tests/unit/piiSanitizer.test.ts | 2 +- tests/unit/sre-redact-logs.test.ts | 515 ++++++++++++++-------------- 6 files changed, 294 insertions(+), 258 deletions(-) create mode 100644 docs/DEVELOPER-ENVIRONMENT.md diff --git a/.gitignore b/.gitignore index 88bfda3ece..b91ffc9f01 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,8 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +# Local gitleaks artifacts (do not commit) +gitleaks-local.json !.env.example !.env.homolog.example !.env.devin-bridge.example diff --git a/README.md b/README.md index ba0cf4d2ba..dad0af6aa7 100644 --- a/README.md +++ b/README.md @@ -513,6 +513,8 @@ Pix copia-e-cola:
+

Developer notes: The project may generate a local .env file during npm install/postinstall for developer convenience. This file is intentionally ignored via .gitignore (see .gitignore) and must never be committed — if accidentally committed, rotate any exposed secrets and remove the file from history. See docs/DEVELOPER-ENVIRONMENT.md for guidance on managing local environment files and secrets.

+ ## 📡 OmniRoute Radar The main free-tier headline remains **~1.53B tokens/month** from the documented, diff --git a/docs/DEVELOPER-ENVIRONMENT.md b/docs/DEVELOPER-ENVIRONMENT.md new file mode 100644 index 0000000000..0b70014bd4 --- /dev/null +++ b/docs/DEVELOPER-ENVIRONMENT.md @@ -0,0 +1,29 @@ +# Developer environment notes + +This page explains the project's local `.env` behavior and how to handle environment files and secrets when developing OmniRoute. + +## .env postinstall behavior + +The project may generate a local `.env` file during `npm install` / `postinstall` for developer convenience. This file is intended only for local development and testing and must never be committed to version control. + +Key points: + +- The repository's `.gitignore` already ignores `.env*` files (see the `.gitignore` entry). Do not remove or alter that rule unless you deliberately intend to commit a specific example file and have a documented process for it. +- If a real secret is accidentally committed to the repo, rotate/revoke the credential immediately and remove it from the repository history (for example, using `git filter-repo` or an equivalent remediation workflow). Contact the security/contact owner if you need help. +- For CI and production, use the CI secrets or a secrets manager (GitHub Actions Secrets, Azure Key Vault, HashiCorp Vault, etc.) rather than committing secrets to files. + +## Recommended local workflow + +- Keep `.env` in your local workspace only. Use `.env.example` (already tracked) to document required variables and acceptable example values. +- When running tests locally that require secret-like values, prefer synthetic placeholders or runtime-generated ephemeral keys rather than real credentials. +- Add a short comment in tests that use placeholders so reviewers understand the fixture is synthetic. + +## Scanner notes + +- Some compiled or binary assets (e.g., embedded base64 WASM blobs) can contain ASCII substrings that look like credentials and may trigger text-based secret scanners. If these assets are legitimate, either mark them in the scanner's allowlist or exclude the directories in the scanner config. + +## If you find a leak + +1. Rotate/revoke the key immediately. +2. Remove the secret from the history and force-push a cleaned branch if necessary. +3. Notify maintainers and follow your org's incident response checklist. diff --git a/tests/unit/github-collector.test.ts b/tests/unit/github-collector.test.ts index e61a4e0d0f..17c725be6c 100644 --- a/tests/unit/github-collector.test.ts +++ b/tests/unit/github-collector.test.ts @@ -129,7 +129,7 @@ void test("scanText: detects eval(base64) pattern", () => { void test("scanText: detects hardcoded private keys", () => { const content = - "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----"; + "-----BEGIN RSA PRIVATE KEY-----\nTEST_RSA_PRIVATE_KEY_PLACEHOLDER_DO_NOT_USE\n-----END RSA PRIVATE KEY-----"; const findings = scanText(content, "leaked.md"); assert.ok(findings.some((f) => f.pattern.includes("Private key"))); }); diff --git a/tests/unit/piiSanitizer.test.ts b/tests/unit/piiSanitizer.test.ts index 76db2557e4..8a035b358d 100644 --- a/tests/unit/piiSanitizer.test.ts +++ b/tests/unit/piiSanitizer.test.ts @@ -147,7 +147,7 @@ test("sanitizePII detects AWS access key", async () => { delete process.env.PII_RESPONSE_SANITIZATION_MODE; const { sanitizePII } = await import("@/lib/piiSanitizer"); - const input = "Key: AKIAIOSFODNN7EXAMPLE"; + const input = "Key: AKIAEXAMPLE123456789"; const result = sanitizePII(input); assert.ok(result.text.includes("[AWS_KEY_REDACTED]"), "AWS access key should be redacted"); diff --git a/tests/unit/sre-redact-logs.test.ts b/tests/unit/sre-redact-logs.test.ts index 4d628fbb2c..6c180fe792 100644 --- a/tests/unit/sre-redact-logs.test.ts +++ b/tests/unit/sre-redact-logs.test.ts @@ -1,256 +1,259 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { Writable } from "node:stream"; -import { redactString, redact, RedactTransform } from "../../scripts/sre/redact-logs.mjs"; - -// ─── 1. email ──────────────────────────────────────────────────────────────── - -test("redactString: standard email is replaced", () => { - const { output, counts } = redactString("contact alice@example.com for details"); - assert.equal(output, "contact [REDACTED_EMAIL] for details"); - assert.equal(counts.EMAIL, 1); -}); - -test("redactString: sub-domain email is replaced", () => { - const { output } = redactString("ping ops+sre@mail.omniroute.dev today"); - assert.equal(output, "ping [REDACTED_EMAIL] today"); -}); - -test("redactString: email-like but missing TLD is preserved", () => { - // "user@host" is not a valid email; should NOT match. - const { output, counts } = redactString("note user@host is mentioned"); - assert.equal(output, "note user@host is mentioned"); - assert.equal(counts.EMAIL ?? 0, 0); -}); - -// ─── 2. IPv4 ──────────────────────────────────────────────────────────────── - -test("redactString: IPv4 address is redacted", () => { - const { output, counts } = redactString("client connected from 192.168.1.42"); - assert.equal(output, "client connected from [REDACTED_IPV4]"); - assert.equal(counts.IPV4, 1); -}); - -test("redactString: IPv4 with port is redacted including port", () => { - const { output } = redactString("connect 10.0.0.1:5432 succeeded"); - assert.equal(output, "connect [REDACTED_IPV4] succeeded"); -}); - -test("redactString: invalid octet (256) is NOT redacted", () => { - const { output, counts } = redactString("value 256.300.1.1 invalid"); - // The "256.300.1.1" should NOT match (octets > 255). - // It's possible that a partial substring like "56.300" might still match - // through other regex runs; assert that no full-IP redaction appears. - assert.equal(output.includes("[REDACTED_IPV4]"), false); - assert.equal((counts.IPV4 ?? 0), 0); -}); - -test("redactString: 127.0.0.1 is redacted (loopback is still PII for log shipping)", () => { - const { output } = redactString("local check from 127.0.0.1 ok"); - assert.equal(output, "local check from [REDACTED_IPV4] ok"); -}); - -// ─── 3. IPv6 ──────────────────────────────────────────────────────────────── - -test("redactString: full IPv6 is redacted", () => { - const { output, counts } = redactString("peer 2001:0db8:85a3:0000:0000:8a2e:0370:7334 connected"); - assert.equal(output, "peer [REDACTED_IPV6] connected"); - assert.equal(counts.IPV6, 1); -}); - -test("redactString: compressed IPv6 is redacted", () => { - const { output } = redactString("from fe80::1 to ::1"); - // Both addresses should be replaced. - assert.match(output, /from \[REDACTED_IPV6\] to \[REDACTED_IPV6\]/); -}); - -test("redactString: ::1 loopback is redacted", () => { - const { output } = redactString("traffic from ::1 only"); - assert.match(output, /traffic from \[REDACTED_IPV6\] only/); -}); - -// ─── 4. Bearer tokens ─────────────────────────────────────────────────────── - -test("redactString: Bearer token in Authorization header is redacted", () => { - const { output, counts } = redactString("Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345"); - assert.match(output, /\[REDACTED_BEARER\]/); - assert.equal(counts.BEARER, 1); -}); - -test("redactString: 'Bearer' word without a token is preserved", () => { - const { output, counts } = redactString("the bearer of bad news"); - // "bad news" is too short to match (needs 16+ chars). - assert.equal(output, "the bearer of bad news"); - assert.equal((counts.BEARER ?? 0), 0); -}); - -// ─── 5. OpenAI keys ───────────────────────────────────────────────────────── - -test("redactString: sk- prefix key is redacted", () => { - const { output, counts } = redactString("OPENAI_KEY=sk-proj-abc123XYZ456def789GHI012jkl"); - assert.match(output, /\[REDACTED_API_KEY\]/); - assert.ok((counts.OPENAI_KEY ?? 0) >= 1 || (counts.GENERIC_KEY ?? 0) >= 1); -}); - -test("redactString: sk- short token (too short) is NOT redacted", () => { - // 18 chars after "sk-" — minimum is 20. - const { output } = redactString("noise: sk-abcdefghijklmnopqr here"); - assert.equal(output, "noise: sk-abcdefghijklmnopqr here"); -}); - -test("redactString: anthropic sk-ant- key is redacted", () => { - const { output, counts } = redactString("key: sk-ant-api03-abcdefghij1234567890ABCD"); - assert.match(output, /\[REDACTED_API_KEY\]/); - assert.equal(counts.ANTHROPIC_KEY, 1); -}); - -test("redactString: Google AIza key is redacted", () => { - // Total 39 chars: "AIza" (4) + 35 alnum/hyphen/underscore. - const key = "AIzaSyD-1234567890abcdefghijklmnopqrstu"; - assert.equal(key.length, 39); - const { output, counts } = redactString(`google_key=${key}`); - assert.match(output, /\[REDACTED_API_KEY\]/); - assert.equal(counts.GOOGLE_KEY, 1); -}); - -// ─── 6. GitHub tokens ─────────────────────────────────────────────────────── - -test("redactString: ghp_ token is redacted", () => { - const token = "ghp_" + "a".repeat(36); - const { output, counts } = redactString(`token=${token}`); - assert.match(output, /\[REDACTED_API_KEY\]/); - assert.equal(counts.GITHUB_TOKEN, 1); -}); - -test("redactString: github_pat_ token is redacted", () => { - const token = "github_pat_" + "B".repeat(40); - const { output } = redactString(`pat is ${token} end`); - assert.match(output, /pat is \[REDACTED_API_KEY\] end/); -}); - -// ─── 7. AWS keys ──────────────────────────────────────────────────────────── - -test("redactString: AKIA access key is redacted", () => { - const key = "AKIAIOSFODNN7EXAMPLE"; // 20 chars - const { output, counts } = redactString(`aws_access_key_id=${key}`); - assert.match(output, /\[REDACTED_AWS_KEY\]/); - assert.equal(counts.AWS_KEY, 1); -}); - -test("redactString: ASIA (temporary) key is redacted", () => { - const key = "ASIAIOSFODNN7EXAMPLE"; - const { output, counts } = redactString(`temp=${key}`); - assert.match(output, /\[REDACTED_AWS_KEY\]/); - assert.equal(counts.AWS_KEY, 1); -}); - -// ─── 8. Generic api_key=value ─────────────────────────────────────────────── - -test("redactString: api_key=value pair is redacted", () => { - const { output, counts } = redactString(`api_key=${"x".repeat(20)}`); - assert.match(output, /\[REDACTED_API_KEY\]/); - assert.equal(counts.GENERIC_KEY, 1); -}); - -test("redactString: password=... is redacted", () => { - const { output, counts } = redactString(`password: ${"hunter2hunter2hunter2"}`); - assert.match(output, /\[REDACTED_API_KEY\]/); - assert.equal(counts.GENERIC_KEY, 1); -}); - -test("redactString: short value (< 12 chars) is NOT redacted", () => { - const { output, counts } = redactString("password: short"); - assert.equal(output, "password: short"); - assert.equal((counts.GENERIC_KEY ?? 0), 0); -}); - -// ─── 9. Combined / order of operations ────────────────────────────────────── - -test("redactString: line with email AND ip AND key redacts all three", () => { - const line = `2026-06-25T07:00:00Z ERROR user=alice@example.com ip=10.0.0.5 key=sk-proj-${"A".repeat(30)}`; - const { output, counts } = redactString(line); - assert.match(output, /\[REDACTED_EMAIL\]/); - assert.match(output, /\[REDACTED_IPV4\]/); - assert.match(output, /\[REDACTED_API_KEY\]/); - // Counts should be at least one of each category. - assert.ok((counts.EMAIL ?? 0) >= 1); - assert.ok((counts.IPV4 ?? 0) >= 1); - assert.ok((counts.OPENAI_KEY ?? 0) >= 1); -}); - -test("redactString: empty string yields empty output", () => { - const { output, counts } = redactString(""); - assert.equal(output, ""); - assert.deepEqual(counts, {}); -}); - -test("redactString: non-PII log line is unchanged", () => { - const line = '2026-06-25T07:00:00Z INFO request_id=req_abc123 method=GET path=/v1/models'; - const { output } = redactString(line); - assert.equal(output, line); -}); - -test("redactString: key inside larger word (not at boundary) is not matched", () => { - // `task-abc123XYZ456def789GHI012jkl345` is not preceded by 'sk-' so should - // not match the OPENAI_KEY pattern. - const { output, counts } = redactString("some task-abcdefghij1234567890KL here"); - assert.equal(output, "some task-abcdefghij1234567890KL here"); - assert.equal((counts.OPENAI_KEY ?? 0), 0); -}); - -// ─── 10. Stable markers across runs ───────────────────────────────────────── - -test("redactString: same input twice yields the same redacted output", () => { - const line = `ip=192.168.0.1 user=${"a".repeat(40)}@example.com`; - const first = redactString(line).output; - const second = redactString(line).output; - assert.equal(first, second); -}); - -// ─── 11. redact() shorthand ───────────────────────────────────────────────── - -test("redact(): shorthand returns just the output", () => { - assert.equal(redact("email bob@example.com here"), "email [REDACTED_EMAIL] here"); -}); - -// ─── 12. RedactTransform stream ───────────────────────────────────────────── - -test("RedactTransform: streams input chunks to output, redacting as it goes", async () => { - const t = new RedactTransform(); - const out = []; - const sink = new Writable({ - write(chunk, _enc, cb) { - out.push(typeof chunk === "string" ? chunk : chunk.toString("utf8")); - cb(); - }, - }); - const src = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode("email a@b.com ")); - controller.enqueue(new TextEncoder().encode("ip=1.2.3.4 ")); - controller.enqueue(new TextEncoder().encode("end\n")); - controller.close(); - }, - }); - await src.pipeThrough(new TextDecoderStream()).pipeThrough(t).pipeTo( - new WritableStream({ - write(chunk) { - sink.write(chunk, "utf8", () => {}); - }, - }), - ); - const joined = out.join(""); - assert.match(joined, /\[REDACTED_EMAIL\]/); - assert.match(joined, /\[REDACTED_IPV4\]/); - // Counts accumulated on the transform. - assert.equal(t.counts.EMAIL ?? 0, 1); - assert.equal(t.counts.IPV4 ?? 0, 1); -}); - -// ─── 13. Counts are independent between calls ─────────────────────────────── - -test("redactString: counts do not bleed across calls", () => { - redactString("a@b.com"); - const { counts } = redactString("no pii here at all"); - assert.deepEqual(counts, {}); -}); \ No newline at end of file +import test from "node:test"; +import assert from "node:assert/strict"; +import { Writable } from "node:stream"; +import { redactString, redact, RedactTransform } from "../../scripts/sre/redact-logs.mjs"; + +// ─── 1. email ──────────────────────────────────────────────────────────────── + +test("redactString: standard email is replaced", () => { + const { output, counts } = redactString("contact alice@example.com for details"); + assert.equal(output, "contact [REDACTED_EMAIL] for details"); + assert.equal(counts.EMAIL, 1); +}); + +test("redactString: sub-domain email is replaced", () => { + const { output } = redactString("ping ops+sre@mail.omniroute.dev today"); + assert.equal(output, "ping [REDACTED_EMAIL] today"); +}); + +test("redactString: email-like but missing TLD is preserved", () => { + // "user@host" is not a valid email; should NOT match. + const { output, counts } = redactString("note user@host is mentioned"); + assert.equal(output, "note user@host is mentioned"); + assert.equal(counts.EMAIL ?? 0, 0); +}); + +// ─── 2. IPv4 ──────────────────────────────────────────────────────────────── + +test("redactString: IPv4 address is redacted", () => { + const { output, counts } = redactString("client connected from 192.168.1.42"); + assert.equal(output, "client connected from [REDACTED_IPV4]"); + assert.equal(counts.IPV4, 1); +}); + +test("redactString: IPv4 with port is redacted including port", () => { + const { output } = redactString("connect 10.0.0.1:5432 succeeded"); + assert.equal(output, "connect [REDACTED_IPV4] succeeded"); +}); + +test("redactString: invalid octet (256) is NOT redacted", () => { + const { output, counts } = redactString("value 256.300.1.1 invalid"); + // The "256.300.1.1" should NOT match (octets > 255). + // It's possible that a partial substring like "56.300" might still match + // through other regex runs; assert that no full-IP redaction appears. + assert.equal(output.includes("[REDACTED_IPV4]"), false); + assert.equal(counts.IPV4 ?? 0, 0); +}); + +test("redactString: 127.0.0.1 is redacted (loopback is still PII for log shipping)", () => { + const { output } = redactString("local check from 127.0.0.1 ok"); + assert.equal(output, "local check from [REDACTED_IPV4] ok"); +}); + +// ─── 3. IPv6 ──────────────────────────────────────────────────────────────── + +test("redactString: full IPv6 is redacted", () => { + const { output, counts } = redactString("peer 2001:0db8:85a3:0000:0000:8a2e:0370:7334 connected"); + assert.equal(output, "peer [REDACTED_IPV6] connected"); + assert.equal(counts.IPV6, 1); +}); + +test("redactString: compressed IPv6 is redacted", () => { + const { output } = redactString("from fe80::1 to ::1"); + // Both addresses should be replaced. + assert.match(output, /from \[REDACTED_IPV6\] to \[REDACTED_IPV6\]/); +}); + +test("redactString: ::1 loopback is redacted", () => { + const { output } = redactString("traffic from ::1 only"); + assert.match(output, /traffic from \[REDACTED_IPV6\] only/); +}); + +// ─── 4. Bearer tokens ─────────────────────────────────────────────────────── + +test("redactString: Bearer token in Authorization header is redacted", () => { + const { output, counts } = redactString("Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345"); + assert.match(output, /\[REDACTED_BEARER\]/); + assert.equal(counts.BEARER, 1); +}); + +test("redactString: 'Bearer' word without a token is preserved", () => { + const { output, counts } = redactString("the bearer of bad news"); + // "bad news" is too short to match (needs 16+ chars). + assert.equal(output, "the bearer of bad news"); + assert.equal(counts.BEARER ?? 0, 0); +}); + +// ─── 5. OpenAI keys ───────────────────────────────────────────────────────── + +test("redactString: sk- prefix key is redacted", () => { + const { output, counts } = redactString("OPENAI_KEY=sk-proj-abc123XYZ456def789GHI012jkl"); + assert.match(output, /\[REDACTED_API_KEY\]/); + assert.ok((counts.OPENAI_KEY ?? 0) >= 1 || (counts.GENERIC_KEY ?? 0) >= 1); +}); + +test("redactString: sk- short token (too short) is NOT redacted", () => { + // 18 chars after "sk-" — minimum is 20. + const { output } = redactString("noise: sk-abcdefghijklmnopqr here"); + assert.equal(output, "noise: sk-abcdefghijklmnopqr here"); +}); + +test("redactString: anthropic sk-ant- key is redacted", () => { + const { output, counts } = redactString("key: sk-ant-api03-abcdefghij1234567890ABCD"); + assert.match(output, /\[REDACTED_API_KEY\]/); + assert.equal(counts.ANTHROPIC_KEY, 1); +}); + +test("redactString: Google AIza key is redacted", () => { + // Total 39 chars: "AIza" (4) + 35 alnum/hyphen/underscore. + const key = "AIzaSyD-1234567890abcdefghijklmnopqrstu"; + assert.equal(key.length, 39); + const { output, counts } = redactString(`google_key=${key}`); + assert.match(output, /\[REDACTED_API_KEY\]/); + assert.equal(counts.GOOGLE_KEY, 1); +}); + +// ─── 6. GitHub tokens ─────────────────────────────────────────────────────── + +test("redactString: ghp_ token is redacted", () => { + const token = "ghp_" + "a".repeat(36); + const { output, counts } = redactString(`token=${token}`); + assert.match(output, /\[REDACTED_API_KEY\]/); + assert.equal(counts.GITHUB_TOKEN, 1); +}); + +test("redactString: github_pat_ token is redacted", () => { + const token = "github_pat_" + "B".repeat(40); + const { output } = redactString(`pat is ${token} end`); + assert.match(output, /pat is \[REDACTED_API_KEY\] end/); +}); + +// ─── 7. AWS keys ──────────────────────────────────────────────────────────── + +test("redactString: AKIA access key is redacted", () => { + const key = "AKIAEXAMPLE123456789"; // 20 chars + const { output, counts } = redactString(`aws_access_key_id=${key}`); + assert.match(output, /\[REDACTED_AWS_KEY\]/); + assert.equal(counts.AWS_KEY, 1); +}); + +test("redactString: ASIA (temporary) key is redacted", () => { + const key = "ASIAIOSFODNN7EXAMPLE"; + const { output, counts } = redactString(`temp=${key}`); + assert.match(output, /\[REDACTED_AWS_KEY\]/); + assert.equal(counts.AWS_KEY, 1); +}); + +// ─── 8. Generic api_key=value ─────────────────────────────────────────────── + +test("redactString: api_key=value pair is redacted", () => { + const { output, counts } = redactString(`api_key=${"x".repeat(20)}`); + assert.match(output, /\[REDACTED_API_KEY\]/); + assert.equal(counts.GENERIC_KEY, 1); +}); + +test("redactString: password=... is redacted", () => { + const { output, counts } = redactString(`password: ${"hunter2hunter2hunter2"}`); + assert.match(output, /\[REDACTED_API_KEY\]/); + assert.equal(counts.GENERIC_KEY, 1); +}); + +test("redactString: short value (< 12 chars) is NOT redacted", () => { + const { output, counts } = redactString("password: short"); + assert.equal(output, "password: short"); + assert.equal(counts.GENERIC_KEY ?? 0, 0); +}); + +// ─── 9. Combined / order of operations ────────────────────────────────────── + +test("redactString: line with email AND ip AND key redacts all three", () => { + const line = `2026-06-25T07:00:00Z ERROR user=alice@example.com ip=10.0.0.5 key=sk-proj-${"A".repeat(30)}`; + const { output, counts } = redactString(line); + assert.match(output, /\[REDACTED_EMAIL\]/); + assert.match(output, /\[REDACTED_IPV4\]/); + assert.match(output, /\[REDACTED_API_KEY\]/); + // Counts should be at least one of each category. + assert.ok((counts.EMAIL ?? 0) >= 1); + assert.ok((counts.IPV4 ?? 0) >= 1); + assert.ok((counts.OPENAI_KEY ?? 0) >= 1); +}); + +test("redactString: empty string yields empty output", () => { + const { output, counts } = redactString(""); + assert.equal(output, ""); + assert.deepEqual(counts, {}); +}); + +test("redactString: non-PII log line is unchanged", () => { + const line = "2026-06-25T07:00:00Z INFO request_id=req_abc123 method=GET path=/v1/models"; + const { output } = redactString(line); + assert.equal(output, line); +}); + +test("redactString: key inside larger word (not at boundary) is not matched", () => { + // `task-abc123XYZ456def789GHI012jkl345` is not preceded by 'sk-' so should + // not match the OPENAI_KEY pattern. + const { output, counts } = redactString("some task-abcdefghij1234567890KL here"); + assert.equal(output, "some task-abcdefghij1234567890KL here"); + assert.equal(counts.OPENAI_KEY ?? 0, 0); +}); + +// ─── 10. Stable markers across runs ───────────────────────────────────────── + +test("redactString: same input twice yields the same redacted output", () => { + const line = `ip=192.168.0.1 user=${"a".repeat(40)}@example.com`; + const first = redactString(line).output; + const second = redactString(line).output; + assert.equal(first, second); +}); + +// ─── 11. redact() shorthand ───────────────────────────────────────────────── + +test("redact(): shorthand returns just the output", () => { + assert.equal(redact("email bob@example.com here"), "email [REDACTED_EMAIL] here"); +}); + +// ─── 12. RedactTransform stream ───────────────────────────────────────────── + +test("RedactTransform: streams input chunks to output, redacting as it goes", async () => { + const t = new RedactTransform(); + const out = []; + const sink = new Writable({ + write(chunk, _enc, cb) { + out.push(typeof chunk === "string" ? chunk : chunk.toString("utf8")); + cb(); + }, + }); + const src = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("email a@b.com ")); + controller.enqueue(new TextEncoder().encode("ip=1.2.3.4 ")); + controller.enqueue(new TextEncoder().encode("end\n")); + controller.close(); + }, + }); + await src + .pipeThrough(new TextDecoderStream()) + .pipeThrough(t) + .pipeTo( + new WritableStream({ + write(chunk) { + sink.write(chunk, "utf8", () => {}); + }, + }) + ); + const joined = out.join(""); + assert.match(joined, /\[REDACTED_EMAIL\]/); + assert.match(joined, /\[REDACTED_IPV4\]/); + // Counts accumulated on the transform. + assert.equal(t.counts.EMAIL ?? 0, 1); + assert.equal(t.counts.IPV4 ?? 0, 1); +}); + +// ─── 13. Counts are independent between calls ─────────────────────────────── + +test("redactString: counts do not bleed across calls", () => { + redactString("a@b.com"); + const { counts } = redactString("no pii here at all"); + assert.deepEqual(counts, {}); +}); From 6ff2e7b2c2fdc19ed2e9637ea4325af689728ea3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rouzbeh=E2=80=A0?= <78313022+rqzbeh@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:53:41 +0330 Subject: [PATCH 05/10] fix(antigravity): heal empty-projectId accounts via retryable auto-onboarding (#10424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(antigravity): heal empty-projectId accounts via retryable auto-onboarding Accounts with an empty Cloud Code projectId get a permanent 422 "Missing Google projectId" when loadCodeAssist returns no project. The 3.8.50 bootstrap attempts to CREATE the project via onboardUser, but a single failed attempt (transient network/upstream error) was memoized forever in onboardAttemptedCache: every later request in the process skipped onboarding and 422'd, even though a retry would succeed. Replace the permanent per-token Set with a failure-backoff map: failed onboard attempts are retried after a 5-minute backoff (bounded, self-healing), the in-flight lock still dedupes concurrent calls, and success clears the failure marker and memoizes the project as before. Accounts that CAN be onboarded now heal automatically on a later request or token refresh — no user action. Tests: the existing "does not retry" case is now framed as the backoff window; a new case proves the account heals (retries onboarding and recovers the project) once the backoff expires. * chore(changelog): fragment for #10424 antigravity project autocreate * feat(antigravity): BYOP fast-fail + manual GCP project-id override Port decolua/9router#2934 + VansRouter 802a859: - tryOnboardUser now returns a three-way status; a 200 onboardUser response WITHOUT cloudaicompanionProject means Google deprecated automatic project creation for standard-tier (personal) accounts (BYOP). Such accounts are cached permanently (no pointless ~18s re-onboard) and the executor fails fast with 403 GCP_PROJECT_REQUIRED + actionable 'enter your project id' message instead of the generic 422 or a delayed 429. - Transient onboard failures keep the existing 5-min backoff heal. - Manual project-id override: the EditConnectionModal now stamps providerSpecificData.isProjectIdManual when the operator enters a project id, and tokenRefresh skips auto-discovery for flagged accounts so the manual value is never overwritten. * chore(changelog): cover BYOP fast-fail + manual override in #10424 fragment * test(antigravity): expect fast 403 GCP_PROJECT_REQUIRED when loadCodeAssist finds no project (#10424) Google now marks accounts without an onboarded project as BYOP (automatic project creation deprecated for standard-tier accounts, #2934). The PR's BYOP fast-fail path returns 403 gcp_project_required instead of the old generic 422 missing_project_id; align the #2334 executor test with that contract so CI unit-test shard 2/4 passes. * fix(antigravity): persist isProjectIdManual, fix BYOP citation, dodge refresh-retry Review follow-up on #10424: 1. EditConnectionModal: isProjectIdManual was set on updates.providerSpecificData right after the project-id field, then the OAuth path (Antigravity is always OAuth) rebuilt providerSpecificData from connection.providerSpecificData before the request went out, discarding the flag — tokenRefresh.ts was guarding a field never actually persisted. The flag now lands in the single surviving antigravity merge, with a jsdom regression test (modeled on edit-connection-modal-openai-store-toggle). 2. The '#2934' citation for the Google BYOP claim pointed at an unrelated closed issue. Swapped for the real tracking issue #8491 (empty Google projectId -> 422 class) across bootstrap/executor/test comments. 3. BYOP fast-fail now returns 422 instead of 403: chatCore's generic 401/403 -> refresh-and-retry path was hitting Google's OAuth token endpoint on every request from an affected account (pointless — refreshing cannot create a GCP project), and 422 matches the sibling missing_project_id error the client already maps to an action-needed prompt. Also: eslint-disable-next-line for the pre-existing react-hooks/set-state-in-effect baseline noise in the modal (repo convention, same pattern as 11 other dashboard files). * chore(ci): drop unused eslint-disable in EditConnectionModal form hydration The react-hooks/set-state-in-effect disable added in the previous commit is unused under the repo's pinned eslint-plugin-react-hooks (7.0.1) — the rule does not fire on this line at that version, so the unused directive tripped the whole-repo 'No new ESLint warnings' gate (max-warnings 0). Verified with the lockfile-pinned plugin: lint:json is clean (0 errors, 0 warnings). * fix(build): bound and retry the opencode-plugin npm install in prepublish The plugin's node_modules is gitignored, so every fresh CI checkout runs a full npm install inside @omniroute/opencode-plugin during build:cli. npm's unbounded fetch retries turn a stalled registry CDN connection (the recurring onnxruntime-class ETIMEDOUT flake) into a 20-30 minute hang — the DAST 'Build CLI bundle' step has been cancelled at the 30m cap repeatedly. - Bound npm fetch: --fetch-timeout 60s, 2 retries with capped backoff — a stalled connection now fails fast instead of hanging the job. - Retry the install up to 3 times with a 10s pause between attempts, so transient CDN failures recover in-build. Net effect: the step either completes (network OK) or fails quickly with a clear error (network down) — it can no longer eat the whole job budget. * ci(quality): use the npm-ci-retry action on every install step Fast Quality Gates failed on the recurring onnxruntime-node postinstall ETIMEDOUT (Microsoft CDN 150.171.x.x) - the same transient flake that has hit Vitest and dast-smoke today. Only the Build job used the retry action; the other five jobs (Docs, Fast Quality Gates, Vitest, Unit Tests, changelog) still ran a bare install and die on any CDN hiccup. Use the existing retry action (3 attempts, exponential backoff) on every install step for consistency. * Merge branch 'release/v3.8.50' into fix/antigravity-project-autocreate * test(fix): refresh expired alibaba quota sample validity and onnxruntime pin for v3.8.50 base - alibaba-free-tier-quota-fetcher.test.ts: sample quotaValidityPeriod (2026-08-16 16:00 UTC) is in the past, making every quota entry classify as expired/not_capable; bump to 2028-01-01 UTC so the text/merge classification tests exercise the intended path again. - optional-transformers-dependency.test.ts: onnxruntime-node pin assertion updated from ~1.24.3 to ~1.27.0 to match package.json (bumped by #10403); the regular-not-optional intent is unchanged. * test(fix): widen modelsDevSync lastSync wait from 200ms default to 2000ms The truthy-spellings loop asserted each enabled case completes its first fetch within waitFor's 200ms default timeout, which trips under CI runner load (observed on PR 10424 shard 2/4). Match the file's other lastSync waits (2000ms) so the sync-completion assertion is load-tolerant. --------- Co-authored-by: Rouzbeh --- .../10424-antigravity-project-autocreate.md | 2 + open-sse/executors/antigravity.ts | 38 ++++- .../services/antigravityProjectBootstrap.ts | 159 ++++++++++++++---- open-sse/services/tokenRefresh.ts | 1 + .../components/modals/EditConnectionModal.tsx | 6 + .../alibaba-free-tier-quota-fetcher.test.ts | 2 +- .../antigravity-discovery-bootstrap.test.ts | 112 +++++++++++- .../antigravity-missing-project-chat.test.ts | 68 ++++++++ .../optional-transformers-dependency.test.ts | 4 +- ...-modal-antigravity-project-manual.test.tsx | 132 +++++++++++++++ tests/unit/executor-antigravity.test.ts | 11 +- tests/unit/modelsDevSync-extended.test.ts | 2 +- 12 files changed, 489 insertions(+), 48 deletions(-) create mode 100644 changelog.d/fixes/10424-antigravity-project-autocreate.md create mode 100644 tests/unit/dashboard/edit-connection-modal-antigravity-project-manual.test.tsx diff --git a/changelog.d/fixes/10424-antigravity-project-autocreate.md b/changelog.d/fixes/10424-antigravity-project-autocreate.md new file mode 100644 index 0000000000..81fc6734c4 --- /dev/null +++ b/changelog.d/fixes/10424-antigravity-project-autocreate.md @@ -0,0 +1,2 @@ +- **fix(antigravity):** accounts with an empty Cloud Code `projectId` now heal themselves — failed auto-onboarding (`onboardUser`) attempts are retried after a short backoff instead of being memoized forever, so the missing Google project is created without user action on a later request or token refresh ([#10424](https://github.com/diegosouzapw/OmniRoute/pull/10424)) — thanks @rqzbeh +- **fix(antigravity):** Google deprecated automatic project creation for standard-tier (personal) accounts — when `onboardUser` completes without a project id the account now fails fast with a clear `403 GCP_PROJECT_REQUIRED` message (no more generic 422 or delayed 429 RESOURCE_EXHAUSTED), and a manual GCP Project ID override is available in the connection editor so operators can enter their own project id ([#10424](https://github.com/diegosouzapw/OmniRoute/pull/10424)) — thanks @rqzbeh diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 137b996920..dda321c266 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -28,7 +28,10 @@ import { resolveAntigravityOutputCap, } from "./antigravityOutputCap.ts"; export { MAX_ANTIGRAVITY_OUTPUT_TOKENS } from "./antigravityOutputCap.ts"; -import { ensureAntigravityProjectAssigned } from "../services/antigravityProjectBootstrap.ts"; +import { + ensureAntigravityProjectAssigned, + ANTIGRAVITY_REQUIRES_MANUAL_PROJECT, +} from "../services/antigravityProjectBootstrap.ts"; import { persistDiscoveredAntigravityProjectId } from "../services/antigravityProjectPersist.ts"; import { markAntigravityMissingCloudCodeProject } from "../services/antigravityProjectPersistence.ts"; import { @@ -577,6 +580,7 @@ export class AntigravityExecutor extends BaseExecutor { // its Google account already owns a Cloud Code project (the OAuth-time loadCodeAssist // returned empty/transiently failed). Mirror the Cloud Code bootstrap to recover it // here — the helper memoizes per access-token, so this is a one-time round-trip. + let requiresManualProject = false; if (!projectId && credentials?.accessToken) { const discovered = await ensureAntigravityProjectAssigned( credentials.accessToken, @@ -584,7 +588,7 @@ export class AntigravityExecutor extends BaseExecutor { getAntigravityClientProfile(credentials), signal ); - if (discovered) { + if (discovered && discovered !== ANTIGRAVITY_REQUIRES_MANUAL_PROJECT) { projectId = discovered; // #8491: persist the recovered id so it survives the next token refresh // or process restart instead of being silently rediscovered every time. @@ -594,10 +598,40 @@ export class AntigravityExecutor extends BaseExecutor { credentials.providerSpecificData ); } + requiresManualProject = discovered === ANTIGRAVITY_REQUIRES_MANUAL_PROJECT; } if (!projectId) { markAntigravityMissingCloudCodeProject(credentials?.connectionId); + if (requiresManualProject) { + // Google no longer auto-creates GCP projects for standard-tier + // accounts (tracked in #8491): fail fast with a clear instruction + // instead of the generic 422 — a fabricated/omitted id only earns a + // delayed 429 RESOURCE_EXHAUSTED from Google's quota check. + const errorBody = { + error: { + message: + "GCP_PROJECT_REQUIRED: Google Antigravity now requires a free GCP Project ID. " + + "Create one at console.cloud.google.com and enter it in Providers → Antigravity " + + "(connection settings → Project ID). Automatic project creation is no longer " + + "available for personal accounts.", + type: "gcp_project_required", + code: "gcp_project_required", + }, + }; + // 422, not 403: chatCore's generic "401/403 → refresh credentials and + // retry" path would otherwise hit Google's OAuth token endpoint on + // every request from an affected account — pointless, since refreshing + // the token cannot create a GCP project. 422 also matches the sibling + // missing_project_id error, which the client already maps to a clear + // "action needed" prompt. + const resp = new Response(JSON.stringify(errorBody), { + status: 422, + headers: { "Content-Type": "application/json" }, + }); + // Returning a Response object signals the executor to stop and forward it + return resp as unknown as never; + } // (#489) Return a structured error instead of throwing — gives the client a clear signal // to show a "Reconnect OAuth" prompt rather than an opaque "Internal Server Error". const errorMsg = diff --git a/open-sse/services/antigravityProjectBootstrap.ts b/open-sse/services/antigravityProjectBootstrap.ts index 7d69216f1e..95d7aaf570 100644 --- a/open-sse/services/antigravityProjectBootstrap.ts +++ b/open-sse/services/antigravityProjectBootstrap.ts @@ -20,7 +20,10 @@ import { } from "./antigravityHeaders.ts"; import { extractCodeAssistOnboardTierId } from "./codeAssistSubscription.ts"; import type { AntigravityClientProfile } from "./antigravityClientProfile.ts"; -import { ANTIGRAVITY_BOOTSTRAP_BASE_URLS, getAntigravityOnboardUrls } from "../config/antigravityUpstream.ts"; +import { + ANTIGRAVITY_BOOTSTRAP_BASE_URLS, + getAntigravityOnboardUrls, +} from "../config/antigravityUpstream.ts"; const LOAD_CODE_ASSIST_PATH = "/v1internal:loadCodeAssist"; const BOOTSTRAP_TIMEOUT_MS = 8_000; @@ -47,7 +50,39 @@ function evictOldest(cache: Map): void { const projectCache = new Map(); /** Per-key lock to prevent concurrent onboard attempts for the same token. */ -const onboardLocks = new Map>(); +const onboardLocks = new Map>(); + +/** + * Sentinel returned by ensureAntigravityProjectAssigned when Google's + * onboardUser completed but did NOT return a project id — no automatic + * project creation for standard-tier (personal) accounts (tracked in #8491), + * so Google requires a user-defined GCP project (BYOP). The + * caller must fail fast with a clear "enter your GCP project id" error + * instead of retrying (a fabricated id gets a delayed 429 RESOURCE_EXHAUSTED). + */ +export const ANTIGRAVITY_REQUIRES_MANUAL_PROJECT = "__REQUIRES_GCP_PROJECT__"; + +/** + * Per-token cache of accounts Google told us to Bring Your Own Project. + * Permanent for the process lifetime (LRU-capped): re-running onboardUser + * for such an account is a pointless ~18s quota-check round-trip that + * always comes back empty. Cleared by clearAntigravityProjectCache(); a + * manually-entered project id (stored on the connection) short-circuits + * before this is consulted. + */ +const requiresManualProjectCache = new Set(); + +function markRequiresManualProject(key: string): void { + if (requiresManualProjectCache.size >= MAX_CACHE_SIZE) { + const oldest = requiresManualProjectCache.values().next().value; + if (oldest !== undefined) requiresManualProjectCache.delete(oldest); + } + requiresManualProjectCache.add(key); +} + +/** Outcome of an onboardUser attempt — three-way so the caller can distinguish + * "transient failure (retry later)" from "Google says bring your own project". */ +type AntigravityOnboardStatus = "onboarded" | "requires_manual_project" | "failed"; type FetchLike = (url: string, init?: RequestInit) => Promise; @@ -138,7 +173,7 @@ async function tryOnboardUser( clientProfile: AntigravityClientProfile, tierId: string, signal?: AbortSignal -): Promise { +): Promise { const urls = getAntigravityOnboardUrls(); const headers = getAntigravityContentHeaders(clientProfile, accessToken); @@ -157,7 +192,20 @@ async function tryOnboardUser( }); if (response.ok) { - return true; + // Accounts Google expects to Bring Their Own Project: onboardUser + // returns 200 without a `cloudaicompanionProject` in the body — no + // automatic project creation for standard-tier/personal accounts + // (tracked in #8491). Detect that so we can fail fast with a clear + // instruction instead of retrying forever or fabricating an id that + // Google later rejects with a delayed 429 RESOURCE_EXHAUSTED. + const body = await response.text().catch(() => ""); + if (body && !/cloudaicompanionProject/.test(body)) { + console.warn( + `[models] antigravity onboardUser done but no project in response at ${url} — Google BYOP (user-defined GCP project) required` + ); + return "requires_manual_project"; + } + return "onboarded"; } console.warn( @@ -171,18 +219,40 @@ async function tryOnboardUser( console.warn(`[models] antigravity onboardUser threw for ${url}: ${msg} — trying next`); } } - return false; + return "failed"; } -/** Per-token memoization for accounts we already tried onboarding (avoid repeated calls). */ -const onboardAttemptedCache = new Set(); +/** + * Per-token failure backoff for the onboardUser creation path. + * + * A FAILED onboard attempt must never be memoized as "done": a transient + * upstream/network error would otherwise poison the account for the whole + * process lifetime, so every later request 422s with "Missing Google + * projectId" even though onboarding would succeed on retry. Instead we record + * WHEN a failure happened and only skip re-attempts while the short backoff + * window is open — the account heals itself on the next request after it + * expires. Successful discoveries are memoized in `projectCache` (with LRU + * eviction) and clear any pending failure marker. + */ +const onboardFailureAt = new Map(); +const ONBOARD_RETRY_BACKOFF_MS = 5 * 60 * 1000; -function addToOnboardAttemptedCache(key: string): void { - if (onboardAttemptedCache.size >= MAX_CACHE_SIZE) { - const oldest = onboardAttemptedCache.values().next().value; - if (oldest !== undefined) onboardAttemptedCache.delete(oldest); +function markOnboardFailure(key: string): void { + if (onboardFailureAt.size >= MAX_CACHE_SIZE) { + const oldest = onboardFailureAt.keys().next().value; + if (oldest !== undefined) onboardFailureAt.delete(oldest); } - onboardAttemptedCache.add(key); + onboardFailureAt.set(key, Date.now()); +} + +function isOnboardOnBackoff(key: string): boolean { + const failedAt = onboardFailureAt.get(key); + if (failedAt === undefined) return false; + if (Date.now() - failedAt >= ONBOARD_RETRY_BACKOFF_MS) { + onboardFailureAt.delete(key); + return false; + } + return true; } /** @@ -212,49 +282,71 @@ export async function ensureAntigravityProjectAssigned( } const { projectId: initialProjectId, tierId } = await tryLoadCodeAssist( - accessToken, fetchImpl, clientProfile, signal + accessToken, + fetchImpl, + clientProfile, + signal ); let projectId = initialProjectId; + // Google told us this account must Bring Its Own Project — fail fast with + // the sentinel instead of repeating the pointless ~18s onboard round-trip. + if (!projectId && requiresManualProjectCache.has(cacheKey)) { + return ANTIGRAVITY_REQUIRES_MANUAL_PROJECT; + } + // loadCodeAssist is read-only — if the account was never onboarded, it returns // empty. Call onboardUser to create the project, then retry discovery. - if (!projectId && !onboardAttemptedCache.has(cacheKey)) { + // Re-attempts are bounded by a short failure backoff (not a permanent memo), + // so a transient onboard failure heals on the next request. Accounts Google + // marks BYOP are cached permanently and short-circuit above. + if (!projectId && !isOnboardOnBackoff(cacheKey)) { // Per-key lock: concurrent calls for the same token share one onboard attempt. let lock = onboardLocks.get(cacheKey); if (!lock) { lock = (async () => { let aborted = false; + let succeeded = false; + let requiresManual = false; try { - const onboarded = await tryOnboardUser( - accessToken, fetchImpl, clientProfile, tierId, signal + const status = await tryOnboardUser( + accessToken, + fetchImpl, + clientProfile, + tierId, + signal ); - if (onboarded) { - const retry = await tryLoadCodeAssist( - accessToken, fetchImpl, clientProfile, signal - ); + if (status === "requires_manual_project") { + markRequiresManualProject(cacheKey); + requiresManual = true; + return; + } + if (status === "onboarded") { + const retry = await tryLoadCodeAssist(accessToken, fetchImpl, clientProfile, signal); if (retry.projectId) { evictOldest(projectCache); projectCache.set(cacheKey, retry.projectId); - return true; + succeeded = true; + return; } } - return false; } catch (e) { aborted = signal?.aborted === true; - return false; + return; } finally { onboardLocks.delete(cacheKey); - if (!aborted) addToOnboardAttemptedCache(cacheKey); + if (!aborted && !requiresManual) { + if (succeeded) onboardFailureAt.delete(cacheKey); + else markOnboardFailure(cacheKey); + } } })(); onboardLocks.set(cacheKey, lock); } - const success = await lock; - if (success) { - const cached = projectCache.get(cacheKey); - if (cached) return cached; - } + await lock; + if (projectCache.has(cacheKey)) return projectCache.get(cacheKey); + if (requiresManualProjectCache.has(cacheKey)) return ANTIGRAVITY_REQUIRES_MANUAL_PROJECT; } if (projectId) { @@ -268,10 +360,17 @@ export async function ensureAntigravityProjectAssigned( /** Exported for tests. */ export function clearAntigravityProjectCache(): void { projectCache.clear(); - onboardAttemptedCache.clear(); + onboardFailureAt.clear(); + requiresManualProjectCache.clear(); onboardLocks.clear(); } +/** Test-only: clear the onboard failure backoff (simulates backoff expiry). */ +export function clearAntigravityOnboardBackoff(key?: string): void { + if (key) onboardFailureAt.delete(key); + else onboardFailureAt.clear(); +} + /** Exported for tests — inspect cache state. */ export function getAntigravityProjectFromCache( accessToken: string, diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 7e5e86be1a..6b8b018a04 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -336,6 +336,7 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: if ( result?.accessToken && (provider === "antigravity" || provider === "agy") && + !credentials.providerSpecificData?.isProjectIdManual && !(credentials.projectId || credentials.providerSpecificData?.projectId) ) { try { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx index a698ee628f..4c33d2882a 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx @@ -649,6 +649,12 @@ export default function EditConnectionModal({ clientProfile: normalizeAntigravityClientProfileSetting( formData.antigravityClientProfile ), + // A manually-entered project id must not be overwritten by + // auto-discovery (loadCodeAssist) on later token refreshes. This + // merge is the single surviving write of providerSpecificData for + // antigravity (both OAuth and API-key branches rebuild the object + // above), so the flag has to land here to actually persist. + isProjectIdManual: !!trimmedCloudCodeProjectId, }; } if (updates.providerSpecificData) { diff --git a/tests/unit/alibaba-free-tier-quota-fetcher.test.ts b/tests/unit/alibaba-free-tier-quota-fetcher.test.ts index 734ee7dd38..d075e27aeb 100644 --- a/tests/unit/alibaba-free-tier-quota-fetcher.test.ts +++ b/tests/unit/alibaba-free-tier-quota-fetcher.test.ts @@ -41,7 +41,7 @@ const SAMPLE_CONSOLE_RESPONSE = { freeTierQuotas: [ { quotaInitTotal: 1000000, - quotaValidityPeriod: 1786896000000, + quotaValidityPeriod: 1830297600000, freeTierOnly: true, quotaTotalPercentage: 99.98, model: "qwen3.6-plus", diff --git a/tests/unit/antigravity-discovery-bootstrap.test.ts b/tests/unit/antigravity-discovery-bootstrap.test.ts index d2f5617dfe..43914cf571 100644 --- a/tests/unit/antigravity-discovery-bootstrap.test.ts +++ b/tests/unit/antigravity-discovery-bootstrap.test.ts @@ -21,8 +21,10 @@ import assert from "node:assert/strict"; import { ensureAntigravityProjectAssigned, clearAntigravityProjectCache, + clearAntigravityOnboardBackoff, getAntigravityProjectFromCache, getAntigravityLoadCodeAssistUrls, + ANTIGRAVITY_REQUIRES_MANUAL_PROJECT, } from "../../open-sse/services/antigravityProjectBootstrap.ts"; // Reset the module-level memoization cache between tests. @@ -261,10 +263,15 @@ describe("onboardUser fallback", () => { } if (url.endsWith(":onboardUser")) { onboardCalls++; - return new Response(JSON.stringify({ done: true }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); + // Google's LRO returns the created project inside the response — a body + // WITHOUT cloudaicompanionProject means BYOP (manual project required). + return new Response( + JSON.stringify({ done: true, cloudaicompanionProject: "proj-onboarded" }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); } return new Response("Not Found", { status: 404 }); }; @@ -294,7 +301,7 @@ describe("onboardUser fallback", () => { assert.equal(projectId, undefined, "must return undefined when both fail"); }); - test("does not retry onboardUser for the same token", async () => { + test("does not re-attempt onboardUser within the failure backoff window", async () => { let onboardCalls = 0; const mockFetch = async (url: string, _init?: RequestInit): Promise => { @@ -306,8 +313,10 @@ describe("onboardUser fallback", () => { } if (url.endsWith(":onboardUser")) { onboardCalls++; - return new Response(JSON.stringify({ done: true }), { - status: 200, + // Transient upstream failure (500) — NOT the BYOP signal, so the + // failure-backoff semantics are what is under test here. + return new Response("Upstream error", { + status: 500, headers: { "Content-Type": "application/json" }, }); } @@ -317,7 +326,94 @@ describe("onboardUser fallback", () => { await ensureAntigravityProjectAssigned("dedup-token", mockFetch); await ensureAntigravityProjectAssigned("dedup-token", mockFetch); - assert.equal(onboardCalls, 1, "onboardUser must be called only once per token"); + assert.equal(onboardCalls, 1, "onboardUser must be attempted once within the backoff window"); + }); + + test("retries onboardUser after the failure backoff expires (account heals itself)", async () => { + let onboardCalls = 0; + + const mockFetch = async (url: string, _init?: RequestInit): Promise => { + if (url.endsWith(":loadCodeAssist")) { + // Only the retry AFTER the second (healed) onboard attempt yields a project. + if (onboardCalls >= 2) { + return new Response(JSON.stringify({ cloudaicompanionProject: "proj-healed" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url.endsWith(":onboardUser")) { + onboardCalls++; + if (onboardCalls === 1) { + // First attempt: transient upstream failure -> failure backoff. + return new Response("Upstream error", { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + // Second (healed) attempt: Google returns the created project. + return new Response( + JSON.stringify({ done: true, cloudaicompanionProject: "proj-healed-onboard" }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + return new Response("Not Found", { status: 404 }); + }; + + // First attempt: onboard fails transiently -> failure recorded. + const first = await ensureAntigravityProjectAssigned("heal-token", mockFetch); + assert.equal(first, undefined); + assert.equal(onboardCalls, 1); + + // Immediately after: backoff blocks a re-attempt. + const second = await ensureAntigravityProjectAssigned("heal-token", mockFetch); + assert.equal(second, undefined); + assert.equal(onboardCalls, 1, "no re-attempt inside the backoff window"); + + // Simulate the backoff expiring: the next request heals the account. + clearAntigravityOnboardBackoff(); + const healed = await ensureAntigravityProjectAssigned("heal-token", mockFetch); + assert.equal(healed, "proj-healed"); + assert.equal(onboardCalls, 2, "onboardUser must be retried after backoff expiry"); + }); + + test("returns the BYOP sentinel when onboardUser completes without a project (Google #8491)", async () => { + let onboardCalls = 0; + + const mockFetch = async (url: string, _init?: RequestInit): Promise => { + if (url.endsWith(":loadCodeAssist")) { + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url.endsWith(":onboardUser")) { + onboardCalls++; + // 200 done WITHOUT cloudaicompanionProject = BYOP: Google deprecated + // automatic project creation for standard-tier personal accounts. + return new Response(JSON.stringify({ done: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("Not Found", { status: 404 }); + }; + + const first = await ensureAntigravityProjectAssigned("byop-token", mockFetch); + assert.equal(first, ANTIGRAVITY_REQUIRES_MANUAL_PROJECT); + + // The account is cached as BYOP — a second call must NOT re-run the + // pointless ~18s onboard round-trip (no extra fetch, same sentinel). + const second = await ensureAntigravityProjectAssigned("byop-token", mockFetch); + assert.equal(second, ANTIGRAVITY_REQUIRES_MANUAL_PROJECT); + assert.equal(onboardCalls, 1, "onboardUser must not be re-attempted for a cached BYOP account"); }); test("skips onboardUser when loadCodeAssist succeeds on first try", async () => { diff --git a/tests/unit/antigravity-missing-project-chat.test.ts b/tests/unit/antigravity-missing-project-chat.test.ts index 341846cd3e..5238427a65 100644 --- a/tests/unit/antigravity-missing-project-chat.test.ts +++ b/tests/unit/antigravity-missing-project-chat.test.ts @@ -84,3 +84,71 @@ test("Antigravity missing-project 422 stays fail-closed without account cooldown assert.equal(persisted?.lastErrorType, "oauth_missing_project_id"); assert.match(String(persisted?.lastError), /Missing Google projectId/); }); + +test("Antigravity BYOP account (onboardUser done, no project) returns fast 422 GCP_PROJECT_REQUIRED", async () => { + const connection = await providersDb.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + name: "antigravity-byop", + email: "antigravity-byop@example.test", + accessToken: "fake-antigravity-byop-token", + refreshToken: "fake-antigravity-byop-refresh", + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + providerSpecificData: {}, + isActive: true, + testStatus: "active", + }); + assert(connection && typeof connection.id === "string"); + + let onboardCalls = 0; + globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + if (request.url.startsWith("https://oauth2.googleapis.com/token")) { + // Token refresh during the attempt — answer it so the test focuses on BYOP. + return new Response( + JSON.stringify({ access_token: "fake-antigravity-byop-token", expires_in: 3600 }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + if (request.url.endsWith(":loadCodeAssist")) { + // Empty loadCodeAssist — account never onboarded. + return new Response("{}", { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (request.url.endsWith(":onboardUser")) { + onboardCalls += 1; + // 200 done WITHOUT cloudaicompanionProject — Google BYOP (#8491): + // no automatic project creation for standard-tier accounts. + return new Response(JSON.stringify({ done: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected external fetch: ${request.url}`); + }; + + const response = await handleChat( + buildRequest({ + body: { + model: "antigravity/gemini-2.5-flash", + stream: false, + messages: [{ role: "user", content: "BYOP account must fail fast with a clear 422" }], + }, + }) + ); + const payload = (await response.json()) as { + error?: { code?: string; type?: string; message?: string }; + }; + + assert.equal(response.status, 422); + // 422 is outside chatCore's 401/403 refresh-retry set, so the executor's + // error body passes through untouched — the actionable message must survive. + assert.match(String(payload.error?.message), /GCP_PROJECT_REQUIRED/); + assert.match(String(payload.error?.message), /console\.cloud\.google\.com/); + assert.equal(onboardCalls, 1, "onboardUser must be attempted exactly once (BYOP is cached)"); +}); diff --git a/tests/unit/build/optional-transformers-dependency.test.ts b/tests/unit/build/optional-transformers-dependency.test.ts index 07bf0d8b69..7e1c07fe7f 100644 --- a/tests/unit/build/optional-transformers-dependency.test.ts +++ b/tests/unit/build/optional-transformers-dependency.test.ts @@ -15,7 +15,7 @@ test("@huggingface/transformers is a regular dependency so npm ci never skips it // pin dragged onnxruntime-node@1.21.0 whose NAN build no longer compiles), which // broke `npm ci`/`next build` with "Can't resolve @huggingface/transformers" // (lazy import in src/lib/memory/embedding/transformersLocal.ts). As a regular - // dep with onnxruntime-node@~1.24.3 (napi prebuilds, no node-gyp) it stays + // dep with onnxruntime-node@~1.27.0 (napi prebuilds, no node-gyp) it stays // installable and the memory embedding path requires() cleanly. const pkg = readJson<{ dependencies?: Record; @@ -38,7 +38,7 @@ test("transformers + onnxruntime-node are regular dependencies (not optional)", assert.equal( pkg.dependencies?.["onnxruntime-node"], - "~1.24.3", + "~1.27.0", "onnxruntime-node is a regular dep (napi prebuilds, installable on Node 24/26)" ); assert.equal(pkg.optionalDependencies?.["onnxruntime-node"], undefined); diff --git a/tests/unit/dashboard/edit-connection-modal-antigravity-project-manual.test.tsx b/tests/unit/dashboard/edit-connection-modal-antigravity-project-manual.test.tsx new file mode 100644 index 0000000000..e6f1ae3fe6 --- /dev/null +++ b/tests/unit/dashboard/edit-connection-modal-antigravity-project-manual.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment jsdom +// +// Regression guard for the review on #10424: EditConnectionModal set +// providerSpecificData.isProjectIdManual right after the project-id field, +// but the OAuth connection path (Antigravity is always OAuth) rebuilt +// providerSpecificData from connection.providerSpecificData before the save +// request went out, discarding the flag. tokenRefresh.ts guards auto-discovery +// with `!credentials.providerSpecificData?.isProjectIdManual`, so without this +// fix a manually-entered GCP Project ID was silently overwritten on the next +// token refresh. +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/store/notificationStore", () => ({ + useNotificationStore: () => ({ notify: vi.fn() }), +})); + +vi.mock("@/store/emailPrivacyStore", () => ({ + default: () => ({ hidden: false, toggle: vi.fn() }), +})); + +const { default: EditConnectionModal } = + await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx"); + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); + +function renderModal(connection: Record, onSave = vi.fn()) { + act(() => { + root.render( + + ); + }); +} + +function findProjectIdInput(): HTMLInputElement | null { + return container.querySelector('input[placeholder="antigravityProjectIdPlaceholder"]'); +} + +function clickSave() { + const button = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent === "save" + ); + expect(button).toBeTruthy(); + button!.click(); +} + +describe("EditConnectionModal — antigravity isProjectIdManual persistence (#10424 review)", () => { + it("persists isProjectIdManual=true on save when a GCP Project ID is entered manually", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + renderModal( + { + id: "conn-ag-1", + provider: "antigravity", + authType: "oauth", + name: "Antigravity account", + providerSpecificData: {}, + }, + onSave + ); + + const input = findProjectIdInput(); + expect(input).not.toBeNull(); + // React controlled input: use the native setter so the value change is + // seen by the onChange handler, then dispatch an input event. + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )!.set!; + await act(async () => { + setter.call(input, "gcp-proj-10424"); + input!.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await act(async () => { + clickSave(); + }); + + expect(onSave).toHaveBeenCalledTimes(1); + const updates = onSave.mock.calls[0][0] as { + providerSpecificData: Record; + }; + expect(updates.providerSpecificData?.isProjectIdManual).toBe(true); + }); + + it("persists isProjectIdManual=false when the project id field is left empty", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + renderModal( + { + id: "conn-ag-2", + provider: "antigravity", + authType: "oauth", + name: "Antigravity account 2", + providerSpecificData: {}, + }, + onSave + ); + + await act(async () => { + clickSave(); + }); + + expect(onSave).toHaveBeenCalledTimes(1); + const updates = onSave.mock.calls[0][0] as { + providerSpecificData: Record; + }; + expect(updates.providerSpecificData?.isProjectIdManual).toBe(false); + }); +}); diff --git a/tests/unit/executor-antigravity.test.ts b/tests/unit/executor-antigravity.test.ts index abdd7474a5..1bce1a6f59 100644 --- a/tests/unit/executor-antigravity.test.ts +++ b/tests/unit/executor-antigravity.test.ts @@ -281,9 +281,11 @@ test("AntigravityExecutor.transformRequest auto-discovers a missing projectId vi } }); -// #2334: when loadCodeAssist also finds no project (truly un-onboarded account), the -// structured 422 must still be returned so the dashboard can prompt a reconnect. -test("AntigravityExecutor.transformRequest still 422s when loadCodeAssist finds no project (#2334)", async () => { +// #8491: when loadCodeAssist also finds no project and Google marks the +// account BYOP (no automatic project creation for standard-tier accounts), +// the fast 422 GCP_PROJECT_REQUIRED must be returned so the dashboard can +// prompt the user to enter a GCP Project ID. +test("AntigravityExecutor.transformRequest fast-422s with GCP_PROJECT_REQUIRED when loadCodeAssist finds no project (#8491)", async () => { clearAntigravityProjectCache(); seedAntigravityIdeVersionCache("2.1.1"); const executor = new AntigravityExecutor(); @@ -305,7 +307,8 @@ test("AntigravityExecutor.transformRequest still 422s when loadCodeAssist finds if (!(result instanceof Response)) throw new Error("Expected a 422 Response"); assert.equal(result.status, 422); const payload = (await result.json()) as ErrorPayload; - assert.equal(payload.error.code, "missing_project_id"); + assert.equal(payload.error.code, "gcp_project_required"); + assert.match(payload.error.message, /GCP_PROJECT_REQUIRED/); } finally { globalThis.fetch = originalFetch; clearAntigravityProjectCache(); diff --git a/tests/unit/modelsDevSync-extended.test.ts b/tests/unit/modelsDevSync-extended.test.ts index acdd1306f9..d08dd840fa 100644 --- a/tests/unit/modelsDevSync-extended.test.ts +++ b/tests/unit/modelsDevSync-extended.test.ts @@ -671,7 +671,7 @@ test("the usual truthy spellings all start the sync, and nothing else does", asy // then never fetched anything; pin the fetch actually having run // for each truthy spelling, not just the first one. assert.ok( - await waitFor(() => modelsDev.getSyncStatus().lastSync !== null), + await waitFor(() => modelsDev.getSyncStatus().lastSync !== null, 2000), `MODELS_DEV_SYNC_ENABLED=${JSON.stringify(value)} should have completed a sync` ); } From e3bca29bbcf57afb035733dcab347d7b725a7bcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rouzbeh=E2=80=A0?= <78313022+rqzbeh@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:54:27 +0330 Subject: [PATCH 06/10] fix(docker): real image tags (bifrost/cliproxyapi) + complete OMNIROUTE_BASE_PATH runtime patcher (#10482) * fix(docker): real image tags + complete OMNIROUTE_BASE_PATH runtime patcher Three docker issues fixed: 1. Images that do not exist: - bifrost: ghcr.io/maximhq/bifrost:1.5.21 never existed (1.5.x tops at v1.5.16, all tags carry the v prefix) -> ghcr.io/maximhq/bifrost:v1.6.11 - cliproxyapi: ghcr.io/router-for-me/* is not publicly pullable (403); the official prebuilt image is docker.io/eceasy/cli-proxy-api, where the pinned v6.9.7 exists -> docker.io/eceasy/cli-proxy-api:v6.9.7 - Verified still-current: redis:8.6.5-alpine (already on Redis 8 since #9065; ioredis 5.10 is RESP2/3-compatible, no modules used) and qdrant:v1.12.4 -- both exist, unchanged. 2. OMNIROUTE_BASE_PATH ignored on prebuilt images (root cause): Next 16 (webpack and Turbopack) app-router renders SSR asset URLs from assetPrefix ALONE; basePath only affects routing. The runtime patcher (ensure-docker-base-path) rewrote basePath literals only, so a prebuilt root-path image patched to /omniroute served the page but every /_next/static shell reference stayed unprefixed (404 behind a subpath proxy), the RSC flight-payload chunk refs came from client-reference manifests baked with unprefixed paths, and the Turbopack client process shim ships an empty env object so the client never learns the subpath. Extended patch-standalone-base-path.mjs to also rewrite: - assetPrefix literals (mirrors the subpath for SSR asset URLs) - the NEXT_PUBLIC_OMNIROUTE_BASE_PATH env mirror in the inline config - the client process.env shim (.env={}) with the two basePath keys - every baked "/_next/static URL (manifests, media imports, .html pages) next.config.mjs now mirrors basePath into assetPrefix so REBUILT images bake prefixed assets too. E2E-verified on the published main-web image: HTML under /omniroute now has 16/16 prefixed JS srcs and 82/82 prefixed flight refs (was 13/9 + ~150 unprefixed), prefixed assets return 200. * chore(changelog): fragment for #10482 (docker images + basepath patcher) * chore(changelog): bullet-form fragment for #10482 * Merge branch 'release/v3.8.50' into fix/docker-compose-images-and-basepath * test(fix): refresh expired alibaba quota sample validity and onnxruntime pin for v3.8.50 base - alibaba-free-tier-quota-fetcher.test.ts: sample quotaValidityPeriod (2026-08-16 16:00 UTC) is in the past, making every quota entry classify as expired/not_capable; bump to 2028-01-01 UTC so the text/merge classification tests exercise the intended path again. - optional-transformers-dependency.test.ts: onnxruntime-node pin assertion updated from ~1.24.3 to ~1.27.0 to match package.json (bumped by #10403); the regular-not-optional intent is unchanged. --------- Co-authored-by: Rouzbeh --- .../fixes/10482-docker-images-and-basepath.md | 1 + docker-compose.yml | 7 +- docs/architecture/cluster-decisions.md | 6 +- docs/guides/DOCKER_GUIDE.md | 8 ++- next.config.mjs | 6 ++ scripts/docker/patch-standalone-base-path.mjs | 70 +++++++++++++++++-- tests/unit/docker-base-path-patch.test.ts | 70 +++++++++++++++---- 7 files changed, 143 insertions(+), 25 deletions(-) create mode 100644 changelog.d/fixes/10482-docker-images-and-basepath.md diff --git a/changelog.d/fixes/10482-docker-images-and-basepath.md b/changelog.d/fixes/10482-docker-images-and-basepath.md new file mode 100644 index 0000000000..c85ae91937 --- /dev/null +++ b/changelog.d/fixes/10482-docker-images-and-basepath.md @@ -0,0 +1 @@ +- **fix(docker):** point the bifrost sidecar at the real `ghcr.io/maximhq/bifrost:v1.6.11` tag and the cliproxyapi sidecar at the official `docker.io/eceasy/cli-proxy-api:v6.9.7` image (the previously pinned tags never existed), and complete the runtime `OMNIROUTE_BASE_PATH` subpath patch for Next 16 standalone (assetPrefix + client env + baked asset URLs) so prebuilt images respect the webpath env var ([#10482](https://github.com/diegosouzapw/OmniRoute/pull/10482)) diff --git a/docker-compose.yml b/docker-compose.yml index 522ca3bc1c..d2cf960caa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -247,7 +247,7 @@ services: # fall back to the chatCore path with zero code changes. See # docs/architecture/cluster-decisions.md for the activation plan. bifrost: - image: ghcr.io/maximhq/bifrost:1.5.21 + image: ghcr.io/maximhq/bifrost:v1.6.11 container_name: omniroute-bifrost restart: unless-stopped ports: @@ -266,9 +266,12 @@ services: - bifrost # ── Profile: cliproxyapi (CLIProxyAPI as sidecar) ───────────────── + # Official pre-built image lives on Docker Hub (eceasy/cli-proxy-api); + # ghcr.io/router-for-me/* is not publicly pullable. v6.9.7 is the pinned + # version the sidecar integration (port 8317, /v1/models healthcheck) targets. cliproxyapi: container_name: cliproxyapi - image: ghcr.io/router-for-me/cliproxyapi:v6.9.7 + image: docker.io/eceasy/cli-proxy-api:v6.9.7 restart: unless-stopped ports: - "${CLIPROXYAPI_PORT:-8317}:${CLIPROXYAPI_PORT:-8317}" diff --git a/docs/architecture/cluster-decisions.md b/docs/architecture/cluster-decisions.md index c306cbd072..d3f28e1cde 100644 --- a/docs/architecture/cluster-decisions.md +++ b/docs/architecture/cluster-decisions.md @@ -55,9 +55,9 @@ The two profiles here are **scale-out options for deployments that hit the SQLit **What it adds:** -| Service | Image | Ports | Notes | -| --------- | -------------------------------- | ------ | ----------------------------------------------------------------------- | -| `bifrost` | `ghcr.io/maximhq/bifrost:1.5.21` | `8080` | Go-based Tier-1 router; persistent logs volume `omniroute_bifrost_logs` | +| Service | Image | Ports | Notes | +| --------- | --------------------------------- | ------ | ----------------------------------------------------------------------- | +| `bifrost` | `ghcr.io/maximhq/bifrost:v1.6.11` | `8080` | Go-based Tier-1 router; persistent logs volume `omniroute_bifrost_logs` | **Activation:** set `BIFROST_BASE_URL=http://bifrost:8080` in `.env.example`. The existing sidecar proxy route at [`src/app/api/v1/relay/chat/completions/bifrost/route.ts`](../../src/app/api/v1/relay/chat/completions/bifrost/route.ts) (added in PR #4381) will pick this up automatically. diff --git a/docs/guides/DOCKER_GUIDE.md b/docs/guides/DOCKER_GUIDE.md index 2fb1b75f79..6503ac343b 100644 --- a/docs/guides/DOCKER_GUIDE.md +++ b/docs/guides/DOCKER_GUIDE.md @@ -285,8 +285,12 @@ Next.js `basePath` is compiled into the standalone bundle. OmniRoute records the value in a sentinel file at the app root (written during `npm run build`; read by `scripts/docker/ensure-docker-base-path.mjs`) and compares it with `OMNIROUTE_BASE_PATH` when the container starts. When they differ and the image was -built for the domain root, the entrypoint rewrites the standalone manifests and embedded -`basePath` literals before `node dev/run-standalone.mjs` runs. +built for the domain root, the entrypoint rewrites the standalone manifests, the +embedded `basePath`/`assetPrefix` literals (Next 16 renders SSR asset URLs from +`assetPrefix` alone — the patcher mirrors the subpath into it), the baked +`/_next/static` asset URLs (client-reference manifests, media imports, prerendered +error pages) and the client `process.env` shim before `node dev/run-standalone.mjs` +runs. ### Compose build (recommended) diff --git a/next.config.mjs b/next.config.mjs index 34be60c02e..2f22b8aebd 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -113,6 +113,12 @@ const nextConfig = { // keeps operating on un-prefixed paths — see src/server/authz/pipeline.ts for // the two redirect call sites that re-add it via `request.nextUrl.basePath`. basePath: normalizeBasePath(process.env.OMNIROUTE_BASE_PATH), + // Next 16 (both webpack and Turbopack) app-router renders SSR asset URLs from + // `assetPrefix` ALONE — basePath only affects routing/links. Without mirroring + // it here, a subpath build emits /_next/static shell references that 404 + // behind a reverse proxy. The Docker runtime patcher (ensure-docker-base-path) + // rewrites the same knob for prebuilt root-path images. + assetPrefix: normalizeBasePath(process.env.OMNIROUTE_BASE_PATH) || undefined, // Client-visible mirror of basePath for fetch/EventSource rewriting under reverse // proxies (installBasePathFetch), and for client display helpers (useDisplayBaseUrl) // that append the subpath to window.location.origin when building curl/endpoint diff --git a/scripts/docker/patch-standalone-base-path.mjs b/scripts/docker/patch-standalone-base-path.mjs index e0d2185245..27ef53ec7c 100644 --- a/scripts/docker/patch-standalone-base-path.mjs +++ b/scripts/docker/patch-standalone-base-path.mjs @@ -67,21 +67,76 @@ export function patchJsonManifestFile(filePath, basePath) { } const BASE_PATH_LITERAL_RE = - /basePath\s*:\s*(?:""|''|`{2})|basePath\s*:\s*void 0|"basePath"\s*:\s*""/g; + /(?:basePath|assetPrefix)\s*:\s*(?:""|''|``)|(?:basePath|assetPrefix)\s*:\s*void 0|"(?:basePath|assetPrefix)"\s*:\s*""|"NEXT_PUBLIC_OMNIROUTE_BASE_PATH"\s*:\s*""|NEXT_PUBLIC_OMNIROUTE_BASE_PATH\s*:\s*""/g; /** + * Rewrite the bare config literals Next bakes into the standalone output: + * - `basePath` (routing + server-rendered links) — the original scope; + * - `assetPrefix` (Next 16 app-router renders SSR asset URLs from + * `assetPrefix` ALONE — basePath only affects routing, so a subpath + * deploy must mirror it or every `/_next/static` shell reference 404s); + * - the `NEXT_PUBLIC_OMNIROUTE_BASE_PATH` env mirror in the inline + * nextConfig (server.js) so server-side env reads stay consistent. + * * @param {string} content * @param {string} basePath */ export function patchBasePathLiterals(content, basePath) { const escaped = basePath.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); return content.replace(BASE_PATH_LITERAL_RE, (match) => { - if (match.startsWith('"basePath"')) return `"basePath":"${escaped}"`; - if (match.includes("void 0")) return `basePath:"${escaped}"`; - return `basePath:"${escaped}"`; + if (match.startsWith('"NEXT_PUBLIC_OMNIROUTE_BASE_PATH"')) { + return `"NEXT_PUBLIC_OMNIROUTE_BASE_PATH":"${escaped}"`; + } + if (match.startsWith("NEXT_PUBLIC_OMNIROUTE_BASE_PATH")) { + return `NEXT_PUBLIC_OMNIROUTE_BASE_PATH:"${escaped}"`; + } + if (match.startsWith('"')) { + // `"basePath":""` / `"assetPrefix":""` (JSON-ish inline config) + const key = match.slice(1, match.indexOf('"', 1)); + return `"${key}":"${escaped}"`; + } + // `basePath:""` / `basePath:void 0` / `assetPrefix:""` (minified code) + const key = match.slice(0, match.indexOf(":")).trim(); + return `${key}:"${escaped}"`; }); } +/** + * Turbopack's client `process` shim ships an empty env object (`.env={}`). + * Next 16's client code reads NEXT_PUBLIC_* / OMNIROUTE_BASE_PATH from it at + * runtime, so without this the client never learns the subpath and the + * dashboard's fetch/EventSource rewriting (basePathFetch) silently stays on + * the root path. Populate the two keys the app reads. + * + * @param {string} content + * @param {string} basePath + */ +export function patchProcessEnvShim(content, basePath) { + const escaped = basePath.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + return content.replace(/\.env=\{\}/g, () => { + const keys = `OMNIROUTE_BASE_PATH:"${escaped}",NEXT_PUBLIC_OMNIROUTE_BASE_PATH:"${escaped}"`; + return `.env={${keys}}`; + }); +} + +/** + * Rewrite baked absolute asset URLs (`"/_next/static/..."`) to the subpath. + * Covers the client-reference-manifest chunk lists (they are serialized into + * the RSC flight payload verbatim) and the client/server chunk media imports + * — every `/ _next/static` reference must be prefixed because the standalone + * server only serves assets under basePath. + * + * @param {string} content + * @param {string} basePath + */ +export function patchBakedAssetUrls(content, basePath) { + const escaped = basePath.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + return content.replace( + /(["'`])\/_next\/static/g, + (_match, quote) => `${quote}${escaped}/_next/static` + ); +} + /** * @param {string} rootDir * @param {string} basePath @@ -98,9 +153,12 @@ function walkAndPatchTextFiles(rootDir, basePath) { stack.push(full); continue; } - if (!/\.(?:js|json|cjs|mjs)$/.test(entry.name)) continue; + if (!/\.(?:js|json|cjs|mjs|html)$/.test(entry.name)) continue; const before = fs.readFileSync(full, "utf8"); - const after = patchBasePathLiterals(before, basePath); + const after = [patchBasePathLiterals, patchProcessEnvShim, patchBakedAssetUrls].reduce( + (content, patch) => patch(content, basePath), + before + ); if (after !== before) { fs.writeFileSync(full, after); patchedFiles += 1; diff --git a/tests/unit/docker-base-path-patch.test.ts b/tests/unit/docker-base-path-patch.test.ts index f62ce84ea4..a71a922bd6 100644 --- a/tests/unit/docker-base-path-patch.test.ts +++ b/tests/unit/docker-base-path-patch.test.ts @@ -7,6 +7,8 @@ import { patchBasePathLiterals, patchJsonManifestFile, patchStandaloneBasePath, + patchProcessEnvShim, + patchBakedAssetUrls, } from "../../scripts/docker/patch-standalone-base-path.mjs"; test("patchBasePathLiterals rewrites empty basePath literals", () => { @@ -19,10 +21,7 @@ test("patchBasePathLiterals rewrites empty basePath literals", () => { test("patchJsonManifestFile updates nested basePath fields", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-basepath-")); const filePath = path.join(dir, "routes-manifest.json"); - fs.writeFileSync( - filePath, - JSON.stringify({ basePath: "", nested: { basePath: "" } }, null, 2) - ); + fs.writeFileSync(filePath, JSON.stringify({ basePath: "", nested: { basePath: "" } }, null, 2)); assert.equal(patchJsonManifestFile(filePath, "/omniroute"), true); const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); assert.equal(parsed.basePath, "/omniroute"); @@ -33,14 +32,8 @@ test("patchStandaloneBasePath rewrites a root-path standalone tree", () => { const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-standalone-")); const distRoot = path.join(appRoot, ".build", "next"); fs.mkdirSync(path.join(distRoot, "server"), { recursive: true }); - fs.writeFileSync( - path.join(distRoot, "routes-manifest.json"), - JSON.stringify({ basePath: "" }) - ); - fs.writeFileSync( - path.join(distRoot, "server", "chunk.js"), - 'export const config={basePath:""};' - ); + fs.writeFileSync(path.join(distRoot, "routes-manifest.json"), JSON.stringify({ basePath: "" })); + fs.writeFileSync(path.join(distRoot, "server", "chunk.js"), 'export const config={basePath:""};'); fs.writeFileSync(path.join(appRoot, "BUILD_OMNIROUTE_BASE_PATH"), "\n"); const result = patchStandaloneBasePath({ @@ -68,3 +61,56 @@ test("patchStandaloneBasePath rejects mismatched non-root builds", () => { /does not match the image build/ ); }); + +test("patchBasePathLiterals rewrites assetPrefix literals (Next 16 SSR asset URLs)", () => { + // Next 16 app-router renders SSR asset URLs from assetPrefix ALONE. + assert.equal( + patchBasePathLiterals('{"assetPrefix":""}', "/omniroute"), + '{"assetPrefix":"/omniroute"}' + ); + assert.equal(patchBasePathLiterals('assetPrefix:""', "/omniroute"), 'assetPrefix:"/omniroute"'); + assert.equal( + patchBasePathLiterals("assetPrefix:void 0", "/omniroute"), + 'assetPrefix:"/omniroute"' + ); + // Asset prefix must mirror the basePath so both routing and assets align. + const mixed = patchBasePathLiterals('{"basePath":"","assetPrefix":""}', "/omniroute"); + assert.match(mixed, /"basePath":"\/omniroute"/); + assert.match(mixed, /"assetPrefix":"\/omniroute"/); +}); + +test("patchBasePathLiterals rewrites the NEXT_PUBLIC env mirror", () => { + assert.equal( + patchBasePathLiterals('{"env":{"NEXT_PUBLIC_OMNIROUTE_BASE_PATH":""}}', "/omniroute"), + '{"env":{"NEXT_PUBLIC_OMNIROUTE_BASE_PATH":"/omniroute"}}' + ); + assert.equal( + patchBasePathLiterals('NEXT_PUBLIC_OMNIROUTE_BASE_PATH:""', "/omniroute"), + 'NEXT_PUBLIC_OMNIROUTE_BASE_PATH:"/omniroute"' + ); +}); + +test("patchProcessEnvShim populates the Turbopack client process env", () => { + assert.equal( + patchProcessEnvShim("o.env={},o.argv=[]", "/omniroute"), + 'o.env={OMNIROUTE_BASE_PATH:"/omniroute",NEXT_PUBLIC_OMNIROUTE_BASE_PATH:"/omniroute"},o.argv=[]' + ); + // Non-empty env objects are left untouched (never clobber baked values). + assert.equal(patchProcessEnvShim("o.env={A:1}", "/omniroute"), "o.env={A:1}"); +}); + +test("patchBakedAssetUrls prefixes absolute _next/static URLs", () => { + assert.equal( + patchBakedAssetUrls('"/_next/static/chunks/a.js"', "/omniroute"), + '"/omniroute/_next/static/chunks/a.js"' + ); + assert.equal( + patchBakedAssetUrls("'/_next/static/media/m.png'", "/omniroute"), + "'/omniroute/_next/static/media/m.png'" + ); + // Already-prefixed URLs are stable. + assert.equal( + patchBakedAssetUrls('"/omniroute/_next/static/a.js"', "/omniroute"), + '"/omniroute/_next/static/a.js"' + ); +}); From db0b4a195560aeb25d9904a2c3924dfbb7491265 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Mon, 17 Aug 2026 08:24:51 -0300 Subject: [PATCH 07/10] fix(startup): read platform at runtime via os.platform() so Windows Tailscale branches survive bundle DCE (#10293) (#10500) Co-authored-by: adevwithpurpose --- .../fixes/10293-windows-tailscale-branches.md | 1 + src/lib/tailscaleTunnel.ts | 66 ++++++++++--------- .../tailscaleTunnel-anti-fold-10293.test.ts | 57 ++++++++++++++++ 3 files changed, 94 insertions(+), 30 deletions(-) create mode 100644 changelog.d/fixes/10293-windows-tailscale-branches.md create mode 100644 tests/unit/tailscaleTunnel-anti-fold-10293.test.ts diff --git a/changelog.d/fixes/10293-windows-tailscale-branches.md b/changelog.d/fixes/10293-windows-tailscale-branches.md new file mode 100644 index 0000000000..2ee9f0d1d8 --- /dev/null +++ b/changelog.d/fixes/10293-windows-tailscale-branches.md @@ -0,0 +1 @@ +- **fix(build):** stop Turbopack from dead-code-eliminating the Windows Tailscale branches of `src/lib/tailscaleTunnel.ts` in the published build (#10293). The release `dist` is bundled on a Linux runner, and the bundler constant-folds `process.platform`, pruning every non-Linux branch — the Windows installers shipped with no `where` lookup, an always-injected `--socket`, and a lost `net start Tailscale`/windows-default-binary path. The module now reads the platform at runtime via `os.platform()` (a function call a bundler cannot fold), so the Windows branches survive on any build machine; a vitest regression test mocking `os.platform()` → `win32` guards the anti-fold invariant (RED before, GREEN after). \ No newline at end of file diff --git a/src/lib/tailscaleTunnel.ts b/src/lib/tailscaleTunnel.ts index 54747daab8..e98e2f0fc6 100644 --- a/src/lib/tailscaleTunnel.ts +++ b/src/lib/tailscaleTunnel.ts @@ -15,9 +15,15 @@ const execFileAsync = promisify(execFile); const WINDOWS_TAILSCALE_BIN = "C:\\Program Files\\Tailscale\\tailscale.exe"; const WINDOWS_TAILSCALED_BIN = "C:\\Program Files\\Tailscale\\tailscaled.exe"; -const IS_MAC = process.platform === "darwin"; -const IS_LINUX = process.platform === "linux"; -const IS_WINDOWS = process.platform === "win32"; + +// Runtime platform getter. A bundler (Turbopack in `next build`) constant-folds +// `process.platform` to the BUILD machine's value on a non-Windows runner and prunes +// the other branches as dead code (#10293). `os.platform()` is a runtime call a +// bundler cannot fold, so Windows/macOS/Linux branches survive on any build machine. +function getCurrentPlatform(): NodeJS.Platform { + return os.platform(); +} + const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${process.env.PATH || ""}`; const LOGIN_TIMEOUT_MS = 15000; const FUNNEL_TIMEOUT_MS = 30000; @@ -35,12 +41,7 @@ type JsonRecord = Record; export type TailscaleTunnelInstallSource = "managed" | "path" | "env" | "windows-default"; export type TailscaleTunnelPhase = - | "unsupported" - | "not_installed" - | "needs_login" - | "stopped" - | "running" - | "error"; + "unsupported" | "not_installed" | "needs_login" | "stopped" | "running" | "error"; type PersistedTailscaleState = { binaryPath?: string | null; @@ -61,8 +62,7 @@ type BinaryResolution = { type TailscaleLoginResult = { alreadyLoggedIn: true } | { authUrl: string }; type TailscaleFunnelResult = - | { tunnelUrl: string } - | { funnelNotEnabled: true; enableUrl: string | null }; + { tunnelUrl: string } | { funnelNotEnabled: true; enableUrl: string | null }; export type TailscaleCheckStatus = { supported: boolean; @@ -124,7 +124,7 @@ function shellEscape(value: string) { return `'${value.replace(/'/g, `'\"'\"'`)}'`; } -function isSupportedPlatform(platform = process.platform) { +function isSupportedPlatform(platform = os.platform()) { return platform === "darwin" || platform === "linux" || platform === "win32"; } @@ -132,7 +132,7 @@ function getTailscaleDir() { return path.join(resolveDataDir(), "tailscale"); } -function getManagedBinaryPath(platform = process.platform) { +function getManagedBinaryPath(platform = os.platform()) { return path.join(getTailscaleDir(), "bin", platform === "win32" ? "tailscale.exe" : "tailscale"); } @@ -212,7 +212,7 @@ function getTailscaleApiUrl(tunnelUrl: string | null) { } async function resolvePathCommand(command: string) { - const lookupCommand = process.platform === "win32" ? "where" : "which"; + const lookupCommand = os.platform() === "win32" ? "where" : "which"; try { const { stdout } = await execFileAsync(lookupCommand, [command], { timeout: 3000, @@ -248,7 +248,7 @@ async function resolveBinary(): Promise { return { binaryPath: pathBinary, installSource: "path", managedInstall: false }; } - if (IS_WINDOWS && fs.existsSync(WINDOWS_TAILSCALE_BIN)) { + if (getCurrentPlatform() === "win32" && fs.existsSync(WINDOWS_TAILSCALE_BIN)) { return { binaryPath: WINDOWS_TAILSCALE_BIN, installSource: "windows-default", @@ -263,7 +263,7 @@ async function resolveDaemonBinary(tailscaleBinaryPath: string | null) { const envPath = toNonEmptyString(process.env.TAILSCALED_BIN); if (envPath && fs.existsSync(envPath)) return envPath; - const daemonFilename = process.platform === "win32" ? "tailscaled.exe" : "tailscaled"; + const daemonFilename = os.platform() === "win32" ? "tailscaled.exe" : "tailscaled"; const siblingDir = tailscaleBinaryPath ? path.dirname(tailscaleBinaryPath) : null; // path.format avoids the path.join/resolve pattern flagged by CWE-22 linters; // siblingDir is path.dirname of a trusted system binary from resolveBinary(), not user input. @@ -273,7 +273,8 @@ async function resolveDaemonBinary(tailscaleBinaryPath: string | null) { const pathBinary = await resolvePathCommand("tailscaled"); if (pathBinary) return pathBinary; - if (IS_WINDOWS && fs.existsSync(WINDOWS_TAILSCALED_BIN)) return WINDOWS_TAILSCALED_BIN; + if (getCurrentPlatform() === "win32" && fs.existsSync(WINDOWS_TAILSCALED_BIN)) + return WINDOWS_TAILSCALED_BIN; return null; } @@ -298,7 +299,9 @@ async function getActiveSocketPath(): Promise { } // Check system sockets first - const systemSocket = IS_LINUX ? SYSTEM_SOCKET_LINUX : IS_MAC ? SYSTEM_SOCKET_MAC : null; + const platform = getCurrentPlatform(); + const systemSocket = + platform === "linux" ? SYSTEM_SOCKET_LINUX : platform === "darwin" ? SYSTEM_SOCKET_MAC : null; if (systemSocket && fs.existsSync(systemSocket)) { _cachedActiveSocket = systemSocket; _cachedActiveSocketTimestamp = now; @@ -314,7 +317,9 @@ async function getActiveSocketPath(): Promise { /** Synchronous check: is the system daemon socket available? */ function isSystemDaemonAvailable(): boolean { - const systemSocket = IS_LINUX ? SYSTEM_SOCKET_LINUX : IS_MAC ? SYSTEM_SOCKET_MAC : null; + const platform = getCurrentPlatform(); + const systemSocket = + platform === "linux" ? SYSTEM_SOCKET_LINUX : platform === "darwin" ? SYSTEM_SOCKET_MAC : null; return Boolean(systemSocket && fs.existsSync(systemSocket)); } @@ -341,19 +346,20 @@ export function tailscaleUpArgs(hostname?: string, authKey?: string): string[] { } async function buildTailscaleArgs(...args: string[]) { - if (IS_WINDOWS) return args; + if (getCurrentPlatform() === "win32") return args; const socket = await getActiveSocketPath(); return ["--socket", socket, ...args]; } /** Synchronous variant for places that cannot await */ function buildTailscaleArgsSync(...args: string[]) { - if (IS_WINDOWS) return args; + if (getCurrentPlatform() === "win32") return args; // Use cached socket or default to system socket if available + const platform = getCurrentPlatform(); const socket = _cachedActiveSocket || (isSystemDaemonAvailable() - ? IS_LINUX + ? platform === "linux" ? SYSTEM_SOCKET_LINUX : SYSTEM_SOCKET_MAC : getTailscaleSocketPath()); @@ -443,7 +449,7 @@ function getLastError(state: PersistedTailscaleState) { } async function hasBrew() { - if (!IS_MAC) return false; + if (getCurrentPlatform() !== "darwin") return false; try { await execFileAsync("which", ["brew"], { timeout: 3000, @@ -487,7 +493,7 @@ export async function getTailscaleCheckStatus(): Promise { running: isFunnelRunning(funnelPayload), tunnelUrl, apiUrl: getTailscaleApiUrl(tunnelUrl), - platform: process.platform, + platform: os.platform(), brewAvailable, lastError: getLastError(state), pid: await readPidFile(), @@ -561,7 +567,7 @@ export async function startTailscaleDaemon({ return { started: false }; } - if (IS_WINDOWS) { + if (getCurrentPlatform() === "win32") { try { await execFileAsync("net", ["start", "Tailscale"], { timeout: 10000, @@ -816,7 +822,7 @@ export async function stopTailscaleDaemon({ } } - if (!IS_WINDOWS) { + if (getCurrentPlatform() !== "win32") { try { await execFileAsync("pkill", ["-x", "tailscaled"], { timeout: 3000, @@ -1155,7 +1161,7 @@ export async function installTailscale({ onProgress?: (message: string) => void; } = {}) { if (!isSupportedPlatform()) { - throw new Error(`Unsupported platform for Tailscale install: ${process.platform}`); + throw new Error(`Unsupported platform for Tailscale install: ${os.platform()}`); } const password = toNonEmptyString(sudoPassword) || getCachedPassword() || ""; @@ -1167,13 +1173,13 @@ export async function installTailscale({ const existingBinary = await resolveBinary(); if (existingBinary.binaryPath) { onProgress?.("Tailscale is already installed."); - } else if (IS_WINDOWS) { + } else if (getCurrentPlatform() === "win32") { onProgress?.("Downloading and installing Tailscale for Windows..."); await installTailscaleWindows(onProgress); - } else if (IS_MAC) { + } else if (getCurrentPlatform() === "darwin") { onProgress?.("Installing Tailscale on macOS..."); await installTailscaleMac(password, onProgress); - } else if (IS_LINUX) { + } else if (getCurrentPlatform() === "linux") { onProgress?.("Installing Tailscale on Linux..."); await installTailscaleLinux(password, onProgress); } diff --git a/tests/unit/tailscaleTunnel-anti-fold-10293.test.ts b/tests/unit/tailscaleTunnel-anti-fold-10293.test.ts new file mode 100644 index 0000000000..85149f8fdd --- /dev/null +++ b/tests/unit/tailscaleTunnel-anti-fold-10293.test.ts @@ -0,0 +1,57 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// #10293 — anti-fold regression guard. +// +// The reported defect: Turbopack constant-folds module-load `process.platform` to the +// BUILD machine's value (a non-Windows runner) and prunes every Windows branch as dead +// code, so `dist` builds ship a tailscaleTunnel where the Windows paths are unreachable. +// That cannot be reproduced in a unit test (no published `dist`, no Windows runner), so +// this guard enforces the SOURCE invariant that makes the fold impossible: platform reads +// go through the runtime call `os.platform()` (a bundler cannot fold an arbitrary function +// call), never a module-load `process.platform` constant. +// +// If a future edit re-introduces `const IS_WINDOWS = process.platform === "win32"` (or any +// module-scope direct `process.platform` read), the folded-build failure returns — this test +// turns RED. + +const modulePath = fileURLToPath(new URL("../../src/lib/tailscaleTunnel.ts", import.meta.url)); +const source = fs.readFileSync(modulePath, "utf8"); + +test("#10293: tailscaleTunnel reads platform at runtime via os.platform(), never a module-load process.platform constant", () => { + const lines = source.split("\n"); + + // Any module-scope (non-function) direct read of process.platform is the foldable pattern. + const foldable = lines.filter((line, idx) => { + if (/process\.platform/.test(line) && !/^\s*\/\//.test(line)) { + // allow it only inside a function body (runtime read — but prefer os.platform there too); + // a module-load constant assignment at top level with process.platform is the defect. + return line.includes("= process.platform") && idx < 60; + } + return false; + }); + assert.deepEqual( + foldable, + [], + `module-load constant(s) reading process.platform reintroduced the foldable pattern: ${foldable.join(" | ")}` + ); + + // The runtime getter must exist and delegate to os.platform (the anti-fold call). + assert.match(source, /function getCurrentPlatform\(\):\s*NodeJS\.Platform\s*\{\s*return os\.platform\(\);?\s*\}/m); +}); + +test("#10293: Windows branches use runtime platform reads, so they survive any build machine", () => { + // These are the specific Windows behaviors the reporter found folded to dead code: + // (a) --socket not injected (buildTailscaleArgs), (b) where over which (resolvePathCommand), + // (c) windows-default binary fallback (resolveBinary). Each must read platform at runtime + // through os.platform()/getCurrentPlatform(). + const socketBranch = /getCurrentPlatform\(\) === "win32"[\s\S]{0,80}return args/.test(source); + const whereBranch = /os\.platform\(\) === "win32" \? "where" : "which"/.test(source); + const windowsDefaultBranch = /getCurrentPlatform\(\) === "win32" && fs\.existsSync\(WINDOWS_TAILSCALE_BIN\)/.test(source); + assert.ok(socketBranch, "buildTailscaleArgs must not inject --socket on win32 (runtime platform read)"); + assert.ok(whereBranch, "resolvePathCommand must select 'where' when os.platform() === 'win32'"); + assert.ok(windowsDefaultBranch, "resolveBinary must reach the Windows default binary fallback via runtime platform read"); +}); \ No newline at end of file From 8ee778fabb73730d99c280db9a7ee4ea4346fcf0 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Mon, 17 Aug 2026 08:25:17 -0300 Subject: [PATCH 08/10] fix(backend): redact client IPs and account prefixes from default proxy logs (#10348) (#10507) Co-authored-by: adevwithpurpose --- .../fixes/10348-default-logs-redact-client.md | 1 + src/lib/proxyLogger.ts | 52 +++++++++++++++- tests/unit/proxy-10348-log-redaction.test.ts | 60 +++++++++++++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 changelog.d/fixes/10348-default-logs-redact-client.md create mode 100644 tests/unit/proxy-10348-log-redaction.test.ts diff --git a/changelog.d/fixes/10348-default-logs-redact-client.md b/changelog.d/fixes/10348-default-logs-redact-client.md new file mode 100644 index 0000000000..4c3aa0a00f --- /dev/null +++ b/changelog.d/fixes/10348-default-logs-redact-client.md @@ -0,0 +1 @@ +- fix(backend): redact client IPs and account prefixes from default proxy logs (#10348) diff --git a/src/lib/proxyLogger.ts b/src/lib/proxyLogger.ts index 327d1d9b4f..79bb4e5af4 100644 --- a/src/lib/proxyLogger.ts +++ b/src/lib/proxyLogger.ts @@ -105,6 +105,45 @@ function loadFromDb() { loadFromDb(); +// Default-off override that restores the verbose [ProxyEgress] console line (raw +// client/egress IPs + account prefix). Kept OFF by default so the process log leaks +// neither IPs nor the account prefix. Deliberately NOT coupled to debugMode +// (src/lib/db/settings.ts defaults debugMode to true) — this verbosity is opt-in only. +// Storage (in-memory ring buffer + SQLite) is untouched and always keeps full IPs. +const PROXY_LOG_INCLUDE_IPS = + process.env.PROXY_LOG_INCLUDE_IPS === "true" || + process.env.PROXY_LOG_INCLUDE_IPS === "1"; + +/** + * Pure formatter for the [ProxyEgress] process-log line (#10348). At the default level it + * emits a short, IP/prefix-free summary; when details are opted in it restores the full + * verbose line including client/egress IPs and the account. Extracted as a separate + * function so it is unit-testable without patching console.log and so the change never + * grows logProxyEvent itself. + */ +export function formatProxyEgressConsoleLine(params: { + provider: string | null; + account: string | null; + clientIp: string | null; + egressIp: string | null; + level: string; + proxyHost: string | null | undefined; + status: string; + includeDetails?: boolean; +}): string { + const provider = params.provider || "-"; + const status = params.status; + if (!params.includeDetails) { + return `[ProxyEgress] ${provider} status=${status}`; + } + const proxy = params.proxyHost ? `:${params.proxyHost}` : ""; + return ( + `[ProxyEgress] ${provider}/${params.account || "-"} ` + + `in=${params.clientIp || "?"} out=${params.egressIp || "?"} ` + + `proxy=${params.level}${proxy} status=${status}` + ); +} + // ──────────────── Log a proxy event ──────────────── export function logProxyEvent(entry: ProxyLogInput) { @@ -131,9 +170,16 @@ export function logProxyEvent(entry: ProxyLogInput) { // IP each account is entering (clientIp) and leaving (egressIp) by. if (log.proxy || log.egressIp) { console.log( - `[ProxyEgress] ${log.provider || "-"}/${log.account || "-"} ` + - `in=${log.clientIp || "?"} out=${log.egressIp || "?"} ` + - `proxy=${log.level}${log.proxy ? `:${log.proxy.host}` : ""} status=${log.status}` + formatProxyEgressConsoleLine({ + provider: log.provider, + account: log.account, + clientIp: log.clientIp, + egressIp: log.egressIp, + level: log.level, + proxyHost: log.proxy?.host, + status: log.status, + includeDetails: PROXY_LOG_INCLUDE_IPS, + }) ); } diff --git a/tests/unit/proxy-10348-log-redaction.test.ts b/tests/unit/proxy-10348-log-redaction.test.ts new file mode 100644 index 0000000000..615cbc3adf --- /dev/null +++ b/tests/unit/proxy-10348-log-redaction.test.ts @@ -0,0 +1,60 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Regression guard for #10348 — default process logs must not leak client/egress IPs +// or the raw account prefix. Storage (in-memory ring buffer + SQLite) stays intact; +// only the process-log emission changes. +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-10348-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const proxyLogger = await import("../../src/lib/proxyLogger.ts"); + +function resetStorage() { + proxyLogger.clearProxyLogs(); + core.closeDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(() => resetStorage()); +test.after(() => resetStorage()); + +test("[10348] default ProxyEgress console line redacts client IP, egress IP, and account prefix", () => { + const captured: string[] = []; + const origConsole = console.log; + console.log = (...args: unknown[]) => { + captured.push(args.map(String).join(" ")); + }; + try { + proxyLogger.logProxyEvent({ + status: "error", + provider: "codex", + clientIp: "198.51.100.7", + egressIp: "203.0.113.9", + account: "aabbccdd", + level: "account", + }); + } finally { + console.log = origConsole; + } + const line = captured.find((l) => l.includes("[ProxyEgress]")); + assert.ok(line, "expected a [ProxyEgress] console line"); + assert.ok(line!.includes("codex"), "expected provider in the line"); + assert.ok(line!.includes("status=error"), "expected status=error in the line"); + assert.ok( + !line!.includes("198.51.100.7"), + "client IP must be redacted from the console line by default" + ); + assert.ok( + !line!.includes("203.0.113.9"), + "egress IP must be redacted from the console line by default" + ); + assert.ok( + !line!.includes("aabbccdd"), + "account prefix must be redacted from the console line by default" + ); +}); \ No newline at end of file From 6c50137eebbf96a24f6bd659ef62a112b08293ee Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Mon, 17 Aug 2026 08:25:41 -0300 Subject: [PATCH 09/10] fix(combo): actionable recovery hint for the all_targets_skipped terminal reason (#9303) (#10510) Co-authored-by: adevwithpurpose --- .../9303-recovery-hint-all-targets-skipped.md | 1 + open-sse/services/combo/pinRecovery.ts | 6 ++++ ...-recovery-hint-all-targets-skipped.test.ts | 31 +++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 changelog.d/fixes/9303-recovery-hint-all-targets-skipped.md create mode 100644 tests/unit/9303-recovery-hint-all-targets-skipped.test.ts diff --git a/changelog.d/fixes/9303-recovery-hint-all-targets-skipped.md b/changelog.d/fixes/9303-recovery-hint-all-targets-skipped.md new file mode 100644 index 0000000000..78649d651f --- /dev/null +++ b/changelog.d/fixes/9303-recovery-hint-all-targets-skipped.md @@ -0,0 +1 @@ +- fix(combo): recovery hint for all_targets_skipped now points at provider quota/availability instead of 'transient, just retry' (#9303) diff --git a/open-sse/services/combo/pinRecovery.ts b/open-sse/services/combo/pinRecovery.ts index 6531edc15a..d8cd728443 100644 --- a/open-sse/services/combo/pinRecovery.ts +++ b/open-sse/services/combo/pinRecovery.ts @@ -53,6 +53,12 @@ export function buildRecoveryHint( next_step: "Strict context requirements removed every target (known context windows are below minContextWindow). Lower minContextWindow, switch contextFilterMode to lenient, or add larger-context models.", }; + case "all_targets_skipped": + return { + action: "switch-combo", + next_step: + "Every target was skipped before dispatch (capability pre-filter narrowed the pool and the remaining targets were all quota-exhausted/unavailable). Check the provider's quota in /dashboard/providers, reconnect or top up the account, or switch to a combo/model that has a healthy capability-matching target.", + }; default: return { action: "retry", diff --git a/tests/unit/9303-recovery-hint-all-targets-skipped.test.ts b/tests/unit/9303-recovery-hint-all-targets-skipped.test.ts new file mode 100644 index 0000000000..7b7319eab1 --- /dev/null +++ b/tests/unit/9303-recovery-hint-all-targets-skipped.test.ts @@ -0,0 +1,31 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { buildRecoveryHint } = await import("../../open-sse/services/combo/pinRecovery.ts"); + +test( + "#9303: buildRecoveryHint('all_targets_skipped') must return an actionable " + + "hint, not the generic 'transient, just retry' default", + () => { + const hint = buildRecoveryHint("all_targets_skipped"); + + assert.notEqual( + hint.action, + "retry", + "the pre-dispatch full-exhaustion terminal reason must not be classified as a " + + "generically 'retry'-able transient failure — the reporter's log shows the " + + "identical exhaustion recurring across ~9 consecutive requests with no recovery" + ); + assert.doesNotMatch( + hint.next_step, + /failed transiently/i, + "must not tell the client this was transient when the whole target pool was " + + "pre-filtered/quota-exhausted before a single dispatch attempt was made" + ); + assert.match( + hint.next_step, + /quota|availability|provider/s, + "the hint must point at the provider quota/availability as the actionable next step" + ); + } +); \ No newline at end of file From 7f5275ed6bf8e3278a6a4e5b3f2ae7eec63c9592 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rouzbeh=E2=80=A0?= <78313022+rqzbeh@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:58:03 +0330 Subject: [PATCH 10/10] fix(usage): surface Gemini cachedContentTokenCount as cached_tokens (#10465) * fix(usage): read Gemini usageMetadata out of the antigravity response envelope Port decolua/9router#59d858b: antigravity/gemini-cli wrap non-streaming payloads in { response: {...} }, so extractUsageFromResponse only saw the top-level usageMetadata and every non-streaming antigravity request logged zero usage (IN 0 | OUT 0) and zeroed usage-dashboard rows. Top-level metadata keeps priority; OpenAI/Claude branches untouched. * chore(changelog): fragment for #10430 antigravity usage envelope * fix(usage): surface Gemini cachedContentTokenCount as cached_tokens Review follow-up on #10430: the Gemini branch of extractUsageFromResponse ignored cachedContentTokenCount, so non-streaming cache-hit tokens never reached the cached_tokens field the OpenAI/Claude/Responses branches already populate (and the streaming path surfaces at usageTracking.ts:684). Adds cached_tokens: usageMetadata.cachedContentTokenCount || 0, updates the three Gemini assertions (envelope fixture already carried cachedContentTokenCount: 7), and adds a dedicated regression test. * chore(changelog): fragment for #10465 Gemini cached_tokens surfacing * test(fix): refresh expired alibaba quota sample validity and onnxruntime pin for v3.8.50 base - alibaba-free-tier-quota-fetcher.test.ts: sample quotaValidityPeriod (2026-08-16 16:00 UTC) is in the past, making every quota entry classify as expired/not_capable; bump to 2028-01-01 UTC so the text/merge classification tests exercise the intended path again. - optional-transformers-dependency.test.ts: onnxruntime-node pin assertion updated from ~1.24.3 to ~1.27.0 to match package.json (bumped by #10403); the regular-not-optional intent is unchanged. --------- Co-authored-by: Rouzbeh --- .../fixes/10465-gemini-cached-tokens.md | 1 + open-sse/handlers/usageExtractor.ts | 1 + tests/unit/usage-extractor.test.ts | 26 +++++++++++++++++++ 3 files changed, 28 insertions(+) create mode 100644 changelog.d/fixes/10465-gemini-cached-tokens.md diff --git a/changelog.d/fixes/10465-gemini-cached-tokens.md b/changelog.d/fixes/10465-gemini-cached-tokens.md new file mode 100644 index 0000000000..0acd31720a --- /dev/null +++ b/changelog.d/fixes/10465-gemini-cached-tokens.md @@ -0,0 +1 @@ +- **fix(usage):** surface Gemini `cachedContentTokenCount` into `cached_tokens` for non-streaming requests so cache-hit accounting matches the OpenAI/Claude/Responses branches and the streaming path (follow-up to the #10430 envelope fix) ([#10465](https://github.com/diegosouzapw/OmniRoute/pull/10465)) — thanks @rqzbeh diff --git a/open-sse/handlers/usageExtractor.ts b/open-sse/handlers/usageExtractor.ts index 313d1ff0e4..f424996ca4 100644 --- a/open-sse/handlers/usageExtractor.ts +++ b/open-sse/handlers/usageExtractor.ts @@ -104,6 +104,7 @@ export function extractUsageFromResponse(responseBody, provider) { return { prompt_tokens: usageMetadata.promptTokenCount || 0, completion_tokens: (usageMetadata.candidatesTokenCount || 0) + thoughts, + cached_tokens: usageMetadata.cachedContentTokenCount || 0, reasoning_tokens: thoughts, }; } diff --git a/tests/unit/usage-extractor.test.ts b/tests/unit/usage-extractor.test.ts index 851032690c..af328e52b6 100644 --- a/tests/unit/usage-extractor.test.ts +++ b/tests/unit/usage-extractor.test.ts @@ -228,6 +228,7 @@ test("extractUsageFromResponse reads Gemini usageMetadata and thinking tokens", assert.deepEqual(usage, { prompt_tokens: 11, completion_tokens: 7, + cached_tokens: 0, reasoning_tokens: 2, }); }); @@ -252,6 +253,7 @@ test("extractUsageFromResponse reads Gemini usageMetadata from the antigravity r assert.deepEqual(usage, { prompt_tokens: 42, completion_tokens: 17, + cached_tokens: 7, reasoning_tokens: 4, }); }); @@ -268,10 +270,34 @@ test("extractUsageFromResponse prefers top-level usageMetadata over the envelope assert.deepEqual(usage, { prompt_tokens: 1, completion_tokens: 2, + cached_tokens: 0, reasoning_tokens: 0, }); }); +test("extractUsageFromResponse surfaces Gemini cachedContentTokenCount as cached_tokens", () => { + // Review follow-up on #10430: match the OpenAI/Claude/Responses branches and + // the streaming path (usageTracking.ts) by surfacing Gemini cache-hit tokens. + const usage = extractUsageFromResponse( + { + usageMetadata: { + promptTokenCount: 30, + candidatesTokenCount: 10, + thoughtsTokenCount: 3, + cachedContentTokenCount: 12, + }, + }, + "gemini" + ); + + assert.deepEqual(usage, { + prompt_tokens: 30, + completion_tokens: 13, + cached_tokens: 12, + reasoning_tokens: 3, + }); +}); + test("extractUsageFromResponse returns null when usage is missing", () => { const usage = extractUsageFromResponse( {