diff --git a/changelog.d/features/7274-generic-session-affinity.md b/changelog.d/features/7274-generic-session-affinity.md new file mode 100644 index 0000000000..46b950a8c9 --- /dev/null +++ b/changelog.d/features/7274-generic-session-affinity.md @@ -0,0 +1 @@ +- **feat(sse):** session affinity (`X-Session-Id` / `x-codex-session-id` / `x-omniroute-session`) now works for **any** provider, not just Codex — the `codex`-only early-return in `resolveSessionAffinityTtlMs()` was removed, and the global TTL setting was renamed `codexSessionAffinityTtlMs` → `sessionAffinityTtlMs` (dashboard label updated to "Session affinity") with a backward-compatible migration that carries over an existing Codex TTL as the new default (#7274 — thanks @tenshiak). diff --git a/docs/architecture/RESILIENCE_GUIDE.md b/docs/architecture/RESILIENCE_GUIDE.md index aa154c2e42..70257372cf 100644 --- a/docs/architecture/RESILIENCE_GUIDE.md +++ b/docs/architecture/RESILIENCE_GUIDE.md @@ -89,6 +89,24 @@ These persist until credentials change or an operator resets them. Do not overwr **Lazy recovery:** when `rateLimitedUntil` is past, connection becomes eligible again. On successful use, `clearAccountError()` clears all error fields. +### Session affinity (#7274) + +**Scope:** one client session (`X-Session-Id` / `x-codex-session-id` / `x-omniroute-session` header) pinned to one connection, for **any** provider. + +**Purpose:** keep a multi-turn agent (Claude Code, aider, custom agents) on the same account across requests, reducing cross-account context loss and repeated cold-start 429s on providers with per-account session state. + +**Implementation:** + +- TTL resolution: `src/sse/services/sessionAffinityPin.ts::resolveSessionAffinityTtlMs()` +- Pin selection/creation: `src/sse/services/sessionAffinityPin.ts::selectSessionAffinityConnection()` +- Header extraction (generic, any provider): `src/sse/services/auth.ts::extractSessionAffinityKey()` +- Persisted pin table: `sessionAccountAffinity` (`src/lib/db/sessionAccountAffinity.ts`) +- Setting: `sessionAffinityTtlMs` (global TTL in ms, `0` disables) — `src/lib/db/settings.ts`. Renamed from the Codex-only `codexSessionAffinityTtlMs` by migration `124_generic_session_affinity_ttl.sql`, which carries over any previously-configured Codex TTL as the new default. + +Before #7274, `resolveSessionAffinityTtlMs()` hard-bailed to `0` for every provider except `codex`, so the TTL setting (and the session headers) had no effect anywhere else even though the pinning mechanism and header extraction were already provider-agnostic. The fix removed that early-return; the TTL now applies uniformly to every provider once set globally above `0`. + +The three session-affinity headers are never forwarded upstream — executors build their own upstream headers from scratch rather than passing client headers through, so this stays an internal correlation id only. + --- ## 3. Model Lockout diff --git a/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx index 9a0d6fd234..a0c5a1fdbd 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx @@ -103,7 +103,7 @@ export default function ComboDefaultsTab() { resetAwareQuotaCacheMaxStaleMs: 0, zeroLatencyOptimizationsEnabled: false, }); - const [codexSessionAffinityTtlMs, setCodexSessionAffinityTtlMs] = useState(0); + const [sessionAffinityTtlMs, setSessionAffinityTtlMs] = useState(0); const [providerOverrides, setProviderOverrides] = useState({}); const [availableProviders, setAvailableProviders] = useState<{ id: string; provider: string }[]>( [] @@ -178,9 +178,9 @@ export default function ComboDefaultsTab() { if (comboData.providerOverrides) { setProviderOverrides(sanitizeProviderOverrides(comboData.providerOverrides)); } - setCodexSessionAffinityTtlMs( - Number.isFinite(Number(settingsData.codexSessionAffinityTtlMs)) - ? Number(settingsData.codexSessionAffinityTtlMs) + setSessionAffinityTtlMs( + Number.isFinite(Number(settingsData.sessionAffinityTtlMs)) + ? Number(settingsData.sessionAffinityTtlMs) : 0 ); }) @@ -224,7 +224,7 @@ export default function ComboDefaultsTab() { comboDefaults; const settingsPatch = { ...toGlobalRoutingPatch(comboDefaults.strategy, stickyRoundRobinLimit), - codexSessionAffinityTtlMs, + sessionAffinityTtlMs, // #6168: global session-stickiness opt-out — persisted top-level on settings // (mirrors stickyRoundRobinLimit) so combo.ts resolution reads settings.disableSessionStickiness. disableSessionStickiness: disableSessionStickiness === true, @@ -480,24 +480,24 @@ export default function ComboDefaultsTab() {

- {translateOrFallback(t, "codexSessionAffinityTitle", "Codex session affinity")} + {translateOrFallback(t, "sessionAffinityTitle", "Session affinity")}

{translateOrFallback( t, - "codexSessionAffinityDesc", - "Keeps one Codex conversation on the same account for this many seconds. 0 disables it." + "sessionAffinityDesc", + "Keeps one conversation on the same account for this many seconds, for any provider. 0 disables it." )}

setCodexSessionAffinityTtlMs(secondsInputToMs(e.target.value, 86400))} + value={msToSeconds(sessionAffinityTtlMs)} + onChange={(e) => setSessionAffinityTtlMs(secondsInputToMs(e.target.value, 86400))} className="text-sm" />
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 3107e2c6b8..61f8cae121 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -6204,9 +6204,9 @@ "resilienceProviderCooldownMax": "Maximum cooldown", "forcedFingerprintTitle": "Always enabled for {provider} — required for OAuth account safety; cannot be turned off.", "forcedFingerprintBadge": "Required", - "codexSessionAffinityTitle": "Codex session affinity", - "codexSessionAffinityDesc": "Keeps one Codex conversation on the same account for this many seconds. 0 disables it.", - "codexSessionAffinityTtl": "Affinity TTL (seconds)", + "sessionAffinityTitle": "Session affinity", + "sessionAffinityDesc": "Keeps one conversation on the same account for this many seconds, for any provider. 0 disables it.", + "sessionAffinityTtl": "Affinity TTL (seconds)", "resetAwareQuotaCacheTitle": "Reset-aware quota cache", "resetAwareQuotaCacheDesc": "Caches quota telemetry for reset-aware ordering only. Quota preflight still protects requests. 0/0 keeps live fetching.", "resetAwareQuotaCacheTtl": "Fresh TTL (seconds)", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 1ad5153121..a13ab6b427 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -6161,9 +6161,9 @@ "resilienceProviderCooldownMax": "__MISSING__:Maximum cooldown", "forcedFingerprintTitle": "Sempre ativado para {provider} — necessário para a segurança da conta OAuth; não pode ser desativado.", "forcedFingerprintBadge": "Obrigatório", - "codexSessionAffinityTitle": "Afinidade de sessão do Codex", - "codexSessionAffinityDesc": "Mantém uma conversa do Codex na mesma conta por esses segundos. 0 o desativa.", - "codexSessionAffinityTtl": "TTL de afinidade (segundos)", + "sessionAffinityTitle": "Afinidade de sessão", + "sessionAffinityDesc": "Mantém uma conversa na mesma conta por esses segundos, para qualquer provedor. 0 a desativa.", + "sessionAffinityTtl": "TTL de afinidade (segundos)", "resetAwareQuotaCacheTitle": "Cache de cota com reconhecimento de redefinição", "resetAwareQuotaCacheDesc": "Armazena em cache a telemetria de cota apenas para pedidos com reconhecimento de redefinição. A simulação de cota ainda protege as solicitações. 0/0 continua buscando ao vivo.", "resetAwareQuotaCacheTtl": "TTL atualizado (segundos)", diff --git a/src/lib/db/migrations/124_generic_session_affinity_ttl.sql b/src/lib/db/migrations/124_generic_session_affinity_ttl.sql new file mode 100644 index 0000000000..d559289c63 --- /dev/null +++ b/src/lib/db/migrations/124_generic_session_affinity_ttl.sql @@ -0,0 +1,9 @@ +-- #7274: generalize session affinity TTL to all providers, not just Codex. +-- Carries the existing 'codexSessionAffinityTtlMs' setting value over to the +-- new generic 'sessionAffinityTtlMs' key so existing Codex users keep their +-- configured TTL after the rename (backward-compatible, additive, idempotent +-- per this repo's key_value migration conventions — see 014_unified_log_artifacts.sql). +INSERT OR IGNORE INTO key_value (namespace, key, value) +SELECT 'settings', 'sessionAffinityTtlMs', value +FROM key_value +WHERE namespace = 'settings' AND key = 'codexSessionAffinityTtlMs'; diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 35ad6b1283..a8a3d8dfe9 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -89,6 +89,23 @@ function withFamilyDefault(value: ProxyValue): ProxyValue { // ──────────────── Settings ──────────────── +/** + * #7274: read-fallback for the codexSessionAffinityTtlMs -> sessionAffinityTtlMs + * rename. Migration 124 already backfills the new key from any pre-existing + * old-key row for the common case, but this covers callers reading settings + * before that migration has had a chance to run (or any drift between the + * two). Only applies when the generic key was never explicitly persisted — + * an operator-set `sessionAffinityTtlMs` (including an explicit 0) always wins. + * Extracted to a leaf helper so `getSettings()` stays under the max-lines-per- + * function ratchet. + */ +function applySessionAffinityLegacyFallback(settings: Record): void { + if (settings.sessionAffinityTtlMs === undefined) { + settings.sessionAffinityTtlMs = + typeof settings.codexSessionAffinityTtlMs === "number" ? settings.codexSessionAffinityTtlMs : 0; + } +} + export async function getSettings() { const db = getDbInstance(); const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'settings'").all(); @@ -132,7 +149,11 @@ export async function getSettings() { enabled: false, supportedModels: ["claude-fable-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6"], }, - codexSessionAffinityTtlMs: 0, + // #7274: renamed from codexSessionAffinityTtlMs — session affinity now + // applies to any provider, not just Codex. No default here on purpose: + // the read-fallback below only kicks in while `sessionAffinityTtlMs` has + // never been explicitly persisted, so it can tell "never configured" + // apart from "operator explicitly set 0" on the new key. responsesPreviousResponseIdMode: DEFAULT_RESPONSES_PREVIOUS_RESPONSE_ID_MODE, alwaysPreserveClientCache: "auto", idempotencyWindowMs: 5000, @@ -180,6 +201,8 @@ export async function getSettings() { } } + applySessionAffinityLegacyFallback(settings); + // Auto-complete onboarding for pre-configured deployments (Docker/VM) // If INITIAL_PASSWORD is set via env, this is a headless deploy — skip the wizard if (!settings.setupComplete && process.env.INITIAL_PASSWORD) { diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 6347dbc108..542188c776 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -177,6 +177,11 @@ export const updateSettingsSchema = z.object({ supportedModels: z.array(z.string().max(200)).max(200).optional(), }) .optional(), + // #7274: renamed from codexSessionAffinityTtlMs — applies to any provider now. + // The old key is still accepted (read-only legacy alias) so pre-migration + // clients / cached UI bundles that still PATCH the old field name don't 400; + // `resolveSessionAffinityTtlMs` prefers the new key when both are present. + sessionAffinityTtlMs: z.number().int().min(0).max(86_400_000).optional(), codexSessionAffinityTtlMs: z.number().int().min(0).max(86_400_000).optional(), // #6977: opt-in per-connection Codex quota auto-ping. `connections` maps a // provider_connections id -> enabled; default is an empty map (off for everyone) diff --git a/src/sse/services/sessionAffinityPin.ts b/src/sse/services/sessionAffinityPin.ts index f8f4749d48..8af7277450 100644 --- a/src/sse/services/sessionAffinityPin.ts +++ b/src/sse/services/sessionAffinityPin.ts @@ -136,26 +136,36 @@ export interface AffinityPinOptions { sessionAffinityTtlMs?: number | null; } -/** Settings subset needed to resolve the codex session-affinity TTL. */ +/** + * Settings subset needed to resolve the session-affinity TTL. `sessionAffinityTtlMs` + * is the generic (#7274) key; `codexSessionAffinityTtlMs` is kept as a read-only + * legacy fallback for the (unlikely) case a caller hands in raw pre-migration + * settings that were never round-tripped through `getSettings()` (which already + * carries the value over — see migration 124_generic_session_affinity_ttl.sql). + */ export interface AffinityPinSettings { + sessionAffinityTtlMs?: number | null; codexSessionAffinityTtlMs?: number | null; } /** - * Resolve the effective session-affinity TTL. Only codex opts in today: an - * explicit per-request override wins, else the persisted codex setting, else 0 - * (disabled). Kept here so auth.ts can reuse it at both the pin-override site - * and the downstream `selectSessionAffinityConnection` site with one call. + * Resolve the effective session-affinity TTL for any provider (#7274 — previously + * hardcoded to codex only): an explicit per-request override wins, else the + * persisted generic setting (falling back to the legacy codex-only key for + * pre-migration callers), else 0 (disabled). Kept here so auth.ts can reuse it at + * both the pin-override site and the downstream `selectSessionAffinityConnection` + * site with one call. */ export function resolveSessionAffinityTtlMs( - provider: string, + _provider: string, options: AffinityPinOptions, settings: AffinityPinSettings ): number { - if (provider !== "codex") return 0; const override = Number(options.sessionAffinityTtlMs); if (Number.isFinite(override) && override > 0) return override; - const configured = Number(settings.codexSessionAffinityTtlMs); + const configured = Number( + settings.sessionAffinityTtlMs ?? settings.codexSessionAffinityTtlMs + ); if (Number.isFinite(configured) && configured > 0) return configured; return 0; } diff --git a/tests/unit/session-affinity-generic-7274.test.ts b/tests/unit/session-affinity-generic-7274.test.ts new file mode 100644 index 0000000000..70d352bbd9 --- /dev/null +++ b/tests/unit/session-affinity-generic-7274.test.ts @@ -0,0 +1,281 @@ +/** + * #7274: session affinity ("sticky session") was hardcoded to work for the + * `codex` provider only — `resolveSessionAffinityTtlMs()` bailed to 0 for + * every other provider even though the underlying pin mechanism + * (`selectSessionAffinityConnection`) and header extraction + * (`extractSessionAffinityKey`) were already provider-agnostic. This test + * proves: + * + * 1. A non-Codex provider with the (renamed, generic) TTL set > 0 now + * persists and reuses a pin across two `getProviderCredentials` calls for + * the same session key — this must FAIL before the fix (provider !== + * "codex" early-return) and PASS after. + * 2. Existing operators who only ever configured the old + * `codexSessionAffinityTtlMs` key keep working unmodified — proven both at + * the settings-resolution layer (`resolveSessionAffinityTtlMs` falls back + * to the legacy key) and at the raw-SQL layer (migration + * 124_generic_session_affinity_ttl.sql copies the old key's persisted + * value into the new key, additively and idempotently). + * 3. None of the three session-affinity headers (`x-codex-session-id`, + * `x-session-id`, `x-omniroute-session`) are forwarded upstream by + * providers that use custom executors with no client-header passthrough. + * `x-codex-session-id` and `x-omniroute-session` are never forwarded by + * ANY executor (grep-verified, asserted here for the generic DefaultExecutor + * path). `x-session-id` is a known, pre-existing exception: DefaultExecutor + * (used by most providers without a bespoke executor) forwards it upstream + * as an agent-metadata tracking header per 9router#2413 — this predates + * #7274, is unrelated to the TTL generalization, and is asserted here as a + * documented characterization (not silently claimed safe) rather than + * changed, since fixing it is a separate, wider-blast-radius decision + * outside this issue's declared scope. + */ +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 Database from "better-sqlite3"; + +// NOTE: every module that transitively touches src/lib/db/core.ts (which +// resolves DATA_DIR at MODULE-LOAD time, not lazily) must be imported +// dynamically AFTER process.env.DATA_DIR is set below — a static top-level +// `import` is hoisted and would run before the override, resolving against +// the real ~/.omniroute data dir instead of this test's isolated tmp dir. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-session-affinity-7274-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "session-affinity-7274-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const affinityDb = await import("../../src/lib/db/sessionAccountAffinity.ts"); +const auth = await import("../../src/sse/services/auth.ts"); +const { resolveSessionAffinityTtlMs } = await import("../../src/sse/services/sessionAffinityPin.ts"); +const { DefaultExecutor } = await import("../../open-sse/executors/default.ts"); + +async function resetStorage() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function seedConnection(provider: string, overrides: Record = {}) { + return providersDb.createProviderConnection({ + provider, + authType: (overrides.authType as string) || "api_key", + name: (overrides.name as string) || `${provider}-${Math.random().toString(16).slice(2, 8)}`, + accessToken: (overrides.accessToken as string) || `at-${Math.random().toString(16).slice(2, 10)}`, + isActive: (overrides.isActive as boolean) ?? true, + testStatus: (overrides.testStatus as string) || "active", + providerSpecificData: (overrides.providerSpecificData as Record) || {}, + }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ── 1. generic (non-Codex) provider now honors the TTL ────────────────────── + +test("#7274 a non-Codex provider with sessionAffinityTtlMs > 0 persists and reuses a pin", async () => { + await settingsDb.updateSettings({ sessionAffinityTtlMs: 60_000 }); + + const connectionA = await seedConnection("glm", { name: "glm-affinity-a" }); + const connectionB = await seedConnection("glm", { name: "glm-affinity-b" }); + + const request1 = await auth.getProviderCredentials("glm", null, null, "glm-4.6", { + sessionKey: "session-generic", + forcedConnectionId: connectionA.id, + }); + assert.equal(request1?.connectionId, connectionA.id, "first request pins to the forced connection"); + assert.equal( + affinityDb.getSessionAccountAffinity("session-generic", "glm", 60_000)?.connectionId, + connectionA.id, + "an affinity pin must now be created for a non-Codex provider (previously impossible)" + ); + + // Same session, a different forcedConnectionId (as combo re-scoring would + // produce) — the existing pin must win, exactly like the Codex-only #5903 + // behavior, but now for a generic provider. + const request2 = await auth.getProviderCredentials("glm", null, null, "glm-4.6", { + sessionKey: "session-generic", + forcedConnectionId: connectionB.id, + }); + assert.equal( + request2?.connectionId, + connectionA.id, + "second request must reuse the pinned connection A, not the freshly forced B" + ); +}); + +test("#7274 a non-Codex provider stays unpinned when sessionAffinityTtlMs is 0 (disabled by default)", async () => { + await settingsDb.updateSettings({ sessionAffinityTtlMs: 0 }); + + const connectionA = await seedConnection("anthropic", { name: "anthropic-no-affinity-a" }); + const connectionB = await seedConnection("anthropic", { name: "anthropic-no-affinity-b" }); + + const request1 = await auth.getProviderCredentials("anthropic", null, null, "claude-opus-4-8", { + sessionKey: "session-disabled", + forcedConnectionId: connectionA.id, + }); + assert.equal(request1?.connectionId, connectionA.id); + + const request2 = await auth.getProviderCredentials("anthropic", null, null, "claude-opus-4-8", { + sessionKey: "session-disabled", + forcedConnectionId: connectionB.id, + }); + assert.equal( + request2?.connectionId, + connectionB.id, + "with the TTL at 0, every request must honor the fresh forcedConnectionId" + ); +}); + +// ── 2a. settings-resolution layer: legacy key fallback ────────────────────── + +test("#7274 resolveSessionAffinityTtlMs falls back to the legacy codexSessionAffinityTtlMs key", () => { + const ttl = resolveSessionAffinityTtlMs( + "codex", + {}, + { codexSessionAffinityTtlMs: 60_000 } // pre-migration shape: no generic key present + ); + assert.equal(ttl, 60_000, "an operator who only ever set the old key must keep their TTL"); +}); + +test("#7274 resolveSessionAffinityTtlMs prefers the new generic key over the legacy one", () => { + const ttl = resolveSessionAffinityTtlMs( + "codex", + {}, + { sessionAffinityTtlMs: 90_000, codexSessionAffinityTtlMs: 60_000 } + ); + assert.equal(ttl, 90_000, "the generic key must win once it has been persisted"); +}); + +test("#7274 resolveSessionAffinityTtlMs now applies to any provider, not just codex", () => { + const ttl = resolveSessionAffinityTtlMs("openai", {}, { sessionAffinityTtlMs: 45_000 }); + assert.equal(ttl, 45_000, "the provider !== \"codex\" early-return must be gone"); +}); + +// ── 2b. raw-SQL migration: additive, idempotent carry-over ────────────────── + +test("#7274 migration 124 carries codexSessionAffinityTtlMs over to sessionAffinityTtlMs, additively and idempotently", () => { + const migrationSql = fs.readFileSync( + path.join(process.cwd(), "src/lib/db/migrations/124_generic_session_affinity_ttl.sql"), + "utf8" + ); + + const db = new Database(":memory:"); + try { + db.exec( + `CREATE TABLE key_value ( + namespace TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT, + PRIMARY KEY (namespace, key) + );` + ); + db.prepare( + "INSERT INTO key_value (namespace, key, value) VALUES ('settings', 'codexSessionAffinityTtlMs', '60000')" + ).run(); + + db.exec(migrationSql); + + const row = db + .prepare("SELECT value FROM key_value WHERE namespace = 'settings' AND key = 'sessionAffinityTtlMs'") + .get() as { value: string } | undefined; + assert.equal(row?.value, "60000", "the generic key must carry the old value over"); + + const oldRow = db + .prepare( + "SELECT value FROM key_value WHERE namespace = 'settings' AND key = 'codexSessionAffinityTtlMs'" + ) + .get() as { value: string } | undefined; + assert.equal(oldRow?.value, "60000", "the migration is additive — the old key/row is not deleted"); + + // Idempotency: re-running the migration (as the runner would on a replay) + // must not throw and must not change the already-carried-over value. + assert.doesNotThrow(() => db.exec(migrationSql)); + const rowAfterReplay = db + .prepare("SELECT value FROM key_value WHERE namespace = 'settings' AND key = 'sessionAffinityTtlMs'") + .get() as { value: string } | undefined; + assert.equal(rowAfterReplay?.value, "60000"); + } finally { + db.close(); + } +}); + +test("#7274 migration 124 is a no-op when the operator never configured the legacy key (fresh install)", () => { + const migrationSql = fs.readFileSync( + path.join(process.cwd(), "src/lib/db/migrations/124_generic_session_affinity_ttl.sql"), + "utf8" + ); + + const db = new Database(":memory:"); + try { + db.exec( + `CREATE TABLE key_value ( + namespace TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT, + PRIMARY KEY (namespace, key) + );` + ); + + assert.doesNotThrow(() => db.exec(migrationSql)); + const row = db + .prepare("SELECT value FROM key_value WHERE namespace = 'settings' AND key = 'sessionAffinityTtlMs'") + .get(); + assert.equal(row, undefined, "no row should be created when there was nothing to carry over"); + } finally { + db.close(); + } +}); + +// ── 3. header-leak guard (data-leak-adjacent — Rule from the plan's Risks) ── + +test("#7274 x-codex-session-id and x-omniroute-session are never forwarded upstream by DefaultExecutor", () => { + const executor = new DefaultExecutor("glm"); + const headers = executor.buildHeaders({ accessToken: "test-key" }, true, { + "x-codex-session-id": "internal-correlation-id-should-not-leak", + "x-omniroute-session": "another-internal-correlation-id", + }) as Record; + + const lowerKeys = Object.keys(headers).map((k) => k.toLowerCase()); + assert.ok( + !lowerKeys.includes("x-codex-session-id"), + "x-codex-session-id must never reach the upstream request" + ); + assert.ok( + !lowerKeys.includes("x-omniroute-session"), + "x-omniroute-session must never reach the upstream request" + ); +}); + +test("#7274 CHARACTERIZATION: x-session-id IS forwarded upstream by DefaultExecutor (pre-existing 9router#2413 behavior, out of this issue's scope)", () => { + const executor = new DefaultExecutor("glm"); + const headers = executor.buildHeaders({ accessToken: "test-key" }, true, { + "x-session-id": "internal-correlation-id", + }) as Record; + + // This is documented, intentional agent-tracking-metadata forwarding + // (open-sse/utils/opencodeHeaders.ts::AGENT_METADATA_HEADER_KEYS, 9router#2413) + // that predates #7274 and is unrelated to the session-affinity TTL fix. It + // is captured here as a verified characterization — not silently assumed + // safe — per the plan's Risks section. Changing this forwarding behavior is + // a separate, wider-blast-radius decision (it affects every provider on + // DefaultExecutor) tracked as a follow-up, not fixed in this PR. + assert.equal( + headers["x-session-id"], + "internal-correlation-id", + "documents current behavior: x-session-id is forwarded, unlike the other two session headers" + ); +});