codex-switch-cli 0.1.1__tar.gz → 0.1.2__tar.gz

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 (38) hide show
  1. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/PKG-INFO +1 -1
  2. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/pyproject.toml +1 -1
  3. codex_switch_cli-0.1.2/src/codex_switch/__init__.py +1 -0
  4. codex_switch_cli-0.1.2/src/codex_switch/probe.py +241 -0
  5. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/PKG-INFO +1 -1
  6. codex_switch_cli-0.1.2/tests/test_probe.py +160 -0
  7. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/tests/test_smoke.py +1 -1
  8. codex_switch_cli-0.1.1/src/codex_switch/__init__.py +0 -1
  9. codex_switch_cli-0.1.1/src/codex_switch/probe.py +0 -69
  10. codex_switch_cli-0.1.1/tests/test_probe.py +0 -85
  11. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/README.md +0 -0
  12. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/setup.cfg +0 -0
  13. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/auth.py +0 -0
  14. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/cli.py +0 -0
  15. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/config.py +0 -0
  16. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/doctor.py +0 -0
  17. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/install.py +0 -0
  18. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/instances.py +0 -0
  19. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/models.py +0 -0
  20. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/paths.py +0 -0
  21. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/routing.py +0 -0
  22. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/runtime.py +0 -0
  23. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/wizard.py +0 -0
  24. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/wrapper.py +0 -0
  25. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/SOURCES.txt +0 -0
  26. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/dependency_links.txt +0 -0
  27. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/entry_points.txt +0 -0
  28. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/requires.txt +0 -0
  29. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/top_level.txt +0 -0
  30. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/tests/test_auth.py +0 -0
  31. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/tests/test_config.py +0 -0
  32. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/tests/test_doctor.py +0 -0
  33. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/tests/test_instances.py +0 -0
  34. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/tests/test_integration_wrapper.py +0 -0
  35. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/tests/test_routing.py +0 -0
  36. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/tests/test_runtime.py +0 -0
  37. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/tests/test_wizard.py +0 -0
  38. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/tests/test_wrapper.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-switch-cli
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Transparent account-aware wrapper for the Codex CLI
5
5
  Project-URL: Homepage, https://github.com/ForeverHYX/codex-switch
6
6
  Project-URL: Repository, https://github.com/ForeverHYX/codex-switch
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codex-switch-cli"
7
- version = "0.1.1"
7
+ version = "0.1.2"
8
8
  description = "Transparent account-aware wrapper for the Codex CLI"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.2"
@@ -0,0 +1,241 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import pty
5
+ import re
6
+ import select
7
+ import subprocess
8
+ import time
9
+ from pathlib import Path
10
+
11
+ from codex_switch.auth import CodexCommandError, login_status
12
+ from codex_switch.models import InstanceConfig, ProbeResult
13
+ from codex_switch.runtime import build_instance_env
14
+
15
+
16
+ QUOTA_PATTERNS = (
17
+ re.compile(r"remaining[^0-9]*(\d+)", re.IGNORECASE),
18
+ re.compile(r"(\d+)[^0-9]*remaining", re.IGNORECASE),
19
+ )
20
+ READY_PATTERNS = (
21
+ "OpenAI Codex",
22
+ "tab to queue message",
23
+ "context left",
24
+ "Codex ready",
25
+ "BOOTED",
26
+ )
27
+ ANSI_ESCAPE_RE = re.compile(
28
+ r"\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b[@-_]"
29
+ )
30
+
31
+
32
+ def parse_remaining_quota(output: str) -> int:
33
+ for pattern in QUOTA_PATTERNS:
34
+ match = pattern.search(output)
35
+ if match:
36
+ return int(match.group(1))
37
+ raise ValueError("Unable to parse remaining quota from /status output")
38
+
39
+
40
+ def _failure(instance: InstanceConfig, reason: str) -> ProbeResult:
41
+ return ProbeResult(
42
+ instance_name=instance.name,
43
+ order=instance.order,
44
+ quota_remaining=None,
45
+ ok=False,
46
+ reason=reason,
47
+ )
48
+
49
+
50
+ def _sanitize_terminal_output(raw_output: bytes) -> str:
51
+ text = raw_output.decode("utf-8", errors="ignore").replace("\r", "\n")
52
+ text = ANSI_ESCAPE_RE.sub("", text)
53
+ text = text.replace("\b", "")
54
+ return text
55
+
56
+
57
+ def _trusted_project_override(path: Path) -> str:
58
+ escaped = str(path).replace("\\", "\\\\").replace('"', '\\"')
59
+ return f'projects."{escaped}".trust_level="trusted"'
60
+
61
+
62
+ def _looks_ready(output: str) -> bool:
63
+ return any(pattern in output for pattern in READY_PATTERNS)
64
+
65
+
66
+ def _fallback_logged_in_result(
67
+ real_codex_path: str,
68
+ instance: InstanceConfig,
69
+ reason: str,
70
+ ) -> ProbeResult | None:
71
+ try:
72
+ status = login_status(real_codex_path, instance)
73
+ except CodexCommandError:
74
+ return None
75
+ if not status.logged_in:
76
+ return None
77
+ return ProbeResult(
78
+ instance_name=instance.name,
79
+ order=instance.order,
80
+ quota_remaining=0,
81
+ ok=True,
82
+ reason=f"{reason}. Falling back to login-only availability.",
83
+ )
84
+
85
+
86
+ def _run_status_probe(
87
+ real_codex_path: str,
88
+ instance: InstanceConfig,
89
+ *,
90
+ timeout: int = 6,
91
+ ) -> tuple[int, str]:
92
+ instance_home = Path(instance.home_dir)
93
+ instance_home.mkdir(parents=True, exist_ok=True)
94
+ env = build_instance_env(instance.name, instance_home)
95
+ command = [
96
+ real_codex_path,
97
+ "-C",
98
+ str(instance_home),
99
+ "-c",
100
+ _trusted_project_override(instance_home),
101
+ "--no-alt-screen",
102
+ ]
103
+
104
+ master_fd, slave_fd = pty.openpty()
105
+ process: subprocess.Popen[bytes] | None = None
106
+ sent_status = False
107
+ sent_exit = False
108
+ sent_trust = False
109
+ status_sent_at: float | None = None
110
+ exit_sent_at: float | None = None
111
+ output = bytearray()
112
+ deadline = time.monotonic() + timeout
113
+ startup_deadline = time.monotonic() + min(3.0, timeout / 2)
114
+
115
+ try:
116
+ process = subprocess.Popen(
117
+ command,
118
+ stdin=slave_fd,
119
+ stdout=slave_fd,
120
+ stderr=slave_fd,
121
+ env=env,
122
+ cwd=instance_home,
123
+ close_fds=True,
124
+ )
125
+ finally:
126
+ os.close(slave_fd)
127
+
128
+ try:
129
+ while True:
130
+ now = time.monotonic()
131
+ if now >= deadline:
132
+ raise subprocess.TimeoutExpired(cmd=command, timeout=timeout)
133
+
134
+ wait_for = min(0.2, deadline - now)
135
+ ready, _, _ = select.select([master_fd], [], [], wait_for)
136
+ if ready:
137
+ try:
138
+ chunk = os.read(master_fd, 4096)
139
+ except OSError:
140
+ chunk = b""
141
+ if chunk:
142
+ output.extend(chunk)
143
+
144
+ cleaned_output = _sanitize_terminal_output(output)
145
+
146
+ if (
147
+ not sent_trust
148
+ and "Do you trust the contents of this directory?" in cleaned_output
149
+ ):
150
+ os.write(master_fd, b"1\n")
151
+ sent_trust = True
152
+
153
+ if not sent_status and (
154
+ _looks_ready(cleaned_output) or time.monotonic() >= startup_deadline
155
+ ):
156
+ os.write(master_fd, b"/status\n")
157
+ sent_status = True
158
+ status_sent_at = time.monotonic()
159
+
160
+ if sent_status and not sent_exit:
161
+ try:
162
+ parse_remaining_quota(cleaned_output)
163
+ except ValueError:
164
+ if status_sent_at is not None and time.monotonic() - status_sent_at > 2:
165
+ os.write(master_fd, b"/exit\n")
166
+ sent_exit = True
167
+ exit_sent_at = time.monotonic()
168
+ else:
169
+ os.write(master_fd, b"/exit\n")
170
+ sent_exit = True
171
+ exit_sent_at = time.monotonic()
172
+
173
+ if (
174
+ sent_exit
175
+ and exit_sent_at is not None
176
+ and time.monotonic() - exit_sent_at > 1
177
+ and process.poll() is None
178
+ ):
179
+ process.terminate()
180
+
181
+ poll_result = process.poll()
182
+ if poll_result is not None:
183
+ while True:
184
+ ready, _, _ = select.select([master_fd], [], [], 0)
185
+ if not ready:
186
+ break
187
+ try:
188
+ chunk = os.read(master_fd, 4096)
189
+ except OSError:
190
+ break
191
+ if not chunk:
192
+ break
193
+ output.extend(chunk)
194
+ return poll_result, _sanitize_terminal_output(output)
195
+ except subprocess.TimeoutExpired:
196
+ process.kill()
197
+ process.wait(timeout=1)
198
+ raise
199
+ finally:
200
+ os.close(master_fd)
201
+
202
+
203
+ def probe_instance(real_codex_path: str, instance: InstanceConfig) -> ProbeResult:
204
+ try:
205
+ returncode, output = _run_status_probe(real_codex_path, instance)
206
+ except FileNotFoundError as exc:
207
+ return _failure(instance, f"Probe could not launch the real Codex binary: {exc}")
208
+ except subprocess.TimeoutExpired:
209
+ fallback = _fallback_logged_in_result(
210
+ real_codex_path,
211
+ instance,
212
+ "Probe timed out",
213
+ )
214
+ if fallback is not None:
215
+ return fallback
216
+ return _failure(instance, "Probe timed out")
217
+
218
+ if returncode != 0:
219
+ fallback = _fallback_logged_in_result(
220
+ real_codex_path,
221
+ instance,
222
+ f"Probe exited with exit code {returncode}",
223
+ )
224
+ if fallback is not None:
225
+ return fallback
226
+ return _failure(instance, f"Probe exited with exit code {returncode}")
227
+
228
+ try:
229
+ remaining = parse_remaining_quota(output)
230
+ except ValueError as exc:
231
+ fallback = _fallback_logged_in_result(real_codex_path, instance, str(exc))
232
+ if fallback is not None:
233
+ return fallback
234
+ return _failure(instance, str(exc))
235
+
236
+ return ProbeResult(
237
+ instance_name=instance.name,
238
+ order=instance.order,
239
+ quota_remaining=remaining,
240
+ ok=True,
241
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-switch-cli
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Transparent account-aware wrapper for the Codex CLI
5
5
  Project-URL: Homepage, https://github.com/ForeverHYX/codex-switch
6
6
  Project-URL: Repository, https://github.com/ForeverHYX/codex-switch
@@ -0,0 +1,160 @@
1
+ import subprocess
2
+ import os
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from codex_switch.models import InstanceConfig
7
+ from codex_switch.auth import LoginStatus
8
+ from codex_switch.probe import parse_remaining_quota
9
+ from codex_switch.probe import probe_instance
10
+
11
+
12
+ def test_parse_remaining_quota_from_status_output() -> None:
13
+ output = """
14
+ Account: acct-001
15
+ Requests remaining: 42
16
+ """
17
+
18
+ assert parse_remaining_quota(output) == 42
19
+
20
+
21
+ def test_probe_instance_returns_failure_for_malformed_output(
22
+ tmp_path, monkeypatch
23
+ ) -> None:
24
+ instance = InstanceConfig(
25
+ name="acct-001",
26
+ order=1,
27
+ home_dir=str(tmp_path / "home"),
28
+ )
29
+
30
+ monkeypatch.setattr(
31
+ "codex_switch.probe._run_status_probe",
32
+ lambda *args, **kwargs: (0, "Account: acct-001\n"),
33
+ )
34
+ monkeypatch.setattr(
35
+ "codex_switch.probe.login_status",
36
+ lambda *args, **kwargs: LoginStatus(logged_in=False, output="", returncode=1),
37
+ )
38
+
39
+ result = probe_instance("/usr/local/bin/codex", instance)
40
+
41
+ assert result.ok is False
42
+ assert result.quota_remaining is None
43
+ assert "Unable to parse remaining quota" in result.reason
44
+
45
+
46
+ def test_probe_instance_returns_failure_for_nonzero_exit(
47
+ tmp_path, monkeypatch
48
+ ) -> None:
49
+ instance = InstanceConfig(
50
+ name="acct-001",
51
+ order=1,
52
+ home_dir=str(tmp_path / "home"),
53
+ )
54
+
55
+ monkeypatch.setattr(
56
+ "codex_switch.probe._run_status_probe",
57
+ lambda *args, **kwargs: (1, "Requests remaining: 42\npermission denied"),
58
+ )
59
+
60
+ result = probe_instance("/usr/local/bin/codex", instance)
61
+
62
+ assert result.ok is False
63
+ assert result.quota_remaining is None
64
+ assert "exit code 1" in result.reason
65
+
66
+
67
+ def test_probe_instance_returns_failure_for_timeout(tmp_path, monkeypatch) -> None:
68
+ instance = InstanceConfig(
69
+ name="acct-001",
70
+ order=1,
71
+ home_dir=str(tmp_path / "home"),
72
+ )
73
+
74
+ def raise_timeout(*args, **kwargs):
75
+ raise subprocess.TimeoutExpired(cmd=["codex"], timeout=15)
76
+
77
+ monkeypatch.setattr("codex_switch.probe._run_status_probe", raise_timeout)
78
+
79
+ result = probe_instance("/usr/local/bin/codex", instance)
80
+
81
+ assert result.ok is False
82
+ assert result.quota_remaining is None
83
+ assert result.reason == "Probe timed out"
84
+
85
+
86
+ def test_probe_instance_handles_tty_only_codex_process(tmp_path) -> None:
87
+ home_dir = tmp_path / "acct-home"
88
+ home_dir.mkdir()
89
+ instance = InstanceConfig(
90
+ name="acct-001",
91
+ order=1,
92
+ home_dir=str(home_dir),
93
+ )
94
+ script_path = tmp_path / "fake_tty_codex.py"
95
+ launcher_path = tmp_path / "fake-codex"
96
+ script_path.write_text(
97
+ "import os\n"
98
+ "import sys\n"
99
+ "print('OpenAI Codex ready', flush=True)\n"
100
+ "if not sys.stdin.isatty():\n"
101
+ " print('stdin is not a terminal', file=sys.stderr, flush=True)\n"
102
+ " raise SystemExit(1)\n"
103
+ "expected_cwd = os.environ.get('EXPECTED_CWD')\n"
104
+ "if expected_cwd and os.getcwd() != expected_cwd:\n"
105
+ " print(f'bad cwd: {os.getcwd()}', file=sys.stderr, flush=True)\n"
106
+ " raise SystemExit(2)\n"
107
+ "for raw_line in sys.stdin:\n"
108
+ " line = raw_line.strip()\n"
109
+ " if line == '/status':\n"
110
+ " print('Requests remaining: 42', flush=True)\n"
111
+ " elif line == '/exit':\n"
112
+ " raise SystemExit(0)\n"
113
+ )
114
+ launcher_path.write_text(
115
+ "#!/bin/sh\n"
116
+ f'exec "{sys.executable}" "{script_path}" "$@"\n'
117
+ )
118
+ os.chmod(launcher_path, 0o755)
119
+
120
+ previous_expected_cwd = os.environ.get("EXPECTED_CWD")
121
+ os.environ["EXPECTED_CWD"] = str(home_dir)
122
+ try:
123
+ result = probe_instance(str(launcher_path), instance)
124
+ finally:
125
+ if previous_expected_cwd is None:
126
+ os.environ.pop("EXPECTED_CWD", None)
127
+ else:
128
+ os.environ["EXPECTED_CWD"] = previous_expected_cwd
129
+
130
+ assert result.ok is True
131
+ assert result.quota_remaining == 42
132
+
133
+
134
+ def test_probe_instance_falls_back_to_logged_in_account_when_quota_parse_fails(
135
+ tmp_path, monkeypatch
136
+ ) -> None:
137
+ instance = InstanceConfig(
138
+ name="acct-001",
139
+ order=1,
140
+ home_dir=str(tmp_path / "home"),
141
+ )
142
+
143
+ monkeypatch.setattr(
144
+ "codex_switch.probe._run_status_probe",
145
+ lambda *args, **kwargs: (0, "Account status unavailable\n"),
146
+ )
147
+ monkeypatch.setattr(
148
+ "codex_switch.probe.login_status",
149
+ lambda *args, **kwargs: LoginStatus(
150
+ logged_in=True,
151
+ output="Logged in using ChatGPT",
152
+ returncode=0,
153
+ ),
154
+ )
155
+
156
+ result = probe_instance("/usr/local/bin/codex", instance)
157
+
158
+ assert result.ok is True
159
+ assert result.quota_remaining == 0
160
+ assert "falling back" in (result.reason or "").lower()
@@ -5,7 +5,7 @@ from codex_switch.cli import app
5
5
 
6
6
 
7
7
  def test_package_exposes_version() -> None:
8
- assert __version__ == "0.1.1"
8
+ assert __version__ == "0.1.2"
9
9
 
10
10
 
11
11
  def test_cli_app_displays_help() -> None:
@@ -1 +0,0 @@
1
- __version__ = "0.1.1"
@@ -1,69 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import re
4
- import subprocess
5
- from pathlib import Path
6
-
7
- from codex_switch.models import InstanceConfig, ProbeResult
8
- from codex_switch.runtime import build_instance_env
9
-
10
-
11
- QUOTA_PATTERNS = (
12
- re.compile(r"remaining[^0-9]*(\d+)", re.IGNORECASE),
13
- re.compile(r"(\d+)[^0-9]*remaining", re.IGNORECASE),
14
- )
15
-
16
-
17
- def parse_remaining_quota(output: str) -> int:
18
- for pattern in QUOTA_PATTERNS:
19
- match = pattern.search(output)
20
- if match:
21
- return int(match.group(1))
22
- raise ValueError("Unable to parse remaining quota from /status output")
23
-
24
-
25
- def _failure(instance: InstanceConfig, reason: str) -> ProbeResult:
26
- return ProbeResult(
27
- instance_name=instance.name,
28
- order=instance.order,
29
- quota_remaining=None,
30
- ok=False,
31
- reason=reason,
32
- )
33
-
34
-
35
- def probe_instance(real_codex_path: str, instance: InstanceConfig) -> ProbeResult:
36
- env = build_instance_env(instance.name, Path(instance.home_dir))
37
- try:
38
- completed = subprocess.run(
39
- [real_codex_path, "--no-alt-screen"],
40
- input="/status\n/exit\n",
41
- text=True,
42
- capture_output=True,
43
- env=env,
44
- check=False,
45
- timeout=15,
46
- )
47
- except FileNotFoundError as exc:
48
- return _failure(instance, f"Probe could not launch the real Codex binary: {exc}")
49
- except subprocess.TimeoutExpired:
50
- return _failure(instance, "Probe timed out")
51
-
52
- output = f"{completed.stdout}\n{completed.stderr}"
53
- if completed.returncode != 0:
54
- return _failure(
55
- instance,
56
- f"Probe exited with exit code {completed.returncode}",
57
- )
58
-
59
- try:
60
- remaining = parse_remaining_quota(output)
61
- except ValueError as exc:
62
- return _failure(instance, str(exc))
63
-
64
- return ProbeResult(
65
- instance_name=instance.name,
66
- order=instance.order,
67
- quota_remaining=remaining,
68
- ok=True,
69
- )
@@ -1,85 +0,0 @@
1
- import subprocess
2
-
3
- from codex_switch.models import InstanceConfig
4
- from codex_switch.probe import parse_remaining_quota
5
- from codex_switch.probe import probe_instance
6
-
7
-
8
- def test_parse_remaining_quota_from_status_output() -> None:
9
- output = """
10
- Account: acct-001
11
- Requests remaining: 42
12
- """
13
-
14
- assert parse_remaining_quota(output) == 42
15
-
16
-
17
- def test_probe_instance_returns_failure_for_malformed_output(
18
- tmp_path, monkeypatch
19
- ) -> None:
20
- instance = InstanceConfig(
21
- name="acct-001",
22
- order=1,
23
- home_dir=str(tmp_path / "home"),
24
- )
25
-
26
- monkeypatch.setattr(
27
- "codex_switch.probe.subprocess.run",
28
- lambda *args, **kwargs: subprocess.CompletedProcess(
29
- args=["codex"],
30
- returncode=0,
31
- stdout="Account: acct-001\n",
32
- stderr="",
33
- ),
34
- )
35
-
36
- result = probe_instance("/usr/local/bin/codex", instance)
37
-
38
- assert result.ok is False
39
- assert result.quota_remaining is None
40
- assert "Unable to parse remaining quota" in result.reason
41
-
42
-
43
- def test_probe_instance_returns_failure_for_nonzero_exit(
44
- tmp_path, monkeypatch
45
- ) -> None:
46
- instance = InstanceConfig(
47
- name="acct-001",
48
- order=1,
49
- home_dir=str(tmp_path / "home"),
50
- )
51
-
52
- monkeypatch.setattr(
53
- "codex_switch.probe.subprocess.run",
54
- lambda *args, **kwargs: subprocess.CompletedProcess(
55
- args=["codex"],
56
- returncode=1,
57
- stdout="Requests remaining: 42\n",
58
- stderr="permission denied",
59
- ),
60
- )
61
-
62
- result = probe_instance("/usr/local/bin/codex", instance)
63
-
64
- assert result.ok is False
65
- assert result.quota_remaining is None
66
- assert "exit code 1" in result.reason
67
-
68
-
69
- def test_probe_instance_returns_failure_for_timeout(tmp_path, monkeypatch) -> None:
70
- instance = InstanceConfig(
71
- name="acct-001",
72
- order=1,
73
- home_dir=str(tmp_path / "home"),
74
- )
75
-
76
- def raise_timeout(*args, **kwargs):
77
- raise subprocess.TimeoutExpired(cmd=["codex"], timeout=15)
78
-
79
- monkeypatch.setattr("codex_switch.probe.subprocess.run", raise_timeout)
80
-
81
- result = probe_instance("/usr/local/bin/codex", instance)
82
-
83
- assert result.ok is False
84
- assert result.quota_remaining is None
85
- assert result.reason == "Probe timed out"