mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
Validated in local merge-train @ 8f27177d1 (full parity suite green: typecheck+file-size+complexity+cognitive+changelog+unit shards 1&2+vitest)
This commit is contained in:
committed by
GitHub
parent
8bf2e6929f
commit
735e2d0783
1
changelog.d/features/7274-generic-session-affinity.md
Normal file
1
changelog.d/features/7274-generic-session-affinity.md
Normal file
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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<any>({});
|
||||
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() {
|
||||
<div className="grid grid-cols-1 gap-3 pt-3 border-t border-border/50">
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{translateOrFallback(t, "codexSessionAffinityTitle", "Codex session affinity")}
|
||||
{translateOrFallback(t, "sessionAffinityTitle", "Session affinity")}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{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."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
label={translateOrFallback(t, "codexSessionAffinityTtl", "Affinity TTL (seconds)")}
|
||||
label={translateOrFallback(t, "sessionAffinityTtl", "Affinity TTL (seconds)")}
|
||||
type="number"
|
||||
min={0}
|
||||
max={86400}
|
||||
step={60}
|
||||
value={msToSeconds(codexSessionAffinityTtlMs)}
|
||||
onChange={(e) => setCodexSessionAffinityTtlMs(secondsInputToMs(e.target.value, 86400))}
|
||||
value={msToSeconds(sessionAffinityTtlMs)}
|
||||
onChange={(e) => setSessionAffinityTtlMs(secondsInputToMs(e.target.value, 86400))}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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';
|
||||
@@ -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<string, unknown>): 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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
281
tests/unit/session-affinity-generic-7274.test.ts
Normal file
281
tests/unit/session-affinity-generic-7274.test.ts
Normal file
@@ -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<string, unknown> = {}) {
|
||||
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<string, unknown>) || {},
|
||||
});
|
||||
}
|
||||
|
||||
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<string, string>;
|
||||
|
||||
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<string, string>;
|
||||
|
||||
// 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"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user