@bitkyc08/opencodex 2.78.0 → 2.79.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.
Files changed (71) hide show
  1. package/gui/dist/assets/App-BOUa-ek3.js +54 -0
  2. package/gui/dist/assets/App-CSJvmjNs.css +1 -0
  3. package/gui/dist/assets/{Tray-DiJpSub0.js → Tray-DUtzB4zF.js} +1 -1
  4. package/gui/dist/assets/{index-DmQB8D_R.css → index-Brk6F3fn.css} +1 -1
  5. package/gui/dist/assets/index-DwHNDF4s.js +94 -0
  6. package/gui/dist/assets/{quota-summary-B_a96nXj.js → quota-summary-BuL4WIBo.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +3 -2
  9. package/src/adapters/devin.ts +22 -1
  10. package/src/claude/desktop-picker.ts +11 -9
  11. package/src/claude/inbound-content-options.ts +29 -2
  12. package/src/claude/intercept/local-ca-files.ts +265 -0
  13. package/src/claude/intercept/local-ca-windows.ts +73 -0
  14. package/src/claude/intercept/local-ca.ts +12 -24
  15. package/src/claude/intercept/picker-ca-persistence.ts +106 -0
  16. package/src/claude/intercept/picker-ca-startup.ts +16 -0
  17. package/src/claude/intercept/picker-ca-store.ts +70 -0
  18. package/src/claude/intercept/picker-ca.ts +43 -9
  19. package/src/claude/intercept/picker-runtime.ts +14 -4
  20. package/src/claude/intercept/runtime.ts +32 -23
  21. package/src/cli/ensure-readiness.ts +48 -0
  22. package/src/cli/gui-pair-client.ts +15 -7
  23. package/src/cli/gui.ts +4 -1
  24. package/src/cli/hub.ts +1 -0
  25. package/src/cli/index.ts +10 -11
  26. package/src/cli/status.ts +2 -2
  27. package/src/cli/update-restart-child.ts +4 -1
  28. package/src/codex/account-credit-use.ts +8 -2
  29. package/src/codex/auth-api/account-pause-group.ts +113 -0
  30. package/src/codex/auth-api/routes.ts +18 -12
  31. package/src/codex/auth-context.ts +45 -3
  32. package/src/codex/autostart-health.ts +10 -0
  33. package/src/codex/pool-credit-policy.ts +67 -0
  34. package/src/generated/compatibility-version.json +106 -46
  35. package/src/lib/gui-pair-capability.ts +1 -1
  36. package/src/lib/gui-pair-intent.ts +170 -0
  37. package/src/lib/windows-owner-acl.ts +143 -0
  38. package/src/providers/openai-sidecar-credit.ts +26 -0
  39. package/src/providers/openai-sidecar.ts +35 -0
  40. package/src/server/claude-messages.ts +1 -0
  41. package/src/server/gui-pair-delivery.ts +23 -0
  42. package/src/server/index/serve-options.ts +5 -8
  43. package/src/server/live-native-calls.ts +94 -0
  44. package/src/server/live.ts +28 -5
  45. package/src/server/local-management-read-client.ts +15 -11
  46. package/src/server/management/oauth-account-routes.ts +15 -0
  47. package/src/server/management/route-registry.ts +1 -0
  48. package/src/server/management/shared.ts +9 -3
  49. package/src/server/messages-native-scope.ts +57 -0
  50. package/src/server/messages-native.ts +9 -0
  51. package/src/server/responses/adapter-dispatch.ts +31 -2
  52. package/src/server/responses/compact.ts +17 -2
  53. package/src/server/responses/core-auth.ts +18 -1
  54. package/src/server/responses/core-codex-account.ts +18 -8
  55. package/src/server/responses/non-replayable-error.ts +48 -0
  56. package/src/server/responses/passthrough-delivery.ts +5 -1
  57. package/src/server/responses/passthrough-dispatch.ts +21 -19
  58. package/src/server/responses/request-prepare.ts +11 -1
  59. package/src/server/responses/sidecar-execution.ts +1 -0
  60. package/src/server/search.ts +4 -0
  61. package/src/server/startup-health-cache.ts +2 -6
  62. package/src/vision/describe.ts +17 -10
  63. package/src/vision/index.ts +1 -0
  64. package/src/web-search/executor.ts +17 -10
  65. package/src/web-search/format-result.ts +304 -20
  66. package/src/web-search/loop.ts +2 -1
  67. package/src/web-search/passthrough-bridge.ts +1 -0
  68. package/src/web-search/run-turn-loop.ts +1 -1
  69. package/gui/dist/assets/App-BtfG5id-.js +0 -54
  70. package/gui/dist/assets/App-lCIslZ1L.css +0 -1
  71. package/gui/dist/assets/index-Btc3Ogw4.js +0 -94
@@ -0,0 +1,143 @@
1
+ import { resolveTrustedWindowsPowerShellExe } from "./windows-elevation";
2
+
3
+ export interface WindowsPrivateEntry {
4
+ path: string;
5
+ directory: boolean;
6
+ }
7
+ export interface WindowsOwnerAclResult {
8
+ success: boolean;
9
+ timedOut: boolean;
10
+ stdout: string | Uint8Array;
11
+ }
12
+ export type WindowsOwnerAclRunner = (entries: readonly WindowsPrivateEntry[], timeoutMs: number) => WindowsOwnerAclResult;
13
+
14
+ export const WINDOWS_OWNER_ACL_TIMEOUT_MS = 5_000;
15
+ const SID = /^S-1-\d+(?:-\d+)+$/i;
16
+ const ADMINISTRATORS = "S-1-5-32-544";
17
+ const PRIVILEGED_GRANTEES = new Set(["S-1-5-18", ADMINISTRATORS]); // LocalSystem, BUILTIN\Administrators
18
+ const PRIVATE_DACL_FLAGS = 0x1004; // DiscretionaryAclPresent | DiscretionaryAclProtected
19
+ const FULL_CONTROL = 2032127;
20
+
21
+ // This script is constant. Literal paths are environment data, never PowerShell source.
22
+ // Read the descriptor through .NET (Owner, Group and Access, as Get-Acl does). Get-Acl is a module-autoloaded
23
+ // cmdlet: PowerShell 5.1 resolves it through the per-profile module analysis cache, and with a fresh or
24
+ // redirected LOCALAPPDATA that rebuild scans every PSModulePath module and can outlast the inspection budget
25
+ // (hosted Windows runners: the pairing CLI timed out at 30 s). Every call below is a .NET member, so nothing
26
+ // autoloads and no module on PSModulePath can shadow the read. GetAccessRules projects away unsupported ACE details, so the
27
+ // ACEs are read from the serialized descriptor instead. .NET canonicalizes it first: compatible entries for the
28
+ // same principal merge and entries that grant nothing drop, but another principal's entry is never folded into
29
+ // the user's, and the owner is untouched. The policy is therefore the effective DACL, not the on-disk bytes.
30
+ const ACL_SCRIPT = String.raw`
31
+ $ErrorActionPreference='Stop'
32
+ try {
33
+ # ASCII has no preamble, so a UTF-8 console code page cannot prefix the protocol with a BOM.
34
+ try { [Console]::OutputEncoding=[System.Text.Encoding]::ASCII } catch { $encodingUnchanged=$true }
35
+ $countText=[Environment]::GetEnvironmentVariable('OCX_ACL_COUNT')
36
+ if ($countText -notmatch '^[1-9][0-9]*$') { throw 'count' }
37
+ $count=[int]$countText
38
+ $identity=[System.Security.Principal.WindowsIdentity]::GetCurrent()
39
+ $principal=[System.Security.Principal.WindowsPrincipal]::new($identity)
40
+ $administrators=[System.Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
41
+ $enabled=$principal.IsInRole($administrators)
42
+ [Console]::WriteLine(('U|{0}|{1}|{2}' -f $identity.User.Value,$identity.Owner.Value,$enabled))
43
+ $sections=[System.Security.AccessControl.AccessControlSections]'Owner,Group,Access'
44
+ for ($i=0; $i -lt $count; $i++) {
45
+ $path=[Environment]::GetEnvironmentVariable(('OCX_ACL_PATH_{0}' -f $i))
46
+ if ([string]::IsNullOrEmpty($path)) { throw 'path' }
47
+ if ([System.IO.Directory]::Exists($path)) { $acl=[System.Security.AccessControl.DirectorySecurity]::new($path, $sections) }
48
+ else { $acl=[System.Security.AccessControl.FileSecurity]::new($path, $sections) }
49
+ $acls=@($acl)
50
+ if ($acls.Count -ne 1) { throw 'acl' }
51
+ $bytes=$acls[0].GetSecurityDescriptorBinaryForm()
52
+ $raw=[System.Security.AccessControl.RawSecurityDescriptor]::new([byte[]]$bytes, 0)
53
+ $aceCount=-1
54
+ if ($null -ne $raw.DiscretionaryAcl) { $aceCount=$raw.DiscretionaryAcl.Count }
55
+ [Console]::WriteLine(('E|{0}|{1}|{2}|{3}' -f $i,$raw.Owner.Value,[int]$raw.ControlFlags,$aceCount))
56
+ if ($null -ne $raw.DiscretionaryAcl) {
57
+ foreach ($ace in $raw.DiscretionaryAcl) {
58
+ if ($ace -is [System.Security.AccessControl.CommonAce]) {
59
+ [Console]::WriteLine(('A|{0}|{1}|{2}|{3}|{4}|{5}' -f $i,[int]$ace.AceType,[int]$ace.AceFlags,[int]$ace.AccessMask,$ace.SecurityIdentifier.Value,$ace.IsCallback))
60
+ } else {
61
+ [Console]::WriteLine(('A|{0}|{1}|X' -f $i,[int]$ace.AceType))
62
+ }
63
+ }
64
+ }
65
+ }
66
+ [Console]::WriteLine('END')
67
+ exit 0
68
+ } catch { exit 3 }
69
+ `;
70
+ const ENCODED_ACL_SCRIPT = Buffer.from(ACL_SCRIPT, "utf16le").toString("base64");
71
+
72
+ function defaultWindowsOwnerAclRunner(entries: readonly WindowsPrivateEntry[], timeoutMs: number): WindowsOwnerAclResult {
73
+ const env: Record<string, string | undefined> = { ...process.env, OCX_ACL_COUNT: String(entries.length) };
74
+ for (let i = 0; i < entries.length; i++) env[`OCX_ACL_PATH_${i}`] = entries[i]!.path;
75
+ const result = Bun.spawnSync([
76
+ resolveTrustedWindowsPowerShellExe(), "-NoLogo", "-NoProfile", "-NonInteractive",
77
+ "-ExecutionPolicy", "Bypass", "-EncodedCommand", ENCODED_ACL_SCRIPT,
78
+ ], {
79
+ env, stdin: "ignore", stdout: "pipe", stderr: "ignore",
80
+ timeout: timeoutMs, windowsHide: true,
81
+ });
82
+ return { success: result.success, timedOut: result.exitedDueToTimeout ?? false, stdout: result.stdout ?? new Uint8Array() };
83
+ }
84
+ let ownerAclRunner: WindowsOwnerAclRunner = defaultWindowsOwnerAclRunner;
85
+ /** Test-only access to the real subprocess runner, for native diagnostics. */
86
+ export const windowsOwnerAclDefaultRunnerForTests: WindowsOwnerAclRunner = defaultWindowsOwnerAclRunner;
87
+
88
+ export function setWindowsOwnerAclRunnerForTests(runner: WindowsOwnerAclRunner | null): void {
89
+ ownerAclRunner = runner ?? defaultWindowsOwnerAclRunner;
90
+ }
91
+
92
+ /** Pure, strict line-protocol parser and owner/effective-DACL policy. No account names or SDDL. */
93
+ export function windowsPrivateEntriesAclMatches(stdout: string | Uint8Array, entries: readonly WindowsPrivateEntry[]): boolean {
94
+ if (entries.length === 0) return false;
95
+ // The script emits ASCII only. Reject BOMs, NULs, non-ASCII bytes and replacement decoding.
96
+ if (typeof stdout !== "string" && stdout.some(byte => byte > 127)) return false;
97
+ const text = typeof stdout === "string" ? stdout : Buffer.from(stdout).toString("ascii");
98
+ if (/[^\x20-\x7e\r\n]/.test(text)) return false;
99
+ const normalized = text.replace(/\r\n/g, "\n");
100
+ if (normalized.includes("\r")) return false;
101
+ const lines = (normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized).split("\n");
102
+ if (lines.at(-1) !== "END") return false;
103
+ const token = lines[0]!.split("|");
104
+ if (token.length !== 4 || token[0] !== "U" || !SID.test(token[1]!) || !SID.test(token[2]!)
105
+ || (token[3] !== "True" && token[3] !== "False")) return false;
106
+ const user = token[1]!.toUpperCase();
107
+ const acceptsAdministrators = token[2]!.toUpperCase() === ADMINISTRATORS && token[3] === "True";
108
+ let at = 1;
109
+ for (let i = 0; i < entries.length; i++) {
110
+ const entry = (lines[at++] ?? "").split("|");
111
+ if (entry.length !== 5 || entry[0] !== "E" || entry[1] !== String(i) || !SID.test(entry[2]!)
112
+ || !/^(0|[1-9]\d{0,4})$/.test(entry[3]!) || !/^[1-3]$/.test(entry[4]!)) return false;
113
+ const flags = Number(entry[3]);
114
+ if (flags > 0xffff || (flags & PRIVATE_DACL_FLAGS) !== PRIVATE_DACL_FLAGS) return false;
115
+ const owner = entry[2]!.toUpperCase();
116
+ if (owner !== user && !(owner === ADMINISTRATORS && acceptsAdministrators)) return false;
117
+ // The serving account needs its own Full Control entry. SYSTEM and Administrators may also hold plain
118
+ // allow entries: LocalSystem and an elevated administrator can already take ownership of any object, and a
119
+ // filtered administrator token holds Administrators only as deny-only, so the allow entry grants it nothing.
120
+ const grantees = new Set<string>();
121
+ for (let k = Number(entry[4]); k > 0; k--) {
122
+ const ace = (lines[at++] ?? "").split("|");
123
+ if (ace.length !== 7 || ace[0] !== "A" || ace[1] !== String(i) || ace[2] !== "0"
124
+ || ace[3] !== (entries[i]!.directory ? "3" : "0") || !/^[1-9]\d{0,9}$/.test(ace[4]!)
125
+ || !SID.test(ace[5]!) || ace[6] !== "False") return false;
126
+ const grantee = ace[5]!.toUpperCase();
127
+ if (grantees.has(grantee)) return false;
128
+ grantees.add(grantee);
129
+ if (grantee === user ? ace[4] !== String(FULL_CONTROL) : !PRIVILEGED_GRANTEES.has(grantee)) return false;
130
+ }
131
+ if (!grantees.has(user)) return false;
132
+ }
133
+ return at === lines.length - 1;
134
+ }
135
+
136
+ /** Fresh, read-only verification; runner failures never disclose paths, SIDs or ACL output. */
137
+ export function verifyWindowsPrivateEntries(entries: readonly WindowsPrivateEntry[], timeoutMs: number): boolean {
138
+ if (entries.length === 0 || !Number.isFinite(timeoutMs) || timeoutMs <= 0) return false;
139
+ try {
140
+ const result = ownerAclRunner(entries, Math.max(1, Math.min(WINDOWS_OWNER_ACL_TIMEOUT_MS, Math.floor(timeoutMs))));
141
+ return result.success && !result.timedOut && windowsPrivateEntriesAclMatches(result.stdout, entries);
142
+ } catch { return false; }
143
+ }
@@ -0,0 +1,26 @@
1
+ import {
2
+ CodexPoolAccountCreditsOffError,
3
+ releaseCodexAuthContextProbeLease,
4
+ unwrapUpstreamRetryEvidenceError,
5
+ type CodexAuthContext,
6
+ type CodexAuthPolicyConfig,
7
+ } from "../codex/auth-context";
8
+ import { poolContextCreditHoldResetAt } from "../codex/pool-credit-policy";
9
+
10
+ export function createOpenAiSidecarCreditGuard(
11
+ context: CodexAuthContext,
12
+ policy: CodexAuthPolicyConfig,
13
+ ): (() => void) | undefined {
14
+ if (context.kind !== "pool") return undefined;
15
+ return () => {
16
+ const resetAt = poolContextCreditHoldResetAt(context, policy);
17
+ if (resetAt === undefined) return;
18
+ releaseCodexAuthContextProbeLease(context);
19
+ throw new CodexPoolAccountCreditsOffError(context.accountId, resetAt);
20
+ };
21
+ }
22
+
23
+ export function openAiSidecarCreditRefusal(error: unknown): CodexPoolAccountCreditsOffError | undefined {
24
+ const cause = unwrapUpstreamRetryEvidenceError(error);
25
+ return cause instanceof CodexPoolAccountCreditsOffError ? cause : undefined;
26
+ }
@@ -1,4 +1,5 @@
1
1
  import { resolveProviderApiKey } from "./key-store";
2
+ import { createOpenAiSidecarCreditGuard } from "./openai-sidecar-credit";
2
3
  import {
3
4
  CodexPoolAuthenticationError,
4
5
  headersForCodexAuthContext,
@@ -31,6 +32,7 @@ export interface OpenAiForwardSidecarCandidate {
31
32
  export interface ResolvedOpenAiForwardSidecar extends OpenAiForwardSidecarCandidate {
32
33
  authContext: CodexAuthContext;
33
34
  headers: Headers;
35
+ beforeDispatch?: () => void;
34
36
  recordOutcome?: (outcome: CodexUpstreamOutcome) => void;
35
37
  /** Hand back an acquired recovery probe when no sidecar request reached upstream. */
36
38
  releaseProbeLease?: () => void;
@@ -123,6 +125,37 @@ function directSidecarHeaders(
123
125
  return selected;
124
126
  }
125
127
 
128
+ /**
129
+ * The caller's own ChatGPT credential for a call the caller created itself.
130
+ *
131
+ * A V3 `existingCall` sideband join attaches to a WebRTC call that ChatGPT voice or Codex Desktop
132
+ * created with the caller's login, so only that login can join it; a Pool-selected account is a
133
+ * different ChatGPT account and the upstream refuses the handshake. This hands the join the same
134
+ * caller-owned headers Codex Direct mode forwards: an explicit bearer whose token claim matches
135
+ * its `chatgpt-account-id`, never the proxy admission secret, materialized through the same main
136
+ * policy (hard lock, credits), whose refusals surface as `CodexAccountCooldownError`.
137
+ * Returns undefined when the caller presents no such credential.
138
+ */
139
+ export function resolveCallerOwnedOpenAiSidecar(
140
+ candidates: readonly OpenAiForwardSidecarCandidate[],
141
+ incomingHeaders: Headers,
142
+ config: OcxConfig,
143
+ options: { admission?: Pick<DataPlaneAdmission, "source">; codexAuthPolicy?: CodexAuthPolicyConfig } = {},
144
+ ): ResolvedOpenAiForwardSidecar | undefined {
145
+ const candidate = candidates[0];
146
+ if (!candidate) return undefined;
147
+ try {
148
+ validateForwardAdmissionCredential(incomingHeaders, config);
149
+ } catch (error) {
150
+ if (error instanceof ForwardAdmissionCredentialError) return undefined;
151
+ throw error;
152
+ }
153
+ if (!hasCallerCodexBearer(incomingHeaders)) return undefined;
154
+ const headers = directSidecarHeaders(incomingHeaders, options.codexAuthPolicy ?? config, options.admission);
155
+ if (!headers) return undefined;
156
+ return { ...candidate, authContext: { kind: "main", accountId: null }, headers };
157
+ }
158
+
126
159
  export async function resolveFirstUsableOpenAiSidecar(
127
160
  candidates: readonly OpenAiForwardSidecarCandidate[],
128
161
  incomingHeaders: Headers,
@@ -176,6 +209,7 @@ export async function resolveFirstUsableOpenAiSidecar(
176
209
  ...candidate,
177
210
  authContext,
178
211
  headers: selectedHeaders,
212
+ beforeDispatch: createOpenAiSidecarCreditGuard(authContext, policy),
179
213
  recordOutcome: (outcome: CodexUpstreamOutcome) => recordCodexUpstreamOutcome(
180
214
  config,
181
215
  authContext.accountId,
@@ -226,6 +260,7 @@ export async function resolveFirstUsableOpenAiSidecar(
226
260
  ...candidate,
227
261
  authContext,
228
262
  headers: selectedHeaders,
263
+ beforeDispatch: createOpenAiSidecarCreditGuard(authContext, policy),
229
264
  ...(authContext.kind === "pool" || authContext.kind === "main-pool"
230
265
  ? {
231
266
  recordOutcome: (outcome: CodexUpstreamOutcome) => recordCodexUpstreamOutcome(
@@ -1205,6 +1205,7 @@ async function handleClaudeMessagesWithBudget(
1205
1205
  return await handleNativeMessages({
1206
1206
  req, config, logCtx, ...(logIds ? { logIds } : {}),
1207
1207
  route: nativeMessagesRoute, body: nativeBody, requestedModel, translatorBudget, selector: nativeSelector,
1208
+ modelScope: resolveAdmissionModelScope(config, logIds?.admission),
1208
1209
  // Compatibility identity is an opaque request-local handle, separate from credentials.
1209
1210
  clientIdentity: captureAnthropicClientIdentity(req.headers),
1210
1211
  callerAnthropicBeta: req.headers.get("anthropic-beta"),
@@ -0,0 +1,23 @@
1
+ import type { OcxConfig } from "../types";
2
+ import { GUI_PAIR_BROWSER_ORIGIN_HEADER, GUI_PAIR_CAPABILITY_HEADER, standaloneGuiPairingOrigin } from "../lib/gui-pair-capability";
3
+ import { consumeGuiPairIntent, GUI_PAIR_INTENT_HEADER } from "../lib/gui-pair-intent";
4
+ import { createGuiPairingGrant, type GuiSessionState } from "./gui-session";
5
+
6
+ export class GuiPairingIntentRequiredError extends Error {
7
+ constructor() {
8
+ super("Standalone GUI pairing requires a fresh local CLI intent");
9
+ this.name = "GuiPairingIntentRequiredError";
10
+ }
11
+ }
12
+
13
+ /** The serving route must authenticate the existing process-bound capability first. */
14
+ export function deliverGuiPairingGrant(req: Request, config: OcxConfig, state: GuiSessionState) {
15
+ const origin = req.headers.get(GUI_PAIR_BROWSER_ORIGIN_HEADER) ?? "";
16
+ if (config.runtimeRole !== "hub") {
17
+ if (standaloneGuiPairingOrigin(config) !== origin
18
+ || !consumeGuiPairIntent(req.headers.get(GUI_PAIR_CAPABILITY_HEADER), req.headers.get(GUI_PAIR_INTENT_HEADER))) {
19
+ throw new GuiPairingIntentRequiredError();
20
+ }
21
+ }
22
+ return createGuiPairingGrant(origin, config, state);
23
+ }
@@ -174,15 +174,14 @@ import { LOCAL_MANAGEMENT_NONCE_HEADER } from "../../lib/local-management-capabi
174
174
  import { LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION } from "../../lib/local-provider-reload-contract";
175
175
  import { LOCAL_ASIDE_SYNC_CAPABILITY_VERSION } from "../../lib/local-aside-sync-contract";
176
176
  import {
177
- GUI_PAIR_BROWSER_ORIGIN_HEADER,
178
177
  GUI_PAIR_CAPABILITY_VERSION,
179
178
  GUI_PAIR_PATH,
180
179
  } from "../../lib/gui-pair-capability";
181
180
  import {
182
181
  GuiPairingGrantRateLimitError,
183
182
  consumeGuiPairingGrant,
184
- createGuiPairingGrant,
185
183
  } from "../gui-session";
184
+ import { deliverGuiPairingGrant, GuiPairingIntentRequiredError } from "../gui-pair-delivery";
186
185
  import { recordCursorSeen } from "../../integrations/cursor-seen";
187
186
  import { detectCursorInstalls } from "../../integrations/cursor-detect";
188
187
  import { loadCursorEffortTable } from "../../integrations/cursor-effort-table";
@@ -681,18 +680,16 @@ export function createServeOptions(ctx: ServeOptionsContext) {
681
680
  return withManagementCors(Response.json({ error: "GUI pairing capability required" }, { status: 403 }), req, config);
682
681
  }
683
682
  try {
684
- const grant = createGuiPairingGrant(
685
- req.headers.get(GUI_PAIR_BROWSER_ORIGIN_HEADER) ?? "",
686
- config,
687
- managementAuth,
688
- );
683
+ const grant = deliverGuiPairingGrant(req, config, managementAuth);
689
684
  return withManagementCors(Response.json(grant, {
690
685
  status: 201,
691
686
  headers: { "Cache-Control": "no-store" },
692
687
  }), req, config);
693
688
  } catch (error) {
694
689
  const status = error instanceof GuiPairingGrantRateLimitError ? 429 : 403;
695
- return withManagementCors(Response.json({ error: "GUI pairing grant refused" }, {
690
+ return withManagementCors(Response.json({ error: "GUI pairing grant refused",
691
+ ...(error instanceof GuiPairingIntentRequiredError ? { code: "local_pairing_intent_required" } : {}),
692
+ }, {
696
693
  status,
697
694
  ...(status === 429 ? { headers: { "Retry-After": "60" } } : {}),
698
695
  }), req, config);
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Upstream call ids created through the native voice call-create path (`handleLive`).
3
+ *
4
+ * A sideband join names only a call id, and two kinds of call reach the same join routes. A call
5
+ * this process created was negotiated under the account the Pool selected, and its join must stay
6
+ * on that account (openai/codex #35830; thread affinity carries it). A call the client created
7
+ * itself — ChatGPT voice handing a call to a Codex thread, or Codex Desktop when its renderer owns
8
+ * the call — belongs to the caller's own ChatGPT login and arrives as a V3 `existingCall` join with
9
+ * no create on this proxy. Membership here is what tells the two apart.
10
+ *
11
+ * Ids only: no account, credential, model, or caller is stored. Process-local and bounded, so a
12
+ * restart, the TTL, or eviction past capacity turns a created call into an unknown one; a later
13
+ * rejoin is then authenticated as the caller (see `resolveLiveRelay`).
14
+ */
15
+ export const NATIVE_LIVE_CALL_TTL_MS = 6 * 60 * 60_000;
16
+ export const MAX_NATIVE_LIVE_CALLS = 1024;
17
+ const MAX_LOCATION_LENGTH = 4096;
18
+ const MAX_CALL_ID_LENGTH = 128;
19
+ const RTC_CALL_ID_RE = /^rtc_[A-Za-z0-9_-]+$/;
20
+ const UUID_CALL_ID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
21
+
22
+ function isRealtimeCallIdSegment(segment: string): boolean {
23
+ if (segment.length > MAX_CALL_ID_LENGTH) return false;
24
+ return RTC_CALL_ID_RE.test(segment) || UUID_CALL_ID_RE.test(segment);
25
+ }
26
+
27
+ /**
28
+ * The call id a client reads from a call-create Location, extracted the way openai/codex does
29
+ * (`decode_call_id_from_location`, codex-api realtime_call.rs): drop the query, scan the path
30
+ * segments from the end, and take the first `rtc_…` or hyphenated-UUID segment. The backend
31
+ * answers `/v1/realtime/calls/calls/rtc_…`, the API `/v1/live/rtc_…`; both yield the id the client
32
+ * then joins with.
33
+ */
34
+ export function liveCallIdFromLocation(location: string | null | undefined): string | null {
35
+ if (!location || location.length > MAX_LOCATION_LENGTH) return null;
36
+ const path = location.split("?")[0] ?? "";
37
+ for (const segment of path.split("/").reverse()) {
38
+ if (isRealtimeCallIdSegment(segment)) return segment;
39
+ }
40
+ return null;
41
+ }
42
+
43
+ export class NativeLiveCalls {
44
+ /** Insertion order is creation order, so the first key is always the oldest entry. */
45
+ private readonly entries = new Map<string, number>();
46
+
47
+ constructor(
48
+ private readonly now: () => number = Date.now,
49
+ private readonly ttlMs: number = NATIVE_LIVE_CALL_TTL_MS,
50
+ private readonly capacity: number = MAX_NATIVE_LIVE_CALLS,
51
+ ) {}
52
+
53
+ /** Record the call a successful create's Location names; returns the id, or null when none. */
54
+ record(location: string | null | undefined): string | null {
55
+ const id = liveCallIdFromLocation(location);
56
+ if (!id) return null;
57
+ this.prune();
58
+ this.entries.delete(id);
59
+ while (this.entries.size >= this.capacity) {
60
+ const oldest = this.entries.keys().next().value;
61
+ if (oldest === undefined) break;
62
+ this.entries.delete(oldest);
63
+ }
64
+ this.entries.set(id, this.now() + this.ttlMs);
65
+ return id;
66
+ }
67
+
68
+ has(id: string): boolean {
69
+ const expiresAt = this.entries.get(id);
70
+ if (expiresAt === undefined) return false;
71
+ if (expiresAt <= this.now()) {
72
+ this.entries.delete(id);
73
+ return false;
74
+ }
75
+ return true;
76
+ }
77
+
78
+ get size(): number {
79
+ this.prune();
80
+ return this.entries.size;
81
+ }
82
+
83
+ clear(): void {
84
+ this.entries.clear();
85
+ }
86
+
87
+ private prune(): void {
88
+ const now = this.now();
89
+ for (const [id, expiresAt] of this.entries) if (expiresAt <= now) this.entries.delete(id);
90
+ }
91
+ }
92
+
93
+ /** The process-wide registry `handleLive` writes and native sideband joins read. */
94
+ export const nativeLiveCalls = new NativeLiveCalls();
@@ -44,9 +44,14 @@ import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort";
44
44
  import { sidecarEnter } from "../lib/sidecar-tracker";
45
45
  import { hardenSecretPath } from "../lib/windows-secret-acl";
46
46
  import type { OcxConfig } from "../types";
47
- import { resolveFirstUsableOpenAiSidecar, selectOpenAiImagesProvider } from "../providers/openai-sidecar";
47
+ import {
48
+ resolveCallerOwnedOpenAiSidecar,
49
+ resolveFirstUsableOpenAiSidecar,
50
+ selectOpenAiImagesProvider,
51
+ } from "../providers/openai-sidecar";
48
52
  import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential, type DataPlaneAdmission } from "./auth-cors";
49
53
  import { admissionScopeDenial } from "./admission-model-scope";
54
+ import { nativeLiveCalls } from "./live-native-calls";
50
55
  import type { RequestLogContext } from "./request-log";
51
56
  import { codexLogAccountId } from "./responses";
52
57
  import type { AdmissionLease } from "../lib/admission";
@@ -613,6 +618,12 @@ async function readRequestBodyCapped(req: Request, maxBytes: number): Promise<Ar
613
618
  export interface LiveScopeDestination {
614
619
  admission?: DataPlaneAdmission;
615
620
  model: string | undefined;
621
+ /**
622
+ * The call a sideband join attaches to. When this process did not create it (no entry in
623
+ * `nativeLiveCalls`), the client created it with its own ChatGPT login — a V3 `existingCall` —
624
+ * and the join is authenticated as that caller instead of a Pool-selected account.
625
+ */
626
+ callerOwnedCallId?: string;
616
627
  }
617
628
 
618
629
  /**
@@ -691,9 +702,14 @@ export async function resolveLiveRelay(
691
702
  let forwardAuthError: Response | undefined;
692
703
  if (candidates.forwardCandidates.length > 0) {
693
704
  try {
694
- forward = await resolveFirstUsableOpenAiSidecar(candidates.forwardCandidates, req.headers, config, {
695
- beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease),
696
- });
705
+ const joinsForeignCall = destination?.callerOwnedCallId !== undefined
706
+ && !nativeLiveCalls.has(destination.callerOwnedCallId);
707
+ forward = (joinsForeignCall
708
+ ? resolveCallerOwnedOpenAiSidecar(candidates.forwardCandidates, req.headers, config)
709
+ : undefined)
710
+ ?? await resolveFirstUsableOpenAiSidecar(candidates.forwardCandidates, req.headers, config, {
711
+ beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease),
712
+ });
697
713
  if (forward) {
698
714
  logCtx.provider = formatCodexProviderForLog(
699
715
  forward.providerName,
@@ -756,7 +772,8 @@ export async function resolveLiveRelay(
756
772
  providerBaseUrl: provider.baseUrl,
757
773
  usesBackendShape: isChatGptBackendBaseUrl(provider.baseUrl),
758
774
  keyed: false,
759
- recordOutcome: status => forward.recordOutcome?.(status),
775
+ // A caller-owned or Direct credential selected no Pool account, so there is nothing to record.
776
+ ...(forward.recordOutcome ? { recordOutcome: (status: number | "timeout" | "connect_error") => forward.recordOutcome?.(status) } : {}),
760
777
  };
761
778
  }
762
779
  if (forwardAuthError) return forwardAuthError;
@@ -864,6 +881,11 @@ export async function handleLive(
864
881
  detachBodyGuard();
865
882
  }
866
883
  if (payload instanceof Response) return payload;
884
+ // Remember the calls this proxy created, so their joins keep the Pool account that created
885
+ // them while a join for any other call is authenticated as its caller (`resolveLiveRelay`).
886
+ if (upstreamResponse.status >= 200 && upstreamResponse.status < 300) {
887
+ nativeLiveCalls.record(upstreamResponse.headers.get("location"));
888
+ }
867
889
  const relayHeaders: Record<string, string> = {};
868
890
  for (const name of LIVE_RELAY_HEADERS) {
869
891
  const value = upstreamResponse.headers.get(name);
@@ -902,6 +924,7 @@ export async function resolveLiveSidebandUpgrade(
902
924
  const relay = await resolveLiveRelay(req, config, logCtx, turnAdmissionLease, {
903
925
  admission,
904
926
  model: liveSidebandModel(target),
927
+ ...("callId" in target ? { callerOwnedCallId: target.callId } : {}),
905
928
  });
906
929
  if (relay instanceof Response) return relay;
907
930
  return {
@@ -25,6 +25,8 @@ export type LocalManagementReadResult =
25
25
 
26
26
  export interface LocalManagementReadDeps {
27
27
  fetchImpl?: typeof fetch;
28
+ /** Default transport seam; receives the caller deadline. */
29
+ directFetch?: typeof directLocalHttpFetch;
28
30
  readRuntime?: (pid: number) => RuntimePortState | null;
29
31
  createNonce?: () => string;
30
32
  now?: () => number;
@@ -84,18 +86,20 @@ export async function fetchBoundLocalManagementRead(
84
86
  if (!capability) return { kind: "unavailable", reason: "capability-unavailable" };
85
87
 
86
88
  try {
87
- const response = await (deps.fetchImpl ?? directLocalHttpFetch)(
88
- `http://${probeHostname(target.hostname)}:${target.port}${path}`,
89
- {
90
- headers: {
91
- [LOCAL_MANAGEMENT_EXPECTED_PID_HEADER]: String(target.pid),
92
- [LOCAL_MANAGEMENT_NONCE_HEADER]: nonce,
93
- [LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER]: String(expiresAt),
94
- [LOCAL_MANAGEMENT_CAPABILITY_HEADER]: capability,
95
- },
96
- signal: AbortSignal.timeout(deps.timeoutMs ?? 4_000),
89
+ const timeoutMs = deps.timeoutMs ?? 4_000;
90
+ const url = `http://${probeHostname(target.hostname)}:${target.port}${path}`;
91
+ const init: RequestInit = {
92
+ headers: {
93
+ [LOCAL_MANAGEMENT_EXPECTED_PID_HEADER]: String(target.pid),
94
+ [LOCAL_MANAGEMENT_NONCE_HEADER]: nonce,
95
+ [LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER]: String(expiresAt),
96
+ [LOCAL_MANAGEMENT_CAPABILITY_HEADER]: capability,
97
97
  },
98
- );
98
+ signal: AbortSignal.timeout(timeoutMs),
99
+ };
100
+ const response = deps.fetchImpl
101
+ ? await deps.fetchImpl(url, init)
102
+ : await (deps.directFetch ?? directLocalHttpFetch)(url, init, { timeoutMs });
99
103
  if (deps.requireResponseProof && !verifyLocalAttestationProof(
100
104
  runtime.attestationSecret, nonce, target.pid, target.port, response.headers.get(LOCAL_ATTESTATION_PROOF_HEADER),
101
105
  )) {
@@ -1164,6 +1164,21 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
1164
1164
  }, 200, req, config);
1165
1165
  }
1166
1166
 
1167
+ if (url.pathname === "/api/keys/reveal" && req.method === "POST") {
1168
+ if (principal !== "gui-session") {
1169
+ return jsonResponse({ error: "dashboard session required" }, 403, req, config);
1170
+ }
1171
+ const body = await readJsonBody(req);
1172
+ if (!body || Object.keys(body).length !== 1 || typeof body.id !== "string" || !body.id) {
1173
+ return jsonResponse({ error: "invalid body" }, 400, req, config);
1174
+ }
1175
+ const entry = config.apiKeys?.find(key => key.id === body.id);
1176
+ if (!entry) return jsonResponse({ error: "key not found" }, 404, req, config);
1177
+ const response = jsonResponse({ key: entry.key }, 200, req, config);
1178
+ response.headers.set("Cache-Control", "no-store");
1179
+ return response;
1180
+ }
1181
+
1167
1182
  if (url.pathname === "/api/keys/rotate" && req.method === "POST") {
1168
1183
  const body = await readJsonBody(req);
1169
1184
  if (!body || Object.keys(body).length !== 1 || typeof body.id !== "string" || !body.id) {
@@ -324,6 +324,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
324
324
  { method: "PATCH", path: "/api/keys", module: "server/management/oauth-account-routes", mutates: true },
325
325
  { method: "PATCH", path: "/api/oauth/accounts/pool", module: "server/management/oauth-account-routes", mutates: true },
326
326
  { method: "POST", path: "/api/keys", module: "server/management/oauth-account-routes", mutates: true },
327
+ { method: "POST", path: "/api/keys/reveal", module: "server/management/oauth-account-routes", mutates: false, exempt: { reason: "session-only", why: "Revealing a data key requires the gui-session principal; the admin token is refused." } },
327
328
  { method: "POST", path: "/api/keys/rotate", module: "server/management/oauth-account-routes", mutates: true },
328
329
  { method: "POST", path: "/api/keys/rotate/commit", module: "server/management/oauth-account-routes", mutates: true },
329
330
  { method: "POST", path: "/api/oauth/accounts/clear-cooldown", module: "server/management/oauth-account-routes", mutates: true },
@@ -89,6 +89,11 @@ export type TokPerSecondResult =
89
89
  | { kind: "value"; value: number; estimated: boolean }
90
90
  | { kind: "unavailable"; reason: MetricUnavailableReason };
91
91
 
92
+ export type DecodeTimingBasis = "generation-window" | "legacy-post-visible-output";
93
+ export type DecodeTokPerSecondResult =
94
+ | (Extract<TokPerSecondResult, { kind: "value" }> & { timingBasis: DecodeTimingBasis })
95
+ | Extract<TokPerSecondResult, { kind: "unavailable" }>;
96
+
92
97
  export type CostEstimateReason =
93
98
  | "usage_estimated"
94
99
  | "cache_detail_missing"
@@ -149,7 +154,7 @@ export const MIN_DECODE_WINDOW_MS = 1_000;
149
154
  */
150
155
  export function decodeTokPerSecondResult(
151
156
  entry: Pick<MetricSource, "durationMs" | "firstOutputMs" | "genStartMs" | "lastOutputMs" | "usageStatus" | "usage">,
152
- ): TokPerSecondResult {
157
+ ): DecodeTokPerSecondResult {
153
158
  if (!entry.usage) return { kind: "unavailable", reason: "usage_missing" };
154
159
  if (entry.usageStatus === "unsupported") return { kind: "unavailable", reason: "usage_unsupported" };
155
160
  if (entry.usage.outputTokens <= 0) return { kind: "unavailable", reason: "output_missing" };
@@ -159,7 +164,8 @@ export function decodeTokPerSecondResult(
159
164
  if (generationMs <= 0) return { kind: "unavailable", reason: "invalid_duration" };
160
165
  if (generationMs < MIN_DECODE_WINDOW_MS) return { kind: "unavailable", reason: "decode_window_too_short" };
161
166
  const value = tokensPerSecond(entry.usage.outputTokens, generationMs);
162
- return value === null ? { kind: "unavailable", reason: "invalid_duration" } : { kind: "value", value, estimated: true };
167
+ return value === null ? { kind: "unavailable", reason: "invalid_duration" }
168
+ : { kind: "value", value, estimated: true, timingBasis: "generation-window" };
163
169
  }
164
170
  // A row that predates TTFT capture, or a non-streaming turn that never recorded one, has no
165
171
  // window to measure. That is a different fact from a bad duration, so it gets its own reason.
@@ -173,7 +179,7 @@ export function decodeTokPerSecondResult(
173
179
  if (windowMs < MIN_DECODE_WINDOW_MS) return { kind: "unavailable", reason: "decode_window_too_short" };
174
180
  const value = tokensPerSecond(entry.usage.outputTokens, windowMs);
175
181
  if (value === null) return { kind: "unavailable", reason: "invalid_duration" };
176
- return { kind: "value", value, estimated: true };
182
+ return { kind: "value", value, estimated: true, timingBasis: "legacy-post-visible-output" };
177
183
  }
178
184
 
179
185
  export function unavailableCostReason(entry: MetricSource): MetricUnavailableReason {
@@ -0,0 +1,57 @@
1
+ import {
2
+ AdmissionModelDeniedError, routeAllowedByScope, UNNAMED_DESTINATION_MODEL, type AdmissionModelScope,
3
+ } from "./admission-model-scope";
4
+
5
+ type Rec = Record<string, unknown>;
6
+ const isRec = (value: unknown): value is Rec => value !== null && typeof value === "object" && !Array.isArray(value);
7
+ const isAdvisor = (tool: Rec): boolean => typeof tool.type === "string" && /^advisor_\d{8}$/.test(tool.type);
8
+
9
+ /** Only executable declarations are policy inputs; schemas, arguments and results stay opaque. */
10
+ export function nativeMessagesToolScopeDenial(
11
+ scope: AdmissionModelScope | undefined,
12
+ providerName: string,
13
+ requestedModel: string,
14
+ body: Readonly<Rec>,
15
+ ): AdmissionModelDeniedError | undefined {
16
+ if (!scope || scope.models.length === 0) return undefined;
17
+ const definitions = new Map<string, Rec[]>();
18
+ const withdrawn = new Set<string>();
19
+ const anonymous: Rec[] = [];
20
+ if (Array.isArray(body.tools)) for (const tool of body.tools) {
21
+ if (!isRec(tool)) continue;
22
+ if (typeof tool.name !== "string") { anonymous.push(tool); continue; }
23
+ const declarations = definitions.get(tool.name) ?? [];
24
+ declarations.push(tool);
25
+ definitions.set(tool.name, declarations);
26
+ }
27
+ if (Array.isArray(body.messages)) for (const message of body.messages) {
28
+ if (!isRec(message) || message.role !== "system" || !Array.isArray(message.content)) continue;
29
+ // Upstream rejects tool changes in turn-scoped messages; do not depend on that. An addition
30
+ // always counts, and only a permanent removal withdraws a declaration (fail closed).
31
+ const permanent = message.clear_at === undefined || message.clear_at === "never";
32
+ for (const block of message.content) {
33
+ if (!isRec(block) || !isRec(block.tool)) continue;
34
+ const tool = block.tool;
35
+ if (block.type === "tool_addition" && tool.type === "tool_definition" && isRec(tool.definition)) {
36
+ const definition = tool.definition;
37
+ if (typeof definition.name !== "string") { anonymous.push(definition); continue; }
38
+ // A temporary replacement keeps the declaration it shadows in scope as well.
39
+ definitions.set(definition.name, permanent ? [definition] : [...definitions.get(definition.name) ?? [], definition]);
40
+ withdrawn.delete(definition.name);
41
+ } else if (tool.type === "tool_reference" && typeof tool.name === "string") {
42
+ if (block.type === "tool_removal" && permanent) withdrawn.add(tool.name);
43
+ if (block.type === "tool_addition") withdrawn.delete(tool.name);
44
+ }
45
+ }
46
+ }
47
+ // Deferred definitions can still be loaded by tool search in the current turn.
48
+ const active = [...anonymous, ...[...definitions].flatMap(([name, tools]) => withdrawn.has(name) ? [] : tools)];
49
+ for (const tool of active) {
50
+ if (!isAdvisor(tool)) continue;
51
+ const modelId = typeof tool.model === "string" && tool.model.trim() ? tool.model : undefined;
52
+ if (modelId === undefined || !routeAllowedByScope(scope, { providerName, modelId })) {
53
+ return new AdmissionModelDeniedError(requestedModel, { providerName, modelId: modelId ?? UNNAMED_DESTINATION_MODEL });
54
+ }
55
+ }
56
+ return undefined;
57
+ }