forked from omnigent-ai/omnigent
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_codex_native_app_server.py
More file actions
2200 lines (1835 loc) · 83.1 KB
/
Copy pathtest_codex_native_app_server.py
File metadata and controls
2200 lines (1835 loc) · 83.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Tests for codex-native app-server policy-hook trust handling."""
from __future__ import annotations
import json
import stat
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import pytest
try:
import tomllib
except ImportError: # pragma: no cover - Python < 3.11
import tomli as tomllib # type: ignore[no-redef]
from omnigent.codex_native_app_server import (
_FRAMEWORK_APPROVED_TOOLS,
_POLICY_HOOK_TIMEOUT_SECONDS,
CodexNativeAppServer,
_build_native_codex_app_server_argv,
_codex_policy_hooks_settings,
_hooks_list_diagnostics,
_model_discovery_cache,
_our_policy_hooks_from_list,
_sync_codex_developer_instructions,
build_codex_native_server,
discover_codex_model_options,
framework_approved_tools,
trust_codex_router_hooks,
trust_native_policy_hooks,
)
from omnigent.codex_native_hook import _EVALUATE_POLICY_TIMEOUT_S
from omnigent.inner.codex_executor import (
_populate_codex_home_config,
_provider_codex_config_overrides,
)
async def test_discover_codex_model_options_strips_secrets_and_stops_process(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Pre-launch discovery uses an empty home, no credentials, and clean teardown."""
from omnigent import codex_native_app_server
captured_env: dict[str, str] = {}
class _FakeProcess:
pid = None
returncode: int | None = None
terminated = False
def terminate(self) -> None:
self.terminated = True
self.returncode = 0
def kill(self) -> None:
self.returncode = -1
async def wait(self) -> int:
self.returncode = 0 if self.returncode is None else self.returncode
return self.returncode
process = _FakeProcess()
async def _fake_start(
*,
codex_path: str,
listen_url: str,
env: dict[str, str],
cwd: Path,
) -> _FakeProcess:
assert codex_path == "/test/codex"
assert listen_url.startswith("ws://127.0.0.1:")
assert cwd.is_dir()
assert Path(env["CODEX_HOME"]).is_dir()
captured_env.update(env)
return process
async def _fake_wait(process: _FakeProcess, port: int) -> None:
assert process is not None
assert port > 0
class _FakeClient:
def __init__(self, *, ws_url: str, client_name: str) -> None:
assert ws_url.startswith("ws://127.0.0.1:")
assert client_name == "omnigent-codex-model-discovery"
async def connect(self) -> None:
return None
async def close(self) -> None:
return None
async def request(
self,
method: str,
params: dict[str, object],
) -> dict[str, object]:
assert method == "model/list"
assert params == {"includeHidden": False}
return {
"result": {
"data": [
{
"id": "coding-model",
"model": "coding-model",
"isDefault": True,
}
],
"nextCursor": None,
}
}
monkeypatch.setattr(
codex_native_app_server,
"_clean_codex_env",
lambda: {
"PATH": "/bin",
"OPENAI_API_KEY": "openai-secret",
"OPENAI_BASE_URL": "https://example.invalid/v1",
"DATABRICKS_BEARER": "databricks-secret",
"DATABRICKS_CODEX_TOKEN": "databricks-secret",
},
)
monkeypatch.setattr(
codex_native_app_server,
"_start_codex_model_discovery_process",
_fake_start,
)
monkeypatch.setattr(codex_native_app_server, "_wait_for_discovery_listener", _fake_wait)
monkeypatch.setattr(codex_native_app_server, "CodexAppServerClient", _FakeClient)
_model_discovery_cache.clear()
options = await discover_codex_model_options(codex_path="/test/codex")
assert options == [{"id": "coding-model", "model": "coding-model", "isDefault": True}]
assert captured_env == {"PATH": "/bin", "CODEX_HOME": captured_env["CODEX_HOME"]}
assert process.terminated is True
_model_discovery_cache.clear()
# Spelled out per session class rather than derived from the constants under
# test: a comprehension over ``_FRAMEWORK_APPROVED_TOOLS`` passes no matter what
# is added to it, so it can never catch the approval surface growing.
#
# A plain codex session pre-approves exactly the one tool the framework calls
# unprompted on any session. Any Smart Routing session — pinned harness or auto
# — additionally pre-approves the four its routed spawns run on: discover the
# agent, start the routed child, deliver the task, collect the result. Nobody is
# watching for an approval prompt in the middle of a spawn.
_PLAIN_TOOL_APPROVALS = {"sys_session_rename": {"approval_mode": "approve"}}
_ROUTED_TOOL_APPROVALS = {
"sys_session_rename": {"approval_mode": "approve"},
"sys_session_create": {"approval_mode": "approve"},
"sys_agent_list": {"approval_mode": "approve"},
"sys_session_send": {"approval_mode": "approve"},
"sys_read_inbox": {"approval_mode": "approve"},
}
def test_the_framework_tool_approvals_are_scoped_to_the_session_class() -> None:
assert set(framework_approved_tools(routed_spawns=False)) == set(_PLAIN_TOOL_APPROVALS)
assert set(framework_approved_tools(routed_spawns=True)) == set(_ROUTED_TOOL_APPROVALS)
# The base set is a subset of the routed one, so a routed session never
# loses an approval a plain session has.
assert set(_FRAMEWORK_APPROVED_TOOLS) <= set(_ROUTED_TOOL_APPROVALS)
def test_sync_developer_instructions_preserves_and_restores_user_config(tmp_path: Path) -> None:
"""Framework instructions append without replacing the user's Codex guidance."""
codex_home = tmp_path / "codex-home"
codex_home.mkdir()
config_path = codex_home / "config.toml"
config_path.write_text(
'model = "gpt-5.5"\ndeveloper_instructions = "Keep user guidance."\n',
encoding="utf-8",
)
_sync_codex_developer_instructions(codex_home, "Rename the session.")
_sync_codex_developer_instructions(codex_home, "Rename the session.")
config = tomllib.loads(config_path.read_text(encoding="utf-8"))
assert config["model"] == "gpt-5.5"
assert config["developer_instructions"] == ("Keep user guidance.\n\nRename the session.")
_sync_codex_developer_instructions(codex_home, None)
resumed_config = tomllib.loads(config_path.read_text(encoding="utf-8"))
assert resumed_config["developer_instructions"] == "Keep user guidance."
def test_sync_developer_instructions_survives_reseeded_config(tmp_path: Path) -> None:
"""A persisted sidecar restores the original base after config reseeding."""
codex_home = tmp_path / "codex-home"
source_home = tmp_path / "source-home"
codex_home.mkdir()
source_home.mkdir()
config_path = codex_home / "config.toml"
config_path.write_text(
'developer_instructions = "Keep original guidance."\n',
encoding="utf-8",
)
(source_home / "config.toml").write_text(
'developer_instructions = "New shared guidance."\n',
encoding="utf-8",
)
_sync_codex_developer_instructions(codex_home, "Rename the session.")
config_path.unlink()
_populate_codex_home_config(codex_home, source_home)
reseeded = tomllib.loads(config_path.read_text(encoding="utf-8"))
assert reseeded["developer_instructions"] == "New shared guidance."
_sync_codex_developer_instructions(codex_home, None)
resumed = tomllib.loads(config_path.read_text(encoding="utf-8"))
assert resumed["developer_instructions"] == "Keep original guidance."
def test_sync_developer_instructions_recovers_legacy_augmented_config(tmp_path: Path) -> None:
"""A missing sidecar does not capture an existing framework suffix as user base."""
codex_home = tmp_path / "codex-home"
codex_home.mkdir()
config_path = codex_home / "config.toml"
config_path.write_text(
'developer_instructions = "Keep user guidance.\\n\\nRename the session."\n',
encoding="utf-8",
)
_sync_codex_developer_instructions(codex_home, "Rename the session.")
active = tomllib.loads(config_path.read_text(encoding="utf-8"))
assert active["developer_instructions"] == "Keep user guidance.\n\nRename the session."
_sync_codex_developer_instructions(codex_home, None)
resumed = tomllib.loads(config_path.read_text(encoding="utf-8"))
assert resumed["developer_instructions"] == "Keep user guidance."
def test_sync_developer_instructions_skips_invalid_config(tmp_path: Path) -> None:
"""Optional title metadata never blocks Codex startup on malformed config."""
codex_home = tmp_path / "codex-home"
codex_home.mkdir()
config_path = codex_home / "config.toml"
config_path.write_text("invalid = [", encoding="utf-8")
_sync_codex_developer_instructions(codex_home, "Rename the session.")
assert config_path.read_text(encoding="utf-8") == "invalid = ["
_CWD = "/home/user/repo"
_OUR_COMMAND = "/venv/bin/python -m omnigent.codex_native_hook evaluate-policy --bridge-dir /b"
_USER_COMMAND = "bash /home/user/.config/llm-cli/hooks/guard.sh"
def _hook(key: str, command: str, trust: str, current_hash: str = "sha256:h") -> dict[str, Any]:
"""
Build a ``hooks/list`` hook metadata entry.
:param key: Hook key, e.g. ``"/b/codex-home/hooks.json:pre_tool_use:0:0"``.
:param command: Hook command string (used to identify ownership).
:param trust: Trust status, e.g. ``"untrusted"`` / ``"trusted"``.
:param current_hash: The hook's content hash, e.g. ``"sha256:h"``.
:returns: A hook metadata dict shaped like ``hooks/list`` output.
"""
return {
"key": key,
"command": command,
"trustStatus": trust,
"currentHash": current_hash,
}
def test_hooks_list_empty_result_does_not_fall_back_to_envelope() -> None:
"""A valid empty result remains authoritative over envelope metadata."""
listed = {
"result": {},
"data": [{"cwd": _CWD, "hooks": [_hook("k1", _OUR_COMMAND, "trusted")]}],
}
assert _our_policy_hooks_from_list(listed, _CWD) == []
assert "returned no hooks" in _hooks_list_diagnostics(listed, _CWD)
@dataclass
class _Req:
"""
One recorded JSON-RPC request issued to the fake client.
:param method: RPC method name, e.g. ``"hooks/list"``.
:param params: RPC params dict.
"""
method: str
params: dict[str, Any]
@dataclass
class _FakeCodexClient:
"""
Fake Codex app-server client scripted for the trust flow.
Returns the current hook set for ``hooks/list`` and, on
``config/batchWrite``, flips a hook to ``trusted`` when the written
``trusted_hash`` matches the hook's ``currentHash`` (mirroring codex's
real trust evaluation). ``flip_on_trust=False`` simulates a hash
mismatch where trust never takes.
:param hooks: Initial hook metadata (mutated as trust is written).
:param flip_on_trust: Whether a matching trusted_hash flips trust.
"""
hooks: list[dict[str, Any]]
flip_on_trust: bool = True
requests: list[_Req] = field(default_factory=list)
async def request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
"""
Handle one scripted RPC request.
:param method: RPC method, e.g. ``"hooks/list"`` or
``"config/batchWrite"``.
:param params: RPC params.
:returns: A response envelope matching the real app-server shape.
"""
self.requests.append(_Req(method=method, params=params))
if method == "hooks/list":
return {"result": {"data": [{"cwd": _CWD, "hooks": self.hooks}]}}
if method == "config/batchWrite":
if self.flip_on_trust:
written = params["edits"][0]["value"]
for hook in self.hooks:
update = written.get(hook["key"])
if update and update.get("trusted_hash") == hook["currentHash"]:
hook["trustStatus"] = "trusted"
return {"result": {"status": "ok"}}
raise AssertionError(f"unexpected RPC method {method!r}")
def _batchwrite_calls(client: _FakeCodexClient) -> list[_Req]:
"""
Return the config/batchWrite requests the trust flow issued.
:param client: The fake client after the flow ran.
:returns: Recorded batchWrite requests (empty if none issued).
"""
return [r for r in client.requests if r.method == "config/batchWrite"]
async def _fake_wait_until_ready(self: CodexNativeAppServer) -> None:
"""
Skip app-server socket probing in startup unit tests.
:param self: The app-server wrapper under test.
:returns: None.
"""
async def _fake_trust_policy_hooks(self: CodexNativeAppServer) -> None:
"""
Skip Codex ``hooks/list`` RPCs in startup unit tests.
:param self: The app-server wrapper under test.
:returns: None.
"""
def _disable_codex_startup_rpc(monkeypatch: pytest.MonkeyPatch) -> None:
"""
Patch Codex startup RPC waits for unit tests.
:param monkeypatch: Pytest monkeypatch fixture.
:returns: None.
"""
monkeypatch.setattr(CodexNativeAppServer, "_wait_until_ready", _fake_wait_until_ready)
monkeypatch.setattr(CodexNativeAppServer, "_trust_policy_hooks", _fake_trust_policy_hooks)
def test_build_codex_native_server_profile_error_names_profile(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Missing Databricks profile errors identify the runner-visible profile.
The native Codex terminal can fail before the TUI launches if the
runner process cannot resolve the Databricks profile it was given.
The message must include that profile name so operators can tell a
stale/missing runner env apart from a generic Codex startup failure.
"""
monkeypatch.setattr(
"omnigent.codex_native_app_server._find_codex_cli",
lambda: sys.executable,
)
monkeypatch.setattr(
"omnigent.codex_native_app_server._databricks_gateway_host",
lambda _profile: None,
)
monkeypatch.setenv("DATABRICKS_CONFIG_FILE", str(tmp_path / "missing-databrickscfg"))
with pytest.raises(OSError, match="profile 'oss'"):
build_codex_native_server(
socket_path=tmp_path / "codex.sock",
codex_home=tmp_path / "codex-home",
cwd=tmp_path,
model=None,
profile="oss",
bridge_dir=tmp_path / "bridge",
ap_server_url=None,
ap_auth_headers={},
)
def test_build_codex_native_server_uses_profile_host_without_static_token(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Native Codex accepts Databricks CLI OAuth profiles without static tokens.
A default Omnigent install may not include ``databricks-sdk`` in the
runner process. In that case a bearer cannot be minted at startup, but the
profile's host is still enough: Codex gets an ``auth.command`` that runs
``databricks auth token --profile`` at request time.
"""
monkeypatch.setattr(
"omnigent.codex_native_app_server._find_codex_cli",
lambda: sys.executable,
)
cfg_path = tmp_path / "databrickscfg"
cfg_path.write_text(
"\n".join(
[
"[oss]",
"host = https://example.cloud.databricks.com",
"auth_type = databricks-cli",
"",
]
),
encoding="utf-8",
)
monkeypatch.setenv("DATABRICKS_CONFIG_FILE", str(cfg_path))
# This test exercises the profile-host base URL + auth command, not model
# resolution. Force live codex discovery offline so the build makes no
# model-services network call for the profile host; the explicit
# ``model="test-model"`` is then used as-is.
def _discovery_offline(_profile: str | None) -> object:
raise RuntimeError("model discovery is offline in this test")
monkeypatch.setattr(
"omnigent.runtime.credentials.databricks.resolve_databricks_workspace",
_discovery_offline,
)
app_server = build_codex_native_server(
socket_path=tmp_path / "codex.sock",
codex_home=tmp_path / "codex-home",
cwd=tmp_path,
model="test-model",
profile="oss",
bridge_dir=tmp_path / "bridge",
ap_server_url=None,
ap_auth_headers={},
)
overrides = "\n".join(app_server.config_overrides)
assert "https://example.cloud.databricks.com/ai-gateway/codex/v1" in overrides
assert 'databricks auth token --profile \\"oss\\"' in overrides
def test_build_codex_native_server_without_bypass_emits_no_bypass_config(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The default (``bypass_sandbox=False``) writes no approval/sandbox overrides.
Guards the safe default: an app-server built without the opt-in must
leave Codex's normal approval-prompt + own-sandbox stance untouched, so
no ``approval_policy`` / ``sandbox_mode`` override leaks in. A regression
that always emitted them would silently disable the sandbox for every
native Codex session.
"""
monkeypatch.setattr(
"omnigent.codex_native_app_server._find_codex_cli",
lambda: sys.executable,
)
app_server = build_codex_native_server(
socket_path=tmp_path / "codex.sock",
codex_home=tmp_path / "codex-home",
cwd=tmp_path,
model=None,
profile=None,
bridge_dir=tmp_path / "bridge",
ap_server_url=None,
ap_auth_headers={},
)
overrides = "\n".join(app_server.config_overrides)
assert "approval_policy" not in overrides
assert "sandbox_mode" not in overrides
def test_build_codex_native_server_bypass_emits_full_access_config(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
``bypass_sandbox=True`` puts the app-server threads into the bypass stance.
The ``--remote`` TUI launched with
``--dangerously-bypass-approvals-and-sandbox`` fixes the thread's
approval/sandbox stance, but the chat/forwarder seam drives the SAME
thread through the app-server, so the app-server config must match —
``approval_policy="never"`` (no prompts a headless seam can't answer)
and ``sandbox_mode="danger-full-access"`` (commands run with no command
sandbox, the #657 ask). Without these the app-server-driven turns would
keep prompting / keep the sandbox even though the TUI bypassed it.
"""
monkeypatch.setattr(
"omnigent.codex_native_app_server._find_codex_cli",
lambda: sys.executable,
)
app_server = build_codex_native_server(
socket_path=tmp_path / "codex.sock",
codex_home=tmp_path / "codex-home",
cwd=tmp_path,
model=None,
profile=None,
bridge_dir=tmp_path / "bridge",
ap_server_url=None,
ap_auth_headers={},
bypass_sandbox=True,
)
assert 'approval_policy="never"' in app_server.config_overrides
assert 'sandbox_mode="danger-full-access"' in app_server.config_overrides
@pytest.mark.parametrize(
("model", "expected_pin"),
[
pytest.param(None, "gpt-5.4-mini", id="default-launch"),
pytest.param("gpt-5.5", "gpt-5.5", id="explicit-pick"),
],
)
def test_build_codex_native_server_pins_profile_resolved_model(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
model: str | None,
expected_pin: str,
) -> None:
"""
A profile launch pins the model it routes to, in codex's own spelling.
A launch naming no model still gets a concrete model from the profile's
catalog via ``-c model=``, which outranks the ``config.toml`` copied from
the user's shared home. Leaving ``pinned_model`` unset there let the
forwarder mirror the shared file's stale model back as this session's
``model_override`` (live-caught: a session running
``databricks-gpt-5-6-luna`` reported the shared file's ``gpt-5.4``).
The pin uses codex's spelling because that is the vocabulary
``config.toml`` and every reader of it — including the web catalog's
row ids — compare in.
"""
from omnigent import codex_native_app_server
monkeypatch.setattr(
"omnigent.codex_native_app_server._find_codex_cli",
lambda: sys.executable,
)
monkeypatch.setattr(
codex_native_app_server,
"_databricks_launch_materialization",
lambda *, model, profile: codex_native_app_server._DatabricksLaunchMaterialization(
config_overrides=[f'model="{model or "databricks-gpt-5-4-mini"}"'],
model=model or "databricks-gpt-5-4-mini",
host="https://ws.example",
),
)
app_server = build_codex_native_server(
socket_path=tmp_path / "codex.sock",
codex_home=tmp_path / "codex-home",
cwd=tmp_path,
model=model,
profile="oss",
bridge_dir=tmp_path / "bridge",
ap_server_url=None,
ap_auth_headers={},
)
assert app_server.pinned_model == expected_pin
@pytest.mark.parametrize(
("model", "profile", "extra_overrides"),
[
# Subscription: an explicit pick and a catalog-resolved Default.
("gpt-5.5", None, ['model_provider="openai"']),
("gpt-5.6-terra", None, ['model_provider="openai"']),
# cli-config: the user's own provider table rides the config copy.
("gpt-5.5", None, ['model_provider="Databricks"']),
# Databricks profile: Default and an explicit pick.
(None, "oss", None),
("databricks-gpt-5-5", "oss", None),
],
)
def test_launch_argv_and_config_pin_name_the_same_model(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
model: str | None,
profile: str | None,
extra_overrides: list[str] | None,
) -> None:
"""
The argv ``-c model=`` and the config copy's pin never drift apart.
Both artifacts are written from one resolved value, on EVERY provider
shape and for Default and explicit picks alike — the structural end of
the stale-config-line class where a copied ``model =`` governed a
session the argv never named. On the profile shape the file
deliberately holds codex's own spelling (the vocabulary its readers
compare in) while argv carries the wire spelling; the guard asserts
they name the same model, and byte-identity everywhere else.
"""
from omnigent import codex_native_app_server
from omnigent.codex_model_vocabulary import comparable_model_id
monkeypatch.setattr(
"omnigent.codex_native_app_server._find_codex_cli",
lambda: sys.executable,
)
monkeypatch.setattr(
codex_native_app_server,
"_databricks_launch_materialization",
lambda *, model, profile: codex_native_app_server._DatabricksLaunchMaterialization(
config_overrides=[f'model="{model or "databricks-gpt-5-6-luna"}"'],
model=model or "databricks-gpt-5-6-luna",
host="https://ws.example",
),
)
app_server = build_codex_native_server(
socket_path=tmp_path / "codex.sock",
codex_home=tmp_path / "codex-home",
cwd=tmp_path,
model=model,
profile=profile,
bridge_dir=tmp_path / "bridge",
ap_server_url=None,
ap_auth_headers={},
extra_config_overrides=list(extra_overrides) if extra_overrides else None,
)
argv_models = [
override.split("=", 1)[1]
for override in app_server.config_overrides
if override.split("=", 1)[0] == "model"
]
assert len(argv_models) == 1, app_server.config_overrides
argv_model = json.loads(argv_models[0]) if argv_models[0].startswith('"') else argv_models[0]
pinned = app_server.pinned_model
assert pinned, "every launch must pin a model"
assert comparable_model_id(argv_model) == comparable_model_id(pinned)
if profile is None:
assert argv_model == pinned
async def test_codex_launch_catalog_reads_the_store_then_probes_once(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The launch catalog is store-first; a miss probes once and persists."""
from omnigent import codex_native_app_server
monkeypatch.setenv("OMNIGENT_DATA_DIR", str(tmp_path))
monkeypatch.setattr(
codex_native_app_server,
"resolve_native_codex_launch",
lambda *, model, spec=None: codex_native_app_server.NativeCodexLaunch(
config_overrides=['model_provider="openai"'], model=model, profile=None
),
)
calls: list[int] = []
async def _fake_probe(*, codex_path: str | None = None) -> list[dict[str, object]]:
del codex_path
calls.append(1)
return [{"id": "gpt-5.6-terra", "model": "gpt-5.6-terra", "isDefault": True}]
monkeypatch.setattr(codex_native_app_server, "probe_codex_model_options", _fake_probe)
first = await codex_native_app_server.codex_launch_catalog()
second = await codex_native_app_server.codex_launch_catalog()
assert first == second
assert first == [{"id": "gpt-5.6-terra", "model": "gpt-5.6-terra", "isDefault": True}]
assert len(calls) == 1, "the second read must come from the store, not a re-probe"
def _test_app_server(
tmp_path: Path,
codex_home: Path,
bridge_dir: Path,
workspace: Path,
env: dict[str, str] | None = None,
) -> CodexNativeAppServer:
"""
Build a Codex app-server wrapper for startup unit tests.
:param tmp_path: Test temp directory, e.g. ``Path("/tmp/test")``.
:param codex_home: Private Codex home to write.
:param bridge_dir: Bridge directory for the generated MCP args.
:param workspace: Working directory for the subprocess.
:param env: Process env for the app-server, carrying the routing
signals the session class is read from. ``None`` is a plain session.
:returns: Configured app-server wrapper.
"""
return CodexNativeAppServer(
codex_path=sys.executable,
socket_path=tmp_path / "codex.sock",
codex_home=codex_home,
env=dict(env or {}),
config_overrides=[],
cwd=workspace,
bridge_dir=bridge_dir,
python_executable="/new/python",
)
async def test_start_upserts_mcp_server_config_across_relaunches(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""
Codex native startup upserts MCP config across relaunches.
Repeated native terminal startup uses the same private ``CODEX_HOME``.
The generated config must remain valid TOML and the user's real
symlinked config must stay untouched.
"""
real_codex_home = tmp_path / "real-codex-home"
real_codex_home.mkdir()
source_config = real_codex_home / "config.toml"
original = """\
[projects."/repo"]
trust_level = "trusted"
[mcp_servers.omnigent] # stale generated table
command = "/old/python"
args = ["old"]
[mcp_servers.omnigent.env] # stale generated env
OLD = "1"
[mcp_servers.omnigent.tools.sys_session_rename] # stale generated approval
approval_mode = "prompt"
[mcp_servers.other]
command = "other"
args = []
"""
source_config.write_text(original, encoding="utf-8")
codex_home = tmp_path / "codex-home"
bridge_dir = tmp_path / "bridge"
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.setenv("CODEX_HOME", str(real_codex_home))
_disable_codex_startup_rpc(monkeypatch)
server = _test_app_server(tmp_path, codex_home, bridge_dir, workspace)
await server.start()
await server.close()
await server.start()
await server.close()
assert source_config.read_text(encoding="utf-8") == original
config_path = codex_home / "config.toml"
assert not config_path.is_symlink()
rendered = config_path.read_text(encoding="utf-8")
assert rendered.count("[mcp_servers.omnigent]") == 1
assert "[mcp_servers.omnigent.env]" not in rendered
parsed = tomllib.loads(rendered)
assert parsed["mcp_servers"]["other"]["command"] == "other"
assert parsed["mcp_servers"]["omnigent"] == {
"command": "/new/python",
"args": [
"-I",
"-m",
"omnigent.claude_native_bridge",
"serve-mcp",
"--bridge-dir",
str(bridge_dir),
],
"tools": _PLAIN_TOOL_APPROVALS,
}
async def test_start_writes_fresh_mcp_config_without_leading_blanks(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""
Codex native startup writes fresh MCP config without leading blanks.
Codex should be able to read a newly-created private ``config.toml``
without cosmetic leading whitespace from the generated section
separator logic.
"""
real_codex_home = tmp_path / "real-codex-home"
real_codex_home.mkdir()
codex_home = tmp_path / "codex-home"
bridge_dir = tmp_path / "bridge"
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.setenv("CODEX_HOME", str(real_codex_home))
_disable_codex_startup_rpc(monkeypatch)
server = _test_app_server(tmp_path, codex_home, bridge_dir, workspace)
await server.start()
await server.close()
rendered = (codex_home / "config.toml").read_text(encoding="utf-8")
assert rendered.startswith("[mcp_servers.omnigent]\n")
assert stat.S_IMODE(codex_home.stat().st_mode) == 0o700
assert stat.S_IMODE((codex_home / "config.toml").stat().st_mode) == 0o600
parsed = tomllib.loads(rendered)
assert parsed["mcp_servers"]["omnigent"] == {
"command": "/new/python",
"args": [
"-I",
"-m",
"omnigent.claude_native_bridge",
"serve-mcp",
"--bridge-dir",
str(bridge_dir),
],
"tools": _PLAIN_TOOL_APPROVALS,
}
# ── The codex-native session classes ────────────────────────────────
#
# Everything below is a per-class snapshot of the private CODEX_HOME a
# codex-native session boots on. A plain session must be indistinguishable from
# a pre-Smart-Routing one: codex's bundled model catalog (no ``codex debug
# models`` probe), no ``spawn_agent`` routing gate in hooks.json, and only the
# one framework tool approval.
#
# Every Smart Routing session — pinned harness or auto — adds the extended
# catalog, the spawn gate and the routed-spawn approvals, because on this arm
# the spawn tools are neither gated nor pre-approved without them and the spawn
# simply stalls on a prompt nobody is watching. The home is therefore the same
# shape for pinned and auto; what separates them is the cross-family framing in
# ``developer_instructions``, which the launch site adds for auto-harness only
# (``test_routed_spawn_note_appends_then_restores_the_user_base``).
#
# The catalog-only shape is still reachable: on a codex too old for the spawn
# gate the advertisement is dropped, and the session degrades to it.
def _stub_model_catalog_probe(monkeypatch: pytest.MonkeyPatch) -> list[str]:
"""Replace the ``codex debug models`` probe and record its calls."""
from omnigent.inner import codex_executor
probes: list[str] = []
def _probe(codex_path: str, source_home: Path, *, timeout: float) -> dict[str, Any]:
del source_home, timeout
probes.append(codex_path)
return {
"models": [
{"slug": "gpt-5.6-luna", "visibility": "list", "supported_reasoning_levels": []}
]
}
monkeypatch.setattr(codex_executor, "_find_codex_cli", lambda: "/bin/codex")
monkeypatch.setattr(codex_executor, "_MODEL_CATALOG_CACHE", {})
monkeypatch.setattr(codex_executor, "_MODEL_CATALOG_FAILURES", {})
monkeypatch.setattr(codex_executor, "_probe_codex_model_catalog", _probe)
return probes
async def _start_codex_home(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
*,
env: dict[str, str],
) -> tuple[Path, list[str]]:
"""Boot an app-server with *env* and return its home plus probe calls."""
real_codex_home = tmp_path / "real-codex-home"
real_codex_home.mkdir()
(real_codex_home / "config.toml").write_text('model = "gpt-5.5"\n', encoding="utf-8")
(real_codex_home / "hooks.json").write_text(
json.dumps(
{
"hooks": {
"PreToolUse": [{"hooks": [{"type": "command", "command": "user-pre"}]}],
"Stop": [{"hooks": [{"type": "command", "command": "user-stop"}]}],
}
}
),
encoding="utf-8",
)
codex_home = tmp_path / "codex-home"
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.setenv("CODEX_HOME", str(real_codex_home))
_disable_codex_startup_rpc(monkeypatch)
probes = _stub_model_catalog_probe(monkeypatch)
server = _test_app_server(tmp_path, codex_home, tmp_path / "bridge", workspace, env)
await server.start()
await server.close()
return codex_home, probes
#: The regex the routing gate is registered under (codex flattens the tool name).
_SPAWN_MATCHER = r".*spawn_agent"
def _mcp_tool_approvals(codex_home: Path) -> dict[str, Any]:
parsed = tomllib.loads((codex_home / "config.toml").read_text(encoding="utf-8"))
return parsed["mcp_servers"]["omnigent"]["tools"]
def _hook_matchers(codex_home: Path, event: str) -> list[str | None]:
payload = json.loads((codex_home / "hooks.json").read_text(encoding="utf-8"))
return [entry.get("matcher") for entry in payload["hooks"].get(event, [])]
async def test_a_plain_codex_native_session_looks_like_a_plain_codex_session(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
codex_home, probes = await _start_codex_home(tmp_path, monkeypatch, env={})
assert probes == []
assert not (codex_home / "model_catalog.json").exists()
assert "model_catalog_json" not in (codex_home / "config.toml").read_text(encoding="utf-8")
assert _mcp_tool_approvals(codex_home) == _PLAIN_TOOL_APPROVALS
# The policy gate and the user's own hooks are a plain codex session's
# pre-existing PreToolUse entries; what it must not gain is a gate on the
# spawn tool, which stalls ~30 s on a wedged server before failing open.
assert _SPAWN_MATCHER not in _hook_matchers(codex_home, "PreToolUse")
async def test_a_smart_routing_codex_native_session_gains_the_spawn_apparatus(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Pinned and auto-harness alike: the routed spawn has to be able to run.
The pinned class used to be withheld the advertisement, which took the
``spawn_agent`` gate AND the four routed-spawn approvals with it — so a
pinned Smart Routing session's spawns did not merely go unrouted, they
stalled on an approval prompt nobody was watching.
"""
from omnigent.inner.codex_executor import (
CODEX_EXTENDED_CATALOG_ENV_VAR,
CODEX_ROUTER_DIR_ENV_VAR,
CODEX_ROUTER_SESSION_ID_ENV_VAR,
)
router_dir = tmp_path / "router"
router_dir.mkdir()
codex_home, probes = await _start_codex_home(
tmp_path,
monkeypatch,
env={
CODEX_EXTENDED_CATALOG_ENV_VAR: "1",
CODEX_ROUTER_DIR_ENV_VAR: str(router_dir),
CODEX_ROUTER_SESSION_ID_ENV_VAR: "conv_abc",
},
)
assert probes == ["/bin/codex"]
assert (codex_home / "model_catalog.json").is_file()
assert "model_catalog_json" in (codex_home / "config.toml").read_text(encoding="utf-8")
assert _mcp_tool_approvals(codex_home) == _ROUTED_TOOL_APPROVALS
# Omnigent's policy hook stays first, then the spawn gate, then user hooks.
assert _hook_matchers(codex_home, "PreToolUse") == [None, _SPAWN_MATCHER, None]
async def test_an_old_codex_degrades_a_routed_session_to_catalog_only(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Below the spawn gate's CLI floor the session launches, unrouted.
The advertisement is dropped, so everything keyed off it falls back to the
plain shape — no gate, no routed-spawn approvals — while the extended
catalog (keyed off its own env var) stays. The gear still offers the
subagent-routing row; the choice simply no-ops until codex is upgraded.
"""
from omnigent.inner.codex_executor import (
CODEX_EXTENDED_CATALOG_ENV_VAR,