From 861e25de4720cf341c17751a4968c8a1d805e81b Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:38:42 +0530 Subject: [PATCH 1/4] fix(codex): support API gateway account switching Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../loopx-codex-provider-routing/NATIVE.md | 9 ++- .../native_account_store.py | 60 ++++++++++++++++--- .../native_app.py | 14 ++--- .../presentation/test_native_account_store.py | 31 +++++++++- tests/presentation/test_native_app.py | 45 ++++++++++++++ 5 files changed, 140 insertions(+), 19 deletions(-) diff --git a/packages/loopx-codex-provider-routing/NATIVE.md b/packages/loopx-codex-provider-routing/NATIVE.md index 9231c32e33..bd521d802e 100644 --- a/packages/loopx-codex-provider-routing/NATIVE.md +++ b/packages/loopx-codex-provider-routing/NATIVE.md @@ -44,7 +44,11 @@ dotfiles adapter imports this owner; it contains no second credential writer. The native owner uses the official `openai_base_url` override on the built-in `openai` provider and removes that override on subscription return. The provider identity stays stable for existing conversations; other custom providers remain -outside this owner. +launchable when they define a top-level Responses API gateway. Account switching +preserves that provider, its model catalog and its gateway URL while changing +only the native login mode and credential files. A custom provider must not +shadow the built-in `openai` provider, and profile overrides remain ineligible +because their effective provider cannot be bound safely to the transaction. A local authenticated transport sends the original selected model and Responses body to OpenAI. An explicit upstream quota rejection, 401, 403 or temporary 429 can @@ -85,6 +89,9 @@ incomplete credential transaction. 空闲、重启、冷却和恢复边界返回。扩展内置唯一原生账号 owner,dotfiles 只导入并映射 旧配置;LoopX 不依赖 dotfiles。API 上游 Key 留在扩展私有存储,原生槽位只保存本地 中转凭据,不绕过原事务或复制会话历史。 +顶层 Responses API 网关可以直接启动并参与账号切换;切换保留 provider、模型目录和网关 +地址,只更新原生登录模式与凭据。自定义 provider 不得覆盖内置 `openai`,profile 覆写 +仍不参与账号事务。 Key 轮换依据输出前收到的明确额度、认证、权限或限流错误,每次请求每把 Key 最多 尝试一次;网络效果不明、普通请求错误和已开始输出的流不会重放,不替换模型,也不做 后台付费探测。关闭后停止新请求并等待已接收请求结束。App 显示实际请求结果和上次 diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py index fe85dafc82..102a1d125a 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py @@ -14,6 +14,7 @@ import subprocess import tempfile import tomllib +from urllib.parse import urlsplit import uuid API_KEY_SLOTS_VERSION = 1 @@ -28,6 +29,47 @@ def api_endpoint(value): return value +def gateway_endpoint(value): + """Accept a Responses API gateway without allowing credentials in the URL.""" + try: + parts = urlsplit(value) if isinstance(value, str) else None + port = parts.port if parts is not None else None + except ValueError: + parts = None + port = None + if (parts is None or parts.scheme not in ('http', 'https') or not parts.hostname + or parts.username is not None or parts.password is not None + or parts.query or parts.fragment or port == 0): + raise ValueError('API gateway requires a clean HTTP(S) Responses endpoint') + if parts.scheme == 'http' and parts.hostname not in ('127.0.0.1', '::1', 'localhost'): + raise ValueError('Unencrypted API gateways must stay on the loopback interface') + return value + + +def provider_route(cfg): + """Return the selected provider after validating launch-safe configuration.""" + providers = cfg.get('model_providers') or {} + if not isinstance(providers, dict): + raise ValueError('Invalid Codex provider definitions') + if 'openai' in providers: + raise ValueError('A custom provider must not shadow the built-in OpenAI provider') + provider = cfg.get('model_provider', 'openai') + if not isinstance(provider, str) or not provider: + raise ValueError('Invalid Codex provider selection') + if provider == 'openai': + endpoint = cfg.get('openai_base_url') + if endpoint is not None: + gateway_endpoint(endpoint) + return provider + definition = providers.get(provider) + if not isinstance(definition, dict): + raise ValueError('Selected Codex provider is not a configured API gateway') + if definition.get('wire_api', 'responses') != 'responses': + raise ValueError('API gateways must use the Responses protocol') + gateway_endpoint(definition.get('base_url')) + return provider + + def auth_kind(raw): obj = json.loads(raw) return 'api' if obj.get('auth_mode') == 'apikey' else 'subscription' @@ -137,11 +179,11 @@ def file_config(raw, auth=None): cfg = tomllib.loads(text) except (ValueError, UnicodeError): raise ValueError('Invalid GPT config; repair locally without printing its contents') from None - # Preserve unused custom definitions; they do not change the native route. - if (cfg.get('model_provider', 'openai') != 'openai' - or 'openai' in (cfg.get('model_providers') or {}) - or cfg.get('model_catalog_json') or cfg.get('profiles') or cfg.get('profile')): - raise ValueError('Account switching requires an ordinary native GPT config') + provider = provider_route(cfg) + # A profile can replace the checked provider at runtime, so account switching + # cannot safely infer which route the credential transaction owns. + if cfg.get('profiles') or cfg.get('profile'): + raise ValueError('Account switching does not support provider profile overrides') if cfg.get('cli_auth_credentials_store') not in (None, 'auto', 'file'): raise ValueError('Explicit keyring/ephemeral storage is preserved; use native sign-in or review file storage locally') if cfg.get('forced_login_method') not in (None, 'chatgpt', 'api'): @@ -151,17 +193,19 @@ def file_config(raw, auth=None): if mode == 'api': identity(auth) endpoint = api_endpoint(json.loads(auth)['loopx_api_base_url']) - # Change auth settings and the owned API endpoint; preserve model and other config. + # Change auth settings and the owned built-in API endpoint. A selected + # gateway owns its own base_url, model catalog and provider identity. section = re.search(r'^\s*\[', text, re.M) head, tail = (text[:section.start()], text[section.start():]) if section else (text, '') settings = [('cli_auth_credentials_store', 'file'), ('forced_login_method', mode)] - if mode == 'api': + if mode == 'api' and provider == 'openai': if cfg.get('openai_base_url') and cfg.get('forced_login_method') != 'api': raise ValueError('An existing OpenAI base URL must not be replaced by API fallback') if cfg.get('openai_base_url'): api_endpoint(cfg['openai_base_url']) settings.append(('openai_base_url', endpoint)) - elif cfg.get('forced_login_method') == 'api' and cfg.get('openai_base_url'): + elif (mode == 'chatgpt' and provider == 'openai' + and cfg.get('forced_login_method') == 'api' and cfg.get('openai_base_url')): api_endpoint(cfg['openai_base_url']) pattern = r'^openai_base_url\s*=.*(?:\n|$)' if len(re.findall(pattern, head, re.M)) != 1: diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_app.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_app.py index 41a380bb82..00df691549 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_app.py +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_app.py @@ -12,7 +12,6 @@ import plistlib import re import subprocess -import tomllib from . import native_account_store as accounts @@ -152,12 +151,13 @@ def check_provider(spec, role="gpt"): if role != "gpt": raise ValueError("Only native Codex is supported") path = spec["home"] / "config.toml" - cfg = tomllib.loads(path.read_text()) if path.exists() else {} - if (cfg.get("model_provider", "openai") != "openai" or cfg.get("model_catalog_json") - or "openai" in (cfg.get("model_providers") or {})): - raise ValueError("Account switching requires the built-in OpenAI provider") - if cfg.get("forced_login_method") == "api": - accounts.api_endpoint(cfg.get("openai_base_url")) + if not path.exists(): + return "openai" + try: + cfg = accounts.tomllib.loads(path.read_text()) + except (ValueError, UnicodeError): + raise ValueError("Invalid Codex config; repair it locally without printing secrets") from None + return accounts.provider_route(cfg) def account_preflight(home, spec): diff --git a/tests/presentation/test_native_account_store.py b/tests/presentation/test_native_account_store.py index f9885d89e4..d78ed61c34 100644 --- a/tests/presentation/test_native_account_store.py +++ b/tests/presentation/test_native_account_store.py @@ -170,14 +170,39 @@ def test_unused_provider_survives_account_enrollment_switch_and_rollback(self): self.assertEqual(self.manager.config.read_bytes(), before) self.assertTrue(before.endswith(tail.encode())) - def test_non_native_routes_and_profiles_are_not_converted(self): + def test_gateway_provider_and_catalog_survive_api_and_subscription_switches(self): + gateway = ('# kept\nmodel="gateway-model"\nmodel_provider="gateway"\n' + 'model_catalog_json="models.json"\n' + '[model_providers.gateway]\nname="Gateway"\n' + 'base_url="https://gateway.example/v1"\n' + 'wire_api="responses"\n' + 'requires_openai_auth=true\n' + '[mcp_servers.demo]\ncommand="demo"\n') + self.manager.config.write_text(gateway) + self.current() + self.manager.enroll_api('api', api_key='sk-loopx-relay-synthetic-local-token', + base_url='http://127.0.0.1:32100/v1') + self.manager.switch('api') + cfg = tomllib.loads(self.manager.config.read_text()) + self.assertEqual(cfg['model_provider'], 'gateway') + self.assertEqual(cfg['model_catalog_json'], 'models.json') + self.assertEqual(cfg['forced_login_method'], 'api') + self.assertNotIn('openai_base_url', cfg) + self.manager.switch('a') + cfg = tomllib.loads(self.manager.config.read_text()) + self.assertEqual(cfg['model_provider'], 'gateway') + self.assertEqual(cfg['model_catalog_json'], 'models.json') + self.assertEqual(cfg['forced_login_method'], 'chatgpt') + self.assertNotIn('openai_base_url', cfg) + self.assertEqual(cfg['mcp_servers']['demo']['command'], 'demo') + + def test_ambiguous_routes_and_profiles_are_not_converted(self): for raw in (b'model_provider="optional_http"\n', - b'model_catalog_json="models.json"\n', b'[model_providers.openai]\n', b'profile="custom"\n', b'[profiles.custom]\nmodel_provider="optional_http"\n'): with self.subTest(raw=raw): - with self.assertRaisesRegex(ValueError, 'native GPT'): + with self.assertRaises(ValueError): accounts.file_config(raw) def test_live_enrollment_shares_cli_lease_and_preserves_switch_recovery(self): diff --git a/tests/presentation/test_native_app.py b/tests/presentation/test_native_app.py index ac4c3762b8..83075fd2fe 100644 --- a/tests/presentation/test_native_app.py +++ b/tests/presentation/test_native_app.py @@ -45,6 +45,51 @@ def test_default_profile_discovery_is_read_only_and_does_not_require_dotfiles(pr assert list(home.iterdir()) == [] +def test_selected_api_gateway_is_launchable_with_its_catalog(profile): + home, spec = profile + spec["home"].mkdir(mode=0o700) + (spec["home"] / "config.toml").write_text( + 'model = "gateway-model"\n' + 'model_provider = "gateway"\n' + 'model_catalog_json = "models.json"\n' + 'forced_login_method = "api"\n' + '[model_providers.gateway]\n' + 'name = "Gateway"\n' + 'base_url = "https://gateway.example/v1"\n' + 'wire_api = "responses"\n' + 'env_key = "GATEWAY_API_KEY"\n' + ) + assert app.check_provider(spec) == "gateway" + + +@pytest.mark.parametrize( + ("config", "message"), + [ + ('model_provider = "missing"\n', "not a configured API gateway"), + ( + 'model_provider = "gateway"\n' + '[model_providers.gateway]\n' + 'base_url = "http://outside.example/v1"\n', + "loopback", + ), + ( + 'model_provider = "gateway"\n' + '[model_providers.gateway]\n' + 'base_url = "https://gateway.example/v1"\n' + 'wire_api = "chat"\n', + "Responses", + ), + ('[model_providers.openai]\nbase_url = "https://outside.example/v1"\n', "shadow"), + ], +) +def test_ambiguous_api_gateway_configs_fail_closed(profile, config, message): + _, spec = profile + spec["home"].mkdir(mode=0o700) + (spec["home"] / "config.toml").write_text(config) + with pytest.raises(ValueError, match=message): + app.check_provider(spec) + + def test_existing_profile_reuses_vault_and_locks_without_copying(profile): home, _ = profile path = home / ".config/personal/apps.json" From b90b19ce136b794e69c36e1bcb6f4f028a0d7868 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:46:12 +0530 Subject: [PATCH 2/4] fix(codex): keep API and subscription routes paired Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../loopx-codex-provider-routing/NATIVE.md | 18 +++--- .../native_account_store.py | 55 +++++++++++++++---- .../presentation/test_native_account_store.py | 11 +++- 3 files changed, 63 insertions(+), 21 deletions(-) diff --git a/packages/loopx-codex-provider-routing/NATIVE.md b/packages/loopx-codex-provider-routing/NATIVE.md index bd521d802e..d14531cf26 100644 --- a/packages/loopx-codex-provider-routing/NATIVE.md +++ b/packages/loopx-codex-provider-routing/NATIVE.md @@ -44,11 +44,13 @@ dotfiles adapter imports this owner; it contains no second credential writer. The native owner uses the official `openai_base_url` override on the built-in `openai` provider and removes that override on subscription return. The provider identity stays stable for existing conversations; other custom providers remain -launchable when they define a top-level Responses API gateway. Account switching -preserves that provider, its model catalog and its gateway URL while changing -only the native login mode and credential files. A custom provider must not -shadow the built-in `openai` provider, and profile overrides remain ineligible -because their effective provider cannot be bound safely to the transaction. +launchable when they define a top-level Responses API gateway. An API account +slot records its selected gateway provider; selection restores that provider and +its model catalog, while a subscription slot selects the built-in `openai` +provider and removes the managed API endpoint. API and subscription logins are +therefore never sent to the other route. A custom provider must not shadow the +built-in `openai` provider, and profile overrides remain ineligible because their +effective provider cannot be bound safely to the transaction. A local authenticated transport sends the original selected model and Responses body to OpenAI. An explicit upstream quota rejection, 401, 403 or temporary 429 can @@ -89,9 +91,9 @@ incomplete credential transaction. 空闲、重启、冷却和恢复边界返回。扩展内置唯一原生账号 owner,dotfiles 只导入并映射 旧配置;LoopX 不依赖 dotfiles。API 上游 Key 留在扩展私有存储,原生槽位只保存本地 中转凭据,不绕过原事务或复制会话历史。 -顶层 Responses API 网关可以直接启动并参与账号切换;切换保留 provider、模型目录和网关 -地址,只更新原生登录模式与凭据。自定义 provider 不得覆盖内置 `openai`,profile 覆写 -仍不参与账号事务。 +顶层 Responses API 网关可以直接启动并参与账号切换。API 槽位记录所选网关,重新选中时 +恢复该 provider 与模型目录;订阅槽位改用内置 `openai`,并移除受管的 API 地址,避免把 +订阅凭据发往网关。自定义 provider 不得覆盖内置 `openai`,profile 覆写仍不参与账号事务。 Key 轮换依据输出前收到的明确额度、认证、权限或限流错误,每次请求每把 Key 最多 尝试一次;网络效果不明、普通请求错误和已开始输出的流不会重放,不替换模型,也不做 后台付费探测。关闭后停止新请求并等待已接收请求结束。App 显示实际请求结果和上次 diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py index 102a1d125a..3ffee2c2ec 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py @@ -150,6 +150,10 @@ def identity(raw): if (not isinstance(key, str) or not key.startswith('sk-loopx-relay-') or not 32 <= len(key) <= 512 or any(c.isspace() for c in key) or obj.get('tokens')): raise ValueError() + provider = obj.get('loopx_model_provider') + if provider is not None and (not isinstance(provider, str) or not provider + or any(c.isspace() for c in provider)): + raise ValueError() endpoint = api_endpoint(obj.get('loopx_api_base_url')) return digest(encoded(['apikey', key, endpoint])) tokens = obj['tokens'] @@ -173,13 +177,22 @@ def identity(raw): raise ValueError('Invalid native account cache; inspect the local login or relay configuration') from None +def routed_auth(raw, provider): + """Bind a relay credential to the provider restored when its slot is selected.""" + if auth_kind(raw) != 'api' or not isinstance(provider, str) or not provider: + raise ValueError('Invalid API gateway account route') + obj = json.loads(raw) + obj['loopx_model_provider'] = provider + return encoded(obj) + + def file_config(raw, auth=None): try: text = raw.decode() cfg = tomllib.loads(text) except (ValueError, UnicodeError): raise ValueError('Invalid GPT config; repair locally without printing its contents') from None - provider = provider_route(cfg) + current_provider = provider_route(cfg) # A profile can replace the checked provider at runtime, so account switching # cannot safely infer which route the credential transaction owns. if cfg.get('profiles') or cfg.get('profile'): @@ -189,23 +202,30 @@ def file_config(raw, auth=None): if cfg.get('forced_login_method') not in (None, 'chatgpt', 'api'): raise ValueError('Unsupported account login method') mode = 'api' if auth is not None and auth_kind(auth) == 'api' else 'chatgpt' + target_provider = 'openai' endpoint = None if mode == 'api': identity(auth) - endpoint = api_endpoint(json.loads(auth)['loopx_api_base_url']) - # Change auth settings and the owned built-in API endpoint. A selected - # gateway owns its own base_url, model catalog and provider identity. + auth_obj = json.loads(auth) + endpoint = api_endpoint(auth_obj['loopx_api_base_url']) + target_provider = auth_obj.get('loopx_model_provider', 'openai') + candidate = dict(cfg, model_provider=target_provider) + provider_route(candidate) + # Subscription slots use the built-in provider. API slots restore the + # provider bound to that credential, so a gateway request never inherits a + # ChatGPT login and a subscription never inherits a relay endpoint. section = re.search(r'^\s*\[', text, re.M) head, tail = (text[:section.start()], text[section.start():]) if section else (text, '') settings = [('cli_auth_credentials_store', 'file'), ('forced_login_method', mode)] - if mode == 'api' and provider == 'openai': + if target_provider != current_provider: + settings.insert(0, ('model_provider', target_provider)) + if mode == 'api' and target_provider == 'openai': if cfg.get('openai_base_url') and cfg.get('forced_login_method') != 'api': raise ValueError('An existing OpenAI base URL must not be replaced by API fallback') if cfg.get('openai_base_url'): api_endpoint(cfg['openai_base_url']) settings.append(('openai_base_url', endpoint)) - elif (mode == 'chatgpt' and provider == 'openai' - and cfg.get('forced_login_method') == 'api' and cfg.get('openai_base_url')): + elif mode == 'chatgpt' and cfg.get('openai_base_url'): api_endpoint(cfg['openai_base_url']) pattern = r'^openai_base_url\s*=.*(?:\n|$)' if len(re.findall(pattern, head, re.M)) != 1: @@ -382,8 +402,12 @@ def status(self): def enroll_api(self, name, *, api_key, base_url): """Enroll an explicit local relay credential without selecting it.""" + provider = 'openai' + if self.config.exists(): + provider = provider_route(tomllib.loads(self.config.read_text())) raw = encoded({'auth_mode': 'apikey', 'OPENAI_API_KEY': api_key, - 'loopx_api_base_url': api_endpoint(base_url)}) + 'loopx_api_base_url': api_endpoint(base_url), + 'loopx_model_provider': provider}) ident = identity(raw) target = self.slot(name) with self.lock(enrollment=True): @@ -459,9 +483,20 @@ def switch(self, name, dry_run=False): # Reread under the lock: retain refresh-token rotation from the last use. state, outgoing = self.selected() raw = outgoing if state and state['slot'] == name else read_private(target) + config_raw = self.config.read_bytes() if self.config.exists() else b'' + current_provider = provider_route(tomllib.loads(config_raw.decode()) if config_raw else {}) + if state and state['slot'] == 'openai-api' and auth_kind(outgoing) == 'api': + outgoing = routed_auth(outgoing, current_provider) + if auth_kind(raw) == 'api' and not json.loads(raw).get('loopx_model_provider'): + provider = current_provider if current_provider != 'openai' else 'openai' + raw = routed_auth(raw, provider) ident = identity(raw) - converted = file_config(self.config.read_bytes() if self.config.exists() else b'', raw) - updates = [('slot:' + state['slot'], outgoing)] if state else [] + converted = file_config(config_raw, raw) + updates = [] + if state: + updates.append(('slot:' + state['slot'], outgoing)) + if not state or state['slot'] != name: + updates.append(('slot:' + name, raw)) updates += [('config', converted), ('auth', raw), ('active', encoded({'slot': name, 'identity': ident, 'binding': self.binding}))] return dict(self.transaction(updates, 'switch'), slot=name, diff --git a/tests/presentation/test_native_account_store.py b/tests/presentation/test_native_account_store.py index d78ed61c34..2a267265a2 100644 --- a/tests/presentation/test_native_account_store.py +++ b/tests/presentation/test_native_account_store.py @@ -170,7 +170,7 @@ def test_unused_provider_survives_account_enrollment_switch_and_rollback(self): self.assertEqual(self.manager.config.read_bytes(), before) self.assertTrue(before.endswith(tail.encode())) - def test_gateway_provider_and_catalog_survive_api_and_subscription_switches(self): + def test_gateway_route_is_bound_to_api_and_subscriptions_use_builtin_openai(self): gateway = ('# kept\nmodel="gateway-model"\nmodel_provider="gateway"\n' 'model_catalog_json="models.json"\n' '[model_providers.gateway]\nname="Gateway"\n' @@ -179,9 +179,9 @@ def test_gateway_provider_and_catalog_survive_api_and_subscription_switches(self 'requires_openai_auth=true\n' '[mcp_servers.demo]\ncommand="demo"\n') self.manager.config.write_text(gateway) - self.current() self.manager.enroll_api('api', api_key='sk-loopx-relay-synthetic-local-token', base_url='http://127.0.0.1:32100/v1') + self.current() self.manager.switch('api') cfg = tomllib.loads(self.manager.config.read_text()) self.assertEqual(cfg['model_provider'], 'gateway') @@ -190,11 +190,16 @@ def test_gateway_provider_and_catalog_survive_api_and_subscription_switches(self self.assertNotIn('openai_base_url', cfg) self.manager.switch('a') cfg = tomllib.loads(self.manager.config.read_text()) - self.assertEqual(cfg['model_provider'], 'gateway') + self.assertEqual(cfg['model_provider'], 'openai') self.assertEqual(cfg['model_catalog_json'], 'models.json') self.assertEqual(cfg['forced_login_method'], 'chatgpt') self.assertNotIn('openai_base_url', cfg) self.assertEqual(cfg['mcp_servers']['demo']['command'], 'demo') + self.manager.switch('api') + cfg = tomllib.loads(self.manager.config.read_text()) + self.assertEqual(cfg['model_provider'], 'gateway') + self.assertEqual(cfg['forced_login_method'], 'api') + self.assertNotIn('openai_base_url', cfg) def test_ambiguous_routes_and_profiles_are_not_converted(self): for raw in (b'model_provider="optional_http"\n', From 94e6e226521612a3dfcdc15bbc7c4cebdd4ae54f Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:48:06 +0530 Subject: [PATCH 3/4] fix(codex): bind legacy API slots before switching Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../native_account_store.py | 7 ++++++- .../presentation/test_native_account_store.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py index 3ffee2c2ec..0eb7333e08 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py @@ -472,8 +472,13 @@ def switch(self, name, dry_run=False): if self.pending.exists(): raise ValueError('Interrupted account operation; run codex app recover with GPT closed') raw = read_private(target) + config_raw = self.config.read_bytes() if self.config.exists() else b'' + current_provider = provider_route(tomllib.loads(config_raw.decode()) if config_raw else {}) + if auth_kind(raw) == 'api' and not json.loads(raw).get('loopx_model_provider'): + provider = current_provider if current_provider != 'openai' else 'openai' + raw = routed_auth(raw, provider) ident = identity(raw) - converted = file_config(self.config.read_bytes() if self.config.exists() else b'', raw) + converted = file_config(config_raw, raw) state, outgoing = self.selected() if dry_run: return {'preview': True, 'slot': name, 'home': str(self.spec['home']), diff --git a/tests/presentation/test_native_account_store.py b/tests/presentation/test_native_account_store.py index 2a267265a2..b05bef3c5e 100644 --- a/tests/presentation/test_native_account_store.py +++ b/tests/presentation/test_native_account_store.py @@ -201,6 +201,25 @@ def test_gateway_route_is_bound_to_api_and_subscriptions_use_builtin_openai(self self.assertEqual(cfg['forced_login_method'], 'api') self.assertNotIn('openai_base_url', cfg) + def test_legacy_api_slot_inherits_a_stale_selected_gateway(self): + self.current() + self.manager.enroll_api('api', api_key='sk-loopx-relay-synthetic-local-token', + base_url='http://127.0.0.1:32100/v1') + legacy = json.loads(self.manager.slot('api').read_text()) + legacy.pop('loopx_model_provider') + accounts.atomic(self.manager.slot('api'), accounts.encoded(legacy)) + gateway = ('model="gateway-model"\nmodel_provider="gateway"\n' + 'model_catalog_json="models.json"\n' + '[model_providers.gateway]\n' + 'base_url="https://gateway.example/v1"\nwire_api="responses"\n') + self.manager.config.write_text(gateway) + self.manager.switch('api') + bound = json.loads(self.manager.slot('api').read_text()) + cfg = tomllib.loads(self.manager.config.read_text()) + self.assertEqual(bound['loopx_model_provider'], 'gateway') + self.assertEqual(cfg['model_provider'], 'gateway') + self.assertEqual(cfg['model_catalog_json'], 'models.json') + def test_ambiguous_routes_and_profiles_are_not_converted(self): for raw in (b'model_provider="optional_http"\n', b'[model_providers.openai]\n', From 573d02138d5b7d15dbb7e831790ba7929613f09d Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:03:00 +0800 Subject: [PATCH 4/4] fix(codex): serialize TOML provider names and validate converted config Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../native_account_store.py | 25 ++++- .../presentation/test_native_account_store.py | 92 +++++++++++++++++++ 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py index 0eb7333e08..a98a35b88c 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py @@ -186,6 +186,12 @@ def routed_auth(raw, provider): return encoded(obj) +def toml_string(value): + if not isinstance(value, str) or any(ord(c) < 0x20 or ord(c) == 0x7f for c in value): + raise ValueError('Invalid TOML setting value') + return json.dumps(value, ensure_ascii=False) + + def file_config(raw, auth=None): try: text = raw.decode() @@ -233,13 +239,26 @@ def file_config(raw, auth=None): head = re.sub(pattern, '', head, flags=re.M) for key, value in settings: pattern = rf'^{key}\s*=.*$' + rendered = f'{key} = {toml_string(value)}' if key in cfg: if len(re.findall(pattern, head, re.M)) != 1: raise ValueError('Nonstandard auth setting syntax requires local review') - head = re.sub(pattern, f'{key} = "{value}"', head, flags=re.M) + head = re.sub(pattern, lambda _: rendered, head, flags=re.M) else: - head = f'{key} = "{value}"\n' + head - return (head + tail).encode() + head = f'{rendered}\n' + head + converted = (head + tail).encode() + try: + updated = tomllib.loads(converted.decode()) + except (ValueError, UnicodeError): + raise ValueError('Nonstandard auth setting syntax requires local review') from None + expected_base_url = endpoint if mode == 'api' and target_provider == 'openai' else ( + None if mode == 'chatgpt' else cfg.get('openai_base_url')) + if (provider_route(updated) != target_provider + or updated.get('cli_auth_credentials_store') != 'file' + or updated.get('forced_login_method') != mode + or updated.get('openai_base_url') != expected_base_url): + raise ValueError('Nonstandard auth setting syntax requires local review') + return converted class Accounts: diff --git a/tests/presentation/test_native_account_store.py b/tests/presentation/test_native_account_store.py index b05bef3c5e..c77b96d01d 100644 --- a/tests/presentation/test_native_account_store.py +++ b/tests/presentation/test_native_account_store.py @@ -4,6 +4,8 @@ import json import os from pathlib import Path +import plistlib +import re import sqlite3 import subprocess import sys @@ -220,6 +222,96 @@ def test_legacy_api_slot_inherits_a_stale_selected_gateway(self): self.assertEqual(cfg['model_provider'], 'gateway') self.assertEqual(cfg['model_catalog_json'], 'models.json') + def test_quoted_and_backslash_gateway_providers_survive_switch_and_independent_readback(self): + host_module = importlib.import_module('loopx_codex_provider_routing.native_host') + cases = [ + ('gateway"quoted', '"gateway\\"quoted"'), + (r'gateway\provider', "'gateway\\provider'"), + ] + for index, (provider, table_key) in enumerate(cases): + with self.subTest(provider=provider): + case_home = self.home / f'case-{index}' + case_home.mkdir() + codex = case_home / '.codex' + codex.mkdir(mode=0o700) + case_bundle = case_home / 'Applications/Codex.app/Contents/MacOS/Codex' + case_bundle.parent.mkdir(parents=True) + case_bundle.write_text('#!/bin/sh\nexit 0\n') + case_bundle.chmod(0o755) + (case_bundle.parents[1] / 'Info.plist').write_bytes( + plistlib.dumps({'CFBundleExecutable': 'Codex'}) + ) + gateway = ( + f'model = "gateway-model"\n' + f'model_provider = {table_key}\n' + f'model_catalog_json = "models.json"\n' + f'[model_providers.{table_key}]\n' + f'base_url = "https://gateway.example/v1"\n' + f'wire_api = "responses"\n' + ) + config_path = codex / 'config.toml' + config_path.write_text(gateway) + config_path.chmod(0o600) + accounts.atomic(codex / 'auth.json', self.a) + with patch.dict( + os.environ, + { + 'CODEX_HOME': str(codex), + 'CODEX_ELECTRON_USER_DATA_PATH': str(case_home / 'frontend'), + }, + ), patch.object(host_module.shutil, 'which', return_value=str(case_bundle)): + adapter = host_module.discover(user_home=case_home) + host = host_module.NativeHost(adapter, user_home=case_home, reconcile_services=False) + host.manager.idle = lambda: None + host.enroll_api( + 'openai-api', + 'sk-loopx-relay-synthetic-local-token', + 'http://127.0.0.1:32100/v1', + ) + enrolled = host.enroll_current(host.revision()) + self.assertEqual(enrolled['active_slot'], 'primary') + self.assertEqual(apps.check_provider(host.spec), 'openai') + switched = host.switch('openai-api', enrolled['selection_revision'], reopen=False) + self.assertEqual(switched['active_slot'], 'openai-api') + self.assertTrue(switched['current_login_matches']) + self.assertFalse(switched['recovery_required']) + # Independent readback via a fresh NativeHost and check_provider. + reader = host_module.NativeHost(adapter, user_home=case_home, reconcile_services=False) + readback = reader.status() + self.assertEqual(readback['active_slot'], 'openai-api') + self.assertEqual(readback['api_slot'], 'openai-api') + self.assertTrue(readback['current_login_matches']) + self.assertFalse(readback['recovery_required']) + self.assertEqual(apps.check_provider(reader.spec), provider) + # Also verify inserting model_provider when omitted under subscription. + back = host.switch('primary', readback['selection_revision'], reopen=False) + self.assertEqual(back['active_slot'], 'primary') + omitted = re.sub(r'^model_provider\s*=.*\n', '', config_path.read_text(), flags=re.M) + config_path.write_text(omitted) + reinserted = host.switch('openai-api', back['selection_revision'], reopen=False) + self.assertEqual(reinserted['active_slot'], 'openai-api') + self.assertEqual(apps.check_provider(reader.spec), provider) + self.assertTrue(reader.status()['current_login_matches']) + + def test_multiline_or_unparseable_setting_syntax_fails_before_transaction(self): + self.current() + self.manager.enroll_api('api', api_key='sk-loopx-relay-synthetic-local-token', + base_url='http://127.0.0.1:32100/v1') + multiline = ( + 'model_provider = "openai"\n' + 'cli_auth_credentials_store = "file"\n' + 'forced_login_method = """\n' + 'chatgpt"""\n' + ) + self.manager.config.write_text(multiline) + before_auth = self.manager.auth.read_bytes() + before_active = self.manager.active.read_bytes() + with self.assertRaises(ValueError): + self.manager.switch('api') + self.assertFalse(self.manager.pending.exists()) + self.assertEqual(self.manager.auth.read_bytes(), before_auth) + self.assertEqual(self.manager.active.read_bytes(), before_active) + def test_ambiguous_routes_and_profiles_are_not_converted(self): for raw in (b'model_provider="optional_http"\n', b'[model_providers.openai]\n',