@bitkyc08/opencodex 2.52.0-preview.20260911 → 2.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/index-CWXut3rG.js +115 -0
- package/gui/dist/assets/index-EdoPnm9_.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/devin.svg +49 -0
- package/gui/dist/provider-icons/omo.svg +42 -0
- package/package.json +3 -1
- package/src/AGENTS.md +1 -1
- package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +0 -1
- package/src/adapters/command-code.ts +0 -1
- package/src/adapters/cursor/checkpoint-store.ts +37 -0
- package/src/adapters/cursor/live-transport.ts +74 -2
- package/src/adapters/cursor.ts +6 -0
- package/src/adapters/devin/cloud-direct/auth.ts +264 -0
- package/src/adapters/devin/cloud-direct/catalog.ts +306 -0
- package/src/adapters/devin/cloud-direct/chat.ts +1274 -0
- package/src/adapters/devin/cloud-direct/index.ts +65 -0
- package/src/adapters/devin/cloud-direct/metadata.ts +134 -0
- package/src/adapters/devin/cloud-direct/wire.ts +206 -0
- package/src/adapters/devin/live-models.ts +133 -0
- package/src/adapters/devin-cli/acp.ts +204 -0
- package/src/adapters/devin-cli/adapter.ts +345 -0
- package/src/adapters/devin-cli/binary.ts +69 -0
- package/src/adapters/devin-cli/models.ts +57 -0
- package/src/adapters/devin.ts +326 -0
- package/src/adapters/google.ts +1 -1
- package/src/adapters/openai-chat.ts +31 -10
- package/src/adapters/openai-responses.ts +16 -1
- package/src/adapters/registry.ts +25 -1
- package/src/bridge.ts +8 -2
- package/src/claude/inbound-cache-stabilize.ts +130 -0
- package/src/claude/inbound.ts +45 -5
- package/src/cli/account-auth.ts +60 -1
- package/src/cli/account-extended.ts +23 -30
- package/src/cli/account.ts +2 -1
- package/src/cli/capabilities.ts +50 -8
- package/src/cli/config-command.ts +2 -2
- package/src/cli/dispatch.ts +14 -2
- package/src/cli/export-command.ts +11 -25
- package/src/cli/help.ts +2 -2
- package/src/cli/opencode.ts +5 -0
- package/src/cli/registry.ts +15 -4
- package/src/clients/config-export/cline.ts +71 -0
- package/src/clients/config-export/contracts.ts +10 -1
- package/src/clients/config-export/model-metadata.ts +33 -0
- package/src/clients/config-export/zcode.ts +23 -12
- package/src/clients/config-export.ts +156 -4
- package/src/codex/account-store.ts +7 -2
- package/src/codex/auth-context.ts +1 -1
- package/src/codex/catalog/effort.ts +4 -3
- package/src/codex/catalog/metadata.ts +8 -4
- package/src/codex/catalog/native-models.ts +24 -4
- package/src/codex/catalog/parsing.ts +19 -1
- package/src/codex/catalog/provider-fetch.ts +57 -0
- package/src/codex/catalog/sync.ts +1 -1
- package/src/codex/context-compat.ts +97 -0
- package/src/codex/context-owner.ts +201 -0
- package/src/codex/history-provider.ts +161 -14
- package/src/codex/inject-coordination.ts +3 -2
- package/src/codex/inject.ts +128 -9
- package/src/codex/pool-rotation.ts +8 -292
- package/src/codex/quota.ts +41 -12
- package/src/codex/retired-model-migration.ts +41 -0
- package/src/codex/routing.ts +327 -22
- package/src/codex/warmup.ts +2 -2
- package/src/combos/failover.ts +5 -0
- package/src/combos/resolve.ts +11 -15
- package/src/config.ts +46 -14
- package/src/generated/compatibility-version.json +293 -113
- package/src/grok/grpc-web.ts +120 -0
- package/src/grok/reset-coupon-ledger.ts +139 -0
- package/src/grok/reset-coupons.ts +278 -0
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/cline-document.ts +73 -0
- package/src/integrations/cline-io.ts +149 -0
- package/src/integrations/cline-transaction.ts +42 -0
- package/src/integrations/config-io.ts +21 -2
- package/src/integrations/journal.ts +43 -1
- package/src/integrations/omp-yaml-source.ts +123 -4
- package/src/integrations/ownership-policy.ts +7 -0
- package/src/integrations/ownership.ts +36 -0
- package/src/integrations/registry.ts +27 -0
- package/src/integrations/state.ts +5 -2
- package/src/integrations/store.ts +6 -0
- package/src/integrations/writer.ts +53 -9
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/abort.ts +36 -0
- package/src/lib/local-destinations.ts +1 -1
- package/src/lib/upstream-retry.ts +36 -1
- package/src/oauth/account-quota-rank.ts +11 -0
- package/src/oauth/callback-server.ts +10 -5
- package/src/oauth/devin/api-base.ts +63 -0
- package/src/oauth/devin/login.ts +1 -0
- package/src/oauth/devin/register-user.ts +186 -0
- package/src/oauth/devin/types.ts +71 -0
- package/src/oauth/devin-cli.ts +149 -0
- package/src/oauth/devin.ts +170 -0
- package/src/oauth/generic-account-failover.ts +169 -1
- package/src/oauth/index.ts +20 -1
- package/src/oauth/login-cli.ts +19 -5
- package/src/oauth/pool-kernel.ts +321 -0
- package/src/oauth/pool-settings-capability.ts +127 -9
- package/src/oauth/store.ts +7 -3
- package/src/oauth/token-guardian.ts +1 -1
- package/src/providers/codebuddy-models.ts +0 -3
- package/src/providers/command-code-efforts.ts +23 -4
- package/src/providers/default-aliases.ts +4 -0
- package/src/providers/derive.ts +8 -0
- package/src/providers/devin-cli-authmode-migration.ts +57 -0
- package/src/providers/key-failover.ts +175 -0
- package/src/providers/model-rename-startup.ts +21 -1
- package/src/providers/qoder-models.ts +0 -1
- package/src/providers/quota-key-accounts.ts +50 -0
- package/src/providers/quota-routing-cache.ts +65 -6
- package/src/providers/quota-types.ts +7 -0
- package/src/providers/quota.ts +113 -30
- package/src/providers/registry.ts +237 -77
- package/src/providers/stale-context-window-migration.ts +92 -0
- package/src/providers/zai-responses-migration.ts +45 -0
- package/src/quota/reset-observer.ts +2 -1
- package/src/quota/reset-seen-store.ts +9 -1
- package/src/remote-control/crypto.ts +442 -0
- package/src/remote-control/host.ts +175 -0
- package/src/remote-control/index.ts +100 -0
- package/src/remote-control/protocol.ts +200 -0
- package/src/remote-control/relay.ts +162 -0
- package/src/remote-control/workspace-agent-protocol.ts +246 -0
- package/src/remote-control/workspace-rpc-framing.ts +128 -0
- package/src/remote-control/workspace-tools.ts +237 -0
- package/src/remote-control/workspace-utf8.ts +24 -0
- package/src/responses/custom-tool-compat.ts +23 -0
- package/src/router.ts +6 -1
- package/src/routing/compatibility/behavior.ts +3 -0
- package/src/server/adapter-resolve.ts +6 -2
- package/src/server/auth-cors.ts +71 -6
- package/src/server/chat-completions.ts +5 -3
- package/src/server/chat-native.ts +9 -0
- package/src/server/claude-messages.ts +25 -30
- package/src/server/context-history.ts +207 -0
- package/src/server/images.ts +28 -1
- package/src/server/index.ts +42 -22
- package/src/server/live.ts +2 -1
- package/src/server/management/agent-settings-routes.ts +7 -1
- package/src/server/management/config-routes.ts +3 -3
- package/src/server/management/grok-coupon-routes.ts +287 -0
- package/src/server/management/integration-routes.ts +6 -1
- package/src/server/management/oauth-account-routes.ts +124 -7
- package/src/server/management/provider-routes.ts +77 -2
- package/src/server/management/route-registry.ts +6 -1
- package/src/server/management-api.ts +7 -0
- package/src/server/request-log.ts +4 -0
- package/src/server/responses/codex-ws-exchange.ts +90 -14
- package/src/server/responses/codex-ws-wire.ts +51 -1
- package/src/server/responses/compact.ts +8 -1
- package/src/server/responses/core.ts +188 -22
- package/src/server/responses/ws-upstream.ts +1 -1
- package/src/server/responses-undeclared-tool-guard.ts +261 -12
- package/src/server/zai-responses-startup.ts +21 -0
- package/src/types/config.ts +33 -3
- package/src/types/provider.ts +54 -4
- package/src/types/request.ts +1 -1
- package/src/types/tools.ts +36 -6
- package/src/update/job.ts +33 -11
- package/src/vision/plan.ts +40 -37
- package/src/web-search/index.ts +1 -0
- package/gui/dist/assets/index-BoBRSehJ.css +0 -1
- package/gui/dist/assets/index-Dx0xv2EA.js +0 -115
|
@@ -54,6 +54,7 @@ import { fetchCursorUsableModels } from "../../adapters/cursor/live-models";
|
|
|
54
54
|
import { recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog";
|
|
55
55
|
import { fetchQoderModels } from "../../adapters/qoder/live-models";
|
|
56
56
|
import { resolveQoderProfile } from "../../adapters/qoder/profiles";
|
|
57
|
+
import { fetchDevinUsableModels } from "../../adapters/devin/live-models";
|
|
57
58
|
import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
|
|
58
59
|
import {
|
|
59
60
|
COMBO_NAMESPACE,
|
|
@@ -1699,6 +1700,62 @@ async function fetchProviderModelsWithAuth(
|
|
|
1699
1700
|
stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias) : configured,
|
|
1700
1701
|
), "degraded");
|
|
1701
1702
|
}
|
|
1703
|
+
if (prov.adapter === "devin") {
|
|
1704
|
+
if (!apiKey) return observed(configured, "degraded");
|
|
1705
|
+
const cachedDevin = getFreshCached(name, ttlMs);
|
|
1706
|
+
if (cachedDevin) {
|
|
1707
|
+
return observed(
|
|
1708
|
+
withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedDevin)),
|
|
1709
|
+
"authoritative",
|
|
1710
|
+
);
|
|
1711
|
+
}
|
|
1712
|
+
if (isModelsFetchCoolingDown(name)) {
|
|
1713
|
+
const cooling = getStaleCached(name);
|
|
1714
|
+
return observed(
|
|
1715
|
+
withConfiguredRetention(
|
|
1716
|
+
cooling ? applyConfigHintsToCachedModels(name, prov, cooling) : configured,
|
|
1717
|
+
),
|
|
1718
|
+
"degraded",
|
|
1719
|
+
);
|
|
1720
|
+
}
|
|
1721
|
+
const liveResult = await fetchDevinUsableModels({ apiKey, baseUrl: prov.baseUrl });
|
|
1722
|
+
if (liveResult.ok) {
|
|
1723
|
+
// Live catalog is the source of truth — use the discovered base models
|
|
1724
|
+
// directly, not a filtered subset of the static seed.
|
|
1725
|
+
//
|
|
1726
|
+
// That extends to the context window. Cognition publishes no window
|
|
1727
|
+
// anywhere, so the per-account catalog is the only first-party number,
|
|
1728
|
+
// and the shipped static table is a degraded-mode guess that was wrong
|
|
1729
|
+
// for nine of its eleven rows. The live value is applied first and the
|
|
1730
|
+
// config hints run after it, so an explicit per-model override and an
|
|
1731
|
+
// enabled Context cap still win — this only replaces the number nobody
|
|
1732
|
+
// chose.
|
|
1733
|
+
const result = liveResult.models.map((id) => {
|
|
1734
|
+
const liveWindow = liveResult.contextWindows[id];
|
|
1735
|
+
return {
|
|
1736
|
+
id,
|
|
1737
|
+
provider: name,
|
|
1738
|
+
...(liveWindow ? { contextWindow: liveWindow } : {}),
|
|
1739
|
+
...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
|
|
1740
|
+
} as CatalogModel;
|
|
1741
|
+
});
|
|
1742
|
+
const forCache = withConfiguredRetention(result, { retainComboTargets: false });
|
|
1743
|
+
if (!setCached(name, forCache, Date.now(), cacheGeneration)) {
|
|
1744
|
+
return observed(withConfiguredRetention(configured), "degraded");
|
|
1745
|
+
}
|
|
1746
|
+
markProviderDiscoveryOk(name, liveResult.models.length);
|
|
1747
|
+
return observed(withConfiguredRetention(forCache), "authoritative");
|
|
1748
|
+
}
|
|
1749
|
+
if (isCurrentCacheGeneration()) {
|
|
1750
|
+
markModelsFetchFailure(name);
|
|
1751
|
+
markProviderDiscoveryFailed(name, { reason: liveResult.error === "auth" ? "provider" : "invalid_response" });
|
|
1752
|
+
}
|
|
1753
|
+
const stale = getStaleCached(name);
|
|
1754
|
+
return observed(
|
|
1755
|
+
withConfiguredRetention(stale ? applyConfigHintsToCachedModels(name, prov, stale) : configured),
|
|
1756
|
+
"degraded",
|
|
1757
|
+
);
|
|
1758
|
+
}
|
|
1702
1759
|
if (prov.adapter === "cursor") {
|
|
1703
1760
|
if (!apiKey) return observed(configured, "degraded");
|
|
1704
1761
|
// Cursor uses a bespoke GetUsableModels RPC (not /models), returning the full effort-suffixed
|
|
@@ -260,7 +260,7 @@ export function finishUpstreamNativeEntry(clone: RawEntry, priority: number, con
|
|
|
260
260
|
if (priority !== 9) clone.priority = priority;
|
|
261
261
|
applyNativeOpenAiContextOverride(clone, contextCap);
|
|
262
262
|
// GPT-5.6 natives keep their exact upstream ladders (e.g. luna has max but no ultra).
|
|
263
|
-
// Older natives (gpt-5.5 / 5.
|
|
263
|
+
// Older natives (gpt-5.5 / 5.3-codex-spark) get mock max + ultra
|
|
264
264
|
// (wire-clamped to xhigh). Ultra is always advertised regardless of v2 toggle.
|
|
265
265
|
if (!isGpt56NativeSlug(String(clone.slug ?? ""))) ensureUltraReasoningLevel(clone);
|
|
266
266
|
return ensureStrictCatalogFields(normalizeServiceTiers(clone));
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** Backend path and opt-in config compatibility for native Codex history/notes. */
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getCodexHome } from "./paths";
|
|
5
|
+
|
|
6
|
+
export const CONTEXT_BACKEND_PREFIX = "/backend-api/codex";
|
|
7
|
+
|
|
8
|
+
/** The single definition of the opt-in, shared by injection and the runtime gate. */
|
|
9
|
+
export function contextExperimentalEnabled(configContent: string): boolean {
|
|
10
|
+
let parsed: { features?: { context_management?: { experimental_mode?: boolean } } };
|
|
11
|
+
try {
|
|
12
|
+
parsed = Bun.TOML.parse(configContent) as typeof parsed;
|
|
13
|
+
} catch {
|
|
14
|
+
// Injection tolerates incomplete user config; malformed TOML is not an opt-in.
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
return parsed.features?.context_management?.experimental_mode === true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let activation: { key: string; active: boolean } | undefined;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether this proxy may serve the context relay at all, decided by opencodex reading Codex own
|
|
24
|
+
* config rather than by anything a caller sends.
|
|
25
|
+
*
|
|
26
|
+
* Rewriting the injected base URL is what makes the feature REACHABLE, and gating only that would
|
|
27
|
+
* leave the ownership registry and both endpoint prefixes live for anyone who can already reach
|
|
28
|
+
* the data plane. An opt-in that a direct POST walks around is not an opt-in, so the same
|
|
29
|
+
* predicate guards recording and dispatch. An absent, unreadable or malformed config is not an
|
|
30
|
+
* opt-in. The result is cached against the config identity and re-read when the file changes, so
|
|
31
|
+
* turning the feature off takes effect without a restart and steady-state traffic does not parse
|
|
32
|
+
* TOML per request.
|
|
33
|
+
*/
|
|
34
|
+
export function contextRelayActivated(configPath?: string): boolean {
|
|
35
|
+
let key: string;
|
|
36
|
+
let path: string;
|
|
37
|
+
try {
|
|
38
|
+
// Resolved here, not in a default parameter: those are evaluated before the body, so a
|
|
39
|
+
// CODEX_HOME that became unreadable while the proxy runs would throw past this try. This
|
|
40
|
+
// function is now on the model path, where that would abort a turn upstream already served.
|
|
41
|
+
path = configPath ?? join(getCodexHome(), "config.toml");
|
|
42
|
+
const seen = statSync(path);
|
|
43
|
+
key = `${path}:${seen.mtimeMs}:${seen.size}:${String(seen.ino)}`;
|
|
44
|
+
} catch {
|
|
45
|
+
activation = undefined;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
if (activation?.key === key) return activation.active;
|
|
49
|
+
let active = false;
|
|
50
|
+
try {
|
|
51
|
+
active = contextExperimentalEnabled(readFileSync(path, "utf8"));
|
|
52
|
+
} catch {
|
|
53
|
+
// A readable stat with an unreadable body caches false under that identity, so the feature
|
|
54
|
+
// stays off until the content itself changes. Fail-closed is the right direction here.
|
|
55
|
+
active = false;
|
|
56
|
+
}
|
|
57
|
+
activation = { key, active };
|
|
58
|
+
return active;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Test seam: the cache is keyed by config identity, which a temp home reuses across cases. */
|
|
62
|
+
export function resetContextRelayActivationForTests(): void {
|
|
63
|
+
activation = undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const CONTEXT_ENDPOINTS = new Set([
|
|
67
|
+
"alpha/history/v2/list_windows", "alpha/history/v2/list_items",
|
|
68
|
+
"alpha/history/v2/read_item", "alpha/history/v2/search_contents",
|
|
69
|
+
"alpha/notes/v2/thread_hint", "alpha/notes/v2/list_files_by_prefix",
|
|
70
|
+
"alpha/notes/v2/read_file", "alpha/notes/v2/search_contents",
|
|
71
|
+
"alpha/notes/v2/append_to_file", "alpha/notes/v2/write_file",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
export function contextEndpoint(path: string): string | undefined {
|
|
75
|
+
const endpoint = path.startsWith("/v1/") ? path.slice(4) : "";
|
|
76
|
+
return CONTEXT_ENDPOINTS.has(endpoint) ? endpoint : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Alias only the data-plane prefix. Existing auth/origin and route gates still run. */
|
|
80
|
+
export function codexCompatibleUrl(rawUrl: string): URL {
|
|
81
|
+
const url = new URL(rawUrl);
|
|
82
|
+
if (url.pathname === CONTEXT_BACKEND_PREFIX || url.pathname.startsWith(CONTEXT_BACKEND_PREFIX + "/")) {
|
|
83
|
+
url.pathname = "/v1" + url.pathname.slice(CONTEXT_BACKEND_PREFIX.length);
|
|
84
|
+
}
|
|
85
|
+
return url;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Change only marker-managed built-in routing, and only with an explicit context opt-in. */
|
|
89
|
+
export function contextCompatibleBaseLine(content: string, line: string): string {
|
|
90
|
+
if (!contextExperimentalEnabled(content)) return line;
|
|
91
|
+
const match = /^openai_base_url = "([^"]+)"$/.exec(line);
|
|
92
|
+
if (!match) return line;
|
|
93
|
+
const url = new URL(match[1]);
|
|
94
|
+
if (url.pathname !== "/v1" || !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)) return line;
|
|
95
|
+
url.pathname = CONTEXT_BACKEND_PREFIX;
|
|
96
|
+
return `openai_base_url = "${url.href}"`;
|
|
97
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
2
|
+
import type { CodexAuthContext } from "./auth-context";
|
|
3
|
+
import { MAIN_CODEX_ACCOUNT_ID } from "./account-id";
|
|
4
|
+
import { nativeUserIdClaims } from "./reserve-availability";
|
|
5
|
+
|
|
6
|
+
export type ContextSessionOwner = Readonly<
|
|
7
|
+
| { kind: "stored"; accountId: string; physicalIdentity: string; userIdentity?: string;
|
|
8
|
+
callerCredentialIdentity: string; ambiguous: boolean }
|
|
9
|
+
| { kind: "caller"; physicalIdentity?: string; userIdentity?: string;
|
|
10
|
+
callerCredentialIdentity: string; ambiguous: boolean }
|
|
11
|
+
>;
|
|
12
|
+
|
|
13
|
+
const TTL_MS = 24 * 60 * 60_000;
|
|
14
|
+
const MAX_ENTRIES = 2048;
|
|
15
|
+
const MAX_BYTES = 1024 * 1024;
|
|
16
|
+
const salt = randomBytes(32);
|
|
17
|
+
type Entry = { owner: ContextSessionOwner; destination: string; touchedAt: number; bytes: number };
|
|
18
|
+
const owners = new Map<string, Entry>();
|
|
19
|
+
let totalBytes = 0;
|
|
20
|
+
|
|
21
|
+
function digest(domain: string, value: string): string {
|
|
22
|
+
return createHmac("sha256", salt).update(domain).update("\0").update(value).digest("hex");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function validId(value: string | null | undefined): value is string {
|
|
26
|
+
return typeof value === "string" && /^[A-Za-z0-9._:-]{1,512}$/.test(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function destinationIdentity(destination: string): string | undefined {
|
|
30
|
+
if (!destination || destination.length > 4096) return undefined;
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(destination);
|
|
33
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.hash) return undefined;
|
|
34
|
+
return digest("destination", url.href.replace(/\/+$/, ""));
|
|
35
|
+
} catch { return undefined; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function physicalIdentity(headers: Headers): string | undefined {
|
|
39
|
+
const account = headers.get("chatgpt-account-id");
|
|
40
|
+
return validId(account) ? digest("physical-account", account) : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The stable person behind an accepted credential, when the token says so.
|
|
45
|
+
*
|
|
46
|
+
* `chatgpt-account-id` names a WORKSPACE, and two people in one workspace send the same value.
|
|
47
|
+
* Ownership therefore also binds the user claim carried by the credential that upstream actually
|
|
48
|
+
* accepted. The claim is read without signature verification, which is why upstream acceptance
|
|
49
|
+
* remains the evidence and a conflicting pair of claims fails closed instead of picking one.
|
|
50
|
+
*/
|
|
51
|
+
function stableUserIdentity(headers: Headers): { identity?: string; conflict: boolean } {
|
|
52
|
+
const authorization = headers.get("authorization");
|
|
53
|
+
if (!authorization || authorization.length > 32_768 || !/^Bearer [^\s]+$/i.test(authorization)) {
|
|
54
|
+
return { conflict: false };
|
|
55
|
+
}
|
|
56
|
+
const claims = nativeUserIdClaims(authorization.slice(7));
|
|
57
|
+
if (claims.conflict) return { conflict: true };
|
|
58
|
+
return { ...(claims.userId ? { identity: digest("native-user", claims.userId) } : {}), conflict: false };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function callerCredentialIdentity(headers: Headers): string | undefined {
|
|
62
|
+
const authorization = headers.get("authorization");
|
|
63
|
+
if (!authorization || authorization.length > 32_768 || !/^Bearer [^\s]+$/i.test(authorization)) return undefined;
|
|
64
|
+
const account = headers.get("chatgpt-account-id");
|
|
65
|
+
if (account !== null && !validId(account)) return undefined;
|
|
66
|
+
return digest("caller-credential", JSON.stringify([authorization.slice(7), account]));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether two accepted observations describe the same owner.
|
|
71
|
+
*
|
|
72
|
+
* A proven stable user may present a refreshed credential. Without that proof on BOTH sides the
|
|
73
|
+
* only continuity evidence left is the credential itself, because a workspace id is shared by
|
|
74
|
+
* everyone in the organization and would otherwise let one member rebind another session.
|
|
75
|
+
*/
|
|
76
|
+
function sameOwnerIdentity(prior: ContextSessionOwner, next: ContextSessionOwner): boolean {
|
|
77
|
+
if (prior.kind !== next.kind) return false;
|
|
78
|
+
if (prior.physicalIdentity !== next.physicalIdentity) return false;
|
|
79
|
+
return prior.userIdentity !== undefined && next.userIdentity !== undefined
|
|
80
|
+
? prior.userIdentity === next.userIdentity
|
|
81
|
+
: prior.callerCredentialIdentity === next.callerCredentialIdentity;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function remove(key: string): void {
|
|
85
|
+
const prior = owners.get(key);
|
|
86
|
+
if (!prior) return;
|
|
87
|
+
owners.delete(key);
|
|
88
|
+
totalBytes -= prior.bytes;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function sweep(now: number): void {
|
|
92
|
+
for (const [key, entry] of owners) {
|
|
93
|
+
if (now < entry.touchedAt || now - entry.touchedAt >= TTL_MS) remove(key);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Called only after a model attempt was accepted by its actual destination. */
|
|
98
|
+
export function recordContextSessionOwner(
|
|
99
|
+
principalId: string | undefined, inboundHeaders: Headers, destination: string, auth: CodexAuthContext,
|
|
100
|
+
outboundHeaders: Headers, substituteMainCredential: boolean, now = Date.now(),
|
|
101
|
+
): void {
|
|
102
|
+
// Ownership is partitioned per admission principal. Without one there is no caller identity to
|
|
103
|
+
// own anything, so nothing is recorded rather than creating an entry any local process matches.
|
|
104
|
+
if (!principalId || !Number.isFinite(now)) return;
|
|
105
|
+
// A malformed explicit parent must not fall back to an unrelated local session.
|
|
106
|
+
const root = inboundHeaders.get("x-codex-parent-thread-id") ?? inboundHeaders.get("session-id");
|
|
107
|
+
if (!validId(root)) return;
|
|
108
|
+
const destinationKey = destinationIdentity(destination);
|
|
109
|
+
const credential = callerCredentialIdentity(outboundHeaders);
|
|
110
|
+
if (!destinationKey || !credential) return;
|
|
111
|
+
const physical = physicalIdentity(outboundHeaders);
|
|
112
|
+
const user = stableUserIdentity(outboundHeaders);
|
|
113
|
+
if (user.conflict) {
|
|
114
|
+
// An accepted credential naming two different users is evidence of nobody. Whatever entry
|
|
115
|
+
// this session already had stops being trustworthy at that moment.
|
|
116
|
+
markContextSessionAmbiguous(principalId, root, now);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
let owner: ContextSessionOwner;
|
|
120
|
+
if (auth.kind !== "main" || substituteMainCredential) {
|
|
121
|
+
if (!physical) return;
|
|
122
|
+
if (auth.kind !== "main" && (!validId(auth.accountId)
|
|
123
|
+
|| auth.chatgptAccountId !== outboundHeaders.get("chatgpt-account-id"))) return;
|
|
124
|
+
// Direct proxy-bearer substitution has no token snapshot in its `main` context;
|
|
125
|
+
// its accepted outbound identity is still evidence for the stored main slot.
|
|
126
|
+
owner = { kind: "stored", accountId: auth.kind === "main" ? MAIN_CODEX_ACCOUNT_ID : auth.accountId,
|
|
127
|
+
physicalIdentity: physical, ...(user.identity ? { userIdentity: user.identity } : {}),
|
|
128
|
+
callerCredentialIdentity: credential, ambiguous: false };
|
|
129
|
+
} else {
|
|
130
|
+
owner = { kind: "caller", ...(physical ? { physicalIdentity: physical } : {}),
|
|
131
|
+
...(user.identity ? { userIdentity: user.identity } : {}),
|
|
132
|
+
callerCredentialIdentity: credential, ambiguous: false };
|
|
133
|
+
}
|
|
134
|
+
sweep(now);
|
|
135
|
+
const key = digest("root-session", `${principalId}\u0000${root}`);
|
|
136
|
+
const prior = owners.get(key);
|
|
137
|
+
if (prior) {
|
|
138
|
+
// Same workspace, different person is exactly the case a workspace id cannot see, and a
|
|
139
|
+
// credential proving nobody must not inherit an entry just by sharing that id.
|
|
140
|
+
if (prior.owner.ambiguous || prior.destination !== destinationKey
|
|
141
|
+
|| !sameOwnerIdentity(prior.owner, owner)) {
|
|
142
|
+
owner = { ...prior.owner, ambiguous: true };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const ownerDestination = prior?.destination ?? destinationKey;
|
|
146
|
+
const bytes = Buffer.byteLength(JSON.stringify([key, ownerDestination, owner]), "utf8");
|
|
147
|
+
remove(key);
|
|
148
|
+
owners.set(key, { owner: Object.freeze(owner), destination: ownerDestination, touchedAt: now, bytes });
|
|
149
|
+
totalBytes += bytes;
|
|
150
|
+
while (owners.size > MAX_ENTRIES || totalBytes > MAX_BYTES) {
|
|
151
|
+
const oldest = owners.keys().next().value;
|
|
152
|
+
if (oldest === undefined) break;
|
|
153
|
+
remove(oldest);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Missing/expired/evicted ownership is unknown; never infer it from active routing. */
|
|
158
|
+
export function getContextSessionOwner(
|
|
159
|
+
principalId: string | undefined, sessionId: string, destination: string, now = Date.now(),
|
|
160
|
+
): ContextSessionOwner | undefined {
|
|
161
|
+
if (!principalId || !validId(sessionId) || !Number.isFinite(now)) return undefined;
|
|
162
|
+
const destinationKey = destinationIdentity(destination);
|
|
163
|
+
if (!destinationKey) return undefined;
|
|
164
|
+
sweep(now);
|
|
165
|
+
const key = digest("root-session", `${principalId}\u0000${sessionId}`);
|
|
166
|
+
const entry = owners.get(key);
|
|
167
|
+
if (!entry || entry.destination !== destinationKey) return undefined;
|
|
168
|
+
owners.delete(key);
|
|
169
|
+
entry.touchedAt = now;
|
|
170
|
+
owners.set(key, entry);
|
|
171
|
+
return entry.owner;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Compare only already-materialized headers; this function never reads credentials. */
|
|
175
|
+
export function contextSessionOwnerMatches(owner: ContextSessionOwner, headers: Headers): boolean {
|
|
176
|
+
if (owner.ambiguous) return false;
|
|
177
|
+
const user = stableUserIdentity(headers);
|
|
178
|
+
if (user.conflict) return false;
|
|
179
|
+
if (owner.physicalIdentity !== physicalIdentity(headers)) return false;
|
|
180
|
+
if (owner.userIdentity !== user.identity) return false;
|
|
181
|
+
// A stored credential was minted by this proxy for the account it selected, so a proven user
|
|
182
|
+
// may present a refreshed token. A caller-supplied bearer is not ours: it becomes this
|
|
183
|
+
// session credential only when a model turn was accepted with it, so history has to present
|
|
184
|
+
// exactly that credential rather than any token carrying the same claims.
|
|
185
|
+
return owner.kind === "stored" && owner.userIdentity !== undefined
|
|
186
|
+
? true
|
|
187
|
+
: owner.callerCredentialIdentity === callerCredentialIdentity(headers);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Poison an entry whose accepted evidence stopped being coherent. */
|
|
191
|
+
function markContextSessionAmbiguous(principalId: string, root: string | null, now: number): void {
|
|
192
|
+
if (!validId(root)) return;
|
|
193
|
+
const key = digest("root-session", principalId + "\u0000" + root);
|
|
194
|
+
const prior = owners.get(key);
|
|
195
|
+
if (!prior || prior.owner.ambiguous) return;
|
|
196
|
+
owners.set(key, { ...prior, owner: Object.freeze({ ...prior.owner, ambiguous: true }), touchedAt: now });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function clearContextSessionOwnersForTests(): void {
|
|
200
|
+
owners.clear(); totalBytes = 0;
|
|
201
|
+
}
|