Merge release/v3.8.50 (7f5275ed) into fix/qdrant-health-badge

This commit is contained in:
Rouzbeh
2026-08-17 11:42:22 +00:00
50 changed files with 1718 additions and 370 deletions

2
.gitignore vendored
View File

@@ -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

View File

@@ -513,6 +513,8 @@ Pix copia-e-cola:
<br/>
<p><strong>Developer notes:</strong> The project may generate a local <code>.env</code> file during npm install/postinstall for developer convenience. This file is intentionally ignored via <code>.gitignore</code> (see <code>.gitignore</code>) and must never be committed — if accidentally committed, rotate any exposed secrets and remove the file from history. See <a href="docs/DEVELOPER-ENVIRONMENT.md">docs/DEVELOPER-ENVIRONMENT.md</a> for guidance on managing local environment files and secrets.</p>
## 📡 OmniRoute Radar
The main free-tier headline remains **~1.53B tokens/month** from the documented,

View File

@@ -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).

View File

@@ -0,0 +1 @@
- fix(backend): redact client IPs and account prefixes from default proxy logs (#10348)

View File

@@ -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

View File

@@ -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

View File

@@ -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))

View File

@@ -0,0 +1 @@
- fix(combo): recovery hint for all_targets_skipped now points at provider quota/availability instead of 'transient, just retry' (#9303)

View File

@@ -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`)

View File

@@ -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}"

View File

@@ -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.

View File

@@ -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.

View File

@@ -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)

View File

@@ -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

View File

@@ -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 =

View File

@@ -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`

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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<string, unknown>): void {
const projectCache = new Map<string, string>();
/** Per-key lock to prevent concurrent onboard attempts for the same token. */
const onboardLocks = new Map<string, Promise<boolean>>();
const onboardLocks = new Map<string, Promise<void>>();
/**
* 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<string>();
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<Response>;
@@ -138,7 +173,7 @@ async function tryOnboardUser(
clientProfile: AntigravityClientProfile,
tierId: string,
signal?: AbortSignal
): Promise<boolean> {
): Promise<AntigravityOnboardStatus> {
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<string>();
/**
* 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<string, number>();
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,

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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 */
});
}

View File

@@ -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);

View File

@@ -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<string, unknown> {
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 };
}

View File

@@ -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,

View File

@@ -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<string, unknown> | null = null;
let lastFetch = 0;
@@ -25,17 +26,22 @@ async function loadAliases(): Promise<Record<string, unknown>> {
}
/**
* 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<string | null | undefined> {
if (!model) return model;
const aliases = await loadAliases();
const target = aliases[model];
const target = aliases[model] ?? (DEFAULT_MODEL_ALIAS_SEED as Record<string, unknown>)[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<string, unknown> | null | undefined
): Promise<void> {
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);
}
/**

View File

@@ -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.

View File

@@ -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,
})
);
}

View File

@@ -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<string, unknown>;
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<BinaryResolution> {
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<string> {
}
// 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<string> {
/** 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<TailscaleCheckStatus> {
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);
}

View File

@@ -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({

View File

@@ -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<string, unknown>
);
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, () =>

View File

@@ -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"
);
}
);

View File

@@ -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<Response> => {
@@ -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<Response> => {
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<Response> => {
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 () => {

View File

@@ -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)");
});

View File

@@ -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);
});

View File

@@ -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<string, unknown>, onSave = vi.fn()) {
act(() => {
root.render(
<EditConnectionModal
isOpen={true}
connection={connection}
providerId={connection.provider as string}
onSave={onSave}
onClose={vi.fn()}
/>
);
});
}
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<string, unknown>;
};
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<string, unknown>;
};
expect(updates.providerSpecificData?.isProjectIdManual).toBe(false);
});
});

View File

@@ -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"'
);
});

View File

@@ -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();

View File

@@ -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")));
});

View File

@@ -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) {

View File

@@ -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<void>) {
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");
});

View File

@@ -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`
);
}

View File

@@ -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");

View File

@@ -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"
);
});

View File

@@ -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<string, unknown>) {
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);
});

View File

@@ -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, {});
});
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, {});
});

View File

@@ -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");
});

View File

@@ -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(
{