Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3e739d2
chore(release): open dev at 2.79.0 before releasing 2.78.0 (#6631)
github-actions[bot] Oct 5, 2026
0889b60
fix(claude): strip Claude Code's rotating billing line before Respons…
lidge-jun Oct 5, 2026
37189ca
fix(devin): combine consecutive tool results for one invocation (carr…
lidge-jun Oct 5, 2026
7e7468a
fix(web-search): budget batched results per query so none are dropped…
lidge-jun Oct 5, 2026
bdcfa23
docs(service): translate the busy-lease guidance on every locale page…
lcxhh521 Oct 5, 2026
60d80f7
fix(logs): identify decode estimate timing basis (#6582)
luvs01 Oct 5, 2026
e2b6eef
fix(messages): enforce admission scope on Advisor models (#6617)
luvs01 Oct 5, 2026
c566bb5
fix(gui): guide native login switching and manual retry (#6581)
luvs01 Oct 5, 2026
bd1b5f8
fix(codex): synchronize manual account pause across matching main and…
Yuxin-Qiao Oct 5, 2026
f71d560
fix(cli): cover the bounded startup health probe read (#6587)
Yuxin-Qiao Oct 5, 2026
46a9bed
fix(claude): retain approved Desktop picker trust across restarts (#6…
lidge-jun Oct 5, 2026
648cbe9
ci: typecheck gui/ in prepush when the branch changes it (carry #6485…
lidge-jun Oct 5, 2026
7e3e37a
fix(responses): redact terminal reset-replacement errors (#6616)
luvs01 Oct 5, 2026
d00ec9f
fix(codex): enforce stored-account credit policy through dispatch (#6…
luvs01 Oct 5, 2026
6774f0f
fix(gui): require one-use config write intent for standalone pairing …
luvs01 Oct 5, 2026
5d69e5c
fix(gui): delete and reveal API keys from the key table (#6653)
lidge-jun Oct 6, 2026
6ebe85a
fix(cli): accept NTFS file ids above 2^53 in the update restart marke…
lidge-jun Oct 6, 2026
0a99ba4
fix(claude): migrate legacy Windows local-CA ACLs and stop re-inspect…
lidge-jun Oct 6, 2026
7a8a107
fix(gui): read pairing-intent ACLs without PowerShell cmdlet autoload…
lidge-jun Oct 6, 2026
f924820
test(responses): keep the post-refresh replay inside the pacing windo…
lidge-jun Oct 6, 2026
fde8eeb
test(update): stop the lease holder fixture from cold-importing the s…
lidge-jun Oct 6, 2026
d01d36d
ci(windows): run the Windows shards on Windows-sensitive PRs and nigh…
lidge-jun Oct 6, 2026
87e6dc0
fix(cli): let ocx ensure wait for a slow cold start while its child l…
lidge-jun Oct 6, 2026
f494403
test(audio): give the non-readiness cases an isolation budget (#6682)
lidge-jun Oct 6, 2026
38455fb
fix(live): join client-created voice calls as the caller that owns th…
lidge-jun Oct 6, 2026
9443c41
test(runner): share Bun's transpiler cache across sandboxed homes (#6…
lidge-jun Oct 6, 2026
5735670
Merge main into 2.79.0 promotion
lidge-jun Oct 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 146 additions & 17 deletions .github/workflows/ci.yml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "opencodex-desktop"
version = "2.78.0"
version = "2.79.0"
description = "OpenCodex desktop shell"
authors = ["OpenCodex contributors"]
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenCodex",
"version": "2.78.0",
"version": "2.79.0",
"identifier": "com.opencodex.desktop",
"build": {
"frontendDist": "../ui",
Expand Down
28 changes: 28 additions & 0 deletions devlog/_plan/261006_realtime_live_compat/000_plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Realtime voice: sideband joins for calls opencodex did not create

ChatGPT voice ("GPT Live") can now hand a call to a Codex thread on the user's Mac: the ChatGPT client creates the WebRTC call with its own ChatGPT login, and the local Codex app-server joins the call's control sideband (`V3`, `transport=ExistingCall`). With opencodex injected, that join arrives at `ws://127.0.0.1:<port>/v1/live/<call_id>`, and opencodex re-authenticates it with whichever Pool account it selects. The join therefore reaches the upstream as an account other than the caller that started the call, and Codex reports `502 Bad Gateway: realtime websocket handshake failed` after five attempts. The credential overwrite is confirmed in source and in the user's ledger; that the upstream refused the join because of the account (rather than another handshake fault) is a strong inference, because Bun hides the upstream handshake status.

- Archetype: satisfy-spec (compatibility with the current Codex/ChatGPT voice clients).
- Trigger: user report on 2026-10-06 that live voice through opencodex stopped working after the Codex/ChatGPT update.
- Class: C3 relay repair with C4 care, because it changes which credential a sideband join carries.
- Goal: one ordinary PR to `dev` so a native sideband join for a call opencodex did not create is authenticated as the caller that owns it, while joins for calls opencodex created keep the existing Pool account continuity.
- Non-goals: GPT-Live public `/v1/live/sessions` API family, external audio-key aliases, Codex config injection for the provider-table mode, merge, release, restarting the user's proxy, live paid probes.
- Verifier: focused regression tests for the new join decision and the call registry, the unchanged #35830 continuity test, and the existing scope/external-audio/layout/ratchet/structure/privacy/Lab gates, all run by hosted CI only. Explicit security review is required (credential handling, `MAINTAINERS.md`). The user instructed no local test runs ("로컬 테스트 하지 말고"); typecheck, suite, lint and build are left to exact-head CI and named as not run locally.
- Stop: PR open against `dev`, exact-head CI inspected, independent review findings folded.
- Artifacts: `.tmp/realtime-investigation/` (ignored) holds the seven investigation reports; Aside's public-web report is under the Aside account artifact directory.
- Resources: this worktree, git/gh for this branch, gpt-6.1-sol leaves, Aside exec for public research. No token or wall-clock budget was set by the user.

## Evidence

Runtime (read-only, identifiers omitted): Codex `logs_2.sqlite` shows four `RealtimeConversationStart` operations with `version=V3; transport=ExistingCall; output_modality=Audio` from `codex_chatgpt_ios_remote` 1.2026.266 (2026-10-03, three sessions) and 1.2026.267 (2026-10-05, one session). Each dialed `/v1/live/<call-id>` on the loopback proxy five times and failed with 502. opencodex `usage.jsonl` holds the matching twenty `gpt-live` rows (`upstream_server_error`, `failureStage=headers-only`).

The account labels on those rows differ: the last voice session that completed its handshake (2026-09-30, create 201 then join 101; audibility not recorded) ran on one Pool account for both legs, the 2026-10-03 joins on a second account and the 2026-10-05 joins on a third. The configured Pool holds five ChatGPT accounts. A local sanitized comparison (booleans only, no identifiers printed) found that none of the five Pool credentials carries the ChatGPT account id of the Codex login on this Mac, which is the credential Codex attaches to an ExistingCall join; both accounts used for the failing joins are non-main Pool accounts.

Source: `resolveLiveRelay` (`src/server/live.ts`) resolves every native join through `resolveFirstUsableOpenAiSidecar`, which in Pool mode selects by Pool policy and thread affinity and replaces the caller's `authorization`/`chatgpt-account-id`. Continuity for calls opencodex created is carried only by thread affinity (`tests/server/server-live.test.ts`, "call-create and its sideband join bind to the same pool account (openai/codex #35830)"). Nothing records which calls opencodex created, so an externally created call is joined under an unrelated account. Upstream Codex attaches its own configured-provider ChatGPT auth to the ExistingCall join (`codex-rs/core/src/realtime_conversation.rs` ExistingCall branch; `client.rs` `current_client_setup`), which is the account that owns the call.

The rest of the Codex 6cfe29984..41acdad24 realtime range is transparent to opencodex; see `010_incompatibility_matrix.md`.

## Work phases

1. wp1 (this document set): investigation and roadmap, docs only.
2. wp2: implement `020_existing_call_join.md`, add the regression test, update the owning structure doc, publish the PR, inspect exact-head CI.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Incompatibility matrix: Codex realtime 6cfe29984..41acdad24 and Desktop 26.930.31730 vs opencodex

Each row is a request or behavior the current Codex clients produce, checked against opencodex at `fde8eebd61`. Status values: **confirmed-broken** (source plus runtime evidence), **ok** (opencodex relays it unchanged), **unresolved** (not proven either way; outside this unit unless stated).

| # | Client behavior | opencodex behavior | Status | Evidence |
|---|---|---|---|---|
| 1 | V3 `existingCall` sideband join `GET /v1/live/<call_id>` (WebSocket upgrade) for a call the client created itself: ChatGPT iOS remote 1.2026.266/267, and Desktop when the renderer owns the call (`clientOwnsCall`, creating it at `chatgpt.com/backend-api/wham/realtime/calls`) | Replaces the caller's `authorization`/`chatgpt-account-id` with a Pool-selected account; the call belongs to the caller's account, so the upstream handshake fails and opencodex answers 502 | **confirmed-broken** (overwrite confirmed; refusal cause inferred) | Codex `logs_2.sqlite` V3 ExistingCall starts with five 502 retries each; `~/.opencodex/usage.jsonl` twenty `gpt-live` 502 rows on two different Pool accounts, both unlike the 2026-09-30 working account; `src/server/live.ts` `resolveLiveRelay`; `src/providers/openai-sidecar.ts` Pool selection; Desktop `app-initial-576fc7ca620e.js:4605,4611` |
| 2 | V3 WebRTC call-create `POST /v1/live` through opencodex, then join on the same thread | Pool account chosen at create; thread affinity keeps the join on the same account | ok | `tests/server/server-live.test.ts` #35830 continuity case; 2026-09-30 201 then 101 on one account |
| 3 | `thread/realtime/listVoices` (#49073 now aborts voice on failure) | Not a network request: the app-server answers from a built-in list | ok | `codex-rs/core/src/session/handlers.rs:66`; `app-server/src/request_processors/turn_processor.rs:328` |
| 4 | V3 `backend_reasoning_status` (#47377) as `delegation.context.append` on `commentary` | Sideband frames relayed byte for byte, no event allowlist | ok | `src/server/index/websocket-handler.ts:146-183`; `src/server/index/live-sideband.ts:580-600` |
| 5 | `partial_answer` phase (#51241, #51260), per-request realtime context (#47596), transcript tail flush (#50531), stale answer filter (#47975), live tool-call metadata (#49401) | Client-local, or carried inside existing frames and Responses requests | ok | L3 report; no realtime serializer/parser file changed in the range |
| 6 | RTP timestamps on a 20 ms grid (#48824), microphone channel selection (#49836), device selection (#49437) | WebRTC media does not pass through opencodex | ok | `structure/data-planes/inbound-compat.md` (no media relay); `codex-rs/voice-host` |
| 7 | Realtime WebSocket network-policy errors (#47101 and follow-ups) | Client-side error mapping; loopback still dialed | ok | `codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs` diff |
| 8 | `in_app_voice` managed feature gate (#49683) | Requirements-only client gate | ok | commit `6996cde69` |
| 9 | Desktop 26.930.21537 → 26.930.31730 voice transport selection, start RPC, call-create endpoint and headers | No contract change found between the two bundles | ok (bounded) | L4 paired-bundle comparison |
| 10 | Provider-table injection mode and the Design B fallback file omit `experimental_realtime_ws_base_url` | Sideband would bypass opencodex in those modes | unresolved, out of scope | L7; the user's logs show the sideband reaching the loopback proxy |
| 11 | External audio-key create on the `/backend-api/codex/live` alias tests the raw path | Non-Frameless defaults for that alias | unresolved, out of scope | L5; affects opencodex audio keys, not Codex clients |
| 12 | Public GPT-Live API `/v1/live/sessions` family | Not used by Codex clients | out of scope | Aside report; Codex URL builders unchanged |
| 13 | Upstream handshake status behind opencodex's 502 | Bun hides the upstream handshake status; the precise upstream code (likely 404) is not recorded | unresolved | `src/server/index/live-sideband.ts` handshake failure mapping |

Row 1 is the only confirmed incompatibility and is the scope of `020_existing_call_join.md`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Diff plan: authenticate ExistingCall joins as the call owner

## Behavior

A native sideband join (`/v1/live/<id>`, `/v1/realtime/calls/<id>`, `/v1/realtime?call_id=<id>`) is split by who created the call:

- The call was created through this opencodex process (native `POST /v1/live` or `/v1/realtime/calls` returned a Location naming it): unchanged. Pool selection and thread affinity choose the account, as the #35830 case requires.
- Otherwise the client created the call with its own ChatGPT login (V3 `existingCall`). When the caller presents an explicit ChatGPT bearer whose token claim matches its `chatgpt-account-id` and passes forward-admission validation, the join carries the caller's own credentials, exactly as Codex Direct mode does. Without such a caller credential the current behavior stays.

Standalone sessions (`/v1/live?model=`, `/v1/realtime?model=`) and the external audio-key path are untouched.

## Changes

1. `src/providers/openai-sidecar.ts`: export `resolveCallerOwnedOpenAiSidecar(candidates, incomingHeaders, config, { admission })`. It returns the first forward candidate with `authContext: { kind: "main", accountId: null }` and `directSidecarHeaders(...)` when the caller credential is explicit, forwardable (`validateForwardAdmissionCredential`) and a Codex bearer; otherwise `undefined`. Materialization keeps the existing main-account hard-lock and credits checks, whose errors extend `CodexAccountCooldownError` and are already mapped by `resolveLiveRelay`.
2. `src/server/live-native-calls.ts` (new, small): bounded process-local set of upstream call ids created through the native call-create path. `recordNativeLiveCall(location)` extracts the call id the way Codex does (`decode_call_id_from_location` in `codex-rs/codex-api/src/endpoint/realtime_call.rs`: drop the query, scan path segments from the end, take the first `rtc_<nonempty>` or 36-character hyphenated UUID), bounded to the join grammar of 128 characters; entries expire after six hours, at most 1024 are kept, oldest evicted first; `isNativeLiveCall(id)`; `clearNativeLiveCallsForTests()`. Ids only, no account or credential.
3. `src/server/live.ts`:
- `handleLive`: after a 2xx upstream response, record the Location.
- `LiveScopeDestination` gains `callerOwnedCallId?: string`.
- `resolveLiveRelay`: when `callerOwnedCallId` is set and not a native call, try `resolveCallerOwnedOpenAiSidecar` first inside the existing `try`; if it returns a sidecar, use it as `forward` (no Pool selection, no outcome recording against a Pool account). Otherwise fall through to the current selection.
- `resolveLiveSidebandUpgrade`: pass `callerOwnedCallId` for the three join styles.
4. `tests/server/server-live-existing-call.test.ts` (new sibling; `server-live.test.ts` has 16 lines of ratchet headroom), Pool mode with two accounts and round-robin:
- a join for a call never created through opencodex reaches the upstream with the caller's `authorization` and `chatgpt-account-id` and the session/thread headers, on all three join forms;
- a call created through opencodex (API `/v1/live/<id>` Location and backend `/v1/realtime/calls/calls/<id>` Location) keeps the Pool account on join;
- no caller bearer keeps the current Pool selection; a claim/header account mismatch and the proxy admission secret as bearer are refused with 401 before any upstream dispatch, as today;
- an active main-account hard lock refuses the caller-owned join (cooldown response) without falling back to a Pool account;
- a configured key with a provider scope that excludes `openai` is refused on the caller-owned branch, as on the Pool branch;
- the caller-owned join records no Pool outcome.
`tests/server/live-native-calls.test.ts` (new): Location extraction for API, backend (`/calls/calls/<id>`), UUID, query, trailing-slash and trailing-suffix shapes and rejection of malformed values; TTL expiry, oldest-first eviction at capacity, and clear, with an injected clock. Both files registered in `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. The #35830 case stays as the regression guard for opencodex-created calls.
5. Docs: `structure/data-planes/inbound-compat.md` (admission paragraph: a native join now consults call-id membership, still no model/account binding; streaming-audio section: the ownership rule); review the `src/providers/` and `src/server/` owner docs from `structure/INDEX.md` and update only those whose explanation changes; `docs-site/src/content/docs/guides/codex-integration.md` voice paragraph gains the client-created call case, and translated locales are checked for contradiction.

## Risks

- Continuity for opencodex-created calls now depends on the call id being retained: a restart, six-hour expiry, or eviction past 1024 calls turns it into an unknown call, and a later rejoin then carries the caller's credentials. The relay socket does not survive a restart, joins happen right after create, and the effect is limited to Pool users whose caller account differs from the creating account. Persisting call-to-account bindings is broader scope and not needed for the #35830 contract.
- Model/provider admission scope still runs after either resolution; a native join still names no model, so model-scoped keys stay denied for joins as today.
- Explicit security review is required on the PR (credential selection).
- The caller bearer is forwarded only to the canonical sideband root or the configured `experimental_realtime_ws_base_url`, the same destinations that already receive Pool bearers.
- No local test run (user instruction); hosted CI is the verifier.
18 changes: 15 additions & 3 deletions docs-site/src/content/docs/fr/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,21 @@ est sélectionné, sauf si `claudeCode.intercept.picker: false` est défini. Il
modèles de l'onglet Code de Desktop first-party pour y afficher les modèles opencodex disponibles par
leur nom. Lors de la première activation, macOS peut demander l'autorisation d'une autorité de certification
locale dans le trousseau de connexion. Cette autorité est limitée à `claude.ai` et à ses sous-domaines.
Sa clé de signature n'existe que dans le processus OpenCodex en cours : chaque redémarrage d'OpenCodex
publie une nouvelle autorité et macOS demande donc de nouveau votre confiance — approuvez la demande,
ou lancez ensuite `ocx claude desktop picker trust`, après chaque redémarrage.
Son identité de signature exportable est protégée par le magasin d'identifiants du système et réutilisée
lors des redémarrages ordinaires. Aucune clé de signature picker en clair n'est enregistrée dans le
répertoire de configuration OpenCodex. La validation complète de l'autorité contrainte et la vérification
de la confiance du système restent obligatoires. Avec la même identité approuvée et un magasin accessible,
un redémarrage n'ajoute ni ne supprime de réglages de confiance des certificats. La restauration au démarrage
n'installe jamais la confiance : si elle manque, est révoquée ou inconnue, le picker reste en attente.
Exécutez explicitement `ocx claude desktop picker on` ou `ocx claude desktop picker trust` pour l'accorder.

Une migration unique depuis une ancienne identité peut demander votre consentement pour supprimer sa
confiance. Si ce nettoyage échoue, le picker reste indisponible et le profil appliqué utilise un relais
sans déchiffrement jusqu'à sa réussite. macOS peut aussi demander de déverrouiller le trousseau ou
d'autoriser l'accès d'une application aux identifiants, y compris après un redémarrage ou une mise à jour.
Le picker reste non pris en charge sous Windows et Linux : aucun travail de CA, de magasin d'identifiants
ou de proxy picker n'y démarre. L'interception Claude principale reste disponible ; ses fichiers CA locaux
sont protégés par des vérifications du propriétaire, des liens symboliques, des permissions et des ACL Windows.

Lorsque le mode picker est actif, Claude Desktop accède au réseau par OpenCodex. Si OpenCodex s'arrête,
Desktop reste hors ligne jusqu'à son redémarrage complet ou jusqu'à la désactivation du mode picker.
Expand Down
Loading
Loading